Webpack 插件架构 - Tapable

前言

webpack 基于 Tapable 实现了一套插件架构体系,它能够在特定时机触发钩子,并附带上足够的上下文信息。

在插件定义的钩子回调中,与这些上下文背后的数据结构、接口交互产生 side effect,进而影响编译状态和后续流程。

Tapable 为了适配 webpack 体系下的复杂交互需求,如某些场景需要支持将前一个钩子回调的处理结果传入下一个钩子回调中,因此提供了不同类型的钩子。

阅读完本文,相信你会对 Tapable 的钩子类型、特点以及应用场景,有了一个熟悉的过程。

一、理解插件

从代码形态上看,插件是一个具有 apply 方法的类,函数运行时会拿到参数 compiler,通过 compiler 可以调用 hook 对象注册各种钩子回调,亦或者是获取 compilation 实例。

class SomePlugin {
 apply(compiler) {
 compiler.hooks.compilation.tap('Plugin', compilation => {
 // 拿到 compilation 实例监听 compilation.hooks.xxx
 });
 }
}

其中 hooks.compilation 的构造实例为 Tapable 库所提供的钩子对象;tap 为订阅函数,用于注册回调。它的定义如下:

const { SyncHook } = require("tapable");
class Compiler {
 constructor(context, options = {}) {
 this.hooks = Object.freeze({
 compilation: new SyncHook(["compilation", "params"]),
 ...
 });
 }
}

当在合适时机需要触发钩子时,通过 call 通知插件介入程序流程:

compiler.hooks.compilation.call(compilation, params);

webpack 的插件体系都是基于 Tapable 提供的各类钩子展开,下面我们看看 Tapable 的核心和特点。

二、Tapable 基本用法

Tapable 虽然有很多类型钩子,但每个类型的钩子使用步骤不外乎:

  1. 创建钩子实例;
  2. 调用订阅 api 注册回调,包括:tap、tapAsync、tapPromise;
  3. 调用发布 api 触发回调,包括:call、callAsync、promise。

例如,最简单的一个 SyncHook 同步类型钩子,通过 tap 注册回调,使用 call 触发回调。

const { SyncHook } = require("tapable");
const hook = new SyncHook();
hook.tap("syncHook", () => {
 console.log("同步钩子!");
});
hook.call(); // 输出:同步钩子!

Tapable 提供的类型钩子分 SyncAsync 两大类,下面我们看看 webpack 中常用的几类钩子的使用特点与具体实现。

  1. SyncHook,同步钩子;
  2. SyncBailHook,同步熔断钩子;
  3. SyncWaterfallHook,同步瀑布流钩子;
  4. SyncLoopHook,同步循环钩子;
  5. AsyncSeriesHook,异步串行钩子;
  6. AsyncParallelHook,异步并行钩子。

waterfall 瀑布流是指 前一个回调的返回值会被作为参数传入下一个回调中

bail 熔断是指 依次调用回调,若有任何一个回调返回非 undefined 值,则终止后续的调用

loop 循环调用,直到所有回调函数都返回 undefined

三、同步钩子

3.1、SyncBook

上例我们使用的就是 SyncBook 钩子,也是最容易理解的一个,触发后按照注册顺序逐个调用回调,伪代码表示如下:

function syncCall() {
 const taps = [fn1, fn2, fn3];
 for (let i = 0; i < taps.length; i++) {
 const cb = taps[i];
 cb();
 }
}

想要实现这样一个 Hooks 也非常容易,简单实现如下:

class SyncHook { // 同步钩子
 constructor(args) { // args => ['name'],没有实际作用,仅帮助使用说明
 this.tasks = [];
 }
 // 这个 name 没有特别作用,方便开发者区别
 tap(name, task) {
 this.tasks.push(task);
 }
 call(...args) {
 this.tasks.forEach(task => task(...args));
 }
}

3.2、SyncBailHook

bail 类型钩子,在回调队列中,若任意一个回调返回了非 undefined 的值,则中断后续回调的执行,直接返回该值,伪代码表示如下:

function bailCall() {
 const taps = [fn1, fn2, fn3];
 for (let i in taps) {
 const cb = taps[i];
 const result = cb(lastResult);
 if (result !== undefined) {
 return result; // 熔断
 }
 }
 return undefined;
}

使用实例:

const { SyncBailHook } = require("tapable");
const hook = new SyncBailHook();
hook.tap('fn1', () => {
 console.log('fn1');
 return '我要熔断!'
});
hook.tap('fn2', () => {
 console.log('fn2');
});
console.log(hook.call());
// 运行结果:
// fn1
// 我要熔断!

可见,tap('fn2') 的注册函数并未执行。

在 webpack 源码中,SyncBailHook 通常被用在关注回调运行结果的场景,如:compiler.hooks.shouldEmit

