改一行 CSS,dev server 转了十几秒才刷新;提一个 PR,CI 上的生产构建能跑掉大半杯咖啡的时间。这种项目大概率你也遇到过,而且越到项目后期越难受。Webpack 5 在这件事上给的东西不少,持久化缓存、更聪明的 Tree-shaking、更灵活的分包策略,但配置项太多,很容易开了一堆结果没开在关键路径上。
这篇按「构建慢在哪一段」的顺序把优化手段串起来,每个配置都说清它作用在哪个阶段、什么场景有效、边界在哪,最后给一份可以直接复制的生产配置和一份上线前的 checklist。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- Webpack 构建的三个阶段,以及每个优化手段对应哪一段
- 持久化缓存怎么开、各配置项管什么、失效规则是什么
babel-loader/ ESLint / Stylelint 这一层的缓存怎么叠Tree-shaking的两步机制,以及让它失效的六种常见写法SplitChunks的 Chunk 模型、缓存组配置和长期缓存的坑- 并行构建的四种方案,以及它什么时候是负优化
- HMR、SourceMap 选型和构建分析工具的正确用法
- 一份可直接复制的生产配置 + 上线前 checklist
# 一、先搞清楚构建慢在哪一段
在改任何配置之前,得先知道 Webpack 一次构建到底干了什么。整个过程大致分三段,每一段慢的原因和药方都不一样:
entry
│
┌────────▼──────────────────────────────────────────────┐
│ 初始化阶段 │
│ 读 config → 注册内置插件 → 创建 compiler │
│ 耗时占比很小,基本不用管 │
└────────┬──────────────────────────────────────────────┘
│
┌────────▼──────────────────────────────────────────────┐
│ Make 阶段 │
│ 读文件 → Loader 转译 → acorn 生成 AST │
│ → 分析依赖 → 递归下去 → 产出 ModuleGraph │
│ │
│ 慢在:babel-loader / ts-loader / eslint / sass │
│ 药方:持久化缓存、Loader 级缓存、thread-loader、 │
│ 收窄 resolve 与 include 范围 │
└────────┬──────────────────────────────────────────────┘
│
┌────────▼──────────────────────────────────────────────┐
│ Seal 阶段 │
│ 遍历 ModuleGraph → 生成 Chunk → 代码转译 → │
│ 产物优化(Tree-shaking)→ 写出文件 │
│ │
│ 慢在:Terser 压缩、SplitChunks 启发式计算 │
│ 药方:TerserPlugin parallel、精简 cacheGroups │
└────────┬──────────────────────────────────────────────┘
│
dist/
先说结论,绝大多数项目的时间都花在 Make 阶段的 Loader 转译上,其次是 Seal 阶段的压缩。所以优化的第一顺位永远是「让 Loader 少干活」,持久化缓存就是干这个的;第二顺位才是「让压缩并行」。
你要是也在调构建,建议先用 speed-measure-webpack-plugin 跑一次,看清楚耗时分布再动手,不要凭感觉一顿加插件。
# 二、持久化缓存,二次构建的最大杠杆
# 2.1 一行配置开启
持久化缓存(Persistent Caching)是 Webpack 5 最有分量的新特性。它把首次构建的过程和结果数据序列化到本地文件系统,下次构建时直接把 Module、ModuleGraph、Chunk 这些对象的状态读回来,跳过解析、转译、链接这些真正花时间的操作。
社区里被反复引用的一组实测数据是这样的:一个约 360 份 JS 文件、合计 3 万行代码的中大型项目,配了 babel-loader 和 eslint-loader,不开缓存时构建耗时在 11000 毫秒到 18000 毫秒之间;开了缓存之后,第二次构建降到 500 毫秒到 800 毫秒,差了将近 50 倍。这个量级和我自己在中等规模项目上的体感是对得上的,不过具体倍数跟项目结构关系很大,别把它当成承诺。
开启只需要一行:
module.exports = {
//...
cache: {
type: 'filesystem'
},
//...
};
type 的另一个取值是 memory,那是开发模式的默认值,进程一退就没了。要跨进程复用就必须用 filesystem。
# 2.2 配置项逐个说清
cache.cacheDirectory 是缓存文件的落盘位置,默认在 node_modules/.cache/webpack。放在这里的好处是天然被 .gitignore 覆盖;改到项目根目录下的话,记得自己加一条忽略规则。
cache.buildDependencies 是最容易漏配的一项。它声明「哪些文件变了就整个缓存作废」,通常填各种配置文件:
module.exports = {
cache: {
type: 'filesystem',
buildDependencies: {
config: [
path.join(__dirname, 'webpack.dll_config.js'),
path.join(__dirname, '.babelrc')
],
},
},
};
不配这个会出什么事?你改了 .babelrc 里的 preset,构建却还在用旧规则转译出来的缓存产物,然后开始怀疑人生。这种问题排查起来特别费劲,因为代码看着完全没问题。最省事的写法是 config: [__filename],把 webpack 配置文件自己加进去,再把 babel、postcss 这类配置文件补上。
cache.managedPaths 是受控目录,命中的路径会跳过新旧内容哈希和时间戳的比对,直接用缓存副本,默认值是 ['./node_modules']。这个假设成立的前提是你不会手改 node_modules 里的文件,用 patch-package 之类工具的项目要留个心眼。新版本里 snapshot.managedPaths 也控制同一类行为,具体取哪个以你用的 Webpack 版本文档为准。
cache.maxAge 是缓存失效时间,默认 5184000000 毫秒,也就是 60 天。cache.profile 打开后会输出缓存处理过程的详细日志,默认 false,排查「为什么缓存没生效」时很有用。
完整配置长这样:
const path = require('path');
module.exports = {
cache: {
type: 'filesystem',
cacheDirectory: path.resolve(__dirname, '.webpack-cache'),
buildDependencies: {
config: [__filename],
},
managedPaths: [/^(.+?[\\/]node_modules[\\/])/],
maxAge: 30 * 24 * 60 * 60 * 1000, // 30天
compression: 'gzip',
profile: true,
},
};
compression 值得单独说一句。开 gzip 能把缓存目录体积压下来,代价是每次读写都要多一次压缩解压。本地磁盘够用的话不开更快,CI 上要把缓存目录当 artifact 传来传去时再考虑开。
# 2.3 缓存到底缓存了什么
Webpack 的构建过程可以拆成三段。初始化阶段根据配置装配内置插件。Make 阶段从 entry 开始,读文件、调 Loader 转译、用 acorn 生成 AST、分析出依赖列表,然后对每个依赖递归重复这套流程,最终产出完整的模块依赖图 ModuleGraph。Seal 阶段遍历这张图,把每个模块做代码转译(比如 import 转成 require 调用),分析运行时依赖,合并模块代码和运行时代码生成 chunk,执行 Tree-shaking 这类产物优化,最后写出文件。
持久化缓存做的事情就是在首次构建结束后,把 Module、Chunk、ModuleGraph 这三类对象的状态序列化存下来;下次构建开始时尝试读回来并恢复。恢复成功的部分,Loader 链、AST 解析、依赖分析就全都不用再跑了。
明白了这一点就能理解它的边界。缓存加速的是 Make 阶段,Seal 阶段的压缩该跑还得跑。所以开了缓存之后你会发现开发构建快得飞起,生产构建的提升却没那么夸张,那是因为生产构建的大头在 Terser 上。
# 2.4 Loader 层再叠一层缓存
Webpack 原生缓存之外,各个 Loader 自己也有缓存能力,两层可以叠加。
babel-loader 的写法:
module.exports = {
module: {
rules: [{
test: /\.m?js$/,
loader: 'babel-loader',
options: {
cacheDirectory: true,
cacheCompression: false, // 关闭压缩以提升缓存读写速度
},
}]
},
};
cacheCompression: false 这一项建议一定要加。Babel 默认会 gzip 压缩缓存文件,省下来的磁盘空间远不如压缩解压花掉的时间值钱。
ESLint 和 Stylelint 走插件形式,各有各的缓存文件:
const ESLintPlugin = require('eslint-webpack-plugin');
const StylelintPlugin = require('stylelint-webpack-plugin');
module.exports = {
plugins: [
new ESLintPlugin({
cache: true,
cacheLocation: './.eslintcache',
}),
new StylelintPlugin({
files: '**/*.css',
cache: true,
cacheLocation: './.stylelintcache',
}),
],
};
顺带提一句,老项目里常见的 eslint-loader 已经废弃了,官方推荐换成上面这个 eslint-webpack-plugin。原因是 loader 形态会把 lint 塞进转译链路里串行执行,插件形态可以并行跑,构建阻塞更少。
这两层缓存都开上之后,构建性能通常还能再有三成到八成的提升,具体幅度看你的 lint 规则有多重。
# 三、Tree-shaking,把没用的代码摇下来
# 3.1 三个前提缺一不可
Tree-shaking 是基于 ES Module 规范的 Dead Code Elimination。它静态分析模块之间的导入导出关系,找出那些从来没被别人用过的导出值,然后删掉。Webpack 从 2.0 就支持了,但真正好用是在 5 之后。
要让它跑起来,三个条件必须同时满足: