ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

webpack 5 持久化缓存:构建速度提升的原理与最佳实践

webpack 5 持久化缓存:构建速度提升的原理与最佳实践 先交代一下背景我接手过一个不算小的中后台项目两千多个模块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 等好几个 loaderloader 内还要解析 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 cache3.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/cacheJenkins 用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 或插件本身写得不严谨而不是缓存机制自身有问题。先排除这个变量再回来看缓存配置思路会清晰很多。
返回列表