Webpack 是一個子產品化打包工具,它被廣泛地應用在前端領域的大多數項目中。利用 Webpack 我們不僅可以打包 JS 檔案,還可以打包圖檔、CSS、字型等其他類型的資源檔案。而支援打包非 JS 檔案的特性是基于 Loader 機制來實作的。是以要學好 Webpack,我們就需要掌握 Loader 機制。本文阿寶哥将帶大家一起深入學習 Webpack 的 Loader 機制,閱讀完本文你将了解以下内容:
- Loader 的本質是什麼?
- Normal Loader 和 Pitching Loader 是什麼?
- Pitching Loader 的作用是什麼?
- Loader 是如何被加載的?
- Loader 是如何被運作的?
- 多個 Loader 的執行順序是什麼?
- Pitching Loader 的熔斷機制是如何實作的?
- Normal Loader 函數是如何被運作的?
- Loader 對象上
屬性有什麼作用?raw
- Loader 函數體中的
和 this.callback
方法是哪裡來的?this.async
- Loader 最終的傳回結果是如何被處理的?
一、Loader 的本質是什麼?
由上圖可知,Loader 本質上是導出函數的 JavaScript 子產品。所導出的函數,可用于實作内容轉換,該函數支援以下 3 個參數:
/**
* @param {string|Buffer} content 源檔案的内容
* @param {object} [map] 可以被 https://github.com/mozilla/source-map 使用的 SourceMap 資料
* @param {any} [meta] meta 資料,可以是任何内容
*/
function webpackLoader(content, map, meta) {
// 你的webpack loader代碼
}
module.exports = webpackLoader;
了解完導出函數的簽名之後,我們就可以定義一個簡單的
simpleLoader
:
function simpleLoader(content, map, meta) {
console.log("我是 SimpleLoader");
return content;
}
module.exports = simpleLoader;
以上的
simpleLoader
并不會對輸入的内容進行任何處理,隻是在該 Loader 執行時輸出相應的資訊。Webpack 允許使用者為某些資源檔案配置多個不同的 Loader,比如在處理
.css
檔案的時候,我們用到了
style-loader
和
css-loader
,具體配置方式如下所示:
webpack.config.js
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: /\.css$/i,
use: ['style-loader', 'css-loader'],
},
],
},
};
Webpack 這樣設計的好處,是可以保證每個 Loader 的職責單一。同時,也友善後期 Loader 的組合和擴充。比如,你想讓 Webpack 能夠處理 Scss 檔案,你隻需先安裝
sass-loader
,然後在配置 Scss 檔案的處理規則時,設定 rule 對象的
use
屬性為
['style-loader', 'css-loader', 'sass-loader']
即可。
二、Normal Loader 和 Pitching Loader 是什麼?
2.1 Normal Loader
Loader 本質上是導出函數的 JavaScript 子產品,而該子產品導出的函數(若是 ES6 子產品,則是預設導出的函數)就被稱為 Normal Loader。需要注意的是,這裡我們介紹的 Normal Loader 與 Webpack Loader 分類中定義的 Loader 是不一樣的。在 Webpack 中,loader 可以被分為 4 類:pre 前置、post 後置、normal 普通和 inline 行内。其中 pre 和 post loader,可以通過
rule
對象的
enforce
屬性來指定:
// webpack.config.js
const path = require("path");
module.exports = {
module: {
rules: [
{
test: /\.txt$/i,
use: ["a-loader"],
enforce: "post", // post loader
},
{
test: /\.txt$/i,
use: ["b-loader"], // normal loader
},
{
test: /\.txt$/i,
use: ["c-loader"],
enforce: "pre", // pre loader
},
],
},
};
了解完 Normal Loader 的概念之後,我們來動手寫一下 Normal Loader。首先我們先來建立一個新的目錄:
$ mkdir webpack-loader-demo
然後進入該目錄,使用
npm init -y
指令執行初始化操作。該指令成功執行後,會在目前目錄生成一個
package.json
檔案:
{
"name": "webpack-loader-demo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
提示:本地所使用的開發環境:Node v12.16.2;Npm 6.14.4;
接着我們使用以下指令,安裝一下
webpack
和
webpack-cli
依賴包:
$ npm i webpack webpack-cli -D
安裝完項目依賴後,我們根據以下目錄結構來添加對應的目錄和檔案:
├── dist # 打包輸出目錄
│ └── index.html
├── loaders # loaders檔案夾
│ ├── a-loader.js
│ ├── b-loader.js
│ └── c-loader.js
├── node_modules
├── package-lock.json
├── package.json
├── src # 源碼目錄
│ ├── data.txt # 資料檔案
│ └── index.js # 入口檔案
└── webpack.config.js # webpack配置檔案
dist/index.html
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Webpack Loader 示例</title>
</head>
<body>
<h3>Webpack Loader 示例</h3>
<p id="message"></p>
<script src="./bundle.js"></script>
</body>
</html>
src/index.js
import Data from "./data.txt"
const msgElement = document.querySelector("#message");
msgElement.innerText = Data;
src/data.txt
大家好,我是阿寶哥
loaders/a-loader.js
function aLoader(content, map, meta) {
console.log("開始執行aLoader Normal Loader");
content += "aLoader]";
return `module.exports = '${content}'`;
}
module.exports = aLoader;
在
aLoader
函數中,我們會對
content
内容進行修改,然後傳回
module.exports = '${content}'
字元串。那麼為什麼要把
content
指派給
module.exports
屬性呢?這裡我們先不解釋具體的原因,後面我們再來分析這個問題。
loaders/b-loader.js
function bLoader(content, map, meta) {
console.log("開始執行bLoader Normal Loader");
return content + "bLoader->";
}
module.exports = bLoader;
loaders/c-loader.js
function cLoader(content, map, meta) {
console.log("開始執行cLoader Normal Loader");
return content + "[cLoader->";
}
module.exports = cLoader;
在 loaders 目錄下,我們定義了以上 3 個 Normal Loader。這些 Loader 的實作都比較簡單,隻是在 Loader 執行時往
content
參數上添加目前 Loader 的相關資訊。為了讓 Webpack 能夠識别 loaders 目錄下的自定義 Loader,我們還需要在 Webpack 的配置檔案中,設定
resolveLoader
屬性,具體的配置方式如下所示:
webpack.config.js
const path = require("path");
module.exports = {
entry: "./src/index.js",
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "dist"),
},
mode: "development",
module: {
rules: [
{
test: /\.txt$/i,
use: ["a-loader", "b-loader", "c-loader"],
},
],
},
resolveLoader: {
modules: [
path.resolve(__dirname, "node_modules"),
path.resolve(__dirname, "loaders"),
],
},
};
當目錄更新完成後,在 webpack-loader-demo 項目的根目錄下運作
npx webpack
指令就可以開始打包了。以下内容是阿寶哥運作
npx webpack
指令之後,控制台的輸出結果:
開始執行cLoader Normal Loader
開始執行bLoader Normal Loader
開始執行aLoader Normal Loader
asset bundle.js 4.55 KiB [emitted] (name: main)
runtime modules 937 bytes 4 modules
cacheable modules 187 bytes
./src/index.js 114 bytes [built] [code generated]
./src/data.txt 73 bytes [built] [code generated]
webpack 5.45.1 compiled successfully in 99 ms
通過觀察以上的輸出結果,我們可以知道 Normal Loader 的執行順序是從右到左。此外,當打包完成後,我們在浏覽器中打開 dist/index.html 檔案,在頁面上你将看到以下資訊:
Webpack Loader 示例
大家好,我是阿寶哥[cLoader->bLoader->aLoader]
由頁面上的輸出資訊 ”大家好,我是阿寶哥[cLoader->bLoader->aLoader]“ 可知,Loader 在執行的過程中是以管道的形式,對資料進行處理,具體處理過程如下圖所示:
現在你已經知道什麼是 Normal Loader 及 Normal Loader 的執行順序,接下來我們來介紹另一種 Loader —— Pitching Loader。
2.2 Pitching Loader
在開發 Loader 時,我們可以在導出的函數上添加一個
pitch
屬性,它的值也是一個函數。該函數被稱為 Pitching Loader,它支援 3 個參數:
/**
* @remainingRequest 剩餘請求
* @precedingRequest 前置請求
* @data 資料對象
*/
function (remainingRequest, precedingRequest, data) {
// some code
};
其中
data
參數,可以用于資料傳遞。即在
pitch
函數中往
data
對象上添加資料,之後在
normal
函數中通過
this.data
的方式讀取已添加的資料。而
remainingRequest
和
precedingRequest
參數到底是什麼?這裡我們先來更新一下
a-loader.js
檔案:
function aLoader(content, map, meta) {
// 省略部分代碼
}
aLoader.pitch = function (remainingRequest, precedingRequest, data) {
console.log("開始執行aLoader Pitching Loader");
console.log(remainingRequest, precedingRequest, data)
};
module.exports = aLoader;
在以上代碼中,我們為 aLoader 函數增加了一個
pitch
屬性并設定它的值為一個函數對象。在函數體中,我們輸出了該函數所接收的參數。接着,我們以同樣的方式更新
b-loader.js
和
c-loader.js
檔案:
b-loader.js
function bLoader(content, map, meta) {
// 省略部分代碼
}
bLoader.pitch = function (remainingRequest, precedingRequest, data) {
console.log("開始執行bLoader Pitching Loader");
console.log(remainingRequest, precedingRequest, data);
};
module.exports = bLoader;
c-loader.js
function cLoader(content, map, meta) {
// 省略部分代碼
}
cLoader.pitch = function (remainingRequest, precedingRequest, data) {
console.log("開始執行cLoader Pitching Loader");
console.log(remainingRequest, precedingRequest, data);
};
module.exports = cLoader;
當所有檔案都更新完成後,我們在 webpack-loader-demo 項目的根目錄再次執行
npx webpack
指令後,就會輸出相應的資訊。這裡我們以
b-loader.js
的
pitch
函數的輸出結果為例,來分析一下
remainingRequest
和
precedingRequest
參數的輸出結果:
/Users/fer/webpack-loader-demo/loaders/c-loader.js!/Users/fer/webpack-loader-demo/src/data.txt #剩餘請求
/Users/fer/webpack-loader-demo/loaders/a-loader.js #前置請求
{} #空的資料對象
除了以上的輸出資訊之外,我們還可以很清楚的看到 Pitching Loader 和 Normal Loader 的執行順序:
開始執行aLoader Pitching Loader
...
開始執行bLoader Pitching Loader
...
開始執行cLoader Pitching Loader
...
開始執行cLoader Normal Loader
開始執行bLoader Normal Loader
開始執行aLoader Normal Loader
很明顯對于我們的示例來說,Pitching Loader 的執行順序是 從左到右,而 Normal Loader 的執行順序是 從右到左。具體的執行過程如下圖所示:
提示:Webpack 内部會使用 loader-runner 這個庫來運作已配置的 loaders。
看到這裡有的小夥伴可能會有疑問,Pitching Loader 除了可以提前運作之外,還有什麼作用呢?其實當某個 Pitching Loader 傳回非
undefined
值時,就會實作熔斷效果。這裡我們更新一下
bLoader.pitch
方法,讓它傳回
"bLoader Pitching Loader->"
字元串:
bLoader.pitch = function (remainingRequest, precedingRequest, data) {
console.log("開始執行bLoader Pitching Loader");
return "bLoader Pitching Loader->";
};
當更新完
bLoader.pitch
方法,我們再次執行
npx webpack
指令之後,控制台會輸出以下内容:
開始執行aLoader Pitching Loader
開始執行bLoader Pitching Loader
開始執行aLoader Normal Loader
asset bundle.js 4.53 KiB [compared for emit] (name: main)
runtime modules 937 bytes 4 modules
...
由以上輸出結果可知,當
bLoader.pitch
方法傳回非
undefined
值時,跳過了剩下的 loader。具體執行流程如下圖所示:
提示:Webpack 内部會使用 loader-runner 這個庫來運作已配置的 loaders。
之後,我們在浏覽器中再次打開 dist/index.html 檔案。此時,在頁面上你将看到以下資訊:
Webpack Loader 示例
bLoader Pitching Loader->aLoader]
介紹完 Normal Loader 和 Pitching Loader 的相關知識,接下來我們來分析一下 Loader 是如何被運作的。
三、Loader 是如何被運作的?
要搞清楚 Loader 是如何被運作的,我們可以借助斷點調試工具來找出 Loader 的運作入口。這裡我們以大家熟悉的 Visual Studio Code 為例,來介紹如何配置斷點調試環境:
當你按照上述步驟操作之後,在目前項目(webpack-loader-demo)下,會自動建立 .vscode 目錄并在該目錄下自動生成一個 launch.json 檔案。接着,我們複制以下内容直接替換 launch.json 中的原始内容。
{
"version": "0.2.0",
"configurations": [{
"type": "node",
"request": "launch",
"name": "Webpack Debug",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "debug"],
"port": 5858
}]
}
利用以上配置資訊,我們建立了一個 Webpack Debug 的調試任務。當運作該任務的時候,會在目前工作目錄下執行
npm run debug
指令。是以,接下來我們需要在 package.json 檔案中增加 debug 指令,具體内容如下所示:
// package.json
{
"scripts": {
"debug": "node --inspect=5858 ./node_modules/.bin/webpack"
},
}
做好上述的準備之後,我們就可以在 a-loader 的
pitch
函數中添加一個斷點。對應的調用堆棧如下所示:
通過觀察以上的調用堆棧資訊,我們可以看到調用
runLoaders
方法,該方法是來自于 loader-runner 子產品。是以要搞清楚 Loader 是如何被運作的,我們就需要分析
runLoaders
方法。下面我們來開始分析項目中使用的 loader-runner 子產品,它的版本是 4.2.0。其中
runLoaders
方法被定義在
lib/LoaderRunner.js
檔案中:
// loader-runner/lib/LoaderRunner.js
exports.runLoaders = function runLoaders(options, callback) {
// read options
var resource = options.resource || "";
var loaders = options.loaders || [];
var loaderContext = options.context || {}; // Loader上下文對象
var processResource = options.processResource || ((readResource, context,
resource, callback) => {
context.addDependency(resource);
readResource(resource, callback);
}).bind(null, options.readResource || readFile);
// prepare loader objects
loaders = loaders.map(createLoaderObject);
loaderContext.context = contextDirectory;
loaderContext.loaderIndex = 0;
loaderContext.loaders = loaders;
// 省略大部分代碼
var processOptions = {
resourceBuffer: null,
processResource: processResource
};
// 疊代PitchingLoaders
iteratePitchingLoaders(processOptions, loaderContext, function(err, result) {
// ...
});
};
由以上代碼可知,在
runLoaders
函數中,會先從
options
配置對象上擷取
loaders
資訊,然後調用
createLoaderObject
函數建立 Loader 對象,調用該方法後會傳回包含
normal
、
pitch
、
raw
和
data
等屬性的對象。目前該對象的大多數屬性值都為
null
,在後續的處理流程中,就會填充相應的屬性值。
// loader-runner/lib/LoaderRunner.js
function createLoaderObject(loader) {
var obj = {
path: null,
query: null,
fragment: null,
options: null,
ident: null,
normal: null,
pitch: null,
raw: null,
data: null,
pitchExecuted: false,
normalExecuted: false
};
// 省略部分代碼
obj.request = loader;
if(Object.preventExtensions) {
Object.preventExtensions(obj);
}
return obj;
}
在建立完 Loader 對象及初始化 loaderContext 對象之後,就會調用
iteratePitchingLoaders
函數開始疊代 Pitching Loader。為了讓大家對後續的處理流程有一個大緻的了解,在看具體代碼前,我們再來回顧一下前面運作 txt loaders 的調用堆棧:
與之對應
runLoaders
函數的
options
對象結構如下所示:
基于上述的調用堆棧和相關的源碼,阿寶哥也畫了一張相應的流程圖:
看完上面的流程圖和調用堆棧圖,接下來我們來分析一下流程圖中相關函數的核心代碼。這裡我們先來分析
iteratePitchingLoaders
:
// loader-runner/lib/LoaderRunner.js
function iteratePitchingLoaders(options, loaderContext, callback) {
// abort after last loader
if(loaderContext.loaderIndex >= loaderContext.loaders.length)
// 在processResource函數内,會調用iterateNormalLoaders函數
// 開始執行normal loader
return processResource(options, loaderContext, callback);
// 首次執行時,loaderContext.loaderIndex的值為0
var currentLoaderObject = loaderContext.loaders[loaderContext.loaderIndex];
// 如果目前loader對象的pitch函數已經被執行過了,則執行下一個loader的pitch函數
if(currentLoaderObject.pitchExecuted) {
loaderContext.loaderIndex++;
return iteratePitchingLoaders(options, loaderContext, callback);
}
// 加載loader子產品
loadLoader(currentLoaderObject, function(err) {
if(err) {
loaderContext.cacheable(false);
return callback(err);
}
// 擷取目前loader對象上的pitch函數
var fn = currentLoaderObject.pitch;
// 辨別loader對象已經被iteratePitchingLoaders函數處理過
currentLoaderObject.pitchExecuted = true;
if(!fn) return iteratePitchingLoaders(options, loaderContext, callback);
// 開始執行pitch函數
runSyncOrAsync(fn,loaderContext, ...);
// 省略部分代碼
});
}
在
iteratePitchingLoaders
函數内部,會從最左邊的 loader 對象開始處理,然後調用
loadLoader
函數開始加載 loader 子產品。在
loadLoader
函數内部,會根據
loader
的類型,使用不同的加載方式。對于我們目前的項目來說,會通過
require(loader.path)
的方式來加載 loader 子產品。具體的代碼如下所示:
// loader-runner/lib/loadLoader.js
module.exports = function loadLoader(loader, callback) {
if(loader.type === "module") {
try {
if(url === undefined) url = require("url");
var loaderUrl = url.pathToFileURL(loader.path);
var modulePromise = eval("import(" + JSON.stringify(loaderUrl.toString()) + ")");
modulePromise.then(function(module) {
handleResult(loader, module, callback);
}, callback);
return;
} catch(e) {
callback(e);
}
} else {
try {
var module = require(loader.path);
} catch(e) {
// 省略相關代碼
}
// 處理已加載的子產品
return handleResult(loader, module, callback);
}
};
不管使用哪種加載方式,在成功加載
loader
子產品之後,都會調用
handleResult
函數來處理已加載的子產品。該函數的作用是,擷取子產品中的導出函數及該函數上
pitch
和
raw
屬性的值并指派給對應
loader
對象的相應屬性:
// loader-runner/lib/loadLoader.js
function handleResult(loader, module, callback) {
if(typeof module !== "function" && typeof module !== "object") {
return callback(new LoaderLoadingError(
"Module '" + loader.path + "' is not a loader (export function or es6 module)"
));
}
loader.normal = typeof module === "function" ? module : module.default;
loader.pitch = module.pitch;
loader.raw = module.raw;
if(typeof loader.normal !== "function" && typeof loader.pitch !== "function") {
return callback(new LoaderLoadingError(
"Module '" + loader.path + "' is not a loader (must have normal or pitch function)"
));
}
callback();
}
在處理完已加載的
loader
子產品之後,就會繼續調用傳入的
callback
回調函數。在該回調函數内,會先在目前的
loader
對象上擷取
pitch
函數,然後調用
runSyncOrAsync
函數來執行
pitch
函數。對于我們的項目來說,就會開始執行
aLoader.pitch
函數。
看到這裡的小夥伴,應該已經知道 loader 子產品是如何被加載的及 loader 子產品中定義的 pitch 函數是如何被運作的。由于篇幅有限,阿寶哥就不再詳細展開介紹 loader-runner 子產品中其他函數。接下來,我們将通過幾個問題來繼續分析 loader-runner 子產品所提供的功能。
四、Pitching Loader 的熔斷機制是如何實作的?
// loader-runner/lib/LoaderRunner.js
function iteratePitchingLoaders(options, loaderContext, callback) {
// 省略部分代碼
loadLoader(currentLoaderObject, function(err) {
var fn = currentLoaderObject.pitch;
// 辨別目前loader已經被處理過
currentLoaderObject.pitchExecuted = true;
// 若目前loader對象上未定義pitch函數,則處理下一個loader對象
if(!fn) return iteratePitchingLoaders(options, loaderContext, callback);
// 執行loader子產品中定義的pitch函數
runSyncOrAsync(
fn,
loaderContext, [loaderContext.remainingRequest,
loaderContext.previousRequest, currentLoaderObject.data = {}],
function(err) {
if(err) return callback(err);
var args = Array.prototype.slice.call(arguments, 1);
var hasArg = args.some(function(value) {
return value !== undefined;
});
if(hasArg) {
loaderContext.loaderIndex--;
iterateNormalLoaders(options, loaderContext, args, callback);
} else {
iteratePitchingLoaders(options, loaderContext, callback);
}
}
);
});
}
在以上代碼中,
runSyncOrAsync
函數的回調函數内部,會根據目前
loader
對象
pitch
函數的傳回值是否為
undefined
來執行不同的處理邏輯。如果
pitch
函數傳回了非
undefined
的值,則會出現熔斷。即跳過後續的執行流程,開始執行上一個
loader
對象上的 normal loader 函數。具體的實作方式也很簡單,就是
loaderIndex
的值減 1,然後調用
iterateNormalLoaders
函數來實作。而如果
pitch
函數傳回
undefined
,則繼續調用
iteratePitchingLoaders
函數來處理下一個未處理
loader
對象。
五、Normal Loader 函數是如何被運作的?
// loader-runner/lib/LoaderRunner.js
function iterateNormalLoaders(options, loaderContext, args, callback) {
if(loaderContext.loaderIndex < 0)
return callback(null, args);
var currentLoaderObject = loaderContext.loaders[loaderContext.loaderIndex];
// normal loader的執行順序是從右到左
if(currentLoaderObject.normalExecuted) {
loaderContext.loaderIndex--;
return iterateNormalLoaders(options, loaderContext, args, callback);
}
// 擷取目前loader對象上的normal函數
var fn = currentLoaderObject.normal;
// 辨別loader對象已經被iterateNormalLoaders函數處理過
currentLoaderObject.normalExecuted = true;
if(!fn) { // 目前loader對象未定義normal函數,則繼續處理前一個loader對象
return iterateNormalLoaders(options, loaderContext, args, callback);
}
convertArgs(args, currentLoaderObject.raw);
runSyncOrAsync(fn, loaderContext, args, function(err) {
if(err) return callback(err);
var args = Array.prototype.slice.call(arguments, 1);
iterateNormalLoaders(options, loaderContext, args, callback);
});
}
由以上代碼可知,在 loader-runner 子產品内部會通過調用
iterateNormalLoaders
函數,來執行已加載
loader
對象上的 normal loader 函數。與
iteratePitchingLoaders
函數一樣,在
iterateNormalLoaders
函數内部也是通過調用
runSyncOrAsync
函數來執行
fn
函數。不過在調用 normal loader 函數前,會先調用
convertArgs
函數對參數進行處理。
convertArgs
函數會根據
raw
屬性來對 args[0](檔案的内容)進行處理,該函數的具體實作如下所示:
// loader-runner/lib/LoaderRunner.js
function convertArgs(args, raw) {
if(!raw && Buffer.isBuffer(args[0]))
args[0] = utf8BufferToString(args[0]);
else if(raw && typeof args[0] === "string")
args[0] = Buffer.from(args[0], "utf-8");
}
// 把buffer對象轉換為utf-8格式的字元串
function utf8BufferToString(buf) {
var str = buf.toString("utf-8");
if(str.charCodeAt(0) === 0xFEFF) {
return str.substr(1);
} else {
return str;
}
}
相信看完
convertArgs
函數的相關代碼之後,你對
raw
屬性的作用有了更深刻的了解。
六、Loader 函數體中的 this.callback 和 this.async 方法是哪裡來的?
Loader 可以分為同步 Loader 和異步 Loader,對于同步 Loader 來說,我們可以通過
return
語句或
this.callback
的方式來同步地傳回轉換後的結果。隻是相比
return
語句,
this.callback
方法則更靈活,因為它允許傳遞多個參數。
sync-loader.js
module.exports = function(source) {
return source + "-simple";
};
sync-loader-with-multiple-results.js
module.exports = function (source, map, meta) {
this.callback(null, source + "-simple", map, meta);
return; // 當調用 callback() 函數時,總是傳回 undefined
};
需要注意的是
this.callback
方法支援 4 個參數,每個參數的具體作用如下所示:
this.callback(
err: Error | null, // 錯誤資訊
content: string | Buffer, // content資訊
sourceMap?: SourceMap, // sourceMap
meta?: any // 會被 webpack 忽略,可以是任何東西
);
而對于異步 loader,我們需要調用
this.async
方法來擷取
callback
函數:
async-loader.js
module.exports = function(source) {
var callback = this.async();
setTimeout(function() {
callback(null, source + "-async-simple");
}, 50);
};
那麼以上示例中,
this.callback
和
this.async
方法是哪裡來的呢?帶着這個問題,我們來從 loader-runner 子產品的源碼中,一探究竟。
this.async
// loader-runner/lib/LoaderRunner.js
function runSyncOrAsync(fn, context, args, callback) {
var isSync = true; // 預設是同步類型
var isDone = false; // 是否已完成
var isError = false; // internal error
var reportedError = false;
context.async = function async() {
if(isDone) {
if(reportedError) return; // ignore
throw new Error("async(): The callback was already called.");
}
isSync = false;
return innerCallback;
};
}
在前面我們已經介紹過
runSyncOrAsync
函數的作用,該函數用于執行 Loader 子產品中設定的 Normal Loader 或 Pitching Loader 函數。在
runSyncOrAsync
函數内部,最終會通過
fn.apply(context, args)
的方式調用 Loader 函數。即會通過
apply
方法設定 Loader 函數的執行上下文。
此外,由以上代碼可知,當調用
this.async
方法之後,會先設定
isSync
的值為
false
,然後傳回
innerCallback
函數。其實該函數與
this.callback
都是指向同一個函數。
this.callback
// loader-runner/lib/LoaderRunner.js
function runSyncOrAsync(fn, context, args, callback) {
// 省略部分代碼
var innerCallback = context.callback = function() {
if(isDone) {
if(reportedError) return; // ignore
throw new Error("callback(): The callback was already called.");
}
isDone = true;
isSync = false;
try {
callback.apply(null, arguments);
} catch(e) {
isError = true;
throw e;
}
};
}
如果在 Loader 函數中,是通過
return
語句來傳回處理結果的話,那麼
isSync
值仍為
true
,将會執行以下相應的處理邏輯:
// loader-runner/lib/LoaderRunner.js
function runSyncOrAsync(fn, context, args, callback) {
// 省略部分代碼
try {
var result = (function LOADER_EXECUTION() {
return fn.apply(context, args);
}());
if(isSync) { // 使用return語句傳回處理結果
isDone = true;
if(result === undefined)
return callback();
if(result && typeof result === "object" && typeof result.then === "function") {
return result.then(function(r) {
callback(null, r);
}, callback);
}
return callback(null, result);
}
} catch(e) {
// 省略異常處理代碼
}
}
通過觀察以上代碼,我們可以知道在 Loader 函數中,可以使用
return
語句直接傳回
Promise
對象,比如這種方式:
module.exports = function(source) {
return Promise.resolve(source + "-promise-simple");
};
現在我們已經知道 Loader 是如何傳回資料,那麼 Loader 最終傳回的結果是如何被處理的的呢?下面我們來簡單介紹一下。
七、Loader 最終的傳回結果是如何被處理的?
// webpack/lib/NormalModule.js(Webpack 版本:5.45.1)
build(options, compilation, resolver, fs, callback) {
// 省略部分代碼
return this.doBuild(options, compilation, resolver, fs, err => {
// if we have an error mark module as failed and exit
if (err) {
this.markModuleAsErrored(err);
this._initBuildHash(compilation);
return callback();
}
// 省略部分代碼
let result;
try {
result = this.parser.parse(this._ast || this._source.source(), {
current: this,
module: this,
compilation: compilation,
options: options
});
} catch (e) {
handleParseError(e);
return;
}
handleParseResult(result);
});
}
由以上代碼可知,在
this.doBuild
方法的回調函數中,會使用
JavascriptParser
解析器對傳回的内容進行解析操作,而底層是通過 acorn 這個第三方庫來實作 JavaScript 代碼的解析。而解析後的結果,會繼續調用
handleParseResult
函數進行進一步處理。這裡阿寶哥就不展開介紹了,感興趣的小夥伴可以自行閱讀一下相關源碼。
八、為什麼要把 content 指派給 module.exports 屬性呢?
最後我們來回答前面留下的問題 —— 在 a-loader.js 子產品中,為什麼要把
content
指派給
module.exports
屬性呢?要回答這個問題,我們将從 Webpack 生成的 bundle.js 檔案(已删除注釋資訊)中找到該問題的答案:
__webpack_modules__
var __webpack_modules__ = ({
"./src/data.txt": ((module)=>{
eval("module.exports = '大家好,我是阿寶哥[cLoader->bLoader->aLoader]'\n\n//#
sourceURL=webpack://webpack-loader-demo/./src/data.txt?");
}),
"./src/index.js":((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var
_data_txt__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./data.txt */ \"./src/data.txt\");...
);
})
});
__webpack_require__
// The module cache
var __webpack_module_cache__ = {};
// The require function
function __webpack_require__(moduleId) {
// Check if module is in cache
var cachedModule = __webpack_module_cache__[moduleId];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
// Create a new module (and put it into the cache)
var module = __webpack_module_cache__[moduleId] = {
exports: {}
};
// Execute the module function
__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
// Return the exports of the module
return module.exports;
}
在生成的 bundle.js 檔案中,
./src/index.js
對應的函數内部,會通過調用
__webpack_require__
函數來導入
./src/data.txt
路徑中的内容。而在
__webpack_require__
函數内部會優先從緩存對象中擷取
moduleId
對應的子產品,若該子產品已存在,就會傳回該子產品對象上
exports
屬性的值。如果緩存對象中不存在
moduleId
對應的子產品,則會建立一個包含
exports
屬性的
module
對象,然後會根據
moduleId
從
__webpack_modules__
對象中,擷取對應的函數并使用相應的參數進行調用,最終傳回
module.exports
的值。是以在 a-loader.js 檔案中,把
content
指派給
module.exports
屬性的目的是為了導出相應的内容。
九、總結
本文介紹了 Webpack Loader 的本質、Normal Loader 和 Pitching Loader 的定義和使用及 Loader 是如何被運作的等相關内容,希望閱讀完本文之後,你對 Webpack Loader 機制能有更深刻的了解。文中阿寶哥隻介紹了 loader-runner 子產品,其實 loader-utils(Loader 工具庫)和 schema-utils(Loader Options 驗證庫)這兩個子產品也與 Loader 息息相關。在編寫 Loader 的時候,你可能就會使用到它們。如果你對如何編寫一個 Loader 感興趣的話,可以閱讀 writing-a-loader 這個文檔或掘金上 手把手教你撸一個 Webpack Loader 這篇文章。
十、參考資源
- Webpack 官網
- Github — loader-runner