In the previous article “Webpack source code interpretation: Clear build main process”, we had a general understanding of Webpack build main process, where we skipped one important content Tapable. The WebPack plug-in provides third-party developers with a way to hook into the compilation process of the WebPack engine, and Tapable is the core foundation of the plug-in.

This article first analyzes the basic principles of Tapable and then writes a custom plug-in on this basis.

Tapable

If you’ve read the webpack source code, you’ll be familiar with Tapable. It is no exaggeration to say that Tapable is the super steward of webPack’s control of the event flow.

The core function of Tapable is to execute registered events in sequence when triggered based on different hooks. It’s a classic publish-subscribe model. Tapable provides a total of nine hook types in two broad categories, detailed in the following mind map:

In addition to the Sync and Async categories, you should also notice keywords such as Bail, Waterfall, and Loop, which specify the order in which the registered event callback handler triggers.

  • Basic hook: The events are registered in sequencehandler.handlerDo not interfere with each other;
  • Bail hook: The events are registered in sequencehandler, if any of themhandlerThe return value is notundefined, the remaininghandlerWill not be implemented;
  • Waterfall hook: The events are registered in sequencehandler, the former onehandlerThe return value of thehandlerInto the participation;
  • Loop hook: The events are registered in sequencehandler, if anyhandlerThe return value ofundefined, then the event chain againFrom the beginningStart executing until allhandlerAll returnundefined

Basic usage

Let’s take SyncHook for example:

const {
    SyncHook
} = require(".. /lib/index");
let sh = new SyncHook(["name"])
sh.tap('A'.(name) = > {
    console.log('A:', name)
})
sh.tap({
    name: 'B'.before: 'A'  // Affects the order in which callback B is executed before callback A
}, (name) = > {
    console.log('B:', name)
})
sh.call('Tapable')

// output:
B:Tapable
A:Tapable
Copy the code

Here we define a synchronous hook, sh, and notice that its constructor takes an array type input parameter [“name”], representing a list of parameters that its registration event will receive, to tell the caller what parameters it will receive when writing the callback handler. In the example, each event callback receives the parameter name.

The tap method of the hook registers the callback handler, invokes the call method to trigger the hook, and executes the registered callback functions in turn.

When callback B is registered, the before parameter, before: ‘A’, is passed, which directly affects the order in which the callback is executed, i.e. callback B is fired before callback A. In addition, you can order callbacks by specifying the stages for callbacks.

The source code interpretation

Hook the base class

From the above example, we see that there are two external interfaces on the hook: TAP, which registers the event callback, and Call, which fires the event.

Although Tapable provides several types of hooks, all hooks inherit from a base class Hook, and their initialization process is similar. Here’s SyncHook again:

The factory class is used to generate different compile methods, which essentially return strings of control-flow code based on the event registration order. Finally, 'new Function' generates real Function assignments to each hook object.
class SyncHookCodeFactory extends HookCodeFactory {
    content({ onError, onDone, rethrowIfPossible }) {
        return this.callTapsSeries({
            onError: (i, err) = >onError(err), onDone, rethrowIfPossible }); }}const factory = new SyncHookCodeFactory();
// Override the tapAsync method in the Hook base class because the 'Sync' Sync Hook disallows tapAsync calls
const TAP_ASYNC = () = > {
    throw new Error("tapAsync is not supported on a SyncHook");
};
// Override the tapPromise method in the Hook base class, because the 'Sync' Sync Hook disallows calls in tapPromise mode
const TAP_PROMISE = () = > {
    throw new Error("tapPromise is not supported on a SyncHook");
};
Compile is what each type of hook needs to implement, calling the respective factory function to generate the hook's call method.
const COMPILE = function(options) {
    factory.setup(this, options);
    return factory.create(options);
};
function SyncHook(args = [], name = undefined) {
    const hook = new Hook(args, name);  // Instantiate the parent Hook and modify the Hook
    hook.constructor = SyncHook;
    hook.tapAsync = TAP_ASYNC;
    hook.tapPromise = TAP_PROMISE;
    hook.compile = COMPILE;
    return hook;
}
Copy the code

The tap method

What happens when the TAP method registers the callback? In the Hook base class, the code for tap is as follows:

class Hook{
    constructor(args = [], name = undefined){
        this.taps = []
    }
    tap(options, fn) {
        this._tap("sync", options, fn);
    }
    _tap(type, options, fn) {
        // Omit the input preprocessing part of the code
        this._insert(options); }}Copy the code

We see that this ends up in the this._insert method, where the job of this. _INSERT is to insert the callback FN into the internal TAPS array and adjust the order of the TAPS array based on the before or stage parameters. The specific code is as follows:

_insert(item) {
	// Each time an event is registered, call is reset and the call method needs to be recompiled to generate it
  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;
  // In the body of the while loop, adjust the callback order according to before and stage
  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;  // TAPS taps temporarily stores all registered callback functions
}
Copy the code

Whether you call TAP, tapAsync, or tapPromise, the callback handler is temporarily stored in the TAPS array, emptying the previously generated call method (this.call = this._call).

Call method

Now that you’ve registered the event callback, it’s time to fire the event. Similarly, there are three tap registration methods for call: Call, callAsync, and Promise. The call method is used directly to trigger a synchronous Sync Hook event, and the callAsync or Promise method is used to trigger an asynchronous Async Hook event.

const CALL_DELEGATE = function(. args) {
    // When call is first executed, the actual function fn is generated based on the hook type and callback array. And reassign to this.call
    // On the second call, run fn directly without calling _createCall again
    this.call = this._createCall("sync");
    return this.call(... args); };class Hoook {
    constructor(args = [], name = undefined){
        this.call = CALL_DELEGATE
        this._call = CALL_DELEGATE
    }
	
    compile(options) {
        throw new Error("Abstract: should be overridden");
    }
	
    _createCall(type) {
        // Entering the function body means that the call is executed for the first time or the call is reset. In this case, compile is called to generate the call method
        return this.compile({
            taps: this.taps,
            interceptors: this.interceptors,
            args: this._args,
            type: type }); }}Copy the code

This.pile method is called by _createCall to compile the call method that generates the real call, but compile is an empty implementation in the Hook base class. It requires that subclasses that inherit from the Hook parent must implement this method (that is, an abstract method). Go back to SyncHook to see the compiler implementation:

const HookCodeFactory = require("./HookCodeFactory");
class SyncHookCodeFactory extends HookCodeFactory {
    content({ onError, onDone, rethrowIfPossible }) {
        return this.callTapsSeries({
            onError: (i, err) = >onError(err), onDone, rethrowIfPossible }); }}const factory = new SyncHookCodeFactory();
const COMPILE = function(options) {
    // Call the setup and create methods in the factory class to concatenate the string, and then instantiate new Function to get the Function fn
    factory.setup(this, options);
    return factory.create(options);
};
function SyncHook(args = [], name = undefined) {
    const hook = new Hook(args, name);
    hook.compile = COMPILE;
    return hook;
}
Copy the code

In the SyncHook class, compile calls the create method of the HookCodeFactory class. There is no internal table for create. Factory. create returns the compiled function, which is ultimately assigned to this.

Here the Hook uses a trick called an inert function. When this. Call is first specified, it is run in the CALL_DELEGATE body, and the CALL_DELEGATE reassigns this.call so that the next time it is executed, Performance is optimized by executing the assigned this.call method directly without having to go through the call generation process again.

An inert function has two main advantages:

  1. High efficiency: The lazy function only executes the calculation logic when it is run for the first time, and then returns the result of the first execution when the function is run again, saving a lot of execution time.
  2. Deferred execution: In some scenarios, some environmental information needs to be determined, and once it is determined, it does not need to be reevaluated. Can be understood asSniffer program. For example, lazy load overrides can be used as followsaddEvent:
