在Vite官网中,有这样一段话
在开发中,Vite 开发服务器会创建一个插件容器来调用 Rollup 构建钩子,与 Rollup 如出一辙。
而这个插件容器就是通过下述的createPluginContainer
方法创建的,这个函数的调用发生在服务启动时,在createServer
函数中
// createServer 函数内
// 初始化
const container = await createPluginContainer(config, watcher)
// 定义 server
const server: ViteDevServer = {
// ...
pluginContainer: container
// ...
}
看下createPluginContainer
整体代码
// 从 rollup 中导出 PluginContext 接口
import { PluginContext as RollupPluginContext } from 'rollup'
// 定义新的 PluginContext 接口
type PluginContext = Omit<
RollupPluginContext,
// not documented
| 'cache'
// deprecated
| 'emitAsset'
| 'emitChunk'
| 'getAssetFileName'
| 'getChunkFileName'
| 'isExternal'
| 'moduleIds'
| 'resolveId'
>
// 主函数
export async function createPluginContainer(
{ plugins, logger, root, build: { rollupOptions } }: ResolvedConfig,
watcher?: FSWatcher
): Promise<PluginContainer> {
// 定义一个 Context 类
class Context implements PluginContext {
constructor(initialPlugin?: Plugin) {}
// 自己实现的 rollup 方法
parse(code: string, opts: any = {}) {}
async resolve() {}
getModuleInfo(id: string) {}
getModuleIds() {}
addWatchFile(id: string) {}
getWatchFiles() {}
emitFile(assetOrFile: EmittedFile) {} // 暂未实现
setAssetSource() {} // 暂未实现
getFileName() {} // 暂未实现
warn() {}
error() {}
}
// 定义一个 TransformContext 类
class TransformContext extends Context {
constructor(
filename: string,
code: string,
inMap?: SourceMap | string
) {}
_getCombinedSourcemap(createIfNull = false) {}
getCombinedSourcemap() {}
}
// 创建容器对象,对象内属性就是 Vite 支持的 Rollup 钩子函数
const container: PluginContainer = {
options: await (async () => {})(), // 注意这里是一个立即执行函数
async buildStart() {},
async resolveId() {},
async load() {},
async transform() {},
async close() {},
}
return container
}
大体逻辑就是,当 Vite 构建到某一时机的时候,会调用container
中的函数,函数内遍历并执行所有插件的对应钩子函数;传入的参数就是Context
实例或TransformContext
实例,不同钩子函数传入的实例不同,后面会介绍。
这些钩子的执行时机如下
在服务器启动时被调用:
在每个传入模块请求时被调用:
在服务器关闭时被调用:
依次来看下
options
函数
这是构建阶段的第一个钩子
这个钩子函数的调用发生在创建容器的时候,也就是在createPluginContainer
内,container.options
是一个立即执行函数
// createPluginContainer 内
// 创建容器对象,对象内属性就是 Vite 支持的 Rollup 钩子函数
const container: PluginContainer = {
options: await (async () => {})(), // 注意这里是一个立即执行函数
// ...
}
代码定义
import * as acorn from 'acorn'
import acornClassFields from 'acorn-class-fields'
import acornStaticClassFeatures from 'acorn-static-class-features'
export let parser = acorn.Parser.extend(
acornClassFields,
acornStaticClassFeatures
)
const container = {
options: await(async () => {
// 传入 createPluginContainer 的参数 config.build.rollupOptions
let options = rollupOptions
for (const plugin of plugins) {
if (!plugin.options) continue
// 调用所有 vite 插件中定义的 options 钩子函数并传入配置中的 config.build.rollupOptions
options = (await plugin.options.call(minimalContext, options)) || options
}
// 扩展 acorn 解析器
if (options.acornInjectPlugins) {
parser = acorn.Parser.extend(
...[acornClassFields, acornStaticClassFeatures].concat(
options.acornInjectPlugins
)
)
}
return {
acorn,
acornInjectPlugins: [],
...options,
}
})()
}
这个钩子函数比较简单,就是扩展acorn
解析器、替换或修改options
配置,最后传入buildStart
钩子函数中
buildStart
函数
开始构建之前调用
在调用httpServer.listen
方法时出发这个钩子函数
let isOptimized = false
// overwrite listen to run optimizer before server start
const listen = httpServer.listen.bind(httpServer)
httpServer.listen = (async (port: number, ...args: any[]) => {
if (!isOptimized) {
try {
// 调用 所有插件的 buildStart 钩子函数
await container.buildStart({})
await runOptimize()
isOptimized = true
} catch (e) {
httpServer.emit('error', e)
return
}
}
return listen(port, ...args)
}) as any
接着来看下container.buildStart
的定义
const container = {
async buildStart() {
await Promise.all(
plugins.map((plugin) => {
// 如果 vite 插件定义了 buildStart,则执行
if (plugin.buildStart) {
return plugin.buildStart.call(
new Context(plugin) as any,
container.options as NormalizedInputOptions
)
}
})
)
},
}
container.buildStart
就是执行所有插件的buildStart
钩子函数并传入options
钩子函数的返回值,this
指向Context
实例。这个函数的作用就是在开始构建之前获取配置项,用于其他钩子函数使用;或初始化一些变量。
resolveId
函数
Vite通过transformMiddleware
中间件拦截并处理模块请求,其中就包含调用resolveId
钩子函数,用于解析文件路径。也就是说每个文件请求都会调用resolveId
钩子函数,去解析文件路径。
// 获取请求模块在项目中的绝对路径
const id = (await pluginContainer.resolveId(url))?.id || url
看下container.resolveId
的定义
const container = {
/**
* @param rawId 代码中使用的路径,比如 @/main.ts
* @param importer 导入模块的位置
* @param skips 要跳过解析的模块集合
* @returns { external?: boolean | 'absolute' | 'relative', id: string } | null
*/
async resolveId(rawId, importer = join(root, 'index.html'), skips, ssr) {
const ctx = new Context()
ctx.ssr = !!ssr
ctx._resolveSkips = skips
let id: string | null = null
const partial: Partial<PartialResolvedId> = {}
// 遍历所有插件
for (const plugin of plugins) {
if (!plugin.resolveId) continue
if (skips?.has(plugin)) continue // 如果这个插件在 skips 中,跳过
// ctx._activePlugin 表示当前正在执行 resolveId 钩子函数的插件
ctx._activePlugin = plugin
// 调用 plugin 的 resolveId 钩子函数
const result = await plugin.resolveId.call(
ctx as any,
rawId,
importer,
{}, // 配置项
ssr
)
// 如果没有返回值继续调用剩余插件的 resolveId 钩子函数,如果有返回值退出循环
if (!result) continue
if (typeof result === 'string') {
id = result
} else {
id = result.id
Object.assign(partial, result)
}
break
}
// 最后返回一个对象;对象内有一个属性 id,值是解析后的绝对路径
if (id) {
partial.id = isExternalUrl(id) ? id : normalizePath(id)
return partial as PartialResolvedId
} else {
return null
}
},
}
遍历所有插件,如果当前插件有resolveId
钩子函数并且没在skips
中,则执行resolveId
钩子函数。如果其中一个插件的resolveId
钩子函数有返回值,就不会继续执行剩余插件的resolveId
钩子函数。
@rollup/plugin-alias
以@rollup/plugin-alias
为例,看一下resolveId
钩子函数的执行
在上一篇处理配置项的时候,看过 Vite 是怎么注册插件的,这其中就包含@rollup/plugin-alias
,代码如下
import aliasPlugin from '@rollup/plugin-alias'
export async function resolvePlugins(
config: ResolvedConfig,
prePlugins: Plugin[],
normalPlugins: Plugin[],
postPlugins: Plugin[]
): Promise<Plugin[]> {
return [
// ...
aliasPlugin({ entries: config.resolve.alias }),
// ...
]
}
调用aliasPlugin
方法并将配置的别名传入
看下aliasPlugin
方法
function getEntries({ entries }: RollupAliasOptions): readonly Alias[] {
if (!entries) {
return [];
}
if (Array.isArray(entries)) {
return entries;
}
return Object.entries(entries).map(([key, value]) => {
return { find: key, replacement: value };
});
}
export default function alias(options: RollupAliasOptions = {}): Plugin {
// 获取别名数组,[{ find: string | RegExp, replacement: string | function }]
const entries = getEntries(options);
return {
name: 'alias',
buildStart(inputOptions) {},
resolveId(importee, importer, resolveOptions) {}
};
}
alias
方法返回一个对象,对象内容就是插件名称和钩子函数。并缓存了entries
(别名数组)
当 Vite 调用container.resolveId
函数时,会执行这个插件的resolveId
钩子函数,代码如下
resolveId(importee, importer, resolveOptions) {
const importeeId = normalizeId(importee) // 代码中使用的路径
const importerId = normalizeId(importer) // 该模块的导入位置
// 获取和 importeeId 匹配的别名配置
const matchedEntry = entries.find((entry) =>
matches(entry.find, importeeId)
)
if (!matchedEntry || !importerId) {
return null
}
// 获取替换路径
const updatedId = normalizeId(
importeeId.replace(matchedEntry.find, matchedEntry.replacement)
)
// ...
return this.resolve(
updatedId, // 替换后的路径
importer, // 该文件的导入位置,默认是 index.html
// Vite 中 resolveOptions 始终为空对象
Object.assign({ skipSelf: true }, resolveOptions)
).then((resolved) => {
let finalResult: PartialResolvedId | null = resolved
if (!finalResult) {
finalResult = { id: updatedId }
}
return finalResult
})
}
判断是否命中别名;如果命中,根据别名替换成正常路径,并调用this.resolve
方法
this.resolve
方法定义在Context
类中,看下代码定义
async resolve(
id: string, // 替换后的路径
importer?: string, // 文件导入位置
options?: { skipSelf?: boolean }
) {
let skips: Set<Plugin> | undefined
if (options?.skipSelf && this._activePlugin) {
skips = new Set(this._resolveSkips)
skips.add(this._activePlugin)
}
let out = await container.resolveId(id, importer, skips, this.ssr)
if (typeof out === 'string') out = { id: out }
return out as ResolvedId | null
}
this._activePlugin
代表当前正在执行resolveId
钩子函数的插件,如果传入的options.skipSelf
为true
,则将this._activePlugin
添加到skips
中,并再次执行container.resolveId
。
container.resolveId
大体流程是遍历所有插件,如果插件没有定义resolveId
函数或者在skips
中存在,则跳过当前插件,并继续执行剩余插件。如果需要处理这个路径则返回处理后的路径,反之返回null
。最后回到@rollup/plugin-alias
插件的resolveId
函数中,将结果返回。由于插件返回了结果,所以最开始的container.resolveId
不会再继续执行其他插件。
也就是说resolve
方法的作用是,如果当前插件处理过路径,但是处理后的路径还需要其他插件处理,此时就可以调用this.resolve
方法。这个方法会调用除这个插件外的其他插件去解析传入的路径。
load
钩子函数
load
钩子函数是resolveId
钩子函数的下一个钩子函数,可用于读取文件代码、拦截文件读取。
举个例子
假设请求的文件是main.ts
,在 Vite 中,通过container.resolveId
获取到这个文件的绝对路径,然后调用load
钩子函数。此时我不想返回main.ts
文件里面的代码了,那我就可以在load
钩子函数中返回一个代码字符串,那么 Vite 就会拿着这个代码字符串走后续流程。
Vite 中load
钩子函数的执行时机和resloveId
钩子函数一样,也是在transformMiddleware
中间件中调用,在resloveId
钩子函数之后
const loadResult = await pluginContainer.load(id, ssr)
// 伪代码
if (loadResult == null) {
code = await fs.readFile(file, 'utf-8')
} else {
if (isObject(loadResult)) {
code = loadResult.code
map = loadResult.map
} else {
code = loadResult
}
}
调用pluginContainer.load
方法并将文件路径传入。先看后续,如果有结果返回,就用返回的结果,如果没有则通过fs.readFile
读取文件。
看下pluginContainer.load
的实现
const container = {
async load(id, ssr) {
const ctx = new Context()
ctx.ssr = !!ssr
for (const plugin of plugins) {
if (!plugin.load) continue
ctx._activePlugin = plugin
const result = await plugin.load.call(ctx as any, id, ssr)
if (result != null) {
return result
}
}
return null
},
}
调用所有插件的load
钩子函数,如果有插件返回,直接返回结果;剩余插件停止执行。
transform
钩子函数
这个钩子的作用是转换代码
和上面两个钩子一样,都是在transformMiddleware
中间件中调用,在load
钩子函数之后
const transformResult = await pluginContainer.transform(code, id, map, ssr)
看下pluginContainer.transform
的实现
const container = {
/**
* @param code 文件源码
* @param id 文件路径
* @param inMap sourcemap 相关
* @returns {object} { code: 经插件转换后的代码, map: sourcemap 相关 }
*/
async transform(code, id, inMap, ssr) {
const ctx = new TransformContext(id, code, inMap as SourceMap)
for (const plugin of plugins) {
if (!plugin.transform) continue
ctx._activePlugin = plugin
ctx._activeId = id
ctx._activeCode = code
let result: TransformResult | string | undefined
try {
// 调用插件的 transform 钩子函数,并传入 源码、文件路径
result = await plugin.transform.call(ctx as any, code, id, ssr)
} catch (e) {}
if (!result) continue
if (isObject(result)) {
code = result.code || ''
} else {
code = result
}
}
return {
code,
map: ctx._getCombinedSourcemap(),
}
},
}
声明一个TransformContext
实例,遍历所有插件,如果插件定义了transform
,则调用这个函数,并传入文件源码、文件路径。最后返回一个对象,对象内容是转换后的代码和sourcemap。
Ps: 如果其中一个插件的transform
函数有返回,并不会阻断其他插件的transform
函数执行,而是更新code
变量,当下一个插件使用时,获取的就是最新的code
。
来看个例子,Vite自带的esbuildPlugin
是怎么转换 ts 文件的
esbuildPlugin插件实现原理
代码定义在packages/vite/src/node/plugins/esbuild.ts
中,这个插件的作用是编译ts、tsx、jsx文件
假设请求文件是main.ts
,当调用pluginContainer.transform
时,会调用esbuildPlugin
中的transform
钩子函数,去转换代码。
这里我们假设没有配置config.esbuild
,看下定义
export function esbuildPlugin(options: ESBuildOptions = {}): Plugin {
const filter = createFilter(
options.include || /\.(tsx?|jsx)$/,
options.exclude || /\.js$/
)
return {
name: 'vite:esbuild',
configureServer(_server) {},
async transform(code, id) {
// 匹配 ts、tsx、jsx 文件
if (filter(id) || filter(cleanUrl(id))) {
// 获取编译后文件内容 { code: 编译后代码, map: sourcemap }
const result = await transformWithEsbuild(code, id, options)
// ...
// 通过 esbuild.jsxInject 来自动为每一个被 ESbuild 转换的文件注入 JSX helper。
// 设置为 false 来禁用 ESbuild 转换
if (options.jsxInject && /\.(?:j|t)sx\b/.test(id)) {
result.code = options.jsxInject + ';' + result.code
}
// 返回编译后结果
return {
code: result.code,
map: result.map,
}
}
},
}
}
默认情况下,ESbuild 会被应用在
ts
、jsx
、tsx
文件。可以通过esbuild.include
和esbuild.exclude
对要处理的文件类型进行配置,这两个配置的类型应为string | RegExp | (string | RegExp)[]
。
当匹配成功之后,调用transformWithEsbuild
转换插件,这其实也是Vite快的一个原因之一,通过 ESbuild 转换代码。
看下transformWithEsbuild
函数
export async function transformWithEsbuild(
code: string,
filename: string,
options?: TransformOptions,
inMap?: object
): Promise<ESBuildTransformResult> {
let loader = options?.loader
if (!loader) {
// 获取文件后缀,比如 ts
const ext = path
.extname(/\.\w+$/.test(filename) ? filename : cleanUrl(filename))
.slice(1)
if (ext === 'cjs' || ext === 'mjs') {
loader = 'js'
} else {
loader = ext as Loader
}
}
// 获取 config.esbuild.tsconfigRaw 的配置项
// 可以是一个对象,也可以是一个 JSON
let tsconfigRaw = options?.tsconfigRaw
// 如果没有配置或者配置为对象
if (typeof tsconfigRaw !== 'string') {
const meaningfulFields: Array<keyof TSCompilerOptions> = [
'jsxFactory',
'jsxFragmentFactory',
'useDefineForClassFields',
'importsNotUsedAsValues',
]
const compilerOptionsForFile: TSCompilerOptions = {}
if (loader === 'ts' || loader === 'tsx') {
// 调用 tsconfck 的 parse 方法并传入当前请求文件路径获取tsconfig.json 文件中的配置项
// loadedTsconfig = { compilerOptions: 配置项, inclueds: [], ... }
const loadedTsconfig = await loadTsconfigJsonForFile(filename)
// 获取配置
const loadedCompilerOptions = loadedTsconfig.compilerOptions ?? {}
// 遍历 meaningfulFields,将 影响编译结果的配置项提取到 compilerOptionsForFile 中
for (const field of meaningfulFields) {
if (field in loadedCompilerOptions) {
compilerOptionsForFile[field] = loadedCompilerOptions[field]
}
}
if (loadedCompilerOptions.target?.toLowerCase() === 'esnext') {
compilerOptionsForFile.useDefineForClassFields =
loadedCompilerOptions.useDefineForClassFields ?? true
}
}
// 拼接 tsconfigRaw
tsconfigRaw = {
...tsconfigRaw,
compilerOptions: {
...compilerOptionsForFile,
...tsconfigRaw?.compilerOptions,
},
}
}
const resolvedOptions = {
sourcemap: true,
// ensure source file name contains full query
sourcefile: filename, // 文件绝对路径
...options,
loader,
tsconfigRaw,
} as ESBuildOptions
delete resolvedOptions.include
delete resolvedOptions.exclude
delete resolvedOptions.jsxInject
try {
// 调用 ESbuild 的 transform 方法,编译代码
// result = { code: 编译后代码,map: sourcemap 相关 }
const result = await transform(code, resolvedOptions)
let map: SourceMap
// ...
// 返回结果
return {
...result,
map,
}
} catch (e: any) {}
}
上面代码先是整合 ESbuild 的配置项,然后通过 ESbuild 的transform
方法将 ts 文件编译成 js 文件,最后返回编译后代码以及 sourcemap 相关信息。
总结
Vite之所以支持 Rollup 中的一些插件钩子函数,是因为 Vite 创建了一个插件容器,用于在不同阶段调用所有插件的钩子函数。