理解Webpack插件机制:从插件使用、各类编译对象、Tapable到自定义插件与钩子开发

前面我们介绍了Webpack中的loader,它主要是用在模块引入时转换使用。除了loader之外,Webpack还有一种更强大的外部插入工具,这就是插件Plugin。

插件可以深入到Webpack中的打包和编译过程,在Webpack打包的不同阶段时,触发相应的钩子函数,从而实现对构建过程的定制与扩展。

Plugin使用示例

为了了解Plugin的作用和使用方式,我们举例一些现有的知名Plugin。

HtmlWebpackPlugin

在Webpack中,入口文件和生成文件都是JavaScript,但作为一个前端工程,入口文件应该是HTML。我们可以自己手写一个HTML入口文件,但如果是多入口,或者文件名根据内容变化等场景,我们手写HTML入口文件就会变的非常麻烦。HtmlWebpackPlugin插件可以自动生成HTML入口文件,帮我们免去烦恼。首先我们创建两个JavaScript入口文件:

js 复制代码
// src/index.js
import "./index.css";
import { abc } from "./index.module.scss";
import data from "./index.xml";

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}
genEle("jzplp1", "qaz");
genEle("jzplp2", abc);
console.log(data);

// src/another.js
console.log("another");

入口文件中引入了两种CSS文件,分别为普通CSS文件和SCSS的CSS Modules,这里分别列出:

css 复制代码
/* src/index.css */
.qaz {
  color: blue;
}

/* src/index.module.scss */
.abc {
  color: red;
}

然后是index.xml的内容:

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<note>
  <to>jzplp1</to>
  <from>jzplp2</from>
</note>

最后是Webpack配置,这里我们使用了多个JavaScript入口,以及hash文件名,这时候手动生成HTML入口文件确实不方便,因此引入了HtmlWebpackPlugin插件。

js 复制代码
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  mode: "production",
  // 多个入口
  entry: {
    index: "./src/index.js",
    another: "./src/another.js",
  },
  output: {
    clean: true,
    // 根据入口文件名和内容hash生成文件名
    filename: "[name]_[contenthash].js",
    path: path.resolve(__dirname, "dist"),
  },
  module: {
    rules: [
      {
        test: /\.xml$/,
        use: "xml-loader",
      },
      {
        test: /\.scss$/,
        use: ["style-loader", "css-loader", "sass-loader"],
      },
      {
        test: /\.css$/,
        use: ["style-loader", "css-loader"],
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: "jzplp-test",
    }),
  ],
};

我们看一下打包生成文件和浏览器效果,可以看到入口文件确实都被自动引入到了HTML中,且在浏览器上正常展示。

TerserWebpackPlugin

TerserWebpackPlugin是一个压缩JS代码的Webpack插件。它已经被内置在Webpack中作为压缩代码工具默认启用,但如果希望修改配置,依然要手动引入插件。因为已经内置+生成代码优化相关功能统一配置,因此这个插件不在plugins中配置。下面我们举个例子:

js 复制代码
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const TerserWebpackPlugin = require("terser-webpack-plugin");

module.exports = {
  mode: "production",
  // 多个入口
  entry: {
    index: "./src/index.js",
    another: "./src/another.js",
  },
  output: {
    clean: true,
    filename: "[name]_[contenthash].js",
    path: path.resolve(__dirname, "dist"),
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: "jzplp-test",
    }),
  ],
  optimization: {
    minimize: true,
    minimizer: [new TerserWebpackPlugin({
        exclude: /index/,
    })],
  },
};

TerserWebpackPlugin是在optimization.minimizer中配置,由于默认是开启的,因此我们测试下开启和关闭(exclude)的打包生成代码区别。可以看到,一个被压缩了,一个没有被压缩。

js 复制代码
// 开启效果
!(function (e, t) {
  const n = document.createElement("div");
  ((n.className = t), (n.textContent = e), document.body.appendChild(n));
})("jzplp1", "");

// 关闭效果
/******/ (() => { // webpackBootstrap
function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}
genEle("jzplp1", "");
/******/ })()
;

ProgressPlugin

ProgressPlugin是一个报告编译进度的插件,接收一个函数,当编译进度更新时就会触发回调函数。

js 复制代码
const webpack = require("webpack");
module.exports = {
  // ...其它配置
  plugins: [
    new webpack.ProgressPlugin((percentage, message, ...args) => {
      console.log("---", percentage, message, args);
    }),
  ],
};

ProgressPlugin是一个Webpack内置插件,因此我们不需要引入别的包就可以使用。回调参数中percentage是一个0-1之间的数字,表示进度。message是当前执行内容的简要描述。执行打包,输出如下:

js 复制代码
--- 0  []
--- 0.01 setup [ 'before run' ]
--- 0.01 setup [ 'before run', 'NodeEnvironmentPlugin' ]
--- 0.01 setup [ 'before run' ]
--- 0.02 setup [ 'run' ]
// 省略大部分输出
--- 0.99 cache [ 'shutdown' ]
--- 0.99 cache [ 'shutdown' ]
--- 1  []

MiniCssExtractPlugin

前面我们使用style-loader时,创建的CSS代码是被包含在JS代码内的,以JavaScript的方式引入。使用MiniCssExtractPlugin插件,可以将CSS代码创建为一个独立的CSS文件。配合HtmlWebpackPlugin使用时,CSS文件用link标签直接引入。

js 复制代码
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const webpack = require("webpack");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");

module.exports = {
  mode: "production",
  // 多个入口
  entry: {
    index: "./src/index.js",
    another: "./src/another.js",
  },
  output: {
    clean: true,
    // 根据入口文件名和内容hash生成文件名
    filename: "[name].js",
    path: path.resolve(__dirname, "dist"),
  },
  module: {
    rules: [
      {
        test: /\.scss$/,
        use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"],
      },
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, "css-loader"],
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: "jzplp-test",
    }),
    new MiniCssExtractPlugin(),
  ],
};

注意我们除了新建MiniCssExtractPlugin插件外,还把style-loader替换成插件自带的loader了。生成结果如下。

js 复制代码
// dist/index.js
(() => {
  "use strict";
  function e(e, t) {
    const n = document.createElement("div");
    ((n.className = t), (n.textContent = e), document.body.appendChild(n));
  }
  (e("jzplp1", "qaz"), e("jzplp2", "fSAWgreSNL09jvgiZJuV"), console.log(data));
})();

// dist/another.js
console.log("another");

然后是dist/index.css文件。注意我们引入了多种CSS/SCSS文件,但最后合并生成为了一个。

css 复制代码
.qaz {
  color: blue;
}
.fSAWgreSNL09jvgiZJuV{color:red}

如果不使用HtmlWebpackPlugin插件,生成的结果就只有上面的文件,CSS和JS文件是分离的。如果使用了HtmlWebpackPlugin插件会多一个dist/index.html将上面的文件都引入。

html 复制代码
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>jzplp-test</title>
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <script defer="defer" src="index.js"></script>
    <script defer="defer" src="another.js"></script>
    <link href="index.css" rel="stylesheet" />
  </head>
  <body></body>
</html>

钩子和生命周期

新建插件和钩子用法

插件是一个JavaScript类,这个类需要有一个apply方法,Webpack在注入插件的时候会调用。如果需要接收插件参数,则需要从构造函数中接收。

js 复制代码
const pluginName = "JzplpPlugin";

module.exports = class JzplpPlugin {
  options = {};
  constructor(options) {
    console.log('constructor')
    // 获取插件参数并保存
    this.options = options;
  }

  apply(compiler) {
    console.log("apply", this.options);
    // 钩子
    compiler.hooks.compile.tap(pluginName, () => {
      console.log("hook compile");
    });
  }
};

apply方法接收一个compiler参数,在它上面可有很多hooks钩子,我们可以编写触发相应钩子的回调函数。这里再看下Webpack配置和执行结果。

js 复制代码
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const webpack = require("webpack");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const JzplpPlugin = require("./plugin/jzplpPlugin");

module.exports = {
  /// 其它Webpack配置
  plugins: [
    new JzplpPlugin({ abc: 123 }),
    // 其它Webpack插件
  ]
};

/* 打包时命令行输出
constructor
apply { abc: 123 }
hook compile
*/

Webpack提供了非常多的钩子,我们在后面会介绍。有些钩子只能同步方式回调,有些可以异步方式,对应三种tap方法,这里以代码形式举例说明:

js 复制代码
const pluginName = "JzplpPlugin";

module.exports = class JzplpPlugin {
  apply(compiler) {
    // tap 同步回调
    compiler.hooks.compile.tap(pluginName, () => {
      console.log("hooks tap");
    });

    // tapAsync 异步回调
    compiler.hooks.run.tapAsync(pluginName, (data, callback) => {
      setTimeout(() => {
        console.log("hooks tapAsync");
        callback();
      }, 1000);
    });

    // tapPromise 异步回调
    compiler.hooks.make.tapPromise(pluginName, () => {
      return new Promise((resolve) =>
        setTimeout(() => {
          console.log("hooks tapPromise Promise");
          resolve();
        }, 1000),
      );
    });

    // tapPromise 异步回调 async
    compiler.hooks.emit.tapPromise(pluginName, async () => {
      await new Promise((resolve) =>
        setTimeout(() => {
          resolve();
        }, 1000),
      );
      console.log("hooks tapPromise async");
    });
  }
};

/* 打包时命令行输出
hooks tapAsync
hooks tap
hooks tapPromise Promise
hooks tapPromise async
*/

compiler编译器钩子

Webpack提供了很多插件钩子,这些钩子实际上对应Webpack打包过程中的生命周期过程。这里我们简述一下插件钩子和生命周期关系。首先介绍compiler编译器钩子。

1.初始化阶段

钩子名称 含义和触发时机
environment 初始化环境配置
afterEnvironment 环境准备完成后触发
entryOption webpack配置的entry处理之后
afterPlugins 所有插件初始化之后
afterResolvers 所有模块解析器(Resolver)初始化之后

2.启动阶段

钩子名称 含义和触发时机
initialize 当编译器对象被初始化时触发
beforeRun 开始构建之前触发
run 构建正式开始
watchRun 监听模式下,每次重新构建之前触发

3.编译阶段

钩子名称 含义和触发时机
beforeCompile 创建Compilation实例前,可修改参数
compile 创建Compilation实例前,不可修改参数
thisCompilation 初始化Compilation时
compilation 初始化Compilation之后
make Compilation创建完毕,生命周期开始前
afterCompile Compilation编译过程结束后

4.输出阶段

钩子名称 含义和触发时机
shouldEmit 在输出产物之前调用,返回true/false决定是否输出
emit 在输出全部产物到文件目录之前
assetEmitted 每一个文件输出后触发
afterEmit 在输出全部产物到文件目录之后

5.结束阶段

钩子名称 含义和触发时机
done 编译完成之后
failed 编译失败之后
invalid 监听模式下,一次编译失效后(可能来源于文件变更导致的失效,不是编译失败)
watchClose 监听模式关闭
shutdown 编译器关闭

这些钩子虽然很多,而且非常细致,进行一个步骤的前前后后有多个钩子顺序触发。这些钩子可能是同步的,可能是异步的,会有不同的参数和返回值,这里就不一一描述了,可以查看Webpack文档。编译阶段实际上并没有做编译工作,而是创建Compilation,由它来完成编译。我们会在后面介绍Compilation相关使用和钩子。

compilation编译过程钩子

在前面的钩子列表介绍中,我们介绍了compiler编译器钩子。但它实际上并不负责真正的编译任务,而是创建compilation编译过程,由它来负责真正的编译工作。在生产模式打包过程中,compiler只会触发一次,compilation一般也只触发一次。但在监听模式下,compilation会触发多次。下面我们介绍一下compilation编译过程的相关钩子和生命周期关系。

1.构建阶段

钩子名称 含义和触发时机
buildModule 单个模块构建开始之前触发
succeedModule 单个模块构建成功触发
failedModule 单个模块构建失败触发
finishModules 所有模块都完成构建且没有错误时触发

2.优化阶段

钩子名称 含义和触发时机
seal 停止接收新的模块,开始优化阶段
optimizeDependencies 依赖优化开始时触发
afterOptimizeDependencies 依赖优化之后触发
optimize 优化阶段开始时触发
optimizeModules 模块优化阶段开始时触发
afterOptimizeModules 模块优化完成之后触发
optimizeChunks chunk优化开始时触发
afterOptimizeChunks chunk优化完成之后触发
optimizeTree 优化依赖树之前触发
afterOptimizeTree 优化依赖树之后触发
optimizeChunkModules 优化Chunk内的模块开始时触发
afterOptimizeChunkModules 优化Chunk内的模块完成之后触发

3.Record阶段

钩子名称 含义和触发时机
shouldRecord 返回值决定是否存储record
reviveModules 从record中恢复模块信息
beforeModuleIds 为每个模块分配id之前
moduleIds 为每个模块分配一个id
optimizeModuleIds 在模块id优化开始时调用
afterOptimizeModuleIds 在模块 id 优化完成时调用
reviveChunks 从record中恢复chunk信息
beforeChunkIds 在为每个chunk分配id之前
chunkIds 为每个chunk分配id时
optimizeChunkIds chunk id优化阶段开始时
afterOptimizeChunkIds chunk id优化结束之后
recordModules 将模块信息存储到record中
recordChunks 将chunk存储到record中
beforeModuleHash 在创建模块哈希之前
afterModuleHash 在创建模块哈希之后
beforeHash 在compilation添加哈希之前
afterHash 在compilation添加哈希之后
recordHash record的信息存储到records中
record 将compilation相关信息存储到record中

4.资源生成和优化阶段

钩子名称 含义和触发时机
beforeModuleAssets 在创建模块asset之前
shouldGenerateChunkAssets 是否生成chunk asset,返回false不生成
beforeChunkAssets 在创建 chunk asset 之前
additionalAssets 创建额外asset
optimizeAssets 优化存储在compilation.assets中的所有asset
afterOptimizeAssets asset优化之后
processAssets 处理和修改asset,其中包含多个步骤
afterProcessAssets processAssets完成后

5.资源密封和后处理阶段

钩子名称 含义和触发时机
needAdditionalSeal compilation是否需要解除seal以引入其他文件
afterSeal 在needAdditionalSeal之后
chunkHash 为每个chunk生成hash
moduleAsset 单个模块生成单个asset,被添加到compilation时
chunkAsset 单个chunk生成单个asset,被添加到compilation时
assetPath 决定asset的路径
needAdditionalPass 决定asset在输出后是否需要进一步处理
childCompiler 子compiler设置之后

通过上面的钩子可以看到,构建,优化,缓存,生成module,chunk和assets等,都是在compilation编译过程中运行的。compiler和compilation钩子的含义和触发时机涉及到Webpack整个生命周期和构建优化流程,要想理解需要深入Webpack各种知识和源码,是一个较大的主题,因此这里就不详细介绍了。

使用编译过程钩子

插件在执行apply方法时,compilation对象还没有创建,因此无法监听钩子。可以通过监听compiler钩子,在回调中拿到compilation对象,然后再监听compilation钩子。

js 复制代码
const pluginName = "JzplpPlugin";

module.exports = class JzplpPlugin {
  apply(compiler) {
    compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
      console.log("compiler thisCompilation");

      compilation.hooks.finishModules.tap(pluginName, () => {
        console.log("compilation finishModules");
      });
      compilation.hooks.optimize.tap(pluginName, () => {
        console.log("compilation optimize");
      });
    });
  }
};

/* 打包时命令行输出
compiler thisCompilation
compilation finishModules
compilation optimize
*/

获取compilation对象最常用的compiler钩子是thisCompilation和compilation,这两个在compilation对象创建时触发。其中thisCompilation时间更早。其它compiler钩子也能拿到compilation对象,但是由于它们触发时间较晚,晚于一些compilation钩子的时间,即使我们监听了,也不会被触发。例如make, afterCompile, emit等钩子。但是这些钩子拿到compilation对象后,可以访问对象中挂载的数据,因此还是有意义的。

Node.js脚本和各类对象

在真正开始插件开发之前,我们先来看一下如何在Node.js中使用Webpack。为什么要先了解这个?因为Node.js中使用Webpack,用的正是我们前面介绍的compiler对象。了解如何使用Webpack,对后面的插件开发也有帮助。

编译脚本

首先创建文件script/a.js,里面放置使用Webpack编译的脚本。

js 复制代码
const webpack = require('webpack');
const config = require("../webpack.config.js");

webpack(config, (err, stats) => {
  console.log("打包成功!");
});

引入webpack后,当作一个函数调用,第一个参数为打包配置,注意这里Webpack不会自动读入webpack.config.js文件,需要我们手动引入并传给Webpack。第二个参数是一个回调函数,无论打包成功还是失败都会触发回调。命令行执行node script/a.js之后,可以看到一致的打包结果。

打包失败可以分为两种类型。第一种是编译失败,这种错误不会在err中展示,而是放到stats中。第二种是非编译错误,例如Webpack内部错误,我们传的配置不对,或者Webpack插件引发报错等,这种错误会中断打包流程,在err中展示。

js 复制代码
const webpack = require("webpack");
const config = require("../webpack.config.js");

webpack(config, (err, stats) => {
  if (err) {
    console.error(err.stack || err);
    return;
  }
  const info = stats.toJson();

  if (stats.hasErrors()) {
    console.log("stats error", info.errors);
    return;
  }

  if (stats.hasWarnings()) {
    console.log("stats warn", info.warnings);
  }
  console.log("打包成功!");
});

这里输出了几种错误类型。我们故意引发错误试一下:

js 复制代码
// 传入错误配置: webpack('', (err, stats) => {
PS E:\testProj\webpack-plugin\use-plugin> node script/a.js
ValidationError: Invalid configuration object. Webpack has been initialized using a configuration object that does not match the API schema.
 - configuration should be an object:
...省略

// 自定义插件引发异常 apply(compiler) { throw new Error(); }
PS E:\testProj\webpack-plugin\use-plugin> node script/a.js
Error
    at JzplpPlugin.apply (E:\testProj\webpack-plugin\use-plugin\plugin\jzplpPlugin.js:5:11)
    at createCompiler (E:\testProj\webpack-plugin\use-plugin\node_modules\webpack\lib\webpack.js:102:12)
...省略

// 编译错误,代码中的文件名不对 import { abc } from "./index.module.scss1";
PS E:\testProj\webpack-plugin\use-plugin> node script/a.js
stats error [
  {
    moduleIdentifier: 'E:\\testProj\\webpack-plugin\\use-plugin\\src\\index.js',
    moduleName: './src/index.js',
    loc: '2:0-43',
    message: "Module not found: Error: Can't resolve './index.module.scss1' in 'E:\\testProj\\webpack-plugin\\use-plugin\\src'",
...省略

使用compiler对象

如果不对webpack对象传入回调函数,webpack对象会返回一个Compiler的实例。这个实例实际上就是插件中的那个Compiler对象。对这个对象调用run方法,参数和效果与前面编译脚本一致。

js 复制代码
const webpack = require("webpack");
const config = require("../webpack.config.js");

const compiler = webpack(config);
compiler.run((err, stats) => {
  console.log("打包成功!");
});

除了run方法之外,compiler对象中还有一些方法,例如watch监听模式,close关闭编译器,保存缓存等。这里拿到的compiler对象,和在插件里一样可以使用各种钩子,也可以使用compilation钩子。

js 复制代码
const webpack = require("webpack");
const config = require("../webpack.config.js");

const pluginName = "JzplpPlugin";
const compiler = webpack(config);

compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
  console.log("compiler thisCompilation");

  compilation.hooks.finishModules.tap(pluginName, () => {
    console.log("compilation finishModules");
  });
  compilation.hooks.optimize.tap(pluginName, () => {
    console.log("compilation optimize");
  });
});

compiler.run((err, stats) => {
  console.log("打包成功!");
});

/* 打包时命令行输出
compiler thisCompilation
compilation finishModules
compilation optimize
打包成功!
*/

stats对象

编译时回调函数入参的stats对象,我们前面构建脚本中使用它来判断构建过程是否有错误,事实上它有很多属性和方法,首先我们列举一下主要方法。

  • stats.hasErrors() 返回编译过程是否有错误
  • stats.hasWarnings() 返回编译过程是否有警告
  • stats.toJson(options) 以JSON对象形式返回编译信息
  • stats.toString(options) 以字符串形式返回编译信息

其中toJson和toString的参数一致,包含字符串参数预设(Stats Presets)和参数对象等形式,可以查看Webpack文档中Stats对象参数部分,这里就不描述了。stats保存了大量代码编译过程中的信息,这里我们使用toJson方法输出对象,看一下都包含了哪些信息:

js 复制代码
const webpack = require("webpack");
const config = require("../webpack.config.js");

const compiler = webpack(config);
compiler.run((err, stats) => {
  const info = stats.toJson();
  console.log(JSON.stringify(info));
});

然后是输出结果。由于输出结果很长,因此这里只截取了部分输出信息。

json 复制代码
{
  "hash": "317735e3c5acfb30d4b5", // 一次编译的哈希值
  "version": "5.107.2", // webpack版本
  "time": 742, // 编译时间(毫秒)
  "builtAt": 1781607761628, // 构建时间戳
  "outputPath": "E:\\testProj\\webpack-plugin\\use-plugin\\dist", // 输出目录路径
  "assetsByChunkName": { // 输出资源对应的Chunk名称
    "index": ["index.css", "index.js"],
    "another": ["another.js"]
  },
  "assets": [/* ... */], // asset列表
  "chunks": [/* ... */], // chunk列表
  "modules": [/* ... */], // module列表
  "entrypoints": {/* ... */}, // entry列表
  "namedChunkGroups": [/* ... */],
  "errors": [], // error列表
  "errorsCount": 0, // 错误个数
  "warnings": [], // warning列表
  "warningsCount": 0, // warning个数
  "children": [/* ... */] // 子编译器列表
}

其中children中每个元素是一个上述的结构,例如在本次打包中HtmlWebpackPlugin插件就创建了一个子编译器,用来生成HTML文件。stats内还有几个列表,我们分别介绍其中的属性。注意Webpack中文文档中的部分属性描述可能是错的。

json 复制代码
// asset属性
{
  "type": "asset",
  "name": "index.js", // 输出文件名
  "size": 631, // 文件大小(字节为单位)
  "emitted": true, // 资源文件是否要生成到output目录中
  "comparedForEmit": false, // 指定是否对该资源文件和输出文件系统上相同文件进行比较
  "cached": false, // 是否已缓存
  "info": {"javascriptModule": false, "minimized": true, "size": 631 },
  "chunkNames": ["index"], // 该资源文件包含的 chunks
  "related": [], //	关联文件信息
  "chunks": [57], // 该资源文件包含的 chunk ID
}

