Webpack: 插件架构之Hook体系

概述

Webpack 之所以能够应对 Web 场景下极度复杂、多样的构建需求,关键就在于其健壮、扩展性极强的插件架构,而插件架构的精髓又在于其灵活多变的 Hook 体系,可以说,只有真正掌握 Hook 底层设计与实现逻辑,深入理解不同 Hook 的运行特性与用法,才能灵活处理各种问题,更快更好地编写出 Webpack 插件。

本文将聚焦在 Webpack Hook 底层的 Tapable 框架,详细枚举了 Tapable 提供的钩子及各类型钩子的特点、运行逻辑、实现原理,并进一步讨论 Tapable 框架在 Webpack 的作用,进而揭示 Webpack 插件架构的核心逻辑。阅读本文,你将:

  • 深入了解 Hook 类型,以及不同类型的特点、运行特性;
  • 理解如何识别 Webpack 特定钩子类型,正确调用处理。

Tapable 全解析

网上不少资料将 Webpack 的插件架构归类为“事件/订阅”模式,我认为这种归纳有失偏颇。订阅模式是一种松耦合架构,发布器只是在特定时机发布事件消息,订阅者并不或者很少与事件直接发生交互,举例来说,我们平常在使用 HTML 事件的时候很多时候只是在这个时机触发业务逻辑,很少调用上下文操作。

而 Webpack 的插件体系是一种基于 Tapable 实现的强耦合架构,它在特定时机触发钩子时会附带上足够的上下文信息,插件定义的钩子回调中,能也只能与这些上下文背后的数据结构、接口交互产生 side effect,进而影响到编译状态和后续流程。

Tapable 是 Webpack 插件架构的核心支架,但它的代码量其实很少,本质上就是围绕着 订阅/发布 模式叠加各种特化逻辑,适配 Webpack 体系下复杂的事件源-处理器之间交互需求,比如:

  • 有些场景需要支持将前一个处理器的结果传入下一个回调处理器;
  • 有些场景需要支持异步并行调用这些回调处理器。

先简单看看 Tapable 的用法:

const { SyncHook } = require("tapable");

// 1. 创建钩子实例
const sleep = new SyncHook();

// 2. 调用订阅接口注册回调
sleep.tap("test", () => {
  console.log("callback A");
});

// 3. 调用发布接口触发回调
sleep.call();

// 运行结果:
// callback A

使用 Tapable 时通常需要经历三个步骤:

  • 创建钩子实例,如上例第 4 行;
  • 调用订阅接口注册回调,包括:taptapAsynctapPromise,如上例第 7 行;
  • 调用发布接口触发回调,包括:callcallAsyncpromise,如上例第 12 行。

Webpack 内部的钩子大体上都遵循上面三个步骤,只是在某些钩子中还可以使用异步风格的 tapAsync/callAsync、promise 风格 tapPromise/promise,具体使用哪一类函数与钩子类型有关。

Hook 类型汇总

Tabable 提供如下类型的钩子:

名称简介统计
SyncHook同步钩子Webpack 共出现 71 次,如 Compiler.hooks.compilation
SyncBailHook同步熔断钩子Webpack 共出现 66 次,如 Compiler.hooks.shouldEmit
SyncWaterfallHook同步瀑布流钩子Webpack 共出现 37 次,如 Compilation.hooks.assetPath
SyncLoopHook同步循环钩子Webpack 中未使用
AsyncParallelHook异步并行钩子Webpack 仅出现 1 次:Compiler.hooks.make
AsyncParallelBailHook异步并行熔断钩子Webpack 中未使用
AsyncSeriesHook异步串行钩子Webpack 共出现 16 次,如 Compiler.hooks.done
AsyncSeriesBailHook异步串行熔断钩子Webpack 中未使用
AsyncSeriesLoopHook异步串行循环钩子Webpack 中未使用
AsyncSeriesWaterfallHook异步串行瀑布流钩子Webpack 共出现 5 次,如 NormalModuleFactory.hooks.beforeResolve

类型虽多,但整体遵循两种分类规则:

  • 按回调逻辑,分为:
    • 基本类型,名称不带 Waterfall/Bail/Loop 关键字:与通常 订阅/回调 模式相似,按钩子注册顺序,逐次调用回调;
    • waterfall 类型:前一个回调的返回值会被带入下一个回调;
    • bail 类型:逐次调用回调,若有任何一个回调返回非 undefined 值,则终止后续调用;
    • loop 类型:逐次、循环调用,直到所有回调函数都返回 undefined
  • 按执行回调的并行方式,分为:
    • sync :同步执行,启动后会按次序逐个执行回调,支持 call/tap 调用语句;
    • async :异步执行,支持传入 callback 或 promise 风格的异步回调函数,支持 callAsync/tapAsyncpromise/tapPromise 两种调用语句。

所有钩子都可以按名称套进这两条规则里面,对插件开发者来说不同类型的钩子会直接影响到回调函数的写法,以及插件与其他插件的互通关系,但是有一些基本能力、概念是通用的:tap/callinterceptcontext、动态编译等。

接下来展开详细介绍每种钩子的特点与执行逻辑。

SyncHook 钩子

SyncHook 算的上是简单的钩子了,触发后会按照注册的顺序逐个调用回调,且不关心这些回调的返回值,底层逻辑大致如下述代码:

function syncCall() {
  const callbacks = [fn1, fn2, fn3];
  for (let i = 0; i < callbacks.length; i++) {
    const cb = callbacks[i];
    cb();
  }
}

举个例子:

const { SyncHook } = require("tapable");

class Somebody {
  constructor() {
    this.hooks = {
      sleep: new SyncHook(),
    };
  }
  sleep() {
    //   触发回调
    this.hooks.sleep.call();
  }
}

const person = new Somebody();

// 注册回调
person.hooks.sleep.tap("test", () => {
  console.log("callback A");
});
person.hooks.sleep.tap("test", () => {
  console.log("callback B");
});
person.hooks.sleep.tap("test", () => {
  console.log("callback C");
});

person.sleep();
// 输出结果:
// callback A
// callback B
// callback C

示例中,Somebody 初始化时声明了一个 sleep 钩子,并在后续调用 sleep.tap 函数连续注册三次回调,在调用 person.sleep() 语句触发 sleep.call 之后,tapable 会按照注册的先后按序执行三个回调。

在这里插入图片描述

上述示例中,触发回调时用到了钩子的 call 函数,我们也可以选择异步风格的 callAsync ,选用 callcallAsync 并不会影响回调的执行逻辑:按注册顺序依次执行 + 忽略回调执行结果,两者唯一的区别是 callAsync 需要传入 callback 函数,用于处理回调队列可能抛出的异常:

// call 风格
try {
  this.hooks.sleep.call();
} catch (e) {
    // 错误处理逻辑
}
// callAsync 风格
this.hooks.sleep.callAsync((err) => {
  if (err) {
    // 错误处理逻辑
  }
});

由于调用方式不会 Hook 本身的规则,所以对使用者来说,无需关心底层到底用的是 call 还是 callAsync,上面的例子只需要做简单的修改就可以适配 callAsync 场景:

const { SyncHook } = require("tapable");

class Somebody {
  constructor() {
    this.hooks = {
      sleep: new SyncHook(),
    };
  }
  sleep() {
    //   触发回调
    this.hooks.sleep.callAsync((err) => {
      if (err) {
        console.log(`interrupt with "${err.message}"`);
      }
    });
  }
}

const person = new Somebody();

// 注册回调
person.hooks.sleep.tap("test", (cb) => {
  console.log("callback A");
  throw new Error("我就是要报错");
});
// 第一个回调出错后,后续回调不会执行
person.hooks.sleep.tap("test", () => {
  console.log("callback B");
});

person.sleep();

// 输出结果:
// callback A
// interrupt with "我就是要报错"

SyncBailHook 钩子

bail 单词有熔断的意思,而 bail 类型钩子的特点是在回调队列中,若任一回调返回了非 undefined 的值,则中断后续处理,直接返回该值,用一段伪代码来表示:

function bailCall() {
  const callbacks = [fn1, fn2, fn3];
  for (let i in callbacks) {
    const cb = callbacks[i];
    const result = cb(lastResult);
    // 如果有任意一个回调返回结果,则停止调用剩下的回调
    if (result !== undefined) {
      // 熔断
      return result;
    }
  }
  return undefined;
}

SyncBailHook 的调用顺序与规则都跟 SyncHook 相似,主要区别一是 SyncBailHook 增加了熔断逻辑,例如:

const { SyncBailHook } = require("tapable");

class Somebody {
  constructor() {
    this.hooks = {
      sleep: new SyncBailHook(),
    };
  }
  sleep() {
    return this.hooks.sleep.call();
  }
}

const person = new Somebody();

// 注册回调
person.hooks.sleep.tap("test", () => {
  console.log("callback A");
  // 熔断点
  // 返回非 undefined 的任意值都会中断回调队列
  return '返回值:tecvan'
});
person.hooks.sleep.tap("test", () => {
  console.log("callback B");
});

console.log(person.sleep());

// 运行结果:
// callback A
// 返回值:tecvan

其次,相比于 SyncHookSyncBailHook 运行结束后,会将熔断值返回给call函数,例如上例第20行, callback A 返回的 返回值:tecvan 会成为 this.hooks.sleep.call 的调用结果。

在 Webpack 中被如何使用

SyncBailHook 通常用在发布者需要关心订阅回调运行结果的场景, Webpack 内部有接近 100 个地方用到这种钩子,举个例子: compiler.hooks.shouldEmit,对应的 call 语句:

class Compiler {
  run(callback) {
    //   ...

    const onCompiled = (err, compilation) => {
      if (this.hooks.shouldEmit.call(compilation) === false) {
        // ...
      }
    };
  }
}

此处 Webpack 会根据 shouldEmit 钩子的运行结果确定是否执行后续的操作,其它场景也有相似逻辑,如:

  • NormalModuleFactory.hooks.createModule :预期返回新建的 Module 对象;
  • Compilation.hooks.needAdditionalSeal :预期返回 bool 值,判定是否进入 unseal 状态;
  • Compilation.hooks.optimizeModules :预期返回 bool 值,用于判定是否继续执行优化操作。

SyncWaterfallHook 钩子

waterfall 钩子的执行逻辑跟 lodash 的 flow 函数有点像,大致上就是将前一个函数的返回值作为参数传入下一个函数,逻辑如下:

function waterfallCall(arg) {
  const callbacks = [fn1, fn2, fn3];
  let lastResult = arg;
  for (let i in callbacks) {
    const cb = callbacks[i];
    // 上次执行结果作为参数传入下一个函数
    lastResult = cb(lastResult);
  }
  return lastResult;
}

理解上述逻辑后,SyncWaterfallHook 的特点也就很明确了:

  1. 上一个函数的结果会被带入下一个函数;
  2. 最后一个回调的结果会作为 call 调用的结果返回。

例如:

const { SyncWaterfallHook } = require("tapable");

class Somebody {
  constructor() {
    this.hooks = {
      sleep: new SyncWaterfallHook(["msg"]),
    };
  }
  sleep() {
    return this.hooks.sleep.call("hello");
  }
}

const person = new Somebody();

// 注册回调
person.hooks.sleep.tap("test", (arg) => {
  console.log(`call 调用传入: ${arg}`);
  return "tecvan";
});

person.hooks.sleep.tap("test", (arg) => {
  console.log(`A 回调返回: ${arg}`);
  return "world";
});

console.log("最终结果:" + person.sleep());
// 运行结果:
// call 调用传入: hello
// A 回调返回: tecvan
// 最终结果:world

示例中,sleep 钩子为 SyncWaterfallHook 类型,之后注册了两个回调,从处理结果可以看到,第一个回调收到的 arg = hello ,即第10行 call 调用时传入的参数;第二个回调收到的是第一个回调返回的结果 tecvan;之后 call 调用返回的是第二个回调的结果 world

使用时,SyncWaterfallHook 钩子有一些注意事项:

  • 初始化时必须提供参数,例如上例 new SyncWaterfallHook(["msg"]) 构造函数中,必须传入参数 ["msg"] ,用于动态编译 call 的参数依赖,后面我们会讲到 动态编译 的细节;
  • 发布调用 call 时,需要传入初始参数。

在 Webpack 中被如何使用

SyncWaterfallHook 在 Webpack 中总共出现了 50+次,其中比较有代表性的例子是 NormalModuleFactory.hooks.factory ,在 Webpack 内部实现中,会在这个钩子内根据资源类型 resolve 出对应的 module 对象:

class NormalModuleFactory {
  constructor() {
    this.hooks = {
      factory: new SyncWaterfallHook(["filename", "data"]),
    };

    this.hooks.factory.tap("NormalModuleFactory", () => (result, callback) => {
      let resolver = this.hooks.resolver.call(null);

      if (!resolver) return callback();

      resolver(result, (err, data) => {
        if (err) return callback(err);

        // direct module
        if (typeof data.source === "function") return callback(null, data);

        // ...
      });
    });
  }

  create(data, callback) {
    //   ...
    const factory = this.hooks.factory.call(null);
    // ...
  }
}

大致上就是在创建模块,通过 factory 钩子将 module 的创建过程外包出去,在钩子回调队列中依据 waterfall 的特性逐步推断出最终的 module 对象。

SyncLoopHook 钩子

loop 型钩子的特点是循环执行,直到所有回调都返回 undefined ,不过这里循环的维度是单个回调函数,例如有回调队列 [fn1, fn2, fn3]loop 钩子先执行 fn1 ,如果此时 fn1 返回了非 undefined 值,则继续执行 fn1 直到返回 undefined 后,才向前推进执行 fn2 。伪代码:

function loopCall() {
  const callbacks = [fn1, fn2, fn3];
  for (let i in callbacks) {
    const cb = callbacks[i];
    // 重复执行
    while (cb() !== undefined) {}
  }
}

由于 loop 钩子循环执行的特性,使用时务必十分注意,避免陷入死循环。示例:

const { SyncLoopHook } = require("tapable");

class Somebody {
  constructor() {
    this.hooks = {
      sleep: new SyncLoopHook(),
    };
  }
  sleep() {
    return this.hooks.sleep.call();
  }
}

const person = new Somebody();
let times = 0;

// 注册回调
person.hooks.sleep.tap("test", (arg) => {
  ++times;
  console.log(`${times} 次执行回调A`);
  if (times < 4) {
    return times;
  }
});

person.hooks.sleep.tap("test", (arg) => {
  console.log(`执行回调B`);
});

person.sleep();
// 运行结果
// 第 1 次执行回调A
// 第 2 次执行回调A
// 第 3 次执行回调A
// 第 4 次执行回调A
// 执行回调B

可以看到示例中一直在执行回调 A,直到满足判定条件 times >= 4 ,A 返回 undefined 后,才开始执行回调B。

虽然 Tapable 提供了 SyncLoopHook 钩子,但 Webpack 源码中并没有使用到,所以大家理解用法就行,不用深究。

AsyncSeriesHook 钩子

前面这些以 Sync 开头的都是同步风格的钩子,执行逻辑相对简单,但不支持异步回调,所以 Tapable 还提供了一系列 Async 开头的异步钩子,支持在回调函数中执行异步操作,执行逻辑比较复杂。

例如 AsyncSeriesHook,它有这样一些特点:

  • 支持异步回调,可以在回调函数中写 callbackpromise 风格的异步操作;
  • 回调队列依次执行,前一个执行结束后,才会开始执行下一个;
  • SyncHook 一样,不关心回调的执行结果。

用一段伪代码来表示:

function asyncSeriesCall(callback) {
  const callbacks = [fn1, fn2, fn3];
  //   执行回调 1
  fn1((err1) => {
    if (err1) {
      callback(err1);
    } else {
      //   执行回调 2
      fn2((err2) => {
        if (err2) {
          callback(err2);
        } else {
          //   执行回调 3
          fn3((err3) => {
            if (err3) {
              callback(err2);
            }
          });
        }
      });
    }
  });
}

先来看一个 callback 风格的示例:

const { AsyncSeriesHook } = require("tapable");

const hook = new AsyncSeriesHook();

// 注册回调
hook.tapAsync("test", (cb) => {
  console.log("callback A");
  setTimeout(() => {
    console.log("callback A 异步操作结束");
    // 回调结束时,调用 cb 通知 tapable 当前回调已结束
    cb();
  }, 100);
});

hook.tapAsync("test", () => {
  console.log("callback B");
});

hook.callAsync();
// 运行结果:
// callback A
// callback A 异步操作结束
// callback B

从代码输出结果可以看出,A 回调内部的 setTimeout 执行完毕调用 cb 函数,tapable 才认为当前回调执行完毕,开始执行 B 回调。

除了 callback 风格外,也可以使用 promise 风格调用 tap/call 函数,改造上例:

const { AsyncSeriesHook } = require("tapable");

const hook = new AsyncSeriesHook();

// 注册回调
hook.tapPromise("test", () => {
  console.log("callback A");
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log("callback A 异步操作结束");
      resolve();
    }, 100);
  });
});

hook.tapPromise("test", () => {
  console.log("callback B");
  return Promise.resolve();
});

hook.promise();
// 运行结果:
// callback A
// callback A 异步操作结束
// callback B

有三个改动点:

  • tapAsync 更改为 tapPromise
  • Tap 回调需要返回 promise 对象,如上例第 8 行;
  • callAsync 调用更改为 promise

在 Webpack 中被如何使用

AsyncSeriesHook 钩子在 Webpack 中总共出现了 30+ 次,相对来说都是一些比较容易理解的时机,比如在构建完毕后触发 compiler.hooks.done 钩子,用于通知单次构建已经结束:

class Compiler {
  run(callback) {
    if (err) return finalCallback(err);

    this.emitAssets(compilation, (err) => {
      if (err) return finalCallback(err);

      if (compilation.hooks.needAdditionalPass.call()) {
        // ...
        this.hooks.done.callAsync(stats, (err) => {
          if (err) return finalCallback(err);

          this.hooks.additionalPass.callAsync((err) => {
            if (err) return finalCallback(err);
            this.compile(onCompiled);
          });
        });
        return;
      }

      this.emitRecords((err) => {
        if (err) return finalCallback(err);

        // ...
        this.hooks.done.callAsync(stats, (err) => {
          if (err) return finalCallback(err);
          return finalCallback(null, stats);
        });
      });
    });
  }
}

AsyncParallelHook 钩子

AsyncSeriesHook 类似,AsyncParallelHook 也支持异步风格的回调,不过 AsyncParallelHook 是以并行方式,同时执行回调队列里面的所有回调,逻辑上近似于:

function asyncParallelCall(callback) {
  const callbacks = [fn1, fn2];
  // 内部维护了一个计数器
  var _counter = 2;

  var _done = function() {
    _callback();
  };
  if (_counter <= 0) return;
  // 按序执行回调
  var _fn0 = callbacks[0];
  _fn0(function(_err0) {
    if (_err0) {
      if (_counter > 0) {
        // 出错时,忽略后续回调,直接退出
        _callback(_err0);
        _counter = 0;
      }
    } else {
      if (--_counter === 0) _done();
    }
  });
  if (_counter <= 0) return;
  // 不需要等待前面回调结束,直接开始执行下一个回调
  var _fn1 = callbacks[1];
  _fn1(function(_err1) {
    if (_err1) {
      if (_counter > 0) {
        _callback(_err1);
        _counter = 0;
      }
    } else {
      if (--_counter === 0) _done();
    }
  });
}

AsyncParallelHook 钩子的特点:

  • 支持异步风格;
  • 并行执行回调队列,不需要做任何等待;
  • SyncHook 一样,不关心回调的执行结果。

实践应用

综上,Tapable 合计提供了 10 种钩子,支持同步、异步、熔断、循环、waterfall 等功能特性,以此支撑起 Webpack 复杂的构建需求。虽然多数情况下我们不需要手动调用 Tapable,但编写插件时可以借助这些知识,识别 Hook 类型与执行特性后,正确地调用,正确地实现交互。

例如:对于 compiler.hooks.done 钩子,官网介绍:

在这里插入图片描述

这是一个 AsyncSeriesHook 钩子,意味着:

  • 支持异步语法,我们可以用 tap/tapAsync/tapPromise 方式注册回调;
  • Webpack 会按照注册顺序串行执行回调;
  • Webpack 不关心回调的返回值,但可以通过 callback 函数传递 Error 信息。

又或者,对于 compilation.hooks.optimizeChunkModules 钩子,官网介绍:
在这里插入图片描述

这是一个 SyncBailHook 钩子,因此:

  • 不支持异步语法,我们只能用 tap 注册回调;
  • 若任意回调有返回值,则中断 Hook 流程,后面回调不再执行,所以使用时需要谨慎。

其它 Hook 也能用类似方法,参照分析出钩子的应用技巧。

  • 提示:Webpack 官方文档并没有覆盖介绍所有钩子,必要时建议读者直接翻阅 Webpack 源码,分析钩子类型。

Hook 动态编译

至此,Webpack 中用到的 Hook 子类都已介绍完毕,不同 Hook 适用于不同场景,解决不同问题,而它们底层都基于 Tapable 的“动态编译”实现,可以说,理解了动态编译,也就掌握了 Tapable 的核心实现逻辑。

动态编译是一个非常大胆的设计,不同 Hook 所谓的同步、异步、bail、waterfall、loop 等回调规则都是 Tapable 根据 Hook 类型、参数、回调队列等参数,调用 new Function 语句动态拼装出一段控制执行流程的 JavaScript 代码实现控制的。例如:

const { SyncHook } = require("tapable");

const sleep = new SyncHook();

sleep.tap("test", () => {
  console.log("callback A");
});
sleep.call();

调用 sleep.call 时,Tapable 内部处理流程大致为:

编译过程主要涉及三个实体:

  • tapable/lib/SyncHook.js :定义 SyncHook 的入口文件;
  • tapable/lib/Hook.jsSyncHook 只是一个代理接口,内部实际上调用了 Hook 类,由 Hook 负责实现钩子的逻辑(其它钩子也是一样的套路);
  • tapable/lib/HookCodeFactory.js :动态编译出 callcallAsyncpromise 函数内容的工厂类,注意,其他钩子也都会用到 HookCodeFactory 工厂函数。

SyncHook (其他钩子类似))调用 call 后,Hook 基类收集上下文信息并调用 createCall 及子类传入的 compiler 函数;compiler 调用 HookCodeFactory 进而使用 new Function 方法动态拼接出回调执行函数。上面例子对应的生成函数:

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

那么问题来了,通过 new Functioneval 等方式实现的动态编译,存在诸如性能、安全性等方面的问题,所以社区很少见到类似的设计,真的有必要用这种方式实现 Hook 吗?

这放在 SyncHook 这种简单场景确实大可不必,但若是更复杂的 Hook,如 AsyncSeriesWaterfallHook

const { AsyncSeriesWaterfallHook } = require("tapable");

const sleep = new AsyncSeriesWaterfallHook(["name"]);

sleep.tapAsync("test1", (name, cb) => {
  console.log(`执行 A 回调: 参数 name=${name}`);
  setTimeout(() => {
    cb(undefined, "tecvan2");
  }, 100);
});

sleep.tapAsync("test", (name, cb) => {
  console.log(`执行 B 回调: 参数 name=${name}`);
  setTimeout(() => {
    cb(undefined, "tecvan3");
  }, 100);
});

sleep.tapAsync("test", (name, cb) => {
  console.log(`执行 C 回调: 参数 name=${name}`);
  setTimeout(() => {
    cb(undefined, "tecvan4");
  }, 100);
});

sleep.callAsync("tecvan", (err, name) => {
  console.log(`回调结束, name=${name}`);
});

// 运行结果:
// 执行 A 回调: 参数 name=tecvan
// 执行 B 回调: 参数 name=tecvan2
// 执行 C 回调: 参数 name=tecvan3
// 回调结束, name=tecvan4

AsyncSeriesWaterfallHook 的特点是异步 + 串行 + 前一个回调的返回值会传入下一个回调,对应生成函数:

(function anonymous(name, _callback) {
  "use strict";
  var _context;
  var _x = this._x;
  function _next1() {
    var _fn2 = _x[2];
    _fn2(name, function(_err2, _result2) {
      if (_err2) {
        _callback(_err2);
      } else {
        if (_result2 !== undefined) {
          name = _result2;
        }
        _callback(null, name);
      }
    });
  }
  function _next0() {
    var _fn1 = _x[1];
    _fn1(name, function(_err1, _result1) {
      if (_err1) {
        _callback(_err1);
      } else {
        if (_result1 !== undefined) {
          name = _result1;
        }
        _next1();
      }
    });
  }
  var _fn0 = _x[0];
  _fn0(name, function(_err0, _result0) {
    if (_err0) {
      _callback(_err0);
    } else {
      if (_result0 !== undefined) {
        name = _result0;
      }
      _next0();
    }
  });
});

