手把手教你擼一個 Webpack Loader

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

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

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 的功能:把源模塊轉換成通用模塊。node

Loader 怎麼用 ?

知道它的強大功能之後,咱們要怎麼使用 loader 呢?webpack

1. 配置 webpack config 文件

既然 loader 是 webpack 模塊,若是咱們要使其生效,確定離不開配置。我這裏收集了三種配置方法,任你挑選。git

單個 loader 的配置

增長 config.module.rules 數組中的規則對象(rule object)。github

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

多個 loader 的配置

增長 config.module.rules 數組中的規則對象以及 config.resolveLoaderweb

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 模塊專用,本文就很少討論了。npm

2. 簡單上手

配置完成後,當你在 webpack 項目中引入模塊時,匹配到 rule (例如上面的 /\.js$/)就會啓用對應的 loader (例如上面的 a-loader 和 b-loader)。這時,假設咱們是 a-loader 的開發者,a-loader 會導出一個函數,這個函數接受的惟一參數是一個包含源文件內容的字符串。咱們暫且稱它爲「source」。json

接着咱們在函數中處理 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

推薦: 翻譯項目Master的自述:

1. 乾貨|人人都是翻譯項目的Master

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

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


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

相關文章
相關標籤/搜索