【万字长文|趣味图解】彻底弄懂Webpack中的Loader机制

11,795 阅读24分钟

一、前言

本文是 从零到亿系统性的建立前端构建知识体系✨ 中的第四篇,整体难度 ⭐️⭐️⭐️。

前端发展到了今天,web应用越来越复杂和庞大,前端技术迅猛发展,各路大神各显神通,多种优秀的前端框架、新语言和其他相关技术(如下图所示)不断涌现,这些都极大地提高了我们的开发效率。

image.png

面对这些框架所衍生出来的文件,现代的模块打包工具,例如 Webpack 本身只能处理 js  和 JSON 文件,其他类型文件它是不能够处理的。需要借助 Loader 来处理这些类型的文件,并将它们转换为有效的模块。

因此要学好 Webpack,我们就需要掌握其核心——Loader 机制。接下来通过夺命十连问打开局面:

  • Loader 的本质是什么?
  • 在 Webpack 中如何使用自定义 Loader?有几种方式?
  • Loader 的类型有哪几种?它们的运行顺序是怎么样的?如何控制它们的运行顺序?
  • 什么是 Normal Loader?什么是 Pitching Loader?它们的运行机制有什么不同?
  • 如果一个文件指定了多个 Loader,如何控制使得只执行特定的 Loader,忽略其他的 Loader?
  • Loader 为什么是自右向左执行的?如何做到的?
  • 项目中对.css、.less、.scss、.tsx、.vue等文件是如何做解析的?它们的原理是什么?
  • Webpack 中完整的 Loader 运行机制是怎么样的?
  • 为什么最后的 Loader 处理结果必须是JS类型的字符串?
  • 给你个需求:需要在打包过程中移除console.log函数,你会通过哪种方式进行处理?是通过 Loader 还是 Babel Plugin?再或者是 Webpack Plugin?给出你的理由

二、Loader的本质是什么?

image.png

Loader 本质上是导出为函数的 JavaScript 模块。它接收资源文件或者上一个 Loader 产生的结果作为入参,也可以用多个 Loader 函数组成 loader chain(链),最终输出转换后的结果

/**
 *
 * @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 代码
}

loader chain(链):这里拿 .less 文件举例

  module: {
    rules: [
      {
        test: /\.less$/,
        use: [
          "style-loader", //将css内容变成style标签插入到html中去
          "css-loader", //解析css文件的路径等
          "less-loader", //将less=>css
        ],
      },
    ],
  },

这里要注意的是,如果是组成的loader chain(链),它们的执行顺序是从右向左,或者说是从下往上执行的,至于什么会这样下面会详细说到。

loader chain(链) 这样设计的好处,是可以保证每个 Loader 的职责单一。同时,也方便后期 Loader 的组合和扩展。

了解完 loader 是什么之后,我们就可以写一个简单的 ALoader

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

ALoader 该自定义 Loader 并不会对输入的内容进行任何处理,只是在该 Loader 执行时输出相应的信息。有了自定义 Loader 后,该如何在 Webpack 中使用呢?

三、在 Webpack 中如何使用自定义 Loader?

image.png

在 Webpack 中使用自定义 Loader 主要有三种方式:

  • (1)配置 Loader 的绝对路径
      {
        test: /\.js$/,
        use: [
          {
            loader: path.resolve(__dirname, "./loaders/simpleLoader.js"),
            options: {
              /* ... */
            },
          },
        ],
      },
  • (2)配置 resolveLoader.alias 配置别名
  resolveLoader: {
     alias: {
       "simpleLoader": path.resolve(__dirname, "./loaders/simpleLoader.js"),
     },
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: "simpleLoader",
            options: {
              /* ... */
            },
          },
        ],
      },
    ],
  },

但这里有个问题,如果写了好几个自定义 Loader ,那这里就要配好几个别名,比较繁琐,不推荐。

  • (3)配置 resolveLoader.modules
  resolveLoader: {
    //找loader的时候,先去loaders目录下找,找不到再去node_modules下面找
    modules: ["loaders", "node_modules"],
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: "simpleLoader",
            options: {
              /* ... */
            },
          },
        ],
      },
    ],
  },

如果要使用第三方 Loader,直接配置 Loader 名即可,默认会在node_modules下查找。

四、Loader的四种类型

image.png

Loader 按类型分可以分为四种:前置(pre)普通(normal)行内(inline)后置(post)

我们平常使用的大多数就是 普通(normal)类型的,这里要说明的一个点是 Loader 的类型和它本身没有任何关系,而是和配置的 enforce属性有关系

举个简单的🌰:

  module: {
    rules: [
      {
        test: /\.css$/,
        use: ["css-loader"],
      },
    ],
  },

上面对 .css 文件的解析中用到的 css-loader 中并没有指定 enforce 属性,那这个 css-loader 就是普通(normal)类型的 Loader,而当配置 enforce: "pre" 后,该 Loader 就变成前置(pre)类型的 Loader。

  module: {
    rules: [
      {
        test: /\.css$/,
        use: ["css-loader"],
        enforce: "pre", //这里也可以是post,默认不写就是normal
      },
    ],
  },

这里特殊一点的是行内(inline) Loader,平时一般用的比较少,先眼熟一下,后面会详细讲。它长这样( loader + 感叹号 + 文件路径):

import xxx from "inline-loader1!inline-loader2!/src/xxx.css";

这就表示用 inline-loader1inline-loader2 这两个 Loader 来解析引入的文件。

在上面讲 loader chain(链)的时候提到过 Loader 的执行顺序是由右向左,或者由下到上执行。其实这种说法的并不准确,在这里我引用官方的说法(什么是Pitching 阶段和Normal 阶段下节就会讲到):

所有一个接一个地进入的 Loader,都有两个阶段:

  1. Pitching 阶段: Loader 上的 pitch 方法,按照 后置(post)、行内(inline)、普通(normal)、前置(pre) 的顺序调用。
  2. Normal 阶段: Loader 上的 常规方法,按照 前置(pre)、普通(normal)、行内(inline)、后置(post) 的顺序调用。模块源码的转换, 发生在这个阶段。
  3. 同等类型下的 Loader 执行顺序才是由右向左,或者由下到上执行。

理论说完,接下来讲一个实际的应用场景:在项目开始构建之前,为了更早的发现错误,一般会先进行 eslint 校验。这个时候就需要前置(pre) Loader,如果在前置 Loader 中发现了错误那就提前退出构建:

  module: {
    rules: [
      {
        test: /\.js$/,
        use: ["eslint-loader"],
        enforce: "pre", //编译前先对js文件进行校验
      },
      {
        test: /\.js$/,
        use: ["babel-loader"],
      },
    ],
  },

这里顺带引出一个很有意思的思考题:像上面这样配置前置 Loader 去校验文件,它是在编译前先校验所有的 .js 文件再编译,还是校验一个编译一个呢?这样真的能够更早的发现错误吗?

答案:校验一个编译一个,至于原因后面 手写 webpack 文章 中会有详细讲解。

五、Normal Loader 和 Pitching Loader

5.1、Normal Loader

image.png

前面提到 Loader 本质上是导出函数的 JavaScript 模块,而该模块导出的函数(若是 ES6 模块,则是默认导出的函数)就被称为 Normal Loader。需要注意的是,这里说的 Normal Loader 与 Webpack Loader 分类中定义的 Loader 是不一样的,是两个不同的概念。

我们最开始在第二节中写的自定义 Loader 其实就是一个 Normal Loader ,在原来基础上给源代码加点注释生成 Aloader:

//a-loader.js
function ALoader(content, map, meta) {
  console.log("执行 a-loader 的normal阶段");
  return content + "//给你加点注释(来自于Aloader)";
}
module.exports = ALoader;

接下来我们新建一个项目运行一下这个 Loader:

npm init  //初始化项目
yarn add webpack webpack-cli html-webpack-plugin//安装依赖

安装完项目依赖后,根据以下目录结构来添加对应的目录和文件:

├── dist # 打包输出目录
├── loaders # loaders文件夹
│   └── a-loader.js
├── node_modules
├── package-lock.json
├── package.json
├── src # 源码目录
├── ├── index.html
│   └── index.js # 入口文件
└── webpack.config.js # webpack配置文件

package.json:

{
  "name": "loader",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build": "webpack"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "webpack": "^5.74.0",
    "webpack-cli": "^4.10.0",
    "html-webpack-plugin": "^5.5.0",
  }
}

src/index.js

const a = 1 ;

src/index.html

<!DOCTYPE html>
<html lang="en">
  <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>详解loader</title>
  </head>
  <body>
    <div id="root">hello</div>
  </body>
</html>

webpack.config.js

const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  mode: "development",
  devtool: "source-map",
  entry: "./src/index.js",
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "main.js",
  },
  resolveLoader: {
    //找loader的时候,先去loaders目录下找,找不到再去node_modules下面找
     modules: ["loaders", "node_modules"],
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          "a-loader", 
        ],
      },
    ],
  },
  plugins: [new HtmlWebpackPlugin({ template: "./src/index.html" })],
};

配置完成后运行 yarn build 命令会开始构建,查看控制台和dist文件夹中打包后的内容:

image.png

image.png

我们接着照葫芦画瓢再写两个自定义 Loader:b-loader.js、c-loader.js。

b-loader.js

function BLoader(content, map, meta) {
  console.log("执行 b-loader 的normal阶段");
  return content + "//给你加点注释(来自于BLoader)";
}
module.exports = BLoader;

c-loader.js

function CLoader(content, map, meta) {
  console.log("执行 c-loader 的normal阶段");
  return content + "//给你加点注释(来自于CLoader)";
}
module.exports = CLoader;

配置webpack.config.js

     {
        test: /\.js$/,
        use: ["c-loader", "b-loader", "a-loader"],
     },

添加后再次构建,得到如下结果:验证之前 Loader 在 Normal阶段从右向左执行的说法。

image.png

image.png

5.2、Pitch Loader

image.png

其实我们在导出的 Loader 函数上还有一个可选属性:pitch。它的值也是一个函数,该函数就被称为 Pitching Loader

我们可以在这个函数中做一些事情,在ALoader、BLoader、CLoader这三个 Loader 中添加 pitch 函数:

a-loader.js

function ALoader(content, map, meta) {
  console.log("执行 a-loader 的normal阶段");
  return content + "//给你加点注释(来自于Aloader)";
}

ALoader.pitch = function () {
  console.log("ALoader的pitch阶段");
};

module.exports = ALoader;

b-loader.js

function BLoader(content, map, meta) {
  console.log("执行 b-loader 的normal阶段");
  return content + "//给你加点注释(来自于BLoader)";
}

BLoader.pitch = function () {
  console.log("BLoader的pitch阶段");
};

module.exports = BLoader;

c-loader.js

