关于Tapable的源码解析和应用场景

前言

本文根据 tapable 工具库最新的 2.2.1 版本来做的源码解析。整体分为几个部分,目录结构描述、各个钩子函数介绍和实现代码流程,再深入探讨 tapable 如何在 webpack 中被用来创建强大的插件系统。

目录结构

钩子函数分类

在各个钩子函数中,按照执行方式又可以分为同步、异步:

同步钩子 包含SyncBailHook.jsSyncHook.jsSyncLoopHook.jsSyncWaterfallHook.js

异步钩子 中包含分为串行(Series)钩子:AsyncSeriesBailHook.jsAsyncSeriesHook.jsAsyncSeriesLoopHook.jsAsyncSeriesWaterfallHook.js 和并行(Parallel):AsyncParallelBailHook.jsAsyncParallelHook.js

按执行机制分,可以分为基本类型、瀑布类型、熔断类型和循环类型:

基本类型SyncHook.jsAsyncSeriesHook.jsAsyncParallelHook.js

瀑布类型(Waterfall :类似reduce函数,插件按照注册的顺序被调用,每个插件接收前一个插件返回的值作为输入。包含SyncWaterfallHook.jsAsyncSeriesWaterfallHook.js

熔断类型(Bail) :如果任何一个处理函数返回一个非 undefined 的值,整个钩子的执行将立即停止,并且这个返回值将成为整个钩子的最终结果。包含AsyncParallelBailHook.jsAsyncSeriesBailHook.jsSyncBailHook.js

循环类型(Loop) :允许插件或函数根据条件反复执行,如果某个插件返回非 undefined 值,则钩子从头开始再次执行,直到所有插件都返回 undefined,这时循环结束。包含AsyncSeriesLoopHook.jsSyncLoopHook.js

其他工具函数

除了SyncHook.js等钩子函数外,Hook.js 是整个钩子的基类,定义了钩子的基础结构和公共接口;HookCodeFactory.js 是用于动态生成钩子的执行代码的工厂类;HookMap.js 是一个钩子容器,用于管理和维护多个钩子实例;MultiHook.js 用于组合多个钩子成一个单一的钩子。

执行流程解析

我们以一个最简单的 SyncHook.js 为例,对钩子函数执行流程中每一步做一个解析。

SyncHook.js 的使用如下:

javascript 复制代码
const SyncHook = require("./lib/SyncHook");

// 实例化函数,定义形参
const syncHook = new SyncHook(["arguments1", "arguments2"]);

//第二步:注册事件1
syncHook.tap("注册事件1", (arguments1, arguments2) => {  console.log("注册事件1:", arguments1, arguments2);});

//第二步:注册事件2
syncHook.tap("注册事件2", (arguments1, arguments2) => {  console.log("注册事件2:", arguments1, arguments2);});

//第三步:注册事件3
syncHook.tap("注册事件3", (arguments1, arguments2) => {  console.log("注册事件3:", arguments1, arguments2);});
//第三步:触发事件,传实参
syncHook.call("参数1", "参数2");

/**
 * 执行结果如下:
 * 注册事件1: 参数1 参数2
 * 注册事件2: 参数1 参数2
 * 注册事件3: 参数1 参数2
 */

可以看到,通过new一个实例,带入所需的形参,通过tab方法,收集各个需要执行的事件,最终通过call方法,传入实参去触发执行事件。

我们先看看源码 syncHook.js中做了什么:

javascript 复制代码
// SyncHookCodeFactory 继承自 HookCodeFactory 类,用于创建特定于同步钩子的代码
class SyncHookCodeFactory extends HookCodeFactory {
	// 重写了父类中的 content 方法。它定义了同步钩子的执行内容,特别是如何处理错误(onError)和完成(onDone)的情况
	content({ onError, onDone, rethrowIfPossible }) {
		// 用于顺序调用所有注册的 tap
		return this.callTapsSeries({
			onError: (i, err) => onError(err),
			onDone,
			rethrowIfPossible
		});
	}
}

const factory = new SyncHookCodeFactory();

// 定义以下两个方法,尝试以异步方式使用 SyncHook 时抛出错误
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");
};

// 这个函数用于编译钩子。它使用 factory 的 setup 和 create 方法来设置和创建钩子的执行代码。
const COMPILE = function(options) {
	factory.setup(this, options);
	return factory.create(options);
};

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

