In my last article, I wrote about the basic usage of Tapable, which we know is an enhanced version of the publishave-subscribe model, so this article would like to take a look at its source code. I read the source code for Tapable and found that it is quite abstract, and it would be confusing to dive directly into it. Therefore, this article will start with the simplest SyncHook and publish/subscribe model, and then gradually abstract it into its source code.

This article can run the example code has been uploaded to GitHub, we take down while playing while reading the article effect better: github.com/dennis-jian… .

SyncHookBasic implementation of

The SyncHook was used in the last article, but I won’t expand on it here. The example he uses looks like this:

const { SyncHook } = require("tapable");

// Instantiate an accelerated hook
const accelerate = new SyncHook(["newSpeed"]);

// Register the first callback and record the current speed as it accelerates
accelerate.tap("LoggerPlugin".(newSpeed) = >
  console.log("LoggerPlugin".` accelerated to${newSpeed}`));// Register a callback to check for overspeed
accelerate.tap("OverspeedPlugin".(newSpeed) = > {
  if (newSpeed > 120) {
    console.log("OverspeedPlugin"."You are speeding!!"); }});// Trigger the acceleration event to see the effect
accelerate.call(500);
Copy the code

In fact, this usage is a basic publish-subscribe model. As I mentioned in my previous post on publish-subscribe, we can quickly implement a SyncHook similar to that:

class SyncHook {
    constructor(args = []) {
        this._args = args;       // The received parameters are saved
        this.taps = [];          // An array of callbacks
    }

    // The tap instance method is used to register the callback
    tap(name, fn) {
        // The logic is simple, just save the callback parameters passed in
        this.taps.push(fn);
    }

    The call instance method is used to trigger events that perform all callbacks
    call(. args) {
        // The logic is simple, just take the registered callbacks out and execute them one by one
        const tapsLength = this.taps.length;
        for(let i = 0; i < tapsLength; i++) {
            const fn = this.taps[i]; fn(... args); }}}Copy the code

This code is very simple, it is a basic publish-subscribe model, using the same method as above, changing SyncHook export from Tapable to use our own:

// const { SyncHook } = require("tapable");
const { SyncHook } = require("./SyncHook");
Copy the code

It works the same way:

Note: The args we passed into the constructor is not used. Tapable uses it to dynamically generate the call body, as we’ll see later in code factories.

SyncBailHookBasic implementation of

Here’s a basic implementation of SyncBailHook. SyncBailHook prevents subsequent callbacks from executing if the current callback returns a value that is not undefined. Here’s the basic usage:

const { SyncBailHook } = require("tapable");    // 使用的是SyncBailHook

const accelerate = new SyncBailHook(["newSpeed"]);

accelerate.tap("LoggerPlugin".(newSpeed) = >
  console.log("LoggerPlugin".` accelerated to${newSpeed}`));// Register a callback to check for overspeed
// An error is returned if the speed is too high
accelerate.tap("OverspeedPlugin".(newSpeed) = > {
  if (newSpeed > 120) {
    console.log("OverspeedPlugin"."You are speeding!!");

    return new Error('You were speeding!! '); }});// Because the last callback returned a value that was not undefined
// This callback will no longer run
accelerate.tap("DamagePlugin".(newSpeed) = > {
  if (newSpeed > 300) {
    console.log("DamagePlugin"."It was going so fast, the car was falling apart..."); }}); accelerate.call(500);
Copy the code

SyncBailHook’s implementation is similar to SyncHook’s, except that the call is executed differently. SyncBailHook detects the return value of each callback and terminates subsequent callbacks if it is not undefined.

class SyncBailHook {
    constructor(args = []) {
        this._args = args;       
        this.taps = [];          
    }

    tap(name, fn) {
        this.taps.push(fn);
    }

    // The rest of the code is the same as SyncHook, except that the call implementation is different
    // Each return value needs to be checked, and if it is not undefined, the execution is terminated
    call(. args) {
        const tapsLength = this.taps.length;
        for(let i = 0; i < tapsLength; i++) {
            const fn = this.taps[i];
            constres = fn(... args);if( res ! = =undefined) returnres; }}}Copy the code

Then change SyncBailHook from our own introduction:

// const { SyncBailHook } = require("tapable"); 
const { SyncBailHook } = require("./SyncBailHook"); 
Copy the code

