天天看點

【Webpack】1054- 多圖詳解,一次性搞懂Webpack Loader

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 的本質是什麼?

【Webpack】1054- 多圖詳解,一次性搞懂Webpack 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

【Webpack】1054- 多圖詳解,一次性搞懂Webpack 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 在執行的過程中是以管道的形式,對資料進行處理,具體處理過程如下圖所示:

【Webpack】1054- 多圖詳解,一次性搞懂Webpack Loader

現在你已經知道什麼是 Normal Loader 及 Normal Loader 的執行順序,接下來我們來介紹另一種 Loader —— Pitching Loader。

2.2 Pitching Loader

【Webpack】1054- 多圖詳解,一次性搞懂Webpack 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】1054- 多圖詳解,一次性搞懂Webpack 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】1054- 多圖詳解,一次性搞懂Webpack 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】1054- 多圖詳解,一次性搞懂Webpack Loader

當你按照上述步驟操作之後,在目前項目(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​

​ 函數中添加一個斷點。對應的調用堆棧如下所示:

【Webpack】1054- 多圖詳解,一次性搞懂Webpack Loader

通過觀察以上的調用堆棧資訊,我們可以看到調用 ​

​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 的調用堆棧:

【Webpack】1054- 多圖詳解,一次性搞懂Webpack Loader

與之對應 ​

​runLoaders​

​​ 函數的 ​

​options​

​ 對象結構如下所示:

【Webpack】1054- 多圖詳解,一次性搞懂Webpack Loader

基于上述的調用堆棧和相關的源碼,阿寶哥也畫了一張相應的流程圖:

【Webpack】1054- 多圖詳解,一次性搞懂Webpack Loader

看完上面的流程圖和調用堆棧圖,接下來我們來分析一下流程圖中相關函數的核心代碼。這裡我們先來分析 ​

​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