阅读 1654

编写自定义webpack插件从理解Tapable开始

在上篇文章《Webpack源码解读:理清编译主流程》中,大体了解了webpack的编译主流程,其中我们跳过了一个重要内容Tapable。webpack 插件向第三方开发者提供了钩入webpack引擎中的编译流程的方式,而Tapable是插件的最核心基础。

本文首先分析Tapable的基本原理,在此基础上编写一个自定义插件。

Tapable

如果你阅读了 webpack 的源码,一定不会对 tapable 不陌生。毫不夸张的说, tapable是webpack控制事件流的超级管家。

Tapable的核心功能就是依据不同的钩子将注册的事件在被触发时按序执行。它是典型的”发布订阅模式“。Tapable提供了两大类共九种钩子类型,详细类型如下思维导图:

除了SyncAsync分类外,你应该也注意到了BailWaterfallLoop等关键词,它们指定了注册的事件回调handler触发的顺序。

  • Basic hook:按照事件注册顺序,依次执行handlerhandler之间互不干扰;
  • Bail hook:按照事件注册顺序,依次执行handler,若其中任一handler返回值不为undefined,则剩余的handler均不会执行;
  • Waterfall hook:按照事件注册顺序,依次执行handler,前一个handler的返回值将作为下一个handler的入参;
  • Loop hook:按照事件注册顺序,依次执行handler,若任一handler的返回值不为undefined,则该事件链再次从头开始执行,直到所有handler均返回undefined

基本用法

我们以SyncHook为例:

const {
    SyncHook
} = require("../lib/index");
let sh = new SyncHook(["name"])
sh.tap('A', () => {
    console.log('A:', name)
})
sh.tap({
    name: 'B',
    before: 'A'  // 影响该回调的执行顺序, 回调B比回调A先执行
}, () => {
    console.log('B:', name)
})
sh.call('Tapable')

// output:
B:Tapable
A:Tapable
复制代码

这里我们定义了一个同步钩子sh,注意到它的构造函数接收一个数组类型入参["name"],代表了它的注册事件将接收到的参数列表,以此来告知调用方在编写回调handler时将会接收到哪些参数。示例中,每个事件回调都会接收name的参数。

通过钩子的tap方法可以注册回调handler,调用call方法来触发钩子,依次执行注册的回调函数。

在注册回调B时,传入了before参数,before: 'A',它直接影响了该回调的执行顺序,即回调B会在回调A之前触发。此外,你也可以指定回调的stage来给回调排序。

源码解读

Hook基类

从上面的例子中,我们看到钩子上有两个对外的接口:tapcalltap负责注册事件回调,call负责触发事件。

虽然Tapable提供多个类型的钩子,但所有钩子都是继承于一个基类Hook,且它们的初始化过程都是相似的。这里我们仍以SyncHook为例:

// 工厂类的作用是生成不同的compile方法,compile本质根据事件注册顺序返回控制流代码的字符串。最后由`new Function`生成真实函数赋值到各个钩子对象上。
class SyncHookCodeFactory extends HookCodeFactory {
    content({ onError, onDone, rethrowIfPossible }) {
        return this.callTapsSeries({
            onError: (i, err) => onError(err),
            onDone,
            rethrowIfPossible
        });
    }
}
const factory = new SyncHookCodeFactory();
// 覆盖Hook基类中的tapAsync方法,因为`Sync`同步钩子禁止以tapAsync的方式调用
const TAP_ASYNC = () => {
    throw new Error("tapAsync is not supported on a SyncHook");
};
// 覆盖Hook基类中的tapPromise方法,因为`Sync`同步钩子禁止以tapPromise的方式调用
const TAP_PROMISE = () => {
    throw new Error("tapPromise is not supported on a SyncHook");
};
// compile是每个类型hook都需要实现的,需要调用各自的工厂函数来生成钩子的call方法。
const COMPILE = function(options) {
    factory.setup(this, options);
    return factory.create(options);
};
function SyncHook(args = [], name = undefined) {
    const hook = new Hook(args, name);  // 实例化父类Hook,并修饰hook
    hook.constructor = SyncHook;
    hook.tapAsync = TAP_ASYNC;
    hook.tapPromise = TAP_PROMISE;
    hook.compile = COMPILE;
    return hook;
}
复制代码

tap方法

当执行tap方法注册回调时,又如何执行的呢? 在Hook基类中,关于tap的代码如下:

class Hook{
    constructor(args = [], name = undefined){
        this.taps = []
    }
    tap(options, fn) {
        this._tap("sync", options, fn);
    }
    _tap(type, options, fn) {
        // 这里省略入参预处理部分代码
        this._insert(options);
    }
}
复制代码

我们看到最终会执行到this._insert方法中,而this._insert的工作就是将回调fn插入到内部的taps数组中,并依据beforestage参数来调整taps数组的排序。具体代码如下:

_insert(item) {
	// 每次注册事件时,将call重置,需要重新编译生成call方法
  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循环体中,依据before和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暂存所有注册的回调函数
}
复制代码

不论是调用taptapAsync或者tapPromise,都会将回调handler暂存至taps数组中,清空之前已经生成的call方法(this.call = this._call)。

call方法

注册好事件回调后,接下来该如何触发事件了。同样的,call也存在三种调用方式:callcallAsyncpromise,分别对应三种tap注册方式。触发同步Sync钩子事件时直接使用call方法,触发异步Async钩子事件时需要使用callAsyncpromise方法,继续看看在Hook基类中call是如何定义的:

const CALL_DELEGATE = function(...args) {
    // 在第一次执行call时,会依据钩子类型和回调数组生成真实执行的函数fn。并重新赋值给this.call
    // 在第二次执行call时,直接运行fn,不再重复调用_createCall
    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) {
        // 进入该函数体意味是第一次执行call或call被重置,此时需要调用compile去生成call方法
        return this.compile({
            taps: this.taps,
            interceptors: this.interceptors,
            args: this._args,
            type: type
        });
    }
}
复制代码

_createCall会调用this.compile方法来编译生成真实调用的call方法,但在Hook基类中compile是空实现。它要求继承Hook父类的子类必须实现这个方法(即抽象方法)。回到SyncHook中查看compiler的实现:

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) {
    // 调用工厂类中的setup和create方法拼接字符串,之后实例化 new 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;
}
复制代码

SyncHook类中compile会调用工厂类HookCodeFactorycreate方法,这里对create的内部暂时不表,factory.create返回编译好的function,最终赋值给this.call方法。

这里Hook使用了一个技巧——惰性函数,当第一次指定this.call方法时,此时会运行到CALL_DELEGATE函数体中,CALL_DELEGATE会重新赋值this.call,这样在下一次执行时,直接执行赋值后的this.call方法,而不用再次进行生成call的过程,从而优化了性能。

惰性函数有两个主要优点:

  1. 效率高:惰性函数仅在第一次运行时执行计算逻辑,之后函数再次运行时都返回第一次执行的结果,节约了很多执行时间;
  2. 延迟执行:在某些场景下,需要判断一些环境信息,一旦确定后就不再需要重新判断。可以理解为嗅探程序。比如可以用下面的方式使用惰性载入重写addEvent
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);
}
复制代码

HookCodeFactory工厂类

在上节提到,factory.create返回编译好的function赋值给call方法。 每个类型的钩子都会构造一个工厂类负责拼接调度回调handler时序的函数字符串,通过new Function()的实例化方式来生成执行函数。

延伸:new Function

在 JavaScript 中有三种函数定义的方式:

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

// 定义2. 函数表达式
const add = function(a, b){
    return a + b
}

// 定义3. new Function
const add = new Function('a', 'b', 'return a + b')
复制代码

前两种函数定义方式是”静态“的,之所谓是”静态“的是函数定义之时,它的功能就确定下来了。而第三种函数定义方式则是”动态“,所谓”动态“是函数功能可以在程序运行过程中变化。

定义1 与 定义2也是有区别的哦,最关键的区别在于 JavaScript 函数和变量声明的“提前”(hoist)行为。这里就不做展开了。

比如,我需要动态构造一个 n 个数相加的函数:

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))
复制代码

打印函数字符串add.toString(),可以得到:

function anonymous(x0,x1,x2,x3) {
    return x0+x1+x2+x3;
}
复制代码

函数add的函数入参和函数体会根据nums的长度而动态生成,这样你可以根据实际情况来控制传入参数的个数,并且函数也只处理这几个入参。

new Function的函数声明方式较前两者首先性能上会有点吃亏,每次实例化都会消耗性能。其次,new Function声明的函数不支持”闭包“,对比如下代码:

function bar(){
    let name = 'bar'
    let func = function(){return name}
    return func
}
bar()()  // "bar", func中name读取到bar词法作用域中的name变量