It works the same way:

Abstract duplicate code

Now we only implement SyncHook and SyncBailHook. In the last article on usage, there are 9 hooks in total. If every Hook is implemented like the above, it is ok. But a closer look at the SyncHook and SyncBailHook classes shows that they are identical except for the call implementation, so as ambitious engineers, we can refer to this part of the repeated code as a base class: Hook.

The Hook class needs to contain some common code, and the different parts of call are implemented by the subclasses themselves. So a Hook class looks like this:

const CALL_DELEGATE = function(. args) {
	this.call = this._createCall();
	return this.call(... args); };// Hook is the base class of SyncHook and SyncBailHook
// The general structure is the same, the difference is the call
// Different subclasses call differently
// Tapable's Hook base class provides an abstract interface compile to dynamically generate call functions
class Hook {
    constructor(args = []) {
        this._args = args;       
        this.taps = [];          

        // Base class call is initialized as CALL_DELEGATE
        This.call = _createCall() this.call = _createCall()
        // We will talk about it later when we implement subclasses
        this.call = CALL_DELEGATE;
    }

    // compile an abstract interface
    // Implemented by subclasses, the base class compile cannot be called directly
    compile(options) {
      throw new Error("Abstract: should be overridden");
    }

    tap(name, fn) {
        this.taps.push(fn);
    }

    // _createCall calls the subclass's implementation compile to generate the call method
    _createCall() {
      return this.compile({
        taps: this.taps,
        args: this._args, }); }}Copy the code

The official corresponding source code here: github.com/webpack/tap…

Subclass SyncHook implementation

Now that we have a Hook base class, our SyncHook needs to extend the base overwrite. Tapable does this without using class extends, but manually:

const Hook = require('./Hook');

function SyncHook(args = []) {
    // Manually inherit the Hook first
	  const hook = new Hook(args);
    hook.constructor = SyncHook;

    // Then implement your own compile function
    // compile should create a call function and return it
		hook.compile = function(options) {
        // The call function is implemented the same as before
        const { taps } = options;
        const call = function(. args) {
            const tapsLength = taps.length;
            for(let i = 0; i < tapsLength; i++) {
                const fn = this.taps[i];
                fn(...args);
            }
        }

        return call;
    };
    
	return hook;
}

SyncHook.prototype = null;
Copy the code

Note: We initialize this.call as a CALL_DELEGATE function in the base Hook constructor for a reason, the main reason being to make sure this points correctly. Think about what would happen if instead of CALL_DELEGATE, we just called this.call = this._createcall (). Let’s analyze the execution process:

  1. When users use it, they must use itnew SyncHook()“Is executedconst hook = new Hook(args);
  2. new Hook(args)Can be carried toHookConstructor of, that is, will runthis.call = this._createCall()
  3. At that timethisIt points to the base classHookAn instance of thethis._createCall()The base class will be calledthis.compile()
  4. Because of the base classcomplieThe function is an abstract interface and can be called directly with an errorAbstract: should be overridden.

How do we solve this problem by using this.call = CALL_DELEGATE?

  1. usingthis.call = CALL_DELEGATEAfter the base classHookOn thecallIt is simply assigned to a proxy function that will not be called immediately.
  2. The same is true for usersnew SyncHook(), which will be executedHookConstructor of
  3. HookThe constructor will givethis.callThe assignment forCALL_DELEGATE, but not immediately.
  4. new SyncHook()Proceed with the method on the newly created instancehook.complieOverridden as the correct method.
  5. When the user callshook.call“Will be implementedthis._createCall()That’s going to be calledthis.complie()
  6. That’s called at this pointcomplieIt’s already overwritten correctly, so you get the right answer.

An implementation of a subclass SyncBailHook

The SyncBailHook subclass is similar to the SyncHook subclass, but the hook.compile subclass is different:

const Hook = require('./Hook');

function SyncBailHook(args = []) {
    // The basic structure is the same as SyncHook
	  const hook = new Hook(args);
    hook.constructor = SyncBailHook;

    
    // Compile is implemented as a Bail version
		hook.compile = function(options) {
        const { taps } = options;
        const call = function(. args) {
            const tapsLength = taps.length;
            for(let i = 0; i < tapsLength; i++) {
                const fn = this.taps[i];
                constres = fn(... args);if( res ! = =undefined) break; }}return call;
    };
    
	return hook;
}

