告别手动修改:一个Webpack插件搞定所有HTML的脚本自动注入
这篇文章,会以我在项目中的 InjectScriptPlugin 为例,从 0 到 1 讲清楚:
-
webpack 插件的工作原理 -
如何从需求抽象出一个插件 -
完整实现一个插件类 -
如何在 vue.config.js中接入并验证
一、先弄清楚:webpack 插件到底在干嘛?
一句话概括:
Loader 负责“处理一个个文件”,Plugin 负责“参与整个打包生命周期”。
-
Loader:面对的是某种「资源类型」,比如 .vue、.scss、图片等。它本质上就是一个函数,输入源码,输出处理结果。 -
Plugin:面对的是「webpack 自己」,通过监听它的各种生命周期钩子(hooks),在合适的时机做一些“全局性”的事情: -
修改入口 / 入口依赖 -
操作最终产物 compilation.assets -
统计体积、生成报告、上传 sourcemap……
本质上,webpack 插件就是一个带有 apply(compiler) 方法的类。在 apply 里会拿到 compiler(编译器实例),从它身上可以订阅各种钩子(compiler.hooks.xxx.tap(...)),从而插入自己的逻辑。
我的场景——构建完成时,给所有 HTML 文件注入 <script> 标签,显然是一个“全局产物级”的操作,因此非常适合用 Plugin 来做。
二、拆解需求:我们到底想让插件帮我们干什么?
把我们的目标翻译成「插件需求」大致是这样:
-
在 webpack 产物输出前一刻,拿到所有要写入磁盘的文件内容(也就是 compilation.assets)。 -
在里面筛选出所有 .html文件。 -
把插件配置里传进来的 scripts数组(如['/xx/a.js', '/xx/b.js']),转换成:
<scriptsrc="/xx/a.js"></script><scriptsrc="/xx/b.js"></script>
-
把这段脚本插入到每一个 HTML 的 </head>前面。 -
把更新后的 HTML 内容重新写回 compilation.assets,让 webpack 最终输出改好的内容。
这 5 步,基本就是这个 InjectScriptPlugin 做的全部事情。
三、实现一个最小可用的 webpack 插件类
先看一下我的项目中的完整实现,然后我们逐段拆解:
classInjectScriptPlugin{ constructor (options) {this.options = options } apply (compiler) { compiler.hooks.emit.tapAsync('InjectScriptPlugin', (compilation, callback) => {// 查找生成的HTML文件Object.keys(compilation.assets) .forEach((fileName) => {if (fileName.endsWith('.html')) {// 获取HTML文件内容const asset = compilation.assets[fileName]const content = asset.source() .toString()// options.scripts 是一个包含脚本路径的数组const scripts = this.options.scripts.map(script => {// 构建后的文件路径可能需要根据Webpack的输出配置进行调整return`<script src="${script}"></script>` }) .join('\n')// 插入脚本到HTML的<head>标签结束前const newContent = content.replace(/<\/head>/i, `${scripts}\n</head>`)// 更新HTML文件内容 compilation.assets[fileName] = {source: () => newContent,size: () => newContent.length } } }) callback() }) }}module.exports = InjectScriptPlugin
下面分步骤说明每一块在做什么。
1. 插件的基本结构:constructor + apply
classInjectScriptPlugin{constructor (options) {this.options = options } apply (compiler) {// ... }}
-
constructor(options):接收用户配置(比如scripts数组),挂到实例上,供后面使用。 -
apply(compiler):webpack 初始化插件时会调用这个方法,把compiler对象传进来。所有的逻辑,基本都从这里开始。
经验小结:
-
插件几乎都长这个样子:“类 + 构造函数 + apply”。 -
把可配置项收进 constructor,而不是写死在源码里,插件的「复用性」会更好。
2. 选择合适的生命周期:为什么用 compiler.hooks.emit?
apply (compiler) { compiler.hooks.emit.tapAsync('InjectScriptPlugin', (compilation, callback) => {// ... callback() }) }
-
compiler.hooks.emit触发时机:所有模块完成编译,产物已经生成到了compilation.assets里,正准备写入输出目录(dist)之前。 -
对我们来说,此时: -
HTML 文件已经由 html-webpack-plugin等插件生成。 -
还没真正写入磁盘,我们还有“修改机会”。
这里用的是 tapAsync,表示这个钩子是异步的:
-
第一个参数 'InjectScriptPlugin':插件名字,方便调试。 -
第二个参数是回调 (compilation, callback): -
compilation:这次构建过程的产物、依赖图等信息。 -
callback:处理完后必须调用它,告诉 webpack “我结束了,可以继续后面的流程”。
注意:如果做的是文件 IO / 网络请求等异步操作,记得一定要调用 callback,否则构建会卡住。
3. 遍历 compilation.assets,定位所有 HTML
Object.keys(compilation.assets) .forEach((fileName) => {if (fileName.endsWith('.html')) {const asset = compilation.assets[fileName]const content = asset.source() .toString()// ... } })
-
compilation.assets是一个对象,key 是文件名,value 是一个「虚拟文件」对象。 -
常用的两个方法: -
asset.source():拿到文件内容(通常是字符串或 Buffer)。 -
asset.size():拿到文件大小(字节数)。 -
这里通过 fileName.endsWith('.html')来过滤出所有 HTML 产物。
拿到内容后,把它转成字符串 content,后续就可以按普通字符串来操作了(比如 replace)。
4. 把配置里的 scripts 转成 <script> 标签
const scripts = this.options.scripts.map(script => {return`<script src="${script}"></script>`}) .join('\n')
-
this.options.scripts来自插件配置(后面在vue.config.js里会看到)。 -
通过 map把每个路径变成一行<script src="..."></script>。 -
再通过 join('\n')拼成一大段可直接插入 HTML 的字符串。
这是实践中非常重要的一点:路径前缀是否需要加上 publicPath、是否需要考虑 CDN / 灰度版本目录,都要结合实际项目来调。
5. 用正则把脚本插入到 </head> 之前
const newContent = content.replace(/<\/head>/i, `${scripts}\n</head>`)
这里有几个小细节:
-
用的正则是 /<\/head>/i: -
/i标志表示不区分大小写(</HEAD>也能匹配)。 -
把原来的 </head>替换为:scripts + 换行 + 原来的 </head>。 -
这样就保证了脚本一定出现在 <head>里,并且保持 HTML 结构合法。
潜在坑点(可以在文章里顺带提醒读者):
-
如果 HTML 模板里没有 <head>(极少数场景),这一招就会失效,需要再做兜底判断。 -
如果有多个 </head>(比如字符串里写了 demo),第一次匹配到的可能不是想要的那个,需要更严谨的匹配方式。
6. 把修改后的内容重新写回 compilation.assets
compilation.assets[fileName] = {source: () => newContent,size: () => newContent.length}
这是 webpack 的一个约定:产物可以是任意实现了 source() 和 size() 方法的对象。
-
在这里直接用一个简单的字面量对象替换了原始 asset。 -
这两个方法在后续写文件 / 统计体积的时候会被调用到: -
source()返回最终写入磁盘的内容。 -
size()返回大小,方便统计信息。
到这里,插件的核心逻辑就跑通了:每次构建时,它都会在 HTML 输出之前,把配置好的脚本插进 <head> 里。
四、如何在 Vue CLI 项目中接入这个插件?
在 vue.config.js 里的配置是这样的(只看和插件相关的部分):
const InjectScriptPlugin = require('./scripts/InjectScriptPlugin')const { scripts } = require('./config')module.exports = defineConfig({ // ... configureWebpack: {plugins: [new InjectScriptPlugin({ scripts }),// ... ].filter(Boolean),// ... },// ...})
这里有几个点可以在文章里说明清楚:
-
插件注册方式:和任何 webpack 插件一样, new InjectScriptPlugin({ scripts })塞进plugins数组里即可。 -
配置项来源: scripts是从项目配置模块./config里导出的,这样方便按环境不同配置不同脚本(比如灰度版、UAT 环境)。 -
.filter(Boolean)小技巧:在plugins里还有process.env.VUE_APP_ANALYZER && new BundleAnalyzerPlugin()这类按环境启用的插件,配合.filter(Boolean)可以优雅地剔除false/undefined,保持数组干净。
这也给读者一个实践建议:
把可变的配置(比如注入哪些脚本)从插件实现里抽出来,统一放到配置文件中,根据环境灵活切换。
五、这个插件解决了什么问题?有什么需要注意的?
1. 解决的问题
-
不侵入业务代码:不需要在每个入口手动
import这些脚本,也不需要在模板里写死<script>标签,一切交给构建阶段自动处理,业务代码更干净。 -
对多页面 / 子应用友好:只要是构建产出的
.html,都会被统一处理,非常适合微前端、多入口页面等场景。
六、如果要扩展这个插件,可以怎么做?
在原有基础上,展示插件的可扩展性,比如:
-
支持按文件名模式注入例如只对 index.html或满足某个正则的 HTML 文件注入脚本:
new InjectScriptPlugin({ scripts,include: [/index\.html$/] // or exclude: [...]})
-
支持插入到
<body>前或后增加一个position选项:head/body-start/body-end,对应不同匹配位置。 -
支持自定义标签属性比如:
scripts: [ { src: '/a.js', async: true }, { src: '/b.js', defer: true }]
然后在插件里根据字段生成不同的 <script> 标签。
通过这些例子,读者会更清楚:一个 webpack 插件就是在生命周期里“拿到正确的信息 + 按规则修改 + 还回去”,思路一旦通了,扩展就非常自然。
七、总结:写 webpack 插件,其实没有想象中那么难
-
核心认知:webpack 插件本质上就是一个类,webpack 在构建流程中调用它的 apply(compiler),再从compiler/compilation的各种 hooks 里“插入自己的逻辑”。 -
实现套路: -
明确要在构建「哪一个阶段」做什么(比如这次是在 emit阶段修改 HTML 产物)。 -
在合适的 hook 里拿到需要的上下文(如 compilation.assets)。 -
安全地读写内容( asset.source()/asset.size()),并且在异步 hook 里记得调用callback()。 -
这个例子的价值:通过 InjectScriptPlugin,实现了一个实用的、对业务零侵入的功能:自动为所有 HTML 注入一批关键脚本,同时也给出了一个非常适合作为「webpack 插件入门范例」的实践。
夜雨聆风
