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

掘金引流終版.gif

構建專欄系列目錄入口javascript

焦傳鍇,微醫前端技術部平臺支撐組。學習也要有呼有吸。css

1、Loader

1.1 loader 幹啥的?

webpack 只能理解 JavaScript 和 JSON 文件,這是 webpack 開箱可用的自帶能力。**loader **讓 webpack 可以去處理其餘類型的文件,並將它們轉換爲有效模塊,以供應用程序使用,以及被添加到依賴圖中。前端

也就是說,webpack 把任何文件都看作模塊,loader 能 import 任何類型的模塊,可是 webpack 原生不支持譬如 css 文件等的解析,這時候就須要用到咱們的 loader 機制了。 咱們的 loader 主要經過兩個屬性來讓咱們的 webpack 進行聯動識別:java

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

那麼問題來了,你們必定想知道本身要定製一個 loader 的話須要怎麼作呢?node

1.2 開發準則

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

  • 簡單易用
  • 使用鏈式傳遞。(因爲 loader 是能夠被鏈式調用的,因此請保證每個 loader 的單一職責)
  • 模塊化的輸出。
  • 確保無狀態。(不要讓 loader 的轉化中保留以前的狀態,每次運行都應該獨立於其餘編譯模塊以及相同模塊以前的編譯結果)
  • 充分使用官方提供的 loader utilities
  • 記錄 loader 的依賴。
  • 解析模塊依賴關係。

根據模塊類型,可能會有不一樣的模式指定依賴關係。例如在 CSS 中,使用@import 和 url(...)語句來聲明依賴。這些依賴關係應該由模塊系統解析。git

能夠經過如下兩種方式中的一種來實現:github

  • 經過把它們轉化成 require 語句。
  • 使用 this.resolve 函數解析路徑。
  • 提取通用代碼。
  • 避免絕對路徑。
  • 使用 peer dependencies。若是你的 loader 簡單包裹另一個包,你應該把這個包做爲一個 peerDependency 引入。

1.3 上手

一個 loader 就是一個 nodejs 模塊,他導出的是一個函數,這個函數只有一個入參,這個參數就是一個包含資源文件內容的字符串,而函數的返回值就是處理後的內容。也就是說,一個最簡單的 loader 長這樣:web

module.exports = function (content) {
	// content 就是傳入的源內容字符串
  return content
}
複製代碼

當一個 loader 被使用的時候,他只能夠接收一個入參,這個參數是一個包含包含資源文件內容的字符串。 是的,到這裏爲止,一個最簡單 loader 就已經完成了!接下來咱們來看看怎麼給他加上豐富的功能。api

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 語法樹
);
複製代碼

接下來舉個例子: image.png 這裏注意[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 方法中返回了值,那麼他會直接「往回走」,跳事後續的步驟,來舉個例子: 假設咱們如今是這樣:use: ['a-loader', 'b-loader', 'c-loader'], 那麼正常的調用順序是這樣: image.png 如今 b-loader 的 pitch 改成了有返回值:

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

module.exports.pitch = function (remainingRequest, precedingRequest, data) {
  return "誒,我直接返回,就是玩兒~"
};
複製代碼

那麼如今的調用就會變成這樣,直接「回頭」,跳過了原來的其餘三個步驟: image.png

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"
}
/******/ })()
;
複製代碼

2、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 提供了很是多的鉤子供咱們使用,這些方法的組合可讓咱們在構建過程的不一樣時間獲取不一樣的內容,具體詳情可參見官網直達

上面的連接中咱們會發現鉤子會有不一樣的類型,好比 SyncHookSyncBailHookAsyncParallelHookAsyncSeriesHook ,這些不一樣的鉤子類型都是由 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 image.png 能夠看到按照咱們但願的格式準確輸出了內容,這樣一個簡單的功能插件就完成了!

參考文章

Writing a Loader | webpack Writing a Plugin | webpack 深刻淺出 Webpack webpack/webpack | github

本文完整代碼直通車:github

未命名_自定義px_2021-06-21-0.gif

相關文章
相關標籤/搜索