webpack-外掛機制雜記

菜的黑人牙膏發表於2019-03-04

系列文章

Webpack系列-第一篇基礎雜記
Webpack系列-第二篇外掛機制雜記
Webpack系列-第三篇流程雜記

前言

webpack本身並不難,他所完成的各種複雜炫酷的功能都依賴於他的外掛機制。或許我們在日常的開發需求中並不需要自己動手寫一個外掛,然而,瞭解其中的機制也是一種學習的方向,當外掛出現問題時,我們也能夠自己來定位。

Tapable

Webpack的外掛機制依賴於一個核心的庫, Tapable
在深入webpack的外掛機制之前,需要對該核心庫有一定的瞭解。

Tapable是什麼

tapable 是一個類似於nodejs 的EventEmitter 的庫, 主要是控制鉤子函式的釋出與訂閱。當然,tapable提供的hook機制比較全面,分為同步和非同步兩個大類(非同步中又區分非同步並行和非同步序列),而根據事件執行的終止條件的不同,由衍生出 Bail/Waterfall/Loop 型別。

Tapable的使用 (該小段內容引用文章

基本使用

const {
  SyncHook
} = require('tapable')

// 建立一個同步 Hook,指定引數
const hook = new SyncHook(['arg1', 'arg2'])

// 註冊
hook.tap('a', function (arg1, arg2) {
	console.log('a')
})

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

hook.call(1, 2)
複製程式碼

鉤子型別

image

image

BasicHook:執行每一個,不關心函式的返回值,有SyncHook、AsyncParallelHook、AsyncSeriesHook。

BailHook:順序執行 Hook,遇到第一個結果result!==undefined則返回,不再繼續執行。有:SyncBailHook、AsyncSeriseBailHook, AsyncParallelBailHook。

什麼樣的場景下會使用到 BailHook 呢?設想如下一個例子:假設我們有一個模組 M,如果它滿足 A 或者 B 或者 C 三者任何一個條件,就將其打包為一個單獨的。這裡的 A、B、C 不存在先後順序,那麼就可以使用 AsyncParallelBailHook 來解決:

x.hooks.拆分模組的Hook.tap('A', () => {
   if (A 判斷條件滿足) {
     return true
   }
 })
 x.hooks.拆分模組的Hook.tap('B', () => {
   if (B 判斷條件滿足) {
     return true
   }
 })
 x.hooks.拆分模組的Hook.tap('C', () => {
   if (C 判斷條件滿足) {
     return true
   }
 })

複製程式碼

如果 A 中返回為 true,那麼就無須再去判斷 B 和 C。 但是當 A、B、C 的校驗,需要嚴格遵循先後順序時,就需要使用有順序的 SyncBailHook(A、B、C 是同步函式時使用) 或者 AsyncSeriseBailHook(A、B、C 是非同步函式時使用)。

WaterfallHook:類似於 reduce,如果前一個 Hook 函式的結果 result !== undefined,則 result 會作為後一個 Hook 函式的第一個引數。既然是順序執行,那麼就只有 Sync 和 AsyncSeries 類中提供這個Hook:SyncWaterfallHook,AsyncSeriesWaterfallHook 當一個資料,需要經過 A,B,C 三個階段的處理得到最終結果,並且 A 中如果滿足條件 a 就處理,否則不處理,B 和 C 同樣,那麼可以使用如下

x.hooks.tap('A', (data) => {
   if (滿足 A 需要處理的條件) {
     // 處理資料 data
     return data
   } else {
     return
   }
 })
x.hooks.tap('B', (data) => {
   if (滿足B需要處理的條件) {
     // 處理資料 data
     return data
   } else {
     return
   }
 })
 x.hooks.tap('C', (data) => {
   if (滿足 C 需要處理的條件) {
     // 處理資料 data
     return data
   } else {
     return
   }
 })
複製程式碼

LoopHook:不停的迴圈執行 Hook,直到所有函式結果 result === undefined。同樣的,由於對序列性有依賴,所以只有 SyncLoopHook 和 AsyncSeriseLoopHook (PS:暫時沒看到具體使用 Case)

Tapable的原始碼分析

Tapable 基本邏輯是,先通過類例項的 tap 方法註冊對應 Hook 的處理函式, 這裡直接分析sync同步鉤子的主要流程,其他的非同步鉤子和攔截器等就不贅述了。

const hook = new SyncHook(['arg1', 'arg2'])
複製程式碼

從該句程式碼, 作為原始碼分析的入口,

class SyncHook extends Hook {
    // 錯誤處理,防止呼叫者呼叫非同步鉤子
	tapAsync() {
		throw new Error("tapAsync is not supported on a SyncHook");
	}
    // 錯誤處理,防止呼叫者呼叫promise鉤子
	tapPromise() {
		throw new Error("tapPromise is not supported on a SyncHook");
	}
    // 核心實現
	compile(options) {
		factory.setup(this, options);
		return factory.create(options);
	}
}
複製程式碼

從類SyncHook看到, 他是繼承於一個基類Hook, 他的核心實現compile等會再講, 我們先看看基類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;
	this._x = undefined;
}
複製程式碼

