硬核向 | 如何实现一个webpack插件(内含 Tapable 指南)

1,416 阅读12分钟

前言

上文提到,webpack5源码导读:我们如何调试源码,插件是 webpack 的支柱功能,webpack 自身也是构建于我们开发者在 webpack 配置中用到的相同的插件系统之中。说的大白话点,就是它自身也是基于这套插件构建的,这种基于事件流的插件机制是 webpack 的骨架,而控制这些插件在 webpack 事件流上的运行就是基于一个库(Tapable),因此想要深入的了解 webpack,插件是绕不过的一道槛。本文将会详细的探究如何实现一个 webpack 插件,以点带面,让小伙伴们也能了解到插件的奥秘。

Tapable 指南

什么是 Tapable

tapable 有些类似于 Node.js 中的 Events 库。

const EventEmitter = require('events');
const emitter = new EventEmitter();

emitter.on('event', () => {
  console.log('触发了一个事件');
});

emitter.emit('event');

简单的来说,是一种实现了发布订阅模式的库,通过 tapable 我们可以注册自定义事件,在合适的时机去触发注册的事件。

Tapable 的用法

tapable 提供了一系列事件的发布订阅 api,这些 api 就是各种类型的钩子。官方文档提供了以下九种钩子。

const {
    SyncHook,
    SyncBailHook,
    SyncWaterfallHook,
    SyncLoopHook,
    AsyncParallelHook,
    AsyncParallelBailHook,
    AsyncSeriesHook,
    AsyncSeriesBailHook,
    AsyncSeriesWaterfallHook
 } = require("tapable");

这里可以简单的就 SyncHook 钩子函数做一个示例。

const myHook = new SyncHook(["arg1", "arg2", "arg3"]);

myHook.tap('myHook1', (arg1, arg2, arg3) => {
  console.log('myHook1', arg1, arg2, arg3);
});

myHook.tap('myHook2', (arg1, arg2, arg3) => {
  console.log('myHook2', arg1, arg2, arg3);
});

myHook.call('Y', 'L', 'G');

//打印结果为:
// myHook1 Y L G
// myHook2 Y L G

这里简单理一下执行的逻辑,大概可以分为三步。

  1. 根据需求实例化不同种类的 Hook,在实例化的过程中接受一个字符串数组为参数,对字符串的值没有要求,但要尽量满足语义化。要注意数组中的字符串个数要与实际传参的个数相对应。

  2. 通过 tap 函数来注册事件要接受两个参数,第一个是起到占位符函数的字符串,如在 webpack 插件中,这个字符串的值一般是插件的名字,第二个参数是注册的回调函数,如下例:

compiler.hooks.make.tapAsync("EntryPlugin", (compilation, callback) => {
  compilation.addEntry(context, dep, options, err => {
    callback(err);
  });
});
  1. 通过 call 函数传入对应的参数,在执行的过程中,传入的参数会传递给所有的注册事件进行使用。

感兴趣的读者,可以引入 tapable 包自行尝试一下,这里简单的说一下,对于同步钩子而言,tap 是唯一注册事件的方法,通过 call 来进行触发。异步钩子则可以通过 tap,tapAsync,tapPromise进行注册,这里要注意,异步钩子也可以通过 tap 进行注册。

按照同步和异步进行分类。

同步表示注册的事件函数会同步的执行

异步则表示注册的事件会异步的执行

image.png

这里要提一下异步钩子,被分成了两类,串行并行,我们还可以以单词的词义来进行区分,

series: 一连串的,系列

parallel:平行的,同时发生的

具体点来讲

  • AsyncSeriesHook: 可被串联执行的异步钩子函数。

  • AsyncParallelHook: 可被并联调用的异步钩子函数。

按照返回值进行分类。

image.png

Basic Hook: 基本类型钩子,它仅仅按顺序连续执行每个注册的事件函数,并不关心调用事件的返回值如何。

Waterfall Hook: 瀑布钩子,和基本类型钩子一样,也会按顺序连续执行注册的事件函数,区别在于,它将上一个事件函数的返回值传递到下一个事件函数为参数,如其中某个函数没有返回值,则将上一个存在的返回值传递下去,另一个注意的点是下一个事件函数存在多个参数时,返回值仅仅能修改第一个参数。

const { SyncWaterfallHook } = require('tapable');

const myHook = new SyncWaterfallHook(['arg1', 'arg2', 'arg3']);

