Webpack事件流模块-tapable

Tapable是一个专注于事件处理的模块,类似于 Nodejs 的EventEmitter。相比EventEmitter,Tapable 提供了例如瀑布流、线性异步、并行异步等更加高级的事件流处理机制。正如其名,通过它我们能触摸到 Webpack 编译的各个阶段,这也是 Webpack 插件机制的核心设计理念。

Hooks

Tapable 导出了多个功能各异的钩子类(Hook Class),Webpack 在编译的每个阶段都会有一个钩子实例。插件可以通过taptapAynctapPromise方法来注册同步或者异步回调,每个方法都有插件名称和回调函数两个参数。

让我们通过“小明的一天”这个例子来了解这些钩子吧。

同步 Hooks

每一个钩子类的构造函数都接受一个数组作为参数,这个数组决定了这个钩子触发时可以传递的参数,而这些参数会传递给每一个回调函数。

先让我们了解一下简单的同步钩子。

同步 Hooks 的最后一个回调的返回值会成为 call 方法的返回值

SyncHook

同步钩子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
constructor() {
this.hooks = {
wakeUp: new SyncHook([])
};
}

// 小明起床
wakeUp() {
// 没有参数
this.hooks.wakeUp.call();
}


// ...

// 通过tap方法监听`wakeUp`
xiaoming.hooks.wakeUp.tap('Plugin', () => {
log('小明起床了');
});

// 触发钩子上的回调,打印 “小明起床了”
xiaoming.wakeUp();

SyncBailHook

同步钩子,如果某个回调返回了值,后续的回调会不再执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
constructor() {
this.hooks = {
// ...
breakfast: new SyncBailHook([]),
};
}

dress() {
this.hooks.breakfast.call();
}

// ...

xiaoming.hooks.breakfast.tap('Dad', () => {
log('爸爸给了他讨厌的鸡蛋,他拒绝了');
});

xiaoming.hooks.breakfast.tap('Mom', () => {
log('妈妈给小明喜欢的蛋糕,他收下了');
return '蛋糕';
});

xiaoming.hooks.breakfast.tap('Sister', () => {
log('小明已经有了早餐,姐姐不会给他了');
});

// ...

xiaoming.breakfast();

/*

输出结果:

爸爸给了他讨厌的鸡蛋,他拒绝了

妈妈给小明喜欢的蛋糕,他收下了

*/

SyncWaterfallHook

同步瀑布流钩子,这个和同步钩子的区别在于,下一个回调会接受上一个返回的结果,有点像 Array 的reduce方法。

SyncWaterfallHook 的构造函数的参数数组必须有一个值。

“我们假设小明去上学了,路上遇到了几个同学,然后记录整个过程。”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
constructor() {
this.hooks = {
// ...
gotoSchool: new SyncWaterfallHook(['who']),
};
}

gotoSchool() {
const classmates = this.hooks.gotoSchool.call(['小明']);
log(classmates.join(',') + '一起到了教室');
}

// ...

xiaoming.hooks.gotoSchool.tap('Xiaozhang', classmates => {
log('小张遇到了小明,现在一起上学的有:' + [...classmates, '小张'].join(','));

return [...classmates, '小张'];
});

xiaoming.hooks.gotoSchool.tap('Xiaohong', classmates => {
log('小红遇到了小明,现在一起上学的有:' + [...classmates, '小红'].join(','));
return [...classmates, '小红'];
});

// ...

xiaoming.gotoSchool();

/*

输出结果:

小明去上学了

小张遇到了小明,现在一起上学的有:小明,小张

小红遇到了小明,现在一起上学的有:小明,小张,小红

小明,小张,小红一起到了教室

*/

SyncLoopHook

吐槽一下官方文档,只留了个TODO,关于这个的文档和 Demo 都没有,只能通过搜索资料和看源码分析怎么用。

