Webpack 原理系列:如何編寫loader
關于 Webpack Loader,網上已經有很多很多的資料,很難講出花來,但是要寫 Webpack 的系列博文又沒辦法繞開這一點,所以我閱讀了超過 20 個開源項目,盡量全面地總結了一些編寫 Loader 時需要了解的知識和技巧。包含:
那么,我們開始吧。
認識 Loader
- 如果要做總結的話,我認為 Loader 是一個帶有副作用的內容轉譯器!
Webpack Loader 最核心的只能是實現內容轉換器 —— 將各式各樣的資源轉化為標準 JavaScript 內容格式,例如:
- css-loader 將 css 轉換為 __WEBPACK_DEFAULT_EXPORT__ = ".a{ xxx }"格式
- html-loader 將 html 轉換為 __WEBPACK_DEFAULT_EXPORT__ = "
- vue-loader 更復雜一些,會將 .vue 文件轉化為多個 JavaScript 函數,分別對應 template、js、css、custom block
那么為什么需要做這種轉換呢?本質上是因為 Webpack 只認識符合 JavaScript 規范的文本(Webpack 5之后增加了其它 parser):在構建(make)階段,解析模塊內容時會調用 acorn 將文本轉換為 AST 對象,進而分析代碼結構,分析模塊依賴;這一套邏輯對圖片、json、Vue SFC等場景就不 work 了,就需要 Loader 介入將資源轉化成 Webpack 可以理解的內容形態。
- Plugin 是 Webpack 另一套擴展機制,功能更強,能夠在各個對象的鉤子中插入特化處理邏輯,它可以覆蓋 Webpack 全生命流程,能力、靈活性、復雜度都會比 Loader 強很多,我們下次再講。
Loader 基礎
代碼層面,Loader 通常是一個函數,結構如下:
- module.exports = function(source, sourceMap?, data?) {
- // source 為 loader 的輸入,可能是文件內容,也可能是上一個 loader 處理結果
- return source;
- };
Loader 函數接收三個參數,分別為:
- source:資源輸入,對于第一個執行的 loader 為資源文件的內容;后續執行的 loader 則為前一個 loader 的執行結果
- sourceMap: 可選參數,代碼的 sourcemap 結構
- data: 可選參數,其它需要在 Loader 鏈中傳遞的信息,比如 posthtml/posthtml-loader 就會通過這個參數傳遞參數的 AST 對象
其中 source 是最重要的參數,大多數 Loader 要做的事情就是將 source 轉譯為另一種形式的 output ,比如 webpack-contrib/raw-loader 的核心源碼:
- //...
- export default function rawLoader(source) {
- // ...
- const json = JSON.stringify(source)
- .replace(/\u2028/g, '\\u2028')
- .replace(/\u2029/g, '\\u2029');
- const esModule =
- typeof options.esModule !== 'undefined' ? options.esModule : true;
- return `${esModule ? 'export default' : 'module.exports ='} ${json};`;
- }
這段代碼的作用是將文本內容包裹成 JavaScript 模塊,例如:
- // source
- I am Tecvan
- // output
- module.exports = "I am Tecvan"
經過模塊化包裝之后,這段文本內容轉身變成 Webpack 可以處理的資源模塊,其它 module 也就能引用、使用它了。
返回多個結果
上例通過 return 語句返回處理結果,除此之外 Loader 還可以以 callback 方式返回更多信息,供下游 Loader 或者 Webpack 本身使用,例如在 webpack-contrib/eslint-loader 中:
- export default function loader(content, map) {
- // ...
- linter.printOutput(linter.lint(content));
- this.callback(null, content, map);
- }
通過 this.callback(null, content, map) 語句同時返回轉譯后的內容與 sourcemap 內容。callback 的完整簽名如下:
- this.callback(
- // 異常信息,Loader 正常運行時傳遞 null 值即可
- err: Error | null,
- // 轉譯結果
- content: string | Buffer,
- // 源碼的 sourcemap 信息
- sourceMap?: SourceMap,
- // 任意需要在 Loader 間傳遞的值
- // 經常用來傳遞 ast 對象,避免重復解析
- data?: any
- );
異步處理涉
及到異步或 CPU 密集操作時,Loader 中還可以以異步形式返回處理結果,例如 webpack-contrib/less-loader 的核心邏輯:
- import less from "less";
- async function lessLoader(source) {
- // 1. 獲取異步回調函數
- const callback = this.async();
- // ...
- let result;
- try {
- // 2. 調用less 將模塊內容轉譯為 css
- result = await (options.implementation || less).render(data, lessOptions);
- } catch (error) {
- // ...
- }
- const { css, imports } = result;
- // ...
- // 3. 轉譯結束,返回結果
- callback(null, css, map);
- }
- export default lessLoader;
在 less-loader 中,邏輯分三步:
- 調用 this.async 獲取異步回調函數,此時 Webpack 會將該 Loader 標記為異步加載器,會掛起當前執行隊列直到 callback 被觸發
- 調用 less 庫將 less 資源轉譯為標準 css
- 調用異步回調 callback 返回處理結果
this.async 返回的異步回調函數簽名與上一節介紹的 this.callback 相同,此處不再贅述。
緩存
Loader 為開發者提供了一種便捷的擴展方法,但在 Loader 中執行的各種資源內容轉譯操作通常都是 CPU 密集型 —— 這放在單線程的 Node 場景下可能導致性能問題;又或者異步 Loader 會掛起后續的加載器隊列直到異步 Loader 觸發回調,稍微不注意就可能導致整個加載器鏈條的執行時間過長。
為此,默認情況下 Webpack 會緩存 Loader 的執行結果直到資源或資源依賴發生變化,開發者需要對此有個基本的理解,必要時可以通過 this.cachable 顯式聲明不作緩存,例如:
- module.exports = function(source) {
- this.cacheable(false);
- // ...
- return output;
- };
上下文與 Side Effect
除了作為內容轉換器外,Loader 運行過程還可以通過一些上下文接口,有限制地影響 Webpack 編譯過程,從而產生內容轉換之外的副作用。
上下文信息可通過 this 獲取,this 對象由 NormolModule.createLoaderContext 函數在調用 Loader 前創建,常用的接口包括:
- const loaderContext = {
- // 獲取當前 Loader 的配置信息
- getOptions: schema => {},
- // 添加警告
- emitWarning: warning => {},
- // 添加錯誤信息,注意這不會中斷 Webpack 運行
- emitError: error => {},
- // 解析資源文件的具體路徑
- resolve(context, request, callback) {},
- // 直接提交文件,提交的文件不會經過后續的chunk、module處理,直接輸出到 fs
- emitFile: (name, content, sourceMap, assetInfo) => {},
- // 添加額外的依賴文件
- // watch 模式下,依賴文件發生變化時會觸發資源重新編譯
- addDependency(dep) {},
- };
其中,addDependency、emitFile 、emitError、emitWarning 都會對后續編譯流程產生副作用,例如 less-loader 中包含這樣一段代碼:
- try {
- result = await (options.implementation || less).render(data, lessOptions);
- } catch (error) {
- // ...
- }
- const { css, imports } = result;
- imports.forEach((item) => {
- // ...
- this.addDependency(path.normalize(item));
- });
解釋一下,代碼中首先調用 less 編譯文件內容,之后遍歷所有 import 語句,也就是上例 result.imports 數組,一一調用 this.addDependency 函數將 import 到的其它資源都注冊為依賴,之后這些其它資源文件發生變化時都會觸發重新編譯。
Loader 鏈式調用
使用上,可以為某種資源文件配置多個 Loader,Loader 之間按照配置的順序從前到后(pitch),再從后到前依次執行,從而形成一套內容轉譯工作流,例如對于下面的配置:
- module.exports = {
- module: {
- rules: [
- {
- test: /\.less$/i,
- use: [
- "style-loader",
- "css-loader",
- "less-loader",
- ],
- },
- ],
- },
- };
這是一個典型的 less 處理場景,針對 .less 后綴的文件設定了:less、css、style 三個 loader 協作處理資源文件,按照定義的順序,Webpack 解析 less 文件內容后先傳入 less-loader;less-loader 返回的結果再傳入 css-loader 處理;css-loader 的結果再傳入 style-loader;最終以 style-loader 的處理結果為準,流程簡化后如:
上述示例中,三個 Loader 分別起如下作用:
- less-loader:實現 less => css 的轉換,輸出 css 內容,無法被直接應用在 Webpack 體系下
- css-loader:將 css 內容包裝成類似 module.exports = "${css}" 的內容,包裝后的內容符合 JavaScript 語法
- style-loader:做的事情非常簡單,就是將 css 模塊包進 require 語句,并在運行時調用 injectStyle 等函數將內容注入到頁面的 style 標簽
三個 Loader 分別完成內容轉化工作的一部分,形成從右到左的調用鏈條。鏈式調用這種設計有兩個好處,一是保持單個 Loader 的單一職責,一定程度上降低代碼的復雜度;二是細粒度的功能能夠被組裝成復雜而靈活的處理鏈條,提升單個 Loader 的可復用性。
不過,這只是鏈式調用的一部分,這里面有兩個問題:
- Loader 鏈條一旦啟動之后,需要所有 Loader 都執行完畢才會結束,沒有中斷的機會 —— 除非顯式拋出異常
- 某些場景下并不需要關心資源的具體內容,但 Loader 需要在 source 內容被讀取出來之后才會執行
為了解決這兩個問題,Webpack 在 loader 基礎上疊加了 pitch 的概念。
Loader Pitch
網絡上關于 Loader 的文章已經有非常非常多,但多數并沒有對 pitch 這一重要特性做足夠深入的介紹,沒有講清楚為什么要設計 pitch 這個功能,pitch 有哪些常見用例等。
在這一節,我會從 what、how、why 三個維度展開聊聊 loader pitch 這一特性。
什么是 pitch
Webpack 允許在這個函數上掛載名為 pitch 的函數,運行時 pitch 會比 Loader 本身更早執行,例如:
- const loader = function (source){
- console.log('后執行')
- return source;
- }
- loader.pitch = function(requestString) {
- console.log('先執行')
- }
- module.exports = loader
Pitch 函數的完整簽名:
- function pitch(
- remainingRequest: string, previousRequest: string, data = {}
- ): void {
- }
包含三個參數:
- remainingRequest : 當前 loader 之后的資源請求字符串
- previousRequest : 在執行當前 loader 之前經歷過的 loader 列表
- data : 與 Loader 函數的 data 相同,用于傳遞需要在 Loader 傳播的信息
這些參數不復雜,但與 requestString 緊密相關,我們看個例子加深了解:
- module.exports = {
- module: {
- rules: [
- {
- test: /\.less$/i,
- use: [
- "style-loader", "css-loader", "less-loader"
- ],
- },
- ],
- },
- };
css-loader.pitch 中拿到的參數依次為:
- // css-loader 之后的 loader 列表及資源路徑
- remainingRequest = less-loader!./xxx.less
- // css-loader 之前的 loader 列表
- previousRequest = style-loader
- // 默認值
- data = {}
調度邏輯
Pitch 翻譯成中文是拋、球場、力度、事物最高點等,我覺得 pitch 特性之所以被忽略完全是這個名字的鍋,它背后折射的是一整套 Loader 被執行的生命周期概念。
實現上,Loader 鏈條執行過程分三個階段:pitch、解析資源、執行,設計上與 DOM 的事件模型非常相似,pitch 對應到捕獲階段;執行對應到冒泡階段;而兩個階段之間 Webpack 會執行資源內容的讀取、解析操作,對應 DOM 事件模型的 AT_TARGET 階段:

pitch 階段按配置順序從左到右逐個執行 loader.pitch 函數(如果有的話),開發者可以在 pitch 返回任意值中斷后續的鏈路的執行:

那么為什么要設計 pitch 這一特性呢?在分析了 style-loader、vue-loader、to-string-loader 等開源項目之后,我個人總結出兩個字:「阻斷」!
示例:style-loader
先回顧一下前面提到過的 less 加載鏈條:
- less-loader :將 less 規格的內容轉換為標準 css
- css-loader :將 css 內容包裹為 JavaScript 模塊
- style-loader :將 JavaScript 模塊的導出結果以 link 、style 標簽等方式掛載到 html 中,讓 css 代碼能夠正確運行在瀏覽器上
實際上, style-loader 只是負責讓 css 能夠在瀏覽器環境下跑起來,本質上并不需要關心具體內容,很適合用 pitch 來處理,核心代碼:
- // ...
- // Loader 本身不作任何處理
- const loaderApi = () => {};
- // pitch 中根據參數拼接模塊代碼
- loaderApi.pitch = function loader(remainingRequest) {
- //...
- switch (injectType) {
- case 'linkTag': {
- return `${
- esModule
- ? `...`
- // 引入 runtime 模塊
- : `var api = require(${loaderUtils.stringifyRequest(
- this,
- `!${path.join(__dirname, 'runtime/injectStylesIntoLinkTag.js')}`
- )});
- // 引入 css 模塊
- var content = require(${loaderUtils.stringifyRequest(
- this,
- `!!${remainingRequest}`
- )});
- content = content.__esModule ? content.default : content;`
- } // ...`;
- }
- case 'lazyStyleTag':
- case 'lazySingletonStyleTag': {
- //...
- }
- case 'styleTag':
- case 'singletonStyleTag':
- default: {
- // ...
- }
- }
- };
- export default loaderApi;
關鍵點:
- loaderApi 為空函數,不做任何處理
- loaderApi.pitch 中拼接結果,導出的代碼包含:
引入運行時模塊 runtime/injectStylesIntoLinkTag.js復用 remainingRequest 參數,重新引入 css 文件
運行結果大致如:
- var api = require('xxx/style-loader/lib/runtime/injectStylesIntoLinkTag.js')
- var content = require('!!css-loader!less-loader!./xxx.less');
注意了,到這里 style-loader 的 pitch 函數返回這一段內容,后續的 Loader 就不會繼續執行,當前調用鏈條中斷了:
之后,Webpack 繼續解析、構建 style-loader 返回的結果,遇到 inline loader 語句:
- var content = require('!!css-loader!less-loader!./xxx.less');
所以從 Webpack 的角度看,實際上對同一個文件調用了兩次 loader 鏈,第一次在 style-loader 的 pitch 中斷,第二次根據 inline loader 的內容跳過了 style-loader。
相似的技巧在其它倉庫也有出現,比如 vue-loader,感興趣的同學可以查看我之前發在 ByteFE 公眾號上的文章《Webpack 案例 ——vue-loader 原理分析》,這里就不展開講了。
進階技巧
開發工具
Webpack 為 Loader 開發者提供了兩個實用工具,在諸多開源 Loader 中出現頻率極高:
webpack/loader-utils:提供了一系列諸如讀取配置、requestString 序列化與反序列化、計算 hash 值之類的工具函數
webpack/schema-utils:參數校驗工具
這些工具的具體接口在相應的 readme 上已經有明確的說明,不贅述,這里總結一些編寫 Loader 時經常用到的樣例:如何獲取并校驗用戶配置;如何拼接輸出文件名。
獲取并校驗配置
Loader 通常都提供了一些配置項,供開發者定制運行行為,用戶可以通過 Webpack 配置文件的 use.options 屬性設定配置,例如:
- module.exports = {
- module: {
- rules: [{
- test: /\.less$/i,
- use: [
- {
- loader: "less-loader",
- options: {
- cacheDirectory: false
- }
- },
- ],
- }],
- },
- };
在 Loader 內部,需要使用 loader-utils 庫的 getOptions 函數獲取用戶配置,用 schema-utils 庫的 validate 函數校驗參數合法性,例如 css-loader:
- // css-loader/src/index.js
- import { getOptions } from "loader-utils";
- import { validate } from "schema-utils";
- import schema from "./options.json";
- export default async function loader(content, map, meta) {
- const rawOptions = getOptions(this);
- validate(schema, rawOptions, {
- name: "CSS Loader",
- baseDataPath: "options",
- });
- // ...
- }
使用 schema-utils 做校驗時需要提前聲明配置模板,通常會處理成一個額外的 json 文件,例如上例中的 "./options.json"。
拼接輸出文件名
Webpack 支持以類似 [path]/[name]-[hash].js 方式設定 output.filename即輸出文件的命名,這一層規則通常不需要關注,但某些場景例如 webpack-contrib/file-loader 需要根據 asset 的文件名拼接結果。
file-loader 支持在 JS 模塊中引入諸如 png、jpg、svg 等文本或二進制文件,并將文件寫出到輸出目錄,這里面有一個問題:假如文件叫 a.jpg ,經過 Webpack 處理后輸出為 [hash].jpg ,怎么對應上呢?此時就可以使用 loader-utils 提供的 interpolateName 在 file-loader 中獲取資源寫出的路徑及名稱,源碼:
- import { getOptions, interpolateName } from 'loader-utils';
- export default function loader(content) {
- const context = options.context || this.rootContext;
- const name = options.name || '[contenthash].[ext]';
- // 拼接最終輸出的名稱
- const url = interpolateName(this, name, {
- context,
- content,
- regExp: options.regExp,
- });
- let outputPath = url;
- // ...
- let publicPath = `__webpack_public_path__ + ${JSON.stringify(outputPath)}`;
- // ...
- if (typeof options.emitFile === 'undefined' || options.emitFile) {
- // ...
- // 提交、寫出文件
- this.emitFile(outputPath, content, null, assetInfo);
- }
- // ...
- const esModule =
- typeof options.esModule !== 'undefined' ? options.esModule : true;
- // 返回模塊化內容
- return `${esModule ? 'export default' : 'module.exports ='} ${publicPath};`;
- }
- export const raw = true;
代碼的核心邏輯:
- 根據 Loader 配置,調用 interpolateName 方法拼接目標文件的完整路徑
- 調用上下文 this.emitFile 接口,寫出文件
- 返回 module.exports = ${publicPath} ,其它模塊可以引用到該文件路徑
除 file-loader 外,css-loader、eslint-loader 都有用到該接口,感興趣的同學請自行前往查閱源碼。
單元測試
在 Loader 中編寫單元測試收益非常高,一方面對開發者來說不用去怎么寫 demo,怎么搭建測試環境;一方面對于最終用戶來說,帶有一定測試覆蓋率的項目通常意味著更高、更穩定的質量。
閱讀了超過 20 個開源項目后,我總結了一套 Webpack Loader 場景下常用的單元測試流程,以 Jest · 🃏 Delightful JavaScript Testing 為例:
- 創建在 Webpack 實例,并運行 Loader
- 獲取 Loader 執行結果,比對、分析判斷是否符合預期
- 判斷執行過程中是否出錯
如何運行 Loader
有兩種辦法,一是在 node 環境下運行調用 Webpack 接口,用代碼而非命令行執行編譯,很多框架都會采用這種方式,例如 vue-loader、stylus-loader、babel-loader 等,優點的運行效果最接近最終用戶,缺點是運行效率相對較低(可以忽略)。
以 posthtml/posthtml-loader 為例,它會在啟動測試之前創建并運行 Webpack 實例:
- // posthtml-loader/test/helpers/compiler.js 文件
- module.exports = function (fixture, config, options) {
- config = { /*...*/ }
- options = Object.assign({ output: false }, options)
- // 創建 Webpack 實例
- const compiler = webpack(config)
- // 以 MemoryFS 方式輸出構建結果,避免寫磁盤
- if (!options.output) compiler.outputFileSystem = new MemoryFS()
- // 執行,并以 promise 方式返回結果
- return new Promise((resolve, reject) => compiler.run((err, stats) => {
- if (err) reject(err)
- // 異步返回執行結果
- resolve(stats)
- }))
- }
- 小技巧:如上例所示,用 compiler.outputFileSystem = new MemoryFS()語句將 Webpack 設定成輸出到內存,能避免寫盤操作,提升編譯速度。
另外一種方法是編寫一系列 mock 方法,搭建起一個模擬的 Webpack 運行環境,例如 emaphp/underscore-template-loader ,優點的運行速度更快,缺點是開發工作量大通用性低,了解了解即可。
比對結果
上例運行結束之后會以 resolve(stats) 方式返回執行結果,stats 對象中幾乎包含了編譯過程所有信息,包括耗時、產物、模塊、chunks、errors、warnings 等等,我在之前的文章 分享幾個 Webpack 實用分析工具 對此已經做了較深入的介紹,感興趣的同學可以前往閱讀。
在測試場景下,可以從 stats 對象中讀取編譯最終輸出的產物,例如 style-loader 的實現:
- // style-loader/src/test/helpers/readAsset.js 文件
- function readAsset(compiler, stats, assets) => {
- const usedFs = compiler.outputFileSystem
- const outputPath = stats.compilation.outputOptions.path
- const queryStringIdx = targetFile.indexOf('?')
- if (queryStringIdx >= 0) {
- // 解析出輸出文件路徑
- asset = asset.substr(0, queryStringIdx)
- }
- // 讀文件內容
- return usedFs.readFileSync(path.join(outputPath, targetFile)).toString()
- }
解釋一下,這段代碼首先計算 asset 輸出的文件路徑,之后調用 outputFileSystem 的 readFile 方法讀取文件內容。
接下來,有兩種分析內容的方法:
- 調用 Jest 的 expect(xxx).toMatchSnapshot() 斷言判斷當前運行結果是否與之前的運行結果一致,從而確保多次修改的結果一致性,很多框架都大量用了這種方法
- 解讀資源內容,判斷是否符合預期,例如 less-loader 的單元測試中會對同一份代碼跑兩次 less 編譯,一次由 Webpack 執行,一次直接調用 less 庫,之后分析兩次運行結果是否相同
對此有興趣的同學,強烈建議看看 less-loader 的 test 目錄。
異常判斷
最后,還需要判斷編譯過程是否出現異常,同樣可以從 stats 對象解析:
- export default getErrors = (stats) => {
- const errors = stats.compilation.errors.sort()
- return errors.map(
- e => e.toString()
- )
- }
大多數情況下都希望編譯沒有錯誤,此時只要判斷結果數組是否為空即可。某些情況下可能需要判斷是否拋出特定異常,此時可以 expect(xxx).toMatchSnapshot() 斷言,用快照對比更新前后的結果。
調試
開發 Loader 的過程中,有一些小技巧能夠提升調試效率,包括:
- 使用 ndb 工具實現斷點調試
- 使用 npm link 將 Loader 模塊鏈接到測試項目
- 使用 resolveLoader 配置項將 Loader 所在的目錄加入到測試項目中,如:
- // webpack.config.js
- module.exports = {
- resolveLoader:{
- modules: ['node_modules','./loaders/'],
- }
- }
【編輯推薦】