myHook.tap('myHook1', (arg1, arg2, arg3) => {
  console.log('myHook1:', arg1, arg2, arg3);

  return 'cool';
});

myHook.tap('myHook2', (arg1, arg2, arg3) => {
  console.log('myHook2:', arg1, arg2, arg3);
});

myHook.tap('myHook3', (arg1, arg2, arg3) => {
  console.log('myHook3:', arg1, arg2, arg3);
});

hook.call('Y', 'L', 'G');

//打印结果为:
// myHook1 Y L G
// myHook2 cool L G
// myHook3 cool L G

Bail Hook: 保险钩子,如果任意一个注册事件函数返回非 underfined 的值,钩子执行的过程将会立即中断。

const { SyncBailHook } = require('tapable');

const myHook = new SyncBailHook(['arg1', 'arg2', 'arg3']);

myHook.tap('myHook1', (arg1, arg2, arg3) => {
  console.log('myHook1:', arg1, arg2, arg3);

  // 存在返回值,钩子的执行过程被中断
  return true
});

myHook.tap('myHook2', (arg1, arg2, arg3) => {
  console.log('myHook2:', arg1, arg2, arg3);
});

hook.call('Y', 'L', 'G');

//打印结果为:
// myHook1 Y L G

Loop Hook: 循环钩子,执行顺序与基本类型钩子一致,不同的是,如果任何一个注册的事件函数返回的值为非 underfined,则将会重头执行所有注册的钩子函数。直至所有事件函数的返回值都为 underfined。

如何实现一个 Webpack Plugin

什么是插件

在 webpack 编译时期,会为不同的编译对象初始化很多不同的 Hook,开发者们可以在编写的插件中监听,也就是用(tap,tapAsync,tapPromise)注册这些钩子,在打包的不同时期,触发(call)这些钩子,就可以在编译的过程中注入特定的逻辑,修改编译的结果来满足开发的需要。

如这里可以举个例子,描述一下 emit 钩子的作用,以及在这个钩子注册的事件被触发时,我们可以做些什么。正如官方文档所描述,这个钩子的触发时机是在输出 asset 到 output 目录之前执行。说明此时源文件的转换和组装已经完成,我们可以通过 emit 钩子此时的回调函数中的参数,compilation,来读取输出的资源,模块,以及依赖。

class myPlugin {
  apply(compiler) {
    // 注册 "emit" 钩子,
    compiler.hooks.emit.tap('myPlugin', (compilation) => {
      // 自定义逻辑,如打印存放当前模块所有依赖的文件路径
      compilation.chunks.forEach((chunk) => {
        chunk.forEachModule((module) => {
          module.fileDependencies.forEach((filepath) => {
            console.log(filepath);
          });
        });
      })
    }
  }
}

image.png

结合上面的例子以及描述,我们再综合一下官网的创建插件说明来看,

  • 一个 JavaScript 命名函数或 JavaScript 类。

  • 在插件函数的 prototype 上定义一个 apply 方法。

  • 指定一个绑定到 webpack 自身的事件钩子

  • 处理 webpack 内部实例的特定数据。

  • 功能完成后调用 webpack 提供的回调。

让我们说的更方便理解一些,如果插件是一个函数,需要在原型链上指定 apply 方法,如果是一个 class 类,则一定要在类的属性上,添加 apply 方法,方法名必须是 apply,少一个字母多一个字母都不行,这在源码中是写死的,在 apply 方法中,通过 compiler 注册指定的事件钩子,在回调函数中拿到 compilation 对象,使用 compilation 修改编译后的数据,从而影响打包结果来达到我们的目的。

这里有一点需要注意,如果是异步钩子,在完成自定义逻辑后要执行 callback() 函数,来通知 webpack 继续编译。

常用的插件钩子介绍

这里用我的语言简单说一下,在开发插件时,Compiler 和 Compilation 的不同。

Compiler 对象

Compiler 对象在 webpack 启动时就已经被实例化,它和 compilation 实例不同,它是全局唯一的,在它的实例对象中,可以得到所有的配置信息,包括所有注册的 plugins 和 loaders。

Compilation 对象

每当文件发生变动时,都会有新的 compilation 实例被创建,它能够访问到所有的模块和依赖,我们可以通过一系列的钩子来访问或者修改打包的 module,assets,chunks。

下面是一些常用钩子的介绍

钩子调用时机参数类型
afterPlugins在初始化内部插件集合完成设置之后调用compilerSyncHook
run在开始读取 records 之前调用compilerAsyncSeriesHook
compile在创建一个新的 compilation 创建之前compilationParamsSyncHook
compilationcompilation 创建之后执行compilation, compilationParamsSyncHook
emit输出 asset 到 output 目录之前执行compilationAsyncSeriesHook
afterEmit输出 asset 到 output 目录之后执行compilationAsyncSeriesHook
done在 compilation 完成时执行statsAsyncSeriesHook

这里有一张 webpack 基于不同模块钩子执行的运行图。

image.png

如何实现插件

这里我们创建一个项目

mkdir webpack-plugins
npm init -y
npm install webpack webpack-cli --save-dev

创建好依赖后,我们来补充一下项目结构

├── dist 
├── plugins 
│   └── log-webpack-plugin.js 
│   └── copy-rename-webpack-plugin.js 
├── node_modules 
├── package-lock.json 
├── package.json 
├── src 
│   └── foo.js 
│   └── index.js 
└── webpack.config.js 

webpack.config.js

