文章标签:Webpack 5、Jenkins、filesystem cache、前端工程化、构建性能优化 适用场景:Vue 2 / Webpack 5 老项目、容器化 Jenkins Agent、重复构建耗时较长 核心结论:只在 Webpack 配置里开启缓存还不够,必须保证下一次 Jenkins 构建真正能读取上一次的缓存目录。
摘要
一个 Vue 2 + Webpack 5 项目的生产构建长期稳定在 4 分钟左右。Webpack 虽然已经配置 filesystem 缓存,但 Jenkins 每次构建前都会清空工作空间,导致 node_modules/.cache/webpack 被删除,所以连续两次构建仍然都是冷构建。
最终通过以下闭环落地:
-
为 Webpack 开启文件系统缓存,并配置可靠的失效依赖。
-
采集缓存状态、Webpack 编译耗时和完整构建耗时。
-
Jenkins 不再每次清空整个工作空间,只清理 dist 和发布压缩包。
-
工作空间已有 node_modules 时直接复用,不存在时才调用 CI 依赖恢复脚本。
-
每次仍执行 npm install、重新生成 dist、校验发布包并上传制品库。
实测结果:Webpack 编译从 211.7s 降到 11.6s,自定义构建总耗时从 288s 降到 30s。
发布到 CSDN 时,先上传上图,再将相对路径替换为 CSDN 生成的图片地址。本文已对内部域名、人员、仓库地址、流水线编号和共享目录做脱敏处理。
一、指标采集:先证明慢在哪里
1. 为什么不能只看 Jenkins 总时长
一条前端流水线通常包括:
拉取代码
→ 恢复或安装依赖
→ Webpack 编译
→ 压缩发布包
→ 计算 MD5 / SHA256
→ 上传制品
→ 通知与后续检查
只看 Jenkins 总时长,无法判断究竟是 npm install、Webpack 编译,还是制品上传慢。因此需要同时采集以下指标:
| 指标 | 用途 |
|---|---|
| 缓存目录是否存在 | 判断本次是冷构建还是热构建 |
| Webpack stats.time | 记录 Webpack 编译器耗时 |
| 完整 build 耗时 | 包含配置加载、产物写入等开销 |
| npm install 耗时 | 区分依赖安装和编译瓶颈 |
| Git commit、环境、Node 版本 | 保证前后数据可比较 |
| Webpack hash、错误数、警告数 | 辅助验证构建结果 |
2. 在 Webpack Node API 回调中记录性能
项目通过 build/build.js 调用 Webpack Node API,可以直接从 stats 中读取数据:
const buildStartedAt = Date.now()
function writeBuildPerformanceLog(stats, totalBuildMs) {
const info = stats.toJson({
all: false,
hash: true,
timings: true,
errorsCount: true,
warningsCount: true,
})
const cacheEnabled = webpackConfig.cache
&& webpackConfig.cache.type === 'filesystem'
const record = {
timestamp: new Date().toISOString(),
label: process.env.BUILD_PERFORMANCE_LABEL || 'default',
env: process.env.CUR_ENV || 'unknown',
nodeVersion: process.version,
cacheEnabled: Boolean(cacheEnabled),
cacheDirectory: cacheEnabled
? webpackConfig.cache.cacheDirectory
: null,
webpackCompileMs: info.time,
totalBuildMs,
hash: info.hash,
errorsCount: info.errorsCount || 0,
warningsCount: info.warningsCount || 0,
}
// 也可以将该对象追加到 JSONL 文件,供后续生成趋势和对比报告。
console.log('[build-performance] ' + JSON.stringify(record))
}
webpack(webpackConfig, (error, stats) => {
if (error) throw error
// 原有的产物生成逻辑保持不变。
writeBuildPerformanceLog(stats, Date.now() - buildStartedAt)
})
性能日志写入失败不应阻断正式制品生成,因此生产实现中应捕获文件写入异常,只输出警告。
3. 优化前后的真实数据
测试条件保持相同:相同 Node 版本、生产环境参数和代码 hash。
| 指标 | 第一次 | 第二次 | 最终热构建 |
|---|---|---|---|
| Webpack 缓存状态 | 未命中 | 未命中 | 命中 965MB |
| CI 依赖恢复 | 32s | 31s | 跳过 |
| npm install | 5s | 5s | 4s |
| Webpack 编译器统计 | 211.7s | 195.5s | 11.6s |
| Webpack 完整阶段 | 240s | 225s | 14s |
| 生成发布包 | 5s | 6s | 6s |
| 自定义构建总耗时 | 288s | 272s | 30s |
| 构建结果 | SUCCESS | SUCCESS | SUCCESS |
前两次虽然相差 16 秒,但日志都显示"未恢复到 Webpack 缓存"。这 16 秒只能视为共享节点 CPU、I/O 和并发任务产生的正常波动,不能写成缓存收益。
最终热构建与第一次相比:
-
Webpack 编译减少约 94.5%。
-
自定义构建总耗时减少约 89.6%。
-
构建速度约提升 9.6 倍。
4. npm install 为什么从约 1 分钟降到 4 秒
这里需要把"执行了 npm install"和"从零安装全部依赖"区分开。三种场景的实测数据如下:
| 依赖场景 | 前置状态 | 缓存恢复耗时 | npm install 耗时 | 日志特征 |
|---|---|---|---|---|
| 完整冷安装 A | 工作空间没有 node_modules | 无 | 53s | added 1593 packages in 53s |
| 完整冷安装 B | 工作空间没有 node_modules | 无 | 约 1min | added 1593 packages in 1m |
| 公共缓存恢复后校验 | 先由 CI 脚本恢复 node_modules | 31~32s | 4~5s | up to date in 4s |
| 当前工作空间直接复用 | node_modules 已保留 | 跳过 | 4s | up to date in 4s |
因此,现在并不是没有执行 npm install,而是没有重新安装全部 1593 个依赖包:
工作空间保留 node_modules
→ 跳过公共缓存恢复
→ 仍然执行 npm install
→ npm 根据 package.json 和 package-lock.json 校验现有依赖
→ 依赖未变化,输出 up to date in 4s
如果 package.json 或 package-lock.json 发生变化,npm install 仍会安装、更新或删除受影响的依赖;如果切换到没有 node_modules 的新工作空间,则会回退为公共缓存恢复或完整冷安装。
需要注意,"恢复 node_modules 31~32 秒 + npm install 4~5 秒"是两个独立阶段,不能把约 36 秒全部算作 npm install。当前将"构建前清空工作空间"设为"否"后,依赖恢复阶段被跳过,才进一步省掉了这 31~32 秒。
在当前热构建中,npm install 已经只有约 4 秒,继续切换 pnpm 的可优化空间很小,反而需要额外处理 lockfile、依赖提升规则和流水线兼容性。后续应优先拆分 Jenkins 排队、Git 拉取、Webpack、压缩、制品上传、镜像构建和部署耗时。
二、措施:找到"开了缓存但不生效"的根因
1. 只开启 filesystem cache 为什么不够
Webpack 5 可以将缓存写入:
node_modules/.cache/webpack
但如果 Jenkins 每次构建开始都执行 cleanWs:
删除整个工作空间
→ 删除 node_modules
→ 同时删除 node_modules/.cache/webpack
→ 下一次仍然只能冷构建
Webpack 配置本身没有问题,问题发生在缓存生命周期:上一轮成功写入,下一轮开始前又被删除。
2. node_modules 依赖缓存不等于 Webpack 编译缓存
项目原本已有 CI 平台的 node_modules 备份和恢复能力,所以 npm install 只需要几秒。但实际验证发现:
依赖恢复成功
≠ node_modules/.cache/webpack 恢复成功
部分公共备份脚本会过滤隐藏目录,或者使用不匹配 .cache 的通配符。即使脚本打印"备份成功",也必须在下一次构建前显式检查:
if [ -d ./node_modules/.cache/webpack ]; then
echo "Webpack cache hit"
else
echo "Webpack cache miss"
fi
3. 最终措施
在不修改 CI 平台公共脚本的情况下,采用以下最小方案:
-
Jenkins"构建前清空工作空间"设置为"否"。
-
自定义脚本只删除 dist 和 dist.zip。
-
工作空间已有 node_modules 时跳过公共恢复。
-
首次构建或切换到新节点时,再恢复 node_modules 作为兜底。
-
保留 npm install,让实际依赖与 lockfile 对齐。
-
每次从空的 dist 开始生成产物,不复用旧发布包。
三、代码优化:配置可靠失效的 Webpack 缓存
1. Webpack 生产配置
在 build/webpack.prod.conf.js 中配置:
const path = require('path')
const filesystemCacheEnabled =
process.env.WEBPACK_FILESYSTEM_CACHE !== 'false'
const filesystemCacheDirectory = process.env.WEBPACK_CACHE_DIRECTORY
? path.resolve(process.env.WEBPACK_CACHE_DIRECTORY)
: path.resolve(__dirname, '../node_modules/.cache/webpack')
const webpackConfig = {
mode: 'production',
cache: filesystemCacheEnabled
? {
type: 'filesystem',
cacheDirectory: filesystemCacheDirectory,
// 不同环境使用不同缓存命名空间,避免混用构建结果。
name: 'web-app-' + (process.env.CUR_ENV || 'unknown'),
buildDependencies: {
// 构建规则或锁文件发生变化后,旧缓存必须失效。
config: [
__filename,
path.resolve(__dirname, 'webpack.base.conf.js'),
path.resolve(__dirname, '../config/index.js'),
path.resolve(__dirname, '../package-lock.json'),
],
},
}
: false,
}
同时保留单次关闭缓存的环境变量,便于基准测试和故障回退:
WEBPACK_FILESYSTEM_CACHE=false npm run build
2. Webpack 缓存避免了哪些重复工作
冷构建通常需要完整执行:
扫描全部模块
→ 构建依赖图
→ Vue / Babel / TypeScript / CSS / Less Loader 转换
→ 模块解析和代码生成
→ Chunk 优化
→ Terser 压缩
→ 序列化缓存
热构建会先校验源码快照、构建配置和 lockfile。未变化模块读取缓存,只重新处理变化模块及其影响的 Chunk。
缓存不会跳过:
-
新代码编译。
-
源码与配置快照校验。
-
dist 产物生成。
-
dist.zip 压缩。
-
MD5 / SHA256 计算。
-
制品库上传。
四、构建优化:Jenkins 中如何完整落地
1. Jenkins 配置建议
| 配置项 | 建议值 | 原因 |
|---|---|---|
| 工程类型 | 自定义 | 控制恢复、构建、备份和打包顺序 |
| 构建前清空工作空间 | 否 | 保留 Webpack filesystem cache |
| Node 版本 | 与基线一致 | Node 版本不同不应混用缓存 |
| 构建发布包路径 | ./dist.zip | 明确指向构建制品 |
| 超时时间 | 覆盖冷构建上限 | 缓存未命中时仍能完成构建 |
2. 可直接改造的自定义构建脚本
下面使用通用 CI 路径。实际使用时通过 CI_CACHE_HOME 指定平台脚本目录,不要在公开博客中暴露公司内部挂载路径。
set -eu
# 该目录可由 CI 平台通过环境变量注入;未配置时使用公开示例路径。
CI_CACHE_HOME="${CI_CACHE_HOME:-/opt/ci/cache-scripts}"
RESTORE_SCRIPT="$CI_CACHE_HOME/restore-node-modules.sh"
BACKUP_SCRIPT="$CI_CACHE_HOME/backup-node-modules.sh"
WEBPACK_CACHE_DIR=./node_modules/.cache/webpack
BUILD_TOTAL_START=$(date +%s)
# 输出带时间戳的阶段日志,便于比较不同流水线的耗时。
log() {
printf '[custom-build][%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"
}
log_duration() {
PHASE_NAME=$1
PHASE_START_TIME=$2
PHASE_END_TIME=$(date +%s)
log "$PHASE_NAME finished in $((PHASE_END_TIME - PHASE_START_TIME))s"
}
# 无论成功或失败都记录总耗时与退出码。
on_exit() {
EXIT_CODE=$?
BUILD_TOTAL_END=$(date +%s)
if [ "$EXIT_CODE" -eq 0 ]; then
log "build succeeded, total $((BUILD_TOTAL_END - BUILD_TOTAL_START))s"
else
log "build failed, exit code $EXIT_CODE, total $((BUILD_TOTAL_END - BUILD_TOTAL_START))s"
fi
}
trap on_exit EXIT
log "build started"
log "workspace: $(pwd)"
log "Node: $(node -v)"
log "npm: $(npm -v)"
log "Git branch: $(git rev-parse --abbrev-ref HEAD 2>/dev/null || printf 'unknown')"
log "Git commit: $(git rev-parse --short HEAD 2>/dev/null || printf 'unknown')"
# 只清理发布产物,不删除 node_modules 和 Webpack 缓存。
PHASE_START=$(date +%s)
rm -rf ./dist
rm -f ./dist.zip
log_duration "clean artifacts" "$PHASE_START"
# 工作空间有依赖时直接复用;首次构建或换节点时才恢复。
if [ -d ./node_modules ]; then
log "node_modules found in workspace; skip shared restore"
elif [ -f "$RESTORE_SCRIPT" ]; then
PHASE_START=$(date +%s)
log "restore node_modules from CI cache"
sh "$RESTORE_SCRIPT"
log_duration "restore node_modules" "$PHASE_START"
else
log "restore script not found; npm install will perform a cold install"
fi
if [ -d "$WEBPACK_CACHE_DIR" ]; then
log "Webpack cache hit: $(du -sh "$WEBPACK_CACHE_DIR" | awk '{print $1}')"
else
log "Webpack cache miss; this run is a cold build"
fi
PHASE_START=$(date +%s)
npm install
log_duration "npm install" "$PHASE_START"
PHASE_START=$(date +%s)
# 如果项目没有重复的 postbuild,可直接改成 npm run build。
npm_config_ignore_scripts=true npm run build
log_duration "Webpack build" "$PHASE_START"
if [ ! -d "$WEBPACK_CACHE_DIR" ]; then
log "Webpack cache directory is missing: $WEBPACK_CACHE_DIR"
exit 1
fi
log "Webpack cache generated: $(du -sh "$WEBPACK_CACHE_DIR" | awk '{print $1}')"
# 公共备份仅作为换节点后的依赖兜底,不假设它一定包含隐藏缓存目录。
if [ -f "$BACKUP_SCRIPT" ]; then
PHASE_START=$(date +%s)
sh "$BACKUP_SCRIPT"
log_duration "backup node_modules" "$PHASE_START"
else
log "backup script not found; skip shared dependency backup"
fi
PHASE_START=$(date +%s)
(
cd dist
zip -qry ../dist.zip .
)
if [ ! -s ./dist.zip ]; then
log "dist.zip is missing or empty"
exit 1
fi
log "artifact generated: $(du -h ./dist.zip | awk '{print $1}')"
log_duration "package artifact" "$PHASE_START"
3. 为什么本方案使用 npm install,而不是 npm ci
npm ci 会先删除现有 node_modules。当 Webpack 缓存位于 node_modules/.cache/webpack 时,它会把需要复用的缓存一起删除。
本方案通过 npm install 对齐 lockfile 与已有依赖。如果团队必须使用 npm ci,应把 cacheDirectory 放到 node_modules 之外,并对缓存目录做独立持久化,不能直接照搬本文脚本。
4. 为什么工作空间没有清理,代码仍然会更新
Git 拉取仍会更新受版本控制的文件;Webpack 会根据源码快照判断哪些模块需要失效;npm install 会根据 lockfile 调整依赖;脚本会删除并重新生成 dist。
需要警惕的是未纳入 Git 的残留文件。因此该方案更适合:
-
每个 Jenkins Job 有独立工作目录。
-
同一个 Job 主要构建同一条开发链路。
-
构建脚本不会把临时文件写入源码目录。
-
出现异常时可以快速回退到冷构建。
五、如何证明缓存真的生效
1. 错误的判断方式
第二次比第一次快十几秒
→ 缓存生效
这个结论不可靠。共享节点的 CPU、内存、I/O 和并发任务都会带来波动。
2. 正确的验收条件
第二次构建需要同时满足:
工作空间已存在 node_modules
已跳过公共依赖恢复
已命中 node_modules/.cache/webpack
Webpack 编译耗时显著下降
重新生成 dist.zip
制品上传成功
流水线最终状态为 SUCCESS
最终日志中的关键证据:
工作空间已存在 node_modules,跳过公共缓存恢复
已命中 Webpack 热缓存,大小:965M
Webpack compiled in 11631 ms
Webpack 构建完成,耗时 14 秒
全部流程成功,总耗时 30 秒
Finished: SUCCESS
3. 标准冷/热对比步骤
使用相同提交、Node 版本、环境参数和构建节点:
# 冷构建基线:明确关闭 Webpack filesystem cache。
WEBPACK_FILESYSTEM_CACHE=false BUILD_PERFORMANCE_LABEL=cache-disabled npm run build
# 开启缓存并生成首份缓存。
BUILD_PERFORMANCE_LABEL=cache-seed npm run build
# 第二次读取已有缓存。
BUILD_PERFORMANCE_LABEL=cache-reuse npm run build
建议每种场景至少重复 3 次并取中位数,不要只选择最快的一次。
六、新代码会不会受到旧缓存影响
Webpack filesystem cache 不是上一轮 dist 的备份,而是编译中间结果。它会根据源文件快照、依赖图和 buildDependencies 判断缓存是否有效。
修改一个 Vue 文件后,Webpack 会重新处理:
-
该 Vue 文件及相关 Loader。
-
它依赖或影响的模块。
-
相关 Chunk 的代码生成与压缩。
未变化的其他模块才继续使用缓存。
建议在相同提交上分别执行热构建和禁用缓存的冷构建,对比:
-
主要 JS/CSS 产物的内容 hash。
-
Chunk 列表。
-
页面核心操作。
-
JavaScript 错误、资源 404 和业务接口成功率。
如果构建会写入时间戳或版本文件,应排除这些预期变化后再比较,不能直接要求整个 dist.zip 字节级一致。
七、风险、边界与回滚
1. 不清空工作空间的风险
保留工作空间也可能保留:
-
前一个分支留下的未跟踪文件。
-
构建脚本生成的临时文件。
-
不完整或损坏的 node_modules。
-
与新 Node、Webpack 或 Loader 不兼容的旧缓存。
2. 应强制冷构建的场景
-
Node 大版本升级。
-
Webpack、Loader 或压缩器大版本升级。
-
切换差异很大的分支。
-
缓存损坏或读取报错。
-
发布产物与当前 Git 提交不一致。
-
页面出现无法解释的旧代码。
3. 回滚方式
方案一:将 Jenkins"构建前清空工作空间"改回"是"。
方案二:单次关闭 Webpack 缓存:
WEBPACK_FILESYSTEM_CACHE=false npm run build
方案三:只删除明确的 Webpack 缓存目录:
rm -rf ./node_modules/.cache/webpack
八、常见问题
Q1:速度快这么多,是不是跳过了新代码?
不是。Webpack 只复用未变化模块的中间结果。变化文件和受影响 Chunk 会重新编译,dist 也会从空目录重新生成。
Q2:为什么 npm install 只需要几秒,Webpack 还需要几分钟?
npm install 解决依赖是否存在以及版本是否对齐;Webpack 仍需执行 Loader、模块解析、Chunk 生成和压缩。这是两个不同的性能问题。
Q3:npm install 只需要 4 秒,是不是没有执行?
不是。构建脚本仍然执行 npm install;因为工作空间已经保留 node_modules,并且 package.json、package-lock.json 没有要求调整依赖,所以 npm 主要完成依赖树和锁文件校验,日志显示 up to date in 4s。只有工作空间没有依赖时,才需要重新安装全部依赖,实测为 53 秒至约 1 分钟。
Q4:为什么容器 ID 不同也能复用缓存?
关键不是容器 ID,而是容器是否挂载同一个持久化 Jenkins 工作目录。如果任务被调度到另一台没有该工作目录的宿主机,就会回退为冷构建。
Q5:为什么不直接上 HappyPack 或 DLLPlugin?
Webpack 5 已提供官方 filesystem cache。HappyPack 不是 Webpack 5 的优先方案;DLLPlugin 会增加依赖更新、manifest 和版本治理成本。在没有 loader/plugin 耗时证据前,先打通官方持久化缓存闭环更直接。
九、落地检查清单
指标采集
- 记录 Webpack stats.time。
- 记录完整构建耗时。
- 记录 Node 版本、环境、Git 提交和 Webpack hash。
- 区分冷构建、首次写入缓存和热构建。
措施
- 相同条件至少重复测量 3 次。
- 只有明确命中缓存的数据才计入收益。
- 同时验证构建成功、制品上传和页面核心功能。
代码优化
- 开启 Webpack 5 filesystem cache。
- 为构建配置和 lockfile 配置 buildDependencies。
- 按项目和环境隔离 cache name。
- 保留单次关闭缓存的环境开关。
构建优化
- 确认 Jenkins 是否清理工作空间。
- 确认第二次构建前缓存目录真实存在。
- 只清理发布产物,不复用旧 dist。
- 新工作空间或换节点时可回退到冷构建。
- 明确强制冷构建和回滚条件。
十、总结
这次优化最重要的经验不是"增加一行 cache: filesystem",而是建立完整的缓存生命周期:
配置缓存
→ 记录构建数据
→ 确认缓存写入
→ 让下一次构建能够读取
→ 校验失效条件
→ 重新生成并发布产物
→ 使用冷/热数据验收
在相同提交的理想热构建场景中,Webpack 编译时间减少约 94.5%,自定义构建总耗时减少约 89.6%。但真正能够上线的构建优化,必须同时保证新代码正常编译、产物完整生成、异常可回滚,而不是只追求更小的耗时数字。