可能是全網最全最新最細的 webpack-tapable-2.0 的原始碼分析

jizhi發表於2019-03-02

tapable (2.0.0-beta 版本)

之前分析了 tapable 0.2.8 版本的原始碼,看起來很好懂,但是也存在一些缺點,就是無法明確地知道 plugin 是屬於同步、還是非同步,無法更加細粒度的管理這些 handler,而且關於 async 的外掛都是採用遞迴的方式,自然記憶體的佔用就很大,

但是 tapable 2.0.0-beta 版本的重構,猶如藝術品一般,讓人驚豔。原始碼內部採用 getter 惰性載入與快取的方式,以及利用 new Function 去消除遞迴呼叫。

消除遞迴呼叫的方式就是在第一次呼叫 call 的時候,通過字串拼接可執行的字串程式碼(原始碼內部稱之為 compile),通過 new Function 來生成 fn,並且快取下來。這樣的作用就是將遞迴程式碼非遞迴化,能減少記憶體的消耗。

先來張圖,直觀感受下 Tapable 的架構,為什麼稱之為藝術。

可能是全網最全最新最細的 webpack-tapable-2.0 的原始碼分析

可以看出 Tabable 重構之後多了一個 Hook 的概念,有同步鉤子,非同步序列鉤子,非同步並行鉤子等。每種鉤子都是一個類,它們都是繼承於 Hook 基類。闡述下各種 Hook 類的作用。

Hook 類

名稱 鉤入的方式 作用
Hook taptapAsynctapPromise 鉤子基類
SyncHook tap 同步鉤子
SyncBailHook tap 同步鉤子,只要執行的 handler 有返回值,剩餘 handler 不執行
SyncLoopHook tap 同步鉤子,只要執行的 handler 有返回值,一直迴圈執行此 handler
SyncWaterfallHook tap 同步鉤子,上一個 handler 的返回值作為下一個 handler 的輸入值
AsyncParallelBailHook taptapAsynctapPromise 非同步鉤子,handler 並行觸發,但是跟 handler 內部呼叫回撥函式的邏輯有關
AsyncParallelHook taptapAsynctapPromise 非同步鉤子,handler 並行觸發
AsyncSeriesBailHook taptapAsynctapPromise 非同步鉤子,handler 序列觸發,但是跟 handler 內部呼叫回撥函式的邏輯有關
AsyncSeriesHook taptapAsynctapPromise 非同步鉤子,handler 序列觸發
AsyncSeriesLoopHook taptapAsynctapPromise 非同步鉤子,可以觸發 handler 迴圈呼叫
AsyncSeriesWaterfallHook taptapAsynctapPromise 非同步鉤子,上一個 handler 可以根據內部的回撥函式傳值給下一個 handler

Hook Helper 與 Tapable 類

名稱 作用
HookCodeFactory 編譯生成可執行 fn 的工廠類
HookMap Map 結構,儲存多個 Hook 例項
MultiHook 組合多個 Hook 例項
Tapable 向前相容老版本,例項必須擁有 hooks 屬性

簡單上手

tapable 2.0.0-beta 版本的使用跟之前分析的 0.2.8 版本完全不同,但是實現的功能,以及原理是一致的。

const { SyncHook } = require('tapable')

// 例項化 SyncHook
const sh = new SyncHook(['arg1'])

// 通過 tap 註冊 handler
sh.tap('1', function (arg1, arg2) {
    console.log(arg1, arg2, 1);
});
sh.tap({
  name: '2',
  before: '1',
}, function (arg1) {
    console.log(arg1, 2);
});
sh.tap({
  name: '3',
  stage: -1,
}, function (arg1) {
    console.log(arg1, 3);
});

// 通過 call 執行 handler
sh.call('tapable', 'tapable-2.0.0')

// 列印順序如下
tapable, 3
tapable, 2
tapable, undefined, 1
複製程式碼

如上所述,例項化 SyncHook 的時候接收字串陣列。它的長度會影響你通過 call 方法呼叫 handler 時入參個數。就像例子所示,呼叫 call 方法傳入的是兩個引數,實際上 handler 只能接收到一個引數,因為你在 new SyncHook 的時候傳入的字串陣列長度是1。SyncHook 物件是通過 tap 方法去註冊 handler的,第一個引數必須是字串或者物件,其實即使是字串,也會在內部轉成物件,變成如下結構:

interface Tap {
  name: string, // 標記每個 handler,必須有
  before: string | array, // 插入到指定的 handler 之前
	type: string, // 型別:'sync', 'async', 'promise'
	fn: Function, // handler
	stage: number, // handler 順序的優先順序,預設為 0,越小的排在越前面執行
	context: boolean // 內部是否維護 context 物件,這樣在不同的 handler 就能共享這個物件
}
複製程式碼

因為我 name 為 2 的 handler 註冊的時候,是傳了一個物件,它的 before 屬性為 1,說明這個 handler 要插到 name 為 1 的 handler 之前執行,因此列印的順序在第二位,但是又因為 name 為 3 的 handler 註冊的時候,stage 屬性為 -1,比其他的 handler 的 stage 要小,所以它會被移到最前面執行。

探索原理

那麼既然我們從 SyncHook 這個最簡單的鉤子類入手,也知道了如何使用,那麼我們從原始碼的角度來感受下 Tapable 重構版猶如藝術版的架構設計吧。找到入口 tapable/index.js

exports.__esModule = true;
exports.Tapable = require("./Tapable");
exports.SyncHook = require("./SyncHook");
exports.SyncBailHook = require("./SyncBailHook");
exports.SyncWaterfallHook = require("./SyncWaterfallHook");
exports.SyncLoopHook = require("./SyncLoopHook");
exports.AsyncParallelHook = require("./AsyncParallelHook");
exports.AsyncParallelBailHook = require("./AsyncParallelBailHook");
exports.AsyncSeriesHook = require("./AsyncSeriesHook");
exports.AsyncSeriesBailHook = require("./AsyncSeriesBailHook");
exports.AsyncSeriesWaterfallHook = require("./AsyncSeriesWaterfallHook");
exports.HookMap = require("./HookMap");
exports.MultiHook = require("./MultiHook");
複製程式碼

各種鉤子類以及鉤子輔助類都掛載在對應的屬性上。我們先來看 SyncHook

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

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

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;
複製程式碼

可以看出,SyncHook 是繼承於父類 Hook,並且原型上重寫了 tapAsync、tapPromise、compile 三個方法,也就是 SyncHook 不支援通過 tapAsync 與 tapPromise 來註冊 handler 的,因為它內部的邏輯是不支援非同步的。compile 方法是用來編譯生成對應的 fn,而呼叫 call 方法,其實就是執行了編譯生成的 fn。這個是後話,我們先來看下 Hook 類的實現,所有的鉤子都是繼承於 Hook 基類。

const util = require("util");

const deprecateContext = util.deprecate(() => {},
"Hook.context is deprecated and will be removed");

class Hook {
	constructor(args) {
		if (!Array.isArray(args)) args = []; // args 必須是陣列
    this._args = args;
		this.taps = []; // 存放每次執行 tap 方法的生成的 options 物件
    this.interceptors = []; //存放攔截器
    /**
     *  以下三種方法都是惰性載入,再執行一次之後,會快取編譯的 fn,
     *  只有在加入新 handler 的情況下,才會重新編譯,快取編譯生成的新 fn
     *  而 fn 其實函式體內將之前版本遞迴部分都磨平了,這樣會減少記憶體的消耗。
     **/
    // 提供 call 方法,執行 sync handler
    this.call = this._call;
    // 提供 promise 方法,執行 promise handler
    this.promise = this._promise;
    // 提供 callAsync 方法,執行 async handler
    this.callAsync = this._callAsync;
    // 會在編譯的 setup 期間過濾 this.taps 得到所有的 handler 組成的陣列
		this._x = undefined;
	}

  // 所有子類都必須重寫編譯方法,因為每個 Hook 子類都有自己的 compile rules。
	compile(options) {
		throw new Error("Abstract: should be overriden");
	}

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

  //  註冊 'sync' fn
	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)"
			);
		if (typeof options.name !== "string" || options.name === "")
			throw new Error("Missing name for tap");
		if (typeof options.context !== "undefined") deprecateContext();
		options = Object.assign({ type: "sync", fn: fn }, options);
		options = this._runRegisterInterceptors(options);
		this._insert(options);
  }
  
  //  註冊 'async' fn
	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)"
			);
		if (typeof options.name !== "string" || options.name === "")
			throw new Error("Missing name for tapAsync");
		if (typeof options.context !== "undefined") deprecateContext();
		options = Object.assign({ type: "async", fn: fn }, options);
		options = this._runRegisterInterceptors(options);
		this._insert(options);
	}

  //  註冊 'promise' fn
	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)"
			);
		if (typeof options.name !== "string" || options.name === "")
			throw new Error("Missing name for tapPromise");
		if (typeof options.context !== "undefined") deprecateContext();
		options = Object.assign({ type: "promise", fn: fn }, options);
		options = this._runRegisterInterceptors(options);
		this._insert(options);
	}

  // 每次執行 tap 的時候,傳入的 options 都要經過 interceptor.register 函式的邏輯。
	_runRegisterInterceptors(options) {
		for (const interceptor of this.interceptors) {
			if (interceptor.register) {
				const newOptions = interceptor.register(options);
				if (newOptions !== undefined) {
					options = newOptions;
				}
			}
		}
		return options;
	}

	withOptions(options) {
		const mergeOptions = opt =>
			Object.assign({}, options, typeof opt === "string" ? { name: opt } : opt);

		// Prevent creating endless prototype chains
		options = Object.assign({}, options, this._withOptions);
		const base = this._withOptionsBase || this;
		const newHook = Object.create(base);

		newHook.tap = (opt, fn) => base.tap(mergeOptions(opt), fn);
		newHook.tapAsync = (opt, fn) => base.tapAsync(mergeOptions(opt), fn);
		newHook.tapPromise = (opt, fn) => base.tapPromise(mergeOptions(opt), fn);
		newHook._withOptions = options;
		newHook._withOptionsBase = base;
		return newHook;
	}

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

  // 註冊攔截器
	intercept(interceptor) {
		this._resetCompilation();
		this.interceptors.push(Object.assign({}, interceptor));
		if (interceptor.register) {
			for (let i = 0; i < this.taps.length; i++) {
				this.taps[i] = interceptor.register(this.taps[i]);
			}
		}
	}

  // 每次註冊新 handler,要重新編譯
	_resetCompilation() {
		this.call = this._call;
		this.callAsync = this._callAsync;
		this.promise = this._promise;
	}
  // 插入 tap 物件,可能根據 before,stage 屬性,調整 handler 的執行順序
	_insert(item) {
		this._resetCompilation();
		let before;
		if (typeof item.before === "string") {
			before = new Set([item.before]);
		} else if (Array.isArray(item.before)) {
			before = new Set(item.before);
		}
		let stage = 0;
		if (typeof item.stage === "number") {
			stage = item.stage;
		}
    let i = this.taps.length;
    // 根據 before,stage 屬性,調整 handler 的執行順序
		while (i > 0) {
			i--;
			const x = this.taps[i];
			this.taps[i + 1] = x;
			const xStage = x.stage || 0;
			if (before) {
				if (before.has(x.name)) {
					before.delete(x.name);
					continue;
				}
				if (before.size > 0) {
					continue;
				}
			}
			if (xStage > stage) {
				continue;
			}
			i++;
			break;
		}
		this.taps[i] = item;
	}
}

function createCompileDelegate(name, type) {
	return function lazyCompileHook(...args) {
    // 重新賦值 this.call, this.promise, this.callAsync
    // 因為第一個呼叫 call 的時候,會走到 _createCall 去 compile,生成 fn
    // 但是第二次呼叫 call 的時候,fn 已經賦值給了 this.call 了,不需要走到 compile 的邏輯了。
		this[name] = this._createCall(type);
		return this[name](...args);
	};
}

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;
複製程式碼

可以看到,Hook 提供了 tap、tapAsync、tapPromise 來註冊 handler,通過了 call、callAsync、promise 三種方式來呼叫 handler,同時內部還對這三種呼叫方式做了惰性求值,並且會快取編譯結果直到注入了新 handler。

分析完 Hook 類的大致功能,我們再回到 SyncHook 類。發現 compile 方法裡面 new SyncHookCodeFactory。從字面上的理解就是生成同步鉤子程式碼的工廠類,它繼承於 HookCodeFactory 類。那麼分析下 HookCodeFactory.js

/*
	MIT License http://www.opensource.org/licenses/mit-license.php
	Author Tobias Koppers @sokra
*/
"use strict";

class HookCodeFactory {
	constructor(config) {
		this.config = config;
		this.options = undefined;
		this._args = undefined;
	}

	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`,
							onDone: () => "",
							rethrowIfPossible: true
						})
				);
				break;
			case "async":
				fn = new Function(
					this.args({
						after: "_callback"
					}),
					'"use strict";\n' +
						this.header() +
						this.content({
							onError: err => `_callback(${err});\n`,
							onResult: result => `_callback(null, ${result});\n`,
							onDone: () => "_callback();\n"
						})
				);
				break;
			case "promise":
				......
				fn = new Function(this.args(), code);
				break;
		}
		this.deinit();
		return fn;
	}

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

	init(options) {
		this.options = options;
		this._args = options.args.slice();
	}

	deinit() {
		this.options = undefined;
		this._args = undefined;
	}

	header() {
		let code = "";
		......
		return code;
	}

	needContext() {
		for (const tap of this.options.taps) if (tap.context) return true;
		return false;
	}

	callTap(tapIndex, { onError, onResult, onDone, rethrowIfPossible }) {
		......
		return code;
	}

	callTapsSeries({ onError, onResult, onDone, rethrowIfPossible }) {
    ......
	}

	callTapsLooping({ onError, onDone, rethrowIfPossible }) {
		......
	}

	callTapsParallel({
		onError,
		onResult,
		onDone,
		rethrowIfPossible,
		onTap = (i, run) => run()
	}) {
		......
		return code;
	}

	args({ before, after } = {}) {
		......
	}

  ......
}

module.exports = HookCodeFactory;

複製程式碼

HookCodeFactory 的原型上有很多方法,但是千萬不要慌,也不要畏懼。如果看不懂程式碼,我們可以一步步 debugger 去除錯。

SyncHook 在執行 compile 的時候會呼叫 HookCodeFactory 的 setup、create 方法,我們先來看下這兩個方法

setup(instance, options) {
  // 過濾出傳入的 handler
  instance._x = options.taps.map(t => t.fn);
}
init(options) {
  this.options = options;
  this._args = options.args.slice();
}
deinit() {
  this.options = undefined;
  this._args = undefined;
}
create(options) {
  // 獲取呼叫方 new SyncHook(options)
  this.init(options);
  let fn;
  // 判斷 handler 的型別,通過 new Function 將字串變成 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`,
            onDone: () => "",
            rethrowIfPossible: true
          })
      );
      break;
    case "async":
      fn = new Function(
        this.args({
          after: "_callback"
        }),
        '"use strict";\n' +
          this.header() +
          this.content({
            onError: err => `_callback(${err});\n`,
            onResult: result => `_callback(null, ${result});\n`,
            onDone: () => "_callback();\n"
          })
      );
      break;
    case "promise":
      ......
      fn = new Function(this.args(), code);
      break;
  }
  // 重置引數,因為 SyncHook 類儲存的是一份 HookCodeFactory 類的例項,所以每次編譯完,為了防止影響 其他SyncHook 例項。
  this.deinit();
  // 返回編譯生成的函式
  return fn;
}
複製程式碼

