Webpack tapable 源码研究

1,746 阅读8分钟

研究tapable源码的理由

  1. 大佬写的代码,当然值得一看了。
  2. tapable源码的代码量够少,可以让我们花少量时间就能研究的明白,还能有所收获。

上一篇文章《Webpack tapable 使用研究》研究了tapable的用法,了解用法有助于我们理解源码。感兴趣可以看看。

查看SyncHook.js文件

看源码,第一感觉肯定是充满疑惑的。

先从用法最简单的SyncHook来看吧。我想象的SyncHook大致是这样:

export default class SyncHook {
  constructor() {
    this.taps = [];
  }

  tap(name, fn) {
    this.taps.push({
      name,
      fn,
    });
  }

  call() {
    this.taps.forEach(tap => tap.fn());
  }
}

有个tap方法,有个call方法,有个变量存储注册的插件,可是实际上不是:

const Hook = require("./Hook");
const HookCodeFactory = require("./HookCodeFactory");

class SyncHookCodeFactory extends HookCodeFactory {
    ...
}

const factory = new SyncHookCodeFactory();

class SyncHook extends Hook {
	tapAsync() {
		throw new Error("tapAsync is not supported on a SyncHook");
	}

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

	compile(options) {
		factory.setup(this, options);
		return factory.create(options);
	}
}

module.exports = SyncHook;

没有tap也没有call,反而有tapAsync和tapPromise。还有个不知干啥的compile方法,里面还用了工厂。SyncHook继承自Hook。

分析:tap和call方法肯定是要有的,不在这里,那就在它的基类Hook里。这里使用到了继承和工厂模式,我们可以通过源码学习它们的实践了。

查看SyncBailHook.js、SyncLoopHook.js、SyncWaterfallHook.js文件

我们不急着看Hook.js,既然它用到继承,就是将公共的、可复用的逻辑抽象到父类中了。如果直接看父类,我们可能不容易发现作者抽象的思路,为什么要将这些点抽象到父类中。

我们先看看这些继承了Hook的子类,看看它们有那些公共的地方,再去看父类Hook.js。

// SyncBailHook.js
class SyncBailHookCodeFactory extends HookCodeFactory {
	...
}

const factory = new SyncBailHookCodeFactory();

class SyncBailHook extends Hook {
	tapAsync() {
		throw new Error("tapAsync is not supported on a SyncBailHook");
	}

	tapPromise() {
		throw new Error("tapPromise is not supported on a SyncBailHook");
	}

	compile(options) {
		factory.setup(this, options);
		return factory.create(options);
	}
}

module.exports = SyncBailHook;

SyncBailHook与SyncHook的区别就是换了个工厂给compile方法。其他没有什么不同。SyncLoopHook.js、SyncWaterfallHook.js全都类似,只是使用的工厂不同。

分析:还是分析不出什么,同步的钩子看完了,接着在看异步钩子类。

查看AsyncParallelHook.js文件

const Hook = require("./Hook");
const HookCodeFactory = require("./HookCodeFactory");

class AsyncParallelHookCodeFactory extends HookCodeFactory {
	...
}

const factory = new AsyncParallelHookCodeFactory();

class AsyncParallelHook extends Hook {
	compile(options) {
		factory.setup(this, options);
		return factory.create(options);
	}
}

Object.defineProperties(AsyncParallelHook.prototype, {
	_call: { value: undefined, configurable: true, writable: true }
});

module.exports = AsyncParallelHook;

连tapAsync和tapPromise的异常抛出都没有了,只剩compile方法了。下面还用Object.defineProperties给还AsyncParallelHook定义了一个_call方法。其他的异步钩子类,也跟AsyncParallelHook文件很类似,就是compile中使用的工厂不同。将_call的value定义为null。

分析:这里用Object.defineProperties定义类方法是个疑惑点,为什么不直接写在类中,而是用这种方式呢?

再就是说明各个Hook之间的主要区别,在于compile方法,compile方法里使用的不同工厂类,也是主要的区别点。其他所有逻辑,都抽象到Hook.js里了。

我们现在的疑惑,compile方法到底是干啥的?

查看Hook.js

