多图详解,一次性搞懂Webpack Loader

发表于 3年以前  | 总阅读数:237 次

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 感兴趣的话,可以阅读 writing-a-loader 这个文档或掘金上 手把手教你撸一个 Webpack Loader 这篇文章。

十、参考资源

  • Webpack 官网
  • Github — loader-runner

本文由哈喽比特于3年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/0Kr7NusNRsxn3b5Fg7KPtA

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:1年以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:1年以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:1年以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:1年以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:1年以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:1年以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:1年以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:1年以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:1年以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:1年以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:1年以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:1年以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:1年以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:1年以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:1年以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:1年以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:1年以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:1年以前  |  398次阅读  |  详细内容 »
 相关文章
Android插件化方案 5年以前  |  237329次阅读
vscode超好用的代码书签插件Bookmarks 2年以前  |  8176次阅读
 目录