Skip to content

Loader #35

Open
Open
@TieMuZhen

Description

@TieMuZhen

一、Loader 的本质

众所周知,webpack中万物皆模块,但是呢webpack默认只能处理js模块,那要是用户import一个图片,webpack处理不了,那不就很尴尬了,所以才有了loader机制,So,loader其实就是处理模块的,如图片有file-loader,vue文件有vue-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;

我们就可以定义一个简单的 demoLoader:

function demoLoader(content, map, meta) {
  console.log("我是 demoLoader");
  return content;
}
module.exports = demoLoader;

以上的demoLoader并不会对输入的内容进行任何处理,只是在该 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

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"
}

接着我们使用以下命令,安装一下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。

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 的执行顺序是 从右到左。具体的执行过程如下图所示:

看到这里有的小伙伴可能会有疑问,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。具体执行流程如下图所示:

之后,我们在浏览器中再次打开 dist/index.html 文件。此时,在页面上你将看到以下信息:

Webpack Loader 示例
bLoader Pitching Loader->aLoader]

三、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属性的目的是为了导出相应的内容。

参考文章

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions