往期文章:
1. loader 十問
在我學習webpack loader的過程中,也閱讀了網上很多相關文章,收穫不少。但是大多都只介紹了loader的配置方式或者loader的編寫方式,對其中引數、api及其他細節的介紹並不清晰。
這裡有一個「loader十問」,是我在閱讀loader原始碼前心中的部分疑問:
- webpack預設配置是在哪處理的,loader有什麼預設配置麼?
- webpack中有一個resolver的概念,用於解析模組檔案的真實絕對路徑,那麼loader和普通模組的resolver使用的是同一個麼?
- 我們知道,除了config中的loader,還可以寫inline的loader,那麼inline loader和normal config loader執行的先後順序是什麼?
- 配置中的
module.rules
在webpack中是如何生效與實現的? - webpack編譯流程中loader是如何以及在何時發揮作用的?
- loader為什麼是自右向左執行的?
- 如果在某個pitch中返回值,具體會發生什麼?
- 如果你寫過loader,那麼可能在loader function中用到了
this
,這裡的this
究竟是什麼,是webpack例項麼? - loader function中的
this.data
是如何實現的? - 如何寫一個非同步loader,webpack又是如何實現loader的非同步化的?
也許你也會有類似的疑問。下面我會結合loader相關的部分原始碼,為大家還原loader的設計與實現原理,解答這些疑惑。
2. loader執行的總體流程
webpack編譯流程非常複雜,但其中涉及loader的部分主要包括了:
- loader(webpack)的預設配置
- 使用loaderResolver解析loader模組路徑
- 根據
rule.modules
建立RulesSet規則集 - 使用loader-runner執行loader
其對應的大致流程如下:
首先,在Compiler.js
中會為將使用者配置與預設配置合併,其中就包括了loader部分。
然後,webpack就會根據配置建立兩個關鍵的物件——NormalModuleFactory
和ContextModuleFactory
。它們相當於是兩個類工廠,通過其可以建立相應的NormalModule
和ContextModule
。其中NormalModule
類是這篇文章主要關注的,webpack會為原始碼中的模組檔案對應生成一個NormalModule
例項。
在工廠建立NormalModule
例項之前還有一些必要步驟,其中與loader最相關的就是通過loader的resolver來解析loader路徑。
在NormalModule
例項建立之後,則會通過其.build()
方法來進行模組的構建。構建模組的第一步就是使用loader來載入並處理模組內容。而loader-runner這個庫就是webpack中loader的執行器。
最後,將loader處理完的模組內容輸出,進入後續的編譯流程。
上面就是webpack中loader涉及到的大致流程。下面會結合原始碼對其進行具體的分析,而在原始碼閱讀分析過程中,就會找到「loader十問」的解答。
3. loader執行部分的具體分析
3.1. webpack預設配置
Q:1. webpack預設配置是在哪處理的,loader有什麼預設配置麼?
webpack和其他工具一樣,都是通過配置的方式來工作的。隨著webpack的不斷進化,其預設配置也在不斷變動;而曾經版本中的某些最佳實踐,也隨著版本的升級進入了webpack的預設配置。
webpack的入口檔案是lib/webpack.js
,會根據配置檔案,設定編譯時的配置options (source code)(上一篇《視覺化展示webpack內部外掛與鉤子關係?》提到的plugin也是在這裡觸發的)
options = new WebpackOptionsDefaulter().process(options);
compiler = new Compiler(options.context);
compiler.options = options;
複製程式碼
由此可見,預設配置是放在WebpackOptionsDefaulter
裡的。因此,如果你想要檢視當前webpack預設配置項具體內容,可以在該模組裡檢視。
例如,在module.rules
這部分的預設值為[]
;但是此外還有一個module.defaultRules
配置項,雖然不開放給開發者使用,但是包含了loader的預設配置 (source code):
this.set("module.rules", []);
this.set("module.defaultRules", "make", options => [
{
type: "javascript/auto",
resolve: {}
},
{
test: /.mjs$/i,
type: "javascript/esm",
resolve: {
mainFields:
options.target === "web" ||
options.target === "webworker" ||
options.target === "electron-renderer"
? ["browser", "main"]
: ["main"]
}
},
{
test: /.json$/i,
type: "json"
},
{
test: /.wasm$/i,
type: "webassembly/experimental"
}
]);
複製程式碼
此外值得一提的是,
WebpackOptionsDefaulter
繼承自OptionsDefaulter
,而OptionsDefaulter
則是一個封裝的配置項存取器,封裝了一些特殊的方法來操作配置物件。
3.2. 建立NormalModuleFactory
NormalModule
是webpack中不得不提的一個類函式。原始碼中的模組在編譯過程中會生成對應的NormalModule
例項。
NormalModuleFactory
是NormalModule
的工廠類。其建立是在Compiler.js
中進行的,Compiler.js
是webpack基本編譯流程的控制類。compiler.run()
方法中的主體(鉤子)流程如下:
.run()
在觸發了一系列beforeRun
、run
等鉤子後,會呼叫.compile()
方法,其中的第一步就是呼叫this.newCompilationParams()
建立NormalModuleFactory
例項。
newCompilationParams() {
const params = {
normalModuleFactory: this.createNormalModuleFactory(),
contextModuleFactory: this.createContextModuleFactory(),
compilationDependencies: new Set()
};
return params;
}
複製程式碼
3.3. 解析(resolve)loader的真實絕對路徑
Q:2. webpack中有一個resolver的概念,用於解析模組檔案的真實絕對路徑,那麼loader模組與normal module(原始碼模組)的resolver使用的是同一個麼?
在NormalModuleFactory
中,建立出NormalModule
例項之前會涉及到四個鉤子:
- beforeResolve
- resolve
- factory
- afterResolve
其中較為重要的有兩個:
- resolve部分負責解析loader模組的路徑(例如css-loader這個loader的模組路徑是什麼);
- factory負責來基於resolve鉤子的返回值來建立
NormalModule
例項。
resolve
鉤子上註冊的方法較長,其中還包括了模組資源本身的路徑解析。resolver
有兩種,分別是loaderResolver和normalResolver。
const loaderResolver = this.getResolver("loader");
const normalResolver = this.getResolver("normal", data.resolveOptions);
複製程式碼
由於除了config檔案中可以配置loader外,還有inline loader的寫法,因此,對loader檔案的路徑解析也分為兩種:inline loader和config檔案中的loader。resolver鉤子中會先處理inline loader。
3.3.1. inline loader
import Styles from `style-loader!css-loader?modules!./styles.css`;
複製程式碼
上面是一個inline loader的例子。其中的request為style-loader!css-loader?modules!./styles.css
。
首先webpack會從request中解析出所需的loader (source code):
let elements = requestWithoutMatchResource
.replace(/^-?!+/, "")
.replace(/!!+/g, "!")
.split("!");
複製程式碼
因此,從style-loader!css-loader?modules!./styles.css
中可以取出兩個loader:style-loader
和css-loader
。
然後會將“解析模組的loader陣列”與“解析模組本身”一起並行執行,這裡用到了neo-async
這個庫。
neo-async
庫和async
庫類似,都是為非同步程式設計提供一些工具方法,但是會比async
庫更快。
解析返回的結果格式大致如下:
[
// 第一個元素是一個loader陣列
[ {
loader:
`/workspace/basic-demo/home/node_modules/html-webpack-plugin/lib/loader.js`,
options: undefined
} ],
// 第二個元素是模組本身的一些資訊
{
resourceResolveData: {
context: [Object],
path: `/workspace/basic-demo/home/public/index.html`,
request: undefined,
query: ``,
module: false,
file: false,
descriptionFilePath: `/workspace/basic-demo/home/package.json`,
descriptionFileData: [Object],
descriptionFileRoot: `/workspace/basic-demo/home`,
relativePath: `./public/index.html`,
__innerRequest_request: undefined,
__innerRequest_relativePath: `./public/index.html`,
__innerRequest: `./public/index.html`
},
resource: `/workspace/basic-demo/home/public/index.html`
}
]
複製程式碼
其中第一個元素就是該模組被引用時所涉及的所有inline loader,包含loader檔案的絕對路徑和配置項。
3.3.2. config loader
Q:3. 我們知道,除了config中的loader,還可以寫inline的loader,那麼inline loader和normal config loader執行的先後順序是什麼?
上面一節中,webpack首先解析了inline loader的絕對路徑與配置。接下來則是解析config檔案中的loader (source code),即module.rules
部分的配置:
const result = this.ruleSet.exec({
resource: resourcePath,
realResource:
matchResource !== undefined
? resource.replace(/?.*/, "")
: resourcePath,
resourceQuery,
issuer: contextInfo.issuer,
compiler: contextInfo.compiler
});
複製程式碼
NormalModuleFactory
中有一個ruleSet
的屬性,這裡你可以簡單理解為:它可以根據模組路徑名,匹配出模組所需的loader。RuleSet
細節此處先按下不表,其具體內容我會在下一節介紹。
這裡向this.ruleSet.exec()
中傳入原始碼模組路徑,返回的result
就是當前模組匹配出的config中的loader。如果你熟悉webpack配置,會知道module.rules
中有一個enforce
欄位。基於該欄位,webpack會將loader分為preLoader、postLoader和loader三種 (source code):
for (const r of result) {
if (r.type === "use") {
// post型別
if (r.enforce === "post" && !noPrePostAutoLoaders) {
useLoadersPost.push(r.value);
// pre型別
} else if (
r.enforce === "pre" &&
!noPreAutoLoaders &&
!noPrePostAutoLoaders
) {
useLoadersPre.push(r.value);
} else if (
!r.enforce &&
!noAutoLoaders &&
!noPrePostAutoLoaders
) {
useLoaders.push(r.value);
}
}
// ……
}
複製程式碼
最後,使用neo-aysnc來並行解析三類loader陣列 (source code):
asyncLib.parallel(
[
this.resolveRequestArray.bind(
this,
contextInfo,
this.context,
useLoadersPost, // postLoader
loaderResolver
),
this.resolveRequestArray.bind(
this,
contextInfo,
this.context,
useLoaders, // loader
loaderResolver
),
this.resolveRequestArray.bind(
this,
contextInfo,
this.context,
useLoadersPre, // preLoader
loaderResolver
)
]
// ……
}
複製程式碼
那麼最終loader的順序究竟是什麼呢?下面這一行程式碼可以解釋:
loaders = results[0].concat(loaders, results[1], results[2]);
複製程式碼
其中results[0]
、results[1]
、results[2]
、loader
分別是postLoader、loader(normal config loader)、preLoader和inlineLoader。因此合併後的loader順序是:post、inline、normal和pre。
然而loader是從右至左執行的,真實的loader執行順序是倒過來的,因此inlineLoader是整體後於config中normal loader執行的。
3.3.3. RuleSet
Q:4. 配置中的
module.rules
在webpack中是如何生效與實現的?
webpack使用RuleSet
物件來匹配模組所需的loader。RuleSet
相當於一個規則過濾器,會將resourcePath應用於所有的module.rules
規則,從而篩選出所需的loader。其中最重要的兩個方法是:
- 類靜態方法
.normalizeRule()
- 例項方法
.exec()
webpack編譯會根據使用者配置與預設配置,例項化一個RuleSet
。首先,通過其上的靜態方法.normalizeRule()
將配置值轉換為標準化的test物件;其上還會儲存一個this.references
屬性,是一個map型別的儲存,key是loader在配置中的型別和位置,例如,ref-2
表示loader配置陣列中的第三個。
p.s. 如果你在.compilation中某個鉤子上列印出一些NormalModule上request相關欄位,那些用到loader的模組會出現類似
ref-
的值。從這裡就可以看出一個模組是否使用了loader,命中了哪個配置規則。
例項化後的RuleSet
就可以用於為每個模組獲取對應的loader。這個例項化的RuleSet
就是我們上面提到的NormalModuleFactory
例項上的this.ruleSet
屬性。工廠每次建立一個新的NormalModule
時都會呼叫RuleSet
例項的.exec()
方法,只有當通過了各類測試條件,才會將該loader push到結果陣列中。
3.4. 執行loader
3.4.1. loader的執行時機
Q:5. webpack編譯流程中loader是如何以及在何時發揮作用的?
loader的絕對路徑解析完畢後,在NormalModuleFactory
的factory
鉤子中會建立當前模組的NormalModule
物件。到目前為止,loader的前序工作已經差不多結束了,下面就是真正去執行各個loader。
我們都知道,執行loader讀取與處理模組是webpack模組處理的第一步。但如果說到詳細的執行時機,就涉及到webpack編譯中compilation
這個非常重要的物件。
webpack是以入口維度進行編譯的,compilation
中有一個重要方法——.addEntry()
,會基於入口進行模組構建。.addEntry()
方法中呼叫的._addModuleChain()
會執行一系列的模組方法 (source code)
this.semaphore.acquire(() => {
moduleFactory.create(
{
// ……
},
(err, module) => {
if (err) {
this.semaphore.release();
return errorAndCallback(new EntryModuleNotFoundError(err));
}
// ……
if (addModuleResult.build) {
// 模組構建
this.buildModule(module, false, null, null, err => {
if (err) {
this.semaphore.release();
return errorAndCallback(err);
}
if (currentProfile) {
const afterBuilding = Date.now();
currentProfile.building = afterBuilding - afterFactory;
}
this.semaphore.release();
afterBuild();
});
}
}
)
}
複製程式碼
其中,對於未build過的模組,最終會呼叫到NormalModule
物件的.doBuild()
方法。而構建模組(.doBuild()
)的第一步就是執行所有的loader。
這時候,loader-runner就登場了。
3.4.2. loader-runner —— loader的執行庫
Q:6. loader為什麼是自右向左執行的?
webpack將loader的執行工具剝離出來,獨立成了loader-runner庫。因此,你可以編寫一個loader,並用獨立的loader-runner來測試loader的效果。
loader-runner分為了兩個部分:loadLoader.js與LoaderRunner.js。
loadLoader.js是一個相容性的模組載入器,可以載入例如cjs、esm或SystemJS這種的模組定義。而LoaderRunner.js則是loader模組執行的核心部分。其中暴露出來的.runLoaders()
方法則是loader執行的啟動方法。
如果你寫過或瞭解如何編寫一個loader,那麼肯定知道,每個loader模組都支援一個.pitch
屬性,上面的方法會優先於loader的實際方法執行。實際上,webpack官方也給出了pitch與loader本身方法的執行順序圖:
|- a-loader `pitch`
|- b-loader `pitch`
|- c-loader `pitch`
|- requested module is picked up as a dependency
|- c-loader normal execution
|- b-loader normal execution
|- a-loader normal execution
複製程式碼
這兩個階段(pitch和normal)就是loader-runner中對應的iteratePitchingLoaders()
和iterateNormalLoaders()
兩個方法。
iteratePitchingLoaders()
會遞迴執行,並記錄loader的pitch
狀態與當前執行到的loaderIndex
(loaderIndex++
)。當達到最大的loader序號時,才會處理實際的module:
if(loaderContext.loaderIndex >= loaderContext.loaders.length)
return processResource(options, loaderContext, callback);
複製程式碼
當loaderContext.loaderIndex
值達到整體loader陣列長度時,表明所有pitch都被執行完畢(執行到了最後的loader),這時會呼叫processResource()
來處理模組資源。主要包括:新增該模組為依賴和讀取模組內容。然後會遞迴執行iterateNormalLoaders()
並進行loaderIndex--
操作,因此loader會“反向”執行。
接下來,我們討論幾個loader-runner的細節點:
Q:7. 如果在某個pitch中返回值,具體會發生什麼?
官網上說:
if a loader delivers a result in the pitch method the process turns around and skips the remaining loaders
這段說明表示,在pitch中返回值會跳過餘下的loader。這個表述比較粗略,其中有幾個細節點需要說明:
首先,只有當loaderIndex
達到最大陣列長度,即pitch過所有loader後,才會執行processResource()
。
if(loaderContext.loaderIndex >= loaderContext.loaders.length)
return processResource(options, loaderContext, callback);
複製程式碼
因此,在pitch中返回值除了跳過餘下loader外,不僅會使.addDependency()
不觸發(不將該模組資源新增進依賴),而且無法讀取模組的檔案內容。loader會將pitch返回的值作為“檔案內容”來處理,並返回給webpack。
Q:8. 如果你寫過loader,那麼可能在loader function中用到了
this
,這裡的this
究竟是什麼,是webpack例項麼?
其實這裡的this
既不是webpack例項,也不是compiler、compilation、normalModule等這些例項。而是一個叫loaderContext
的loader-runner特有物件。
每次呼叫runLoaders()
方法時,如果不顯式傳入context,則會預設建立一個新的loaderContext
。所以在官網上提到的各種loader API(callback、data、loaderIndex、addContextDependency等)都是該物件上的屬性。
Q:9. loader function中的
this.data
是如何實現的?
知道了loader中的this
其實是一個叫loaderContext
的物件,那麼this.data
的實現其實就是loaderContext.data
的實現 (source code):
Object.defineProperty(loaderContext, "data", {
enumerable: true,
get: function() {
return loaderContext.loaders[loaderContext.loaderIndex].data;
}
});
複製程式碼
這裡定義了一個.data
的(存)取器。可以看出,呼叫this.data
時,不同的normal loader由於loaderIndex
不同,會得到不同的值;而pitch方法的形參data
也是不同的loader下的data (source code)。
runSyncOrAsync(
fn,
loaderContext,
[loaderContext.remainingRequest, loaderContext.previousRequest, currentLoaderObject.data = {}],
function(err) {
// ……
}
);
複製程式碼
runSyncOrAsync()
中的陣列[loaderContext.remainingRequest, loaderContext.previousRequest, currentLoaderObject.data = {}]
就是pitch方法的入參,而currentLoaderObject
就是當前loaderIndex
所指的loader物件。
因此,如果你想要儲存一個“貫穿始終”的資料,可以考慮儲存在this
的其他屬性上,或者通過修改loaderIndex,來取到其他loader上的資料(比較hack)。
Q:10. 如何寫一個非同步loader,webpack又是如何實現loader的非同步化的?
pitch與normal loader的實際執行,都是在runSyncOrAsync()
這個方法中。
根據webpack文件,當我們呼叫this.async()
時,會將loader變為一個非同步的loader,並返回一個非同步回撥。
在具體實現上,runSyncOrAsync()
內部有一個isSync
變數,預設為true
;當我們呼叫this.async()
時,它會被置為false
,並返回一個innerCallback
作為非同步執行完後的回撥通知:
context.async = function async() {
if(isDone) {
if(reportedError) return; // ignore
throw new Error("async(): The callback was already called.");
}
isSync = false;
return innerCallback;
};
複製程式碼
我們一般都使用this.async()
返回的callback來通知非同步完成,但實際上,執行this.callback()
也是一樣的效果:
var innerCallback = context.callback = function() {
// ……
}
複製程式碼
同時,在runSyncOrAsync()
中,只有isSync
標識為true
時,才會在loader function執行完畢後立即(同步)回撥callback來繼續loader-runner。
if(isSync) {
isDone = true;
if(result === undefined)
return callback();
if(result && typeof result === "object" && typeof result.then === "function") {
return result.catch(callback).then(function(r) {
callback(null, r);
});
}
return callback(null, result);
}
複製程式碼
看到這裡你會發現,程式碼裡有一處會判斷返回值是否是Promise(typeof result.then === "function"
),如果是Promise則會非同步呼叫callback。因此,想要獲得一個非同步的loader,除了webpack文件裡提到的this.async()
方法,還可以直接返回一個Promise。
4. 尾聲
以上就是webapck loader相關部分的原始碼分析。相信到這裡,你已經對最開始的「loader十問」有了答案。希望這篇文章能夠讓你在學會配置loader與編寫一個簡單的loader之外,能進一步瞭解loader的實現。
閱讀原始碼的過程中可能存在一些紕漏,歡迎大家來一起交流。
告別「webpack配置工程師」
webpack是一個強大而複雜的前端自動化工具。其中一個特點就是配置複雜,這也使得「webpack配置工程師」這種戲謔的稱呼開始流行?但是,難道你真的只滿足於玩轉webpack配置麼?
顯然不是。在學習如何使用webpack之外,我們更需要深入webpack內部,探索各部分的設計與實現。萬變不離其宗,即使有一天webpack“過氣”了,但它的某些設計與實現卻仍會有學習價值與借鑑意義。因此,在學習webpack過程中,我會總結一系列【webpack進階】的文章和大家分享。
歡迎感興趣的同學多多交流與關注!
往期文章: