从一次让我印象深刻的升级说起。之前维护一个 Vue3 + TypeScript 的中型后台项目,业务代码大概有几百个模块,第一次跑 npm run dev 通常要 40 秒,改完一行代码触发重编译也要 8~10 秒。后来项目升级到 vue-cli 5,同一份代码第二次启动直接缩到 12 秒,重编译降到 2 秒左右。差别不在 webpack 5 本身变快了,而是 vue-cli 5 默认打开了 webpack 的持久化缓存(cache.type = 'filesystem')。构建过程把上一次的编译结果写进了磁盘缓存,重启构建、切分支、改少量代码,大部分模块都不用再解析、再转译,直接从缓存里恢复。这篇文章会从原理、配置、失效排查和工具协作几个层面,把 webpack 持久化缓存讲透,适合所有底层使用 webpack 5 且被构建速度困扰的开发者。
1. 为什么构建会慢:webpack 每次都在重复哪些工作
1.1 一次完整构建最耗时的四件事
一个 webpack 构建从入口开始,大体要经过四个阶段:
- 模块解析(resolve):根据
import/require把相对路径解析成绝对路径,查找到目标文件。这部分对文件多、目录深的项目尤其明显。 - 模块转译(loaders):把
.ts、.vue、.scss等源码喂给对应的 loader,产出 webpack 能识别的 JavaScript 模块代码。这一步几乎永远是耗时大头,babel、ts、vue-loader 每个文件都要完整跑一遍。 - 语法分析与依赖图构建(parse + module graph):webpack 解析生成的 JS,找出嵌套的
import,递归构建整个依赖图。项目模块越多,递归越深,构建时间和模块数量基本是线性关系。 - 代码生成与优化(seal + chunk + minimize):依赖图整理成 chunk,经过压缩、代码拆分、hash 计算,最终输出到 dist 目录。
对中型以上的项目,这几件事全部跑完几十秒很正常。真正让人难受的是:你只改了一个组件里的文案,其他几百个模块的源码、它们经过 babel / vue-loader 转换后的结果、它们的依赖关系,一秒钟前和现在没有任何区别,但 webpack 还是会从头把它们读一遍、转译一遍、解析一遍。持久化缓存的核心思路就是一句话:把没变的中间产物记住,下次直接拿来用。
1.2 缓存方案演进:为什么 webpack 4 时代没有“标准答案”
webpack 4 时代想提速,常见的方案有这么几种:
- 给 loader 加 cacheDirectory。比如
babel-loader的cacheDirectory: true,只缓存 loader 转换后的代码。它解决的问题只有转译这一步,resolve、parse、chunk 生成这些照样全量执行。 - cache-loader。它能把 loader 处理结果落到磁盘,理论上比单个 loader 自带缓存更通用。问题是需要手动插到 loader 链最前面,很容易因为 loader 顺序变化、参数不敏感导致缓存命中错误,最终出现“改完代码没生效”的灵异事件。
- hard-source-webpack-plugin。它做了模块级别的缓存,是当时最接近官方方案的东西。坑也不少:和部分 loader 不兼容、webpack 版本升级容易失效、缓存损坏后需要手动删目录,甚至出现过缓存命中后产物内容缺失的情况。
这些方案最大的问题不是效果差,而是没有一个统一、内置于 webpack 引擎层的缓存机制,很多细节要靠使用者自己兜底。webpack 5 把缓存做进了构建引擎内部,从模块解析到 chunk 优化结果都纳入缓存体系,这才让持久化缓存成为标准配置。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 深入缓存存储原理:磁盘上到底留下了什么
2.1 三类核心产物被序列化落盘
开启 cache.type='filesystem' 后,webpack 会在缓存目录(默认是 node_modules/.cache/webpack)写入经过序列化的构建数据。落盘内容包括但不限于三个层次:
- 转译后的模块代码。每个源码文件经过 loader 转换后的最终 JS 代码,以及模块本身的元数据(比如 resource 路径、请求路径、loader 配置的 hash)。这是缓存里最大的一块。
- 模块间的依赖关系。A 引入 B,这个边关系、每个模块的依赖列表、模块 ID 都会保存。有了它,下次构建就不需要重新做递归解析,直接恢复出一张现成的依赖图。
- chunk 与产物生成阶段的中间信息。比如 chunk 的组成、代码拆分结果、runtime 模板、压缩前的代码等。这部分来自 seal 阶段,让 webpack 在缓存命中时能跳过大量树摇和代码生成逻辑。
这不等于把 dist 目录存一份,而是整个编译过程中内存数据结构的持久化快照。webpack 重启后,如果条件匹配,它可以直接从磁盘恢复出一个接近完成的内部状态,跳过前面大部分编译流程。
2.2 快照机制:判断“这个模块没变过”
缓存要安全,就必须验证磁盘缓存里的模块是否仍然有效。webpack 使用的机制叫快照(snapshot)。它主要记录三类信息:
- 文件内容 hash,用于精确判断文件是否真的变了;
- 文件时间戳,用于快速判断,在某些场景下可以省掉内容 hash 计算;
- 依赖信息,比如模块引用了哪些 loader、loader 配置是否变化、resolve 依赖了哪些
package.json。
对 node_modules 这类托管目录,webpack 默认用比较轻量的时间戳校验,因为依赖包通常不会频繁变动;对项目源码,默认做更严格的校验,避免“内容变了但时间戳没变”或者“时间戳变了但内容没变”的边界情况。这一步是持久化缓存正确性的关键防线。
2.3 命中判定的大致流程
一次构建启动时,webpack 会做类似下面的事:
- 读取缓存目录里对应
cache.name的索引信息; - 用
buildDependencies(配置文件、config 依赖等)做快速校验,不通过就直接整体失效; - 对缓存中的每个模块做快照校验,检查源码、依赖文件是否变化;
- 有效模块直接复用,无效模块重新执行 loader 与 parse,同时更新缓存。
这个机制决定了持久化缓存是模块级别的精准复用,而不是“构建整体偷懒”。你改了 A 文件,A 相关缓存失效,B、C、D 不受影响,照样命中。这也是为什么在大型项目里它的收益几乎不受改动范围影响。
3. 实战配置与收益验证:如何正确打开持久化缓存
3.1 最简开启方式
如果项目是自定义 webpack 配置,最直接的打开方式:
javascript复制// webpack.config.js
module.exports = {
// 其他配置...
cache: {
type: 'filesystem'
}
};
development 模式下,webpack 5 默认使用内存缓存(cache.type = 'memory'),生产模式默认关闭。如果你希望开发模式和构建模式都能跨进程复用缓存,就要显式设成 filesystem。Vue CLI 5、CRA 5 的用户在多数情况下已经默认开启,不需要额外配置。
3.2 生产环境完整配置项解读
一个相对完整、适合生产环境的配置示例:
javascript复制const path = require('path');
module.exports = {
mode: 'production',
cache: {
type: 'filesystem',
// 自定义缓存目录,默认是 node_modules/.cache/webpack
cacheDirectory: path.resolve(__dirname, 'node_modules/.cache/webpack'),
// 缓存配置的名字。默认会根据 output.path 与 mode 推导;多编译器场景必须显式指定
name: 'production-cache',
// 手动设置缓存版本号,升级 webpack 或大规模调整配置后递增
version: '1.0.0',
// 打包存储方式,默认 'pack',不需要动
store: 'pack',
// 用 gzip 压缩缓存文件,减小体积,但略增加压缩开销
compression: 'gzip',
// 配置文件发生变化时,让缓存整体失效
buildDependencies: {
config: [__filename],
},
},
experiments: {
// 进一步缓存“未受影响模块”的处理结果,大型项目收益明显
cacheUnaffected: true,
},
};
几个容易忽略的配置项,我习惯用表格来记:
| 配置项 | 作用 | 注意点 |
|---|---|---|
name |
缓存标识 | 不同模式建议用不同 name,避免 dev/prod 切换互相冲击 |
version |
缓存版本号 | webpack 升级或配置结构大改之后建议递增 |
buildDependencies.config |
配置文件依赖 | 配置里引用的外部文件也要加进来 |
cacheDirectory |
缓存目录 | CI 中需要持久化的就是这个目录 |
idleTimeout |
空闲多久后写入缓存 | 默认值能覆盖大多数场景,一般不用调 |
compression |
是否压缩缓存 | 空间换时间,看磁盘情况选 |
3.3 验证缓存是否生效
配置完,最直接的验证方法是看效果:
- 第一次构建后,去缓存目录看一眼,文件已经生成。
- 第二次运行同一个命令,观察构建时间。如果从 40 秒掉到 10 秒以内,说明大部分模块都从缓存恢复成功。
- 删掉
node_modules/.cache/webpack再构建一次,如果时间明显回升,再次证明缓存是提速的主要来源。
我在一个 200 多个模块的 Vue3 项目中测试过一组数据:完全冷启动(清空缓存)约 41 秒;开启 filesystem 缓存后,第二次完整构建约 12 秒;改动一个组件文件后的增量构建约 6 秒。模块越多,这个比例会越明显。
4. 缓存失效机制与排查手册:让缓存按预期工作的关键
4.1 缓存失效的触发场景
持久化缓存不是打开就完事,它有一整套失效逻辑。最常见的触发场景包括:
- 源码文件变化:文件内容 hash 变化,对应模块缓存失效,相关依赖重新编译。
- 配置本身变化:
webpack.config.js或其他 buildDependencies 文件变化,整体缓存失效。 - node_modules 变化:新增、删除、升级依赖,导致 resolve 结果变化,相关缓存失效。
- 运行环境变化:Node.js 版本变化、全局工具链变化,可能导致缓存的数据结构无法复用。
- 缓存文件损坏或不兼容:比如 webpack 版本升级、写入被中断,webpack 检测到包文件不匹配后会重新编译。
大部分“改了配置没生效”的困惑,都集中在第二类。webpack 默认会把配置文件本身计入 buildDependencies,但如果你用了 --env、从外部读取 .env 文件、或者把配置拆成了多个文件却没有被自动收集,就需要手动补上。
javascript复制buildDependencies: {
config: [
__filename,
path.join(__dirname, 'webpack.base.js'),
path.join(__dirname, 'env.js'),
]
}
4.2 name、version、buildDependencies 怎么组合
这三者之间的关系,我提供一个经过验证的套路:
- name 区分场景:开发用
development-cache,生产构建用production-cache。两个环境的 plugins、optimization 差异巨大,共用一份缓存会导致切换构建模式时频繁失效,得不偿失。 - version 管理升级:webpack 大版本升级、配置结构大规模调整之后,缓存格式不一定兼容,稳妥起见递增 version,或者直接删除缓存目录。
- buildDependencies 防“脏缓存”:config 文件列表越完整越好。只要 config 文件变了,缓存整体重建,这比带着可疑缓存继续跑安全得多。
这个组合的核心原则是:让缓存保持单一职责,在“尽可能复用”和“绝不让错误缓存生效”之间找到平衡。
4.3 CI/CD 场景下的缓存迁移策略
本地开发把 .cache 放在 node_modules 下没什么问题,但 CI 里 node_modules 经常是全新安装,缓存目录就丢了。这时需要在 CI 的缓存规则里单独把 .cache/webpack 目录持久化出来。以 GitHub Actions 为例:
yaml复制- uses: actions/cache@v3
with:
path: node_modules/.cache/webpack
key: webpack-cache-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
主要注意两点:key 要包含依赖锁文件的 hash,依赖一变,缓存自然换一批;工作目录的绝对路径尽量保持固定,因为缓存中记录的是绝对路径,路径变了容易整体失效。如果你们用的是 Jenkins、GitLab CI,思路完全一样:把缓存目录做成跨构建机共享的持久化路径。
4.4 缓存损坏的常见报错与处理
文件系统缓存最常见的报错是 PackFileMismatchError 这类提示,翻译过来就是缓存文件和当前编译环境不匹配。webpack 的做法是打印警告后自动全量重新编译,不会让构建直接失败,但本次构建得不到缓存收益。如果构建进程被强制终止(kill 掉、CI 超时),缓存目录里可能会出现写了一半的文件,处理办法也很朴素:删掉 node_modules/.cache/webpack,重新构建。
建议在 package.json 里加一条脚本:
json复制{
"scripts": {
"clean:cache": "rimraf node_modules/.cache"
}
}
排查“缓存是否在捣乱”的第一步永远是删缓存重跑,这一招能解决 80% 以上的问题。
5. 与开发工具链协作的避坑记录:HMR、loader、CI 一个都不能少
5.1 开发模式下热更新与持久化缓存的关系
开发模式开启 filesystem 缓存后,HMR 依然正常。热更新链路走的是 dev server 的增量编译,和模块缓存并不冲突。有个实际场景值得注意:项目足够大时,dev server 重启一次的成本也很高,用 filesystem 缓存可以保证 dev server 重启后,启动速度快速恢复。这也是为什么很多脚手架在生产构建默认开启 filesystem 缓存的同时,开发模式也选择开启。如果只追求 dev server 下的最快响应,memory 缓存确实比磁盘更快,代价是重启后缓存失效,如何取舍看团队偏好。
5.2 babel-loader 自带的 cacheDirectory 还有没有必要开
这是一个很常见的问题。开启 webpack filesystem 缓存后,loader 的转译结果已经被整体缓存,babel-loader 的 cacheDirectory 在功能上就重复了。继续开着主要副作用是额外占用磁盘空间。我个人的做法是:在已经开启持久化缓存的项目里,把 babel-loader 的 cacheDirectory 关掉;特殊场景,比如同一个仓库多个 webpack 配置并行构建,或者 dev/prod 使用不同 cache name 频繁切换,babel 自己的缓存能兜住底层变化,留着也不碍事。
5.3 自定义 loader 里那些“隐形地雷”
这类问题在大型团队内部特别有现实意义。自定义 loader 如果存在下面的行为,持久化缓存很容易踩坑:
- 调用
this.cacheable(false):等同于告诉 webpack“这个模块不要缓存”,会导致模块永远无法命中缓存。 - 依赖环境变量或运行时上下文(
process.env.NODE_ENV、全局日期等),却没有把这种依赖声明为模块依赖。环境变量变了,缓存还在用旧值,最终产物就是错的。 - 读取额外文件,但没有通过
this.addDependency()声明。文件变了,快照不会察觉,缓存继续命中,代码却不生效。
如果你的团队在使用自定义 loader,强烈建议在 loader 文档里约定:所有可变上下文都要通过 loader options 传入,所有读过的文件都要 this.addDependency()。这两条规则能省掉 90% 的“改了不生效”类问题。
5.4 顺带聊聊 Vite 和 webpack 在缓存思路上的差异
很多人拿 vue3、vite 和 webpack 对比。Vite 开发环境按需编译,天然不依赖持久化缓存;但它的生产构建阶段,同样有自己的缓存策略(比如 node_modules/.vite)。两者解决的是同一类问题:减少重复转译。差异在于,webpack 5 的 filesystem cache 是构建器内置、模块级别、和快照体系深度整合的,而 Vite 的依赖预构建缓存主要针对第三方依赖。理解 webpack 的持久化缓存,对理解现代前端构建工具的整体优化思路很有帮助。
最后补充一点个人习惯:在接手的任何 webpack 5 项目里,第一件事就是确认 cache 配置是否是 filesystem、cache name 是否按环境隔离,然后跑一次“删缓存冷启动 + 二次构建”做时间基准。如果未来某天二次构建突然变慢,或者代码改动后产物没变化,先怀疑缓存,而不是先怀疑代码。持久化缓存是把双刃剑——配置得当,收益立竿见影;配置疏忽,它可能带来最隐蔽的脏缓存问题。webpack 5 把这项能力内置化,已经让它从前端工程化里的可选项变成了标配项,越早理清它的行为边界,越能在后续大型项目优化中掌握主动权。