初始化完成後, 通常會註冊一個事件, 如:

// 註冊
hook.tap('a', function (arg1, arg2) {
	console.log('a')
})

hook.tap('b', function (arg1, arg2) {
	console.log('b')
})
複製程式碼

很明顯, 這兩個語句都會呼叫基類中的tap方法:

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");
	// 執行攔截器的register函式, 比較簡單不分析
	options = this._runRegisterInterceptors(options);
	// 處理註冊事件
	this._insert(options);
}
複製程式碼

從上面的原始碼分析, 可以看到_insert方法是註冊階段的關鍵函式, 直接進入該方法內部

_insert(item) {
    // 重置所有的 呼叫 方法
	this._resetCompilation();
	// 將註冊事件排序後放進taps陣列
	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;
	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;
}
}
複製程式碼

_insert主要是排序tap並放入到taps陣列裡面, 排序的演算法並不是特別複雜,這裡就不贅述了, 到了這裡, 註冊階段就已經結束了, 繼續看觸發階段。

hook.call(1, 2)  // 觸發函式
複製程式碼

在基類hook中, 有一個初始化過程,

this.call = this._call; 

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

我們可以看出_call是由createCompileDelegate生成的, 往下看

function createCompileDelegate(name, type) {
	return function lazyCompileHook(...args) {
		this[name] = this._createCall(type);
		return this[name](...args);
	};
}
複製程式碼

createCompileDelegate返回一個名為lazyCompileHook的函式,顧名思義,即懶編譯, 直到呼叫call的時候, 才會編譯出正在的call函式。

createCompileDelegate也是呼叫的_createCall, 而_createCall呼叫了Compier函式

_createCall(type) {
	return this.compile({
		taps: this.taps,
		interceptors: this.interceptors,
		args: this._args,
		type: type
	});
}  
compile(options) {
	throw new Error("Abstract: should be overriden");
}
複製程式碼

可以看到compiler必須由子類重寫, 返回到syncHook的compile函式, 即我們一開始說的核心方法

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 {
    ...
	compile(options) {
		factory.setup(this, options);
		return factory.create(options);
	}
}
複製程式碼

關鍵就在於SyncHookCodeFactory和工廠類HookCodeFactory, 先看setup函式,

setup(instance, options) {
  // 這裡的instance 是syncHook 例項, 其實就是把tap進來的鉤子陣列給到鉤子的_x屬性裡.
  instance._x = options.taps.map(t => t.fn);
}
複製程式碼

然後是最關鍵的create函式, 可以看到最後返回的fn,其實是一個new Function動態生成的函式

create(options) {
  // 初始化引數,儲存options到本物件this.options,儲存new Hook(["options"]) 傳入的引數到 this._args
  this.init(options);
  let fn;
  // 動態構建鉤子,這裡是抽象層,分同步, 非同步, promise
  switch (this.options.type) {
    // 先看同步
    case "sync":
      // 動態返回一個鉤子函式
      fn = new Function(
        // 生成函式的引數,no before no after 返回引數字串 xxx,xxx 在
        // 注意這裡this.args返回的是一個字串,
        // 在這個例子中是options
        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() +
          // 這個 content 呼叫的是子類類的 content 函式,
          // 引數由子類傳,實際返回的是 this.callTapsSeries() 返回的類容
          this.content({
            onError: err => `_callback(${err});\n`,
            onResult: result => `_callback(null, ${result});\n`,
            onDone: () => "_callback();\n"
          })
      );
      break;
    case "promise":
      let code = "";
      code += '"use strict";\n';
      code += "return new Promise((_resolve, _reject) => {\n";
      code += "var _sync = true;\n";
      code += this.header();
      code += this.content({
        onError: err => {
          let code = "";
          code += "if(_sync)\n";
          code += `_resolve(Promise.resolve().then(() => { throw ${err}; }));\n`;
          code += "else\n";
          code += `_reject(${err});\n`;
          return code;
        },
        onResult: result => `_resolve(${result});\n`,
        onDone: () => "_resolve();\n"
      });
      code += "_sync = false;\n";
      code += "});\n";
      fn = new Function(this.args(), code);
      break;
  }
  // 把剛才init賦的值初始化為undefined
  // this.options = undefined;
  // this._args = undefined;
  this.deinit();

  return fn;
}
複製程式碼

最後生成的程式碼大致如下, 參考文章

"use strict";
function (options) {
  var _context;
  var _x = this._x;
  var _taps = this.taps;
  var _interterceptors = this.interceptors;
// 我們只有一個攔截器所以下面的只會生成一個
  _interceptors[0].call(options);

  var _tap0 = _taps[0];
  _interceptors[0].tap(_tap0);
  var _fn0 = _x[0];
  _fn0(options);
  var _tap1 = _taps[1];
  _interceptors[1].tap(_tap1);
  var _fn1 = _x[1];
  _fn1(options);
  var _tap2 = _taps[2];
  _interceptors[2].tap(_tap2);
  var _fn2 = _x[2];
  _fn2(options);
  var _tap3 = _taps[3];
  _interceptors[3].tap(_tap3);
  var _fn3 = _x[3];
  _fn3(options);
}
複製程式碼

ok, 以上就是Tapabled的機制, 然而本篇的主要物件其實是基於tapable實現的compile和compilation物件。不過由於他們都是基於tapable,所以介紹的篇幅相對短一點。

compile

compile是什麼

compiler 物件代表了完整的 webpack 環境配置。這個物件在啟動 webpack 時被一次性建立,並配置好所有可操作的設定,包括 options,loader 和 plugin。當在 webpack 環境中應用一個外掛時,外掛將收到此 compiler 物件的引用。可以使用 compiler 來訪問 webpack 的主環境。

也就是說, compile是webpack的整體環境。

compile的內部實現

