关于 tapable 你须要知道这些

首发于个人博客: www.ahonn.me/post/58webpack

在阅读 webpack 前若是不了解 tapable 的话,颇有可能会看得云里雾里,那么 tapable 究竟是什么,又有什么用呢?本文主要介绍 tapable 的使用以及相关实现,经过学习 tapable 可以进一步的了解 webpack 的插件机制。如下内容皆基于 tapable v1.1.3 版本git

tapable 是一个相似于 Node.js 中的 EventEmitter的库,但更专一于自定义事件的触发和处理。webpack 经过 tapable 将实现与流程解耦,全部具体实现经过插件的形式存在。github

基本使用

想要了解 tapable 的实现,那就必然得知道 tapable 的用法以及有哪些使用姿式。tapable 中主要提供了同步与异步两种钩子。咱们先从简单的同步钩子开始提及。web

同步钩子

SyncHook

以最简单的 SyncHook 为例:编程

const { SyncHook } = require('tapable');
const hook = new SyncHook(['name']);
hook.tap('hello', (name) => {
	console.log(`hello ${name}`);
});
hook.tap('hello again', (name) => {
	console.log(`hello ${name}, again`);
});

hook.call('ahonn');
// hello ahonn
// hello ahonn, again
复制代码

能够看到当咱们执行 hook.call('ahonn') 时会依次执行前面 hook.tap(name, callback) 中的回调函数。经过 SyncHook 建立同步钩子,使用 tap 注册回调,再调用 call 来触发。这是 tapable 提供的多种钩子中比较简单的一种,经过 EventEmitter 也能轻松的实现这种效果。json

此外,tapable 还提供了不少有用的同步钩子:promise

  • SyncBailHook:相似于 SyncHook,执行过程当中注册的回调返回非 undefined 时就中止不在执行。
  • SyncWaterfallHook:接受至少一个参数,上一个注册的回调返回值会做为下一个注册的回调的参数。
  • SyncLoopHook:有点相似 SyncBailHook,可是是在执行过程当中回调返回非 undefined 时继续再次执行当前的回调。

异步钩子

除了同步执行的钩子以外,tapable 中还有一些异步钩子,最基本的两个异步钩子分别是 AsyncParallelHook 和 AsyncSeriesHook 。其余的异步钩子都是在这两个钩子的基础上添加了一些流程控制,相似于 SyncBailHook 之于 SyncHook 的关系。缓存

AsyncParallelHook

AsyncParallelHook 顾名思义是并行执行的异步钩子,当注册的全部异步回调都并行执行完毕以后再执行 callAsync 或者 promise 中的函数。异步

const { AsyncParallelHook } = require('tapable');
const hook = new AsyncParallelHook(['name']);

console.time('cost');

hook.tapAsync('hello', (name, cb) => {
  setTimeout(() => {
    console.log(`hello ${name}`);
    cb();
  }, 2000);
});
hook.tapPromise('hello again', (name) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log(`hello ${name}, again`);
      resolve();
    }, 1000);
  });
});

hook.callAsync('ahonn', () => {
  console.log('done');
  console.timeEnd('cost');
});
// hello ahonn, again
// hello ahonn
// done
// cost: 2008.609ms

// 或者经过 hook.promise() 调用
// hook.promise('ahonn').then(() => {
// console.log('done');
// console.timeEnd('cost');
// });
复制代码

能够看到 AsyncParallelHook 比 SyncHook 复杂不少,SyncHook 之类的同步钩子只能经过 tap 来注册, 而异步钩子还可以经过 tapAsync 或者 tapPromise 来注册回调,前者以 callback 的方式执行,然后者则经过 Promise 的方式来执行。异步钩子没有 call 方法,执行注册的回调经过 callAsync 与 promise 方法进行触发。二者间的不一样如上代码所示。async

AsyncSeriesHook

若是你想要顺序的执行异步函数的话,显然 AsyncParallelHook 是不适合的。因此 tapable 提供了另一个基础的异步钩子:AsyncSeriesHook。

const { AsyncSeriesHook } = require('tapable');
const hook = new AsyncSeriesHook(['name']);

console.time('cost');

hook.tapAsync('hello', (name, cb) => {
  setTimeout(() => {
    console.log(`hello ${name}`);
    cb();
  }, 2000);
});
hook.tapPromise('hello again', (name) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log(`hello ${name}, again`);
      resolve();
    }, 1000);
  });
});

hook.callAsync('ahonn', () => {
  console.log('done');
  console.timeEnd('cost');
});
// hello ahonn
// hello ahonn, again
// done
// cost: 3011.162ms
复制代码