从源码可以看出,SyncHook 这个类主要是作为一种特定类型钩子的接口和配置器 ,它自身不直接处理收集 tap 事件(插件注册)和触发执行的逻辑,这些功能是在 Hook.js 和 HookCodeFactory.js 中实现的。

解析Hook类

Hook.js 类实现了钩子的基本功能,如存储 tap(即插件或事件监听器)的列表,以及定义通用的接口。将Hook.js源码中的拦截器和异步处理等代码去除后展示如下:

ini 复制代码
const CALL_DELEGATE = function(...args) {
	this.call = this._createCall("sync");
	return this.call(...args);
};

class Hook {
	constructor(args = []) {
		this._args = args; // 存储传入的参数列表。
		this.taps = []; // 存储所有的 tap(即插件或监听器)
		this._call = CALL_DELEGATE; // _call 和 call 都是调用代理,初始时指向 CALL_DELEGATE
		this.call = CALL_DELEGATE;
		this._x = undefined; // 用于内部存储
		this.compile = this.compile; // 是一个在子类中被重写的抽象方法,用于编译并创建钩子的具体调用逻辑
		this.tap = this.tap;
	}

	compile(options) {
		throw new Error("Abstract: should be overridden");
	}

	// 用于根据钩子的类型(如同步或异步)创建相应的调用函数
	_createCall(type) {
		return this.compile({
			taps: this.taps,
			args: this._args,
			type: type
		});
	}

	_tap(type, options, fn) {
		if (typeof options === "string") {
			options = {
				name: options.trim()
			};
		} 
		options = Object.assign({ type, fn }, options);
		this._insert(options);
	}

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

	// 重置编译
	_resetCompilation() {
		this.call = this._call;
	}

	// 将新的 tap 插入到正确的位置
	_insert(item) {
		this._resetCompilation();
		let before;
		if (typeof item.before === "string") { // 如果 item.before 是字符串,则创建一个只包含这个字符串的 Set。
			before = new Set([item.before]);
		} else if (Array.isArray(item.before)) { //如果 item.before 是数组,则将其转换为 Set。
			before = new Set(item.before);
		}
		let stage = 0; // stage 参数用于定义 tap 的优先级。
		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;
	}
}

由上可以看到,Hook 内部定义了一个 this.taps 数组做注册事件的维护,当调用 syncHook.tap 函数时,Hook内部执行流程为 this.tab -> this._tap -> this._insert ,通过 _insert 函数将新增的tap 插到正确的位置。

而在 _insert 方法中,before 和 stage 参数被用来控制新 tap 的插入位置。这个过程确保了 tap 能够按照指定的顺序和优先级被执行。下面是这两个参数如何控制插入位置的具体机制:

- 处理 before 参数

before 参数指定了一组 tap 的名称,新的 tap 应该被插入到这些指定的 tap 之前。如果 before 是字符串,就转换为只包含这个字符串的 Set。 如果 before 是数组,就转换为包含所有数组项的 Set。 在遍历现有的 taps 时,检查每个 tap 的名称是否在 before 集合中。 如果在集合中找到匹配的名称,从集合中移除该名称,并继续遍历,直到集合为空或遍历结束。

- 处理 stage 参数

stage 参数为每个 tap 定义了一个优先级,较低的数字表示较高的优先级。为新的 tap 定义一个 stage 值,如果未指定,则默认为 0。 在遍历现有的 taps 时,比较每个 tap 的 stage 值。 如果现有 tap 的 stage 值大于新 tap 的 stage 值,就找到了插入位置。

而当调用 syncHook.call 时,Hook内部的执行流程为this.call -> CALL_DELEGATE -> this._createCall -> this.compile 。最后一步 this.compile 的在定义 SyncHook.js 时初始化,相关代码在 HookCodeFactory 中生成。

解析HookCodeFactory类

HookCodeFactory 是库中用于动态生成钩子的执行代码,同样,我们将其中的异步处理和拦截器部分先去除,只留下支持 syncHook 钩子的部分代码:

javascript 复制代码
class HookCodeFactory {
    // 构造函数,接收配置对象
    constructor(config) {
        this.config = config;
        this.options = undefined;
        this._args = undefined;
    }

