build-memory.md 13 KB

前端打包优化说明(build-mem-v1)

一句话:把生产构建从「被内核 OOM Killer 杀掉、根本跑不完」变成「稳定跑完」, 并让单次构建省约 3 分钟、峰值内存降 1.1 GB、JS 产物降 10 MB。

指标 优化前 优化后
构建结果 ❌ 7:43 被内核杀掉 ✅ 18:34 完成
构建峰值 RSS 8.49 GB(被杀时) 9.73 GB(含 swap 兜底)
参与打包的模块数 13,098 11,913
JS 产物 63 MB 53 MB
dist 总大小 296 MB 288 MB

1. 改动清单

文件 改动 作用
package.json 堆上限 12288 → 8192 V8 不再越过物理内存失控
package.json 新增 pnpm.overrides(echarts / xlsx / three) 消除三套重复依赖
package.json 新增 packageManager: pnpm@9.15.9 防止用 pnpm 8 重解析依赖
package.json 新增 build:lean 跳过 gzip 的快速构建
src/components/registerGlobComp.ts 移除 @qiaoqiaoyun/drag-free 不再把整包 antd + 421 个图标拖进构建
.gitignore 忽略 .pnpm-store 防止 pnpm 本地仓库进版本库
docs/build-memory.md 本文档 —

图片压缩保持原方案 vite-plugin-imagemin 不变(原因见 §7);gzip 保持默认最高级别 9(见 §5.3)。


2. 环境前提

项 要求 / 现状
Node ≥ 18.12(本项目实际 v22.21.0)
pnpm 必须 9.x。仓库 pnpm-lock.yaml 是 lockfileVersion: '9.0',用 pnpm 8 会重解析依赖并把 vue 降到 3.2.0,构建直接报 Failed to resolve vue/compiler-sfc
WSL2 内存 12 GB + swap 8 GB(C:\Users\<user>\.wslconfig → [wsl2] memory=12GB / swap=8GB)
构建可用内存 需要 约 9.7 GB;若同时开着 VS Code(语言服务会到 3~5 GB),建议构建前关掉 Remote-WSL 窗口

国内网络可在本地 .npmrc(已被 gitignore)指镜像:

registry=https://registry.npmmirror.com

3. 原来的构建为什么会失败

原始配置 NODE_OPTIONS=--max-old-space-size=12288(12 GB)高于机器物理内存。 V8 按上限规划回收节奏,于是「不急着回收」,实测:

指标 结果
耗时 7 分 43 秒
进程峰值 RSS 8.49 GB
结局 被内核 OOM Killer 杀掉(anon-rss:8488300kB、total-vm:42239712kB)
产物 dist 只写了一半(260 / 4463 文件)

堆上限压低后也证明这条路走不通:6144 直接 V8 堆溢出,7168 在 21 分钟时堆溢出,只有 8192 能跑完。 说明构建的真实需求就在 7~8 GB 之间。


4. 内存到底花在哪(带埋点实测)

用 v8.getHeapSpaceStatistics() 在关键阶段打点:

时刻 阶段 heapUsed old_space large_object RSS
t+0s buildStart 0.07 GB 41 MB 16 MB 0.17 GB
t+168s transform 2500 模块 4.30 GB — — 5.29 GB
t+354s transform 完成(13,098 模块) 5.38 GB — — 6.03 GB
t+356s renderStart 5.38 GB 5,179 MB 404 MB 6.03 GB
t+461s generateBundle 5.47 GB 5,598 MB 635 MB 6.73 GB
t+659s writeBundle 5.35 GB — — 6.24 GB

三个关键结论:

  1. 96% 的堆在 old_space(普通 JS 对象),大字符串(large_object)只有 0.4~0.6 GB —— 不是图片、不是大文件,而是成千上万个 AST 节点对象。
  2. 内存几乎在 transform 阶段就定型,渲染/压缩/写产物基本不再增长。
  3. 源码 106.88 MB → 堆 5.38 GB ≈ 50 倍放大,平均约 420 KB/模块。 决定内存的是模块数量,不是文件大小 —— 这就是「产物 JS 只有 63 MB,构建却要 5 GB+」的原因。

Rollup 3 为每个模块保留:源码字符串 + ESTree AST(纯 JS 对象,最占内存)+ 作用域/绑定信息 + 渲染产物。


5. 三项优化

5.1 依赖去重(pnpm.overrides)

三个间接依赖的版本范围与项目主版本没有交集,pnpm 只能同时装两份,两份都会被打进构建:

