天天看点

【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