乐于分享
好东西不私藏

告别手动修改:一个Webpack插件搞定所有HTML的脚本自动注入

告别手动修改:一个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 来做。

二、拆解需求:我们到底想让插件帮我们干什么?

把我们的目标翻译成「插件需求」大致是这样:

  1. 在 webpack 产物输出前一刻,拿到所有要写入磁盘的文件内容(也就是 compilation.assets)。
  2. 在里面筛选出所有 .html 文件
  3. 把插件配置里传进来的 scripts 数组(如 ['/xx/a.js', '/xx/b.js']),转换成:
<scriptsrc="/xx/a.js"></script><scriptsrc="/xx/b.js"></script>
  1. 把这段脚本插入到每一个 HTML 的 </head> 前面
  2. 把更新后的 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'asynctrue },  { src'/b.js'defertrue }]

然后在插件里根据字段生成不同的 <script> 标签。

通过这些例子,读者会更清楚:一个 webpack 插件就是在生命周期里“拿到正确的信息 + 按规则修改 + 还回去”,思路一旦通了,扩展就非常自然。

七、总结:写 webpack 插件,其实没有想象中那么难

  • 核心认知:webpack 插件本质上就是一个类,webpack 在构建流程中调用它的 apply(compiler),再从 compiler / compilation 的各种 hooks 里“插入自己的逻辑”。
  • 实现套路
    1. 明确要在构建「哪一个阶段」做什么(比如这次是在 emit 阶段修改 HTML 产物)。
    2. 在合适的 hook 里拿到需要的上下文(如 compilation.assets)。
    3. 安全地读写内容(asset.source() / asset.size()),并且在异步 hook 里记得调用 callback()
  • 这个例子的价值通过 InjectScriptPlugin,实现了一个实用的、对业务零侵入的功能:自动为所有 HTML 注入一批关键脚本,同时也给出了一个非常适合作为「webpack 插件入门范例」的实践。
本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » 告别手动修改:一个Webpack插件搞定所有HTML的脚本自动注入

评论 抢沙发

4 + 3 =
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
×
订阅图标按钮