给 Vite Chrome Extension 补一个开发态自动 reload

写 Chrome Extension content script 时,最容易让人烦的是旧运行时:代码已经改了,浏览器还在跑旧版本。

真实开发链路通常是这样:vite build --watch 已经把 dist/content/index.jsdist/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 的链路

链路可以这样设计:

  1. Vite 继续用 vite build --watch,每次源码变化后产出真实扩展包。
  2. Vite 插件在构建产物和 manifest.json 都写完后,通知本地开发 reload server。
  3. 开发态 background service worker 通过 WebSocket 连到本地 server。
  4. background 收到“构建完成”消息后,先给已打开的目标页面发消息。
  5. content script 收到消息后,用 setTimeout 延迟刷新当前页面。
  6. 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 文档里,jscss 都是文件列表。这里没有给 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。这个场景可以把通知放在写 manifest 的最后一步,或者放在另一个 enforce: 'post' 的插件里,保证所有输出都已经准备好。

配置形状可以先写成这样:

const extensionReloadPlugin = (enabled: boolean) => ({
  name: 'extension-dev-reload',
  apply: 'build',
  async closeBundle() {
    if (!enabled) return;
    await notifyReloadServer({ type: 'extension-build-finished' });
  }
});

closeBundle 里只在开发态通知,本地 server 不应该影响正式构建。构建失败时不要发 reload 消息,浏览器继续跑上一次可用扩展,开发体验反而更稳。

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.querychrome.tabs.sendMessage 至少需要能访问目标页面;如果只查匹配 host 的标签页,先用 host_permissions 覆盖目标 URL。开发态想简单一点,也可以只在 dev manifest 里加 tabs 权限,但不要带进生产包。Chrome runtime API里的 chrome.runtime.reload() 本身就是重新加载扩展的官方入口,问题不在 reload 能不能调用,而在调用前有没有把页面刷新任务安排好。

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 端口派生出来,重点是不要全局广播到所有开发扩展。

验证清单

这套方案至少要验证这些点:

  1. 修改 content script 代码后,dist/content/index.js 更新,已打开目标页自动刷新,DOM 里能看到新逻辑。
  2. 修改 CSS 后,dist/content/style.css 更新,页面刷新后样式同步生效。
  3. 构建失败时不会触发 reload,页面保留上一次可用版本。
  4. 关闭 reload server 后,vite build --watch 不崩;server 恢复后,background 可以重连。
  5. 生产构建后的 manifest 不包含 dev background、WebSocket 地址和开发态权限。
  6. 最终 JS 产物里没有顶层 import / export,manifest 能按 classic script 注入。
  7. 如果项目有产物检查脚本,也要确认没有 process.envBufferglobal 这类浏览器运行时不该裸露的 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 里,是这套方案能放心使用的前提。