手把手教你擼一個 Webpack Loader
文:小 boy(滬江網校Web前端工程師)
本文原創,轉載請註明作者及出處
經常逛 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 = {n //...n module: {n rules: [{n test: /.js$/,n use: [{n //這裡寫 loader 的路徑n loader: path.resolve(__dirname, loaders/a-loader.js), n options: {/* ... */}n }]n }]n }n}n
多個 loader 的配置
增加 config.module.rules
數組中的規則對象以及 config.resolveLoader
。
let webpackConfig = {n //...n module: {n rules: [{n test: /.js$/,n use: [{n //這裡寫 loader 名即可n loader: a-loader, n options: {/* ... */}n }, {n loader: b-loader, n options: {/* ... */}n }]n }]n },n resolveLoader: {n // 告訴 webpack 該去那個目錄下找 loader 模塊n modules: [node_modules, path.resolve(__dirname, loaders)]n }n}n
其他配置
也可以通過 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
{n test: /.js/,n use: [n bar-loader,n mid-loader,n foo-loadern ]n }n
在上面的配置中:
- 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;nimport { validateOptions } from schema-utils;nnconst schema = {n type: object,n properties: {n test: {n type: stringn }n }n}nnexport default function(source) {n const options = getOptions(this);nn validateOptions(schema, options, Example Loader);nn // 在這裡寫轉換 source 的邏輯 ...n return `export default ${ JSON.stringify(source) }`;n};n
loader 的依賴
如果我們在 loader 中用到了外部資源(也就是從文件系統中讀取的資源),我們必須聲明這些外部資源的信息。這些信息用於在監控模式(watch mode)下驗證可緩存的 loder 以及重新編譯。下面這個例子簡要地說明了怎麼使用 addDependency
方法來做到上面說的事情。
import path from path;nnexport default function(source) {n var callback = this.async();n var headerPath = path.resolve(header.js);nn this.addDependency(headerPath);nn fs.readFile(headerPath, utf-8, function(err, header) {n if(err) return callback(err);n //這裡的 callback 相當於非同步版的 returnn callback(null, header + "n" + source);n });n};n
模塊依賴
不同的模塊會以不同的形式指定依賴。比如在 CSS 中我們使用 @import
和 url(...)
聲明來完成指定,而我們應該讓模塊系統解析這些依賴。
如何讓模塊系統解析不同聲明方式的依賴呢?下面有兩種方法:
- 把不同的依賴聲明統一轉化為
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-loader
將 node-sass
指定為同伴依賴:"peerDependencies": {n "node-sass": "^4.0.0"n}n
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: {n rules: [{n test: /.html$/,n use: [html-loader, html-minify-loader] // 處理順序 html-minify-loader => html-loader => webpackn }]n},nresolveLoader: {n // 因為 html-loader 是開源 npm 包,所以這裡要添加 node_modules 目錄n modules: [path.join(__dirname, ./src/loaders), node_modules]n}n
接下來,我們提供示例 html 和 js 來測試 loader:
src/example.html
:
<!DOCTYPE html>n<html lang="en">n<head>n <meta charset="UTF-8">n <meta name="viewport" content="width_=device-width, initial-scale=1.0">n <meta http-equiv="X-UA-Compatible" content="ie=edge">n <title>Document</title>n</head>n<body>n n</body>n</html>n
src/app.js
:
var html = require(./expamle.html);nconsole.log(html);n
好了,現在我們著手處理 src/loaders/html-minify-loader.js
。前面我們說過,loader 也是一個 node 模塊,它導出一個函數,該函數的參數是 require 的源模塊,處理 source 後把返回值交給下一個 loader。所以它的 「模版」 應該是這樣的:
module.exports = function (source) {n // 處理 source ...n return handledSource;n}n
或
module.exports = function (source) {n // 處理 source ...n this.callback(null, handledSource)n return handledSource;n}n
注意:如果是處理順序排在最後一個的 loader,那麼它的返回值將最終交給 webpack 的
require
,換句話說,它一定是一段可執行的 JS 腳本 (用字元串來存儲),更準確來說,是一個 node 模塊的 JS 腳本,我們來看下面的例子。
// 處理順序排在最後的 loadernmodule.exports = function (source) {n // 這個 loader 的功能是把源模塊轉化為字元串交給 require 的調用方n return module.exports = + JSON.stringify(source);n}n
整個過程相當於這個 loader 把源文件
這裡是 source 模塊n
轉化為
// example.jsnmodule.exports = 這裡是 source 模塊;n
然後交給 require 調用方:
// applySomeModule.jsnvar source = require(example.js); nnconsole.log(source); // 這裡是 source 模塊n
而我們本次串聯的兩個 loader 中,解析 html 、轉化為 JS 執行腳本的任務已經交給 html-loader
了,我們來處理 html 壓縮問題。
作為普通 node 模塊的 loader 可以輕而易舉地引用第三方庫。我們使用 minimize
這個庫來完成核心的壓縮功能:
// src/loaders/html-minify-loader.jsnnvar Minimize = require(minimize);nnmodule.exports = function(source) {n var minimize = new Minimize();n return minimize.parse(source);n};n
當然, minimize 庫支持一系列的壓縮參數,比如 comments 參數指定是否需要保留注釋。我們肯定不能在 loader 里寫死這些配置。那麼 loader-utils
就該發揮作用了:
// src/loaders/html-minify-loader.jsnvar loaderUtils = require(loader-utils);nvar Minimize = require(minimize);nnmodule.exports = function(source) {n var options = loaderUtils.getOptions(this) || {}; //這裡拿到 webpack.config.js 的 loader 配置n var minimize = new Minimize(options);n return minimize.parse(source);n};n
這樣,我們可以在 webpack.config.js 中設置壓縮後是否需要保留注釋:
module: {n rules: [{n test: /.html$/,n use: [html-loader, {n loader: html-minify-loader,n options: {n comments: falsen }n }] n }]n },n resolveLoader: {n // 因為 html-loader 是開源 npm 包,所以這裡要添加 node_modules 目錄n modules: [path.join(__dirname, ./src/loaders), node_modules]n }n
當然,你還可以把我們的 loader 寫成非同步的方式,這樣不會阻塞其他編譯進度:
var Minimize = require(minimize);nvar loaderUtils = require(loader-utils);nnmodule.exports = function(source) {n var callback = this.async();n if (this.cacheable) {n this.cacheable();n }n var opts = loaderUtils.getOptions(this) || {};n var minimize = new Minimize(opts);n minimize.parse(source, callback);n};n
你可以在這個倉庫查看相關代碼,npm start
以後可以去 http://localhost:9000
打開控制台查看 loader 處理後的內容。
總結
到這裡,對於「如何開發一個 loader」,我相信你已經有了自己的答案。總結一下,一個 loader 在我們項目中 work 需要經歷以下步驟:
- 創建 loader 的目錄及模塊文件
- 在 webpack 中配置 rule 及 loader 的解析路徑,並且要注意 loader 的順序,這樣在
require
指定類型文件時,我們能讓處理流經過指定 laoder。 - 遵循原則設計和開發 loader。
最後,Talk is cheep,趕緊動手擼一個 loader 耍耍吧~
參考
Writing a loader
http://weixin.qq.com/r/MjoLExLEsU-OrVZw928g (二維碼自動識別)
推薦: 翻譯項目Master的自述:
1. 乾貨|人人都是翻譯項目的Master
2. iKcamp出品微信小程序教學共5章16小節匯總(含視頻)
3. 開始免費連載啦~每周2更共11堂iKcamp課|基於Koa2搭建Node.js實戰項目教學(含視頻)| 課程大綱介紹
推薦閱讀:
※人與智能的塑造,AI如何賦能教育?
※滬江網校,說好的退款說變就變?
※滬江開心詞場里的「pk一下」功能中人並非真實匹配,產品經理是怎麼考慮的?
※嚯~滬江請30000名老師給全國1000所學校50萬學生上課