一杯 ☕ 时间学会编写 webpack 插件

711 阅读6分钟

在现代前端开发中,webpack 已经成为了一个不可或缺的工具;

开发过程中,大家也或多或少接触过 webpack 中各式各样的插件。

可以说 插件系统赋予了 webpack 丰富的可扩展性,让我们可以根据项目需求定制化地配置和扩展 webpack 的功能。

今天我们就用一杯 ☕ 的时间,教你实现一个简单的 webpack 插件!

插件的本质

要想学会编写插件,首先得弄明白 webpack 插件到底是什么。

apply 方法

其实很简单,webpack 的插件本质上就是一个 拥有 apply 方法的函数或类

不信?那我们扒几个插件源码瞅瞅:

  • html-webpack-plugin

    image.png
  • copy-webpack-plugin

    image.png

从上面的代码中可以看出,这些插件中都实现了 apply 方法;

webpack 在初始化时就会调用这个 apply 方法,并传入一个参数 compiler 对象。

这里的 compiler 包含了 当前 webpack 环境的各种配置信息,以及关于模块和编译的所有信息

其中,对于插件来说最重要的就是 上面挂载了许多的 hook,通过 compiler.hooks.钩子名称 的语法,我们就能拿到对应的 hook 来进行一系列操作。

hook 介绍

hook 就类似于我们写 Vue 时的生命周期函数。

webpack 会在不同的构建流程中触发对应的 hook,并传入不同的上下文参数;

可以说,编写插件的过程就是 —— 找到对应的 hook 并注册,然后在回调函数中使用传入的上下文参数实现逻辑的过程,比如:

// 通过 compiler.hooks.compilation 拿到对应钩子
// 通过 tap 方法来注册回调
compiler.hooks.compilation.tap('MyPlugin', (compilation) => {
  // 在回调函数中拿到参数 compilation
  // 执行一些逻辑
});

webpack 提供了几百种 hook ,我们来简单了解几个 hook 的使用:

compiler.hooks.compilation

触发时机

webpack 在每次编译过程中都会新建一个 compilation 对象,这个钩子就会在 compilation 对象新建时被触发;

上下文参数:

这个钩子的上下文参数是当前编译期间的 compilation 对象;同时,compilation 对象上又挂载了其它的许多钩子可供调用:

// 在每次新建 Compilation 对象时触发
compiler.hooks.compilation.tap('FirstPlugin', (compilation) => {
  // 这个钩子在 webpack 处理 Chunk ID 的阶段触发
  compilation.hooks.chunkIds.tap('SecondPlugin', () => {})
});

compiler.hooks.emit

触发时机

webpack 即将生成文件(将要输出资源到输出目录)时,compiler.hooks.emit 钩子会被触发。

上下文参数:

它的参数同样是当前编译期间的 compilation 对象。

compiler.hooks.done

触发时机

webpack 完成编译、打包并且输出结果后触发。

上下文参数:

这个钩子的上下文参数是 stats,也就是本次 编译的相关信息

通过监听这个钩子,我们就可以做输出日志、通知其他系统、执行清理等操作。

webpack 完整 hook 目录戳这里 👉 webpack hook

hook 类型

了解了 webpack 提供了哪些 hook,你就已经掌握了一大半插件的工作原理啦。

现在不妨来思考一个问题:如果我们在一个 hook 上注册了多个回调函数,那么这些回调函数会被如何调用呢?

比如说,下面这段代码它的输出顺序是什么:

compiler.hooks.compile.tap('FirstPlugin', 
  () => console.log('FirstPlugin')
);
compiler.hooks.compile.tap('SecondPlugin', 
  () => console.log('SecondPlugin')
);

实际上,上面这段代码的输出顺序取决于 hook 的类型。

对于 compile 这个 hook 来说,它是一个 同步的钩子(SyncHook),因此它的输出顺序就是 先输出 'FirstPlugin',然后输出 'SecondPlugin'

对于不同的类型的 hook,还提供不同的注册方式来注册回调函数

下面我们一起来看看 webpack 中具体有哪些类型的 hook

同步钩子(SyncHook)