    // 根据提供的选项创建函数
    create(options) {
        this.init(options);
        let fn;
        // 仅处理同步类型的钩子
        if (this.options.type === "sync") {
            // 用来动态创建一个新的函数
            fn = new Function(
                this.args(),
                '"use strict";\n' +
                    this.header() +
                    this.content()
            );
        }
        return fn;
    }

    // 设置实例的状态,主要是初始化_taps属性
    setup(instance, options) {
        instance._x = options.taps.map(t => t.fn);
    }

    // 初始化options和_args属性
    init(options) {
        this.options = options;
        this._args = options.args.slice();
    }

    // 生成函数头部代码,定义_x变量
    header() {
        let code = "";
        code += "var _x = this._x;\n";
        return code;
    }

    // 生成函数参数列表
    args() {
        return this._args.join(", ");
    }

    // 生成执行所有tap函数的代码
    content() {
        let code = "";
        this.options.taps.forEach((tap, i) => {
            code += `var _fn${i} = _x[${i}];\n`; // 为每个tap定义变量
            code += `_fn${i}(${this.args()});\n`; // 调用tap函数
        });
        return code;
    }
}

从上面代码可以看出,当我们每次执行 syncHook.call 时,实际抛出的是 HookCodeFactorythis.create 方法根据 new Function 所动态生成的 fn 。

- new Function的简单介绍

首先我们对 new Function 做一个简单介绍,它是 JavaScript 中的一种构造函数,用于动态创建一个新的 Function 对象。它允许你将字符串形式的代码转换成可执行的函数。这种方法在需要动态生成和执行代码的场景中特别有用。new Function 的基本语法如下:

javascript 复制代码
new Function ([arg1[, arg2[, ...argN]],] functionBody)

使用示例:

javascript 复制代码
const sum = new Function('a', 'b', 'return a + b');
console.log(sum(2, 6)); // 输出: 8

HookCodeFactory 类中,调用 this.create 返回对应函数去执行。其中根据 this.header() + this.content() 两部分去拼接代码。

this.header() 方法的作用是生成执行函数的头部代码,包括一些初始化和预备代码,为后续的 tap 调用做准备。比如 _x 存储所有的 tap 函数引用,以及一个上下文变量 _context 。

this.content() 方法负责生成函数体中的核心逻辑,即实际调用注册的 taps 的代码。就是去遍历 this.options.taps ,然后生成一个一个的 fn 。

根据上面我们调用 syncHook 的例子,在 create 方法最后 returnfn 前打印了 console.log(fn.toString()) ,展示如下:

ini 复制代码
function anonymous(arguments1, arguments2) {
    "use strict";
    var _context;
    var _x = this._x;
    var _fn0 = _x[0];
    _fn0(arguments1, arguments2);
    var _fn1 = _x[1];
    _fn1(arguments1, arguments2);
    var _fn2 = _x[2];
    _fn2(arguments1, arguments2);
}

拦截器 interceptors

在 tapable 库中,拦截器(interceptors)提供了一种方式来拦截和修改钩子的行为,例如在每个 tap 执行之前或之后执行某些操作,或者修改 tap 的参数。首先我们先来看一下拦截器的使用例子:

javascript 复制代码
const SyncHook = require("./lib/SyncHook");

const syncHook = new SyncHook(["arguments1", "arguments2"]);

// 定义一个拦截器
const interceptor = {
    register(tapInfo) {
        // 可以修改 tapInfo
        console.log(`注册了一个 tap: ${tapInfo.name}`);
        return tapInfo; // 一定要返回 tapInfo
    },
    call(arg1, arg2) {
        // 在每个 tap 被调用之前执行
        console.log('即将调用一个 tap');
    },
    tap(tapInfo) {
        // 在每个 tap 被执行之前执行
        console.log(`即将执行 tap: ${tapInfo.name}`);
    }
};

// 将拦截器添加到钩子
syncHook.intercept(interceptor);

//第二步:注册事件1
syncHook.tap("注册事件1", (arguments1, arguments2) => {
  console.log("注册事件1:", arguments1, arguments2);
});

//第三步:触发事件,这里传的是实参,会被每一个注册函数接收到
syncHook.call("参数1", "参数2");

/**
 * 执行结果如下:
 * 注册了一个 tap: 注册事件1
 * 即将调用一个 tap
 * 即将执行 tap: 注册事件1
 * 注册事件1: 参数1 参数2
 */

