您好,登錄后才能下訂單哦!
Webpack 中怎么編寫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 通常是一個函數,結構如下:
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; };
除了作為內容轉換器外,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 之間按照配置的順序從前到后(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 這一重要特性做足夠深入的介紹,沒有講清楚為什么要設計 pitch 這個功能,pitch 有哪些常見用例等。
在這一節,我會從 what、how、why 三個維度展開聊聊 loader 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 等開源項目之后,我個人總結出兩個字:「阻斷」!
先回顧一下前面提到過的 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;
代碼的核心邏輯:
鴻蒙官方戰略合作共建——HarmonyOS技術社區
根據 Loader 配置,調用 interpolateName 方法拼接目標文件的完整路徑
調用上下文 this.emitFile 接口,寫出文件
返回 module.exports = ${publicPath} ,其它模塊可以引用到該文件路徑
除 file-loader 外,css-loader、eslint-loader 都有用到該接口,感興趣的同學請自行前往查閱源碼。
在 Loader 中編寫單元測試收益非常高,一方面對開發者來說不用去怎么寫 demo,怎么搭建測試環境;一方面對于最終用戶來說,帶有一定測試覆蓋率的項目通常意味著更高、更穩定的質量。
閱讀了超過 20 個開源項目后,我總結了一套 Webpack Loader 場景下常用的單元測試流程,以 Jest · ? Delightful JavaScript Testing 為例:
鴻蒙官方戰略合作共建——HarmonyOS技術社區
創建在 Webpack 實例,并運行 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/'], } }
關于Webpack 中怎么編寫loader問題的解答就分享到這里了,希望以上內容可以對大家有一定的幫助,如果你還有很多疑惑沒有解開,可以關注億速云行業資訊頻道了解更多相關知識。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。