上面的示例代码与 AsyncParallelHook 的示例代码几乎相同,不一样的是 hook 是经过 new AsyncSeriesHook() 实例化的。经过 AsyncSeriesHook 就可以顺序的执行注册的回调,除此以外注册与触发的用法都是相同的。

一样的,异步钩子也有一些带流程控制的钩子:

  • AsyncParallelBailHook:执行过程当中注册的回调返回非 undefined 时就会直接执行 callAsync 或者 promise 中的函数(因为并行执行的缘由,注册的其余回调依然会执行)。
  • AsyncSeriesBailHook:执行过程当中注册的回调返回非 undefined 时就会直接执行 callAsync 或者 promise 中的函数,而且注册的后续回调都不会执行。
  • AsyncSeriesWaterfallHook:与 SyncWaterfallHook 相似,上一个注册的异步回调执行以后的返回值会传递给下一个注册的回调。

其余

tapable 中除了这一些核心的钩子以外还提供了一些功能,例如 HookMapMultiHook 等。这里就不详细描述它们了,有兴趣的能够自行前往游览。

具体实现

想知道 tapable 的具体实现就必须去阅读相关的源码。因为篇幅有限,这里咱们就经过阅读 SyncHook 相关的代码来看看相关实现,其余的钩子思路上大致一致。咱们经过如下代码来慢慢深刻 tapable 的实现:

const { SyncHook } = require('tapable');
const hook = new SyncHook(['name']);
hook.tap('hello', (name) => {
	console.log(`hello ${name}`);
});
hook.call('ahonn');
复制代码

入口

首先,咱们实例化了 SyncHook,经过 package.json 能够知道 tapable 的入口在 /lib/index.js ,这里导出了上面提到的那些同步/异步的钩子。SyncHook 对应的实如今 /lib/SyncHook.js

在这个文件中,咱们能够看到 SyncHook 类的结构以下:

class SyncHook exntends Hook {
	tapAsync() { ... }
	tapPromise() { ... }
	compile(options) { ... }
}
复制代码

new SyncHook() 以后,咱们会调用对应实例的 tap 方法进行注册回调。很明显,tap 不是在 SyncHook 中实现的,而是在父类中。

注册回调

能够看到 /lib/Hook.js 文件中 Hook 类中实现了 tapable 钩子的绝大多数方法,包括 taptapAsynctapPromisecallcallAsync 等方法。

咱们主要关注 tap 方法,能够看到该方法除了作了一些参数的检查以外还调用了另外的两个内部方法:_runRegisterInterceptors_insert_runRegisterInterceptors() 是运行 register 拦截器,咱们暂且忽略它(有关拦截器能够查看 tapable#interception )。

重点关注一下 _insert 方法:

_insert(item) {
  this._resetCompilation();
  let before;
  if (typeof item.before === 'string') before = new Set([item.before]);
  else if (Array.isArray(item.before)) {
    before = new Set(item.before);
  }
  let stage = 0;
  if (typeof item.stage === 'number') stage = item.stage;
  let i = this.taps.length;
  while (i > 0) {
    i--;
    const x = this.taps[i];
    this.taps[i + 1] = x;
    const xStage = x.stage || 0;
    if (before) {
      if (before.has(x.name)) {
        before.delete(x.name);
        continue;
      }
      if (before.size > 0) {
        continue;
      }
    }
    if (xStage > stage) {
      continue;
    }
    i++;
    break;
  }
  this.taps[i] = item;
}
复制代码

这里分红三个部分看,第一部分是 this. _resetCompilation() ,这里主要是重置一下 callcallAsync, promise 这三个函数。至于为何要这么作,咱们后面再讲,这里先插个眼。

第二部分是一堆复杂的逻辑,主要是经过 options 中的 before 与 stage 来肯定当前 tap 注册的回调在什么位置,也就是提供了优先级的配置,默认的话是添加在当前现有的 this.taps 后。将 before 与 stage 相关代码去除后 _insert 就变成了这样:

_insert(item) {
  this._resetCompilation();
  let i = this.taps.length;
  this.taps[i] = item;
}
复制代码

触发

到目前为止尚未什么特别的骚操做,咱们继续看。当咱们注册了回调以后就能够经过 call 来进行触发了。经过 Hook 类的构造函数咱们能够看到。

constructor(args) {
  if (!Array.isArray(args)) args = [];
  this._args = args;
  this.taps = [];
  this.interceptors = [];
  this.call = this._call;
  this.promise = this._promise;
  this.callAsync = this._callAsync;
  this._x = undefined;
}
复制代码

这时候能够发现 callcallAsyncpromise 都指向了下划线开头的同名函数,在文件底部咱们看到了以下代码:

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

Object.defineProperties(Hook.prototype, {
	_call: {
		value: createCompileDelegate("call", "sync"),
		configurable: true,
		writable: true
	},
	_promise: {
		value: createCompileDelegate("promise", "promise"),
		configurable: true,
		writable: true
	},
	_callAsync: {
		value: createCompileDelegate("callAsync", "async"),
		configurable: true,
		writable: true
	}
});
复制代码

这里能够看到第一次执行 call的时候实际上跑的是 lazyCompileHook 这个函数,这个函数会调用 this._createCall('sync') 来生成新函数执行,后面再次调用 call 时其实也是执行的生成的函数。

到这里其实咱们就能够明白前面在调用 tap 时执行的 this. _resetCompilation() 的做用了。也就是说,只要没有新的 tap 来注册回调,call 调用的就都会是同一个函数(第一次调用 call 生成的)。 执行新的 tap 来注册回调后的第一次 call 方法调用都会从新生成函数。

这里其实我不太明白为何要经过 Object.defineProperties 在原型链上添加方法,直接写在 Hook class 中的效果应该是同样的。tapable 目前的 v2.0.0 beta 版本中已经不这样实现了,若是有人知道为何。请评论告诉我吧。

为何须要从新生成函数呢?秘密就在 this._createCall('sync') 中的 this.complie() 里。

_createCall(type) {
  return this.compile({
    taps: this.taps,
    interceptors: this.interceptors,
    args: this._args,
    type: type
  });
}
复制代码

编译函数

this.complie() 不是在 Hook 中实现的,咱们跳回到 SyncHook 中能够看到:

compile(options) {
  factory.setup(this, options);
  return factory.create(options);
}
复制代码

这里出现了一个 factory,能够看到 factory 是上面的 SyncHookCodeFactory 类的实例,SyncHookCodeFactory 中只实现了 content。因此咱们往上继续看父类 HookCodeFactorylib/HookCodeFactory.js)中的 setupcreate