 /** * 
  * @type {import('webpack').Configuration} 
  * 
 */ 
const path = require('path');
const webpack = require('webpack');
const LogWebpackPlugin = require('./plugins/log-webpack-plugin');

module.exports = {
  mode: 'development',
  entry: path.resolve(__dirname, 'src/index.js'),
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname, 'dist'),
    clean: true,
  },
  plugins: [
    new LogWebpackPlugin({
      emitCallback: () => {
        console.log('emit 事件发生啦')
      }, 
      compilationCallback: () => {
        console.log('compilation 事件发生啦')
      },
      doneCallback: () => {
        console.log('done 事件发生啦')
      },
    })
  ]
}

我们这里将会以两个插件举例说明,争取让读者也明明白白的。

logWebpackPlugin

log-webpack-plugin.js

class LogWebpackPlugin {
  constructor({ emitCallback, compilationCallback, doneCallback }) {
    this.emitCallback = emitCallback
    this.compilationCallback = compilationCallback;
    this.doneCallback = doneCallback
  }
  apply(compiler) {
    compiler.hooks.emit.tap('LogWebpackPlugin', () => {
      this.emitCallback();
    });

    compiler.hooks.compilation.tap('LogWebpackPlugin', (err) => {
      this.compilationCallback();
    });

    compiler.hooks.done.tap('LogWebpackPlugin', (err) => {
      this.doneCallback();
    });
  }
}

module.exports = LogWebpackPlugin;

执行 webpack 打包命令,看看 console.log 在不同的编译时期打印的信息。

image.png

上述的插件,可以传入自定义的函数,在 webpack 不同的编译时期,去触发那个函数,这个插件很简单,但也清晰的展现了插件的结构和原理。再一次重申,所谓插件,就是 webpack 依托事件流的机制,在打包的不同时期,暴露出钩子函数,使开发者能拿到不同编译时期的 compilation 实例,来访问或改变实例上的 module,assets,chunks,来实现所需的功能。

CopyRenameWebpackPlugin

让我们来模拟一个需求,我想让 /dist 目录下的指定文件复制到另一个指定的文件夹且重命名,让我们来思考一下应该怎么做。先展示一下代码。

首先在 webpack.config.js 中加一些代码

const CopyRenameWebpackPlugin = require('./plugins/copy-rename-webpack-plugin');


plugins: [
  ......
  new CopyRenameWebpackPlugin({
    entry: 'main.js',
    output: [
      '../copy/main1.js',
      '../copy/main2.js'
    ],
  })
]

copy-rename-webpack-plugin.js

class CopyRenameWebpackPlugin {
  constructor(options) {
    this.options = options || {};
  }
  apply(compiler) {
    const pluginName = CopyRenameWebpackPlugin.name;
    const { entry, output } = this.options;

    let fileContent = null;

    compiler.hooks.emit.tapAsync(pluginName, (compilation, callback) => {

      const assets = compilation.getAssets();
      assets.forEach(({ name, source }) => {
        if(entry !== name) return;

        fileContent = source;
      });

      output.forEach((dir) => {
        compilation.emitAsset(dir, fileContent);
      })

      callback();
    });
  }
}

module.exports = CopyRenameWebpackPlugin;

让我们再来看一下输出的结果。

