秒启动的基石,vite 依赖预构建的原理
vite 在开发环境能够做到秒启动的原因有两个
- No Bundle:即跳过打包,通过浏览器 ESModule 解析源文件
- 依赖预构建:将常用依赖提前编译和处理,从而在启动阶段大大减少了开销
依赖预构建不仅能实现 vite 的秒启动,还能够兼容 CommonJS 的依赖产物、合并 ESModule 多个模块,下面就展开讲讲 vite 依赖预构建的实现原理
实现原理
依赖预构建的核心方法是 optimizeDeps
,在 packages/vite/src/node/optimizer/index.ts
文件下,主要有 4 个实现步骤
- 缓存判断,命中缓存直接返回
- 依赖扫描
- 添加依赖到优化列表
- 执行依赖打包
可以看到实现步骤非常清晰,下面就具体分析每一步的实现原理
export async function optimizeDeps(
config: ResolvedConfig,
force = config.optimizeDeps.force,
asCommand = false
): Promise<DepOptimizationMetadata> {
// 第一步:缓存判断,命中缓存直接返回
const cachedMetadata = await loadCachedDepOptimizationMetadata(config, force, asCommand)
if (cachedMetadata) {
return cachedMetadata
}
// 第二步:依赖扫描
const deps = await discoverProjectDependencies(config).result
// 第三步:添加依赖到优化列表
await addManuallyIncludedOptimizeDeps(deps, config)
const depsInfo = toDiscoveredDependencies(config, deps)
// 第四步:执行依赖打包
const result = await runOptimizeDeps(config, depsInfo).result
await result.commit()
// 返回打包 meta 信息,后续写入 _metadata.json
return result.metadata
}
第一步:缓存判断
通过 loadCachedDepOptimizationMetadata
方法判断预构建的缓存 meta 信息是否存在,meta 信息都统一保存在 _meta.json 文件中
缓存判断的实现步骤如下
- 通过
cleanupDepsCacheStaleDirs
方法,清理异常退出或执行中断的缓存目录,放在 setTimeout 中异步执行是为了确保加载缓存数据之前,所有残留的缓存目录都已经被清理,保证缓存的一致性和正确性 - 通过
getDepsCacheDir
获取缓存依赖路径,也就是 _metadata.json 文件所在的文件路径 - 通过
parseDepsOptimizerMetadata
方法解析 meta 数据和依赖预构建缓存 optimized 相关的数据 - 将 meta 数据中的 hash 和
getDepHash
依赖获取的 hash 值做对比,如果相同说明命中缓存,直接返回 meta 信息,如果不相同,则移除掉 _metadata.json 文件,准备刷新缓存
export async function loadCachedDepOptimizationMetadata(
config: ResolvedConfig,
ssr: boolean,
force = config.optimizeDeps.force,
asCommand = false
): Promise<DepOptimizationMetadata | undefined> {
if (firstLoadCachedDepOptimizationMetadata) {
firstLoadCachedDepOptimizationMetadata = false
// 清理异常退出残留的依赖处理目录
setTimeout(() => cleanupDepsCacheStaleDirs(config), 0)
}
// 获取缓存依赖路径
const depsCacheDir = getDepsCacheDir(config, ssr)
if (!force) {
let cachedMetadata: DepOptimizationMetadata | undefined
try {
// 获取 _metadata.json 文件所在路径
const cachedMetadataPath = path.join(depsCacheDir, '_metadata.json')
// 解析 meta 数据
cachedMetadata = parseDepsOptimizerMetadata(
await fsp.readFile(cachedMetadataPath, 'utf-8'),
depsCacheDir
)
} catch (e) {}
// 命中缓存,直接读取缓存 mata 信息
if (cachedMetadata && cachedMetadata.hash === getDepHash(config, ssr)) {
return cachedMetadata
}
}
// 移除文件,准备刷新缓存
await fsp.rm(depsCacheDir, { recursive: true, force: true })
}
getDepHash
方法需要展开讲讲,影响缓存 hash 变化的主要有两个方面:lock 文件和配置文件,lock 文件内部记录着依赖的具体信息,如果发生变化自然需要重新构建。另一方面配置文件中的一些参数会影响依赖预构建的方式,如果变化的话同样也需要重新构建
目前 vite 支持的 npm、yarn、pnpm、bun 作为依赖管理工具,bun 是一个更快速的依赖编译和解析工具,这里提供一篇文章作为参考
影响预构建的配置有以下几个配置
- mode:开发 / 生产环境
- root:项目根路径
- resolve:路径解析配置
- buildTarget:最终构建的浏览器兼容目标,比如 es2020,edge88 等等
- assetsInclude:自定义资源类型
- plugins:插件配置
- optimizeDeps:预构建配置
将 lock 文件中的依赖和配置参数合并之后,通过 crypto 的 createHash
方法生成当前依赖和配置的最终 hash
export function getDepHash(config: ResolvedConfig, ssr: boolean): string {
const lockfilePath = lookupFile(config.root, lockfileNames)
// 获取 lock 文件内容
let content = lockfilePath ? fs.readFileSync(lockfilePath, 'utf-8') : ''
if (lockfilePath) {
const lockfileName = path.basename(lockfilePath)
const { checkPatches } = lockfileFormats.find((f) => f.name === lockfileName)!
if (checkPatches) {
const fullPath = path.join(path.dirname(lockfilePath), 'patches')
const stat = tryStatSync(fullPath)
if (stat?.isDirectory()) {
content += stat.mtimeMs.toString()
}
}
}
const optimizeDeps = getDepOptimizationConfig(config, ssr)
// 增加会影响依赖预构建的配置
content += JSON.stringify(
{
// 开发 / 生产环境
mode: process.env.NODE_ENV || config.mode,
// 项目根路径
root: config.root,
// 路径解析配置
resolve: config.resolve,
// 最终构建的浏览器兼容目标
buildTarget: config.build.target,
// 自定义资源类型
assetsInclude: config.assetsInclude,
// 插件
plugins: config.plugins.map((p) => p.name),
// 预构建配置
optimizeDeps: {
include: optimizeDeps?.include,
exclude: optimizeDeps?.exclude,
esbuildOptions: {
...optimizeDeps?.esbuildOptions,
plugins: optimizeDeps?.esbuildOptions?.plugins?.map((p) => p.name),
},
},
},
// 特殊正则和函数类型
(_, value) => {
if (typeof value === 'function' || value instanceof RegExp) {
return value.toString()
}
return value
}
)
// 通过调用 crypto 的 createHash 方法生成哈希
return getHash(content)
}
第二步:依赖扫描
在第一步没有命中缓存之后,接下来这一步就要开始扫描有哪些依赖,discoverProjectDependencies
方法比较简单,核心在于通过 scanImports
方法获取依赖扫描的结果
export function discoverProjectDependencies(config: ResolvedConfig): {
cancel: () => Promise<void>
result: Promise<Record<string, string>>
} {
// 获取依赖扫描结果
const { cancel, result } = scanImports(config)
return {
cancel,
result: result.then(({ deps, missing }) => {
return deps
}),
}
}
scanImports
方法主要分为三步
- 通过
computeEntries
方法寻找入口 - 通过
prepareEsbuildScanner
方法执行依赖扫描,建立 esbuild 上下文 - 执行 esbuild 上下文的
rebuild
方法,获取依赖扫描结果
export function scanImports(config: ResolvedConfig): {
cancel: () => Promise<void>
result: Promise<{
deps: Record<string, string>
missing: Record<string, string>
}>
} {
const deps: Record<string, string> = {}
const missing: Record<string, string> = {}
let entries: string[]
const scanContext = { cancelled: false }
// 第一步:寻找入口
const esbuildContext: Promise<BuildContext | undefined> = computeEntries(config).then(
(computedEntries) => {
entries = computedEntries
if (scanContext.cancelled) return
// 第二步:使用 Esbuild 执行依赖扫描
return prepareEsbuildScanner(config, entries, deps, missing, scanContext)
}
)
const result = esbuildContext.then((context) => {
return context.rebuild().then(() => {
return {
deps: orderedDependencies(deps),
missing,
}
})
})
return {
cancel: async () => {
scanContext.cancelled = true
return esbuildContext.then((context) => context?.cancel())
},
result,
}
}
第一步 computeEntries
方法用于寻找依赖扫描的入口,按照以下三个顺序寻找
- 首先从 optimizeDeps.entries 中获取入口,支持 glob 语法
- 其次从 build.rollupOptions.input 中获取入口,同时兼容字符串、数组、对象配置方式
- 最后是兜底逻辑,没有配置入口,默认从根目录寻找
async function computeEntries(config: ResolvedConfig) {
let entries: string[] = []
const explicitEntryPatterns = config.optimizeDeps.entries
const buildInput = config.build.rollupOptions?.input
// 先从 optimizeDeps.entries 中获取入口,支持 glob 语法
if (explicitEntryPatterns) {
entries = await globEntries(explicitEntryPatterns, config)
}
// 其次从 build.rollupOptions?.input 中获取入口,兼容数组和对象
else if (buildInput) {
const resolvePath = (p: string) => path.resolve(config.root, p)
if (typeof buildInput === 'string') {
entries = [resolvePath(buildInput)]
} else if (Array.isArray(buildInput)) {
entries = buildInput.map(resolvePath)
} else if (isObject(buildInput)) {
entries = Object.values(buildInput).map(resolvePath)
}
} else {
// 兜底逻辑,如果没有配置,自动从根目录寻找
entries = await globEntries('**/*.html', config)
}
entries = entries.filter((entry) => isScannable(entry) && fs.existsSync(entry))
return entries
}
第二步 prepareEsbuildScanner
中,通过 esbuildScanPlugin
方法通过定义 esbuild 插件的形式,定义在扫描过程中需要的文件处理,比如
- 支持 对 html、vue、svelte、astro(一种新兴的类 html 语法) 四种后缀的入口文件进行了解析
- 支持对 bare import 场景的处理逻辑
- external 规则处理,排除不需要扫描的依赖
最后利用 esbuild 的 context
方法,将相对路径解析为决定路径的上下文环境,此时的产物是不写入磁盘的,能够节省 IO 的时间
async function prepareEsbuildScanner(
config: ResolvedConfig,
entries: string[],
deps: Record<string, string>,
missing: Record<string, string>,
scanContext?: { cancelled: boolean }
): Promise<BuildContext | undefined> {
const container = await createPluginContainer(config)
if (scanContext?.cancelled) return
// 扫描需要用到的 Esbuild 插件
const plugin = esbuildScanPlugin(config, container, deps, missing, entries)
const { plugins = [], ...esbuildOptions } = config.optimizeDeps?.esbuildOptions ?? {}
return await esbuild.context({
absWorkingDir: process.cwd(),
write: false, // ! 产物不写入磁盘,节省 IO 时间
stdin: {
contents: entries.map((e) => `import ${JSON.stringify(e)}`).join('\n'),
loader: 'js',
},
bundle: true,
format: 'esm',
logLevel: 'silent',
plugins: [...plugins, plugin],
...esbuildOptions,
})
}
最后通过 scanImports
方法扫描依赖的结果 result 作为整个方法的返回,至此第二步扫描依赖就结束了
第三步:添加依赖到优化列表
在扫描了依赖之后,接下来就需要将依赖添加到优化列表
首先会通过 addManuallyIncludedOptimizeDeps
方法处理在配置文件自定义添加到优化列表的依赖,也就是配置文件中的 optimizeDeps 配置,主要实现步骤如下
- 获取配置文件中的 optimizeDeps 配置
- 创建路径解析函数
- 遍历需要添加的依赖数组
- 对依赖的 id 进行标准化处理
- 解析依赖路径,得到依赖入口文件路径
- 将满足条件的依赖放入 deps 对象
export async function addManuallyIncludedOptimizeDeps(
deps: Record<string, string>,
config: ResolvedConfig,
ssr: boolean,
extra: string[] = [],
filter?: (id: string) => boolean
): Promise<void> {
const { logger } = config
// 获取配置文件中的 optimizeDeps 配置
const optimizeDeps = getDepOptimizationConfig(config, ssr)
const optimizeDepsInclude = optimizeDeps?.include ?? []
if (optimizeDepsInclude.length || extra.length) {
// 定义需要添加的依赖,排除不需要添加的依赖数组
const includes = [...optimizeDepsInclude, ...extra]
for (let i = 0; i < includes.length; i++) {
const id = includes[i]
if (glob.isDynamicPattern(id)) {
const globIds = expandGlobIds(id, config)
includes.splice(i, 1, ...globIds)
i += globIds.length - 1
}
}
// 创建路径解析函数
const resolve = createOptimizeDepsIncludeResolver(config, ssr)
// 遍历需要添加的依赖数组
for (const id of includes) {
// 对依赖的 id 进行标准化处理
const normalizedId = normalizeId(id)
if (!deps[normalizedId] && filter?.(normalizedId) !== false) {
// 解析依赖路径,得到依赖入口文件路径
const entry = await resolve(id)
// 将满足条件的依赖放入 deps 对象
if (entry) {
if (isOptimizable(entry, optimizeDeps)) {
if (!entry.endsWith('?__vite_skip_optimization')) {
deps[normalizedId] = entry
}
}
}
}
}
}
}
接下来将 esbuild 扫描的依赖和配置中自定义添加的依赖通过 toDiscoveredDependencies
方法转化一个标准的优化列表
export function toDiscoveredDependencies(
config: ResolvedConfig,
deps: Record<string, string>,
ssr: boolean,
timestamp?: string
): Record<string, OptimizedDepInfo> {
const browserHash = getOptimizedBrowserHash(getDepHash(config, ssr), deps, timestamp)
const discovered: Record<string, OptimizedDepInfo> = {}
// 遍历依赖列表,标准化为统一的对象
for (const id in deps) {
const src = deps[id]
discovered[id] = {
id,
file: getOptimizedDepPath(id, config, ssr),
src,
browserHash: browserHash,
exportsData: extractExportsData(src, config, ssr),
}
}
return discovered
}
至此第三步将依赖添加到优化列表就结束了
第四步:执行依赖打包
接下来就到了最后一步,将上一步获取的依赖优化列表,通过 runOptimizeDeps
方法进行打包
打包过程首先通过 prepareEsbuildOptimizerRun
方法,准备 esbuild 的运行环境,主要步骤会遍历所有依赖,将扁平化依赖记录到 flatIdDeps。再通过 esbuild 的 context
方法,创建上下文,入口就是所有扁平化后的依赖路径
这里的扁平化路径的目的是用作对象的唯一 key,比如 react/jsx-dev-runtime
,被重写为react_jsx-dev-runtime
async function prepareEsbuildOptimizerRun(
resolvedConfig: ResolvedConfig,
depsInfo: Record<string, OptimizedDepInfo>,
ssr: boolean,
processingCacheDir: string,
optimizerContext: { cancelled: boolean },
): Promise<{
context?: BuildContext
idToExports: Record<string, ExportsData>
}> {
// 扁平化路径依赖记录
const flatIdDeps: Record<string, string> = {}
// ...
// 遍历所有依赖,将扁平化路径依赖记录到 flatIdDeps
await Promise.all(
Object.keys(depsInfo).map(async (id) => {
const src = depsInfo[id].src!
const exportsData = await(
depsInfo[id].exportsData ?? extractExportsData(src, config, ssr),
)
if (exportsData.jsxLoader && !esbuildOptions.loader?.['.js']) {
esbuildOptions.loader = {
'.js': 'jsx',
...esbuildOptions.loader,
}
}
// 扁平化路径,`react/jsx-dev-runtime`,被重写为`react_jsx-dev-runtime`
const flatId = flattenId(id)
flatIdDeps[flatId] = src
idToExports[id] = exportsData
flatIdToExports[flatId] = exportsData
}),
)
// ...
// 创建 esbuild 上下文,入口为所有扁平化的依赖路径
const context = await esbuild.context({
absWorkingDir: process.cwd(),
entryPoints: Object.keys(flatIdDeps), // 入口
bundle: true,
platform,
define,
format: 'esm',
target: isBuild ? config.build.target || undefined : ESBUILD_MODULES_TARGET,
external,
logLevel: 'error',
splitting: true,
sourcemap: true,
outdir: processingCacheDir,
ignoreAnnotations: !isBuild,
metafile: true,
plugins,
charset: 'utf8',
...esbuildOptions,
supported: {
'dynamic-import': true,
'import-meta': true,
...esbuildOptions.supported,
},
})
return { context, idToExports }
}
在创建好 esbuild 上下文之后,会调用 rebuild
方法开始执行预构建过程,然后会经历两次遍历过程
- 首先会遍历 depsInfo 依赖信息,将重新构建的依赖信息添加到 metadata 的 optimized 部分
- 然后遍历 metadata 的文件输出路径,将非 js 文件添加到 metadata 的 chunk 部分
const runResult = preparedRun.then(({ context, idToExports }) => {
return context.rebuild().then((result) => {
// metadata
const meta = result.metafile!
// 遍历依赖信息,添加到 metadata 的 optimized 部分
for (const id in depsInfo) {
const output = esbuildOutputFromId(meta.outputs, id, processingCacheDir)
const { exportsData, ...info } = depsInfo[id]
addOptimizedDepInfo(metadata, 'optimized', {
...info,
fileHash: getHash(metadata.hash + depsInfo[id].file + JSON.stringify(output.imports)),
browserHash: metadata.browserHash,
// 判断是否有要转换为 ESM 格式
needsInterop: needsInterop(config, ssr, id, idToExports[id], output),
})
}
// 遍历 metadata 的输出文件路径
for (const o of Object.keys(meta.outputs)) {
// 如果不是 js 文件
if (!o.match(jsMapExtensionRE)) {
const id = path.relative(processingCacheDirOutputPath, o).replace(jsExtensionRE, '')
// 根据 id 获取构建依赖的文件路径
const file = getOptimizedDepPath(id, resolvedConfig, ssr)
// 如果不存在相同的文件,则将输出的文件信息放入 metadata 的 chunk 部分
if (!findOptimizedDepInfoInRecord(metadata.optimized, (depInfo) => depInfo.file === file)) {
addOptimizedDepInfo(metadata, 'chunks', {
id,
file,
needsInterop: false,
browserHash: metadata.browserHash,
})
}
}
}
// ! 注意到此时返回的是 succesfulResult
return succesfulResult
})
})
在执行完成 rebuild 操作之后,会返回一个 succesfulResult 对象,主要包含三个属性
- metadata:优化后的依赖信息,包括每个依赖的文件路径、导出信息、构建输出文件
- cancel:取消依赖预构建操作
- commit:提交预构建的优化结果,将最后结果写入 _metadata.json 文件
const succesfulResult: DepOptimizationResult = {
metadata,
cancel: cleanUp,
commit: async () => {
committed = true
// meta 信息写入 _metadata.json 文件
const dataPath = path.join(processingCacheDir, '_metadata.json')
fs.writeFileSync(dataPath, stringifyDepsOptimizerMetadata(metadata, depsCacheDir))
// 将临时文件夹中的优化结果,重命名为全局的依赖缓存文件夹
const temporalPath = depsCacheDir + getTempSuffix()
const depsCacheDirPresent = fs.existsSync(depsCacheDir)
if (isWindows) {
if (depsCacheDirPresent) await safeRename(depsCacheDir, temporalPath)
await safeRename(processingCacheDir, depsCacheDir)
} else {
if (depsCacheDirPresent) fs.renameSync(depsCacheDir, temporalPath)
fs.renameSync(processingCacheDir, depsCacheDir)
}
// 删除临时路径(旧的全局依赖缓存文件夹),确保临时文件夹的清理工作在后台进行
if (depsCacheDirPresent) fsp.rm(temporalPath, { recursive: true, force: true })
},
}
最后会执行 commit
方法,完成依赖预构建的全部过程
// 第四步:执行依赖打包
const result = await runOptimizeDeps(config, depsInfo).result
await result.commit()
// 返回打包 meta 信息,后续写入 _metadata.json
return result.metadata
总结
最后总结一下 vite 依赖预构建的实现步骤
- 缓存判断:根据文件生成的 hash 和 _metadata.json 中的记录的缓存文件 hash 进行对比,如果相同说明命中缓存
- 依赖扫描:获取扫描入口,使用 esbuild 进行扫描
- 添加依赖到优化列表:将配置文件自定义的优化依赖和扫描依赖结果添加到优化列表
- 依赖打包:使用 esbuild 进行依赖打包,产物写入 _metadata.json 文件