Webpack原理系列之徹底搞懂loader原理

語言: CN / TW / HK

前言

loader是webpack打包過程中非常重要的一環,通過了解loader的執行過程,不僅可以學習到很多設計思想,還可以在以後遇到webpack配置問題,處理起來得心應手

如何寫一個loader

loader本質是一個函式,接收檔案內容,返回處理過後的原始碼,下面是一個簡單的loader示例 js module.exports = function(source) { const code = transform(source) // 在這裡你可以對檔案內容進行轉換或處理 return code } 以上實現了一個簡單的loader, 看起來是不是很簡單。下面稍微升級一點難度。實現一個簡單的style-loader

js function loader(source) { let script = `let style = document.createElement("style"); style.innerHTML = ${JSON.stringify(source)}; document.head.appendChild(style); `; return script; } module.exports = loader; 當我們配置上style-loader後,遇到import 'a.css'時會將其原本的內容替換成一段JS指令碼,並將樣式程式碼插入到head標籤中

loader的種類

雖說要實現一個loader很簡單,但是需要注意的是,在webpack中loader可以分以下幾種型別:

  • pre loader
  • normal loader
  • inline loader
  • post loader

以上loader的執行是從上到下執行的。也就是 pre-loader => normal loader => inline loader => post loader,我們先來看一個例子。

程式碼包含兩個檔案index.jstest.js, 在匯入test.js時使用了inline-loader, 我們先不關心各種Loader是怎麼寫的。

```js // index.js import test from 'inline-loader2!inline-loader1!./test' export default function func() { return test }

// test.js export default 1 ```

下面的程式碼配置了另外三種loader ```js const path = require('path')

function loaderPath(loaders) { return loaders.map(loader => path.resolve(__dirname, 'loaders', loader + '.js')) } module.exports = { mode: 'development', entry: './src/index.js', output: { filename: 'bundle.js', }, module: { rules: [ // pre loader { test: /.js$/, enforce: 'pre', use: loaderPath(['pre-loader1', 'pre-loader2']) }, // normal loader { test: /.js$/, use: loaderPath(['normal-loader1', 'normal-loader2']) }, // post loader { test: /.js$/, enforce: 'post', use: loaderPath(['post-loader1', 'post-loader2']) } ] } } `` 上面配置中需要注意的點 1. 通過enforce屬性,設定loader的執行順序 2. 通過!分割inline-loader`

看下執行結果 ```js // index.js 執行的loader pre-loader2 pre-loader1 normal-loader2 normal-loader1 post-loader2 post-loader1

// test.js 執行的loader pre-loader2 pre-loader1 normal-loader2 normal-loader1 inline-loader2 inline-loader1 post-loader2 post-loader1 ```

inline loader的寫法

通過上面的示例,我們大體瞭解了loader的執行順序,大家先留個印象。但是大家可能比較疑惑,inline-loader的寫法怎麼這麼奇怪。有時候我們專案在編譯的時候經常會看到類似的log, 比如vue編譯的時候, 有這麼一長串: js -!../node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!../node_modules/vue-loader/lib/index.js??vue-loader-options!./app.vue?vue&type=template&id=5ef48958&scoped=true& 上面的內容其實可以分為四部分 ```js -!../node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options

!../node_modules/vue-loader/lib/index.js??vue-loader-options

!./app.vue

?vue&type=template&id=5ef48958&scoped=true& `inline-loader`其實是通過`!`將loader進行分割,例如js import test from 'inline-loader2!inline-loader1!./test' // 包含 inline-loader2 和 inline-loader1 `` 那麼-!`這個字首又是什麼呢,其實字首有多種寫法: Webpack中文文件

|符號|變數|含義| |---|---|---| |-!|noPreAutoLoaders|不要前置和普通 loader| |!|noAutoLoaders|不要普通 loader| |!!|noPrePostAutoLoaders|其他loader都不要,只要內聯 loader|

比如我們在前面加了!!字首,那麼normal, pre, post loader都不會執行, 所以內聯loader是比較靈活的,在日常專案中並不推薦使用

loader是如何執行的

其實webpack為了實現loader的功能,單獨開發了一個loader執行器,也就是loader-runnner。下面看個簡單的例子

```js import { runLoaders } from "loader-runner";

runLoaders({ resource: "/abs/path/to/file.txt?query", // 需要處理的檔案路徑 loaders: ["/abs/path/to/loader.js"], // loader檔案路徑 context: { minimize: true }, // loader上下文,可通過this獲取 processResource: (loaderContext, resourcePath, callback) => { ... }, readResource: fs.readFile.bind(fs) }, function(err, result) { // 處理後的檔案內容 }) ``` 在執行runLoaders過後,會獲取到檔案最終的內容。上面的例子在執行後,會經過如下的流程 (注意:enforce的pre、post配置是webpack自身制定的規則,runLoaders只負責執行

  1. 按照post -> inline -> normal -> pre順序, 從左到右執行相同型別的loader.pitch
  2. 按照pre -> normal -> inline -> post順序, 從右到左執行相同型別的loader

未命名檔案 (12).png

pitch和normal執行順序完全相反,pitch先執行

pitch loader

看了上面loader執行的過程,大家可能又比較疑惑pitch loader是什麼。其實在開發 Loader 時,我們可以在匯出的函式上新增一個 pitch 函式,就像下面這樣:

```js function loader(source) { console.log('normal-loader1') return source }

/ * * @param {} remainingRequest 剩餘需要執行的pitch loader * @param {} precedingRequest 已經執行過得pitch loader * @param {} data / loader.pitch = function(remainingRequest, precedingRequest, data) { console.log(remainingRequest) console.log(precedingRequest) console.log(data) }

module.exports = loader 當檔案經過該loader處理時,pitch會先執行,並打印出下面內容js D:\code\pre-loader1.js!D:\code\pre-loader2.js!D:\code\webpack-demo\src\test.js // 剩餘需要執行的pitch loader

D:\code\post-loader1.js!D:\code\post-loader2.js!D:\code\normal-loader1.js // 已經執行過得pitch loader

{} // 空物件 ```

再測試下一開始的例子,將會列印下面的內容 ```js // pitch 優先執行了,並且是從post開始 post-loader1 pitch post-loader2 pitch inline-loader1 pitch inline-loader2 pitch normal-loader1 pitch normal-loader2 pitch pre-loader1 pitch pre-loader2 pitch

pre-loader2 pre-loader1 normal-loader2 normal-loader1 inline-loader2 inline-loader1 post-loader2 post-loader1 ```

pitch loader的熔斷機制

當pitch返回一個非空的值時,將會跳過後面pitch loadernormal loader的執行 js function loader(source) { console.log('normal-loader1') return source } loader.pitch = function(remainingRequest, precedingRequest, data) { console.log('normal-loader1 pitch'); return 'let a = 0' // 這裡返回了非空值 } module.exports = loader 我們在normal-loader1的pitch函式中返回了非空值測試下:

js post-loader1 pitch post-loader2 pitch inline-loader1 pitch inline-loader2 pitch normal-loader1 pitch inline-loader2 inline-loader1 post-loader2 post-loader1 可以看到loader只執行到了normal-loader1 pitch, normal-loader1自身的loader也不會執行。 並且normal-loader1 pitch的返回值,將作為inline-loader2source引數(大家注意下面紅色箭頭

未命名檔案 (13).png

loader上下文

我們再回到前面的例子 ```js import { runLoaders } from "loader-runner";

runLoaders({ resource: "/abs/path/to/file.txt?query", // 需要處理的檔案路徑 loaders: ["/abs/path/to/loader.js"], // loader檔案路徑 context: { minimize: true }, // loader上下文,可通過this獲取 processResource: (loaderContext, resourcePath, callback) => { ... }, readResource: fs.readFile.bind(fs) }, function(err, result) { // 處理後的檔案內容 }) 大家會發現有一個`context`屬性,那它是幹嘛的呢。下面舉個簡單的例子js function loader(source) { const callback = this.async() setTimeout(() => { callback(null, source) // 等同於this.callback }, 2000) } `` 上面的程式碼,我們通過this呼叫了async方法,獲取一個callback, 這種方式可以讓我們在Loader中實現非同步操作`。

什麼是loader上下文呢,簡單來講就是this, loader的this上有許多變數和函式,能方便我們獲取當前需要處理的檔案,或者非同步處理檔案內容。原理也很簡單, 就是通過apply來實現 js loader.apply(loaderContext, args)

loader-runner自帶的上下文屬性

其實loader上下文的屬性可以分為loader-runner內建的上下文屬性 和 webpack內建的上下文屬性,什麼意思呢?拋開webpack這個構建工具,如我們只是單純使用loader-runner它將包含下面這些上下文屬性js function loader(source) { this.resource // 需要處理的資源路徑 this.request // 完整的請求 this.loaders // loader物件陣列 this.readResource // 讀取資源的方法,預設fs.readFile this.loaderIndex // 當前正在執行的loader索引 this.callback // 回撥方法 this.async // 非同步方法,返回一個回撥函式 this.remainingRequest // 剩餘請求 this.currentRequest // 當前請求 this,previousRequest // 已經處理過得請求 this.data // 當前loader的公共資料 return source } module.exports = loader

webpack的loader上下文屬性

前面我們知道在執行runLoaders方法時,可以傳一個自己的context,最終會和內建的上下文屬性合併。我們直接來看下webpack的原始碼。 js // webpack\lib\NormalModule.js doBuild(options, compilation, resolver, fs, callback) { // 建立loader上下文 const loaderContext = this.createLoaderContext( resolver, options, compilation, fs ); // 執行Loader runLoaders( { resource: this.resource, loaders: this.loaders, context: loaderContext, readResource: fs.readFile.bind(fs) }, (err, result) => { return callback(); } ); }

loaderContext原始碼如下 js // webpack\lib\NormalModule.js createLoaderContext(resolver, options, compilation, fs) { // .. const loaderContext = { version: 2, emitWarning: warning => { }, emitError: error => { }, getLogger: name => { }, // TODO remove in webpack 5 exec: (code, filename) => { }, resolve(context, request, callback) { }, getResolve(options) { }, emitFile: (name, content, sourceMap, assetInfo) => { }, rootContext: options.context, webpack: true, sourceMap: !!this.useSourceMap, mode: options.mode || "production", _module: this, _compilation: compilation, _compiler: compilation.compiler, fs: fs }; compilation.hooks.normalModuleLoader.call(loaderContext, this); return loaderContext; }

從上面的程式碼可以知道normalModuleLoader hook可以方便的獲取到loaderContext, 並且擴充套件loader功能

js compiler.hooks.compilation.tap("LoaderPlugin", compilation => { compilation.hooks.normalModuleLoader.tap( "LoaderPlugin", (loaderContext, module) => { // 擴充套件loaderContext } ); }); 另外,關於webpack中loaderContext的屬性用法,大家感興趣可以看下

Webpack中文文件

實現loader-runner

前面介紹了loader-runner用法,不如趁熱打鐵實現一波~, 實現起來也是非常簡單的, 先來看下整體流程圖

loader-runner流程圖.png

在實現之前我們先來回顧下runLoaders用法 ```js import { runLoaders } from "loader-runner";

runLoaders({ resource: "/abs/path/to/file.txt?query", // 需要處理的檔案路徑 loaders: ["/abs/path/to/loader.js"], // loader檔案路徑 context: { minimize: true }, // loader上下文,可通過this獲取 processResource: (loaderContext, resourcePath, callback) => { ... }, readResource: fs.readFile.bind(fs) }, function(err, result) { // 處理後的檔案內容 }) ```

1. 初始化loaderContext

先來實現初始化邏輯 ```js function createLoaderObject(loader) { // 獲取loader函式 let normal = require(loader)

// 獲取pitch函式 let pitch = normal.pitch

// 如果為true loader接收的是Buffer,否則是字串 let raw = normal.raw

return { path: loader, normal, pitch, raw, data: {}, // 每個loader可以攜帶一個自定義的資料物件 pitchExecuted: false, // pitch是否執行 normalExecuted: false // normal是否執行 } }

function runLoaders(options, finalCallback) { const { resource, // 資源路徑 loaders = [], // loader配置 context = {}, // 上下文物件 readResource = fs.readFile } = options

const loaderObjects = loaders.map(createLoaderObject) const loaderContext = context loaderContext.resource = resource loaderContext.loaders = loaderObjects loaderContext.readResource = readResource loaderContext.loaderIndex = 0 // 當前正在執行的Loader索引 // 呼叫它會執行下一個loader loaderContext.callback = null // 預設Loader是同步的 loaderContext.async = null

// 定義request getter Object.defineProperty(loaderContext, 'request', { get() { // loader1!loader2!loader3!./a.js return loaderContext.loaders .map(loader => loader.path) .concat(loaderContext.resource) .join('!') } }) // 定義remainingRequest getter Object.defineProperty(loaderContext, 'remainingRequest', { get() { return loaderContext.loaders .slice(loaderContext.loaderIndex + 1) .map(loader => loader.path) .concat(loaderContext.resource) .join('!') } }) // 定義currentRequest getter Object.defineProperty(loaderContext, 'currentRequest', { get() { return loaderContext.loaders .slice(loaderContext.loaderIndex) .map(loader => loader.path) .concat(loaderContext.resource) .join('!') } }) // 定義previousRequest getter Object.defineProperty(loaderContext, 'previousRequest', { get() { return loaderContext.loaders .slice(0, loaderContext.loaderIndex) .map(loader => loader.path) .concat(loaderContext.resource) .join('!') } }) // 定義data getter Object.defineProperty(loaderContext, 'data', { get() { return loaderContext.loaders[loaderContext.loaderIndex] } })

let processOptions = { resourceBuffer: null, // 本次要讀取的資原始檔Buffer readResource }

// 迭代執行pitch iteratePitchingLoader(processOptions, loaderContext, (err, result) => { // 最終的回撥 finalCallback && finalCallback(err, { result, resourceBuffer: processOptions.resourceBuffer }) }) }

exports.runLoaders = runLoaders `` 上面的程式碼中,主要做了這麼幾件事 + 為每個loader建立loader物件 + 基於傳入的context,再初始化一些內建上下文 + 定義一些requestgetter,因為這樣才能根據loaderIndex實時獲取到當前正在執行loader`的request資訊 + 迭代pitch

下面我們詳細看下iteratePitchingLoader的實現

2. iteratePitchingLoader

```js function iteratePitchingLoader(processOptions, loaderContext, pitchingCallback) { // 從左向右執行,越界了,就可以讀取檔案了 if (loaderContext.loaderIndex >= loaderContext.loaders.length) { return processResource(processOptions, loaderContext, pitchingCallback) } // 獲取當前要執行的loader let currentLoader = loaderContext.loaders[loaderContext.loaderIndex]

// 沒有pitch的情況會執行 if (currentLoader.pitchExecuted) { loaderContext.loaderIndex++ return iteratePitchingLoader(processOptions, loaderContext, pitchingCallback) } let fn = currentLoader.pitch currentLoader.pitchExecuted = true // 沒有pitch的情況會執行 if (!fn) { return iteratePitchingLoader(processOptions, loaderContext, pitchingCallback) }

runSyncOrAsync(fn, loaderContext, [ loaderContext.remainingRequest, loaderContext.previousRequest, loaderContext.data ], (err, ...args) => { // pitch返回值不為空 跳過後續loader, 掉頭執行前一個Loader的normal if (args.length && args.some(e => e)) { loaderContext.loaderIndex-- iterateNormalLoaders(processOptions, loaderContext, args, pitchingCallback) } else { return iteratePitchingLoader(processOptions, loaderContext, pitchingCallback) } }) } `` 上面的程式碼主要做了這幾件事 + 從左向右執行,判斷是否越界了,超過就代表就可以讀取檔案了(呼叫processResource方法),同時也代表pitch沒有返回值+ 沒有pitch的情況, 繼續向後迭代,並使loaderIndex+++ 存在pitch, 就呼叫runSyncOrAsync`

3. runSyncOrAsync

```js function runSyncOrAsync(fn, loaderContext, args, runCallback) { let isSync = true

loaderContext.callback = (...args) => { runCallback(...args) } loaderContext.async = function() { isSync = false return loaderContext.callback } const result = fn.apply(loaderContext, args) if (isSync) { runCallback(null, result) } } ``runSyncOrAsync實現比較簡單,只是在loaderContext上掛載了一些回撥方法。其實最後執行的都是loaderContext.callback。 在執行完上面的內容後,會通過runCallback拿到返回結果,並判斷結果是否為空,如果為空就繼續迭代。否則就開始迭代normal loader`

4. iterateNormalLoaders

看完上面iteratePitchingLoader的實現後,其實大家也能猜到這個方法的實現了,其實就是反過來迭代了。 ```js function convertArgs(args, raw) { if (raw && !Buffer.isBuffer(args[0])) { args[0] = Buffer.from(args[0]) } else if (!raw && Buffer.isBuffer(args[0])) { args[0] = args[0].toString() } }

function iterateNormalLoaders(processOptions, loaderContext, args, pitchingCallback) { // 如果超出左邊邊界,就呼叫結束回撥 if (loaderContext.loaderIndex < 0) { return pitchingCallback(null, ...args) } // 獲取當前loader let currentLoader = loaderContext.loaders[loaderContext.loaderIndex] if (currentLoader.normalExecuted) { loaderContext.loaderIndex-- return iterateNormalLoaders(processOptions, loaderContext, args, pitchingCallback) } let normalFn = currentLoader.normal currentLoader.normalExecuted = true convertArgs(args, currentLoader.raw) // 執行normal loader runSyncOrAsync(normalFn, loaderContext, args, (err, ...returnArgs) => { return iterateNormalLoaders(processOptions, loaderContext, returnArgs, pitchingCallback) }) }

function processResource(processOptions, loaderContext, pitchingCallback) { // 呼叫readResource 讀取檔案內容,讀取完成後,拿到檔案內容向左迭代 processOptions.readResource(loaderContext.resource, (err, resourceBuffer) => { processOptions.resourceBuffer = resourceBuffer loaderContext.loaderIndex-- // 迭代執行normal loader iterateNormalLoaders(processOptions, loaderContext, [resourceBuffer], pitchingCallback) }) } ```

以上就是loader-runner的執行過程,是不是非常簡單~,原始碼已放入github

預告

下一篇我將基於本文內容,分析一下vue-loader原始碼,大家敬請期待