共计 7460 个字符,预计需要花费 19 分钟才能阅读完成。
前文
最近在看 webpack
的源码,发现有个比较头疼的点是:代码看起来非常跳跃,往往看不到几行就插入一段新内容,为了理解又不得不先学习相关的前置知识。层层嵌套之后,发现最基础的还是 tapable
模型,因此先对这部分的内容做一个介绍。
引子 -webpack 的基本流程
Webpack
的流程可以分为以下三大阶段:
初始化 :启动构建,读取与合并配置参数,加载 Plugin
,实例化 Compiler。这个 compile 对象会穿行在本次编译的整个周期。
编译 :从 Entry 发出,针对每个 Module 串行调用对应的 Loader 去翻译文件内容,再找到该 Module 依赖的 Module,递归地进行编译处理。
输出:对编译后的 Module
组合成 Chunk
,把 Chunk
转换成文件,输出到文件系统。
在这个过程中,最核心的就是 插件化的设计:在不同的阶段执行相应的一些插件,来执行某些功能。
而这里的 阶段,指的就是hook
。理论太抽象,来看一段 webpack 的源码(4.x 版本):
// webpack/lib/MultiCompiler.js
const {Tapable, SyncHook, MultiHook} = require("tapable");
class MultiCompiler extends Tapable {constructor(compilers) {super();
this.hooks = {done: new SyncHook(["stats"]),
invalid: new MultiHook(compilers.map(c => c.hooks.invalid)),
run: new MultiHook(compilers.map(c => c.hooks.run)),
watchClose: new SyncHook([]),
watchRun: new MultiHook(compilers.map(c => c.hooks.watchRun)),
infrastructureLog: new MultiHook(compilers.map(c => c.hooks.infrastructureLog)
)
};
}
/// 省略其他代码
}
这是 compile
的构造函数,有几个注意点:
- 显示继承了
Tapable
,也就是本文的话题对象 - 注意看 this.hooks 部分的内容:
done
invalid
,run
,watchClose
等等都是内置的生命周期,具体的代码暂时不去关心。
这部分代码主要是为了说明一个思路: webpack 的生命周期 hook
,实际上是一个个插件的集合,代表的含义是,在某个阶段需要挂载某些插件。
到这里,脑海里有这种大概雏形就好,接下来我们开始介绍Tapable
。
Tapable 机制初探
Tapable 的核心思路有点类似于 nodejs
中的 events
,最基本的 发布 / 订阅 模式。
const EventEmitter = require('events');
const myEmitter = new EventEmitter();
// 注册事件对应的监听函数
myEmitter.on('安歌发布新文章', (title, tag) => {console.log("前去围观并吐槽",title, tag)
});
// 触发事件 并传入参数
myEmitter.emit('安歌发布新文章',’标题 tapable 机制‘, '标签 webpack');
这个结构很简单也很清晰:
- events.on 用于注册要监听的事件和对应的毁掉方法
- events.emit 用于触发对应的事件
tapable
的核心用法与此相似,那为什么多次一举要使用它呢?
根据前面的 demo,不妨假设一下,如果我们注册了很多事件,比如 event.on(’起床‘)
,event.on(’吃饭‘)
,event.on(’上班‘)
等等,那 事件之间可能就存在一些依赖关系,比如要先起床然后才能上班这样的时序依赖,而 tapable
就可以帮助我们很方便的管理这些关系。
基本用法
接下来用一个前几天参加的公司中秋晚会的例子,来简单说明一下 Tapable
的用法:
我把自己的参加流程分成以下阶段:
-
晚宴前
- 检查着装
- 乘坐班车到酒店
-
晚宴中
- 用餐并欣赏表演
- 在当前桌进行博饼
- 如果成为当前桌状元,那么就留下来博王中王,
-
晚宴后
- 拍一些照片,打车回家
- 用拍好的照片发朋友圈纪念
那么先写个全局 demo:
// 1. 引入 tapable,先不管具体的钩子类型
const {
SyncHook,
SyncBailHook,
SyncWaterfallHook,
} = require("tapable");
// 2. 定义不同阶段对应的钩子,// 钩子: 晚宴前
let beforeDinner = new SyncHook(["stageName"]);
// 钩子:晚宴中
let atTheDinner = new SyncBailHook(["stageName"]);
// 钩子 晚宴后
let afterDinner = new SyncWaterfallHook(["stageName"]);
// 3. 为不同阶段注册事件,这里先写出晚宴前的事件
beforeDinner.tap('检查着装', (stageName)=>{console.log(`${stageName}: 检查着装 `)
})
beforeDinner.tap('乘坐班车到酒店', (stageName)=>{console.log(`${stageName}: 乘坐班车到酒店 `)
})
// 每个阶段触发自身需要执行的事件
beforeDinner.call('晚宴前');
atTheDinner.call('晚宴中');
afterDinner.call('晚宴后');
// 输出结果:// 晚宴前: 检查着装
// 晚宴前: 乘坐班车到酒店
// ... 省略后面的输出
这个 demo
简单的定义了三个阶段,先不去关具体的 hook
类型,了解下整体的结构:
-
beforeDinner
在实例化时,使用数组声明了参数stageName
, 这个地方的参数类型仅仅作为接口定义的目的使用,为了方便触发的时候传入对应的参数; -
call
方法其实就类似前文的emit
,与之不同的是,event.emit
表示事件触发,而hook.call
表示当前钩子要开始执行钩子上注册的所有事件。(当然我们只注册了晚宴前的 2 个事件) -
hook.call(param)
执行之后,该hook
对应的事件就按照注册顺序以及特定规则(具体规则后面说明,暂时略过)依次执行,因此上面的beforeDinner.call('晚宴前');
会输出对应的阶段名称和事件名称。
到这里,我们已经用上了最基本的 tapable
了。回顾下它和 events
最大的区别:
tapable
不仅提供了事件的注册和执行,还用不同的Hook
将事件进行分类(这里例子用三个阶段将基础事件分类)
SyncBailHook
接下来就是晚宴中的事件,这里有个注意点:晚宴中的第三个事件”如果成为当前桌状元,那么就留下来博王中王“是一个带有前提条件的事件,所以我们用了SyncBailHook
,并且这么注册事件:
atTheDinner.tap('用餐并欣赏表演', (stageName) => {console.log(`${stageName}: 用餐并欣赏表演 `);
})
atTheDinner.tap('在当前桌进行博饼', (stageName) => {console.log(`${stageName}: 在当前桌进行博饼 `);
// 关键伪代码
let getChampion = false // 如果获得状元
if(!getChampion){console.log(`${stageName}: 没有获得当前桌状元,不需要参与博王中王 `); // 注意这里的 return
return '提前结束!';
}
})
atTheDinner.tap('博王中王', (stageName) => {console.log(`${stageName}: 博王中王 `);
})
SyncBailHook 翻译过来意思是“熔断类型的钩子”,作用就像保险丝,一旦有危险,则启动保护(一旦该钩子的某个事件,执行返回除了 undefined 以外的值,后面注册的事件就不再执行)。正如前面的例子中,如果在“当前桌子博饼”中没有成功搏到“状元”,就不会进行后面的“搏王中王”事件。常用于处理某些需要条件判断才触发的事件。
SyncWaterfallHook
晚宴之后的事件,与前面不用的地方在于:事件 2 发朋友圈 用的是事件 1 中所拍的照片,换句话说后面的事件依赖于前面事件的执行结果。所以可以这么写:
afterDinner.tap('回家前拍照', (stageName) => {console.log(`${stageName}: 拍一些照片,打车回家 `);
let pictures = ['image1','image2'];
return pictures;
})
afterDinner.tap('回家后发朋友圈', (pictures)=> {
// 注意这里的内置参数 不再是 stageName 而是 pictures
return console.log(` 回家后,用 ${pictures}: 发朋友圈 `);
})
实例化 afterDinner
时使用了SyncWaterfallHook
,顾名思义,这种瀑布式的钩子,作用就是在执行该钩子内注册的事件时,会把每个阶段的执行结果传递给后面的阶段。
小结
这部分我们介绍了 tapable
的基本用法和三种基本类型的hook
,大概可以总结一下:
hook
表示事件的集合,hook
的类型决定了注册在这个 hook
的事件如何执行
异步类型的hook
总览
开胃菜结束,接下来要真正开始系统化的了解 tapable
了,(好消息是如果前面的例子都看懂了,后面的学起来会非常简单,坏消息是:又要涉及前端最棘手的问题之一 – 异步)
先来一览所有的 hook
类型:
总体上,hook
类型分成同步和异步两大类,异步再分为异步串行和异步并行。
先前已经介绍了同步 hook
里面的前三种。第四种 SynloopHook
也简要介绍下:
假设写文章这个事情,分成 校对 和发表 两个步骤,校对必须 3 次以上,才可以执行发表事件:
// 当监听函数被触发的时候,如果该监听函数返回 true 时则这个监听函数会反复执行,如果返回 undefined 则表示退出循环
let writeArticle = SyncLoopHook();
let count = 0;
writeArticle.tap('校对',()=>{console.log('执行校对', count++)
if(count<3){return true; // 没有达到 3 次则继续校对}
return
})
writeArticle.tap('发表',()=>{console.log('发表')
})
异步并行 AsyncParallelHook
异步的 hook,注册和触发可以用 tapAsync/callAsync
和tapPromise/promise
两种语法,写法上略有不用。直接上 demo:
// AsyncParallelHook 钩子:tapAsync/callAsync 的使用
const {AsyncParallelHook} = require("tapable");
// 创建实例
let asyncParallelHook = new AsyncParallelHook(["demoName"]);
// 注册事件
console.time("time");
asyncParallelHook.tapAsync("异步事件 1", (demoName, done) => {setTimeout(() => {console.log("1", demoName, new Date());
done(); // 需要注意的是这里的 `done` 方法}, 1000);
});
asyncParallelHook.tapAsync("异步事件 2", (demoName, done) => {setTimeout(() => {console.log("2", demoName, new Date());
done();}, 2000);
});
asyncParallelHook.tapAsync("异步事件 3", (demoName, done) => {setTimeout(() => {console.log("3", demoName, new Date());
done();
console.timeEnd("time");
}, 3000);
});
// 触发事件,让监听函数执行
asyncParallelHook.callAsync("异步并行", () => {
// 只有当前钩子的所有事件都执行 done 才进入这个 callback
console.log("complete");
});
// 输出
// 异步事件 1 异步并行
// Sun Sep 08 2019 21:24:12 GMT+0800 (GMT+08:00) {}
// 异步事件 2 异步并行
// Sun Sep 08 2019 21:24:13 GMT+0800 (GMT+08:00) {}
// 异步事件 3 异步并行
// Sun Sep 08 2019 21:24:14 GMT+0800 (GMT+08:00) {}
// complete
// time: 3007.266845703125ms
// time: 3007.640ms
需要注意的是这里的 done
方法,每个注册的的事件都可以调用到这个 done
方法,这个方法的作用是:向对应的 hook 实例告知,当前的异步事件完成,只有当所有的事件回调都执行了 done
方法,才会进入钩子本身的回调函数(demo 中的 console.log(“complete”);)
从例子中的计时情况来看,很明显所有的事件是并行的 — 事件 1 2 3 分别需要 1s 2s 3s,最终执行完也只花了 3s。
用 tapPromise/promise
来写的话,如下:
asyncParallelHook.tapPromise("异步事件 1", (demoName) => {return new Promise((resolve, reject) => {setTimeout(() => {console.log("1", demoName, new Date());
resolve("1");
}, 1000);
});
});
// ... 省略重复代码
asyncParallelHook.promise("异步并行").then(() => {console.log("最终结果", new Date());
}).catch(err => {console.log("发现错误", new Date());
});
区别在于:
- 使用
tapPromise
注册时,回调函数必须返回一个promise
- 使用
tabAsync
注册使用done
表示当前执行完成,使用tapPromise
时则只要使用resolve()
即可 - 如果其中一个事件没有
resolve
, 而是reject(error)
,那么会进入asyncParallelHook
的catch
而不是then
这种写法其实很类似 ES6 中的promise.all
, 比较好理解
异步串行AsyncSeriesHook
其实到这里,已经一只脚踏进成功的大门了。异步串行和异步并行的写法,完全一样。只需要简单把前面例子中, 实例化的语句改成:
let asyncSeriesHook = new AsyncSeriesHook()
然后看看 3 个异步事件执行完后的事件间隔(并行的时候是 3s,串行时总时长变成 6s)。
没错,就是这么简单~!
引申 — 实例案例 webpack-dev-middleware
中tapable
的应用
webpack-dev-middleware
是一个 webpack 的插件,作用是 监听 webpack 的编译变化并写入到内存中。 核心代码:
// webpack-dev-middleware/lib/context.js
const context = {
state: false,
webpackStats: null, //
callbacks: [],
options,
compiler,
watching: null,
forceRebuild: false,
};
function invalid(callback) {if (context.state) {
context.options.reporter(context.options, {
log,
state: false,
});
}
// We are now in invalid state
context.state = false;
if (typeof callback === 'function') {callback();
}
}
// 关键代码 利用 compile 的 hook 观察编译变化 并插入操作
context.compiler.hooks.invalid.tap('WebpackDevMiddleware', invalid);
context.compiler.hooks.run.tap('WebpackDevMiddleware', invalid);
context.compiler.hooks.done.tap('WebpackDevMiddleware', done);
context.compiler.hooks.watchRun.tap(
'WebpackDevMiddleware',
(comp, callback) => {invalid(callback);
}
);
核心的代码就是使用 webpack
提供的内置 hook
watchRun
来插入自定义的操作(检查编译情况,生成临时结果到内存)
总结
呼~ tapable 的内容大概写完了,本文介绍了同步的几种钩子,和异步的 2 种代表性的钩子,至于异步并行熔断等等,就是前面介绍的钩子的合成,比较简单。回顾一下主要的内容:
- 同步 hook 触发后,按照事件注册顺序依次调用,并根据钩子类型,有一些特殊行为(bail loop);
- 异步的 hook 有
tapAsync/callAsync
和tapPromise/promise
两种使用方式,并且都可以观察事件整体执行结果; - 异步串行和异步并行的区别,在于注册的事件依次执行(前一个完成才开始执行后一个)还是并发执行;
理解清楚 tapable 之后,再开始学习 webpack 的源码,会相对顺畅一些。
—– 惯例偷懒分割线 —–
如果觉得写得不好 / 有错误 / 表述不明确,都欢迎指出
如果有帮助,欢迎点赞和收藏,转载请征得同意后著明出处。如果有问题也欢迎私信交流,主页有邮箱地址
如果觉得作者很辛苦,也欢迎打赏~