前端开发··2 阅读·预计 10 分钟

Vite 依赖预构建的失效治理:从 optimizeDeps 缓存误判到冷启动回归的排查链路

引言

Vite 的开发期快,很大程度依赖「依赖预构建」:把 node_modules 里散落的 CommonJS 依赖提前打包成 ESM,并缓存起来。但这套缓存一旦因为依赖升级、配置改动或路径变化而失效,Vite 就会退回到「重新扫描 + 重新预构建」的冷启动。本文不重复 pre-bundle 的原理,只拆缓存失效的三个诱因,以及如何让「重新优化」不再频繁触发。

一、预构建的收益来自「命中缓存」,失效就是白干

Vite 首次启动会扫描依赖、用 esbuild 预构建、写入 node_modules/.vite 缓存。这步只在缓存有效时被跳过,一旦失效,冷启动就多了一次完整扫描。

# 反例:装了新依赖却不清缓存,Vite 可能用旧缓存「漏掉」新依赖
npm install lodash-es
pnpm dev          # 若缓存没失效,新依赖可能没被预构建,运行时才发现

# 正例:依赖变化后显式失效,强制重新优化
pnpm dev --force   # 或删掉缓存目录

--force 会强制 Vite 忽略缓存、重新预构建,适合「装了依赖但行为异常」时的排查。但更好的做法是理解 Vite 何时自动失效,避免每次都靠 --force 暴力刷新。

二、手动失效缓存:optimizeDeps 的三个触发条件

Vite 会根据依赖版本、锁文件、配置变化自动判断缓存是否失效,但这些判断有边界,理解它才不会「莫名重新优化」。

// vite.config.js —— 反例:环境变量里塞进「非依赖」变量,触发无谓失效
import { defineConfig } from 'vite';

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), '');
  return {
    optimizeDeps: {
      include: ['lodash-es'],
      // 这里没写 exclude,esbuild 可能把不该预构建的也扫进去
    },
    define: { __DEV__: mode === 'development' },
  };
});

依赖预构建的失效与 optimizeDepsinclude/exclude 强相关。include 里的包若版本变化,缓存会失效;exclude 里的包则完全不参与预构建——放错位置的包,要么被反复重扫,要么被漏掉。

// vite.config.js —— 正例:显式声明 include/exclude,边界清晰
import { defineConfig } from 'vite';

export default defineConfig({
  optimizeDeps: {
    // 把这些 CJS 依赖纳入预构建,一次性打包成 ESM
    include: ['lodash-es', 'dayjs', 'axios'],
    // 本地 monorepo 包、无需转译的 ESM 包,排除以加快扫描
    exclude: ['@my-scope/lib', 'vue-demi'],
  },
});

include 是「强制预构建清单」,exclude 是「跳过清单」。把真正需要转译的 CJS 依赖放 include,把已是 ESM 或本地包放 exclude,扫描范围最小化,缓存失效的概率也随之降低。

三、冷启动回归排查:从 --debug 到缓存目录

当预构建表现异常(反复重扫、依赖报错),靠「感觉」排查效率极低。Vite 提供了明确的观测入口。

# 反例:全靠重启猜,不知道哪一步慢、哪一步重扫
pnpm dev

# 正例:看预构建到底做了什么、扫描了哪些依赖
DEBUG=vite:deps pnpm dev        # 打印依赖发现的完整日志
pnpm dev --force --debug         # 强制重建时输出更详细的 esbuild 日志
# 检查缓存目录,确认预构建产物是否新鲜
ls node_modules/.vite/deps/      # 看 _metadata.json 里的 hash 与依赖版本
cat node_modules/.vite/deps/_metadata.json

_metadata.json 记录了预构建产物的哈希与依赖版本。当它和当前 package-lock.jsonvite.config.js 不一致时,Vite 就会判定缓存失效。DEBUG=vite:deps 能直接看到「为什么失效、重新扫了哪些包」,把玄学变成可读日志。

四、用 optimizeDeps.forcenoDiscovery 收尾

开发期和高频容器化场景,有两处细节能显著影响预构建的稳定性。

// vite.config.js —— 正例:精细控制预构建的发现与强制
import { defineConfig } from 'vite';

export default defineConfig({
  optimizeDeps: {
    // 只预构建 include 里显式列的,不再自动扫描源码里的裸导入
    noDiscovery: true,
    include: ['lodash-es', 'dayjs'],
    // 完全跳过缓存,每次启动都重建(仅调试时用,别常驻)
    // force: true,
  },
});

noDiscovery: true 关闭「扫描源码里所有裸导入自动发现依赖」的行为,只预构建 include 里显式声明的包。好处是启动扫描更快、缓存判定更稳定(不再因为源码里一次新 import 就触发重扫),代价是你必须手动维护 include 清单。

# 反例:把 force 常驻打开,等于每次启动都白做一遍预构建
# vite.config.js 里 force: true 应该只用于临时调试

# 正例:CI 或 Docker 构建里,预先把缓存打进镜像
vite build
cp -r node_modules/.vite .vite-cache   # 把缓存作为构建产物层缓存

对于 Docker 多阶段构建,把 node_modules/.vite 作为一个可复用的缓存层,能让每次改代码重新构建时,依赖预构建这一步直接命中,省下可观的构建时间。

五、预构建失效的治理清单

把上面的点收敛成一张可执行清单:

// 正例:Vite 依赖预构建治理清单
// 1. include 放「需要转译的 CJS 依赖」,exclude 放「ESM/本地包」
// 2. 依赖变化先理解失效原因(DEBUG=vite:deps),别无脑 --force
// 3. 用 noDiscovery 收敛扫描范围,减少因源码 import 触发的重扫
// 4. 容器化构建把 .vite 缓存做成可复用层,命中即省时

// 反模式速查
//   装依赖后不清理缓存,新依赖漏预构建
//   force:true 常驻:每次启动都白做预构建
//   include/exclude 放反:CJS 漏转、ESM 被多扫
//   靠重启猜问题:不读 _metadata.json 和 DEBUG 日志

这四条的共同目标,是让依赖预构建从「时快时慢的黑盒」变成「缓存可观测、失效可解释、范围可收敛」的稳定环节。

结语

Vite 依赖预构建的坑,几乎都藏在「缓存何时失效」这件事上。依赖升级会失效、配置改动会失效、源码里一次新 import 也会触发重扫——而这些失效本可以通过 include/exclude 的精确划分、noDiscovery 的扫描收敛、以及 DEBUG=vite:deps 的可观测性,被提前消化。当预构建从「碰运气的黑盒」变成「命中可证、失效可知」的稳定环节,你才真正留住了 Vite 开发期那份「秒开」的体验,而不是在依赖一多之后,眼睁睁看着它退回冷启动的反复重扫。

0 评论

评论区

登录 后参与评论