webpack 原始碼探索之外掛機制

Monine發表於2019-03-04

最近在一直在為面試做準備,搜了很多大佬記錄的面試經驗和麵試內容,對自己不太熟悉和已經記憶模糊的知識點內容進行復習鞏固,爭取能夠有一個好的狀態。這篇文章以我的經驗講述了我是如何從原始碼的角度瞭解到 webpack 外掛機制,也簡單描述了 webpack 編譯構建的機制。

webpack 原始碼探索之外掛機制

使用 vscode 除錯功能,執行專案打包程式,一步一步走 webpack(version: 3.10.0)執行程式碼。

先來看看 webpack 函式原始碼

function webpack(options, callback) {
	const webpackOptionsValidationErrors = validateSchema(webpackOptionsSchema, options);
	if(webpackOptionsValidationErrors.length) {
		throw new WebpackOptionsValidationError(webpackOptionsValidationErrors);
	}
	let compiler;
	if(Array.isArray(options)) {
		compiler = new MultiCompiler(options.map(options => webpack(options)));
	} else if(typeof options === "object") {
		// TODO webpack 4: process returns options
		new WebpackOptionsDefaulter().process(options);

		compiler = new Compiler();
		compiler.context = options.context;
		compiler.options = options;
		new NodeEnvironmentPlugin().apply(compiler);
		if(options.plugins && Array.isArray(options.plugins)) {
			compiler.apply.apply(compiler, options.plugins);
		}
		compiler.applyPlugins("environment");
		compiler.applyPlugins("after-environment");
		compiler.options = new WebpackOptionsApply().process(options, compiler);
	} else {
		throw new Error("Invalid argument: options");
	}
	if(callback) {
		if(typeof callback !== "function") throw new Error("Invalid argument: callback");
		if(options.watch === true || (Array.isArray(options) && options.some(o => o.watch))) {
			const watchOptions = Array.isArray(options) ? options.map(o => o.watchOptions || {}) : (options.watchOptions || {});
			return compiler.watch(watchOptions, callback);
		}
		compiler.run(callback);
	}
	return compiler;
}
複製程式碼

原始碼分析

首先檢查 webpack 配置是否有符合要求

const webpackOptionsValidationErrors = validateSchema(webpackOptionsSchema, options);
if(webpackOptionsValidationErrors.length) {
  throw new WebpackOptionsValidationError(webpackOptionsValidationErrors);
}
複製程式碼

validateSchema 是一個依賴 ajv(JSON 模式驗證器) 外掛的對 webpack 配置進行驗證的函式,需要提供一份 webpack 配置的 JSON 格式的驗證描述 webpackOptionsSchema 和我們專案的配置資訊 options,先用 ajv 的 compile 方法編譯 webpackOptionsSchema 得到驗證器,再用驗證器驗證 options 並返回驗證結果。類似於 React 中使用的 prop-types 驗證父元件傳給子元件的屬性和 Vue 中的 props 自定義驗證。

根據專案配置 options 確定 compiler 物件

這裡需要先著重介紹下 compiler 物件,它對我們瞭解 webpack 的構建機制和接下來的講解至關重要,需要理解它到底是什麼,有什麼作用。先借用官網的一些介紹:

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

compiler 物件在 webpack 構建過程中代表著整個 webpack 環境,包含上下文、專案配置資訊、執行、監聽、統計等等一系列的資訊,提供給 loader 和外掛使用。它繼承於 Tapable(Tapable 是 webpack 的一個底層庫,類似於 NodeJS 的 EventEmitter 類),使用事件的釋出 compiler.applyPlugins(`eventName`) 訂閱compiler.plugin(`eventName`, callback) 模式註冊 new WebpackPlugin().apply(compiler) 所有外掛,外掛必須提供 apply 方法給 webpack 完成註冊流程,外掛在 apply 方法內做一些初始化操作並監聽 webpack 構建過程中的生命週期事件,等待構建時生命週期事件的釋出。

所有外掛都會在構建方法 compiler.run(callback) 之前註冊,當 webpack 構建到某個階段就會釋出一個生命週期事件,此時所有訂閱了當前釋出的生命週期事件的外掛會按照註冊順序一個一個執行訂閱時提供的回撥函式,回撥函式的引數是與釋出的生命週期事件相對應的引數,比如常用的 compilation 生命週期事件回撥函式引數就包含 compilation 物件(此物件也是 webpack 構建機制的重要成員),entry-option 生命週期事件回撥函式引數是 context(專案上下文路徑)和 entry(專案配置的入口物件)。另外,外掛如果需要非同步執行編譯,則還會提供一個回撥函式作為監聽回撥函式的引數,非同步編譯完成必須呼叫回撥函式。

簡單點說,webpack 的構建包含很多個階段,每個階段都會發布對應的生命週期事件,外掛需要提供 apply 方法註冊並在此方法內監聽指定的生命週期事件,事件釋出後會順序執行監聽的回撥函式並提供相對應的引數。

OK,瞭解完 compiler 物件後繼續看程式碼。

if(Array.isArray(options)) {
  compiler = new MultiCompiler(options.map(options => webpack(options)));
} else if(typeof options === "object") {
  // ...
  compiler = new Compiler();
  // ...
}
複製程式碼

這裡有一層判斷決定 compiler 物件是 MultiCompiler 類還是 Compiler 類的例項,二者的關係其實是一層包裝關係,從程式碼可以看出,MultiCompiler 類的引數是多個 Compiler 例項成員組成的陣列,再進入到 MultiCompiler.js 檢視原始碼會發現,在 MultiCompiler 的建構函式中,例項有個 compilers 屬性指向這個由多個 Compiler 例項成員組成的陣列,以及遍歷 compilers 陣列給每個 compiler 成員註冊(監聽) doneinvalid 兩個生命週期事件。

對於 outputPath、inputFileSystem、outputFileSystem 這三個屬性,會使用取值函式(getter)和存值函式(setter)進行攔截(outputPath 只有取值函式),檢視 Compiler 原始碼 Compiler 類的建構函式,會發現這三個屬性都是 Compiler 例項上的屬性,再看 MultiCompiler 中這三個屬性的存取值函式會發現,都是在遍歷 MultiCompiler 例項的 compilers 屬性對每一個 compiler 成員做相應的存取值操作(其中 inputFileSystem 和 outputFileSystem 的取值函式是丟擲錯誤),也就是說對 MultiCompiler 例項的 inputFileSystem 和 outputFileSystem 屬性賦值其實就是對所有 Compiler 例項的 inputFileSystem 和 outputFileSystem 屬性賦值。MultiCompiler 類還覆寫了 Compiler 類中的 watch、run、purgeInputFileSystem 三個方法,無一例外也都是遍歷 compilers 讓每個 compiler 成員執行與之相對應的方法。

一般情況下,我們專案配置的 options 是一個 object,至於什麼情況下會使用到陣列,我個人看法是一個大的專案中包含多個小的子專案,需要能夠單獨打包小的子專案,也需要能夠一次打包整個大的專案,這時候 Array.isArray(options) === true 才應用到 MultiCompiler。

new WebpackOptionsDefaulter().process(options);
複製程式碼

我們以 typeof options === "object" 常規專案為例,程式碼往下執行,首先建立了 WebpackOptionsDefaulter 例項,然後馬上執行 process 方法並傳入 options 作為引數。檢視 WebpackOptionsDefaulter 原始碼 會發現,它本身只有一個建構函式,建構函式內大規模使用 this.set() 方法初始化 webpack options 預設配置,這個 set 方法是來自它所繼承的 OptionsDefaulter 類,執行的 process 方法也是出自 OptionsDefaulter 類,可以說 WebpackOptionsDefaulter 類只是一層外殼,設定所有的 webpack 預設配置資訊,藉由 procss 方法將 webpack 預設的配置資訊與專案配置資訊融合,提供出接下來需要使用的 options。

