React-Webpack5-TypeScript打造工程化多页面应用
多页面应用打包
日常工作中大部分场景下我们都是在使用webpack构建传统单页面spa应用。
所谓的单页面应用也就是说打包后的代码仅仅生成一份html文件,基于前端路由js去控制渲染不同的页面。
当然所谓的多页面应用简单来说也就是打包后生成多个html文件。
这篇文章中我们来重点介绍多页面应用,文章中涉及的内容纯干货。我们废话不多说,一篇文章让你彻底搞懂所谓工程化的多页面应用构建。
文章中涉及的模板配置可以点击这里查看戳这里👇。
不要忘记给一个star呀大佬们(祈求脸.jpg)
前边部分是基于基础配置从零开始搭建一个React+TypeScript+Webpack的讲解部分,如果这块你已经足够了解了,可以直接跳到 切入多页面应用 去查看动态多页面部分的配置。
最终我们达到的效果如下👇:

原谅我实在是不会gif...
初始化目录结构
让我们来先初始化最基础的工程目录:

让我们先来安装webpack以及React:
yarn add -D webpack webpack-cli
yarn add react react-dom
webpack-cli是webpack的命令行工具,用于在命令行中使用webpack。
接下来让我们去分别创建不同的页面目录,假设我们存在两个多页面应用。
一个editor编辑器页面,一个home主页。
安装完成之后让我来改变改变目录文件:

创建的项目配置如下,我们分别先来讲讲这两个基础文件夹
containers文件夹中存放不同项目中的业务逻辑packages文件夹中存放不同项目中的入口文件
这两个文件中的内容我们先不关心,仅建立好最基础的文件目录。
配置react支持
接下来让我们的项目先支持最原始的jsx文件,让项目支持react的jsx。
支持jsx需要额外配置babel去处理jsx文件,将jsx转译成为浏览器可以识别的js。
这里我们需要用到如下几个库:
babel-loader@babel/core@babel/preset-env@babel/plugin-transform-runtime@babel/preset-react
我们来稍微梳理一下这几个babel的作用,具体babel原理这里我不进行过分深究。
babel-loader
首先对于我们项目中的jsx文件我们需要通过一个"转译器"将项目中的jsx文件转化成js文件,babel-loader在这里充当的就是这个转译器。
@babel/core
但是babel-loader仅仅识别出了jsx文件,内部核心转译功能需要@babel/core这个核心库,@babel/core模块就是负责内部核心转译实现的。
@babel/preset-env
@babel/prest-env是babel转译过程中的一些预设,它负责将一些基础的es 6+语法,比如const/let...转译成为浏览器可以识别的低级别兼容性语法。
这里需要注意的是
@babel/prest-ent并不会对于一些es6+并没有内置一些高版本语法的实现比如Promise等polyfill,你可以将它理解为语法层面的转化不包含高级别模块(polyfill)的实现。
@babel/plugin-transform-runtime
@babel/plugin-transform-runtime,上边我们提到了对于一些高版本内置模块,比如Promise/Generate等等@babel/preset-env并不会转化,所以@babel/plugin-transform-runtime就是帮助我们来实现这样的效果的,他会在我们项目中如果使用到了Promise之类的模块之后去实现一个低版本浏览器的polyfill。
其实与
@babel/plugin-transform-runtime达到相同的效果还可以直接安装引入@babel/polyfill,不过相比之下这种方式不被推荐,他存在污染全局作用域,全量引入造成提及过大以及模块之间重复注入等缺点。
此时这几个插件我们已经可以实现将es6+代码进行编译成为浏览器可以识别的低版本兼容性良好的js代码了,不过我们还缺少最重要一点。
目前这些插件处理的都是js文件,我们得让她能够识别并处理jsx文件。
@babel/preset-react
此时就引入了我们至关重要的@babel/preset-react这个插件,在jsx中我们使用的jsx标签实质上最终会被编译成为:

有兴趣的朋友可以看看我之前的这篇文章
React中的jsx原理解析。
最终我们希望将.jsx文件转化为js文件同时将jsx标签转化为React.createElement的形式,此时我们就需要额外使用babel的另一个插件-@babel/preset-react。
@babel/preset-react是一组预设,所谓预设就是内置了一系列babel plugin去转化jsx代码成为我们想要的js代码。
babel所需要的配置这里我们已经讲完了需要用到的包和对应的作用,因为babel涉及的编译原理部分的直接特别多所以我们这里仅仅了解如何配置就可以了,有兴趣的朋友可以移步babel官网去详细查看。
项目babel配置
接下来让我们来安装这5个插件,并且在webpack中进行配置:
yarn add -D @babel/core @babel/preset-env babel-loader @babel/plugin-transform-runtime @babel/preset-react
创建基础webpack配置
当我们安装完成上边的编译工具后,我们就来创建一个基础的webpack.config.js来使用它来转译我们的jsx文件:

我们来在项目跟目录下创建一个scripts/webpack.base.js文件。
关于webpack对于代码的转译,所谓转译直白来讲也就是webpack默认只能处理基于js json 的内容。
如果我们想让webpack处理我们的jsx内容,就需要配置loader告诉它,
"嘿,webpack碰到.jsx后缀的文件使用这个loader来处理。"
我们来编写基础的babel-loader配置:
webpack.base.js
// scripts/webpack.base.js
const path = require('path');
module.exports = {
// 入口文件,这里之后会着重强调
entry: {
main: path.resolve(__dirname, '../src/packages/home/index.jsx'),
},
module: {
rules: [
{
test: /\.jsx?$/,
use: 'babel-loader',
},
],
},
};
此时我们已经告诉webpack,如果遇到jsx或者js代码,我们需要使用babel-loader进行处理,通过baebel将项目中的js/jsx文件处理成为低版本浏览器可以识别的代码。
.babelrc
上边我们讲到了babel-loader仅仅是一个桥梁,真正需要转译作用的其他的插件。接下来就让我们来使用它:
babel-loader提供了两种配置方式,一种是直接在webpack配置文件中编写options,另一个是官方推荐的在项目目录下建立.babelrc文件单独配置babel。
这里我们采用第二种推荐的配置:
// .babelrc
{
"presets": [
"@babel/preset-env",
"@babel/preset-react"
],
"plugins": [
[
"@babel/plugin-transform-runtime",
{
"regenerator": true
}
]
]
}
packages/home创建入口文件index.jsx
接下来让我们在packages/home目录下创建home业务的入口文件
// packages/home/index.jsx
import ReactDom from 'react-dom'
const Element = <div>hello</div>
ReactDom.render(<Element />,document.getElementById('root'))
到这一步相关基础配置文件已经初具模型了,我们需要做的就是当我们调用webpack打包我们项目的时候使用我们刚才书写的webpack.base.js这个配置文件。
pacakge.json增加build脚本
我们来稍微修改一下pacakge.json:
{
"name": "pages",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build": "webpack --config ./scripts/webpack.base.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.15.5",
"@babel/plugin-transform-runtime": "^7.15.0",
"@babel/preset-env": "^7.15.6",
"@babel/preset-react": "^7.14.5",
"babel-loader": "^8.2.2",
"webpack": "^5.53.0",
"webpack-cli": "^4.8.0"
},
"dependencies": {
"react": "^17.0.2",
"react-dom": "^17.0.2"
}
}
这里我们删除了初始化的
test脚本,增加了build命令。
接下来让我们运行npm run build:

这一步我们已经成功让webpack识别jsx代码并且支持将高版本js转化为低版本javascript代码了。
如果有疑问的话,可以停留下来在想一想大概流程。主要就是:
- 创建
babel配置转译jsx/js内容。 - 创建入口文件。
webpack中对于jsx/js内容使用babel-loader调用babel配置好的预设和插件进行转译。
接下来让我们继续来支持TypeScript吧!
配置TypeScript支持
针对TypeScript代码的支持其实业内存在两种编译方式:
- 直接通过
TypeScript去编译ts/tsx代码。 - 通过
babel进行转译。
其实这两种方式都是可以达到编译TypeScript代码成为JavaScript并且兼容低版本浏览器代码的。
有兴趣的朋友可以自行搜索这两种方式的差异,平常在一些类库的打包时我会直接使用tsc结合tsconfig.js编译ts代码。
日常工作中,大部分情况我个人还是会使用babel进行转译,因为涉及到业务往往是需要css等静态资源与ts代码一起打包,那么使用babel + webpack其实可以很好的一次性囊括了所有的资源编译过程。
babel支持Typescirpt
babel内置了一组预设去转译TypeScript代码 --@babel/preset-typescript。
接下来让我们来使用@babel/preset-typescript预设来支持TypeScript语法吧。
npm install --save-dev @babel/preset-typescript
安装完成之后让我们一步一步来修改之前的配置:
首先我们先来修改之前.babelrc配置文件,让babel支持转译的ts文件:
{
"presets": [
"@babel/preset-env",
"@babel/preset-react",
+ "@babel/preset-typescript"
],
"plugins": [
[
"@babel/plugin-transform-runtime",
{
"regenerator": true
}
]
]
}
这里我们在presets添加了@babel/preset-typescrpt预设去让babel支持typescript语法。
此时我们的babel已经可以识别TypeScript语法了
webpack支持ts/tsx文件
不要忘记同时修改我们的webpack中babel-loader的匹配规则:
// webpack.base.jf
const path = require('path');
module.exports = {
// 入口文件,这里之后会着重强调
entry: {
// 这里修改`jsx`为`tsx`
main: path.resolve(__dirname, '../src/packages/home/index.tsx'),
},
module: {
rules: [
{
// 同时认识ts jsx js tsx 文件
test: /\.(t|j)sx?$/,
use: 'babel-loader',
},
],
},
};
这里我们将ts,js,tsx,jsx文件都交给babel-loader处理。
初始化tsconfig.json
现在,我们项目中已经可以支持tsx文件的编写,同时也支持编译ts文件为低版本js文件了。
在使用Ts时,通常我们需要配置typescript的配置文件,没错就是tsconfig.json。
也许你已经见到过很多次tsconfig.json了,接下来让我们去安装typescript并且初始化吧~
项目内安装Ts:
yarn add -D typescript
调用tsc --init命令初始化tsconfig.json:
npx tsc --init
关于
npx命令和npm的关系,之后我会在另一篇文章中细细讲述。了解他在这里的用途:调用当前项目内node_modules/typescript/bin的可执行文件执行init命令就可以了。
现在我们的目录应该是这样的:

虽然说我们使用
babel进行的编译,tsconfig.json并不会在编译时生效。但是tsconfig.json中的配置非常影响我们的开发体验,接下来我们就来稍微修改一下一下它吧。
配置tsconfig.json
首先我们来找到对应的jsx选项:

他的作用是指定jsx的生成什么样的代码,简单来说也就是jsx代码将被转化成为什么。
这里我们将它修改为react。

接下来我们来修改一下ts中的模块解析规则,将它修改为node:
"moduleResolution": "node",

这里暂时我们先修改这两个配置,后续配置我们会在后边的讲解中渐进式的进行配置。
推荐一本开源的电子书,这里罗列了大部分
tsconfig.json配置信息
处理报错
我们已经在项目中完美支持了typescript,接下里让我们把pacakges/home/index.jsx改为packages/home/index.tsx吧.
修改完成后缀后我们再来看看我们想项目文件:

我们来一个一个解决这些报错:
首先我们引用第三方包在TypeScript文件时,简单来说它会寻找对应包的package.json中的type字段查找对应的类型定义文件。
react和react-dom这两个包代码中都不存在对应的类型声明,所以我们需要单独安装他们对应的类型声明文件:
yarn add -D @types/react-dom @types/react
大多数额外的类型定义包,你可在这里找到
安装完成之后,我们重新再看看当前的index.tsx文件:

此时,仅剩下一个报错了。让我们来仔细定位一下错误。ts告诉我们ReactDom.render方法中传入的参数类型不兼容。嗯,本质上是我们react语法写错了。修改后的代码如下:

此时我们的项目已经可以完成支持typescript和react了。
webpack配置静态资源支持
一个成熟的项目只能有ts怎么能够呢? 毕竟一个成熟的业务仔怎么脱离css的魔抓呢😂
也许你之前接触过webpack5之前的静态资源处理,file-loader,url-loader,row-loader这些loader是不是听起来特别熟悉。
webpack默认是不支持非js文件的,所以在webpack5之前我们通过loader的方式返回可执行的js脚本文件,内部将处理这些webpack不认识的文件。
在webpack 5+版本之后,这些loader的作用都已经被内置了~
接下来我们来看看应该如何配置,具体对应的作用可以查看webpack资源模块
处理图片,文件资源文件
资源模块类型(asset module type),通过添加 4 种新的模块类型,来替换所有这些 loader:
asset/resource发送一个单独的文件并导出 URL。之前通过使用file-loader实现。asset/inline导出一个资源的 data URI。之前通过使用url-loader实现。asset/source导出资源的源代码。之前通过使用raw-loader实现。asset在导出一个 data URI 和发送一个单独的文件之间自动选择。之前通过使用url-loader,并且配置资源体积限制实现。
当在 webpack 5 中使用旧的 assets loader(如 file-loader/url-loader/raw-loader 等)和 asset 模块时,你可能想停止当前 asset 模块的处理,并再次启动处理,这可能会导致 asset 重复,你可以通过将 asset 模块的类型设置为 'javascript/auto' 来解决。
关于配置
type:'asset'后,webpack 将按照默认条件,自动地在resource和inline之间进行选择:小于 8kb 的文件,将会视为inline模块类型,否则会被视为resource模块类型。
我们可以通过设置 Rule.parser.dataUrlCondition.maxSize 选项来修改此条件
其实
maxSize就相当于url-loader中的limit属性,资源大小在maxSize之内使用行内asset/inline处理,超过之后就使用resource导出资源。当然这个配置也支持导出一个函数自定义配置实现。
了解了assets模块的用途之后,我们来试着配置它来处理静态资源:
const path = require('path');
module.exports = {
// 入口文件,这里之后会着重强调
entry: {
main: path.resolve(__dirname, '../src/packages/home/index.tsx'),
},
module: {
rules: [
{
// 同时认识ts jsx js tsx 文件
test: /\.(t|j)sx?$/,
use: 'babel-loader',
},
{
test: /\.(png|jpe?g|svg|gif)$/,
type:'asset/inline'
},
{
test: /\.(eot|ttf|woff|woff2)$/,
type: 'asset/resource',
generator: {
filename: 'fonts/[hash][ext][query]',
},
},
],
},
};
这一步我们已经关于图片和字体文件配置已经配置完毕了,接下来我们来修改一下目录结构验证一下我们的配置是否生效:
验证配置效果
修改packages
首先让我们先来修改packages,packages文件夹之前讲过是存放多页面应用中每个页面的入口文件的。让我们在home文件夹下先新建一个app.tsx:
// src/packages/home/app.tsx
import React from 'react'
import ReactDom from 'react-dom'
// 这里App仅接着就会降到 它就是就是一个React FunctionComponent
import { App } from '../../containers/home/app.tsx'
ReactDom.render(<App />, document.getElementById('root'))
修改containers
containers文件夹中的内容是存放每个页面应用不同的业务逻辑的部分。
让我们在containers中新建app.tsx作为跟应用,以及同级目录下新建assets,styles,views三个目录:
assets存放当前模块相关静态资源,比如图片,字体文件等。styles存在当前模块相关样式文件,我们还没有配置相关样式文件的处理,之后会详细介绍。views存放当前模块下相关页面逻辑页面拆分
我们给assets中新建一个images文件夹,放入一张logo,图片。
修改app.tsx内容如下:
import React from 'react'
import Banner from './assets/image/banner.png'
const App: React.FC = () => {
return <div>
<p>Hello,This is pages!</p>
<img src={Banner} alt="" />
</div>
}
export {
App
}
yarn build
我们当前的目录结构如下:

基本的目录结构我们已经搭建成功,接下来让我们运行yarn build。
首先根据webpack中的入口文件会去寻找packages/home/index.tsx,我们在index.tsx中引入了对应的containers/app.tsx,webpack会基于我们的import语法去处理模块依赖关系构建模块。
同时因为我们在app.tsx中引入了图片
// webpack.base.js
{
test: /\.(png|jpe?g|svg|gif)$/,
type: 'asset/inline'
},
此时type:'assets/inline'针对图片的处理就会生效了!
让我们来看看build之后的文件:

asset/inline会讲资源文件内置成为base64和url-loader是相同的作用。
当前目前
webpack的asset/inline模块会将所有资源转化为base64在行内迁入,如果要达到url-loader中limit的配置需要禁用asset/inline使用url-loader处理或者使用type:'asset'来处理.
解决报错
细心的你可能已经发现了,目前我们项目中存在两个问题:
ts文件中针对image的引入,ts并不能正确的识别。

解决这个问题的方式其实很简单,我们定义一个image.d.ts在根目录下:
declare module '*.svg';
declare module '*.png';
declare module '*.jpg';
declare module '*.jpeg';
declare module '*.gif';
declare module '*.bmp';
declare module '*.tiff';
declare为声明语法,意思为声明ts全局模块,这样我们就可以正常引入对应的资源了。
- 我们在
index.tsx中引入了对应的app.tsx,当存在后缀时ts会进行报错提示:

接下来让我们来解决这个问题吧。其实无法就是引入文件时默认后缀名的问题:
- 目前
webpack不支持默认后缀名.tsx - 而
tsconfig.json中是支持后缀名.tsx,所以显示声明会提示错误。
我们来统一这两个配置:
别名统一
修改webpack别名配置
// webpack.base.js
const path = require('path');
module.exports = {
// 入口文件,这里之后会着重强调
entry: {
main: path.resolve(__dirname, '../src/packages/home/index.tsx'),
},
resolve: {
alias: {
'@src': path.resolve(__dirname, '../src'),
'@packages': path.resolve(__dirname, '../src/packages'),
'@containers': path.resolve(__dirname, '../src/containers'),
},
mainFiles: ['index', 'main'],
extensions: ['.ts', '.tsx', '.scss', 'json', '.js'],
},
module: {
rules: [
{
// 同时认识ts jsx js tsx 文件
test: /\.(t|j)sx?$/,
use: 'babel-loader',
},
{
test: /\.(png|jpe?g|svg|gif)$/,
type: 'asset/inline'
},
{
test: /\.(eot|ttf|woff|woff2)$/,
type: 'asset/resource',
generator: {
filename: 'fonts/[hash][ext][query]',
},
},
],
},
};
这里我们添加了resolve的参数,配置了别名@src,@package,@container。
以及当我们不书写文件后缀时,默认的解析规则extensions规则。
同时还配置了mainFiles,解析文件夹路径~
这个三个配置都比较基础,就不过多深入了哈。如果仍然还是不是很懂,用到的时候多翻翻慢慢也就记住啦!
让我们来尝试修改index.tsx,使用别名来引入:

此时我们发现并没有路径提示,这个!是真的无法接受!
原因是我们是基于typescript开发,所以ts文件中并不知道我们在webpack中配置的别名路径。
所以我们需要做的就是同步修改tsconfig.json,让tsconfig.json也支持别名以及刚才的配置,达到最佳的开发体验。
修改tsconfig.json别名配置
我们来修改tsconfig.json,让ts同步支持引入:
// tsconfig.json
...
"baseUrl": "./",
/* Specify the base directory to resolve non-relative module names. */
"paths": {
"@src/*": ["./src/*"],
"@packages/*": ["./src/packages/*"],
"@containers/*": ["./src/containers/*"],
},
如果要配置paths那么一定是要配置baseUrl的,所谓baseUrl就是我们的paths是相对于那个路径开始的。
所以我们在paths中添加对应的别名路径就可以完成配置,让ts也可以合理解析出我们的类型别名。
此时我们再来看看:

已经可以正确出现路径提示了,是不是非常nice。
针对路径/文件目录解析规则配置我们目前就告一段落了~
配置css/sass
接下来我们给项目添加一些样式来美化它。
这里其实React项目有太多有关css的争吵了,但是无论如何我们是都要在webpack中针对css进行处理的。
这里我选择使用
sass预处理器进行演示,其他less等都是同理。
针对于sass文件,同样是webpack不认识的文件。咱们同样是需要loader
去处理。
这里用到的loader如下:
sass-loaderresolve-url-loaderpostcss-loadercss-loaderMiniCssExtractPlugin.loader
我们来一个一个来分析这些loader的作用的对应的配置:
sass-loader
针对于sass文件我们首先一定是要使用sass编译成为css的,所以我们首先需要对.scss结尾的文件进行编译成为css文件。
这里我们需要安装:
yarn add -D sass-loader sass
sass-loader需要预先安装 Dart Sass 或 Node Sass(可以在这两个链接中找到更多的资料)。这可以控制所有依赖的版本, 并自由的选择使用的 Sass 实现。
sass-loader的作用就类似我们之前讲到过的babel-loader,可以将它理解成为一个桥梁,sass转译成为css的核心是由node-sass或者dart-sass去进行编译工作的。
resolve-url-loader
上一步我们已经讲到过sass-loader将sass文件转化为css文件。
但是这里有一个致命的问题,就是关于webpack对scss文件中
由于 Saass 的实现没有提供 url 重写的功能,所以相关的资源都必须是相对于输出文件(ouput)而言的。
- 如果生成的 CSS 传递给了
css-loader,则所有的 url 规则都必须是相对于入口文件的(例如:main.scss)。 - 如果仅仅生成了 CSS 文件,没有将其传递给
css-loader,那么所有的 url 都是相对于网站的根目录的。
所以针对于sass编译后的css文件中的路径是不正确的,并不是我们想要的相对路径模式。
想要解决路径引入的问题业内有很多现成的办法,比如通过
- 路径变量定义引入路径
- 定义别名,
sass中使用别名引入路径 resolve-url-loader
这里我们采用
resolve-url-loader来处理文件引入路径问题。
不要忘记
yarn add -D resolve-url-loader
postcss-loader
PostCSS是什么?或许,你会认为它是预处理器、或者后处理器等等。其实,它什么都不是。它可以理解为一种插件系统。
针对于postcss其实我这里并不打算深入去讲解,它是babel一样都是两个庞然大物。拥有自己独立的体系,在这里你需要清楚的是我们使用postcss-loader处理生成的css。
第一步首先安装post-css对应的内容:
yarn add -D postcss-loader postcss
postcss-loader同时支持直接在loader中配置规则选项,也支持单独建立文件配置,这里我们选择单独使用文件进行配置:
我们在项目根目录下新建一个postcss.config.js的文件:
module.exports = {
plugins: [
require('autoprefixer'),
require('cssnano')({
preset: 'default',
}),
],
}
这里我们使用到了两个postcss的插件:
autoprefixer插件的作用是为我们的css内容添加浏览器厂商前缀兼容。cssnano的作用是尽可能小的压缩我们的css代码。
接下来我们去安装这两个插件:
yarn add -D cssnano autoprefixer@latest
css-loader
css-loader是解析我们css文件中的@import/require语句分析的.
yarn add -D css-loader
MiniCssExtractPlugin.loader
这个插件将 CSS 提取到单独的文件中。它为每个包含CSS 的 JS 文件创建一个 CSS 文件。它支持按需加载 CSS 和 SourceMaps。
这里需要提一下他和style-loader的区别,这里我们使用了MiniCssExtractPlugin代替了style-loader。
style-loader会将生成的css添加到html的header标签内形成内敛样式,这显然不是我们想要的。所以这里我们使用MiniCssExtractPlugin.loader的作用就是拆分生成的css成为独立的css文件。
yarn add -D mini-css-extract-plugin
生成sass最终配置文件
接下来我们来生成sass文件的最终配置文件:
// webapck.base.js
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
module.exports = {
// 入口文件,这里之后会着重强调
entry: {
main: path.resolve(__dirname, '../src/packages/home/index.tsx'),
},
resolve: {
alias: {
'@src': path.resolve(__dirname, '../src'),
'@packages': path.resolve(__dirname, '../src/packages'),
'@containers': path.resolve(__dirname, '../src/containers'),
},
mainFiles: ['index', 'main'],
extensions: ['.ts', '.tsx', '.scss', 'json', '.js'],
},
module: {
rules: [
{
test: /\.(t|j)sx?$/,
use: 'babel-loader',
},
{
test: /\.(sa|sc)ss$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
},
'css-loader',
'postcss-loader',
{
loader: 'resolve-url-loader',
options: {
keepQuery: true,
},
},
{
loader: 'sass-loader',
options: {
sourceMap: true
},
},
],
},
{
test: /\.(png|jpe?g|svg|gif)$/,
type: 'asset/inline'
},
{
test: /\.(eot|ttf|woff|woff2)$/,
type: 'asset/resource',
generator: {
filename: 'fonts/[hash][ext][query]',
},
},
],
},
plugins: [
new MiniCssExtractPlugin({
filename: 'assets/[name].css',
}),
]
};
// postcss.config.js
module.exports = {
plugins: [
require('autoprefixer'),
require('cssnano')({
preset: 'default',
}),
],
}
完成这些配置之后,同时我们在containers/src/home/styles目录中新建一个sass文件index.scss:
让我们来写一些简单的样式文件:
.body {
background-color: red;
}

这之后,让我们重新运行yarn build
检查生成的dist文件我们发现,我们的sass被成功的编译成为了css文件并且删除了多于空格(进行了压缩)

这一步我们scss的基础配置也已经完成了!
配置html页面
当前我们所有涉及的都是针对单页面应用的配置,此时我们迫切需要一个html展示页面。
此时就引入我们的主角,我们后续的多页面应用也需要机遇这个插件生成html页面
html-webpack-plugin,其实看到这里我相信大家对这个插件原本就已经耳熟能详了。
简单介绍一下它的作用: 这个插件为我们生成 HTML 文件,同时可以支持自定义
html模板。
多页面应用主要基于它的
chunks这个属性配置,我们这里先买个关子。
让我们来使用一下这个插件:
yarn add --dev html-webpack-plugin
我们在项目根目录下创建一个public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id='root'></div>
</body>
</html>
我们使用这个文件作为插件的模板文件,同时与入口文件中的ReactDom.reander(...,document.getElementById('root'))进行呼应,在页面创建一个id=root的div作为渲染节点。
// webpack.base.js
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const htmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
// 入口文件,这里之后会着重强调
entry: {
main: path.resolve(__dirname, '../src/packages/home/index.tsx'),
},
resolve: {
alias: {
'@src': path.resolve(__dirname, '../src'),
'@packages': path.resolve(__dirname, '../src/packages'),
'@containers': path.resolve(__dirname, '../src/containers'),
},
mainFiles: ['index', 'main'],
extensions: ['.ts', '.tsx', '.scss', 'json', '.js'],
},
module: {
rules: [
{
test: /\.(t|j)sx?$/,
use: 'babel-loader',
},
{
test: /\.(sa|sc)ss$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
},
'css-loader',
'postcss-loader',
{
loader: 'resolve-url-loader',
options: {
keepQuery: true,
},
},
{
loader: 'sass-loader',
options: {
sourceMap: true,
},
},
],
},
{
test: /\.(png|jpe?g|svg|gif)$/,
type: 'asset/inline',
},
{
test: /\.(eot|ttf|woff|woff2)$/,
type: 'asset/resource',
generator: {
filename: 'fonts/[hash][ext][query]',
},
},
],
},
plugins: [
new MiniCssExtractPlugin({
filename: 'assets/[name].css',
}),
// 生成html名称为index.html
// 生成使用的模板为public/index.html
new htmlWebpackPlugin({
filename: 'index.html',
template: path.resolve(__dirname, '../public/index.html'),
}),
],
};
此时,当我们再次运行yarn build时,我们生成的dist目录下会多出一个html文件,这个html文件会注入我们打包生成后的的js和css内容。

