[转]【Webpack】🔥Webpack 插件开发如此简单!

本文使用的Webpack-Quickly-Starter快速搭建 Webpack4 本地学习环境。
建议多阅读 Webpack 文档《Writing a Plugin》章节,学习开发简单插件。

本文将带你一起开发你的第一个 Webpack 插件,从 Webpack 配置工程师,迈向 Webpack 开发工程师!
做自己的轮子,让别人用去吧。

完整代码存放在:https://github.com/pingan8787/script-timestamp-webpack-plugin

image

一、背景介绍

本文灵感源自业务中的经验总结,不怕神一样的产品,只怕一根筋的开发

在项目打包遇到问题:“当项目托管到 CDN 平台,希望实现项目中的 index.js 不被缓存”。因为我们需要修改 index.js 中的内容,不想用户被缓存。

思考一阵,有这么几种思路:

  1. 在 CDN 平台中过滤该文件的缓存设置;
  2. 查找 DOM 元素,修改该 script 标签的 src 值,并添加时时间戳;
  3. 打包时动态创建 script 标签引入文件,并添加时时间戳。

(聪明的你还有其他方法,欢迎讨论)

思路分析:

  1. 显然修改 CDN 设置的话,治标不治本;
  2. 在模版文件中,添加 script 标签,执行获取 Webpack 自动添加的 script 标签并为其 src 值添加时间戳。但事实是还没等你修改完, js 文件已经加载完毕,所以放弃
  3. 需要在 index.html 生成之前,修改 js 文件的路径,并添加时间戳。

于是我准备使用第三种方式,在 index.html 生成之前完成下面修改:

image.png

问题简单,实际还是想试试开发 Webpack Plugin。

二、基础知识

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

2.1 Webpack 插件组成

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

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

2.2 Webpack 插件基本架构

插件由一个构造函数实例化出来。构造函数定义 apply 方法,在安装插件时,apply 方法会被 Webpack compiler 调用一次。apply 方法可以接收一个 Webpack compiler对象的引用,从而可以在回调函数中访问到 compiler 对象。

官方文档提供一个简单的插件结构:

class HelloWorldPlugin {
  apply(compiler) {
    compiler.hooks.done.tap('Hello World Plugin', (
      stats /* 在 hook 被触及时,会将 stats 作为参数传入。 */
    ) => {
      console.log('Hello World!');
    });
  }
}
module.exports = HelloWorldPlugin;

使用插件:

// webpack.config.js
var HelloWorldPlugin = require('hello-world');

module.exports = {
  // ... 这里是其他配置 ...
  plugins: [new HelloWorldPlugin({ options: true })]
};

2.3 HtmlWebpackPlugin 介绍

HtmlWebpackPlugin 简化了 HTML 文件的创建,以便为你的 Webpack 包提供服务。这对于在文件名中包含每次会随着编译而发生变化哈希的 webpack bundle 尤其有用。

插件的基本作用概括:生成 HTML 文件

html-webapck-plugin 插件两个主要作用:

  • 为 HTML 文件引入外部资源(如 script / link )动态添加每次编译后的 hash,防止引用文件的缓存问题;
  • 动态创建 HTML 入口文件,如单页应用的 index.html文件。

html-webapck-plugin 插件原理介绍:

  • 读取 Webpack 中 entry 配置的相关入口 chunkextract-text-webpack-plugin 插件抽取的 CSS 样式;
  • 将样式插入到插件提供的 templatetemplateContent 配置指定的模版文件中;
  • 插入方式是:通过 link 标签引入样式,通过 script 标签引入脚本文件;

三、开发流程

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

3.1 插件运行机制

image.png

3.2 初始化插件文件

新建 SetScriptTimestampPlugin.js 文件,并参考官方文档中插件的基本结构,初始化插件代码:

// SetScriptTimestampPlugin.js

class SetScriptTimestampPlugin {
  apply(compiler) {
    compiler.hooks.done.tap('SetScriptTimestampPlugin',
     (compilation, callback) => {
      console.log('SetScriptTimestampPlugin!');
    });
  }
}
module.exports = SetScriptTimestampPlugin;

apply 方法为插件原型方法,接收 compiler 作为参数。

3.3 选择插件触发时机

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

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

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

// SetScriptTimestampPlugin.js

class SetScriptTimestampPlugin {
  apply(compiler) {
-   compiler.hooks.done.tap('SetScriptTimestampPlugin',
+   compiler.hooks.compilation.tap('SetScriptTimestampPlugin', 
      (compilation, callback) => {
      console.log('SetScriptTimestampPlugin!');
    });
  }
}
module.exports = SetScriptTimestampPlugin;

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

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

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

3.4 添加插件替换入口

我们原理上是将模版文件中,指定替换入口,再替换成需要执行的脚本。

image.png

所以我们在模版文件 template.html 中添加 SetScriptTimestampPlugininsetscript 作为标识替换入口:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Webpack 插件开发入门</title>
</head>
<body>
    <!-- other code -->
    <!--SetScriptTimestampPlugin inset script-->
</body>
</html>

3.5 编写插件逻辑

到这一步,才开始编写插件的逻辑。
从上一步中,我们知道在 tap 第二个参数是个回调函数,并且这个回调函数有两个参数: compilationcallback

compilation 继承于compiler,包含 compiler 所有内容(也有 Webpack 的 options),而且也有 plugin 函数接入任务点。

// SetScriptTimestampPlugin.js

class SetScriptTimestampPlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap('SetScriptTimestampPlugin', 
      (compilation, callback) => {
        // 插件逻辑 调用compilation提供的plugin方法
        compilation.plugin(
          "html-webpack-plugin-before-html-processing",
          function(htmlPluginData, callback) {
            // 读取并修改 script 上 src 列表
            let jsScr = htmlPluginData.assets.js[0];
            htmlPluginData.assets.js = [];
            let result = `
                <script>
                    let scriptDOM = document.createElement("script");
                    let jsScr = "./${jsScr}";
                    scriptDOM.src = jsScr + "?" + new Date().getTime();
                    document.body.appendChild(scriptDOM)
                </script>
            `;
            let resultHTML = htmlPluginData.html.replace(
              "<!--SetScriptTimestampPlugin inset script-->", result
            );
            // 返回修改后的结果
            htmlPluginData.html = resultHTML;
          }
        );
      }
    );
  }
}
module.exports = SetScriptTimestampPlugin;