// chunk属性
{
  "rendered": true, // chunk是否输出到最终构建结果
  "initial": true, // chunk是在页面初始化时加载还是按需加载
  "entry": true, // 当前chunk是否包含了webpack运行时
  "recorded": false, // chunk是否已被记录到Webpack的持久化缓存中
  "size": 1131, // 整个chunk的总体积(字节)
  "sizes": { "javascript": 413, "css/mini-extract": 55, "runtime": 663 }, // 按资源类型细分的体积明细
  "names": ["index"], // 当前chunk的别名
  "runtime": ["index"], // 当前chunk所属的运行时环境名称
  "files": ["index.css", "index.js"], // chunk包含的文件名数组
  "hash": "25da9b3c7be8858ab78c", // 基于chunk内容生成的唯一哈希值
  "id": 57, // chunk对应的ID
  "siblings": [], // 与当前chunk同级生成的其他chunk
  "parents": [], // 生成当前chunk的父级chunk
  "children": [], // 当前chunk进一步分割出的子级chunk
  "modules": [/* ... */], // chunk包含的module列表 
  "origins": [/* ... */] // chunk的来源
},

// module属性
{
  "type": "module",
  "moduleType": "javascript/auto", // 模块类型
  "layer": null, // 构建层
  "size": 352, // 模块大小,单位为字节
  "sizes": { "javascript": 352 }, // 按具体资源类型细化该模块的体积组成
  "built": true, // 该模块是否实际执行了构建流程
  "codeGenerated": true, // 该模块是否生成了运行时代码
  "buildTimeExecuted": false, // 该模块是否在构建阶段就被执行了
  "cached": false, // 是否是持久化缓存
  "identifier": "E:\\testProj\\webpack-plugin\\use-plugin\\src\\index.js", // 模块标识符
  "name": "./src/index.js", // 实际文件的路径
  "nameForCondition": "E:\\testProj\\webpack-plugin\\use-plugin\\src\\index.js", // 用于条件匹配(Rule.issuer 或 Rule.resource)时使用的路径
  "index": 0, // 模块在所有模块列表中的全局排序索引
  "cacheable": true, // 是否允许被Webpack缓存
  "optional": false, // 其他模块在请求当前模块时,是否把这次请求标记为可选的
  "issuer": null, // 表示当前模块的父模块
  "issuerName": null, // 父模块路径
  "issuerPath": null, // 从入口模块到当前模块的完整路径
  "failed": false,  // 当前模块编译是否失败
  "errors": 0, // 处理模块时的错误个数
  "warnings": 0, // 处理模块时的警告个数
  "id": 44, // 模块ID
  "issuerId": null, // 父模块ID
  "chunks": [57], // 包含(引用了)当前模块的所有Chunk的ID列表
  "reasons": [/* ... */], // 其他模块请求当前模块的具体信息
  "usedExports": [], // 被其他模块实际使用到的导出名称
  "providedExports": [], // 该模块自己对外提供了哪些导出名称
  "depth": 0 // 该模块在依赖树中的嵌套深度
},

// entry属性
"index": {
  "name": "index", // 入口点的名称
  "chunks": [57], // 该入口点包含的所有 Chunk 的 ID 列表
  "assets": [ // 该入口点首屏加载时需要下载的主要资源列表
    { "name": "index.css", "size": 57 },
    { "name": "index.js", "size": 631 }
  ],
  "assetsSize": 688, // assets数组中所有资源的总体积(字节)
  "auxiliaryAssets": [], // 辅助资源列表
  "auxiliaryAssetsSize": 0, // auxiliaryAssets 数组中所有资源的总体积(字节)
  "children": {}, // 子编译器生成的入口点映射
},

重要对象关系

前面我们介绍stats对象中的属性,提到了asset属性,chunk属性,module属性,entry属性。这每一个对象都是Webpack中的一类重要数据。下面我们介绍一下它们的含义和对应关系。

  • entry 打包入口,即我们Webpack配置中的entry属性,是打包构建的起点。一次打包中可能出现多个属性。
  • module Webpack中一个源代码文件一般表示一个模块,像vue文件可以划分为3个子模块(模板/js/css)。
  • chunk Webpack内部的处理概念,表示一个代码片段集合。一个entry是一个chunk,动态导入或分隔也产生单独的chunk。
  • asset 一个asset代表一个要输出的文件

他们之间的关系如下:

  • Webpack打包是从entry开始,根据import关系生成依赖图,其中每个元素都是一个模块。一个entry中可以包含多个模块,不同的entry可以共享模块。
  • Webpack构建时会将多个的module合并成一个chunk。合并规则为一个entry生成一个chunk,动态导入,分隔/复用代码等会生成单独的chunk。
  • 一个chunk至少生成一个asset,像是sourcemap等场景,一个chunk会生成多个asset。一个asset不能合并多个chunk输出。

因此,实际上Webpack构建是分割,合并,再分割的过程。首先将entry入口分割成一个一个的模块;然后将模块合并成chunk;最后分割成asset文件输出。

compilation对象属性和方法

stats对象中,还能拿到compilation对象,以stats.compilation访问。事实上stats对象输出的部分打包数据就是从compilation对象中整理获取的。compilation对象中的数据大致有:

  • modules 编译中所有处理过的模块
  • chunks 编译中所有生成的Chunk
  • assets 编译中所有生成的asset
  • entrypoints 所有的入口点
  • errors 编译错误信息
  • warnings 编译警告信息
  • hash 本次编译的唯一标识哈希值
  • name 当前编译的名称
  • compiler 指向当前compiler对象的引用

可以看到,和stats输出的数据非常像,但内部结构和格式不一样。compilation对象上还有很多方法,但stats对象存在的回调函数触发时,已经是打包结束的状态了,因此不能操作这些方法。不过在插件开发中,还是可能用到的。这里列举一下compilation对象的部分方法:

  • getStats 返回当前编译的stats对象
  • addModule 添加一个模块
  • getModule 通过标识符获取模块
  • findModule 尝试通过标识符搜索模块
  • buildModule 构建给定的模块
  • processModuleDependencies 处理给定模块依赖
  • addEntry 添加入口
  • rebuildModule 触发模块重建
  • finish 完成编译回调
  • seal 封闭编译
  • unseal 解除封闭编译
  • reportDependencyErrorsAndWarnings 将给定模块的错误和警告添加到编译的错误和警告中
  • addChunkInGroup 将模块添加到现有chunk组或创建一个新的组
  • addChunk 创建或添加一个新的chunk
  • createChildCompiler 允许在webpack中运行另一个webpack实例
  • emitAsset 产出一个新的Asset
  • updateAsset 更新一个Asset
  • deleteAsset 删除一个Asset
  • getAssets 返回当前编译的所有Asset
  • getAsset 获取单个Asset
  • createHash 为本次构建生成唯一标识

compiler对象属性

compiler对象除了前面介绍的方法之外,还有一些属性,这些属性可能对于后面的插件开发是有帮助的。上面介绍过的方法和钩子这里就不再重复介绍了。

  • options Webpack配置对象,为用户配置+默认配置的结合
  • context 项目目录的绝对路径
  • inputFileSystem和outputFileSystem 读取源文件和写入输出文件的工具接口
  • webpack 当前编译使用的webpack对象
  • outputPath 最终资源的输出目录的绝对路径
  • records 打包后可读取,里面存放module和chunk的ID等信息
  • resolverFactory 用于创建 Resolver 实例的工厂对象
  • running 正在打包时读取为true,否则为false
  • cache 缓存管理器示例

其中options对象也就是我们配置的webpack.config.js的内容。但即使我们的配置项很少,这个选项也非常大,因为它合并了Webpack的默认配置项。

自定义插件开发

触发脚本

首先我们从最简单的插件开始,首先是在构建结束后,执行传入的脚本代码。首先是插件代码:

js 复制代码
const pluginName = "JzplpPlugin";

module.exports = class JzplpPlugin {
  options = {};
  constructor(options) {
    console.log('constructor')
    this.options = options;
  }
  apply(compiler) {
    compiler.hooks.done.tap(pluginName, () => {
      this.options?.func?.();
    });
  }
};

然后调用插件时,func参数中传入一个函数,当构建完成时函数代码就会被执行。

js 复制代码
// 使用方式
new JzplpPlugin({
  func: () => {
    console.log("jzplp func");
  },
}),

/* 打包时输出结果
jzplp func
*/

然后我们尝试使用插件实现output.clean的功能,即生成构建结果前,先把旧输出目录中的文件删除。

js 复制代码
const fs = require("fs");
const pluginName = "JzplpPlugin";

module.exports = class JzplpPlugin {
  apply(compiler) {
    // emit钩子,在asset输出到文件前调用
    compiler.hooks.emit.tap(pluginName, () => {
      // 获取Webpack配置中的输出路径
      const path = compiler.options.output.path;
      // 删除目录下所有文件
      fs.rmSync(path, { force: true, recursive: true });
    });
  }
};

新增和修改asset

尝试新增和修改asset,实现对输出文件的直接控制。

js 复制代码
const pluginName = "JzplpPlugin";

module.exports = class JzplpPlugin {
  options = {};
  constructor(options) {
    // 获取插件参数
    this.options = options;
  }
  apply(compiler) {
    // 从compiler中获取webpack对象
    const { webpack } = compiler;
    // Compilation 中放着processAssets钩子的stage常量
    const { Compilation, sources } = webpack;

    compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
      compilation.hooks.processAssets.tap(
        {
          name: pluginName,
          // 此时所有asset已被添加到compilation中
          stage: Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE,
        },
        // assets为 { filePath: fileContent, ... } 的对象
        (assets) => {
          // 新文件内容为asset的路径列表
          const fileContent =
            "# File List\n" +
            Object.keys(assets)
              .map((key) => "* " + key)
              .join("\n");
          // 创建新的源码对象
          const source = new sources.RawSource(fileContent);
          // 生成一个新的asset,放到compilation中
          compilation.emitAsset(this.options?.fileName || "fileList.md", source);
        },
      );
    });
  }
};

/* 文件输出结果
# File List
* index.css
* index.js
* another.js
* index.html
*/

在这个例子中,我们尝试了在asset全部生成后读取所有asset路径,然后写入一个新的asset内,最后输出成文件,放到dist/fileList.md中。然后我们再试一下,修改已有的asset。在下面的插件中,我们尝试合并source,将每个asset顶部新增一句注释。

js 复制代码
const pluginName = "JzplpPlugin";

module.exports = class JzplpPlugin {
  apply(compiler) {
    const { webpack } = compiler;
    const { Compilation, sources } = webpack;
    compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
      compilation.hooks.processAssets.tap(
        {
          name: pluginName,
          stage: Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE,
        },
        (assets) => {
          Object.keys(assets).map((key) => {
            const content = `/* fileName: ${key} */\n`;
            const source = new sources.RawSource(content);
            // 合并两个source
            const newSource = new sources.ConcatSource(source, assets[key]);
            // 更新现有的asset
            compilation.updateAsset(key, newSource);
          });
        },
      );
    });
  }
};

// 输出文件顶部示例
/* fileName: index.js */

这里我们再尝试修改已有的asset内容,使用ReplaceSource来替换。

js 复制代码
const pluginName = "JzplpPlugin";

module.exports = class JzplpPlugin {
  apply(compiler) {
    const { webpack } = compiler;
    const { Compilation, sources } = webpack;
    compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
      compilation.hooks.processAssets.tap(
        {
          name: pluginName,
          stage: Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE,
        },
        (assets) => {
          Object.keys(assets).map((key) => {
            // 获取当前的asset代码
            const src = assets[key].source().toString();
            // 找到要修改的位置
            const index = src.indexOf("jzplp");
            if (index != -1) {
              const reSource = new sources.ReplaceSource(assets[key], key);
              // 区间是前闭后闭,长度为字符串长度-1,第三个参数为替换内容
              reSource.replace(index, index + 4, "jzplpHello");
              compilation.updateAsset(key, reSource);
            }
          });
        },
      );
    });
  }
};

/*
使用插件前生成代码
e.exports = { note: { to: ["jzplp1"], from: ["jzplp2"] } };
使用插件后生成代码
e.exports = { note: { to: ["jzplpHello1"], from: ["jzplp2"] } };
*/

这里将每个文件中第一次出现的jzplp替换为jzplpHello,replace函数不要求替换前后字符串长度一致。

各种Source

前面操作asset的代码中,我们从webpack.sources中取出了很多以Source结尾的类,并使用这些类创建了对象。Source类是Webpack中操作源码的重要类,负责源代码的读取,转换合并等。因此这里列举一下Source的类型和对应作用。

  • RawSource 没有关联Source Map的源码
  • OriginalSource 源码,包含自动生成的Source Map
  • SourceMapSource 源码,包含传入的Source Map
  • ConcatSource 将多个Source拼接成一个
  • ReplaceSource 可以对Source中的源码进行替换操作
  • CachedSource 缓存其它Source的计算结果
  • SizeOnlySource 仅提供资源大小的Source
  • PrefixSource 为源码的每一行提供前缀
  • CompatSource 将非标准Source对象转换为符合标准的Source

重写module路径

在Webpack相关开发中,修改module内容一般放到loader中实现。但我们可以在loader引入之前,修改引入的module路径,从而实现替换文件的效果。

js 复制代码
const pluginName = "JzplpPlugin";

module.exports = class JzplpPlugin {
  apply(compiler) {
    compiler.hooks.normalModuleFactory.tap(
      pluginName,
      (normalModuleFactory) => {
        // 当引入每个新模块时触发,此时模块路径还没有被解析
        normalModuleFactory.hooks.beforeResolve.tap(
          pluginName,
          (resolveData) => {
            // 原模块路径
            const path = resolveData.request;
            // 替换为另一个模块路径
            if (path.endsWith("index1.css"))
              resolveData.request = path.replace("index1.css", "index2.css");
          },
        );
      },
    );
  }
};

在使用插件之前,代码引入的是index1.css代码,但是经过插件处理之后,引入的代码变为了index2.css的内容。这里引入了一种前面没介绍过的插件钩子对象类型normalModuleFactory。normalModuleFactory是一个处理模块的工厂,负责解析模块路径,确定应用的loader,对于每个模块生成一个NormalModule对象。但是它并不实际引入模块代码,引入还是在compilation中的build相关流程中实现,那里模块代码会先经过loader处理。

所以这里我们在解析路径前替换了模块路径,Webpack会按照新的路径解析引入模块,最后生成代码中也是使用新路径的源码。normalModuleFactory中也有多个钩子,这里也简单介绍一下:

  • beforeResolve 遇到新的模块时触发
  • resolve 在初始化解析之前调用
  • resolveForScheme 在请求被解析之前调用
  • afterResolve 在解析带有URI Scheme的请求(如data:,http:)时触发
  • createModule 在创建NormalModule实例之前调用
  • module 在创建NormalModule实例后调用
  • createParser 在Parser实例创建之前调用
  • parser 在创建Parser实例后触发
  • createGenerator 在Generator实例创建之前调用
  • generator 在Generator实例创建之后调用

注意这里仅仅是解析路径,创建Parser实例(解析AST使用),创建Generator实例(代码生成器)。但这里不引入代码,更不会解析AST和代码生成,仅仅是配置项和创建相关实例,实际操作都是在compilation中处理的。

修改HTML中资源URL

前面我们使用HtmlWebpackPlugin插件,生成了HTML入口文件,其中使用标签中引用URL的方式,引入了输出的asset资源文件。下面我们编写插件,实现修改这些标签中的URL。

js 复制代码
const fs = require("fs");
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const pluginName = "JzplpPlugin";