function foo(){
    let name = 'foo'
    let func = new Function('return name')
    return func
}
foo()()  // ReferenceError: name is not defined
复制代码

究其原因是因为new Function的词法作用域指向的是全局作用域。

factory.create的主要逻辑是根据钩子类型type,拼接回调时序控制字符串,如下:

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
    })
);
复制代码

我们以SyncHook为例:

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();
复制代码

可以得到如下的函数字符串:

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);
}
复制代码

其中_x则指向this.taps数组,按序访问到每个handler,并执行handler

更多Hook示例,可以查看RunKit

自定义 webpack plugin

一个插件的自我修养

一个合乎规范的插件应满足以下条件:

  1. 它是一个具名的函数或者JS类;
  2. 在原型链上指定apply方法;
  3. 指定一个明确的事件钩子并注册回调;
  4. 处理 webpack 内部实例的特定数据(CompilerCompilation);
  5. 完成功能后调用webpack传入的回调等;

其中条件4、5并不是必需的,只有功能复杂的插件会同时满足以上五个条件。

在文章《Webpack源码解读:理清编译主流程》中我们知道 webpack 中有两个非常重要的内部对象,compilercompilation对象,在两者的hooks上都事先定义好了不同类型的钩子,这些钩子会在编译的整个过程中在相应时间点时触发。而自定义插件就是“钩住”这个时间点,并执行相关逻辑。

compiler钩子列表 compilation钩子列表

自动上传资源的插件

使用webpack打包资源后都会在本地项目中生成一个dist文件夹用于存放打包后的静态资源,此时可以写一个自动上传资源文件到CDN的webpack插件,每次打包成功后及时的上传至CDN。

当你明确插件的功能时,你需要在合适的钩子上去注册你的回调。在本例中,我们需要将已经打包输出后的静态文件上传至CDN,通过在compiler钩子列表中查询知道compiler.hooks.afterEmit是符合要求的钩子,它是一个AsyncSeriesHook类型。

按照五个基本条件来实现这个插件:

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

// 1. 它是一个具名的函数或者JS类
class AssetUploadPlugin {
    constructor(options) {
        // 这里可以校验传入的参数是否合法等初始化操作
        assert(
            options,
            "check options ..."
        );
    }
    // 2. 在原型链上指定`apply`方法
    // apply方法接收 webpack compiler 对象入参
    apply(compiler) {
        // 3. 指定一个明确的事件钩子并注册回调
        compiler.hooks.afterEmit.tapAsync(  // 因为afterEmit是AsyncSeriesHook类型的钩子,需要使用tapAsync或tapPromise钩入回调
            "AssetUploadPlugin",
            (compilation, callback) => {
                const {
                    outputOptions: { path: outputPath }
                } = compilation;  // 4. 处理 webpack 内部实例的特定数据
                uploadDir(
                    outputPath,
                    this.options.ignore ? { ignore: this.options.ignore } : null
                )
                .then(() => {
                    callback();  // 5. 完成功能后调用webpack传入的回调等;
                })
                .catch(err => {
                    callback(err);
                });
            });
    }
};
// uploadDir就是这个插件的功能性描述
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 "未找到需要上传的文件";
            }
            // TODO: 这里将资源上传至你的静态云服务器中,如京东云、腾讯云等
            // ...
        });
}

module.exports = AssetUploadPlugin
复制代码

webpack.config.js中可以引入这个插件并实例化:

const AssetUploadPlugin = require('./AssetUploadPlugin')
const config = {
    //...
    plugins: [
        new AssetUploadPlugin({
            ignore: []
        })
    ]
}
复制代码

总结

webpack的灵活配置得益于 Tapable 提供强大的钩子体系,让编译的每个过程都可以“钩入”,如虎添翼。正所谓“三人成众”,将一个系统做到插件化时,它的可扩展性将大大提高。 Tapable也可以应用到具体的业务场景中,比如流程监控日志记录埋点上报等,凡是需要“钩入”到具体流程中时,Tapable就有它的应用场景。

最后

码字不易,如果:

  • 这篇文章对你有用,请不要吝啬你的小手为我点赞;
  • 有不懂或者不正确的地方,请评论,我会积极回复或勘误;
  • 期望与我一同持续学习前端技术知识,请关注我吧;
  • 转载请注明出处;

您的支持与关注,是我持续创作的最大动力!

参考