深入源码解析 tapable 实现原理

1,532 阅读11分钟

引子

如果你了解过 webpack,他们会告诉你,webpack 底层是基于 tapable

如果你好奇 tapable 是什么,你可能会看到其他地方的博客:『Tapble是webpack在打包过程中,控制打包在什么阶段调用Plugin的库,是一个典型的观察者模式的实现』。

可能,他们还会告诉你,Tapable的核心功能就是控制一系列注册事件之间的执行流控制,对吧?

如果你了解的继续深一些,可能还会看到下面的表格,见到如此多的钩子:

名称钩入的方式作用
HooktaptapAsynctapPromise钩子基类
SyncHooktap同步钩子
SyncBailHooktap同步钩子,只要执行的 handler 有返回值,剩余 handler 不执行
SyncLoopHooktap同步钩子,只要执行的 handler 有返回值,一直循环执行此 handler
SyncWaterfallHooktap同步钩子,上一个 handler 的返回值作为下一个 handler 的输入值
AsyncParallelBailHooktaptapAsynctapPromise异步钩子,handler 并行触发,但是跟 handler 内部调用回调函数的逻辑有关
AsyncParallelHooktaptapAsynctapPromise异步钩子,handler 并行触发
AsyncSeriesBailHooktaptapAsynctapPromise异步钩子,handler 串行触发,但是跟 handler 内部调用回调函数的逻辑有关
AsyncSeriesHooktaptapAsynctapPromise异步钩子,handler 串行触发
AsyncSeriesLoopHooktaptapAsynctapPromise异步钩子,可以触发 handler 循环调用
AsyncSeriesWaterfallHooktaptapAsynctapPromise异步钩子,上一个 handler 可以根据内部的回调函数传值给下一个 handler
Hook Helper 与 Tapable 类
名称作用
HookCodeFactory编译生成可执行 fn 的工厂类
HookMapMap 结构,存储多个 Hook 实例
MultiHook组合多个 Hook 实例
Tapable向前兼容老版本,实例必须拥有 hooks 属性

那么,问题来了,这些钩子的内部是如何实现的?它们之间有什么样的继承关系? 源码设计上有什么优化地方?

本文接下来,将从 tapable 源码出发,解开 tapable 神秘的面纱。

Tapable 源码核心

先上一张大图,涵盖了 80% 的 tapable 核心流程

上图中,我们看到, tapable 这个框架,最底层的有两个类: 基础类 Hook, 工厂类 HookCodeFactory

上面列表中 tapable 提供的钩子,比如说 SyncHookSyncWaterHooks等,都是继承自基础类 Hook

图中可见,这些钩子,有两个最关键的方法: tap方法、 call 方法。

这两个方法是tapable 暴露给用户的api, 简单且好用。 webpack 是基于这两个api 建构出来的一套复杂的工作流。

我们再来看工厂类 HookCodeFactory,它也衍生出SyncHookCodeFactorySyncWaterCodeFactory 等不同的工厂构造函数,实例化出来不同工厂实例factory

工厂实例factory的作用是,拼接生产出不同的 compile 函数,生产 compile 函数的过程,本质上就是拼接字符串,没有什么魔法,下文中会介绍到。

这些不同的 compile 函数,最终会在 call() 方法被调用。

呼,刚才介绍了一大堆概念,希望没有把读者弄晕

我们首先看一下,call 方法和 tap 方法是如何使用的。

基本用法

下面是简单的一个例子:

let hook = new SyncHook(['foo']);

hook.tap({
    name: 'dudu',
    before: '',
}, (params) => {
    console.log('11')
})

hook.tap({
    name: 'lala',
    before: 'dudu',
}, (params) => {
    console.log('22')
})

hook.tap({
 name: 'xixi',
 stage: -1
}, (params) => {
 console.log('22')
})

hook.call('tapable', 'learn')

上面代码的输出结果:

// 22
// 11

我们使用 tap()方法用于注册事件,使用 call() 来触发所有回调函数执行。

