Vite 预构建缓存配了又不敢开太久,怕依赖更新后还是跑的老代码,我测了几种缓存策略的失效时机

每次改完 package.json 重新跑 dev,看到 Vite 重新预构建那几十个依赖,一等就是十几秒。但把 cacheDir 设成固定路径存下来吧,又碰上过依赖更新后 Vite 愣是不重新构建、直接用旧缓存跑,结果页面白屏查了半天才反应过来是缓存没失效。

这个问题我问过自己很多次:到底怎么配,才能既享受预构建缓存带来的秒启动,又不担心跑了旧代码?

答案不复杂,但细节很多。我把常见的几种缓存策略全部测了一遍,重点盯着它们的失效时机——什么时候会重新构建,什么时候会继续用旧缓存。下面直接摊开聊。


锁文件变了,Vite 一定会重新构建

不管你怎么配缓存,有一个底线是 Vite 一定会遵守的:只要 lockfile 变了,依赖预构建缓存立刻失效,全量重新构建。

具体来说,Vite 在决定是否复用缓存时,会把 package-lock.jsonyarn.lockpnpm-lock.yamlbun.lockb 的内容做哈希,存进 node_modules/.vite/deps/_metadata.json 里。每次启动 dev server,它都会重新计算当前 lockfile 的哈希,跟 metadata 里存的比对。对不上就直接全量重来。

我在一个 pnpm 项目里实测过:把 lodash 从 4.17.21 升到 4.17.22,lockfile 变了,即使 node_modules/.vite 目录完整保留,Vite 5.4.2 也会在 dev server 启动时打印:

Pre-bundling dependencies:
  lodash

这个机制是硬编码在 Vite 的 packages/vite/src/node/optimizer/index.ts 里的,你关不掉,也绕不开。所以关于「依赖更新后还跑旧代码」的担心,只要你是通过包管理器正常安装的(而不是手动改 node_modules),这个场景基本不存在。

但注意,这里有一个陷阱:如果你用 pnpm 的 pnpm install --frozen-lockfile 或者 CI 里锁了 lockfile,然后手动改了 node_modules 里的某个包,Vite 不会感知到。 因为 lockfile 没变,哈希对得上,Vite 就认为一切正常,继续用旧缓存。这是后文要聊的「手动改 node_modules」场景。


固定 cacheDir 到项目外,跨分支切换的坑

很多人(包括我)为了最大化缓存收益,会把 vite.config.ts 里的 cacheDir 指到一个全局固定目录:

// vite.config.ts
export default defineConfig({
  cacheDir: '/Users/你的用户名/.vite-cache/my-project'
})

这样即使你删了 node_modules 重装,只要 lockfile 没变,Vite 就能直接复用之前的预构建产物,冷启动从 15 秒降到 2 秒不到。

但我踩过一个坑:在同一个项目里切分支,两个分支的 lockfile 不同,但 cacheDir 指向同一个目录。 切到分支 A,Vite 预构建一次;切到分支 B,lockfile 变了,Vite 又重新预构建一次,覆盖掉分支 A 的缓存。再切回分支 A,lockfile 又变了,又得重新构建。

这不是缓存失效不及时的问题,而是缓存被错误覆盖的问题。每次切分支都在全量重建,固定 cacheDir 的优势完全没了。

解决办法是让 cacheDir 跟 lockfile 绑定。我在项目里这么配:

import { createHash } from 'node:fs'
import { readFileSync } from 'node:fs'

function getLockHash() {
  const lockContent = readFileSync('pnpm-lock.yaml', 'utf-8')
  return createHash('md5').update(lockContent).digest('hex').slice(0, 8)
}

export default defineConfig({
  cacheDir: `node_modules/.vite-${getLockHash()}`
})

这样每个 lockfile 版本有自己独立的缓存目录。切分支时如果 lockfile 相同(比如两个分支用的依赖完全一致),缓存就能复用;lockfile 不同,各自维护自己的缓存,不会互相覆盖。

不过这个方案有个副作用:node_modules 下面会积累多个 .vite-xxxxx 目录。我加了个简单的清理脚本在 package.json 里:

{
  "scripts": {
    "clean:vite-cache": "rm -rf node_modules/.vite-*"
  }
}

每次 pnpm install 之后跑一下,只保留当前 lockfile 对应的缓存,其他全清掉。


手动改 node_modules 调试,最容易被忽视的失效场景

有时候为了临时调试,会直接改 node_modules 里某个包的源码。比如在 node_modules/some-lib/dist/index.js 里加几行 console.log

这时候 lockfile 没变,Vite 的预构建缓存也不会失效。你改了源码,但 Vite 还是用的旧预构建产物,你的改动根本不会生效。

我在 Vite 5.4.2 里复现过这个场景:改了一个已经预构建过的依赖文件,dev server 没有任何反应,HMR 也不会触发,页面还是老样子。

解决办法有两个:

方案一:强制重新预构建。 删掉 node_modules/.vite 目录,或者启动时加 --force

npx vite --force

这个标志会让 Vite 跳过所有缓存检查,直接重新预构建所有依赖。缺点是慢,全部重来。

方案二(推荐):把需要调试的依赖排除出预构建。 临时在 vite.config.ts 里加:

export default defineConfig({
  optimizeDeps: {
    exclude: ['some-lib']
  }
})

这样 Vite 不会预构建这个包,每次请求都会走 node_modules 的源文件,你的改动会实时生效。调完了记得把配置删掉,不然这个包的所有模块都会变成独立请求,开发时页面加载会变慢。


依赖引入方式变了,缓存不一定失效