打开index.html,就会展示出我们代码中书写的页面啦~
配置开发环境预览
上边的长篇大论已经能满足一个SPA单页面应用的构建了,但是我们总不能每次修改代码都需要执行一次打包命令在预览吧。
这样的话也太过于麻烦了,别担心webpack为我们提供了devServer配置,支持我们每次更新代码热重载。
我们来使用一下这个功能吧~
首先让我们在scripts目录下新建一个webpack.dev.js文件,表示专门用于开发环境下的打包预览:
虽然
devServer已经内置了hot:true达到热重载,但是我们仍然需要安装webpack-dev-server。
// webpack.dev.js
const { merge } = require('webpack-merge');
const baseConfig = require('./webpack.base');
const path = require('path');
const devConfig = {
mode: 'development',
devServer: {
// static允许我们在DevServer下访问该目录的静态资源
// 简单理解来说 当我们启动DevServer时相当于启动了一个本地服务器
// 这个服务器会同时以static-directory目录作为跟路径启动
// 这样的话就可以访问到static/directory下的资源了
static: {
directory: path.join(__dirname, '../public'),
},
// 默认为true
hot: true,
// 是否开启代码压缩
compress: true,
// 启动的端口
port: 9000,
},
};
module.exports = merge(devConfig, baseConfig);
关于devServer的基础配置在代码中进行了注释讲解,当然还存在一些其他的proxy,onlistening等配置需要的小朋友可以去这里查阅。
这里需要提到的是
webpack-merge这个插件是基于webpack配置合并的,这里我们基于webpack.base.js和webpack.dev.js合并导出了一个配置对象。
接下里再让我们修改一下pacakge.json下的scripts命令。
devServer需要使用webpack serve启动。
...
"scripts": {
+ "dev": "webpack serve --config ./scripts/webpack.dev.js",
"build": "webpack --config ./scripts/webpack.base.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
...
接下来让我们运行yarn dev就可以在localhost:9000访问到我们刚才需要的页面并且实时支持热重载了。
支持端口被占用启动
这里有一个小tip当我们的devServer端口被占用的时候我们再次启动项目会因为相同的端口被占用而报错

解决端口占用的问题我们需要借助一个第三方库node-portfinder
它的用法很简答有兴趣的同学可以点击上方库名移步官网来查看用法:
首先先让我们来给scripts/utils/constant.js中添加一个常量为固定的启动端口:
// scripts/utils/constant.js
...
// 固定端口
const BASE_PROT = 9000
...
module.exports = {
MAIN_FILE,
log,
separator,
BASE_PROT
}
接下来让我们来修改一下上边的webpack.dev.js:
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.base')
const portfinder = require('portfinder')
const path = require('path')
const { BASE_PROT } = require('./utils/constant')
portfinder.basePort = BASE_PROT
const devConfig = {
mode: 'development',
devServer: {
// static允许我们在DevServer下访问该目录的静态资源
// 简单理解来说 当我们启动DevServer时相当于启动了一个本地服务器
// 这个服务器会同时以static-directory目录作为跟路径启动
// 这样的话就可以访问到static/directory下的资源了
static: {
directory: path.join(__dirname, '../public'),
},
// 默认为true
hot: true,
// 是否开启代码压缩
compress: true,
// 启动的端口
port: BASE_PROT,
},
}
module.exports = async function () {
try {
// 端口被占用时候 portfinder.getPortPromise 返回一个新的端口(往上叠加)
const port = await portfinder.getPortPromise()
devConfig.devServer.port = port
return merge(devConfig, baseConfig)
} catch (e) {
throw new Error(e)
}
}
我们把导出从对象变成了导出一个函数,webpack中配置的导出支持一个对象的同时也支持一个函数~
然后函数中调用portfinder.getPortPromise()判断当前端口是否占用,如果占用portfinder会返回一个新的端口,此时我们修改devConfig中的端口并且返回最新的配置进行启动就可以了!
让我们重新运行yarn dev~

切入多页面应用
接下来正式进入我们的多入口文件部分讲解。
原理
拆分js
所谓基于webpack实现多页面应用,简单来将就是基于多个entry,将js代码分为多个entry入口文件。
比如我在src/packages/editor新建一个入口文件index.tsx,同时修改webpack中的entry配置为两个入口文件,webpack就会基于入口文件的个数自动进行不同chunk之间的拆分。
简单来说就是
webapck会基于入口文件去拆分生成的js代码成为一个一个chunk。

上边的配置我们运行yarn build之后生成的dist目录如下:

我们可以看到根据不同的入口文件生成了两份js文件,一份是main.js一份是editor.js。
我们第一步已经完成了,基于不同的入口文件打包生成不同的js。
拆分html
但是现在我们现在拆分出来的js还是在同一个index.html中进行引入,我们想要达到的效果是main.js在main.html中引入成为一个页面。
editor.js在editor.html中引入成为一个单独的页面。
要实现这种功能,我们需要在html-webpack-plugin上做手脚。
不知道大家还记不记得我们之前留下的chunks这个关子。
所谓的
chunks配置指的是生成的html文件仅仅包含指定的chunks块。
这不正是我们想要的嘛!
现在我们打包生成了两份js文件分别是editor.js和main.js,现在我们想生成两份单独的html文件,两个html文件中分别引入不同的editor.js和main.js。
此时我们每次打包只需要调用两次htmlWebpackPlugin,一份包含editor这个chunk,一份包含main这个chunk不就可以了嘛。
让我们来试一试:

接下来我们运行yarn build来看一看生成的文件:

来看看我们生成的html和js结构,大功告成没一点问题!这样的确能解决,这也是基于webpack打包多页面应用的原理。
可是如果我们存在很多个页面的话,首先我们每次都需要手动修改入口文件然后在进行添加一个htmlWebpackPlugin这显然是不人性的。
同时如果这个项目下有很多个多页应用,但是我每次开发仅仅关心某一个应用进行开发,比如我负责的是home模块,我并不想使用和关心editor模块。那么每次我还需要在dev环境下进行打包吗?显然是不需要的。
接下来就让我们尝试来修改这些配置将它变成自动化且按需打包的工程化配置吧。
工程化多页配置
工程化原理
我们之前已经讲清楚了webpack中的原理了,接下来我们需要实现的过程是:
- 每次打包通过
node脚本去执行打包命令。 - 每次打包通过命令行交互命令,读取
pacakges下的目录让用户选择需要打包的页面。 - 当用户选中对应需要打包的目录后,通过环境变量注入的方式动态进行打包不同的页面。
这里我们需要额外用到一下几个库,还不太清楚的同学可以点击去查看一下文档:
这个库改进了node的源生模块child_process,用于开启一个node子进程。
inquirer提供一些列api用于nodejs中和命令行的交互。
chalk为我们的打印带上丰富的颜色.
yarn add -D chalk inquirer execa
实现代码
首先让我们在scripts下创建一个utils的文件夹。
utils/constant.js
constant.js中存放我们关于调用脚本声明的一些常量:
// 规定固定的入口文件名 packages/**/index.tsx
const MAIN_FILE = 'index.tsx'
const chalk = require('chalk')
// 打印时颜色
const error = chalk.bold.red
const warning = chalk.hex('#FFA500')
const success = chalk.green
const maps = {
success,
warning,
error,
}
// 因为环境变量的注入是通过字符串方式进行注入的
// 所以当 打包多个文件时 我们通过*进行连接 比如 home和editor 注入的环境变量为home*editor
// 注入多个包环境变量时的分隔符
const separator = '*'
const log = (message, types) => {
console.log(maps[types](message))
}
module.exports = {
MAIN_FILE,
log,
separator,
BASE_PORT,
}
utils/helper.js
const path = require('path')
const fs = require('fs')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { MAIN_FILE } = require('./constant')
// 获取多页面入口文件夹中的路径
const dirPath = path.resolve(__dirname, '../../src/packages')
// 用于保存入口文件的Map对象
const entry = Object.create(null)
// 读取dirPath中的文件夹个数
// 同时保存到entry中 key为文件夹名称 value为文件夹路径
fs.readdirSync(dirPath).filter(file => {
const entryPath = path.join(dirPath, file)
if (fs.statSync(entryPath)) {
entry[file] = path.join(entryPath, MAIN_FILE)
}
})
// 根据入口文件list生成对应的htmlWebpackPlugin
// 同时返回对应wepback需要的入口和htmlWebpackPlugin
const getEntryTemplate = packages => {
const entry = Object.create(null)
const htmlPlugins = []
packages.forEach(packageName => {
entry[packageName] = path.join(dirPath, packageName, MAIN_FILE)
htmlPlugins.push(
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '../../public/index.html'),
filename: `${packageName}.html`,
chunks: ['manifest', 'vendors', packageName],
})
)
})
return { entry, htmlPlugins }
}
module.exports = {
entry,
getEntryTemplate,
}
helper.js中其实主要导出的getEntryTemplate方法,这个方法输入一个package的数组,同时返回对应webpack需要的entry和html-wepback-plugin组成的数组。
utils/dev.js
当我们定义好两个辅助文件之后,接下来我们就要实现和"用户"交互的部分了,也就是当用户调用我们这个脚本之后。
- 首先动态读取
packages下的目录,获取当前项目下所有的页面文件。 - 通过命令交互罗列当前所有页面,提供给用户选择。
- 用户选中后,通过
execa调用webpack命令同时注入环境变量进行根据用户选中内容打包。
const inquirer = require('inquirer')
const execa = require('execa')
const { log, separator } = require('./constant')
const { entry } = require('./helper')
// 获取packages下的所有文件
const packagesList = [...Object.keys(entry)]
// 至少保证一个
if (!packagesList.length) {
log('不合法目录,请检查src/packages/*/main.tsx', 'warning')
return
}
// 同时添加一个全选
const allPackagesList = [...packagesList, 'all']
// 调用inquirer和用户交互
inquirer
.prompt([
{
type: 'checkbox',
message: '请选择需要启动的项目:',
name: 'devLists',
choices: allPackagesList, // 选项
// 校验最少选中一个
validate(value) {
return !value.length ? new Error('至少选择一个项目进行启动') : true
},
// 当选中all选项时候 返回所有packagesList这个数组
filter(value) {
if (value.includes('all')) {
return packagesList
}
return value
},
},
])
.then(res => {
const message = `当前选中Package: ${res.devLists.join(' , ')}`
// 控制台输入提示用户当前选中的包
log(message, 'success')
runParallel(res.devLists)
})
// 调用打包命令
async function runParallel(packages) {
// 当前所有入口文件
const message = `开始启动: ${packages.join('-')}`
log(message, 'success')
log('\nplease waiting some times...', 'success')
await build(packages)
}
// 真正打包函数
async function build(buildLists) {
// 将选中的包通过separator分割
const stringLists = buildLists.join(separator)
// 调用通过execa调用webapck命令
// 同时注意路径是相对 执行node命令的cwd的路径
// 这里我们最终会在package.json中用node来执行这个脚本
await execa('webpack', ['server', '--config', './scripts/webpack.dev.js'], {
stdio: 'inherit',
env: {
packages: stringLists,
},
})
}
dev.js中的逻辑其实很简单,就是通过命令行和用户交互获得用户想要启动的项目之后通过用户选中的packages然后通过execa执行webpack命令同时动态注入一个环境变量。
注入的环境变量是*分割的包名。比如用户如果选中app和editor那么就会注入一个packages的环境变量为app*editor。
修改webpack.base.js
我们已经可以在命令行中和用户交互,并且获得用户选中的pacakge。
此时我们就基于wepback.base.js来修改,达到读取用户环境变量进行动态打包的效果:
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const htmlWebpackPlugin = require('html-webpack-plugin');
const { separator } = require('./utils/constant')
const { getEntryTemplate } = require('./utils/helper')
// 将packages拆分成为数组 ['editor','home']
const packages = process.env.packages.split(separator)
// 调用getEntryTemplate 获得对应的entry和htmlPlugins
const { entry, htmlPlugins } = getEntryTemplate(packages)
module.exports = {
// 动态替换entry
entry,
resolve: {
alias: {
'@src': path.resolve(__dirname, '../src'),
'@packages': path.resolve(__dirname, '../src/packages'),
'@containers': path.resolve(__dirname, '../src/containers'),
},
mainFiles: ['index', 'main'],
extensions: ['.ts', '.tsx', '.scss', 'json', '.js'],
},
module: {
rules: [
{
test: /\.(t|j)sx?$/,
use: 'babel-loader',
},
{
test: /\.(sa|sc)ss$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
},
'css-loader',
'postcss-loader',
{
loader: 'resolve-url-loader',
options: {
keepQuery: true,
},
},
{
loader: 'sass-loader',
options: {
sourceMap: true,
},
},
],
},
{
test: /\.(png|jpe?g|svg|gif)$/,
type: 'asset/inline',
},
{
test: /\.(eot|ttf|woff|woff2)$/,
type: 'asset/resource',
generator: {
filename: 'fonts/[hash][ext][query]',
},
},
],
},
plugins: [
new MiniCssExtractPlugin({
filename: 'assets/[name].css',
}),
// 同时动态生成对应的htmlPlugins
...htmlPlugins
],
};
因为我们的目录结构是固定的,所以通过环境变量中的packages传入getEntryTemplate方法可以获得对应的入口文件,以及生成对应的htmlWebpackPlugin。
到这一步其实我们已经实现了动态打包的所有逻辑了。
接下来让我们来替换一下package.json中的脚本
修改package.json
"scripts": {
- "dev": "webpack serve --config ./scripts/webpack.dev.js",
+ "dev": "node ./scripts/utils/dev.js",
"build": "webpack --config ./scripts/webpack.base.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
我们将dev命令替换成为执行scripts/utils/dev.js。
接下来我们来试一下运行yarn dev:

我们选中home进行打包:


可以看到这次生成的打包结果就真的只有home.html和home.js相关的内容了,让我们打开localhost:9000/home.html可以看到页面上出现了我们想要的内容~
此时尝试去访问http://localhost:9000/editor.html会得到Cannot GET /editor.html。
这一步我们大功告成啦~但是当前只有dev环境下,让我们接下来来改造production环境下的配置。
改造production环境
production环境的代码和dev环境的流程思路是一摸一样的,只是针对于webpack配置有所不同。
所以我们在scripts新建一个webpack.prod.js文件:
// 这里我使用了默认的`webpack`production下的配置
// 如果你需要额外的配置,可以额外添加配置。
// 这里提供动态多页应用的流程 具体压缩/优化插件和配置 各位小哥可以去官网查看配置~
// 之后我也会在文章开头的github仓库中提供不同branch去实践最佳js代码压缩优化
const path = require('path')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const FriendlyErrorsWebpackPlugin = require('friendly-errors-webpack-plugin')
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.base')
const prodConfig = {
mode: 'production',
devtool: 'source-map',
output: {
filename: 'js/[name].js',
path: path.resolve(__dirname, '../dist'),
},
plugins: [
new CleanWebpackPlugin(),
new FriendlyErrorsWebpackPlugin()
]
}
module.exports = merge(prodConfig, baseConfig)
scripts/build.js,这里和dev下的思路是一模一样的,公用代码逻辑其实可以拆分~
const inquirer = require('inquirer')
const execa = require('execa')
const { log, separator } = require('./constant')
const { entry } = require('./helper')
const packagesList = [...Object.keys(entry)]
if (!packagesList.length) {
log('不合法目录,请检查src/packages/*/main.tsx', 'warning')
return
}
const allPackagesList = [...packagesList, 'all']
inquirer
.prompt([
{
type: 'checkbox',
message: '请选择需要打包的项目:',
name: 'buildLists',
choices: allPackagesList, // 选项
validate(value) {
return !value.length ? new Error('至少选择一个内容进行打包') : true
},
filter(value) {
if (value.includes('all')) {
return packagesList
}
return value
},
},
])
.then(res => {
// 拿到所有结果进行打包
const message = `当前选中Package: ${res.buildLists.join(' , ')}`
log(message, 'success')
runParallel(res.buildLists)
})
function runParallel(packages) {
const message = `开始打包: ${packages.join('-')}`
log(message, 'warning')
build(packages)
}
async function build(buildLists) {
const stringLists = buildLists.join(separator)
await execa('webpack', ['--config', './scripts/webpack.prod.js'], {
stdio: 'inherit',
env: {
packages: stringLists,
},
})
}
这一步其实我们已经完成了动态多页应用的实现了,原理部分之前也已经讲清楚了。
其实核心就是把我环境变量通过execa和inquirer进行命令行交互动态注入环境变量打包对应选中文件。
如果对
webpack中环境变量还是不太熟悉的同学可以点击这篇文章,Wepback中环境变量的各种姿势。
Eslint & prettier
完成了核心的应用流程打包代码,接下来我们来聊一些轻松的代码检查。
一份良好的工程架构代码规范检查是必不可少的配置。
prettier
yarn add --dev --exact prettier
安装完成之后我们在项目根目录下:
echo {}> .prettierrc.js
我们来个这个js内容添加一些基础配置
module.exports = {
printWidth: 100, // 代码宽度建议不超过100字符
tabWidth: 2, // tab缩进2个空格
semi: false, // 末尾分号
singleQuote: true, // 单引号
jsxSingleQuote: true, // jsx中使用单引号
trailingComma: 'es5', // 尾随逗号
arrowParens: 'avoid', // 箭头函数仅在必要时使用()
htmlWhitespaceSensitivity: 'css', // html空格敏感度
}
我们再来添加一份.prettierignore让prettier忽略检查一些文件:
//.prettierignore
**/*.min.js
**/*.min.css
.idea/
node_modules/
dist/
build/
同时让我们为我们的代码基于husky和lint-staged添加git hook
具体配置可以参照这里husky&list-staged
安装完成后,在我们每次commit时候都会触发lit-staged自动修复我们匹配的文件:

因为我们项目中是ts文件,所以要稍微修改一下他支持的后缀文件:
// package.json
...
"lint-staged": {
"*.{js,css,md,ts,tsx,jsx}": "prettier --write"
}
...
ESlint
Eslint其实就不用多说了,大名鼎鼎嘛。
yarn add eslint --dev
初始化eslint
npx eslint --init
eslint回和我们进行一些列的交互提示,按照提示进行选择我们需要的配置就可以了:

当
prettier和eslint共同工作时,他们可能会冲突。我们需要安装yarn add -D eslint-config-prettie插件并且覆盖eslint部分规则。
安装完成之后,我们稍微修改一下eslint的配置文件,让冲突时,优先使用prettier覆盖eslint规则:
// .eslint.js
module.exports = {
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended",
// 添加`prettier`拓展 用于和`prettier`冲突时覆盖`eslint`规则
"prettier"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 12,
"sourceType": "module"
},
"plugins": [
"react",
"@typescript-eslint"
],
"rules": {
}
};
同时我们来添加.eslintignore忽略掉一些我们的非ts目录文件,比如构建的一些脚本文件。
*.d.ts
scripts/**
写在最后
关于 Pages ,欢迎大家在留言区留下自己的意见指出对文章中的不足。
相信文章中的代码还有很多优化的点,这里提供给大家的主要是一个流程。
希望大家在评论区留下对于代码中存在的不足,或者可以持续优化点的。我们共同探讨😊!
后续如果有时间我会将Pages 持续完善然后集成到我cli中去,期待和大家分享cli部分的pages~
转载自:https://juejin.cn/post/7011128931533193230