搞懂搞透 webpack 的灵魂 tapable

1,136 阅读4分钟

这段时间没更新文章,把时间全耗在了 tabable 核心原理分析上,现在终于把 tabable 搞懂了,通过本文分享给大家。最后我发现了一个非常容易掌握 tabable 的方法。

经过这几天的学习,我把 tapable 总结成一句话「通过一种机制,监听特定的事件」。拿最经典的一个例子音频播放器来说,核心播放服务只有一个,而需要监听播放事件的对象有很多,比如 mini播放器,主播放器,这两个播放器都要监听音频播放进度、播放开始、播放暂停事件。下图中的两个播放器都要监听播放进度:

遇到这种需求,大多数人的做法是采用「发布订阅模式」,使用 tapable 也能解决类似这种事件监听的业务,但是它更灵活,能做更多的业务,比如 hook 函数数据之间传递,异步 hook,hook 流处理。这是在我第一次了解到 asyncHook,syncHook,syncBailHook、syncWaterfallHook 这种编程思想。当然 tapable 终究服务于 webpack,很多场景都是为 webpack 的设计而考虑的。

1. 整体 Hook 概述

tapable 的核心类有如下几个,主要分为同步 hook 和异步 hook:

2. 监听与触发事件

可以通过 3 种方式来添加「事件监听函数」,也就说如果想监听某个事件,直接通过下面这几个方法来添加即可:

2.1、tap(options, fn):添加同步监听函数

options 可以是对象或字符串,fn 为同步回调函数。可用于所有类型的 hook,包含 sync 类型和 async 类型。

hook.tap('SuyanSyncHook', (name, age) => {
    console.log(`syncHook name: ${name}, age: ${age}`);
});

通过 call 方法来触发事件:syncHook.call('suyan', 20);

2.2、tapAsync(options, fn):添加异步监听函数

监听函数最后一个参数为一个回调函数,这个函数不能用于 sync 类型的 hook。

asyncHook.tapAsync('SuyanasyncSeriesHook', (source, callback) => {
    setTimeout(() => {
        console.log(`source3: ${source}`);
        callback();
    }, 2000);
});

通过 callAsync 来触发事件:

asyncSeriesHook.callAsync('关注公众号素燕', ret => {
    console.log(`ret = ${ret}`);
});

2.3、tapPromise(options, fn):promise 的方式;

需要返回一个 promise 对象;不能用于 sync 类型的 hook。

asynchook.tapPromise('SuyanasyncParallelHook', (source) => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log(`source2: ${source}`);
            resolve(`${source},素燕`);
        }, 1000);
    });
});

通过 promise 来触发事件:

asyncHook.promise('关注公众号素燕').then(ret => {
    console.log(`ret = ${ret}`);
});

3. 各种 Hook 实现分析

我们下面来介绍各种 Hook 的作用,总共有两类 Hook,第一类是同步 Hook。

3.1、SyncHook

同步 Hook,hook 事件之间互不干扰。下面是使用 SyncHook 的例子,向 syncHook 中添加了两个回调函数,当调用 call 函数的时候,先前通过 tap 添加的回调函数将被执行:

const SyncHook = require('./lib/SyncHook');
// 创建一个 SyncHook,后面跟一个参数列表
const syncHook = new SyncHook(['name', 'age']);
// 添加一个 hook 事件
syncHook.tap('SuyanSyncHook', (name, age) => {
    console.log(`syncHook name: ${name}, age: ${age}`);
});
// 添加一个 hook 事件
syncHook.tap('SuyanSyncHook', (name, age) => {
    console.log(`1syncHook name: ${name}, age: ${age}`);
});
// 调用 hook
syncHook.call('suyan', 20);

这是咋么做到的呢?我们通过源码来分析下 tapable 的核心原理,理解了其中一个原理,其它的 Hook 可以用同样的方式来理解。

所有的 hook 都继承自 Hook 这个类,在我看来它主要做了 3 件事:

提供调用函数 call、promise、callAsync; 保存函数执行时的上下文,比如函数参数、监听者; 拦截器处理; 我们看一下它的构造函数 constructor

constructor(args) {
 // 一个数组,用来保 hook 的参数
 if (!Array.isArray(args)) args = [];
 // 参数
 this._args = args;
 // 监听器,或者是订阅者
 this.taps = [];
 // 拦截器
 this.interceptors = [];
 // 这几个函数最终其实指向的是 Object 的原型
 // 普通调用
 this.call = this._call;
 // promise 调用
 this.promise = this._promise;
 // 异步调用
 this.callAsync = this._callAsync;
 // 所有的回调函数
 this._x = undefined;
}

通过 Hook 收集的信息,然后通过 HookCodeFactory 来生成函数,比如上面的 SyncHook 代码,最终生成的代码如下:


"use strict";
var _context;
// _x 保存了所有的回调函数,也就是 tap 时的函数
// syncHook.tap('SuyanSyncHook', FN);
var _x = this._x;

// 执行第一个回调函数
var _fn0 = _x[0];
_fn0(name, age);

// 执行第二个回调函数
var _fn1 = _x[1];
_fn1(name, age);

tapable 的核心就是动态生成函数 Function。在 JavaScript 中可以直接定义函数,也可以通过 new Function 来生成一个函数,下面的函数是等价的:

// 直接定义函数
function sum(a, b) {
    return a + b;
}
// 通过 new Function 生成函数
const sum = new Function(['a', 'b'], 'return a + b;');

这就是 Hook 的本质,可以通过最终生成的代码理解每一个 Hook 的作用。

总之,tapable 的核心原理是收集函数要执行时的全部信息,根据这些信息 taps、intercepts、args、type ,通过 new Function 生成最终要调用的函数,当需要通知监听者时,直接执行生成的函数。

3.2、SyncBailHook

可以通 Hook 的名字理解它作用,比如 SyncHook,它很「纯」,最基本的 Hook。而 SyncBailHook、SyncWaterfallHook、SyncLoopHook 这 3 个 Hook 添加了修饰符,它们与监听函数的返回值有关。

Bail(熔断),带有这个词的 Hook 表示只要有一个 Hook 的监听函数返回不为 undefined,监听就会截止。由于第二个监听函数返回了「学习 webpack」,第三个监听函数将不会被执行。

const SyncBailHook = require('./lib/SyncBailHook');

// 一个接一个的 hook,只要有一个返回 undefined 的就截止
const syncBailHook = new SyncBailHook(['source']);
syncBailHook.tap('SuyansyncBailHook', (source) => {
    console.log(`source1: ${source}`);
});
syncBailHook.tap('SuyansyncBailHook', source => {
    console.log(`source2: ${source}`);
    return '学习 webpack';
});
syncBailHook.tap('SuyansyncBailHook', source => {
    console.log(`source3: ${source}`);
});

let ret = syncBailHook.call('关注公众号素燕,');
console.log(`ret = ${ret}`);

我们在看下编译后的代码:

"use strict";
var _context;
var _x = this._x;
console.log('this._x = : ', this._x);
var _fn0 = _x[0];
var _result0 = _fn0(source);
if (_result0 !== undefined) {
    return _result0;
} else {
    var _fn1 = _x[1];
    var _result1 = _fn1(source);
    if (_result1 !== undefined) {
        return _result1;
    } else {
        var _fn2 = _x[2];
        var _result2 = _fn2(source);
        if (_result2 !== undefined) {
            return _result2;
        } else {
        }
    }
}

3.3、SyncWaterfallHook

waterfall(瀑布流),监听函数的返回值会传递给下一个监听函数,就如同水流一样,源源不断。每一个监听函数的返回值会传递到下一个回调函数,下面这个 demo 最终的返回值为「关注公众号素燕,和素燕一起学习 webpack」:

const SyncWaterfallHook = require('./lib/SyncWaterfallHook');

// 一个接一个的 hook,上一个函数的返回值是下一个函数的参数
const syncWaterfallHook = new SyncWaterfallHook(['source']);
syncWaterfallHook.tap('SuyanSyncWaterfallHook', (source) => {
    console.log(`source1: ${source}`);
    return `${source}和`;
});
syncWaterfallHook.tap('SuyanSyncWaterfallHook', source => {
    console.log(`source2: ${source}`);
    return `${source}素燕`;;
});
syncWaterfallHook.tap('SuyanSyncWaterfallHook', source => {
    console.log(`source3: ${source}`);
    return `${source}一起学习 webpack`;;
});

let ret = syncWaterfallHook.call('关注公众号素燕,');
console.log(`ret = ${ret}`);

最终编译的代码如下:

"use strict";
var _context;
var _x = this._x;
var _fn0 = _x[0];
var _result0 = _fn0(source);
if(_result0 !== undefined) {
    source = _result0;
}
var _fn1 = _x[1];
var _result1 = _fn1(source);
if(_result1 !== undefined) {
    source = _result1;
}
var _fn2 = _x[2];
var _result2 = _fn2(source);
if(_result2 !== undefined) {
    source = _result2;
}
return source;

3.4、SyncLoopHook

loop(循环),只要返回值不为「假值」,监听函数将一直被调用。不详细讲解,可自行学习。

