webpack中loader机制解析

1.webapck中loader机制

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 的组合和扩展。

了解完导出函数的签名之后,我们就可以定义一个简单的 simpleLoader:

function simpleLoader(content, map, meta) {
console.log(“我是 SimpleLoader”);
return content;
}
module.exports = simpleLoader;
以上的 simpleLoader 并不会对输入的内容进行任何处理,只是在该 Loader 执行时输出相应的信息。有了自定义 Loader 后,该如何在 Webpack 中使用呢?

2. 在 Webpack 中如何使用自定义 Loader?

在 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下查找。

3. Loader的四种类型

Loader 按类型分可以分为四种:、、、。

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

举个简单的🌰:

module: {
rules: [
{
test: /.css$/,
use: [“css-loader”],
},
],
},
上面对 .css 文件的解析中用到的 中并没有指定 属性,那这个 就是普通(normal)类型的 Loader,而当配置 enforce: “pre” 后,该 Loader 就变成前置(pre)类型的 Loader。

module: {
rules: [
{
test: /.css$/,
use: [“css-loader”],
enforce: “pre”, //这里也可以是post,默认不写就是normal
},
],
},
这里特殊一点的是行内(inline) Loader,平时一般用的比较少,先眼熟一下,后面会详细讲。它长这样:

import xxx from “inline-loader1!inline-loader2!/src/xxx.css”;
这就表示用 inline-loader1 和 inline-loader2 这两个 Loader 来解析引入的文件。

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

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

Pitching 阶段: Loader 上的 pitch 方法,按照 后置(post)、行内(inline)、普通(normal)、前置(pre) 的顺序调用。
Normal 阶段: Loader 上的 常规方法,按照 前置(pre)、普通(normal)、行内(inline)、后置(post) 的顺序调用。模块源码的转换, 发生在这个阶段。
同等类型下的 Loader 执行顺序才是由右向左,或者由下到上执行。
理论说完,接下来讲一个实际的应用场景:在项目开始构建之前,为了更早的发现错误,一般会先进行 eslint 校验。这个时候就需要前置(pre) Loader,如果在前置 Loader 中发现了错误那就提前退出构建:

module: {
rules: [
{
test: /.jsKaTeX parse error: Expected 'EOF', got '}' at position 82: …js文件进行校验 }̲, { …/,
use: [“babel-loader”],
},
],
},
这里顺带引出一个很有意思的思考题:像上面这样配置前置 Loader 去校验文件,它是在编译前先校验所有的 .js 文件再编译,还是校验一个编译一个呢?这样真的能够更早的发现错误吗?

答案:校验一个编译一个,至于原因下一篇文章会有讲解。

4. Normal Loader 和 Pitching Loader

5.1、Normal Loader
Loader 本质上是导出函数的 JavaScript 模块,而该模块导出的函数(若是 ES6 模块,则是默认导出的函数)就被称为 。需要注意的是,这里说的 与 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

详解loader
hello
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文件夹中打包后的内容:

在这里插入图片描述
在这里插入图片描述

我们接着照葫芦画瓢再写两个自定义 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阶段从右向左执行的说法。

在这里插入图片描述
在这里插入图片描述
5.2、Pitch Loader
其实我们在导出的 Loader 函数上还有一个可选属性:pitch。它的值也是一个函数,该函数就被称为 。

我们可以在这个函数中做一些事情,在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;
配置完成后我们再次运行 yarn build 启动编译:
在这里插入图片描述
由此,我们可以得出结论:在 Loader 的运行过程中,如果发现该 Loader 上有pitch属性,会先执行 pitch 阶段,再执行 normal 阶段。
在这里插入图片描述
如果此时再结合上之前所讲的四种类型:前置(pre)、普通(normal)、行内(inline)、后置(post),则执行顺序为:
在这里插入图片描述
此时再看之前的结论,是不是更清晰明了。

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

当一个 Loader 的 pitch 阶段有返回值时,将跳过后续 Loader 的 pitch 阶段,直接进行到该 Loader 的 normal 阶段。

举个例子🌰:如果 BLoader 的 pitch 阶段有返回值,将直接进入到 ALoader 的 normal 阶段。
在这里插入图片描述
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;
运行结果:
在这里插入图片描述
在这里插入图片描述
这里可能有同学要问了,Loader 不就是为了处理文件的吗,这里文件直接都不读了,那么 Loader 的意义在哪里?

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

六、Pitch阶段的参数解析
PitchLoader 内部有三个很重要的参数:PreviousRequest、CurrentRequest、remainingRequest,它们分别代表不同纬度的 Loader 数组。

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

在这里插入图片描述
现在执行到了 loader3,那么PreviousRequest代表的是之前执行过pitch阶段的loader:loader1 和 loader2。

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

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

其中remainRequest和PreviousRequest作为pitchLoader作为 pitch函数的默认参数,这里官方有介绍:

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”);
};

5. Loader的内联方式

在某些情况下,我们对一个类型的文件配置了多个 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;
正常情况下,此时的运行顺序为:
在这里插入图片描述
如果此时想指定执行某些类型的 Loader,忽略掉其他类型应该怎么办?

(通过为内联 import 语句添加前缀):

src/index.js

import test from “!c-loader!./test.js”;

const a = 1;
此时 Loader 的执行顺序就变成了(忽略掉了 normal类型的 ALoader):
在这里插入图片描述
使用!! 前缀,将禁用其他类型的loader,只要内联loader
import test from “!!c-loader!./test.js”;

const a = 1;
此时loader的执行顺序就变成了:
在这里插入图片描述
** 使用 -!前缀, 将禁用所有已配置的 preLoader 和 loader,但是不禁用 postLoaders,也就是不要 pre 和 normal loader:**
import test from “-!c-loader!./test.js”;

const a = 1;
此时 Loader 的执行顺序就变成了(演示中没有 preLoader):
在这里插入图片描述
原理实现其实挺简单的:只是按标识符做了一个过滤。接下来我们通过以下代码简单了解一下其中的原理:

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: /.jsKaTeX parse error: Expected 'EOF', got '}' at position 72: …normalLoader }̲, { test:…/,
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,
“读到的原始的文件”
); //读到的原始的文件
}
);

6. 实现loader-runner

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

loader-runner 到现在我们已经完整的知道了 Loader 的运行机制,接下来将进一步深挖该库的源码。
会导出核心函数runLoaders,runLoaders接受两个参数:option参数对象和执行完成后的回调函数,在回调函数的默认参数中可以查看源代码等信息。
import { runLoaders } from “loader-runner”;

runLoaders({
resource: “资源的绝对路径”, //要解析资源的绝对路径
loaders: [“loader的绝对路径”],//loader的绝对路径,这里可以放多个
context: { minimize: true },//路径上下文
}, function(err, result) {
//err:错误信息 result:输出解析后的结果
})
整体思路:
在这里插入图片描述
6.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;
6.2 执行pitch阶段的loaders
该步骤的核心思想:

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

6.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
);
});
}
6.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”);
}
}
6.5 执行最终回调函数
function runLoaders(option, finalCallback) {
//省略其他代码

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

7 .实战演练

7.1 手写babel-loader
先安装 babel 的一系列依赖:

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

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

现在再查看效果:源代码中的箭头函数已经被转换成普通函数。
在这里插入图片描述
7.2 手写less-loader
在开发换环境下,我们对 .less文件解析时一般会用到三个 Loader :less-loader,css-loader,style-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
],
},
],
}
整体流程图:
在这里插入图片描述
这里因为没有 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;
7.3 手写css-loader
css-loader 其实它的核心会做两件事:

解析@import语法
解析url中的路径
这里我们不展开,只做一个返回即可。
loaders/css-loader.js

function cssLoader(css) {
return css;
}

module.exports = cssLoader;
7.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 !!! 你上你也行。
在这里插入图片描述
7.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 阶段。
在这里插入图片描述
而 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”)进行解析时,走的是这个逻辑:

在这里插入图片描述
最后返回的内容是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,此时的执行顺序变为了:
在这里插入图片描述
因为 css-loader 和 less-loader 没有 pitch阶段,因此只走了后面的逻辑,即将源文件交给 less-loader 和 css-loader 执行,此时 less-loader 解析后返回 css,css-loader 解析后返回:

module.exports=“#root{color:red}”
7.6 为什么要这么处理?
当我们希望把两个 Loader 进行联合使用的时候,就需要使用这种方式。因为 css-loader 返回的是js文本,但 style-loader 要的是 css文本,只能用 require 加载这个js文件模块,得到导出结果才是css代码。

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

8. 总结

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值