function CLoader(content, map, meta) {
  console.log("执行 c-loader 的normal阶段");
  return content + "//给你加点注释(来自于CLoader)";
}

CLoader.pitch = function () {
  console.log("CLoader的pitch阶段");
};

module.exports = CLoader;

还是这样配置webpack.config.js:

     {
        test: /\.js$/,
        use: ["c-loader", "b-loader", "a-loader"],
     },

配置完成后我们再次运行 yarn build 启动编译:

image.png

由此,我们可以得出结论:在 Loader 的运行过程中,如果发现该 Loader 上有pitch属性,会先执行 pitch 阶段,再执行 normal 阶段。

image.png

如果此时再结合上之前所讲的四种类型:前置(pre)普通(normal)行内(inline)后置(post),执行顺序会是什么样呢?

先理解一下下面这张图:

image.png

再举一个实际的例子:

假如现在我们的 rule 配置是这样:

  {
    test: /\.js$/,
    use: [
      {
        loader: "a-loader",
        enforce: "pre",
      },
      {
        loader: "b-loader",
        enforce: "post",
      },
      {
        loader: "c-loader",
        enforce: "pre",
      },
      {
        loader: "d-loader",
        enforce: "post",
      },
      {
        loader: "e-loader",
        enforce: "normal",
      },
      {
        loader: "f-loader",
        enforce: "normal",
      },
    ],
  },

这些 loader 的执行顺序是什么样的?


答案是:Webpack 内部先会对 loader 的类型进行分类,先找出各个类型的 loader,比如该例子:

// post类型loader
const postLoaders = ["b-loader", "d-loader"];
// inline类型loader
const inlineLoaders = [];
// normal类型loader
const normalLoaders = ["e-loader", "f-loader"];
// pre类型loader
const preLoaders = ["a-loader", "c-loader"];

找出所有类型的 loader 之后进行合并:

let loaders = [
  ...postLoaders,
  ...inlineLoaders,
  ...normalLoaders,
  ...preLoaders,
];
// 结果为: ['b-loader', 'd-loader', 'e-loader', 'f-loader', 'a-loader', 'c-loader']

这个时候再去理解它的执行顺序就是:

b-loader 的 pitch 阶段 -> 
d-loader 的 pitch 阶段 -> 
e-loader 的 pitch 阶段 -> 
f-loader 的 pitch 阶段 -> 
a-loader 的 pitch 阶段 -> 
c-loader 的 pitch 阶段 -> 
c-loader 的 normal 阶段 -> 
a-loader 的 normal 阶段 ->
f-loader 的 normal 阶段 -> 
e-loader 的 normal 阶段 ->
d-loader 的 normal 阶段 -> 
b-loader 的 normal 阶段 ->

此时再看之前的结论,是不是更清晰明了:

  1. Pitching 阶段: Loader 上的 pitch 方法,按照 后置(post)、行内(inline)、普通(normal)、前置(pre) 的顺序调用。
  2. Normal 阶段: Loader 上的 常规方法,按照 前置(pre)、普通(normal)、行内(inline)、后置(post) 的顺序调用。模块源码的转换, 发生在这个阶段。

扩展:

在 Pitch 阶段,如果执行到该 Loader 的 pitch 属性函数时有返回值,就直接结束 Pitch阶段,并直接跳到该Loader pitch 阶段的前一个 Loader 的 normal 阶段继续执行(若无前置Loader,则直接返回)

举个例子🌰:如果 BLoader 的 pitch 阶段有返回值,将直接进入到 CLoader 的 normal 阶段。

image.png

b-loader.js

function BLoader(content, map, meta) {
  console.log("执行 b-loader 的normal阶段");
  return content + "//给你加点注释(来自于BLoader)";
}

BLoader.pitch = function () {
  console.log("BLoader的pitch阶段");
  return "hello world";
};

module.exports = BLoader;

运行结果:

image.png image.png

这里可能有同学要问了,Loader 不就是为了处理文件的吗,这里文件直接都不读了,那么 Loader 的意义在哪里?

这里咱们先不急,等后面我们手写 Loader 就知道啦。

六、Pitch阶段的参数解析

PitchLoader 内部有三个很重要的参数:PreviousRequestCurrentRequestremainingRequest,它们分别代表不同纬度的 Loader 数组。

假设现有5个 loader 要执行(loader1、loader2、loader3、loader4、loader5):

image.png

现在执行到了 loader3,那么PreviousRequest代表的是之前执行过pitch阶段的loader:loader1 和 loader2。

CurrentRequest代表的是当前正在执行pitch阶段的loader和后面未执行pitch阶段的loader:loader3、loader4、loader5、源文件。

remainingRequest代表未执行过pitch阶段的loader:loader4、loader5、源文件。

其中remainRequestPreviousRequest作为pitchLoader作为 pitch函数的默认参数,这里官方有介绍:webpack.js.org/api/loaders…

Loader.pitch = function (remainingRequest, previousRequest, data) {
  console.log(remainingRequest, previousRequest, data)
};

这里的第三个参数 data,可以用于数据传递。即在 pitch 函数中往 data 对象上添加数据,之后在 normal 函数中通过 this.data 的方式读取已添加的数据,也就是注入上下文。

function loader(source) {
  console.log(this.data.a); //这里可以拿到值为1
  return source ;
}

loader.pitch = function () {
  this.data.a = 1;//注入参数
  console.log("loader-pitch");
};

七、Loader的内联方式

image.png

在某些情况下,我们对一个类型的文件配置了多个 Loader,但只想执行特定的 Loader 怎么办?比如只想执行内联类型的 CLoader。

rule配置

    rules: [
      {
        test: /\.js$/,
        use: ["a-loader"],
      },
      {
        test: /\.js$/,
        use: ["b-loader"],
        enforce: "post",
      },
    ],

src/index.js

import test from "c-loader!./test.js"; //使用内联Loader

const a = 1;

a-loader.js

function ALoader(content, map, meta) {
  console.log("执行 a-loader 的normal阶段");
  return content + "//给你加点注释(来自于Aloader)";
}

ALoader.pitch = function () {
  console.log("ALoader的pitch阶段");
};

module.exports = ALoader;

b-loader.js

function BLoader(content, map, meta) {
  console.log("执行 b-loader 的normal阶段");
  return content + "//给你加点注释(来自于BLoader)";
}

BLoader.pitch = function () {
  console.log("BLoader的pitch阶段");
};

module.exports = BLoader;

c-loader.js

function CLoader(content, map, meta) {
  console.log("执行 c-loader 的normal阶段");
  return content + "//给你加点注释(来自于CLoader)";
}

CLoader.pitch = function () {
  console.log("CLoader的pitch阶段");
};

module.exports = CLoader;

正常情况下,此时的运行顺序为:

image.png

如果此时想指定执行某些类型的 Loader,忽略掉其他类型应该怎么办?

使用 ! 前缀,将禁用所有已配置的 normal loader(普通 loader)(通过为内联 import 语句添加感叹号前缀):

src/index.js

import test from "!c-loader!./test.js";

const a = 1;

此时 Loader 的执行顺序就变成了(忽略掉了 normal类型的 ALoader):

image.png

使用 !! 前缀,将禁用其他类型的loader,只要内联loader

import test from "!!c-loader!./test.js";

const a = 1;

此时loader的执行顺序就变成了:

image.png

使用 -! 前缀,将禁用所有已配置的 preLoader 和 loader,但是不禁用 postLoaders,也就是不要 pre 和 normal loader:

import test from "-!c-loader!./test.js";

const a = 1;

此时 Loader 的执行顺序就变成了(演示中没有 preLoader):

image.png

原理实现其实挺简单的:只是按标识符做了一个过滤。接下来我们通过以下代码简单了解一下其中的原理:

const { runLoaders } = require("loader-runner"); //webpack内容用的此库解析loaders
const path = require("path");
const entryFile = path.resolve(__dirname, "./src/index.js"); //拿到入口文件的绝对路径

//模拟使用行内loader
let modulePath = `-!inline-loader1!inline-loader2!${entryFile}`;
//模拟webpack.config.js中的配置
let rules = [
  {
    test: /\.js$/,
    use: ["normal-loader1", "normal-loader2"], //使用两个normalLoader
  },
  {
    test: /\.js$/,
    enforce: "post",
    use: ["post-loader1", "post-loader2"],//使用两个postLoader
  },
  {
    test: /\.js$/,
    enforce: "pre",
    use: ["pre-loader1", "pre-loader2"],//使用两个preLoader
  },
];

let preLoaders = [],
  inlineLoaders = [],
  postLoaders = [],
  normalLoaders = [];

//找出 inlineLoaders
let useInlineLoadersArray = modulePath.replace(/^-?!+/, "").split("!"); //['inline-loader1','inline-loader2','入口模块的路径']
let resource = useInlineLoadersArray.pop(); //弹出最后一个元素 resource = 入口模块的路径
inlineLoaders = useInlineLoadersArray; //[inline-loader1,inline-loader2]

//对其他类型的loader进行分类
for (let i = 0; i < rules.length; i++) {
  let rule = rules[i];
  if (rule.test.test(resource)) {
    if (rule.enforce === "pre") {
      preLoaders.push(...rule.use);
    } else if (rule.enforce === "post") {
      postLoaders.push(...rule.use);
    } else {
      normalLoaders.push(...rule.use);
    }
  }
}

let loaders = [];

if (request.startsWith("!!")) {
  //使用 !! 前缀,将禁用其他类型的loader,只要内联loader
  loaders = [...inlineLoaders];
} else if (request.startsWith("-!")) {
  //使用 -! 前缀,将禁用所有已配置的 preLoader 和 loader,但是不禁用 postLoaders,也就是不要前置和普通 loader
  loaders = [...postLoaders, ...inlineLoaders];
} else if (request.startsWith("!")) {
  //使用 ! 前缀,将禁用所有已配置的 normal loader(普通 loader)
  loaders = [...postLoaders, ...inlineLoaders, ...preLoaders];
} else {
  loaders = [...postLoaders, ...inlineLoaders, ...normalLoaders, ...preLoaders];
}

//把loader数组从名称变成绝对路径,因为runLoaders接收的是绝对路径
loaders = loaders.map((loader) => path.resolve(__dirname, "loaders", loader));

runLoaders(
  {
    resource, //你要加载的资源
    loaders,
  },
  (err, result) => {
    console.log(err, "err"); //运行错误
    console.log(result, "最后要输出的result"); //运行的结果
    console.log(
      result.resourceBuffer ? result.resourceBuffer.toString("utf8") : null,
      "读到的原始的文件"
    ); //读到的原始的文件
  }
);

八、实现loader-runner(可跳过)

image.png

在上面提到 Loader 的内联方式时,我们使用到了一个库:loader-runner。Webpack 内部也会使用该库来运行已配置的 loaders。

到现在我们已经完整的知道了 Loader 的运行机制,接下来将进一步深挖该库的源码。

loader-runner会导出核心函数runLoadersrunLoaders接受两个参数:option参数对象和执行完成后的回调函数,在回调函数的默认参数中可以查看源代码等信息。

import { runLoaders } from "loader-runner";

runLoaders({
	resource: "资源的绝对路径", //要解析资源的绝对路径
	loaders: ["loader的绝对路径"],//loader的绝对路径,这里可以放多个
	context: { minimize: true },//路径上下文
}, function(err, result) {
   //err:错误信息 result:输出解析后的结果
})

整体思路:

image.png

8.1、runLoaders基本结构

在这一步骤中主要是定义一些最基本的数据供后续使用。

const fs = require("fs");

//根据loader模块的绝对路径得到loader对象
function createLoaderObject(loader) {
  const normal = require(loader); //拿到normal阶段的函数
  const pitch = normal.pitch; //拿到pitch阶段的函数,可能有也可能没有
  return {
    path: loader, //loader的绝对路径
    normal,
    pitch,
    raw: normal.raw, //如果raw为true,那么normal的参数就是buffer类型
    data: {}, //每个loader对象都有一个自定义的data对象,你可以随意赋值,举个例子,在loader的pitch阶段给数据,可以在normal阶段接收
    pitchExecuted: false, //此loader的pitch函数已经执行过了吗
    normalExecuted: false, //此loader的normal函数已经执行过了吗
  };
}

function runLoaders(option, finalCallback) {
  const {
    resource,
    loaders = [], //里面放的是loader的绝对路径 【loader1的绝对路径,loader2的绝对路径】
    context = {}, //默认的上下文对象,不给的话就是一个空对象
    readResource = fs.readFile, //读文件的函数
  } = option;

  //loaderContext将会成为loader执行时的this指针。之前我们在loader中使用的this.getOptions就是在这里面拿的
  loaderContext = context; //context会成为loader执行过程中默认的的this指针
  loaderContext.resource = resource;
  loaderContext.readResource = readResource;
  loaderContext.loaders = loaders.map(createLoaderObject); //是一个数组,放着每个loader的各种信息
  loaderContext.loaderIndex = 0; //当前正在执行loader的索引,先执行pitch再执行normal阶段,都是靠索引控制的,先递增再递减,比如pitch阶段:0-1-2-3,到了normal阶段就是:3-2-1-0
  loaderContext.callback = null; //调用callback可以让当前的loader执行结束,并且向后续的loader传递多个参数
  loaderContext.async = null; //是内置方法,可以把同步变成异步

  //接下来定义属性
  //剩下的请求
  Object.defineProperty(loaderContext, "remainRequest", {
    get() {
      return loaderContext.loaders
        .slice(loaderContext.loaderIndex + 1)
        .map((loader) => loader.path)
        .concat(loaderContext.resource)
        .join("!");
    },
  });

  //当前的请求
  Object.defineProperty(loaderContext, "currentRequest", {
    get() {
      return loaderContext.loaders
        .slice(loaderContext.loaderIndex)
        .map((loader) => loader.path)
        .concat(loaderContext.resource)
        .join("!");
    },
  });

  //之前的请求
  Object.defineProperty(loaderContext, "PreviousRequest", {
    get() {
      return loaderContext.loaders
        .slice(0, loaderContext.loaderIndex)
        .map((loader) => loader.path)
        .concat(loaderContext.resource)
        .join("!");
    },
  });

  //各个loader上单独定义的参数
  Object.defineProperty(loaderContext, "data", {
    get() {
      return loaderContext.loaders[loaderContext.loaderIndex].data;
    },
  });

  //处理选项
  let processOptions = {
    resourceBuffer: null, //存放着要加载的模块的原始内容,默认为空,等文件加载后会赋值
    readResource, //读取文件的方法,默认值是fs.readFile
  };
}

module.exports.runLoaders = runLoaders;

8.2、执行pitch阶段的loaders

该步骤的核心思想:

  1. 如果 loaderIndex 已经大于等于 loader 的长度了,代表 pitch 阶段执行完了,可以开始读文件了(loaderContext.loaderIndex为当前正在执行loader的索引,先执行pitch阶段再执行normal阶段,都是靠索引控制的,先递增再递减,比如pitch阶段:0-1-2-3,到了normal阶段就是:3-2-1-0)
  2. 拿到当前的 loader,如果当前的 pitch 阶段已经执行过了,就可以让当前的索引加1,执行下一个 loader 的 pitch 阶段
  3. 拿到 pitch 函数,如果当前 loader 的 pitch 函数没有,则执行下一个 loader 的 pitch 函数
  4. 如果 pitchFn 有值,以同步或者异步调用 pitchFn 方法,以 loaderContext 为 this指针
  5. 如果 pitchFn 的运行结果不为 undefined,则需要掉头执行前一个 loader 的 normal 阶段
  6. 如果 pitchFn 的运行结果为 undefined,则需要执行下一个 loader 的 pitch 阶段

另外,下面的代码中有提到关于 Loader 中同步执行或异步执行的API,这里就不给大家过多解释了,较简单,可自行查阅官网