module.exports = class JzplpPlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap(pluginName, (compilation) => {
      // 获取package.json路径
      const pkgPath = path.resolve(compiler.options.context, "package.json");
      // 读取package.json数据
      const pkgData = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));

      // 获取HtmlWebpackPlugin的钩子
      const hooks = HtmlWebpackPlugin.getCompilationHooks(compilation);
      // alterAssetTags钩子,标签生成后,写入前触发
      hooks.alterAssetTags.tap(pluginName, (data) => {
        // 处理script标签
        data.assetTags.scripts.forEach((tag) => {
          if (tag.tagName === "script" && tag?.attributes?.src)
            tag.attributes.src = `${tag.attributes.src}?v=${pkgData.version}`;
        });
        // 处理link标签
        data.assetTags.styles.forEach((tag) => {
          if (tag.tagName === "link" && tag?.attributes?.href)
            tag.attributes.href = `${tag.attributes.href}?v=${pkgData.version}`;
        });
      });
    });
  }
};

在上面的插件中,首先读取了package.json文件内容,然后获取HtmlWebpackPlugin插件中的自定义钩子,然后对HtmlWebpackPlugin插件生成标签数据进行改动,其中script标签引入的是JavaScript文件,link标签引入的是CSS文件。通过对URL上增加版本号,可以使得不同版本的代码路径不一致,从而不同版本间浏览器缓存失效。是否使用插件的生成效果对比如下:

html 复制代码
<!-- 不使用插件 -->
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>jzplp-test</title>
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <script defer="defer" src="index.js"></script>
    <script defer="defer" src="another.js"></script>
    <link href="index.css" rel="stylesheet" />
  </head>
  <body></body>
</html>

<!-- 使用插件 -->
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>jzplp-test</title>
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <script defer="defer" src="index.js?v=1.0.0"></script>
    <script defer="defer" src="another.js?v=1.0.0"></script>
    <link href="index.css?v=1.0.0" rel="stylesheet" />
  </head>
  <body></body>
</html>

Tapable

在前面自定义插件中,我们使用了HtmlWebpackPlugin插件中的自定义钩子,这些钩子代表着HtmlWebpackPlugin生成HTML的不同阶段。因此插件也是可以自定义钩子,并且被其它插件触发的。Webpack中的钩子是使用Tapable实现事件监听和触发的,要想自定义钩子,首先需要了解Tapable。

Tapable初步

首先让我们抛开Webpack,用几个例子来描述下Tapable的作用。

js 复制代码
const { SyncHook } = require("tapable");

// 创建钩子
const hook = new SyncHook(["arg1", "arg2"]);
// 监听钩子
hook.tap("jzplpTest1", (arg1, arg2) => {
  console.log("jzplpTest1", arg1, arg2);
});
// 监听钩子
hook.tap("jzplpTest2", (arg1, arg2) => {
  console.log("jzplpTest2", arg1, arg2);
});
// 触发钩子
hook.call("a1", "a2");

/* 命令行输出
jzplpTest1 a1 a2
jzplpTest2 a1 a2
*/

上面是一个同步钩子的示例,可以看到,创建钩子之后,可以对其进行监听回调,同时也可以触发事件。当事件触发时,所有监听的回调都能收到结果。然后我们再试一下异步钩子。

js 复制代码
const { AsyncParallelHook } = require("tapable");

// 创建钩子
const hook = new AsyncParallelHook(["arg1"]);

// 监听钩子
hook.tapAsync("jzplpTest", (data, callback) => {
  setTimeout(() => {
    console.log(data);
    callback();
  }, 1000);
});

// 触发钩子
hook.callAsync("a1", () => {
  console.log("a1 end");
});

// 触发钩子
hook.promise("a2").then(() => {
  console.log("a2 end");
});

/* 命令行输出
a1
a1 end
a2
a2 end
*/

触发异步钩子也有回调函数和Promise两种方式。注意监听钩子的回调函数结束之后,对应触发钩子的回调函数才会执行。

Tapable类型列表

Tapable中的钩子类型除了同步和异步之外,还有很多种钩子,其中有这些分类:

  • Sync 同步钩子
  • Async 异步钩子
  • Bail 熔断
  • Waterfall 瀑布
  • Loop 循环
  • Parallel 并行请求
  • AsyncSeries 串行请求

将上面这些分类组合,可以得到Tapable中的所有钩子类型:

  • SyncHook 同步钩子
  • SyncBailHook 同步熔断钩子
  • SyncWaterfallHook 同步瀑布钩子
  • SyncLoopHook 同步循环钩子
  • AsyncParallelHook 异步并行请求钩子
  • AsyncParallelBailHook 异步并行请求熔断钩子
  • AsyncSeriesHook 异步串行请求钩子
  • AsyncSeriesBailHook 异步串行请求熔断钩子
  • AsyncSeriesLoopHook 异步串行请求循环钩子
  • AsyncSeriesWaterfallHook 异步串行请求瀑布钩子

这些钩子类型不仅在自定义钩子中会用到,事实上Webpack内部也大量使用了这些不同类型的钩子。只看名称还是不懂这些钩子的使用和区别,下面我们按照钩子分类来介绍一下使用方式。

Bail熔断

Bail类型钩子的特点是,回调函数串行调用,一个结束了再调用另一个。如果某一个回调函数返回非undefined值,后面的回调函数便不再触发。触发者收到的为第一个非 undefined值,或者undefined(所有回调都执行完毕)。注意,函数没有返回值时,相当于返回undefined。

js 复制代码
const { SyncBailHook } = require("tapable");

const hook = new SyncBailHook(["arg1"]);

hook.tap("test1", (arg1) => {
  console.log("test1", arg1);
});
hook.tap("test2", (arg1) => {
  console.log("test2", arg1);
  if (arg1 === "stop") return 1;
});
hook.tap("test3", (arg1) => {
  console.log("test3", arg1);
});

const data1 = hook.call("run");
console.log("run call", data1);
const data2 = hook.call("stop");
console.log("stop call", data2);

/* 命令行输出
test1 run
test2 run
test3 run
run call undefined
test1 stop
test2 stop
stop call 1
*/

当第一个run触发执行时,所有回调都没有返回值,因此所有回调都被执行了。stop触发时,第二个钩子返回了1,因此后面的第三个钩子不再执行,且call调用拿到的值也是1。

Waterfall瀑布

Waterfall类型的钩子,会从头到尾串行执行所有的回调函数。但后一个回调函数收到的,是前一个回调函数返回的结果。call收到的值为最后一个回调函数返回的结果。

js 复制代码
const { SyncWaterfallHook } = require("tapable");

const hook = new SyncWaterfallHook(["arg1"]);

hook.tap("test1", (arg1) => {
  console.log("test1", arg1);
  return arg1 + 1;
});
hook.tap("test2", (arg1) => {
  console.log("test2", arg1);
  return arg1 * 3;
});
hook.tap("test3", (arg1) => {
  console.log("test3", arg1);
  return arg1 + 10;
});

const data1 = hook.call(1);
console.log("run call", data1);

/* 命令行输出
test1 1
test2 2
test3 6
run call 16
*/

在上面的例子中,我们对一开始传入的值进行累计计算,返回给了call最后得计算结果。当某个回调返回undefined(无返回值)时,下一个回调收到的是上一个回调的结果(相当于绕过一个回调函数的返回值)。另外如果钩子还存在多于一个的参数,那么除第一个参数外,其它参数不参与返回值计算,每个钩子收到的都是call函数提供的值。

js 复制代码
const { SyncWaterfallHook } = require("tapable");

const hook = new SyncWaterfallHook(["arg1", "arg2"]);

hook.tap("test1", (arg1, arg2) => {
  console.log("test1", arg1, arg2);
  return arg1 + 1;
});
hook.tap("test2", (arg1, arg2) => {
  console.log("test2", arg1, arg2);
});
hook.tap("test3", (arg1, arg2) => {
  console.log("test3", arg1, arg2);
  return arg1 + 10;
});

const data1 = hook.call(1, "jzplp");
console.log("run call", data1);

/* 命令行输出
test1 1 jzplp
test2 2 jzplp
test3 2 jzplp
run call 12
*/

