为方便初学者快速上手,暂时抛开那些晦涩的概念,直接跟着笔者写一个 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. // 1. 定义了一个Class
  2. class MyPlugin {
  3. // 2. 定义 apply 方法,这个方法会在 webpack 内部执行;
  4. // 所以 compiler 也是从 webpack 内部返回的;
  5. apply(compiler) {
  6. // 3. 指定 hooks 中的 compilation 事件执行 tap
  7. // tap 分为两种,同步(tap)和异步(tapAsync);
  8. // 还有 tapPromise ,顾名思义采用 Promise 写法
  9. compiler.hooks.compilation.tapAsync('myPlugin', (compilation, callback) => {
  10. // 4. 业务逻辑,通常需要处理 webpack 内部实例的特定数据
  11. // do some thing...
  12. // 5. 如果采用 tapAsync 一定要执行 callback 使其继续
  13. callback();
  14. });
  15. }
  16. }

完成需求

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

  1. plugins: [
  2. new HtmlWebpackPlugin({
  3. title: 'test'
  4. }),
  5. ]

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

  1. plugins: [
  2. new HtmlWebpackPlugin({
  3. title: 'test'
  4. }),
  5. new SetStyleTimeStamp(),
  6. ]

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

  1. const HtmlWebpackPlugin = require('html-webpack-plugin');
  2. class SetStyleTimeStamp {
  3. apply(compiler) {
  4. compiler.hooks.compilation.tap('SetStyleTimeStamp', (compilation) => {
  5. // 在这里笔者选用了 afterTemplateExecution hook,事实上使用 alterAssetTagGroups 也是可以的;有兴趣的读者可以尝试下。
  6. HtmlWebpackPlugin.getHooks(compilation).afterTemplateExecution.tap('SetStyleTimeStamp', (htmlPluginData) => {
  7. // hook 返回了 headTags、bodyTags、outputName、plugin 信息;
  8. // 我们要调整的是 bodyTags 里面的数据
  9. const { bodyTags } = htmlPluginData;
  10. bodyTags.forEach(({ attributes, tagName }) => {
  11. // 为 script 和 link 标签 src 或 href 属性加上时间戳
  12. if (tagName === 'script') {
  13. attributes.src = `${attributes.src}?t=${Date.parse(new Date())}`;
  14. }
  15. if (tagName === 'link') {
  16. attributes.href = `${attributes.href}?t=${Date.parse(new Date())}`;
  17. }
  18. });
  19. return htmlPluginData;
  20. });
  21. });
  22. }
  23. }
  24. module.exports = SetStyleTimeStamp;

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

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

写一个完整的插件

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

  1. const defaultOptions = {
  2. filename: 'index.html',
  3. title: 'Test',
  4. };
  5. class CustomHtmlPlugin {
  6. constructor(options) {
  7. // 合并用户配置项,后面会使用到
  8. this.options = Object.assign({}, defaultOptions, options);
  9. }
  10. apply(compiler) {
  11. // 在“生成资源到 output 目录之前”执行我们的插件内容
  12. // 这个组件写得简单,插件内没有异步执行的内容,也可以直接使用同步;
  13. compiler.hooks.emit.tapAsync('CustomHtmlPlugin', (compilation, callback) => {
  14. const { filename, title } = this.options;
  15. // 获取 webpack 配置的 entry 文件信息
  16. const entryNames = Array.from(compilation.entrypoints.keys());
  17. // 根据 entry 信息获取输出的资源文件地址;
  18. // 这样做是因为资源文件里还包含着很多不需要直接引入的文件,比如异步加载的文件。
  19. const resources = entryNames.map((entryName) => {
  20. // 这里是包含热加载文件内容的;目前插件只考虑了 build 的执行,没有做特殊处理;
  21. // 如果在实际应用场景中,是需要考虑开发环境的缓存和热加载的
  22. const file = compilation.entrypoints.get(entryName).getFiles()[0];
  23. // 这里也没有考虑有 css 文件输出的情况
  24. return `<script src="${file}?t=${Date.parse(new Date())}"></script>`;
  25. });
  26. // 定义输出的 HTML 内容
  27. const html = `<!doctype html>
  28. <html>
  29. <head>
  30. <title>${title}</title>
  31. <meta name="viewport" content="width=device-width, initial-scale=1"></head>
  32. </head>
  33. <body>
  34. ${resources}
  35. </body>
  36. </html>`;
  37. // 在 compilation 中追加 assets 信息,就能增加输出的资源文件
  38. compilation.assets[filename] = {
  39. source: () => html,
  40. size: () => html.length
  41. };
  42. callback();
  43. });
  44. }
  45. }
  46. module.exports = CustomHtmlPlugin;

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

自定义钩子

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

  1. // tapable 是 webpack 的核心工具库
  2. const { AsyncSeriesWaterfallHook } = require('tapable');
  3. // 定义自定义钩子
  4. const hooks = {
  5. myCustomHook: new AsyncSeriesWaterfallHook(['pluginData']),
  6. };
  7. const defaultOptions = {
  8. filename: 'index.html',
  9. title: 'Test',
  10. };
  11. class CustomHtmlPlugin {
  12. constructor(options) {
  13. this.options = Object.assign({}, defaultOptions, options);
  14. }
  15. apply(compiler) {
  16. compiler.hooks.emit.tapAsync('CustomHtmlPlugin', (compilation, callback) => {
  17. const { filename, title } = this.options;
  18. const entryNames = Array.from(compilation.entrypoints.keys());
  19. const resources = entryNames.map((entryName) => {
  20. const file = compilation.entrypoints.get(entryName).getFiles()[0];
  21. return `<script src="${file}?t=${Date.parse(new Date())}"></script>`;
  22. });
  23. const html = `<!doctype html>
  24. <html>
  25. <head>
  26. <title>${title}</title>
  27. <meta name="viewport" content="width=device-width, initial-scale=1"></head>
  28. </head>
  29. <body>
  30. ${resources}
  31. </body>
  32. </html>`;
  33. // 在适当的位置执行自定义钩子
  34. hooks.myCustomHook.promise({ html, resources, compilation });
  35. compilation.assets[filename] = {
  36. source: () => html,
  37. size: () => html.length
  38. };
  39. callback();
  40. });
  41. }
  42. }
  43. CustomHtmlPlugin.hooks = hooks;
  44. module.exports = CustomHtmlPlugin;

自定义钩子的使用如下:

  1. const CustomHtmlPlugin = require('./CustomHtmlPlugin');
  2. class UseCustomHook {
  3. apply(compiler) {
  4. CustomHtmlPlugin.hooks.myCustomHook.tapAsync('UseCustomHook', (data, callback) => {
  5. // 二次开发处理数据逻辑
  6. console.log('UseCustomHook', data, callback);
  7. callback();
  8. });
  9. }
  10. }
  11. module.exports = UseCustomHook;

webpack 配置内容:

  1. plugins: [
  2. new CustomHtmlPlugin({
  3. title: 'test'
  4. }),
  5. new UseCustomHook(),
  6. ]

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

  1. const htmlHooks = new WeakMap();
  2. // 将 compilation 作为了 hooks 的 key
  3. htmlHooks.set(compilation, {
  4. myCustomHook: new AsyncSeriesWaterfallHook(['pluginData']),
  5. });
  6. htmlWebpackPlugin.getHooks = (compilation) => {
  7. return htmlHooks.get(compilation);
  8. };
  9. // 使用的时候
  10. htmlWebpackPlugin.getHooks(compilation).myCustomHook.tapAsync()

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

参考资料:

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