Tapable学习

20 阅读10分钟

2020.2.28

前言

看webpack源码就绕不过Tapable,Tapable介绍里都会提到这么一句:Tapable是个类似于Node.js EventEmitter的库。于是不禁发问:EventEmitter有什么场景无法满足事件管理,导致开发了个Tapable呢?

本文带着这个问题,对Tapable是什么、如何使用、底层如何实现等知识进行介绍。

NodeJS EventEmitter

先来看一个使用EventEmitter注册自定义事件并触发的例子:

const EventEmitter = require('events').EventEmitter; 
const events = new EventEmitter()
// 注册自定义事件
events.on('login', (param) => {
  console.log('triggered', param)
})
events.once('logout', function() {
  console.log('logout triggered')
})
// 触发
events.emit('login', 'success') // triggered sucess
events.emit('loginout') // logout triggered

EventEmitter:

1.提供了on emit once三个方法

2.如果给一个事件订阅了多个监听器,那么会按注册顺序执行

那么,如果一个事件名称注册了多个监听器,如何控制这些监听器的执行顺序?如何在监听器之间传递值?如何中止某个监听器的执行?监听器有多个异步操作,想等这些异步操作都执行完了如何处理?

似乎做不到,猜想也许正是由于这些问题,产生了Tapable.

Tapable是什么

一个用于自定义事件的触发和处理管理的库,比EventEmitter强大。它定义了多种事件类型,能以更多方式控制监听器的执行,具体来说,

Tapable提供了9个钩子类型,使用不同类型的钩子自定义事件,能覆盖以下场景:

  • 连续地执行监听器
  • 并行地执行监听器
  • 一个接一个地执行监听器,从前面的监听器获取输入
  • 异步地执行监听器
  • 在允许时停止执行监听器

Tapable是webpack的一个核心组件,但它也可以用于其他类似提供插件接口场景的应用。

webpack中很多对象是继承自Tapable的.

Tapable的9个钩子类型

const {
	SyncHook,
	SyncBailHook,
	SyncWaterfallHook,
	SyncLoopHook,
	AsyncParallelHook,
	AsyncParallelBailHook,
	AsyncSeriesHook,
	AsyncSeriesBailHook,
	AsyncSeriesWaterfallHook
 } = require("tapable");
序号钩子名称执行方式特点
1SyncHook同步串行不关心监听函数返回值
2SyncBailHook同步串行监听函数中只要有一个return 有值,后面的就不执行
3SyncWaterfallHook同步串行上一个监听函数返回值可以传递给下一个监听函数
4SyncLoopHook同步循环监听函数返回true则一直循环执行,返回undefined则停止
5AsyncParallelHook异步并行不关心监听函数返回值
6AsyncParallelBailHook异步并行监听函数返回值不为null则后面的不执行,然后执行callAsync的回调函数(如果有的话)
7AsyncSeriesHook异步串行不关心监听函数的参数
8AsyncSeriesWaterfallHook异步串行监听函数的参数不为null,则直接执行callAsync的回调函数(如果有的话)
9AsyncSeriesBailHook异步串行上一个监听函数callback(err, data)的第二个参数data作为值传递给下一个监听函数的参数

分类

每个钩子都可以订阅一个或者多个function。根据这些function如何执行可以将钩子分为4类:

  • basic hook 按顺序执行
  • waterfall 瀑布流式执行,与basic hook不同的是,可以在相邻的function之间传值
  • bail 允许你提前退出,如果有一个function有返回值,则停下来,后面的function就不执行了
  • loop 允许循环执行function

换个维度,根据钩子是同步的还是异步的,还可以分为3类:

  • Sync 同步函数
  • AsyncSeries,异步function串行执行
  • AsyncParallel,异步funciton并行执行

分类

每个钩子都可以订阅一个或者多个function。根据这些function如何执行可以将钩子分为4类:

  • basic hook 按顺序执行
  • waterfall 瀑布流式执行,与basic hook不同的是,可以在相邻的function之间传值
  • bail 允许你提前退出,如果有一个function有返回值,则停下来,后面的function就不执行了
  • loop 允许循环执行function

换个维度,根据钩子是同步的还是异步的,还可以分为3类:

  • Sync 同步函数
  • AsyncSeries,异步function串行执行
  • AsyncParallel,异步funciton并行执行

Tapable的使用

基本用法

注册

注册:tap/tapAsync/tapPromise