class Compiler extends Tapable {
  constructor(context) {
    super();
    this.hooks = {
      /** @type {SyncBailHook<Compilation>} */
      shouldEmit: new SyncBailHook(["compilation"]),
      /** @type {AsyncSeriesHook<Stats>} */
      done: new AsyncSeriesHook(["stats"]),
      /** @type {AsyncSeriesHook<>} */
      additionalPass: new AsyncSeriesHook([]),
      /** @type {AsyncSeriesHook<Compiler>} */
      ......
      ......
      some code
    };
    ......
    ......
    some code
}
複製程式碼

可以看到, Compier繼承了Tapable, 並且在例項上繫結了一個hook物件, 使得Compier的例項compier可以像這樣使用

compiler.hooks.compile.tapAsync(
  'afterCompile',
  (compilation, callback) => {
    console.log('This is an example plugin!');
    console.log('Here’s the `compilation` object which represents a single build of assets:', compilation);

    // 使用 webpack 提供的 plugin API 操作構建結果
    compilation.addModule(/* ... */);

    callback();
  }
);
複製程式碼

compilation

什麼是compilation

compilation 物件代表了一次資源版本構建。當執行 webpack 開發環境中介軟體時,每當檢測到一個檔案變化,就會建立一個新的 compilation,從而生成一組新的編譯資源。一個 compilation 物件表現了當前的模組資源、編譯生成資源、變化的檔案、以及被跟蹤依賴的狀態資訊。compilation 物件也提供了很多關鍵時機的回撥,以供外掛做自定義處理時選擇使用。

compilation的實現

class Compilation extends Tapable {
	/**
	 * Creates an instance of Compilation.
	 * @param {Compiler} compiler the compiler which created the compilation
	 */
	constructor(compiler) {
		super();
		this.hooks = {
			/** @type {SyncHook<Module>} */
			buildModule: new SyncHook(["module"]),
			/** @type {SyncHook<Module>} */
			rebuildModule: new SyncHook(["module"]),
			/** @type {SyncHook<Module, Error>} */
			failedModule: new SyncHook(["module", "error"]),
			/** @type {SyncHook<Module>} */
			succeedModule: new SyncHook(["module"]),

			/** @type {SyncHook<Dependency, string>} */
			addEntry: new SyncHook(["entry", "name"]),
			/** @type {SyncHook<Dependency, string, Error>} */
		}
	}
}
複製程式碼

具體參考上面提到的compiler實現。

編寫一個外掛

瞭解到tapable\compiler\compilation之後, 再來看外掛的實現就不再一頭霧水了
以下程式碼源自官方文件

class MyExampleWebpackPlugin {
  // 定義 `apply` 方法
  apply(compiler) {
    // 指定要追加的事件鉤子函式
    compiler.hooks.compile.tapAsync(
      'afterCompile',
      (compilation, callback) => {
        console.log('This is an example plugin!');
        console.log('Here’s the `compilation` object which represents a single build of assets:', compilation);

        // 使用 webpack 提供的 plugin API 操作構建結果
        compilation.addModule(/* ... */);

        callback();
      }
    );
  }
}
複製程式碼

可以看到其實就是在apply中傳入一個Compiler例項, 然後基於該例項註冊事件, compilation同理, 最後webpack會在各流程執行call方法。

compiler和compilation一些比較重要的事件鉤子

compier

事件鉤子 觸發時機 引數 型別
entry-option 初始化 option - SyncBailHook
run 開始編譯 compiler AsyncSeriesHook
compile 真正開始的編譯,在建立 compilation 物件之前 compilation SyncHook
compilation 生成好了 compilation 物件,可以操作這個物件啦 compilation SyncHook
make 從 entry 開始遞迴分析依賴,準備對每個模組進行 build compilation AsyncParallelHook
after-compile 編譯 build 過程結束 compilation AsyncSeriesHook
emit 在將記憶體中 assets 內容寫到磁碟資料夾之前 compilation AsyncSeriesHook
after-emit 在將記憶體中 assets 內容寫到磁碟資料夾之後 compilation AsyncSeriesHook
done 完成所有的編譯過程 stats AsyncSeriesHook
failed 編譯失敗的時候 error SyncHook

compilation

事件鉤子 觸發時機 引數 型別
normal-module-loader 普通模組 loader,真正(一個接一個地)載入模組圖(graph)中所有模組的函式。 loaderContext module SyncHook
seal 編譯(compilation)停止接收新模組時觸發。 - SyncHook
optimize 優化階段開始時觸發。 - SyncHook
optimize-modules 模組的優化 modules SyncBailHook
optimize-chunks 優化 chunk chunks SyncBailHook
additional-assets 為編譯(compilation)建立附加資源(asset)。 - AsyncSeriesHook
optimize-chunk-assets 優化所有 chunk 資源(asset)。 chunks AsyncSeriesHook
optimize-assets 優化儲存在 compilation.assets 中的所有資源(asset) assets AsyncSeriesHook

總結

外掛機制並不複雜,webpack也不復雜,複雜的是外掛本身..
另外, 本應該先寫流程的, 流程只能後面補上了。

引用

不滿足於只會使用系列: tapable
webpack系列之二Tapable
編寫一個外掛
Compiler
Compilation
compiler和comnpilation鉤子
看清楚真正的 Webpack 外掛

相關文章