带着疑惑,我们来看tapable有着最核心的逻辑的Hook.js文件,先省略一些部分,先看关键的api:

class Hook {
    constructor(args) {
		if (!Array.isArray(args)) args = [];
		this._args = args;
		this.taps = [];
		this.interceptors = [];
		this.call = this._call;
		this.promise = this._promise;
		this.callAsync = this._callAsync;
	}

	compile(options) {
		throw new Error("Abstract: should be overriden");
	}
	
	tap(options, fn) {
		...
	}

	tapAsync(options, fn) {
		...
	}

	tapPromise(options, fn) {
		...
	}
	
	intercept(interceptor) {
		...
	}
}

Object.defineProperties(Hook.prototype, {
	_call: {
		value: createCompileDelegate("call", "sync"),
		configurable: true,
		writable: true
	},
	_promise: {
		value: createCompileDelegate("promise", "promise"),
		configurable: true,
		writable: true
	},
	_callAsync: {
		value: createCompileDelegate("callAsync", "async"),
		configurable: true,
		writable: true
	}
});

module.exports = Hook;

先看构造函数,接收args的数组,作为插件的参数标识。taps变量存储插件,interceptors变量存储拦截器。

再看方法,compile方法在这,标识是个抽象方法,由子类重写,也符合我们查看子类的预期。

tap、tapAsync、tapPromise、intercept在子类中都会被继承下来,但是在同步的钩子中,tapAsync、tapPromise被抛了异常了,不能用,也符合使用时的预期。

这里比较疑惑的是call、promise、callAsync这三个调用方法,为啥不像tap这样写在类里,而是写在构造函数的变量里,而且下面Object.defineProperties定义了三个_call、_promise、_callAsync三个私有方法,它们和call、promise、callAsync是什么关系?

我们接着深入的看。

注册过程:tap、tapAsync、tapPromise方法

既然调用方法call、promise、callAsync的实现比较复杂,我们就先看tap、tapAsync、tapPromise这些注册方法,实现比较简单:

    tap(options, fn) {
		if (typeof options === "string") options = { name: options };
		if (typeof options !== "object" || options === null)
			throw new Error(
				"Invalid arguments to tap(options: Object, fn: function)"
			);
		options = Object.assign({ type: "sync", fn: fn }, options);
		if (typeof options.name !== "string" || options.name === "")
			throw new Error("Missing name for tap");
		options = this._runRegisterInterceptors(options);
		this._insert(options);
	}

	tapAsync(options, fn) {
		if (typeof options === "string") options = { name: options };
		if (typeof options !== "object" || options === null)
			throw new Error(
				"Invalid arguments to tapAsync(options: Object, fn: function)"
			);
		options = Object.assign({ type: "async", fn: fn }, options);
		if (typeof options.name !== "string" || options.name === "")
			throw new Error("Missing name for tapAsync");
		options = this._runRegisterInterceptors(options);
		this._insert(options);
	}

	tapPromise(options, fn) {
		if (typeof options === "string") options = { name: options };
		if (typeof options !== "object" || options === null)
			throw new Error(
				"Invalid arguments to tapPromise(options: Object, fn: function)"
			);
		options = Object.assign({ type: "promise", fn: fn }, options);
		if (typeof options.name !== "string" || options.name === "")
			throw new Error("Missing name for tapPromise");
		options = this._runRegisterInterceptors(options);
		this._insert(options);
	}

它们三个的实现非常类似。核心功能是拼起一个options对象,options的内容如下:

options:{
    name, // 插件名称
    type: "sync" | "async" | "promise", // 插件注册的类型
    fn, // 插件的回调函数,被call时的响应函数
    stage, // 插件调用的顺序值
    before,// 插件在哪个插件之前调用
}

拼好了options,就利用_insert方法将其放到taps变量里,以供后续调用。_insert方法内部就是实现了根据stage和before两个值,对options的插入到taps中的顺序做了调整并插入。

intercept方法

intercept方法将拦截器的相应回调放到interceptors里,以供对应的时机调用。

调用过程: call方法、callAsync方法、promise方法

注册过程机会没什么区别,区别在于调用过程,最终影响插件的执行顺序和逻辑。

首先先解决为什么_call方法要写成Object.defineProperties中定义,而不是类中定义,这样的好处是,方便我们为_call方法赋值为另一个函数,代码中将_call的value赋值成了createCompileDelegate方法的返回值,而如果将_call直接声明到类中,不好做到。再就是可以直接在子类(如AsyncParallelHook)中,再利用Object.defineProperties将_call的vale赋值为null。就可以得到一个没有_call方法的子类了。

再看一个私有方法:

    _resetCompilation() {
		this.call = this._call;
		this.callAsync = this._callAsync;
		this.promise = this._promise;
	}

此方法在_insert和intercept中调用,也就是在每次的注册新插件或注册新的拦截器,会触发一次私有调用方法到call等变量的一次赋值。

为什么每次都要重新赋值呢?每次的_call方法不一样了吗?我先给出答案,确实,每次赋值都是一个全新的new出来的_call方法。因为注册新插件或注册新的拦截器会形成一个新的_call方法,所以每次都要重新赋值一次。

那为什么要每次生成一个新的_call方法呢?直接写死不好吗,不就是调用taps变量里的插件和拦截器吗?

原因是因为我们的插件彼此有着联系,所以我们用了这么多类型的钩子来控制这些联系,每次注册了新的插件或拦截器,我们就要重新排布插件和拦截器的调用顺序,所以每次都要生成新的_call方法。接下来我们来看代码:

function createCompileDelegate(name, type) {
	return function lazyCompileHook(...args) {
		this[name] = this._createCall(type);
		return this[name](...args);
	};
}

生成_call方法的是createCompileDelegate方法,这里用到了闭包,存储了name和type。然后返回了一个lazyCompileHook方法给_call变量。当_call方法被调用时,_createCall方法也立即被调用。

    _createCall(type) {
		return this.compile({
			taps: this.taps,
			interceptors: this.interceptors,
			args: this._args,
			type: type
		});
	}

这里调用了compile方法,也就是说我们的调用方法(call方法、callAsync方法、promise方法)和compile是息息相关的。看SyncHook中的compile

class SyncHookCodeFactory extends HookCodeFactory {
    ...
}

const factory = new SyncHookCodeFactory();
export default class SyncHook {
    ...
    compile(options) {
		factory.setup(this, options);
		return factory.create(options);
	}
}

compile关联了HookCodeFactory,我们来看HookCodeFactory的setup和create方法都干了什么:

    setup(instance, options) {
		instance._x = options.taps.map(t => t.fn);
	}

setup就是将插件的回调函数,都存在钩子实例的_x变量上。

    create(options) {
		this.init(options);
		let fn;
		switch (this.options.type) {
			case "sync":
				fn = new Function(
					this.args(),
					'"use strict";\n' +
						this.header() +
						this.content({
							onError: err => `throw ${err};\n`,
							onResult: result => `return ${result};\n`,
							resultReturns: true,
							onDone: () => "",
							rethrowIfPossible: true
						})
				);
				break;
		...
	}
	

create方法我们只关注跟Sync相关的,这里的变量fn就是最终在调用的时刻,生成了一个call方法的执行体。我们来看一下这个生成的call方法什么样:

实验代码:

import { SyncHook } from 'tapable';

const hook = new SyncHook(['options']);

hook.tap('A', function (arg) {
  console.log('A', arg);
})

hook.tap('B', function () {
  console.log('b')
})

hook.call(6);


console.log(hook.call);
console.log(hook);

打印结果如下:

image

可以看到我们的call方法中的x就是setup方法中设置的我们插件的回调函数啊,call方法生成的代码,就是根据我们使用不同的钩子,根据我们设计的逻辑,调用这些回调。

在看一下hook对象下的call和callAsync有何不同,callAsync没有被调用,所以它还是lazyCompileHook函数,也验证了我们的思考,call方法是在调用时,才被生成了上面那样的执行函数。

结束语

tapable的核心逻辑,就研究完毕了,感兴趣的小伙伴可以继续再看看。可以看到源码中对于面向对象继承的使用,工厂模式的使用,调用时才生成执行逻辑这种操作。都是值得我们学习的。