其中同步钩子使用tap注册,异步钩子使用tapAsync/tapPromise注册(效果不同)

调用

call/callAsync

Tapable SyncHook Demo:

const { SyncHook } = require('tapable')
const hook = new SyncHook(['arg1', 'arg2'])  // 自定义callback的参数

hook.tap('eventname', (arg1, arg2, arg3) => {
  console.log(arg1, arg2, arg3) // 1, undefined, undefined
})

hook.call(1)
const { SyncHook } = require('tapable')
class Car {
  constructor() {
    this.hooks = {
      accelarate: new SyncHook(['newSpeed']),
      brake: new SyncHook(),
    }
  }
}
const myCar = new Car()

myCar.hooks.accelarate.tap('eventname1', (speed) => {
  console.log('speed cb 1:', speed)
})

myCar.hooks.accelarate.tap('eventname2', (speed) => {
  console.log('speed cb 2:', speed)
})

myCar.hooks.accelarate.call(50)

拦截器interception

tapable还提供了拦截器。所有的钩子都提供了拦截API, 共有register, call, tap, loop 四个API,这里不展开讲

原理实现

从上面的demo来看,Tapable的使用方式与EventEmitter还是不太一样的。(不同于on emit)那么它是如何实现事件的监听与触发的呢(call方法执行时如何找到监听器函数并按规则执行)?

SyncHook源码阅读

我们以SyncHook为例,了解下内部机制:

Tapable声明了四个类

class Hook {} // 基础的Hook类

class SyncHook extends Hook {} // 同步钩子类

class HookCodeFactory {}  // 用于生成hook代码的工厂类,类里的create方法使用new Function 为sync, async, promise三类钩子生成call时的fn

class SyncHookCodeFactory extends HookCodeFactory {}

其中,HookCodeFactory是编译生成可执行 fn 的工厂类,这意味着Hook的函数体代码是拼接生成的, HookCodeFactory类里的create方法使用new Function 为sync, async, promise三类钩子生成函数代码

初始化

