首屏在替谁付费:重做 Vite 产物治理的责任边界
当 CI 报出"chunk 大于阈值",或 dist/assets 中出现一个醒目的大文件时,团队很容易直接打开 manualChunks,按 node_modules、框架名或目录名强行切包。
这通常是一次过早优化。
构建产物过大真正要回答的问题是:某次访问中,用户为什么要在这个时刻下载、解析、编译并执行这些代码?
同样是 500 KB JavaScript:
- 如果它只在用户进入报表页并点击"高级分析"后才加载,未必是首屏问题;
- 如果它被首页入口静态引用,即使经过 Brotli 压缩,浏览器仍要承担解压后的解析、编译与执行成本;
- 如果它被拆成几十个低复用的小 chunk,也可能把单个大包问题变成请求链、调度和交互等待问题。
因此,Vite 产物治理不应以"消灭大 chunk"为目标,而应以让每份代码在正确的运行时边界加载 为目标。压缩大小主要反映网络传输成本;浏览器处理 JavaScript 时仍要承担解压、解析、编译与执行成本。web.dev

一、先建立三张账:不要只盯 dist 文件大小
1. 构建输出账:产物里有什么
关注以下内容:
- 每个 JS、CSS、图片、字体和 WASM 文件的原始大小;
- source map 是否被部署到公开静态目录;
- 入口 chunk、动态入口 chunk 与共享 chunk 的关系;
- 哪些资源被内联进 JavaScript 或 CSS;
- 是否存在同一依赖的多个版本。
它回答的是:发布物里有哪些东西。
但它不能直接回答用户首屏下载了哪些资源。一个 1 MB 的编辑器 chunk 可能不在首页请求链中;一个 180 KB 的共享 chunk,却可能被首页每次访问都下载。
2. 网络传输账:用户实际收到了什么
要区分原始大小、压缩后大小、请求数、优先级、缓存命中和请求瀑布:
- raw size 适合盘点部署成本与未压缩代码规模;
- gzip 或 Brotli size 更接近网络传输成本;
- 冷缓存与热缓存应分开测量;
- 首屏关键请求链通常比所有资源总和更重要。
把稳定的图表库独立成带哈希的长期缓存 chunk,可能增加首次进入图表页的请求,却减少后续访问的重复下载。这个结果不能只用"请求数更少"或"文件数更少"判断。
3. 运行时账:主线程为谁工作
JavaScript 的成本不止下载。代码到达浏览器后,还会经历解压、解析、编译与执行。验收时应观察:
- 首屏渲染前是否存在长任务;
- LCP 前是否执行了与首屏无关的重模块;
- 点击某项功能后是否因懒加载出现明显等待;
- 关键路由首次访问与再次访问的传输量是否合理;
- INP 和业务操作完成时间是否真的改善。
判断单位应从"文件"切换为"访问场景"。首页、订单详情页、报表页、富文本编辑和地图查看,应分别拥有不同的加载责任账本。
二、先分清 Vite 的两条链路:开发快,不等于生产包小
依赖预构建主要服务于开发模式。Vite 会将依赖转换成更适合开发服务器按 ESM 提供的形式,以减少浏览器在开发时面对大量依赖内部模块请求的负担。它不等同于生产构建,也不是生产包瘦身的首选开关。Vite:依赖预构建
因此:
- 开发服务器启动快、HMR 迅速,不代表首屏产物一定小;
optimizeDeps主要解决开发依赖预构建问题;- 生产问题应从入口、静态导入、动态导入、共享依赖和资源输出结构入手。
还要确认项目版本。采用 Rollup 的项目通常使用 build.rollupOptions;采用 Rolldown 的 Vite 版本或上层框架,可能使用 build.rolldownOptions。不同版本和框架封装的配置入口并不完全相同,不能直接复制网上的分包片段。Vite:生产构建
三、从"最大模块"追到"为什么此刻加载"
第一步:按访问场景列出入口
| 场景 | 用户何时需要 | 应承担的资源 |
|---|---|---|
| 首页首屏 | 首次打开 | 应用壳、首屏业务组件、必要状态与样式 |
| 订单详情 | 路由进入后 | 详情模块及该页特有依赖 |
| 图表分析 | 进入分析页或触发分析操作后 | 图表引擎、数据处理和导出能力 |
| 富文本编辑 | 新建或编辑内容后 | 编辑器内核、插件和语言包 |
| 地图定位 | 进入地图功能后 | 地图库、地图样式和地理数据能力 |
没有访问场景,所谓分包策略往往只是在重新摆放文件。
第二步:用 manifest 还原资源关系
诊断阶段可以开启 build.manifest。Manifest 能记录入口、动态入口、普通 chunk、静态依赖、动态依赖以及关联的 CSS 和静态资源,适合还原入口最终牵出的资源。Vite:后端集成
重点检查:
- 首页入口静态依赖了哪些 chunk?
- 哪些只属于特定路由的模块进入了首屏依赖链?
- 某个异步模块是否又拉起了一大段共享依赖?
- 某个 chunk 是否同时附带大 CSS、字体或图片?
第三步:用可视化报告定位归因
rollup-plugin-visualizer 的 Treemap 或 Sunburst 适合回答"什么最大、哪里可能重复";Network 视图则更适合回答"为什么这个模块被包含"。它还可以输出 raw data、列表或 Markdown 报告,便于在 CI 中追踪变化。rollup-plugin-visualizer
ts
import { defineConfig } from 'vite'
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
visualizer({
filename: 'reports/bundle-stats.html',
template: 'treemap',
gzipSize: true,
brotliSize: true,
open: false,
}),
],
build: {
manifest: true,
},
})
可视化图中的面积不能直接等同于 CDN 的实际传输字节数。最终仍要结合构建输出、压缩结果和浏览器 Network 瀑布验证。
第四步:固定追问两个问题
对每个异常模块或 chunk,分别问:
- **为什么被引入?**是全量导入、barrel file 转发、重复依赖,还是共享组件静态引用了重型能力?
- **为什么现在加载?**它真的属于首屏,还是只属于低频路由或用户点击后的功能?
前一个问题决定删除、替换或缩窄导入;后一个问题决定静态加载、路由拆分、功能拆分或延后加载。
四、依赖治理优先于懒加载
把重型库改成动态导入,确实能让它离开首屏;但如果用户最终一定会使用它,只是改变了付款时间。更优先的顺序通常是:先确认功能是否必要,再缩小导入边界,最后才考虑延迟加载。
Tree Shaking 依赖构建工具对模块导入导出关系进行静态分析。清晰的 ESM 边界、可分析的导出和正确的副作用声明,有助于删除未使用代码。web.dev:Tree Shaking
ts
// 可能扩大依赖图
import * as utils from './utils'
// 是否可摇树取决于库的导出设计
import utilityLibrary from 'utility-library'
// 更明确地导入实际需要的能力
import { formatPrice } from './utils/formatPrice'
import debounce from 'utility-library/debounce'
优先检查:
- 图标库、日期库和语言包的全量导入;
- 只用一个 locale 却注册全部 locale;
- 组件库的聚合入口;
- CommonJS 兼容层导致的保守打包;
- 具有副作用的模块;
- monorepo 中同一依赖的多个版本。
公共入口并非不能使用,但不能让调用方无意中扩大依赖边界。一个 index.ts 同时转发轻量工具和编辑器内核时,应确认轻量调用是否意外携带了重模块。
依赖决策可以按以下顺序进行:
- 删除:功能是否仍在使用?
- 替换:原生 API 或更小的实现是否足够?
- 缩窄导入:能否使用子路径或具名导入?
- 去重:是否存在多版本或重复封装?
- 延迟加载:它是否只属于低频场景或明确交互?
五、把分割点放在责任交接处
代码分割的目标,是减少启动阶段不必要的 JavaScript,让当前页面只承担当前任务所需的代码。web.dev:代码分割
默认从路由级开始
路由通常是自然的责任边界:用户进入订单页时,不应先下载报表、富文本编辑器和地图模块。
Vue Router 可以让路由组件返回 import() Promise。模块通常会在首次访问对应路由时请求,路由系统会复用已解析的组件定义;这不等于所有组件实例都被永久缓存。Vue Router:路由懒加载
ts
const routes = [
{
path: '/analytics',
component: () => import('./pages/AnalyticsPage.vue'),
},
]
React 中可使用顶层声明的 lazy 和 Suspense:
tsx
import { lazy, Suspense } from 'react'
const AnalyticsPage = lazy(() => import('./pages/AnalyticsPage'))
export function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<AnalyticsPage />
</Suspense>
)
}
React 的 lazy 会缓存加载 Promise 和解析后的组件。加载中由最近的 Suspense 显示占位,加载失败则交给最近的 Error Boundary。不要在组件渲染函数内部声明 lazy,否则可能导致状态重置。React:lazy
再考虑功能级拆分
适合继续拆分的能力通常具有三个特征:重量明显、低频使用、不属于当前页面的基本任务,例如编辑器、图表、地图、文件导入导出、代码编辑器和高级筛选。
ts
async function openExportDialog() {
const { createExportTask } = await import('./export/createExportTask')
await createExportTask()
}
不要把每个按钮和小组件都改成动态导入。过细拆分会增加请求、依赖解析和加载状态;边界应对应用户可理解的加载序列,而不是源码的最小单位。React:Suspense
懒加载还必须配套处理加载态、失败态、用户已输入状态、重试机制和预取策略。如果这次点击对应用户的核心任务,可以在路由进入后、鼠标接近操作区域时或浏览器空闲时预取,而不是让用户承担完整下载等待。
六、自动分包与手工分包:分的是共享关系
手工分包只有在能说明以下问题时才有意义:
- 哪些模块总是一起访问?
- 哪些模块跨多个异步边界共享?
- 哪些模块更新频率低,值得长期缓存?
- 拆出后会不会进入首页关键请求链?
不要把所有第三方依赖塞进一个 vendor。这可能让首页加载后台依赖,也可能因任一依赖变化导致整块缓存失效。"第三方"不是运行时责任;首页共享的运行时和报表页专用的图表库不应承担相同的加载策略。
同样,也不要为了消除 chunk 阈值告警而盲目拆分。大 chunk 是诊断信号,不是必然错误。先根据访问路径、共享关系和缓存收益提出假设,再用网络和性能数据验证。
七、别遗漏 CSS、字体、图片与 WASM
只分析 JavaScript,常会得到"包变小了,但页面没明显变快"的结果。
Vite 支持 CSS 代码分割。异步模块关联的 CSS 通常会随相应模块加载;关闭 CSS 分割则会将项目 CSS 汇总为单个文件。应根据首屏样式和异步页面的实际需求选择,而不是机械追求文件更少。Vite:构建配置
还应检查:
- 首屏字体是否过大、字重是否过多;
- 图标是否误引入整套 SVG;
- 大图是否被内联进 JavaScript;
- WASM 是否只属于特定功能,却被入口静态引用。
资源内联阈值会影响"少一次请求"和"增大主产物"之间的取舍。不同 Vite 版本的默认值和配置名称应以对应版本文档为准,不能把旧版文档中的默认值当成当前项目的事实。
Source map 变小不等于用户体验变好。应分别考虑 source map 是否公开、是否上传到错误监控平台、是否需要保留 hidden source map,以及部署统计是否把 map 与用户实际下载资源混在一起。
八、为动态 chunk 设计发布失败路径
动态导入会引入一种部署期问题:用户仍在运行旧 HTML 或旧运行时代码,而新部署已经删除旧哈希 chunk。此时,后续动态导入可能请求不存在的旧资源。
Vite 提供了 vite:preloadError 事件,可用于处理这类预加载或动态导入失败。刷新页面只是恢复手段之一,不能对所有加载错误机械刷新。Vite:生产构建
ts
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault()
window.location.reload()
})
更完整的策略包括:
- 记录错误类型、当前版本与目标 chunk;
- 仅对可确认的版本错配采取刷新;
- 为 HTML 与带哈希的静态资源设置不同缓存策略;
- 评估是否需要保留一段时间的历史静态资源;
- 演练"用户停留页面后再触发懒加载"的发布路径。
九、用体验验收,而不是用文件大小宣布胜利
| 指标 | 优化前 | 优化后 | 说明 |
|---|---|---|---|
| 首页冷缓存关键 JS 传输量 | 关注首屏请求链,而非全部产物 | ||
| 首页热缓存传输量 | 验证长期缓存收益 | ||
| 目标路由首次进入等待 | 防止把问题转移给导航 | ||
| 重功能首次操作等待 | 验证懒加载没有制造卡顿 | ||
| LCP、INP 与长任务 | 用真实体验确认收益 | ||
| 动态 chunk 加载失败率 | 覆盖发布与缓存错配风险 |
最后,把流程固化为团队约束:
- 新增重型依赖时,说明它属于哪个访问场景;
- 新增动态导入时,提供加载态、失败态和缓存策略;
- 调整分包时,比较冷缓存、热缓存和关键交互;
- 构建报告异常时,先追问"为什么被引入、为什么现在加载";
- 不把
dist更小直接等同于用户体验更好。
Vite 产物治理的终点不是更漂亮的 chunk 名,而是首屏不再为未来才会发生的交互预付成本。