写点什么

Webpack 基石 tapable 揭秘

发布于: 2021 年 03 月 10 日

Webpack 基于 tapable 构建了其复杂庞大的流程管理系统,基于 tapable 的架构不仅解耦了流程节点和流程的具体实现,还保证了 Webpack 强大的扩展能力;学习掌握 tapable,有助于我们深入理解 Webpack。

一、tapable 是什么?


The tapable package expose many Hook classes,which can be used to create hooks for plugins.


tapable 提供了一些用于创建插件的钩子类。


个人觉得 tapable 是一个基于事件的流程管理工具。

二、tapable 架构原理和执行过程

tapable 于 2020.9.18 发布了 v2.0 版本。此文章内容也是基于 v2.0 版本。

2.1 代码架构

tapable 有两个基类:Hook 和 HookCodeFactory。Hook 类定义了 Hook interface(Hook 接口), HookCodeFactoruy 类的作用是动态生成一个流程控制函数。生成函数的方式是通过我们熟悉的 New Function(arg,functionBody)。

2.2 执行流程

tapable 会动态生成一个可执行函数来控制钩子函数的执行。我们以 SyncHook 的使用来举一个例子,比如我们有这样的一段代码:

// SyncHook使用import { SyncHook } from '../lib';const syncHook = new SyncHook();syncHook.tap('x', () => console.log('x done'));syncHook.tap('y', () => console.log('y done'));
复制代码

上面的代码只是注册好了钩子函数,要让函数被执行,还需要触发事件(执行调用)

syncHook.call();
复制代码

syncHook.call()在调用时会生成这样的一个动态函数:

function anonymous() {    "use strict";    var _context;    var _x = this._x;    var _fn0 = _x[0];    _fn0();    var _fn1 = _x[1];    _fn1();}
复制代码

这个函数的代码非常简单:就是从一个数组中取出函数,依次执行。注意:不同的调用方式,最终生成的的动态函数是不同的。如果把调用代码改成:

syncHook.callAsync( () => {console.log('all done')} )
复制代码

那么最终生成的动态函数是这样的:

function anonymous(_callback) {    "use strict";    var _context;    var _x = this._x;    var _fn0 = _x[0];    var _hasError0 = false;    try {        _fn0();    } catch(_err) {        _hasError0 = true;        _callback(_err);    }    if(!_hasError0) {        var _fn1 = _x[1];        var _hasError1 = false;        try {            _fn1();        } catch(_err) {            _hasError1 = true;            _callback(_err);        }        if(!_hasError1) {            _callback();        }    }}
复制代码

这个动态函数相对于前面的动态函数要复杂一些,但仔细一看,执行逻辑也非常简单:同样是从数组中取出函数,依次执行;只不过这次多了 2 个逻辑:


  • 错误处理

  • 在数组中的函数执行完后,执行了回调函数


通过研究最终生成的动态函数,我们不难发现:动态函数的模板特性非常突出。前面的例子中,我们只注册了 x,y2 个钩子,这个模板保证了当我们注册任意个钩子时,动态函数也能方便地生成出来,具有非常强的扩展能力。


那么这些动态函数是如何生成的呢?其实 Hook 的生成流程是一样的。hook.tap 只是完成参数准备,真正的动态函数生成是在调用后(水龙头打开后)。完整流程如下:



三、Hook 类型详解

在 tapablev2 中,一共提供了 12 种类型的 Hook,接下来,通过梳理 Hook 怎么执行和 Hook 完成回调何时执行 2 方面来理解 tapable 提供的这些 Hook 类。

3.1 SyncHook

钩子函数按次序依次全部执行;如果有 Hook 回调,则 Hook 回调在最后执行。

const syncHook = new SyncHook();syncHook.tap('x', () => console.log('x done'));syncHook.tap('y', () => console.log('y done'));syncHook.callAsync(() => { console.log('all done') }); /*输出:x doney doneall done*/
复制代码

3.2 SyncBailHook

钩子函数按次序执行。如果某一步钩子返回了非 undefined,则后面的钩子不再执行;如果有 Hook 回调,直接执行 Hook 回调。

