往期文章:javascript
在我學習webpack loader的過程當中,也閱讀了網上不少相關文章,收穫很多。可是大多都只介紹了loader的配置方式或者loader的編寫方式,對其中參數、api及其餘細節的介紹並不清晰。css
這裏有一個「loader十問」,是我在閱讀loader源碼前心中的部分疑問:html
module.rules
在webpack中是如何生效與實現的?this
,這裏的this
到底是什麼,是webpack實例麼?this.data
是如何實現的?也許你也會有相似的疑問。下面我會結合loader相關的部分源碼,爲你們還原loader的設計與實現原理,解答這些疑惑。前端
webpack編譯流程很是複雜,但其中涉及loader的部分主要包括了:java
rule.modules
建立RulesSet規則集其對應的大體流程以下:node
首先,在Compiler.js
中會爲將用戶配置與默認配置合併,其中就包括了loader部分。webpack
而後,webpack就會根據配置建立兩個關鍵的對象——NormalModuleFactory
和ContextModuleFactory
。它們至關因而兩個類工廠,經過其能夠建立相應的NormalModule
和ContextModule
。其中NormalModule
類是這篇文章主要關注的,webpack會爲源碼中的模塊文件對應生成一個NormalModule
實例。git
在工廠建立NormalModule
實例以前還有一些必要步驟,其中與loader最相關的就是經過loader的resolver來解析loader路徑。github
在NormalModule
實例建立以後,則會經過其.build()
方法來進行模塊的構建。構建模塊的第一步就是使用loader來加載並處理模塊內容。而loader-runner這個庫就是webpack中loader的運行器。web
最後,將loader處理完的模塊內容輸出,進入後續的編譯流程。
上面就是webpack中loader涉及到的大體流程。下面會結合源碼對其進行具體的分析,而在源碼閱讀分析過程當中,就會找到「loader十問」的解答。
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
則是一個封裝的配置項存取器,封裝了一些特殊的方法來操做配置對象。
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;
}
複製代碼
Q:2. webpack中有一個resolver的概念,用於解析模塊文件的真實絕對路徑,那麼loader模塊與normal module(源碼模塊)的resolver使用的是同一個麼?
在NormalModuleFactory
中,建立出NormalModule
實例以前會涉及到四個鉤子:
其中較爲重要的有兩個:
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。
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文件的絕對路徑和配置項。
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執行的。
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到結果數組中。
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就登場了。
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。
以上就是webapck loader相關部分的源碼分析。相信到這裏,你已經對最開始的「loader十問」有了答案。但願這篇文章可以讓你在學會配置loader與編寫一個簡單的loader以外,能進一步瞭解loader的實現。
閱讀源碼的過程當中可能存在一些紕漏,歡迎你們來一塊兒交流。
webpack是一個強大而複雜的前端自動化工具。其中一個特色就是配置複雜,這也使得「webpack配置工程師」這種戲謔的稱呼開始流行🤷可是,難道你真的只知足於玩轉webpack配置麼?
顯然不是。在學習如何使用webpack以外,咱們更須要深刻webpack內部,探索各部分的設計與實現。萬變不離其宗,即便有一天webpack「過氣」了,但它的某些設計與實現卻仍會有學習價值與借鑑意義。所以,在學習webpack過程當中,我會總結一系列【webpack進階】的文章和你們分享。
歡迎感興趣的同窗多多交流與關注!
往期文章: