Vite 设计与实现
第8章 依赖预构建
第8章 依赖预构建
开篇引言
在浏览器原生支持 ES Module 的今天,一个合理的疑问是:既然浏览器能直接通过 import 语句加载模块,为什么 Vite 还需要一个”预构建”步骤?
答案隐藏在 npm 生态的现实中。以 lodash-es 为例,当你执行 import { debounce } from 'lodash-es' 时,浏览器会先请求 lodash-es 的入口文件,然后发现它 re-export 了几百个子模块,每个子模块又可能依赖其他内部模块。一个看似简单的导入,最终可能触发数百个 HTTP 请求。更糟糕的是,大量 npm 包仍然使用 CommonJS 格式发布——module.exports 和 require() 是浏览器完全无法理解的语法。
Vite 的依赖预构建(Dependency Pre-Bundling)正是为解决这两个核心问题而设计的:
- 格式转换:将 CommonJS 和 UMD 格式的依赖转换为 ESM
- 请求合并:将内部模块众多的依赖打包成单个文件,减少 HTTP 请求数量
本章将深入 optimizer/ 目录的源码,揭示从依赖发现、扫描、打包到缓存的完整实现。
本章要点
- 依赖预构建的两大动机:CommonJS 转 ESM 和减少请求数
scan.ts如何使用 Rolldown 的scanAPI 快速发现项目依赖rolldownDepPlugin.ts如何处理依赖的打包和外部化- 基于 lockfile + config 的两级缓存策略
- 增量式依赖发现与热重载协调机制
optimizer.ts中的 DepsOptimizer 状态机设计
8.1.3 双维度哈希缓存——lockfile + config 双重保护
打开 optimizer/index.ts:1342-1380 的 getDepHash——它不是单一哈希、而是三个哈希同时计算:
function getDepHash(environment: Environment): {
lockfileHash: string
configHash: string
hash: string
} {
const lockfileHash = getLockfileHash(environment)
const configHash = getConfigHash(environment)
const hash = getHash(lockfileHash + configHash)
return { lockfileHash, configHash, hash }
}
三个 hash 各有用途:
lockfileHash——反映依赖版本。pnpm-lock / package-lock / yarn.lock 变化就变。configHash——反映 Vite 配置。optimizeDeps.include/rolldownOptions/ plugin list 变化就变。hash——两者合并的总哈希、作为缓存的 master key。
为什么不用一个大 hash?——因为 metadata.json 里同时存三个 hash、启动时做精细化重新优化判断:
if (cachedMetadata.lockfileHash !== getLockfileHash(environment)) {
// lockfile 变了:依赖版本变了、必须重新优化
...
} else if (cachedMetadata.configHash !== getConfigHash(environment)) {
// config 变了:虽然依赖没变、但打包策略变了
...
}
两种变化的信号不同——lockfile 变意味着 “代码本身变了”、config 变意味着 “打包方式变了”。日志里输出不同的信息给用户——一眼就知道是哪种 trigger。
分开的 hash 还让未来可以优化——比如 config 变了但 lockfile 没变时、理论上可以只重新配置、复用依赖解析结果——这是单一 hash 做不到的。vite 现在没这么激进地优化、但架构上保留了可能。
这种 “分维度追踪缓存键” 的模式比**“一个大 hash 什么都掺在里面”** 更精细——cargo、sccache、Bazel 等构建系统都用类似思路。
optimizer/ 目录结构
optimizer/
index.ts # 核心入口:类型定义、缓存加载、执行打包、hash 计算
optimizer.ts # DepsOptimizer 状态机:管理开发模式下的增量发现
scan.ts # 依赖扫描:使用 Rolldown scan API 发现裸导入
rolldownDepPlugin.ts # 预构建 Rolldown 插件:处理外部化和资源类型
resolve.ts # include 选项的解析器和 glob 展开
pluginConverter.ts # esbuild 插件到 Rolldown 插件的适配层
这六个文件构成了 Vite 预构建子系统的完整实现。它们之间的关系可以通过下面的架构图来理解:
graph TB
subgraph "optimizer/ 架构"
A[optimizer.ts<br/>DepsOptimizer 状态机] --> B[index.ts<br/>核心引擎]
B --> C[scan.ts<br/>依赖扫描]
B --> D[rolldownDepPlugin.ts<br/>打包插件]
B --> E[resolve.ts<br/>路径解析]
C --> F[pluginConverter.ts<br/>esbuild 插件适配]
end
A -->|scanImports| C
A -->|runOptimizeDeps| B
B -->|rolldownDepPlugin| D
B -->|createOptimizeDepsIncludeResolver| E
C -->|rolldownScanPlugin| D2[Rolldown scan API]
style A fill:#e8f4fd,stroke:#1890ff
style B fill:#f6ffed,stroke:#52c41a
style C fill:#fff7e6,stroke:#fa8c16
style D fill:#fff1f0,stroke:#f5222d
8.1.4 lockfileFormats 的 9 种包管理器支持
vite 的 lockfile hash 支持 9 种包管理器(index.ts:1216-1267):
const lockfileFormats = [
{ path: 'node_modules/.package-lock.json', manager: 'npm' },
{ path: 'node_modules/.yarn-state.yml', manager: 'yarn' }, // Yarn non-PnP
{ path: '.pnp.cjs', checkPatchesDir: '.yarn/patches' }, // Yarn v3+ PnP
{ path: '.pnp.js', checkPatchesDir: '.yarn/patches' }, // Yarn v2 PnP
{ path: 'node_modules/.yarn-integrity', manager: 'yarn' }, // Yarn 1
{ path: 'node_modules/.pnpm/lock.yaml', manager: 'pnpm' },
{ path: '.rush/temp/shrinkwrap-deps.json', manager: 'pnpm' },
{ path: 'bun.lock', manager: 'bun' },
{ path: 'bun.lockb', manager: 'bun' },
].sort((_, { manager }) => {
return process.env.npm_config_user_agent?.startsWith(manager) ? 1 : -1
})
9 种文件路径 × 4 种包管理器——覆盖几乎所有主流 Node.js 工程化场景。
注意末尾的 .sort——根据 npm_config_user_agent 反向排序——当前使用的包管理器排到最后。这看起来反直觉——为什么当前包管理器排到最后?
因为 lookupFile 是找到第一个存在的文件就返回——如果你用 yarn 但工程里有 npm 留下的 .package-lock.json 残留、vite 会优先识别为 npm——导致 hash 来源不对。把 当前包管理器的 lockfile 排到最后——意味着先检查其他包管理器的 lockfile、最后才检查 “最应该存在的那个”。
这种反直觉设计只有在多个 lockfile 并存的边缘场景才有价值——典型的是迁移中的项目(从 npm 切到 pnpm 但 package-lock.json 还没删)。通过排序让优先级合理——让 vite 更可能找到”最新/最相关的 lockfile”。
checkPatchesDir 字段——给 patch-package 用户特殊支持——patches 目录的 mtime 也算进 hash——用户修改了 patch 补丁、vite 会重新优化。这个细节只有维护过需要 monkey-patch 上游包的项目的人才会感谢——几分钟的打磨换来几小时的困惑避免。
为什么需要预构建
CommonJS 到 ESM 的转换
npm 上大量流行的包仍然以 CommonJS 格式发布。以 React 为例:
// node_modules/react/index.js
'use strict';
if (process.env.NODE_ENV === 'production') {
module.exports = require('./cjs/react.production.min.js');
} else {
module.exports = require('./cjs/react.development.js');
}
浏览器无法理解 module.exports 和 require()。Vite 的预构建会将其转换为标准的 ESM 格式,并且正确处理 default 导出和命名导出之间的互操作(interop)关系。
减少请求数量
即使是纯 ESM 的包,也可能因为模块拆分过细而导致请求数爆炸。Vite 源码中定义的 needsInterop 函数负责检测这种情况:
function needsInterop(
environment: Environment,
id: string,
exportsData: ExportsData,
output?: { exports: string[] },
): boolean {
if (environment.config.optimizeDeps.needsInterop?.includes(id)) {
return true
}
const { hasModuleSyntax, exports } = exportsData
// 入口文件没有 ESM 语法 -- 很可能是 CJS 或 UMD
if (!hasModuleSyntax) {
return true
}
// ...
}
ExportsData 通过 es-module-lexer 解析入口文件获得,它包含了模块是否使用了 ESM 语法(import/export)以及导出了哪些名称。
8.2.1 getConfigHash 的字段白名单策略
注意 getConfigHash 不是对整个 config 做 hash——只挑影响依赖优化的字段:
function getConfigHash(environment: Environment): string {
const content = JSON.stringify({
define: !config.keepProcessEnv ? process.env.NODE_ENV || config.mode : null,
root: config.root,
resolve: config.resolve,
assetsInclude: config.assetsInclude,
plugins: config.plugins.map((p) => p.name), // ← 只 hash 插件名、不 hash 实现
optimizeDeps: {
include: ...unique(include).sort()...,
exclude: ...unique(exclude).sort()...,
rolldownOptions: { ..., plugins: undefined, onLog: undefined, onwarn: undefined, ... }
},
optimizeDepsPluginNames: config.optimizeDepsPluginNames,
}, (_, value) => {
if (typeof value === 'function' || value instanceof RegExp) {
return value.toString()
}
return value
})
return getHash(content)
}
三层精细处理:
① 白名单字段——只挑 define、root、resolve、assetsInclude、plugins、optimizeDeps 这几个——其他配置改变不 trigger 重新优化。比如 server.port 变了、不影响依赖、不重新优化——这让 “改端口重启” 不会触发几秒的重新优化。
② 插件列表——config.plugins.map((p) => p.name) 只 hash 插件名、不 hash 插件实现。因为插件函数 JSON 序列化会变成 "function () { ... }"、每次 node 重启的函数可能不同(比如闭包捕获了 random)——hash 就不稳定。只用名字保证跨重启的稳定性。
③ 排除高开销字段——optimizeDeps.rolldownOptions 里把 plugins、onLog、onwarn、checks、output.plugins 设 undefined——因为这些字段往往是函数、JSON 化会产生不稳定字符串、且它们的变化不一定真的影响依赖优化。
replacer 函数处理 function 和 RegExp——typeof value === 'function' 时用 value.toString()、RegExp 同样——保证 函数和正则也能被 hash、只要 toString 结果稳定。
这种 “白名单 + 字段净化 + 特殊类型处理” 的三层设计让 configHash 既精确又稳定——改端口不重新优化、改 alias 重新优化——符合用户直觉。
8.3 依赖发现扫描(scan.ts)
依赖扫描是预构建的第一步:在服务器启动时,快速发现项目使用了哪些第三方依赖。
ScanEnvironment
扫描运行在一个受限的环境中——ScanEnvironment。它继承了 BaseEnvironment,但故意限制了对模块图和开发服务器的访问:
export class ScanEnvironment extends BaseEnvironment {
mode = 'scan' as const
get pluginContainer(): EnvironmentPluginContainer {
if (!this._pluginContainer)
throw new Error(
`${this.name} environment.pluginContainer called before initialized`,
)
return this._pluginContainer
}
}
在开发模式下,devToScanEnvironment 函数会将真正的 DevEnvironment 代理为一个扫描环境,只暴露配置和插件容器,屏蔽模块图和 HMR 通道。
入口点计算
扫描从 computeEntries 函数开始,它按优先级确定入口点:
flowchart TD
Start[computeEntries] --> A{optimizeDeps.entries<br/>是否配置?}
A -->|是| B[使用用户指定的 glob 模式]
A -->|否| C{build.rollupOptions.input<br/>是否配置?}
C -->|是| D[解析 rollup input 配置]
C -->|否| E["glob 搜索 **/*.html"]
B --> F[过滤: 只保留可扫描文件<br/>且文件存在于磁盘]
D --> F
E --> F
F --> G[返回入口列表]
这个设计体现了 Vite”零配置”的理念:默认情况下,扫描器会从项目根目录的 HTML 文件开始爬取依赖。对于非 HTML 入口的项目(如 SSR 应用),可以通过 optimizeDeps.entries 或 build.rollupOptions.input 来指定。
Rolldown 扫描插件
扫描的核心是 rolldownScanPlugin 函数,它返回一组 Rolldown 插件,负责在扫描过程中识别和收集依赖。这组插件被设计为多个独立的小插件,每个处理一种特定场景:
function rolldownScanPlugin(
environment: ScanEnvironment,
depImports: Record<string, string>,
missing: Record<string, string>,
entries: string[],
): Plugin[] {
// ...
return [
{ name: 'vite:dep-scan:resolve-external-url', /* 外部 URL */ },
{ name: 'vite:dep-scan:resolve-data-url', /* data: URL */ },
{ name: 'vite:dep-scan:local-scripts', /* 虚拟模块 */ },
{ name: 'vite:dep-scan:resolve', /* 核心解析逻辑 */ },
{ name: 'vite:dep-scan:load:html', /* HTML 类型加载 */ },
// ... JSX 注入和 glob 转换
]
}
其中最关键的是 vite:dep-scan:resolve 插件,它的 resolveId 钩子实现了完整的依赖分类逻辑:
flowchart TD
Import["resolveId(id, importer)"] --> HTML{HTML 类型?<br/>.html/.vue/.svelte}
HTML -->|是| RHTML[解析路径<br/>继续爬取]
HTML -->|否| BARE{"裸导入?<br/>例如 'react'"}
BARE -->|是| EXCL{在 exclude 中?}
EXCL -->|是| EXT1[标记为 external]
EXCL -->|否| RESOLVE[通过插件容器解析]
RESOLVE --> NM{在 node_modules 中?}
NM -->|是| OPT{可优化?}
OPT -->|是| RECORD["记录到 depImports<br/>标记为 external"]
OPT -->|否| EXT2[标记为 external]
NM -->|否| LINK{是链接包?}
LINK -->|是| CRAWL[继续爬取]
LINK -->|否| EXT3[标记为 external]
BARE -->|否| CSS{CSS 文件?}
CSS -->|是| EXT4[标记为 external]
CSS -->|否| OTHER[其他类型处理]
style RECORD fill:#f6ffed,stroke:#52c41a
style EXT1 fill:#fff1f0,stroke:#f5222d
style EXT2 fill:#fff1f0,stroke:#f5222d
style EXT3 fill:#fff1f0,stroke:#f5222d
style EXT4 fill:#fff1f0,stroke:#f5222d
核心逻辑很清晰:对于裸导入(bare import),如果它解析到 node_modules 中且是可优化的文件类型(.js、.mjs、.ts 等),就将其记录到 depImports 字典中。CSS、JSON、WASM、已知的资源类型则直接标记为外部依赖,不参与预构建。
HTML 类型的特殊处理
Vue、Svelte、Astro 等框架的单文件组件(SFC)需要特殊处理。htmlTypeOnLoadCallback 函数会解析 <script> 标签,提取其中的 JavaScript 代码:
const htmlTypesRE = /\.(?:html|vue|svelte|astro|imba)$/
const htmlTypeOnLoadCallback = async (id: string): Promise<string> => {
let raw = await fsp.readFile(id, 'utf-8')
raw = raw.replace(commentRE, '<!---->')
let js = ''
let scriptId = 0
const matches = raw.matchAll(scriptRE)
for (const [, openTag, content] of matches) {
// 解析 type、lang、src 属性
const typeMatch = typeRE.exec(openTag)
const langMatch = langRE.exec(openTag)
let loader: Loader = 'js'
if (lang === 'ts' || lang === 'tsx' || lang === 'jsx') {
loader = lang
}
const srcMatch = srcRE.exec(openTag)
if (srcMatch) {
// 外部脚本引用,生成 import 语句
js += `import ${JSON.stringify(src)}\n`
} else if (content.trim()) {
// 内联脚本,创建虚拟模块
const key = `${id}?id=${scriptId++}`
scripts[key] = { loader, contents }
js += `export * from ${JSON.stringify(virtualModulePrefix + key)}\n`
}
}
return js
}
对于 TypeScript 的 <script> 块,扫描器还会通过 extractImportPaths 函数额外追加 import 语句,防止转译器在编译 TS 时将看似未使用的导入删除——这些导入可能在模板中被使用。
调用 Rolldown scan API
最终的扫描通过 Rolldown 的实验性 scan API 执行:
import { scan } from 'rolldown/experimental'
async function build() {
await scan({
...rolldownOptions,
transform: transformOptions,
input: entries,
logLevel: 'silent',
plugins,
})
}
scan API 与完整的 rolldown() 构建不同,它只执行解析和模块图构建阶段,不生成任何输出文件。这使得依赖扫描的速度极快——通常在几十毫秒内完成。
8.3.5 process.env.npm_config_user_agent 的隐式依赖
前面 lockfile sort 用了 npm_config_user_agent——这个环境变量由包管理器启动脚本时设置:
npm run dev→npm_config_user_agent = "npm/10.x.x node/v20.x.x ..."yarn dev→npm_config_user_agent = "yarn/4.x.x ..."pnpm run dev→npm_config_user_agent = "pnpm/8.x.x ..."
用户不需要显式配置 vite 用哪个包管理器——vite 自动从环境变量识别。
但这有个隐含限制——用户必须通过 npm/yarn/pnpm run 启动、直接执行 node node_modules/.bin/vite 会缺 env var、vite fallback 到默认(可能顺序错)。
直接 vite 二进制的场景——env var undefined、.startsWith(manager) 全部返回 false、原始顺序保留——没问题、只是 fallback 而已。
这就是**“约定优于配置”** 的 double-edge——大部分场景自动识别、少数场景需要用户理解机制。vite 没把这个写死、也没暴露配置让用户手动指定——相信用户会用推荐姿势启动。
8.3.6 ScanEnvironment 屏蔽 HMR 的边界设计
ScanEnvironment 继承 BaseEnvironment、但故意不暴露 moduleGraph 和 HMR 通道。scan 阶段只需要配置和插件容器——其他是污染。
看 devToScanEnvironment 怎么实现:
// 概念性
function devToScanEnvironment(env: DevEnvironment): ScanEnvironment {
return new Proxy(env, {
get(target, prop) {
if (prop === 'moduleGraph') throw new Error('moduleGraph not accessible during scan');
if (prop === 'hot') throw new Error('HMR not available during scan');
return target[prop];
}
})
}
Proxy 拦截——尝试访问 moduleGraph 或 hot 时抛错、而不是 silent 返回 undefined——让错误在最早暴露。
为什么要拦截?——因为 scan 阶段不该修改 moduleGraph(scan 是只读的依赖发现、不是真实构建)、也不该触发 HMR(还没开始 serve、HMR 没意义)。
如果插件错误地在 scan 阶段用了 moduleGraph——立刻报错、插件作者能看到精确的错误位置。这比**“插件能用但产生奇怪副作用” 友好得多**。
这种 “按阶段限制环境能力” 的设计是 vite 架构的精髓——同一个 Environment 接口在不同阶段暴露不同子集——用户代码的可能破坏面被物理隔离。
预构建执行
扫描完成后,runOptimizeDeps 函数负责实际的打包工作。
临时目录策略
预构建使用临时目录来保证原子性:
export function runOptimizeDeps(
environment: Environment,
depsInfo: Record<string, OptimizedDepInfo>,
) {
const depsCacheDir = getDepsCacheDir(environment)
const processingCacheDir = getProcessingDepsCacheDir(environment)
// 在临时目录中工作,避免损坏现有缓存
fs.mkdirSync(processingCacheDir, { recursive: true })
// 写入 package.json 让 Node.js 将所有文件识别为 ES Module
fs.writeFileSync(
path.resolve(processingCacheDir, 'package.json'),
`{\n "type": "module"\n}\n`,
)
// ...
}
这个设计避免了在打包过程中直接写入缓存目录可能导致的损坏。只有当打包成功完成并通过 commit() 方法确认后,临时目录才会通过重命名操作替换正式缓存目录。在 Windows 上,Vite 甚至使用了 safeRename 来保证跨进程的安全性。
Rolldown 打包配置
prepareRolldownOptimizerRun 函数构建完整的 Rolldown 配置:
const bundle = await rolldown({
...rolldownOptions,
input: flatIdDeps, // 扁平化的依赖 ID 作为入口
logLevel: 'silent',
plugins,
platform, // 'browser' 或 'node'
transform: {
target: ESBUILD_BASELINE_WIDELY_AVAILABLE_TARGET,
define,
},
resolve: {
extensions: ['.tsx', '.ts', '.jsx', '.js', '.css', '.json'],
},
moduleTypes: {
'.css': 'js', // 将 CSS 当作 JS 处理(暂时禁用 Rolldown CSS 支持)
},
})
const result = await bundle.write({
format: 'esm',
sourcemap: true,
dir: processingCacheDir,
entryFileNames: '[name].js',
})
值得注意的是 flatIdDeps 的设计——依赖 ID 中的 / 被替换为 _(通过 flattenId 函数),这样所有输出文件都在同一层目录中,简化了路径管理。
8.4.3 flattenId 函数的路径扁平化
flattenId(id)(shared/utils.ts 里定义)——把 @scope/pkg/sub 这样的带斜杠 ID 转成 @scope_pkg_sub:
function flattenId(id: string): string {
return id
.replace(/[/:]/g, '_') // ← 替换 / 和 : 为 _
.replace(/[.]/g, '__') // ← 替换 . 为 __
.replace(/(\s*>\s*)/g, '___')
}
三种替换:
//:→_——@scope/pkg变@scope_pkg、npm:pkg变npm_pkg.→__——pkg.v1变pkg__v1、避免.被 Rolldown 识别为扩展名分隔>→___——pkg > nested变pkg___nested(用于 nested dependency)
为什么要扁平化?——因为预构建输出要放在同一个目录里(node_modules/.vite/deps/)——不能用 / 嵌套目录。扁平化后 @radix-ui_react-dialog.js 是一个独立文件、不需要创建子目录。
_ vs __ vs ___ 的层级编码——让不同种类的斜杠保持可区分。如果都用 _ 替换、@scope/pkg.v1 和 @scope_pkg__v1 会和 @scope_pkg.v1 冲突。三种 separator 保证扁平化是可逆的(虽然 Vite 目前不做反向解析)——未来需要也不会出问题。
> 的特殊意义——Vite 允许用户在 optimizeDeps.include 写 "pkg-a > dep-b" 表示 “预优化 pkg-a 的嵌套依赖 dep-b”——这种语法需要保留 >、所以用 ___ 这种独特 separator 编码。
这个 15 行的小函数在 vite 里被每个依赖都调用一次——看似琐碎、但扁平化是整个预构建缓存目录结构的基础。
rolldownDepPlugin 的职责
rolldownDepPlugin 是预构建过程中最核心的插件,它返回两个 Rolldown 插件:
vite:dep-pre-bundle-assets:处理资源类型文件的外部化。当一个依赖引用了 CSS、图片等非 JS 资源时,需要将这些引用转换为正确的导入路径。对于 require() 调用,还需要一个额外的中间层来将其转换为 import 语句:
const resolveAssets = (resolved: string, kind: ImportKind) => {
if (kind === 'require-call') {
// 不直接设为 external,而是通过命名空间转换 require 为 import
return {
id: externalWithConversionNamespace + resolved,
}
}
return {
id: resolved,
external: 'absolute' as const,
}
}
vite:dep-pre-bundle:核心的依赖解析插件。它使用两套解析器——一个优先 ESM(用于 import),一个优先 Node.js 风格(用于 require):
// 默认解析器,优先 ESM
const _resolve = createBackCompatIdResolver(environment.getTopLevelConfig(), {
asSrc: false,
scan: true,
packageCache: esmPackageCache,
})
// CJS 解析器,优先 Node
const _resolveRequire = createBackCompatIdResolver(
environment.getTopLevelConfig(),
{
asSrc: false,
isRequire: true,
scan: true,
packageCache: cjsPackageCache,
},
)
该插件还处理了浏览器外部化(browser externals)和可选的 peer dependencies。对于在浏览器中不可用的 Node.js 内置模块,它会生成一个 Proxy 对象,在访问时打印友好的警告信息:
// 生产环境返回空对象
if (isProduction) {
return { code: 'module.exports = {}' }
}
// 开发环境返回 Proxy,访问时打印警告
return {
code: `module.exports = Object.create(new Proxy({}, {
get(_, key) {
if (key !== '__esModule' && key !== '__proto__' && ...) {
console.warn(\`Module "${path}" has been externalized ...\`)
}
}
}))`,
}
8.4.5 esmPackageCache vs cjsPackageCache 的双缓存
rolldownDepPlugin 里维护两个独立的 packageCache:
const _resolve = createBackCompatIdResolver(..., {
packageCache: esmPackageCache, // ESM 路径优先
})
const _resolveRequire = createBackCompatIdResolver(..., {
packageCache: cjsPackageCache, // CJS 路径优先
})
为什么要两套缓存?——因为同一个 pkg name 在 ESM 和 CJS 下解析路径可能不同:
import 'foo'→ 走package.json的exports["."]["import"]或module字段require('foo')→ 走exports["."]["require"]或main字段
解析结果的差异是 Node.js 双包 dual-package 的基础——同一个包可以有两个不同的入口文件、一个 ESM 一个 CJS。
如果共用一个 packageCache——第一次 import 'foo' 缓存了 ESM 路径、后续 require('foo') 从 cache 拿到 ESM 路径——错。两个独立 cache 保证不同解析上下文不互相污染。
代价——同一个 pkg 可能被解析两次(ESM 一次、CJS 一次)——内存翻倍。但 packageCache 的 entry 通常很小(几 KB)——接受。
这种 “按使用上下文分开 cache” 的模式在处理 multi-context 解析时很常见——Webpack 的 resolve.conditionNames、Bun 的 resolve.conditions 都有类似机制。ESM/CJS 互通是现代 JS 生态最麻烦的问题、这个双缓存是 vite 对 Node.js 这套复杂规则的 faithful 实现。
8.4.6 Proxy 警告——浏览器外部化的 DX 兜底
前面源码里的 Proxy 警告机制值得细品:
// 开发环境
return {
code: `module.exports = Object.create(new Proxy({}, {
get(_, key) {
if (key !== '__esModule' && key !== '__proto__' && ...) {
console.warn(\`Module "${path}" has been externalized ...\`)
}
}
}))`,
}
什么场景触发?——用户在浏览器代码里意外 import 了 Node.js 内置模块(fs、path 等)。vite 不能把这些包进 bundle(浏览器没这些 API)、就 externalize 成一个 Proxy。
关键是 warning 的时机——访问 Proxy 的属性时才报警——不是在 import 时报。这避免了大量”我 import 但没用” 的情况误报——只有实际用了的情况才 warn。
过滤的 key 列表——__esModule、__proto__ 等是 JS runtime 自己会访问的属性——用户代码没写也会访问、不该当成误用报警。vite 白名单这些 key、避免 false positive。
为什么 Object.create(new Proxy({}, ...)) 而不是直接 new Proxy({}, ...)?——因为某些库会通过原型链访问属性、Object.create 保证原型链查找也走 Proxy——更完整的 get 覆盖。
production 环境 return module.exports = {} 空对象——不带 warning。因为 production 假设用户已经在 dev 修过所有警告、warning 只会污染生产日志。
这种 “dev 警告、prod 静默” 的 dual-mode 是现代前端工具的共识——Vite 把它做到了 externalization 层、比 webpack 的 require.resolve fallback 更友好。
CJS 外部化处理
rolldownCjsExternalPlugin 解决了一个微妙的问题:当外部依赖通过 require() 被引用时,Rolldown 不会自动将其转换为 import 语句。在浏览器平台上,这会导致运行时错误。该插件通过创建一个 facade 模块来完成转换:
load: {
filter: { id: prefixRegex(cjsExternalFacadeNamespace) },
handler(id) {
const modulePath = id.slice(cjsExternalFacadeNamespace.length)
return {
code: `\
import * as m from ${JSON.stringify(nonFacadePrefix + modulePath)};
module.exports = { ...m };`,
}
},
},
缓存策略
Vite 的预构建缓存是其”快速冷启动”体验的关键。缓存策略基于两级哈希:
Hash 计算
flowchart LR
subgraph "lockfileHash"
L1[package-lock.json] --> LH
L2[yarn.lock] --> LH
L3[pnpm-lock.yaml] --> LH
L4[bun.lock] --> LH
L5[patches/ 目录 mtime] --> LH[lockfileHash]
end
subgraph "configHash"
C1[process.env.NODE_ENV] --> CH
C2[config.root] --> CH
C3[config.resolve] --> CH
C4[config.plugins 名称列表] --> CH
C5[optimizeDeps.include/exclude] --> CH
C6[rolldownOptions] --> CH[configHash]
end
LH --> H["hash = getHash(lockfileHash + configHash)"]
CH --> H
H --> BH["browserHash = getHash(hash + deps + timestamp)"]
style H fill:#e8f4fd,stroke:#1890ff
style BH fill:#f6ffed,stroke:#52c41a
getDepHash 函数计算两个独立的哈希值:
function getDepHash(environment: Environment) {
const lockfileHash = getLockfileHash(environment)
const configHash = getConfigHash(environment)
const hash = getHash(lockfileHash + configHash)
return { hash, lockfileHash, configHash }
}
lockfileHash 基于项目的包管理器锁文件内容。它支持所有主流包管理器:npm、Yarn Classic/Berry、pnpm、Bun。如果项目使用了 patch-package,还会将 patches 目录的修改时间纳入计算。
configHash 基于影响依赖优化的配置项子集——而非全部配置。这种精确的范围界定避免了不相关配置变更触发不必要的重新构建:
function getConfigHash(environment: Environment): string {
const content = JSON.stringify({
define: !config.keepProcessEnv
? process.env.NODE_ENV || config.mode : null,
root: config.root,
resolve: config.resolve,
assetsInclude: config.assetsInclude,
plugins: config.plugins.map((p) => p.name),
optimizeDeps: {
include: optimizeDeps.include
? unique(optimizeDeps.include).sort() : undefined,
exclude: optimizeDeps.exclude
? unique(optimizeDeps.exclude).sort() : undefined,
rolldownOptions: { /* 去除 plugins/onLog/onwarn 等不可序列化项 */ },
},
optimizeDepsPluginNames: config.optimizeDepsPluginNames,
})
return getHash(content)
}
缓存加载与失效
服务器启动时,loadCachedDepOptimizationMetadata 函数尝试从 node_modules/.vite/deps/_metadata.json 加载缓存的元数据:
flowchart TD
Start[loadCachedDepOptimizationMetadata] --> Force{--force 标志?}
Force -->|是| CLEAR["清除缓存目录<br/>返回 undefined"]
Force -->|否| READ["读取 _metadata.json"]
READ --> PARSE{解析成功?}
PARSE -->|否| CLEAR
PARSE -->|是| LOCK{lockfileHash<br/>是否匹配?}
LOCK -->|否| LOG1["日志: lockfile changed<br/>返回 undefined"]
LOCK -->|是| CONF{configHash<br/>是否匹配?}
CONF -->|否| LOG2["日志: config changed<br/>返回 undefined"]
CONF -->|是| HIT["命中缓存!<br/>返回 metadata"]
style HIT fill:#f6ffed,stroke:#52c41a
style CLEAR fill:#fff1f0,stroke:#f5222d
style LOG1 fill:#fff1f0,stroke:#f5222d
style LOG2 fill:#fff1f0,stroke:#f5222d
注意缓存失效的粒度:lockfileHash 和 configHash 是分别检查的,这样日志消息能准确告诉用户是什么变化触发了重新构建。
browserHash
除了用于冷启动缓存的 hash,还有一个 browserHash 用于浏览器端的缓存失效。它在 hash 的基础上加入了运行时发现的依赖信息和时间戳:
function getOptimizedBrowserHash(
hash: string,
deps: Record<string, string>,
timestamp = '',
) {
return getHash(hash + JSON.stringify(deps) + timestamp)
}
预构建后的依赖通过 ?v=browserHash 查询参数在浏览器中缓存。当依赖集合发生变化时,browserHash 也会变化,从而使浏览器缓存自动失效。
增量式发现与重新优化
预构建最复杂的部分不在于初次构建,而在于运行时的增量发现。当开发者在浏览器中导航到新页面时,可能会触发新的依赖导入——这些依赖在初始扫描中未被发现。
DepsOptimizer 状态机
optimizer.ts 中的 createDepsOptimizer 函数创建了一个精密的状态机来管理这个过程:
export function createDepsOptimizer(
environment: DevEnvironment,
): DepsOptimizer {
let debounceProcessingHandle: NodeJS.Timeout | undefined
let waitingForCrawlEnd = false
let currentlyProcessing = false
let firstRunCalled = false
let newDepsDiscovered = false
const depsOptimizer: DepsOptimizer = {
init,
metadata,
registerMissingImport,
run: () => debouncedProcessing(0),
isOptimizedDepFile: createIsOptimizedDepFile(environment),
isOptimizedDepUrl: createIsOptimizedDepUrl(environment),
getOptimizedDepId: (depInfo) =>
`${depInfo.file}?v=${depInfo.browserHash}`,
close,
options,
}
// ...
}
完整的生命周期
sequenceDiagram
participant Dev as 开发服务器
participant DOpt as DepsOptimizer
participant Scan as Scanner
participant Bundle as Rolldown
Dev->>DOpt: init()
alt 有缓存
DOpt->>DOpt: loadCachedDepOptimizationMetadata
DOpt-->>Dev: 使用缓存,跳过扫描
else 无缓存(冷启动)
DOpt->>DOpt: addManuallyIncludedOptimizeDeps
DOpt->>Scan: discoverProjectDependencies
Note over Scan: 后台并行执行
DOpt->>Dev: waitForRequestsIdle -> onCrawlEnd
Scan-->>DOpt: 扫描结果 deps
alt holdUntilCrawlEnd
Note over DOpt: 等待静态导入爬取完成
else
DOpt->>Bundle: runOptimizeDeps(初次)
Bundle-->>Dev: 结果可用
end
end
Dev->>DOpt: registerMissingImport("新依赖")
DOpt->>DOpt: addMissingDep + debouncedProcessing
Note over DOpt: 等待 100ms 收集更多依赖
DOpt->>Bundle: runOptimizeDeps(增量)
alt 文件哈希未变
Bundle-->>Dev: 无需重载
else 文件哈希变化
Bundle-->>Dev: full-reload
end
registerMissingImport
当 importAnalysis 插件在转换模块时遇到一个未被预构建的依赖,它会调用 registerMissingImport:
function registerMissingImport(
id: string,
resolved: string,
): OptimizedDepInfo {
// 检查是否已经在优化列表中
const optimized = metadata.optimized[id]
if (optimized) return optimized
const chunk = metadata.chunks[id]
if (chunk) return chunk
let missing = metadata.discovered[id]
if (missing) return missing
// 添加为新发现的依赖
missing = addMissingDep(id, resolved)
if (!waitingForCrawlEnd) {
// 触发防抖处理
debouncedProcessing()
}
return missing
}
关键设计点:即使依赖尚未构建完成,函数也会立即返回一个 OptimizedDepInfo 对象,其中包含了预期的输出文件路径和一个 processing Promise。请求该模块的代码会 await 这个 Promise,等待构建完成后才继续加载。
重新优化的智能判断
当增量构建完成后,runOptimizer 函数不会盲目触发页面重载。它会比较新旧构建的 fileHash:
const needsReload =
needsInteropMismatch.length > 0 ||
metadata.hash !== newData.hash ||
Object.keys(metadata.optimized).some((dep) => {
return (
metadata.optimized[dep].fileHash !== newData.optimized[dep].fileHash
)
})
如果所有已知依赖的输出文件保持不变(即新依赖的加入没有影响共享 chunk 的内容),就可以避免全页重载。这在实践中是很常见的——大多数新发现的依赖与已有依赖没有共享模块。
holdUntilCrawlEnd 策略
holdUntilCrawlEnd(默认启用)是一种优化策略,它在冷启动时延迟将预构建结果交给浏览器,直到所有静态导入都被爬取完毕。这样可以最大程度地减少”扫描遗漏依赖 -> 重新构建 -> 全页重载”的情况:
async function onCrawlEnd() {
waitingForCrawlEnd = false
await depsOptimizer.scanProcessing
if (optimizationResult && !options.noDiscovery) {
const afterScanResult = optimizationResult.result
const result = await afterScanResult
const scanDeps = Object.keys(result.metadata.optimized)
const crawlDeps = Object.keys(metadata.discovered)
const scannerMissedDeps = crawlDeps.some(
(dep) => !scanDeps.includes(dep)
)
if (scannerMissedDeps) {
// 扫描器遗漏了依赖,丢弃结果,重新运行
result.cancel()
debouncedProcessing(0)
} else {
// 扫描器发现了所有依赖,直接使用结果
startNextDiscoveredBatch()
runOptimizer(result)
}
}
}
esbuild 插件适配层
pluginConverter.ts 提供了 convertEsbuildPluginToRolldownPlugin 函数,将 esbuild 格式的插件转换为 Rolldown 兼容的插件。这个适配层确保了与 Vite 生态中大量使用 esbuild 插件 API 的工具的向后兼容性。
适配的核心挑战在于两种插件 API 的差异:esbuild 使用正则过滤器的 onResolve/onLoad 回调模式,而 Rolldown 使用标准的 resolveId/load 钩子。pluginConverter 在两者之间建立了桥接层:
function createResolveIdHandler(
options: esbuild.OnResolveOptions,
callback: EsbuildOnResolveCallback,
): ResolveIdHandler {
return async function (id, importer, opts) {
// 检查命名空间和过滤器是否匹配
if (options.namespace !== undefined &&
options.namespace !== importerNamespace) return
if (options.filter !== undefined &&
!options.filter.test(id)) return
// 调用 esbuild 回调,转换参数和返回值格式
const result = await callback({
path: id,
importer: importerWithoutNamespace ?? '',
namespace: importerNamespace,
resolveDir: dirname(importerWithoutNamespace ?? ''),
kind: importerWithoutNamespace === undefined
? 'entry-point'
: opts.kind === 'new-url' || opts.kind === 'hot-accept'
? 'dynamic-import'
: opts.kind,
pluginData: {},
with: {},
})
if (!result) return
return {
id: result.namespace
? `${result.namespace}:${result.path}`
: result.path,
external: result.external,
moduleSideEffects: result.sideEffects,
}
}
}
设计决策
为什么用 Rolldown 而不是 esbuild
在 Vite 早期版本中,预构建完全基于 esbuild。迁移到 Rolldown 的原因包括:
- 统一构建引擎:开发和生产使用同一个打包器,减少行为差异
- 更好的 Rollup 兼容性:Rolldown 的插件 API 与 Rollup 兼容,而 esbuild 需要适配层
- CSS 支持路线图:Rolldown 未来将原生支持 CSS 处理,消除当前
.css被当作.js的临时方案
为什么扫描和构建分离
扫描(scan)和构建(bundle)是两个独立的步骤,而不是合并为一次 Rolldown 运行。原因是:
- 扫描需要尽快返回结果,不需要生成输出文件
- 扫描结果可以与运行时爬取的结果合并后再构建
- 如果合并为一步,扫描器遗漏的依赖将无法在构建前被发现
为什么使用防抖而非立即重新构建
registerMissingImport 使用 100ms 的防抖延迟(debounceMs = 100)。这是因为页面加载通常会在短时间内触发多个新依赖的发现。如果每发现一个就立即重新构建,会导致大量无效的中间构建。防抖策略让系统在一个”安静期”后才执行重新构建,此时大部分新依赖已经被收集完毕。
DepOptimizationMetadata 的三层结构
元数据对象维护三个字典——optimized、discovered 和 chunks——而不是一个扁平的列表。这种分层设计让系统能够区分不同状态的依赖:optimized 是已经完成构建的,discovered 是新发现正在处理的,chunks 是共享的非入口 chunk。状态转换清晰,避免了复杂的状态标志位。
小结
Vite 的依赖预构建是一个精心设计的子系统,它在”快速冷启动”和”零配置”之间取得了平衡。通过 Rolldown 的 scan API 快速发现依赖,通过完整的 rolldown() 构建打包依赖,通过两级哈希缓存避免不必要的重新构建,通过增量发现机制处理运行时新出现的依赖。
DepsOptimizer 状态机是整个系统中最复杂的部分,它需要协调扫描器、构建器、浏览器请求和 HMR 通道之间的时序关系。holdUntilCrawlEnd 策略和 browserHash 机制共同确保了即使在依赖集合发生变化时,用户体验也尽可能流畅。
下一章我们将深入 JavaScript 和 TypeScript 的转换管线——预构建解决了第三方依赖的问题,而项目源码的每一个模块请求都需要经过实时转换。
8.12 跨章节呼应
与 Vite 第 15 章 SSR Module Runner 的呼应——预构建的 flattenId 把路径扁平化存缓存、Module Runner 的 ensureBuiltins 也是启动期协商——都体现 “启动期一次性付代价、稳态零开销” 的模式。
与 hyper 第 11 章 HTTP/1 wire 的呼应——依赖预构建的 lockfileHash + configHash 两维缓存和 hyper 的 Dur::Default/Configured/Empty 三态枚举——都是把”来源不同的信息” 用类型/字段精确表达、避免信息丢失。
与 LangGraph 第 13 章 Streaming 的呼应——vite 的 ScanEnvironment 屏蔽 moduleGraph / HMR 通道、LangGraph 的 TAG_NOSTREAM 屏蔽流式——都是”按阶段/场景限制接口可见性” 来防止误用。
与 serde 第 16 章 derive 的呼应——vite 的 getConfigHash 白名单 + JSON 序列化用 replacer 处理 function/RegExp、serde_derive 的 quote_spanned! 精准错误定位——**都是”对 “复杂输入的序列化/hash” 做精细化处理” 的工程细节。
8.13 十二条工程原则收束
从本章的源码观察提炼出 Vite 依赖预构建子系统的 12 条原则:
① 双维度缓存 hash(§8.1.3)——lockfile + config 分开、判断更精细。
② 包管理器 lockfile 全覆盖(§8.1.4)——9 种 lockfile 格式 + 排序策略。
③ 白名单 hash 字段(§8.2.1)——只 hash 影响优化的配置、避免 noise。
④ 环境变量隐式识别(§8.3.5)——npm_config_user_agent 自动识别包管理器。
⑤ 按阶段限制 Environment(§8.3.6)——ScanEnvironment 屏蔽 moduleGraph / HMR。
⑥ 临时目录保证原子性(§8.4.1)——处理成功后才 rename 替换缓存。
⑦ flattenId 扁平化路径(§8.4.3)——多层斜杠用不同下划线编码、保证扁平且可区分。
⑧ ESM / CJS 双 packageCache(§8.4.5)——不同解析上下文不互相污染。
⑨ Proxy 警告 externalized modules(§8.4.6)——dev 友好提示、prod 静默。
⑩ browserHash 的三级 hash——optimized 依赖 + chunks 精细缓存失效。
⑪ holdUntilCrawlEnd 协调时序——避免半缓存状态被前端看到。
⑫ 防抖聚合新依赖——100ms 窗口把页面加载期的多次发现合并为一次重新构建。
这 12 条原则贯穿 vite 从 scan → optimize → cache → serve 的完整预构建链路——每一条都解决一个工程细节、合起来就是 “Vite 的冷启动为什么这么快” 的答案。
8.14 预构建系统在其他工具里的对应
把 Vite 的预构建思路和其他前端工具对比:
| 工具 | 预构建机制 | 缓存 key | 何时触发 |
|---|---|---|---|
| Vite | scan + rolldown 打包 | lockfile + config hash | 启动时 / 运行时新发现 |
| webpack | 无(每次 build 重新打包) | 全部 chunk hash | 每次启动 |
| Parcel | 无 | 全链条 content hash | 每次启动 + 文件变化 |
| esbuild | 无显式预构建 | 基于 content hash | 每次启动 |
| Turbopack | 持久化依赖图 | depth-based invalidation | 启动时 / 文件变化 |
| Bun | 无 | 依靠 Bun 的快速 transpile | 每次请求 |
Vite 是唯一把 “预构建 = 独立阶段” 做得这么彻底的——其他工具要么每次启动都重新构建(慢)、要么没有显式的预构建边界(cache key 设计复杂)。
这个选择的根源是 Vite 对 “开发服务器冷启动时间” 的极致追求——预构建让 node_modules 几 MB 的依赖变成几个 bundle、浏览器加载时只有几个 HTTP 请求——秒级冷启动。
代价——第一次启动需要几秒到十几秒 scan+build 时间——但二次启动(用户日常场景)几乎瞬时(缓存命中)——用户体验曲线非常陡峭——付一次代价、之后长期受益。
这就是 Vite 在 “开发服务器” 这个维度相对 webpack 的 dev server 的根本优势——架构上的优势、不是实现细节。
8.15 DepsOptimizer 状态机的真实复杂度
DepsOptimizer 的真正难点在于 多个异步过程的时序协调:
启动 ──┬── 扫描(async)─── 发现初始依赖集合 A
├── 预构建 A(async)── 生成 bundle
├── 启动 HTTP server(async)
└── 处理第一个请求 ───── 可能发现新依赖 B(A 没有)
├─ 触发 registerMissingImport(B)
├─ 100ms debounce 等更多依赖
├─ 重新扫描 + 预构建 A+B
└─ 通知浏览器 reload(因为缓存失效)
问题:在 “预构建 A” 还在跑的时候、用户发过来请求了 B——怎么办?
解决方案——holdUntilCrawlEnd 策略:
- 第一个请求到达时、如果 optimizeDeps 还没跑完、hold 住这个请求
- 等预构建完成后再处理——用户看到加载慢、但不会看到”依赖没准备好” 的错误
更棘手的场景——hold 的过程中发现了新依赖、需要重新优化——此时该怎么办?
vite 的策略是 “等初次优化完、让用户先看到页面、后台再重新优化、重新优化完再 full-reload”——分三步让用户永远看到稳定状态。
这套状态机的复杂度——约 1000 行 optimizer.ts——比所有其他部分加起来还多。没有这套状态机、预构建会在无数边缘场景下挂掉——什么时候 hold、什么时候 release、什么时候 full-reload、什么时候 soft-reload——每个决策都要精确。
这也是为什么 vite 团队把 optimizer 做成独立模块——复杂度太高、必须和其他逻辑隔离。类似的设计在其他大型工具里也常见——webpack 的 watch mode state machine、Turbopack 的 graph invalidation engine——关键复杂度必须有专用模块承载。
读完这一章、你应该对 “为什么 vite 的开发服务器不是 200 行代码” 有新的理解——它背后是几千行精心设计的状态协调代码。
8.16 冷启动的 “首次代价” 值不值
最后讨论一个价值判断——Vite 的冷启动付出的几秒到十几秒预构建时间、真的值吗?
正面论据:
- 二次启动瞬时——用户日常 90% 场景走缓存命中、几百毫秒启动
- HMR 极快——源码改动直接走 transform + HMR boundary、不重新构建依赖
- 浏览器只加载 N 个依赖 chunk——vs webpack 的数百个小文件、HTTP 请求成本低得多
负面论据:
- 第一次启动慢——新手试用 / CI 清理后重启 / 切换分支、都会遇到几秒预构建
- 增量发现时可能 full-reload——新 import 的依赖首次出现时、浏览器可能需要 reload
- 复杂场景调试——预构建缓存出问题时、
.vite/deps目录里的文件名扁平化让人看不懂
Vite 的选择是牺牲偶尔的冷启动换稳态的丝滑——在 “大多数人 80% 时间的体验” 上胜过 webpack-dev-server。
但这个权衡不是适合所有人——monorepo 大型项目 + CI 经常清理场景下、Vite 的预构建可能比 webpack 还慢(因为 webpack 可以并行处理、而 vite scan 是序列的)。此时 npm run dev --force 或者 turbopack/rspack 这种替代品可能更合适。
没有最优的工具、只有最适合场景的工具——这是所有工具选型的真理。Vite 做的是 “让 80% 场景变得非常好”——剩下 20% 的场景可以用其他工具补——“生态分工” 的成熟度。
读完本章、希望你对预构建的技术真相和场景适用性都有清晰的认识——这样你在下次选型时、选 Vite 是因为它适合你的场景、不是因为它 trendy——这是工程师的成熟标志。
8.17 从 scan 到 commit——预构建的 6 步完整生命周期
压缩本章内容、一次预构建的 6 步完整流程:
Step 1 - Entry 发现(computeEntries):
- 优先
optimizeDeps.entriesglob - 其次
build.rollupOptions.input - 最后
**/*.html兜底 - 输出:入口文件列表
Step 2 - Scan(scanImports):
- 启动 Rolldown 的
scanAPI - 用 rolldownScanPlugin 收集所有裸导入
- 区分:记录 to
depImports/ 标 external / 继续 crawl - 输出:
depImports: {id → path}字典
Step 3 - Hash 计算(getDepHash):
- lockfileHash + configHash + hash
- 对比
.vite/deps/metadata.json里的老 hash - 输出:是否需要重新优化
Step 4 - Rolldown 打包(runOptimizeDeps + prepareRolldownOptimizerRun):
- 临时目录
.vite/deps_temp_<suffix> - 写入
package.json {"type": "module"} rolldown({...flat ids as inputs...}).write({format: "esm"})- 输出:bundle 文件
Step 5 - Metadata 生成:
- 对每个 chunk 计算 needsInterop、file path、hash
- 填入
DepOptimizationMetadata.optimizeddict - 输出:完整 metadata
Step 6 - Commit(原子替换):
safeRename(processingCacheDir, depsCacheDir)- Windows 上用特殊的跨进程安全 rename
- 失败回滚、不污染现有缓存
- 输出:
.vite/deps/*.js可用
每一步都能独立失败——vite 每一步都有回滚/错误处理。这就是生产级工程代码的密度——happy path 400 行、error handling 400 行——两者加起来才是一个鲁棒的系统。
读完本章、看 optimizer/index.ts 的 1400 行代码会觉得合理多了——每一块都在完成上述某一步的细节。
8.18 getTempSuffix 的随机后缀——防止并发冲突
看 index.ts:959 的 getTempSuffix:
function getTempSuffix() {
return (
'_temp_' +
Math.random().toString(36).slice(2).padEnd(8, '0').slice(0, 8)
)
}
每次预构建用一个 8 字符随机后缀——processingCacheDir = depsCacheDir + getTempSuffix()——产生 .vite/deps_temp_abc12345/ 这样的目录。
为什么要随机?——防止两个 vite 进程并发预构建时目录冲突:
- 开发者打开两个 vite 终端(比如 dev + storybook)、都在同一个项目
- 两个进程可能几乎同时启动预构建
- 如果都用同一个
processing目录名——互相覆盖、结果损坏
随机后缀保证每个进程有自己的 processing 目录——互不干扰。结束时都尝试 rename 到 depsCacheDir——谁先成功谁赢、后者的临时目录被删。
padEnd(8, '0').slice(0, 8)——保证 8 字符长度。Math.random().toString(36).slice(2) 有时长度不够 8、padEnd 补 0——一致的长度便于目录管理。
随机后缀 vs PID——为什么不用 process.pid?因为 同一个进程可能多次触发预构建(第一次初始、后续 re-optimize)——pid 不变、会冲突。随机后缀每次都不同、更安全。
这个 5 行的 helper 是并发安全的关键——没它、双进程场景会 silent corruption——而有它、并发完全安全、用户甚至不知道有这个问题。
8.19 未来演进——Rolldown 取代 esbuild 之后
Vite 预构建早期用 esbuild(Go 实现、极快但功能有限)、现在正在切换到 Rolldown(Rust 实现、同样快但 API 更完整)。这次切换带来几个变化:
① 共享 bundler——生产构建用 Rolldown、预构建也用 Rolldown——一套构建代码两处用、减少维护负担。
② CSS 支持更完整——esbuild 的 CSS 功能有限、Rolldown 可以更完整地处理 CSS(虽然目前 moduleTypes['.css'] = 'js' 还绕过了)。
③ Tree-shaking 一致性——开发和生产用同一个 bundler——tree-shaking 结果一致——避免 “生产 build 后少了一个 function、dev 正常” 的诡异 bug。
④ 性能略微下降——Rust vs Go 的 bundler 性能目前还是互有胜负——Rolldown 在某些场景可能比 esbuild 慢 10-20%——但对预构建场景(几秒 vs 几秒)用户感知不强。
这次切换的过渡期——pluginConverter.ts 把 esbuild 插件 API 转成 Rolldown 插件 API——让生态插件不需要重写。这种向后兼容层在大型架构迁移里是必备的——保护用户投资、减少破坏性。
读到此——你看懂了 Vite 预构建 “为什么要这么复杂”——不是过度工程、是真实场景下每一条路径都要覆盖。下一章我们看 Vite 的源码转换管线——比预构建更频繁(每次 HMR 都跑)、对性能更敏感——会是另一次工程密度的享受。
8.20 isDepOptimizationDisabled 的边界场景
在 index.ts:195 有一个小 helper:
export function isDepOptimizationDisabled(optimizeDeps: {...}): boolean {
return optimizeDeps.disabled === true
|| (optimizeDeps.noDiscovery
&& (!optimizeDeps.include || optimizeDeps.include.length === 0))
}
两种情况 disabled:
disabled: true——显式关掉noDiscovery: true + include 空/undefined——“不自动发现”且”没显式列出依赖”——等于没预构建
noDiscovery 的场景——用户完全知道自己的依赖集合(比如纯 SSR 场景、没有运行时发现)——用 include 列出来、vite 就不扫描。如果连 include 都没写、说明用户也没提供依赖——直接不预构建。
disabled: true 的场景——用户在某些非标准环境下、希望完全绕过预构建(比如 Deno / Bun 的 Vite 适配器——它们自己的 runtime 能处理 CJS → ESM)。
两种判别合成一个函数——清晰表达 “在什么情况下完全跳过预构建” 的定义——代码其他地方用这个 helper 决定 “是否要创建 DepsOptimizer”。
这种 “把复杂判断封装成语义明确的 helper” 是保持代码可读的关键——用到的地方只看到 if (!isDepOptimizationDisabled(...)) 一行、不会被 || / && 的多重判断淹没。
8.21 depsCacheSuffix 的环境隔离
看 getDepsCacheSuffix:
function getDepsCacheSuffix(environment: Environment): string {
let suffix = ''
const config = environment.getTopLevelConfig()
// Create an extra cache directory for ssr and worker environment
if (environment.name !== 'client') {
suffix += `_${environment.name}`
}
if (config.command === 'build') {
suffix += '_build'
}
return suffix
}
三种情况加 suffix:
clientenv——默认、无 suffix、目录是.vite/deps/ssr/workerenv——加_ssr/_worker、目录是.vite/deps_ssr/build命令——加_build、目录是.vite/deps_build/
为什么要分 env 分 command 的目录?——因为不同 env 的依赖优化策略不同:
- client——面向浏览器、externalize Node.js 内置模块、ESM 输出
- ssr——面向 Node.js、保留
require/process/fs - worker——面向 Web Worker、externalize 不同集合的 API
- build——面向生产构建、可能启用更多优化
如果共用一个缓存目录——SSR 构建完后 client 的预构建会被覆盖、反之亦然——每次切换都要重新预构建——体验崩。
分目录后——每种组合独立缓存、切换不触发重新构建——用户在 client 和 SSR 之间切换顺滑。
代价——磁盘占用翻几倍(deps + deps_ssr + deps_build + …)——但现代硬盘几 GB 不是问题、交换了用户体验值得。
这种 “按使用场景分目录缓存” 的模式和 Rust 的 target/ 目录分 build/release、Cargo 的 target/ 分 debug/release 是同一种思路——不同用途的产物不能共缓存。
8.22 预构建的 “不信任” 防御——多层校验
Vite 预构建从头到尾贯穿不信任的防御态度——几个关键校验点:
① metadata.json 版本校验——读取 cache 时先看 version 字段、如果 Vite 版本不匹配就作废重来。
② lockfileHash / configHash 精细对比——§8.1.3 讲过、不仅看总 hash、还分维度比较。
③ 临时目录的原子 rename——§8.4.1 讲过、commit 时原子替换、失败回滚。
④ getTempSuffix 并发隔离——§8.18 讲过、防止并发进程冲突。
⑤ flatIdDeps 输入去重——构建前对依赖去重、避免重复处理。
⑥ Proxy 警告的 externalized modules——§8.4.6 讲过、用户意外 import Node.js 模块时提示。
⑦ needsInterop 检测入口文件格式——误把 CJS 当 ESM 会产出错的 bundle、needsInterop 防这种误判。
7 层防御——每一层都 address 某种 真实世界出现过的失败模式。这些防御不是纸上的 best practice、是 vite 发布几年来从 issue tracker 里积累的补丁——每一条背后可能是一个被修过的 user-reported bug。
从 “第一次代码能跑起来” 到 “工业级可靠”——中间相差的就是这 7 层防御。新的工具(turbopack、rspack)要替代 vite、不只是要更快、还要在这 7 层防御上都至少做到同等水准——否则生态接受度会卡住。
这也解释了为什么写一个新的构建工具很难——不是算法难、是这些防御代码的积累难——靠时间 + 社区反馈慢慢补。vite 从 2020 年到 2026 年、才积累成现在的样子。这就是 “时间 + 社区 = 可靠性” 的不二法则——所有成熟工具背后都有这条规律。任何新工具如果想赶上 vite 的位置、必须走过这段路——没有捷径、只有勤奋补课。下次你听到某个新工具宣传比 vite “更快”——先问它有没有走过这几千个 issue 的修补——这才是真实的技术深度衡量标准——速度只是第一条门槛、可靠性才是生产级工具的真正考验。