寶啊~談談Tapable

掘金引流終版.gif

構建專欄系列目錄入口html

胡寧:微醫前端技術部平臺支撐組,最近是一陣信奉快樂的風~前端

tapable 是一個相似於 Node.js 中的 EventEmitter 的庫,但更專一於自定義事件的觸發和處理。webpack 經過 tapable 將實現與流程解耦,全部具體實現經過插件的形式存在。webpack

Tapable 和 webpack 的關係

  1. webpack 是什麼?

本質上,webpack 是一個用於現代 JavaScript 應用程序的 靜態模塊打包工具。當 webpack 處理應用程序時,它會在內部構建一個 依賴圖(dependency graph),此依賴圖對應映射到項目所需的每一個模塊,並生成一個或多個 bundle。git

  1. webpack 的重要模塊
  • 入口(entry)
  • 輸出(output)
  • loader(對模塊的源代碼進行轉換)
  • plugin(webpack 構建流程中的特定時機注入擴展邏輯來改變構建結果或作你想要的事)

插件(plugin)是 webpack 的支柱功能。webpack 自身也是構建於你在 webpack 配置中用到的相同的插件系統之上。github

  1. webpack 的構建流程

webpack 本質上是一種事件流的機制,它的工做流程就是將各個插件串聯起來,而實現這一切的核心就是 Tapable。webpack 中最核心的負責編譯的 Compiler 和負責建立 bundle 的 Compilation 都是 Tapable 的實例(webpack5 前)。webpack5 以後是經過定義屬性名爲 hooks 來調度觸發時機。Tapable 充當的就是一個複雜的發佈訂閱者模式web

以 Compiler 爲例:數組

// webpack5 前,經過繼承
...
const {
	Tapable,
	SyncHook,
	SyncBailHook,
	AsyncParallelHook,
	AsyncSeriesHook
} = require("tapable");
...
class Compiler extends Tapable {
	constructor(context) {
		super();
		...
	}
}

// webpack5
...
const {
	SyncHook,
	SyncBailHook,
	AsyncParallelHook,
	AsyncSeriesHook
} = require("tapable");
...
class Compiler {
	constructor(context) {
		this.hooks = Object.freeze({
			/** @type {SyncHook<[]>} */
			initialize: new SyncHook([]),

			/** @type {SyncBailHook<[Compilation], boolean>} */
			shouldEmit: new SyncBailHook(["compilation"]),
			...
		})
	}
	...
}
複製代碼

Tapable 的使用姿式

tapable 對外暴露了 9 種 Hooks 類。這些 Hooks 類的做用就是經過實例化來建立一個執行流程,並提供註冊和執行方法,Hook 類的不一樣會致使執行流程的不一樣。promise

const {
	SyncHook,
	SyncBailHook,
	SyncWaterfallHook,
	SyncLoopHook,
	AsyncParallelHook,
	AsyncParallelBailHook,
	AsyncSeriesHook,
	AsyncSeriesBailHook,
	AsyncSeriesWaterfallHook
 } = require("tapable");
複製代碼

每一個 hook 都能被註冊屢次,如何被觸發取決於 hook 的類型markdown

按同步、異步(串行、並行)分類

  • Sync:只能被同步函數註冊,如 myHook.tap()
  • AsyncSeries:能夠被同步的,基於回調的,基於 promise 的函數註冊,如 myHook.tap(),myHook.tapAsync() , myHook.tapPromise()。執行順序爲串行
  • AsyncParallel:能夠被同步的,基於回調的,基於 promise 的函數註冊,如 myHook.tap(),myHook.tapAsync() , myHook.tapPromise()。執行順序爲並行

Untitled.png

按執行模式分類

  • Basic:執行每個事件函數,不關心函數的返回值

Tapable bda4604e3f27488082fd7a2820082dbc.png

  • Bail:執行每個事件函數,遇到第一個結果 result !== undefined 則返回,再也不繼續執行

_(1).png

  • Waterfall:若是前一個事件函數的結果 result !== undefined,則 result 會做爲後一個事件函數的第一個參數

_(2).png

  • Loop:不停的循環執行事件函數,直到全部函數結果 result === undefined

_(4).png

Untitled 1.png

使用方式

Hook 類使用

簡單來講就是下面步驟app

  1. 實例化構造函數 Hook
  2. 註冊(一次或者屢次)
  3. 執行(傳入參數)
  4. 若是有須要還能夠增長對整個流程(包括註冊和執行)的監聽-攔截器

以最簡單的 SyncHook 爲例:

// 簡單來講就是實例化 Hooks 類
// 接收一個可選參數,參數是一個參數名的字符串數組
const hook = new SyncHook(["arg1", "arg2", "arg3"]);
// 註冊
// 第一個入參爲註冊名
// 第二個爲註冊回調方法
hook.tap("1", (arg1, arg2, arg3) => {
  console.log(1, arg1, arg2, arg3);
  return 1;
});
hook.tap("2", (arg1, arg2, arg3) => {
  console.log(2, arg1, arg2, arg3);
  return 2;
});
hook.tap("3", (arg1, arg2, arg3) => {
  console.log(3, arg1, arg2, arg3);
  return 3;
});
// 執行
// 執行順序則是根據這個實例類型來決定的
hook.call("a", "b", "c");

//------輸出------
// 先註冊先觸發
1 a b c
2 a b c
3 a b c
複製代碼

上面的例子爲同步的狀況,若註冊異步則:

let { AsyncSeriesHook } = require("tapable");
let queue = new AsyncSeriesHook(["name"]);
console.time("cost");
queue.tapPromise("1", function (name) {
  return new Promise(function (resolve) {
    setTimeout(function () {
      console.log(1, name);
      resolve();
    }, 1000);
  });
});
queue.tapPromise("2", function (name) {
  return new Promise(function (resolve) {
    setTimeout(function () {
      console.log(2, name);
      resolve();
    }, 2000);
  });
});
queue.tapPromise("3", function (name) {
  return new Promise(function (resolve) {
    setTimeout(function () {
      console.log(3, name);
      resolve();
    }, 3000);
  });
});
queue.promise("weiyi").then((data) => {
  console.log(data);
  console.timeEnd("cost");
});
複製代碼

HookMap 類使用

A HookMap is a helper class for a Map with Hooks

官方推薦將全部的鉤子實例化在一個類的屬性 hooks 上,如:

class Car {
	constructor() {
		this.hooks = {
			accelerate: new SyncHook(["newSpeed"]),
			brake: new SyncHook(),
			calculateRoutes: new AsyncParallelHook(["source", "target", "routesList"])
		};
	}
	/* ... */
	setSpeed(newSpeed) {
		// following call returns undefined even when you returned values
		this.hooks.accelerate.call(newSpeed);
	}
}
複製代碼

註冊&執行:

const myCar = new Car();

myCar.hooks.accelerate.tap("LoggerPlugin", newSpeed => console.log(`Accelerating to ${newSpeed}`));

myCar.setSpeed(1)
複製代碼

而 HookMap 正是這種推薦寫法的一個輔助類。具體使用方法:

const keyedHook = new HookMap(key => new SyncHook(["arg"]))

keyedHook.for("some-key").tap("MyPlugin", (arg) => { /* ... */ });
keyedHook.for("some-key").tapAsync("MyPlugin", (arg, callback) => { /* ... */ });
keyedHook.for("some-key").tapPromise("MyPlugin", (arg) => { /* ... */ });

const hook = keyedHook.get("some-key");
if(hook !== undefined) {
	hook.callAsync("arg", err => { /* ... */ });
}
複製代碼

MultiHook 類使用

A helper Hook-like class to redirect taps to multiple other hooks

至關於提供一個存放一個 hooks 列表的輔助類:

const { MultiHook } = require("tapable");

this.hooks.allHooks = new MultiHook([this.hooks.hookA, this.hooks.hookB]);
複製代碼

Tapable 的原理

核心就是經過 Hook 來進行註冊的回調存儲和觸發,經過 HookCodeFactory 來控制註冊的執行流程。

首先來觀察一下 tapable 的 lib 文件結構,核心的代碼都是存放在 lib 文件夾中。其中 index.js 爲全部可以使用類的入口。Hook 和 HookCodeFactory 則是核心類,主要的做用就是註冊和觸發流程。還有兩個輔助類 HookMap 和 MultiHook 以及一個工具類 util-browser。其他均是以 Hook 和 HookCodeFactory 爲基礎類衍生的以上分類所說起的 9 種 Hooks。整個結構是很是簡單清楚的。如圖所示:

Untitled 2.png

接下來說一下最重要的兩個類,也是 tapable 的源碼核心。

Hook

首先看 Hook 的屬性,能夠看到屬性中有熟悉的註冊的方法:tap、tapAsync、tapPromise。執行方法:call、promise、callAsync。以及存放全部的註冊項 taps。constructor 的入參就是每一個鉤子實例化時的入參。從屬性上就可以知道是 Hook 類爲繼承它的子類提供了最基礎的註冊和執行的方法

class Hook {
	constructor(args = [], name = undefined) {
		this._args = args;
		this.name = name;
		this.taps = [];
		this.interceptors = [];
		this._call = CALL_DELEGATE;
		this.call = CALL_DELEGATE;
		this._callAsync = CALL_ASYNC_DELEGATE;
		this.callAsync = CALL_ASYNC_DELEGATE;
		this._promise = PROMISE_DELEGATE;
		this.promise = PROMISE_DELEGATE;
		this._x = undefined;

		this.compile = this.compile;
		this.tap = this.tap;
		this.tapAsync = this.tapAsync;
		this.tapPromise = this.tapPromise;
	}
	...
}
複製代碼

那麼 Hook 類是如何收集註冊項的?如代碼所示:

class Hook {
	...
	tap(options, fn) {
		this._tap("sync", options, fn);
	}

	tapAsync(options, fn) {
		this._tap("async", options, fn);
	}

	tapPromise(options, fn) {
		this._tap("promise", options, fn);
	}

	_tap(type, options, fn) {
		if (typeof options === "string") {
			options = {
				name: options.trim()
			};
		} else if (typeof options !== "object" || options === null) {
			throw new Error("Invalid tap options");
		}
		if (typeof options.name !== "string" || options.name === "") {
			throw new Error("Missing name for tap");
		}
		if (typeof options.context !== "undefined") {
			deprecateContext();
		}
		// 合併參數
		options = Object.assign({ type, fn }, options);
		// 執行註冊的 interceptors 的 register 監聽,並返回執行後的 options
		options = this._runRegisterInterceptors(options);
		// 收集到 taps 中
		this._insert(options);
	}
	_runRegisterInterceptors(options) {
		for (const interceptor of this.interceptors) {
			if (interceptor.register) {
				const newOptions = interceptor.register(options);
				if (newOptions !== undefined) {
					options = newOptions;
				}
			}
		}
		return options;
	}
	...
}
複製代碼

能夠看到三種註冊的方法都是經過_tap 來實現的,只是傳入的 type 不一樣。_tap 主要作了兩件事。

  1. 執行 interceptor.register,並返回 options
  2. 收集註冊項到 this.taps 列表中,同時根據 stage 和 before 排序。(stage 和 before 是註冊時的可選參數)

收集完註冊項,接下來就是執行這個流程:

const CALL_DELEGATE = function(...args) {
	this.call = this._createCall("sync");
	return this.call(...args);
};
const CALL_ASYNC_DELEGATE = function(...args) {
	this.callAsync = this._createCall("async");
	return this.callAsync(...args);
};
const PROMISE_DELEGATE = function(...args) {
	this.promise = this._createCall("promise");
	return this.promise(...args);
};
class Hook {
	constructor() {
		...
		this._call = CALL_DELEGATE;
		this.call = CALL_DELEGATE;
		this._callAsync = CALL_ASYNC_DELEGATE;
		this.callAsync = CALL_ASYNC_DELEGATE;
		this._promise = PROMISE_DELEGATE;
		this.promise = PROMISE_DELEGATE;
		...
	}
	compile(options) {
		throw new Error("Abstract: should be overridden");
	}

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

執行流程能夠說是異曲同工,最後都是經過_createCall 來返回一個 compile 執行後的值。從上文可知,tapable 的執行流程有同步,異步串行,異步並行、循環等,所以 Hook 類只提供了一個抽象方法 compile,那麼 compile 具體是怎麼樣的呢。這就引出了下一個核心類 HookCodeFactory。

HookCodeFactory

見名知意,該類是一個返回 hookCode 的工廠。首先來看下這個工廠是如何被使用的。這是其中一種 hook 類 AsyncSeriesHook 使用方式:

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

class AsyncSeriesHookCodeFactory extends HookCodeFactory {
	content({ onError, onDone }) {
		return this.callTapsSeries({
			onError: (i, err, next, doneBreak) => onError(err) + doneBreak(true),
			onDone
		});
	}
}

const factory = new AsyncSeriesHookCodeFactory();
// options = {
// taps: this.taps,
// interceptors: this.interceptors,
// args: this._args,
// type: type
// }
const COMPILE = function(options) {
	factory.setup(this, options);
	return factory.create(options);
};

function AsyncSeriesHook(args = [], name = undefined) {
	const hook = new Hook(args, name);
	hook.constructor = AsyncSeriesHook;
	hook.compile = COMPILE;
	...
	return hook;
}
複製代碼

HookCodeFactory 的職責就是將執行代碼賦值給 hook.compile,從而使 hook 獲得執行能力。來看看該類內部運轉邏輯是這樣的:

class HookCodeFactory {
	constructor(config) {
		this.config = config;
		this.options = undefined;
		this._args = undefined;
	}
	...
	create(options) {
		...
		this.init(options);
		// type
		switch (this.options.type) {
			case "sync": fn = new Function(省略...);break;
			case "async": fn = new Function(省略...);break;
			case "promise": fn = new Function(省略...);break;
		}
		this.deinit();
		return fn;
	}
	init(options) {
		this.options = options;
		this._args = options.args.slice();
	}

	deinit() {
		this.options = undefined;
		this._args = undefined;
	}
}
複製代碼

最終返回給 compile 就是 create 返回的這個 fn,fn 則是經過 new Function()進行建立的。那麼重點就是這個 new Function 中了。

先了解一下 new Function 的語法

new Function ([arg1[, arg2[, ...argN]],] functionBody)

  • arg1, arg2, ... argN:被函數使用的參數的名稱必須是合法命名的。參數名稱是一個有效的 JavaScript 標識符的字符串,或者一個用逗號分隔的有效字符串的列表;例如「×」,「theValue」,或「a,b」。
  • functionBody:一個含有包括函數定義的 JavaScript 語句的字符串。

基本用法:

const sum = new Function('a', 'b', 'return a + b');
console.log(sum(2, 6));
// expected output: 8
複製代碼

使用 Function 構造函數的方法:

class HookCodeFactory {
	create() {
		...
		fn = new Function(this.args({...}), code)
		...
		return fn
	}
	args({ before, after } = {}) {
		let allArgs = this._args;
		if (before) allArgs = [before].concat(allArgs);
		if (after) allArgs = allArgs.concat(after);
		if (allArgs.length === 0) {
			return "";
		} else {
			return allArgs.join(", ");
		}
	}
}
複製代碼

這個 this.args()就是返回執行時傳入參數名,爲後面 code 提供了對應參數值。

fn = new Function(
	this.args({...}), 
	'"use strict";\n' +
		this.header() +
		this.contentWithInterceptors({
			onError: err => `throw ${err};\n`,
			onResult: result => `return ${result};\n`,
			resultReturns: true,
			onDone: () => "",
			rethrowIfPossible: true
		})
)
header() {
	let code = "";
	if (this.needContext()) {
		code += "var _context = {};\n";
	} else {
		code += "var _context;\n";
	}
	code += "var _x = this._x;\n";
	if (this.options.interceptors.length > 0) {
		code += "var _taps = this.taps;\n";
		code += "var _interceptors = this.interceptors;\n";
	}
	return code;
}

contentWithInterceptors() {
	// 因爲代碼過多這邊描述一下過程
	// 1. 生成監聽的回調對象如:
	// {
	// onError,
	// onResult,
	// resultReturns,
	// onDone,
	// rethrowIfPossible
	// }
  // 2. 執行 this.content({...}),入參爲第一步返回的對象
	...
}
複製代碼

而對應的 functionBody 則是經過 header 和 contentWithInterceptors 共同生成的。this.content 則是根據鉤子類型的不一樣調用不一樣的方法以下面代碼則調用的是 callTapsSeries:

class SyncHookCodeFactory extends HookCodeFactory {
	content({ onError, onDone, rethrowIfPossible }) {
		return this.callTapsSeries({
			onError: (i, err) => onError(err),
			onDone,
			rethrowIfPossible
		});
	}
}
複製代碼

HookCodeFactory 有三種生成 code 的方法:

// 串行
callTapsSeries() {...}
// 循環
callTapsLooping() {...}
// 並行
callTapsParallel() {...}
// 執行單個註冊回調,經過判斷 sync、async、promise 返回對應 code
callTap() {...}
複製代碼
  1. 並行(Parallel)原理:並行的狀況只有在異步的時候才發生,所以執行全部的 taps 後,判斷計數器是否爲 0,爲 0 則執行結束回調(計數器爲 0 有多是由於 taps 所有執行完畢,有多是由於返回值不爲 undefined,手動設置爲 0)
  2. 循環(Loop)原理:生成 do{}while(__loop)的代碼,將執行後的值是否爲 undefined 賦值給_loop,從而來控制循環
  3. 串行:就是按照 taps 的順序來生成執行的代碼
  4. callTap:執行單個註冊回調
  • sync:按照順序執行
var _fn0 = _x[0];
_fn0(arg1, arg2, arg3);
var _fn1 = _x[1];
_fn1(arg1, arg2, arg3);
var _fn2 = _x[2];
_fn2(arg1, arg2, arg3);
複製代碼
  • async 原理:將單個 tap 封裝成一個_next[index]函數,當前一個函數執行完成即調用了 callback,則會繼續執行下一個_next[index]函數,如生成以下 code:
function _next1() {
  var _fn2 = _x[2];
  _fn2(name, (function (_err2) {
    if (_err2) {
      _callback(_err2);
    } else {
      _callback();
    }
  }));
}

function _next0() {
  var _fn1 = _x[1];
  _fn1(name, (function (_err1) {
    if (_err1) {
      _callback(_err1);
    } else {
      _next1();
    }
  }));
}
var _fn0 = _x[0];
_fn0(name, (function (_err0) {
  if (_err0) {
    _callback(_err0);
  } else {
    _next0();
  }
}));
複製代碼
  • promise:將單個 tap 封裝成一個_next[index]函數,當前一個函數執行完成即調用了 promise.then(),then 中則會繼續執行下一個_next[index]函數,如生成以下 code:
function _next1() {
  var _fn2 = _x[2];
  var _hasResult2 = false;
  var _promise2 = _fn2(name);
  if (!_promise2 || !_promise2.then)
    throw new Error('Tap function (tapPromise) did not return promise (returned ' + _promise2 + ')');
  _promise2.then((function (_result2) {
    _hasResult2 = true;
    _resolve();
  }), function (_err2) {
    if (_hasResult2) throw _err2;
    _error(_err2);
  });
}

function _next0() {
  var _fn1 = _x[1];
  var _hasResult1 = false;
  var _promise1 = _fn1(name);
  if (!_promise1 || !_promise1.then)
    throw new Error('Tap function (tapPromise) did not return promise (returned ' + _promise1 + ')');
  _promise1.then((function (_result1) {
    _hasResult1 = true;
    _next1();
  }), function (_err1) {
    if (_hasResult1) throw _err1;
    _error(_err1);
  });
}
var _fn0 = _x[0];
var _hasResult0 = false;
var _promise0 = _fn0(name);
if (!_promise0 || !_promise0.then)
  throw new Error('Tap function (tapPromise) did not return promise (returned ' + _promise0 + ')');
_promise0.then((function (_result0) {
  _hasResult0 = true;
  _next0();
}), function (_err0) {
  if (_hasResult0) throw _err0;
  _error(_err0);
});
複製代碼

將以上的執行順序以及執行方式來進行組合,就獲得瞭如今的 9 種 Hook 類。若後續須要更多的模式只須要增長執行順序或者執行方式就可以完成拓展。

如圖所示:

Tapable bda4604e3f27488082fd7a2820082dbc 1.png

如何助力 webpack

插件可使用 tapable 對外暴露的方法向 webpack 中注入自定義構建的步驟,這些步驟將在構建過程當中觸發。

webpack 將整個構建的步驟生成一個一個 hook 鉤子(即 tapable 的 9 種 hook 類型的實例),存儲在 hooks 的對象裏。插件能夠經過 Compiler 或者 Compilation 訪問到對應的 hook 鉤子的實例,進行註冊(tap,tapAsync,tapPromise)。當 webpack 執行到相應步驟時就會經過 hook 來進行執行(call, callAsync,promise),從而執行註冊的回調。以 ConsoleLogOnBuildWebpackPlugin 自定義插件爲例:

const pluginName = 'ConsoleLogOnBuildWebpackPlugin';

class ConsoleLogOnBuildWebpackPlugin {
  apply(compiler) {
    compiler.hooks.run.tap(pluginName, (compilation) => {
      console.log('webpack 構建過程開始!');
    });
  }
}

module.exports = ConsoleLogOnBuildWebpackPlugin;
複製代碼

能夠看到在 apply 中經過 compiler 的 hooks 註冊(tap)了在 run 階段時的回調。從 Compiler 類中能夠了解到在 hooks 對象中對 run 屬性賦值 AsyncSeriesHook 的實例,並在執行的時候經過 this.hooks.run.callAsync 觸發了已註冊的對應回調:

class Compiler {
	constructor(context) {
		this.hooks = Object.freeze({
				...
				run: new AsyncSeriesHook(["compiler"]),
				...
		})
	}
	run() {
		...
		const run = () => {
			this.hooks.beforeRun.callAsync(this, err => {
				if (err) return finalCallback(err);

				this.hooks.run.callAsync(this, err => {
					if (err) return finalCallback(err);

					this.readRecords(err => {
						if (err) return finalCallback(err);

						this.compile(onCompiled);
					});
				});
			});
		};
		...
	}
}
複製代碼

如圖所示,爲該自定義插件的執行過程:

_(1) 1.png

總結

  1. tapable 對外暴露 9 種 hook 鉤子,核心方法是註冊、執行、攔截器
  2. tapable 實現方式就是根據鉤子類型以及註冊類型來拼接字符串傳入 Function 構造函數建立一個新的 Function 對象
  3. webpack 經過 tapable 來對整個構建步驟進行了流程化的管理。實現了對每一個構建步驟都能進行靈活定製化需求。

若有意見,歡迎一鍵素質三連,寶~。

參考資料

[1]webpack 官方文檔中對於 plugin 的介紹: webpack.docschina.org/concepts/pl…

[2]tapable 相關介紹:www.zhufengpeixun.com/grow/html/1…

[3]tabpable 源碼:github.com/webpack/tap…

[4]webpack 源碼:github.com/webpack/web…

相關文章
相關標籤/搜索