在上面插件逻辑中,具体做了这些事:

  1. 执行 compilation.plugin 方法,并传入两个参数:插件事件和回调方法。

所谓“插件事件”即插件所提供的一些事件,用于监听插件状态,这里列举几个 html-webpack-plugin 提供的事件(完整可查看《html-webpack-plugin》):
Async:

  • html-webpack-plugin-before-html-generation
  • html-webpack-plugin-before-html-processing
  • html-webpack-plugin-alter-asset-tags

Sync:

  • html-webpack-plugin-alter-chunks
  1. 获取脚本文件名称列表并清空。

在回调方法中,通过 htmlPluginData.assets.js 获取需要通过 script 引入的脚本文件名称列表,拷贝一份,并清空原有列表。

image.png

  1. 编写替换逻辑。

替换逻辑即:动态创建一个 script 标签,将其 src 值设置为上一步读取到的脚本文件名,并在后面拼接 时间戳 作为参数。

  1. 插入替换逻辑。

通过 htmlPluginData.html 可以获取到模版文件的字符串输出,我们只需要将模版字符串中替换入口 `` 替换成我们上一步编写的替换逻辑即可。

  1. 返回HTML文件。

最后将修改后的 HTML 字符串,赋值给原来的 htmlPluginData.html 达到修改效果。

3.5 使用插件

自定义插件使用方式,与其他插件一致,在 plugins 数组中实例化:

// webpack.config.js

const SetScriptTimestampPlugin = require("./SetScriptTimestampPlugin.js");
module.exports = {
    // ... 省略其他配置
  plugins: [
    // ... 省略其他插件
    new SetScriptTimestampPlugin()  
  ]
}

到这一步,我们已经实现需求“当项目托管到 CDN 平台,希望实现项目中的 index.js 不被缓存”。


image.png

四、案例拓展

这里以之前 SetScriptTimestampPlugin 插件为例子,继续拓展。

4.1 读取插件配置参数

每个插件本质是一个类,跟一个类实例化相同,可以在实例化时传入配置参数,在构造函数中操作:

// SetScriptTimestampPlugin.js

class SetScriptTimestampPlugin {
  constructor(options) {
    this.options = options;
  }
  apply(compiler) {
    console.log(this.options.filename); // "index.js"
    // ... 省略其他代码
  }
}
module.exports = SetScriptTimestampPlugin;

使用时:

// webpack.config.js

const SetScriptTimestampPlugin = require("./SetScriptTimestampPlugin.js");
module.exports = {
    // ... 省略其他配置
  plugins: [
    // ... 省略其他插件
    new SetScriptTimestampPlugin({
        filename: "index.js"
    })  
  ]
}

4.2 添加多脚本文件的时间戳

如果我们此时需要同时修改多个脚本文件的时间戳,也只需要将参数类型和执行脚本做下调整。
具体修改脚本,这里不具体展开,篇幅有限,可以自行思考实现咯~
这里展示使用插件时的参数:

// webpack.config.js

const SetScriptTimestampPlugin = require("./SetScriptTimestampPlugin.js");
module.exports = {
    // ... 省略其他配置
  plugins: [
    // ... 省略其他插件
    new SetScriptTimestampPlugin({
        filename: ["index.js", "boundle.js", "pingan.js"]
    })  
  ]
}

生成结果:

<script src="./index.js?1582425467655"></script>
<script src="./boundle.js?1582425467655"></script>
<script src="./pingan.js?1582425467655"></script>

五、总结

本文通用自定义 Webpack 插件来实现日常一些比较棘手的需求。主要为大家介绍了 Webpack 插件的基本组成和简单架构,也介绍了 HtmlWebpackPlugin 插件。并通过这些基础知识,完成了一个 HTML 文本替换插件,最后通过两个场景来拓展插件使用范围。

最后,关于 Webpack 插件开发,还有更多知识可以学习,建议多看看官方文档《Writing a Plugin》进行学习。

本文纯属个人经验总结,如有异议,欢迎指点。

参考文档

  1. Writing a Plugin
  2. HtmlWebpackPlugin - Webpack
  3. 扩展 HtmlwebpackPlugin 插入自定义的脚本
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,904评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,581评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,527评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,463评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,546评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,572评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,582评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,330评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,776评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,087评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,257评论 1 344
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,923评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,571评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,192评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,436评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,145评论 2 366
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,127评论 2 352

推荐阅读更多精彩内容