If you don’t know about Tapable before you read Webpack, you’re probably going to lose sight of it, so what is Tapable and what does it do? This paper mainly introduces the use of Tapable and related implementation. By learning Tapable, we can further understand the plug-in mechanism of Webpack. The following is based on Tapable V1.1.3.

Tapable is a library similar to EventEmitter in Node.js, but more focused on triggering and handling custom events. Webpack decouples the implementation from the process through Tapable, with all the concrete implementations in the form of plug-ins.

The basic use

To understand the implementation of Tapable, it is necessary to know how to use Tapable and how to use it. Tapable mainly provides synchronous and asynchronous hooks. Let’s start with a simple synchronization hook.

Synchronous hooks

SyncHook

Take the simplest Synchook example:

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 // hello ahonn // hello ahonn // hello ahonn // hello ahonn // hook.call(‘ahonn’); Create a synchronous hook using Synchook, register the callback using tap, and call Call to trigger it. This is one of the simpler hooks available in Tapable and can be easily implemented with EventEmitter.

In addition, Tapable provides a number of useful synchronization hooks:

  • SyncBailHook: Similar to Synchook, execution stops when a registered callback returns non-undefined during execution.
  • SyncWaterFallHook: Accepts at least one argument, and the return value of the last registered callback will be used as an argument for the next registered callback.
  • SynCloophook: Kind of like SyncBailHook, but continues to execute the current callback again if the callback returns non-undefined during execution.

    Asynchronous hooks

    In addition to asynchronously executed hooks, there are also some asynchronous hooks in Tapable. The two most basic asynchronously executed hooks are AsyncParallelHook and AsyncSeriesHook. Other asynchronous hooks add some flow control to these hooks, similar to the SyncBailHook relationship to Synchook.

AsyncParallelHook

AsyncParallelHook is, as the name implies, an asynchronous hook that executes in parallel. The function in CallAsync or Promise is executed after all registered asynchronous callbacks have been executed in parallel.

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: // Promise ('ahonn'). Then (() => {// console.log('done'); // console.timeEnd('cost'); / /});

You can see that AsyncParallelHook is much more complex than Synchook. Synchronous hooks like Synchook can only be registered by TAP, while asynchronous hooks can also be registered by TAPAsync or TApPromise for callbacks. The former is executed as a callback, while the latter is executed as a Promise. The asynchronous hook does not have a call method, and the callbacks that perform the registration are fired through the CallAsync and Promise methods. The difference between the two is shown in the code above.

AsyncSeriesHook

If you want to execute asynchronous functions sequentially, then obviously AsyncParallelHook is not appropriate. So Tapable provides another basic asynchronous hook: 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.16ms

The above example code is almost identical to the AsyncParallelHook example, except that the Hook is instantiated with new AsyncSeriesHook(). The AsyncSeriesHook allows you to execute registered callbacks sequentially, except that registration and firing are used the same way.

Similarly, asynchronous hooks have some hooks with flow control:

  • AsyncParallelBailHook: When a registered callback returns non-undefined during execution, the function in CallAsync or Promise will be executed directly (other registered callbacks will be executed due to parallel execution).
  • AsyncSeriesBailHook: When a registered callback returns a non-undefined value during execution, the function in CallAsync or Promise will be executed directly, and no subsequent registered callbacks will be executed.
  • AsyncSeriesWaterFallHook: Similar to SyncWaterFallHook, the return value of the last registered asynchronous callback after execution is passed to the next registered callback.

other

In addition to these core hooks, Tapable also provides some functions, such as HookMap, MultiHook, etc. They are not described in detail here, you can have your own visit.

The specific implementation

To see how Tapable is implemented, you must read the source code. Since space is limited, let’s take a look at the implementation of Synchook by reading the code related to it, but the rest of the hooks are generally the same. Let’s drill down into the implementation of Tapable with the following code:

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

The entrance

First, we instantiate the Synchook. From package.json, we can see that the entry to Tapable is at /lib/index.js, which exports the synchronous/asynchronous hooks mentioned above. The corresponding SyncHook implementation is in /lib/synchook.js.

