从手把手编写 webpack 插件开始

875 阅读6分钟

为方便初学者快速上手,暂时抛开那些晦涩的概念,直接跟着笔者写一个 webpack 插件,来感受一下吧。笔者这里的代码是基于 webpack 5.x 版本写的,跟 4.x 版本有一定差异。这是一篇入门文,如果已经有基础,就不需要看此文了。

写一个简单的插件

我们假设这样一个场景:发布静态资源之后,需要清除用户浏览器端的缓存。清缓存的方法之一就是在资源文件 URL 之后增加一个时间戳参数。如图,在原来的基础上增加红框区域的内容: image.png

**特别说明:**本文例子的目的是为了讲解如何编写一个 webpack 插件,而不是为了解“清除用户浏览器端的缓存”问题。如果您看本文是为了解上面的问题,那只要在 “webpack-html-plugin” 插件上配置 hash: true 即可达到同等效果。

一个插件的必要部件

一个 webpack 插件必须包含以下几部分:

  1. 一个 Javascript 具名函数或对象
  2. 在 prototype 上定义一个 apply 方法
  3. 从指定一个 hook 事件进入 tap 开始
  4. 处理 webpack 内部实例的特定数据
  5. 完成所有功能后执行 callback 函数结束 (如果是异步 tap)

根据以上描述,一个插件的基本结构如下:

// 1. 定义了一个Class
class MyPlugin {
  // 2. 定义 apply 方法,这个方法会在 webpack 内部执行;
  // 所以 compiler 也是从 webpack 内部返回的;
	apply(compiler) {
    // 3. 指定 hooks 中的 compilation 事件执行 tap
    // tap 分为两种,同步(tap)和异步(tapAsync);
    // 还有 tapPromise ,顾名思义采用 Promise 写法
    compiler.hooks.compilation.tapAsync('myPlugin', (compilation, callback) => {
    	// 4. 业务逻辑,通常需要处理 webpack 内部实例的特定数据
      // do some thing...
      
      // 5. 如果采用 tapAsync 一定要执行 callback 使其继续
      callback();
    });
  }
}

完成需求

有了以上的理解之后,就可以着手开发上面的需求了。假设 html 文件是使用 “webpack-html-plugin” 插件自动生成的。那么 webpack 配置文件的插件部分的配制类似这样的:

plugins: [
  new HtmlWebpackPlugin({
    title: 'test'
  }),
]

现在需要加入我们自己的插件:

plugins: [
  new HtmlWebpackPlugin({
    title: 'test'
  }),
  new SetStyleTimeStamp(),
]

因为要改的内容是 “webpack-html-plugin” 插件生成的,所以需要阅读下此插件的帮助文档。在文档的 Events 这节中介绍了插件提供的几个 hooks,这些就是可以修改资源内容(数据)时机。选对时机很重要,不确定选哪个时可以好好阅读这一节的内容,必要时可能还需要写代码尝试才能领会。再往下看下,有一个 plugin.js 的示例;可以看到这段代码跟我们上面的“基本结构代码”很相似,所以接下来我们要写的代码也类似。 SetStyleTimeStamp.js 内容如下:

const HtmlWebpackPlugin = require('html-webpack-plugin');

class SetStyleTimeStamp {
  apply(compiler) {
    compiler.hooks.compilation.tap('SetStyleTimeStamp', (compilation) => {
      // 在这里笔者选用了 afterTemplateExecution hook,事实上使用 alterAssetTagGroups 也是可以的;有兴趣的读者可以尝试下。
      HtmlWebpackPlugin.getHooks(compilation).afterTemplateExecution.tap('SetStyleTimeStamp', (htmlPluginData) => {
        // hook 返回了 headTags、bodyTags、outputName、plugin 信息;
        // 我们要调整的是 bodyTags 里面的数据
        const { bodyTags } = htmlPluginData;
        bodyTags.forEach(({ attributes, tagName }) => {
          // 为 script 和 link 标签 src 或 href 属性加上时间戳
          if (tagName === 'script') {
            attributes.src = `${attributes.src}?t=${Date.parse(new Date())}`;
          }
          if (tagName === 'link') {
            attributes.href = `${attributes.href}?t=${Date.parse(new Date())}`;
          }
        });
        return htmlPluginData;
      });
    });
  }
}

module.exports = SetStyleTimeStamp;

以上插件的内容很简单,一句话就能说清楚:在 afterTemplateExecution hook 中调整 script 和 link 标签的 src 或 href 属性,为其加上时间戳。

以上插件的实现是基于 webpack-html-plugin 插件之上去做的,总感觉只能算个“子插件”,不够“完整”。如果我们抛弃对 webpack-html-plugin 的依赖,又该如何实现呢?

写一个完整的插件

html 插件实现基础能力是,产生 HTML 文档内容然后输出到指定文件上。创建 CustomHtmlPlugin.js :

const defaultOptions = {
  filename: 'index.html',
  title: 'Test',
};

