分析并提高打包构建速度
随着项目功能和业务代码的增加,必然会带来构建时间过长的问题。
当本地启动 devServer 或 build 时,如果时间过长会严重影响工作效率。
一、分析影响构建速度的因素
1、分析相关文件
stats 可以在统计输出里指定想看到的信息。
在 package.json 中使用 stats:
"scripts":{
"build:stats": "webpack --env production --json > stats.json"
}
如果是 vue-cli3 搭建的项目工程,可以按照以下方式使用:
"scripts":{
"build:stats": "vue-cli-service build --mode prod --json > stats.json"
}
配置好运行命令后,就会在根目录生成一个 stats.json 文件,可以查看分析结果。
2、分析速度
配置 speed-measure-webpack-plugin 后可以在运行打包命令时看到每个loader 和插件执行耗时:
安装:
yarn add speed-measure-webpack-plugin -D
配置如下:
const SpeedMeasurePlugin = require('speed-measure-webpack-plugin')
const smp = new SpeedMeasurePlugin()
// 将默认的 webpack 配置文件包裹起来
const webpackConfig = smp.wrap({
plugins: [new MyPlugin(), new MyOtherPlugin()]
})
3、分析体积
使用 webpack-bundle-analyzer 构建完成后,会在 http://127.0.0.1:8888
展示相关文件大小。
安装:
yarn add webpack-bundle-analyzer -D
配置如下:
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin()
]
}
分析完影响构建速度的因素,接下来要对构建速度进行优化。
常见的提升 webpack 构建速度的思路有:
- 通过优化 loader、resolve 和 module 的配置缩小文件搜索范围;
- 使用 DllPlugin 插件采用分包的方式预编译资源模块;
- 使用 thread-loader、parallel-webpack 或 HappyPack 实现多线程加速编译。
二、缩小文件搜索范围
缩小文件搜索范围,可以加快匹配文件的速度,可以通过以下方式来实现:
- 优化 loader 配置
- 优化 resolve.alias 配置
- 优化 resolve.extensions 配置
- 优化 resolve.modules 的配置
- 优化 resolve.mainFields 配置
- 优化 module.noParse 配置
1、优化 loader 配置
使用 loader 时,一般会通过 include、exclude、test 属性来匹配文件,可以从正则匹配、path.resolve 的范围入手,缩小文件搜索范围,加快匹配速度:
module.exports = {
module: {
rules: [
{
// 如果项目中只有 js 文件就不要写成 /\.jsx?$/,以提升正则表达式性能
test: /\.js$/,
// babel-loader 支持缓存转换出的结果,通过 cacheDirectory 选项开启
use: ['babel-loader?cacheDirectory'],
// 只对项目根目录下的 src 目录中的文件采用 babel-loader
include: path.resolve(__dirname, 'src')
}
]
}
}
2、优化 resolve.alias 配置
resolve.alias 用于创建 import 或 require 的别名,来确保模块引入变得更简单。
以 React 库为例,安装到 node_modules 后其目录结构如下:
├── dist
│ ├── react.js
│ └── react.min.js
├── lib
│ ...
│ ├── LinkedStateMixin.js
│ ├── createClass.js
│ └── React.js
├── package.json
└── react.js
可以看到安装的 React 库中包含两套代码:
- 采用 CommonJS 规范的模块化代码放在 lib 下,以 package.json 中指定的入口文件 react.js 为模块的入口;
- 把 React 所有相关的代码打包好的完整代码放到一个单独的文件中,这些代码没有采用模块化可以直接执行。其中 dist/react.js 用于开发环境,包含检查和警告的代码。dist/react.min.js 用于线上环境。
默认情况下 webpack 会从入口文件 ./node_modules/react/react.js 开始递归的解析和处理依赖的文件,这非常耗时。
通过配置 resolve.alias
可以让 webpack 在处理 React 库时,直接使用单独完整的 react.min.js 文件,从而跳过耗时的递归解析操作。
相关 webpack 配置如下:
module.exports = {
resolve: {
// 使用 alias 把导入 react 的语句换成直接使用单独完整的 react.min.js 文件
// 减少耗时的递归解析操作
alias: {
react: path.resolve(__dirname, './node_modules/react/dist/react.min.js')
}
}
}
除了 React 库外,大多数库发布到 npm 仓库时都包含打包好的完整文件,对于这些库也可以对它们配置 alias。
但对于有些库使用本优化方法后会影响到 Tree-Shaking 去除无效代码的优化,因为打包好的完整文件中有部分代码项目可能永远用不上。
一般对整体性比较强的库采用本方法优化,因为完整文件中的代码是一个整体,每一行都是不可或缺的。但是对于一些工具类的库,例如 lodash,项目中可能只用到了其中几个工具函数,就不能使用本方法去优化,因为会导致输出代码中包含很多永远不会执行的代码。
3、优化 resolve.extensions 配置
resolve.extensions 用来自动解析文件后缀,默认值为:
module.exports = {
//...
resolve: {
extensions: ['.js', '.json'],
},
};
当遇到 require('./data')
导入语句时,webpack 会先去寻找 ./data.js
文件,如果该文件不存在就去寻找 ./data.json
文件,如果还是找不到就报错。
如果这个列表越长,或者正确的后缀在越后面,就会造成尝试的次数越多,所以 resolve.extensions 的配置也会影响到构建的性能。在配置时需要遵守以下几点,以做到尽可能的优化构建性能:
- 后缀尝试列表要尽可能的小,不要把项目中不存在的类型加到后缀尝试列表中;
- 频率出现最高的文件后缀优先放在最前面,以做到尽快的退出寻找过程;
- 在源码中写导入语句时,要尽可能的带上后缀,从而可以避免寻找过程。例如在确定的情况下把
require('./data')
写成require('./data.json')
4、优化 resolve.modules 的配置
resolve.modules 用于配置 webpack 去哪些目录下寻找第三方模块,默认目录是 node_modules,即是先去当前目录下的 ./node_modules 目录寻找模块,如果没找到就去上一级目录 ../node_modules 中找,再没有就去 ../../node_modules 中找,以此类推,这与 Node.js 的模块寻找机制相似。
当安装的第三方模块都放在项目根目录下的 ./node_modules 目录下时,没有必要按照默认的方式去一层层的寻找,可以指明存放第三方模块的绝对路径,以减少寻找,配置如下:
module.exports = {
resolve: {
// 使用绝对路径指明第三方模块存放的位置,以减少搜索步骤
// 其中 __dirname 表示当前工作目录,也就是项目根目录
modules: [path.resolve(__dirname, 'node_modules')]
}
}
5、优化 resolve.mainFields 配置
resolve.mainFields 用于配置第三方模块使用哪个入口文件。
安装的第三方模块中都会有一个 package.json 文件用于描述这个模块的属性,其中有些字段用于描述入口文件在哪里, resolve.mainFields 用于配置采用哪个字段作为入口文件的描述。
可以存在多个字段描述入口文件,因为有些模块可以同时用在多个环境,针对不同的运行环境需要不同的代码。以 isomorphic-fetch 为例,它是 fetch API 的一个实现,但可同时用于浏览器和 Node.js 环境。 它的 package.json 中就有 2 个入口文件描述字段:
{
"browser": "fetch-npm-browserify.js",
"main": "fetch-npm-node.js"
}
resolve.mainFields 的默认值和当前的 target 配置有关系,对于关系如下:
- 当 target 等于 web 或 webworker 时,值为
['browser', 'module', 'main']
- 当 target 等于其他情况时,值为
['module', 'main']
以 target 等于 web 为例,Webpack 会先采用第三方模块中的 browser 字段去寻找模块的入口文件,如果不存在就采用 module 字段,以此类推。
为了减少搜索步骤,在明确第三方模块的入口文件描述字段时,可以把它设置的尽量少。由于大多数第三方模块都采用 main 字段去描述入口文件的位置,可以这样配置 Webpack:
module.exports = {
resolve: {
// 只采用 main 字段作为入口文件描述字段,以减少搜索步骤
mainFields: ['main']
}
}
注意:使用本方法优化时,需要考虑到所有运行时依赖的第三方模块的入口文件描述字段,就算有一个模块搞错了都可能会造成构建出的代码无法正常运行。
6、优化 module.noParse 配置
module.noParse 配置项可以让 webpack 忽略对没有采用模块化的文件的递归解析处理,这样做的好处是能提高构建性能。例如 jQuery、ChartJS 庞大又没有采用模块化标准,让 webpack 去解析这些文件耗时又没有意义。
上面提到的 react.min.js 文件就没有采用模块化,可以通过配置 module.noParse 忽略对 react.min.js 文件的递归解析处理,配置如下:
const path = require('path')
module.exports = {
module: {
// react.min.js 没有采用模块化,忽略对它的递归解析处理
noParse: [/react\.min\.js$/]
}
}
注意被忽略掉的文件里不应该包含 import
、require
、define
等模块化语句,不然会导致构建出的代码中包含无法在浏览器环境下执行的模块化语句。
三、采用分包方式预编译资源模块
可以将框架的基础包和业务基础包打包成一个文件。
使用 webpack 内置的 DllPlugin 插件进行分包,使用 DllReferencePlugin 引入打包好的 manifest.json 文件。
1、使用方式
首先使用 DLLPlugin 进行分包,创建一个 webpack.dll.js:
const path = require('path')
const webpack = require('webpack')
module.exports = {
entry: {
library: ['vue', 'vuex', 'vue-router']
},
output: {
filename: '[name]_[chunkhash].dll.js',
path: path.resolve(__dirname, './build/library'),
library: '[name]'
},
plugins: [
new webpack.DllPlugin({
name: '[name]_[hash]',
path: resolve(__dirname, './build/library/[name].json')
})
]
}
然后在 package.json 中增加配置:
"scripts": {
"dll": "webpack --config webpack.dll.js"
}
运行这条命令,就会生成一个分出的基础包。在 webpack 配置文件中增加一个在生产环境起作用的插件配置:
module.exports = {
plugins: [
new webpack.DllReferencePlugin({
manifest: require('./build/library/library.json')
})
]
}
2、示例
下面以 React 项目为例接入 DllPlugin,最终构建出的目录结构如下:
├── main.js
├── polyfill.dll.js
├── polyfill.manifest.json
├── react.dll.js
└── react.manifest.json
其中包含两个动态链接库文件,分别是:
polyfill.dll.js
:包含项目所有依赖的 polyfill,例如 Promise、fetch 等 API;react.dll.js
:包含 React 的基础运行环境,例如react
和react-dom
。
其中 react.dll.js
内容大致如下:
var _dll_react = (function (modules) {
// ...
})([
function (module, exports, __webpack_require__) {
// 模块 ID 为 0 的模块对应的代码
},
function (module, exports, __webpack_require__) {
// 模块 ID 为 1 的模块对应的代码
}
// ...
])
可见一个动态链接库文件中包含了大量模块的代码,这些模块存放在一个数组里,用数组的索引号作为 ID。并且通过 _dll_react
变量把自己暴露在全局中,可以通过 window._dll_react
访问到里面包含的模块。
其中 polyfill.manifest.json 和 react.manifest.json 文件也由 DllPlugin 生成出,用于描述动态链接库文件中包含哪些模块。
其中 react.manifest.json 内容大致如下:
{
// 描述该动态链接库文件暴露在全局的变量名称
"name": "_dll_react",
"content": {
"./node_modules/process/browser.js": {
"id": 0,
"meta": {}
},
// ...
"./node_modules/react-dom/lib/ReactBrowserEventEmitter.js": {
"id": 42,
"meta": {}
},
"./node_modules/react/lib/lowPriorityWarning.js": {
"id": 47,
"meta": {}
},
// ...
"./node_modules/react-dom/lib/SyntheticTouchEvent.js": {
"id": 210,
"meta": {}
},
"./node_modules/react-dom/lib/SyntheticTransitionEvent.js": {
"id": 211,
"meta": {}
},
}
}
可见 manifest.json
文件清楚地描述了与其对应的 dll.js
文件中包含了哪些模块,以及每个模块的路径和 ID。
main.js
文件是编译出来的执行入口文件,当遇到其依赖的模块在 dll.js
文件中时,会直接通过 dll.js
文件暴露出的全局变量去获取打包在 dll.js
文件的模块。所以在 index.html
文件中需要把依赖的两个 dll.js
文件给加载进去,index.html
内容如下:
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
<div id="app"></div>
<!--导入依赖的动态链接库文件-->
<script src="./dist/polyfill.dll.js"></script>
<script src="./dist/react.dll.js"></script>
<!--导入执行入口文件-->
<script src="./dist/main.js"></script>
</body>
</html>
四、多线程加速编译
1、使用 thread-loader
原理:每次 webpack 解析一个模块,thread-loader(官方推荐)会将它及它的依赖分配给 worker 线程中。
yarn add thread-loader -D
配置如下:
module.exports = {
module: {
rules: [
{
test: /\.js$/,
include: path.resolve('src'),
use: [
'thread-loader'
// 耗时的 loader(例如 babel-loader)
]
}
]
}
}
2、使用 parallel-webpack
原理:parallel-webpack 允许并行运行多个 Webpack 构建,从而将工作分散到各个处理器上,从而有助于显着加快构建速度。
yarn add parallel-webpack -D
配置如下:
var path = require('path')
module.exports = [
{
entry: './pageA.js',
output: {
path: path.resolve(__dirname, './dist'),
filename: 'pageA.bundle.js'
}
},
{
entry: './pageB.js',
output: {
path: path.resolve(__dirname, './dist'),
filename: 'pageB.bundle.js'
}
}
]
上面 parallel-webpack 将并行运行两个指定的构建。
3、使用 HappyPack
原理:每次 webapck 解析一个模块时,HappyPack 会将它及它的依赖分配到 worker 线程中。由于 HappyPack 对 file-loader、url-loader 支持不友好,所以不建议对该 loader 使用。
yarn add --save-dev happypack
配置如下:
const HappyPack = require('happypack')
module.exports = {
plugins: [
new HappyPack({
id: 'jsx',
threads: 4,
loaders: ['babel-loader']
}),
new HappyPack({
id: 'styles',
threads: 2,
loaders: ['style-loader', 'css-loader', 'less-loader']
})
]
}