天天看點

【Webpack】999- 手把手教你寫一個 loader / plugin

【Webpack】999- 手把手教你寫一個 loader / plugin
焦傳锴,微醫前端技術部平台支撐組。學習也要有呼有吸。

​一、Loader​

1.1 loader 幹啥的?​

webpack 隻能了解 JavaScript 和 JSON 檔案,這是 webpack 開箱可用的自帶能力。loader 讓 webpack 能夠去處理其他類型的檔案,并将它們轉換為有效子產品,以供應用程式使用,以及被添加到依賴圖中。

也就是說,webpack 把任何檔案都看做子產品,loader 能 import 任何類型的子產品,但是 webpack 原生不支援譬如 css 檔案等的解析,這時候就需要用到我們的 loader 機制了。 我們的 loader 主要通過兩個屬性來讓我們的 webpack 進行關聯識别:

  1. test 屬性,識别出哪些檔案會被轉換。
  2. use 屬性,定義出在進行轉換時,應該使用哪個 loader。

那麼問題來了,大家一定想知道自己要定制一個 loader 的話需要怎麼做呢?

1.2 開發準則​

俗話說的好,沒有規矩不成方圓,編寫我們的 loader 時,官方也給了我們一套用法準則(Guidelines),在編寫的時候應該按照這套準則來使我們的 loader 标準化:

  • 簡單易用。
  • 使用鍊式傳遞。(由于 loader 是可以被鍊式調用的,是以請保證每一個 loader 的單一職責)
  • 子產品化的輸出。
  • 確定無狀态。(不要讓 loader 的轉化中保留之前的狀态,每次運作都應該獨立于其他編譯子產品以及相同子產品之前的編譯結果)
  • 充分使用官方提供的loader utilities。
  • 記錄 loader 的依賴。
  • 解析子產品依賴關系。
根據子產品類型,可能會有不同的模式指定依賴關系。例如在 CSS 中,使用@import 和 url(...)語句來聲明依賴。這些依賴關系應該由子產品系統解析。 可以通過以下兩種方式中的一種來實作:
  • 通過把它們轉化成 require 語句。
  • 使用 this.resolve 函數解析路徑。
  • 提取通用代碼。
  • 避免絕對路徑。
  • 使用 peer dependencies。如果你的 loader 簡單包裹另外一個包,你應該把這個包作為一個 peerDependency 引入。

1.3 上手​

一個 loader 就是一個 nodejs 子產品,他導出的是一個函數,這個函數隻有一個入參,這個參數就是一個包含資源檔案内容的字元串,而函數的傳回值就是處理後的内容。也就是說,一個最簡單的 loader 長這樣:

module.exports = function (content) {
 // content 就是傳入的源内容字元串
  return content
}      

當一個 loader 被使用的時候,他隻可以接收一個入參,這個參數是一個包含包含資源檔案内容的字元串。 是的,到這裡為止,一個最簡單 loader 就已經完成了!接下來我們來看看怎麼給他加上豐富的功能。

1.4 四種 loader​

我們基本可以把常見的 loader 分為四種:

  1. 同步 loader
  2. 異步 loader
  3. "Raw" Loader
  4. Pitching loader

① 同步 loader 與 異步 loader

一般的 loader 轉換都是同步的,我們可以采用上面說的直接 return 結果的方式,傳回我們的處理結果:

module.exports = function (content) {
 // 對 content 進行一些處理
  const res = dosth(content)
  return res
}      

也可以直接使用 ​

​this.callback()​

​​ 這個 api,然後在最後直接 **return undefined **的方式告訴 webpack 去 ​

​this.callback()​

​ 尋找他要的結果,這個 api 接受這些參數:

this.callback(
  err: Error | null, // 一個無法正常編譯時的 Error 或者 直接給個 null
  content: string | Buffer,// 我們處理後傳回的内容 可以是 string 或者 Buffer()
  sourceMap?: SourceMap, // 可選 可以是一個被正常解析的 source map
  meta?: any // 可選 可以是任何東西,比如一個公用的 AST 文法樹
);      

接下來舉個例子:

【Webpack】999- 手把手教你寫一個 loader / plugin

這裡注意​​

​[this.getOptions()](https://webpack.docschina.org/api/loaders/#thisgetoptionsschema)​

​ 可以用來擷取配置的參數

從 webpack 5 開始,this.getOptions 可以擷取到 loader 上下文對象。它用來替代來自loader-utils中的 getOptions 方法。
module.exports = function (content) {
  // 擷取到使用者傳給目前 loader 的參數
  const options = this.getOptions()
  const res = someSyncOperation(content, options)
  this.callback(null, res, sourceMaps);
  // 注意這裡由于使用了 this.callback 直接 return 就行
  return
}      

這樣一個同步的 loader 就完成了!

再來說說異步: 同步與異步的差別很好了解,一般我們的轉換流程都是同步的,但是當我們遇到譬如需要網絡請求等場景,那麼為了避免阻塞建構步驟,我們會采取異步建構的方式,對于異步 loader 我們主要需要使用 ​

​this.async()​

​ 來告知 webpack 這次建構操作是異步的,不多廢話,看代碼就懂了:

module.exports = function (content) {
  var callback = this.async()
  someAsyncOperation(content, function (err, result) {
    if (err) return callback(err)
    callback(null, result, sourceMaps, meta)
  })
}      

② "Raw" loader

預設情況下,資源檔案會被轉化為 UTF-8 字元串,然後傳給 loader。通過設定 raw 為 true,loader 可以接收原始的 Buffer。每一個 loader 都可以用 String 或者 Buffer 的形式傳遞它的處理結果。complier 将會把它們在 loader 之間互相轉換。大家熟悉的 file-loader 就是用了這個。簡而言之:你加上 ​

​module.exports.raw = true;​

​ 傳給你的就是 Buffer 了,處理傳回的類型也并非一定要是 Buffer,webpack 并沒有限制。

module.exports = function (content) {
  console.log(content instanceof Buffer); // true
  return doSomeOperation(content)
}
// 劃重點↓
module.exports.raw = true;      

③ Pitching loader

我們每一個 loader 都可以有一個 ​

​pitch​

​ 方法,大家都知道,loader 是按照從右往左的順序被調用的,但是實際上,在此之前會有一個按照從左往右執行每一個 loader 的 pitch 方法的過程。pitch 方法共有三個參數:

  1. remainingRequest:loader 鍊中排在自己後面的 loader 以及資源檔案的絕對路徑以​

    ​!​

    ​作為連接配接符組成的字元串。
  2. precedingRequest:loader 鍊中排在自己前面的 loader 的絕對路徑以​

    ​!​

    ​作為連接配接符組成的字元串。
  3. data:每個 loader 中存放在上下文中的固定字段,可用于 pitch 給 loader 傳遞資料。

在 pitch 中傳給 data 的資料,在後續的調用執行階段,是可以在 ​

​this.data​

​ 中擷取到的:

module.exports = function (content) {
  return someSyncOperation(content, this.data.value);// 這裡的 this.data.value === 42
};

module.exports.pitch = function (remainingRequest, precedingRequest, data) {
  data.value = 42;
};      

注意! 如果某一個 loader 的 pitch 方法中傳回了值,那麼他會直接“往回走”,跳過後續的步驟,來舉個例子:

【Webpack】999- 手把手教你寫一個 loader / plugin

假設我們現在是這樣:​​

​use: ['a-loader', 'b-loader', 'c-loader'],​

​​那麼正常的調用順序是這樣:

【Webpack】999- 手把手教你寫一個 loader / plugin

現在 b-loader 的 pitch 改為了有傳回值:

// b-loader.js
module.exports = function (content) {
  return someSyncOperation(content);
};

module.exports.pitch = function (remainingRequest, precedingRequest, data) {
  return "诶,我直接傳回,就是玩兒~"
};      

那麼現在的調用就會變成這樣,直接“回頭”,跳過了原來的其他三個步驟:

【Webpack】999- 手把手教你寫一個 loader / plugin

1.5 其他 API​

  • this.addDependency:加入一個檔案進行監聽,一旦檔案産生變化就會重新調用這個 loader 進行處理
  • this.cacheable:預設情況下 loader 的處理結果會有緩存效果,給這個方法傳入 false 可以關閉這個效果
  • this.clearDependencies:清除 loader 的所有依賴
  • this.context:檔案所在的目錄(不包含檔案名)
  • this.data:pitch 階段和正常調用階段共享的對象
  • this.getOptions(schema):用來擷取配置的 loader 參數選項
  • this.resolve:像 require 表達式一樣解析一個 request。​

    ​resolve(context: string, request: string, callback: function(err, result: string))​

  • this.loaders:所有 loader 組成的數組。它在 pitch 階段的時候是可以寫入的。
  • this.resource:擷取目前請求路徑,包含參數:​

    ​'/abc/resource.js?rrr'​

  • this.resourcePath:不包含參數的路徑:​

    ​'/abc/resource.js'​

  • this.sourceMap:bool 類型,是否應該生成一個 sourceMap

官方還提供了很多實用 Api ,這邊隻列舉一些可能常用的,更多可以戳連結👇更多詳見官方連結

1.6 來個簡單實踐​

功能實作

接下來我們簡單實踐制作兩個 loader ,功能分别是在編譯出的代碼中加上 ​

​/** 公司@年份 */​

​​ 格式的注釋和簡單做一下去除代碼中的 ​

​console.log​

​ ,并且我們鍊式調用他們:

company-loader.js

module.exports = function (source) {
  const options = this.getOptions() // 擷取 webpack 配置中傳來的 option
  this.callback(null, addSign(source, options.sign))
  return
}

function addSign(content, sign) {
  return `/** ${sign} */\n${content}`
}      

console-loader.js

module.exports = function (content) {
  return handleConsole(content)
}

function handleConsole(content) {
  return content.replace(/console.log\(['|"](.*?)['|"]\)/, '')
}      

調用測試方式

功能就簡單的進行了一下實作,這裡我們主要說一下如何測試調用我們的本地的 loader,方式有兩種,一種是通過 Npm link 的方式進行測試,這個方式的具體使用就不細說了,大家可以簡單查閱一下。 另外一種就是直接在項目中通過路徑配置的方式,有兩種情況:

  1. 比對(test)單個 loader,你可以簡單通過在 rule 對象設定 path.resolve 指向這個本地檔案

webpack.config.js

{
  test: /\.js$/
  use: [
    {
      loader: path.resolve('path/to/loader.js'),
      options: {/* ... */}
    }
  ]
}      
  1. 比對(test)多個 loaders,你可以使用 resolveLoader.modules 配置,webpack 将會從這些目錄中搜尋這些 loaders。例如,如果你的項目中有一個 /loaders 本地目錄:

webpack.config.js

resolveLoader: {
  // 這裡就是說先去找 node_modules 目錄中,如果沒有的話再去 loaders 目錄查找
  modules: [
    'node_modules',
    path.resolve(__dirname, 'loaders')
  ]
}      

配置使用

我們這裡的 webpack 配置如下所示:

module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          'console-loader',
          {
            loader: 'company-loader',
            options: {
              sign: 'we-doctor@2021',
            },
          },
        ],
      },
    ],
  },      