//执行pitch阶段的loaders
function iteratePitchingLoaders(
  processOptions,
  loaderContext,
  pitchingCallback
) {
  //如果loaderIndex已经大于等于loader的长度了,代表pitch阶段执行完了,开始读文件
  if (loaderContext.loaderIndex >= loaderContext.loaders.length) {
    //processResource函数8.3会写
    return processResource(processOptions, loaderContext, pitchingCallback);
  }

  //先拿到当前的loader
  const currentLoader = loaderContext.loaders[loaderContext.loaderIndex];

  //如果当前的pitch已经执行过了,就可以让当前的索引加1,执行下一个loader的pitch
  if (currentLoader.pitchExecuted) {
    loaderContext.loaderIndex++;
    return iteratePitchingLoaders(
      processOptions,
      loaderContext,
      pitchingCallback
    );
  }

  //拿到pitch函数
  let pitchFn = currentLoader.pitch;
  currentLoader.pitchExecuted = true; //不管pitch函数有没有,都把这个pitchExecuted设为true,代表执行过pitch了

  if (!pitchFn) {
    //如果当前loader的pitch函数没有,则执行下一个loader的pitch
    return iteratePitchingLoaders(
      processOptions,
      loaderContext,
      pitchingCallback
    );
  }

  //如果pitchFn有值,以同步或者异步调用pitchFn方法,以loaderContext为this指针
  runSyncOrAsync(
    pitchFn,
    loaderContext,
    [
      loaderContext.remainRequest,
      loaderContext.PreviousRequest,
      loaderContext.data,
    ], //这里是给pitchFn传的参数
    (err, ...args) => {
      //判读有没有返回值 args就是返回值,就需要掉头执行前一个loader的normal阶段
      if (args.length > 0 && args.some((item) => item)) {
        loaderContext.loaderContext--;
        //这个函数在8.4中会写
        iterateNormalLoader(
          processOptions,
          loaderContext,
          args,
          pitchingCallback
        );
      } else {
        //如果没有return 就执行下一个loader的pitch
        iteratePitchingLoaders(processOptions, loaderContext, pitchingCallback);
      }
    }
  );
}

function runSyncOrAsync(pitchFn, loaderContext, args, runCallback) {
  let isSync = true; //默认loader的执行是同步的
  let isDone = false; //是否执行完成
  loaderContext.callback = (err, ...args) => {
    if (isDone) {
      //为了保证runCallback只调用一次,不能重复执行
      throw new Error("this callback已经执行完成了");
    }
    isDone = true;
    runCallback(err, ...args);
  };
  loaderContext.async = () => {
    isSync = false; //把isSync是否同步执行的标志 从同步变成异步
    //this.async()返回的结果就是this.callback,他们是一样的
    return loaderContext.callback;
  };
  let result = pitchFn.apply(loaderContext, args);

  //如果是同步的,由本方法直接调用runCallback,用来执行下一个loader
  if (isSync) {
    isDone = true;
    runCallback(null, result);
  }
  //如果是异步的,需要自己手动出发callback 也就是runCallback
}


function runLoaders(option, finalCallback) {
  //省略8.1中的代码

  //开始从左向右遍历loader的pitch方法
+  iteratePitchingLoaders(processOptions, loaderContext, (err, result) => {
+    finalCallback(err, {
+      result,
+      resourceBuffer: processOptions.resourceBuffer,
+    });
+  });
}

8.3、处理源文件

该步骤的核心思想:拿到源文件后将其传给 normal 阶段的第一个 Loader。

//处理文件
function processResource(processOptions, loaderContext, pitchingCallback) {
  processOptions.readResource(loaderContext.resource, (err, resourceBuffer) => {
    processOptions.resourceBuffer = resourceBuffer; //拿到源文件的buffer
    loaderContext.loaderIndex--; //减1后会开始执行normal阶段的loader
    //8.4会写该函数
    iterateNormalLoader(
      processOptions,
      loaderContext,
      [resourceBuffer],
      pitchingCallback
    );
  });
}

8.4、执行normal阶段的loader

该步骤的核心思想:遍历执行 normal 阶段的函数,如果 loaderContext.loaderIndex < 0,代表 normal 阶段的 loader 已经全部执行完成,开始执行成功的回调函数。

//执行normal阶段的loader
function iterateNormalLoader(
  processOptions,
  loaderContext,
  args,
  pitchingCallback
) {
  //代表normal阶段的loader已经全部执行完成
  if (loaderContext.loaderIndex < 0) {
    return pitchingCallback(null, args);
  }
  //获取当前正在执行的loader
  const currentLoader = loaderContext.loaders[loaderContext.loaderIndex];

  if (currentLoader.normalExecuted) {
    loaderContext.loaderIndex--;
    return iterateNormalLoader(
      processOptions,
      loaderContext,
      args,
      pitchingCallback
    );
  }

  //拿到normal函数
  let normalFn = currentLoader.normal;
  currentLoader.normalExecuted = true;
  //一般loader里拿到的source都是字符串,但是如果要加载一些图片字体之类的,它需要接收一个buffer,这个时候用户可以自定义接收的数据类型是string还是buffer,默认是string
  //loader.raw=true 这么设置代表需要的是buffer数据类型
  //这里需要处理一下参数
  convertArgs(args, currentLoader.raw);

  runSyncOrAsync(normalFn, loaderContext, args, (err, ...returnArgs) => {
    if (err) return pitchingCallback(err);

    return iterateNormalLoader(
      processOptions,
      loaderContext,
      returnArgs,
      pitchingCallback
    );
  });
}

function convertArgs(args, raw) {
  if (raw && !Buffer.isBuffer(args[0])) {
    //如果需要buffer 但原来接收的不是buffer,则转buffer
    args[0] = Buffer.from(args[0]);
  } else if (!raw && Buffer.isBuffer(args[0])) {
    //如果不需要buffer 但是它是buffer 则转字符串
    args[0] = args[0].toString("utf8");
  }
}

8.5、执行最终回调函数

function runLoaders(option, finalCallback) {
  //省略其他代码

  //开始从左向右遍历loader的pitch方法
  iteratePitchingLoaders(processOptions, loaderContext, (err, result) => {
    //这里执行外面传进来的回调函数
    finalCallback(err, {
      result,
      resourceBuffer: processOptions.resourceBuffer,
    });
  });
}

九、实战演练

image.png

纸上得来终觉浅,绝知此事要躬行。

理论讲了这么多,也该上战场见见世面了!!!

9.1、手写babel-loader

如果不太熟悉 babel 的同学,可以前往上篇文章,里面有详细介绍:前端工程化基石 -- AST(抽象语法树)以及AST的广泛应用🔥

先安装 babel 的一系列依赖:

yarn add @babel/core @babel/preset-env

babel-loader 做的事情其实很简单,只需将 Loader 中的源代码交给 babel 库处理,拿到处理过后的值返回,仅此而已。

webpack.config.js

    rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: "my-babel-loader",
            options: {
              presets: ["@babel/preset-env"],
            },
          },
        ],
      },
    ],

src/index.js

const sum = (a, b) => a + b; //这里是个箭头函数,需要通过loader转换成普通函数
sum(1, 2);

loaders/my-babel-loader.js

const babel = require("@babel/core");
const path = require("path");

function babelLoader(source) {
  //loade里面的this=loaderContext,是一个唯一的对象,不管在哪个loader或方法里,它的this都是同一个对象,称为loaderContext,这个等会就会实现
  const options = this.getOptions(); //拿到在webpack中传递给该loader的参数,也就是presets: ["@babel/preset-env"],
  console.log("自己写的babel-loader");
  const { code } = babel.transformSync(source, options); //交给babel库去解析
  return code;
}

module.exports = babelLoader;

解释一下上面的代码:

@babel/core 负责把源代码转成 AST 抽象语法树,然后遍历语法树,生成新代码,但@babel/core并不认识任何具体的语法,也不会转换任何语法,它需要依赖babel插件。比如@babel/plugin-transform-arrow-functions可以识别箭头函数语法,并且把箭头函数转换成普通函数。

但是因为语法太多,每个语法都需要插件,我们需要把多个插件打包在一起形成预设,@babel/preset-env就是这样诞生的。

现在再查看效果:源代码中的箭头函数已经被转换成普通函数。

image.png

9.2、手写less-loader

在开发换环境下,我们对 .less文件解析时一般会用到三个 Loader :less-loadercss-loaderstyle-loader

安装依赖:yarn add less -D

src/index.js中使用less

import "./index.less";

src/index.less

@color: red;
#root {
  color: @color;
}

webpack.config.js:

  module: {
    rules: [
      {
        test: /\.less$/,
        use: [
          "style-loader", //将css内容变成style标签插入到html中去
          "css-loader", //一般会解析url合@import等语法
          "less-loader", //将less=>css
        ],
      },
    ],
  }

整体流程图:

image.png

这里因为没有 pitch 阶段,所以就是将源文件交给 less-loader 处理,然后交给 css-loader 处理,最后再给 style-loader 处理。

less-loader 实现思路:调用 less 库将 .less源代码转换为 .css 文件即可。

loaders/less-loader.js

const less = require("less");

//这里接受的参数是less源代码
function lessLoader(lessSource) {
  let css;
  //这里看着像是异步的,其实是同步的
  less.render(lessSource, { filename: this.resource }, (err, output) => {
    css = output.css;
  });//这里less.render其实也就是把less解析成AST,然后再生成css
  return css;
}
module.exports = lessLoader;

9.3、手写css-loader

css-loader 其实它的核心会做两件事:

  • 解析@import语法
  • 解析url中的路径

这里我们不展开,只做一个返回即可。

loaders/css-loader.js

function cssLoader(css) {
  return css;
}

module.exports = cssLoader;

9.4、手写style-loader

style-loader会接收 css-loader 返回的代码,它需要返回一段js,这点很重要!!!

因为Webpack只认识js和json,因此最左侧的 Loader 必须返回的是 js 代码。

实现思路:创建一个 style 标签,将css代码添加到 head 中去

function styleLoader(cssSource) {
  let script = `
      let style=document.createElement("style");
      style.innerHTML=${JSON.stringify(cssSource)};
      document.head.appendChild(style)
    `;
  return script;
}

module.exports = styleLoader;

打包之后查看效果:so easy !!! 你上你也行。

image.png

9.5、真实源码中的做法

通过上面的几个例子🌰,我们已经大致了解这几个 Loader 的工作原理,但真实的源码中真的也是这么做的吗?

在真正的源码中,less-loader 返回的还是css代码,而 css-loader 返回的却是js代码:

function cssLoader(css) {
  return `module.exports=${JSON.stringify(css)}`;
}

module.exports = cssLoader;

这个时候 style-loader 再像我们上面那样实现就有问题了:因为 style-loader 需要接收的是css代码,而此时上一个 Loader(css-loader)返回的是js代码

这个时候该怎么办呢?是不是就不能配合使用了?

方法一:改造 style-loader

既然 css-loader 返回的是js,那我们直接将 js 转换成 css 不就好了吗?

思路:style-loader 接收的是这么一个字符串:module.exports="#root{color:red}",我们只需要将 = 号后面的内容解析出来即可。

