Vite 依赖预构建:optimizeDeps 原理、缓存失效与 Monorepo 实战

深入 Vite 依赖预构建机制:为什么需要预构建(ESM 兼容 + 性能)、esbuild 扫描与打包、optimizeDeps 配置、缓存失效与 .vite 目录、Monorepo 与链接包处理、按需预构建。

引言

Vite 启动时会对 node_modules 里的依赖做一次「预构建」——这是它比传统 bundler 启动快的关键。预构建解决两个问题:① 兼容 CJS/老包(把 CommonJS 转成 ESM)② 性能(把分散的依赖合并成少量大模块,减少请求数)。本文讲透预构建:先拆解 esbuild 扫描与打包的完整流程,再给 optimizeDeps 配置实战(include/exclude/force),接着讲缓存失效与 .vite 目录管理,最后覆盖 Monorepo、链接包与动态导入的预构建疑难。

前置:/vite-config-guide/(构建配置)、/vite-scaffold-engineering/(工程起步)。Node 模块系统见 [[nodejs]]。


目录


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)
目标依赖转换 + 请求数优化全量产物优化
工具esbuildRollup
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 模块系统

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「vite」更多文章

  1. Vite 静态资源与媒体资产处理:图片、字体、SVG 与 Worker
  2. Vite 环境变量与生产构建最佳实践:import.meta.env、构建模式与产物优化
  3. Vite 浏览器兼容与 Legacy 构建:build.target、Polyfill 与兼容插件