从 Tapable 中得到的启发
Tapable
Why Tapable
前端开发中 Webpack 本质上是基于事件流的运行机制,它的工作流程是将特定的任务分发到指定的事件钩子中去完成。而实现这一切的核心就是 tapable,Webpack 中的两个基础模块:负责编译的 Compiler 和负责创建 bundle 的 Compilation 都是 tapable 构造函数的实例。
在 Webpack 4.0 的源码中会看到下面这些以 Sync、Async 开头,以 Hook 结尾的方法,这些都tapable 核心库的构造类,它为我们提供不同的事件流机制:
- SyncBailHook:同步执行,前一步返回是 undefined 才会进入下一个函数,否则直接结束
- SyncWaterfallHook:同步执行,前一个函数的执行结果作为下一个函数的参数传入
- SyncLoopHook:同步执行每个函数,若某个函数返回不为 undefined 则继续循环执行该函数,直至该函数返回 undefined 再进入下一个函数
- AsyncParallelHook:异步并行执行,知道所有异步函数执行结束再进入最后的 finalCallback
- AsyncParallelBailHook:异步并行执行,只要监听函数的返回值不为 undefined,就会忽略后面的监听函数执行,直接跳跃到callAsync等触发函数绑定的回调函数,然后执行这个被绑定的回调函数
- AsyncSeriesHook:异步串行执行,函数参数都来自于最初传入的参数
- AsyncSeriesBailHook:异步串行执行,只要监听函数的返回值不为 undefined,就会忽略后面的监听函数执行,直接跳跃到callAsync等触发函数绑定的回调函数,然后执行这个被绑定的回调函数
- AsyncSeriesWaterfallHook:异步串行执行,上一个监听函数的中的callback(err, data)的第二个参数,可以作为下一个监听函数的参数
Tapable and EventEmitter
Tapable 和 EventEmitter 都是实现了 事件的订阅与发布 功能,很多刚接触Tapable的同学可能会懵逼,这玩意和 EventEmitter 有什么区别呢?
- tapable 在创建订阅中心时需要指定回调函数的参数列表
- tapable 触发事件时不需要指定事件名,所有的事件都会被调用
// SyncHook 钩子的使用
const { SyncHook } = require("tapable");
// 创建实例
let syncHook = new SyncHook(["name"]);
// 注册事件
syncHook.tap("login", (name) => console.log(name)); // gaollard
syncHook.tap("register", (name) => console.log(name)); // gaollard
// 触发事件
syncHook.call("gaollard");
// 引入 events 模块
const events = require('events');
// 创建 eventEmitter 对象
const userEvent = new events.EventEmitter();
userEvent.addListener('login', function(name) {
console.log(name)
})
userEvent.addListener('register', function(name) {
console.log(name) // 打印 gaollard
})
userEvent.emit('login', 'gaollard')
Sync 类型钩子
- 注册事件 tap
- 触发事件 call
SyncHook
SyncHook 为串行同步执行,什么都不需要关心,在触发事件之后,会按照事件注册的先后顺序执行所有的事件处理函数,参数就是调用call传入的参数:
// SyncHook 钩子的使用
const { SyncHook } = require("tapable");
// 创建实例 ["name"] 用于声明回调函数的参数个数
let userSyncHook = new SyncHook(["name"]);
// 注册事件 第一个参数为事件名, 第二个参数为注册的回调函数
userSyncHook.tap("login", (name) => console.log(name));
userSyncHook.tap("register", (name) => console.log(name));
// 触发事件
userSyncHook.call("gaollard");
console.log(userSyncHook);
在 tapable 解构的 SyncHook 是一个类,注册事件需先创建实例,创建实例时支持传入一个数组,数组内存储事件触发时传入的参数,实例的 tap 方法用于注册事件,支持传入两个参数,第一个参数为事件名称,在 Webpack 中一般用于存储事件对应的插件名称, 第二个参数为事件处理函数,函数参数为执行 call 方法触发事件时所传入的参数的形参。
SyncBailHook
SyncBailHook 为串行同步执行,如果事件处理函数执行时有一个返回值不为 undefined
,则跳过剩下未执行的事件处理函数:
// 创建实例
let userSyncHook = new SyncBailHook(["name"]);
// 注册事件
userSyncHook.tap("login", (name) => {
console.log(name)
return null // 返回值不为 undefined
});
userSyncHook.tap("register", (name) => {
console.log(name)
});
// 触发事件,让监听函数执行
userSyncHook.call("gaollard"); // 只会打印一次
SyncWaterfallHook
SyncWaterfallHook 为串行同步执行,上一个事件处理函数的返回值作为参数传递给下一个事件处理函数,依次类推,当然,只有第一个事件处理函数的参数可以通过 call 传递,而 call 的返回值为最后一个事件处理函数的返回值:
// 创建实例
let userSyncHook = new SyncWaterfallHook(["name"]);
// 注册事件
userSyncHook.tap("login", (name) => {
console.log('login', name) // 打印 gaollard
});
userSyncHook.tap("register", (name) => {
console.log('register', name) // login回调未返回值, 所以参数为 "gaollard"
return "hello"
});
userSyncHook.tap("enroll", (name) => {
console.log("enroll", name) // register回调返回"hello", 所以参数为 "hello"
});
// 触发事件
userSyncHook.call("gaollard");
SyncLoopHook
SyncLoopHook 为串行同步执行,但是 SyncLoopHook 中的每一个事件回调函数都会被循环执行,事件处理函数返回 undefined 表示结束循环,当前的事件回调循环结束后进入到下一个回调函数中,直到整个流程结束:
// 创建实例
let userSyncHook = new SyncLoopHook(["name"]);
let num1 = 1
// 注册事件
userSyncHook.tap("login", (name) => {
console.log('login', name, num1)
return (++num1) > 10 ? undefined : true
});
userSyncHook.tap("register", (name) => {
console.log('login', name, num1)
return (++num1) > 20 ? undefined : true
});
// 触发事件
userSyncHook.call("manbax");
卧槽,连 21 也被打印出来了??? 发现了 tapable 一个BUG(写完去github提issue)
Async 类型钩子
Async 类型可以使用 tap
、tapSync
和 tapPromise
注册不同类型的插件 “钩子”,分别通过 call、callAsync 和 promise 方法调用,我们下面会针对 AsyncParallelHook 和 AsyncSeriesHook 的 async 和 promise 两种方式分别介绍和模拟。
AsyncParallelHook
AsyncParallelHook
为异步并行执行,通过 tapAsync
注册的事件,通过 callAsync
触发;通过 tapPromise
注册的事件,通过 promise
触发(返回值可以调用 then
方法)
- tapAsync/callAsync
const { AsyncParallelHook } = require("tapable");
// 创建实例
let asyncParallelHook = new AsyncParallelHook(["name"]);
console.time("time");
// 注册事件
asyncParallelHook.tapAsync("login", (name, done) => {
setTimeout(() => {
console.log("login", name, new Date());
done();
}, 1000);
});
asyncParallelHook.tapAsync("register", (name, done) => {
setTimeout(() => {
console.log("register", name, new Date());
done();
console.timeEnd("time");
}, 2000);
});
// 触发事件, callAsync 的最后一个参数为回调函数,在所有事件处理函数执行完毕后执行。
asyncParallelHook.callAsync("manbax", () => {
console.log("complete");
});
上面的代码中:两个事件处理函数会并行的执行,都执行完成后(done 被调用),触发 callAsync 回调函数。所有 tapAsync
注册的事件处理函数最后一个参数都为一个回调函数 done
,每个事件处理函数在异步代码执行完毕后调用 done
函数,则可以保证 callAsync
会在所有异步函数都执行完毕后执行,接下来看一看 callAsync
是如何实现的:
// 模拟 SyncLoopHook 类
class AsyncParallelHook {
constructor(args) {
this.args = args;
this.taps = [];
}
tapAsync(name, task) {
this.taps.push({
name: name,
fn: task,
type: 'sync',
});
}
callAsync(...args) {
if (args.length < this.args.length) {
throw new Error("参数不足");
}
let sum = 0
const fn = args.pop();
const params = args.splice(0, this.args.length);
const done = () => {
(++sum === this.taps.length) && fn()
}
this.taps.forEach(task => {
task.fn(params, done)
})
}
}
- tapPromise/promise
要使用 tapPromise
注册事件,对事件处理函数有一个要求,必须返回一个 Promise 实例,而 promise
方法也返回一个 Promise 实例,callAsync
的回调函数在 promise
方法中用 then
的方式代替:
const { AsyncParallelHook } = require("tapable");
// 创建实例
let asyncParallelHook = new AsyncParallelHook(["name"]);
console.time("time");
// 注册事件
asyncParallelHook.tapPromise("login", (name) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log("login", name, new Date());
resolve();
}, 1000);
})
});
asyncParallelHook.tapAsync("register", (name) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log("register", name, new Date());
resolve();
console.timeEnd("time");
}, 2000);
})
});
// 触发事件
asyncParallelHook.promise("manbax").then(() => {
console.log("complete");
});
AsyncParallelHook 的实现:
class AsyncParallelHook {
constructor(args) {
this.args = args;
this.taps = [];
}
tapPromise(name, task) {
this.taps.push({
name: name,
fn: task,
type: 'async',
});
}
promise(...args) {
if (args.length < this.args.length) {
throw new Error("参数不足");
}
return new Promise.all(this.taps.map(task => task.fn(...args)))
}
}
AsyncParallelBailHook
- tapPromise/promise
const { AsyncParallelBailHook } = require("tapable");
// 创建实例
let userHook = new AsyncParallelBailHook(["name"]);
console.time("time");
// 注册事件
userHook.tapPromise("login", (name) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log("login", name, new Date());
resolve(undefined) // 此处为 undefined 进入到下一个回调
}, 1000);
})
});
userHook.tapPromise("register", (name) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log("register", name, new Date());
resolve("2"); // 这个回调完成后直接触发最后回调
}, 2000);
})
});
userHook.tapPromise("enroll", (name) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log("enroll", name, new Date());
reject("2");
console.timeEnd("time");
}, 3000);
})
});
// 触发事件
userHook.promise("manbax").then((res) => {
console.log("complete", res)
}).catch(err => {
console.log("error", err)
})
AsyncSeriesHook
AsyncSeriesHook 为异步串行执行,与 AsyncParallelHook 相同,通过 tapAsync 注册的事件,通过 callAsync 触发,通过 tapPromise 注册的事件,通过 promise 触发,可以调用 then 方法。
- tapAsync/callAsync
const { AsyncSeriesHook } = require("tapable");
// 创建实例
let userHook = new AsyncSeriesHook(["name"]);
console.time()
userHook.tapAsync('login', function(name, done) {
setTimeout(() => {
console.log('login--', name, new Date())
done()
}, 1000)
})
userHook.tapAsync('register', function(name, done){
setTimeout(() => {
console.log('register--', name, new Date())
done()
}, 2000)
})
// 整个调用花费了 3S
userHook.callAsync('manbax', () => {
console.log('complete')
console.timeEnd()
})
- tapPromise/promise
const { AsyncSeriesHook } = require("tapable");
// 创建实例
let userHook = new AsyncSeriesHook(["name"]);
console.time()
userHook.tapPromise('login', function(name){
return new Promise((resolve) => {
setTimeout(() => {
console.log('login--', name, new Date())
resolve()
}, 1000)
})
})
userHook.tapPromise('register', function(name){
return new Promise((resolve) => {
setTimeout(() => {
console.log('register--', name, new Date())
resolve()
}, 2000)
})
})
// 整个调用花费了 3S
userHook.promise('manbax').then(res => {
console.log('complete')
console.timeEnd()
})
AsyncSeriesBailHook
const { AsyncSeriesBailHook } = require("tapable");
// 创建实例
let userHook = new AsyncSeriesBailHook(["name"]);
console.time()
userHook.tapAsync('login', function(name, done) {
setTimeout(() => {
console.log('login--', name, new Date())
done(1) // 这里返回1, 第二个不会执行(register)
}, 1000)
})
userHook.tapAsync('register', function(name, done){
setTimeout(() => {
console.log('register--', name, new Date())
done(2)
}, 2000)
})
// 整个调用花费了 3S
userHook.callAsync('manbax', (_, data) => {
console.log('complete')
console.timeEnd()
})
AsyncSeriesWaterfallHook
- tapAsync/callAsync: tapAsync 中的 done 回调函数需要传入两个参数,第一个表示是否有异常,第二个为返回值。
const { AsyncSeriesWaterfallHook } = require("tapable");
// 创建实例
let userHook = new AsyncSeriesWaterfallHook(["name"]);
console.time()
userHook.tapAsync('login', function(name, done) {
setTimeout(() => {
console.log('login--', name, new Date())
done(null, "1")
}, 1000)
})
userHook.tapAsync('register', function(name, done){
setTimeout(() => {
console.log('register--', name, new Date())
done(null, "2")
}, 2000)
})
// 整个调用花费了 3S
userHook.callAsync('manbax', (_, data) => {
console.log('complete', data)
console.timeEnd()
})
API模拟实现
SyncHook
// 模拟 SyncHook 类
class MySyncHook {
constructor(args) {
this.args = args;
this.taps = [];
}
tap(name, task) {
this.taps.push({
name: name,
fn: task,
type: 'sync',
});
}
call(...args) {
if (args.length < this.args.length) {
// 参数不足时抛出异常
throw new Error("参数不足");
}
// 参数长度与创建实例传入数组长度一直,不足补 undefined
// 因为长度不足时已经抛出异常,故注释
// args = args.slice(0, this.args.length);
// 依次执行事件处理函数
this.taps.forEach(task => task.fn(...args));
}
}
SyncBailHook
// 模拟 SyncBailHook 类
class SyncBailHook {
constructor(args) {
this.args = args;
this.taps = [];
}
tap(name, task) {
this.taps.push({
name: name,
fn: task,
type: 'sync',
});
}
call(...args) {
if (args.length < this.args.length) {
throw new Error("参数不足");
}
let i = 0, res;
do {
res = this.taps[i++].fn(...args)
} while (res === undefined && i < this.taps.length)
}
}
SyncWaterfallHook
// 模拟 SyncWaterfallHook 类
class SyncWaterfallHook {
constructor(args) {
this.args = args;
this.taps = [];
}
tap(name, task) {
this.taps.push({
name: name,
fn: task,
type: 'sync',
});
}
call(...args) {
if (args.length < this.args.length) {
throw new Error("参数不足");
}
return this.taps.reduce((res, current) => {
let _res = current.fn(res)
// 若当前的回调函数没有返回值,那么就使用上一个参数
return _res !== undefined ? _res : res
}, ...args)
}
}
SyncLoopHook
// 模拟 SyncLoopHook 类
class SyncLoopHook {
constructor(args) {
this.args = args;
this.taps = [];
}
tap(name, task) {
this.taps.push({
name: name,
fn: task,
type: 'sync',
});
}
call(...args) {
if (args.length < this.args.length) {
throw new Error("参数不足");
}
let i = 0
while (i < this.taps.length) {
const task = this.taps[i++].fn
const magic = function () {
let res = task(...args)
if (res !== undefined) {
magic()
}
};
magic();
}
}
}
AsyncSeriesHook
class AsyncSeriesHook {
constructor(args) {
this.args = args;
this.taps = [];
}
tapAsync(name, task) {
this.taps.push({
name: name,
fn: task,
});
}
callAsync(...args) {
if (args.length < this.args.length) {
throw new Error("参数不足");
}
let i = 0
const cb = args.pop()
const _args = args.splice(0, args.length)
const next = () => {
const task = this.taps[i++]
if (task) {
task.fn(..._args, next)
} else {
cb()
}
}
next()
}
}
AsyncSeriesWaterfallHook
class AsyncSeriesWaterfallHook {
constructor() {
this.tasks = [];
}
tap(name, task) {
this.tasks.push(task);
}
call(...args, finalCb) {
let count = 0;
const len = this.tasks.length;
const next = (err, data) => {
if(count === len) return finalCb()
let task = this.tasks[count];
if (count === 0) {
task(...args, next);
} else {
task(data, next);
}
count++;
};
next()
}
}
总结
仔细思考发现 Tapable 事件机制 就像工厂里面生产线:
- 前序工位的输出是后序工位的输入
- 当某个产品在流产线上的工位发生异常时,这个产品的后序流程终止
它非常适合用于解决流水作业,就像 Webpack 对文件进行处理正是这样的场景。学习 tapable 有助于帮助我们更高的理解 Webpack。
tapable的注册事件的方法有:tab/tapSync/tapPromise 和触发事件的方法 call/callAsync/promise,在 Webpack 中,我们通过这些API来设计钩子,这些 “钩子” 能够将 Webpack 中插件/加载器/功能独立的模块连接起来,以减少耦合性和提高扩展性。