给 Vite Chrome Extension 补一个开发态自动 reload
写 Chrome Extension content script 时,最容易让人烦的是旧运行时:代码已经改了,浏览器还在跑旧版本。
真实开发链路通常是这样:vite build --watch 已经把 dist/content/index.js 和 dist/content/style.css 更新了,Chrome 扩展管理页里的已加载扩展还是旧运行时;就算手动点了 reload,已经打开的目标页面里也可能还挂着旧 content script。最后变成一套固定体力活:改代码、等 watch、reload extension、刷新页面、再看 DOM 有没有变。
这个问题不适合用普通 Web App 的 HMR 心智直接套。content script 没有 Vite dev server 的 HTML 入口,也不是网页里主动加载的模块。更稳的做法是把它拆成三段:Vite 负责持续写真实 dist,Chrome 扩展运行时负责重新加载扩展,目标页面负责刷新后重新注入 content script。
自动 reload 的链路
开发态的构建、通知和重载按以下顺序衔接:
- Vite 继续用
vite build --watch,每次源码变化后产出真实扩展包。 - Vite 插件在构建产物和
manifest.json都写完后,通知本地开发 reload server。 - 开发态 background service worker 通过 WebSocket 连到本地 server。
- background 收到「构建完成」消息后,先给已打开的目标页面发消息。
- content script 收到消息后,用
setTimeout延迟刷新当前页面。 - background 稍等一下,再调用
chrome.runtime.reload()重新加载扩展。

