Webpack 之 Tapable 原来这么重要

129 阅读7分钟

介绍:Tapable 在 Webpack 中究竟担任着怎样的角色

Webpack 的整个执行过程是基于事件驱动的,在一个事件总线上会暴露大量的 Hook 供插件扩展各种自定义功能。而 Tapable 就是用来提供各种各样的 Hook。

Webpack Hook 是 Webpack 生态系统中的核心机制,它为插件提供了深入参与整个编译生命周期的能力。这些 Hooks 就像是编译过程中预设的“事件点”,当 Webpack 运行到某个特定阶段,它就会触发相应的 Hook。插件可以监听(tap)这些 Hook,并在它们被触发时执行自己的逻辑。

代码示例分析:

const {
  SyncHook
} = require('tapable')

// 创建一个同步 Hook,指定参数
const hook = new SyncHook(['arg1', 'arg2'])

// 注册
hook.tap('a', function (arg1, arg2) {
        console.log('a')
})

hook.tap('b', function (arg1, arg2) {
        console.log('b')
})
//触发事件
hook.call(1, 2)

从以上代码中不难看出,这和 EventEmit(发布订阅模式)有些类似,即先注册事件,然后触发事件。不过,Tapable 是一个专门为 Webpack 的编译流程而设计和优化且功能强大的发布订阅模式实现。它借鉴了发布订阅模式的解耦思想,并通过引入同步/异步控制,中断机制和多种执行模式,使其完美地支撑 Webpack 复杂而精密的插件系统。

从官方介绍中,可以看到 Tapable 提供了很多类型的 Hook,分为同步异步两大类(异步中又分为异步并行和异步串行),而又根据事件终止的条件不同,又衍生出 Bail/Waterfall/Loop 类型。

Hook 的分类如下:

从以上图中,我们总结出:

  1. Basic(基础)类型:这是最常见,最直接的 Hook 类型,它会简单地按照注册顺序依次执行所有监听器,并忽略监听器地返回值。比如:SyncHook(同步),AsyncParalleHook(异步并行),AsyncSeriesHook(异步串行)。

  2. Bail(中断)类型:Bail Hooks 提供了“中断”或停止后续监听器执行的能力。如果一个监听器返回了非 undefined 的值,Hook 会立即停止执行,并返回该值。比如:SyncBailHook(同步中断),AsyncSeriesBaillHook(异步串行),AsyncParalleBail(异步并行中断)。

  3. Waterfall(瀑布)类型:会将一个监听器的结果作为参数传递给下一个监听器。这形成了一个链式反应,每个监听器都可以对前一个监听器的输出进行转换。例如:SyncWaterfallHook(同步瀑布),AsyncSeriesWaterfallHook(异步串行瀑布)。

  4. Loop(循环)类型:这设计用于重复一个过程,直到所有监听器都返回undefined。如果一个监听器返回了非undefined值,整个 Hook 的执行会从头开始。比如:SyncLoopHook。

原理:Tapable 在 Webpack 中的底层实现

Tapable 代码的主脉络:

image.png

Hook 类关系图很简单,各种 Hook都继承自一个基类,同时内部包含了一个 xxxCodeFactory 类,会在生成 hook 执行代码中用到。

事件注册

Tapable 提供了三种事件注册的方法:tap,tapAsync,tapPromise 。实现逻辑都在 Hook 基类中。

其中 tap 是同步方法,tapAsync 和 tapPromise 是异步方法。

注:在同步钩子中使用异步注册方法会报错

事件触发

事件触发的起点是 Tapable 实例的 call 方法。触发方法与注册方法相对应:tap——>call,tapAsync——>callAsync,tapPromise——>promise。具体逻辑如下:

this.call = this._call = this._createCompileDelegate("call", "sync");
this.promise = this._promise = this._createCompileDelegate("promise", "promise");
this.callAsync = this._callAsync = this._createCompileDelegate("callAsync", "async"); 
   // ...
