Vite 设计与实现
第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 双维度哈希缓存——lockfile + config 双重保护
打开 packages/vite/src/node/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.2 lockfileFormats 的 9 种包管理器支持
vite 的 lockfile hash 支持 9 种包管理器(packages/vite/src/node/optimizer/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.3 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.4 依赖发现扫描(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.4.1 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.4.2 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 接口在不同阶段暴露不同子集——用户代码的可能破坏面被物理隔离。
8.5 预构建执行
扫描完成后,runOptimizeDeps 函数负责实际的打包工作。
8.5.1 临时目录策略
预构建使用临时目录来保证原子性:
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 来保证跨进程的安全性。
8.5.2 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.5.3 flattenId 函数的路径扁平化
flattenId(id)(packages/vite/src/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.5.4 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.5.5 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.6 跨章节呼应
与 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.7 十二条工程原则收束
从本章的源码观察提炼出 Vite 依赖预构建子系统的 12 条原则:
① 双维度缓存 hash(§8.1)——lockfile + config 分开、判断更精细。
② 包管理器 lockfile 全覆盖(§8.2)——9 种 lockfile 格式 + 排序策略。
③ 白名单 hash 字段(§8.3)——只 hash 影响优化的配置、避免 noise。
④ 环境变量隐式识别(§8.4.1)——npm_config_user_agent 自动识别包管理器。
⑤ 按阶段限制 Environment(§8.4.2)——ScanEnvironment 屏蔽 moduleGraph / HMR。
⑥ 临时目录保证原子性(§8.5.1)——处理成功后才 rename 替换缓存。
⑦ flattenId 扁平化路径(§8.5.3)——多层斜杠用不同下划线编码、保证扁平且可区分。
⑧ ESM / CJS 双 packageCache(§8.5.4)——不同解析上下文不互相污染。
⑨ Proxy 警告 externalized modules(§8.5.5)——dev 友好提示、prod 静默。
⑩ browserHash 的三级 hash——optimized 依赖 + chunks 精细缓存失效。
⑪ holdUntilCrawlEnd 协调时序——避免半缓存状态被前端看到。
⑫ 防抖聚合新依赖——100ms 窗口把页面加载期的多次发现合并为一次重新构建。
这 12 条原则贯穿 vite 从 scan → optimize → cache → serve 的完整预构建链路——每一条都解决一个工程细节、合起来就是 "Vite 的冷启动为什么这么快" 的答案。
8.8 预构建系统在其他工具里的对应
把 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.9 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.10 冷启动的 "首次代价" 值不值
最后讨论一个价值判断——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.11 从 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 行——两者加起来才是一个鲁棒的系统。
读完本章、看 packages/vite/src/node/optimizer/index.ts 的 1400 行代码会觉得合理多了——每一块都在完成上述某一步的细节。
8.12 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.13 未来演进——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.14 isDepOptimizationDisabled 的边界场景
在 packages/vite/src/node/optimizer/index.ts:195 有一个小 helper:
export function isDepOptimizationDisabled(
optimizeDeps: DepOptimizationOptions,
): boolean {
return (
optimizeDeps.disabled === true ||
optimizeDeps.disabled === 'dev' ||
(!!optimizeDeps.noDiscovery && !optimizeDeps.include?.length)
)
}
三种情况 disabled:
disabled: true——显式关掉disabled: 'dev'——只在 dev 关掉。disabled的类型是boolean | 'build' | 'dev'(同文件:147),默认'build'——也就是 默认只在 build 期关闭,而 build 期的预构建本来就在 Vite 5.1 被移除了, 所以默认值等于"不关"noDiscovery: true + include 空/undefined——"不自动发现"且"没显式列出依赖"——等于没预构建
值得注意的是 disabled 自己带着 @deprecated:源码注释写明"Deps
optimization during build was removed in Vite 5.1. This option is now
redundant and will be removed in a future version. Switch to using
optimizeDeps.noDiscovery and an empty or undefined
optimizeDeps.include."——换句话说,前两个分支是为兼容留下的,
第三个才是现在推荐的写法。这也解释了为什么三个分支要合成一个 helper:
调用方不需要知道哪个是历史包袱。
noDiscovery 的场景——用户完全知道自己的依赖集合(比如纯 SSR 场景、没有运行时发现)——用 include 列出来、vite 就不扫描。如果连 include 都没写、说明用户也没提供依赖——直接不预构建。
disabled: true 的场景——用户在某些非标准环境下、希望完全绕过预构建(比如 Deno / Bun 的 Vite 适配器——它们自己的 runtime 能处理 CJS → ESM)。
三种判别合成一个函数——清晰表达 "在什么情况下完全跳过预构建" 的定义——代码其他地方用这个 helper 决定 "是否要创建 DepsOptimizer"。
这种 "把复杂判断封装成语义明确的 helper" 是保持代码可读的关键——用到的地方只看到 if (!isDepOptimizationDisabled(...)) 一行、不会被 || / && 的多重判断淹没。
8.15 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.16 预构建的 "不信任" 防御——多层校验
Vite 预构建从头到尾贯穿不信任的防御态度——几个关键校验点:
① metadata.json 版本校验——读取 cache 时先看 version 字段、如果 Vite 版本不匹配就作废重来。
② lockfileHash / configHash 精细对比——§8.1 讲过、不仅看总 hash、还分维度比较。
③ 临时目录的原子 rename——§8.5.1 讲过、commit 时原子替换、失败回滚。
④ getTempSuffix 并发隔离——§8.12 讲过、防止并发进程冲突。
⑤ flatIdDeps 输入去重——构建前对依赖去重、避免重复处理。
⑥ Proxy 警告的 externalized modules——§8.5.5 讲过、用户意外 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 的修补——这才是真实的技术深度衡量标准——速度只是第一条门槛、可靠性才是生产级工具的真正考验。