主题
甘肃托育三端 webpack 构建优化记录
日期:2026-08-14 范围:pc(政府端)/ medicine_pc(医疗端)/ sass(SaaS 平台端) 技术栈:vue-cli 4.4.6(webpack 4)+ Vue 2 + Element-UI
一、背景与诉求
- 政府/医疗端构建产物碎片 chunk 太多(近 40+,每个几十 KB),大量串行排队
- 领导要求:
- 合并碎片 chunk
- 登录页请求控制在 7-8 个
- 构建时预压缩(gzip),降低请求时服务器实时压缩开销
二、排查过程(基线分析)
对 pc 跑 vue-cli-service build --mode prod.gs --report,基线结论:
| 指标 | 基线值 |
|---|---|
| JS 总数 | 87 个 |
| 总体积 | 16 MB |
| 登录页首屏 initial | 3 个(chunk-libs + chunk-elementUI + app) |
| chunk-libs | 8327 KB(8.1 MB) ← 真正的瓶颈 |
| gzip 预压缩 | 0 个 |
关键认知(纠正了最初的判断)
- 登录页首屏只有 3 个请求,不是 40。所谓"40 个碎片串行"是访问业务页时按需加载路由 chunk 的现象,不是登录页首屏。
- 真正的瓶颈是
chunk-libs单文件 8.1 MB(登录页首屏光这一个文件就 8 MB)。 - 路由用
(resolve) => require(["@/views/xxx"], resolve)旧式异步,96 条路由 → 96 个路由 chunk(碎片来源)。 - 路由碎片配置层合并不了:
require异步每条路由一个 chunk,splitChunks 只能提取公共依赖、合并不了独立路由 chunk。 @/views共 248 个.vue,constantRoutes 用 64 个,剩 184 个是 loadView 动态路由(后端菜单)。
三、配置层优化(三端一致)
1. splitChunks 重构
js
config.optimization.splitChunks({
chunks: 'all',
maxInitialRequests: 6, // ⚠️ 默认 3 太低,会阻止 vendor 分组(见下方踩坑)
maxAsyncRequests: 6,
minSize: 30000, // 默认值(30KB)
maxSize: 0, // 默认值(0=不二次拆分)
cacheGroups: {
elementUI: { test: /element-ui/, priority: 25 }, // element-ui
echarts: { test: /(echarts|zrender)/, priority: 20 }, // 图表(async 公共 vendor)
docs: { test: /(xlsx|jspdf|pdfjs-dist)/, priority: 20 }, // 文档/导出大库
libs: { test: /node_modules/, chunks:'initial', priority: 10 }, // 兜底 initial node_modules
commons: { test: resolve('src/components'), minChunks: 2, priority: 5 }
}
})分组原则:只给 echarts/xlsx/jspdf 这类大库单独分组,小库(quill/vue-core 几百 KB)合并进 libs 兜底——否则分组太多会占满 initial 名额、挤掉 libs。
2. gzip 预压缩
- pc/medicine 补装
compression-webpack-plugin@6.1.2(webpack 4 兼容版,sass 已有) - 配置:
threshold: 51200(50 KB)、只压js/css/html/svg、deleteOriginalAssets: false
3. 修 sass 错误配置
- 删
minSize: 1000000(1 MB 太大,导致 vendor 提取失效、被打散进各路由) - test 去掉图片(jpg/png/gif 本身已压缩,gzip 无收益还多生成无用
.gz)
⚠️ 踩坑:maxInitialRequests 默认 3
webpack 4 的 maxInitialRequests 默认是 3(entry 首屏并发上限)。当 cacheGroup 分组数 + app 超过 3,低 priority 的分组被放弃,模块全塞回 app.js:
- 现象:splitChunks 加了多个分组后,
app.js从 489 KB 暴涨到 8 MB,chunk-libs不生成 - 正解:
maxInitialRequests提到覆盖分组数的值(本项目用 6)
四、最终数据
| 端 | app.js | chunk-libs | .gz 数(改前→改后) | 首屏 initial |
|---|---|---|---|---|
| pc 政府端 | 492 KB | 6952 KB | 54 → 12 | 5 |
| medicine 医疗端 | 476 KB | 7048 KB | 17 → 11 | 5 |
| sass SaaS 端 | 1412 KB | 7452 KB | 17 → 7 | 5 |
成果
- gzip 预压缩:三端大文件全部
.gz,首屏 vendor(app/elementUI/echarts/docs/libs)全覆盖,省服务器实时压缩 CPU - vendor 分离:echarts/docs/elementUI 独立 chunk,业务发版不连带失效这些库缓存
- app.js 瘦:业务代码与 vendor 分离(pc 492 KB / medicine 476 KB)
- threshold 50 KB:
.gz数大减(只压大文件),<50 KB 小文件走 nginx 实时 gzip(开销可忽略) - 登录页 6 个请求(5 initial + login chunk)
五、部署必做
nginx 配 gzip_static on;——不配的话构建出的 .gz 不会被发送,nginx 仍走实时压缩,预压缩白做。
nginx
gzip_static on;六、评估后放弃的(没做,记录原因)
1. 路由合并(方案 A:require → import + webpackChunkName)
- 实测引入问题:
() => import()在 vue-cli 4 + webpack 4 这套里异常合并——74 个路由 import 被合成 2 个巨型 chunk(1.67 MB + 276 KB),且webpackChunkName: "group-xxx"注释完全失效(chunk 名是 hash 不是 group-),app.js暴涨 1.6 MB - 已干净回退(router/index.js 恢复 require,重建确认 app 492 KB / 87 js / 54 gz 全恢复)
- 结论:
require是这个项目唯一能正常代码分割的写法,不要改 import(babeldynamic-import-node只在 dev 启用,生产仍异常,根因疑似 vue-cli4+webpack4 对import(/* chunkName */)的处理问题)
2. loadView 动态路由合并
loadView = (view) => require([\@/views/${view}`])`,路径是后端返回的变量/* webpackChunkName */只认静态字符串 +[request]占位符,不能用变量 → 184 个动态路由页面注定各自独立 chunk,合不了
3. echarts 改 async(真正减首屏体积)
- echarts 被
@/components/Echarts全局组件引用 → 注定 initial(首屏加载),配置层chunks:'async'无效(实测退回 chunk-libs) - 要减首屏必须动业务代码(全局组件改懒加载)
- ⚠️ 更正:docs(xlsx/jspdf)后来实测能
chunks:'async'出首屏(只被 2 个页面引用,属"少 chunk"情况);echarts / quill / @vue-office(多页面共享)才不能——详见第八章「各 SDK 能否 async」结论表
七、经验沉淀
maxInitialRequests 默认 3 的坑 + splitChunks 默认值速查 + gzip 配置要点,已写入 ~/.claude/rules/engineering.md 的「splitChunks 分组与 maxInitialRequests 陷阱(webpack4)」小节,避免以后重蹈。
八、深挖第二个问题:chunk-libs 拆分 / @vue-office 不进登录页(2026-08-14 续)
背景
领导进一步要求:chunk-libs(7.6M,首屏大头)继续拆,让 @vue-office 等大 SDK 不进登录页,每个路由只加载自己需要的代码。
排查:chunk-libs 大头到底是谁
vue-cli 的 --report(webpack-bundle-analyzer)只分析 src,不含 node_modules——chunk-libs 全是 node_modules,report 根本看不到。
改用 webpack stats:vue.config.js 临时加一个 inline plugin,构建时把 stats.toJson() 写成 stats.json,再 node 分析 chunk-libs 的模块:
js
new (class WriteStats {
apply(compiler) {
compiler.hooks.done.tap('WriteStats', stats => {
require('fs').writeFileSync('stats.json', JSON.stringify(
stats.toJson({ all: false, chunks: true, modules: true, chunkModules: true, source: false })
));
});
}
})()chunk-libs(7656KB)真实构成 top:
| 模块 | 体积 |
|---|---|
| @vue-office/pdf | 2732 KB |
| @vue-office/excel | 1629 KB |
| @vue-office/pptx | 1345 KB |
| @vue-office/docx | 171 KB |
| @vue-office 合计 | 5877 KB(chunk-libs 的 77%) |
| html2canvas | 431 KB |
| quill | 429 KB |
| 其余(vue-core/jquery/vue-amap/...) | ~920 KB |
@vue-office 全家桶(文件预览)是首屏大头,登录页根本用不到。 它通过 @/views/components/filePreview 全局注册进了 main.js 链路 → initial。
五种尝试(记录成败,避免重试)
| # | 方案 | 结果 |
|---|---|---|
| 1 | filePreview 整体异步注册(Vue.component('filePreview', () => import(...))) | ❌ @vue-office 没出 chunk-libs |
| 2 | filePreview 内部 @vue-office 改异步组件(components: { VueOfficeDocx: () => import('@vue-office/docx') }) | ❌ webpack4 不识别 Vue SFC 同步注册组件里的 components import(),@vue-office 还在 chunk-libs |
| 3 | filePreview 取消全局、21 个页面改局部 import | ⚠️ filePreview 组件出首屏(小赚),但 @vue-office 还是没出 |
| 4 | vueOffice cacheGroup chunks:'all'(priority 30) | ⚠️ @vue-office 出 chunk-libs 了(7656→1812),但单独的 chunk-vue-office(5848)进了首屏 |
| 5 | vueOffice cacheGroup chunks:'async' | ❌ @vue-office 退回 chunk-libs(7656),chunk-vue-office 根本没生成 |
根因:webpack4 对 node_modules 的硬限制
webpack4 splitChunks 对 node_modules vendor 有强制 initial 兜底:
chunks:'all'→ 提取出来的 vendor chunk 算首屏chunks:'async'→ 拒绝提取,退回 chunk-libs
两条路都出不了首屏。webpack5 才修了 chunks:'async' 对 node_modules 的支持。
关键区别:路由的
() => import("@/views/...")(src)能正常分割(产物 80+ chunk),但全局注册组件里的() => import('node_modules/...')(node_modules)不行——webpack4 对 src vs node_modules 的动态 import 区别对待。
结论:各 SDK 到底能不能改 async(不首屏)
基于本次实测(webpack4 / vue-cli 4.4.6):
| SDK | 引用方式 | webpack4 能 async? | 原因 |
|---|---|---|---|
| docs(xlsx/jspdf) | 2 个页面用(少 chunk) | ✅ 能 | chunks:'async' 成功提取、出首屏 |
| echarts | 17 个图表组件用(多页面共享) | ❌ 不能 | chunks:'async' 退回 chunk-libs(多 chunk 共享,vendors 兜底 initial) |
| quill | Editor 全局注册(多页面用) | ❌ 不能 | Editor 改异步也没用,quill 退回 chunk-libs |
| @vue-office | filePreview 全局 + 21 页面 | ❌ 不能 | 5 种方式全失败(整体异步/组件异步/局部注册/chunks:all/chunks:async) |
通用规则(webpack4,实测):
- node_modules 大库被全局组件或多页面共享引用 → 拆不出首屏(
chunks:'all'算首屏 /chunks:'async'退回 chunk-libs) - node_modules 大库被少数 chunk引用 →
chunks:'async'可出首屏(如 docs) - src 模块(路由
() => import("@/views/..."))→ 正常分割,不受此限制
一句话:webpack4 下,凡是"全局注册组件里的 node_modules 大库"(echarts/quill/@vue-office 这类),配置层和业务层都拆不动它出首屏。要让它们真正按需加载,唯一出路是升级 webpack5(chunks:'async' 对 node_modules 生效,不管共享数)。
最终方案(webpack4 下的折中)
sass 接受 @vue-office 首屏(拆不动),保留:
- filePreview 改 21 页面局部注册(filePreview 组件跟着页面走、出首屏,
$refs.filePreview.open()不受影响,真机验证 OK) - vueOffice cacheGroup 删除(不让 chunk-vue-office 进首屏)
- @vue-office 留在 chunk-libs 首屏,但 gzip 已把传输砍 72%(7656→2179KB)
pc/medicine 同病同因(filePreview 也全局注册、@vue-office 也首屏),领导说只搞 sass,这俩没动。
三端实测(领导验证,修复前后)
| 端 | 请求数 | 已传输 | 加载时间 | 最大单文件 | |
|---|---|---|---|---|---|
| 政府端 pc | 前 | 59 | 11.3 M | 2.1 s | 2.8 M |
| 后 | 21 | 12.3 M | 1.6 s | 2.8 M | |
| 机构端 sass | 前 | 18 | 11.8 M | 2.8 s | 8.9 M |
| 后 | 19 | 11.9 M | 2.0 s | 7.8 M | |
| 医疗机构 medicine | 前 | 66 | 11.4 M | 1.9 s | 2.8 M |
| 后 | 25 | 14.4 M | 2.3 s | 7.2 M |
观察:
- pc/medicine 请求数大减(59→21、66→25):threshold 50KB + splitChunks vendor 分离,首屏请求结构精简
- sass 加载时间明显快(2.8→2.0s):gzip 预压缩省了服务器实时压缩开销
- @vue-office 首屏体积未减(webpack4 限制),但 gzip 已减传输
- medicine 最大单文件 2.8→7.2M:splitChunks 后 chunk-libs 集中了 vendor(之前分散)
唯一真正让 @vue-office 出首屏的出路
升级 webpack5(chunks:'async' 对 node_modules 生效)。大工程,需评估。
九、webpack5 升级评估(让 echarts/quill/@vue-office 出首屏的唯一出路)
为什么要考虑升级
第八章结论:webpack4 下 echarts/quill/@vue-office(全局组件里的 node_modules 大库)拆不出首屏。webpack5 修了 chunks:'async' 对 node_modules 的支持,才能让它们真正按需加载。
升级路径
vue-cli4 绑死 webpack4,不能单独换。三条路:
| 路径 | 改动量 | 保留 Vue 2.6? | 推荐度 |
|---|---|---|---|
| vue-cli5(@vue/cli-service 5,内置 webpack5) | 中 | ✅ 保留 | ⭐ 推荐 |
| Vite(替代 webpack) | 大(重写构建) | 需 vite-plugin-vue2 | 高风险 |
| 手动 webpack5(脱离 vue-cli) | 最大 | ✅ | 不推荐 |
改动点(vue-cli5 路径)
- 依赖升级:@vue/cli-service + @vue/cli-plugin-*
4.4.6 → 5.x;webpack4 → 5;sass-loader10 → 12+;compression-webpack-plugin6 → 10+(6.x 只支持 webpack4) - Node polyfill(最大坑):webpack5 不再自动 polyfill node 核心(crypto/buffer/process/path)。项目里
import crypto from 'crypto'(pc login.vue)、crypto-js/jsencrypt 等若用到 node API,要手动配resolve.fallback+ 装 polyfill 包(crypto-browserify 等),逐个验证 - vue.config.js breaking:
devServer.before→setupMiddlewares;chainWebpack 部分 API 变 - splitChunks:webpack5
chunks:'async'对 node_modules 生效(目标达成)+maxAsyncRequests默认5 → 30 - asset modules:file-loader/url-loader → webpack5 asset modules(vue-cli5 兼容,基本透明)
改动量:中到大
- 依赖升级 +
npm i重装 - Node polyfill 配置(crypto 包 / crypto-js / jsencrypt 逐个验)
- vue.config.js 适配(devServer / chainWebpack)
- 三端全功能回归:构建 + 运行时,加密(登录)/上传/图表/地图/文件预览全过一遍
主要风险
- Node polyfill:
crypto包 / crypto-js 报错(最大不确定性,必须实测) - 插件/loader 版本不兼容(html-webpack-plugin 5、compression-webpack-plugin 10 等)
- 运行时功能挂(polyfill 缺失导致加密/解密挂)
收益
- ✅ echarts/quill/@vue-office 真正出首屏(本次目标达成)
- 构建更快(webpack5 持久缓存 filesystem)
- 包更小(tree-shaking 改进、未用代码剔除更狠)
- 长期维护(webpack4 已 EOL,vue-cli4 停维)
建议
- 不建议为"SDK 出首屏"单独升级:改动大、风险高,而 gzip 已把 chunk-libs 传输砍 72%(7656→2179KB),首屏实际传输可控——ROI 不值
- 若项目要长期维护 + 上 webpack5 生态:可规划 vue-cli5 升级,排期 Node polyfill 验证 + 全功能回归(粗估 2~5 人天,看 polyfill 坑的深浅)
- 短期:保持 webpack4 + 接受 @vue-office 首屏,是当前性价比最高的选择
十、gzip 三层配合 + 浏览器怎么判断生效
三个角色(别混淆)
| 角色 | 什么时候 | 干啥 | 谁的 CPU |
|---|---|---|---|
| CompressionPlugin(webpack 构建) | 打包时 | 把 >50KB 的 js/css 压成 .gz 放产物 | 构建机(一次性) |
nginx gzip_static on(请求时) | 收到请求 | 同目录有 .gz 就直发它 | 零(用现成的) |
nginx gzip on(请求时兜底) | 收到请求 | 没 .gz 的文件实时压 | 服务器(小文件,开销小) |
nginx 必须配两条(缺一不可)
nginx
gzip_static on; # 有 .gz 直发(预压缩,省 CPU) ← 不配则 .gz 白生成
gzip on; # 兜底:没 .gz 的(小文件)实时压两种错误配法
- 只
gzip on、没gzip_static on:CompressionPlugin 生成的.gz白生成,nginx 每次请求仍实时压原文件 → "省实时压缩 CPU"目标达不成(最常踩的坑) - 只 CompressionPlugin、nginx 啥都不配:
.gz不发、原文件也不压 → 大文件全量传输,首屏爆炸
浏览器怎么判断 gzip 生效(content-Encoding)
基础判断(传输有没有压缩):
- F12 → Network → 点任一 js/css → Response Headers → 看到
Content-Encoding: gzip= 该响应被 gzip 压缩传输(首屏传输减 70%+) - 只要看到
content-Encoding: gzip,就说明浏览器收到的是压缩版、gzip 这层优化生效了
但 content-Encoding: gzip 区分不了:
- 预压缩(
gzip_static发 .gz)vs 实时压缩(gzip现场压)——两种响应头都是content-Encoding: gzip,浏览器看上去完全一样 - 即:光看响应头,无法知道服务器是"发预压缩的 .gz"还是"现场压的"
要确认 gzip_static(预压缩)真生效(服务器省 CPU):
- 看
Content-Length≈ 产物里.gz文件的大小(gzip_static 直发 .gz,大小一致) - 或看服务器 CPU 占用(gzip_static 低、实时 gzip 高)
- 或 nginx 加自定义
add_header X-Gzip-Static hit;(手动标记,gzip_static 自身不发)
一句话:浏览器 content-Encoding: gzip 只能确认"首屏传输减了"(用户侧收益到手);服务器到底省没省 CPU(gzip_static vs 实时)得看服务器端配置和负载。
附:splitChunks 默认值速查(webpack 4)
| 参数 | 默认值 | 含义 |
|---|---|---|
chunks | 'async' | 默认只对懒加载 chunk 生效 |
minSize | 30000(30 KB) | 低于此不单独提取、并入父 chunk |
maxSize | 0 | 0 = 不二次拆分(maxSize 是"超过就拆",与合并相反) |
minChunks | 1 | 至少被几个 chunk 引用才提取 |
maxAsyncRequests | 5 | 异步加载最大并发请求数 |
maxInitialRequests | 3 | 入口首屏最大并发请求数(本项目提到 6) |