首屏在替谁付费:重做 Vite 产物治理的责任边界

首屏在替谁付费:重做 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:后端集成

重点检查:

  1. 首页入口静态依赖了哪些 chunk?
  2. 哪些只属于特定路由的模块进入了首屏依赖链?
  3. 某个异步模块是否又拉起了一大段共享依赖?
  4. 某个 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,分别问:

  1. **为什么被引入?**是全量导入、barrel file 转发、重复依赖,还是共享组件静态引用了重型能力?
  2. **为什么现在加载?**它真的属于首屏,还是只属于低频路由或用户点击后的功能?

前一个问题决定删除、替换或缩窄导入;后一个问题决定静态加载、路由拆分、功能拆分或延后加载。

四、依赖治理优先于懒加载

把重型库改成动态导入,确实能让它离开首屏;但如果用户最终一定会使用它,只是改变了付款时间。更优先的顺序通常是:先确认功能是否必要,再缩小导入边界,最后才考虑延迟加载。

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 同时转发轻量工具和编辑器内核时,应确认轻量调用是否意外携带了重模块。

依赖决策可以按以下顺序进行:

  1. 删除:功能是否仍在使用?
  2. 替换:原生 API 或更小的实现是否足够?
  3. 缩窄导入:能否使用子路径或具名导入?
  4. 去重:是否存在多版本或重复封装?
  5. 延迟加载:它是否只属于低频场景或明确交互?

五、把分割点放在责任交接处

代码分割的目标,是减少启动阶段不必要的 JavaScript,让当前页面只承担当前任务所需的代码。web.dev:代码分割

默认从路由级开始

路由通常是自然的责任边界:用户进入订单页时,不应先下载报表、富文本编辑器和地图模块。

Vue Router 可以让路由组件返回 import() Promise。模块通常会在首次访问对应路由时请求,路由系统会复用已解析的组件定义;这不等于所有组件实例都被永久缓存。Vue Router:路由懒加载

ts 复制代码
const routes = [
  {
    path: '/analytics',
    component: () => import('./pages/AnalyticsPage.vue'),
  },
]

React 中可使用顶层声明的 lazySuspense

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

懒加载还必须配套处理加载态、失败态、用户已输入状态、重试机制和预取策略。如果这次点击对应用户的核心任务,可以在路由进入后、鼠标接近操作区域时或浏览器空闲时预取,而不是让用户承担完整下载等待。

六、自动分包与手工分包:分的是共享关系

手工分包只有在能说明以下问题时才有意义:

  1. 哪些模块总是一起访问?
  2. 哪些模块跨多个异步边界共享?
  3. 哪些模块更新频率低,值得长期缓存?
  4. 拆出后会不会进入首页关键请求链?

不要把所有第三方依赖塞进一个 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()
})

更完整的策略包括:

  1. 记录错误类型、当前版本与目标 chunk;
  2. 仅对可确认的版本错配采取刷新;
  3. 为 HTML 与带哈希的静态资源设置不同缓存策略;
  4. 评估是否需要保留一段时间的历史静态资源;
  5. 演练"用户停留页面后再触发懒加载"的发布路径。

九、用体验验收,而不是用文件大小宣布胜利

指标 优化前 优化后 说明
首页冷缓存关键 JS 传输量 关注首屏请求链,而非全部产物
首页热缓存传输量 验证长期缓存收益
目标路由首次进入等待 防止把问题转移给导航
重功能首次操作等待 验证懒加载没有制造卡顿
LCP、INP 与长任务 用真实体验确认收益
动态 chunk 加载失败率 覆盖发布与缓存错配风险

最后,把流程固化为团队约束:

  1. 新增重型依赖时,说明它属于哪个访问场景;
  2. 新增动态导入时,提供加载态、失败态和缓存策略;
  3. 调整分包时,比较冷缓存、热缓存和关键交互;
  4. 构建报告异常时,先追问"为什么被引入、为什么现在加载";
  5. 不把 dist 更小直接等同于用户体验更好。

Vite 产物治理的终点不是更漂亮的 chunk 名,而是首屏不再为未来才会发生的交互预付成本。

参考资料

相关推荐
做前端的娜娜子1 天前
施工蓝图:vite.config.js —— 项目的指挥中心
前端·react native·vite
D_jing202 天前
【踩坑解决】pnpm+Vite引入@koi/core报错spark-md5找不到模块(幽灵依赖深度解析)
pnpm·vite·幽灵依赖·前端踩坑
Flynt3 天前
pnpm 12 换上了 Rust 内核,我拿项目实测了一轮构建速度
rust·vite·前端工程化
xiaoyan20153 天前
2026最新款Electron41+React19+AntDesign电脑端后台管理系统Exe
react.js·electron·vite
梨想橙汁3 天前
Vite 优化、踩坑汇总 + Webpack 迁移 Vite 实战
前端·webpack·vite
猫不易4 天前
Webpack 与 Vite:从 Loader / Plugin 到 Rolldown 统一引擎
前端·vite
今日无bug5 天前
从0到1手撕流式输出:Vue3 + Vite 实现 LLM 流式响应全解析
前端·vue.js·vite
天道kabuto5 天前
Vite 项目报错 @rollup/rollup-win32-x64-msvc 的解决与版本锁定
vite
天道kabuto5 天前
Webpack 迁 Vite 踩坑记:一个让人抓狂的 Sourcemap 报错怎么破?
webpack·vite