從執行的邏輯來看,就是先從 taps 裡面過濾出 handler,然後根據型別來生成對應的 fn。所以我們在呼叫 call、callAsync、promise 的時候,執行的就是編譯生成的 fn,並且把引數傳入。

上面的例子是用到的 SyncHook,只會走到 case "sync" 的邏輯,我們重點分析如何生成 fn 的,其餘的也是依葫蘆畫瓢。

fn = new Function(
  this.args(),
  '"use strict";\n' +
    this.header() +
    this.content({
      onError: err => `throw ${err};\n`,
      onResult: result => `return ${result};\n`,
      onDone: () => "",
      rethrowIfPossible: true
    })
);
複製程式碼

那我們從下面三個步驟來看:

  • 生成 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(", ");
      }
    }
    複製程式碼

    根據例項化 SyncHook 傳入的引數以逗號拼接形參字串。支援 before 與 after 屬性,能夠在字串的頭部或者尾部插入對應的屬性值字串。比如 new SyncHook(['arg1', 'arg2']),那麼經過 this.args 處理後,就變成 "arg1, arg2"。再通過 fn = new Function("arg1, arg2") 之後,就變成 fn 接收 arg1 與 arg2兩個形參了。假如你在使用 call 方法的時候傳入三個引數,那麼第三個引數就獲取不到了,因為 fn 只支援兩個引數。

  • 生成 fn 函式體的頭部程式碼字串

    header() {
      let code = "";
      // tap 的時候傳入了 {context: true}
      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";
      }
      for (let i = 0; i < this.options.interceptors.length; i++) {
        const interceptor = this.options.interceptors[i];
        if (interceptor.call) {
          code += `${this.getInterceptor(i)}.call(${this.args({
            before: interceptor.context ? "_context" : undefined
          })});\n`;
        }
      }
      return code;
    }
    
    needContext() {
      for (const tap of this.options.taps) if (tap.context) return true;
      return false;
    }
    
    getInterceptor(idx) {
      return `_interceptors[${idx}]`;
    }
    複製程式碼

    header 函式主要是生成頭部的一些引數,可以看到如果通過 tap、tapPromise、tapAsync 註冊 handler的時候傳入了 context: true,那麼會生成 _context 物件,並且會將 _context 傳入每一個 handler,因為這是個物件引用,所以對於每個 handler 來說,其實是共享了一份 _context 物件。同時 Hook 是支援通過 intercept 方法註冊攔截器的,該方法接收一個物件作為入參,該物件都會儲存在鉤子例項的 interceptors 陣列。資料結構如下:

    interface HookInterceptor {
      call: (context?, ...args) => void, // 還未開始執行 handler 之前執行
      loop: (context?, ...args) => void,
      tap: (context?, tap: Tap) => void, // 插入一個 handler
      register: (tap: Tap) => Tap, // 改變 tap 物件
      context: boolean
    }
    複製程式碼

    從介面來看,我們可以通過 intercept 方法來插入自己的邏輯,不僅可以註冊 handler 還可以改變 tap 物件,這樣使得鉤子變得更靈活,更有彈性。

  • 生成 fn 函式體的中間執行程式碼的字串

    看完了 header 的邏輯,我們再來看 content 的邏輯,因為 content 對於每種鉤子的程式碼生成都不一樣,所以是在對應的鉤子生成的工廠類上做了覆蓋,那麼對於 SyncHook 而言,content 是在 SyncHookCodeFactory 這個工廠類重寫了 content 方法。

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

    可以看到 SyncHookCodeFactory 這個類的 content 方法是接收一個物件,並且內部又呼叫了 HookCodeFactory 類上的 callTapsSeries 方法,同時將 onError、onDone、rethrowIfPossible 傳入了。我們看下 callTapsSeries 的定義。

    callTapsSeries({ onError, onResult, onDone, rethrowIfPossible }) {
      if (this.options.taps.length === 0) return onDone();
      const firstAsync = this.options.taps.findIndex(t => t.type !== "sync");
      const next = i => {
        if (i >= this.options.taps.length) {
          return onDone();
        }
        const done = () => next(i + 1);
        const doneBreak = skipDone => {
          if (skipDone) return "";
          return onDone();
        };
        return this.callTap(i, {
          onError: error => onError(i, error, done, doneBreak),
          onResult:
            onResult &&
            (result => {
              return onResult(i, result, done, doneBreak);
            }),
          onDone:
            !onResult &&
            (() => {
              return done();
            }),
          rethrowIfPossible:
            rethrowIfPossible && (firstAsync < 0 || i < firstAsync)
        });
      };
      return next(0);
    }
    複製程式碼

    從上面可以看出函式內部維護了一個 next 函式,next 函式內部會呼叫 callTap,而 callTap 內部會在合適的時機呼叫 done,那麼又會走到 next 函式,那麼這樣就形成了自執行的機制,而函式退出的條件就是遍歷了所有的 this.options.taps 之後,這個資料是維護了我們通過 tap、tapPromise、tapAsync 註冊 handler 的資訊。

阻力與尋找解決辦法。

從上面剖析 SyncHook 原始碼的結果來看,尤其是 compile 那塊涉及到拼接字串,通過 new Function 生成 fn。這一塊可閱讀性比較差,所以我們以具體的 Hook 類的使用場景,來覆蓋原始碼的每個步驟,一步步除錯。

