插件原理
插件
通常是一个带有 apply 函数的类
class SomePlugin {
apply(compiler) {}
}
apply 函数运行时会得到参数 compiler,以此为起点调用 hook 对象注册的各种钩子回调,比如:compiler.hooks.make.tapAsync,make 时钩子名称,tapAsync 定义了钩子的调用方式,插件系统架构基于这样的模式构建,webpack 内置对象都带有 hooks 属性,比如 compilation 对象:
class SomePlugin {
apply(compiler) {
compiler.hooks.thisCompilation.tap('SomePlugin', (compilation) => {
compilation.hooks.optimizeChunkAssets.tapAsync('SomePlugin', () => {})
})
}
}
核心语句是 compiler.hooks.thisCompilation.tap,其中 thisCompilation 为 tapable 仓库提供的钩子对象,tap 为订阅函数,用于注册回调
webpack 的插件体系基于 tapable 提供的各类钩子展开
tapable 解析
tapable 是 webpack 插件的核心,本质是个发布订阅,
基本用法
tapable 使用时的步骤
- 创建钩子实例
- 调用订阅接口注册回调,比如:tap、tapAsync、tapPromise
- 调用发布接口触发回调,比如:call、callAsync、promise
例子:
const { SyncHook } = require('tapable')
const sleep = new SyncHook()
sleep.tap('test', () => {
console.log('callback A')
})
sleep.call()
使用 tap 注册回调,使用 call 触发回调,有些钩子中还可以使用异步风格的
tapAsync/callAsync,promise 风格的 tapPromise/promise,具体使用哪一类函数与钩子类型有关
钩子类型
- SyncHook - 同步钩子
- SyncBailHook - 同步熔断钩子
- SyncWaterfallHook - 同步瀑布流钩子
- SyncLoopHook - 同步循环钩子
- AsyncParallelHook - 异步并行钩子
- AsyncParallelBailHook - 异步并行熔断钩子
- AsyncSeriesHook - 异步串行钩子
- AsyncSeriesBailHook - 异步串行熔断钩子
- AsyncSeriesLoopHook - 异步串行循环钩子
- AsyncSeriesWaterfallHook - 异步串行瀑布流钩子
钩子分类规则
- 按回调逻辑分
- 基本类型:名称不带 Waterfall/Bail/Loop 关键字,与通常发布/订阅模式相似,按钩子注册顺序,逐次调用回调。
- waterfall 类型:前一个回调的返回值会被带入下一个回调
- bail 类型:逐次调用回调,若有任何一个回调返回非 undefined 值,则终止后续调用
- loop 类型:逐次、循环调用,直到所有函数都返回 undefined
- 按执行回调的并行方式分:
- sync:同步执行,启动后会按次序逐个执行回调,支持 call/tap 调用语句
- async:异步执行,支持传入 callback 或 promise 风格的异步回调函数,支持 callAsync/tapAsync、promise/tapPromise 两种调用语句
不同类型的钩子会直接影响到回调函数的写法,以及插件与其他插件的互通关系,但一些基本能力和概念是通用的:tap/call、intercept、context、动态编译等。
同步钩子
SyncHook 钩子
基本逻辑 syncHook 算的上是简单的钩子了,触发后会按照注册的顺序逐个调用回调,且不关心这些回调的返回值,逻辑如下:
function syncCall() {
const callbacks = [fn1, fn2, fn3]
for (let i = 0; i < callbacks.length; i++) {
const cb = callbacks[i]
cb()
}
}
例子
const { SyncHook } = require('tapable')
class Somebody {
constructor() {
this.hooks = {
sleep: new SyncHook()
}
}
sleep() {
// 触发回调
this.hooks.sleep.call()
}
}
const person = new Somebody()
// 注册回调
person.hooks.sleep.tap('test', () => {
console.log('callback A')
})
person.hooks.sleep.tap('test', () => {
console.log('callback B')
})
person.hooks.sleep.tap('test', () => {
console.log('callback C')
})
person.sleep()
// 输出结果:
// callback A
// callback B
// callback C
上面用的是 call 函数,也可以选择异步风格的 callAsync,但是 call 或者 callAsync 并不会影响回调的执行逻辑:按注册顺序依次执行 + 忽略回调执行结果,两者唯一的区别是 callAsync 需要传入 callback 函数,用于处理回调队列可能抛出的异常:
// call 风格
try {
this.hooks.sleep.call()
} catch (e) {
// 错误处理逻辑
}
// callAsync 风格
this.hooks.sleep.callAsync((err) => {
if (err) {
// 错误处理逻辑
}
})
调用的方式不会改变钩子本身的规则,使用者无需关注提供者到底使用 call 还是 callAsync,改为 callAsync 方式
SyncBailHook 熔断钩子,什么叫熔断呢?特点就是在回调队列中,若任何一个回调返回额非 undefined 的值,则中断后续处理,直接返回该值,为代码
function bailCall() {
const callbacks = [fn1, fn2, fn3]
for (let i in callbacks) {
const cb = callbacks[i]
const result = cb(lastResult)
if (result !== undefined) {
// 熔断
return result
}
}
return undefined
}
例子 SyncBailHook 的调用顺序和规则都跟 SyncHook 相似,主要区别就是 SyncBailHook 加了熔断逻辑
const { SyncBailHook } = require('tapable')
class Somebody {
constructor() {
this.hooks = {
sleep: new SyncBailHook()
}
}
sleep() {
return this.hooks.sleep.call()
}
}
const person = new Somebody()
// 注册回调
person.hooks.sleep.tap('test', () => {
console.log('callback A')
// 熔断点
// 返回非 undefined 的任意值都会中断回调队列
return '返回值:tecvan'
})
person.hooks.sleep.tap('test', () => {
console.log('callback B')
})
console.log(person.sleep())
// 运行结果:
// callback A
// 返回值:tecvan
相比与 SyncHook,SyncBailHook 运行结束后,会将熔断值返回给 call 函数
在 webpack 中的使用场景
通常使用在需要关心运行结果的情况下,比如 compiler.hooks.shouldEmit 对应的 call 语句
class Compiler {
run(callback) {
// ...
const onCompiled = (err, compilation) => {
if (this.hooks.shouldEmit.call(compilation) === false) {
// ...
}
}
}
}
webpack 会根据 shouldEmit 钩子运行的结果判断是否继续执行后续的操作,其他也有这样的逻辑:
- NormalModuleFactory.hooks.createModule:预期返回新建的 module 对象
- Compilation.hooks.needAdditionalSeal:预期返回 bool 值,判定是否进入 unseal 状态
- Compilation.hooks.optimizeModules:预期返回 bool 值,用于判断是否继续执行优化操作
SyncWaterfallHook 钩子
基本逻辑 waterfall 钩子的执行逻辑跟 lodash 的 flow 函数有点像,大致上就是会将前一个函数的返回值作为参数参入下一个函数
function waterfallCall(arg) {
const callbacks = [fn1, fn2, fn3]
let lastResult = arg
for (let i in callbacks) {
const cb = callbacks[i]
// 上次执行结果作为参数传入下一个函数
lastResult = cb(lastResult)
}
return lastResult
}
- 上一个函数的结果会被带入下一个函数
- 最后一个回调的结果会作为 call 调用的结果返回
例子
const { SyncWaterfallHook } = require('tapable')
class Somebody {
constructor() {
this.hooks = {
sleep: new SyncWaterfallHook(['msg'])
}
}
sleep() {
return this.hooks.sleep.call('hello')
}
}
const person = new Somebody()
// 注册回调
person.hooks.sleep.tap('test', (arg) => {
console.log(`call 调用传入: ${arg}`)
return 'tecvan'
})
person.hooks.sleep.tap('test', (arg) => {
console.log(`A 回调返回: ${arg}`)
return 'world'
})
console.log('最终结果:' + person.sleep())
// 运行结果:
// call 调用传入: hello
// A 回调返回: tecvan
// 最终结果:world
注意:
- 初始化时必须提供参数,用于动态编译 call 的参数依赖
- 发布调用 call 时需要传入初始参数
webpack 中使用场景 比如 NormalModuleFactory.hooks.factory 会根据资源类型 resolve 出对应的 module 对象:
class NormalModuleFactory {
constructor() {
this.hooks = {
factory: new SyncWaterfallHook(['filename', 'data'])
}
this.hooks.factory.tap('NormalModuleFactory', () => (result, callback) => {
let resolver = this.hooks.resolver.call(null)
if (!resolver) return callback()
resolver(result, (err, data) => {
if (err) return callback(err)
// direct module
if (typeof data.source === 'function') return callback(null, data)
// ...
})
})
}
create(data, callback) {
// ...
const factory = this.hooks.factory.call(null)
// ...
}
}
SyncLoopHook
基本逻辑 loop 型钩子的特点是循环执行直到所有回调都返回 undefined,只不过这里循环的纬度是单个回调函数,比如回调队列[fn1,fn2,fn3],loop 钩子先执行 fn1,如果此时 fn1 返回了非 undefined 值,则继续执行 fn1 直到返回 undefined 后才向前推进执行 fn2 伪代码:
function loopCall() {
const callbacks = [fn1, fn2, fn3]
for (let i in callbacks) {
const cb = callbacks[i]
// 重复执行
while (cb() !== undefined) {}
}
}
例子 因为 loop 钩子循环执行的特性,使用时需要注意:
const { SyncLoopHook } = require('tapable')
class Somebody {
constructor() {
this.hooks = {
sleep: new SyncLoopHook()
}
}
sleep() {
return this.hooks.sleep.call()
}
}
const person = new Somebody()
let times = 0
// 注册回调
person.hooks.sleep.tap('test', (arg) => {
++times
console.log(`第 ${times} 次执行回调A`)
if (times < 4) {
return times
}
})
person.hooks.sleep.tap('test', (arg) => {
console.log(`执行回调B`)
})
person.sleep()
// 运行结果
// 第 1 次执行回调A
// 第 2 次执行回调A
// 第 3 次执行回调A
// 第 4 次执行回调A
// 执行回调B
webpack 中并没有用到
异步钩子
AsyncSeriesHook 基本特点:
- 支持异步回调,可以在回调函数中些 callback 或 promise 风格的异步操作
- 回调队列依次执行,前一个执行结束后才会开始下一个
- 与 SyncHook 一样,不关心回调的执行结果
伪代码表示:
function asyncSeriesCall(callback) {
const callbacks = [fn1, fn2, fn3]
// 执行回调 1
fn1((err1) => {
if (err1) {
callback(err1)
} else {
// 执行回调 2
fn2((err2) => {
if (err2) {
callback(err2)
} else {
// 执行回调 3
fn3((err3) => {
if (err3) {
callback(err2)
}
})
}
})
}
})
}
例子
const { AsyncSeriesHook } = require('tapable')
const hook = new AsyncSeriesHook()
// 注册回调
hook.tapAsync('test', (cb) => {
console.log('callback A')
setTimeout(() => {
console.log('callback A 异步操作结束')
// 回调结束时,调用 cb 通知 tapable 当前回调已结束
cb()
}, 100)
})
hook.tapAsync('test', () => {
console.log('callback B')
})
hook.callAsync()
// 运行结果:
// callback A
// callback A 异步操作结束
// callback B
除了使用 callback 风格也可以使用 promise 风格调用 tap/call 函数
const { AsyncSeriesHook } = require('tapable')
const hook = new AsyncSeriesHook()
// 注册回调
hook.tapPromise('test', () => {
console.log('callback A')
return new Promise((resolve) => {
setTimeout(() => {
console.log('callback A 异步操作结束')
resolve()
}, 100)
})
})
hook.tapPromise('test', () => {
console.log('callback B')
return Promise.resolve()
})
hook.promise()
// 运行结果:
// callback A
// callback A 异步操作结束
// callback B
- 将 tapAsync 改为 tapPromise
- Tap 回调需要返回 promise 对象,如上例第 8 行
- callAsync 调用更改为 promise
webpack 中使用场景 构建完毕后触发 compiler.hooks.done 用于通知单次构建已经结束:
class Compiler {
run(callback) {
if (err) return finalCallback(err)
this.emitAssets(compilation, (err) => {
if (err) return finalCallback(err)
if (compilation.hooks.needAdditionalPass.call()) {
// ...
this.hooks.done.callAsync(stats, (err) => {
if (err) return finalCallback(err)
this.hooks.additionalPass.callAsync((err) => {
if (err) return finalCallback(err)
this.compile(onCompiled)
})
})
return
}
this.emitRecords((err) => {
if (err) return finalCallback(err)
// ...
this.hooks.done.callAsync(stats, (err) => {
if (err) return finalCallback(err)
return finalCallback(null, stats)
})
})
})
}
}
AsyncParallelHook
异步并行的方式,同时执行回调队列里面的所有回调,逻辑上近似于如下:
function asyncParallelCall(callback) {
const callbacks = [fn1, fn2]
// 内部维护了一个计数器
var _counter = 2
var _done = function () {
_callback()
}
if (_counter <= 0) return
// 按序执行回调
var _fn0 = callbacks[0]
_fn0(function (_err0) {
if (_err0) {
if (_counter > 0) {
// 出错时,忽略后续回调,直接退出
_callback(_err0)
_counter = 0
}
} else {
if (--_counter === 0) _done()
}
})
if (_counter <= 0) return
// 不需要等待前面回调结束,直接开始执行下一个回调
var _fn1 = callbacks[1]
_fn1(function (_err1) {
if (_err1) {
if (_counter > 0) {
_callback(_err1)
_counter = 0
}
} else {
if (--_counter === 0) _done()
}
})
}
特点:
- 支持异步风格
- 并行执行回调队列,不需要做任何等待
- 与 SyncHook 一样,不关心回调的执行结果
其他
- AsyncParallelBailHook:异步 + 并行 + 熔断,启动后同时执行所有回调,但任意回调有返回值时,忽略剩余未执行完的回调,直接返回该结果
- AsyncSeriesBailHook:异步 + 串行 + 熔断,启动后按序逐个执行回调,过程中若有任意回调返回非 undefined 值,则停止后续调用,直接返回该结果
- AsyncSeriesLoopHook:异步 + 串行 + 循环,启动后按序逐个执行回调,若有任意回调返回非 undefined 值,则重复执行该回调直到返回 undefined 后,才继续执行下一个回调
动态编译
tapable 插件架构
- 编译过程的特定节点以钩子形式,通知插件此刻正在发生的事情
- 通过 tapable 提供的回调机制,以参数的方式传递上下文信息