function addEvent(type, element, fun) {
    if (element.addEventListener) {
        addEvent = function(type, element, fun) {
            element.addEventListener(type, fun, false);
        };
    } else if (element.attachEvent) {
        addEvent = function(type, element, fun) {
            element.attachEvent("on" + type, fun);
        };
    } else {
        addEvent = function(type, element, fun) {
            element["on" + type] = fun;
        };
    }
    return addEvent(type, element, fun);
}
Copy the code

HookCodeFactory factory class

As mentioned in the previous section, factory.create returns the compiled function assignment to the call method. Each type of hook constructs a factory class that concatenates Function strings that schedule callbacks to the handler sequence, generating the execution Function via an instantiation of new Function().

Extension: New Function

There are three ways to define functions in JavaScript:

1. Function declaration
function add(a, b){
    return a + b
}

// define 2
const add = function(a, b){
    return a + b
}

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

The first two ways of defining a function are “static”. What is “static” is that when a function is defined, its function is defined. The third way of defining functions is “dynamic”, in which functions can change as the program runs.

There are also differences between definition 1 and definition 2. The key difference is the “hoist” behavior for JavaScript functions and variable declarations. I’m not going to do the expansion here.

For example, I need to dynamically construct a function that adds n numbers:

let nums = [1.2.3.4]
let len = nums.length
let params = Array(len).fill('x').map((item, idx) = >{
    return ' ' + item + idx
})
const add = new Function(params.join(', '), `
    return ${params.join('+')};
`)
console.log(add.toString())
console.log(add.apply(null, nums))
Copy the code

Print the function string add.tostring () to get:

function anonymous(x0,x1,x2,x3) {
    return x0+x1+x2+x3;
}
Copy the code

The parameters and functions for add will be generated dynamically based on the length of nums, so you can control how many arguments are passed as needed, and the function will only handle these inputs.

The Function declaration of new Function is a bit of a performance disadvantage compared to the previous two. Each instantiation consumes performance. New Function does not support closures.

function bar(){
    let name = 'bar'
    let func = function(){return name}
    return func
}
bar()()  // "bar", func reads the name variable in the bar lexical scope

function foo(){
    let name = 'foo'
    let func = new Function('return name')
    return func
}
foo()()  // ReferenceError: name is not defined
Copy the code

The reason is that the lexical scope of new Function points to the global scope.

The main logic of factory.create is to concatenate the callback timing control string based on the hook type as follows:

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}));Copy the code

Let’s take SyncHook for example:

let sh = new SyncHook(["name"]);
sh.tap("A".(name) = > {
    console.log("A");
});
sh.tap('B'.(name) = > {
    console.log("B");
});
sh.tap("C".(name) = > {
    console.log("C");
});
sh.call();
Copy the code

You get the following function string:

function anonymous(name) {
    "use strict";
    var _context;
    var _x = this._x;
    var _fn0 = _x[0];
    _fn0(name);
    var _fn1 = _x[1];
    _fn1(name);
    var _fn2 = _x[2];
    _fn2(name);
}
Copy the code

Where _x points to the this.taps array, accesses each handler in order, and executes the handler.

For more Hook examples, see RunKit

Why does Tapable have to work so hard to dynamically generate function bodies? Because it is a proponent of “execution efficiency optimization,” functions that generate as little new call stack as possible are optimally executed.

Customize the WebPack Plugin

A plug-in for self-cultivation

A compliant plug-in should satisfy the following criteria:

  1. It is a named function or JS class;
  2. Specify on the prototype chainapplyMethods;
  3. Specify an explicit event hook and register the callback;
  4. Processing specific data for webpack internal instances (CompilerCompilation);
  5. Call the callback passed in by WebPack after completing the function;

Conditions 4 and 5 are not necessary, and only complex plug-ins meet the above five conditions.

In the article “Webpack source interpretation: We learned that there are two very important internal objects in Webpack, compiler and Compilation objects. In both cases hooks are predefined for different types of hooks that fire at specific points in the compilation process. A custom plug-in “hooks” to that point in time and executes the logic.

Compiler The compilation hook list

Automatic upload of resources plug-in

After using Webpack to package resources, a dist folder will be generated in the local project to store the packed static resources. At this time, you can write a Webpack plug-in that automatically uploads resource files to THE CDN, and upload them to the CDN timely after each successful package.

Once you know what the plugin does, you need to register your callbacks on the appropriate hooks. In this case, we need to upload the static file with the output packaged to the CDN. By querying in the Compiler hook list, we know that Compiler.hooks. AfterEmit is the hook that meets the requirements and is of type AsyncSeriesHook.

Implement this plug-in according to five basic conditions:

const assert = require("assert");
const fs = require("fs");
const glob = require("util").promisify(require("glob"));

// 1. It is a named function or JS class
class AssetUploadPlugin {
    constructor(options) {
        // It is possible to verify that the parameters passed are valid, etc
        assert(
            options,
            "check options ..."
        );
    }
    // 2. Specify the 'apply' method on the prototype chain
    // The apply method accepts the Webpack Compiler object as an input parameter
    apply(compiler) {
        // 3. Specify an explicit event hook and register the callback
        compiler.hooks.afterEmit.tapAsync(  // Because afterEmit is a hook of type AsyncSeriesHook, you need to hook in the callback using tapAsync or tapPromise
            "AssetUploadPlugin".(compilation, callback) = > {
                const {
                    outputOptions: { path: outputPath }
                } = compilation;  // 4. Handle specific data for webpack internal instances
                uploadDir(
                    outputPath,
                    this.options.ignore ? { ignore: this.options.ignore } : null
                )
                .then(() = > {
                    callback();  // 5. Call the callback passed in by WebPack after completing the function;
                })
                .catch(err= >{ callback(err); }); }); }};// uploadDir is the functional description of this plugin
function uploadDir(dir, options) {
    if(! dir) {throw new Error("dir is required for uploadDir");
    }
    if(! fs.existsSync(dir)) {throw new Error(`dir ${dir} is not exist`);
    }
    return fs
        .statAsync(dir)
        .then(stat= > {
            if(! stat.isDirectory()) {throw new Error(`dir ${dir} is not directory`);
            }
        })
        .then(() = > {
            return glob(
                "* * / *".Object.assign(
                    {
                        cwd: dir,
                        dot: false.nodir: true
                    },
                    options
                )
            );
        })
        .then(files= > {
            if(! files || ! files.length) {return "No file to upload found";
            }
            // TODO:Here will upload resources to your static cloud server, such as Jingdong cloud, Tencent cloud, etc
            // ...
        });
}

module.exports = AssetUploadPlugin
Copy the code

In webpack.config.js you can import this plug-in and instantiate it:

const AssetUploadPlugin = require('./AssetUploadPlugin')
const config = {
    / /...
    plugins: [
        new AssetUploadPlugin({
            ignore: []})]}Copy the code

conclusion

Webpack’s flexible configuration benefits from Tapable’s powerful hook system, which allows each compilation process to be “hooked”. As the so-called “three into many”, a system to do plug-in, its scalability will be greatly improved. Tapable can also be used in specific service scenarios, such as process monitoring, logging, and buried point reporting. Tapable can be used in scenarios that need to be “hooked” into specific processes.

The last

Code words are not easy if:

  • This article is useful to you, please don’t be stingy your little hands for me;
  • If you don’t understand or incorrect, please comment. I will reply or erratum actively.
  • Expect to continue to learn front-end technology knowledge with me, please pay attention to me;
  • Reprint please indicate the source;

Your support and attention is the biggest motivation for my continuous creation!

reference

  • Function – MDN
  • “The new Function” grammar