Webpack 系列第六篇:如何編寫loader

全文 5000 字,深度剖析 Webpack Loader 的特性、運行機制、開發技巧,歡迎點贊關注。寫做不易,未經做者贊成,禁止任何形式轉載!!!

關於 Webpack Loader,網上已經有不少不少的資料,很難講出花來,可是要寫 Webpack 的系列博文又沒辦法繞開這一點,因此我閱讀了超過 20 個開源項目,儘可能全面地總結了一些編寫 Loader 時須要瞭解的知識和技巧。包含:javascript

那麼,咱們開始吧。php

認識 Loader

若是要作總結的話,我認爲 Loader 是一個帶有反作用的內容轉譯器!

Webpack Loader 最核心的只能是實現內容轉換器 —— 將各式各樣的資源轉化爲標準 JavaScript 內容格式,例如:css

  • css-loader 將 css 轉換爲 __WEBPACK_DEFAULT_EXPORT__ = ".a{ xxx }" 格式
  • html-loader 將 html 轉換爲 __WEBPACK_DEFAULT_EXPORT__ = "<!DOCTYPE xxx" 格式
  • vue-loader 更復雜一些,會將 .vue 文件轉化爲多個 JavaScript 函數,分別對應 template、js、css、custom block

那麼爲何須要作這種轉換呢?本質上是由於 Webpack 只認識符合 JavaScript 規範的文本(Webpack 5以後增長了其它 parser):在構建(make)階段,解析模塊內容時會調用 acorn 將文本轉換爲 AST 對象,進而分析代碼結構,分析模塊依賴;這一套邏輯對圖片、json、Vue SFC等場景就不 work 了,就須要 Loader 介入將資源轉化成 Webpack 能夠理解的內容形態。html

Plugin 是 Webpack 另外一套擴展機制,功能更強,可以在各個對象的鉤子中插入特化處理邏輯,它能夠覆蓋 Webpack 全生命流程,能力、靈活性、複雜度都會比 Loader 強不少。

Loader 基礎

代碼層面,Loader 一般是一個函數,結構以下:前端

module.exports = function(source, sourceMap?, data?) {
  // source 爲 loader 的輸入,多是文件內容,也多是上一個 loader 處理結果
  return source;
};

Loader 函數接收三個參數,分別爲:vue

  • source:資源輸入,對於第一個執行的 loader 爲資源文件的內容;後續執行的 loader 則爲前一個 loader 的執行結果
  • sourceMap: 可選參數,代碼的 sourcemap 結構
  • data: 可選參數,其它須要在 Loader 鏈中傳遞的信息,好比 posthtml/posthtml-loader 就會經過這個參數傳遞參數的 AST 對象

其中 source 是最重要的參數,大多數 Loader 要作的事情就是將 source 轉譯爲另外一種形式的 output ,好比 webpack-contrib/raw-loader 的核心源碼:java

//... 
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 模塊,例如:node

// source
I am Tecvan

// output
module.exports = "I am Tecvan"

通過模塊化包裝以後,這段文本內容轉身變成 Webpack 能夠處理的資源模塊,其它 module 也就能引用、使用它了。webpack

返回多個結果

上例經過 return 語句返回處理結果,除此以外 Loader 還能夠以 callback 方式返回更多信息,供下游 Loader 或者 Webpack 自己使用,例如在 webpack-contrib/eslint-loader 中:git

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) {},
};

其中,addDependencyemitFileemitErroremitWarning 都會對後續編譯流程產生反作用,例如 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 模塊的導出結果以 linkstyle 標籤等方式掛載到 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 中出現頻率極高:

這些工具的具體接口在相應的 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 提供的 interpolateNamefile-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;

代碼的核心邏輯:

  1. 根據 Loader 配置,調用 interpolateName 方法拼接目標文件的完整路徑
  2. 調用上下文 this.emitFile 接口,寫出文件
  3. 返回 module.exports = ${publicPath} ,其它模塊能夠引用到該文件路徑

除 file-loader 外,css-loader、eslint-loader 都有用到該接口,感興趣的同窗請自行前往查閱源碼。

單元測試

在 Loader 中編寫單元測試收益很是高,一方面對開發者來講不用去怎麼寫 demo,怎麼搭建測試環境;一方面對於最終用戶來講,帶有必定測試覆蓋率的項目一般意味着更高、更穩定的質量。

閱讀了超過 20 個開源項目後,我總結了一套 Webpack Loader 場景下經常使用的單元測試流程,以 Jest · 🃏 Delightful JavaScript Testing 爲例:

  1. 建立在 Webpack 實例,並運行 Loader
  2. 獲取 Loader 執行結果,比對、分析判斷是否符合預期
  3. 判斷執行過程當中是否出錯

如何運行 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 原理分析系列第七篇文章,說實話最開始並無想到能寫這麼多,後續還會繼續 focus 在這個前端工程化領域,個人目標是能攢成一本本身的書,感興趣的同窗歡迎點贊關注,若是以爲有什麼地方遺漏、疑惑,歡迎評論討論。

往期文章

相關文章
相關標籤/搜索