双引擎的基础, vite 在 dev 的插件机制
我们都知道,vite 在开发时使用的是 esbuild 作为依赖预构建和 ts、jsx 文件转译工具,通过浏览器的 ESM 加载,而在生产打包时使用的是 Rollup 作为打包工具。这样的双引擎架构可以同时享受到到开发时 esbuild 的极速体验,也可以在生产打包时使用 Rollup 实现代码分割、自动预加载、异步 chunk 加载优化等,但这同时也带来一个问题:在开发环境,如何兼容 Rollup 的插件?
在 vite 中的解决方案是
- 在开发阶段,借鉴 WMR 思路,实现插件容器机制,模拟 Rollup 调度各个 Vite 插件的执行逻辑
- vite 的插件语法完全兼容 Rollup
下面就具体分析一下 vite 在开发环境的插件机制实现原理
实现原理
在 vite 启动 server 的过程中,会通过 createPluginContainer
方法创建插件容器,在之前的文章提到过,插件容器主要有三个作用
- 管理的插件的生命周期
- 在插件之间传递上下文对象,上下文对象包含 vite 的内部状态和配置信息,这样插件通过上下文对象就能访问和修改 vite 内部状态
- 根据插件的钩子函数,在特定的时机执行插件
1const container = await createPluginContainer(config, moduleGraph, watcher)
其中前两个作用十分重要,下面我们就逐步分析插件容器生命周期管理和传递上下文的实现原理
管理插件生命周期
在开发阶段,vite 会模拟 Rollup 的插件执行逻辑,所以先介绍一下 Rollup 的插件执行机制,这里主要介绍 Rollup 的 build 相关钩子
- 调用
options
钩子转换配置,得到处理后的配置对象 - 调用
buildStart
钩子,开始构建流程 - 调用
resolveId
钩子解析文件路径(从input
配置指定的入口文件开始) - 调用
load
钩子加载模块内容 - 执行所有
transform
钩子对模块内容进行自定义转换(比如 babel 转译) - 拿到最后的模块内容,进行 AST 分析,得到所有的 import 内容,调用
moduleParsed
钩子 - 所有的 import 都解析完毕,执行
buildEnd
钩子,Build 阶段结束
在 vite 中由于 AST 分析是通过 esbuild 进行的,所有没有模拟 moduleParsed
钩子,并且使用 close
钩子封装了 Rollup 的 buildEnd
钩子和 closeBundle
钩子
在 vite 中模拟了如下钩子,下面会详细讲讲每个钩子的实现
options
:存储插件容器的配置,用于获取用户传入的配置buildStart
:构建开始阶段的钩子函数,执行自定义初始化操作resolveId
:解析依赖的钩子函数,对模块依赖进行自定义解析或转换load
:加载模块的钩子函数transform
:代码转换钩子函数,对代码进行自定义转换或优化close
:插件容器关闭阶段钩子函数,执行清理或者收尾工作
1// 文件地址:packages/vite/src/node/server/pluginContainer.ts
2const container: PluginContainer = {
3 options,
4 getModuleInfo,
5 async buildStart() {},
6 async resolveId() {},
7 async load() {},
8 async transform() {},
9 async close() {},
10}
options 钩子
options
钩子是一个立即执行函数,在插件容器创建的时候,就立即执行 options
方法来获取配置选项
执行过程中会遍历所有 options 钩子,通过 handleHookPromise
方法执行,如果配置中有 acornInjectPlugins 属性的话会注册到 acorn.Parser 解析器中,用于自定义 AST 解析过程
1const container: PluginContainer = {
2 options: await(async () => {
3 let options = rollupOptions
4 // 遍历所有 options 钩子
5 for (const optionsHook of getSortedPluginHooks('options')) {
6 options =
7 (await handleHookPromise(optionsHook.call(minimalContext, options))) ||
8 options
9 }
10 if (options.acornInjectPlugins) {
11 parser = acorn.Parser.extend(
12 ...(arraify(options.acornInjectPlugins) as any)
13 )
14 }
15 return {
16 acorn,
17 acornInjectPlugins: [],
18 ...options,
19 }
20 })(),
21}
钩子函数统一都会通过 handleHookPromise
方法执行,方法会将 promise 函数执行放入 Set 集合中执行,有两个好处
- 可以追踪所有 Promise 的执行过程,close 时可以保证所有异步任务执行完成再关闭
- 可以去处重复的异步任务
1const processesing = new Set<Promise<any>>()
2
3function handleHookPromise<T>(maybePromise: undefined | T | Promise<T>) {
4 // 如果不是 Promise 直接返回
5 if (!(maybePromise as any)?.then) {
6 return maybePromise
7 }
8 // Promise 异步任务放入集合
9 const promise = maybePromise as Promise<T>
10 processesing.add(promise)
11 // 异步任务执行完成后从集合移除
12 return promise.finally(() => processesing.delete(promise))
13}
getModuleInfo 钩子
getModuleInfo
钩子用于从模块依赖图中获取模块信息,在没有模块信息的时候会通过 Proxy 创建一个代理对象再返回模块信息,使用 Proxy 的好处在于
- 能够限制数据操作权限,只能有 get 获取操作,而不能有其他修改操作
- 动态生成模块信息,可以节约内存,在需要的时候才生成模块信息
1const container: PluginContainer = {
2 getModuleInfo(id: string) {
3 // 从模块依赖图获取模块,如果没有则返回
4 const module = moduleGraph?.getModuleById(id)
5 if (!module) return null
6
7 // 如果没有 module.info 模块信息,则通过 Proxy 创建一个代理访问对象
8 if (!module.info) {
9 module.info = new Proxy(
10 { id, meta: module.meta || EMPTY_OBJECT } as ModuleInfo,
11 ModuleInfoProxy
12 )
13 }
14 return module.info
15 },
16}
17
18const ModuleInfoProxy: ProxyHandler<ModuleInfo> = {
19 get(info: any, key: string) {
20 if (key in info) {
21 return info[key]
22 }
23 },
24}
buildStart 钩子
buildStart
钩子用于在构建开始时做自定义的初始化操作,会通过 hookParallel
并行执行所有 buildStart 钩子
1const container: PluginContainer = {
2 async buildStart() {
3 await handleHookPromise(
4 hookParallel(
5 'buildStart',
6 (plugin) => new Context(plugin),
7 () => [container.options as NormalizedInputOptions]
8 )
9 )
10 },
11}
12
13// 并行执行所有 Promise 钩子
14async function hookParallel<H extends AsyncPluginHooks & ParallelPluginHooks>(
15 hookName: H,
16 context: (plugin: Plugin) => ThisType<FunctionPluginHooks[H]>,
17 args: (plugin: Plugin) => Parameters<FunctionPluginHooks[H]>
18): Promise<void> {
19 const parallelPromises: Promise<unknown>[] = []
20
21 for (const plugin of getSortedPlugins(hookName)) {
22 const hook = plugin[hookName]
23 if (!hook) continue
24
25 const handler: Function = 'handler' in hook ? hook.handler : hook
26
27 // sequential 为 true 表示按顺序执行
28 if ((hook as { sequential?: boolean }).sequential) {
29 // 先并行执行之前的异步任务
30 await Promise.all(parallelPromises)
31 parallelPromises.length = 0
32 // 执行当前 sequential 为 true 的异步任务
33 await handler.apply(context(plugin), args(plugin))
34 } else {
35 parallelPromises.push(handler.apply(context(plugin), args(plugin)))
36 }
37 }
38 await Promise.all(parallelPromises)
39}
resolveId 钩子
resolveId
钩子会遍历已注册的插件,依次调用 resolveId 钩子,直到解析出第一个非空的 id,最后转化为绝对路径或者外部 url 返回
1const container: PluginContainer = {
2 async resolveId(rawId, importer = join(root, 'index.html'), options) {
3 // 遍历已注册的插件,依次调用 resolveId 钩子
4 for (const plugin of getSortedPlugins('resolveId')) {
5 const handler =
6 'handler' in plugin.resolveId
7 ? plugin.resolveId.handler
8 : plugin.resolveId
9
10 const result = await handleHookPromise(
11 handler.call(ctx as any, rawId, importer, {
12 assertions: options?.assertions ?? {},
13 custom: options?.custom,
14 isEntry: !!options?.isEntry,
15 ssr,
16 scan,
17 })
18 )
19
20 if (typeof result === 'string') {
21 id = result
22 } else {
23 id = result.id
24 Object.assign(partial, result)
25 }
26
27 // 如果找到一个非空的 id,则跳出循环,不再继续调用后续插件的 resolveId 钩子函数
28 break
29 }
30
31 if (id) {
32 // 对解析出的 id 进行处理,将其转换为绝对路径或外部 URL
33 partial.id = isExternalUrl(id) ? id : normalizePath(id)
34 return partial as PartialResolvedId
35 } else {
36 // 没有找到匹配的解析路径,则返回 null
37 return null
38 }
39 },
40}
load 钩子
load
钩子用于加载模块时的操作,遍历和执行所有 load 钩子,如果有返回结果的话,更新模块信息
1const container: PluginContainer = {
2 async load(id, options) {
3 const ctx = new Context()
4
5 // 循环遍历 load 插件
6 for (const plugin of getSortedPlugins('load')) {
7 const handler =
8 'handler' in plugin.load ? plugin.load.handler : plugin.load
9
10 // 执行 handler.call 方法
11 const result = await handleHookPromise(
12 handler.call(ctx as any, id, { ssr })
13 )
14
15 // 如果存在返回结果的话,会更新模块信息
16 if (result != null) {
17 if (isObject(result)) {
18 updateModuleInfo(id, result)
19 }
20 return result
21 }
22 }
23 return null
24 },
25}
transform 钩子
transform
钩子用于转换代码的自定义操作,和 load
钩子类似,同样是遍历并执行所有 transform 钩子,如果有返回结果的话,更新模块信息
1const container: PluginContainer = {
2 async transform(code, id, options) {
3 const ctx = new TransformContext(id, code, inMap as SourceMap)
4
5 // 遍历 transform 插件
6 for (const plugin of getSortedPlugins('transform')) {
7 let result: TransformResult | string | undefined
8
9 const handler =
10 'handler' in plugin.transform
11 ? plugin.transform.handler
12 : plugin.transform
13
14 // 执行插件方法
15 try {
16 result = await handleHookPromise(
17 handler.call(ctx as any, code, id),
18 )
19 }
20
21 if (!result) continue
22
23 // 更新模块信息
24 updateModuleInfo(id, result)
25 }
26
27 return {
28 code,
29 map: ctx._getCombinedSourcemap(),
30 }
31 }
32}
close 钩子
close
用结束阶段的自定义操作,首先会通过 Promise.allSettled
方法确保异步任务集合里的任务全部执行完成,再依次调用 buildEnd 和 closeBundle 钩子
1const container: PluginContainer = {
2 async close() {
3 if (closed) return
4 closed = true
5
6 await Promise.allSettled(Array.from(processesing))
7 const ctx = new Context()
8
9 await hookParallel(
10 'buildEnd',
11 () => ctx,
12 () => []
13 )
14
15 await hookParallel(
16 'closeBundle',
17 () => ctx,
18 () => []
19 )
20 },
21}
传递上下文对象
上下文对象通过 Context 实现 PluginContext 接口定义,PluginContext 实际上是 Rollup 内部定义的类型,可以看到 vite 实现了 Rollup 上下文对象
1class Context implements PluginContext {
2 //... 具体实现
3}
4
5type PluginContext = Omit<
6 RollupPluginContext, // Rollup 定义插件上下文接口
7 // not documented
8 | 'cache'
9 // deprecated
10 | 'moduleIds'
11>
Context 上下文对象一共有 14 个核心方法,其中有 3 个方法是我认为比较核心的方法
- parse:使用 acorn 将代码解析为 AST
- resolve:将相对路径解析为绝对路径,从而正确地处理模块之间的引用
- load:加载特定模块代码
1export interface PluginContext extends MinimalPluginContext {
2 // 将文件添加到 Rollup 的监听列表,文件发生更改时重新编译
3 addWatchFile: (id: string) => void
4 // 访问插件缓存,用于在构建之前存储数据(vite 未实现)
5 cache: PluginCache
6 // 记录 debug 信息并输出
7 debug: LoggingFunction
8 // 允许插件在构建过程中对外暴露生成的文件
9 emitFile: EmitFile
10 // 抛出错误并停止构建过程
11 error: (error: RollupError | string) => never
12 // 获取资源的文件名
13 getFileName: (fileReferenceId: string) => string
14 // 获取当前构建中所有模块的 ID 的迭代器
15 getModuleIds: () => IterableIterator<string>
16 // 获取构建中特定模块的信息
17 getModuleInfo: GetModuleInfo
18 // 获取 Rollup 在构建过程中监听的文件数组
19 getWatchFiles: () => string[]
20 // 将信息记录到 Rollup 构建输出
21 info: LoggingFunction
22 // 在构建过程中加载特定模块的代码
23 load: (
24 options: { id: string; resolveDependencies?: boolean } & Partial<
25 PartialNull<ModuleOptions>
26 >
27 ) => Promise<ModuleInfo>
28 /** @deprecated Use `this.getModuleIds` instead */
29 // [已弃用] 提供所有模块 ID 的迭代器(Vite 不实现此方法)
30 moduleIds: IterableIterator<string>
31 // 使用 Acorn 解析代码为 AST
32 parse: (input: string, options?: any) => AcornNode
33 // 在构建过程中将导入路径解析为绝对文件路径
34 resolve: (
35 source: string,
36 importer?: string,
37 options?: {
38 assertions?: Record<string, string>
39 custom?: CustomPluginOptions
40 isEntry?: boolean
41 skipSelf?: boolean
42 }
43 ) => Promise<ResolvedId | null>
44 // 设置由其引用 ID 标识的已发出资源的内容
45 setAssetSource: (
46 assetReferenceId: string,
47 source: string | Uint8Array
48 ) => void
49 // 将警告信息记录到 Rollup 构建输出
50 warn: LoggingFunction
51}
parse
方法比较简单,直接调用了 acorn 的方法将代码解析为 AST
1class Context implements PluginContext {
2 // 调用 acorn.Parser.parse 方法解析代码为 AST
3 parse(code: string, opts: any = {}) {
4 return parser.parse(code, {
5 sourceType: 'module',
6 ecmaVersion: 'latest',
7 locations: true,
8 ...opts,
9 })
10 }
11}
resolve
方法用于将路径解析为绝对路径,具体实现是通过插件容器的 resolveId
方法实现
1class Context implements PluginContext {
2 async resolve( /*...*/ ) {
3 let out = await container.resolveId(id, importer, {
4 assertions: options?.assertions,
5 custom: options?.custom,
6 isEntry: !!options?.isEntry,
7 })
8
9 if (typeof out === 'string') out = { id: out }
10 return out as ResolvedId | null
11 }
12
13 // 解析路径具体实现
14 async resolveId(rawId, importer = join(root, 'index.html'), options) {
15
16 },
17}
18
load
方法用于加载指定模块,有四个执行步骤
- 首先执行模块依赖图的
ensureEntryFromUrl
方法,确保模块依赖图中存在指定的模块入口 - 其次调用
updateModuleInfo
方法更新模块信息 - 然后从指定的入口开始递归加载依赖的模块
- 最后获取加载后的模块信息并返回
1class Context implements PluginContext {
2 async load(
3 options: {
4 id: string
5 resolveDependencies?: boolean
6 } & Partial<PartialNull<ModuleOptions>>
7 ): Promise<ModuleInfo> {
8 // 确保模块依赖图中存在指定的模块入口
9 await moduleGraph?.ensureEntryFromUrl(unwrapId(options.id), this.ssr)
10 // 更新模块信息的属性
11 updateModuleInfo(options.id, options)
12
13 // 从指定的入口开始递归加载依赖的模块
14 await container.load(options.id, { ssr: this.ssr })
15 // 获取加载后的模块信息
16 const moduleInfo = this.getModuleInfo(options.id)
17 return moduleInfo
18 }
19}
总结
vite 在 dev 过程中,会使用 createPluginContainer
方法创建插件容器,插件容器有两个核心功能:管理插件生命周期、传递插件上下文
插件生命周期管理主要是模拟 Rollup 的一系列 build 钩子,包括
- options:存储和自定义配置
- getModuleInfo:获取模块信息
- buildStart:构建开始阶段钩子
- resolveId:解析依赖钩子
- load:模块加载钩子
- transform:代码转换钩子
- close:关闭过程清理工作
插件上下文主要用于在插件执行过程中,传递一系列信息,便于插件访问和修改 vite 内部状态,核心操作方法有 14 个,其中有 3 个比较重要
parse
方法:将代码解析为 ASTresolve
方法:解析路径为绝对路径load
方法:加载指定模块