SyncBailHook.prototype = null;
Copy the code

Abstract code factory

Above, we abstracted SyncHook and SyncBailHook to create a base class Hook to reduce repetitive code. All that subclasses need to implement is the complie method, but if we compare SyncHook to SyncBailHook’s complie method:

SyncHook:

hook.compile = function(options) {
  const { taps } = options;
  const call = function(. args) {
    const tapsLength = taps.length;
    for(let i = 0; i < tapsLength; i++) {
      const fn = this.taps[i];
      fn(...args);
    }
  }

  return call;
};
Copy the code

SyncBailHook:

hook.compile = function(options) {
  const { taps } = options;
  const call = function(. args) {
    const tapsLength = taps.length;
    for(let i = 0; i < tapsLength; i++) {
      const fn = this.taps[i];
      constres = fn(... args);if( res ! = =undefined) returnres; }}return call;
};
Copy the code

We found that both complie were very similar, with a lot of duplicate code, so Tapable abstracted the code factory HookCodeFactory to solve the problem. The function of HookCodeFactory is to generate the call function body returned by complie. HookCodeFactory also adopts the similar idea of Hook in its implementation, and also implements a base class HookCodeFactory first. Different hooks then inherit this class to implement their own code factories, such as SyncHookCodeFactory.

The method to create a function

Before diving further into the code factory, let’s review the way functions are created in JS. Generally, we have the following methods:

  1. Function declaration

    function add(a, b) {
      return a + b;
    }
    Copy the code
  2. Functional expression

    const add = function(a, b) {
      return a + b;
    }
    Copy the code

But in addition to these two methods, there is another, less commonly used method: use the Function constructor. For example, this function is created using the constructor:

const add = new Function('a'.'b'.'return a + b; ');
Copy the code

In the above form, the last argument is the body of the function, and the preceding arguments are the parameters of the function. The resulting function has the same effect as the function expression, and can be called as follows:

add(1.2);    // The result is 3
Copy the code

Note: The above a and b parameters can also be separated by a comma:

const add = new Function('a, b'.'return a + b; ');    // This will have the same effect as above
Copy the code

Of course, functions do not have to have arguments. Functions without arguments can be created as follows:

const sayHi = new Function('alert("Hello")');

sayHi(); // Hello
Copy the code

What’s the difference between creating a function like this and declaring and expressing a function earlier? One of the biggest features of using the Function constructor to create a Function is that the Function body is a string, which means we can generate the string dynamically, thus generating the Function body dynamically. Because SyncHook and SyncBailHook’s call functions are very similar, we can spell out their function bodies as if they were strings. To make this easier, Tapable’s resulting call function does not have a loop in it, but rather unwinds the loop as it is put together. For example, SyncHook spells out the body of the call function like this:

"use strict";
var _x = this._x;
var _fn0 = _x[0];
_fn0(newSpeed);
var _fn1 = _x[1];
_fn1(newSpeed);
Copy the code

The _x in the above code is actually the taps array that holds the callbacks. It is renamed _x to save code size. As you can see from the code, the contents of _x, or TAPS, have been expanded and are pulled out one by one.

The body of the call function generated by SyncBailHook looks like this:

"use strict";
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{}}Copy the code

The main logic of this generated code is the same as that of SyncHook, which expands _x. The difference is that SyncBailHook checks the result of each execution. If the result is not undefined, it will return and the subsequent callback will not be executed.

Create the code factory base class

For this purpose, our code factory base class should be able to generate the most basic call function body. Let’s write a basic HookCodeFactory that currently generates only the SyncHook call body:

class HookCodeFactory {
    constructor() {
        // The constructor defines two variables
        this.options = undefined;
        this._args = undefined;
    }

    The init function initializes variables
    init(options) {
        this.options = options;
        this._args = options.args.slice();
    }

    // deinit resets variables
    deinit() {
        this.options = undefined;
        this._args = undefined;
    }

    // args is used to convert the array args passed in to the comma-separated form received by New Function
    // ['arg1', 'args'] ---> 'arg1, arg2'
    args() {
        return this._args.join(",");
    }

    Setup is simply assigning _x to the generated code
    setup(instance, options) {
        instance._x = options.taps.map(t= > t);
    }