核心逻辑:

  • 生成函数将回调队列各个项封装为 _next0/_next1 函数,这些 next 函数内在逻辑高度相似;
  • 按回调定义的顺序,逐次执行,上一个回调结束后,才调用下一个回调,例如生成代码中的第39行、27行。

相比于用递归、循环之类的手段实现 AsyncSeriesWaterfallHook,这段动态生成的函数逻辑确实会更清晰,更容易理解,这种场景下用动态编译,确实是一个不错的选择。

Tapable 提供的大多数特性都是基于 Hook + HookCodeFactory 实现的,如果大家对此有兴趣,可以在 tapable/lib/Hook.js 的 CALL_DELEGATE/CALL_ASYNC_DELEGATE/PROMISE_DELEGATE 几个函数打断点:

在这里插入图片描述

之后,使用 ndb 命令断点调试,查看动态编译出的代码:
在这里插入图片描述

高级特性:Intercept

除了通常的 tap/call 之外,tapable 还提供了简易的中间件机制 —— intercept 接口,例如

const sleep = new SyncHook();

sleep.intercept({
  name: "test",
  context: true,
  call() {
    console.log("before call");
  },
  loop(){
    console.log("before loop");
  },
  tap() {
    console.log("before each callback");
  },
  register() {
    console.log("every time call tap");
  },
});

intercept 支持注册如下类型的中间件:

签名解释
call(...args) => void调用 call/callAsync/promise 时触发
tap(tap: Tap) => void调用 call 类函数后,每次调用回调之前触发
loop(...args) => voidloop 型的钩子有效,在循环开始之前触发
register`(tap: Tap) => Tapundefined`

其中 register 在每次调用 tap 时被调用;其他三种中间件的触发时机大致如下:

  var _context;
  const callbacks = [fn1, fn2];
  var _interceptors = this.interceptors;
  // 调用 call 函数,立即触发
  _interceptors.forEach((intercept) => intercept.call(_context));
  var _loop;
  var cursor = 0;
  do {
    _loop = false;
    // 每次循环开始时触发 `loop`
    _interceptors.forEach((intercept) => intercept.loop(_context));
    // 触发 `tap`
    var _fn0 = callbacks[0];
    _interceptors.forEach((intercept) => intercept.tap(_context, _fn0));
    var _result0 = _fn0();
    if (_result0 !== undefined) {
      _loop = true;
    } else {
      var _fn1 = callbacks[1];
      // 再次触发 `tap`
      _interceptors.forEach((intercept) => intercept.tap(_context, _fn1));
      var _result1 = _fn1();
      if (_result1 !== undefined) {
        _loop = true;
      }
    }
  } while (_loop);

intercept 特性在 Webpack 内主要被用作进度提示,如 Webpack/lib/ProgressPlugin.js 插件中,分别对 compiler.hooks.emitcompiler.hooks.afterEmit 钩子应用了记录进度的中间件函数。其他类型的插件应用较少。

高级特性:HookMap

Tapable 还有一个值得注意的特性 —— HookMap,它提供了一种集合操作能力,能够降低创建与使用的复杂度,用法比较简单:

const { SyncHook, HookMap } = require("tapable");

const sleep = new HookMap(() => new SyncHook());

// 通过 for 函数过滤集合中的特定钩子
sleep.for("statement").tap("test", () => {
  console.log("callback for statement");
});

// 触发 statement 类型的钩子
sleep.get("statement").call();

HookMap 能够用于实现的动态获取钩子功能,例如在 Webpack 的 lib/parser.js 文件中,parser 文件主要完成将资源内容解析为 AST 集合,之后遍历 AST 并以 HookMap 方式对外通知遍历到的内容。

例如,遇到表达式的时候触发 Parser.hooks.expression 钩子,问题是 AST 结构和内容都很复杂,如果所有情景都以独立的钩子实现,那代码量会急剧膨胀。这种场景就很适合用 HookMap 解决,以 expression 为例:

class Parser {
  constructor() {
    this.hooks = {
      // 定义钩子
      // 这里用到 HookMap ,所以不需要提前遍历枚举所有 expression 场景
      expression: new HookMap(() => new SyncBailHook(["expression"])),
    };
  }

  //   不同场景下触发钩子
  walkMemberExpression(expression) {
    const exprName = this.getNameForExpression(expression);
    if (exprName && exprName.free) {
      // 触发特定类型的钩子
      const expressionHook = this.hooks.expression.get(exprName.name);
      if (expressionHook !== undefined) {
        const result = expressionHook.call(expression);
        if (result === true) return;
      }
    }
    // ...
  }

  walkThisExpression(expression) {
    const expressionHook = this.hooks.expression.get("this");
    if (expressionHook !== undefined) {
      expressionHook.call(expression);
    }
  }
}

上例代码第 15、25 行都通过 this.hooks.expression.get(xxx) 语句动态获取对应钩子实例,之后再调用 call 触发。HookMap 的消费逻辑与普通 Hook 类似,只需要增加 for 函数过滤出你实际监听的 Hook 实例即可,如:

// 钩子消费逻辑
// 选取 CommonJsStuffPlugin 仅起示例作用
class CommonJsStuffPlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap(
      "CommonJsStuffPlugin",
      (compilation, { normalModuleFactory }) => {
        const handler = (parser, parserOptions) => {
          // 通过 for 精确消费钩子
          parser.hooks.expression
            .for("require.main.require")
            .tap(
              "CommonJsStuffPlugin",
              ParserHelpers.expressionIsUnsupported(
                parser,
                "require.main.require is not supported by Webpack."
              )
            );
          parser.hooks.expression
            .for("module.parent.require")
            .tap(
              "CommonJsStuffPlugin",
              ParserHelpers.expressionIsUnsupported(
                parser,
                "module.parent.require is not supported by Webpack."
              )
            );
          parser.hooks.expression
            .for("require.main")
            .tap(
              "CommonJsStuffPlugin",
              ParserHelpers.toConstantDependencyWithWebpackRequire(
                parser,
                "__Webpack_require__.c[__Webpack_require__.s]"
              )
            );
          // ...
        };
      }
    );
  }
}

借助这种能力我们就不需要为每一种情况都单独创建 Hook,只需要在使用时动态创建、获取对应实例即可,能有效降低开发与维护成本。

总结

为了应对构建场景下各种复杂需求,Webpack 内部使用了多种类型的 Hook,分别用于实现同步、异步、熔断、串行、并行的流程逻辑,开发插件时需要注意识别 Hook 类型,据此做出正确的调用与交互逻辑。

我们可以思考为什么 Webpack 内部需要这些不同类型的流程逻辑?比如,为什么需要 SyncBailHook 这种具有熔断特性的钩子?适用于怎么样的场景?在我们日常业务开发中,能否复用这一类流程控制能力?

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/766385.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

Git新仓库创建流程

平时需要创建新仓库,老要去查代码特别烦&#xff0c;在此写下流程方便备用. 1.创建新的云仓库 无论使用GitHub还是Gitee,首先要创建一个云仓库&#xff0c;这里就直接用国内的gitee做演示了&#xff0c;githup老挂加速器太烦&#xff0c;偷个懒. 我这里创建的是一个空仓库&…

SAP 表字段调整,表维护生成器调整

表维护生成器->已生成的对象->更改->专家模式

【OceanBase】OBProxy 无状态的理解

SueWakeup 个人主页&#xff1a;SueWakeup 系列专栏&#xff1a;为祖国的科技进步添砖Java 个性签名&#xff1a;保留赤子之心也许是种幸运吧 本文封面由 凯楠&#x1f4f8;友情提供 目录 前言 OBProxy 无状态的概述 OBProxy 无状态特性带来的优点 1. 高可用 2. 负载均衡…

WLAN的WPA3安全技术

Wi-Fi安全加密的演进下图所示&#xff0c;当前最新的加密方式是WPA3。WPA3对现有网络提供了全方位的安全防护&#xff0c;增强了公共网络、家庭网络和802.1X企业网的安全性。 WPA3的核心为对等实体同时验证方式(Simultaneous Authentication of Equals, SAE)&#xff0c;即通信…

仅1月出刊:计算机科学类知网检索普刊

【欧亚科睿学术】 Journal of Computer Science and Electrical Engineering 《计算机科学与电气工程杂志》是一份同行评审期刊&#xff0c;发表计算机科学和电气工程几个领域的原创研究文章和综述文章。 它由UPUBSCIENCE出版社出版。它支持开放获取政策&#xff0c;即让所有…

vmdk to vhdx 虚拟磁盘格式转换qemu-img

qemu-img是创建、转换、修改磁盘映像的工具&#xff0c;我们可以用它非常方便的转换虚拟磁盘格式&#xff0c;比如在vmdk、vhdx、qcow2、vdi之间相互转换&#xff0c;它在流行的Linux、macOS、Windows平台上都发布有对应的版本。 本文介绍的是Windows版本&#xff0c;它支持下图…

【STM32入门教学】——串口、定时器与参考资料

机器人工程系列文章目录 这里罗列了系列文章链接 概念总述 STM入门教学 还没写完组里急用 文章目录 机器人工程系列文章目录概念总述STM入门教学 前言串口串口的概念cubemxkeil5实物实验关于cubemx生成逻辑printf升级usart.cmain.hretarget.c 定时器定时器的概念cubemxkeil5…

IDEA中使用Maven打包及碰到的问题

1. 项目打包 IDEA中&#xff0c;maven打包的方式有两种&#xff0c;分别是 install 和 package &#xff0c;他们的区别如下&#xff1a; install 方式 install 打包时做了两件事&#xff0c;① 将项目打包成 jar 或者 war&#xff0c;打包结果存放在项目的 target 目录下。…

医疗器械FDA | 医疗器械软件如何做源代码审计?

医疗器械网络安全测试https://link.zhihu.com/?targethttps%3A//www.wanyun.cn/Support%3Fshare%3D24315_ea8a0e47-b38d-4cd6-8ed1-9e7711a8ad5e 医疗器械源代码审计是一个确保医疗器械软件安全性和可靠性的重要过程。以下是医疗器械源代码审计的主要步骤和要点&#xff0c;以…

MIX OTP——依赖项和总体项目

在本章中&#xff0c;我们将讨论如何管理 Mix 中的依赖项。 我们的 kv 应用程序已经完成&#xff0c;现在是时候实现处理我们在第一章中定义的请求的服务器了&#xff1a; 但是&#xff0c;我们不会向 kv 应用程序添加更多代码&#xff0c;而是将 TCP 服务器构建为另一个应用程…

ROS2 rosbag2记录仪

rosbag2类似于行车记录仪&#xff0c;录制一段话题数据&#xff0c;录制完成后可以多次发布出来进行测试和实验&#xff0c;也可以将话题数据分享给别人用于验证算法等。 1.启动talker服务 ros2 run demo_nodes_cpp talker 2.记录话题数据 chatter ros2 bag record /chatte…

数据库操作-DML和DQL

DML DML英文全称是Data Manipulation Language(数据操作语言)&#xff0c;用来对数据库中表的数据记录进行增、删、改操作。 添加数据&#xff08;INSERT&#xff09; 1.指定字段添加数据&#xff1a; insert into 表名 ( 字段名 1, 字段名 2) values ( 值 1, 值 2); 2…

O2OA(翱途)开发平台 V9.1 即将发布,更安全、更高效、更开放

尊敬的O2OA(翱途)平台合作伙伴、用户以及亲爱的开发小伙伴们&#xff0c;O2OA(翱途)平台 V9.1将于7月3日正式发布&#xff0c;届时欢迎大家到O2OA官网部署下载及体验最新版本。新版本我们在如下方面做了更大的努力&#xff1a; 1.扩展数据库兼容性和功能范围&#xff1a;在O2OA…

[SwiftUI 开发] 嵌套的ObservedObject中的更改不会更新UI

1. 发生问题的demo 业务逻辑代码 class Address: ObservableObject {Published var street "123 Apple Street"Published var city "Cupertino" }class User: ObservableObject {Published var name "Tim Cook"Published var address Addr…

使用Python绘制动态螺旋线:旋转动画效果

文章目录 引言准备工作前置条件 代码实现与解析导入必要的库初始化Pygame绘制螺旋线函数主循环 完整代码 引言 螺旋线是一个具有美学和数学魅力的图形。通过编程&#xff0c;我们可以轻松创建动态旋转的螺旋线动画。在这篇博客中&#xff0c;我们将使用Python和Pygame库来实现…

XTDrone-固定翼无人机编队跟踪无人车-配置教程

配置使用ROS版本为Neotic 1 配置 1.1 加载固定翼无人机编队跟踪控制工程文件 cp -r ~/XTDrone/coordination/fixed_wing_formation_control ~/catkin_ws/src 1.2 加载一些用到的功能包 sudo apt-get install ros-noetic-serial #根据自己的ROS版本修改 sudo apt-get insta…

试用笔记之-免费的汇通餐饮管理软件

首先下载免费的汇通餐饮管理软件&#xff1a; http://www.htsoft.com.cn/download/htcanyin.exe 安装后的图标 登录软件&#xff0c;默认没有密码 汇通餐饮管理软件主界面 汇通餐饮软件前台系统 点菜

synchronized用法解析

锁的意义&#xff1a; 比如我跟我老弟要用电脑&#xff0c;我想学java&#xff0c;他想拿电脑打LOL&#xff0c;如果我敲java代码敲的正嗨皮&#xff0c;他突然把电脑抢了过去&#xff0c;代码还没保存&#xff0c;就被他拿去打LOL了&#xff0c;很✓8&#xff0c;那么如何解决…

【Arduino】XIAOFEIYU实验ESP32实验热敏电阻(图文)

今天XIAOFEIYU来实验一下ESP32使用热敏电阻传感器。 热敏电阻具有测试灵敏&#xff0c;测试范围大的特点&#xff0c;具有广泛的使用范围。常温器件适用于-55℃&#xff5e;315℃&#xff0c;高温器件适用温度高于315℃&#xff08;目前最高可达到2000℃&#xff09;&#xff…

[图解]SysML和EA建模住宅安全系统-07-to be块定义图

1 00:00:00,180 --> 00:00:06,820 我们来看&#xff0c;这是之前的那张图&#xff0c;现有的 2 00:00:08,290 --> 00:00:09,160 我们怎么做 3 00:00:09,170 --> 00:00:11,280 你看&#xff0c;我们之前 4 00:00:11,290 --> 00:00:15,600 在现状&#xff0c;as i…