别盯着 dist:Vite 应用的下载与执行成本治理闭环

别急着拆包:用用户旅程治理 Vite 构建成本

构建日志里的大 chunk 警告,很容易让人立刻开始拆包、加 manualChunks,甚至把整个 node_modules 塞进一个 vendor 文件。但这往往只是把"文件看起来大"的问题,替换成"用户仍然要下载、解析和执行大量代码"的问题。

对于典型的 Vite 单页应用,更值得治理的对象不是 dist 目录本身,而是关键用户旅程中的下载与执行成本:用户首次进入首页、登录后进入工作台、打开编辑器或图表页时,究竟请求了什么、传输了多少、主线程忙了多久。

先把"产物过大"拆成三个问题

同一个 JavaScript 文件至少有三种值得记录的尺寸或成本:

  1. 构建后的原始体积:适合发现异常增长。Vite 的 chunk 警告通常基于未压缩的 JavaScript 大小;它是构建阶段的粗略信号,不等同于用户实际下载量。
  2. 实际传输体积:用户从 CDN 收到的 gzip 或 Brotli 压缩后的字节数。它取决于服务器是否正确启用压缩、缓存是否命中,以及资源是否被重复请求。
  3. 解析、编译与执行成本:即使传输体积较小,浏览器仍需解压、解析、编译并执行脚本。启动阶段的大量 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-datalist 或 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 数量更多"。每新增一个异步边界,都要检查四个副作用:

  1. 瀑布流:入口加载完才发现异步模块,而异步模块又依赖多个共享 chunk,用户会经历多轮请求发现。
  2. 预加载竞争:Vite 会根据构建结果为动态导入生成模块预加载关系,以减少部分串行请求;但过度预加载会与图片、字体、关键 CSS 等资源竞争带宽。
  3. 异步 CSS:Vite 默认开启 CSS 代码分割。异步 JavaScript 引入的 CSS 会作为独立资源随相关 chunk 获取,因此一次"拆 JS"可能同时改变 CSS 请求链。
  4. 缓存边界失控:把变化频繁的业务代码和稳定的大依赖捆在一起,会让稳定依赖随着业务发布反复失效;反过来,过度拆分也会产生大量小资源,增加请求与调度成本。

评估拆分方案时,至少对比首次访问、二次访问、跳转到目标路由、触发重功能这四种场景的网络瀑布图,而不是只比较某一个 chunk 的体积。

manualChunks 服务于稳定边界

在项目确实需要显式控制 Rollup 兼容配置时,manualChunks 的价值是控制共享依赖和缓存边界,例如将跨多个重页面复用、更新频率低且体积明显的大依赖隔离出来。

但它不是"把所有 node_modules 放进 vendor"的固定答案:

  • 一个巨型 vendor 可能重新抬高首屏下载;
  • 函数形式的手动分包可能连同依赖一起被合并,结果与直觉不同;
  • 构建器或 Vite 版本升级可能改变默认合并行为;
  • 不合理的公共 chunk 会让本不相关的路由互相牵连。

Vite 不同版本及其底层构建器对配置入口和默认分包行为可能存在差异。使用 manualChunks 前,应以当前项目版本的官方文档和类型定义为准,并把"配置变更、构建结果差异报告、关键路由瀑布图"作为同一个验收项,而不是直接复制旧项目的配置片段。

一套可重复执行的排查闭环

将优化过程固定为下面六步:

  1. 复现:在接近真实设备和网络条件下录制关键路径,保留构建产物清单与 Network/Performance 记录。
  2. 定位:通过可视化报告找出大模块、重复依赖和引入链;通过 Network 找出真实传输与请求瀑布。
  3. 提出假设:例如"编辑器被首屏静态引入""两个路由各自带入同版本库""共享 chunk 导致首页预加载了报表依赖"。
  4. 最小改动:优先删除或替换不必要依赖,再引入路由级或组件级动态导入;只有共享边界明确时才调整手动分包。
  5. 重新验证:重新构建并对比报告,同时检查首次、缓存后和目标功能触发时的请求链与执行时间。
  6. 上线回看:结合真实用户监测观察 LCP、INP、长任务、资源错误率和缓存命中。若只改善实验室体积,却让目标交互等待变长,就应回退或重设边界。

把一次性瘦身变成工程制度

最有效的治理不是规定"bundle 必须小于某个固定 KB 数",而是为关键路由建立分层预算:首访与回访分别限制脚本、样式、图片和字体的传输成本,并对交互延迟或长任务设置告警阈值。

在 CI 中保存构建差异报告;依赖升级、引入编辑器、图表或监控 SDK 等变更一旦超预算,就要求说明新增的用户价值、受影响路由和补偿方案。这样,性能讨论才能从"构建日志变红了"升级为"这次发布是否增加了用户的下载与执行负担"。

压缩、关闭公开 source map、提高警告阈值都可能是发布策略的一部分,但它们不能替代依赖治理与加载边界设计。真正的目标始终是:把用户当前不需要的代码留在未来需要它的时刻,而把当前必须运行的代码保持足够少、足够快、足够稳定。

参考资料

相关推荐
前端_刘师兄1 小时前
前端开发工程师转FAE工程师路线规划
前端
禁止摆烂_才浅1 小时前
前端 AI 面试题
前端·面试·ai编程
程序员老赵1 小时前
Docker 部署 ZLMediaKit:轻松搭建高性能流媒体服务平台
前端·javascript·后端
Liora_Yvonne1 小时前
为什么每次发版,总有用户看到白屏?
前端
风月说与山鬼1 小时前
四、浏览器存储
前端·vue.js
hunterandroid1 小时前
[Android 从零到一] ViewPager2 与 Fragment 生命周期协同:从预加载到状态一致性
android·前端
拾年2751 小时前
前后端分离开发,别再"傻等"后端接口了——聊聊前端接口工程
前端·javascript·react.js
liuxiaocheng1 小时前
上下文工程:LangChain 怎么组织「喂给模型的东西」
前端·后端
breeze jiang1 小时前
CSS 三栏布局怎么写:Flex、Grid 完整方案与 BFC 区别
前端·css