Webpack插件机制之Tapable-源码解析

6,181 阅读9分钟

Webpack的成功之处,不仅在于强大的打包构建能力,也在于它灵活的插件机制。

Webpack本质上是一种事件流的机制,它的工作流程就是将各个插件串联起来,而实现这一切的核心就是Tapable。

在学习Webpack的时候,经常可以看到上述介绍。也就是说学Webpack的前提是要学习Tapable。才能更好的学习Webpack原理。

一、Tapable

其实tapable的核心思路有点类似于node.js中的events,最基本的发布/订阅模式。

const EventEmitter = require('events');
const myEmitter = new EventEmitter();

// 注册事件对应的监听函数
myEmitter.on('start', (params) => {
    console.log("输出", params)
});

// 触发事件 并传入参数
myEmitter.emit('start', '学习webpack工作流'); // 输出 学习webpack工作流

二、tapable钩子介绍

首先,tapable提供的钩子有如下10个。

tapable钩子介绍

const {
    SyncHook,
    SyncBailHook,
    SyncWaterfallHook,
    SyncLoopHook,
    AsyncParallelHook,
    AsyncParallelBailHook,
    AsyncSeriesHook,
    AsyncSeriesBailHook,
    AsyncSeriesLoopHook,
    AsyncSeriesWaterfallHook
 } = require("tapable");

其次,所有钩子的用法简介,如下:(可以简单瞄一眼,就往下看吧)

序号 钩子名称 执行方式 使用要点
1 SyncHook 同步串行 不关心监听函数的返回值
2 SyncBailHook 同步串行 只要监听函数中有一个函数的返回值不为 undefined,则跳过剩下所有的逻辑
3 SyncWaterfallHook 同步串行 上一个监听函数的返回值可以传给下一个监听函数
4 SyncLoopHook 同步循环 当监听函数被触发的时候,如果该监听函数返回true时则这个监听函数会反复执行,如果返回 undefined 则表示退出循环
5 AsyncParallelHook 异步并发 不关心监听函数的返回值
6 AsyncParallelBailHook 异步并发 只要监听函数的返回值不为 null,就会忽略后面的监听函数执行,直接跳跃到callAsync等触发函数绑定的回调函数,然后执行这个被绑定的回调函数
7 AsyncSeriesHook 异步串行 不关心callback()的参数
8 AsyncSeriesBailHook 异步串行 callback()的参数不为null,就会直接执行callAsync等触发函数绑定的回调函数
9 AsyncSeriesWaterfallHook 异步串行 上一个监听函数的中的callback(err, data)的第二个参数,可以作为下一个监听函数的参数。
10 AsyncSeriesLoopHook 异步串行 可以触发handler循环调用。

三、上述Hook使用介绍

(1.1)SyncHook

同步串行,不关心监听函数的返回值。

我们先来介绍最简单的SyncHook,其实每个Hook都大同小异,懂一个其他的就非常好懂了。

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

//所有的构造函数都接收一个可选的参数,这个参数是一个字符串的数组。
let queue = new SyncHook(['param1']); 

// 订阅tap 的第一个参数是用来标识订阅的函数的
queue.tap('event 1', function (param1) {
    console.log(param1, 1);
});

queue.tap('event 2', function (param1) {
    console.log(param1, 2);
});

queue.tap('event 3', function () {
    console.log(3);
});

// 发布的时候触发订阅的函数 同时传入参数
queue.call('hello');

// 控制台输出
/* hello 1
   hello 2
   3
*/

可以看到,这个钩子订阅的事件都是按顺序同步执行的。

(1.2)SyncHook原理

简单模拟下原理。

class SyncHook{
    constructor(){
        this.taps = [];
    }

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

    // 发布
    call(){
        this.taps.forEach(tap => tap(...arguments));
    }
}

(2.1)SyncBailHook

再来看下SyncBailHook的使用。

只要监听函数中有一个函数的返回值不为undefined,则跳过剩下所有的逻辑。

let queue = new SyncBailHook(['param1']); //所有的构造函数都接收一个可选的参数,这个参数是一个字符串的数组。

// 订阅
queue.tap('event 1', function (param1) {// tap 的第一个参数是用来标识订阅的函数的
    console.log(param1, 1);
    return 1;
});

queue.tap('event 2', function (param1) {
    console.log(param1, 2);
});

queue.tap('event 3', function () {
    console.log(3);
});