注意点:

  • 在实例化 SyncHook 时,我们传入字符串数组。数组的长度很重要,会影响你通过 call 方法调用 handler 时入参个数。就像例子所示,调用 call 方法传入的是两个参数,实际上 handler 只能接收到一个参数,因为你在new SyncHook 的时候传入的字符串数组长度是1。

  • 通过 tap 方法去注册 handler 时,第一个参数必须有,格式如下:

    interface Tap {
            name: string,  // 标记每个 handler,必须有,
            before: string | array, // 插入到指定的 handler 之前
            type: string,   // 类型:'sync', 'async', 'promise'
            fn: Function,   // handler
            stage: number,  // handler 顺序的优先级,默认为 0,越小的排在越前面执行
            context: boolean // 内部是否维护 context 对象,这样在不同的 handler 就能共享这个对象
    }
    

    上面参数,我们重点关注 beforestage,这两个参数影响了回调函数的执行顺序 。上文例子中, name'lala'handler 注册的时候,是传了一个对象,它的 before 属性为 dudu,说明这个 handler 要插到 nameduduhandler 之前执行。但是又因为 namexixihandler 注册的时候,stage 属性为 -1,比其他的 handlerstage 要小,所以它会被移到最前面执行。

那么,tapcall是如何实现的呢? 被调用的时候,背后发生了什么?

我们接下来,深入到源码分析 tapable 机制。

下文中分析的源码是 tapable v1.1.3 版本

tap 方法的实现

上文中,我们在注册事件时候,用了 hook.tap() 方法。

tap 方法核心是,把注册的回调函数,维护在这个钩子的一个数组中。

tap 方法实现在哪里呢?

代码里面,hookSyncHook 的实例,SyncHook又继承了 Hook 基类,在 Hook 基类中,具体代码如下:

class Hook {
    tap(options, fn) {
        options = this._runRegisterInterceptors(options);
        this._insert(options);
    }
}

我们发现,tap 方法最终调用了 _insert方法,

_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);
        }
        // 默认 stage是0
        // stage 值越大,
        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;
    }

把注册的方法,都 push 到一个 taps 数组上面。这里对 beforestage 做了处理,使得 push 到 taps 数组的顺序不同,从而决定了 回调函数的执行顺序不同。

call 方法的实现

SyncHook.js 中,我们没有找到 call 方法的定义。再去 Hook 基类上找,发现有这样一句, call 方法 是 _call 方法

this.call = this._call;
class Hook {
    construcotr {
        // 这里发现,call 方法就是 this._call 方法
        this.call = this._call;
    }
    compile(options) {
        throw new Error("Abstract: should be overriden");
    }

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

那么, _call 方法是在哪里定义的呢?看下面, this._call createCompileDelegate("call", "sync") 的返回值。

Object.defineProperties(Hook.prototype, {
    // this._call 是 createCompileDelegate("call", "sync") 的值, 为函数 lazyCompileHook
    _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
    }
});

接着往下看 createCompileDelegate 方法里面做了什么?

// 下面的createCompileDelegate 方法 返回了一个新的方法,
// 参数 name 是闭包保存的字符串 'call'
function createCompileDelegate(name, type) {
    return function lazyCompileHook(...args) {
        // 实际上
        // this.call = this._creteCall(type)
        // return this.call()
        this[name] = this._createCall(type);
        return this[name](...args);
    };
}

上面的代码,createCompileDelegate 先调用 this._createCall() 方法,把返回值赋值给 this[name]

this._createCall() 里面本质是调用了this.compiler 方法,但是基类Hook上的compiler() 方法是一个空实现,顺着这条线索找下来,这是一条死胡同。

this.compiler 方法,真正是定义在衍生类 SyncHook上,也就是在 SyncHook.js 中,SyncHook 类重新定义了 compiler 方法来覆盖:

const factory = new SyncHookCodeFactory();
class SyncHook extends Hook {
    compile(options) {
        factory.setup(this, options);
        return factory.create(options);
    }
}