第二类是异步的 Hook,这种 Hook 一般用在比较耗时的操作,比如网络请求。

3.5、AsyncSeriesHook,异步串行 hook

对于异步 hook,可以使用 tapAsync 和 tapPromise 添加异步函数。监听函数的最后一个参数为 callback,调用 callback 时,如果带有 error 参数,整个监听函数链将会中断,比如 callback('err'),当遇到这种 callback 时,下一个监听函数将不会被执行。举个例子:

const AsyncSeriesHook = require('./lib/AsyncSeriesHook');

// 异步串行,就是一个一个来
const asyncSeriesHook = new AsyncSeriesHook(['source']);
asyncSeriesHook.tapAsync('SuyanasyncSeriesHook', (source, callback) => {
    setTimeout(() => {
        console.log(`source1: ${source}`);
        callback();
    }, 3000);
});
asyncSeriesHook.tapAsync('SuyanasyncSeriesHook', (source, callback) => {
    setTimeout(() => {
        console.log(`source2: ${source}`);
        callback();
    }, 1000);
});

asyncSeriesHook.callAsync('关注公众号素燕', ret => {
    console.log(`ret = ${ret}`);
});

通过最终编译后的代码可以清晰看到整个 hook 做的事情:

"use strict";
var _context;
var _x = this._x;
// 下一个要执行的函数
function _next0() {
    var _fn1 = _x[1];
    _fn1(source, _err1 => {
        if (_err1) {
            _callback(_err1);
        } else {
            _callback();
        }
    });
}
// 第一个要执行的函数
var _fn0 = _x[0];
_fn0(source, _err0 => {
    if (_err0) {
        _callback(_err0);
    } else {
        _next0();
    }
});

3.6、AsyncSeriesBailHook

这个 Hook 和 SyncBailHook 大同小异,只不过回调函数为异步函数,回调函数 callback 接收 2 个参数,第一个为 error,第二个为 result,当遇到 error 和 result 都不为空时,整个监听函数链将会中断。看下面的例子:

const AsyncSeriesBailHook = require('./lib/AsyncSeriesBailHook');

// 异步串行,就是一个一个来
const asyncSeriesBailHook = new AsyncSeriesBailHook(['source']);
asyncSeriesBailHook.tapAsync('SuyanasyncSeriesBailHook', (source, callback) => {
    setTimeout(() => {
        console.log(`source1: ${source}`);
        callback();
    }, 3000);
});
asyncSeriesBailHook.tapAsync('SuyanasyncSeriesBailHook', (source, callback) => {
    setTimeout(() => {
        console.log(`source2: ${source}`);
        callback();
    }, 1000);
});

asyncSeriesBailHook.callAsync('关注公众号素燕', ret => {
    console.log(`ret = ${ret}`);
});

最终编译的代码如下,与 AsyncSeriesHook 很相似,只是在 callback 中多了一个 result 参数:

"use strict";
var _context;
var _x = this._x;
console.log('this._x = : ', this._x);
function _next0() {
    var _fn1 = _x[1];
    _fn1(source, (_err1, _result1) => {
        if (_err1) {
            _callback(_err1);
        } else {
            if (_result1 !== undefined) {
                _callback(null, _result1);
                ;
            } else {
                _callback();
            }
        }
    });
}
var _fn0 = _x[0];
_fn0(source, (_err0, _result0) => {
    if (_err0) {
        _callback(_err0);
    } else {
        if (_result0 !== undefined) {
            _callback(null, _result0);
            ;
        } else {
            _next0();
        }
    }
});

3.7、AsyncSeriesWaterfallHook

这个和 SyncWaterfallHook 类似,上一个回调结果会传递到下一个监听函数中。

3.8、AsyncSeriesLoopHook

异步循环 Hook,只要回调函数不返回 undefined,循环将一直持续执行,知道回调函数的值为 undefined。

3.9、AsyncParalleHook

异步并行 hook,也就是说所有的回调函数同时进行。

3.10、AsyncParalleBailHook

异步并行熔断钩子。

写在最后

tapable 对于初学者来说并不友好,学习起来有一定的学习成本。总的来说,tapable 主要分成同步 hook 和异步 hook,每类 hook 下又分为了 bail(当函数有任何返回值时,接下来的 hook 回调函数将终止)、waterfall(瀑布流,函数返回值将向下一个回调函数传递)、loop(循环,只要函数返回值不为 undefined,将一直循环)。如果想彻底理解各种类型的 hook,可以通过分析最终生成的函数代码来理解,理解起来非常容易。

这节内容属于理论知识,下一节内容我们结合 compiler 来加深对 tapable 的理解。

webpack 系列文章