likes
comments
collection
share

【JavaScript设计模式】增强版发布订阅模式——Webpack的核心Tapable(一)

作者站长头像
站长
· 阅读数 10

Tapable简介

Webpack整体架构的实现就是靠它的插件系统,其中Compiler和Compilation负责管理整个构建流程,同时暴露出一些Hook,然后由不同职责的插件来监听这些Hook,并在合适的时机完成具体的工作。Tapable是整个Webpack插件系统的核心,Webpack中的所有插件都继承了Tapable类(Webpack5中已不再继承)。而从Tapable的一些特性中可以看出,Tapable其实是一种增强版的发布订阅模式。

先看看Tapable提供了哪些Hook

序号Hook类型Hook名称监听方法是否可并行
1SyncHook同步钩子tap
2SyncBailHook同步熔断钩子tap
3SyncWaterfallHook同步瀑布钩子tap
4SyncLoopHook同步循环钩子tap
5AsyncParallelHook异步并行钩子tap|tapAsync|tapPromise
6AsyncParallelBailHook异步并行熔断钩子tap|tapAsync|tapPromise
7AsyncSeriesHook异步串行钩子tap|tapAsync|tapPromise
8AsyncSeriesBailHook异步串行熔断钩子tap|tapAsync|tapPromise
9AsyncSeriesLoopHook异步串行循环钩子tap|tapAsync|tapPromise
10AsyncSeriesWaterfallHook异步串行瀑布钩子tap|tapAsync|tapPromise

Sync:同步。 Async:异步。 Bail:当一个hook注册了多个回调方法,若任意一个回调方法返回了不为undefined的值,就不再执行后面的回调方法。 Waterfall:当一个hook注册了多个回调方法,前一个回调执行完了才会执行下一个回调,而前一个回调的执行结果会作为参数传给下一个回调函数。 Loop:当一个hook注册了回调方法,如果这个回调方法返回了true就重复循环这个回调,只有当这个回调返回undefined才执行下一个回调。 Parallel:当一个hook注册了多个回调方法,这些回调同时开始并行执行。 Series:当一个hook注册了多个回调方法,前一个执行完了才会执行下一个。

下载Tapable(v2.2.1)的源码,打开/index.js:

exports.SyncHook = require("./SyncHook");
exports.SyncBailHook = require("./SyncBailHook");
exports.SyncWaterfallHook = require("./SyncWaterfallHook");
exports.SyncLoopHook = require("./SyncLoopHook");
exports.AsyncParallelHook = require("./AsyncParallelHook");
exports.AsyncParallelBailHook = require("./AsyncParallelBailHook");
exports.AsyncSeriesHook = require("./AsyncSeriesHook");
exports.AsyncSeriesBailHook = require("./AsyncSeriesBailHook");
exports.AsyncSeriesLoopHook = require("./AsyncSeriesLoopHook");
exports.AsyncSeriesWaterfallHook = require("./AsyncSeriesWaterfallHook");
exports.HookMap = require("./HookMap");
exports.MultiHook = require("./MultiHook");

可以看到,Tapable导出了10个钩子函数和2个工具类。其中每一个…Hook.js文件都对应一个钩子函数的实现。而HookMap.js和MultiHook.js分别定义了Tapable的两个工具类:HookMap和MultiHook。余下两个文件:Hook.js和HookCodeFactory.js分别定义了Tapable的核心类:HookHookCodeFactoryTapable中的所有钩子函数都继承自这两个类

什么是发布订阅模式?

软件架构中,发布订阅是一种消息范式,消息的发送者(称为发布者)不会将消息直接发送给特定的接收者(称为订阅者)。而是将发布的消息分为不同的类别,无需了解哪些订阅者(如果有的话)可能存在。同样的,订阅者可以表达对一个或多个类别的兴趣,只接收感兴趣的消息,无需了解哪些发布者(如果有的话)存在。

SyncHook应用示例

SyncHookTapable中最基础的一个钩子,也最接近发布订阅模式的一个钩子。其余的钩子除了实现发布订阅外,还包含了不同类型的流程控制。

