Webpack的基础设施:Tapable
2022-4-12
| 2023-2-19
0  |  0 分钟
password
Created
Feb 19, 2023 03:56 PM
type
Post
status
Published
date
Apr 12, 2022
slug
summary
Webpack的基础设施:Tapable
tags
Typescript
Tapable
category
工程化
icon

Tapable

本质: 支持多种Hook类型, 可进行拦截扩展的发布订阅库
是webpack底层所依赖的核心库之一, 通过Webpack通过Tapable的hooks机制实现了其可扩展的插件能力, 并内置了多个hooks供书写插件时调用.

一. Tapable的优势

1. 丰富的Hook订阅

基本Hook

  1. BasicHook: 基本的Hook, 按照事件注册顺序来进行逐一执行
  1. BailHook: 按照事件注册顺序依次执行, 但如果上一个返回值不为undefined时, 剩余的注册事件回调不会执行
  1. WaterfallHook: 按照事件注册顺序依次执行, 前一个回调的返回值会作为后一个回调的入参
  1. LoopHook: 按照事件注册顺序依次执行, 如果任意回调返回非undefined, 则从头开始重新执行

Hook类型

Hook中又分为同步Hook和异步Hook, 异步Hook又可分为串行(Series)和并行(Paralle)两种
Hook类型与基本Hook的关系可以参照如下的图:
notion image

2. 高效的订阅性能

  1. 使用懒加载来提升call的调用性能
 const CALL_DELEGATE = function(...args) {      // 在第一次执行call时,会依据钩子类型和回调数组生成真实执行的函数fn。并重新赋值给this.call      // 在第二次执行call时,直接运行fn,不再重复调用_createCall      this.call = this._createCall("sync");      return this.call(...args);  };
我们使用call来调用注册的hook时, 首次执行会调用CALL_DELEGATE, 首次调用之后就直接运行fn了, 不会再有动态创建执行函数的消耗
  1. 基于单态, 使用new Function的方式来动态创建执行函数
在执行函数中使用字符串来动态拼接需要执行的函数, 通过这样的方式来提升函数的动态执行性能.
关于new Function的这个操作, 官方的解释是使用了名为monomorphism(单态)的优化方式, 这是一个相对底层的优化方式, 原文可以参照whats-up-with-monomorphism
原文相对来讲比较晦涩难懂, 对应的可以看这篇中文阐述.
大体总结来说是这样的:
  1. 浏览器底层引擎会对对象创建一个"虚拟类", 对于相同key的对象, 使用相同的虚拟类, 一次来达到节省空间, 提升性能的目的
  1. 但是这里存在的问题是, 如果一个虚拟类对应的真正的对象有增加key怎么办呢? 这个时候浏览器会在原有的虚拟类的基础上再额外扩展一个虚拟类, 继承自原类
  1. 对于频繁多加key的情况, 这时候就会产生问题了, 虚拟类反倒成为了负担, 变成了一种"负优化"
  1. 对于tapable来说, 因为需要动态执行callback并传参, 属于"在同一函数中调用不同函数"的情况, 也就无法使用到monomorphizm带来的好处, 反倒会使其变成polymorpic甚至megamorphic, 变成了一种负优化.
  1. 所以tapable使用了new Function的方式来对调用进行了"预生成", 也就是说每个函数都会被独一无二的命名, 以规避megamorphic这样的反优化.

3. 可扩展性

可以对Hook进行拦截器Interception注入, 以支持扩展能力
 const hook = new SyncHook(['arg1', 'arg2', 'arg3']);    hook.intercept({    // 每次调用 hook 实例的 tap() 方法注册回调函数时, 都会调用该方法,    // 并且接受 tap 作为参数, 还可以对 tap 进行修改;    register: (tapInfo) => {      console.log(`${tapInfo.name} is doing its job`);      return tapInfo; // may return a new tapInfo object   },    // 通过hook实例对象上的call方法时候触发拦截器    call: (arg1, arg2, arg3) => {      console.log('Starting to calculate routes');   },    // 在调用被注册的每一个事件函数之前执行    tap: (tap) => {      console.log(tap, 'tap');   },    // loop类型钩子中 每个事件函数被调用前触发该拦截器方法    loop: (...args) => {      console.log(args, 'loop');   },  });  
支持对Hook的tap顺序进行手动代码定义, 这样就不用受束缚于注册的顺序了
 const { SyncHook } = require('tapable');  const hooks = new SyncHook();    hooks.tap(   {      name: 'flag1',   },   () => {      console.log('This is flag1 function.');   }  );  hooks.tap(   {      name: 'flag2',      // flag2 事件函数会在flag1之前进行执行      before: 'flag1',   },   () => {      console.log('This is flag2 function.');   }  );  hooks.call();    // result  This is flag2 function.  This is flag1 function.
也可以通过stage来指定其权重, 权重越大执行越晚(默认stage为0)
 const { SyncHook } = require('tapable');    const hooks = new SyncHook();    hooks.tap(   {      name: 'flag1',      stage: 1,   },   () => {      console.log('This is flag1 function.');   }  );    hooks.tap(   {      name: 'flag2',      // 默认为stage: 0,   },   () => {      console.log('This is flag2 function.');   }  );    hooks.call();    // result  This is flag2 function.  This is flag1 function.  
需要注意的是, 如果同时使用了before和stage, 会优先处理before
一般情况下, 这两个属性不建议混用

二. Tapable的实现机制

1. Hook基类

Hook基类是所有种类Hook所统一继承的类.
Hook的核心是实现了tap, call, compile等核心的方法

2. tap方法

tap用于增加一个订阅回调, 本质上是经过一系列处理之后, 将增加的订阅回调处理成标准的数据格式, 存储到taps的数组中, 供之后call的时候进行调用
  class Hook{      constructor(args = [], name = undefined){          this.taps = []     }      tap(options, fn) {          this._tap("sync", options, fn);     }      _tap(type, options, fn) {          // 省略入参预处理部分代码          this._insert(options);     }      _insert(item) {        // 省略一堆before的判断, 和stage的权重判断, 处理tap的先后顺序        // taps暂存所有注册的回调函数        this.taps[i] = item;     }  }  

3. call方法

call方法是Tapable中的核心发布订阅触发函数, 调用call之后会开始执行之前注册的tap函数的回调
这里CALL_DELEGATE方法实现了一种懒加载机制, 只有第一次执行call时, 会动态生成执行函数, 其他时候会直接调用此生成的执行函数
需要注意的是, 这里的compile并没有实现, 因为不同的hook的compile的实现机制是不一样的, compile则都是用了new Function, 结合字符串拼接的方式来组合出最终我们执行的函数
 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         });     }  }

4. HookCodeFactory方法

在HookCodeFactory中进行了function的创建, 同时在外层override了compile方法
我们以最简单的SyncHook为例, 其compile函数其实就是调用了HookCodeFactory的子类所实例化出来的factory的setup和create函数
 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;  }
factory的setup的实现很简单, 其实就是将taps中的订阅回调注册到当前工厂示例的_x参数中
  setup(instance, options) {   instance._x = options.taps.map(t => t.fn);   }
而create则是通过new Function创建动态执行函数的核心方法, create使用了switch的方式来对不同类型的Hook进行了动态创建, 我们看最简单的SyncHook是怎么实现的
fn = new Function( this.args(), '"use strict";\n' + this.header() + this.contentWithInterceptors({ onError: err => `throw ${err};\n`, onResult: result => `return ${result};\n`, resultReturns: true, onDone: () => "", rethrowIfPossible: true }) );
在create方法中, 通过this.args函数来获取到了所有的args进行了组合
其实逻辑很简单, 就是将所有的参数, join了一个逗号加空格
args({ before, after } = {}) { let allArgs = this._args; if (before) allArgs = [before].concat(allArgs); if (after) allArgs = allArgs.concat(after); if (allArgs.length === 0) { return ""; } else { return allArgs.join(", "); } }
this.header则是将setup步骤中装载的_x, 也就是this.taps引入进来了
header() { let code = ""; if (this.needContext()) { code += "var _context = {};\n"; } else { code += "var _context;\n"; } code += "var _x = this._x;\n"; if (this.options.interceptors.length > 0) { code += "var _taps = this.taps;\n"; code += "var _interceptors = this.interceptors;\n"; } return code; }
contentWithInterceptors则对剩余内容进行了装载, 处理了拦截器的相关逻辑, 增加了相应的事件处理函数onError onResultonDone, 并使用this.content来进行内容装载
this.content被定义在HookCodeFactory作为基类的子类中, 例如SyncHook, 则是定义在SyncHookCodeFactory
class SyncHookCodeFactory extends HookCodeFactory { content({ onError, onDone, rethrowIfPossible }) { return this.callTapsSeries({ onError: (i, err) => onError(err), onDone, rethrowIfPossible }); } }
this.callTapsSeries则又是调用了基类HookCodeFactory中的对应方法😓
this.callTapsSeries使用this.callTap传入数组下标和事件回调来进行内容拼装
下面是我简化了的callTap的代码, 同样是以SyncHook为例
callTap(tapIndex, { onError, onResult, onDone, rethrowIfPossible }) { let code = ""; let hasTapCached = false; code += `var _fn${tapIndex} = ${this.getTapFn(tapIndex)};\n`; const tap = this.options.taps[tapIndex]; switch (tap.type) { case "sync": if (onResult) { // onResult: result => `return ${result};\n`, code += `var _result${tapIndex} = _fn${tapIndex}(${this.args({ before: tap.context ? "_context" : undefined })});\n`; } else { code += `_fn${tapIndex}(${this.args({ before: tap.context ? "_context" : undefined })});\n`; } if (onResult) { code += onResult(`_result${tapIndex}`); } if (onDone) { // onDone: () => "", code += onDone(); } break; // ... }
自此, 动态的函数就完成了, 且此时已经将动态函数复制给了当前Hook的call方法, 之后调用call就是执行这个动态的函数

5. 最后总结一下

当我们创建一个hook, 到调用的时候, 整个的流程是这样的
const { SyncHook } = require('tapable') // 创建了一个SyncHook, 这个时候会通过Hook基类来初始化tap call等方法 const hook = new SyncHook(['arg1', 'arg2']) // 通过tap将对应的一个订阅进行标准化后, 推入了taps数组中 hook.tap('flag1', () => { console.log(1) }) // 通过tap将对应的一个订阅进行标准化后, 推入了taps数组中 hook.tap('flag2', () => { console.log(2) }) // 调用hook, 第一次会先通过new Function创建一个函数, 并动态在此函数中调用taps中的各个回调 // 之后再使用call, 就是调用此函数了, 至此就完成了整个发布与订阅的功能 hook.call('arg1', 'arg2')
以上面的例子, 我们最终call调用的函数其实是这样的:
function fn(arg1, arg2) { "use strict"; var _context; var _x = this._x; var _fn0 = _x[0]; _fn0(arg1, arg2); var _fn1 = _x[1]; _fn1(arg1, arg2); }

三. 参考文献

工程化
  • Typescript
  • Tapable
  • 包管理工具的提速与演进Vite:起源与实现机制
    目录