一文了解Webpack中Tapable事件机制

作者:刘锦泉

引言

Webpack 是前端工程化常用的静态模块打包工具。在合适的时机通过 Webpack 提供的 API 改变输出结果,使 Webpack 可以执行更广泛的任务,拥有更强的构建能力。

Webpack 的插件机制本质上是一种事件流的机制,它的工作流程就是将各个插件串联起来,而实现这一切的核心就是TapableWebpack中最核心的负责编译的Compiler和负责创建bundles的Compilation都是Tapable的实例。

本文将介绍 Tapable 的基本使用以及底层实现。

Tapable

Tapable 是一个类似于 Node.js 中的 EventEmitter 的库,但它更专注于自定义事件的触发和处理 。通过 Tapable 我们可以注册自定义事件,然后在适当的时机去执行自定义事件。这个和我们所熟知的生命周期函数类似,在特定的时机去触发。

我们先看一个 简单 Tapable 的 例子:

js 复制代码
const { SyncHook } = require("tapable");

// 实例化 钩子函数 定义形参
const syncHook = new SyncHook(["name"]);

//通过tap函数注册事件
syncHook.tap("同步钩子1", (name) => {
  console.log("同步钩子1", name);
});

//同步钩子 通过call 发布事件
syncHook.call("古茗前端");

通过上面的例子,我们大致可以将 Tapable 的使用分为以下三步:

  • 实例化钩子函数
  • 事件注册
  • 事件触发

事件注册

  • 同步的钩子要用 tap 方法来注册事件
  • 异步的钩子可以像同步方式一样用 tap 方法来注册,也可以用 tapAsynctapPromise 异步方法来注册。
    • tapAsync: 使用用 tapAsync 方法来注册 hook 时,必须调用callback 回调函数。
    • tapPromise:使用 tapPromise 方法来注册 hook 时,必须返回一个 pormise ,异步任务完成后 resolve

事件触发

  • 同步的钩子要用 call 方法来触发
  • 异步的钩子需要用 callAsyncpromise 异步方法来触发。
    • callAsync:当我们用 callAsync 方法来调用 hook 时,第二个参数是一个回调函数,回调函数的参数是执行任务的最后一个返回值
    • promise:当我们用 promise 方法来调用 hook 时,需要使用 then 来处理执行结果,参数是执行任务的最后一个返回值。

Tapable Hook 钩子

tapable 内置了 9 种 hook 。分为 同步异步 两种执行方式。异步执行 Hook 又可以分为 串行 执行和 并行 执行。除此之外,hook 可以根据执行机制分为 常规 瀑布模式 熔断模式 循环模式 四种执行机制。

同步钩子

同步钩子顾名思义:同步执行,上一个钩子执行完才会执行下一个钩子。

示例代码如下:

js 复制代码
const { SyncHook } = require("tapable");

// 实例化 钩子函数 定义形参
const syncHook = new SyncHook(["name"]);

//通过tap函数注册事件
syncHook.tap("同步钩子1", (name) => {
  console.log("同步钩子1", name);
});

//该监听函数有返回值
syncHook.tap("同步钩子2", (name) => {
  console.log("同步钩子2", name);
});


//同步钩子 通过call 发布事件
syncHook.call("古茗前端");

执行结果如下所示:

异步钩子

异步钩子分为: 串行执行和并行执行。在串行执行中,如果上一个钩子没有调用callback 回调函数,下一个钩子将不会触发对应的事件监听。

示例代码如下:

js 复制代码
const { AsyncParallelHook, AsyncSeriesHook } = require("tapable");


const asyncParallelHook = new AsyncParallelHook(["name"]);

const asyncSeriesHook = new AsyncSeriesHook(["name"]);


//通过tap函数注册事件
asyncParallelHook.tapAsync("异步并行钩子1", (name, callback) => {
  setTimeout(() => {
    console.log("异步并行钩子1", name);
  }, 3000);
});

//该监听函数有返回值
asyncParallelHook.tapAsync("异步并行钩子2", (name, callback) => {
  setTimeout(() => {
    console.log("异步并行钩子2", name);
  }, 1500);
});