class Hook {
  // ...
}
Object.defineProperties(Hook.prototype, {
	_call: {
		value: createCompileDelegate("call", "sync"),
		configurable: true,
		writable: true
	},
} // constructor里: this.call = this._call;

其中createCompileDelegate:

function createCompileDelegate(name, type) {
	return function lazyCompileHook(...args) {
		this[name] = this._createCall(type); // name: 'call'
		return this[name](...args);
	};
}

这里lazyCompileHook是个惰性函数 ,创建this.call函数,并在下一次使用时直接返回(提升性能)

其中_createCall是Hook类的成员函数:

class Hook {
// ...
	_createCall(type) {
		return this.compile({
			taps: this.taps,
			interceptors: this.interceptors,
			args: this._args,
			type: type
		});
	}
// ...
}

其中compile:

class Hook {
// ...
  compile(options) {
		factory.setup(this, options); // 取出taps里的fn
		return factory.create(options);  // factory即是上述四个类中的SyncHookCodeFactory,create用来生成代码
	}
// ...
}

factory.create根据"call", "sync"两个参数,生成了方法的函数体代码,这样就定义好了call函数:

class HookCodeFactory {
  create(options) {
		this.init(options);
		let fn;
		switch (this.options.type) {
			case "sync":
				fn = new Function( // 生成动态的function
					this.args(),
					'"use strict";\n' +
						this.header() +
						this.content({ // this.content 每种类型的钩子不同的实现 会去取this.taps
							onError: err => `throw ${err};\n`,
							onResult: result => `return ${result};\n`,
							resultReturns: true,
							onDone: () => "",
							rethrowIfPossible: true
						})
				);
				break;
			case "async":
				fn = new Function(
					this.args({
						after: "_callback"
					}),
					'"use strict";\n' +
						this.header() +
						this.content({
							onError: err => `_callback(${err});\n`,
							onResult: result => `_callback(null, ${result});\n`,
							onDone: () => "_callback();\n"
						})
				);
				break;
			case "promise":
				let errorHelperUsed = false;
				const content = this.content({
					onError: err => {
						errorHelperUsed = true;
						return `_error(${err});\n`;
					},
					onResult: result => `_resolve(${result});\n`,
					onDone: () => "_resolve();\n"
				});
				let code = "";
				code += '"use strict";\n';
				code += "return new Promise((_resolve, _reject) => {\n";
				if (errorHelperUsed) {
					code += "var _sync = true;\n";
					code += "function _error(_err) {\n";
					code += "if(_sync)\n";
					code += "_resolve(Promise.resolve().then(() => { throw _err; }));\n";
					code += "else\n";
					code += "_reject(_err);\n";
					code += "};\n";
				}
				code += this.header();
				code += content;
				if (errorHelperUsed) {
					code += "_sync = false;\n";
				}
				code += "});\n";
				fn = new Function(this.args(), code);
				break;
		}
		this.deinit();
		return fn;
	}
}

这里的new Function解释下:我们知道,js中定义函数常用的有:

// 定义1. 函数声明
function add(a, b){
    return a + b
}

// 定义2. 函数表达式
const add = function(a, b){
    return a + b
}
```js
还有第三种,我们平常用的相对较少:
```js
// 定义3. new Function
const add = new Function('a', 'b', 'return a + b')

他们的区别就是前两种是静态的,函数的功能是做什么是在定义时就定下来了;而第三种是动态的,也就是函数的功能可能会随程序运行发生变化。

tap收集监听器

class Hook {
  constructor(args) {
		this.taps = [];
		this.call = this._call;
	}
  tap(options, fn) {
      this._insert(options);
  }
  _insert(item) {
    	// ...
    	// 检查name 
    		if (before.has(x.name)) {
					before.delete(x.name);
					continue;
				}
      this.taps[i] = item; // this.taps里保存了所有的监听器
  		// ...
  }
}

示例

假设有下列demo使用代码:

const { SyncHook } = require('tapable')

class Car {
  constructor() {
    this.hooks = {
      accelarate: new SyncHook(['newSpeed']),
      brake: new SyncHook(),
    }
  }
}

const myCar = new Car()

myCar.hooks.accelarate.tap('eventname1', (speed) => {
  console.log('speed cb 1:', speed)
})

myCar.hooks.accelarate.tap('eventname2', (speed) => {
  console.log('speed cb 2:', speed)
})

myCar.hooks.accelarate.call(50) // createCall时,已经根据之前tap,生成了call时的代码。 call执行时,会依次执行callback代码

这里我们执行到call时,进入函数,断点看一下call方法的函数体代码:

function anonymous(newSpeed
/*``*/) {
  "use strict";
  var _context;
  var _x = this._x;
  var _fn0 = _x[0];// 注册的第一个监听器
  _fn0(newSpeed); // 执行
  var _fn1 = _x[1]; // 注册的第二个监听器
  _fn1(newSpeed);// 执行
}

总结一下,比较让我印象深刻的是:generate code这个地方

官网上 有段介绍很棒:

The Hook will compile a method with the most efficient way of running your plugins. It generates code depending on:

  • The number of registered plugins (none, one, many)
  • The kind of registered plugins (sync, async, promise)
  • The used call method (sync, async, promise)
  • The number of arguments
  • Whether interception is used

This ensures fastest possible execution.

也就是,Tapable会根据注册的监听器的数量、种类、call方法,参数个数,是否有拦截器等生成钩子的本体代码。

其他类型Hook如何管理顺序的?

SyncBailHook this.call.toString():

"function anonymous(newSpeed
/*``*/) {
  "use strict";
  var _context;
  var _x = this._x;
  var _fn0 = _x[0];
  var _result0 = _fn0(newSpeed);
  if(_result0 !== undefined) {
    return _result0;;
  } else {
    var _fn1 = _x[1];
    var _result1 = _fn1(newSpeed);
    if(_result1 !== undefined) {
      return _result1;
    } else {
    }
  }
}"

SyncWaterfallHook  this.call.toString():

"function anonymous(newSpeed
/*``*/) {
  "use strict";
  var _context;
  var _x = this._x;
  var _fn0 = _x[0];
  var _result0 = _fn0(newSpeed);
  if(_result0 !== undefined) {
  	newSpeed = _result0;
  }
  var _fn1 = _x[1];
  var _result1 = _fn1(newSpeed);
  if(_result1 !== undefined) {
  	newSpeed = _result1;
  }
  return newSpeed;
}"

AsyncParallelHook Demo:

const { AsyncParallelHook, SyncHook } = require('tapable')

class Car {
  constructor() {
    this.hooks = {
      accelarate: new AsyncParallelHook(['newSpeed']),
      brake: new SyncHook(),
    }
  }
}

const myCar = new Car()

// 1. tap
// myCar.hooks.accelarate.tap('eventname1', (speed) => {
//   console.log('speed cb 1:', speed)
// })

// myCar.hooks.accelarate.tap('eventname2', (speed) => {
//   console.log('speed cb 2:', speed)
// })

// myCar.hooks.accelarate.callAsync(50, (err) => {
//   console.log('end') // 
// })

// 2. tapAsync 注意这种用法里 参数多了个cb 回调
myCar.hooks.accelarate.tapAsync('eventname1', (speed, cb) => {
  setTimeout(() => {
    console.log(1, speed);
    cb();
}, 1000);
})

myCar.hooks.accelarate.tapAsync('eventname2', (speed, cb) => {
  setTimeout(() => {
    console.log(2, speed);
    cb();
}, 2000);
})

myCar.hooks.accelarate.callAsync(50, () => {
  console.log('end') // 
})

// 3.tapPromise
// myCar.hooks.accelarate.tapPromise('eventname1', (speed, cb) => {
//   return new Promise(function (resolve, reject) {
//     setTimeout(() => {
//         console.log(1, speed);
//         resolve();
//     }, 1000);
//   });
// })

// myCar.hooks.accelarate.tapPromise('eventname2', (speed, cb) => {
//   return new Promise(function (resolve, reject) {
//     setTimeout(() => {
//         console.log(2, speed);
//         resolve();
//     }, 2000);
//   });
// })

// myCar.hooks.accelarate.promise(50).then(() => {
//   console.log('end')
// })

AsyncParallelHook  this.callAsync.toString():

function anonymous(
  newSpeed,
  _callback,
  /*``*/
) {
  'use strict';
  var _context;
  var _x = this._x;
  do {
    var _counter = 2;
    var _done = () => {
      _callback();
    };
    if (_counter <= 0) break;
    var _fn0 = _x[0];
    _fn0(newSpeed, _err0 => {
      if (_err0) {
        if (_counter > 0) {
          _callback(_err0);
          _counter = 0;
        }
      } else {
        if (--_counter === 0) _done();
      }
    });
    if (_counter <= 0) break;
    var _fn1 = _x[1];
    _fn1(newSpeed, _err1 => {
      if (_err1) {
        if (_counter > 0) {
          _callback(_err1);
          _counter = 0;
        }
      } else {
        if (--_counter === 0) _done();
      }
    });
  } while (false);
}

AsyncParallel counter计数器,每执行完一个计数器减一,减到0则执行回调

function anonymous(
  newSpeed,
  _callback
  /*``*/
) {
  "use strict";
  var _context;
  var _x = this._x;
  function _next0() {
    var _fn1 = _x[1];
    _fn1(newSpeed, _err1 => {
      if (_err1) {
        _callback(_err1);
      } else {
        _callback();
      }
    });
  }
  var _fn0 = _x[0];
  _fn0(newSpeed, _err0 => {
    if (_err0) {
      _callback(_err0);
    } else {
      _next0();
    }
  });
}

AsyncSeriesHook 按顺序执行,回调函数执行法

Tips

Tapable 2.0 处于beta阶段,据说针对async的执行进行了优化(减小内存消耗问题,消除递归)

webpack + Tapable

webpack 就像一条生产线,具有多个处理流程,每个流程职责单一,流程之间有依赖关系,处理完交个下个流程。

插件就像插入到生产线中的一个功能,能在特定的时机对生产线上的资源做处理。

加入生产线的方式就是监听webpack广播出来的事件。

webpack通过Tapable来组织这条生产线。

好处:有序性好、扩展性好

webpack中都使用了什么样的钩子呢?

Compiler钩子类型描述
doneAsyncSeriesHook如果有两个插件监听了done钩子,意味着两个顺序执行完了才会执行回调
entryOptionAsyncBailHook如果有两个插件监听了entryOption钩子,意味着如果两个插件都开启了时,有一个plugin触发了如果有返回值,则另一个不会触发
makeAsyncParallelHook如果有四个插件监听了make钩子,意味着call触发时,回调函数会等待这四个插件的监听器并行执行完才会执行

调试技巧

1.断点查看function body code: fn.toString()

Reference

  1. 编写自定义webpack插件从理解Tapable开始: juejin.im/post/5dcba2…
  2. webpack4.0源码分析之Tapable: juejin.im/post/5abf33… (demo较多)
  3. Tapable Github:github.com/webpack/tap…
  4. js之惰性函数:juejin.im/entry/5a629…
  5. 可能是全网最全最新最细的 webpack-tapable-2.0 的源码分析