灏天阁

webpack5之Loader和Plugin的实现

· Yin灏

Loader 底层实现

我们之前已经在核心配置中提到了很多 Loader,比如style-loadercss-loadervue-loaderbabel-loader等等,那怎么实现一个自定义 Loader 呢,Loader 本质上是一个导出为函数的 JavaScript 模块,loader runner 库会调用这个函数,然后将上一个 loader 产生的结果或者资源文件传入进去。

现在我们开发一个自定义 loader,我们新建一个 loaders 目录,在新建一个 yj-loader.js

// loaders/yj-loader.js
module.exports = function (content, map, meta) {
  console.log(content);
  console.log(map);
  console.log(meta);
  return content;
};

该函数会接受三个参数

  • content: 资源文件的内容
  • map: sourcemap 相关的数据
  • meta: 一些元数据

下面我们从 loader 的引入路径,执行顺序,异步 loader,获取参数,实现一个 loader 这几个方面在探讨下。

引入路径

现在我们在 webpack 配置该自定义 loader

{
  test: /\.js$/,
  use: [
    './loaders/yj-loader',
  ]
}

可以看到,我们引入的自定义 loader 路径是相对路径,且基于 context 属性,但是如果我们依然希望可以直接去加载自己的 loader 文件,我们可以配置resolveLoader属性

{
  resolveLoader: {
    modules: ["node_modules", "./loaders"];
  }
}

该属性是用来配置 loader 的引入路径,默认是 node_modules,我们 node_modules 没有的话去找我们的 loaders 目录,可以在 module 属性后面添加我们的 loaders 目录,现在我们可以直接使用 loader

{
  test: /\.js$/,
  use: [
    'yj-loader',
  ]
}

执行顺序

之前在介绍 loader 时讲了 loader 执行的顺序是从数组最后往前执行,现在我们新建三个自定义 loader 来证明一下这个结果,我们新建 yj-loader01.js,yj-loader02.js,yj-loader03.js。并在每个 loader 打印

// yj-loader01.js
module.exports = function (content, map, meta) {
  console.log("loader01执行");
  return content;
};

// yj-loader02.js
module.exports = function (content, map, meta) {
  console.log("loader02执行");
  return content;
};

// yj-loader03.js
module.exports = function (content, map, meta) {
  console.log("loader03执行");
  return content;
};
// webpack
{
  test: /\.js$/,
  use: [
    'yj-loader01',
    'yj-loader02',
    'yj-loader03',
  ]
}

现在我们打包一下 npm run build

Snipaste_2022-03-26_19-52-21.png

可以看到 loader03 先执行,loader02 第二执行,loader01 最后执行。其实在 loader 中我们还可以配置一个 pitch-loader,我们修改下 loader

// yj-loader01.js
module.exports = function (content, map, meta) {
  console.log("loader01执行");
  return content;
};
module.exports.pitch = function () {
  console.log("pitch-loader01执行");
};

// yj-loader02.js
module.exports = function (content, map, meta) {
  console.log("loader02执行");
  return content;
};
module.exports.pitch = function () {
  console.log("pitch-loader02执行");
};

// yj-loader03.js
module.exports = function (content, map, meta) {
  console.log("loader03执行");
  return content;
};
module.exports.pitch = function () {
  console.log("pitch-loader03执行");
};

再重新执行打包

Snipaste_2022-03-26_19-56-40.png

可以看到 pitch-loader 是从 01 开始,这是什么原因呢,我们可以查看源码 loader-runner 这个库下面的 lib/LoaderRunner.js 这个文件。

Snipaste_2022-03-26_20-01-48.png

在执行 runLoaders 函数中先执行iteratePitchingLoaders这个函数,也就是说先执行 pitch-loader。

Snipaste_2022-03-26_20-04-50.png

并且在iteratePitchingLoadersloaderContext.loaderIndex++,并且递归执行iteratePitchingLoaders,执行完后才执行iterateNormalLoaders,也就是正常的 loader。

Snipaste_2022-03-26_20-07-07.png

往下看可以看到loaderContext.loaderIndex--,并执行iterateNormalLoaders。所以 loader 的执行顺序是按 loaderIndex 来执行的

总结:

  • runLoader 先优先执行 PitchLoader,在执行 PitchLoader 时进行 loaderIndex++
  • runLoader 之后会执行 NormalLoader,在执行 NormalLoader 时进行 loaderIndex–

那我们能否自定义执行顺序呢,可以,我们需要拆分成多个 Rule 对象,通过enforce来改变它们的顺序

enforce 一共有四种方式:

  • 默认所有的 loader 都是normal
  • 在行内设置的 loader 是inline
  • 可以通过 enforce 设置prepost
  1. Pitching  阶段: loader 上的 pitch 方法,按照  后置(post)、行内(inline)、普通(normal)、前置(pre)  的顺序调用
  2. Normal  阶段: loader 上的常规方法,按照  前置(pre)、普通(normal)、行内(inline)、后置(post)  的顺序调用。模块源码的转换, 发生在这个阶段。

