解读 rollup Plugin (二)–你最不想看的各种钩子函数

这是我参与更文挑战的第 18 天,活动详情查看: 更文挑战

Lynne,一个能哭爱笑永远少女心的前端开发工程师。身处互联网浪潮之中,热爱生活与技术。

rollup plugin 的实现

接上一篇解读 rollup Plugin (一)

当然,也不可能就这么简单啦~毕竟还要考虑实际复杂应用场景的~~

插件驱动

PluginDriver — 插件驱动器,调用插件和提供插件环境上下文等

钩子函数的调用时机

平常书写rollup插件的时候,最关注的就是钩子函数部分了,钩子函数的调用时机有三类:

  • const chunks = rollup.rollup执行期间的构建钩子函数 – Build Hooks
  • chunks.generator(write)执行期间的输出钩子函数 – Output Generation Hooks
  • 监听文件变化并重新执行构建的rollup.watch执行期间的 watchChange 钩子函数

构建钩子函数

为了与构建过程交互,你的插件对象需要包含一些构建钩子函数。构建钩子是构建的各个阶段调用的函数。构建钩子函数可以影响构建执行方式、提供构建的信息或者在构建完成后修改构建。rollup 中有不同的构建钩子函数:

  • async: 处理promise的异步钩子,也有同步版本
  • first: 如果多个插件实现了相同的钩子函数,那么会串式执行,从头到尾,但是,如果其中某个的返回值不是null也不是undefined的话,会直接终止掉后续插件。
  • sequential: 如果多个插件实现了相同的钩子函数,那么会串式执行,按照使用插件的顺序从头到尾执行,如果是异步的,会等待之前处理完毕,在执行下一个插件。
  • parallel: 同上,不过如果某个插件是异步的,其后的插件不会等待,而是并行执行。

构建钩子函数在构建阶段执行,它们被 [rollup.rollup(inputOptions)](https://github.com/rollup/rollup/blob/07b3a02069594147665daa95d3fa3e041a82b2d0/cli/run/build.ts#L34) 触发。它们主要关注在 Rollup 处理输入文件之前定位、提供和转换输入文件。构建阶段的第一个钩子是 options,最后一个钩子总是 buildEnd,除非有一个构建错误,在这种情况下 closeBundle 将在这之后被调用。

此外,在观察模式下,watchChange 钩子可以在任何时候被触发,以通知新的运行将在当前运行产生其输出后被触发。另外,当 watcher 关闭时,closeWatcher 钩子函数将被触发。

输出钩子函数

输出生成钩子函数可以提供关于生成的包的信息并在构建完成后立马执行。它们和构建钩子函数拥有一样的工作原理和相同的类型,但是不同的是它们分别被 ·[bundle.generate(output)](https://github.com/rollup/rollup/blob/07b3a02069594147665daa95d3fa3e041a82b2d0/cli/run/build.ts#L44)[bundle.write(outputOptions)](https://github.com/rollup/rollup/blob/07b3a02069594147665daa95d3fa3e041a82b2d0/cli/run/build.ts#L64) 调用。只使用输出生成钩子的插件也可以通过输出选项传入,因为只对某些输出运行。

输出生成阶段的第一个钩子函数是 outputOptions,如果输出通过 bundle.generate(…) 成功生成则第一个钩子函数是 generateBundle,如果输出通过 [bundle.write(...)](https://github.com/rollup/rollup/blob/07b3a02069594147665daa95d3fa3e041a82b2d0/src/watch/watch.ts#L200) 生成则最后一个钩子函数是 [writeBundle](https://github.com/rollup/rollup/blob/master/src/rollup/rollup.ts#L176),另外如果输出生成阶段发生了错误的话,最后一个钩子函数则是 renderError

另外,closeBundle 可以作为最后一个钩子被调用,但用户有责任手动调用 bundle.close() 来触发它。CLI 将始终确保这种情况发生。

钩子函数加载实现

[PluginDriver](https://github.com/rollup/rollup/blob/07b3a02069594147665daa95d3fa3e041a82b2d0/src/utils/PluginDriver.ts#L124) 中有 9 个 hook 加载函数。主要是因为每种类别的 hook 都有同步和异步的版本。

接下来从分类来看函数钩子的应用场景:

1. hookFirst

加载 first 类型的钩子函数,场景有 resolveIdresolveAssetUrl 等

function hookFirst<H extends keyof PluginHooks, R = ReturnType<PluginHooks[H]>>(
  hookName: H,
  args: Args<PluginHooks[H]>,
  replaceContext?: ReplaceContext | null,
  skip?: number | null
): EnsurePromise<R> {
  // 初始化 promise
  let promise: Promise<any> = Promise.resolve();
  // this.plugins 在实例化 Graph 的时候,进行了初始化
  for (let i = 0; i < this.plugins.length; i++) {
    if (skip === i) continue;
    // 覆盖之前的 promise,换言之就是串行执行钩子函数
    promise = promise.then((result: any) => {
      // 返回非 null 或 undefined 的时候,停止运行,返回结果
      if (result != null) return result;
      // 执行钩子函数
      return this.runHook(hookName, args as any[], i, false, replaceContext);
    });
  }
  // 返回 hook 过的 promise
  return promise;
}
复制代码

2.hookFirstSync

hookFirst 的同步版本,使用场景有 resolveFileUrlresolveImportMeta 等

function hookFirstSync<H extends keyof PluginHooks, R = ReturnType<PluginHooks[H]>>(
  hookName: H,
  args: Args<PluginHooks[H]>,
  replaceContext?: ReplaceContext
): R {
  for (let i = 0; i < this.plugins.length; i++) {
    // runHook 的同步版本
    const result = this.runHookSync(hookName, args, i, replaceContext);
    // 返回非 null 或 undefined 的时候,停止运行,返回结果
    if (result != null) return result as any;
  }
  // 否则返回 null
  return null as any;
}
复制代码

3. hookParallel

并行执行 hook,不会等待当前 hook 完成。使用场景 buildEndbuildStartmoduleParsed 等。

hookParallel<H extends AsyncPluginHooks & ParallelPluginHooks>(
  hookName: H,
  args: Parameters<PluginHooks[H]>,
  replaceContext?: ReplaceContext
): Promise<void> {
  const promises: Promise<void>[] = [];
  for (const plugin of this.plugins) {
    const hookPromise = this.runHook(hookName, args, plugin, false, replaceContext);
    if (!hookPromise) continue;
    promises.push(hookPromise);
  }
  return Promise.all(promises).then(() => {});
}
复制代码

4.hookReduceArg0

对 arg 第一项进行 reduce 操作。使用场景: optionsrenderChunk 等

function hookReduceArg0<H extends keyof PluginHooks, V, R = ReturnType<PluginHooks[H]>>(
    hookName: H,
    [arg0, ...args]: any[], // 取出传入的数组的第一个参数,将剩余的置于一个数组中
    reduce: Reduce<V, R>,
    replaceContext?: ReplaceContext // 替换当前 plugin 调用时候的上下文环境
) {
  let promise = Promise.resolve(arg0); // 默认返回 source.code
  for (let i = 0; i < this.plugins.length; i++) {
    // 第一个 promise 的时候只会接收到上面传递的arg0
    // 之后每一次 promise 接受的都是上一个插件处理过后的 source.code 值
    promise = promise.then(arg0 => {
      const hookPromise = this.runHook(hookName, [arg0, ...args], i, false, replaceContext);
      // 如果没有返回 promise,那么直接返回 arg0
      if (!hookPromise) return arg0;
      // result 代表插件执行完成的返回值
      return hookPromise.then((result: any) =>
        reduce.call(this.pluginContexts[i], arg0, result, this.plugins[i])
      );
    });
  }
  return promise;
}
复制代码

5.hookReduceArg0Sync

hookReduceArg0 同步版本,使用场景 transformgenerateBundle 等

6. hookReduceValue

将返回值减少到类型T,分别处理减少的值。允许钩子作为值。

hookReduceValue<H extends PluginValueHooks, T>(
		hookName: H,
		initialValue: T | Promise<T>,
		args: Parameters<AddonHookFunction>,
		reduce: (
			reduction: T,
			result: ResolveValue<ReturnType<AddonHookFunction>>,
			plugin: Plugin
		) => T,
		replaceContext?: ReplaceContext
	): Promise<T> {
		let promise = Promise.resolve(initialValue);
		for (const plugin of this.plugins) {
			promise = promise.then(value => {
				const hookPromise = this.runHook(hookName, args, plugin, true, replaceContext);
				if (!hookPromise) return value;
				return hookPromise.then(result =>
					reduce.call(this.pluginContexts.get(plugin), value, result, plugin)
				);
			});
		}
		return promise;
	}
复制代码

7. hookReduceValueSync

hookReduceValue的同步版本

8. hookSeq

加载 sequential 类型的钩子函数,和 hookFirst 的区别就是不能中断,使用场景有 onwritegenerateBundle 等

async function hookSeq<H extends keyof PluginHooks>(
  hookName: H,
  args: Args<PluginHooks[H]>,
  replaceContext?: ReplaceContext,
  // hookFirst 通过 skip 参数决定是否跳过某个钩子函数
): Promise<void> {
  let promise: Promise<void> = Promise.resolve();
  for (let i = 0; i < this.plugins.length; i++)
    promise = promise.then(() =>
      this.runHook<void>(hookName, args as any[], i, false, replaceContext),
    );
  return promise;
}
复制代码

9.hookSeqSync

hookSeq 同步版本,不需要构造 promise,而是直接使用 runHookSync 执行钩子函数。使用场景有 closeWatcherwatchChange 等。

hookSeqSync<H extends SyncPluginHooks & SequentialPluginHooks>(
  hookName: H,
  args: Parameters<PluginHooks[H]>,
  replaceContext?: ReplaceContext
): void {
  for (const plugin of this.plugins) {
    this.runHookSync(hookName, args, plugin, replaceContext);
  }
}
复制代码

通过观察上面几种钩子函数的调用方式,我们可以发现,其内部有一个调用钩子函数的方法: runHook(Sync),该函数执行插件中提供的钩子函数。

runHook(Sync)

function runHook<T>(
  hookName: string,
  args: any[],
  pluginIndex: number,
  permitValues: boolean,
  hookContext?: ReplaceContext | null,
): Promise<T> {
  this.previousHooks.add(hookName);
  // 找到当前 plugin
  const plugin = this.plugins[pluginIndex];
  // 找到当前执行的在 plugin 中定义的 hooks 钩子函数
  const hook = (plugin as any)[hookName];
  if (!hook) return undefined as any;

  // pluginContexts 在初始化 plugin 驱动器类的时候定义,是个数组,数组保存对应着每个插件的上下文环境
  let context = this.pluginContexts[pluginIndex];
  // 用于区分对待不同钩子函数的插件上下文
  if (hookContext) {
    context = hookContext(context, plugin);
  }
  return Promise.resolve()
    .then(() => {
      // 许可值允许返回值,而不是一个函数钩子,使用 hookReduceValue 或 hookReduceValueSync 加载。
      if (typeof hook !== 'function') {
        if (permitValues) return hook;
        return error({
          code: 'INVALID_PLUGIN_HOOK',
          message: `Error running plugin hook ${hookName} for ${plugin.name}, expected a function hook.`,
        });
      }
      // 传入插件上下文和参数,返回插件执行结果
      return hook.apply(context, args);
    })
    .catch(err => throwPluginError(err, plugin.name, { hook: hookName }));
}
复制代码

插件上下文

rollup给钩子函数注入了context,也就是上下文环境,用来方便对chunks和其他构建信息进行增删改查。

const context: PluginContext = {
    addWatchFile(id) {},
    cache: cacheInstance,
    emitAsset: getDeprecatedContextHandler(...),
    emitChunk: getDeprecatedContextHandler(...),
    emitFile: fileEmitter.emitFile,
    error(err)
    getAssetFileName: getDeprecatedContextHandler(...),
    getChunkFileName: getDeprecatedContextHandler(),
    getFileName: fileEmitter.getFileName,
    getModuleIds: () => graph.modulesById.keys(),
    getModuleInfo: graph.getModuleInfo,
    getWatchFiles: () => Object.keys(graph.watchFiles),
    isExternal: getDeprecatedContextHandler(...),
    meta: {
        rollupVersion,
        watchMode: graph.watchMode
    },
    get moduleIds() {
        const moduleIds = graph.modulesById.keys();
        return wrappedModuleIds();
    },
    parse: graph.contextParse,
    resolve(source, importer, { custom, skipSelf } = BLANK) {
        return graph.moduleLoader.resolveId(source, importer, custom, skipSelf ? pidx : null);
    },
    resolveId: getDeprecatedContextHandler(...),
    setAssetSource: fileEmitter.setAssetSource,
    warn(warning) {}
};
复制代码

插件的缓存

插件还提供缓存的能力,实现的非常巧妙.

export function createPluginCache(cache: SerializablePluginCache): PluginCache {
	// 利用闭包将cache缓存
	return {
		has(id: string) {
			const item = cache[id];
			if (!item) return false;
			item[0] = 0; // 如果访问了,那么重置访问过期次数,猜测:就是说明用户有意向主动去使用
			return true;
		},
		get(id: string) {
			const item = cache[id];
			if (!item) return undefined;
			item[0] = 0; // 如果访问了,那么重置访问过期次数
			return item[1];
		},
		set(id: string, value: any) {
            // 存储单位是数组,第一项用来标记访问次数
			cache[id] = [0, value];
		},
		delete(id: string) {
			return delete cache[id];
		}
	};
}
复制代码

然后创建缓存后,会添加在插件上下文中:

import createPluginCache from 'createPluginCache';

const cacheInstance = createPluginCache(pluginCache[cacheKey] || (pluginCache[cacheKey] = Object.create(null)));

const context = {
	// ...
    cache: cacheInstance,
    // ...
}
复制代码

之后我们就可以在插件中就可以使用cache进行插件环境下的缓存,进一步提升打包效率:

function testPlugin() {
  return {
    name: "test-plugin",
    buildStart() {
      if (!this.cache.has("prev")) {
        this.cache.set("prev", "上一次插件执行的结果");
      } else {
        // 第二次执行rollup的时候会执行
        console.log(this.cache.get("prev"));
      }
    },
  };
}
let cache;
async function build() {
  const chunks = await rollup.rollup({
    input: "src/main.js",
    plugins: [testPlugin()],
    // 需要传递上次的打包结果
    cache,
  });
  cache = chunks.cache;
}

build().then(() => {
  build();
});
复制代码

总结

恭喜你,把 rollup 那么几种钩子函数都熬着看过来了,可能实际的插件开发中我们未必会用到这些知识,我们也未必能一一掌握,但有些东西你必须得先知道,才能进行下一步~~

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享