Vite 构建优化:依赖预构建、分包与产物分析
本文目录
Vite 的开发体验被广泛验证,但很多项目"dev 很快、build 很迷":冷启动慢、改一行代码全页刷新、产物里混着几 MB 的 vendor、首屏路由加载了用不到的图表库。这些问题各自指向不同的机制——预构建、HMR 边界、分包、tree-shaking。这篇文章按"机制 → 症状 → 配置"的顺序过一遍 Vite 的构建链路,所有结论都可以在自己的项目里用命令和插件验证。
先分清双引擎:dev 与 build 是两套东西
Vite 名字里最关键的假设是"开发与构建用不同引擎":
| 维度 | dev(开发服务器) | build(生产构建) |
|---|---|---|
| 模块格式 | 浏览器原生 ESM,按需编译 | Rollup 打包(Vite 7+ 提供可选的 Rolldown 路线) |
| 依赖处理 | 预构建成 ESM,强缓存 | 打包进 chunk |
| 代码转换 | esbuild(TS/JSX 转译,不做类型检查) | 同为 esbuild 转译 + Rollup 打包优化 |
| CSS | 按需注入,模块内热替换 | 提取、压缩、按 chunk 拆分 |
| 错误暴露 | 未用到的类型错误不报 | 同样不做类型检查,需要单独跑 tsc |
最重要的推论:dev 快不代表 build 产物健康。开发时每个模块单独请求,依赖没进 bundle,你感知不到 lodash 全量引入了 500KB;只有 build 后分析产物才能发现。反过来,vite build 的问题也不能靠"dev 里正常"来排除。
依赖预构建:为什么快,什么时候失效
Vite 启动时用 esbuild 把 node_modules 里的 CommonJS/UMD 依赖扫描出来,预构建成单个 ESM 文件,缓存到 node_modules/.vite(按 lockfile 哈希 + 配置哈希分桶)。动机有两个:
- 互操作:CJS 包不能直接被浏览器 ESM
import,需要转换成 ESM。 - 请求合并:
lodash-es有几百个入口文件,原生 ESM 下浏览器要发几百个请求;预构建合并成单文件减少瀑布。
两个经典症状都出在预构建的扫描边界上:
- 冷启动慢、控制台刷
new dependencies optimized:某个依赖是在运行时才被动态发现的(如通过模板字符串拼接导入),没赶上首次扫描。显式声明:
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
optimizeDeps: {
include: ['lodash-es', '@ant-design/icons', 'echarts/core'],
exclude: ['my-local-linked-pkg'], // 链接的本地包不预构建,保证其代码也走 HMR
},
});
- 依赖更新不生效:缓存 key 由 lockfile 与 Vite 版本决定,手动改了
node_modules(补丁包)后要vite --force重预构建。CI 里如果复用了带.vite缓存的node_modules但 lockfile 变了,缓存会自动失效——机制可靠,不必手动清理。
验证预构建结果:DEBUG="vite:deps" vite 会在终端打印依赖发现与构建的完整决策过程。
CommonJS 互操作的坑
预构建之后,CJS 包在 ESM 里用起来基本透明,但有两类坑:
import React from 'react'; // 命名空间整体拿:兼容性最稳的写法
import { useState } from 'react'; // esbuild 静态分析出的具名导出,预构建会注入,通常可用
import toast from 'react-hot-toast'; // 某些包 default 导出在 interop 后会变成 { default: ... }
| 症状 | 原因 | 处理 |
|---|---|---|
xxx is not a function | 具名导出是运行时计算出来的,esbuild 分析不到 | import pkg from 'xxx' 后取 pkg.foo |
| dev 正常 build 报错 | Rollup 与 esbuild 的 CJS 互操作策略差异 | 统一改用 pkg.default,或给该依赖加 @rollup/plugin-commonjs(通过 vite 插件体系) |
| default 双层嵌套 | interop 语义差异(esModule 标记) | 构建产物里调试断点确认实际结构 |
经验法则:对老 CJS 包,优先整体 import pkg 再取成员,少赌具名导出。
分包策略:manualChunks 与循环依赖
不配置时,Vite(继承 Rollup 的单入口 + 动态导入切分)已经会产出不错的 chunk。手动分包的唯一正当理由是利用浏览器缓存:把"几乎不变"的代码和"天天变"的业务代码分离,让用户升级版本时只重新下载变化部分。
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('echarts') || id.includes('zrender')) return 'charts';
if (id.includes('react') || id.includes('react-dom')) return 'react-vendor';
return 'vendor';
}
},
},
},
},
});
比"按包名枚举"更值得推荐的是函数式分包:判断模块路径归到哪个 chunk,粒度由缓存变更频率决定,而不是照抄教程里的 vendor: ['react', 'react-dom'] 数组写法(数组形式在 monorepo/pnpm 路径下容易失配)。
几个必须知道的边界:
- 循环依赖会让 chunk 拆出问题。模块 A 与 B 互相 import 且被分进两个 chunk 时,初始化顺序可能改变,出现"undefined 在构建后才出现"的报错——dev 下 esbuild 按需加载掩盖了它,build 后顺序固化才爆雷。解法不是绕过分包,而是把循环抽到第三方模块(A、B 都依赖 C),或让一方用动态
import()打破静态环。 - 分包越多,HTTP 请求越多。HTTP/2 下请求成本不高,但每个 chunk 都是一次独立缓存条目与潜在瀑布。经验起点:
vendor(运行时)+ 按业务域拆 2–4 个异步 chunk 即可,先有产物数据再谈细拆。 build.chunkSizeWarningLimit只是把警告线抬高,不是优化。
产物分析:先有数据再动手
分包决策必须基于产物构成数据。rollup-plugin-visualizer 是事实标准:
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
build: { sourcemap: true }, // 有 sourcemap 才能归因到具体模块
plugins: [
visualizer({ filename: 'dist/stats.html', gzipSize: true, brotliSize: true }),
],
});
构建后打开 dist/stats.html(treemap 形式)检查三件事:最大的 chunk 是谁、哪些依赖被打进了意料之外的 chunk、gzipped 后体积与原始体积差距(判断是否值得换按需方案)。
上图是典型的分析结果形态(数值为示意):echarts 全量 与 日期库全量 两项在改为按需注册后显著缩水,而业务代码不变——这说明优化收益来自依赖引入方式而不是删业务代码。你的项目里柱子的名字可能不同,判断方法相同。
按需引入与 tree-shaking 边界
Vite 生产构建依赖 Rollup 的 tree-shaking,它生效的前提很明确:
| 前提 | 失败场景 | 对策 |
|---|---|---|
| ESM 静态结构 | CJS 包默认无法静态分析(esbuild 预构建时尽力而为之) | 优先选 ESM 版本(lodash-es vs lodash) |
| 无副作用声明 | 包未声明 sideEffects: false,保险策略全保留 | 选声明良好的包;或用 build.rollupOptions.treeshake 调整 |
| 具名导入 | import * as X from 'pkg' 整体引入会压制摇树 | 用具名导入 |
| 入口未触发 | barrel file(index.ts 全量 re-export)把整个库拉进来 | 走子路径深导入或按需注册 API |
典型的"按需注册"写法(以图表库为例):
import * as echarts from 'echarts/core';
import { LineChart } from 'echarts/charts';
import { GridComponent, TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
echarts.use([LineChart, GridComponent, TooltipComponent, CanvasRenderer]);
还有一个常被忽略的开关:package.json 里的 sideEffects 字段只对 CSS 等模块要谨慎——标成 false 会让 import 'xx.css' 被摇掉(样式文件通常被认为有副作用),通用做法是保留数组形式列出真正无副作用的目录:
{ "sideEffects": ["*.css", "*.less"] }
产物加载:modulepreload 与入口请求链
构建产物的 HTML 里会为入口的静态导入 chunk 生成 <link rel="modulepreload">,让浏览器在解析 HTML 时并行拉取 JS,避免"入口 JS 下载完才发现还缺 3 个 chunk"的串行瀑布。两个可以干预的点:
build.modulePreload.polyfill(默认开)给不支持 modulepreload 的浏览器注入运行时预加载,老设备占比高的站点别急着关。- 动态导入的 chunk 默认按需拉取。如果某个异步路由"首屏后立刻就会被点到"(如登录后的主页),可以在入口手动加
<link rel="modulepreload" href="...">提前拉,但要配合产物哈希命名维护,收益要实测——多预载一个没人用的 chunk 是净损失。
排瀑布的方法:Lighthouse 或 Performance 面板看请求时序,检查是否存在"JS → JS → CSS"的串行链;串行链的每一环都值得问一次"这个依赖为什么不在前一个 chunk 里"。
CSS 处理链
CSS 在 Vite 里走独立管线:postcss.config.js(autoprefixer 等)→ 预处理器(lang="scss",默认走 Sass 的现代 API)→ @import 内联 → 按 chunk 拆分(cssCodeSplit 默认开)。两个高频问题:
- 样式进了错误的 chunk:动态导入的路由组件的 CSS 会跟随其 JS chunk 异步加载,首屏样式与首屏 JS 的拆分粒度要一致,否则出现"闪一下无样式"。
- CSS 体积爆炸:PostCSS 产物里充满了主题 token 与工具类时,考虑关掉无用的 autoprefixer 范围(
browserslist定得太老会让前缀量翻倍),并检查是否把整个组件库样式全量引了——按需引入组件库的产物收益经常比 JS 侧更明显。
样式表之间还有层叠顺序问题:多个异步 chunk 的 CSS 注入顺序与 chunk 加载顺序一致,依赖全局层叠的组件(覆盖第三方样式)要检查异步场景下顺序是否仍然成立,必要时用 CSS 优先级(@layer 或更高特异性)代替"后注入覆盖"这种脆弱顺序假设。
sourcemap:生产排错的地基
// 生产 sourcemap 的常见取舍
export default defineConfig({
build: {
sourcemap: 'hidden', // 生成 .map 但产物里不引用:可上传到错误监控平台,不暴露给用户
// sourcemap: true // 产物引用 .map,用户可下载——内部工具可用,对外站点慎用
},
});
hidden 是对外站点的标准选择:错误监控平台拿得到符号化堆栈,公网拿不到源码。没有 sourcemap 的生产报错只有一串压缩后的行号,等于放弃线上排错能力——这个配置不是可选项,是构建流水线的一部分。
常见症状速查
| 症状 | 机制 | 第一步动作 |
|---|---|---|
| 冷启动慢、终端不断"new dependencies optimized" | 预构建漏依赖,运行时才被发现 | optimizeDeps.include 补上 + DEBUG="vite:deps" 看决策日志 |
| dev 正常、build 后运行时报 undefined | Rollup 与 esbuild 互操作差异 + 循环依赖 | 检查 CJS 包的 default 导出结构;给循环模块抽公共依赖 |
| 首屏加载了没用的图表库 | barrel file 全量 re-export 把摇树入口堵住 | 看产物归因,改子路径导入或按需注册 |
| 每次 build 都全量重装依赖 | CI 没有任何缓存层 | 缓存 lockfile 后的 node_modules;类型检查单独 job 并行 |
| 升级 Vite 后 dev/build 行为不一致 | esbuild/Rollup 版本行为变化 | 用 preview 回归构建产物,对比 dist diff |
这张表的用法是先定位机制再改配置——同一个症状(比如"build 慢")可能来自预构建、类型检查、sourcemap 三个完全不同的层,盲目改配置只会把问题藏得更深。
环境变量:模式与替换
Vite 把 .env、.env.development、.env.production 按 mode 叠加,只有 VITE_ 前缀的变量会暴露到客户端,且是构建时静态替换:
if (import.meta.env.DEV) analytics.init(import.meta.env.VITE_API_BASE);
边界必须清楚:
- 静态替换意味着
import.meta.env.VITE_X出现在产物里的位置已被替换成字符串,运行时换不了。需要运行时配置(同一产物部署到多环境)时,用构建产物外挂config.js或请求后端配置接口,别指望构建时变量。 - 任何密钥都不该出现在
VITE_变量里——它会被打包进可公开下载的 JS。前端只放"公开的应用配置",不放秘密。 process.env.NODE_ENV与import.meta.env.MODE是两个体系,社区包里常见前者,Vite 会通过define做兼容替换。
缓存与 CI
dev 的预构建缓存(node_modules/.vite)对 CI 通常帮助有限(依赖安装本身就是大头),真正对 CI 有价值的是:
| 缓存 | 位置 | 失效依据 | CI 策略 |
|---|---|---|---|
| 预构建缓存 | node_modules/.vite | lockfile + Vite 版本 + 配置 | 可缓存但收益小 |
| TypeScript 类型检查 | 无缓存 | 全量 | vue-tsc/tsc --noEmit 单独 job 并行跑 |
| 构建缓存 | Vite 6.3+ 实验性 build cache | 配置与依赖图 | 大型项目收益明显,注意按官方文档开启 |
另外两个命令值得形成肌肉记忆:vite build --mode staging 用 staging 的 env 而不是 development(--mode 才决定加载哪套 env,NODE_ENV 是衍生品);vite preview 用来本地预览构建产物,vite preview --outDir 可自定义目录,验证分包效果必须看 preview 而不是 dev。
monorepo 注意点
大仓(pnpm workspace / turborepo 类)下的额外规则:
- 本地包不预构建:workspace 内的包通过 alias 指到源码,享受 HMR;要把它们放进
optimizeDeps.exclude,同时确认这些包自己导出 ESM。 - 共享依赖只留一份:多个 app 依赖同一版本的 react 时,pnpm 提升能保证;若版本漂移,会出现"两个 React"导致的 hooks 报错,用 catalog/统一版本约束治理。
- alias 优先级:
resolve.alias优先于 optimizeDeps 扫描,排查"改了 workspace 包不生效"先看 alias 是否被别的配置覆盖。 - 每个子包
package.json的exports字段要准确,Vite(以及未来的 Rolldown)按exports解析子路径,错误声明会造成 dev 与 build 解析出不同文件。
注意事项与边界
- 别用
import.meta.env做"运行时开关":它是编译期常量,死分支会被摇掉——这既是优化也是陷阱(想在生产产物里保留调试后门时必须用真配置注入)。 - manualChunks 函数里用包名字符串匹配是脆弱的(pnpm 的路径带 hash),匹配
node_modules后再按更稳定的包名分段(如从路径中解析/node_modules/<pkg>/片段)。 - Rollup 与 esbuild 对某些边缘语法(旧版装饰器等)的转译结果不同,升 Vite 大版本后 dev/build 行为差异要回归验证。
vite build不做类型检查,CI 里的类型检查缺失会让"只在 build 类型才触发的类型错误"漏到线上,务必单独跑tsc --noEmit。- 产物优化前先确认目标用户网络与压缩配置:开启 HTTP 压缩的服务器上,gzip 体积(约原始 30%)才是下载真相,别对着未压缩数字做决策。
总结
Vite 构建优化的顺序应当是固定的:先用 visualizer 拿到产物构成数据,再决定动哪里——预构建边界(optimizeDeps.include)解决 dev 冷启动与全页重载,分包(函数式 manualChunks)按缓存变更频率切 chunk,按需注册与 sideEffects 治理摇树边界,环境变量守住静态替换的坑,monorepo 里管住 alias 与依赖版本。脱离产物数据的"优化"大多在错的方向上加配置。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。