class CustomHtmlPlugin {
  constructor(options) {
    // 合并用户配置项,后面会使用到
    this.options = Object.assign({}, defaultOptions, options);
  }
  apply(compiler) {
    // 在“生成资源到 output 目录之前”执行我们的插件内容
    // 这个组件写得简单,插件内没有异步执行的内容,也可以直接使用同步;
    compiler.hooks.emit.tapAsync('CustomHtmlPlugin', (compilation, callback) => {
      const { filename, title } = this.options;
      // 获取 webpack 配置的 entry 文件信息
      const entryNames = Array.from(compilation.entrypoints.keys());
      // 根据 entry 信息获取输出的资源文件地址;
      // 这样做是因为资源文件里还包含着很多不需要直接引入的文件,比如异步加载的文件。
      const resources = entryNames.map((entryName) => {
        // 这里是包含热加载文件内容的;目前插件只考虑了 build 的执行,没有做特殊处理;
        // 如果在实际应用场景中,是需要考虑开发环境的缓存和热加载的
        const file = compilation.entrypoints.get(entryName).getFiles()[0];
        // 这里也没有考虑有 css 文件输出的情况
        return `<script src="${file}?t=${Date.parse(new Date())}"></script>`;
      });
      // 定义输出的 HTML 内容
      const html = `<!doctype html>
<html>
  <head>
    <title>${title}</title>
    <meta name="viewport" content="width=device-width, initial-scale=1"></head>
  </head>
  <body>
    ${resources}
  </body>
</html>`;

      // 在 compilation 中追加 assets 信息,就能增加输出的资源文件
      compilation.assets[filename] = {
        source: () => html,
        size: () => html.length
      };
      
      callback();
    });
  }
}

module.exports = CustomHtmlPlugin;

这里实现的是基本功能,没有充分考虑各种异常情况。想对 webpack-html-plugin 了解更多的,推荐看其源码

自定义钩子

也想给自己的插件提供一些自定义钩子,提供给其他人使用怎么办呢?假设我们在前面的 html 插件中想在采集需要引入的资源文件之后增加一个自定义钩子,用于进行二次开发。在 CustomHtmlPlugin.js 文件中插入自定义钩子代码逻辑后如下(2-6行、37行、49行):

// tapable 是 webpack 的核心工具库
const { AsyncSeriesWaterfallHook } = require('tapable');
// 定义自定义钩子
const hooks = {
  myCustomHook: new AsyncSeriesWaterfallHook(['pluginData']),
};

const defaultOptions = {
  filename: 'index.html',
  title: 'Test',
};

class CustomHtmlPlugin {
  constructor(options) {
    this.options = Object.assign({}, defaultOptions, options);
  }
  apply(compiler) {
    compiler.hooks.emit.tapAsync('CustomHtmlPlugin', (compilation, callback) => {
      const { filename, title } = this.options;
      const entryNames = Array.from(compilation.entrypoints.keys());
      const resources = entryNames.map((entryName) => {
        const file = compilation.entrypoints.get(entryName).getFiles()[0];
        return `<script src="${file}?t=${Date.parse(new Date())}"></script>`;
      });
      const html = `<!doctype html>
<html>
  <head>
    <title>${title}</title>
    <meta name="viewport" content="width=device-width, initial-scale=1"></head>
  </head>
  <body>
    ${resources}
  </body>
</html>`;
      
      // 在适当的位置执行自定义钩子
      hooks.myCustomHook.promise({ html, resources, compilation });

      compilation.assets[filename] = {
        source: () => html,
        size: () => html.length
      };
      
      callback();
    });
  }
}

CustomHtmlPlugin.hooks = hooks;
module.exports = CustomHtmlPlugin;

自定义钩子的使用如下:

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

class UseCustomHook {
	apply(compiler) {
    CustomHtmlPlugin.hooks.myCustomHook.tapAsync('UseCustomHook', (data, callback) => {
    	// 二次开发处理数据逻辑
      console.log('UseCustomHook', data, callback);
      
      callback();
    });
  }
}

module.exports = UseCustomHook;

webpack 配置内容:

plugins: [
  new CustomHtmlPlugin({
    title: 'test'
  }),
  new UseCustomHook(),
]

到这里,可能会有读者会疑惑:你写的自定钩子的使用方法怎么跟 html-webpack-plugin 插件的使用方法不一样呢?其实本质上 html-webpack-plugin 插件的自定钩子实现是一样的,是指它使用了 WeakMap。WeakMap 跟 Map 的区别是它只接受对象作为键名(null除外),不接受其他类型的值作为键名,而且键名所指向的对象,不计入垃圾回收机制。它的自定义钩子实现的示意代码:

const htmlHooks = new WeakMap();
// 将 compilation 作为了 hooks 的 key
htmlHooks.set(compilation, {
  myCustomHook: new AsyncSeriesWaterfallHook(['pluginData']),
});

htmlWebpackPlugin.getHooks = (compilation) => {
  return htmlHooks.get(compilation);
};


// 使用的时候
htmlWebpackPlugin.getHooks(compilation).myCustomHook.tapAsync()

通过上面的示例,是不是觉得写个 webpack 插件也挺简单的?不过在实现一些实际用途的插件时,还是会遇到这样那样的问题。笔者认为这些问题主要会在集中在钩子时机(使用哪个钩子)的选择,compiler/compilation/tapable 对象中可使用的方法及其含义,以及一些特殊情况的处理。特别是后面两点需要通过学习研究 webpack 或已有插件的源码来积累,当然也可以看一些高质量的详解文章。

参考资料:

  1. 《HTML Webpack Plugin》
  2. 《Writing a Plugin》
  3. 《Plugin API》
  4. 《tapable》