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

具体有哪些预先定义好的钩子,我们可以参考官方文档的 API:
接下来,我们来开发一个自己的插件,看看具体如何往这些钩子上挂载任务。
基础知识
Webpack 使用阶段式的构建回调,开发者可以引入它们自己的行为到 Webpack 构建流程中。
在开发之前,需要了解以下 Webpack 相关概念:
Webpack 插件组成
在自定义插件之前,我们需要了解,一个 Webpack 插件由哪些构成,下面摘抄文档:
- 一个具名 JavaScript 函数;
- 在它的原型上定义 apply 方法;
- 指定一个触及到 Webpack 本身的事件钩子;
- 操作 Webpack 内部的实例特定数据;
- 在实现功能后调用 Webpack 提供的 callback。
Webpack 插件基本架构
插件由一个构造函数实例化出来。构造函数定义apply方法,在安装插件时,apply方法会被 Webpackcompiler调用一次。apply方法可以接收一个 Webpackcompiler对象的引用,从而可以在回调函数中访问到compiler对象。
官方文档提供一个简单的插件结构:class HelloWorldPlugin {apply(compiler) {compiler.hooks.done.tap('Hello World Plugin', (stats /* 在 hook 被触及时,会将 stats 作为参数传入。 */) => {console.log('Hello World!');});}}module.exports = HelloWorldPlugin;
开发清除文件注释插件
这里需求是,希望我们开发的这个插件能够自动清除 Webpack 打包结果中的注释,这样一来,我们的 main.js 将更容易阅读,如下图所示:
知道这些过后,还需要明确我们这个任务的执行时机,也就是到底应该把这个任务挂载到哪个钩子上。
我们的需求是删除 bundle.js 中的注释,也就是说只有当 Webpack 需要生成的 main.js 文件内容明确过后才可能实施。
那根据 API 文档中的介绍,我们找到一个叫作 emit 的钩子,这个钩子会在 Webpack 即将向输出目录输出文件时执行,非常符合我们的需求。
我们回到代码中,通过 compiler 对象的 hooks 属性访问到 emit 钩子,再通过 tap 方法注册一个钩子函数,这个方法接收两个参数:
- 第一个是插件的名称,我们这里的插件名称是 RemoveCommentsPlugin
- 第二个是要挂载到这个钩子上的函数
class RemoveCommentsPlugin {apply (compiler) {// compiler => 包含了我们此次构建的所有配置信息// console.log('RemoveCommentsPlugin 启动')compiler.hooks.emit.tap('RemoveCommentsPlugin', (compilation ) => {// compilation => 可以理解为此次打包的上下文for (const name in compilation.assets) {// 输出文件名称console.log(name)}});}}module.exports = RemoveCommentsPlugin;
完成以后,我们将这个插件应用到 Webpack 的配置中,然后回到命令行重新打包,此时打包过程就会打印我们输出的文件名称,如下:
我们再回到代码中,来打印一下每个资源文件的内容,文件内容需要通过遍历的值对象中的 source 方法获取,具体代码如下:
class RemoveCommentsPlugin {apply (compiler) {compiler.hooks.emit.tap('RemoveCommentsPlugin', (compilation ) => {// compilation => 可以理解为此次打包的上下文for (const name in compilation.assets) {// 输出文件内容console.log(compilation.assets[name].source());}});}}module.exports = RemoveCommentsPlugin;
回到命令行,再次打包,此时输出的文件内容也可以正常被打印。
能够拿到文件名和文件内容后,我们回到代码中。这里需要先判断文件名是不是以 .js 结尾,因为 Webpack 打包还有可能输出别的文件,而我们的需求只需要处理 JS 文件。
那如果是 JS 文件,我们将文件内容得到,再通过正则替换的方式移除掉代码中的注释,最后覆盖掉 compilation.assets 中对应的对象,在覆盖的对象中,我们同样暴露一个 source 方法用来返回新的内容。另外还需要再暴露一个 size 方法,用来返回内容大小,这是 Webpack 内部要求的格式,具体代码如下:
class RemoveCommentsPlugin {apply (compiler) {compiler.hooks.emit.tap('RemoveCommentsPlugin', (compilation ) => {// compilation => 可以理解为此次打包的上下文for (const name in compilation.assets) {// 清除注释if (name.endsWith('.js')) {const contents = compilation.assets[name].source();const noComments = contents.replace(/\/\*{2,}\/\s?/g, '');compilation.assets[name] = {source: () => noComments,size: () => noComments.length}}}});}}module.exports = RemoveCommentsPlugin;
完成以后回到命令行终端,再次打包,打包完成过后,我们再来看一下 bundle.js,此时 bundle.js 中每行开头的注释就都被移除了。

以上就是我们实现一个移除注释插件的过程,通过这个过程我们了解了:插件都是通过往 Webpack 生命周期的钩子中挂载任务函数实现的。
自动添加时间戳插件(TODO)
开发的 自动添加时间戳引用脚本文件(SetScriptTimestampPlugin) 插件实现的原理:通过 HtmlWebpackPlugin 生成 HTML 文件前,将模版文件预留位置替换成脚本,脚本中执行自动添加时间戳来引用脚本文件。
插件运行机制
初始化插件文件
class SetScriptTimestampPlugin {apply(compiler) {compiler.hooks.done.tap('SetScriptTimestampPlugin',(compilation, callback) => {console.log('SetScriptTimestampPlugin!');});}}module.exports = SetScriptTimestampPlugin;
选择插件触发时机
选择插件触发时机,其实是选择插件触发的 compiler 钩子(即何时触发插件)。
Webpack 提供钩子有很多,这里简单介绍几个,完整具体可参考文档《Compiler Hooks》:
entryOption: 在 webpack 选项中的entry配置项 处理过之后,执行插件。afterPlugins: 设置完初始插件之后,执行插件。compilation: 编译创建之后,生成文件之前,执行插件。。emit: 生成资源到output目录之前。done: 编译完成。
我们插件应该是要在 HTML 输出之前,动态添加 script 标签,所以我们选择钩入 compilation 阶段,代码修改:
class SetScriptTimestampPlugin {apply(compiler) {compiler.hooks.compilation.tap('SetScriptTimestampPlugin',(compilation, callback) => {console.log('SetScriptTimestampPlugin!');});}}module.exports = SetScriptTimestampPlugin;
在 compiler.hooks 下指定事件钩子函数,便会触发钩子时,执行回调函数。
Webpack 提供三种触发钩子的方法:
tap:以同步方式触发钩子;tapAsync:以异步方式触发钩子;tapPromise:以异步方式触发钩子,返回 Promise;
这三种方式能选择的钩子方法也不同,由于 compilation 是 SyncHook 同步钩子,所以采用 tap 触发方式。tap 方法接收两个参数:插件名称和回调函数。