同步鉤子案例大全

所有的同步鉤子只支援 tap 方法來註冊 sync handler。

syncHook(同步鉤子)

const { SyncHook } = require('tapable')

// 例項化 SyncHook
const sh = new SyncHook(['arg1'])

// 通過 tap 註冊 handler
sh.tap('1', function (arg1, arg2) {
    console.log(arg1, arg2, 1);
});
sh.tap({
  name: '2',
  before: '1',
}, function (arg1) {
    console.log(arg1, 2);
});
sh.tap({
  name: '3',
  stage: -1,
}, function (arg1) {
    console.log(arg1, 3);
});

// 通過 call 執行 handler
sh.call('tapable', 'tapable-2.0.0')

// 列印順序如下
tapable, 3
tapable, 2
tapable, undefined, 1
複製程式碼
  1. tap 的原始碼分析

    • 先校驗 options 引數的格式,再走到 _runRegisterInterceptors 方法,這一步是為了執行攔截器的 register 方法,來改變 options。
    • 接著走到 _insert 內部,內部根據 before、stage 屬性來調整 handler 的順序,並且將所有的資訊儲存到 taps 陣列裡面。
  2. call 的原始碼分析

    • 執行 call,就是執行了原型上的 _call,也就是執行了 createCompileDelegate,這個函式返回的是另外一個 lazyCompileHook 函式,在 lazyCompileHook 函式內部會重新賦值 call 方法,得到編譯後的結果。也就是第二次呼叫 call的時候,其實就是執行 _createCall 方法的返回值。
    • _createCall 內部執行了 compile 方法,這個方法在 SyncHook 的原型上。compile 的內部先執行 SyncHookCodeFactory 上的 setup 方法,然後執行 create 方法。
    • setup 與 create 方法都是在 HookCodeFactory 的原型上,因為 SyncHookCodeFactory 是繼承於 HookCodeFactory。
    • setup 內部的邏輯很簡單,就是從 taps 陣列過濾出傳入的 handler。
    • create 內部先初始化 options 引數,這個是在呼叫 compile 的時候傳入的,然後通過字串拼接執行 new Function 得到 fn,最後執行的也是這個 fn。

我們一般對 new Function 很陌生,所以很好奇 create 裡面到底是生成了什麼。可以在 new Function 打個斷點,一步步 debugger 一下,最後會發現生成的 fn 是如下的函式。

(function anonymous(arg1) {
  // header
  "use strict";
  var _context;
  var _x = this._x;
  // content
  var _fn0 = _x[0];
  _fn0(arg1);
  var _fn1 = _x[1];
  _fn1(arg1);
  var _fn2 = _x[2];
  _fn2(arg1);
})

// arg1 引數,其實就是在 new Function 時候呼叫 this.args 生成的字串而來的,而 this.args 是由例項化鉤子傳入的
// header 塊,this.header 生成的 (HookCodeFactory 原型上)
// content 塊,this.content 生成的 (這個方法會在對應的鉤子工廠類的原型上重寫)
複製程式碼

而執行 sh.call('tapable', 'tapable-2.0.0'),其實執行的就是上述的函式,那麼這個庫的作者處心積慮的這麼做的意義何在呢,當然這個例子也看不出很大的作用,只能看到函式體內部沒有 for 迴圈,函式的執行都是扁平的。最大的好處其實在於你看過 Async*Hook 編譯出來的 fn,你就知道為啥要這麼做了。

SyncBailHook(同步保險鉤子)

const sbh = new SyncBailHook(['arg1'])
sbh.tap({ 
  context: true, 
  name: '1'
}, function (context, arg1) {
  console.log(context, arg1, 1)
  return 1
});
sbh.tap({
  name: '2',
}, function (arg1) {
  // 不會執行
  console.log(arg1, 2)
});

sbh.call('tapable')

// 列印
{}, tapable, 1
複製程式碼

編譯的 fn 如下

(function anonymous(arg1) {
  "use strict";
    var _context = {};
    var _x = this._x;
    var _fn0 = _x[0];
    var _result0 = _fn0(_context, arg1);
    if (_result0 !== undefined) {
        return _result0;;
    } else {
        var _fn1 = _x[1];
        var _result1 = _fn1(arg1);
        if (_result1 !== undefined) {
            return _result1;;
        } else {}
    }

})
複製程式碼

SyncBailHook 從字面上的意思是同步保險鉤子,也就是隻要前面的 handler 返回值不是 undefined,下一個 handler 就不會被觸發。

SyncLoopHook(同步迴圈鉤子)

const slh = new SyncLoopHook()
// 因為 handler 返回值不為 undefined,會一直迴圈執行
slh.tap('1', () => {
  console.log(1)
  return 1
})
slh.tap('2', () => {
  console.log(2)
  return 2
})
slh.call()
複製程式碼

編譯的 fn 如下

(function anonymous() {
    "use strict";
    var _context;
    var _x = this._x;
    var _loop;
    do {
        _loop = false;
        var _fn0 = _x[0];
        var _result0 = _fn0();
        if (_result0 !== undefined) {
            _loop = true;
        } else {
            var _fn1 = _x[1];
            var _result1 = _fn1();
            if (_result1 !== undefined) {
                _loop = true;
            } else {
                if (!_loop) {}
            }
        }
    } while ( _loop );
})
複製程式碼

SyncLoopHook 從字面上的意思是同步迴圈鉤子,也就是隻要前面的 handler 返回值不是 undefined,那麼會一直迴圈執行。

SyncWaterfallHook(同步瀑布鉤子)

// SyncWaterfallHook 必須傳入一個長度不為 0 的陣列
const swfh = new SyncWaterfallHook(['arg'])
swfh.tap('1', (arg) => {
  console.log(arg)
  return 1
})
swfh.tap('2', (arg) => {
  console.log(arg)
  return 2
})
swfh.call('webpack')

// 列印如下
webpack
1
複製程式碼

編譯的 fn 如下

(function anonymous(arg) {
    "use strict";
    var _context;
    var _x = this._x;
    var _fn0 = _x[0];
    var _result0 = _fn0(arg);
    if (_result0 !== undefined) {
        arg = _result0;
    }
    var _fn1 = _x[1];
    var _result1 = _fn1(arg);
    if (_result1 !== undefined) {
        arg = _result1;
    }
    return arg;
})
複製程式碼

對於 SyncWaterfallHook,前面的 handler 返回值作為下一個 handler 的輸入值,並且要求例項化 SyncWaterfallHook 的時候,傳入非零長度的陣列。call 傳入的引數會作為第一個 handler 的入參。

非同步鉤子案例大全

所有的非同步鉤子支援 tap、tapAsync、tapPromise 方法來註冊各種型別的 handler,但是不支援 call 方法來觸發 handler,只支援 promise、callAsync。

AsyncParallelBailHook(非同步並行保險鉤子)

const apbh = new AsyncParallelBailHook()
apbh.tapAsync('1', (next) => {
  setTimeout(() => {
    next(1)
  }, 3000)
})
apbh.tapAsync('2', (next) => {
  setTimeout(() => {
    next(2)
  }, 1000)
})
apbh.callAsync((result) => {
  console.log(result)
  console.log('callback 執行完成')
})

// 列印如下
1 // 3s 後列印的
callback 執行完成
複製程式碼

編譯的 fn 如下

(function anonymous(_callback) {
    "use strict";
    var _context;
    var _x = this._x;
    var _results = new Array(2);
    var _checkDone = () = >{
        for (var i = 0; i < _results.length; i++) {
            var item = _results[i];
            if (item === undefined) return false;
            if (item.result !== undefined) {
                _callback(null, item.result);
                return true;
            }
            if (item.error) {
                _callback(item.error);
                return true;
            }
        }
        return false;
    }
    do {
        var _counter = 2;
        var _done = () = >{
            _callback();
        };
        if (_counter <= 0) break;
        var _fn0 = _x[0];
        _fn0((_err0, _result0) = >{
            if (_err0) {
                if (_counter > 0) {
                    if (0 < _results.length && ((_results.length = 1), (_results[0] = {
                        error: _err0
                    }), _checkDone())) {
                        _counter = 0;
                    } else {
                        if (--_counter === 0) _done();
                    }
                }
            } else {
                if (_counter > 0) {
                    if (0 < _results.length && (_result0 !== undefined && (_results.length = 1), (_results[0] = {
                        result: _result0
                    }), _checkDone())) {
                        _counter = 0;
                    } else {
                        if (--_counter === 0) _done();
                    }
                }
            }
        });
        if (_counter <= 0) break;
        if (1 >= _results.length) {
            if (--_counter === 0) _done();
        } else {
            var _fn1 = _x[1];
            _fn1((_err1, _result1) = >{
                if (_err1) {
                    if (_counter > 0) {
                        if (1 < _results.length && ((_results.length = 2), (_results[1] = {
                            error: _err1
                        }), _checkDone())) {
                            _counter = 0;
                        } else {
                            if (--_counter === 0) _done();
                        }
                    }
                } else {
                    if (_counter > 0) {
                        if (1 < _results.length && (_result1 !== undefined && (_results.length = 2), (_results[1] = {
                            result: _result1
                        }), _checkDone())) {
                            _counter = 0;
                        } else {
                            if (--_counter === 0) _done();
                        }
                    }
                }
            });
        }
    } while ( false );
})
複製程式碼

從 AsyncParallelBailHook 來看,每個 handler 的最後一位形參是 next,它是一個函式,使用者必須手動執行並且傳參,這樣 callback 會拿到該引數並且執行。從例子可以看出,callback 的執行是取決於註冊的 handler 的順序,雖然 next(2) 是在 1s 後就執行了,但是還是不會觸發 callback,而是 next(1) 觸發了 callback。

AsyncParallelHook(非同步並行鉤子)

const apl = new AsyncParallelHook()

apl.tapAsync('1', (next) => {
  setTimeout(() => {
    next(1)
  }, 3000)
})
apl.tapAsync('2', (next) => {
  setTimeout(() => {
    next(2)
  }, 1000)
})
apl.callAsync((result) => {
  console.log(result)
  console.log('callback 執行完成')
})

// 列印如下
2 // 1s 後列印的
callback 執行完成
複製程式碼

編譯的 fn 如下