这些钩子用于同步操作,它们的回调函数会 按照注册的顺序依次执行

  • 注册方式:只能使用 tap 方法;
  • 相应 hookcompilethisCompilation``compilation 等等;
compiler.hooks.compile.tap('MyPlugin', (params) => {
  console.log('我是一个同步钩子');
});

同步熔断钩子(SyncBailHook)

这些钩子也是同步的。但如果 回调函数返回非 undefined 的值,钩子的执行将停止,后续的回调函数将不会被调用

  • 注册方式:只能使用 tap 方法;
  • 相应 hookentryOptionshouldEmitlog 等等;
compiler.hooks.shouldEmit.tap('MyPlugin', (compilation) => {
  //返回 false 会阻断后续回调的调用
  return false; 
});

同步瀑布流钩子(SyncWaterfallHook)

这些钩子是 同步的。它们的每个监听函数都会按照注册的顺序被调用,并 将前一个回调函数的返回值传递给下一个回调函数

  • 注册方式:只能使用 tap 方法;
  • 相应 hookassetPathcontextModuleFiles
compiler.hooks.thisCompilation.tap('MyPlugin', (compilation) => {
  compilation.hooks.assetPath.tap(
    'MyPlugin',
    (filename, data) => {
      // 将自定义的文件名传给下一个回调函数
      return 'custom_prefix_' + filename;
    }
  );
});

异步并行钩子(AsyncParallelHook)

这些钩子支持异步操作,回调函数可以 并行执行

回调函数必须 调用入参中的 callback 或返回 Promise

  • 注册方式:可以使用 taptapAsynctapPromise 方法。
    • 对于使用 tapAsync 注册的回调函数,Webpack等待所有回调函数调用 callback 方法之后才会继续执行
    • 对于使用 tapPromise 注册的回调函数,Webpack等待所有返回的 Promiseresolve 之后才会继续执行
  • 相应 hookmake
compiler.hooks.make.tapAsync('MyPlugin', (compilation, callback) => {
  callback();
});

// 或者

compiler.hooks.afterEmit.tapPromise('MyPlugin', (compilation) => {
  return new Promise((resolve) => {
    resolve();
  });
});

异步串行钩子(AsyncSeriesHook)

这些钩子也支持异步操作,但回调函数会 按照注册的顺序一个接一个依次执行

  • 注册方式:可以使用 taptapAsynctapPromise 方法。调用逻辑同异步并行钩子(AsyncParallelHook)。
  • 相应 hookrunemitafterEmitassetEmitted 等等。

异步瀑布流钩子(AsyncSeriesWaterfallHook)

这些钩子支持异步操作,它们的每个回调函数都会 按照注册的顺序被调用,并将前一个回调函数解析的值传递给下一个监听函数

  • 注册方式:可以使用 taptapAsynctapPromise 方法。调用逻辑同异步并行钩子(AsyncParallelHook)。
  • 相应 hookbeforeResolveafterResolvealternativeRequests 等等。

小试牛刀

到这里,你已经完全掌握了 webpack 插件的奥秘!

小伙伴们不妨动动手指来试着编写一个插件,用来在 webpack 编译完成后输出相应的编译信息。

下面是具体的插件代码:

// CompileInfoPlugin.js

const fs = require('fs')
const path = require('path')
class BuildInfo {
  apply (compiler) {
    // 通过 done 这个钩子注册一个在编译完成后执行的钩子
    // 这里回调函数中的入参 stats 就是本次编译相关信息
    compiler.hooks.done.tap('CompileInfoPlugin', (stats) => {
      // 编译完成后输出一条信息
      console.log('Compile is done.')
      // 将编译信息输出到 dist 目录下的 report 文件中
      const filepath = path.join(process.cwd(), './dist/report')
      const str = stats.toString({
        colors: true,
        modules: false,
        children: false,
        chunks: false,
        chunkModules: false
      })
      fs.writeFileSync(filepath, str)
    })
  }
}

// 导出模块
module.exports = {
  BuildInfo
}

接下来在 webpack 配置中使用上面的插件:

// webpack.config.js

const CompileInfoPlugin = require('./CompileInfoPlugin');

module.exports = {
  // ... 其他配置项
  plugins: [
    new CompileInfoPlugin()
  ]
};

大功告成!🎉🎉🎉