我们即将通过一个SyncHook钩子的简单应用示例来了解SyncHook的内部实现,先来看一个简单的应用示例:

应用示例

const { SyncHook } = require('tapable');
// 实例化
const hook = new SyncHook(['width', 'height']);
const options = { name: "synchook" };
// 订阅
hook.tap(options, (width, height) => {
  console.log('callback1', width, height);
});
// 订阅
hook.tap(options, (width, height) => {
  console.log('callback2', width, height);
});
// 订阅
hook.tap(options, (width, height) => {
  console.log('callback3', width, height);
});
// 执行
hook.call(100, 200);
console.log(hook.call)

输出结果:

callback1 100 200
callback2 100 200
callback3 100 200

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

SyncHook源码

一、分析hook.tap()

以上的订阅过程发生了什么?我们来看/SyncHook.js文件的源码:

const Hook = require("./Hook");
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 TAP_ASYNC = () => {
  throw new Error("tapAsync is not supported on a SyncHook");
};

const TAP_PROMISE = () => {
  throw new Error("tapPromise is not supported on a SyncHook");
};

const COMPILE = function (options) {
  factory.setup(this, options);
  return factory.create(options);
};

function SyncHook(args = [], name = undefined) {
  const hook = new Hook(args, name);
  hook.constructor = SyncHook;
  hook.tapAsync = TAP_ASYNC;
  hook.tapPromise = TAP_PROMISE;
  hook.compile = COMPILE;
  return hook;
}

SyncHook.prototype = null;

module.exports = SyncHook;

可以看到,SyncHook函数的tap方法继承自Hook类,所以打开/hook.js,找到Hooktap方法:

tap()

tap(options, fn) {
  this._tap("sync", options, fn);
}

Hook中的tap方法调用_tap方法并传入三个参数,其中"sync"表示当前订阅的函数为同步。接下来看_tap的内容:

_tap()

_tap(type, options, fn) {
  if (typeof options === "string") {
    options = {
      name: options.trim()
    };
  } else if (typeof options !== "object" || options === null) {
    throw new Error("Invalid tap options");
  }
  if (typeof options.name !== "string" || options.name === "") {
    throw new Error("Missing name for tap");
  }
  if (typeof options.context !== "undefined") {
    deprecateContext();
  }
  options = Object.assign({ type, fn }, options);
  options = this._runRegisterInterceptors(options);
  this._insert(options);
}

_tap做了以下工作:

  • 接收三个参数:type、options和fn;
  • 验证并处理options:可知options为必传,只接收string|object格式。若为object格式,name属性必传且必须是非空字符串;
  • 处理options.context;
  • 将type和fn合并到options对象上;
  • 调用_runRegisterInterceptors注册拦截器(本例不用考虑);
  • 调用_insert方法;

那么_insert做了什么工作呢?来看_insert的内部代码:

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

_insert方法做了以下工作:

  • 重置call、callAsync和promise;
  • 定义before对象和stage,用来获取当前传入的options的插入位置;
  • 循环判断,将options插入到this.taps中的恰当位置保存;

控制台输出hook.taps,可以看到this.taps存储的内容如下:

console.log(hook.taps)
// 输出:
[
  { "type": "sync", "name": "synchook", fn: ƒ },
  { "type": "sync", "name": "synchook", fn: ƒ },
  { "type": "sync", "name": "synchook", fn: ƒ }
]

到此,hook函数的一个订阅过程就基本完成了。

二、分析hook.call()

hook的执行过程即hook.call()调用后发生了什么?从上文/SyncHook.js文件的源码中我们可以看到,SyncHook函数的call方法继承自Hook类。

call()

打开/Hook.js文件查看源码,找到Hookcall方法:

const CALL_DELEGATE = function (...args) {
  this.call = this._createCall("sync");
  return this.call(...args);
};

class Hook {
  constructor(args = [], name = undefined) {
    this._args = args;
    this.name = name;
    this.taps = [];
    this.interceptors = [];
    // 省略部分代码...
    this.call = CALL_DELEGATE;
    // 省略部分代码...
  }
  // 省略部分代码...
  _createCall(type) {
    return this.compile({
      taps: this.taps, // 保存的是此前的订阅s,即hook.taps
      interceptors: this.interceptors, // 拦截器
      args: this._args, // 创建实例时传入的参数:['width', 'height']
      type: type // 'sync'
    });
  }
  // 省略部分代码...
}

由此可知,call()内部调用了hookcompile方法,接下来看compile方法的内容:

compile()

compile方法实际在/SyncHook.js文件中被重写,所以看/SyncHook.js文件的源码:

function SyncHook(args = [], name = undefined) {
  // 省略部分代码...
  hook.compile = COMPILE;
  // 省略部分代码...
}

const factory = new SyncHookCodeFactory();
const COMPILE = function (options) {
  factory.setup(this, options);
  return factory.create(options);
};

可以看到,compile做了两件事:

  1. factory.setup(this, options);
  2. return factory.create(options);

factory.setup()

// 此段代码来自源文件/HookCodeFactory.js
setup(instance, options) {
  instance._x = options.taps.map(t => t.fn);
}

将保存在taps上的fn提取并保存到this._x上。打印结果:

console.log(hook._x)
// 输出:
(3) [ƒ, ƒ, ƒ]
  0: ƒ (width, height)
  1: ƒ (width, height)
  2: ƒ (width, height)

factory.create()

create方法的作用是根据type不同,拼接出不同的代码字符串并且通过new Function创建一个函数,赋值给fn,最后返回fn。从上文可知this.options.type的值为'sync',所以我们现在只需要关注type是'sync'的case分支:

// 此段代码来自源文件/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.contentWithInterceptors({
          onError: err => `throw ${err};\n`,
          onResult: result => `return ${result};\n`,
          resultReturns: true,
          onDone: () => "",
          rethrowIfPossible: true
        })
      );
      break;
      case "async":
      // 省略部分代码...
      break;
      case "promise":
      // 省略部分代码...
      break;
  }
  this.deinit();
  return fn;
}

为了便于理解create方法,我们先了解新建函数的语法:

  • let fn = new Function ([arg1[, arg2[, ...argN]],] functionBody)

可见create中的this.args()对应返回新建函数fn的参数,this.header() + this.contentWithInterceptors()对应返回新建函数fn的函数体字符串。

其中this.args()this.header()的逻辑比较简单:

args()

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(", ");
  }
}

args方法将传入的参数用,拼接为字符串并返回。

header()

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

header方法做了以下工作:

  • 先判断是否需要context对象。如果需要就定义context为一个空对象,不需要就定义context为undefined。
  • 定义_x是this._x,即订阅函数fn组成的数组
  • 判断是否有拦截器,如果有则定义并缓存相应的_taps和_interceptors。
  • 返回拼接的字符串code。

contentWithInterceptors()

contentWithInterceptors主要为了处理拦截器。因为本例不含拦截器,所以略过,直接进入下一步。

contentWithInterceptors(options) {
  if (this.options.interceptors.length > 0) {
    // 省略部分代码...
  } else {
    return this.content(options);
  }
}

content()

content方法见/SyncHook.js文件,在HookCodeFactory的子类SyncHookCodeFactory中被定义:

class SyncHookCodeFactory extends HookCodeFactory {
  content({ onError, onDone, rethrowIfPossible }) {
    return this.callTapsSeries({
      onError: (i, err) => onError(err),
      onDone,
      rethrowIfPossible
    });
  }
}

content方法调用并返回callTapsSeries(),接下来分析callTapsSeries()

callTapsSeries()

