本文深入解析Webpack核心依赖库Tapable,详细阐述SyncHook、SyncBailHook、SyncWaterfallHook及Async系列钩子的执行机制。通过源码模拟实现,对比同步与异步、串行与并行的区别,揭示Webpack插件系统的底层事件流原理,帮助开发者深入理解Webpack源码。
本文旨在深入解析Webpack的核心模块Tapable,帮助读者掌握其工作原理。

前言
Tapable核心机制概述
Webpack作为现代JavaScript应用的静态模块打包器,是实现前端项目自动化与优化的关键工具。其Loader(加载器)和Plugin(插件)由官方及社区共同开发。由于缺乏系统的开发文档,想要编写高质量的Loader或Plugin,必须深入理解Webpack的运行原理,即阅读其源码。而Tapable正是Webpack依赖的核心库,可以说,不理解Tapable就无法真正读懂Webpack的源码。因此,本文将详细解析Tapable提供的各类类,并通过模拟实现来辅助理解。
Tapable介绍
Webpack本质上是一种基于事件流的机制,其工作流程是将各个插件串联起来执行。实现这一机制的核心正是Tapable。在Webpack中,最核心的Compiler(负责编译)和Compilation(负责创建bundles)都是Tapable构造函数的实例。
在Webpack 4.0的源码中,你会频繁看到以Sync或Async开头、以Hook结尾的方法。这些方法构成了Tapable核心库的类,提供了不同的事件流执行机制,我们称之为“钩子”。
// 引入 tapable 如下
const {
SyncHook,
SyncBailHook,
SyncWaterfallHook,
SyncLoopHook,
AsyncParallelHook,
AsyncParallelBailHook,
AsyncSeriesHook,
AsyncSeriesBailHook,
AsyncSeriesWaterfallHook
} = require("tapable");
上述实现事件流机制的“钩子”大致可分为两类:“同步”和“异步”。其中,“异步”又细分为“并行”和“串行”,而所有的“同步”钩子均为串行执行。
同步钩子(Sync Hooks)详解
Sync类型的钩子
1、SyncHook
SyncHook采用串行同步执行模式,且不关注事件处理函数的返回值。当事件被触发后,它会按照事件注册的先后顺序,依次执行所有已注册的事件处理函数。
// SyncHook 钩子的使用
const { SyncHook } = require("tapable");
// 创建实例
let syncHook = new SyncHook(["name", "age"]);
// 注册事件
syncHook.tap("1", (name, age) => console.log("1", name, age));
syncHook.tap("2", (name, age) => console.log("2", name, age));
syncHook.tap("3", (name, age) => console.log("3", name, age));
// 触发事件,让监听函数执行
syncHook.call("panda", 18);
// 1 panda 18
// 2 panda 18
// 3 panda 18
在Tapable中,SyncHook是一个类。注册事件前需先创建其实例,创建实例时可选传一个数组,该数组定义了触发事件时传入的参数列表。实例的tap方法用于注册事件,接受两个参数:第一个是事件名称(在Webpack中通常用于标识插件名,仅为注释作用,可随意命名);第二个是事件处理函数,其参数对应于调用call方法触发事件时传入的参数。
// 模拟 SyncHook 类
class SyncHook {
constructor(args) {
this.args = args;
this.tasks = [];
}
tap(name, task) {
this.tasks.push(task);
}
call(...args) {
// 也可在参数不足时抛出异常
if (args.length < this.args.length) throw new Error("参数不足");
// 传入参数严格对应创建实例传入数组中的规定的参数,执行时多余的参数为 undefined
args = args.slice(0, this.args.length);
// 依次执行事件处理函数
this.tasks.forEach(task => task(...args));
}
}
tasks数组用于存储所有注册的事件处理函数。当调用call方法时,若传入的参数数量超过创建SyncHook实例时指定的数组长度,多余参数将被忽略或视为undefined;若参数不足,则可能抛出异常,这种处理方式较为僵化,后续示例中将不再严格遵循此限制。
2、SyncBailHook
SyncBailHook同样采用串行同步执行模式。其特殊之处在于“保险”机制:如果某个事件处理函数执行后返回了非undefined的值,系统将跳过剩余未执行的事件处理函数,直接结束执行。
// SyncBailHook 钩子的使用
const { SyncBailHook } = require("tapable");
// 创建实例
let syncBailHook = new SyncBailHook(["name", "age"]);
// 注册事件
syncBailHook.tap("1", (name, age) => console.log("1", name, age));
syncBailHook.tap("2", (name, age) => {
console.log("2", name, age);
return "2";
});
syncBailHook.tap("3", (name, age) => console.log("3", name, age));
// 触发事件,让监听函数执行
syncBailHook.call("panda", 18);
// 1 panda 18
// 2 panda 18
通过上述用法对比可以看出,SyncHook与SyncBailHook在逻辑上的主要区别在于call方法的实现不同,从而导致事件执行机制的差异。对于其他类型的“钩子”,核心区别也在于call方法的实现。接下来将展示SyncBailHook类的具体实现。
// 模拟 SyncBailHook 类
class SyncBailHook {
constructor(args) {
this.args = args;
this.tasks = [];
}
tap(name, task) {
this.tasks.push(task);
}
call(...args) {
// 传入参数严格对应创建实例传入数组中的规定的参数,执行时多余的参数为 undefined
args = args.slice(0, this.args.length);
// 依次执行事件处理函数,如果返回值不为空,则停止向下执行
let i = 0, ret;
do {
ret = this.tasks[i++](...args);
} while (!ret);
}
}
在上述代码的call方法中,我们定义返回值ret。若第一次执行后ret为undefined,则继续循环执行后续函数;若ret有值,则立即终止循环,从而实现“保险”功能。
3、SyncWaterfallHook
SyncWaterfallHook采用串行同步执行模式。其特点是:前一个事件处理函数的返回值将作为参数传递给下一个事件处理函数,依次类推。因此,只有第一个事件处理函数的参数可以通过call方法直接传递,而call方法的最终返回值即为最后一个事件处理函数的返回值。
// SyncWaterfallHook 钩子的使用
const { SyncWaterfallHook } = require("tapable");
// 创建实例
let syncWaterfallHook = new SyncWaterfallHook(["name", "age"]);
// 注册事件
syncWaterfallHook.tap("1", (name, age) => {
console.log("1", name, age);
return "1";
});
syncWaterfallHook.tap("2", data => {
console.log("2", data);
return "2";
});
syncWaterfallHook.tap("3", data => {
console.log("3", data);
return "3"
});
// 触发事件,让监听函数执行
let ret = syncWaterfallHook.call("panda", 18);
console.log("call", ret);
// 1 panda 18
// 2 1
// 3 2
// call 3
SyncWaterfallHook名称中的“瀑布”形象地描述了事件处理函数执行的特点。与SyncHook和SyncBailHook的区别在于事件处理函数返回值的流动性。接下来查看SyncWaterfallHook类的具体实现。
// 模拟 SyncWaterfallHook 类
class SyncWaterfallHook {
constructor(args) {
this.args = args;
this.tasks = [];
}
tap(name, task) {
this.tasks.push(task);
}
call(...args) {
// 传入参数严格对应创建实例传入数组中的规定的参数,执行时多余的参数为 undefined
args = args.slice(0, this.args.length);
// 依次执行事件处理函数,事件处理函数的返回值作为下一个事件处理函数的参数
let [first, ...others] = this.tasks;
return reduce((ret, task) => task(ret), first(...args));
}
}
在上述代码中,call方法的逻辑是将存储事件处理函数的tasks数组拆分为两部分:第一个事件处理函数和其余事件处理函数的数组。使用reduce方法进行归并,将第一个事件处理函数执行后的返回值作为归并的初始值,依次调用其余事件处理函数,并将上一次归并的返回值作为参数传递。
4、SyncLoopHook
SyncLoopHook采用串行同步执行模式。当事件处理函数返回true时,表示继续循环,即重复执行当前事件处理函数;返回undefined时,表示结束循环。SyncLoopHook的循环机制与SyncBailHook不同:SyncBailHook仅决定是否继续向下执行后续的事件处理函数,而SyncLoopHook则是循环执行每一个事件处理函数,直到返回undefined为止,才会继续向下执行其他事件处理函数。
// SyncLoopHook 钩子的使用
const { SyncLoopHook } = require("tapable");
// 创建实例
let syncLoopHook = new SyncLoopHook(["name", "age"]);
// 定义辅助变量
let total1 = 0;
let total2 = 0;
// 注册事件
syncLoopHook.tap("1", (name, age) => {
console.log("1", name, age, total1);
return total1++ < 2 ? true : undefined;
});
syncLoopHook.tap("2", (name, age) => {
console.log("2", name, age, total2);
return total2++ < 2 ? true : undefined;
});
syncLoopHook.tap("3", (name, age) => console.log("3", name, age));
// 触发事件,让监听函数执行
syncLoopHook.call("panda", 18);
// 1 panda 18 0
// 1 panda 18 1
// 1 panda 18 2
// 2 panda 18 0
// 2 panda 18 1
// 2 panda 18 2
// 3 panda 18
通过上述执行结果可以清晰看到SyncLoopHook的执行机制。但需注意,返回值必须严格为true才会触发循环,多次执行当前事件处理函数时,必须严格返回undefined才能结束循环并执行后续事件处理函数。如果返回值既不是true也不是undefined,则会导致死循环。
在了解SyncLoopHook的执行机制后,接下来分析其call方法的具体实现。
// 模拟 SyncLoopHook 类
class SyncLoopHook {
constructor(args) {
this.args = args;
this.tasks = [];
}
tap(name, task) {
this.tasks.push(task);
}
call(...args) {
// 传入参数严格对应创建实例传入数组中的规定的参数,执行时多余的参数为 undefined
args = args.slice(0, this.args.length);
// 依次执行事件处理函数,如果返回值为 true,则继续执行当前事件处理函数
// 直到返回 undefined,则继续向下执行其他事件处理函数
this.tasks.forEach(task => {
let ret;
do {
ret = this.task(...args);
} while (ret === true || !(ret === undefined));
});
}
}
在上述代码中,SyncLoopHook类call方法的实现类似于SyncHook和SyncBailHook的结合版。外层循环遍历整个tasks事件处理函数队列,内层通过返回值控制循环,从而控制每个事件处理函数的执行次数。
注意:在Sync类型“钩子”下执行的插件均为顺序执行,只能使用tap方法注册。
异步钩子(Async Hooks)详解
Async类型的钩子
Async类型钩子可以使用tap、tapSync和tapPromise注册不同类型的插件“钩子”,分别通过call、callAsync和promise方法触发。下文将针对AsyncParallelHook和AsyncSeriesHook的async和promise两种方式分别进行介绍和模拟。
1、AsyncParallelHook
AsyncParallelHook采用异步并行执行模式。通过tapAsync注册的事件,通过callAsync触发;通过tapPromise注册的事件,通过promise触发(返回值可调用then方法)。
(1) tapAsync/callAsync
callAsync方法的最后一个参数为回调函数,该回调在所有事件处理函数执行完毕后执行。
// AsyncParallelHook 钩子:tapAsync/callAsync 的使用
const { AsyncParallelHook } = require("tapable");
// 创建实例
let asyncParallelHook = new AsyncParallelHook(["name", "age"]);
// 注册事件
console.time("time");
asyncParallelHook.tapAsync("1", (name, age, done) => {
settimeout(() => {
console.log("1", name, age, new Date());
done();
}, 1000);
});
asyncParallelHook.tapAsync("2", (name, age, done) => {
settimeout(() => {
console.log("2", name, age, new Date());
done();
}, 2000);
});
asyncParallelHook.tapAsync("3", (name, age, done) => {
settimeout(() => {
console.log("3", name, age, new Date());
done();
console.timeEnd("time");
}, 3000);
});
// 触发事件,让监听函数执行
asyncParallelHook.callAsync("panda", 18, () => {
console.log("complete");
});
// 1 panda 18 2018-08-07T10:38:32.675Z
// 2 panda 18 2018-08-07T10:38:33.674Z
// 3 panda 18 2018-08-07T10:38:34.674Z
// complete
// time: 3005.060ms
异步并行是指,即使事件处理函数内部的三个定时器异步操作最长耗时为3秒,但三个事件处理函数执行完成的总耗时也接近3秒。这意味着三个事件处理函数几乎是同时执行的,无需相互等待。
所有通过tapAsync注册的事件处理函数,其最后一个参数均为一个回调函数done。每个事件处理函数在异步代码执行完毕后调用done函数,这样可以保证callAsync会在所有异步函数都执行完毕后执行。接下来分析callAsync的具体实现。
// 模拟 AsyncParallelHook 类:tapAsync/callAsync
class AsyncParallelHook {
constructor(args) {
this.args = args;
this.tasks = [];
}
tabAsync(name, task) {
this.tasks.push(task);
}
callAsync(...args) {
// 先取出最后传入的回调函数
let finalCallback = args.pop();
// 传入参数严格对应创建实例传入数组中的规定的参数,执行时多余的参数为 undefined
args = args.slice(0, this.args.length);
// 定义一个 i 变量和 done 函数,每次执行检测 i 值和队列长度,决定是否执行 callAsync 的回调函数
let i = 0;
let done = () => {
if (++i === this.tasks.length) {
finalCallback();
}
};
// 依次执行事件处理函数
this.tasks.forEach(task => task(...args, done));
}
}
在callAsync中,首先取出最后一个参数(即所有事件处理函数执行完毕后执行的回调),并定义done函数。通过比较计数器i与存储事件处理函数的数组tasks的长度来确定是否执行回调。循环执行每一个事件处理函数,并将done作为最后一个参数传入。因此,每个事件处理函数内部的异步操作完成时,执行done就是为了检测是否该执行callAsync的回调。当所有事件处理函数均执行完毕,满足done函数内部i与length相等的条件时,则调用callAsync的回调。
(2) tapPromise/promise
要使用tapPromise注册事件,对事件处理函数有一个要求:必须返回一个Promise实例。而promise方法也返回一个Promise实例,callAsync的回调函数在promise方法中通过then方式替代。
// AsyncParallelHook 钩子:tapPromise/promise 的使用
const { AsyncParallelHook } = require("tapable");
// 创建实例
let asyncParallelHook = new AsyncParallelHook(["name", "age"]);
// 注册事件
console.time("time");
asyncParallelHook.tapPromise("1", (name, age) => {
return new Promise((resolve, reject) => {
settimeout(() => {
console.log("1", name, age, new Date());
resolve("1");
}, 1000);
});
});
asyncParallelHook.tapPromise("2", (name, age) => {
return new Promise((resolve, reject) => {
settimeout(() => {
console.log("2", name, age, new Date());
resolve("2");
}, 2000);
});
});
asyncParallelHook.tapPromise("3", (name, age) => {
return new Promise((resolve, reject) => {
settimeout(() => {
console.log("3", name, age, new Date());
resolve("3");
console.timeEnd("time");
}, 3000);
});
});
// 触发事件,让监听函数执行
asyncParallelHook.promise("panda", 18).then(ret => {
console.log(ret);
});
// 1 panda 18 2018-08-07T12:17:21.741Z
// 2 panda 18 2018-08-07T12:17:22.736Z
// 3 panda 18 2018-08-07T12:17:23.739Z
// time: 3006.542ms
// [ '1', '2', '3' ]
在上述每个通过tapPromise注册的事件处理函数中,均返回一个Promise实例,并将返回值传入resolve方法。调用promise方法触发事件时,如果所有事件处理函数返回的Promise实例结果都成功,会将结果存储在数组中,并作为参数传递给promise的then方法中成功的回调;如果有一个失败,则将失败的结果返回,作为参数传递给失败的回调。
// 模拟 AsyncParallelHook 类 tapPromise/promise
class AsyncParallelHook {
constructor(args) {
this.args = args;
this.tasks = [];
}
tapPromise(name, task) {
this.tasks.push(task);
}
promise(...args) {
// 传入参数严格对应创建实例传入数组中的规定的参数,执行时多余的参数为 undefined
args = args.slice(0, this.args.length);
// 将所有事件处理函数转换成 Promise 实例,并发执行所有的 Promise
return Promise.all(this.tasks.map(task => task(...args)));
}
}
根据上述对tapPromise和promise使用的描述,可以推测,promise方法的逻辑是通过Promise.all来实现的。
2、AsyncSeriesHook
AsyncSeriesHook采用异步串行执行模式。与AsyncParallelHook相同,通过tapAsync注册的事件,通过callAsync触发;通过tapPromise注册的事件,通过promise触发,可调用then方法。
(1) tapAsync/callAsync
与AsyncParallelHook的callAsync方法类似,AsyncSeriesHook的callAsync方法也是通过传入回调函数的方式,在所有事件处理函数执行完毕后执行callAsync的回调函数。
// AsyncSeriesHook 钩子:tapAsync/callAsync 的使用
const { AsyncSeriesHook } = require("tapable");
// 创建实例
let asyncSeriesHook = new AsyncSeriesHook(["name", "age"]);
// 注册事件
console.time("time");
asyncSeriesHook.tapAsync("1", (name, age, next) => {
settimeout(() => {
console.log("1", name, age, new Date());
next();
}, 1000);
});
asyncSeriesHook.tapAsync("2", (name, age, next) => {
settimeout(() => {
console.log("2", name, age, new Date());
next();
}, 2000);
});
asyncSeriesHook.tapAsync("3", (name, age, next) => {
settimeout(() => {
console.log("3", name, age, new Date());
next();
console.timeEnd("time");
}, 3000);
});
// 触发事件,让监听函数执行
asyncSeriesHook.callAsync("panda", 18, () => {
console.log("complete");
});
// 1 panda 18 2018-08-07T14:40:52.896Z
// 2 panda 18 2018-08-07T14:40:54.901Z
// 3 panda 18 2018-08-07T14:40:57.901Z
// complete
// time: 6008.790ms
异步串行是指,事件处理函数内部三个定时器的异步执行时间分别为1秒、2秒和3秒,而三个事件处理函数执行完的总耗时接近6秒。这意味着三个事件处理函数的执行需要排队,必须逐个执行,当前事件处理函数执行完毕后才能执行下一个。
AsyncSeriesHook类的tapAsync方法注册的事件处理函数参数中的next,可与AsyncParallelHook类中tapAsync方法参数的done进行类比。二者同为回调函数,不同点在于AsyncSeriesHook与AsyncParallelHook的callAsync方法在“并行”和“串行”实现方式上的差异。
// 模拟 AsyncSeriesHook 类:tapAsync/callAsync
class AsyncSeriesHook {
constructor(args) {
this.args = args;
this.tasks = [];
}
tabAsync(name, task) {
this.tasks.push(task);
}
callAsync(...args) {
// 先取出最后传入的回调函数
let finalCallback = args.pop();
// 传入参数严格对应创建实例传入数组中的规定的参数,执行时多余的参数为 undefined
args = args.slice(0, this.args.length);
// 定义一个 i 变量和 next 函数,每次取出一个事件处理函数执行,并维护 i 的值
// 直到所有事件处理函数都执行完,调用 callAsync 的回调
// 如果事件处理函数中没有调用 next,则无法继续
let i = 0;
let next = () => {
let task = this.tasks[i++];
task ? task(...args, next) : finalCallback();
};
next();
}
}
AsyncParallelHook是通过循环依次执行所有事件处理函数,done方法仅用于检测是否已满足条件以执行callAsync的回调。如果中间某个事件处理函数没有调用done,只是不会调用callAsync的回调,但所有的事件处理函数都会执行。
而AsyncSeriesHook的next执行机制更像Express和Koa中的中间件。在注册事件的回调中,如果不调用next,则在触发事件时会在没有调用next的事件处理函数位置“卡死”,即不会继续执行后续的事件处理函数。只有都调用next才能继续,而最后一个事件处理函数中调用next则决定是否调用callAsync的回调。
(2) tapPromise/promise
与AsyncParallelHook类似,通过tapPromise注册事件的事件处理函数需要返回一个Promise实例,promise方法最后也返回一个Promise实例。
// AsyncSeriesHook 钩子:tapPromise/promise 的使用
const { AsyncSeriesHook } = require("tapable");
// 创建实例
let asyncSeriesHook = new AsyncSeriesHook(["name", "age"]);
// 注册事件
console.time("time");
asyncSeriesHook.tapPromise("1", (name, age) => {
return new Promise((resolve, reject) => {
settimeout(() => {
console.log("1", name, age, new Date());
resolve("1");
}, 1000);
});
});
asyncSeriesHook.tapPromise("2", (name, age) => {
return new Promise((resolve, reject) => {
settimeout(() => {
console.log("2", name, age, new Date());
resolve("2");
}, 2000);
});
});
asyncParallelHook.tapPromise("3", (name, age) => {
return new Promise((resolve, reject) => {
settimeout(() => {
console.log("3", name, age, new Date());
resolve("3");
console.timeEnd("time");
}, 3000);
});
});
// 触发事件,让监听函数执行
asyncSeriesHook.promise("panda", 18).then(ret => {
console.log(ret);
});
// 1 panda 18 2018-08-07T14:45:52.896Z
// 2 panda 18 2018-08-07T14:45:54.901Z
// 3 panda 18 2018-08-07T14:45:57.901Z
// time: 6014.291ms
// [ '1', '2', '3' ]
分析上述执行过程,所有的事件处理函数都返回了Promise的实例。若想实现“串行”,则需要让每一个返回的Promise实例都调用then,并在then中执行下一个事件处理函数。这样就保证了只有上一个事件处理函数执行完后才会执行下一个。
// 模拟 AsyncSeriesHook 类 tapPromise/promise
class AsyncSeriesHook {
constructor(args) {
this.args = args;
this.tasks = [];
}
tapPromise(name, task) {
this.tasks.push(task);
}
promise(...args) {
// 传入参数严格对应创建实例传入数组中的规定的参数,执行时多余的参数为 undefined
args = args.slice(0, this.args.length);
// 将每个事件处理函数执行并调用返回 Promise 实例的 then 方法
// 让下一个事件处理函数在 then 方法成功的回调中执行
let [first, ...others] = this.tasks;
return others.reduce((promise, task) => {
return promise.then(() => task(...args));
}, first(...args));
}
}
上述代码中的“串行”是使用reduce归来实现的。首先将存储所有事件处理函数的数组tasks解构成两部分:第一个事件处理函数和存储其他事件处理函数的数组others。对others进行归并,将第一个事件处理函数执行后返回的Promise实例作为归并的初始值。这样在归并的过程中,上一个值始终是上一个事件处理函数返回的Promise实例,可直接调用then方法,并在then的回调中执行下一个事件处理函数,直到归并完成。将reduce最后返回的Promise实例作为promise方法的返回值,则实现promise方法执行后继续调用then来实现后续逻辑。
对其他异步钩子补充
在上述Async异步类型的“钩子”中,我们主要介绍了“串行”和“并行”(AsyncParallelHook和AsyncSeriesHook)以及回调和Promise的两种注册和触发事件的方式。还有一些其他具有一定特点的异步“钩子”并未进行分析,因为它们的机制与同步对应的“钩子”非常相似。
AsyncParallelBailHook和AsyncSeriesBailHook分别为异步“并行”和“串行”执行的“钩子”。若返回值不为undefined(即有返回值),则立即停止向下执行其他事件处理函数。其实现逻辑可结合AsyncParallelHook、AsyncSeriesHook和SyncBailHook。
AsyncSeriesWaterfallHook为异步“串行”执行的“钩子”。前一个事件处理函数的返回值将作为参数传递给下一个事件处理函数。其实现逻辑可结合AsyncSeriesHook和SyncWaterfallHook。
总结
总结
在Tapable源码中,注册事件的方法tap、tapSync、tapPromise和触发事件的方法call、callAsync、promise都是通过compile方法快速编译生成的。本文中这些方法的实现只是遵照了Tapable库这些“钩子”的事件处理机制进行了模拟,旨在方便我们了解Tapable,为学习Webpack原理做铺垫。在Webpack中,这些“钩子”的真正作用是将通过配置文件读取的插件与插件、加载器与加载器之间进行连接,“并行”或“串行”执行。相信在我们对Tapable中这些“钩子”的事件机制有所了解之后,再重新学习Webpack的源码应该会有所头绪。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持本站。