从例子中可以看到,虽然中间的test2回调没有返回值,但后面的test3依然取到了由test1返回的值,并输出了最终结果。arg2虽然没有被返回和处理,但每个回调函数依然能收到相同的由call提供的值。

Loop循环

Loop类型的钩子,也会从头到尾串行执行所有的回调函数。如果所有回调函数都返回undefined值,则正常结束。如果有一个函数返回非undefined值,则钩子会从第一个回调开始重新触发全部回调函数,直到回调函数都返回undefined值。

js 复制代码
const { SyncLoopHook } = require("tapable");

const hook = new SyncLoopHook(["arg1"]);
let loopNum = 3;

hook.tap("test1", (arg1) => {
  console.log("test1", arg1);
});
hook.tap("test2", (arg1) => {
  console.log("test2", arg1);
  if (loopNum-- != 0) return 1;
});
hook.tap("test3", (arg1) => {
  console.log("test3", arg1);
});

hook.call("jzplp");

/* 命令行输出
test1 jzplp
test2 jzplp
test1 jzplp
test2 jzplp
test1 jzplp
test2 jzplp
test1 jzplp
test2 jzplp
test3 jzplp
*/

在例子中,前4次test2回调都返回1,导致从第一个回调函数重新开始执行,直到第五次返回undefined,全部回调函数才执行完毕并结束。注意最后的test3只有最后一次才被执行到。

AsyncParallel并行请求

AsyncParallel是仅适用于异步钩子的类型,指的是所有回调函数一起同时触发,并行执行。返回值会被忽略。

js 复制代码
const { AsyncParallelHook } = require("tapable");

const hook = new AsyncParallelHook(["arg1"]);

hook.tapAsync("test1", (arg1, cb) => {
  console.log("test1 start", arg1);
  setTimeout(() => {
    console.log("test1 end", arg1);
    cb();
  }, 2000);
});
hook.tapAsync("test2", (arg1, cb) => {
  console.log("test2 start", arg1);
  setTimeout(() => {
    console.log("test2 end", arg1);
    cb();
  }, 1000);
});
hook.tapAsync("test3", (arg1, cb) => {
  console.log("test3 start", arg1);
  setTimeout(() => {
    console.log("test3 end", arg1);
    cb();
  }, 1500);
});

hook.callAsync("jzplp", () => {
  console.log("callAsync end");
});

/* 命令行输出
test1 start jzplp
test2 start jzplp
test3 start jzplp
test2 end jzplp
test3 end jzplp
test1 end jzplp
callAsync end
*/

可以看到,虽然第一个test1最早开始(因为它注册最早),但由于定时时间最长,所以最晚结束。三个异步钩子实际上同时执行。

AsyncParallelBailHook钩子

AsyncParallelBailHook是一种钩子类型,并不是分类,因为它的效果有点特色,所以拿出来单独描述。它有Parallel并行执行的特点,也具有Bail钩子返回第一个非undefined值的回调函数的特点。但这里不是按照钩子返回的异步事件顺序决定"第一个非undefined值的回调函数",而是根据它们注册的顺序。

js 复制代码
const { AsyncParallelBailHook } = require("tapable");

const hook = new AsyncParallelBailHook(["arg1"]);

hook.tapAsync("test1", (arg1, cb) => {
  console.log("test1 start", arg1);
  setTimeout(() => {
    console.log("test1 end", arg1);
    cb();
  }, 2000);
});
hook.tapAsync("test2", (arg1, cb) => {
  console.log("test2 start", arg1);
  setTimeout(() => {
    console.log("test2 end", arg1);
    cb(1);
  }, 1000);
});
hook.tapAsync("test3", (arg1, cb) => {
  console.log("test3 start", arg1);
  setTimeout(() => {
    console.log("test3 end", arg1);
    cb(2);
  }, 3000);
});

hook.callAsync("jzplp", (data) => {
  console.log("callAsync end", data);
});

/* 命令行输出
test1 start jzplp
test2 start jzplp
test3 start jzplp
test2 end jzplp
test1 end jzplp
callAsync end 1
test3 end jzplp
*/

这里来理解一下这个例子的执行流程:

  1. 三个异步回调函数被同时触发,这意味着三个定时器都开始定时,最终输出end都会触发。
  2. 首先test2先结束,返回了1,但此时test1还没有结束,因此钩子在等待test1的结果。
  3. test1定时器结束,返回了undefined。于是钩子转而查看test2,看到返回值为1,于是直接触发callAsync的回调函数,拿到值为1。
  4. 最后test3定时器结束,但在此之前钩子就已经执行结束了。

AsyncSeries串行请求

AsyncSeries类型的钩子是异步串行请求,即第一个异步回调完全执行完毕之后,再开始调用第二个回调函数。

js 复制代码
const { AsyncSeriesHook } = require("tapable");

const hook = new AsyncSeriesHook(["arg1"]);

hook.tapAsync("test1", (arg1, cb) => {
  console.log("test1 start", arg1);
  setTimeout(() => {
    console.log("test1 end", arg1);
    cb();
  }, 2000);
});
hook.tapAsync("test2", (arg1, cb) => {
  console.log("test2 start", arg1);
  setTimeout(() => {
    console.log("test2 end", arg1);
    cb();
  }, 1000);
});
hook.tapAsync("test3", (arg1, cb) => {
  console.log("test3 start", arg1);
  setTimeout(() => {
    console.log("test3 end", arg1);
    cb();
  }, 3000);
});

hook.callAsync("jzplp", () => {
  console.log("callAsync end");
});

/* 命令行输出
test1 start jzplp
test1 end jzplp
test2 start jzplp
test2 end jzplp
test3 start jzplp
test3 end jzplp
callAsync end
*/

通过调用结果可以看到,钩子是一个一个顺序触发和结束的。AsyncSeries类型的钩子还包含一些组合场景,包括AsyncSeriesBailHook,AsyncSeriesLoopHook,AsyncSeriesWaterfallHook等,这些钩子符合对应类型的特性,比较好理解,这里就不再赘述了。

stage优先级

前面我们尝试调用compilation中的processAssets钩子时,在参数中除了输入插件名称之外,还有一个stage参数。这个参数实际上指的是回调函数的优先级,它要求输入数字,数值越低,优先级越高,即越早被调用。默认是0,当stage值相同时,越早注册的钩子优先级越高。

js 复制代码
const { SyncHook } = require("tapable");

const hook = new SyncHook(["arg1"]);

hook.tap({ name: "test1", stage: 10 }, (arg1) => {
  console.log("test1", arg1);
});
hook.tap("test2", (arg1) => {
  console.log("test2", arg1);
});
hook.tap({ name: "test3", stage: -10 }, (arg1) => {
  console.log("test3", arg1);
});

hook.call("run");

/* 命令行输出
test3 run
test2 run
test1 run
*/

如果没有stage参数时,回调触发顺序是从前往后的test1, test2, test3。有了stage参数后,就按照stage的值从低到高顺序执行。钩子还有一个withOptions方法,可以将stage等参数包装起来,作为一个钩子调用时带有预设的stage。

js 复制代码
const { SyncHook } = require("tapable");

const hook = new SyncHook(["arg1"]);
const late = hook.withOptions({ stage: 10 });

late.tap("test1", (arg1) => {
  console.log("test1", arg1);
});
hook.tap("test2", (arg1) => {
  console.log("test2", arg1);
});

hook.call("run");

/* 命令行输出
test2 run
test1 run
*/

钩子操作记录和拦截器

不管何种类型的钩子,都有一个intercept方法,会在钩子的一些运行步骤时触发对应回调函数,可以用来记录日志,或者拦截钩子操作。

js 复制代码
const { SyncHook } = require("tapable");

const hook = new SyncHook(["arg1"]);
hook.intercept({
  name: "jzplpLog",
  register: (tap) => {
    console.log("--- register", tap);
  },
  call: (...args) => {
    console.log("--- call", args);
  },
  tap: (tap) => {
    console.log("--- tap", tap);
  },
  done: () => {
    console.log("--- done");
  },
});

hook.tap("test1", (arg1) => {
  console.log("test1", arg1);
});
hook.tap("test2", (arg1) => {
  console.log("test2", arg1);
});
hook.call("run");

/* 命令行输出
--- register { type: 'sync', fn: [Function (anonymous)], name: 'test1' }
--- register { type: 'sync', fn: [Function (anonymous)], name: 'test2' }
--- call [ 'run' ]
--- tap { type: 'sync', fn: [Function (anonymous)], name: 'test1' }
test1 run
--- tap { type: 'sync', fn: [Function (anonymous)], name: 'test2' }
test2 run
--- done
*/

在上面的代码中,我们使用intercept注册了很多回调函数,当对应的步骤触发时,使用回调函数打印日志。主要可以触发的方法有:

  • register tap相关钩子注册的时候触发
  • call call相关函数调用时触发
  • tap 每个tap相关钩子的回调函数触发时触发
  • loop 每次loop相关钩子开始循环时触发
  • error 回调函数抛出异常时触发
  • result Bail或者Waterfall类型的钩子返回一个值时触发
  • done 钩子执行全部成功时触发

其中register方法可以用来拦截并修改钩子的回调函数。它接收一个Tap对象,返回修改后的Tap对象。其中主要有这些属性:

  • name 标识(插件名称)
  • type 执行方式 sync/async/promise,对应三种tap方法
  • stage 回调优先级
  • fn 回调函数
js 复制代码
const { SyncHook } = require("tapable");

const hook = new SyncHook(["arg1"]);
hook.intercept({
  name: "jzplpLog",
  register: (tap) => {
    return {
      ...tap,
      fn: (arg1) => {
        console.log("register", arg1);
      },
    };
  },
});

hook.tap("test1", (arg1) => {
  console.log("test1", arg1);
});
hook.tap("test2", (arg1) => {
  console.log("test2", arg1);
});
hook.call("run");

/* 命令行输出
register run
register run
*/

通过这段代码可以看到,我们使用register替换了回调函数本身,触发钩子后,执行的是替换后的回调函数。

context共享数据

钩子上可以挂载一个context对象,在不同回调函数中共享数据。

js 复制代码
const { SyncHook } = require("tapable");
const hook = new SyncHook(["arg1"]);

hook.tap({ name: "test1", context: true }, (context, arg1) => {
  console.log("test1", context, arg1);
  context.abc = "jzplp";
});
hook.tap({ name: "test2", context: true }, (context, arg1) => {
  console.log("test2", context, arg1);
});
hook.tap("test3", (arg1) => {
  console.log("test3", arg1);
});
hook.call("run");

/* 命令行输出
test1 {} run
test2 { abc: 'jzplp' } run
test3 run
*/

要想使用context,需要设置对应属性为true。然后回调函数中第一个参数就变为了context。可以对它进行读取或者修改,后面的回调函数会收到更新后的数据。使用intercept,同样可以修改context。

js 复制代码
const { SyncHook } = require("tapable");

const hook = new SyncHook(["arg1"]);
hook.intercept({
  name: "jzplpLog",
  context: true,
  tap: (context, tap) => {
    context.abc = "jzplp";
  },
});

hook.tap({ name: "test2", context: true }, (context, arg1) => {
  console.log("test2", context, arg1);
});
hook.call("run");

/* 命令行输出
test2 { abc: 'jzplp' } run
*/

Webpack自定义钩子

前面了解了Tapable的用法后,我们就可以创建自定义钩子了,创建后其它插件也可以监听这个钩子。下面的例子中有两个插件:CreatePlugin用来创建钩子并触发;UsePlugin监听和使用钩子。

js 复制代码
// plugin/create.js
const { SyncHook } = require("tapable");
const pluginName = "CreatePlugin";
// 创建WeakMap,用来关联hook和compilation
const compilationHooks = new WeakMap();

module.exports = class CreatePlugin {
  static getCompilationHooks(compilation) {
    let hooks = compilationHooks.get(compilation);
    // 如果这个compilation没关联过hook,就创建新的并关联
    if (hooks === undefined) {
      hooks = {
        abc: new SyncHook(["arg1"]),
      };
      compilationHooks.set(compilation, hooks);
    }
    return hooks;
  }

  apply(compiler) {
    compiler.hooks.compilation.tap(pluginName, (compilation) => {
      compilation.hooks.optimize.tap(pluginName, () => {
        const hooks = CreatePlugin.getCompilationHooks(compilation);
        // 触发对应钩子
        hooks.abc.call("jzplp");
      });
    });
  }
};

// plugin/use.js
const { SyncHook } = require("tapable");
const CreatePlugin = require("./create");
const pluginName = "UsePlugin";

module.exports = class UsePlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap(pluginName, (compilation) => {
      const hooks = CreatePlugin.getCompilationHooks(compilation);
      // 监听对应钩子
      hooks.abc.tap(pluginName, (arg1) => {
        console.log(pluginName, 'tap', arg1)
      })
    });
  }
};

// webpack.config.js 中引入
  plugins: [
    new CreatePlugin({}),
    new UsePlugin({}),
    // ...
  ],

/* 命令行输出
UsePlugin tap jzplp
*/

这里我们以compilation的关联钩子为例进行创建,像compiler等对象的钩子创建方法也类似。由于compilation对象可能有多个,因此使用WeakMap作为对象和钩子的关联关系,不同的compilation对象的钩子是独立的。因此使用了类的静态函数getCompilationHooks,用于获取对应对象的钩子。创建完之后还需要根据需求在适当的时机触发它。

使用方直接引入插件类,也使用getCompilationHooks方法获取对应钩子,进行监听即可。注意引入插件时,创建方和触发方并没有引入顺序要求。

总结

这篇文章花了比较多的篇幅描述了Webpack插件的相关内容,包括一些常见插件配置方式;常见插件的使用;插件开发方式;各种钩子简介;各种Webpack对象简介;简单的自定义插件示例;Tapable和自定义钩子等。

虽然篇幅不算短,但是内容依旧不深入,很多主题只是浅浅的介绍了。因为Webpack是一个非常复杂的打包工具,从compiler和compilation的钩子数量就能看出,这里面有很多复杂的编译和优化流程,还有各种各样的配置对象。这些内容就不是一篇或者几篇文章能够介绍完的。

在举例自定义插件时,这里也只是举了很简单的小例子,事实上Webpack插件能实现各种各样的功能,能力非常强大。

参考

相关推荐
梦帮科技11 小时前
GRAVIS v4.0:基于Web的极速套利架构设计与实时数据流实现
前端·人工智能·rust·自动化·区块链·智能合约·数字货币
爱勇宝12 小时前
办公资料反复修改、补传、交接混乱,我做了个桌面工具来解决这件事
前端·后端·程序员
微信开发api-视频号协议12 小时前
企业微信外部群开发自动化实践过程
java·前端·微信·自动化·企业微信·ipad
甲维斯13 小时前
Fable5:20美金的顶级设计师!
前端·人工智能
kyriewen13 小时前
我同时用了 Claude Code、Cursor 和 Codex,说说三个工具的真实差距——2026 年选错工具比不用 AI 更浪费时间
前端·ai编程·claude
Jackson__13 小时前
为了拯救我的腰椎和颈椎,我做了款浏览器插件!
前端·javascript·开源
闲猫13 小时前
Python FastAPI + SQLAlchemy 入门教程:从零搭建你的第一个 Web 应用
前端·python·fastapi
林小帅14 小时前
NestJS v11 + Prisma v7 ESM 迁移配置解析
前端·javascript·后端
IT_陈寒14 小时前
React的setState竟然不是立刻生效的,害我调试半天
前端·人工智能·后端