插件机制介绍及其对比
Vite 插件机制和 Rollup 插件机制的区别
Rollup 插件的约定如下:
- 插件应该有一个带有
rollup-plugin-的前缀且名称清晰易懂。 - 在
package.json中包含rollup-plugin关键词。 - 插件应该被测试。我们推荐使用
mocha或者ava两款开箱即用的 promise 能力。 - 尽可能使用异步方法。例如,使用
fs.readFile替换掉fs.readFileSync。 - 插件文档使用英语来进行编写。
- 如何可以的话确定插件输出正确的
source mappings。 - 如果你的插件使用虚拟模块(例如用于辅助的函数),模块ID以
\0为前缀。这样做可以防止其他插件试图处理虚拟模块。
Rollup 插件由 PluginDriver 函数来进行驱动的。里面提供了如下 hook。
hookFirst说明:
链式 Promise执行对应插件的钩子且保持调用参数不变,hookFirst返回首个调用插件结果为非null或undefined的值。适用场景:
链式调用返回第一个被插件处理的值。
支持异步插件。
不改变参数,各个插件独立。
相关的插件hook:
load、resolveDynamicImport、resolveId、shouldTransformCachedModuleresolveId
专门用来解析路径,对于相对路径转换为绝对路径通常只需要一个插件就可以实现。
ts// rollup/src/utils/resolveIdViaPlugins.ts export function resolveIdViaPlugins( source: string, importer: string | undefined, pluginDriver: PluginDriver, moduleLoaderResolveId: ( source: string, importer: string | undefined, customOptions: CustomPluginOptions | undefined, isEntry: boolean | undefined, skip: | readonly { importer: string | undefined; plugin: Plugin; source: string; }[] | null ) => Promise<ResolvedId | null>, skip: | readonly { importer: string | undefined; plugin: Plugin; source: string; }[] | null, customOptions: CustomPluginOptions | undefined, isEntry: boolean ): Promise<ResolveIdResult> { let skipped: Set<Plugin> | null = null; let replaceContext: ReplaceContext | null = null; if (skip) { skipped = new Set(); for (const skippedCall of skip) { if ( source === skippedCall.source && importer === skippedCall.importer ) { skipped.add(skippedCall.plugin); } } replaceContext = (pluginContext, plugin): PluginContext => ({ ...pluginContext, resolve: ( source, importer, { custom, isEntry, skipSelf } = BLANK ) => { return moduleLoaderResolveId( source, importer, custom, isEntry, skipSelf ? [...skip, { importer, plugin, source }] : skip ); } }); } return pluginDriver.hookFirst( 'resolveId', [source, importer, { custom: customOptions, isEntry }], replaceContext, skipped ); }load
load与虚拟模块的加载有很强的关联,通常一个虚拟模块与插件之间是一对一的关系,因此只需要被一个插件所处理就可以了。ts// rollup/src/ModuleLoader.ts source = await this.readQueue.run( async () => (await this.pluginDriver.hookFirst('load', [id])) ?? (await fs.readFile(id, 'utf8')) );hookFirstSync说明:
同步执行对应插件的钩子且保持调用参数不变,
hookFirstSync返回首个调用插件结果为非null或undefined的值。适用场景:
链式调用返回第一个被插件处理的值。
不支持异步插件。
不改变参数,各个插件独立。
相关的插件hook:
renderDynamicImport、resolveAssetUrl、resolveFileUrl、resolveImportMetahookParallel说明: 同参数,对于没返回值的直接执行而对于有返回值则收集后并行执行。不会等待当前插件执行完成,无返回值。
适用场景:
需要任务尽可能的完成。
不改变参数,不影响插件。
支持同步和异步插件钩子。
相关的插件hook:
buildEnd、buildStart、moduleParsed、renderError、renderStart、writeBundle、closeBundle、closeWatcher、watchChangehookReduceArg0说明: 只改变第一个参数,链式
异步调用插件对应的 hook,由 reduce 函数来决策对第一个参数的修改,前后插件有强制的先后依赖。适用场景:
有链式修改插件的第一个参数的需求。
支持
异步调用插件。链式调用。
相关的插件hook:
options、generateBundle、renderChunk、transformhookReduceArg0Sync说明: 只改变第一个参数,链式
同步调用插件对应的 hook,由 reduce 函数来决策对第一个参数的修改,前后插件有强制的先后依赖。适用场景:
有链式修改插件的第一个参数的需求。
仅支持
同步调用插件。链式调用。
相关的插件hook:
augmentChunkHash、outputOptionshookReduceValue说明: 传入插件的参数不变,插件不感知
initialValue发生的变化。通过插件的返回值和reduce函数来确定initialValue的值,链式异步调用。适用场景:
专门用来处理用户自定义的一个变量(
initialValue),也就是说若变量受到插件返回值影响的时候则就可以考虑使用。插件的参数不变,不影响插件的调用。
存在异步插件。
相关的插件hook:
banner、footer、intro、outrohookReduceValueSync说明: 传入插件的参数不变,插件不感知
initialValue发生的变化。通过插件的返回值和reduce函数来确定initialValue的值,链式同步调用。适用场景:
专门用来处理用户自定义的一个变量(
initialValue),也就是说若变量受到插件返回值影响的时候则就可以考虑使用。插件的参数不变,不影响插件的调用。
不存在异步插件
相关的插件hook:
augmentChunkHash、outputOptionshookSeq说明: 传入插件的参数保持不变,链式调用各个插件。
适用场景:
有强烈的插件顺序要求
各个插件之间独立
存在异步插件
相关的插件hook:
options、generateBundle、renderChunk、transform
Rollup 插件执行图: 需要注意的是 Rollup 在执行插件的时候会注入 context,为插件提供额外的能力。
this.pluginContexts = new Map(
this.plugins.map(plugin => [
plugin,
getPluginContext(
plugin,
pluginCache,
graph,
options,
this.fileEmitter,
existingPluginNames
)
])
);
function runHook<H extends AsyncPluginHooks>(
hookName: H,
args: Parameters<PluginHooks[H]>,
plugin: Plugin,
permitValues: boolean,
hookContext?: ReplaceContext | null
): EnsurePromise<ReturnType<PluginHooks[H]>> {
const hook = plugin[hookName];
if (!hook) return undefined as any;
let context = this.pluginContexts.get(plugin)!;
if (hookContext) {
context = hookContext(context, plugin);
}
return Promise.resolve().then(() => {
// ...
});
}注入的 context 能力包含
addWatchFile:(id: string) => void
添加要在
watch模式下监听的其他文件,以至于但这些文件发生改变的时候会触发重构建流程。id可以是绝对路径或者对于当前工作目录的相对路径。这个上下文方法只能用于构建阶段,如buildStart、load、resolveId、transform。注意: 通常用于在
watch模式下提升重打包的速度,只有当给定模块的内容实际发生更改时,才会触发transform的钩子。在transform中使用this.addWatchFile,如果监听到文件发生了变化,那么transform钩子将会重新解析这个模块(是否需要rebuild)。cache
emitAsset
emitChunk
emitFile
生成新的需要被包含在构建输出的模块。方法会返回一个
referenceId,用户可以在各种地方使用referenceId来索引到新生成的模块。emitFile支持两种格式:tsinterface EmittedChunk { type: 'chunk'; id: string; name?: string; fileName?: string; implicitlyLoadedAfterOneOf?: string[]; importer?: string; preserveSignature?: | 'strict' | 'allow-extension' | 'exports-only' | false; } interface EmittedAsset { type: 'asset'; name?: string; fileName?: string; source?: string | Uint8Array; }在以上两个格式中,可以提供了
fileName或name。如果fileName被提供,error
getAssetFileName
getChunkFileName
getFileName
getModuleIds
getModuleInfo
getWatchFiles
isExternal
load
meta
moduleIds
parse
resolve
resolveId
setAssetSource
warnv
Rollup 插件在构建阶段和输出生成阶段会调用各种钩子函数以此来触发 plugin hook。
执行流程图如下: 
Rollup 插件机制总结
优点: Rollup 的插件和其他大型框架大同小异,都是提供统一的接口并贯彻了约定优于配置的思想。8 种 hook 加载函数使 Rollup 的插件开发非常灵活,同时也带来了学习成本。
和 Webpack 相比,Rollup 的插件系统自成一派且没有区分 plugin 和 loader。Rollup 插件机制的核心是构建阶段和输出生成阶段的各种钩子函数。内部通过基于 Promise 实现异步 hook 的调度。
缺点:
源码全都糅杂在一个库中,模块、工具函数管理的看起来很随意。
无法直接移植它的任何工具到我们的项目中,相比起来,webpack 的插件系统封装成了一个插件
tapable就很利于我们学习和使用。
Vite 在其中的作用
Vite 在构建阶段借助了 Rollup 的能力,因此需要兼容 Rollup 的插件生态(将 dev 阶段的插件兼容到 build 阶段),通过借鉴 Rollup 的插件机制来实现一套类似的插件体系。
因此对于 Vite 来说也为插件实现了如下能力
- 实现
Rollup插件钩子的调度。 - 实现类似
Rollup的插件上下文机制。 - 对钩子的返回值进行相应处理
- 实现钩子的类型
Webpack 插件机制
Tapable 的作用
Tapable 是一个类似于 Node.js 中的 EventEmitter 的库,但它更专注于自定义事件的触发和处理。通过 Tapable 我们可以注册自定义事件,然后在适当的时机去执行自定义事件。