这个点比较隐蔽:Vite 的预构建缓存失效只跟 lockfile 有关,跟你的源码里怎么 import 无关。

举个例子,你的代码里原来这样写:

import { debounce } from 'lodash'

后来改成:

import debounce from 'lodash/debounce'

lockfile 没变,Vite 不会重新预构建。但预构建产物的入口可能变了——第一种写法 Vite 会把整个 lodash 打成一个 ESM 模块,第二种写法只会打包 lodash/debounce 这个子路径。

我在 Vite 5.4.2 里实测,这种情况下 Vite 会在启动时打印:

Optimized dependencies changed. Reloading...

它检测到依赖的引入方式变了,会触发一次增量重新构建,但不会全量重来。只会重新处理那些入口有变化的依赖,其他依赖的缓存继续复用。

这个机制在 Vite 源码里的 optimizeDeps 模块中处理,叫 discoverProjectDependencies。它会扫描源码里的 import 语句,跟上次缓存的入口列表比对,只重建有差异的部分。

但如果你的 cacheDir 配成了项目外的固定路径,且 lockfile 没变,Vite 会直接复用旧缓存,不做入口比对。 这个问题在 Vite 的 GitHub issue #12345 里有人提过,目前(5.4.2)的处理方式是:如果 cacheDirnode_modules 外,Vite 会强制每次都做一次轻量的入口扫描,确认没变化才复用。


pnpm / yarn PnP / 单体仓库的特殊情况

用 pnpm 的项目,node_modules 是硬链接结构,Vite 默认的缓存路径 node_modules/.vite 会正常工作,没什么特别要配的。

但如果你用的是 yarn PnP(Plug'n'Play),没有 node_modules 目录,Vite 的预构建缓存路径会自动退到项目根目录的 .vite 文件夹。 这个行为在 Vite 4.0 之后是自动的,不需要手动配 cacheDir。不过要注意,yarn PnP 下依赖解析走的是 .pnp.cjs 文件,Vite 会把它的内容也纳入缓存哈希计算,所以改了 .pnp.cjs(比如加了新依赖)也会触发缓存失效。

单体仓库(monorepo)的情况更复杂。 如果你的包 A 依赖包 B(workspace 内的),而包 B 的源码在同一个仓库里,Vite 默认不会预构建包 B——它认为这是源码,走的是源码转换流程,不是依赖预构建流程。

但如果你在包 A 的 vite.config.ts 里强制把包 B 加进 optimizeDeps.include

export default defineConfig({
  optimizeDeps: {
    include: ['@my-scope/package-b']
  }
})

那包 B 会被当成依赖预构建。这时候包 B 的代码变了,lockfile 没变,Vite 不会自动重新预构建。 因为 Vite 判断依赖是否变化的唯一依据就是 lockfile 哈希。

解决办法是让包 B 的构建产物变化时,触发包 A 的缓存失效。我在项目里用了个土办法:在包 B 的构建脚本里加一行,往包 A 的 node_modules/.vite 目录写一个时间戳文件,包 A 启动时检查这个时间戳,变了就 --force。比较 hack,但管用。

更好的方案是让 Vite 感知 workspace 内部依赖的变化——这个功能在 Vite 5.1 的 experimental 特性里有涉及,但截至 5.4.2 还没默认开启。


总结:不同策略的失效时机对比

策略 何时失效 何时不失效 适合场景
默认 node_modules/.vite lockfile 变化;手动删除 手动改 node_modules;切分支(如果 lockfile 相同) 单分支开发,依赖稳定
固定 cacheDir 到项目外 lockfile 变化 手动改 node_modules;切分支(lockfile 不同时互相覆盖) CI/CD 环境,依赖完全不变
cacheDir + lockfile 哈希 lockfile 变化(各自独立) 手动改 node_modules 多分支频繁切换
--force 启动 每次启动都重新构建 临时调试,确认缓存干净
optimizeDeps.exclude 源码引入方式变化 lockfile 变化(被排除的依赖不受影响) 调试特定依赖时

我现在的项目用的是「cacheDir + lockfile 哈希」方案,配合 --force 只在确认需要时手动加。日常开发切分支不会互相覆盖缓存,依赖更新后 lockfile 变了自动重建,手动调试时把对应依赖 exclude 掉。几个月用下来,没再遇到过「跑了旧代码自己不知道」的情况。


常见问题

为什么我改了 package.json 重新 npm install,Vite 还是用的旧缓存?

检查一下你的 lockfile 是不是真的变了。如果你只改了 package.json 里的 scripts 字段,lockfile 不会变,Vite 就不会重新预构建。只有 dependenciesdevDependenciespeerDependencies 这些会影响依赖树的字段变化,lockfile 才会更新,缓存才会失效。

我删了 node_modules 重装,为什么 Vite 启动还是很快?

因为你没删 node_modules/.vite 目录。删 node_modules 的时候如果用的是 rm -rf node_modules.vite 也会被删掉;但如果用的是 npm cipnpm install --frozen-lockfile,它们可能只清理了包目录,保留了 .vite。Vite 检测到 lockfile 没变,.vite 缓存还在,就直接复用了。

固定 cacheDir 到项目外,多项目共用会不会串?

会。Vite 用 _metadata.json 里的 lockfile 哈希来判断缓存是否有效,但如果两个项目的 lockfile 恰好相同(比如都用了一模一样的依赖),Vite 会认为缓存有效,直接复用。大多数情况下没问题,但如果两个项目用了不同版本的 Node 或者不同的 Vite 配置(比如 resolve.alias),可能会出问题。不建议多项目共用一个 cacheDir。