当监听函数被触发的时候,如果该监听函数返回 true 时则这个监听函数会反复执行,如果返回 undefined 则表示退出循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
let queue = new SyncLoopHook([']);

let count = 3;
queue.tap('1', function () {
console.log('count: ', count--);
if (count > 0) {
return true;
}
return;
});

queue.call();

/*

输出结果:

3
2
1

*/

异步 Hooks

现在很多任务都是异步操作,所以 tapable 还提供了几种异步钩子。

而异步的情况比同步就要复杂一些,异步可以并行(Parallel)执行和线性(Series)执行,同时也会有 Bail, Waterfall 等类型。

下面是不同执行态的 Hook 支持的类型。

执行\类型 Basic Bail Waterfall
Sync
AsyncSeries
AsyncParallel

异步 hooks 有额外的tapAsynctapPromise两种方式来注册异步回调,也就是支持 callback 参数和 promise 的方式来异步返回结果。

因为是并行执行,所以异步并行钩子并没有瀑布类型。

AsyncSeriesHook

异步线性钩子,和同步钩子很相似,区别在于回调函数内部可以是异步的,并在异步完成后通知钩子执行下一个回调。

“我们继续小明的一天,现在老师来了,小明作为小组长开始收集组内同学昨天的作业。”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
constructor() {
this.hooks = {
// ...
collectHomeWork: new AsyncSeriesHook(['homeworks'])
};
}

collectHomeWork(callback) {
log('老师让小明收作业');
const homeworks = [];
return this.hooks.collectHomeWork.callAsync(homeworks, err => {
if (err) {
return callback(err);
}
callback(null, homeworks);
});
}

// ...

xiaoming.hooks.collectHomeWork.tapAsync(
'Xiaohong',
(homeworks, callback) => {
log('小红在书包里面找作业本');
setTimeout(() => {
log('收到了小红的作业');
homeworks.push('小红');
callback();
}, 1000);
}
);

xiaoming.hooks.collectHomeWork.tapAsync(
'Xiaozhang',
(homeworks, callback) => {
log('小张说等他5秒钟赶完作业...');
setTimeout(() => {
log('收到了小张的作业');
homeworks.push('小张');
callback();
}, 5000);
}
);

xiaoming.hooks.collectHomeWork.tapAsync(
'Xiaoli',
(homeworks, callback) => {
log('小李直接交出了作业');
homeworks.push('小李');
callback();
}
);

// ...

xiaoming.collectHomeWork((error, homeworks) => {
log('作业手机完毕,收到了' + homeworks.join(',') + '的作业');
});

/*

输出结果:

3:54:17 PM: 老师让小明收作业

3:54:17 PM: 小红在书包里面找作业本

3:54:18 PM: 收到了小红的作业

3:54:18 PM: 小张说等他5秒钟赶完作业...

3:54:23 PM: 收到了小张的作业

3:54:23 PM: 小李直接交出了作业

3:54:23 PM: 作业搜集完毕,收到了小红,小张,小李的作业

*/

需要注意的是异步钩子的最后一个回调函数的返回值并不会成为触发方法的返回值,所以在整个调用链是直接对homeworks对象进行操作,而不是返回新的对象。

AsyncSeriesBailHook

与 SyncBailHook 类似的,只要某个异步回调返回了值,那么会中断调用链,剩下的回调将会执行。

“今天班级组织了一次抽奖活动,奖品只有一个,谁先抽到谁得。”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
constructor() {
this.hooks = {
// ...
lottery: new AsyncSeriesBailHook([])
}

lottery() {
log('班级组织了抽奖活动,奖品只有一个');
return this.hooks.lottery.promise().then(res => res);
// 用callAsync也是可以的
// return new Promise(resolve =>
// this.hooks.lottery.callAsync((err, a) => {
// resolve(a);
// })
// );
}

// ...


xiaoming.hooks.lottery.tapPromise('Xiaohong', async () => {
log('小红开始抽奖');
await sleep(1000);
log('没抽中');
});

xiaoming.hooks.lottery.tapPromise('Xiaozhang', async () => {
log('小张开始抽奖');
await sleep(1000);
log('抽中了!');
return '小张';
});

xiaoming.hooks.lottery.tapPromise('Xiaoming', async () => {
log('小明开始抽奖');
await sleep(1000);
});

xiaoming.lottery().then(name => {
log('抽奖活动结束,中奖的是' + name);
});

/*

输出结果:

4:36:19 PM: 班级组织了抽奖活动,奖品只有一个

4:36:19 PM: 小红开始抽奖

4:36:20 PM: 没抽中

4:36:20 PM: 小张开始抽奖

4:36:21 PM: 抽中了!

4:36:21 PM: 抽奖活动结束,中奖的是小张
*/

因为小张已经中奖了,所以小明也没有机会再去抽奖了。

AsyncSeriesWaterfallHook

线性异步瀑布流钩子,和 SyncWaterfallHook 的区别在于把同步的回调改成了异步。

抽奖活动后,班级组织了“成语接龙”的游戏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
constructor() {
this.hooks = {
// ...
chengyujielong: new AsyncSeriesWaterfallHook(['firstword'])
}

chengyujielong() {
log('班级组织了“成语接龙”游戏,起始词是“海纳百川”');
return this.hooks.chengyujielong.promise(['海纳百川']).then(res => res);
}

// ...


xiaoming.hooks.chengyujielong.tapPromise('Xiaohong', async (prev) => {
log('小红开始思考 ' + prev);
await sleep(1000);
log('小红回答到:川流不息');
return '川流不息'
});

xiaoming.hooks.chengyujielong.tapPromise('Xiaozhang', async (prev) => {
log('小张开始了思考' + prev);
await sleep(1000);
log('小张回答到:息息相关');
return '息息相关'
});

xiaoming.hooks.chengyujielong.tapPromise('Xiaoming', async (prev) => {
log('小明开始了思考' + prev);
await sleep(1000);
log('小明没有接上 ' + prev);
return prev
});

xiaoming.chengyujielong().then(word => {
log('成语接龙游戏结束,最后一个词是 ' + word);
});

/*

输出结果:

10:48:21 AM: 班级组织了“成语接龙”游戏,起始词是“海纳百川”

10:48:21 AM: 小红开始思考 海纳百川

10:48:22 AM: 小红回答到:川流不息

10:48:22 AM: 小张开始了思考川流不息

10:48:23 AM: 小张回答到:息息相关

10:48:23 AM: 小明开始了思考息息相关

10:48:24 AM: 小明没有接上 息息相关

10:48:24 AM: 成语接龙游戏结束,最后一个词是 息息相关
*/

AsyncParallelHook

前面三个异步钩子都是线性的,现在让我们来了解异步并行钩子。顾名思义,注册的回调是并行执行的。

“娱乐过后,老师突然宣布进行单元测验。”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
constructor() {
this.hooks = {
// ...
test: new AsyncParallelHook([])
}

test() {
log('前一秒还在游戏,老师突然宣布进行单元测试');
return this.hooks.test.promise([]);
}

// ...

xiaoming.hooks.test.tapPromise('Xiaohong', async prev => {
log('小红开始答卷');
await sleep(5000);
log('小红交卷了');
});

xiaoming.hooks.test.tapPromise('Xiaozhang', async prev => {
log('小张开始答卷');
await sleep(2000);
log('小张交卷了');
});

xiaoming.hooks.test.tapPromise('Xiaoming', async prev => {
log('小明开始答卷');
await sleep(4000);
log('小明交卷了');
});


/*

输出结果:

11:16:25 AM: 小红开始答卷

11:16:25 AM: 小张开始答卷

11:16:25 AM: 小明开始答卷

11:16:27 AM: 小张交卷了

11:16:29 AM: 小明交卷了

11:16:30 AM: 小红交卷了

11:16:30 AM: 所有人都交卷了
*/

AsyncParallelBailHook

异步并发,对回调函数的返回值进行依次检测,只要有一个回调函数返回了值,就取其值作为结果。和 SyncBailHook 不同在于,后者是同步执行,所以剩下的回调函数不会执行,而这里是异步的并行执行,所以回调函数本身仍然是执行过的。

AsyncParallelBailHook 对返回值的检测仍然是按回调执行的顺序来的,如果第 1 个回调的处理时间比第 2 个长,那么仍然会等待第一个完成后才会触发异步 call 的回调。如果第 1 个回调也有返回值,即使第 2 个响应更快且有返回值,还是会以第一个为准。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
constructor() {
this.hooks = {
// ...
responder: new AsyncParallelBailHook([])
}

responder() {
log('结束了紧张的单元测试,班级组织了“拼图比赛”,规定时间谁先完成可以获得奖励');
return this.hooks.responder.promise([]);
}

// ...

xiaoming.hooks.responder.tapPromise('Xiaohong', async () => {
log('小红开始拼图');
await sleep(5000);
console.log(1);
});

xiaoming.hooks.responder.tapPromise('Xiaozhang', async () => {
log('小张开始拼图');
await sleep(2000);
console.log(2);
return '小张';
});

xiaoming.hooks.responder.tapPromise('Xiaoming', async () => {
log('小明开始拼图');
await sleep(5000);
console.log(3);
});

xiaoming.responder()
.then(res => {
console.log(11)
log('首先完成拼图的是 ' + res);
});
/*

输出结果:

2:10:48 PM: 小红开始拼图

2:10:48 PM: 小张开始拼图

2:10:48 PM: 小明开始拼图

2:10:51 PM: 完成拼图的是 小张

console:
2
1 // 因为小红的回调靠前,11 仍然会等待小红的回调执行完才会输出
11
3
*/

拦截器(Interception)

所有的 Hooks 都可以通过intercept接口添加拦截器。拦截器有几个触发时机。

  • call:(…args) => void - 当 hook 触发时执行,参数是 hook 传递的参数。
  • tap:(tap:Tap) => void - 当 hook 注册回调时执行。Tap是回调的信息。
  • loop:(…args) => void - 当 loop hook 的每一次循环触发时执行。
  • register:(tap:Tap) => void - 当触发每一个回调时执行。
1
2
3
4
5
6
7
8
interface Tap {
name: string;
type: string;
fn: Function;
stage: number;
context: boolean;
before?: string | Array;
}

Context(上下文)

插件和拦截器可以访问一个可选的context上下文对象,可以在回调和拦截器之间传递数据。

只要声明context: true,就会多一个 context 参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
xiaoming.hooks.wakeUp.tap({ name: "Plugin", context: true }, context => {
log("小明起床了");

// 初始时context为null
if (!context) {
context = {};
}

context.wakeTime = new Date();
});

xiaoming.hooks.wakeUp.intercept({
context: true,
tap(context, tap) {
console.log(111, context.wakeTime);
}
});

xiaoming.hooks.wakeUp.tap({ name: "AnothorPlugin", context: true }, context => {
console.log(222, context.wakeTime);
});

/*

输出:

111 当前时间
111 当前时间
222 当前时间

*/

结语

后续将研究 Tapable 的源码以及在 Webpack 中的实际应用。

完整DEMO

Edit Tapable

右键打开