先交代一下背景:我接手过一个不算小的中后台项目,两千多个模块,webpack 5 配的 dev server 每次冷启动要四十多秒,改一行代码触发增量构建也要三四秒,整个团队每天都在等编译。后来我把大部分精力花在排查各个 loader 的耗时上,效果有,但不明显。真正让构建时间出现数量级变化的操作,是打开 webpack 5 的持久化缓存(filesystem cache)。
这个功能解决的核心问题很简单:相同输入不重复劳动。webpack 每次构建都要重新解析文件、跑 loader、分析依赖,但大部分代码在两次构建之间根本没有任何变化,这些工作等于白做。持久化缓存会把上一次构建的中间结果写到磁盘,下次构建直接复用,非常适合模块多、依赖重、构建链路长的项目。
这篇文章我会从原理讲到配置,再讲我在真实项目里踩过的坑,最后给一套可以直接复用的配置方案。不管你是刚接触 webpack 5 的新手,还是被构建速度折磨已久的老人,应该都能拿走点东西。
1. 为什么持久化缓存能大幅提升构建速度
1.1 构建慢的根源到底在哪
先看一条典型的 webpack 构建链路:配置初始化、模块解析(resolve)、模块构建(build modules)、封装(seal)、生成 chunk、写盘 emit。其中绝大部分时间都耗在模块解析和模块构建上。
模块构建具体做的是:按匹配规则找到 loader,然后一个接一个执行。比如一个.tsx文件通常要经过 ts-loader(或 babel-loader)、eslint-loader 等好几个 loader,loader 内还要解析 AST、做语法转换,最后生成 webpack 能识别的模块对象。模块越多、loader 链路越长,这部分耗时就越夸张。
这个过程有一个关键特点:它是接近纯函数式的。输入是文件内容、配置文件、loader 和插件配置,输出是编辑后的模块对象。输入不变,输出就应该不变。既然输出可以预期,那就没必要每次重新算一遍,把结果存下来就是最直接的优化思路。
1.2 从 webpack 4 时代的补丁方案说起
在 webpack 5 之前,想实现类似效果要拼凑好几样东西:
babel-loader自带的cacheDirectory,只缓存 babel 转译结果。cache-loader,把 loader 执行结果缓存到磁盘。hard-source-webpack-plugin,缓存整个模块构建过程。
这三样我都用过,感受是:能用,但很别扭。cache-loader要手动插到 loader 链最前面,缓存文件多且碎片化严重;hard-source-webpack-plugin的失效判断偶尔抽风,经常出现“改了代码但构建用的还是缓存”的情况,团队里一旦有人遇到这类问题,后面就会形成“出问题先清缓存”的条件反射。
webpack 5 把缓存能力内置进来,由官方统一维护序列化格式和失效判断逻辑,这是最稳妥的选择。开启方式也足够简单,一行配置就能跑起来:
module.exports = { cache: true };但这只是入门配置。想在生产环境真正用好它,还是需要理解缓存是怎么做到“识别变化”、“精确失效”的,否则只能停留在“加了配置发现没什么效果”的阶段。
2. 持久化缓存的底层原理
2.1 本质:把内存里的编译对象序列化到磁盘
webpack 5 的持久化缓存,本质上就是一套序列化与反序列化机制。构建过程中,每个模块的转换结果、模块之间的依赖关系、chunk 的生成结果,都会被序列化后写入磁盘。下次构建时,如果判断缓存仍然有效,就直接从磁盘反序列化恢复这些对象,跳过重跑 loader 和重新建立依赖图的过程。
你可以把它理解成“给编译过程加了记忆”。缓存放哪里,默认是node_modules/.cache/webpack目录,形式有两种:
store: 'pack'(默认值):把缓存打包成一个相对大的文件,序列化速度更快,适合整体读写。store: 'unpack':拆成多个小文件,单次读写的颗粒度更细,但文件数量多,整体性能不如 pack。
从实际效果看,绝大多数项目直接用默认的pack就好,不需要纠结。
2.2 webpack 怎么判断缓存能不能用:五类 key
缓存能不能复用,不能只看“这个文件变没变”。同一个模块的编译结果,受很多因素影响:源码内容、loader 配置、依赖的其他文件、甚至插件的版本和配置方式。
webpack 内部把缓存有效性拆成五类 key,任何一类发生变化,相关缓存就作废:
| 缓存 key 类别 | 大致含义 | 影响范围 |
|---|---|---|
| buildDependencies | 构建依赖,比如 webpack 配置文件本身、babel.config.js、tsconfig.json | 变化会导致整个缓存全部失效 |
| resolveDependencies | 模块解析相关依赖,比如 resolve 配置、alias 配置 | 变化会影响所有模块的解析结果 |
| codeGenerationDependencies | 代码生成阶段的依赖,比如某些内置 plugin 的 hook 行为 | 影响最终生成代码 |
| sealDependencies | 封装阶段的依赖,比如 optimization 配置、chunk 划分逻辑 | 影响 chunk 生成结果 |
| custom identifiers | 自定义标识符,比如 cache 的 name、version、mode 等 | 配合以上 key 一起决定缓存能否使用 |
对源码文件本身,webpack 会在构建时记录文件内容的 hash;下次构建发现 hash 变了,就认为这个模块需要重新构建。对node_modules下的依赖包,webpack 默认通过managedPaths(默认值为项目下的node_modules)做了特殊处理,不会逐个 hash 文件,而是通过读取 package.json 中的版本信息来判断,这也是缓存判断能保持很快的原因之一。
2.3 命中缓存后发生了什么
缓存命中时,webpack 会直接从缓存里恢复模块对象,跳过完整的 loader 执行和依赖解析。如果只改动了一个组件,未命中的只有这个组件及其依赖链上的模块,其他模块全部走缓存,增量构建的耗时就会大幅下降。
这也是为什么要强调“确定性构建”。如果某个 loader 或插件在构建过程中依赖了随机数、时间戳,或者某个不稳定的全局变量,那每次构建的输出都无法保证一致,缓存命中后拿到的“旧结果”就会出问题。你在排查缓存导致打包产物异常时,第一反应应该是检查项目里有没有这类写得不规矩的 loader 或插件。
3. 实操:配置一套能落地的 filesystem cache
3.1 基础配置逐项拆解
先给出一套我在中型项目中实际使用的配置:
// webpack.config.js const path = require('path'); module.exports = { cache: { type: 'filesystem', cacheDirectory: path.resolve(__dirname, 'node_modules/.cache/webpack'), name: 'my-app-cache', version: `${process.env.NODE_ENV || 'development'}-${require('./package.json').devDependencies.webpack || 'unknown'}`, buildDependencies: { config: [__filename], tsconfig: [path.resolve(__dirname, 'tsconfig.json')], }, managedPaths: [path.resolve(__dirname, 'node_modules')], store: 'pack', idleTimeout: 60000, maxMemoryGenerations: 5, }, // 其他配置... };逐项说明一下为什么这么写:
- type: 'filesystem':核心开关,让缓存写到磁盘。只写
cache: true默认就是 memory 加 filesystem 的组合,但显式声明更清晰。 - cacheDirectory:缓存目录,默认已经是这个位置,显式写出来是为了让团队成员都知道缓存在哪,排查问题时方便。
- name:缓存的标识。多个编译实例(比如开发和生产配置分离,或者 monorepo 下同时构建多个包)同时使用持久化缓存时,
name不区分开,两个进程会互相覆盖缓存。这里我会按项目名起名。 - version:手动指定缓存版本号。这个很有用,升级 webpack 或者某个影响构建结果的核心依赖后,手动调一下版本号,能避免旧缓存被错误复用。我比较习惯把
NODE_ENV和 webpack 版本拼进去,保证不同环境和不同版本之间不会串缓存。 - buildDependencies:把配置文件本身、TypeScript 配置文件等作为构建依赖。一旦这些文件内容变化,整体缓存自动失效,这是避免“改了 webpack 配置但缓存没失效”的关键。
- managedPaths:把
node_modules纳入受管路径,周期性检查依赖版本变化。 - store: 'pack':保持默认。
- idleTimeout:构建完成后的空闲等待时间,超过这个时间触发缓存写盘。设置得长一点可以避免频繁写盘,但太长也可能导致构建进程被 kill 时缓存还没落地。
- maxMemoryGenerations:控制内存中保留多少代缓存,超过之后会被 GC 回收。这个参数和持久化缓存的关系不大,但会影响开发模式 watch 时的内存表现。
提示:如果你感觉缓存总是没生效,首先检查
name和version是不是每次构建都在变,尤其是把require('./package.json')整个对象拼进 version 的做法,package.json 里任何字段变化都会导致缓存全量失效,这是一个非常常见的“伪不命中”原因。
3.2 开发模式和生产模式的差异化配置
开发模式最讨厌的是冷启动慢。webpack 5 默认会在内存里做一层 memory cache,保证 watch 模式下的增量构建很快,但重启 dev server 后内存就没了。加上 filesystem cache 之后,重启也能快速恢复大部分模块的编译结果。
生产模式则更关注无代码变更时的二次构建速度,典型的场景就是 CI 上经常“什么都没改,只是重跑了一次流水线”,有缓存在,这一类的构建时间能压到非常低。
一个比较稳妥的差异化配置写法是:
const isProd = process.env.NODE_ENV === 'production'; module.exports = { cache: { type: 'filesystem', name: isProd ? 'my-app-prod' : 'my-app-dev', version: isProd ? `${process.env.CI_JOB_ID || 'local'}-${process.env.NODE_ENV}` : `${process.env.NODE_ENV}`, // ... }, };生产环境把 CI 的任务 ID 拼进version,确保每次 CI 构建都有独立的缓存版本,可以避免一次构建污染下一次构建。如果你觉得这样太激进,也可以不拼CI_JOB_ID,只在关键依赖变化时手动更新版本号。
3.3 怎么确认缓存真的生效了
配置加完之后,别急着干别的,先验证一下:
- 执行一次构建,看
node_modules/.cache/webpack目录是否生成。 - 不改任何代码,再执行一次构建,对比两次构建总耗时,第二次应该有明显下降。
- 改一个模块的源码,再构建一次,看控制台输出是否只处理了相关模块链路。
如果你用的是 webpack 5,控制台会输出webpack compiled successfully之类的信息,但不会直接显示“cache hit”。更直观的方法是看时间,或者用webpack --profile --json > stats.json导出构建统计,确认modules里大部分模块的built耗时都很低。
3.4 CI 环境下的缓存持久化
CI 上跑构建,最容易出现“缓存完全没生效”的情况。因为很多 CI 平台每次任务都开一台全新机器,node_modules/.cache目录在任务结束后就没了,下次任务重建项目时依然从零开始。
解决办法是把缓存目录纳入 CI 的持久化机制里。比如 GitHub Actions 用actions/cache,Jenkins 用workspace下的目录持久化,GitLab CI 用cache关键字配置:
# 示例:GitLab CI cache: key: "$CI_COMMIT_REF_SLUG" paths: - node_modules/.cache/webpack这里的 key 建议按分支维度区分,否则分支之间代码差异大,不同分支的缓存互相污染,命中率反而更低。
4. 常见问题与排查技巧实录
4.1 缓存不命中的几个典型原因
实际用起来,缓存不命中是大家问得最多的问题。我把常见原因整理成一张速查表:
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 每次构建耗时几乎一样,缓存像没开 | version或name每次构建都不同,比如拼了动态随机值 | 固定 version,不要拼时间戳或随机 ID |
| 改了 webpack 配置但缓存没失效 | buildDependencies没有覆盖配置文件本身 | 在buildDependencies.config中加入对应配置文件路径 |
| 只改了 package.json 里一个描述字段,全部缓存失效 | version 里拼了整个 package.json 对象 | 只拼 webpack 版本号或 lock 文件 hash |
| 切分支后构建特别慢 | 两个分支代码差异大,缓存命中率骤降 | 正常现象,可接受;或 CI 按分支隔离缓存 |
| node_modules 里某个依赖升级后构建结果异常 | 缓存没有及时感知依赖变化 | 确认 managedPaths 覆盖 node_modules,必要时调高检测频率 |
4.2 缓存文件损坏了怎么办
持久化缓存也是文件,文件就有损坏的可能。常见表现是构建过程中报一些奇怪的序列化错误,比如Unexpected end of JSON input,或者直接出现Cannot read property of undefined。
遇到这类问题,我的处理顺序是:
- 先删掉
node_modules/.cache/webpack目录,确认问题消失。这一步能排除缓存损坏的可能。 - 确认不是代码问题后,再重新开启缓存,观察会不会再次报错。
- 如果反复出现,检查磁盘空间是否充足,以及是否有多个 webpack 进程同时写同一个缓存目录。
- 升级 webpack 或大量升级依赖后,建议主动清理一次缓存,因为官方并不保证缓存格式跨版本兼容。
4.3 开启缓存后冷构建反而变慢了
这不是错觉。第一次构建时,webpack 除了正常构建,还要把所有可序列化的对象写盘,这是额外的 I/O 开销。所以“开缓存后首次构建变慢”是正常现象,关键是看第二次、第三次的表现。
如果你发现连第二次构建都没变快,大概率是缓存根本没有命中。按 4.1 的表格逐项排查即可。
另外,写缓存是异步的,webpack 不会因为写缓存失败而让构建失败,最多打印一条 warning。如果你在日志里看到Unable to cache类似的提示,别慌,先看是权限问题还是磁盘问题,不阻塞构建的前提下,可以先继续用,但要找时间处理。
4.4 持久化缓存和 contenthash 别搞混
这是两个完全不同的东西。持久化缓存影响的是“构建过程快不快”,[contenthash]影响的是“浏览器缓存识别文件变没变”。持久化缓存放的是编译中间产物,不对外输出;contenthash 是最终产物文件名的一部分,直接关系到用户侧缓存。
两者也有微小关联:当你改动代码后,模块内容变化,持久化缓存会正确失效并重新构建该模块;重新构建后,contenthash 自然也会变。这不是“缓存导致 hash 变化”,而是代码变化本来就该产生新的 hash。别把两件事混在一起排查问题。
5. 效果评估与配套优化建议
5.1 一套可复制的最小验证流程
优化效果要用数据说话,但不同项目差异很大,我分享的验证方法比具体数值更有参考价值。
第一步,关掉缓存,连续构建三次取平均时间,作为基线。
第二步,开启 filesystem cache,重新构建一次(首次会写缓存,时间可能比基线略慢)。
第三步,不修改任何代码,再构建一次,记录耗时。这个数字代表缓存完全命中时的构建速度,正常来说应该比基线快很多。
第四步,随便改一个文件,再构建一次,记录耗时。这个数字更接近日常增量开发的真实感知。
我当时的小项目差不多是 1900 个模块,关缓存冷构建 42 秒左右,开启后完全命中大约 9 秒,改单文件增量构建 1.5 秒左右。虽然具体数字因项目而异,但量级上的差异是普遍成立的。
5.2 和持久化缓存搭配的几个提速动作
持久化缓存解决的是“重复劳动”,但首次构建仍需完整解析和编译。想继续压首次构建时间,可以配合这些手段:
- 用
thread-loader把耗时的 loader 放到 worker 线程里并行执行,多核机器上效果明显。 - 检查 loader 匹配规则,避免让本不需要处理的文件也走一遍 loader。
- 优化
resolve.extensions和resolve.modules,减少文件查找次数。 - webpack 版本较新的项目可以尝试
experiments.cacheUnaffected: true,进一步提升增量构建的缓存精度,注意它是实验特性,生产环境要观察稳定性。
如果你的项目还在 webpack 4,且暂时无法升级,那么用cache-loader加hard-source-webpack-plugin这类方案还可以再撑一阵子。但只要条件允许,升级 webpack 5 并切换到官方缓存机制,才是投入产出比最高的选择。
最后分享一个我经常用的排查技巧:开启持久化缓存之后,如果某次构建结果让人觉得“不对劲”,不要急着去怀疑缓存的老旧数据,先做一次“禁用缓存 + 清空缓存目录”的干净构建,确认问题是否仍存在。绝大多数时候,问题出在 loader 或插件本身写得不严谨,而不是缓存机制自身有问题。先排除这个变量,再回来看缓存配置,思路会清晰很多。