_createCall(type) {
        return this.compile({
                taps: this.taps,
                interceptors: this.interceptors,
                args: this._args,
                type: type
        });
}

_createCompileDelegate(name, type) {
        const lazyCompileHook = (...args) => {
                this[name] = this._createCall(type);
                return this[name](...args);
        };
        return lazyCompileHook;
}

从以上代码,我们可以得知,当调用触发事件的方法时(call,callAsync,promise),Tapable底层的机制就会被激活。

  1. 动态生成执行函数:在 _createCompileDelegate 函数中会动态生成 _createCall 这个执行函数,这个生成函数包含了所有监听器的调用逻辑,并且会将这个函数缓存起来。
  2. 执行生成的函数:一旦执行函数被生成,后续的事件触发就会直接调用这个函数。不同的 Hook 类型是事件触发的核心差异所在。

执行代码生成

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

const factory = new SyncHookCodeFactory();

class SyncHook extends Hook {
   // ... 省略其他代码
        compile(options) {
                factory.setup(this, options);
                return factory.create(options);
        }
}

从以上代码我们可以总结出,不同种类的 Hook 内部都有一个执行代码生成的实现类,它们把通用逻辑封装在了基类中,那具体的差异化部分是怎么实现的呢:

  1. 首先我们看到这个工厂函数都调用到了 create 方法,那这个 create 方法做了什么呢,来看一下它的源码:我们可以看到 create 只实现了公共部分,具体差异的部分在 content。

    1.   create(options) {
                this.init(options);
                switch(this.options.type) {
                        case "sync":
                                return new Function(this.args(), ""use strict";\n" + this.header() + this.content({
                                        onError: err => `throw ${err};\n`,
                                        onResult: result => `return ${result};\n`,
                                        onDone: () => "",
                                        rethrowIfPossible: true
                                }));
                        case "async":
                                return new Function(this.args({
                                        after: "_callback"
                                }), ""use strict";\n" + this.header() + this.content({
                                        onError: err => `_callback(${err});\n`,
                                        onResult: result => `_callback(null, ${result});\n`,
                                        onDone: () => "_callback();\n"
                                }));
                        case "promise":
                                let code = "";
                                code += ""use strict";\n";
                                code += "return new Promise((_resolve, _reject) => {\n";
                                code += "var _sync = true;\n";
                                code += this.header();
                                code += this.content({
                                        onError: err => {
                                                let code = "";
                                                code += "if(_sync)\n";
                                                code += `_resolve(Promise.resolve().then(() => { throw ${err}; }));\n`;
                                                code += "else\n";
                                                code += `_reject(${err});\n`;
                                                return code;
                                        },
                                        onResult: result => `_resolve(${result});\n`,
                                        onDone: () => "_resolve();\n"
                                });
                                code += "_sync = false;\n";
                                code += "});\n";
                                return new Function(this.args(), code);
                }
        }
      
  2. 那我们知道了 content 是实现差异的地方,具体差异怎么实现的呢:比如

    1. SyncHook:SyncHookCodeFactory 传入的 onResult 是空的,onError 只负责处理错误。这告诉callTapsSeries模板,你只需要简单地调用每个监听器就行,无需关心返回值。
    2. SyncBailHook:这个工厂函数传入地 onResult 是一个带有 if 条件地字符串,这告诉callTapsSeries模板,如果返回值是 undefined 那就立即返回。
      //syncHook
      class SyncHookCodeFactory extends HookCodeFactory {
              content({ onError, onResult, onDone, rethrowIfPossible }) {
                      return this.callTapsSeries({
                              onError: (i, err) => onError(err),
                              onDone,
                              rethrowIfPossible
                      });
              }
      }
      //syncBailHook
      content({ onError, onResult, onDone, rethrowIfPossible }) {
              return this.callTapsSeries({
                      onError: (i, err) => onError(err),
                      onResult: (i, result, next) => `if(${result} !== undefined) {\n${onResult(result)};\n} else {\n${next()}}\n`,
                      onDone,
                      rethrowIfPossible
              });
      }
      //AsyncSeriesLoopHook
      class AsyncSeriesLoopHookCodeFactory extends HookCodeFactory {
              content({ onError, onDone }) {
                      return this.callTapsLooping({
                              onError: (i, err, next, doneBreak) => onError(err) + doneBreak(true),
                              onDone
                      });
              }
      }
      // 其他的结构都类似,便不在这里贴代码了
      ```
    
    
  3. 其中,在以上代码中我们看到了 callTapsSeries 这个函数,这是函数执行的模板。它根据事件触发的模板不同,通过不同的传参实现不同流程的模板。此处省略源码

Tapable 在 Webpack 的应用

Tapable 是 Webpack 插件系统和 Webpack 核心连接的桥梁和规范。它定义了插件如何监听并介入 Webpack 的编译过程。要理解它们的关系,需要从三个方面看:角色协议底层实现

角色分工

  • Tapable:是事件中心。它是一个库,定义了 Hook 的类型,注册方法(tap,tapAsync,tapPromise)和触发机制(call,callAsync 和 promise)。Tapable 本身不包含任何编译逻辑,只是提供一套基础的事件管理架构。
  • Webpack 核心:事件的发布者。它在整个编译生命周期的关键时刻,调用 Tapable 的 Hook 触发方法来发布事件。例如,在完成模块构建时,Webpack 核心会调用 compilation.hooks.finishModules.call()
  • 插件:事件订阅者。插件通过 apply 方法获取 compiler 实例,然后 通过调用 compiler.hooks.someHook.tap(...)来注册自己的监听函数。

这种关系完美体现了:Webpack 核心和插件都不直接依赖对方,它们都依赖于 Tapable 定义的抽象 Hook 接口。

协议与接口

Tapable 充当了插件与 Webpack 核心之间唯一的通信协议。这个协议的核心就是 Hook。

  • 插件的视角:插件只需要知道有哪些 Hook 可用,以及每个 Hook 传递什么参数。例如,一个插件如果想在文件写入前做些事情,它只需要遵循协议,在 emit Hook 上进行注册。
  • Webpack 核心的视角:Webpack 核心只需要知道在什么时机触发哪个 Hook,以及向 Hook 传递什么数据。例如,在生成文件时,它将 compilation 对象作为参数传递给 emit Hook。

这个协议保证了插件的可插拔性解耦 。只要插件遵循 Tapable 的接口,它就可以无缝地集成到 Webpack 中,而无需了解 Webpack 核心的内部细节。

底层连接:代码生成与执行

当插件通过 tap 方法注册监听函数时,Tapable 就在插件和 Webpack 核心之间建立了底层的连接。这个连接的实现依赖于 Tapable 的动态代码生成机制。

  • 注册时:当插件调用 compiler.hooks.someHook.tap('MyPlugin', fn) 时,Tapable 的代码生成器会根据 fn 和其他已注册的监听器,动态地拼接出一个新的、优化的 JavaScript 函数。这个函数包含了所有监听器的调用逻辑。

  • 触发时:当 Webpack 核心在编译流程中调用 compiler.hooks.someHook.call() 时,它实际上是直接调用了上面生成的函数。这个函数会高效地执行所有插件注册的回调,而无需进行昂贵的数组遍历或运行时查找。

总结展望

Tapable 是一个用于创建强大、可扩展的插件架构的库,也是 Webpack 能够拥有丰富插件生态系统的基石。它将复杂的程序流分解成一系列可监听的“钩子”(Hooks),让外部插件能够精准地介入和修改核心流程。它既保留了发布-订阅模式的解耦优势,又通过精密的类型系统和创新的代码生成技术,解决了大型应用中事件处理的性能和控制难题。因此,它不仅是 Webpack 的灵魂,也是构建任何大型、可扩展系统时值得借鉴的设计模式。