这里的 factory ,就是本文开头提到的工厂实例。factory.create 的产物如下:

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

this._x 是一个数组,里面存放的就是我们注册的 taps 方法。上面代码的核心就是,遍历我们注册的 taps 方法,并去执行。

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

上面代码中, content 方法是定义在 SyncHook 的衍生类上的,

class SyncHookCodeFactory extends HookCodeFactory {
    // 区分不同的类型的 工程
    // content 方法用于拼接字符串
    // HookCodeFactory 里面会调用 this.content(), 访问到的是这里的 content
    content({ onError, onDone, rethrowIfPossible }) {
        return this.callTapsSeries({
            onError: (i, err) => onError(err),
            onDone,
            rethrowIfPossible
        });
    }
}

到这里为止一目了然,我们可以看到我们的注册回调是怎样在this.call方法中一步步执行的。

在这里的优化, tapable 用到了《javascript 高级程序设计》中的『惰性函数』,缓存下来 this.__createCall call,从而提升性能

惰性函数

什么是惰性函数? 惰性函数有什么作用?

比如说,我们定义一个函数,我们需要在这个函数里面判断不同的浏览器环境,走不同的逻辑。

function addEvent (type, element, fun) {
 if (element.addEventListener) {
 element.addEventListener(type, fun, false);
 } else if(element.attachEvent){
 element.attachEvent('on' + type, fun);
 } else{
 element['on' + type] = fun;
 }
}

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

上面的惰性函数,只会在第一次执行的时候判断环境,之后每次执行,其实是执行被重新赋值的 addEvent 方法,判断的结果被缓存了下来。

tapable 里用到惰性函数的地方,大概可以简化成如下:

this._call = function(){
    this.call = this._creteCall(type)
    return this.call()
}

this._call 只有在第一次执行的时候,才会拼接生产出字符串方法,之后再执行时,就会执行被缓存下来的字符串方法,从而优化了性能。

factory工厂的产物

Tapable有一系列Hook方法,但是这么多的Hook方法都是无非是为了控制注册事件的执行顺序以及异常处理

最简单的SyncHook 的factory 的工厂产物,前面已经讲过,我们从SyncBailHook开始看。

SyncBailHook

这类钩子的特点是,判断 handler 的返回值,是否===undefined, 如果是 undefined , 则执行,如果有返回值,则 return 返回值

// fn, 调用 call 时,实际执行的代码
function anonymous(/*``*/) {
    "use strict";
    var _context;
    var _x = this._x;
    var _fn0 = _x[0];
    var _result0 = _fn0();
    if (_result0 !== undefined) {
        return _result0;
    } else {
        var _fn1 = _x[1];
        var _result1 = _fn1();
        if (_result1 !== undefined) {
            return _result1;
        } else {
        }
    }
}

通过打印fn,我们可以轻易的看出,SyncBailHook提供了中止注册函数执行的机制,只要在某个注册回调中返回一个非undefined的值,运行就会中止。

SyncWaterfallHook

function anonymous(arg1) {
    "use strict";
    var _context;
    var _x = this._x;
    var _fn0 = _x[0];
    var _result0 = _fn0(arg1);
    if (_result0 !== undefined) {
        arg1 = _result0;
    }
    var _fn1 = _x[1];
    var _result1 = _fn1(arg1);
    if (_result1 !== undefined) {
        arg1 = _result1;
    }
    return arg1;
}

可以看出SyncWaterfallHook就是将上一个事件注册回调的返回值作为下一个注册函数的参数,这就要求在new SyncWaterfallHook(['arg1']);需要且只能传入一个形参。

SyncLoopHook

// 打印fn
function anonymous(arg1) {
    "use strict";
    var _context;
    var _x = this._x;
    var _loop;
    do {
        _loop = false;
        var _fn0 = _x[0];
        var _result0 = _fn0(arg1);
        if (_result0 !== undefined) {
            _loop = true;
        } else {
            var _fn1 = _x[1];
            var _result1 = _fn1(arg1);
            if (_result1 !== undefined) {
                _loop = true;
            } else {
                if (!_loop) {
                }
            }
        }
    } while (_loop);
}

SyncLoopHook只有当上一个注册事件函数返回undefined的时候才会执行下一个注册函数,否则就不断重复调用。

AsyncSeriesHook

Series有顺序的意思,这个Hook用于按顺序执行异步函数。

function anonymous(_callback) {
    "use strict";
    var _context;
    var _x = this._x;
    var _fn0 = _x[0];
    _fn0(_err0 => {
        if (_err0) {
            _callback(_err0);
        } else {
            var _fn1 = _x[1];
            _fn1(_err1 => {
                if (_err1) {
                    _callback(_err1);
                } else {
                    _callback();
                }
            });
        }
    });
}

从打印结果可以发现,两个事件之前是串行的,并且next中可以传入err参数,当传入err,直接中断异步,并且将err传入我们在call方法传入的完成回调函数中。

AsyncParallelHook

asyncParallelHook 是异步并发的钩子,适用场景:一些情况下,我们去并发的请求不相关的接口,比如说请求用户的头像接口、地址接口。

factory.create 的产物是下面的字符串

function anonymous(_callback) {
    "use strict";
    var _context;
    var _x = this._x;
    do {
        // _counter 是 注册事件的数量
        var _counter = 2;
        var _done = () => {
            _callback();
        };

        if (_counter <= 0) break;

        var _fn0 = _x[0];

        _fn0(_err0 => {
            // 这个函数是 next 函数
            // 调用这个函数的时间不能确定,有可能已经执行了接下来的几个注册函数
            if (_err0) {
                // 如果还没执行所有注册函数,终止
                if (_counter > 0) {
                    _callback(_err0);
                    _counter = 0;
                }
            } else {
                // 检查 _counter 的值,如果是 0 的话,则结束
                // 同样,由于函数实际调用时间无法确定,需要检查是否已经运行完毕,
                if (--_counter === 0) {
                    _done()
                };
            }
        });

        // 执行下一个注册回调之前,检查_counter是否被重置等,如果重置说明某些地方返回err,直接终止。
        if (_counter <= 0) break;

        var _fn1 = _x[1];

        _fn1(_err1 => {
            if (_err1) {
                if (_counter > 0) {
                    _callback(_err1);
                    _counter = 0;
                }
            } else {
                if (--_counter === 0) _done();
            }
        });

    } while (false);
}

从打印结果看出Event2的调用在AsyncCall in Event1之前,说明异步事件是并发的。

字节跳动大大大大量量量量招人了

字节跳动(杭州|北京|上海)大量招人,福利超级棒,薪资水平秒杀 BAT,上班不打卡、每天下午茶、免费零食无限供应、免费三餐(我念下菜单,大闸蟹鲍鱼扇贝海鲜烤鱼片黑椒牛柳咖喱牛肉麻辣小龙虾)、免费健身房、入职配touch bar15寸顶配全新mbp、每月还有租房房补。 这次真的机会多多,年后研发人数要扩招n倍,技术氛围好,大牛多,加班少,还犹豫什么?快发简历到下方邮箱,就现在!

仅仅是一小部分的jd链接如下, 更多的欢迎加微信~

前端jd: job.toutiao.com/s/bJM4Anjob…

后端jd: job.toutiao.com/s/bJjjTsjob…

测试jd: job.toutiao.com/s/bJFv9bjob…

产品jd: job.toutiao.com/s/bJBgV8job…

前端实习生: job.toutiao.com/s/bJ6NjAjob…

后端实习生: job.toutiao.com/s/bJrjrkjob…

持续招聘大量前端、服务端、客户端、测试、产品,实习社招都阔以

简历发 dujuncheng@bytedance.com,建议加微信 dujuncheng1,可以聊天聊地聊人生,请注明来自掘金以及要投递哪里的岗位