手把手教你擼一個 Webpack Loader

iKcamp發表於2018-01-25

文:小 boy(滬江網校Web前端工程師)

本文原創,轉載請註明作者及出處

webpack

經常逛 webpack 官網的同學應該會很眼熟上面的圖。正如它宣傳的一樣,webpack 能把左側各種型別的檔案(webpack 把它們叫作「模組」)統一打包為右邊被通用瀏覽器支援的檔案。webpack 就像是魔術師的帽子,放進去一條絲巾,變出來一隻白鴿。那這個「魔術」的過程是如何實現的呢?今天我們從 webpack 的核心概念之一 —— loader 來尋找答案,並著手實現這個「魔術」。看完本文,你可以:

  • 知道 webpack loader 的作用和原理。
  • 自己開發貼合業務需求的 loader。

什麼是 Loader ?

在擼一個 loader 前,我們需要先知道它到底是什麼。本質上來說,loader 就是一個 node 模組,這很符合 webpack 中「萬物皆模組」的思路。既然是 node 模組,那就一定會匯出點什麼。在 webpack 的定義中,loader 匯出一個函式,loader 會在轉換源模組(resource)的時候呼叫該函式。在這個函式內部,我們可以通過傳入 this 上下文給 Loader API 來使用它們。回顧一下頭圖左邊的那些模組,他們就是所謂的源模組,會被 loader 轉化為右邊的通用檔案,因此我們也可以概括一下 loader 的功能:把源模組轉換成通用模組。

Loader 怎麼用 ?

知道它的強大功能以後,我們要怎麼使用 loader 呢?

1. 配置 webpack config 檔案

既然 loader 是 webpack 模組,如果我們要使其生效,肯定離不開配置。我這裡收集了三種配置方法,任你挑選。

單個 loader 的配置

增加 config.module.rules 陣列中的規則物件(rule object)。

let webpackConfig = {
    //...
    module: {
        rules: [{
            test: /\.js$/,
            use: [{
                //這裡寫 loader 的路徑
                loader: path.resolve(__dirname, 'loaders/a-loader.js'), 
                options: {/* ... */}
            }]
        }]
    }
}
複製程式碼

多個 loader 的配置

增加 config.module.rules 陣列中的規則物件以及 config.resolveLoader

let webpackConfig = {
    //...
    module: {
        rules: [{
            test: /\.js$/,
            use: [{
                //這裡寫 loader 名即可
                loader: 'a-loader', 
                options: {/* ... */}
            }, {
                loader: 'b-loader', 
                options: {/* ... */}
            }]
        }]
    },
    resolveLoader: {
        // 告訴 webpack 該去那個目錄下找 loader 模組
        modules: ['node_modules', path.resolve(__dirname, 'loaders')]
    }
}
複製程式碼

其他配置

也可以通過 npm link 連線到你的專案裡,這個方式類似 node CLI 工具開發,非 loader 模組專用,本文就不多討論了。

2. 簡單上手

配置完成後,當你在 webpack 專案中引入模組時,匹配到 rule (例如上面的 /\.js$/)就會啟用對應的 loader (例如上面的 a-loader 和 b-loader)。這時,假設我們是 a-loader 的開發者,a-loader 會匯出一個函式,這個函式接受的唯一引數是一個包含原始檔內容的字串。我們暫且稱它為「source」。

接著我們在函式中處理 source 的轉化,最終返回處理好的值。當然返回值的數量和返回方式依據 a-loader 的需求來定。一般情況下可以通過 return 返回一個值,也就是轉化後的值。如果需要返回多個引數,則須呼叫 this.callback(err, values...) 來返回。在非同步 loader 中你可以通過拋錯來處理異常情況。Webpack 建議我們返回 1 至 2 個引數,第一個引數是轉化後的 source,可以是 string 或 buffer。第二個引數可選,是用來當作 SourceMap 的物件。

3. 進階使用

通常我們處理一類原始檔的時候,單一的 loader是不夠用的(loader 的設計原則我們稍後講到)。一般我們會將多個 loader 串聯使用,類似工廠流水線,一個位置的工人(或機器)只幹一種型別的活。既然是串聯,那肯定有順序的問題,webpack 規定 use 陣列中 loader 的執行順序是從最後一個到第一個,它們符合下面這些規則:

  • 順序最後的 loader 第一個被呼叫,它拿到的引數是 source 的內容
  • 順序第一的 loader 最後被呼叫, webpack 期望它返回 JS 程式碼,source map 如前面所說是可選的返回值。
  • 夾在中間的 loader 被鏈式呼叫,他們拿到上個 loader 的返回值,為下一個 loader 提供輸入。

我們舉個例子:

webpack.config.js

    {
        test: /\.js/,
        use: [
            'bar-loader',
            'mid-loader',
            'foo-loader'
        ]
    }
複製程式碼

在上面的配置中:

  • loader 的呼叫順序是 foo-loader -> mid-loader -> bar-loader。
  • foo-loader 拿到 source,處理後把 JS 程式碼傳遞給 mid,mid 拿到 foo 處理過的 “source” ,再處理之後給 bar,bar 處理完後再交給 webpack。
  • bar-loader 最終把返回值和 source map 傳給 webpack。

用正確的姿勢開發 Loader

瞭解了基本模式後,我們先不急著開發。所謂磨刀不誤砍柴工,我們先看看開發一個 loader 需要注意些什麼,這樣可以少走彎路,提高開發質量。下面是 webpack 提供的幾點指南,它們按重要程度排序,注意其中有些點只適用特定情況。

1.單一職責

一個 loader 只做一件事,這樣不僅可以讓 loader 的維護變得簡單,還能讓 loader 以不同的串聯方式組合出符合場景需求的搭配。

2.鏈式組合

這一點是第一點的延伸。好好利用 loader 的鏈式組合的特型,可以收穫意想不到的效果。具體來說,寫一個能一次幹 5 件事情的 loader ,不如細分成 5 個只能幹一件事情的 loader,也許其中幾個能用在其他你暫時還沒想到的場景。下面我們來舉個例子。

假設現在我們要實現通過 loader 的配置和 query 引數來渲染模版的功能。我們在 “apply-loader” 裡面實現這個功能,它負責編譯源模版,最終輸出一個匯出 HTML 字串的模組。根據鏈式組合的規則,我們可以結合另外兩個開源 loader:

  • jade-loader 把模版原始檔轉化為匯出一個函式的模組。
  • apply-loader 把 loader options 傳給上面的函式並執行,返回 HTML 文字。
  • html-loader 接收 HTMl 文字檔案,轉化為可被引用的 JS 模組。

事實上串聯組合中的 loader 並不一定要返回 JS 程式碼。只要下游的 loader 能有效處理上游 loader 的輸出,那麼上游的 loader 可以返回任意型別的模組。

3.模組化

保證 loader 是模組化的。loader 生成模組需要遵循和普通模組一樣的設計原則。

4.無狀態

在多次模組的轉化之間,我們不應該在 loader 中保留狀態。每個 loader 執行時應該確保與其他編譯好的模組保持獨立,同樣也應該與前幾個 loader 對相同模組的編譯結果保持獨立。

5.使用 Loader 實用工具

請好好利用 loader-utils 包,它提供了很多有用的工具,最常用的一個就是獲取傳入 loader 的 options。除了 loader-utils 之外包還有 schema-utils 包,我們可以用 schema-utils 提供的工具,獲取用於校驗 options 的 JSON Schema 常量,從而校驗 loader options。下面給出的例子簡要地結合了上面提到的兩個工具包:

import { getOptions } from 'loader-utils';
import { validateOptions } from 'schema-utils';

const schema = {
  type: object,
  properties: {
    test: {
      type: string
    }
  }
}

export default function(source) {
    const options = getOptions(this);

    validateOptions(schema, options, 'Example Loader');

    // 在這裡寫轉換 source 的邏輯 ...
    return `export default ${ JSON.stringify(source) }`;
};

複製程式碼

loader 的依賴

如果我們在 loader 中用到了外部資源(也就是從檔案系統中讀取的資源),我們必須宣告這些外部資源的資訊。這些資訊用於在監控模式(watch mode)下驗證可快取的 loder 以及重新編譯。下面這個例子簡要地說明了怎麼使用 addDependency 方法來做到上面說的事情。 loader.js:

import path from 'path';

export default function(source) {
    var callback = this.async();
    var headerPath = path.resolve('header.js');

    this.addDependency(headerPath);

    fs.readFile(headerPath, 'utf-8', function(err, header) {
        if(err) return callback(err);
        //這裡的 callback 相當於非同步版的 return
        callback(null, header + "\n" + source);
    });
};
複製程式碼

模組依賴

不同的模組會以不同的形式指定依賴。比如在 CSS 中我們使用 @importurl(...) 宣告來完成指定,而我們應該讓模組系統解析這些依賴。

如何讓模組系統解析不同宣告方式的依賴呢?下面有兩種方法:

  • 把不同的依賴宣告統一轉化為 require 宣告。
  • 通過 this.resolve 函式來解析路徑。

對於第一種方式,有一個很好的例子就是 css-loader。它把 @import 宣告轉化為 require 樣式表檔案,把 url(...) 宣告轉化為 require 被引用檔案。

而對於第二種方式,則需要參考一下 less-loader。由於要追蹤 less 中的變數和 mixin,我們需要把所有的 .less 檔案一次編譯完畢,所以不能把每個 @import 轉為 require。因此,less-loader 用自定義路徑解析邏輯擴充了 less 編譯器。這種方式運用了我們剛才提到的第二種方式 —— this.resolve 通過 webpack 來解析依賴。

如果某種語言只支援相對路徑(例如 url(file) 指向 ./file)。你可以用 ~ 將相對路徑指向某個已經安裝好的目錄(例如 node_modules)下,因此,拿 url 舉例,它看起來會變成這樣:url(~some-library/image.jpg)

程式碼公用

避免在多個 loader 裡面初始化同樣的程式碼,請把這些共用程式碼提取到一個執行時檔案裡,然後通過 require 把它引進每個 loader。

絕對路徑

不要在 loader 模組裡寫絕對路徑,因為當專案根路徑變了,這些路徑會干擾 webpack 計算 hash(把 module 的路徑轉化為 module 的引用 id)。loader-utils 裡有一個 stringifyRequest 方法,它可以把絕對路徑轉化為相對路徑。

同伴依賴

如果你開發的 loader 只是簡單包裝另外一個包,那麼你應該在 package.json 中將這個包設為同伴依賴(peerDependency)。這可以讓應用開發者知道該指定哪個具體的版本。 舉個例子,如下所示 sass-loadernode-sass 指定為同伴依賴:

"peerDependencies": {
  "node-sass": "^4.0.0"
}
複製程式碼

Talk is cheep

以上我們已經為砍柴磨好了刀,接下來,我們動手開發一個 loader。

如果我們要在專案開發中引用模版檔案,那麼壓縮 html 是十分常見的需求。分解以上需求,解析模版、壓縮模版其實可以拆分給兩給 loader 來做(單一職責),前者較為複雜,我們就引入開源包 html-loader,而後者,我們就拿來練手。首先,我們給它取個響亮的名字 —— html-minify-loader

接下來,按照之前介紹的步驟,首先,我們應該配置 webpack.config.js ,讓 webpack 能識別我們的 loader。當然,最最開始,我們要建立 loader 的 檔案 —— src/loaders/html-minify-loader.js

於是,我們在配置檔案中這樣處理: webpack.config.js

module: {
    rules: [{
        test: /\.html$/,
        use: ['html-loader', 'html-minify-loader'] // 處理順序 html-minify-loader => html-loader => webpack
    }]
},
resolveLoader: {
    // 因為 html-loader 是開源 npm 包,所以這裡要新增 'node_modules' 目錄
    modules: [path.join(__dirname, './src/loaders'), 'node_modules']
}
複製程式碼

接下來,我們提供示例 html 和 js 來測試 loader:

src/example.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<body>
    
</body>
</html>
複製程式碼

src/app.js

var html = require('./expamle.html');
console.log(html);
複製程式碼

好了,現在我們著手處理 src/loaders/html-minify-loader.js。前面我們說過,loader 也是一個 node 模組,它匯出一個函式,該函式的引數是 require 的源模組,處理 source 後把返回值交給下一個 loader。所以它的 “模版” 應該是這樣的:

module.exports = function (source) {
    // 處理 source ...
    return handledSource;
}
複製程式碼

module.exports = function (source) {
    // 處理 source ...
    this.callback(null, handledSource)
    return handledSource;
}
複製程式碼

注意:如果是處理順序排在最後一個的 loader,那麼它的返回值將最終交給 webpack 的 require,換句話說,它一定是一段可執行的 JS 指令碼 (用字串來儲存),更準確來說,是一個 node 模組的 JS 指令碼,我們來看下面的例子。

// 處理順序排在最後的 loader
module.exports = function (source) {
    // 這個 loader 的功能是把源模組轉化為字串交給 require 的呼叫方
    return 'module.exports = ' + JSON.stringify(source);
}
複製程式碼

整個過程相當於這個 loader 把原始檔

這裡是 source 模組
複製程式碼

轉化為

// example.js
module.exports = '這裡是 source 模組';
複製程式碼

然後交給 require 呼叫方:

// applySomeModule.js
var source = require('example.js'); 

console.log(source); // 這裡是 source 模組
複製程式碼

而我們本次串聯的兩個 loader 中,解析 html 、轉化為 JS 執行指令碼的任務已經交給 html-loader 了,我們來處理 html 壓縮問題。

作為普通 node 模組的 loader 可以輕而易舉地引用第三方庫。我們使用 minimize 這個庫來完成核心的壓縮功能:

// src/loaders/html-minify-loader.js

var Minimize = require('minimize');

module.exports = function(source) {
    var minimize = new Minimize();
    return minimize.parse(source);
};
複製程式碼

當然, minimize 庫支援一系列的壓縮引數,比如 comments 引數指定是否需要保留註釋。我們肯定不能在 loader 裡寫死這些配置。那麼 loader-utils 就該發揮作用了:

// src/loaders/html-minify-loader.js
var loaderUtils = require('loader-utils');
var Minimize = require('minimize');

module.exports = function(source) {
    var options = loaderUtils.getOptions(this) || {}; //這裡拿到 webpack.config.js 的 loader 配置
    var minimize = new Minimize(options);
    return minimize.parse(source);
};
複製程式碼

這樣,我們可以在 webpack.config.js 中設定壓縮後是否需要保留註釋:

    module: {
        rules: [{
            test: /\.html$/,
            use: ['html-loader', {
                loader: 'html-minify-loader',
                options: {
                    comments: false
                }
            }] 
        }]
    },
    resolveLoader: {
        // 因為 html-loader 是開源 npm 包,所以這裡要新增 'node_modules' 目錄
        modules: [path.join(__dirname, './src/loaders'), 'node_modules']
    }
複製程式碼

當然,你還可以把我們的 loader 寫成非同步的方式,這樣不會阻塞其他編譯進度:

var Minimize = require('minimize');
var loaderUtils = require('loader-utils');

module.exports = function(source) {
    var callback = this.async();
    if (this.cacheable) {
        this.cacheable();
    }
    var opts = loaderUtils.getOptions(this) || {};
    var minimize = new Minimize(opts);
    minimize.parse(source, callback);
};

複製程式碼

你可以在這個倉庫檢視相關程式碼,npm start 以後可以去 http://localhost:9000 開啟控制檯檢視 loader 處理後的內容。

總結

到這裡,對於「如何開發一個 loader」,我相信你已經有了自己的答案。總結一下,一個 loader 在我們專案中 work 需要經歷以下步驟:

  • 建立 loader 的目錄及模組檔案
  • 在 webpack 中配置 rule 及 loader 的解析路徑,並且要注意 loader 的順序,這樣在 require 指定型別檔案時,我們能讓處理流經過指定 laoder。
  • 遵循原則設計和開發 loader。

最後,Talk is cheep,趕緊動手擼一個 loader 耍耍吧~

參考

Writing a loader

手把手教你擼一個 Webpack Loader

手把手教你擼一個 Webpack Loader

推薦: 翻譯專案Master的自述:

1. 乾貨|人人都是翻譯專案的Master

2. iKcamp出品微信小程式教學共5章16小節彙總(含視訊)

3. 開始免費連載啦~每週2更共11堂iKcamp課|基於Koa2搭建Node.js實戰專案教學(含視訊)| 課程大綱介紹


手把手教你擼一個 Webpack Loader

2019年,iKcamp原創新書《Koa與Node.js開發實戰》已在京東、天貓、亞馬遜、噹噹開售啦!

相關文章