function styleLoader(cssSource) {
  let css = cssSource.match(/module.exports="(.+?)"/)[1]; //通过正则解析出等号后面的内容
  let script = `
  let style=document.createElement("style");
  style.innerHTML=${JSON.stringify(css.replace("\n", "0"))}; //剔除换行符
  document.head.appendChild(style)
  `;
  
  return script;
}

这样虽然能解决问题,但是如果 css-loader 不是通过 module.exports 的方式导出的,而是通过其他的方式导出的,那我们这里是不是都得跟着变换?而且这样通过正则匹配的方式也并不一定准确,万一源代码中也有module.exports这样的关键字怎么办?

因此,这样虽然能够解决问题,但是却并不优雅。

方法二:style-loader 的 pitch

css-loader 是通过module.exports方式导出的,而且我们正好也需要接收css字符串,那是不是可以直接通过 require 的方式接收呢?

接下来就有点巧妙了,源码中通过在 pitch 阶段进行 require,然后返回 script :

styleLoader.pitch = function () {
  let script = `
  let style=document.createElement("style");
  style.innerHTML=require("!!../loaders/css-loader.js!../loaders/less-loader.js!./index.less");
  document.head.appendChild(style)
`;
  return script;
};

注意!在require的时候使用的是行内(inline) Loader!!!

这下整个流程就变了,如果在 pitch 阶段如果有返回值,将会执行上一个 Loader 的 Normal 阶段。

image.png

而 style-loader 的上一个 loader 压根就没有了,因此直接退出 loader 解析阶段,将此段代码重新交给 Webpack 进行AST树解析。当在对这段代码中发现了 require 等关键字后,会将 require 后面的路径放到依赖树中,等该模块解析完后再对依赖模块进行解析。

这个时候有同学可能要问了,你这编译个啥啊,又没读文件,又没有使用 css-loader 和 less-loader。

这位同学请你坐下,稍安勿躁,可看仔细了,我们在require的时候,使用的可是行内(inline) Loader,!!代表只使用行内(inline)Loader。

Webpack在对require("!!../loaders/css-loader.js!../loaders/less-loader.js!./index.less")进行解析时,走的是这个逻辑:

image.png

最后返回的内容是module.exports="#root{color:red}",在 style-loader 的 pitch 阶段正好被接收,而且拿到的正好也是css字符串代码。

过程完整梳理:

这里其实是走了两轮编译,在第一次对 index.less 进行解析时,会先走到 style-loader 的 pitch 函数下。在该函数内会 return 一个 script。在这一轮解析完后,会 return 后的内容进行AST分析,也就是对下面这段代码进行分析:

  let style=document.createElement("style");
  style.innerHTML=require("!!../loaders/css-loader.js!../loaders/less-loader.js!./index.less");
  document.head.appendChild(style)

当发现代码中有 require 关键字后,放到本模块的依赖中。当本次编译结束后,开始编译它的依赖模块,也就是!!../loaders/css-loader.js!../loaders/less-loader.js!./index.less,在新的一轮中还是先进行 loader 解析,此时 loader 解析器发现这是一个 行内(inline)Loader,并且还使用 !! 前缀,代表将禁用所有已配置的,只要内联loader,也就是只执行 less-loader 和 css-loader,此时的执行顺序变为了:

image.png

因为 css-loader 和 less-loader 没有 pitch阶段,因此只走了后面的逻辑,即将源文件交给 less-loader 和 css-loader 执行,此时 less-loader 解析后返回 css,css-loader 解析后返回:

module.exports="#root{color:red}"

9.6、为什么要这么处理?

当我们希望把两个 Loader 进行联合使用的时候,就需要使用这种方式。因为 css-loader 返回的是js文本,但 style-loader 要的是 css文本,只能用 require 加载这个js文件模块,得到导出结果才是css代码。

使用 pitch 之后还有一个好处,那就是 css-loader 可以单独使用或配合其他 Loader 进行使用了,因为 Webpack 最左侧的 Loader必须是 js,如果返回的是css代码那将不能单独使用,这样可以不依赖 style-loader。

十、总结

本文以夺命十连问开篇,从 Loader 的本质出发,讲解了如何在Webpack中写自定义 Loader 以及多种使用方式,接着透过 Loader 运行顺序的问题衍生出 Loader 的四种类型、Normal Loader 和 Pitch Loader。最后,我们深挖 Loader 的运行机制,使得我们可以任意控制执行指定的 Loader (如何一个文件指定了多个 Loader )。

在深度上,我们也手写了loader-runner源码,并对常用的babel-loaderless-loadercss-loaderstyle-loader等几个 Loader 进行了源码方面的探索,进一步加深大家对理解。

最后,对于开篇的十连问,我相信你已经有了自己的答案。

十一、推荐阅读

  1. 从零到亿系统性的建立前端构建知识体系✨
  2. 我是如何带领团队从零到一建立前端规范的?🎉🎉🎉
  3. 二十张图片彻底讲明白Webpack设计理念,以看懂为目的
  4. 【中级/高级前端】为什么我建议你一定要读一读 Tapable 源码?
  5. 前端工程化基石 -- AST(抽象语法树)以及AST的广泛应用
  6. 线上崩了?一招教你快速定位问题!
  7. 【Webpack Plugin】写了个插件跟喜欢的女生表白,结果.....
  8. 从构建产物洞悉模块化原理
  9. Webpack深度进阶:两张图彻底讲明白热更新原理!
  10. Esbuild深度调研:吹了三年,能上生产了吗?