// 发布
queue.call('hello', 'world');// 发布的时候触发订阅的函数 同时传入参数

// 控制台输出
/* hello 1 */

可以看到,只要监听函数中有一个函数的返回值不为undefined,则跳过剩下所有的逻辑。

(2.2)SyncBailHook原理

简单模拟下原理。

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

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

    // 发布
    call() {
        for (let i = 0, l = this.taps.length; i < l; i++) {
            let tap = this.taps[i];
            let result = tap(...arguments);
            if (result) {
                break;
            }
        }
    }
}

(3)SyncHook和SyncBailHook总结

上述2种的钩子的执行流程如下图所示:

通过这个2个钩子的介绍,可以发现tapable提供了各种各样的hook来帮我们管理事件是如何执行的。

tapable的核心功能就是控制一系列注册事件之间的执行流控制,比如我注册了三个事件,我可以希望他们是并发的,或者是同步依次执行,又或者其中一个出错后,后面的事件就不执行了,这些功能都可以通过tapablehook实现。

就像起床、上班、吃早饭的关系一样,起床肯定是优先的。但是吃饭和上班就不一定啦。万一要迟到了呢?可能就放弃早饭了!

吃饭

四、Tapable的源码解读

记住重点,核心就是calltap两个方法。
记住重点,核心就是calltap两个方法。
记住重点,核心就是calltap两个方法。

那我们来看下tapable源码的SyncHook是如何实现的,如下。还是那句话,看完一个,其他的自然就懂啦。为了理解,源码均为缩减过的,去除了些非核心代码。

// node_modules/tapable/lib/SyncHook.js
const factory = new SyncHookCodeFactory();
// 继承基础Hook类
class SyncHook extends Hook {
    // 重写Hook的compile方法
    compile(options) {
        // 开发者订阅的事件传
        factory.setup(this, options);
        // 动态生成call方法
    	return factory.create(options);
    }
}
module.exports = SyncHook;

核心代码非常简单,可以看到SyncHook就是继承了Hook基础类。并重写了compile方法。

首先来看下Hook基础类的tap方法。可以看到每次调用tap,就是收集当前hook实例所有订阅的事件到taps数组。

// node_modules/tapable/lib/Hook.js
// 订阅
tap(options, fn) {
    // 同步 整理配置项
    options = Object.assign({ type: "sync", fn: fn }, options);
    // 将订阅的事件存储在taps里面
    this._insert(options);
}

_insert(item) {
    // 将item 推进 this.taps
    this.taps[i] = item;
}

然后来看下Hook基础类的call方法是如何实现的。

// node_modules/tapable/lib/Hook.js
class Hook {
    constructor(args) {
    	this.taps = [];
    	this.call = this._call;
    }

    compile(options) {
    	// 继承类必须重写compile
    	throw new Error("Abstract: should be overriden");
    }
    
    // 执行compile生成call方法
    _createCall(type) {
    	return this.compile({
            taps: this.taps,
    		// ...等参数
    	});
    }
}

// 动态生成call方法
function createCompileDelegate(name, type) {
    return function lazyCompileHook(...args) {
    	// 创造call等函数
    	this[name] = this._createCall(type);
    	// 执行触发call等函数
    	return this[name](...args);
    };
}

// 定义_call方法
Object.defineProperties(Hook.prototype, {
    _call: {
    	value: createCompileDelegate("call", "sync"),
    	configurable: true,
    	writable: true
    },
});

通过上述代码,我们可以发现,call方法究竟是什么,是通过重写的compile方法生成出来的。那我们再看下compile方法究竟做了什么。

先来看下SyncHook的全部代码。

// node_modules/tapable/lib/SyncHook.js
const Hook = require("./Hook");
const HookCodeFactory = require("./HookCodeFactory");

// 继承工厂类
class SyncHookCodeFactory extends HookCodeFactory {
    // call方法个性化定制
    content({ onError, onDone, rethrowIfPossible }) {
    	return this.callTapsSeries({
            onError: (i, err) => onError(err),
            onDone,
            rethrowIfPossible
    	});
    }
}

const factory = new SyncHookCodeFactory();

// 继承基础Hook类
class SyncHook extends Hook {
    // 重写Hook的compile方法
    compile(options) {
        // 开发者订阅的事件传
        factory.setup(this, options);
        // 动态生成call方法
    	return factory.create(options);
    }
}

module.exports = SyncHook;

可以看到compile主要是执行factory的方法,而factorySyncHookCodeFactory的实例,继承了HookCodeFactory类,然后factory实例调用了setup方法。

setup就是将taps中订阅的事件方法统一给了this._x;

// node_modules/tapable/lib/HookCodeFactory.js
setup(instance, options) {
    // 将taps里的所有fn 赋值给 _x
    instance._x = options.taps.map(t => t.fn);
}

然后再看下factory实例调用的create方法。

// node_modules/tapable/lib/HookCodeFactory.js
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;
    }
}

create会将传进来的所有事件,进行组装。最终生成call方法。 如下就是我们这次的案例最终生成的call方法。

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

如果你订阅了5个事件,上述代码就会变成5个函数的依次执行。以及参数必须是创建hook实例就声明好的。否则tap事件传的参数是无用的~

以上代码还是简写了很多,大家可以直接去看下源码,非常精简好理解。给作者大大点赞。👍

总结一下,核心就是calltap两个方法。其实还有tapAsync等...但是原理都是一样的。tap收集订阅的事件,触发call方法时根据hook的种类动态生成对应的执行体。如下图,其他hook的实现也是同理。

Hook设计原理

五、Tapable在Webpack中的应用

Webpack的流程可以分为以下三大阶段:

执行webpack时,会生成一个compiler实例。

// node_modules/webpack/lib/webpack.js
const Compiler = require("./Compiler");
const MultiCompiler = require("./MultiCompiler");

const webpack = (options, callback) => {
	// ...省略了多余代码...
    let compiler;
    if (typeof options === "object") {
    	compiler = new Compiler(options.context);
    } else {
    	throw new Error("Invalid argument: options");
    }
})

我们发现Compiler是继承了Tapable的。同时发现webpack的生命周期hooks都是各种各样的钩子。

// node_modules/webpack/lib/Compiler.js
class Compiler extends Tapable {
    constructor(context) {
    super();
        this.hooks = {
            /** @type {AsyncSeriesHook<Stats>} */
            done: new AsyncSeriesHook(["stats"]),
            /** @type {AsyncSeriesHook<>} */
            additionalPass: new AsyncSeriesHook([]),
            /** @type {AsyncSeriesHook<Compiler>} */
            beforeRun: new AsyncSeriesHook(["compiler"]),
            /** @type {AsyncSeriesHook<Compiler>} */
            run: new AsyncSeriesHook(["compiler"]),
            /** @type {AsyncSeriesHook<Compilation>} */
            emit: new AsyncSeriesHook(["compilation"]),
            /** @type {AsyncSeriesHook<string, Buffer>} */
            assetEmitted: new AsyncSeriesHook(["file", "content"]),
            /** @type {AsyncSeriesHook<Compilation>} */
            afterEmit: new AsyncSeriesHook(["compilation"]),
        
            // ....等等等很多 大家看下源码吧.... 不看也没有关系
        }
    }
}

然后在初始化webpack的配置过程中,会循环我们配置的以及webpack默认的所有插件也就是plugin

// 订阅在options中的所有插件
if (options.plugins && Array.isArray(options.plugins)) {
    for (const plugin of options.plugins) {
        if (typeof plugin === "function") {
            plugin.call(compiler, compiler);
        } else {
            plugin.apply(compiler);
        }
    }
}

这个过程,会把plugin中所有tap事件收集到每个生命周期的hook中。 最后根据每个hook执行call方法的顺序(也就是生命周期)。就可以把所有plugin执行了。

举个例子,下面是我们经常使用的热更新插件代码,它订阅了additionalPasshook

热更新插件
这也就是webpack它工作流程能将各个插件plugin串联起来的原因,而实现这一切的核心就是Tapable

六、吐个槽

虽然插件化设计很灵活,我们可以写插件操作webpack的整个生命周期。但是也发现插件化设计带来的一些问题,就是阅读源码非常不好的体验:

(1)联系松散。使用tapable钩子类似事件监听模式,虽然能有效解耦,但钩子的注册与调用几乎没有联系。

(2)看到源码里一个模块提供了几个钩子,但并不知道,在何时、何地该钩子会被调用,又在何时、何地钩子上被注册了哪些方法。这些以往都是需要通过在代码库中搜索关键词来解决。

(3)钩子数量众多。webpack内部的钩子非常多,数量达到了180+

参考链接

本篇文主要是讲原理,理解tapable。其他的钩子的使用,可以看这篇文章。