compiler = new Compiler();
compiler.context = options.context;
compiler.options = options;
複製程式碼

初始化 webpack options 後,建立 compiler 例項並設定 context(專案上下文路徑)和 options(專案配置)。

new NodeEnvironmentPlugin().apply(compiler);
複製程式碼

上文對 compiler 物件的介紹已經清楚的解釋了上面一行程式碼的行為,就是註冊一個 NodeEnvironmentPlugin 外掛,檢視原始碼發現非常簡單,如下:

class NodeEnvironmentPlugin {
	apply(compiler) {
		compiler.inputFileSystem = new CachedInputFileSystem(new NodeJsInputFileSystem(), 60000);
		const inputFileSystem = compiler.inputFileSystem;
		compiler.outputFileSystem = new NodeOutputFileSystem();
		compiler.watchFileSystem = new NodeWatchFileSystem(compiler.inputFileSystem);
		compiler.plugin("before-run", (compiler, callback) => {
			if(compiler.inputFileSystem === inputFileSystem)
				inputFileSystem.purge();
			callback();
		});
	}
}
複製程式碼

apply 方法執行外掛初始化操作修改了 compiler 的 inputFileSystem、outputFileSystem、watchFileSystem 這三個屬性的值,然後監聽 before-run 事件,顧名思義,這個事件是在 compiler.run(callback) 函式執行會被髮布的。檢視回撥函式內部程式碼,執行了 callback() 方法,立刻能想到這是一個非同步編譯回撥,之前有一層 inputFileSystem 引用的判斷,如果 before-run 事件釋出之前 compiler 的 inputFileSystem 被修改重新賦值,則不做任何操作直接執行 callback;如果沒有被修改重新賦值,則執行 purge 方法。大意就是如果有其它外掛(指專案配置的外掛)提供了 inputFileSystem 物件,就用其它外掛的,如果沒有,那就由我來接管了。

if(options.plugins && Array.isArray(options.plugins)) {
  compiler.apply.apply(compiler, options.plugins);
}
複製程式碼

首先判斷是否有 plugins 屬性並且是否為陣列,判斷語句內的語法稍微有一點繞,咋一眼看上去有些懵,但稍微想一下應該就能明白,就是執行 compiler 的 apply 方法並且繫結 this 為 compiler 物件,再傳入 options.plugins(專案配置的所有外掛)作為引數。這裡使用 apply 方法的目的其實不是為了繫結 this,因為本身 compiler.apply() 方法執行上下文中的 this 指向的就是 compiler,這裡主要目的是為了把 options.plugins 解構為一個一個的引數。

Tapable.prototype.apply = function apply() {
	for(var i = 0; i < arguments.length; i++) {
		arguments[i].apply(this);
	}
};
複製程式碼

因為 Compiler 類繼承自 Tapable 類,Compiler 例項上的 apply 方法呼叫的是 Tapable.prototype.apply,通過上面程式碼可以清楚的看到,之所以要解構 options.plugins 是因為要遍歷 arguments 物件,讓 arguments 成員(外掛例項)呼叫自身的 apply 方法(不是 Function.prototype.apply 方法)執行註冊流程,傳入 this 也就是 compiler 作為引數。

至此,外掛註冊的流程已經非常清晰明瞭,對於開始動手寫一個 webpack 外掛應該沒有什麼畏懼啦。

compiler.applyPlugins("environment");
compiler.applyPlugins("after-environment");
複製程式碼

如果已經理解 compiler 物件那上面兩行程式碼是可以略過的,就是釋出 compiler.applyPlugins 兩個生命週期事件:environment 和 after-environment,如果專案配置的外掛中有監聽這兩個事件的外掛則會執行監聽回撥函式,只提供了預設的 compiler 作為引數,沒有任何其他引數。

compiler.options = new WebpackOptionsApply().process(options, compiler);
複製程式碼