    // create Creates the final call function
    create(options) {
        this.init(options);
        let fn;

        // Expand TAPS directly to a flattened function call
        const { taps } = options;
        let code = ' ';
        for (let i = 0; i < taps.length; i++) {
            code += `
                var _fn${i} = _x[${i}];
                _fn${i}(The ${this.args()});
            `
        }

        // Connect the expanded loop to the header
        const allCodes = ` "use strict"; var _x = this._x; ` + code;

        // Create a function with the parameters passed in and the generated function body
        fn = new Function(this.args(), allCodes);

        this.deinit();  // Reset the variable

        return fn;    // Return the generated function}}Copy the code

At the heart of the above code is the create function, which creates a call function on the fly and returns it, so SyncHook can create code directly using the factory:

// SyncHook.js

const Hook = require('./Hook');
const HookCodeFactory = require("./HookCodeFactory");

const factory = new HookCodeFactory();

// COMPILE calls factory to generate the call function
const COMPILE = function(options) {
	factory.setup(this, options);
	return factory.create(options);
};

function SyncHook(args = []) {
		const hook = new Hook(args);
    hook.constructor = SyncHook;

    // Use HookCodeFactory to create the final call function
    hook.compile = COMPILE;

	return hook;
}

SyncHook.prototype = null;
Copy the code

Get code factory supportSyncBailHook

Now our HookCodeFactory can only generate the simplest SyncHook code, so we need to improve it so that it can also generate the call body of SyncBailHook. You can scroll back and take a closer look at the differences between the two final generated code:

  1. SyncBailHookThat needs to be executed each timeresultIf noundefinedIt returns
  2. SyncBailHookThe generated code is actuallyif... elseNested, we can consider using a recursive function when generating

To enable SyncHook and SyncBailHook’s subclass code factories to pass differentiated result handling, we first split the Create of the HookCodeFactory base class into two parts and split the code assembly logic into a single function:

class HookCodeFactory {
    // ...
  	// omit other identical code
  	// ...

    // create Creates the final call function
    create(options) {
        this.init(options);
        let fn;

        // Assemble the header
        const header = ` "use strict"; var _x = this._x; `;

        // Create a function with the parameters and function body passed in
        fn = new Function(this.args(),
            header +
            this.content());         // Note that the content function is not implemented in the base class HookCodeFactory, but in the subclass

        this.deinit();

        return fn;
    }

    // Assemble the function body
  	// callTapsSeries is not called in the base class, but in the subclass
    callTapsSeries() {
        const { taps } = this.options;
        let code = ' ';
        for (let i = 0; i < taps.length; i++) {
            code += `
                var _fn${i} = _x[${i}];
                _fn${i}(The ${this.args()});
            `
        }

        returncode; }}Copy the code

Pay special attention to the code abovecreateFunction is called when the body of the function is generatedthis.content, butthis.contentIs not implemented in the base class, which requires subclasses to be usedHookCodeFactoryNeed to inherit him and realize their owncontentDelta function, so this delta right herecontentFunction is also an abstract interface. theSyncHookThe code should look like this:

// SyncHook.js

/ /... Omit other code of the same kind...

// SyncHookCodeFactory inherits HookCodeFactory and implements the Content function
class SyncHookCodeFactory extends HookCodeFactory {
    content() {
        return this.callTapsSeries();    // callTapsSeries is the base class}}// Use SyncHookCodeFactory to create a Factory
const factory = new SyncHookCodeFactory();

const COMPILE = function (options) {
    factory.setup(this, options);
    return factory.create(options);
};
Copy the code

** Notice here: ** The subclass’s content actually calls the base class’s callTapsSeries to generate the final function body. So the relationship between these functions is actually like this:

So what is the purpose of this design? To allow subclass Content to pass arguments to base class callTapsSeries, generate a different function body. We’ll see that in a moment in SyncBailHook’s code factory.

To generate the body of the SyncBailHook function, we need to make callTapsSeries support an onResult argument, like this:

class HookCodeFactory {
    / /... Omit other same code...

    // Assemble the body of the function to support options.onResult
    callTapsSeries(options) {
        const { taps } = this.options;
        let code = ' ';
        let i = 0;

        const onResult = options && options.onResult;
        
        // Write a next function to start generating the body of the function with the onResult callback
        // Next and onResult make recursive calls to each other to generate the final function body
        const next = () = > {
            if(i >= taps.length) return ' ';

            const result = `_result${i}`;
            const code = `
                var _fn${i} = _x[${i}];
                var ${result} = _fn${i}(The ${this.args()});
                ${onResult(i++, result, next)}
            `;

            return code;
        }

        // Support the onResult parameter
        if(onResult) {
            code = next();
        } else {
          	// When there is no onResult parameter, SyncHook remains the same as before
            for(; i< taps.length; i++) {
                code += `
                    var _fn${i} = _x[${i}];
                    _fn${i}(The ${this.args()});
                `}}returncode; }}Copy the code

Then our SyncBailHook code factory needs to pass an onResult argument when it inherits the factory base class, like this:

const Hook = require('./Hook');
const HookCodeFactory = require("./HookCodeFactory");

SyncBailHookCodeFactory inherits HookCodeFactory and implements the Content function
// The custom onResult function is passed in to the content, which calls next recursively to generate nested if... else...
class SyncBailHookCodeFactory extends HookCodeFactory {
    content() {
        return this.callTapsSeries({
            onResult: (i, result, next) = >
                `if(${result}! == undefined) {\nreturn${result}; \n} else {\n${next()}}\n`}); }}// Use SyncHookCodeFactory to create a Factory
const factory = new SyncBailHookCodeFactory();

const COMPILE = function (options) {
    factory.setup(this, options);
    return factory.create(options);
};


function SyncBailHook(args = []) {
    // The basic structure is the same as SyncHook
    const hook = new Hook(args);
    hook.constructor = SyncBailHook;

    // Use HookCodeFactory to create the final call function
    hook.compile = COMPILE;

    return hook;
}
Copy the code

Now run the code, the effect is the same as before, and you are done

Implementation of other hooks

At this point, the source architecture and basic implementation of Tapable have been clarified, but this article only uses SyncHook and SyncBailHook as examples. Others, such as AsyncParallelHook, have not been expanded. AsyncParallelHook (); AsyncParallelHook ();

class AsyncParallelHook {
    constructor(args = []) {
        this._args = args;
        this.taps = [];
    }
    tapAsync(name, task) {
        this.taps.push(task);
    }
    callAsync(. args) {
        // Fetch the last callback first
        let finalCallback = args.pop();

        // Define an I variable and a done function that checks the I value and queue length on each execution to determine whether callAsync's final callback is executed
        let i = 0;
        let done = () = > {
            if (++i === this.taps.length) { finalCallback(); }};// Execute the event handlers in turn
        this.taps.forEach(task= > task(...args, done));
    }
}
Copy the code

Then it abstracts its callAsync function into a code factory class and constructs it dynamically using string concatenation. The overall idea is the same as before. Specific implementation process can refer to tapable source code:

Hook type of source

SyncHook class source

SyncBailHook class source

HookCodeFactory class source

conclusion

This article can run the example code has been uploaded to GitHub, we take down while playing while reading the article effect better: github.com/dennis-jian… .

Here is a summary of the idea of this paper:

  1. tapableA variety ofHookIt’s all publish-subscribe.
  2. The variousHookIn fact, there is no problem to implement it independently, but because it is a publish and subscribe model, there will be a lot of repetitive code, sotapableSeveral abstractions were made.
  3. The first abstraction is to extract oneHookBase class, which implements the common parts of initialization and event registration, for eachHookthecallIt’s all different. You have to do it yourself.
  4. The second abstraction is eachHookIn realizing their owncallWhen, found that the code also has many similarities, so extracted a code factory, used for dynamic generationcallThe function body of.
  5. In general,tapableThe code is not that difficult, but with two abstractions, the overall code architecture is a bit less readable, and should be much better after this article has combed through it.

At the end of this article, thank you for your precious time to read this article. If this article gives you a little help or inspiration, please do not spare your thumbs up and GitHub stars. Your support is the motivation of the author’s continuous creation.

Welcome to follow my public numberThe big front end of the attackThe first time to obtain high quality original ~

“Front-end Advanced Knowledge” series:Juejin. Cn/post / 684490…

“Front-end advanced knowledge” series article source code GitHub address:Github.com/dennis-jian…

The resources

Tapable: juejin.cn/post/693979…

Tapable source address: github.com/webpack/tap…