(function anonymous(_callback) {
    "use strict";
    var _context;
    var _x = this._x;
    do {
        var _counter = 2;
        var _done = () = >{
            _callback();
        };
        if (_counter <= 0) break;
        var _fn0 = _x[0];
        _fn0(_err0 = >{
            if (_err0) {
                if (_counter > 0) {
                    _callback(_err0);
                    _counter = 0;
                }
            } else {
                if (--_counter === 0) _done();
            }
        });
        if (_counter <= 0) break;
        var _fn1 = _x[1];
        _fn1(_err1 = >{
            if (_err1) {
                if (_counter > 0) {
                    _callback(_err1);
                    _counter = 0;
                }
            } else {
                if (--_counter === 0) _done();
            }
        });
    } while ( false );
})
複製程式碼

從 AsyncParallelHook 來看,每個 handler 的最後一位形參是 next,它是一個函式,使用者必須手動執行並且傳參,這樣 callback 會拿到該引數並且執行。從例子可以看出,callback 的執行是取決執行 next 函式的快慢。

AsyncSeriesBailHook(非同步序列保險鉤子)

const asbh = new AsyncSeriesBailHook()

asbh.tapPromise('1', () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(1)
    }, 3000)
  })
})
asbh.tapPromise('2', () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(2)
    })
  })
})
asbh.promise().then((res) => {
  console.log(res)
})

// 列印如下
1 // 3s 後列印的
複製程式碼

編譯的 fn 如下

(function anonymous() {
    "use strict";
    return new Promise((_resolve, _reject) = >{
        var _sync = true;
        var _context;
        var _x = this._x;
        var _fn0 = _x[0];
        var _hasResult0 = false;
        var _promise0 = _fn0();
        if (!_promise0 || !_promise0.then) throw new Error('Tap function (tapPromise) did not return promise (returned ' + _promise0 + ')');
        _promise0.then(_result0 = >{
            _hasResult0 = true;
            if (_result0 !== undefined) {
                _resolve(_result0);;
            } else {
                var _fn1 = _x[1];
                var _hasResult1 = false;
                var _promise1 = _fn1();
                if (!_promise1 || !_promise1.then) throw new Error('Tap function (tapPromise) did not return promise (returned ' + _promise1 + ')');
                _promise1.then(_result1 = >{
                    _hasResult1 = true;
                    if (_result1 !== undefined) {
                        _resolve(_result1);;
                    } else {
                        _resolve();
                    }
                },
                _err1 = >{
                    if (_hasResult1) throw _err1;
                    if (_sync) _resolve(Promise.resolve().then(() = >{
                        throw _err1;
                    }));
                    else _reject(_err1);
                });
            }
        },
        _err0 = >{
            if (_hasResult0) throw _err0;
            if (_sync) _resolve(Promise.resolve().then(() = >{
                throw _err0;
            }));
            else _reject(_err0);
        });
        _sync = false;
    });

})
複製程式碼

我們用 tapPromise 方法做了個測試,handler 必須返回一個 Promise,而且 AsyncSeriesBailHook 鉤子的 promise 方法返回的是一個 Promise,then 裡面的回撥函式的引數與註冊的 handler 返回的 Promise 有關。

AsyncSeriesHook(非同步序列鉤子)

const ash = new AsyncSeriesHook()

ash.tapAsync('1', (next) => {
  console.log(1)
  next()
})
ash.tapAsync('2', (next) => {
  console.log(2)
  next('觸發 callback')
})
ash.callAsync(function callback () {
  console.log('callback 執行完了')
})

// 列印如下
1
2
callback 執行完了
複製程式碼

編譯的 fn 如下

(function anonymous(_callback) {
    "use strict";
    var _context;
    var _x = this._x;
    var _fn0 = _x[0];
    _fn0(_err0 = >{
        if (_err0) {
            _callback(_err0);
        } else {
            var _fn1 = _x[1];
            _fn1(_err1 = >{
                if (_err1) {
                    _callback(_err1);
                } else {
                    _callback();
                }
            });
        }
    });
})
複製程式碼

序列執行 handler,handler 引數的最後一個是 next 函式,必須手動執行,才會走到下面的邏輯。callback 的執行是根據 next是否傳參決定的。由之前的 tapbale-0.2.8原始碼分析來看,之前為了實現非同步的鉤子,都需要函式內部有個遞迴呼叫的過程,現在編譯之後,所有的邏輯都扁平化了,不會引起遞迴佔用過多的空間的問題。這也是重構的好處。

AsyncSeriesWaterfallHook(非同步序列瀑布鉤子)

const ash = new AsyncSeriesWaterfallHook(['name'])

ash.tapAsync('1', (name, next) => {
  console.log(name)
  next(null, '來自 handler 1 的引數')
})
ash.tapAsync('2', (name, next) => {
  console.log(name)
  next(null, '來自 handler 2 的引數')
})
ash.callAsync('來自初始化的引數', (err, name) => {
  console.log(name)
})

// 列印如下
來自初始化的引數
來自 handler 1 的引數
來自 handler 2 的引數
複製程式碼

編譯的 fn 如下

(function anonymous(name, _callback) {
    "use strict";
    var _context;
    var _x = this._x;
    var _fn0 = _x[0];
    _fn0(name, (_err0, _result0) = >{
        if (_err0) {
            _callback(_err0);
        } else {
            if (_result0 !== undefined) {
                name = _result0;
            }
            var _fn1 = _x[1];
            _fn1(name, (_err1, _result1) = >{
                if (_err1) {
                    _callback(_err1);
                } else {
                    if (_result1 !== undefined) {
                        name = _result1;
                    }
                    _callback(null, name);
                }
            });
        }
    });
})
複製程式碼