image.png

事实证明,这个插件是满足我们要求的,大概说一下这个插件的思路。

  • 传入想要复制的文件名字,以及输出的目录,进行配置化和灵活化。

  • 想一下用什么钩子,我们要复制打包目录下的文件,此时我们需要的资源是已经处理好的,emit 的时机是输出 assets 到打包目录之前,此时的 compilation 实例中的 assets 是编译处理后的。

  • 我们通过 getAssets 来获取当前编译下所有资源的数组,进行遍历,取得 source 和 name,这个 name 可以理解为文件名字,source 为资源信息,我们通过 name 来比对 传入的 entry,如果一致,这个 source 就是我们需要的资源信息。

  • 发射文件,同样使用 compilation 实例的 emitAsset 方法来写入文件。

现在可能会有读者疑惑,我不知道 compilation 上有 getAssets 这个方法,我也不知道使用这个方法可以获取什么值,我这里有两个法子,相辅相成,首先我们在官方文档中可以了解到一部分。

image.png

  • 在 webpack 源码的根目录下,有 type.d.ts 这个文件,如我们可以查询进入文件,Ctrl + F,直接搜寻 getAssets,如下图。

我们可以知道调用这个方法会返回一个类型是 Asset,仅仅只读的数组。

image.png

让我们再去剖析 Asset 里都有什么。

image.png

我们找到了,它里面含有 name 和 source,和我们解构出来的值是一样的。

重写 CopyRenameWebpackPlugin

如果我们使用的版本是 webpack4,那这篇文章就已经结束了,先看一张图。

image.png

这个提示的大概意思就是,在 webpack5 之前,我们常在 compiler.hooks.emit 钩子注册的事件中对资源进行处理,如删除注释等,现在官方不建议这样用了,人家建议用 compilation.hooks.processAssets这个钩子对 assets 进行处理。于是我们要改造一下我们的代码。

class CopyRenameWebpackPlugin {
  constructor(options) {
    this.options = options || {};
  }
  apply(compiler) {
    const pluginName = CopyRenameWebpackPlugin.name;
    const { entry, output } = this.options;

    let fileContent = null;

    const { webpack } = compiler;
    const { Compilation } = webpack;

    compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
      compilation.hooks.processAssets.tap(
        {
          name: pluginName,
          stage: Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE,
        },
        (assets) => {
          Object.entries(assets).forEach(([name, source]) => {
            if(entry !== name) return;

            fileContent = source;
          })

          output.forEach((dir) => {
            compilation.emitAsset(dir, fileContent);
          })
        }
      )
    })
  }
}

module.exports = CopyRenameWebpackPlugin;

此时已经不报这个警告了。

image.png

其实整体的流程差不多,这边解释一下 compilation.hooks.processAssets 这个钩子。正如官网所描述,它是一个专门处理 assets 的钩子。

image.png

这边要注意的是 stage 这个参数。可以用常量来赋值,也可以使用数字,如 stage: 1000。

image.png

尽管这个钩子的 Hook 参数以及回调参数给的比较清晰,此时我们也可以用 type.d.ts 来查询一下这个钩子的信息。

image.png

这里可以看出 CompilationAssets 是一个对象,它的值的类型依旧是 Source。

image.png

最后再说一点,也是我在学习插件的过程比较在意的一点,webpack 的钩子众多,有很多时期很接近,但官网说的又不清晰,我怎么知道我要用哪个,这一点,说说我的理解。

如在上述例子中,我使用了 compiler.hooks.thisCompilation 这个钩子,我为什么要使用这个,我可以使用 compiler.hooks.compilation 吗,答案是可以的,我之所以使用 thisCompilation 这个钩子,是因为此时 compilation 实例已经创建完毕,使用这个钩子,我可以最早拿到 compilation实例。

image.png

以下三个钩子都是满足条件的,因为这三个钩子触发的时机都在 compilation 创建之后,结束之前执行。

image.png

总结

写到这里,也很感谢每一位读到这里的小伙伴,webpack 插件的内容相信对于大部分开发者来说都是陌生的,但相信,读到这里的同学,对待插件也有一个基本的认识了,学习,我认为最重要的是兴趣为主,无论学习的目的是为了面试,还是因为有开发任务,希望我们都能树立一个心态,那就是我变强了,形成一个正向循环。也希望这篇文章能给读者们带来启迪,打开 webpack 插件的大门。