引言
Vite 启动时会对 node_modules 里的依赖做一次「预构建」——这是它比传统 bundler 启动快的关键。预构建解决两个问题:① 兼容 CJS/老包(把 CommonJS 转成 ESM)② 性能(把分散的依赖合并成少量大模块,减少请求数)。本文讲透预构建:先拆解 esbuild 扫描与打包的完整流程,再给 optimizeDeps 配置实战(include/exclude/force),接着讲缓存失效与 .vite 目录管理,最后覆盖 Monorepo、链接包与动态导入的预构建疑难。
前置:/vite-config-guide/(构建配置)、/vite-scaffold-engineering/(工程起步)。Node 模块系统见 [[nodejs]]。
目录
- 1. 为什么需要预构建:两个核心问题
- 2. 预构建流程:esbuild 扫描与打包
- 3. optimizeDeps 配置实战
- 4. 缓存失效与 .vite 目录
- 5. Monorepo 与链接包的预构建
- 6. 动态导入与按需预构建
- 7. 常见依赖报错与修复
- 8. 预构建 vs 生产打包:角色分工
- 9. 性能调优与进阶技巧
- 10. 速查表
- 延伸阅读
1. 为什么需要预构建:两个核心问题
问题一:兼容性——很多 npm 包还是 CJS。
Vite 开发期用原生 ESM,但 node_modules 里大量包是 CJS(module.exports)。
浏览器不认识 CJS → 需要预先把它们转成 ESM。
问题二:性能——依赖散成几百个小文件,请求爆炸。
import { debounce } from 'lodash-es'
→ 开发期 lodash-es 拆成上千个 ESM 文件 → 浏览器要发上千个请求 → 慢!
预构建:esbuild 把 lodash-es 合并成「一个文件」→ 一次请求。
| 问题 | 预构建解决 |
|---|---|
| CJS 不兼容 | 转为 ESM |
| 请求数爆炸 | 合并成少量大 chunk |
| 大依赖依赖链深 | 扁平化缓存 |
| 开发冷启动慢 | 只构建依赖(源码即时编译) |
心智:预构建 = 「先处理好依赖,让开发期快」——依赖一次转换缓存复用,源码按需即时编译。
2. 预构建流程:esbuild 扫描与打包
完整流程:
1. 启动 dev server → Vite 读取 index.html / 入口模块
2. 扫描 import 语句 → 找出依赖(裸导入,非相对路径)
3. 交给 esbuild 打包:CJS→ESM、合并、压缩
4. 产物写入 node_modules/.vite/deps/
5. 后续请求直接命中缓存,无需再构建
扫描的依赖(裸导入解析到 node_modules 的):
import 'react' // ✓ 扫描
import 'lodash-es/map' // ✓ 子路径
import './utils' // ✗ 相对路径,不预构建
esbuild 预构建的优点:
| 优势 | 说明 |
|---|---|
| 快 | Go 语言实现,秒级构建 |
| 兼容 | 自动处理 CJS/ESM 互操作 |
| 稳定 | 产物缓存,重启秒开 |
| 轻量 | 不分析打包策略,只做依赖 |
查看预构建产物:
ls node_modules/.vite/deps/ # 每个依赖一个 .js 文件 + 索引
cat node_modules/.vite/deps/_metadata.json # 版本与 hash 元信息
记忆:预构建三件事——扫描裸导入、esbuild 打包、写缓存——之后开发期再也不碰依赖。
3. optimizeDeps 配置实战
在 vite.config.ts 里配置预构建:
import { defineConfig } from 'vite'
export default defineConfig({
optimizeDeps: {
// 强制把某些包纳入预构建(默认 esbuild 已扫描大部分)
include: ['my-lib', 'lodash-es/map'],
// 排除(已 ESM 化、或想按需加载的)
exclude: ['@testing-library/react'],
// 强制重新预构建(忽略缓存)
force: true,
// 预构建也用的 esbuild 选项
esbuildOptions: {
target: 'esnext',
},
// 限定只预构建这些顶层依赖(大型项目加速)
needsInterop: [],
},
})
include 的典型场景:
| 场景 | 配置 |
|---|---|
| 纯 ESM 但想合并 | include: ['xx-es'] |
| CJS 包 | 默认自动,也可 include 强制 |
| 动态导入依赖 | include: ['imported-lazy-lib'] |
| Monorepo 链接包 | include + server.watch |
记忆:include 是「手动点名预构建」,exclude 是「我不需要你帮忙」——绝大多数项目无需配置,靠默认扫描就够。
4. 缓存失效与 .vite 目录
缓存存在 node_modules/.vite——什么时候会失效重建?
触发重建:
1. 依赖版本变化(lockfile 变更)
2. 依赖的 import 路径变化
3. 手动 npm install / 更新依赖
4. optimizeDeps 配置变化
5. force: true / 删除 .vite
缓存失效的判定依据:.vite/deps/_metadata.json 里的依赖 hash + lockfile 变化。
手动清缓存:
rm -rf node_modules/.vite
# 或一条命令重新安装并清缓存
npm install && rm -rf node_modules/.vite
常见「缓存问题」症状:
| 症状 | 原因 | 处理 |
|---|---|---|
| 改了依赖源码不生效 | 链接包未被 watch | 加 include + server.watch |
| 新装包后报模块找不到 | 预构建列表过期 | 重启 dev(自动重扫) |
| 版本升级后异常 | 旧缓存残留 | rm -rf node_modules/.vite |
| 依赖仍 CJS 报错 | 预构建未覆盖 | include 强制 + force |
铁律:依赖变了第一反应先清
.vite缓存——90% 的「玄学报错」都是旧缓存搞的鬼。
5. Monorepo 与链接包的预构建
Monorepo(pnpm workspace)中依赖被软链接到根 node_modules——Vite 需要特殊处理:
packages/
app/ # 你的 Vite 应用
ui/ # 共享组件库(pnpm link 到 app)
utils/
问题:链接包源码改了,预构建缓存还是旧版 → 改了不生效。
解法:
import { defineConfig } from 'vite'
export default defineConfig({
optimizeDeps: {
// 1. 把链接包纳入预构建(并在源码变更时重扫)
include: ['@repo/ui', '@repo/utils'],
},
server: {
// 2. 监听链接包的真实源码目录
watch: {
ignored: ['!**/packages/**'],
},
// 3. 源码变更时让预构建失效(fs 事件)
fs: {
allow: ['..', 'packages'],
},
},
})
更彻底的做法:链接包直接「源码模式」——不预构建,把 @repo/ui 的 main 指向 src/index.ts,让 Vite 走源码编译(改动秒级热更)。pnpm 下用 resolutions 或 tsup 出 ESM。
| 方案 | 优点 | 缺点 |
|---|---|---|
| include 预构建 | 简单 | 改源码要重扫 |
| 源码模式(走 src) | 秒级热更 | 需包支持 ESM 源码 |
| watch 源码目录 | 兼顾 | 配置略繁 |
记忆:Monorepo 预构建的痛点是「链接包的缓存失效」——要么 include 让它重扫,要么直接源码模式跳过预构建。
6. 动态导入与按需预构建
动态导入(懒加载)的依赖不会被顶层扫描到:
// 运行时才 import → 初始扫描可能漏
const module = await import('heavy-chart-lib')
处理方式:
export default defineConfig({
optimizeDeps: {
// 1. 显式 include 动态导入的依赖
include: ['heavy-chart-lib'],
},
})
或者接受首次慢加载:Vite 2.9+ 会在运行时发现新依赖并「按需预构建」——首次访问触发一次重载即可。
按需预构建的流程:
1. 页面请求 import('heavy-lib') → 未预构建
2. Vite 发现新依赖 → 立即用 esbuild 预构建
3. 触发页面 reload → 再次请求命中新缓存
记忆:动态依赖要么显式 include,要么接受「首次触发的 reload」——生产场景建议 include 掉主懒加载依赖避免跳变。
7. 常见依赖报错与修复
预构建相关的典型报错:
| 报错 | 原因 | 修复 |
|---|---|---|
Cannot find module 'xx' | 预构建列表过期/链接包未含 | include + force 重启 |
The CJS build of "xx" is not supported | 包 CJS + 特定导出 | include 预构建或 resolve.dedupe |
Failed to resolve dependency | 包不可解析 | 检查版本/lockfile,装新依赖 |
Dep optimization ... reload | 运行时发现新依赖 | 正常,重载即好 |
| esbuild 语法错误 | 依赖用新语法 | optimizeDeps.esbuildOptions.target 调高 |
防 CJS 报错的万能处理:
export default defineConfig({
resolve: {
// 解决重复依赖/命名冲突(多份相同库)
dedupe: ['react', 'react-dom'],
},
optimizeDeps: {
include: ['react', 'react-dom'],
},
})
记忆:预构建报错的主轴是「依赖没进预构建列表或缓存过期」——先 include + force,再谈别的。
8. 预构建 vs 生产打包:角色分工
| 维度 | 开发期预构建(esbuild) | 生产打包(Rollup) |
|---|---|---|
| 目标 | 依赖转换 + 请求数优化 | 全量产物优化 |
| 工具 | esbuild | Rollup |
| Tree Shaking | 不做 | 做 |
| 代码分割 | 不做 | 做 |
| 压缩 | 是 | 是 |
| 产物 | .vite/deps 缓存 | dist/ 部署包 |
关键区分:预构建不做 Tree Shaking/代码分割——那是生产期 Rollup 的职责。所以预构建产物大没关系,只是开发期缓存。
记忆:预构建管「开发期快」,Rollup 管「生产期小」——职责分离,别指望预构建产出的就是最终产物。
9. 性能调优与进阶技巧
预构建性能提升技巧:
| 技巧 | 做法 | 收益 |
|---|---|---|
| 锁定目标 | esbuildOptions.target 按环境 | 避免过度转译 |
| 控制 include | 只 include 必要大依赖 | 加快重扫 |
| 保留缓存 | 不随便删 .vite | 秒级重启 |
| 网络安装 | lockfile 固定版本 | 缓存稳定 |
| 分拆大依赖 | 用子路径导入(lodash-es/map) | 减少打包量 |
debug 预构建:
DEBUG=vite:deps npm run dev # 看预构建详细日志
记忆:预构建调优的核心是「让缓存稳定 + 只构建必要项」——锁版本、控 include、保缓存,启动就快。
10. 速查表
| 需求 | 做法 |
|---|---|
| 强制预构建某包 | optimizeDeps.include: ['xx'] |
| 排除预构建 | optimizeDeps.exclude: ['xx'] |
| 无视缓存重建 | optimizeDeps.force: true |
| 清缓存 | rm -rf node_modules/.vite |
| 动态导入预构建 | include 懒加载依赖 |
| Monorepo 链接包 | include + server.watch + fs.allow |
| CJS 报错 | include + force + resolve.dedupe |
| 查看预构建 | ls node_modules/.vite/deps |
| 追踪日志 | DEBUG=vite:deps |
一句话记忆:预构建把 CJS 依赖转 ESM 并合并成少量模块写进 .vite 缓存;默认自动扫描,include 点名、exclude 豁免、force 强制;Monorepo 链接包要 watch 源码、动态依赖要 include;改依赖不生效先清 .vite——预构建管开发快、Rollup 管生产小。
延伸阅读
- /vite-config-guide/ — optimizeDeps 与 resolve 配置
- /vite-build-optimization/ — 生产打包与 Tree Shaking
- /vite-monorepo-architecture/ — Monorepo 依赖与共享包
- /vite-hmr-internals/ — 依赖更新如何触发热更
- [[nodejs]] — CJS/ESM 模块系统
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。