Vite 在项目启动之前,会首先对项目中的依赖进行预构建,主要解决两个问题:将非 ESM 规范依赖转换为 ESM 规范;解决请求瀑布流问题,如 lodash-es 会被打包成一个模块,避免同时发起太多 HTTP 请求,造成页面卡顿。
预构建是 Vite 本地开发优化的关键策略,可以有效的减少兼容性问题,且预构建依赖可以结合浏览器的缓存,提升加载速率。
在本专栏的 初探 Vite 秒级预构建实现文章中,讲解了预构建的一些表现和常用配置,但这远远不够,大家想必都知道 Esbuild 作为 Vite 极速打包的两驾马车之一,Vite 又是如何利用 Esbuild 那?在依赖预构建的过程中,Esbuild 就被使用了两次,每一次的巧思都让人不由得赞叹。
学习本文,你将学到
- 掌握完整的预构建核心流程及源码
- 学习 Esbuild 核心配置的使用
- 了解 Esbuild 中钩子的执行逻辑和具体使用
预构建核心思路
在正式阅读预构建核心代码之前,提前先讲一下预构建的核心思路,方便下文理解
- 创建 Vite 开发服务器,加载 Vite 配置信息,根据配置执行预构建的 init 方法
- 进行缓存判断,命中缓存,返回缓存结果,不会进行后续的预构建流程
- 扫描和记录 optimizeDeps.include 中引入的依赖
- 寻找依赖预构建入口文件,解析
- 使用 Esbuild 进行依赖扫描,找到项目的第三方依赖(Vite 官方称作 bare import),记录依赖,注意依赖扫描的过程中,并不会进行打包
- 第二次使用 Esbuild,进行依赖打包
- 打包过程结束,将打包产物写入磁盘,也就是生成 node_modules/.vite 目录
本章后续源码为 playgroud/optimize-deps-no-discovery 项目,并将 noDiscovery 配置注释
缓存判断
项目第一次启动时,会将预构建的关键信息写入到 _metadata.json
文件,后续项目启动,会根据 _metadata.json
文件中存储的 lockfileHash
和 configHash
值,判断是否命中缓存,具体代码如下:
ts
export async function loadCachedDepOptimizationMetadata(
environment: Environment,
force = environment.config.optimizeDeps.force ?? false,
asCommand = false,
): Promise<DepOptimizationMetadata | undefined> {
// 获取缓存目录
const depsCacheDir = getDepsCacheDir(environment)
// optimizeDeps.force 为true,会强制进行预构建
if (!force) {
let cachedMetadata: DepOptimizationMetadata | undefined
try {
// _metadata.json 路径
const cachedMetadataPath = path.join(depsCacheDir, METADATA_FILENAME)
// 读取 _metadata.json 内容
cachedMetadata = parseDepsOptimizerMetadata(
await fsp.readFile(cachedMetadataPath, 'utf-8'),
depsCacheDir,
)
}
// hash is consistent, no need to re-bundle
if (cachedMetadata) {
if (cachedMetadata.lockfileHash !== getLockfileHash(environment)) {
// xxx
} else if (cachedMetadata.configHash !== getConfigHash(environment)) {
// xxx
} else {
// 命中缓存
return cachedMetadata
}
}
}
// hash 发生变化,或者使用了 force 参数
await fsp.rm(depsCacheDir, { recursive: true, force: true })
}
关键点在于哈希计算的策略,需要根据可能影响预构建产物的配置和文件信息来生成 hash。
以 getConfigHash 为例,使用 define、root、plugins 等多个配置信息构建 content 对象,通过 getHash 计算出 hash 值,content 的某一项配置发生改变后,缓存实效,Vite 会重新触发预构建
ts
function getConfigHash(environment: Environment): string {
const { config } = environment
const { optimizeDeps } = config
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,
esbuildOptions: {
...optimizeDeps.esbuildOptions,
plugins: optimizeDeps.esbuildOptions?.plugins?.map((p) => p.name),
},
},
},
// 特殊处理函数和正则
(_, value) => {
if (typeof value === 'function' || value instanceof RegExp) {
return value.toString()
}
return value
},
)
return getHash(content)
}
从上面代码还可以看到,JSON.stringify 进行了特殊处理,主要为了应对 stringify 序列化时的一些副作用表现,例如:
- 函数、undefined和 Symbol 会被忽略
- NaN 和 Infinity 格式的数值及 null 都会被当做 null。
- 其他类型的对象,包括 Map/Set/WeakMap/WeakSet,仅会序列化可枚举的属性。
而传入的对象中,包含 plugins 属性,plugin 通常为一个包含 setup 函数的对象,需要借助 JSON.stringify 的 replacer 参数,来实现一些自定义的序列化行为,避免序列化后函数丢失
下面模拟一个简单的案例,理解一下 replacer 参数
ts
const obj = {
name: "Alice",
greet: function () {
return `Hello, ${this.name}!`;
},
regex: /hello/gi
};
console.log(JSON.stringify(obj));
console.log(JSON.stringfy(obj, (_, value) => {
if (typeof value === 'function' || value instanceof RegExp) {
return value.toString()
}
return value
}))
createDepsOptimizer.init
预构建的代码都在 optimizer 文件夹中
未设置 noDiscovery,depsOptimizer 会执行 createDepsOptimizer 类
ts
// packages/vite/src/node/server/environment.ts
const { optimizeDeps } = this.config
if (context.depsOptimizer) {
this.depsOptimizer = context.depsOptimizer
} else if (isDepOptimizationDisabled(optimizeDeps)) {
this.depsOptimizer = undefined
} else {
this.depsOptimizer = (
optimizeDeps.noDiscovery
? createExplicitDepsOptimizer
: createDepsOptimizer
)(this)
}
// 触发 init 方法
async listen(server: ViteDevServer): Promise<void> {
this.hot.listen()
await this.depsOptimizer?.init()
warmupFiles(server, this)
}
依赖扫描
删除掉 .vite 文件夹或者添加 force 属性,使缓存失效,重新执行预构建
没有命中缓存,会执行预构建流程,预构建会使用两次 esbuild,第一次扫描项目中使用的依赖,收集依赖信息;第二次进行依赖打包,输出预构建产物。
预构建扫描的依赖会有两个来源,一个是项目 import 引入的依赖,另一个为手动配置在 optimizeDeps 对象的 include。
optimizeDeps.include
预构建过程会先处理后者,代码如下:
ts
const manuallyIncludedDeps: Record<string, string> = {}
// 手动添加 include 的依赖,
await addManuallyIncludedOptimizeDeps(environment, manuallyIncludedDeps)
// 将解析到的依赖项转换为 Vite 需要的格式
const manuallyIncludedDepsInfo = toDiscoveredDependencies(
environment,
manuallyIncludedDeps,
sessionTimestamp,
)
for (const depInfo of Object.values(manuallyIncludedDepsInfo)) {
addOptimizedDepInfo(metadata, 'discovered', {
...depInfo,
processing: depOptimizationProcessing.promise,
})
newDepsDiscovered = true
}
addManuallyIncludedDeps 负责解析 include 中配置的依赖,精简后,代码如下
ts
export async function addManuallyIncludedOptimizeDeps(
environment: Environment,
deps: Record<string, string>,
): Promise<void> {
const { logger } = environment
const { optimizeDeps } = environment.config
// 获取 include 配置
const optimizeDepsInclude = optimizeDeps.include ?? []
if (optimizeDepsInclude.length) {
// 路径加载函数
const resolve = createOptimizeDepsIncludeResolver(environment)
for (const id of includes) {
const normalizedId = normalizeId(id)
if (!deps[normalizedId]) {
// 解析依赖所在路径
const entry = await resolve(id)
if (entry) {
if (isOptimizable(entry, optimizeDeps)) {
deps[normalizedId] = entry
}
}
}
}
}
}
解析后路径如下
'/Users/xxx/Desktop/daily/test/vite/node_modules/.pnpm/@vitejs+test-dep-no-discovery@file+playground+optimize-deps-no-discovery+dep-no-discovery/node_modules/@vitejs/test-dep-no-discovery/index.js'
然后借助,toDiscoveredDependencies 函数作用是将解析到的依赖项转换为 Vite 需要的格式,详情如下:
有没有感觉到这个结构有些眼熟,类似于预构建产物 _metadata.json
中 optimized 属性,此时依赖扫描阶段,depInfo 信息写入到 discovered 属性中
ts
export function addOptimizedDepInfo(
metadata: DepOptimizationMetadata,
type: 'optimized' | 'discovered' | 'chunks',
depInfo: OptimizedDepInfo,
): OptimizedDepInfo {
metadata[type][depInfo.id] = depInfo
metadata.depInfoList.push(depInfo)
return depInfo
}
寻找入口文件
对于第三方依赖的扫描,首先需要找到一个入口,沿着入口文件,就可以按图索骥的查找到第三方依赖,对项目进行递归处理,便可以扫描到项目中所有的第三方依赖。
scanImports
中通过 computeEntries 函数实现入口文件的寻找,其支持三种配置方式
- 首先从 optimizeDeps.entries 属性配置寻找
- 然后查找 rollup 提供的 build.rollupOptions.input 配置,支持字符串、对象和数组
- 最后从根目录中扫描 html文件,过滤掉 node_modules、test、dist 等文件夹
当前项目没有前两项配置,寻找的入口文件路径 /vite/playground/optimize-deps-no-discovery/index.html
ts
function computeEntries(environment: ScanEnvironment) {
let entries: string[] = []
const explicitEntryPatterns = environment.config.optimizeDeps.entries
const buildInput = environment.config.build.rollupOptions.input
if (explicitEntryPatterns) {
// 先从 optimizeDeps.entries 寻找入口,支持 glob 语法
entries = await globEntries(explicitEntryPatterns, environment)
} else if (buildInput) {
// 其次从 build.rollupOptions.input 配置中寻找,注意需要考虑数组和对象的情况
const resolvePath = async (p: string) => {
const id = (
await environment.pluginContainer.resolveId(p, undefined, {
scan: true,
})
)?.id
return id
}
if (typeof buildInput === 'string') {
entries = [await resolvePath(buildInput)]
} else if (Array.isArray(buildInput)) {
entries = await Promise.all(buildInput.map(resolvePath))
} else if (isObject(buildInput)) {
entries = await Promise.all(Object.values(buildInput).map(resolvePath))
} else {
throw new Error('invalid rollupOptions.input value.')
}
} else {
// 如果用户没有进行上述配置,则自动从根目录开始寻找
entries = await globEntries('**/*.html', environment)
}
return entries
}
找到入口文件后,使用 Esbuild 进行依赖分析,这部分 vite 做了几个非常巧思的设计
- 自定义多款 Esbuild 插件,这部分是核心中的核心,后续详细解读
- Esbuild 参数中 write 设置为 false,代表构建产物不写入硬盘中
- 通过 stdin 声明构建起点
为什么会这么做那?vite在预构建时,找到入口文件,然后扫描它的第三方依赖,对于 ESM 模式(html 文件中的第三方依赖的引入如下),需要进行递归的依赖处理,stdin 中处理后,content 内容为 import xxx.html,相当于将 html 入口文件也视作了模块。
ts
async function prepareEsbuildScanner(
environment: ScanEnvironment,
entries: string[],
deps: Record<string, string>,
missing: Record<string, string>,
scanContext: { cancelled: boolean },
): Promise<BuildContext | undefined> {
if (scanContext.cancelled) return
// 加载 Esbuild 扫描插件
const plugin = esbuildScanPlugin(environment, deps, missing, entries)
const { plugins = [], ...esbuildOptions } =
environment.config.optimizeDeps.esbuildOptions ?? {}
// 通过 Esbuild 进行依赖扫描
return await esbuild.context({
// esbuild 当前工作目录
absWorkingDir: process.cwd(),
// 核心参数,不写入硬盘中
write: false,
stdin: {
contents: entries.map((e) => `import ${JSON.stringify(e)}`).join('\n'),
loader: 'js',
},
// 是否打包第三方依赖
bundle: true,
// 打包后的格式产物为 ESM
format: 'esm',
logLevel: 'silent',
// 自定义的 scan 插件
plugins: [...plugins, plugin],
...esbuildOptions,
tsconfigRaw,
})
}
Esbuild 插件基础知识
为了更好的理解依赖扫描的过程,下面先补充一点 Esbuild 插件的基本知识
Esbuild
插件通常是一个对象,里面有name
和setup
两个属性,name
是插件的名称,setup
是一个函数,参数是一个build
对象,该对象上挂载了一些可供操作的钩子函数,下面是一个简单案例,定义了 onResolve 和 onLoad 两个钩子
ts
const buildPlugin = {
name: 'env',
setup(build) {
build.onResolve({ filter: /^env$/ }, args => ({
path: args.path,
namespace: 'env-ns',
}))
build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({
contents: JSON.stringify(process.env),
loader: 'json',
}))
},
}
onResolve 和 onLoad 是 Esbuild 中两个核心钩子,分别在路径解析和模块内容加载的时机进行触发。
两个钩子都分别会接受两个参数:Options 和 Callback,其中 Options 定义钩子的匹配规则,包含下面两个属性
filter
: 该字段可以传入一个正则表达式(注意:go 语言正则),Esbuild执行过程中, 会将模块路径与该正则进行匹配,匹配成功,钩子方法才会执行。namespace
: 每个模块都有一个关联的命名空间,默认每个模块的命名空间为 file (表示文件系统),可以显示声明命名空间规则进行匹配,匹配成功,钩子方法才会执行。
通常情形下,会在 onResolve 钩子的回调中返回 namespace 属性作为标识,后续在 onLoad 钩子中根据 namespace 进行过滤,例如上面的代码中过滤了要处理的 env 模块。
这里要非常注意钩子间执行顺序:如果同一个路径匹配的多个 onResolve 钩子,会按照代码编写的顺序进行匹配,如果其中某个 onResolve 存在返回值,那么将不会执行后续 onResolve,而是会进入下一个阶段(onLoad),同样的如果有一个 onLoad 钩子存在返回值,后续 onLoad 也不会再触发。
Callback 参数相对复杂一些,这里就不展开了,有兴趣的可以去官网做进一步的学习
入口文件处理
依赖扫描阶段,很难通过单纯的源码阅读,分析出钩子的执行调用顺序,推荐先给所有钩子打上断点,逐步调试,熟悉一下整体思路
- Esbuild 中 stdin 参数为 import xxx.html,filter 为 htmlTypesRE 的 onResolve 钩子被触发,返回结果 namespace 标注为 html。
Vite 不止支持 html 作为入口文件,还支持
vue
、svelte
、astro
、imba
文件
ts
const htmlTypesRE = /.(html|vue|svelte|astro|imba)$/;
function esbuildScanPlugin( /* 一些入参 */ ): Plugin {
// 初始化一些变量
// 返回一个 Esbuild 插件
return {
name: "vite:dep-scan",
setup(build) {
// 标记「类 HTML」文件的 namespace
build.onResolve({ filter: htmlTypesRE }, async ({ path, importer }) => {
// 获取当前模块的绝对路径
const resolved = await resolve(path, importer)
if (!resolved) return
// 如果当前 html 位于 node_module
// 或者不符合入口文件要求的后缀,返回undefined,
// 继续执行 后续 onResolve 钩子
if (
isInNodeModules(resolved) &&
isOptimizable(resolved, optimizeDepsOptions)
)
return
return {
path: resolved,
namespace: 'html',
}
})
// 接受 htmlTypesRE onResolve 的结果,进行模块内容加载
build.onLoad(
{ filter: htmlTypesRE, namespace: "html" },
async ({ path }) => {
// 解析「类 HTML」文件
}
);
},
};
}
- htmlTypesRE 有返回值,进入 namespace:html 的 onLoad 钩子,提取
<script>
标签的内容,并将该模块转换为虚拟模块
先熟悉一下后续使用到的一些正则表达式,否则理解后续代码会有些困难
ts
// demo1
<script type="module">
console.log('Hello, world!');
</script>
// demo2
<script src="path/to/file.js"></script>
// 匹配 HTML 中的 <script type=module> 标签,并提取 script 内容
const scriptModuleRE =
/(<script\b[^>]*type\s*=\s*(?: module |'module')[^>]*>)(.*?)</script>/gims
// --匹配结果
<script type="module">
console.log('Hello, world!');
// --
// 匹配 HTML 中的 <script> 标签,并提取 script 内容
export const scriptRE = /(<script\b(?:\s[^>]*>|>))(.*?)</script>/gims
// --匹配结果
<script type="module">
console.log('Hello, world!');
// --
// 匹配 <script> 标签中的 type 属性
const typeRE = /\btype\s*=\s*(?: ([^ ]+) |'([^']+)'|([^\s' >]+))/im
// -- 匹配结果
module
// --
// 匹配 <script> 标签中的 src 属性,并提取 src 路径
const srcRE = /\bsrc\s*=\s*(?: ([^ ]+) |'([^']+)'|([^\s' >]+))/im
// 注意 这里其实匹配了三种情形
// 空格分隔的路径(如 src=path/to/file)。
// 单引号包裹的路径(如 src='path/to/file')
// 双引号包裹的路径(如 src="path/to/file")
//-- 匹配结果
path/to/file.js
//--
// 匹配 <!-- xxx --> 注释信息
export const commentRE = /<!--(.|[\r\n])*?-->/
// 匹配 <script> 标签中的 lang 属性
const langRE = /\blang\s*=\s*(?: ([^ ]+) |'([^']+)'|([^\s' >]+))/im
下面是 htmlTypesRE onLoad 钩子的代码,主要处理逻辑如下:
Vite 中通过前缀添加 virtual-module: 标识为虚拟模块,后缀添加唯一标识 scriptId 保证不重复
- 对于 html 文件,匹配
script type=module
标签,存储标签内容,返回虚拟模块 - 其他类型入口文件,匹配
script
标签,存储标签内容,返回虚拟模块
ts
build.onLoad(
{ filter: htmlTypesRE, namespace: 'html' },
htmlTypeOnLoadCallback,
)
const htmlTypeOnLoadCallback: (
args: OnLoadArgs,
) => Promise<OnLoadResult | null | undefined> = async ({ path: p }) => {
let raw = await fsp.readFile(p, 'utf-8')
// 移除注释内容,以防止干扰解析过程
raw = raw.replace(commentRE, '<!---->')
const isHtml = p.endsWith('.html')
let js = ''
// 匹配所有的 script 标签和内容
const matches = raw.matchAll(scriptRE)
for (const [, openTag, content] of matches) {
// 提取 script 标签的 type 属性
const typeMatch = typeRE.exec(openTag)
const type =
typeMatch && (typeMatch[1] || typeMatch[2] || typeMatch[3])
// html 情形只处理 type = module 情形
if (isHtml && type !== 'module') {
continue
}
// 设定当前模块的加载器
let loader: Loader = 'js'
if (lang === 'ts' || lang === 'tsx' || lang === 'jsx') {
loader = lang
} else if (p.endsWith('.astro')) {
loader = 'ts'
}
const srcMatch = srcRE.exec(openTag)
if (srcMatch) {
// 如果是 src=path 格式导入,直接通过 import 导入即可
const src = srcMatch[1] || srcMatch[2] || srcMatch[3]
js += `import ${JSON.stringify(src)}\n`
} else if (content.trim()) {
// p 为当前解析的模块路径
const key = `${p}?id=${scriptId++}`
// 记录当前模块
scripts[key] = {
loader,
contents,
resolveDir: normalizePath(path.dirname(p)),
pluginData: {
htmlType: { loader },
},
}
// 将当前模块路径转变为虚拟模块路径
const virtualModulePath = JSON.stringify(virtualModulePrefix + key)
let addedImport = false
if (p.endsWith('.svelte')) {
// svelte 特殊处理,有概率会修改 addedImport
}
// 导出虚拟模块
if (!addedImport) {
js += `export * from ${virtualModulePath}\n`
}
}
}
return {
loader: 'js',
contents: js,
}
}
为什么将模块转换为虚拟模块那?有以下方面的原因
- 当前会有多种类型的 script 脚本,如 Svelte 中的
<script context="module">
、<script>
和 Vue 中的<script>
、<script setup>
等 - HTML 文件也可能同时存在多个
type=module
内联 script - 诸如上述的模块可能同时存在一个文件中,且可能使用相同的变量,为了避免命名冲突,抽离为虚拟模块,动态生成唯一的模块路径。
着重关注下列代码
ts
// .html 文件 script 要求必须满足 type = module
if (isHtml && type !== 'module') {
continue
}
// 其他 .vue .svelte .astro .imba
// 只需要存在 script 标签就进行处理
此时返回的 contents 结果如下,将 index.html 转换为 virtual-module,且 id 标识为 0。
ts
"export * from "virtual-module:/Users/zcxiaobao/Desktop/daily/test/vite/playground/optimize-deps-no-discovery/index.html?id=0"\n\nexport default {}"
返回 namespace:virtual-module
虚拟模块,会依次触发 virtualModuleRE 的 onResolve 和 onLoad 钩子,最终返回当前 script 脚本的内容
ts
build.onResolve({ filter: virtualModuleRE }, ({ path }) => {
return {
path: path.replace(virtualModulePrefix, ''),
namespace: 'script',
}
})
build.onLoad({ filter: /.*/, namespace: 'script' }, ({ path }) => {
// 返回前面存储的内容
return scripts[path]
})
Bare import 处理
Vite 在处理第三方依赖时,仅会处理 bare import 型导入,bare import 指的是直接使用模块名称进行导入,而不是使用相对路径或绝对路径。例如:
ts
import vue from 'vue'
import subRep from '@vuejs/xxx'
对于 bare import 导入,vite 定义了 /^[\w@][^:]/ onResolve
钩子
正则表达式
/^[\w@][^:]/
的作用是匹配以字母、数字、下划线或@
开头,且第二个字符不是冒号:
的字符串
ts
// bare imports: record and externalize ----------------------------------
build.onResolve(
{
filter: /^[\w@][^:]/,
},
async ({ path: id, importer }) => {
// 判断是否在 optimizeDeps.exclude 中记录过,记录过则 extrenalize
if (moduleListContains(exclude, id)) {
return externalUnlessEntry({ path: id })
}
// 判断是否记录过 depImports 中记录过,记录过则 extrenalize
if (depImports[id]) {
return externalUnlessEntry({ path: id })
}
// 解析路径,内部调用各个插件的 resolveId 进行解析
const resolved = await resolve(id, importer)
if (resolved) {
// 判断是否应该 externalize,下面会详细讲一下这里
if (shouldExternalizeDep(resolved, id)) {
return externalUnlessEntry({ path: id })
}
// 如果当前依赖位于 node_modules 或者 optimizeDep.include
// 记录依赖
if (isInNodeModules(resolved) || include?.includes(id)) {
if (isOptimizable(resolved, optimizeDepsOptions)) {
depImports[id] = resolved
}
return externalUnlessEntry({ path: id })
} else if (isScannable(resolved, optimizeDepsOptions.extensions)) {
// resolved 为 「类 html」 文件,则标记上 'html' 的 namespace
const namespace = htmlTypesRE.test(resolved) ? 'html' : undefined
return {
path: path.resolve(resolved),
namespace,
}
} else {
return externalUnlessEntry({ path: id })
}
} else {
// 没有解析到路径,记录到 missing 中,后续会进行检查,抛出相关路径未找到的报错
missing[id] = normalizePath(importer)
}
},
)
依赖扫描过程,只会 bare import 导入的依赖进行记录,因此需要制定一个 external
逻辑来剔除不符合要求的模块,对此 Vite 是分成两种情形来处理的
在 esbuild 中,钩子函数返回值中 external 标注为 true,esbuild 就不会对该路径进行处理。
一种是资源型,通过 onResolve 钩子进行过滤掉
ts
// 协议开头的 URL 或绝对路径,直接标记 external:true
build.onResolve({ filter: externalRE }, ({ path }) => ({
path,
external: true,
}))
// data url,直接标记 external: true,不让 esbuild 继续处理
build.onResolve({ filter: dataUrlRE }, ({ path }) => ({
path,
external: true,
}))
const setupExternalize = (
filter: RegExp,
doExternalize: (path: string) => boolean,
) => {
build.onResolve({ filter }, ({ path }) => {
return {
path,
external: doExternalize(path),
}
})
}
// css 类型
setupExternalize(CSS_LANGS_RE, isUnlessEntry)
// json & wasm
setupExternalize(/.(json|json5|wasm)$/, isUnlessEntry)
// known asset types(主要为一些 images、media 和 font类型)
setupExternalize(
new RegExp(`\.(${KNOWN_ASSET_TYPES.join('|')})$`),
isUnlessEntry,
)
// ?worker 或者 ?raw 这种 query 的资源路径,直接 external
setupExternalize(SPECIAL_QUERY_RE, () => true)
externalUnlessEntry 实现非常简单,即非入口文件,标注为 external:true
。
typescript
const isUnlessEntry = (path: string) => !entries.includes(path)
const externalUnlessEntry = ({ path }: { path: string }) => ({
path,
// 非 entry 则标记 external
external: isUnlessEntry(path),
})
另外一种为模块型,通过 resolve 函数解析出模块的路径,然后使用 shouldExternalizeDep 函数进行过滤
ts
function shouldExternalizeDep(resolvedId: string, rawId: string): boolean {
// 解析之后不是一个绝对路径,不在 esbuild 中进行加载
if (!path.isAbsolute(resolvedId)) {
return true
}
// 1. import 路径本身就是一个绝对路径
// 2. 虚拟模块(Rollup 插件中约定虚拟模块以`\0`开头)
// 都不在 esbuild 中进行加载
if (resolvedId === rawId || resolvedId.includes('\0')) {
return true
}
return false
}
由于正则中添加对于 @ 字符的判断,例如项目中定义 alias------@src/xxx ,也会被匹配到,因此在记录第三方依赖时,无论是否符合要求,都会标注为 external:true
,避免 esbuild 进行打包处理
ts
// 如果当前依赖位于 node_modules 或者 optimizeDep.include
if (isInNodeModules(resolved) || include?.includes(id)) {
if (isOptimizable(resolved, optimizeDepsOptions)) {
depImports[id] = resolved
}
// 不管是否满足 bare import,都要标记为 true
return externalUnlessEntry({ path: id })
}
依赖扫描结果如下:
另一种预构建情形分析
为了更加深刻的理解预构建依赖扫描过程,使用 npx create-vite 创建一个 vue+ts 项目,分析另一种依赖预构建的情形。
修改 launch.json 中 cwd 字段,指向新创建的项目目录。
该项目中 index.html 中,引用如下:
ts
<script type="module" src="/src/main.ts"></script>
// main.ts
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')
- 触发 htmlTypesRE 的 onResolve 钩子,路径为
xxx/playground/vite-demo/index.html
- 触发 htmlTypesRE 的 onLoad 钩子,此时 script 存在 src 标签,执行以下流程
ts
if (srcMatch) {
const src = srcMatch[1] || srcMatch[2] || srcMatch[3]
js += `import ${JSON.stringify(src)}\n`
}
// 返回结果
{
loader: 'js',
contents: 'import "/src/main.ts"export default {}',
}
- ts 文件会触发保底钩子(上一个项目中不会触发这里)
ts 文件,返回 namespace 为 undefined--注意,仍旧是有返回值的,进入 onLoad 钩子
ts
build.onResolve({
filter: /.*/,
},
async ({ path: id, importer }) => {
// 加载路径
const resolved = await resolve(id, importer)
if (resolved) {
if (
shouldExternalizeDep(resolved, id) ||
!isScannable(resolved, optimizeDepsOptions.extensions)
) {
return externalUnlessEntry({ path: id })
}
// 判断是否为类 html 的入口文件,如果不是 namespace 为 undefined
const namespace = htmlTypesRE.test(resolved) ? 'html' : undefined
// 返回结果
return {
path: path.resolve(cleanUrl(resolved)),
namespace,
}
} else {
return externalUnlessEntry({ path: id })
}
},
)
- 匹配
JS_TYPES_RE
的 onLoad 钩子,该钩子没有 namespace 的限制,只需满足 filter 校验
ts
build.onLoad({ filter: JS_TYPES_RE }, async ({ path: id }) => {
// 提取后缀名
let ext = path.extname(id).slice(1)
if (ext === 'mjs') ext = 'js'
const esbuildConfig = environment.config.esbuild
// 读取文件内容
let contents = await fsp.readFile(id, 'utf-8')
return {
loader,
// 值为 main.ts 内容
contents,
}
})
- 依次解析 main.ts 中的第三方依赖,其中
App.vue
会触发 htmlTypesRE 的 onResolve 钩子,触发路径为vite/playground/vite-demo/src/App.vue
,执行 vitrual-module 逻辑,返回结果
ts
{
contents: 'export * from "virtual-module:/Users/zcxiaobao/Desktop/daily/test/vite/playground/vite-demo/src/App.vue?id=0"\n',
loader: "js"
}
- 依次执行 vitrualModueRE 的 onResolve 和 onLoad 钩子,返回当前模块内容
- 由于 App.vue 中引入了 HelloWorld.vue,会继续进入 htmlTypesRE 的 onResolve 钩子,重复上述流程,直至所有模块解析完毕。
小结
经过上面两个项目的依赖扫描分析,可以梳理出依赖处理的流程图,具体如下:
在上面的文章中,系统的讲解了依赖扫描的步骤,核心要注意 esbuild 的 watch 配置信息,以及plugin 中各个钩子的调用逻辑和调用时机,理解这两点,依赖扫描部分就可以较为轻松的理解了。
依赖打包
经过依赖扫描之后,扫描结果如下:
相较于复杂的依赖扫描,打包阶段相对会简单一些,核心流程位于 packages/vite/src/node/optimizer/index.ts 中的 runOptimizeDeps
方法,这里小包把它拆解为几个阶段
准备阶段
在预构建的过程中,Vite 会先创建一个临时目录,用来存放当前的预构建产物,避免直接操作 .vite/deps
文件夹,以防预构建失败,导致现有缓存目录遭到损坏。
ts
// 获取缓存目录 node_module/.vite/deps
const depsCacheDir = getDepsCacheDir(environment)
// 临时缓存目录 node_module/.vite/deps_temp_hash:8
const processingCacheDir = getProcessingDepsCacheDir(environment)
// 创建缓存目录
fs.mkdirSync(processingCacheDir, { recursive: true })
预构建完成后,也就是 successfulResult 函数,将当前的临时目录 processingCacheDir 重命名为 depsCacheDir。源码中根据是否存在 depsCacheDir 文件夹,分别执行 safeRename 和 renameSync 方法。
ts
const temporaryPath = depsCacheDir + getTempSuffix()
const depsCacheDirPresent = fs.existsSync(depsCacheDir)
if (isWindows) {
if (depsCacheDirPresent) {
await safeRename(depsCacheDir, temporaryPath)
}
await safeRename(processingCacheDir, depsCacheDir)
} else {
if (depsCacheDirPresent) {
fs.renameSync(depsCacheDir, temporaryPath)
}
fs.renameSync(processingCacheDir, depsCacheDir)
}
同时还会初始化 metadata 内容,_metadata.json
内容的写入也位于 successfulResult 函数中
ts
// 初始化 matadata 数据
const metadata = initDepsOptimizerMetadata(environment)
// 根据 medadata.hash 和第三方依赖的路径,构建 browserHash
metadata.browserHash = getOptimizedBrowserHash(
metadata.hash,
depsFromOptimizedDepInfo(depsInfo),
)
function depsFromOptimizedDepInfo(
depsInfo: Record<string, OptimizedDepInfo>,
): Record<string, string> {
const obj: Record<string, string> = {}
for (const key in depsInfo) {
// 获取第三方依赖的 src 属性
obj[key] = depsInfo[key].src!
}
return obj
}
function getOptimizedBrowserHash(
hash: string,
deps: Record<string, string>,
timestamp = '',
) {
// 根据 hash,依赖的路径信息,时间戳构建 hash
return getHash(hash + JSON.stringify(deps) + timestamp)
}
// 写入 .vite/deps/_metadata.json
fs.writeFileSync(
dataPath,
stringifyDepsOptimizerMetadata(metadata, depsCacheDir),
)
cleanUp 函数需要稍微关注一下,当取消预构建或者预构建失败时,清空临时目录
php
const cleanUp = () => {
if (!cleaned && !committed) {
cleaned = true
try {
fs.rmSync(processingCacheDir, { recursive: true, force: true })
}
}
}
扁平化操作
由于各个第三方依赖的产物目录结构不一致,深层次的目录嵌套增加 Vite 路径解析的难度,还可能带来了一些不可控的因素。因此为了解决嵌套目录带来的问题,Vite 首先会对依赖扫描的结果进行路径扁平化处理,例如对于 @vitejs/test-dep-no-discovery
依赖,处理结果如下:
本质上其实就是进行正则替换,具体匹配规则如下:
ts
// 匹配路径中的 / 或者 :
const replaceSlashOrColonRE = /[/:]/g
// 匹配路径中的 .
const replaceDotRE = /./g
// 匹配 >,并允许其前后有任意数量的空白字符(\s*),
const replaceNestedIdRE = /\s*>\s*/g
// 匹配 #
const replaceHashRE = /#/g
const flattenId = (id: string): string => {
const flatId = limitFlattenIdLength(
id
.replace(replaceSlashOrColonRE, '_')
.replace(replaceDotRE, '__')
.replace(replaceNestedIdRE, '___')
.replace(replaceHashRE, '____'),
)
return flatId
}
扁平化的操作发生在 prepareEsbuildOptimizerRun
函数中
ts
await Promise.all(
Object.keys(depsInfo).map(async (id) => {
const src = depsInfo[id].src!
const exportsData = await (depsInfo[id].exportsData ??
extractExportsData(environment, src))
if (exportsData.jsxLoader && !esbuildOptions.loader?.['.js']) {
esbuildOptions.loader = {
'.js': 'jsx',
...esbuildOptions.loader,
}
}
const flatId = flattenId(id)
// 得到扁平化依赖产物 flatIdDeps
flatIdDeps[flatId] = src
idToExports[id] = exportsData
}),
)
Esbuild 配置
依赖打包过程同样由 Esbuild 完成,参数配置位于 prepareEsbuildOptimizerRun 方法中,完整配置见下面代码
与依赖扫描存在区别的核心有两点:
- 通过
entryPoints
设置入口文件, 为扁平化处理的依赖扫描后的结果 - 未声明
write:false
,同时设置outdir: processingCacheDir
,构建产物会写入到临时目录
ts
const context = await esbuild.context({
absWorkingDir: process.cwd(),
// 所有依赖的 id 数组,在插件中会转换为真实的路径
entryPoints: Object.keys(flatIdDeps),
bundle: true,
platform,
define,
format: 'esm',
banner:
platform === 'node'
? {
js: `import { createRequire } from 'module';const require = createRequire(import.meta.url);`,
}
: undefined,
target: ESBUILD_MODULES_TARGET,
external,
logLevel: 'error',
splitting: true,
sourcemap: true,
outdir: processingCacheDir,
ignoreAnnotations: true,
metafile: true,
// 预构建专用的插件
plugins,
charset: 'utf8',
...esbuildOptions,
supported: {
...defaultEsbuildSupported,
...esbuildOptions.supported,
},
})
return { context, idToExports }
}
插件钩子逻辑
外部依赖
首先关注对于外部依赖的处理,主要逻辑为
- 处理外部依赖:将某些依赖标记为外部依赖,避免重复打包。
- 转换
require
到import
:通过命名空间和模块路径的处理,将 CommonJS 的require
调用转换为 ES 模块的import
。 - 优化依赖解析:通过自定义解析逻辑,确保依赖路径的正确性和性能。
allExternalTypes 包含可能的外部依赖类型
ts
build.onResolve(
{
filter: new RegExp(
`\.(` + allExternalTypes.join('|') + `)(\?.*)?$`,
),
},
async ({ path: id, importer, kind }) => {
// 已经转换为外部依赖
if (id.startsWith(convertedExternalPrefix)) {
// 当前依赖已被转换,返回路径,exteral 标注为 true
return {
path: id.slice(convertedExternalPrefix.length),
external: true,
}
}
// 解析模块路径
const resolved = await resolve(id, importer, kind)
if (resolved) {
// 解析外部依赖中含有类 js|ts 文件,标记为非外部依赖
if (JS_TYPES_RE.test(resolved)) {
return {
path: resolved,
external: false,
}
}
// 如果为 require 调用
if (kind === 'require-call') {
// 返回name: externalWithConversionNamespace
// 移交 onLoad 处理
return {
path: resolved,
namespace: externalWithConversionNamespace,
}
}
return {
path: resolved,
external: true,
}
}
})
require 函数转换通过 onLoad 钩子实现,会特化处理一下 css 情形
javascript
build.onLoad(
{ filter: /./, namespace: externalWithConversionNamespace },
(args) => {
const modulePath = `"${convertedExternalPrefix}${args.path}"`
return {
contents:
// 如果是 css 请求,同时并非模块化请求
isCSSRequest(args.path) && !isModuleCSSRequest(args.path)
// 生成 import 语句
? `import ${modulePath};`
// 否则,生成 export 语句,导出默认和所有内容
: `export { default } from ${modulePath};` +
`export * from ${modulePath};`,
loader: 'js',
}
})
第三方依赖
其他依赖,通过 /^[\w@][^:]/
的 onResolve 钩子进行处理
- 如果为入口文件,或者是入口文件的别名,直接返回 entry。
resolveEntry 函数返回入口路径的路径信息
ts
let entry: { path: string } | undefined
if (!importer) {
// 判断是否为入口文件
if ((entry = resolveEntry(id))) return entry
// 判断是否为入口文件别名
const aliased = await _resolve(environment, id, undefined, true)
if (aliased && (entry = resolveEntry(aliased))) {
return entry
}
}
function resolveEntry(id: string) {
const flatId = flattenId(id)
if (flatId in qualified) {
return {
path: qualified[flatId],
}
}
}
- 其他依赖,通过 resolveResult 方法进行判断
ts
const resolveResult = (id: string, resolved: string) => {
// 浏览器外部依赖
if (resolved.startsWith(browserExternalId)) {
return {
path: id,
namespace: 'browser-external',
}
}
// 可选的对等依赖
if (resolved.startsWith(optionalPeerDepId)) {
return {
path: resolved,
namespace: 'optional-peer-dep',
}
}
// node 内置模块
if (isBuiltin(environment.config.resolve.builtins, resolved)) {
return
}
// 是否为外部 url,标记为 true
if (isExternalUrl(resolved)) {
return {
path: resolved,
external: true,
}
}
// 排除上述情形后,返回模块路径
return {
path: path.resolve(resolved),
}
}
假设依赖为 optional-peer-dep
类型,触发 onLoad
钩子,非开发环境会抛出错误
ts
build.onLoad(
{ filter: /.*/, namespace: 'optional-peer-dep' },
({ path }) => {
if (isProduction) {
return {
contents: 'module.exports = {}',
}
} else {
const [, peerDep, parentDep] = path.split(':')
return {
contents: `throw new Error(`Could not resolve "${peerDep}" imported by "${parentDep}". Is it installed?`)`,
}
}
},
)
继续调试,会发现出现类似 @vue/compiler-dom, @vue/shared 模块,esbuild 在进行依赖分析时进行递归分析的结果,上述模块正是来源于 vue 中。
第三方依赖扫描完毕后,触发 rebuild 方法,向 metadata 对象中写入 optimized 属性值,返回 successfulResult
函数。
当依赖打包完成后,最终由 successfulResult
将产物写入到 .vite/deps
文件夹中。
总结
预构建的核心流程位于 packages/vite/src/node/optimizer/optimizer.ts中的 init 函数,简化后的代码大致如下:
ts
async function init() {
// 缓存判断
const cachedMetadata = await loadCachedDepOptimizationMetadata(environment)
// 没有缓存执行预构建
if (!cachedMetadata) {
// 记录 optimizeDeps.include 手动引入的依赖
await addManuallyIncludedOptimizeDeps(environment, manuallyIncludedDeps)
// bare import 依赖扫描和记录
discover = discoverProjectDependencies(
devToScanEnvironment(environment),
)
const deps = await discover.result
// 处理需要预构建的依赖
const knownDeps = prepareKnownDeps()
// 依赖打包的产物写入
optimizationResult = runOptimizeDeps(environment, knownDeps)
}
}
Vite 的预构建流程有几分复杂,难点要理解两次 Esbuild 使用的配置和插件区别,第一次 Esbuild 核心要点是扫描和记录,第二次要点的是打包,在源码阅读和理解的时候,始终围绕这两点。
学习本文之后,你是否对 Vite 和 Esbuild 有了新的认识,留下一个思考,你认为 Vite 中还会有哪些场景使用了 Esbuild 那?