现在我们将 loader02 设置 pre

{
  test: /\.js$/,
  use: [
    'yj-loader01'
  ],
},
{
  test: /\.js$/,
  use: [
    'yj-loader02',
  ],
  enforce: 'pre'
},
{
  test: /\.js$/,
  use: [
    'yj-loader03',
  ]
}

Snipaste_2022-03-26_20-16-51.png

现在可以看到 loader02 第一个执行了,pitch-loader02 也就变成了最后一个执行。

异步 loader

我们之前默认创建的 Loader 都是是同步的 Loader,这个 Loader 必须通过return或者this.callback 来返回结果,交给下一个 loader 来处理。通常在有错误的情况下,我们会使用this.callback

this.callback 的用法如下:

  • 第一个参数必须是 Error 或者 null
  • 第二个参数是一个 string 或者 Buffer
// yj-loader.js
module.exports = function (content, map, meta) {
  console.log("执行loader");
  return this.callback(null, content);
};

Snipaste_2022-03-26_20-21-37.png

我们现在使用 this.callback 的方式返回也是可以的,那有时候我们使用 Loader 时会进行一些异步的操作,我们希望在异步操作完成后,再返回这个 loader 处理的结果,这时候需要使用异步的 loader 了,loader-runner 已经给我们实现了this.async函数,我们使用如下

// yj-loader03.js
module.exports = function (content, map, meta) {
  console.log("执行loader03");
  const callback = this.async();
  setTimeout(() => {
    callback(null, content);
  }, 3000);
};

Snipaste_2022-03-26_20-27-33.png

现在依然能够按顺序打印出来,并且在打包过程中可以看到 loader03 打印后延迟了大概 3S 才打印 loader02 和 loader01。

获取参数

我们之前在使用 css-loader 或者 babel-loader 时配置了参数,那我们如何也能配置参数并获取到呢。我们可以通过一个 webpack 官方提供的一个解析库loader-utils,该库里面有一个getOptions方法能够帮助我们获取配置,而且该库在安装 webpack 时已自动帮我们安装。 修改我们的 loader 并在 loader 上添加参数

// webpack
{
  test: /\.js$/,
  use: [
    {
      loader: 'yj-loader03',
      options: {
        name: 'lyj',
        age: 18
      }
    }
  ]
},
// yj-loader-03.js
const { getOptions } = require("loader-utils");

module.exports = function (content, map, meta) {
  console.log("loader03执行");

  // 获取参数
  const options = getOptions(this);
  console.log(options);

  // 获取异步loader
  const callback = this.async();

  setTimeout(() => {
    callback(null, content);
  }, 3000);
};

Snipaste_2022-03-26_20-37-33.png

可以看到我们通过调用getOptions(this)获取到了参数,那如何校验传入到参数呢。我们可以通过一个 webpack 官方提供的校验库schema-utils,里面有validate方法用于验证参数,并且该库是在安装 webpack 时帮我们安装了。

现在我们需要一个校验规则文件,新建一个loader-schema.json

// loader-schema.json
{
  "type": "object", // 传入类型
  "properties": {
    // 属性
    "名字": {
      "type": "string",
      "description": "请输入您的姓名"
    },
    "age": {
      "type": "number",
      "description": "请输入您的年龄"
    }
  },
  "additionalProperties": true // 表示除了以上属性外还可以额外添加其他的属性
}
// yj-loader-03.js
const { getOptions } = require("loader-utils");
const { validate } = require("schema-utils"); // 用于验证loader传参
const loaderSchema = require("./loader-schema.json");

module.exports = function (content, map, meta) {
  console.log("loader03执行");

  // 获取参数
  const options = getOptions(this);
  console.log(options);

  // 参数校验
  validate(loaderSchema, options);

  // 获取异步loader
  const callback = this.async();

  setTimeout(() => {
    callback(null, content);
  }, 3000);
};

现在我们传参对 age 传入字符串并重新打包

Snipaste_2022-03-26_20-46-11.png

schema-utils帮助我们验证了参数并提示了描述,并阻断了构建,说明验证成功。

实现一个 loader

现在我们来实现一个简易的 markdown loader,安装 marked,highlight.js。直接上代码

// mkdown-loader.js
const marked = require("marked");
const hljs = require("highlight.js");

module.exports = function (content) {
  // 设置代码高亮
  marked.setOptions({
    highlight: function (code, lang) {
      return hljs.highlight(lang, code).value;
    },
  });

  // 转成html
  const htmlContent = marked(content);

  // 转成模块化导出
  const innerContent = "`" + htmlContent + "`";
  const moduleCode = `var code = ${innerContent};export default code;`;
  console.log(moduleCode);
  return moduleCode;
};
// webpack loader配置
{
  test: /\.md$/,
  use: 'mkdown-loader'
}
// test.md

# loader 实现

## 引入路径

## 执行顺序

## 异步 loader

    ```
    module.exports = function(content, map, meta) {
      console.log('执行loader03')
      const callback = this.async()
      setTimeout(() => {
        callback(null, content)
      }, 3000)
    }
    ```

## 参数获取
// main.js
import mdContent from "./test.md";
import "highlight.js/styles/default.css";
document.body.innerHTML = mdContent;

重新打包后我们可以看到页面上已经出现了我们的 mkdown 编译内容

Snipaste_2022-03-26_21-11-47.png

Plugins 底层实现

webpack 有两个非常重要的类:CompilerCompilation,他们通过注入插件的方式监听 webpack 的整个过程,插件的注入离不开 hooks,而这些 hooks 是由官方维护的一个Tapable库管理的,因此我们需要先来弄清楚这个库的使用方式。

Tapable

我们可以源码查看下Tapable导出的 hooks,包含了以下几种

  • SyncHook
  • SyncBailHook
  • SyncWaterfallHook
  • SyncLoopHook
  • AsyncParallelHook
  • AsyncParallelBailHook
  • AsyncSeriesHook
  • AsyncSeriesBailHook
  • AsyncSeriesLoopHook
  • AsyncSeriesWaterfallHook

Snipaste_2022-03-27_12-42-57.png

我们可以将Tapable的 hooks 分为同步和异步,

  • 以 sync 开头的,是同步的 Hook
  • 以 async 开头的,两个事件处理回调,不会等待上一次处理回调结束后再执行下一次回调

我们也可以按其他的类别分类

  • bail: 当有返回值时,就不会执行后续的事件触发了
  • Loop: 当返回值为 true,就会反复执行该事件,当返回值为 undefined 或者不返回内容,就退出事件
  • Waterfall: 当返回值不为 undefined 时,会将这次返回的结果作为下次事件的第一个参数
  • Parallel: 并行,会同时执行次事件处理回调结束,才执行下一次事件处理回调
  • Series: 串行,会等待上一是异步的 Hook

我们简单使用下Tapable

1.编写一个 tapable 测试文件

// tapable-test.js
const { SyncWaterfallHook } = require("tapable");

class MyTapable {
  constructor() {
    this.hooks = {
      syncWaterfallHook: new SyncWaterfallHook(["myName", "myAge"]),
    };
    this.on();
  }
  // 注册
  on() {
    this.hooks.syncWaterfallHook.tap("myTap1", (name, age) => {
      console.log("myTap1", name, age);
      return "123";
    });
    this.hooks.syncWaterfallHook.tap("myTap2", (name, age) => {
      console.log("myTap2", name, age);
    });
  }
  // 初始化
  emit() {
    this.hooks.syncWaterfallHook.call("lyj", 18);
  }
}

const tapable = new MyTapable();
tapable.emit();

2.执行tapable-test.js

node tapable-test.js

3.打印结果

Snipaste_2022-03-27_12-58-59.png

可以看到第一个注册 hook 将return 123返回给了第二个 hook 的第一个参数

plugin 注册原理

在 webpack 中到底是怎么注册插件的呢,我们可以通过源码查看

Snipaste_2022-03-27_13-18-34.png

  1. 在调用 webpack 函数的 createCompiler 方法中,注册所有的插件
  2. 在注册插件时,会调用插件函数或者插件对象的 apply 方法
  3. 插件方法会接收 compiler 对象,我们可以通过 compiler 对象来注册 Hook 的事件
  4. 某些插件也会传入一个 compilation 的对象,我们也可以监听 compilation 的 Hook 事件

实现一个 plugin

我们实现一个打包构建目录后自动上传至服务器的插件AutoUploadPlugin

const { NodeSSH } = require("node-ssh");

class AutoUploadPlugin {
  constructor(options) {
    this.ssh = new NodeSSH();
    this.options = options;
  }

  apply(compiler) {
    compiler.hooks.afterEmit.tapAsync(
      "AutoUploadPlugin",
      async (compilation, callback) => {
        // 1.获取输出的文件夹
        const outputPath = compilation.outputOptions.path;

        // 2.连接服务器(ssh连接)
        await this.connectServer();

        // 3.删除原来目录中的内容
        const serverDir = this.options.remotePath;
        await this.ssh.execCommand(`rm -rf ${serverDir}/*`);

        // 4.上传文件到服务器(ssh连接)
        await this.uploadFiles(outputPath, serverDir);

        // 5.关闭ssh
        this.ssh.dispose();
        callback();
      }
    );
  }

  async connectServer() {
    await this.ssh.connect({
      host: this.options.host,
      username: this.options.username,
      password: this.options.password,
    });

    console.log("连接成功~");
  }

  async uploadFiles(localPath, remotePath) {
    const status = await this.ssh.putDirectory(localPath, remotePath, {
      recursive: true,
      concurrency: 10,
    });
    console.log("传送到服务器: ", status ? "成功" : "失败");
  }
}

module.exports = AutoUploadPlugin;

使用该插件

// webpack
{
  plugins: [
    //...
    new AutoUploadPlugin({
      host: "xxx.xxx.xxx.xxx",
      username: "xxx",
      password: "xxx",
    }),
  ];
}

- Book Lists -