首屏在替谁付费:重做 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 中可使用顶层声明的 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

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

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

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

  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 天前
vue3笔记
前端·webpack·vue3·vite·脚手架·vue3笔记·组件式
向北丶16 天前
vite项目中配置alias别名
前端·vite
PedroQue9916 天前
修复 .uvue 页面路径残留问题
前端·vite
梦曦i16 天前
修复 .uvue 页面路径残留问题
前端·vite
做前端的娜娜子21 天前
施工蓝图:vite.config.js —— 项目的指挥中心
前端·react native·vite
D_jing2022 天前
【踩坑解决】pnpm+Vite引入@koi/core报错spark-md5找不到模块(幽灵依赖深度解析)
pnpm·vite·幽灵依赖·前端踩坑
Flynt23 天前
pnpm 12 换上了 Rust 内核,我拿项目实测了一轮构建速度
rust·vite·前端工程化
xiaoyan201523 天前
2026最新款Electron41+React19+AntDesign电脑端后台管理系统Exe
react.js·electron·vite
梨想橙汁23 天前
Vite 优化、踩坑汇总 + Webpack 迁移 Vite 实战
前端·webpack·vite