为方便初学者快速上手,暂时抛开那些晦涩的概念,直接跟着笔者写一个 webpack 插件,来感受一下吧。笔者这里的代码是基于 webpack 5.x 版本写的,跟 4.x 版本有一定差异。这是一篇入门文,如果已经有基础,就不需要看此文了。
写一个简单的插件
我们假设这样一个场景:发布静态资源之后,需要清除用户浏览器端的缓存。清缓存的方法之一就是在资源文件 URL 之后增加一个时间戳参数。如图,在原来的基础上增加红框区域的内容:
特别说明:本文例子的目的是为了讲解如何编写一个 webpack 插件,而不是为了解“清除用户浏览器端的缓存”问题。如果您看本文是为了解上面的问题,那只要在 “webpack-html-plugin” 插件上配置
hash: true
即可达到同等效果。
一个插件的必要部件
一个 webpack 插件必须包含以下几部分:
- 一个 Javascript 具名函数或对象
- 在 prototype 上定义一个 apply 方法
- 从指定一个 hook 事件进入 tap 开始
- 处理 webpack 内部实例的特定数据
- 完成所有功能后执行 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 或已有插件的源码来积累,当然也可以看一些高质量的详解文章。
参考资料: