深入Webpack-編寫Loader

浩麟發表於2018-01-05

Loader 就像是一個翻譯員,能把原始檔經過轉化後輸出新的結果,並且一個檔案還可以鏈式的經過多個翻譯員翻譯。

以處理 SCSS 檔案為例:

  1. SCSS 原始碼會先交給 sass-loader 把 SCSS 轉換成 CSS;
  2. 把 sass-loader 輸出的 CSS 交給 css-loader 處理,找出 CSS 中依賴的資源、壓縮 CSS 等;
  3. 把 css-loader 輸出的 CSS 交給 style-loader 處理,轉換成通過指令碼載入的 JavaScript 程式碼;

可以看出以上的處理過程需要有順序的鏈式執行,先 sass-loader 再 css-loader 再 style-loader。 以上處理的 Webpack 相關配置如下:

module.exports = {
  module: {
    rules: [
      {
        // 增加對 SCSS 檔案的支援
        test: /\.scss/,
        // SCSS 檔案的處理順序為先 sass-loader 再 css-loader 再 style-loader
        use: [
          'style-loader',
          {
            loader:'css-loader',
            // 給 css-loader 傳入配置項
            options:{
              minimize:true, 
            }
          },
          'sass-loader'],
      },
    ]
  },
};
複製程式碼

Loader 的職責

由上面的例子可以看出:一個 Loader 的職責是單一的,只需要完成一種轉換。 如果一個原始檔需要經歷多步轉換才能正常使用,就通過多個 Loader 去轉換。 在呼叫多個 Loader 去轉換一個檔案時,每個 Loader 會鏈式的順序執行, 第一個 Loader 將會拿到需處理的原內容,上一個 Loader 處理後的結果會傳給下一個接著處理,最後的 Loader 將處理後的最終結果返回給 Webpack。

所以,在你開發一個 Loader 時,請保持其職責的單一性,你只需關心輸入和輸出。

Loader 基礎

由於 Webpack 是執行在 Node.js 之上的,一個 Loader 其實就是一個 Node.js 模組,這個模組需要匯出一個函式。 這個匯出的函式的工作就是獲得處理前的原內容,對原內容執行處理後,返回處理後的內容。

一個最簡單的 Loader 的原始碼如下:

module.exports = function(source) {
  // source 為 compiler 傳遞給 Loader 的一個檔案的原內容
  // 該函式需要返回處理後的內容,這裡簡單起見,直接把原內容返回了,相當於該 Loader 沒有做任何轉換
  return source;
};
複製程式碼

由於 Loader 執行在 Node.js 中,你可以呼叫任何 Node.js 自帶的 API,或者安裝第三方模組進行呼叫:

const sass = require('node-sass');
module.exports = function(source) {
  return sass(source);
};
複製程式碼

Loader 進階

以上只是個最簡單的 Loader,Webpack 還提供一些 API 供 Loader 呼叫,下面來一一介紹。

獲得 Loader 的 options

在最上面處理 SCSS 檔案的 Webpack 配置中,給 css-loader 傳了 options 引數,以控制 css-loader。 如何在自己編寫的 Loader 中獲取到使用者傳入的 options 呢?需要這樣做:

const loaderUtils = require('loader-utils');
module.exports = function(source) {
  // 獲取到使用者給當前 Loader 傳入的 options
  const options = loaderUtils.getOptions(this);
  return source;
};
複製程式碼

返回其它結果

上面的 Loader 都只是返回了原內容轉換後的內容,但有些場景下還需要返回除了內容之外的東西。

例如以用 babel-loader 轉換 ES6 程式碼為例,它還需要輸出轉換後的 ES5 程式碼對應的 Source Map,以方便除錯原始碼。 為了把 Source Map 也一起隨著 ES5 程式碼返回給 Webpack,可以這樣寫:

module.exports = function(source) {
  // 通過 this.callback 告訴 Webpack 返回的結果
  this.callback(null, source, sourceMaps);
  // 當你使用 this.callback 返回內容時,該 Loader 必須返回 undefined,
  // 以讓 Webpack 知道該 Loader 返回的結果在 this.callback 中,而不是 return 中 
  return;
};
複製程式碼

其中的 this.callback 是 Webpack 給 Loader 注入的 API,以方便 Loader 和 Webpack 之間通訊。 this.callback 的詳細使用方法如下:

this.callback(
    // 當無法轉換原內容時,給 Webpack 返回一個 Error
    err: Error | null,
    // 原內容轉換後的內容
    content: string | Buffer,
    // 用於把轉換後的內容得出原內容的 Source Map,方便除錯
    sourceMap?: SourceMap,
    // 如果本次轉換為原內容生成了 AST 語法樹,可以把這個 AST 返回,
    // 以方便之後需要 AST 的 Loader 複用該 AST,以避免重複生成 AST,提升效能
    abstractSyntaxTree?: AST
);
複製程式碼

Source Map 的生成很耗時,通常在開發環境下才會生成 Source Map,其它環境下不用生成,以加速構建。 為此 Webpack 為 Loader 提供了 this.sourceMap API 去告訴 Loader 當前構建環境下使用者是否需要 Source Map。 如果你編寫的 Loader 會生成 Source Map,請考慮到這點。

同步與非同步

Loader 有同步和非同步之分,上面介紹的 Loader 都是同步的 Loader,因為它們的轉換流程都是同步的,轉換完成後再返回結果。 但在有些場景下轉換的步驟只能是非同步完成的,例如你需要通過網路請求才能得出結果,如果採用同步的方式網路請求就會阻塞整個構建,導致構建非常緩慢。

在轉換步驟是非同步時,你可以這樣:

module.exports = function(source) {
    // 告訴 Webpack 本次轉換是非同步的,Loader 會在 callback 中回撥結果
    var callback = this.async();
    someAsyncOperation(source, function(err, result, sourceMaps, ast) {
        // 通過 callback 返回非同步執行後的結果
        callback(err, result, sourceMaps, ast);
    });
};
複製程式碼

處理二進位制資料

在預設的情況下,Webpack 傳給 Loader 的原內容都是 UTF-8 格式編碼的字串。 但有些場景下 Loader 不是處理文字檔案,而是處理二進位制檔案,例如 file-loader,就需要 Webpack 給 Loader 傳入二進位制格式的資料。 為此,你需要這樣編寫 Loader:

module.exports = function(source) {
    // 在 exports.raw === true 時,Webpack 傳給 Loader 的 source 是 Buffer 型別的
    source instanceof Buffer === true;
    // Loader 返回的型別也可以是 Buffer 型別的
    // 在 exports.raw !== true 時,Loader 也可以返回 Buffer 型別的結果
    return source;
};
// 通過 exports.raw 屬性告訴 Webpack 該 Loader 是否需要二進位制資料 
module.exports.raw = true;
複製程式碼

以上程式碼中最關鍵的程式碼是最後一行 module.exports.raw = true;,沒有該行 Loader 只能拿到字串。

快取加速

在有些情況下,有些轉換操作需要大量計算非常耗時,如果每次構建都重新執行重複的轉換操作,構建將會變得非常緩慢。 為此,Webpack 會預設快取所有 Loader 的處理結果,也就是說在需要被處理的檔案或者其依賴的檔案沒有發生變化時, 是不會重新呼叫對應的 Loader 去執行轉換操作的。

如果你想讓 Webpack 不快取該 Loader 的處理結果,可以這樣:

module.exports = function(source) {
  // 關閉該 Loader 的快取功能
  this.cacheable(false);
  return source;
};
複製程式碼

其它 Loader API

除了以上提到的在 Loader 中能呼叫的 Webpack API 外,還存在以下常用 API:

  • this.context:當前處理檔案的所在目錄,假如當前 Loader 處理的檔案是 /src/main.js,則 this.context 就等於 /src

  • this.resource:當前處理檔案的完整請求路徑,包括 querystring,例如 /src/main.js?name=1

  • this.resourcePath:當前處理檔案的路徑,例如 /src/main.js

  • this.resourceQuery:當前處理檔案的 querystring。

  • this.target:等於 Webpack 配置中的 Target,詳情見 2-7其它配置項-Target

  • this.loadModule:但 Loader 在處理一個檔案時,如果依賴其它檔案的處理結果才能得出當前檔案的結果時, 就可以通過 this.loadModule(request: string, callback: function(err, source, sourceMap, module)) 去獲得 request 對應檔案的處理結果。

  • this.resolve:像 require 語句一樣獲得指定檔案的完整路徑,使用方法為 resolve(context: string, request: string, callback: function(err, result: string))

  • this.addDependency:給當前處理檔案新增其依賴的檔案,以便再其依賴的檔案發生變化時,會重新呼叫 Loader 處理該檔案。使用方法為 addDependency(file: string)

  • this.addContextDependency:和 addDependency 類似,但 addContextDependency 是把整個目錄加入到當前正在處理檔案的依賴中。使用方法為 addContextDependency(directory: string)

  • this.clearDependencies:清除當前正在處理檔案的所有依賴,使用方法為 clearDependencies()

  • this.emitFile:輸出一個檔案,使用方法為 emitFile(name: string, content: Buffer|string, sourceMap: {...})

其它沒有提到的 API 可以去 Webpack 官網 檢視。

載入本地 Loader

在開發 Loader 的過程中,為了測試編寫的 Loader 是否能正常工作,需要把它配置到 Webpack 中後,才可能會呼叫該 Loader。 在前面的章節中,使用的 Loader 都是通過 Npm 安裝的,要使用 Loader 時會直接使用 Loader 的名稱,程式碼如下:

module.exports = {
  module: {
    rules: [
      {
        test: /\.css/,
        use: ['style-loader'],
      },
    ]
  },
};
複製程式碼

如果還採取以上的方法去使用本地開發的 Loader 將會很麻煩,因為你需要確保編寫的 Loader 的原始碼是在 node_modules 目錄下。 為此你需要先把編寫的 Loader 釋出到 Npm 倉庫後再安裝到本地專案使用。

解決以上問題的便捷方法有兩種,分別如下:

Npm link

Npm link 專門用於開發和除錯本地 Npm 模組,能做到在不釋出模組的情況下,把本地的一個正在開發的模組的原始碼連結到專案的 node_modules 目錄下,讓專案可以直接使用本地的 Npm 模組。 由於是通過軟連結的方式實現的,編輯了本地的 Npm 模組程式碼,在專案中也能使用到編輯後的程式碼。

完成 Npm link 的步驟如下:

  1. 確保正在開發的本地 Npm 模組(也就是正在開發的 Loader)的 package.json 已經正確配置好;
  2. 在本地 Npm 模組根目錄下執行 npm link,把本地模組註冊到全域性;
  3. 在專案根目錄下執行 npm link loader-name,把第2步註冊到全域性的本地 Npm 模組連結到專案的 node_moduels 下,其中的 loader-name 是指在第1步中的 package.json 檔案中配置的模組名稱。

連結好 Loader 到專案後你就可以像使用一個真正的 Npm 模組一樣使用本地的 Loader 了。

ResolveLoader

2-7其它配置項 中曾介紹過 ResolveLoader 用於配置 Webpack 如何尋找 Loader。 預設情況下只會去 node_modules 目錄下尋找,為了讓 Webpack 載入放在本地專案中的 Loader 需要修改 resolveLoader.modules

假如本地的 Loader 在專案目錄中的 ./loaders/loader-name 中,則需要如下配置:

module.exports = {
  resolveLoader:{
    // 去哪些目錄下尋找 Loader,有先後順序之分
    modules: ['node_modules','./loaders/'],
  }
}
複製程式碼

加上以上配置後, Webpack 會先去 node_modules 專案下尋找 Loader,如果找不到,會再去 ./loaders/ 目錄下尋找。

實戰

上面講了許多理論,接下來從實際出發,來編寫一個解決實際問題的 Loader。

該 Loader 名叫 comment-require-loader,作用是把 JavaScript 程式碼中的註釋語法

// @require '../style/index.css'
複製程式碼

轉換成

require('../style/index.css');
複製程式碼

該 Loader 的使用場景是去正確載入針對 Fis3 編寫的 JavaScript,這些 JavaScript 中存在通過註釋的方式載入依賴的 CSS 檔案。

該 Loader 的使用方法如下:

module.exports = {
  module: {
    loaders: [
      {
        test: /\.js$/,
        loaders: ['comment-require-loader'],
        // 針對採用了 fis3 CSS 匯入語法的 JavaScript 檔案通過 comment-require-loader 去轉換 
        include: [path.resolve(__dirname, 'node_modules/imui')]
      }
    ]
  }
};
複製程式碼

該 Loader 的實現非常簡單,完整程式碼如下:

function replace(source) {
    // 使用正則把 // @require '../style/index.css' 轉換成 require('../style/index.css');  
    return source.replace(/(\/\/ *@require) +(('|").+('|")).*/, 'require($2);');
}

module.exports = function (content) {
    return replace(content);
};
複製程式碼

本例項提供專案完整程式碼

深入Webpack-編寫Loader

《深入淺出Webpack》全書線上閱讀連結

閱讀原文

相關文章