Vite 的快不是"打包更快",而是开发期根本不打包。开发服只对浏览器发原生 ESM,浏览器要哪个模块就请求哪个;生产期才切回 Rollup 做完整打包。两套流程各有一套坑:开发期的问题集中在依赖预构建与失效,生产期的问题集中在分包、缓存与路径。
按运行形态 → 预构建 → 别名与分包 → 产物优化 → 验证的顺序拆。
1. 两种运行形态与预构建的必要性
| 阶段 | 工具 | 输入 | 输出 | 特点 |
|---|---|---|---|---|
| dev | esbuild 预构建 + 原生 ESM | 源码按需转换 | 每模块一个请求 | 启动快;请求数随模块数增长 |
| build | Rollup | 整张模块图 | 少量 chunk | 有 tree-shaking 与压缩 |
没有预构建,开发期会出两类问题:CommonJS 依赖(lodash 的 CJS 入口)不能被浏览器当 ESM 加载;依赖内部上百个小模块会让浏览器发起上百个请求。预构建用 esbuild 把每个依赖打成单个 ESM 并缓存到 node_modules/.vite,同时完成 CJS 转换。
// vite.config.ts
export default defineConfig({
plugins: [react()],
optimizeDeps: {
include: ['lodash-es', 'echarts/core'], // 扫描不到的依赖手动列为入口
exclude: ['@my/ui'], // ESM 包或本地源码跳过
},
});
判断"要不要 include"的标准:依赖是否被页面静态 import、是否 CJS、是否模块数很多。三个都不满足就不必加。
2. 预构建的失效场景
预构建缓存只在启动时计算一次,依赖集合变化后不会自动重算,于是出现"明明装了包却报 Failed to resolve import"。
| 场景 | 表现 | 处理 |
|---|---|---|
| 新装依赖未被发现 | 首次访问报解析失败,刷新后正常 | 重启开发服,或 --force 强制重算 |
| 扫描漏了动态 import | 运行时才报错 | 加进 optimizeDeps.include |
| monorepo 里的本地包 | 预构建后改了源码不生效 | 放进 exclude,让源码走原生 ESM |
清缓存用 rm -rf node_modules/.vite 或 vite --force,两者等价;要看扫描结果,跑 DEBUG=vite:deps vite,输出里能看到每个依赖被归到哪个 chunk、是否被 external——排查"某个包没被扫到"时这是唯一直接证据。
3. resolve.alias 与 tsconfig.paths 必须同步
别名有两套解析器:Vite 用 resolve.alias 在运行与构建期解析,TypeScript 用 compilerOptions.paths 做类型检查,不同步就会出现"编辑器红但能跑"或"能编译但运行时 404"。
// vite.config.ts
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
});
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
}
}
两边都配的时候只看一个信号:编辑器红说明缺 paths,构建报 Could not resolve 说明缺 alias;vitest 也报解析错误,说明测试配置没复用同一份 alias。
import() 同样走 alias,但不能做字符串拼接:构建期无法静态分析,Rollup 会把它当外部依赖或直接丢掉。
// 打包不到
const mod = await import(`./views/${name}.tsx`);
// 打包得到:显式映射
const views = {
home: () => import('./views/home.tsx'),
about: () => import('./views/about.tsx'),
};
const mod = await views[name as keyof typeof views]();
4. 分包策略:manualChunks 与路由懒加载
默认分包只有一个入口 chunk + 少量异步 chunk,结果是"改一行业务代码,整个 vendor 的 hash 也变了",缓存全部失效。
| 策略 | 做法 | 收益 | 代价 |
|---|---|---|---|
| 按依赖分包 | manualChunks 把 react、echarts 各自独立 |
业务改动不冲击三方缓存 | 依赖间若有交叉引用会重复打入 |
| 按路由懒加载 | 路由组件用 import() |
首屏只加载当前路由 | 每次切路由多一次往返 |
| 公共依赖抽出 | 多个异步 chunk 共用的模块提为独立 chunk | 避免重复下载 | chunk 数变多,HTTP/1.1 下反而不利 |
export default defineConfig({
build: { rollupOptions: { output: {
manualChunks(id) {
if (!id.includes('node_modules')) return;
if (id.includes('echarts')) return 'vendor-charts';
if (id.includes('react')) return 'vendor-react';
return 'vendor';
},
} } },
});
判断分包是否合理看两点:改一行业务代码后失效的 chunk 是否只有业务那个;某个三方库是否在多个异步 chunk 里重复出现。
5. 产物的三类优化
| 目标 | 手段 | 副作用 |
|---|---|---|
| 体积 | tree-shaking、按需引入、压缩、去掉 sourcemap 发布 | 过度拆分会让请求数上升 |
| 请求数 | 合理的 chunk 粒度、HTTP/2 多路复用、内联小资源 | 内联过多会拖慢首字节 |
| 缓存命中 | 内容 hash 文件名、vendor 与业务分离、长期缓存头 | hash 与 CDN 缓存策略要一致 |
体积的收益顺序是先"少引"再"压缩":import { debounce } from 'lodash' 带进整包,lodash/debounce 只带一个模块;ECharts 用 echarts/core 按需注册比整包小一个量级(按 echarts 5 核对)。
请求数与体积反向:一个 2MB 的 chunk 拆成 20 个 100KB,总体积不变、请求数增加。HTTP/2 并发不受限,HTTP/1.1 超过 6 个并发就排队。
缓存命中取决于 hash 与 CDN 的 TTL 规则是否匹配。[hash] 是内容哈希,内容变了名字就变,可配长 TTL;index.html 必须短 TTL,否则用户拿到旧 HTML 引用已删除的 chunk。
6. Source Map 的选择
| 环境 | build.sourcemap |
用途 | 注意 |
|---|---|---|---|
| 开发 | true(默认走 inline) |
精确定位源码行 | 体积大,只用于本地 |
| 生产 | 'hidden' |
错误监控还原堆栈 | 生成 .map 但不在 JS 末尾写注释 |
| 生产(对外发布) | false |
彻底不暴露 | 线上报错只剩压缩后的行列 |
export default defineConfig({
build: {
sourcemap: 'hidden', // 生成 .map,不写 sourceMappingURL
},
});
hidden 的用法:构建后把 .map 上传监控平台再从 dist 删掉,避免被猜路径下载源码;inline 把 map 塞进 JS,等于随包发布源码,只适合本地。
7. 环境变量的处理
构建期做静态替换:import.meta.env.VITE_API_BASE 会被换成字面量,不是运行时读取。
| 写法 | 是否暴露到客户端 | 说明 |
|---|---|---|
import.meta.env.VITE_X |
是 | 只有 VITE_ 前缀会被注入 |
process.env.X |
否(需 define 手动替换) |
直接用会在浏览器报 process is not defined |
.env.local 里的非 VITE_ 变量 |
否 | 只在 Node 侧可见,前端读不到 |
两条硬规则:凡被 import.meta.env 引用的值都会出现在产物里,前缀只是约定不是保护,密钥不进前端;按环境切换的配置走构建期替换或服务端下发,别在运行时读 .env。
8. 验证构建产物
$ vite build && vite preview --port 4173 # 生产产物起本地服务
$ npx source-map-explorer dist/assets/*.js # 看包由哪些模块构成
$ npx vite-bundle-visualizer # treemap 交互图
preview 与 dev 不是同一条链路,必须分别验证:preview 404 的常见原因是 base、资源引用方式或 public/ 处理。产物分析回答"包为什么这么大":整包引入的图表库、被打进多个 chunk 的公共依赖、本不该出现的服务端依赖。
9. base 路径与多环境构建
部署到子目录(https://example.com/app/)时,base 必须与部署路径一致,否则所有静态资源 404。
export default defineConfig(({ mode }) => ({
base: mode === 'production' ? '/app/' : '/',
}));
// $ vite build --mode staging → 读 .env.staging
// $ vite build --mode production → 读 .env.production
base 定资源前缀,mode 决定加载哪个 .env.[mode],build.outDir 决定产物目录,server.proxy 只影响 dev。
base 写错的表现是 HTML 能打开、JS 与 CSS 全 404。反向代理改写了路径(/app/ → /)时,base 要按浏览器看到的路径配,不是按源站目录配。
10. 常见坑
| 坑 | 表现 | 根因 | 改法 |
|---|---|---|---|
| 只在生产出现的循环依赖 | 某模块在 build 后为 undefined |
dev 的原生 ESM 容忍部分循环,Rollup 求值顺序不同 | 消除循环,或把依赖改为函数内调用 |
| 动态 import 路径拼接 | 对应 chunk 缺失,运行时 404 | 构建期无法静态分析 | 改成显式映射表 |
| CSS 顺序在生产被打乱 | 样式覆盖关系反了 | 异步 chunk 的 CSS 注入顺序与源码顺序不同 | 关键样式放同步入口,或提高选择器特异性 |
| hash 文件名与 CDN 冲突 | 发版后部分用户白屏 | HTML 缓存了旧引用,或 CDN 忽略 query | index.html 短 TTL,chunk 长 TTL |
11. 排错表
| 现象 | 优先核对 | 处理 |
|---|---|---|
Failed to resolve import |
预构建缓存、alias、依赖是否安装 | --force 重建,核对 alias 与 paths |
| dev 正常、preview 404 | base、资源引用路径 |
按部署路径改 base |
| 产物里有源码 | sourcemap 设置 |
改 hidden 并删除 .map |
| 某个库被打进多个 chunk | manualChunks 的匹配规则 |
调整匹配顺序,先匹配具体库 |
| 环境变量读不到 | 前缀、.env 文件名、mode |
用 VITE_ 前缀并核对 mode |
12. 边界:什么时候该换工具或换策略
| 场景 | 更合适的选择 | 理由 |
|---|---|---|
| 需要极致的产物体积 | Rollup/Rspack 直接配,或 SWC 压缩链 | Vite 的默认压缩已够用,但细粒度控制不如手写 |
| 大量 CJS 依赖的老项目 | webpack 或 Rspack | 预构建的转换边界在复杂 CJS 上更易踩坑 |
| 库开发(要出 ESM + CJS 双格式) | tsup / Rollup 库模式 | Vite 面向应用,库模式配置有限 |
一句话收束:开发期靠"不打包",生产期靠"打包得对"。预构建管请求数与模块格式,分包管缓存粒度,sourcemap 与环境变量管可运维性。
13. 自测
- 开发期浏览器请求数暴涨,先怀疑预构建的哪一环?
- 新装了依赖,dev 报解析失败但刷新后正常,为什么?
resolve.alias与tsconfig.paths只配一个,分别会出什么症状?- 按依赖分包与路由懒加载,各自优化体积还是缓存?
- 生产要能还原堆栈又不想公开源码,
sourcemap该选哪个值? - 部署到子目录后 HTML 正常但 JS 全 404,改哪个配置?