Appearance
第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 状态机设计
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 预构建子系统的完整实现。它们之间的关系可以通过下面的架构图来理解:
为什么需要预构建
CommonJS 到 ESM 的转换
npm 上大量流行的包仍然以 CommonJS 格式发布。以 React 为例:
js
// 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 函数负责检测这种情况:
typescript
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)以及导出了哪些名称。
依赖发现扫描(scan.ts)
依赖扫描是预构建的第一步:在服务器启动时,快速发现项目使用了哪些第三方依赖。
ScanEnvironment
扫描运行在一个受限的环境中——ScanEnvironment。它继承了 BaseEnvironment,但故意限制了对模块图和开发服务器的访问:
typescript
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 函数开始,它按优先级确定入口点:
这个设计体现了 Vite"零配置"的理念:默认情况下,扫描器会从项目根目录的 HTML 文件开始爬取依赖。对于非 HTML 入口的项目(如 SSR 应用),可以通过 optimizeDeps.entries 或 build.rollupOptions.input 来指定。
Rolldown 扫描插件
扫描的核心是 rolldownScanPlugin 函数,它返回一组 Rolldown 插件,负责在扫描过程中识别和收集依赖。这组插件被设计为多个独立的小插件,每个处理一种特定场景:
typescript
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 钩子实现了完整的依赖分类逻辑:
核心逻辑很清晰:对于裸导入(bare import),如果它解析到 node_modules 中且是可优化的文件类型(.js、.mjs、.ts 等),就将其记录到 depImports 字典中。CSS、JSON、WASM、已知的资源类型则直接标记为外部依赖,不参与预构建。
HTML 类型的特殊处理
Vue、Svelte、Astro 等框架的单文件组件(SFC)需要特殊处理。htmlTypeOnLoadCallback 函数会解析 <script> 标签,提取其中的 JavaScript 代码:
typescript
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
}