Skip to content

插件原理

插件

通常是一个带有 apply 函数的类 ​

javascript
class SomePlugin {
  apply(compiler) {}
}

apply 函数运行时会得到参数 compiler,以此为起点调用 hook 对象注册的各种钩子回调,比如:compiler.hooks.make.tapAsync,make 时钩子名称,tapAsync 定义了钩子的调用方式,插件系统架构基于这样的模式构建,webpack 内置对象都带有 hooks 属性,比如 compilation 对象:

javascript
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

例子:

javascript
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 算的上是简单的钩子了,触发后会按照注册的顺序逐个调用回调,且不关心这些回调的返回值,逻辑如下:

javascript
function syncCall() {
  const callbacks = [fn1, fn2, fn3]
  for (let i = 0; i < callbacks.length; i++) {
    const cb = callbacks[i]
    cb()
  }
}

例子

javascript
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 函数,用于处理回调队列可能抛出的异常:

javascript
// call 风格
try {
  this.hooks.sleep.call()
} catch (e) {
  // 错误处理逻辑
}
// callAsync 风格
this.hooks.sleep.callAsync((err) => {
  if (err) {
    // 错误处理逻辑
  }
})

调用的方式不会改变钩子本身的规则,使用者无需关注提供者到底使用 call 还是 callAsync,改为 callAsync 方式 ​

SyncBailHook 熔断钩子,什么叫熔断呢?特点就是在回调队列中,若任何一个回调返回额非 undefined 的值,则中断后续处理,直接返回该值,为代码

javascript
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 加了熔断逻辑

javascript
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 语句

javascript
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 函数有点像,大致上就是会将前一个函数的返回值作为参数参入下一个函数 ​

javascript
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 调用的结果返回

例子

javascript
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 对象:

javascript
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 伪代码:

javascript
function loopCall() {
  const callbacks = [fn1, fn2, fn3]
  for (let i in callbacks) {
    const cb = callbacks[i]
    // 重复执行
    while (cb() !== undefined) {}
  }
}

例子 因为 loop 钩子循环执行的特性,使用时需要注意:

javascript
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 一样,不关心回调的执行结果

伪代码表示:

javascript
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)
            }
          })
        }
      })
    }
  })
}

例子

javascript
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 函数

javascript
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 用于通知单次构建已经结束: ​

javascript
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 ​

异步并行的方式,同时执行回调队列里面的所有回调,逻辑上近似于如下:

javascript
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 提供的回调机制,以参数的方式传递上下文信息

如有转载或 CV 的请标注本站原文地址