# Webpack 5 + Jenkins 持久化缓存实战:前端构建从 288 秒降到 30 秒>

文章标签: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 被删除,所以连续两次构建仍然都是冷构建。

最终通过以下闭环落地:

  1. 为 Webpack 开启文件系统缓存,并配置可靠的失效依赖。

  2. 采集缓存状态、Webpack 编译耗时和完整构建耗时。

  3. Jenkins 不再每次清空整个工作空间,只清理 dist 和发布压缩包。

  4. 工作空间已有 node_modules 时直接复用,不存在时才调用 CI 依赖恢复脚本。

  5. 每次仍执行 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%。但真正能够上线的构建优化,必须同时保证新代码正常编译、产物完整生成、异常可回滚,而不是只追求更小的耗时数字。

相关推荐
风骏时光牛马2 小时前
AI多模态:跨越感官边界的智能新体验 要不要开启工作任务模式,帮你配套对应的文案和配图思路?
前端
IT_陈寒3 小时前
JavaScript的这个隐式转换特性差点让我加班到凌晨
前端·人工智能·后端
Loadings3 小时前
AI时代下的前端安全加固实践:安全、体积与性能之间的取舍
前端
计算机魔术师3 小时前
OpenAI 首个踩红线的 AI 模型来了:能自己挖漏洞,但只给少数人用
前端
闲坐含香咀翠3 小时前
从 132MB 到 25MB:列式存储怎么把 CPU 缓存命中率提上去的
前端·数据结构·性能优化
闲坐含香咀翠3 小时前
把 AntV S2 列头计算从 O(n²) 降到 O(n):一次开源组件的性能改造
前端·性能优化
闲坐含香咀翠4 小时前
Worker 常驻 + 零拷贝:postMessage 的结构化克隆算法与 Transferable 的真实代价
前端·性能优化
JunjunZ4 小时前
Naive UI 虚拟级联选择器适配 Element Plus 风格
前端·javascript·vue.js