項目中的 index.js:

function fn() {
  console.log("this is a message")
  return "1234"
}      

執行編譯後的 bundle.js: 可以看到,兩個 loader 的功能都展現到了編譯後的檔案内。

/******/ (() { // webpackBootstrap
var __webpack_exports__ = {};
/*!**********************!*\
  !*** ./src/index.js ***!
  \**********************/
/** we-doctor@2021 */
function fn() {
  
  return "1234"
}
/******/ })()
;      

​二、Plugin​

為什麼要有 plugin​

plugin 提供了很多比 loader 中更完備的功能,他使用階段式的建構回調,webpack 給我們提供了非常多的 hooks 用來在建構的階段讓開發者自由的去引入自己的行為。

基本結構​

一個最基本的 plugin 需要包含這些部分:

  • 一個 JavaScript 類
  • 一個​

    ​apply​

    ​ 方法,​

    ​apply​

    ​ 方法在 webpack 裝載這個插件的時候被調用,并且會傳入 ​

    ​compiler​

    ​ 對象。
  • 使用不同的 hooks 來指定自己需要發生的處理行為
  • 在異步調用時最後需要調用 webpack 提供給我們的​

    ​callback​

    ​ 或者通過 ​

    ​Promise​

    ​ 的方式(後續異步編譯部分會詳細說)
class HelloPlugin{
  apply(compiler){
    compiler.hooks.<hookName>.tap(PluginName,(params)=>{
      /** do some thing */
    })
  }
}
module.exports = HelloPlugin      

Compiler and Compilation​

Compiler 和 Compilation 是整個編寫插件的過程中的**重!中!之!重!**因為我們幾乎所有的操作都會圍繞他們。

​compiler​

​ 對象可以了解為一個和 webpack 環境整體綁定的一個對象,它包含了所有的環境配置,包括 options,loader 和 plugin,當 webpack 啟動時,這個對象會被執行個體化,并且他是全局唯一的,上面我們說到的 ​

​apply​

​ 方法傳入的參數就是它。

​compilation​

​ 在每次建構資源的過程中都會被建立出來,一個 compilation 對象表現了目前的子產品資源、編譯生成資源、變化的檔案、以及被跟蹤依賴的狀态資訊。它同樣也提供了很多的 hook 。

Compiler 和 Compilation 提供了非常多的鈎子供我們使用,這些方法的組合可以讓我們在建構過程的不同時間擷取不同的内容,具體詳情可參見官網直達。

上面的連結中我們會發現鈎子會有不同的類型,比如 ​

​SyncHook​

​​、​

​SyncBailHook​

​​、​

​AsyncParallelHook​

​​、​

​AsyncSeriesHook​

​​ ,這些不同的鈎子類型都是由 ​

​tapable​

​​ 提供給我們的,關于 ​

​tapable​

​​ 的詳細用法與解析可以參考我們前端建構工具系列專欄中的 ​

​tapable​

​ 專題講解。

基本的使用方式是:

compiler/compilation.hooks.<hookName>.tap/tapAsync/tapPromise(pluginName,(xxx)=>{/**dosth*/})      
Tip: 以前的寫法是 ​

​compiler.plugin​

​ ,但是在最新的 webpack@5 可能會引起問題,參見 webpack-4-migration-notes

同步與異步​

plugin 的 hooks 是有同步和異步區分的,在同步的情況下,我們使用 ​

​<hookName>.tap​

​​ 的方式進行調用,而在異步 hook 内我們可以進行一些異步操作,并且有異步操作的情況下,請使用 ​

​tapAsync​

​​ 或者 ​

​tapPromise​

​​ 方法來告知 webpack 這裡的内容是異步的,當然,如果内部沒有異步操作的話,你也可以正常使用 ​

​tap​

​ 。

tapAsync

使用 ​

​tapAsync​

​​ 的時候,我們需要多傳入一個 ​

​callback​

​ 回調,并且在結束的時候一定要調用這個回調告知 webpack 這段異步操作結束了。👇 比如:

class HelloPlugin {
  apply(compiler) {
    compiler.hooks.emit.tapAsync(HelloPlugin, (compilation, callback) => {
      setTimeout(() {
        console.log('async')
        callback()
      }, 1000)
    })
  }
}
module.exports = HelloPlugin      

tapPromise

當使用 ​

​tapPromise​

​​ 來處理異步的時候,我們需要傳回一個 ​

​Promise​

​​ 對象并且讓它在結束的時候 ​

​resolve​

​ 👇

class HelloPlugin {
  apply(compiler) {
    compiler.hooks.emit.tapPromise(HelloPlugin, (compilation) => {
      return new Promise((resolve) => {
        setTimeout(() {
          console.log('async')
          resolve()
        }, 1000)
      })
    })
  }
}
module.exports = HelloPlugin      

做個實踐​

接下來我們通過實際來做一個插件梳理一遍整體的流程和零散的功能點,這個插件實作的功能是在打包後輸出的檔案夾内多增加一個 markdown 檔案,檔案内記錄打包的時間點、檔案以及檔案大小的輸出。

首先我們根據需求确定我們需要的 hook ,由于需要輸出檔案,我們需要使用 compilation 的 emitAsset 方法。 其次由于需要對 assets 進行處理,是以我們使用 ​

​compilation.hooks.processAssets​

​ ,因為 processAssets 是負責 asset 處理的鈎子。

這樣我們插件結構就出來了👇OutLogPlugin.js

class OutLogPlugin {
  constructor(options) {
    this.outFileName = options.outFileName
  }
  apply(compiler) {
    // 可以從編譯器對象通路 webpack 子產品執行個體
    // 并且可以保證 webpack 版本正确
    const { webpack } = compiler
    // 擷取 Compilation 後續會用到 Compilation 提供的 stage
    const { Compilation } = webpack
    const { RawSource } = webpack.sources
    /** compiler.hooks.<hoonkName>.tap/tapAsync/tapPromise */
    compiler.hooks.compilation.tap('OutLogPlugin', (compilation) => {
      compilation.hooks.processAssets.tap(
        {
          name: 'OutLogPlugin',
          // 選擇适當的 stage,具體參見:
          // https://webpack.js.org/api/compilation-hooks/#list-of-asset-processing-stages
          stage: Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE,
        },
        (assets) => {
          let resOutput = `buildTime: ${new Date().toLocaleString()}\n\n`
          resOutput += `| fileName  | fileSize  |\n| --------- | --------- |\n`
          Object.entries(assets).forEach(([pathname, source]) => {
            resOutput += `| ${pathname} | ${source.size()} bytes |\n`
          })
          compilation.emitAsset(
            `${this.outFileName}.md`,
            new RawSource(resOutput),
          )
        },
      )
    })
  }
}
module.exports = OutLogPlugin      

對插件進行配置:webpack.config.js

const OutLogPlugin = require('./plugins/OutLogPlugin')

module.exports = {
  plugins: [
    new OutLogPlugin({outFileName:"buildInfo"})
  ],
}      

打包後的目錄結構:

dist
├─ buildInfo.md
├─ bundle.js
└─ bundle.js.map      

buildInfo.md

【Webpack】999- 手把手教你寫一個 loader / plugin

可以看到按照我們希望的格式準确輸出了内容,這樣一個簡單的功能插件就完成了!

​參考文章​

  • Writing a Loader | webpack(https://webpack.js.org/contribute/writing-a-loader/)
  • Writing a Plugin | webpack(https://webpack.js.org/contribute/writing-a-plugin/)
  • 深入淺出 Webpack(https://webpack.wuhaolin.cn/)
  • webpack/webpack | github(https://github.com/webpack/webpack/blob/master/lib/Compiler.js)

本文完整代碼直通車:github(https://github.com/rodrick278/your-loader-and-plugin)