插件机制基本原理

Webpack 的插件机制就是我们在软件开发中最常见的钩子机制。

钩子机制也特别容易理解,它有点类似于 Web 中的事件。在 Webpack 整个工作过程会有很多环节,为了便于插件的扩展,Webpack 几乎在每一个环节都埋下了一个钩子。这样我们在开发插件的时候,通过往这些不同节点上挂载不同的任务,就可以轻松扩展 Webpack 的能力。

自定义插件 - 图1

具体有哪些预先定义好的钩子,我们可以参考官方文档的 API:

接下来,我们来开发一个自己的插件,看看具体如何往这些钩子上挂载任务。

基础知识

Webpack 使用阶段式的构建回调,开发者可以引入它们自己的行为到 Webpack 构建流程中。
在开发之前,需要了解以下 Webpack 相关概念:

Webpack 插件组成

在自定义插件之前,我们需要了解,一个 Webpack 插件由哪些构成,下面摘抄文档:

  • 一个具名 JavaScript 函数;
  • 在它的原型上定义 apply 方法;
  • 指定一个触及到 Webpack 本身的事件钩子
  • 操作 Webpack 内部的实例特定数据;
  • 在实现功能后调用 Webpack 提供的 callback。

    Webpack 插件基本架构

    插件由一个构造函数实例化出来。构造函数定义 apply 方法,在安装插件时,apply 方法会被 Webpack compiler 调用一次。apply 方法可以接收一个 Webpack compiler对象的引用,从而可以在回调函数中访问到 compiler 对象。
    官方文档提供一个简单的插件结构:
    1. class HelloWorldPlugin {
    2. apply(compiler) {
    3. compiler.hooks.done.tap('Hello World Plugin', (
    4. stats /* 在 hook 被触及时,会将 stats 作为参数传入。 */
    5. ) => {
    6. console.log('Hello World!');
    7. });
    8. }
    9. }
    10. module.exports = HelloWorldPlugin;

    插件的生命周期 https://webpack.docschina.org/api/compiler-hooks/

开发清除文件注释插件

这里需求是,希望我们开发的这个插件能够自动清除 Webpack 打包结果中的注释,这样一来,我们的 main.js 将更容易阅读,如下图所示:
image.png
知道这些过后,还需要明确我们这个任务的执行时机,也就是到底应该把这个任务挂载到哪个钩子上。
我们的需求是删除 bundle.js 中的注释,也就是说只有当 Webpack 需要生成的 main.js 文件内容明确过后才可能实施。
image.png
那根据 API 文档中的介绍,我们找到一个叫作 emit 的钩子,这个钩子会在 Webpack 即将向输出目录输出文件时执行,非常符合我们的需求。

我们回到代码中,通过 compiler 对象的 hooks 属性访问到 emit 钩子,再通过 tap 方法注册一个钩子函数,这个方法接收两个参数:

  • 第一个是插件的名称,我们这里的插件名称是 RemoveCommentsPlugin
  • 第二个是要挂载到这个钩子上的函数
  1. class RemoveCommentsPlugin {
  2. apply (compiler) {
  3. // compiler => 包含了我们此次构建的所有配置信息
  4. // console.log('RemoveCommentsPlugin 启动')
  5. compiler.hooks.emit.tap('RemoveCommentsPlugin', (compilation ) => {
  6. // compilation => 可以理解为此次打包的上下文
  7. for (const name in compilation.assets) {
  8. // 输出文件名称
  9. console.log(name)
  10. }
  11. });
  12. }
  13. }
  14. module.exports = RemoveCommentsPlugin;

完成以后,我们将这个插件应用到 Webpack 的配置中,然后回到命令行重新打包,此时打包过程就会打印我们输出的文件名称,如下:
image.png
我们再回到代码中,来打印一下每个资源文件的内容,文件内容需要通过遍历的值对象中的 source 方法获取,具体代码如下:

  1. class RemoveCommentsPlugin {
  2. apply (compiler) {
  3. compiler.hooks.emit.tap('RemoveCommentsPlugin', (compilation ) => {
  4. // compilation => 可以理解为此次打包的上下文
  5. for (const name in compilation.assets) {
  6. // 输出文件内容
  7. console.log(compilation.assets[name].source());
  8. }
  9. });
  10. }
  11. }
  12. module.exports = RemoveCommentsPlugin;

回到命令行,再次打包,此时输出的文件内容也可以正常被打印。
能够拿到文件名和文件内容后,我们回到代码中。这里需要先判断文件名是不是以 .js 结尾,因为 Webpack 打包还有可能输出别的文件,而我们的需求只需要处理 JS 文件。

那如果是 JS 文件,我们将文件内容得到,再通过正则替换的方式移除掉代码中的注释,最后覆盖掉 compilation.assets 中对应的对象,在覆盖的对象中,我们同样暴露一个 source 方法用来返回新的内容。另外还需要再暴露一个 size 方法,用来返回内容大小,这是 Webpack 内部要求的格式,具体代码如下:

  1. class RemoveCommentsPlugin {
  2. apply (compiler) {
  3. compiler.hooks.emit.tap('RemoveCommentsPlugin', (compilation ) => {
  4. // compilation => 可以理解为此次打包的上下文
  5. for (const name in compilation.assets) {
  6. // 清除注释
  7. if (name.endsWith('.js')) {
  8. const contents = compilation.assets[name].source();
  9. const noComments = contents.replace(/\/\*{2,}\/\s?/g, '');
  10. compilation.assets[name] = {
  11. source: () => noComments,
  12. size: () => noComments.length
  13. }
  14. }
  15. }
  16. });
  17. }
  18. }
  19. module.exports = RemoveCommentsPlugin;

完成以后回到命令行终端,再次打包,打包完成过后,我们再来看一下 bundle.js,此时 bundle.js 中每行开头的注释就都被移除了。

image.png
以上就是我们实现一个移除注释插件的过程,通过这个过程我们了解了:插件都是通过往 Webpack 生命周期的钩子中挂载任务函数实现的。

自动添加时间戳插件(TODO)

开发的 自动添加时间戳引用脚本文件(SetScriptTimestampPlugin) 插件实现的原理:通过 HtmlWebpackPlugin 生成 HTML 文件前,将模版文件预留位置替换成脚本,脚本中执行自动添加时间戳来引用脚本文件。

插件运行机制

自定义插件 - 图6

初始化插件文件

  1. class SetScriptTimestampPlugin {
  2. apply(compiler) {
  3. compiler.hooks.done.tap('SetScriptTimestampPlugin',
  4. (compilation, callback) => {
  5. console.log('SetScriptTimestampPlugin!');
  6. });
  7. }
  8. }
  9. module.exports = SetScriptTimestampPlugin;

选择插件触发时机

选择插件触发时机,其实是选择插件触发的 compiler 钩子(即何时触发插件)。
Webpack 提供钩子有很多,这里简单介绍几个,完整具体可参考文档《Compiler Hooks》:

  • entryOption : 在 webpack 选项中的 entry 配置项 处理过之后,执行插件。
  • afterPlugins : 设置完初始插件之后,执行插件。
  • compilation : 编译创建之后,生成文件之前,执行插件。。
  • emit : 生成资源到 output 目录之前。
  • done : 编译完成。

我们插件应该是要在 HTML 输出之前,动态添加 script 标签,所以我们选择钩入 compilation 阶段,代码修改:

  1. class SetScriptTimestampPlugin {
  2. apply(compiler) {
  3. compiler.hooks.compilation.tap('SetScriptTimestampPlugin',
  4. (compilation, callback) => {
  5. console.log('SetScriptTimestampPlugin!');
  6. });
  7. }
  8. }
  9. module.exports = SetScriptTimestampPlugin;

compiler.hooks 下指定事件钩子函数,便会触发钩子时,执行回调函数。
Webpack 提供三种触发钩子的方法:

  • tap :以同步方式触发钩子;
  • tapAsync :以异步方式触发钩子;
  • tapPromise :以异步方式触发钩子,返回 Promise;

这三种方式能选择的钩子方法也不同,由于 compilationSyncHook 同步钩子,所以采用 tap 触发方式。
tap 方法接收两个参数:插件名称和回调函数。