這裡是對 compiler.options 重新賦值操作,那意思就很明白了,又要對 options 引數做一系列的操作,為什麼說又?因為之前執行 new WebpackOptionsDefaulter().process(options); 已經對 options 做過一次初始化操作,融合了 webpack 的預設配置與專案配置的結果,那這次操作 options 又是為了什麼呢?檢視 WebpackOptionsApply 原始碼 發現 WebpackOptionsApply 繼承自 OptionsApply,再檢視 OptionsApply 原始碼 發現 OptionsApply 可有可無…,不太清楚為什麼要寫這麼一個空類,或許是為 webpack v4 做準備,這兒我們就先不管,意義不大。再回到 WebpackOptionsApply 類的 process 方法,嘩啦啦的一串,將近 300 行,這裡我就不貼程式碼了,我大概描述一下 process 方法內做了哪些事情。

  1. 把一些 options 上的屬性賦值給 compiler 物件

  2. 根據 options.target 的值註冊相應的 webpack 內部外掛

    options.target 配置的意思是告訴 webpack 構建應用於什麼環境的程式碼,它的預設值是 web,另外還有 webworkernodeasync-nodenode-webkitatomelectronelectron-mainelectron-renderer

  3. 根據 options 的配置確定是否要註冊一些內部外掛

    比如如果配置了 externals 屬性需要則註冊 ExternalsPlugin 外掛

  4. 確定 compiler.resolvers 三個屬性 normalcontextloader 的值

  5. 釋出三個生命週期事件:entry-optionafter-pluginsafter-resolvers

  6. 返回 options

大致就是在完善 compiler 物件,根據當前專案配置應用一些相對應的內部外掛,從這裡可以看出,webpack 內部也大量運用外掛機制來實現編譯構建,外掛機制讓 webpack 變得靈活而強大。

if(callback) {
  if(typeof callback !== "function") throw new Error("Invalid argument: callback");
  if(options.watch === true || (Array.isArray(options) && options.some(o => o.watch))) {
    const watchOptions = Array.isArray(options) ? options.map(o => o.watchOptions || {}) : (options.watchOptions || {});
    return compiler.watch(watchOptions, callback);
  }
  compiler.run(callback);
}
return compiler;
複製程式碼

接下來,判斷是否有 callback 引數,如果沒有則直接返回 compiler 物件,注意!此時 webpack 並沒有執行構建程式,也不會執行,因為構建程式 compiler.run(callback) 方法只有當有 callback 引數時才會執行,並且 callback 必須為函式。如果開啟 watch 模式,則 webpack 會監聽檔案變化,當檔案發生變動則會觸發重新編譯,像 webpack-dev-server 和 webpack-dev-middleware 裡 watch 模式是預設開啟的,方便進行開發。

最後執行 compiler.run(callback); 表示開始構建,至此,webpack 構建前的初始化操作已經全部完成,接下來要探索的就是 run 方法是如何執行 webpack 構建的。

本文篇幅已經很長,run 方法內的構建過程不是三言兩語能夠描述清楚,這裡也不打算細說,大概羅列幾個關鍵事件節點:

  1. compile: 開始編譯

  2. make: 從入口點分析模組及其依賴的模組並建立這些模組物件

  3. build-module: 構建模組

  4. after-compile: 完成構建

  5. after-compile 完成構建

  6. emit: 把各個chunk輸出到結果檔案

  7. after-emit: 完成輸出

另外 webpack 構建還有一個關鍵物件 compilation,上文介紹 compiler 時有提到,他們倆是理解和擴充套件 webpack 引擎的關鍵,是 webpack 外掛必不可缺的組成部分。

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

compiler 是 webpack 環境的代表,compilation 則是 webpack 構建內容的代表,它包含了每個構建環節及輸出環節所對應的方法,存放著所有 module、chunk、asset 以及用來生成最後打包檔案的 template 的資訊。

最後,附上一張淘寶 FED 團隊在《細說 webpack 之流程篇》 一文中的 webpack 整體流程圖:

webpack 整體流程圖

相關文章