这张图表示文章方案的顺序约束。稳定接入应确认目标接收端已安排刷新,并验证无接收端、关闭页面与刷新过早等失败路径。
顺序很关键。页面刷新不能放在扩展 reload 后面等 background 再通知,因为 chrome.runtime.reload() 之后旧 background 会被销毁,旧 content script 也可能已经收不到后续消息。更稳的顺序是先让页面自己排一个延迟刷新任务,再 reload extension。
这套链路是开发态自动重载,不是完整 HMR。扩展运行时和目标页面都会刷新,content script 内部状态会丢失。对这种「在第三方页面插 DOM、改 CSS、改解析逻辑」的内部工具来说,这个成本通常可以接受,换来的是少很多手动 reload。
content script 仍然走 build watch
Chrome Extension content script 的最终产物应该是浏览器能直接按 manifest 加载的文件。比如 manifest 里会写:
{
"content_scripts": [
{
"matches": ["https://example.com/*"],
"css": ["content/style.css"],
"js": ["content/index.js"],
"run_at": "document_idle"
}
]
}Chrome 的 content_scripts 文档里,js 和 css 都是文件列表。这里没有给 manifest content script 标 type: "module" 的位置,所以交给 Chrome 的 JS 应该是 classic script。源码可以写 TypeScript、import、CSS import,但最终要打成 manifest 能直接加载的稳定文件。
这个场景的目标是让 Chrome 扩展拿到真实 dist,Vite dev server 不能直接替代 build。Vite 官方 build 文档也支持这条路:vite build --watch 会启用底层 watcher,文件变化后触发重新构建;配置变更和配置依赖变化则需要重启命令。
当前这类 content script 项目比较适合用普通 JS input + IIFE:
export default defineConfig({
build: {
outDir: 'dist',
sourcemap: true,
cssCodeSplit: false,
rolldownOptions: {
input: 'src/content/index.ts',
output: {
format: 'iife',
name: 'ExampleContentScript',
entryFileNames: 'content/index.js',
assetFileNames: 'content/[name][extname]'
}
}
}
});这里的 cssCodeSplit: false 是为了让 content 入口依赖到的 CSS 汇总成稳定的 CSS asset,再写进 manifest 的 content_scripts[].css。它不是把 CSS 当成另一个入口,也不是把 CSS 手动拼进 JS;仍然是从 JS 入口正常 import CSS,然后让 Vite 在 build 里抽出产物。图片、字体这类资源如果被 CSS 引用,还要再按扩展访问规则确认路径;需要让页面侧访问的资源,再补 web_accessible_resources。
reload 通知放在构建结束以后
自动 reload 的通知不要放在 buildStart,也不要放在只代表 bundle 生成但文件未落盘的阶段。通知太早时,页面可能已经刷新,content/index.js 是新的,content/style.css 还是旧的,或者 manifest.json 还没写完。
Vite 的插件系统继承了 Rollup / Rolldown 的构建钩子能力,Vite Plugin API也说明 Vite 插件可以使用兼容的 Rollup hooks。但 enforce: 'post' 只说明插件排序,不能替代对异步写文件的等待。Rollup 的钩子定义还说明,closeBundle 在构建出错后也可能执行,不能单独拿它当成功信号。
下面只展示单次输出的通知顺序:bundle 落盘后,先等待项目自己的 writeManifest() 完成,再通知 server。manifest 如果由别的插件写,要把双方协调进同一条完成链,不能只调整插件位置。
const extensionReloadPlugin = (enabled: boolean) => ({
name: 'extension-dev-reload',
apply: 'build',
async writeBundle() {
if (!enabled) return;
await writeManifest();
try {
await notifyReloadServer({ type: 'extension-build-finished' });
} catch {
console.warn('开发重载服务不可用,本次跳过自动刷新');
}
}
});writeManifest 和 notifyReloadServer 是项目提供的函数,不是 Vite API。这段只负责开发态通知,生产 manifest 仍按项目原有构建流程生成;多输出构建还要合并完成信号,避免每次 output 都重复 reload。构建或 manifest 写入失败时不发通知,reload server 不可用则告警并跳过,不能把可选开发服务变成构建阻塞点。
background 用 WebSocket
知乎文章《如何实现 chrome extension 的热更新》采用 webpack + SSE:构建结束后通过本地事件流通知扩展,扩展 reload,再让页面刷新。这个方向可以借鉴,但在 Manifest V3 里,WebSocket 更合适。
原因主要是 MV3 background 已经变成 service worker。service worker 会休眠,不应该假设它像旧版 background page 一样常驻。Chrome extension service worker lifecycle 文档里明确写了空闲关闭、事件重置计时和版本差异;同一页也记录了 Chrome 116 开始,活跃 WebSocket 流量可以延长 extension service worker 生命周期。开发态 reload server 用 WebSocket,再加断线重连和 heartbeat,比 SSE 更贴近这个生命周期。
background 的代码可以保持很小:
const socket = new WebSocket('ws://127.0.0.1:17321');
socket.addEventListener('message', async (event) => {
const message = JSON.parse(event.data);
if (message.type !== 'extension-build-finished') return;
const tabs = await chrome.tabs.query({
url: ['https://example.com/*']
});
for (const tab of tabs) {
if (!tab.id) continue;
chrome.tabs.sendMessage(tab.id, {
type: 'dev:reload-page'
}).catch(() => {});
}
setTimeout(() => {
chrome.runtime.reload();
}, 100);
});权限边界要提前处理。chrome.tabs.query 和 chrome.tabs.sendMessage 至少需要能访问目标页面;如果只查匹配 host 的标签页,先用 host_permissions 覆盖目标 URL。开发态想简单一点,也可以只在 dev manifest 里加 tabs 权限,但不要带进生产包。Chrome runtime API里的 chrome.runtime.reload() 本身就是重新加载扩展的官方入口,问题不在 reload 能不能调用,而在调用前有没有把页面刷新任务安排好。
上面的两个延时只是开发态协调手段,不是消息送达和扩展重载完成的保证。要稳定使用,需要确认目标 content script 已安排刷新,再重载扩展;没有接收端、页面已关闭或刷新过早时,应允许手动重试。不要把固定等待 100 / 300 ms 当成可靠同步协议。
content script 只需要接一个开发态消息:
declare const __EXTENSION_DEV_RELOAD__: boolean;
if (__EXTENSION_DEV_RELOAD__) {
chrome.runtime.onMessage.addListener((message) => {
if (message.type !== 'dev:reload-page') return;
setTimeout(() => {
window.location.reload();
}, 300);
});
}这里不要直接依赖 import.meta.env.DEV。这类项目的 dev 命令往往仍然是 vite build --watch,不是 Vite dev server。更稳的是在 Vite config 里根据 mode 或自定义环境变量写一个显式 define:
export default defineConfig(({ mode }) => ({
define: {
__EXTENSION_DEV_RELOAD__: JSON.stringify(mode === 'development')
}
}));如果命令只是 vite build --watch,默认 mode 仍可能不是你想象中的开发态。可以把脚本写清楚:
{
"scripts": {
"dev": "vite build --watch --mode development",
"build": "vite build --mode production"
}
}这样 dev-only 分支、dev manifest 和生产构建边界都更明确。
manifest 也要分开发态和生产态
自动 reload 不能只靠代码里的 if 隔离。manifest 也要隔离,因为 dev background、WebSocket 地址和额外权限本身就不该出现在生产扩展包里。
可以在生成 manifest 的插件里按 mode 分支:
const createManifest = (isDevelopment: boolean) => ({
manifest_version: 3,
name: 'Example Content Script',
version: '0.1.0',
permissions: isDevelopment ? ['storage', 'tabs'] : ['storage'],
host_permissions: ['https://example.com/*'],
background: isDevelopment
? {
service_worker: 'dev/reload-background.js',
type: 'module'
}
: undefined,
content_scripts: [
{
matches: ['https://example.com/*'],
css: ['content/style.css'],
js: ['content/index.js'],
run_at: 'document_idle'
}
]
});这个例子里的 undefined 只是表达意图,真正写 JSON 前要把空字段去掉。生产包检查也要把这几项列进去:不能有 dev/reload-background.js,不能有 ws://127.0.0.1,不能因为开发便利多带 tabs 权限。
如果一个仓库里有多个扩展,reload server 还要带上应用名或扩展标识。否则一个扩展重建,会把另一个扩展也 reload 掉。这个标识可以从 package name、manifest name 或本地 dev 端口派生出来,不要全局广播到所有开发扩展。
验证清单
这套方案至少要验证这些点:
- 修改 content script 代码后,
dist/content/index.js更新,已打开目标页自动刷新,DOM 里能看到新逻辑。 - 修改 CSS 后,
dist/content/style.css更新,页面刷新后样式同步生效。 - 构建失败时不会触发 reload,页面保留上一次可用版本。
- 关闭 reload server 后,
vite build --watch不崩;server 恢复后,background 可以重连。 - 生产构建后的 manifest 不包含 dev background、WebSocket 地址和开发态权限。
- 最终 JS 产物里没有顶层
import / export,manifest 能按 classic script 注入。 - 如果项目有产物检查脚本,也要确认没有
process.env、Buffer、global这类浏览器运行时不该裸露的 Node 全局。
本地验证不需要发布扩展。先跑项目自己的 typecheck、test、build 和 dist 检查,再把 dist 当 unpacked extension 加载到 Chrome 里做一次手动验证。确认自动 reload 能稳定工作以后,后面日常开发才可以把手从扩展管理页上拿开。
边界
它解决的是 content script 开发时「构建已经更新,但 Chrome 还在跑旧扩展和旧页面」的问题,不是给 popup、options page 或完整 Web App 做 HMR。popup 本来就更像普通 HTML app,可以另外走 Vite dev server 或独立页面预览;content script 则优先保证最终产物和 manifest 加载方式一致。
它也不是生产能力。正式扩展不应该连本地 WebSocket,不应该自动刷新用户页面,也不应该因为开发方便增加额外权限。自动 reload 必须严格留在 development mode 里,这套方案才算可控。