callTapsSeries({
    onError,
    onResult,
    resultReturns,
    onDone,
    doneReturns,
    rethrowIfPossible
  }) {
    if (this.options.taps.length === 0) return onDone();
    const firstAsync = this.options.taps.findIndex(t => t.type !== "sync");
    const somethingReturns = resultReturns || doneReturns;
    let code = "";
    let current = onDone;
    let unrollCounter = 0;
    for (let j = this.options.taps.length - 1; j >= 0; j--) {
      const i = j;
      const unroll =
        current !== onDone &&
        (this.options.taps[i].type !== "sync" || unrollCounter++ > 20);
      if (unroll) {
        unrollCounter = 0;
        code += `function _next${i}() {\n`;
        code += current();
        code += `}\n`;
        current = () => `${somethingReturns ? "return " : ""}_next${i}();\n`;
      }
      const done = current;
      const doneBreak = skipDone => {
        if (skipDone) return "";
        return onDone();
      };
      const content = this.callTap(i, {
        onError: error => onError(i, error, done, doneBreak),
        onResult:
          onResult &&
          (result => {
            return onResult(i, result, done, doneBreak);
          }),
        onDone: !onResult && done,
        rethrowIfPossible:
          rethrowIfPossible && (firstAsync < 0 || i < firstAsync)
      });
      current = () => content;
    }
    code += current();
    return code;
  }

callTapsSeries方法做了以下工作:

  1. 判断是否存在订阅函数,若不存在则直接返回空字符串;
  2. 定义函数current,并将其赋值为初始传入的onDone: () => "";
  3. 倒序遍历taps,将current赋值给函数done;
  4. 将done传入this.callTap()并执行,再将this.callTap的返回值重新赋值给current;
  5. 重复步骤3-4;
  6. 执行current并返回拼接的字符串code。

也就是说函数current和done的作用就是为了缓存前一次循环拼接出的字符串并将此次拼接的字符串传递到下一次循环。

那么,想知道在循环中拼接字符串的具体内容?重点看下面的callTap方法:

callTap()

由于本例中的type的值为'sync',且不存在拦截器,所以重点看以下相关代码即可:

callTap(tapIndex, { onError, onResult, onDone, rethrowIfPossible }) {
  let code = "";
  let hasTapCached = false;
  for (let i = 0; i < this.options.interceptors.length; i++) {
    // 省略部分代码...
  }
  code += `var _fn${tapIndex} = ${this.getTapFn(tapIndex)};\n`;
  const tap = this.options.taps[tapIndex];
  switch (tap.type) {
    case "sync":
      if (!rethrowIfPossible) {
        code += `var _hasError${tapIndex} = false;\n`;
        code += "try {\n";
      }
      if (onResult) {
        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 (!rethrowIfPossible) {
        code += "} catch(_err) {\n";
        code += `_hasError${tapIndex} = true;\n`;
        code += onError("_err");
        code += "}\n";
        code += `if(!_hasError${tapIndex}) {\n`;
      }
      if (onResult) {
        code += onResult(`_result${tapIndex}`);
      }
      if (onDone) {
        code += onDone();
      }
      if (!rethrowIfPossible) {
        code += "}\n";
      }
      break;
      // 省略余下代码...
  }
  return code;
}

callTap方法做了以下工作(以第一个tap为例):

  1. 初始变量code为空字符串;
  2. 拼接当前订阅函数的定义:var _fn0 = _x[0];
  3. 进入case 'sync'分支;
  4. 判断rethrowIfPossible,值为false则拼接var _hasError0 = false;try {
  5. 判断是否存在onResult,为true则拼接var _result0 = _fn0(args);,为false则拼接_fn0(args);
  6. 判断rethrowIfPossible,值为false则拼接} catch(_err) {_hasError0 = true;throw _err;}if(!_hasError0) {
  7. 判断是否存在onResult,为true则拼接return _result0;
  8. 判断是否存在onDone,为true则拼接done,即上一次循环缓存的current;
  9. 判断rethrowIfPossible,值为false则拼接},即闭合代码块;
  10. 返回拼接的字符串code;

从上文的内容可知,本例中的rethrowIfPossible == true;onResult == undfined;,因此步骤4-6-7-9均不会执行。

那么重新梳理逻辑可知本例中的callTap方法实际做了以下工作(以第一个tap为例):

  1. 初始变量code为空字符串;
  2. 拼接当前订阅函数的定义:var _fn0 = _x[0];
  3. 进入case 'sync'分支;
  4. 判断onResult,拼接_fn0(args);
  5. 判断是否存在onDone,为true则拼接done,即上一次循环缓存的current;
  6. 返回拼接的字符串code;

以上便是完整的拼接流程,最终的拼接结果将通过new Function创建出一个新的函数并赋值给call方法。