多图详解,一次性搞懂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.callbackthis.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-loadercss-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;

接着我们使用以下命令,安装一下 webpackwebpack-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 的方式读取已添加的数据。 而 remainingRequestprecedingRequest 参数到底是什么?这里我们先来更新一下 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.jsc-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.jspitch 函数的输出结果为例,来分析一下 remainingRequestprecedingRequest 参数的输出结果:

/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 LoaderNormal 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-loaderpitch 函数中添加一个断点。对应的调用堆栈如下所示:

通过观察以上的调用堆栈信息,我们可以看到调用 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 对象,调用该方法后会返回包含 normalpitchrawdata 等属性的对象。目前该对象的大多数属性值都为 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 函数来处理已加载的模块。该函数的作用是,获取模块中的导出函数及该函数上 pitchraw 属性的值并赋值给对应 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.callbackthis.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 LoaderPitching 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 感兴趣的话 手把手教你撸一个 Webpack Loader 这篇文章。

十、参考资源

多图详解,一次性搞懂Webpack Loader的相似文章

webpack5 代码分离分析webpack 学习系列(二):你可能不知道 tree shaking分析webpack 之 LoaderRunner 全方位揭秘分析