Vite 源码(三)Vite 为什么可以支持 Rollup 钩子函数

1,365 阅读10分钟

在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.skipSelftrue,则将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 会被应用在 tsjsxtsx 文件。可以通过 esbuild.includeesbuild.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 创建了一个插件容器,用于在不同阶段调用所有插件的钩子函数。