likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 13

前言

上文提到,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 进行注册。

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

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

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

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

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

series: 一连串的,系列

parallel:平行的,同时发生的

具体点来讲

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

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

按照返回值进行分类。

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

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

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

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

  • 一个 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 基于不同模块钩子执行的运行图。

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

如何实现插件

这里我们创建一个项目

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 在不同的编译时期打印的信息。

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

上述的插件,可以传入自定义的函数,在 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;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

重写 CopyRenameWebpackPlugin

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

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

这个提示的大概意思就是,在 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;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

总结

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

转载自:https://juejin.cn/post/7206217901133185081
评论
请登录