//通过tap函数注册事件
asyncSeriesHook.tapAsync("异步串行钩子1", (name, callback) => {
  setTimeout(() => {
    console.log("异步串行钩子1", name);
  }, 3000);
});

//该监听函数有返回值
asyncSeriesHook.tapAsync("异步串行钩子2", (name, callback) => {
  setTimeout(() => {
    console.log("异步串行钩子2", name);
  }, 1500);
});


// 异步并行钩子 通过 callAsync 发布事件
asyncParallelHook.callAsync("古茗前端", () => {
  console.log("1111");
  return "1122";
});

// 异步串行钩子 通过 callAsync 发布事件
asyncSeriesHook.callAsync("古茗前端", () => {
  console.log("1111");
  return "1122";
});

控制台输出结果如下图所示:

串行钩子1没有调用callback, 所以串行钩子2没有触发。添加callback后,控制台输出结果:

熔断类

AsyncSeriesBailHook 是一个异步串行、熔断类型的 Hook。在串行的执行过程中,只要其中一个有返回值,后面的就不会执行了。

示例代码如下:

js 复制代码
const { SyncBailHook, AsyncSeriesBailHook } = require("tapable");

const syncBailHook = new SyncBailHook(["name"]);
const asyncSeriesBailHook = new AsyncSeriesBailHook(["name"]);

syncBailHook.tap("保险类同步钩子1", (name) => {
  console.log("保险类同步钩子1", name);
});

syncBailHook.tap("保险类同步钩子2", (name) => {
  console.log("保险类同步钩子2", name);
  return "有返回值";
});

syncBailHook.tap("保险类同步钩子3", (name) => {
  console.log("保险类同步钩子3", name);
});

asyncSeriesBailHook.tapAsync("保险类异步串行钩子1", (name, callback) => {
  setTimeout(() => {
    console.log("保险类异步串行钩子1", name);
    callback();
  }, 3000);
});

asyncSeriesBailHook.tapAsync("保险类2步串行钩子1", (name, callback) => {
  setTimeout(() => {
    console.log("保险类异步串行钩子2", name);
    callback("有返回值");
  }, 2000);
});

asyncSeriesBailHook.tapAsync("保险类异步串行钩子3", (name) => {
  setTimeout(() => {
    console.log("保险类异步串行钩子3", name);
  }, 1000);
});

syncBailHook.call("古茗前端");
asyncSeriesBailHook.callAsync("古茗前端", (result) => {
  console.log("result", result);
});

控制台输出结果如下图所示:

循环类

SyncLoopHook 是一个同步、循环类型的 Hook。循环类型的含义是不停的循环执行事件函数,直到所有函数结果 result === undefined,不符合条件就调头重新开始执行。

示例代码:

js 复制代码
const { SyncLoopHook } = require("tapable");

const syncLoopHook = new SyncLoopHook(["name"]);

let count = 4;

syncLoopHook.tap("循环钩子1", (name) => {
  console.log("循环钩子1", count);
  return count <= 3 ? undefined : count--;
});

syncLoopHook.tap("循环钩子2", (name) => {
  console.log("循环钩子2", count);
  return count <= 2 ? undefined : count--;
});

syncLoopHook.tap("循环钩子3", (name) => {
  console.log("循环钩子3", count);
  return count <= 1 ? undefined : count--;
});


syncLoopHook.call();

控制台输出结果:

瀑布类

AsyncSeriesWaterfallHook 是一个异步串行、瀑布类型的 Hook。如果前一个事件函数的结果 result !== undefined,则 result 会作为后一个事件函数的第一个参数(也就是上一个函数的执行结果会成为下一个函数的参数)。

示例代码:

js 复制代码
const { AsyncSeriesWaterfallHook, SyncWaterfallHook } = require("tapable");

const syncWaterfallHook = new SyncWaterfallHook(["name"]);

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

syncWaterfallHook.tap("瀑布式同步钩子1", (name) => {
  console.log("瀑布式同步钩子1", name);

  return "古茗前端1";
});

syncWaterfallHook.tap("瀑布式同步钩子2", (name) => {
  console.log("瀑布式同步钩子2", name);
});

syncWaterfallHook.tap("瀑布式同步钩子3", (name) => {
  console.log("瀑布式同步钩子3", name);

  return "古茗前端3";
});

asyncSeriesWaterfallHook.tapAsync("瀑布式异步串行钩子1", (name, callback) => {
  setTimeout(() => {
    console.log("瀑布式异步串行钩子1", name);

    callback();
  }, 1000);
});

asyncSeriesWaterfallHook.tapAsync("瀑布式异步串行钩子2", (name, callback) => {
  console.log("瀑布式异步串行钩子2", name);
  setTimeout(() => {
    callback();
  }, 2000);
});

asyncSeriesWaterfallHook.tapAsync("瀑布式异步串行钩子3", (name, callback) => {
  console.log("瀑布式异步串行钩子3", name);
  setTimeout(() => {
    callback("古茗前端3");
  }, 3000);
});

syncWaterfallHook.call("古茗前端");

asyncSeriesWaterfallHook.callAsync("古茗前端", (result) => {
  console.log("result", result);
});

控制台输出结果:

Tapable 高级特性

Intercept

除了通常的 tap/call 之外,所有 hook 钩子都提供额外的拦截API。--- intercept 接口。

intercept 支持的中间件如下图所示:

intercept 类型 描述
call (...args) => void 当钩子被触发时,向拦截器添加调用将被触发。您可以访问hooks参数
tap (tap: Tap) 将tap添加到拦截器将在插件点击钩子时触发。提供的是Tap对象。无法更改Tap对象
loop (...args) => void 向拦截器添加循环将触发循环钩子的每个循环。
register (tap: Tap) => Tap 或 undefined 将注册添加到拦截器将触发每个添加的Tap,并允许对其进行修改。

context

插件和拦截器可以选择访问可选的上下文对象,该对象可用于向后续插件和拦截器传递任意值。

我们通过下面的小案例,来帮助我们理解。示例代码如下:

js 复制代码
const { SyncHook } = require("tapable");

// 实例化 钩子函数 定义形参
const syncHook = new SyncHook(["name"]);

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

//通过tap函数注册事件
syncHook.tap("同步钩子1", (name) => {
  console.log("同步钩子1", name);
});

//通过tap函数注册事件
syncHook.tap("同步钩子2", (name) => {
  console.log("同步钩子2", name);
});

//同步钩子 通过call 发布事件
syncHook.call("古茗前端");
syncHook.call("古茗前端 call2");

控制台输入结果如图所示:

由上面的案例结果,我们可以知道。intercept 中的 register 会在每一次的 tap 触发。 有几个 tap 就会触发几次 register。然后依次执行钩子里面的 calltap.

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

HookMap

HookMap HookMap是具有Hooks的Map的辅助类.提供了一种集合操作能力,能够降低创建与使用的复杂度,用法比较简单:

js 复制代码
const { SyncHook, HookMap } = require("tapable"); 
const syncMap = new HookMap(() => new SyncHook()); 

// 通过 for 函数过滤集合中的特定钩子 
syncMap.for("some-key").tap("MyPlugin", (arg) => { /* ... */ });
syncMap.for("some-key").tapAsync("MyPlugin", (arg, callback) => { /* ... */ });
syncMap.for("some-key").tapPromise("MyPlugin", (arg) => { /* ... */ });

// 触发 guming-test 类型的钩子 
syncMap.get("guming-test").call();

Tapable 底层原理

我们先将 Tapable 工程源码克隆到本地, 执行如下指令:

shell 复制代码
$ git clone https://github.com/webpack/tapable.git

Tapable 源码的 lib 目录结构, 如下所示:

vbnet 复制代码
lib
├─ AsyncParallelBailHook.js
├─ AsyncParallelHook.js
├─ AsyncSeriesBailHook.js
├─ AsyncSeriesHook.js
├─ AsyncSeriesLoopHook.js
├─ AsyncSeriesWaterfallHook.js
├─ Hook.js
├─ HookCodeFactory.js
├─ HookMap.js
├─ MultiHook.js
├─ SyncBailHook.js
├─ SyncHook.js
├─ SyncLoopHook.js
├─ SyncWaterfallHook.js
├─ index.js
└─ util-browser.js

除了上面我们所提及的基本 hooks 函数、HookMap高级特性,还会有一些 HookCodeFactoryHook 这些文件。我们简单过一下 hooks 函数内的内容, 会发现所有的 hooks 函数都会引用 HookCodeFactoryHook 这两个文件所导出的对象实例。

我们以 syncHook 钩子函数为例, 如下图所示:

我们大致能够知道 一个 hooks 函数 会由一个 CodeFactory 代码工厂 以及 Hook 实例组成。 Hook 实例会针对不同场景的 hooks 函数, 更改其对应的 注册钩子(tapAsync ,tap, tapPromise )事件触发钩子( call , callAsync ) , 编译函数(complier)Complier 函数会由我们 HookCodeFactory 实现。

接下来,我们将通过分析 HookCodeFactoryHook 的内部实现来了解 Tapable 的内部实现机制。

Hook 实例

Hook 实例会生成我们 hooks 钩子函数通用的 事件注册事件触发。核心逻辑,我们大致可以分为以下三个部分:

  1. 实例初始化构造函数
  2. 事件注册 的实现
  3. 事件触发的实现

构造函数

构造函数会对实例属性初始化赋值。代码如下图所示:

注册事件

注册事件主要分为两块,一块是 适配器注册调用, 第二块是 触发事件注册。核心逻辑在 _tap 函数内部实现,代码如下图所示:

适配器调用

在这里会对携带 register 函数的适配器进行调用,更改 options 配置,返回新的 options 配置。代码如下图所示:

触发事件注册

Hook 实例的 taps 会存储我们的注册事件, 同时会根据,注册事件配置的执行顺序去存储对应的注册事件。

触发事件

触发事件会通过调用内部 的 _createCall 函数,函数内部会调用实例的 compile 函数。我们会发现:Hook 实例内部不会去实现 complier的逻辑, 不同钩子的 complier 函数会通过通过对应的 继承 HookCodeFactory 的实例去实现。代码如下图所示:

接下来,我们继续 探究 HookCodeFactory 实例,了解 Tapable 事件触发的逻辑。

HookCodeFactory 实例

HookCodeFactory 实例会根据我们传入的事件触发类型 (sync, async, promise)以及我们的触发机制类型 (常规 瀑布模式 保险模式 循环模式), 生成事件触发函数的函数头, 函数体。通过 new Function 构造出事件触发函数。

Tapable 事件触发的执行,是动态生成执行代码, 包含我们的参数,函数头,函数体,然后通过 new Function 来执行。相较于我们通常的遍历/递归调用事件,这无疑让 webpack 的整个事件机制的执行有了一个更高的性能优势。

由上面我们可知, Hook 实例 的 complier 函数是 HookCodeFactory 实例 create 函数 的返回。

接下来,我们就从 create 函数 一步步揭秘 Tapable 的动态生成执行函数的核心实现。

create

create 函数通过对应的 函数参数函数 header, 函数 content方法构造出我们事件触发的函数的内容, 通过 new Function 创建出我们的触发函数。

create 函数会根据事件的触发类型 ( sync、async、promise),进行不同的逻辑处理。代码如下图所示:

每一种触发机制,都会由 this.args, this.header, this.contentWithInterceptors 三个函数去实现 动态函数的 code。代码如下图所示:

接下来我们看一看 this.contentWithInterceptors 函数如何生成我们事件触发函数的函数体。

contentWithInterceptors

contentWithInterceptors 函数里包含两个模块, 一个是适配器 (interceptor), 一个 content 生成函数。 同时,HookCodeFactory 实例本身不会去实现 content 函数的逻辑,会由继承的实例去实现。整体结构代码如下图所示:

每个 hooks 钩子函数的 CodeFactory 实例会去实现 content 函数。 content 函数会调用 HookCodeFactory 实现的不同运行机制的方法( callTapcallTapsSeriescallTapsLoopingcallTapsParallel), 构造出最终的函数体。实现代码如下图所示:

接下来,就是不同运行机制,根据不同的调用方式 ( sync, async, promise ) 生成对应的执行代码。

动态函数

我们通过下面一个简单案例,看 New Function 输入的内容是什么?

实例代码如下:

js 复制代码
const { SyncHook, AsyncSeriesHook } = require("tapable");

// 实例化 钩子函数 定义形参
const syncHook = new SyncHook(["name"]);
const asyncSeriesHook = new AsyncSeriesHook(["name"])

//通过tap函数注册事件
syncHook.tap("同步钩子1", (name) => {
  console.log("同步钩子1", name);
});

//通过tap函数注册事件
asyncSeriesHook.tapAsync("同步钩子1", (name) => {
  console.log("同步钩子1", name);
});

//同步钩子 通过call 发布事件
syncHook.call("古茗前端sync");
asyncSeriesHook.callAsync("古茗前端async");
asyncSeriesHook.promise("古茗前端promise")

HookCodeFactorycreate 打印 fn, 实例代码如图所示:

sync 同步调用的输出结果如下:

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

}

async 异步调用的输出结果如下:

js 复制代码
function anonymous(name, _callback
) {
"use strict";
var _context;
var _x = this._x;
var _fn0 = _x[0];
_fn0(name, (function(_err0) {
if(_err0) {
_callback(_err0);
} else {
_callback();
}
}));

}

promise 调用的输出结果如下

js 复制代码
 function anonymous(name
) {
"use strict";
var _context;
var _x = this._x;
return new Promise((function(_resolve, _reject) {
var _sync = true;
function _error(_err) {
if(_sync)
_resolve(Promise.resolve().then((function() { throw _err; })));
else
_reject(_err);
};
var _fn0 = _x[0];
_fn0(name, (function(_err0) {
if(_err0) {
_error(_err0);
} else {
_resolve();
}
}));
_sync = false;
}));

}

三种不同方式调用,内部代码实现差异还是比较清晰的。async 调用相较于 sync 多了回调函数。 asyncpromise 的区别再去返回 promise 还是回调函数。

最后,我们来用一些结构图来总结一下 Tapable 中事件触发的逻辑。

HookCodeFactory 会根据我们触发的方式,生成我们对应 new Function 里面的 content, args, header

content 最终会由 callTapsSeriescallTapsLoopingcallTapsParallel 生成。每种生成方式都会包含 Done 处理、Error 处理以及 Result 处理。

总结

其他机制的 Hooks 钩子实现原理大致是相同的, 这里就不一一赘述了。Tapable 是一个非常优秀的库,灵活扩展性高,许多优秀的开源项目的插件化设计都借鉴或采纳了 Tapable 的设计思想,是一个非常值得推荐学习的一个开源工具库。

最后

📚 小茗文章推荐:

关注公众号「Goodme前端团队」,获取更多干货实践,欢迎交流分享~

相关推荐
喵叔哟9 分钟前
重构代码之取消临时字段
java·前端·重构
还是大剑师兰特1 小时前
D3的竞品有哪些,D3的优势,D3和echarts的对比
前端·javascript·echarts
王解1 小时前
【深度解析】CSS工程化全攻略(1)
前端·css
一只小白菜~1 小时前
web浏览器环境下使用window.open()打开PDF文件不是预览,而是下载文件?
前端·javascript·pdf·windowopen预览pdf
方才coding1 小时前
1小时构建Vue3知识体系之vue的生命周期函数
前端·javascript·vue.js
阿征学IT1 小时前
vue过滤器初步使用
前端·javascript·vue.js
王哲晓1 小时前
第四十五章 Vue之Vuex模块化创建(module)
前端·javascript·vue.js
丶21361 小时前
【WEB】深入理解 CORS(跨域资源共享):原理、配置与常见问题
前端·架构·web
发现你走远了1 小时前
『VUE』25. 组件事件与v-model(详细图文注释)
前端·javascript·vue.js
Mr.咕咕1 小时前
Django 搭建数据管理web——商品管理
前端·python·django