重复库 项目用 谁拖进来的旧版 旧版范围
echarts 6.1.0 @qiaoqiaoyun/drag-free ^5.4.3 → 5.6.0(还带 echarts-gl / echarts-liquidfill)
xlsx 0.18.5 @qiaoqiaoyun/drag-free ^0.17.0 → 0.17.5
three 0.162.0 three.path ^0.132.2 → 0.132.2
"pnpm": {
  "overrides": {
    "echarts": "6.1.0",
    "xlsx": "0.18.5",
    "three": "0.162.0"
  }
}

验证:ls node_modules/.pnpm | grep -E '^(echarts|three|xlsx)@' 每项只出现一次。

5.2 移除 @qiaoqiaoyun/drag-free

它的 UMD(1.4 MB)用 require('ant-design-vue') / require('@ant-design/icons-vue') 把整包拖进构建 —— CJS require 无法 tree-shake。

证据 移除前 移除后
antd-vue-vendor-*.js 2.06 MB 1.09 MB
打包进去的 *Outlined 图标数 421(整套) 82(实际用到的)
从未被使用的 AccountBookOutlined 在产物里 消失

该功能("敲敲云—仪表盘设计器")在本项目没有任何路由或代码引用,src/views/super/drag/ 9 个文件是孤立页面。还原方式已写在 registerGlobComp.ts 注释里。

5.3 gzip 压缩级别:保持默认 9(不改)

曾试过降到 6(体积 +0.26%、耗时 -28%),但本项目更在意产物体积,已回退为 zlib 的默认最高级别 9。 level 9 就是 gzip 的上限,再调没有空间了:

变体 体积变化 说明
level 9(现用) 基准 gzip 最高压缩比
level 9 + memLevel=9 +0.22%(更大) 无效
level 9 + strategy=FIXED +22.9%(更大很多) 无效
level 6 +0.26% 更快但更大,已弃用

若还想更小,只有换 brotli(40 个大文件实测):

方案 体积 相对 gzip-9 耗时
gzip -9(现用) 8.79 MB 基准 0.72 s
brotli q5 8.16 MB -7.2% 0.43 s(比 gzip 更快)
brotli q9 7.96 MB -9.5% 1.65 s
brotli q11(插件默认) 7.47 MB -15.1% 30.1 s(41×,会拖慢构建 1~1.5 分钟)

启用方式:.env.production 里 VITE_BUILD_COMPRESS = 'gzip,brotli'(两种产物都出)。 前提是部署侧 nginx 支持 brotli 模块(brotli_static on),否则 .br 文件无人消费; 插件 brotli 分支默认用 BROTLI_MAX_QUALITY(11),建议顺手降到 5~9。

5.4 堆上限 12288 → 8192

见 §3 的实测:6144 / 7168 都会 V8 堆溢出,8192 是能跑完的最小值。


6. 收益汇总(实测)

6.1 分阶段演进(埋点数据)

指标 原始 + 依赖去重 + 移除 drag-free
模块数 13,098 12,542 11,913
转换后源码总量 106.88 MB 101.35 MB 92.37 MB
buildEnd 堆 5.38 GB 4.56 GB 4.02 GB
buildEnd RSS 6.03 GB 5.82 GB 4.69 GB
总 JS 产物 63 MB — 53 MB

6.2 A/B 真实构建(同一插件、同一依赖,唯一变量 = drag-free)

指标 A:保留 drag-free B:移除 drag-free 变化
模块数 12,541 11,913 -628(-5.0%)
vite 阶段耗时 19m 45s 17m 13s -2m 32s(-12.8%)
总耗时 21:16 18:34 -2m 42s(-12.7%)
峰值 RSS 10.88 GB 9.73 GB -1.11 GB(-10.6%)
swap 峰值 0.79 GiB 0.40 GiB -49%
dist 295 MB 288 MB -7 MB
总 JS 60 MB 53 MB -7 MB(-11.7%)
antd-vue-vendor 2.06 MB 1.09 MB -47%
打包的图标数 421 82 -339

7. 图片压缩:为什么保持原方案

中途曾把图片编码器换成 sharp(理由是它任何平台零编译、且压缩率略好),并把压缩移出 Vite 进程。 最终已全部回退,原因:

  • 图片压缩与内存瓶颈无关:sharp 峰值仅约 375 MB,而构建瓶颈是 Rollup 的模块图(§4);
  • 原方案在内存问题解决后完全跑得动,没必要引入新依赖。

当前状态:build/vite/plugin/imagemin.ts 为原版 vite-plugin-imagemin@0.6.1, 在 generateBundle 内压缩,无缓存、无并发上限(原版行为)。

⚠️ 代价:安装需要编译原生二进制

vite-plugin-imagemin 依赖 5 个原生工具,不是 npm 预编译包:

工具 来源 本机补齐方式
gifsicle 1.92 包内 vendor/source/*.tar.gz ./configure --without-simd && make
jpegtran (libjpeg-turbo 1.5.1) 同上 同上
cjpeg (mozjpeg 3.2) 同上 同上
pngquant 2.12.0 同上 缺 libpng-dev 且无 sudo → apt-get download libpng-dev zlib1g-dev 解包到本地,用 libpng-config 垫片喂给 configure
optipng 0.7.7 同上 自带 configure(不接受 --libdir,需单独处理)

影响:全新 Linux 机器 pnpm install 可能因缺 gcc / make / autoconf / nasm / libpng-dev 而失败; Windows 基本装不上。若这成为问题,可改回 sharp 方案(纯下载、零编译,压缩率相当)。

已知行为:72 MB 的 src/assets/images/gif/fanlocal.gif 每次构建都会打一条 imagemin error(gifsicle 压不动),产物中保持原样 —— 这是原版行为。


8. 验证记录

项 结果
5 个原生二进制可执行 ✅ pngquant 2.12.0 / optipng 0.7.7 / gifsicle 1.92 / jpegtran 1.5.1 / cjpeg 3.2
PNG 压缩 ✅ 972 KB → 157 KB
JPEG 压缩 ✅ 16 KB → 5 KB
完整 pnpm build ✅ built in 17m 13s,峰值 RSS 9.73 GB,swap 0.40 GiB
最终产物 288 MB / 4494 文件 / JS 53 MB / 图片 84.1 MB
three.path 与 three 0.162 兼容性 ✅ 13 项几何用例全通过(见下)

three.path 兼容性专项

项目在 5 个三维风路文件里用 PathPointList / PathGeometry。因 pnpm.overrides 把 three 从 0.132.2 统一到 0.162.0,做了实跑验证(Node 里真实构造几何,不需要 WebGL):

  • 项目真实调用方式(两点直线 + set(points,0,0,up,false) + new PathGeometry(n,false) + update(...)) → ✅ 顶点 4 / 索引 6 / 包围盒与输入一致 / 无 NaN
  • 多段折线带圆角、三维斜线 → ✅
  • 它只用到 10 个 three 基础 API(BufferAttribute、BufferGeometry、DynamicDrawUsage、 StaticDrawUsage、Matrix4、QuadraticBezierCurve3、Uint16/32BufferAttribute、Vector3), r162 全部存在且签名未变

两个注意点:

  1. three.path 的 CJS/UMD 构建(main 字段)在现代 three 上会崩 (Class constructor BufferGeometry cannot be invoked without 'new',因为它是 ES5 转译的)。 Vite 走 module 字段用 ESM 构建,所以打包没问题;但 Jest / Node 脚本 require 它会挂。
  2. 它内部用了 BufferAttribute.updateRange(),r169 会被移除 —— 将来升 three 到 r169+ 需一并处理。
  3. npm 上 three.path 只有 1.0.0 / 1.0.1(均 2023-02),没有可升级版本。

9. 已知风险与后续可做

优先级 事项 预期收益
中 8 个内联巨型 SVG 组件(zhijiangSVG 1.30 MB、fanTunnelDualSVG 1.03 MB 等,合计 7.2 MB)外置到 public/ 或改动态加载 少 8 个巨型 AST,估 -300~500 MB
中 升级 Vite 5 + Rollup 4(原生 Rust 解析器,降低每模块开销) 峰值或降至 6~6.5 GB,渲染快 1.5~2.5×
低 docs 里记录的 sharp 方案(若安装编译成为痛点可切回) 安装零编译
— 需要人工冒烟的路径 4 个风机监控页(three.path 几何渲染效果)、主题浅色/深色切换

不要一步跳到 Vite 7:迁移面(unocss、vite-plugin-purge-icons、@rys-fe/vite-plugin-theme 等) 会成倍放大,而对构建内存的进一步帮助有限。


10. 附录:常用命令

# 常规构建
pnpm build

# 跳过 gzip 的快速构建
pnpm build:lean

# 必须用 pnpm 9(锁文件是 v9 格式)
npx pnpm@9 install
corepack enable pnpm && pnpm install   # 已写 packageManager,会锁定 9.15.9

# 校验依赖树是否一致(应输出 "Lockfile is up to date")
npx pnpm@9 install --frozen-lockfile

# 查看去重是否生效
npx pnpm@9 why echarts
ls node_modules/.pnpm | grep -E '^(echarts|three|xlsx)@'

# 清缓存(含图片缓存目录,若日后接入)
pnpm clean:cache

回滚点:本次改动都是小步可逆的 —— 堆上限/gzip 级别是单行数值;pnpm.overrides 删掉即可; registerGlobComp.ts 里保留了 drag-free 的还原注释。