非同步序列執行 handler,handler 引數的最後一個是 next 函式,必須手動執行,才會走到下面的邏輯。callback 的執行是根據 next是否傳參決定的。第一個引數是 error,第二個引數是傳給下一個 handler 的值,如果 error 存在的話,直接會執行 callback。

同非同步鉤子類的總結

分析了所有的同非同步鉤子,根據之前的 tapable 版本,牽涉到非同步執行的鉤子,函式內部肯定是存在遞迴的,這樣寫起來容易讓人看懂。然而 2.0.0-beta 版本採用字串拼接的方法把遞迴部分給抹平了,而且還會快取每次編譯的生成的 fn。這樣來說,空間佔用就變少了,效能更好了。

Tapable

根據 tapbale-0.2.8 原始碼分析,Tapable 是唯一的類。那麼 2.0.0-beta 版為了相容之前的語法,應該怎麼做呢。繼續定位到 Tapable.js

const util = require("util");
const SyncBailHook = require("./SyncBailHook");

function Tapable() {
    // 宣告同步保險鉤子
    this._pluginCompat = new SyncBailHook(["options"]);
    // 註冊 handler,主要是為了將 pluginName camelize 化。
	this._pluginCompat.tap(
		{
			name: "Tapable camelCase",
			stage: 100
		},
		options => {
			options.names.add(
				options.name.replace(/[- ]([a-z])/g, (str, ch) => ch.toUpperCase())
			);
		}
    );
    // 在 hooks 屬性上對應的鉤子上註冊 handler
	this._pluginCompat.tap(
		{
			name: "Tapable this.hooks",
			stage: 200
		},
		options => {
			let hook;
			for (const name of options.names) {
				hook = this.hooks[name];
				if (hook !== undefined) {
					break;
				}
			}
			if (hook !== undefined) {
				const tapOpt = {
					name: options.fn.name || "unnamed compat plugin",
					stage: options.stage || 0
				};
				if (options.async) hook.tapAsync(tapOpt, options.fn);
				else hook.tap(tapOpt, options.fn);
				return true;
			}
		}
	);
}
module.exports = Tapable;

Tapable.addCompatLayer = function addCompatLayer(instance) {
	Tapable.call(instance);
	instance.plugin = Tapable.prototype.plugin;
	instance.apply = Tapable.prototype.apply;
};

// 註冊 handler,實際上會走到 _pluginCompat 屬性上的第二個 handler,進而在對應的 hooks 註冊了 handler。
Tapable.prototype.plugin = util.deprecate(function plugin(name, fn) {
	if (Array.isArray(name)) {
		name.forEach(function(name) {
			this.plugin(name, fn);
		}, this);
		return;
	}
	const result = this._pluginCompat.call({
		name: name,
		fn: fn,
		names: new Set([name])
	});
	if (!result) {
		throw new Error(
			`Plugin could not be registered at '${name}'. Hook was not found.\n` +
				"BREAKING CHANGE: There need to exist a hook at 'this.hooks'. " +
				"To create a compatibility layer for this hook, hook into 'this._pluginCompat'."
		);
	}
}, "Tapable.plugin is deprecated. Use new API on `.hooks` instead");

Tapable.prototype.apply = util.deprecate(function apply() {
	for (var i = 0; i < arguments.length; i++) {
		arguments[i].apply(this);
	}
}, "Tapable.apply is deprecated. Call apply on the plugin directly instead");
複製程式碼

Tapable 重構之後,為了相容之前的版本,費了一點心思,首先 Tapable 上有個 _pluginCompat 屬性是同步保險鉤子,並且註冊了兩個 handler,這兩個 handler 的觸發時機是在於你呼叫 plugin 方法的時候,先將你傳入的外掛名 camelize 化,然後在 hooks 屬性上尋找對應的鉤子例項,並且呼叫 tap 方法真正註冊 handler。

這麼做的目的在於什麼呢?因為 webpack 的 Compiler 類就是繼承於 Tapable,所以 webpack 與 Tapable 升級了,由於內部做了一定的相容,不會對使用者以前的 plugin 造成任何影響。所以使用者不用再重寫他們的 plugin 了。對於 webpack 開發外掛,只需要提供帶有 apply 方法的物件或者提供一個函式,外掛在鉤子例項上註冊 handler 的時候,依然可以通過 compiler.plugin 來註冊外掛,但是命令列會列印出提示語句,提示你儘量使用新語法,可以看出外掛升級之後的影響也是降到最低。

所獲

經過分析了 tapable-0.2.8 以及 tapable-2.0.0-beta 版本的原始碼,深刻地體會到作者 js 的功底之深厚,前一個版本對於 js 基礎好一點的人都能寫出來,但是後一個版本的整體架構設計,以及對前一個版本的相容都是做的非常好的。之前看了 javascript 設計模式什麼的,現在都覺得都是泛泛之談,而真正能應用於實際場景才說明你對各種設計模式融會貫通,不是為了追求設計模式,在無形當中,你的感覺會帶著你走,會寫出高質量的程式碼。這也是大量閱讀優秀原始碼的好處。比如 Vue、Vuex、Vue-Router 的架構設計,以及這篇Vue 全家桶原始碼解析

相關文章