这里 setup 函数把 Hook 类中传过来的 options.taps 中的回调函数(调用 tap 时传入的函数)赋值给了 SyncHook 里的 this._x:

setup(instance, options) {
  instance._x = options.taps.map(t => t.fn);
}
复制代码

而后 factory.create() 执行以后返回,这里咱们能够知道 create() 返回的返回值必然是一个函数(供 call 来调用)。看到对应的源码,create() 方法的实现有一个 switch,咱们着重关注 case 'sync'。将多余的代码删掉以后咱们能够看到 create() 方法是这样的:

create(options) {
  this.init(options);
  let fn;
  switch (this.options.type) {
    case "sync":
      fn = new Function(
        this.args(),
        '"use strict";\n' +
          this.header() +
          this.content({
            onError: err => `throw ${err};\n`,
            onResult: result => `return ${result};\n`,
            resultReturns: true,
            onDone: () => "",
            rethrowIfPossible: true
          })
      );
      break;
  }
  this.deinit();
  return fn;
}
复制代码

能够看到这里用到了 new Function() 来生成函数并返回 ,这是 tapable 的关键。经过实例化 SyncHook 时传入的参数名列表与后面注册的回调信息,生成一个函数来执行它们。对于不一样的 tapable 钩子,最大的不一样就是这里生成的函数不同,若是是带有流程控制的钩子的话,生成的代码中也会有对应的逻辑。

这里咱们在 return fn 以前加一句 fn.toString() 来看看生成出来的函数是什么样的:

function anonymous(name) {
 'use strict';
  var _context;
  var _x = this._x;
  var _fn0 = _x[0];
  _fn0(name);
}
复制代码

因为咱们的代码比较简单,生成出来的代码就很是简单了。主要的逻辑就是获取 this._x 里的第一个函数并传入参数执行。若是咱们在 call 以前再经过 tap 注册一个回调。那么生成的代码中也会对应的获取 _x[1] 来执行第二个注册的回调函数。

到这里整一个 new SyncHook() -> tap -> call 的流程就结束了。主要的两个比较有趣的点在执行 call 的时候会进行缓存,以及经过已知的信息来生成不一样的函数给 call 执行。基本上其余的钩子的运行流程都差很少,具体的生成不一样流程控制的细节这里就不详细说了,各位看官自行看源码吧(具体逻辑在 SyncHookCodeFactory 类的 create 方法中)。

总结

webpack 经过 tapable 这种巧妙的钩子设计很好的将实现与流程解耦开来,值得学习。或许下一次写相似须要插件机制的轮子的时候能够借鉴一些 webpack 的作法。不过 tapable 生成函数的部分看起来不是很优雅,或许 JavaScript 可以支持元编程的话或许可以实现得更好?

若是本文有理解或者表述错误,请评论告诉我。感谢阅读。

相关文章
相关标签/搜索