if (this.hooks.shouldEmit.call(compilation) === false) {
 // ...
}

想要实现一个 SyncBailHook 也并不费劲:

class SyncBailHook {
 constructor(args) {
 this.tasks = [];
 }
 tap(name, task) {
 this.tasks.push(task);
 }
 call(...args) {
 let result; // 当前执行函数的返回结果
 let index = 0; // 当前执行函数的索引
 // 默认先执行一次,看结果是不是undefined,是继续执行,否结束
 do {
 result = this.tasks[index++](...args);
 } while(result === undefined && index < this.tasks.length);
 return result;
 }
}

3.3、SyncWaterfallHook

waterfall 的特点是将前一个回调的返回值作为参数传入下一个回调中,最终返回最后一个回调的返回值。伪代码表示如下:

function waterfallCall(arg) {
 const taps = [fn1, fn2, fn3];
 let lastResult = arg;
 for (let i in taps) {
 const cb = taps[i];
 // 上次执行结果作为参数传入下一个函数
 lastResult = cb(lastResult);
 }
 return lastResult;
}

使用示例:

const { SyncWaterfallHook } = require("tapable");
const hook = new SyncWaterfallHook(['msg']);
hook.tap('fn1', arg => {
 return `${arg}, fn1`;
});
hook.tap('fn2', arg => {
 return `${arg}, fn2`;
});
console.log(hook.call('hello'));
// 运行结果:
// hello, fn1, fn2

不过,在使用 SyncWaterfallHook 钩子有一些注意事项:

  1. 初始化时必须提供参数,例如上例 new SyncWaterfallHook(["msg"]) 构造函数中必须传入参数 ["msg"] ,用于动态编译 call 的参数依赖;
  2. 发布调用 call 时,需要传入初始参数。

在 webpack 源码中,SyncWaterfallHook 被应用于 NormalModuleFactory.hooks.factory,依据 waterfall 的特性逐步推断出最终的 module 对象。

SyncWaterfallHook 的核心实现如下:

class SyncWaterfallHook {
 constructor(args) {
 this.tasks = [];
 }
 tap(name, task) {
 this.tasks.push(task);
 }
 call(...args) {
 // 先拿到第一个返回结果,作为第二个执行函数的参数,依次循环
 let [first, ...others] = this.tasks;
 let result = first(...args);
 others.reduce((a, b) => {
 return b(a);
 }, result);
 }
}

3.4、SyncLoopHook

Loop 的特点是循环执行,当一个执行函数没有返回 undefined 时,会循环执行它,直到它返回 undefined 后再执行下一个函数。

使用示例:

const { SyncLoopHook } = require("tapable");
const hook = new SyncLoopHook(['name']);
let total = 0;
hook.tap('fn1', arg => {
 console.log('exec fn1.');
 return ++total === 3 ? undefined : 'fn1';
});
hook.tap('fn2', arg => {
 console.log('exec fn2.');
});
hook.call('hello');
// 运行结果:
// exec fn1.
// exec fn1.
// exec fn1.
// exec fn2.

我们来看看 SyncLoopHook 的核心实现:

class SyncLoopHook {
 constructor(args) {
 this.tasks = [];
 }
 tap(name, task) {
 this.tasks.push(task);
 }
 call(...args) {
 // 如果执行函数返回的不是 undefined,就一直循环执行它,直到它返回 undefined 再执行下一个
 this.tasks.forEach(task => {
 let result; // 返回结果
 do{
 result = task(...args)
 } while (result !== undefined)
 })
 }
}

四、异步钩子

同步钩子执行顺序简单,但问题在于回调中不能有异步操作,下面我们来看看以 Async 开头的异步钩子。

4.1、AsyncSeriesHook

AsyncSeriesHook 是一个异步串行方式执行的钩子,当上一个异步执行函数执行完毕后再执行下一个执行函数,全部执行完毕后再执行最终回调函数。

因为是异步操作,支持在回调函数中写 callbackpromise 异步操作。例如 callback 伪代码表示如下:

function asyncSeriesCall(callback) {
 const callbacks = [fn1, fn2, fn3];
 fn1((err1) => { // call fn1
 fn2((err2) => { // call fn2
 fn3((err3) => { // call fn3
 callback();
 });
 });
 });
}

callback 异步回调使用示例:

const { AsyncSeriesHook } = require("tapable");
const hook = new AsyncSeriesHook();
hook.tapAsync('fn1', cb => {
 console.log('call fn1');
 setTimeout(() => {
 cb();
 }, 1000);
});
hook.tapAsync('fn2', cb => {
 console.log('call fn2');
});
hook.callAsync();
// 输出结果:
// call fn1
// call fn2 // 1000ms 后执行

callback 异步回调核心实现:

class AsyncSeries {
 constructor(args) {
 this.tasks = [];
 }
 tapAsync(name, task) {
 this.tasks.push(task);
 }
 callAsync(...args) {
 let finalCallback = args.pop();
 let index = 0;
 let next = () => {
 if (this.tasks.length === index) return finalCallback();
 let task = this.tasks[index++];
 task(...args, next);
 }
 next();
 }
}

promise 异步方式使用示例:

const { AsyncSeriesHook } = require("tapable");
const hook = new AsyncSeriesHook();
hook.tapPromise('fn1', () => {
 console.log('call fn1');
 return new Promise(resolve => {
 setTimeout(resolve, 1000);
 });
});
hook.tapPromise('fn2', () => {
 console.log('call fn2');
 return Promise.resolve();
});
hook.promise();
// 输出结果:
// call fn1
// call fn2 // 1000ms 后执行

promise 异步方式核心实现:

class AsyncSeries {
 constructor(args) {
 this.tasks = [];
 }
 tapPromise(name, task) {
 this.tasks.push(task);
 }
 promise(...args) {
 let [first, ...others] = this.tasks;
 return others.reduce((promise, nextPromise) => { // 类似 redux 源码
 return promise.then(() => nextPromise(...args));
 }, first(...args));
 }
}

4.2、AsyncParallelHook

AsyncSeriesHook 类似,AsyncParallelHook 也支持异步风格的回调,不过 AsyncParallelHook 是以并行方式,同时执行回调队列里面的所有回调。

callback 异步方式使用示例:

const { AsyncParallelHook } = require("tapable");
const hook = new AsyncParallelHook();
hook.tapAsync('fn1', cb => {
 console.log('call fn1');
 setTimeout(() => {
 cb();
 }, 1000);
});
hook.tapAsync('fn2', cb => {
 console.log('call fn2');
});
hook.callAsync();
// 输出结果:
// call fn1
// call fn2 // 立即输出,无需等待 1000 ms

callback 异步方式具体实现:

class AsyncParralleHook {
 constructor(args) {
 this.tasks = [];
 }
 tapAsync(name, task) {
 this.tasks.push(task);
 }
 callAsync(...args) {
 let finalCallback = args.pop(); // 首先拿到所有函数都执行完毕后的回调函数
 let index = 0;
 let done = () => { // 很像 Promise.all
 index ++;
 if (index === this.tasks.length) {
 finalCallback();
 }
 }
 this.tasks.forEach(task => {
 task(...args, done);
 })
 }
}

promise 异步方式的使用示例与 AsyncSeriesHook 相似,核心实现是通过 Promise.all 执行每个回调:

class AsyncParralleHook {
 constructor(args) {
 this.tasks = [];
 }
 tapPromise(name, task) {
 this.tasks.push(task);
 }
 promise(...args) {
 let tasks = this.tasks.map(task => task(...args)); // 拿到每个 Promise
 return Promise.all(tasks);
 }
}

五、动态编译

上面,我们对各类 Hooks 的源码实现,都是采用静态编写逻辑的方式。而在 Tapable 源码中,所实现的执行钩子逻辑属于动态编译(代码动态生成去运行)。

比如回到最初我们讲述 SyncHook 的例子中,当我们设置 debugger 调试断点后,在调用 hook.call 的流程中会临时生成一个动态编译文件,里面的代码其实通过字符串拼接而来,通过 new Function 生成。

如 Tapable 源码中会有这样的处理:

// tapable/lib/HookCodeFactory.js
create(options) {
 this.options = options;
 this._args = options.args.slice();
 let fn;
 switch (this.options.type) {
 case 'sync':
 fn = new Function(
 this.args(),
 this.header() +
 this.content({
 onError: err => `throw ${err};\n`,
 onResult: result => `return ${result};\n`,
 resultReturns: true,
 onDone: () => "",
 rethrowIfPossible: true
 })
 );
 break;
 case 'async': // ...
 case 'promise': // ...
 }
 return fn;
}

动态生成的文件内容大致如下:

// 测试用例:
const { SyncHook } = require("tapable");
const hook = new SyncHook();
hook.tap("fn1", () => {
 console.log("fn1");
});
hook.tap("fn2", () => {
 console.log("fn2");
});
debugger;
hook.call(); // 输出:同步钩子!
// 动态编译:
(function anonymous(
) {
"use strict";
var _context;
var _x = this._x;
var _fn0 = _x[0];
_fn0(); // call fn1
var _fn1 = _x[1];
_fn1(); // call fn2
})

当然,若从执行性能、安全性方面考虑,通常不建议采用动态编译。

最后

感谢阅读。

参考:
1. Webpack 插件架构深度讲解。

作者:明里人

%s 个评论

要回复文章请先登录注册