在这个示例中,我们创建了一个 SyncHook 实例并定义了一个拦截器。这个拦截器通过 registercalltap 方法在钩子的不同阶段输出信息。当钩子被调用时,这些拦截器方法会按照预定的方式被触发。

我们将 Hook.js 代码做个简化,只留下拦截器相关的处理方法:

javascript 复制代码
class Hook {
    constructor() {
        this.interceptors = [];
    }

    // 添加拦截器
    intercept(interceptor) {
        this.interceptors.push(Object.assign({}, interceptor));
    }

    // 运行拦截器的注册方法
    _runRegisterInterceptors(options) {
        for (const interceptor of this.interceptors) {
            if (interceptor.register) {
                const newOptions = interceptor.register(options);
                if (newOptions !== undefined) {
                    options = newOptions;
                }
            }
        }
        return options;
    }

    // 模拟 tap 注册,展示拦截器的运行
    tap(options) {
        options = this._runRegisterInterceptors(options);
        // 这里可以添加处理逻辑,例如将 tap 添加到内部存储
        console.log('Tap registered:', options);
    }
}

代码很简单,就是添加一个新的拦截器到拦截器数组中。

然后在 HookCodeFactory 类中,与拦截器相关的处理主要体现在以下几个方面:

contentWithInterceptors 方法,负责将拦截器的行为整合到钩子的执行代码中:

kotlin 复制代码
contentWithInterceptors(options) {
    if (this.options.interceptors.length > 0) {
        // 对于每个拦截器,执行相关的拦截器方法
        // 比如在调用每个 tap 前后执行 interceptor 的 call、error、result 等方法
        ...
    } else {
        return this.content(options);
    }
}

当调用单个 tap 时,callTap 方法会考虑拦截器逻辑。如果拦截器定义了针对特定 tap 的行为(如 tap 方法),则这些行为将被集成到生成的执行代码中:

javascript 复制代码
callTap(tapIndex, { onError, onResult, onDone, rethrowIfPossible }) {
    ...
    // 对每个 tap,检查并执行拦截器的逻辑
    for (let i = 0; i < this.options.interceptors.length; i++) {
        const interceptor = this.options.interceptors[i];
        if (interceptor.tap) {
            ...
            // 执行拦截器的 tap 方法
        }
    }
    ...
}

在 header 方法中准备拦截器:

ini 复制代码
header() {
    ...
    if (this.options.interceptors.length > 0) {
        // 定义与拦截器相关的变量
        code += "var _taps = this.taps;\n";
        code += "var _interceptors = this.interceptors;\n";
    }
    ...
}

上面拦截器的使用示例,最后由new Function 动态拼接出来的代码如下:

ini 复制代码
function anonymous(arguments1, arguments2) {
	"use strict";
	var _context;
	var _x = this._x;
	var _taps = this.taps;
	var _interceptors = this.interceptors;
	_interceptors[0].call(arguments1, arguments2);
	var _tap0 = _taps[0];
	_interceptors[0].tap(_tap0);
	var _fn0 = _x[0];
	_fn0(arguments1, arguments2);
}

在Webpack中的具体应用

webpack 的编译和构建过程中有多个阶段,例如解析、模块加载、打包等。tapable 允许 webpack 在这些不同阶段暴露出钩子,插件可以在这些钩子上执行特定的操作。例如,emit 钩子在输出阶段被调用,允许插件在文件写入磁盘之前修改它们。

Compiler

我们拿 webpack 中的 Compiler 类举例子,其中利用了 tapable 定义了一系列的钩子:

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

class Compiler {
	constructor(context, options = ({})) {
		this.hooks = Object.freeze({
			initialize: new SyncHook([]),
			shouldEmit: new SyncBailHook(["compilation"]),
			done: new AsyncSeriesHook(["stats"]),
			afterDone: new SyncHook(["stats"]),
			additionalPass: new AsyncSeriesHook([]),
			beforeRun: new AsyncSeriesHook(["compiler"]),
			run: new AsyncSeriesHook(["compiler"]),
			emit: new AsyncSeriesHook(["compilation"]),
			assetEmitted: new AsyncSeriesHook(["file", "info"]),
			afterEmit: new AsyncSeriesHook(["compilation"]),
			thisCompilation: new SyncHook(["compilation", "params"]),
			compilation: new SyncHook(["compilation", "params"]),
			normalModuleFactory: new SyncHook(["normalModuleFactory"]),
			contextModuleFactory: new SyncHook(["contextModuleFactory"]),
			beforeCompile: new AsyncSeriesHook(["params"]),
			compile: new SyncHook(["params"]),
			make: new AsyncParallelHook(["compilation"]),
			finishMake: new AsyncSeriesHook(["compilation"]),
			afterCompile: new AsyncSeriesHook(["compilation"]),
			readRecords: new AsyncSeriesHook([]),
			emitRecords: new AsyncSeriesHook([]),
			watchRun: new AsyncSeriesHook(["compiler"]),
			failed: new SyncHook(["error"]),
			invalid: new SyncHook(["filename", "changeTime"]),
			watchClose: new SyncHook([]),
			shutdown: new AsyncSeriesHook([]),
			infrastructureLog: new SyncBailHook(["origin", "type", "args"]),
			environment: new SyncHook([]),
			afterEnvironment: new SyncHook([]),
			afterPlugins: new SyncHook(["compiler"]),
			afterResolvers: new SyncHook(["compiler"]),
			entryOption: new SyncBailHook(["context", "entry"])
		});
                // ......
	}
}

Compiler 方法管理着从开始到结束的整个编译过程。它负责设置编译环境,准备编译所需的所有配置和选项,并控制编译流程的各个阶段。在编译过程的关键时刻,Compiler 会触发相应的钩子。例如,在编译完成后,它会触发 done 钩子:

ini 复制代码
this.hooks.done.callAsync(stats, err => {
    // ...
});

Compiler 提供了 apply 方法,允许插件去订阅这些钩子。插件可以使用 tap(用于同步钩子)或 tapAsync / tapPromise(用于异步钩子)方法来订阅。比如一个常规的 Plugin 写法如下:

javascript 复制代码
class MyPlugin {
    apply(compiler) {
        compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
            // ... 插件逻辑 ...
            callback();
        });
    }
}

下面我贴一下在 Compiler.hooks 中一些写插件时常用的钩子所代表的阶段,给有需要写 Plugin 的读者做参考,有不对的地方欢迎评论区指正:

  • initialize: 在编译器初始化阶段触发。

  • entryOption: 在处理入口选项后触发。这是一个起点,插件可以在此阶段修改入口配置

  • make: 在构建模块时触发。这是一个核心阶段,插件在此处添加模块或改变构建流程。

  • compile: 在编译流程开始前触发。

  • emit: 在将编译后的资源输出到输出目录之前触发。

  • done: 在编译过程完成后触发。

  • failed: 当编译过程中发生错误时触发,我们可以拿来用于错误处理和日志记录。

举个例子,我们可以拿 compile 钩子和 done 钩子做一个构建时间的打印:

javascript 复制代码
class BuildTimePlugin {
    apply(compiler) {
        compiler.hooks.compile.tap('BuildTimePlugin', params => {
            this.startTime = Date.now();
            console.log('构建开始...');
        });

        compiler.hooks.done.tap('BuildTimePlugin', stats => {
            const endTime = Date.now();
            console.log(`构建结束,耗时:${endTime - this.startTime}ms`);
        });
    }
}
相关推荐
魔术师ID12 分钟前
vue 指令
前端·javascript·vue.js
Clown9542 分钟前
Go语言爬虫系列教程 实战项目JS逆向实现CSDN文章导出教程
javascript·爬虫·golang
星空寻流年1 小时前
css3基于伸缩盒模型生成一个小案例
javascript·css·css3
waterHBO2 小时前
直接从图片生成 html
前端·javascript·html
EndingCoder3 小时前
JavaScript 时间转换:从 HH:mm:ss 到十进制小时及反向转换
javascript
互联网搬砖老肖3 小时前
React组件(一):生命周期
前端·javascript·react.js
HCl+NaOH=NaCl+H_2O3 小时前
Quasar组件 Carousel走马灯
javascript·vue.js·ecmascript
℘团子এ4 小时前
vue3中预览Excel文件
前端·javascript
shmily麻瓜小菜鸡4 小时前
在 Angular 中, `if...else if...else`
前端·javascript·angular.js
OK_boom6 小时前
React-useRef
javascript·react.js·ecmascript