我们都知道,Webpack 在构建源码的过程中,会进行大量的文件读写、AST 操作、代码合并和压缩等极其耗费性能的流程,对于一些大型项目来说,可能出现构建时间过长、构建卡顿、甚至内存溢出的情况:
本文将先介绍 Webpack 编译构建流程,了解编译构建主要耗时在哪些地方;然后介绍如何收集 Webpack 编译性能数据,以及使用工具分析编译性能瓶颈;最后介绍几个有效减少编译构建时长的手段。
Webpack 编译构建流程
Webpack 编译构建流程大概分为以下四个阶段:
- 输入阶段: 会把用户配置与内置的配置进行合并,初始化编译环境,然后确定编译入口
entry
- 模块编译阶段: 从
entry
开始,调用对应的loader
转换成 JS 模块。然后调用 JS 解析器把模块转换成 AST 对象并找出该模块的依赖,并递归处理这些依赖的模块,直到把所有的模块都处理完成,最终得到模块之间的依赖图(Module Graph) - 优化阶段: 根据上面步骤得到的处理好的模块结果和依赖图,合并成一个或多个
chunk
,然后对chunk
进行一系列的优化操作,包括 Tree Shaking、分包、代码压缩、代码混淆等等 - 输出阶段: 把处理好的 chunk 结果输出成实体文件 assets 到磁盘
从上面的流程中我们可以看到有不少能造成性能卡点的地方:
- 文件的 IO 操作,执行效率取决于源码文件的数量
- 对模块进行 AST 操作和递归处理,这里的执行效率取决于模块的数量和复杂度
- 对
chunk
进行优化过程中,代码压缩和混淆涉及大量的 AST 操作,一般来说非常耗费性能;另外如果模块数量过多,Tree Shaking 的执行效率也会很低,耗时很长
因此,如果一个项目过于庞大,编译性能可能表现很差。但是这些问题还是可以被优化的。
收集构建性能数据
Webpack 默认提供了编译构建数据 Stats Data,该数据包含模块之间的依赖关系、每个模块的构建详情包括时间、大小等内容。
我们可以通过以下方式获得此数据:
npx webpack --profile --json=stats.json
运行后,会在项目跟目录生成一个 stats.json
文件,Stats 数据大致内容如下:
"version": "5.75.0",
"time": 1218,
"builtAt": 1674729364370,
"publicPath": "auto",
"outputPath": "/demos/xxx/dist",
"assetsByChunkName": {
"main": [
"bundle.js"
]
},
"assets": [
...
],
"chunks": [
...
],
"modules": [
...
],
"entrypoints": [
...
],
"children": [
...
]
你可以查看示例结果查看更多内容。
在 Stats 数据中主要包含了以下几个关键的信息:
entrypoints
:包含每个entry
的名称、对应的assets
名称和大小、chunk ID
等信息;modules
:包含本次构建中处理的模块列表,包括了模块之间的依赖关系、模块体积大小、名称、包含了哪些chunk ID
、构建原因、以及该模块的构建时长 profilechunks
:本次构建生成的chunk
列表,包括chunk ID
、名称、体积大小、对应的module
等信息assets
:本次构建最终生成的output
文件,比如文件名、文件体积、对应的chunk ID
等信息
由于 Stats 数据不是本文的重点,这里就不作过多的展开。关于 Stats 数据的详细解析,可以查看官方文档。
单看这份文件不能很直观看出哪里存在性能瓶颈,我们需要借助一些可视化工具来帮我们分析。
常用的构建性能分析工具
Webpack Analyse
Webpack Analyse 是由 Webpack 官方推出的用于分析构建性能的可视化工具。使用方式很简单,只需要把上一节生成的 stats.json
文件上传到页面中即可查看详细的信息:
我们可以看到本次构建的概要信息:
我们可以点击 Modules
菜单栏查看模块之间的依赖关系、体积大小等等:
我们也可以点击 Assets
或 Chunks
菜单栏查看更多关于 Assets 和 Chunks 的信息。
另外,Webpack Analyse 里的 Hints 页面展示了本次构建每个模块处理的时长,这样我们可以有针对性优化:
Webpack Analyse 目前对 Webpack 5 生成的 Stats 数据不兼容,官方仍未解决。
Progress Plugin
Webpack 官方提供了 ProgressPlugin 插件,可以让我们查看编译构建进度和详细信息。
如果你是使用 webpack-cli 进行 Webpack 编译,你可以添加 --progress
参数开启:
$ npx webpack --progress
如果你是使用 Webpack Node API 启动 Webpack 编译流程,你可以在 webpack.config.js 中添加 ProgressPlugin 插件:
const webpack = require('webpack');
module.exports = {
plugins: [
new webpack.ProgressPlugin(
(percentage, message, ...args) => console.info(percentage, message, ...args);
),
]
}
这样我们就可以实时查看当前编译的进度,正在处于哪个阶段,当前是哪个 Plugin 正在处理模块等内容,进而确定哪个阶段耗时较长,然后就可以着手排查性能问题了。
Bundle Analyzer Plugin
webpack-bundle-analyzer 是一个非常流行的性能分析 Webpack 插件,该插件会在 Webpack 构建流程结束后生成模块 TreeMap 图,从图中可以查看每个模块的体积占比,有否有模块被重复打包,是否把不必要的模块被打包进去。
开启方式很简单,我们只需要安装一下 bundle-analyzer 插件并添加到 plugins
字段中:
$ npm i webpack-bundle-analyzer -D
// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin(),
]
}
等待 Webpack 构建结束后,插件会自动在浏览器中新开一个页面查看模块的体积信息:
从结果图中,我们可以看到产物压缩前后的体积、每个模块具体的体积大小和占比、哪些模块我们可以排除构建进去 bundle 中,哪些模块是重复打包(比如存在不同版本的 core-js),进而进行下一步的优化。
Speed Measure Plugin
speed-measure-webpack-plugin 插件可以收集每个 Loader 和 Plugin 的处理耗时,我们就可以哪种类型的文件处理或者插件处理耗时较长进而进行优化。
开启方式也很简单。首先我们先安装插件:
$ npm install speed-measure-webpack-plugin -D
然后在 webpack 配置文件中添加插件:
// webpack.config.js
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
const smp = new SpeedMeasurePlugin();
const webpackConfig = smp.wrap({
plugins: [new MyPlugin(), new MyOtherPlugin()],
});
执行 Webpack 构建,我们就可以在终端中看到以下的信息输出:
优化构建性能的手段
使用高版本 Node.js 和 Webpack
Webpack 构建强依赖于 Node 环境,构建性能跟它们二者有很大相关性。Node.js 和 Webpack 在支持新特性和版本迭代的同时,也对性能进行不断的优化。因此,我们尽可能保证我们的 Node.js 和 Webpack 版本是最新的。
比如 Node.js 16 版本针对 fs 模块进行优化,而 Webpack 构建流程中对文件的读写是非常频繁的。Webpack 维护者 ScriptedAlchemy 实测在高版本的 Node.js 构建性能有明显的提高:
笔者分别使用不同 Node.js 的版本对此应用(都使用 Webpack 5)进行编译构建:
测试机器是 MacBook Pro 13-inch, M1, 2020,内存 16GB
Node.js 版本 | 冷启动时间(ms) |
---|---|
12.22.6 | 1930 |
14.17.4 | 1821 |
16.14.2 | 717 |
18.0.0 | 695 |
可以看到 Node.js 16 版本以上,构建时间减少了约 60%,构建性能有着大幅的提升。
开启持久化缓存
从上面章节中,我们知道 Webpack 构建主要耗时之一是在模块编译处理(文件 IO 操作、AST 操作),对于一些没有改动的源码,是否能使用上一次编译的结果,然后跳过本次的模块编译过程呢?
持续化缓存 是 Webpack 5 推出的新的特性,它可以把首次 Webpack 构建的中间值和结果缓存到本地,在下次进行构建的时候对于没有更改的模块,会直接文件读写、解析、编译等耗时的操作,直接复用上次构建的 Module、Chunk 等数据,以更快的速度构建出产物。
开启方式非常简单,我们只需要在 Webpack 配置中添加以下配置:
// webpack.config.js
module.exports = {
cache: {
type: 'filesystem'
}
}
执行 Webpack 构建后,默认会自动把缓存生成的 Webpack 模块和 chunk 写入到 node_modules/.cache/webpack
目录中:
以该项目为例,对比开启 cache 前后的构建时间:
是否开启 Cache | 构建时间(ms) |
---|---|
✅ | 14064 |
❌ | 3328 |
构建时间减少了约 76%。使用持久化缓存能大幅提升 dev 本地调试体验,避免每次保存代码时都触发全量的编译构建,之前 Webpack 一直令人诟病的本地调试速度慢的问题也有了原生支持的方案解决了。
另外,配合 CI/CD 系统,可以把构建的缓存存起来,下一次构建任务可以直接共享上次的构建缓存,这样也可以加快构建编译的速度。
开启 lazyCompilation
Webpack 5 推出实验特性 lazyCompilation,它可以按需编译 entry 或者异步引入模块。开启方式很简单:
// webpack.config.js
module.exports = {
// ...
experiments: {
lazyCompilation: true,
},
};
开启以后 lazyCompilation
后,代码中通过 import('./xxx') 导入的模块以及未被访问到的 entry 都不会被立即编译,而是直到页面正式请求该模块资源时才开始构建。避免在第一次构建的时候就把所有代码(entry 和异步引入的模块)都编译完成,即使你仅仅访问其中一到两个页面,这样会造成资源的浪费。
选择合适的 sourcemap 方案
在 Webpack 中,我们可以通过 devtool 选项控制如何生成 sourcemap,并可以设置不同类型的 sourcemap 方案。
在 dev 本地调试阶段,我们会经常重复保存代码并触发新的编译构建。如果我们使用高质量、性能要求高、耗时较长的 sourcemap 方案话,肯定会影响本地调试体验的。因此推荐在 dev 阶段使用 cheap-module-source-map
,在 prod 阶段可以使用高质量的 sourcemap 方案(比如 source-map
)。
开发环境下禁用 optimization
Webpack 默认提供很多产物优化方案,比如 Tree shaking、代码压缩(Minimize)、代码分包(SplitChunks)、去除副作用代码(Side Effects)等等,这些配置能极大的优化生产环境的构建产物,提高应用运行的性能。
但这些优化方案设计大量的 AST 操作运算和文件 IO 操作,在本地调试阶段会大大影响我们的构建性能。因此,在本地调试阶段,我们需要关闭掉对应的配置:
// webpack.config.js
module.exports = {
mode: 'development', // 关闭默认的优化策略
optimization: {
minimize: false, // 关闭代码压缩
usedExports: false, // 关闭 Tree Shaking
concatenateModules: false, // 关闭模块合并
sideEffects: false, // 关闭 sideEffects
}
}
使用 externals
从 Webpack 编译构建流程章节中我们可以知道,如果构建处理的模块越多,构建时间就会越长。我们可以通过 bundle-analyzer-plugin
查看本次构建的模块的体积和文件。如果有一些 npm 包我们可以在运行时(runtime)再去获取这些依赖,不必要打包进去我们的构建产物中,一定程度上可以减少构建时间,还可以利用 CDN 加速资源的请求。
比如我们把 react
和 react-dom
排除构建:
// webpack.config.js
module.exports = {
externals: {
'react': 'React',
'react-dom': 'ReactDOM',
}
}
同时在 HTML 文件中加入以下脚本:
<head>
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
</head>
笔者在实际前端项目开发中,发现在编译构建组件库(antd、@alifd/next)依赖模块较多,耗时比较长,体积相对来说也比较大。比如下图:
在源码中引入组件库的方式是通过 import Form from 'antd/es/form';
的方式引入的。如果仅仅在 webpack.config.js
中配置以下内容,externals
是不会生效的,Form 组件还是被打包进去的:
// webpack.config.js
module.exports = {
externals: {
antd: 'antd'
}
}
对于这种情况,我们需要编写一个函数,动态排除掉依赖:
// webpack.config.js
const antdEsRegex = /antd\/(?:es|lib)\/([-\w+]+)$/;
module.exports = {
extenals: [
({ context: _context, request }, callback) => {
const isAntd = antdEsRegex.test(request);
if (isAntd) {
const componentPath = request.match(antdEsRegex)[1]; // antd/es/form
const res = ['antd', upperFirst(camelCase(componentPath))]; // ['antd', 'Form']
if (componentPath) {
return callback(null, {
root: res, // window['antd']['Form']
commonjs2: res, // require('antd').Form
commonjs: res,
amd: res,
});
}
}
return callback();
}
]
}
其他
Webpack 官方文档中也有一个章节也是专门讲解如果优化构建性能的,感兴趣的话也可以看看,这里就不再展开了。
总结
本文将先整体介绍了 Webpack 编译构建四个阶段,了解编译构建性能卡点;然后介绍如何收集 Webpack Stats Data 以及使用社区较为流行的工具分析编译性能;最后介绍六个有效减少编译构建时长的手段。
还有哪些优化构建性能的手段呢?欢迎大家在评论区讨论。