别急着拆包:用用户旅程治理 Vite 构建成本
构建日志里的大 chunk 警告,很容易让人立刻开始拆包、加 manualChunks,甚至把整个 node_modules 塞进一个 vendor 文件。但这往往只是把"文件看起来大"的问题,替换成"用户仍然要下载、解析和执行大量代码"的问题。
对于典型的 Vite 单页应用,更值得治理的对象不是 dist 目录本身,而是关键用户旅程中的下载与执行成本:用户首次进入首页、登录后进入工作台、打开编辑器或图表页时,究竟请求了什么、传输了多少、主线程忙了多久。

先把"产物过大"拆成三个问题
同一个 JavaScript 文件至少有三种值得记录的尺寸或成本:
- 构建后的原始体积:适合发现异常增长。Vite 的 chunk 警告通常基于未压缩的 JavaScript 大小;它是构建阶段的粗略信号,不等同于用户实际下载量。
- 实际传输体积:用户从 CDN 收到的 gzip 或 Brotli 压缩后的字节数。它取决于服务器是否正确启用压缩、缓存是否命中,以及资源是否被重复请求。
- 解析、编译与执行成本:即使传输体积较小,浏览器仍需解压、解析、编译并执行脚本。启动阶段的大量 JavaScript 可能占用主线程,推迟可交互时间,并增加长任务,从而影响交互体验指标。
因此,不要用"入口文件从 800 KB 降到 300 KB"直接宣布优化成功。更可靠的结论应是:关键路由首次请求字节下降,关键请求链缩短,JavaScript 执行或长任务减少,并且真实用户指标没有恶化。
建立基线:先定义要保护的用户旅程
先选 2~4 条真正重要的路径,而不是只测首页。例如:
- 首次访问首页;
- 登录后进入工作台;
- 从列表进入详情;
- 点击"编辑""导出""查看图表"等重功能。
每条路径记录以下数据:
| 维度 | 要回答的问题 |
|---|---|
| 网络 | 首次访问传输了多少 JS、CSS、字体、图片?缓存后还需要多少? |
| 请求链 | 动态 chunk 是否在用户操作后才被发现,形成串行瀑布流? |
| CPU | 脚本解析、编译、执行和长任务是否阻塞交互? |
| 缓存 | 公共依赖变更是否导致大量稳定资源同时失效? |
| 体验 | LCP、INP、路由切换等待和错误率是否改善? |
Chrome DevTools 的 Network 面板可以同时观察传输大小与资源解压后的大小;Performance 面板则用于确认 JavaScript 执行、长任务和主线程阻塞。两者不能互相替代。测试时还应区分冷缓存、热缓存,以及接近真实设备和网络条件的场景。
从构建报告还原"为什么被带进来"
构建分析的目标不是找到最大的矩形,而是回答三个问题:它是什么、为什么被引入、是否应该在当前路径加载。
可以将 rollup-plugin-visualizer 接入仅在分析构建中启用的 Vite 配置,并打开 gzip 与 Brotli 尺寸统计。Treemap 适合快速定位大模块和疑似重复内容,Network 视图适合追溯模块之间的加载关系,而 raw-data、list 或 Markdown 报告适合提交到 CI,做版本间差异比对。具体输出选项应以所使用版本的插件文档为准。
排查时不要只看入口 JavaScript,还要盘点:
- 入口 chunk、共享 chunk 与异步 chunk;
- 多个版本同时存在的同类依赖;
- CSS、字体、图片和媒体资源;
public目录中被原样复制的文件;- source map;
- polyfill、初始化脚本和分析 SDK。
尤其要注意:小资源可能因为 Vite 的 assetsInlineLimit 被转换为 base64。它减少了请求数,却会增大引用它的 JavaScript 或 CSS;是否划算必须结合复用率、缓存策略和关键路径判断。
先处理"不该加载",再讨论"怎么拆"
拆包无法消除无用代码,只能改变它出现的时间。依赖分析中最常见的根因通常有下面几类。
整包导入与按需引入失效
确认业务代码是否为了一个函数、一个图标或一个日期格式化能力,引入了整个库的入口。命名导入并不天然等于按需:这取决于依赖是否提供可静态分析的 ESM 导出,以及其发布方式是否让打包器能够进行 tree-shaking。
CommonJS、动态访问与副作用边界
Tree-shaking 依赖静态分析。CommonJS 兼容层、运行时动态属性访问、难以分析的导出方式,都可能让无用代码保留。另一方面,也不要把 sideEffects 或模块副作用配置当作通用瘦身开关:样式导入、polyfill 和初始化模块可能确实需要执行,错误标记会造成线上功能缺失。
重复版本与重复能力
报告中出现两套日期库、两套图表基础库,或同一依赖的多个版本时,优先追溯锁文件、间接依赖和封装层,而不是先切 chunk。能够删除、替换或统一版本的依赖,通常比复杂的拆包策略更稳定。
静态资源和初始化代码被低估
首屏成本不只来自业务 chunk。字体、CSS 背景图、监控 SDK、国际化字典、polyfill 和应用初始化逻辑,也可能在页面可交互前占用网络或主线程。应把它们放回具体用户旅程中评估,而不是只看 JavaScript 总量。
按用户旅程划分加载边界
路由级拆分应是默认边界。 用户在首页时,通常不应下载设置页、管理后台、富文本编辑器或地图页面的完整代码。React、Vue 等框架的具体路由 API 不同,但原则一致:让路由组件通过动态 import() 成为异步边界。
ts
// 框架无关的示意:只有用户进入报表页时才请求重模块
async function openReport() {
const { mountReport } = await import('./features/report/mountReport')
mountReport()
}
组件级懒加载只用于明确的重功能。 常见对象包括:
- 富文本或代码编辑器;
- 图表、地图、3D 可视化;
- 导入导出、PDF 预览与文件处理;
- 低频弹窗、帮助中心和高级筛选;
- 首屏不可见且用户不一定触发的交互模块。
不要把每个普通按钮、卡片和小组件都动态导入。过细的边界会增加依赖链、请求调度和状态协调复杂度;对于高概率、首屏必需的小模块,静态导入往往更直接。
动态 import() 返回 Promise,因此懒加载设计必须包含三种界面状态:加载中、加载失败、重试。Vite 在动态导入预加载失败时会触发 vite:preloadError 事件,可用于统一记录错误并设计刷新或重试策略。模块成功加载后通常会被模块系统缓存,但点击事件仍应防抖,或复用进行中的 Promise,避免重复挂载和重复状态初始化。
如果用户即将明确进入某个重功能,可以在空闲时间、悬停或满足业务条件时进行有边界的预取;但预取不应把所有异步模块重新变成首屏负担。
拆得更碎,为什么可能反而更慢
代码分割的收益来自"少发不需要的代码",不来自"chunk 数量更多"。每新增一个异步边界,都要检查四个副作用:
- 瀑布流:入口加载完才发现异步模块,而异步模块又依赖多个共享 chunk,用户会经历多轮请求发现。
- 预加载竞争:Vite 会根据构建结果为动态导入生成模块预加载关系,以减少部分串行请求;但过度预加载会与图片、字体、关键 CSS 等资源竞争带宽。
- 异步 CSS:Vite 默认开启 CSS 代码分割。异步 JavaScript 引入的 CSS 会作为独立资源随相关 chunk 获取,因此一次"拆 JS"可能同时改变 CSS 请求链。
- 缓存边界失控:把变化频繁的业务代码和稳定的大依赖捆在一起,会让稳定依赖随着业务发布反复失效;反过来,过度拆分也会产生大量小资源,增加请求与调度成本。
评估拆分方案时,至少对比首次访问、二次访问、跳转到目标路由、触发重功能这四种场景的网络瀑布图,而不是只比较某一个 chunk 的体积。
让 manualChunks 服务于稳定边界
在项目确实需要显式控制 Rollup 兼容配置时,manualChunks 的价值是控制共享依赖和缓存边界,例如将跨多个重页面复用、更新频率低且体积明显的大依赖隔离出来。
但它不是"把所有 node_modules 放进 vendor"的固定答案:
- 一个巨型 vendor 可能重新抬高首屏下载;
- 函数形式的手动分包可能连同依赖一起被合并,结果与直觉不同;
- 构建器或 Vite 版本升级可能改变默认合并行为;
- 不合理的公共 chunk 会让本不相关的路由互相牵连。
Vite 不同版本及其底层构建器对配置入口和默认分包行为可能存在差异。使用 manualChunks 前,应以当前项目版本的官方文档和类型定义为准,并把"配置变更、构建结果差异报告、关键路由瀑布图"作为同一个验收项,而不是直接复制旧项目的配置片段。
一套可重复执行的排查闭环
将优化过程固定为下面六步:
- 复现:在接近真实设备和网络条件下录制关键路径,保留构建产物清单与 Network/Performance 记录。
- 定位:通过可视化报告找出大模块、重复依赖和引入链;通过 Network 找出真实传输与请求瀑布。
- 提出假设:例如"编辑器被首屏静态引入""两个路由各自带入同版本库""共享 chunk 导致首页预加载了报表依赖"。
- 最小改动:优先删除或替换不必要依赖,再引入路由级或组件级动态导入;只有共享边界明确时才调整手动分包。
- 重新验证:重新构建并对比报告,同时检查首次、缓存后和目标功能触发时的请求链与执行时间。
- 上线回看:结合真实用户监测观察 LCP、INP、长任务、资源错误率和缓存命中。若只改善实验室体积,却让目标交互等待变长,就应回退或重设边界。
把一次性瘦身变成工程制度
最有效的治理不是规定"bundle 必须小于某个固定 KB 数",而是为关键路由建立分层预算:首访与回访分别限制脚本、样式、图片和字体的传输成本,并对交互延迟或长任务设置告警阈值。
在 CI 中保存构建差异报告;依赖升级、引入编辑器、图表或监控 SDK 等变更一旦超预算,就要求说明新增的用户价值、受影响路由和补偿方案。这样,性能讨论才能从"构建日志变红了"升级为"这次发布是否增加了用户的下载与执行负担"。
压缩、关闭公开 source map、提高警告阈值都可能是发布策略的一部分,但它们不能替代依赖治理与加载边界设计。真正的目标始终是:把用户当前不需要的代码留在未来需要它的时刻,而把当前必须运行的代码保持足够少、足够快、足够稳定。