In this file, we can see the structure of the Synchook class as follows:

class SyncHook exntends Hook { tapAsync() { ... } tapPromise() { ... } compile(options) { ... }}

After new Synchook (), we call the tap method of the corresponding instance for the registration callback. Obviously, tap is not implemented in Synchook, but in the superclass.

Register callback

It can be seen that most of the methods of Tapable Hook are implemented in the Hook class of /lib/ hook. js, including TAP, TapAsync, TApApMISE, CALL, CallAsync, and so on.

Focusing on the tap method, you can see that in addition to checking some parameters, the method calls two other internal methods: _runRegisterInterceptors and _insert. _runregisterInterceptors () is the runRegister interceptor, which we’ll ignore for now (see Tapable# Interception for interceptors).

Focus on the _insert method:

_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 is divided into three parts. The first part is this._resetCompilation (), which mainly resets the call, CallAsync, and promise functions. We’ll come back to why we did that, but I’ll make a caveat here.

The second part is a lot of complicated logic, mainly through the options before and stage to determine the location of the current tap registered callback, which provides a priority configuration, the default is to add after the existing this.taps. After removing the before and stage-related code, _insert looks like this:

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

The trigger

So far there’s nothing special about it, so let’s move on. Once the callback is registered, it can be triggered by a call. We can see from the constructor of the Hook class.

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; }

Call, CallAsync, and Promise all refer to functions of the same name that begin with an underscore. At the bottom of the file we see the following code:

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 } });

The first call actually runs LazyCompileHOOK, which calls this._createCall(‘sync’) to generate a new function execution. The next call also executes the generated function.

Now we can see what this._resetCompilation () does when we call tap. That is, as long as there is no new tap to register the callback, the call will always be called by the same function (generated by the first call). The first call method call after executing the new tap to register the callback will regenerate the function.

I’m not quite sure why I want to add methods to the prototype chain using Object.defineProperties, but writing directly to the Hook class should look the same. Tapable is not implemented this way in the current V2.0.0 beta, if anyone knows why. Let me know in the comments.

Why do we need to regenerate the function? The secret is in this.complie() in this._createCall(‘sync’).

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

Compile the function

This.plie () is not implemented in Hook, we jump back to SyncHook to see:

compile(options) {
  factory.setup(this, options);
  return factory.create(options);
}

A factory appears, and you can see that Factory is an instance of the SynchookCodeFactory class above, where only Content is implemented. So let’s move on to setup and create in the parent class hookCodeFactory (lib/ hookCodeFactory.js).

The setup function assigns the options.taps callback to this._x in SyncHook from the Hook class:

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

The factory.create() is then executed and returns, so we know that the value returned by create() must be a function (to be called). Looking at the corresponding source code, the implementation of the create() method has a switch, focusing on case ‘sync’. After removing the extra code, we can see that the create() method looks like this:

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; }

You can see the use of new Function() to generate and return a Function, which is the key to Tapable. Generate a function to execute them by instantiating the list of parameter names passed in when Synchook is instantiated along with the later registered callback information. The main difference between Tapable hooks is that the generated functions are different, and if the hooks are flow-controlled, the generated code will also have the corresponding logic.

Here we add fn.toString() before return fn to see what the resulting function looks like:

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

Since our code is simple, the generated code is very simple. The main logic is to take the first function in this._x and pass in arguments to execute it. If we register a callback through tap before call. The generated code will also get _x[1] to execute the second registered callback function.

The whole new Synchook () -> tap-> call process is over. The two main points of interest are caching when a call is executed, and generating different functions for the call using known information. Basically, other hooks run in the same process, the specific details of generating different process control here will not be detailed, you can see the source code (the specific logic is in the SynchookCodeFactory class create method).

conclusion

Webpack does a great job of decoupling implementations from processes through the clever hook design of Tapable, which is worth learning. Maybe the next time you write a wheel like this that requires a plug-in mechanism you can take a page out of Webpack’s book. But the part of Tapable generating functions doesn’t look very elegant, and maybe it would be better if JavaScript supported metaprogramming?

Please let me know in the comments if this article is misunderstood or misstated. Thanks for reading.