const hook = new SyncBailHook(); hook.tap('x', () => {  console.log('x done');  return false; // 返回了非undefined,y不会执行});hook.tap('y', () => console.log('y done'));hook.callAsync(() => { console.log('all done') }); /*输出:x doneall done*/
复制代码

3.3 SyncWaterfallHook

钩子函数按次序全部执行。后一个钩子的参数是前一个钩子的返回值。最后执行 Hook 回调。

const hook = new SyncWaterfallHook(['count']); hook.tap('x', (count) => {    let result = count + 1;    console.log('x done', result);    return result;});hook.tap('y', (count) => {    let result = count * 2;    console.log('y done', result);    return result;});hook.tap('z', (count) => {    console.log('z done & show result', count);});hook.callAsync(5, () => { console.log('all done') }); /*输出:x done 6y done 12z done & show result 12all done*/
复制代码

3.4 SyncLoopHook

钩子函数按次序全部执行。每一步的钩子都会循环执行,直到返回值为 undefined,再开始执行下一个钩子。Hook 回调最后执行。

const hook = new SyncLoopHook(); let flag = 0;let flag1 = 5; hook.tap('x', () => {    flag = flag + 1;     if (flag >= 5) { // 执行5次,再执行 y        console.log('x done');        return undefined;    } else {        console.log('x loop');        return true;    }});hook.tap('y', () => {    flag1 = flag1 * 2;     if (flag1 >= 20) { // 执行2次,再执行 z        console.log('y done');        return undefined;    } else {        console.log('y loop');        return true;    }});hook.tap('z', () => {    console.log('z done'); // z直接返回了undefined,所以只执行1次    return undefined;}); hook.callAsync(() => { console.log('all done') }); /*输出:x loopx loopx loopx loopx doney loopx doney donez doneall done */
复制代码

3.5  AsyncParallelHook

钩子函数异步并行全部执行。所有钩子的回调返回后,Hook 回调才执行。

const hook = new AsyncParallelHook(['arg1']);const start = Date.now(); hook.tapAsync('x', (arg1, callback) => {    console.log('x done', arg1);     setTimeout(() => {        callback();    }, 1000)});hook.tapAsync('y', (arg1, callback) => {    console.log('y done', arg1);     setTimeout(() => {        callback();    }, 2000)});hook.tapAsync('z', (arg1, callback) => {    console.log('z done', arg1);     setTimeout(() => {        callback();    }, 3000)}); hook.callAsync(1, () => {    console.log(`all done。 耗时:${Date.now() - start}`);}); /*输出:x done 1y done 1z done 1all done。 耗时:3006*/
复制代码

3.6 AsyncSeriesHook

钩子函数异步串行全部执行,会保证钩子执行顺序,上一个钩子结束后,下一个才会开始。Hook 回调最后执行。

const hook = new AsyncSeriesHook(['arg1']);const start = Date.now(); hook.tapAsync('x', (arg1, callback) => {    console.log('x done', ++arg1);     setTimeout(() => {        callback();    }, 1000)});hook.tapAsync('y', (arg1, callback) => {    console.log('y done', arg1);     setTimeout(() => {        callback();    }, 2000)}); hook.tapAsync('z', (arg1, callback) => {    console.log('z done', arg1);     setTimeout(() => {        callback();    }, 3000)}); hook.callAsync(1, () => {    console.log(`all done。 耗时:${Date.now() - start}`);}); /*输出:x done 2y done 1z done 1all done。 耗时:6008*/
复制代码

3.7 AsyncParallelBailHook

钩子异步并行执行,即钩子都会执行,但只要有一个钩子返回了非 undefined,Hook 回调会直接执行。

const hook = new AsyncParallelBailHook(['arg1']);const start = Date.now(); hook.tapAsync('x', (arg1, callback) => {    console.log('x done', arg1);     setTimeout(() => {        callback();    }, 1000)});hook.tapAsync('y', (arg1, callback) => {    console.log('y done', arg1);     setTimeout(() => {        callback(true);    }, 2000)}); hook.tapAsync('z', (arg1, callback) => {    console.log('z done', arg1);     setTimeout(() => {        callback();    }, 3000)}); hook.callAsync(1, () => {    console.log(`all done。 耗时:${Date.now() - start}`);});/*输出:x done 1y done 1z done 1all done。 耗时:2006 */
复制代码

3.8 AsyncSeriesBailHook

钩子函数异步串行执行。但只要有一个钩子返回了非 undefined,Hook 回调就执行,也就是说有的钩子可能不会执行。

const hook = new AsyncSeriesBailHook(['arg1']);const start = Date.now(); hook.tapAsync('x', (arg1, callback) => {    console.log('x done', ++arg1);     setTimeout(() => {        callback(true); // y 不会执行    }, 1000);});hook.tapAsync('y', (arg1, callback) => {    console.log('y done', arg1);     setTimeout(() => {        callback();    }, 2000);}); hook.callAsync(1, () => {    console.log(`all done。 耗时:${Date.now() - start}`);}); /*输出:x done 2all done。 耗时:1006 */
复制代码

3.9 AsyncSeriesWaterfallHook

钩子函数异步串行全部执行,上一个钩子返回的参数会传给下一个钩子。Hook 回调会在所有钩子回调返回后才执行。

const hook = new AsyncSeriesWaterfallHook(['arg']);const start = Date.now(); hook.tapAsync('x', (arg, callback) => {    console.log('x done', arg);     setTimeout(() => {        callback(null, arg + 1);    }, 1000)},); hook.tapAsync('y', (arg, callback) => {    console.log('y done', arg);     setTimeout(() => {        callback(null, true); // 不会阻止 z 的执行    }, 2000)}); hook.tapAsync('z', (arg, callback) => {    console.log('z done', arg);    callback();}); hook.callAsync(1, (x, arg) => {    console.log(`all done, arg: ${arg}。 耗时:${Date.now() - start}`);}); /*输出:x done 1y done 2z done trueall done, arg: true。 耗时:3010 */
复制代码

3.10 AsyncSeriesLoopHook

钩子函数异步串行全部执行,某一步钩子函数会循环执行到返回非 undefined,才会开始下一个钩子。Hook 回调会在所有钩子回调完成后执行。

const hook = new AsyncSeriesLoopHook(['arg']);const start = Date.now();let counter = 0; hook.tapAsync('x', (arg, callback) => {    console.log('x done', arg);    counter++;     setTimeout(() => {        if (counter >= 5) {            callback(null, undefined); // 开始执行 y        } else {            callback(null, ++arg); // callback(err, result)        }    }, 1000)},); hook.tapAsync('y', (arg, callback) => {    console.log('y done', arg);     setTimeout(() => {        callback(null, undefined);    }, 2000)}); hook.tapAsync('z', (arg, callback) => {    console.log('z done', arg);    callback(null, undefined);}); hook.callAsync('AsyncSeriesLoopHook', (x, arg) => {    console.log(`all done, arg: ${arg}。 耗时:${Date.now() - start}`);}); /*x done AsyncSeriesLoopHookx done AsyncSeriesLoopHookx done AsyncSeriesLoopHookx done AsyncSeriesLoopHookx done AsyncSeriesLoopHooky done AsyncSeriesLoopHookz done AsyncSeriesLoopHookall done, arg: undefined。 耗时:7014*/
复制代码

3.11 HookMap

主要作用是 Hook 分组,方便 Hook 组批量调用。

const hookMap = new HookMap(() => new SyncHook(['x'])); hookMap.for('key1').tap('p1', function() {    console.log('key1-1:', ...arguments);});hookMap.for('key1').tap('p2', function() {    console.log('key1-2:', ...arguments);});hookMap.for('key2').tap('p3', function() {    console.log('key2', ...arguments);}); const hook = hookMap.get('key1'); if( hook !== undefined ) {    hook.call('hello', function() {        console.log('', ...arguments)    });} /*输出:key1-1: hellokey1-2: hello*/
复制代码

3.12 MultiHook

MultiHook 主要用于向 Hook 批量注册钩子函数。

const syncHook = new SyncHook(['x']);const syncLoopHook = new SyncLoopHook(['y']);const mutiHook = new MultiHook([syncHook, syncLoopHook]); // 向多个hook注册同一个函数mutiHook.tap('plugin', (arg) => {    console.log('common plugin', arg);}); // 执行函数for (const hook of mutiHook.hooks) {    hook.callAsync('hello', () => {        console.log('hook all done');    });}
复制代码


以上 Hook 又可以抽象为以下几类:

  • xxxBailHook:根据前一步钩子函数的返回值是否是 undefined 来决定要不要执行下一步钩子:如果某一步返回了非 undefined,则后面的钩子不在执行。

  • xxxWaterfallHook:上一步钩子函数返回值就是下一步函数的参数。

  • xxxLoopHook:钩子函数循环执行,直到返回值为 undefined。


注意钩子函数返回值判断是和 undefined 对比,而不是和假值对比(null, false)


Hook 也可以按同步、异步划分:

  • syncXXX:同步钩子

  • asyncXXX:异步钩子


Hook 实例默认都有都有 tap, tapAsync, tapPromise 三个注册钩子回调的方法,不同注册方法生成的动态函数是不一样的。当然也并不是所有 Hook 都支持这几个方法,比如 SyncHook 不支持 tapAsync, tapPromise。


Hook 默认有 call, callAsync,promise 来执行回调。但并不是所有 Hook 都会有这几个方法,比如 SyncHook 不支持 callAsync 和 promise。

四、实践应用

4.1 基于 tapable 实现类 jQuery.ajax()封装


我们先复习下 jQuery.ajax()的常规用法(大概用法是这样,咱不纠结每个参数都正确):

jQuery.ajax({    url: 'api/request/url',    beforeSend: function(config) {        return config; // 返回false会取消此次请求发送    },    success: function(data) {        // 成功逻辑    }    error: function(err) {        // 失败逻辑    },    complete: function() {        // 成功,失败都会执行的逻辑    }});
复制代码


jQuery.ajax 整个流程做了这么几件事:

  • 在请求真正发送前,beforeSend 提供了请求配置预处理的钩子。如果预处理函数返回 false,能取消此次请求的发送。

  • 请求成功(服务端数据返回后)执行 success 函数逻辑。

  • 如果请求失败,则执行 error 函数逻辑。

  • 最终,统一执行 complete 函数逻辑,无论请求成功还是失败。



同时,我们借鉴 axios 的做法,将 beforeSend 改为 transformRequest,加入 transformResponse,再加上统一的请求 loading 和默认的错误处理,这时我们整个 ajax 流程如下:



4.2 简单版的实现

const { SyncHook, AsyncSeriesWaterfallHook } = require('tapable'); class Service {    constructor() {        this.hooks = {            loading:  new SyncHook(['show']),            transformRequest: new AsyncSeriesWaterfallHook(['config', 'transformFunction']),            request: new SyncHook(['config']),            transformResponse: new AsyncSeriesWaterfallHook(['config', 'response', 'transformFunction']),            success: new SyncHook(['data']),            fail: new SyncHook(['config', 'error']),            finally: new SyncHook(['config', 'xhr'])        };         this.init();    }    init() {        // 解耦后的任务逻辑        this.hooks.loading.tap('LoadingToggle', (show) => {            if (show) {                console.log('展示ajax-loading');            } else {                console.log('关闭ajax-loading');            }        });         this.hooks.transformRequest.tapAsync('DoTransformRequest', (            config,            transformFunction= (d) => {                d.__transformRequest = true;                return d;            },            cb        ) => {            console.log(`transformRequest拦截器:Origin:${JSON.stringify(config)};`);            config = transformFunction(config);            console.log(`transformRequest拦截器:after:${JSON.stringify(config)};`);            cb(null, config);        });         this.hooks.transformResponse.tapAsync('DoTransformResponse', (            config,            data,            transformFunction= (d) => {                d.__transformResponse = true;                return d;            },            cb        ) => {            console.log(`transformResponse拦截器:Origin:${JSON.stringify(config)};`);            data = transformFunction(data);            console.log(`transformResponse拦截器:After:${JSON.stringify(data)}`);            cb(null, data);        });         this.hooks.request.tap('DoRequest', (config) => {            console.log(`发送请求配置:${JSON.stringify(config)}`);             // 模拟数据返回            const sucData = {                code: 0,                data: {                    list: ['X50 Pro', 'IQOO Neo'],                    user: 'jack'                },                message: '请求成功'            };             const errData = {                code: 100030,                message: '未登录,请重新登录'            };             if (Date.now() % 2 === 0) {                this.hooks.transformResponse.callAsync(config, sucData, undefined, () => {                    this.hooks.success.callAsync(sucData, () => {                        this.hooks.finally.call(config, sucData);                    });                });            } else {                this.hooks.fail.callAsync(config, errData, () => {                    this.hooks.finally.call(config, errData);                });            }        });    }    start(config) {        this.config = config;         /*            通过Hook调用定制串联流程            1. 先 transformRequest            2. 处理 loading            3. 发起 request         */        this.hooks.transformRequest.callAsync(this.config, undefined, () => {            this.hooks.loading.callAsync(this.config.loading, () => {            });             this.hooks.request.call(this.config);        });    }} const s = new Service(); s.hooks.success.tap('RenderList', (res) => {    const { data } = res;    console.log(`列表数据:${JSON.stringify(data.list)}`);}); s.hooks.success.tap('UpdateUserInfo', (res) => {    const { data } = res;    console.log(`用户信息:${JSON.stringify(data.user)}`);}); s.hooks.fail.tap('HandlerError', (config, error) => {    console.log(`请求失败了,config=${JSON.stringify(config)},error=${JSON.stringify(error)}`);}); s.hooks.finally.tap('DoFinally', (config, data) => {    console.log(`DoFinally,config=${JSON.stringify(config)},data=${JSON.stringify(data)}`);}); s.start({    base: '/cgi/cms/',    loading: true}); /*成功返回输出:transformRequest拦截器:Origin:{"base":"/cgi/cms/","loading":true};transformRequest拦截器:after:{"base":"/cgi/cms/","loading":true,"__transformRequest":true};展示ajax-loading发送请求配置:{"base":"/cgi/cms/","loading":true,"__transformRequest":true}transformResponse拦截器:Origin:{"base":"/cgi/cms/","loading":true,"__transformRequest":true};transformResponse拦截器:After:{"code":0,"data":{"list":["X50 Pro","IQOO Neo"],"user":"jack"},"message":"请求成功","__transformResponse":true}列表数据:["X50 Pro","IQOO Neo"]用户信息:"jack"DoFinally,config={"base":"/cgi/cms/","loading":true,"__transformRequest":true},data={"code":0,"data":{"list":["X50 Pro","IQOO Neo"],"user":"jack"},"message":"请求成功","__transformResponse":true}*/
复制代码


上面的代码,我们可以继续优化:把每个流程点都抽象成一个独立插件,最后再串联起来。如处理 loading 展示的独立成 LoadingPlugin.js,返回预处理 transformResponse 独立成 TransformResponsePlugin.js,这样我们可能得到这么一个结构:



这个结构就和大名鼎鼎的 Webpack 组织插件的形式基本一致了。接下来我们看看 tapable 在 Webpack 中的应用,看一看为什么 tapable 能够称为 Webpack 基石。

4.3 tapable 在 Webpack 中的应用


  • Webpack 中,一切皆插件(Hook)。

  • Webpack 通过 tapable 将这些插件串起来,组成固定流程。

  • tapable 解耦了流程任务和具体实现,同时提供了强大的扩展能力:拿到 Hook,就能插入自己的逻辑。(我们平时写 Webpack 插件,就是找到对应的 Hook 去,然后注册我们自己的钩子函数。这样就方便地把我们自定义逻辑,插入到了 Webpack 任务流程中了)。


如果你需要强大的流程管理能力,可以考虑基于 tapable 去做架构设计。

五、小结


  • tapable 是一个流程管理工具。

  • 提供了 10 种类型 Hook,可以很方便地让我们去实现复杂的业务流程。

  • tapable 核心原理是基于配置,通过 new Function 方式,实时动态生成函数表达式去执行,从而完成逻辑

  • tapable 通过串联流程节点来实现流程控制,保证了流程的准确有序。

  • 每个流程节点可以任意注册钩子函数,从而提供了强大的扩展能力。

  • tapable 是 Webpack 基石,它支撑了 Webpack 庞大的插件系统,又保证了这些插件的有序运行。

  • 如果你也正在做一个复杂的流程系统(任务系统),可以考虑用 tapable 来管理你的流程。


作者:vivo-Ou Fujun


发布于: 2021 年 03 月 10 日阅读数: 14
用户头像

官方公众号:vivo互联网技术,ID:vivoVMIC 2020.07.10 加入

分享 vivo 互联网技术干货与沙龙活动,推荐最新行业动态与热门会议。

评论

发布
暂无评论
Webpack 基石 tapable 揭秘