tapable 助你解析 webpack 的插件系统

2,533 阅读4分钟

tapable

tapable 导出了 9 个 hooks

  • SyncHook
  • SyncBailHook
  • SyncWaterfallHook
  • SyncLoopHook
  • AsyncParallelHook
  • AsyncParallelBailHook
  • AsyncSeriesHook
  • AsyncSeriesBailHook
  • AsyncSeriesWaterfallHook

上述 9 个 hooks 都继承自 Hook 这个 class

tapable Hook 解析

hook 对外提供了 isUsed call promise callAsync compile tap tapAsync tapPromise intercept 这些方法

其中 tap 开头的方法是用来订阅事件的,call promise callAsync 是用来触发事件的,isUsed 返回了一个 boolean 值用来标记当前 hook 中注册的事件是否被执行完成。

isUsed 源码

isUsed() {
		return this.taps.length > 0 || this.interceptors.length > 0;
}

tap tapAsync tapPromise 这三个方法第一个参数传入可以支持传入 string(一般是指 plugin 的名称) 或者一个 Tap 类型,第二个参数是一个回调用来接收事件被 emit 时的调用。

export interface Tap {
    name: string; // 事件名称,一般就是 plugin 的名字
    type: TapType; // 支持三种类型 'sync' 'async' 'promise'
    fn: Function;
    stage: number;
    context: boolean;
}

call promise callAsync 这三个方法在传入参数的时候是依赖于 hook 被实例化的时候传入的 args 数组占位符的数量的,如下示例:

const sync = new SyncHook(['arg1', 'arg2']) // 'arg1' 'arg2' 为参数占位符
sync.tap('Test', (arg1, arg2) => {
  console.log(arg1, arg2) // a2
})
sync.call('a', '2')

其中 promise 调用会返回一个 PromisecallAsync 默认支持传入一个 callback

Sync 开头的 hook 不支持使用 tapAsynctapPromise,可以看下述的以 SyncHook 的源码为例

const TAP_ASYNC = () => {
	throw new Error("tapAsync is not supported on a SyncHook");
};

const TAP_PROMISE = () => {
	throw new Error("tapPromise is not supported on a SyncHook");
};

const COMPILE = function(options) {
	factory.setup(this, options);
	return factory.create(options);
};

function SyncHook(args = [], name = undefined) {
	const hook = new Hook(args, name);
	hook.constructor = SyncHook;
	hook.tapAsync = TAP_ASYNC;
	hook.tapPromise = TAP_PROMISE;
	hook.compile = COMPILE;
	return hook;
}

SyncHook.prototype = null;

在这里面我们可以看到 tapAsynctapPromise 是被重写了直接 throw error

一个简单的使用示范

下面的例子会给大家带来一个简单地示范

class TapableTest {
  constructor() {
    this.hooks = {
      sync: new SyncHook(['context', 'hi']),
      syncBail: new SyncBailHook(),
      syncLoop: new SyncLoopHook(),
      syncWaterfall: new SyncWaterfallHook(['syncwaterfall']),
      asyncParallel: new AsyncParallelHook(),
      asyncParallelBail: new AsyncParallelBailHook(),
      asyncSeries: new AsyncSeriesHook(),
      asyncSeriesBail: new AsyncSeriesBailHook(),
      asyncSeriesWaterfall: new AsyncSeriesWaterfallHook(['asyncwaterfall']) 
    }
  }
  emitSync() {
    this.hooks.sync.call(this, err => {
        console.log(this.hooks.sync.promise)
        console.log(err)
    })
  }
  emitAyncSeries() { 
    this.hooks.asyncSeries.callAsync(err => {
        if (err) console.log(err)
    })
  }
}

const test = new TapableTest()
test.hooks.sync.tap('TestPlugin', (context, callback) => {
  console.log('trigger: ', context)
  callback(new Error('this is sync error'))
})
test.hooks.asyncSeries.tapAsync('AsyncSeriesPlugin', callback => {
    callback(new Error('this is async series error'))
})
test.emitSync()
test.emitAyncSeries()

上述的运行结果可以这查看 runkit

下面来聊一聊 webpack 中的插件是如何依赖 tapable 的

webpack 插件被注入的时机

当我们定义了 webpack 的配置文件后,webpack 会根据这些配置生成一个或多个 compiler ,而插件就是在创建 compiler 时被添加到 webpack 的整个运行期间的, 可以看下述源码:(相关源码可以在 webpack lib 下的 webpack.js 中找到)

const createCompiler = rawOptions => {
	const options = getNormalizedWebpackOptions(rawOptions);
	applyWebpackOptionsBaseDefaults(options);
	const compiler = new Compiler(options.context);
	compiler.options = options;
	new NodeEnvironmentPlugin({
		infrastructureLogging: options.infrastructureLogging
	}).apply(compiler);
	if (Array.isArray(options.plugins)) {
		for (const plugin of options.plugins) {
			if (typeof plugin === "function") {
				plugin.call(compiler, compiler);
			} else {
				plugin.apply(compiler);
			}
		}
	}
	applyWebpackOptionsDefaults(options);
	compiler.hooks.environment.call();
	compiler.hooks.afterEnvironment.call();
	new WebpackOptionsApply().process(options, compiler);
	compiler.hooks.initialize.call();
	return compiler;
};

我们可以看到遍历 options.plugins 这一段,这一段分了两种情况来进行插件的插入

  • 我们的 plugin 可以以函数的方式被 webpack 调用,也就是说我们可以用函数来写插件,这个函数的作用域是当前的 compiler,函数也会接收到一个 compiler
  • 可以传入一个包含 apply 方法的对象实例,apply 方法会被传入 compiler

所以这也就解释了为什么我们的插件需要 new 出来之后传入到 webpack

进入 Compiler 一探究竟

上一个中我们了解到了 plugins 是何时被注入的,我们可以看到在 plugin 的注入时传入了当前被实例化出来的 Compiler,所以现在我们需要了解下 Compiler 中做了什么

进入 Compiler.js (也在 lib 中)我们可以第一时间看到 Compilerconstructor 中定义了一个庞大的 hooks

this.hooks = Object.freeze({
			/** @type {SyncHook<[]>} */
			initialize: new SyncHook([]),

			/** @type {SyncBailHook<[Compilation], boolean>} */
			shouldEmit: new SyncBailHook(["compilation"]),
			/** @type {AsyncSeriesHook<[Stats]>} */
			done: new AsyncSeriesHook(["stats"]),
			/** @type {SyncHook<[Stats]>} */
			afterDone: new SyncHook(["stats"]),
			/** @type {AsyncSeriesHook<[]>} */
			additionalPass: new AsyncSeriesHook([]),
			/** @type {AsyncSeriesHook<[Compiler]>} */
			beforeRun: new AsyncSeriesHook(["compiler"]),
			/** @type {AsyncSeriesHook<[Compiler]>} */
			run: new AsyncSeriesHook(["compiler"]),
			/** @type {AsyncSeriesHook<[Compilation]>} */
			emit: new AsyncSeriesHook(["compilation"]),
			/** @type {AsyncSeriesHook<[string, AssetEmittedInfo]>} */
			assetEmitted: new AsyncSeriesHook(["file", "info"]),
			/** @type {AsyncSeriesHook<[Compilation]>} */
			afterEmit: new AsyncSeriesHook(["compilation"])
      ...
})

看到这些 hook 是不是很熟悉,全是 tapable 中的 hook,webpack 正是依赖于这些复杂的构建 hook 而完成了我们的代码构建,所以在我们编写 plugin 时就可以利用这些 hook 来完成我们的特殊需求。

比如我们经常用到的 HtmlWebpackPlugin ,我们可以看下他是如何运行的,在 HtmlWebpackPluginapply 中我们可以找到这样一段代码:

compiler.hooks.emit.tapAsync('HtmlWebpackPlugin', (compiler, callback) => {
  ...
})

说明 HtmlWebpackPlugin 是利用了 Compileremithook 来完成的

通过深入了解,webpack 是在庞大的插件上运行的,他自己内置了很多插件

上述内容如有错误,请指正