> ## Content Index
> Fetch the complete content index at: https://blog.vercanti.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# 浏览器插件 中级开发指南（Manifest V3 + Vite + Vue）
- URL: https://blog.vercanti.com/liu-lan-qi-cha-jian-zhong-ji-kai-fa-zhi-nan-manifest-v3-vite-vue/
- Published: 2026-08-28T14:35:29.000Z
- Updated: 2026-08-28T14:58:55.000Z
- Description: 本文面向已掌握 Manifest V3 基础（manifest 结构、Service Worker 后台、消息通信）、需要落地真实功能的开发者。每个功能模块给出可直接复用的完整代码，技术栈统一为 Vite + Vue 3 + Pinia，构建假定使用 @crxjs/vite-plugin 或等价的多入口配置（产出 manifest.json 与各 HTML 入口）。 后文所有示例基于以下目录结构。src/manifest.ts 导出 manifest 对象，由构建插件生成最终 manifest.json。 声明式注入（manifest content_s
- Author: yellowdog
- Tags: 前端开发, 浏览器插件开发

> 官方文档：
> 
> - 内容脚本 <https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts>
> - scripting API <https://developer.chrome.com/docs/extensions/reference/api/scripting>
> - declarativeNetRequest <https://developer.chrome.com/docs/extensions/reference/api/declarativeNetRequest>
> - sidePanel <https://developer.chrome.com/docs/extensions/reference/api/sidePanel>
> - devtools <https://developer.chrome.com/docs/extensions/reference/api/devtools>
> - i18n <https://developer.chrome.com/docs/extensions/reference/api/i18n>
> - notifications <https://developer.chrome.com/docs/extensions/reference/api/notifications>  
> 适用版本：Manifest V3 / Chrome 138+（部分 API 标注更高最低版本）  
> 核实日期：2026-06-06

本文面向已掌握 Manifest V3 基础（manifest 结构、Service Worker 后台、消息通信）、需要落地真实功能的开发者。每个功能模块给出可直接复用的完整代码，技术栈统一为 Vite + Vue 3 + Pinia，构建假定使用 `@crxjs/vite-plugin` 或等价的多入口配置（产出 `manifest.json` 与各 HTML 入口）。

---

## 工程结构约定

后文所有示例基于以下目录结构。`src/manifest.ts` 导出 manifest 对象，由构建插件生成最终 `manifest.json`。

```
extension/
├── src/
│   ├── manifest.ts            # manifest 定义
│   ├── background/
│   │   └── index.ts           # Service Worker 入口
│   ├── content/
│   │   ├── index.ts           # 内容脚本（ISOLATED world）
│   │   ├── main-world.ts      # 注入页面 MAIN world 的脚本
│   │   └── App.vue            # 挂到页面的 Vue UI
│   ├── popup/                 # 弹窗 SPA（Vue + Router + Pinia）
│   ├── options/               # 设置页 SPA
│   ├── sidepanel/             # 侧边栏 SPA
│   ├── devtools/              # DevTools 页与面板
│   ├── stores/                # Pinia stores
│   └── i18n/                  # t() helper
├── _locales/
│   ├── zh_CN/messages.json
│   └── en/messages.json
├── rules/
│   └── static-rules.json      # declarativeNetRequest 静态规则
└── vite.config.ts

```

---

## 内容脚本进阶

### 声明式注入 vs 动态注入

声明式注入（manifest `content_scripts`）在匹配页面加载时自动执行，适合稳定的、对所有匹配站点生效的脚本。动态注入（`chrome.scripting`）在运行时按需注入，适合"点击图标才注入""根据用户设置决定是否注入"等场景。

manifest 声明式注入片段：

```ts
// src/manifest.ts
export default {
  manifest_version: 3,
  name: "__MSG_extName__",
  default_locale: "zh_CN",
  version: "1.0.0",
  permissions: ["scripting", "activeTab", "storage", "tabs"],
  host_permissions: ["https://*/*"],
  content_scripts: [
    {
      matches: ["https://*.example.com/*"],
      js: ["src/content/index.ts"],
      run_at: "document_idle", // 注入时机，见下表
      all_frames: false        // 是否注入所有 iframe
    }
  ]
} satisfies chrome.runtime.ManifestV3

```

运行时动态注入（在 Service Worker 或 popup 中调用）：

```ts
// 点击图标时才向当前标签页注入
chrome.action.onClicked.addListener(async (tab) => {
  if (!tab.id) return
  await chrome.scripting.executeScript({
    target: { tabId: tab.id, allFrames: false },
    files: ["src/content/index.ts"]
  })
})

// 直接注入函数并传参（func + args，args 必须 JSON 可序列化）
async function highlightKeyword(tabId: number, keyword: string) {
  await chrome.scripting.executeScript({
    target: { tabId },
    func: (kw: string) => {
      // 此函数在页面的 ISOLATED world 执行
      document.body.innerHTML = document.body.innerHTML.replaceAll(
        kw,
        `<mark>${kw}</mark>`
      )
    },
    args: [keyword] // 通过 args 传入，不能闭包捕获外层变量
  })
}

```

`executeScript` 关键字段：

| 字段                | 类型         | 默认值        | 说明                       |
| ----------------- | ---------- | ---------- | ------------------------ |
| target.tabId      | number     | 必填         | 目标标签页 ID                 |
| target.allFrames  | boolean    | false      | 注入所有帧                    |
| target.frameIds   | number\[\] | —          | 指定帧 ID，与 allFrames 互斥    |
| func              | Function   | —          | 要执行的函数，与 files 互斥        |
| args              | any\[\]    | —          | 传给 func 的参数，须 JSON 可序列化  |
| files             | string\[\] | —          | 注入的脚本文件路径（相对扩展根）         |
| world             | string     | "ISOLATED" | 执行世界，"ISOLATED" 或 "MAIN" |
| injectImmediately | boolean    | false      | 不等待页面加载立即注入              |

### run\_at 时机选择

| 取值                 | 时机                                     | 适用场景                          |
| ------------------ | -------------------------------------- | ----------------------------- |
| document\_start    | DOM 构建前、CSS 加载前                        | 抢在页面脚本前 hook、注入 MAIN world 拦截 |
| document\_end      | DOM 构建完成、子资源未必加载完                      | 操作 DOM 结构，不依赖图片/样式            |
| document\_idle（默认） | DOM 完成且页面空闲（介于 end 与 window.onload 之间） | 常规 UI 注入、读取页面数据               |

需要在页面自身脚本执行前介入（例如改写 `window.fetch`）时，必须用 `document_start` 且注入到 MAIN world。

### all\_frames 与 iframe

`all_frames: true` 会把脚本注入页面的每个同源/跨源 iframe。配合 `match_about_blank` 可注入 `about:blank` 帧。注入到所有帧时务必在脚本里判断 `window.top === window.self`，避免在子帧重复挂载 UI。

```ts
// src/content/index.ts
if (window.top !== window.self) {
  // 错误：在每个 iframe 都挂载浮层会导致页面出现多个 UI
  // 仅在顶层帧挂载主 UI，子帧只做数据采集
} else {
  mountFloatingUI()
}

```

### 注入 MAIN world 桥接页面变量

内容脚本默认运行在 ISOLATED world（隔离环境），与页面共享 DOM 但不共享 JS 变量与原型。要读取页面 `window.__APP_STATE__` 这类变量，需把脚本注入 MAIN world，再用 `window.postMessage` 把数据传回 ISOLATED world。

声明式注入 MAIN world（manifest）：

```ts
// src/manifest.ts 的 content_scripts 增加一条
{
  matches: ["https://*.example.com/*"],
  js: ["src/content/main-world.ts"],
  run_at: "document_start",
  world: "MAIN" // 关键：运行在页面主世界，可访问页面变量
}

```

MAIN world 脚本：读取页面变量并通过 postMessage 发出。

```ts
// src/content/main-world.ts —— 运行在页面 MAIN world
;(function bridgePageVariables() {
  // 此处可直接访问页面全局变量
  const sendState = () => {
    window.postMessage(
      {
        source: "MY_EXT_MAIN", // 自定义来源标识，用于过滤
        type: "PAGE_STATE",
        payload: (window as any).__APP_STATE__ ?? null
      },
      window.location.origin // 限定 targetOrigin，避免泄露给第三方
    )
  }
  sendState()
  // 也可 hook 页面方法，变更时再发一次
  const origPush = history.pushState
  history.pushState = function (...args) {
    const r = origPush.apply(this, args as any)
    sendState()
    return r
  }
})()

```

ISOLATED world 脚本：接收消息并转发给后台。

```ts
// src/content/index.ts —— 运行在 ISOLATED world
window.addEventListener("message", (event) => {
  // 错误：不校验 origin 与 source 会让任意页面脚本伪造消息
  if (event.source !== window) return
  if (event.origin !== window.location.origin) return
  const data = event.data
  if (data?.source !== "MY_EXT_MAIN" || data.type !== "PAGE_STATE") return

  chrome.runtime.sendMessage({ type: "PAGE_STATE", payload: data.payload })
})

```

通信方向小结：MAIN world 与 ISOLATED world 之间只能走 `window.postMessage`（共享同一个 window）；ISOLATED world 与 Service Worker 之间走 `chrome.runtime.sendMessage`。

---

## 在内容脚本里挂载 Vue UI（Shadow DOM 隔离）

直接把 Vue 应用挂到页面 DOM 会被页面 CSS 污染，也会污染页面。用 Shadow DOM（影子 DOM）做样式隔离：创建宿主元素，挂 `shadowRoot`，把 Vue 与样式都放进去。

```ts
// src/content/index.ts
import { createApp } from "vue"
import App from "./App.vue"
// 以 ?inline 引入编译后的 CSS 字符串（Vite 支持），注入 shadow root
import styleText from "./App.css?inline"

function mountFloatingUI() {
  const host = document.createElement("div")
  host.id = "my-ext-root"
  host.style.cssText =
    "position:fixed;z-index:2147483647;top:80px;right:24px;" // 最高层级
  document.documentElement.appendChild(host)

  const shadow = host.attachShadow({ mode: "open" })

  // 把样式注入 shadow root，页面样式无法穿透进来
  const style = document.createElement("style")
  style.textContent = styleText
  shadow.appendChild(style)

  const mountPoint = document.createElement("div")
  shadow.appendChild(mountPoint)

  const app = createApp(App)
  app.mount(mountPoint) // Vue 挂到 shadow root 内部
  return { host, app }
}

if (window.top === window.self) {
  mountFloatingUI()
}

```

App.vue 实现一个跟随页面滚动定位的浮层，并把样式打包成独立 CSS（供上面 `?inline` 引入）：

```vue
<!-- src/content/App.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue"

const visible = ref(true)
const top = ref(80)

function onScroll() {
  // 浮层随页面滚动保持在视口固定位置（这里 host 已 fixed，演示动态偏移）
  top.value = 80 + Math.min(window.scrollY * 0.02, 40)
}

onMounted(() => window.addEventListener("scroll", onScroll, { passive: true }))
onUnmounted(() => window.removeEventListener("scroll", onScroll))
</script>

<template>
  <div v-if="visible" class="panel" :style="{ marginTop: top - 80 + 'px' }">
    <header class="panel__bar">
      助手面板
      <button class="panel__close" @click="visible = false">×</button>
    </header>
    <slot>面板内容</slot>
  </div>
</template>

<style>
/* 这些样式被打进 App.css，再以 ?inline 注入 shadow root */
.panel {
  width: 280px;
  background: #fff;
  border: 1px solid #e5e7eb;
  border-radius: 12px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
  font: 14px/1.5 system-ui, sans-serif;
  color: #111;
}
.panel__bar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 8px 12px;
  font-weight: 600;
}
.panel__close {
  border: 0;
  background: none;
  cursor: pointer;
  font-size: 18px;
}
</style>

```

定位策略：宿主元素用 `position: fixed` 配合最高 `z-index`（`2147483647` 为 32 位有符号最大值）保证浮在所有页面内容之上。若需要锚定到页面某个元素并随其滚动，则用 `position: absolute` \+ `getBoundingClientRect()` 在滚动事件里更新坐标。

---

## popup 与 options 的 Vue 工程化

### 路由（hash 模式）

扩展页面以 `chrome-extension://<id>/popup.html` 加载，必须用 hash 模式路由，history 模式刷新会 404。

```ts
// src/popup/router.ts
import { createRouter, createWebHashHistory } from "vue-router"

const routes = [
  { path: "/", component: () => import("./views/Home.vue") },
  { path: "/detail/:id", component: () => import("./views/Detail.vue") },
  { path: "/settings", component: () => import("./views/Settings.vue") }
]

export const router = createRouter({
  history: createWebHashHistory(), // 必须 hash 模式
  routes
})

```

```ts
// src/popup/main.ts
import { createApp } from "vue"
import { createPinia } from "pinia"
import App from "./App.vue"
import { router } from "./router"

createApp(App).use(createPinia()).use(router).mount("#app")

```

### Pinia + chrome.storage 持久化

把 Pinia 状态同步到 `chrome.storage.local`，让 popup 关闭再打开仍保留，也能与后台、内容脚本共享。

```ts
// src/stores/settings.ts
import { defineStore } from "pinia"
import { ref, watch } from "vue"

export interface Settings {
  enabled: boolean
  theme: "light" | "dark"
  apiBase: string
}

const STORAGE_KEY = "settings"
const DEFAULTS: Settings = { enabled: true, theme: "light", apiBase: "" }

export const useSettingsStore = defineStore("settings", () => {
  const settings = ref<Settings>({ ...DEFAULTS })
  const loaded = ref(false)

  async function load() {
    const res = await chrome.storage.local.get(STORAGE_KEY)
    settings.value = { ...DEFAULTS, ...(res[STORAGE_KEY] ?? {}) }
    loaded.value = true
  }

  async function save() {
    await chrome.storage.local.set({ [STORAGE_KEY]: { ...settings.value } })
  }

  // 加载完成后，状态变更自动落盘
  watch(
    settings,
    () => {
      if (loaded.value) save()
    },
    { deep: true }
  )

  // 监听其他上下文（options 页、后台）的修改，保持多端同步
  chrome.storage.onChanged.addListener((changes, area) => {
    if (area === "local" && changes[STORAGE_KEY]) {
      settings.value = { ...DEFAULTS, ...changes[STORAGE_KEY].newValue }
    }
  })

  return { settings, loaded, load, save }
})

```

### 表单保存设置（options 页）

```vue
<!-- src/options/views/Settings.vue -->
<script setup lang="ts">
import { onMounted } from "vue"
import { useSettingsStore } from "@/stores/settings"
import { storeToRefs } from "pinia"

const store = useSettingsStore()
const { settings } = storeToRefs(store)

onMounted(store.load) // 进入页面先拉取
// 由于 store 内 watch 自动落盘，表单双向绑定即等于"实时保存"
</script>

<template>
  <form @submit.prevent>
    <label>
      <input type="checkbox" v-model="settings.enabled" /> 启用扩展
    </label>
    <label>
      主题
      <select v-model="settings.theme">
        <option value="light">浅色</option>
        <option value="dark">深色</option>
      </select>
    </label>
    <label>
      API 地址
      <input type="url" v-model="settings.apiBase" placeholder="https://..." />
    </label>
  </form>
</template>

```

---

## 国际化 i18n

### \_locales 目录与 messages.json

有 `_locales` 目录时，manifest 必须声明 `default_locale`。manifest 自身的文案用 `__MSG_name__`。

```
_locales/
├── zh_CN/messages.json
└── en/messages.json

```

```json
// _locales/zh_CN/messages.json
{
  "extName": { "message": "我的助手", "description": "扩展名称" },
  "popupTitle": { "message": "快捷操作" },
  "greet": {
    "message": "你好，$NAME$，今天是 $DATE$",
    "placeholders": {
      "name": { "content": "$1", "example": "小明" },
      "date": { "content": "$2" }
    }
  }
}

```

```json
// _locales/en/messages.json
{
  "extName": { "message": "My Assistant" },
  "popupTitle": { "message": "Quick Actions" },
  "greet": {
    "message": "Hello $NAME$, today is $DATE$",
    "placeholders": {
      "name": { "content": "$1" },
      "date": { "content": "$2" }
    }
  }
}

```

messages.json 条目字段：

| 字段                       | 类型     | 必填 | 说明                             |
| ------------------------ | ------ | -- | ------------------------------ |
| message                  | string | 是  | 译文，$NAME$ 引用 placeholder       |
| description              | string | 否  | 给译者的说明                         |
| placeholders             | object | 否  | 占位符表                           |
| placeholders.<n>.content | string | 是  | 替换内容，$1..$9 对应 getMessage 第二参数 |
| placeholders.<n>.example | string | 否  | 示例值                            |

### chrome.i18n.getMessage 与 Vue t() helper

```ts
// src/i18n/index.ts
export function t(key: string, subs?: string | string[]): string {
  return chrome.i18n.getMessage(key, subs) || key // 缺失时回退到 key，便于排查
}

export const uiLanguage = () => chrome.i18n.getUILanguage() // 如 "zh-CN"

// 注册为全局属性，模板里用 $t
import type { App } from "vue"
export function installI18n(app: App) {
  app.config.globalProperties.$t = t
}

```

```vue
<!-- 在组件中使用 -->
<script setup lang="ts">
import { t } from "@/i18n"
const hello = t("greet", ["小明", "2026-06-06"])
</script>

<template>
  <h1>{{ t("popupTitle") }}</h1>
  <p>{{ hello }}</p>
</template>

```

默认语言回退顺序：先按用户 UI 语言（如 `en_GB`）找，找不到回退到基础语言（`en`），再回退到 manifest 的 `default_locale`。因此 `default_locale` 对应的 messages.json 必须包含全部 key。

---

## 右键菜单 contextMenus

`contextMenus` 必须在 manifest 声明 `"contextMenus"` 权限，并在 Service Worker 的 `onInstalled` 中创建（SW 会休眠，重新创建会因 id 重复报错，故放在 onInstalled 一次性创建）。

```ts
// src/background/index.ts
chrome.runtime.onInstalled.addListener(() => {
  // 顶层菜单（多级菜单的父项）
  chrome.contextMenus.create({
    id: "tools",
    title: chrome.i18n.getMessage("extName"),
    contexts: ["all"]
  })

  // 选中文本时出现的子菜单
  chrome.contextMenus.create({
    id: "search-selection",
    parentId: "tools",
    title: '搜索 "%s"', // %s 自动替换为选中文本
    contexts: ["selection"]
  })

  // 针对链接
  chrome.contextMenus.create({
    id: "copy-link",
    parentId: "tools",
    title: "复制链接地址",
    contexts: ["link"]
  })

  // 针对图片
  chrome.contextMenus.create({
    id: "download-image",
    parentId: "tools",
    title: "下载此图片",
    contexts: ["image"]
  })
})

chrome.contextMenus.onClicked.addListener((info, tab) => {
  switch (info.menuItemId) {
    case "search-selection":
      chrome.tabs.create({
        url: "https://www.google.com/search?q=" +
          encodeURIComponent(info.selectionText ?? "")
      })
      break
    case "copy-link":
      // info.linkUrl 为被右键的链接
      console.log("link:", info.linkUrl)
      break
    case "download-image":
      if (info.srcUrl) chrome.downloads.download({ url: info.srcUrl })
      break
  }
})

```

`contexts` 常用取值：`all`、`page`、`selection`、`link`、`image`、`video`、`audio`、`editable`、`action`。按 `contexts` 区分可让同一菜单只在特定目标上出现。

---

## 键盘快捷键 commands

manifest 声明 `commands`，`_execute_action` 为保留命令（打开 popup），其余为自定义命令，在后台监听 `chrome.commands.onCommand`。

```ts
// src/manifest.ts 片段
commands: {
  _execute_action: {
    suggested_key: { default: "Ctrl+Shift+Y", mac: "Command+Shift+Y" },
    description: "打开弹窗"
  },
  "toggle-panel": {
    suggested_key: { default: "Ctrl+Shift+U", mac: "Command+Shift+U" },
    description: "切换页面浮层"
  }
}

```

```ts
// src/background/index.ts
chrome.commands.onCommand.addListener(async (command) => {
  if (command === "toggle-panel") {
    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
    if (tab?.id) {
      // 向内容脚本发消息，由其切换浮层显隐，实现与 UI 联动
      chrome.tabs.sendMessage(tab.id, { type: "TOGGLE_PANEL" })
    }
  }
})

```

与 popup 联动：`_execute_action` 由浏览器直接打开 popup，无需监听。popup 打开后可读取 storage 决定展示内容。自定义命令限制最多 4 个建议快捷键，用户可在 `chrome://extensions/shortcuts` 重新绑定。

---

## 侧边栏 side panel

侧边栏（side panel，Chrome 114+）提供常驻于浏览器侧的 UI。manifest 声明 `"sidePanel"` 权限与默认页面。

```ts
// src/manifest.ts 片段
permissions: ["sidePanel", "tabs"],
side_panel: { default_path: "src/sidepanel/index.html" }

```

`sidePanel` 关键方法：

| 方法               | 参数                         | 说明                       |
| ---------------- | -------------------------- | ------------------------ |
| setOptions       | { tabId?, path?, enabled } | 全局或按标签页配置面板              |
| setPanelBehavior | { openPanelOnActionClick } | 点击图标时打开面板                |
| getOptions       | { tabId? }                 | 读取当前配置                   |
| open             | { tabId? \| windowId? }    | 程序化打开（需用户手势，Chrome 116+） |

按站点启用：监听 `tabs.onUpdated`，匹配域名才 `enabled: true`。

```ts
// src/background/index.ts
const ENABLED_HOST = "example.com"

chrome.runtime.onInstalled.addListener(() => {
  // 点击扩展图标即打开侧边栏
  chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true })
})

chrome.tabs.onUpdated.addListener(async (tabId, info, tab) => {
  if (!tab.url) return
  const isTarget = new URL(tab.url).hostname.endsWith(ENABLED_HOST)
  await chrome.sidePanel.setOptions({
    tabId,
    path: "src/sidepanel/index.html",
    enabled: isTarget // 非目标站点禁用该标签页的面板
  })
})

```

与内容脚本联动：侧边栏是独立扩展页面，与内容脚本之间通过后台或 `chrome.runtime` 长连接通信。

```ts
// 侧边栏页面建立长连接，接收内容脚本经后台转发的数据
const port = chrome.runtime.connect({ name: "sidepanel" })
port.onMessage.addListener((msg) => {
  if (msg.type === "PAGE_STATE") {
    // 渲染来自页面的实时数据
  }
})

```

---

## omnibox 地址栏关键词

`omnibox` 让用户在地址栏输入关键词后按空格进入扩展输入模式。

```ts
// src/manifest.ts 片段
omnibox: { keyword: "ex" } // 地址栏输入 "ex " 触发

```

```ts
// src/background/index.ts
chrome.omnibox.onInputChanged.addListener((text, suggest) => {
  suggest([
    { content: `search ${text}`, description: `搜索：<match>${text}</match>` },
    { content: `open ${text}`, description: `打开页面：${text}` }
  ])
})

chrome.omnibox.onInputEntered.addListener((text, disposition) => {
  const url = "https://www.example.com/?q=" + encodeURIComponent(text)
  // disposition: currentTab / newForegroundTab / newBackgroundTab
  if (disposition === "currentTab") {
    chrome.tabs.update({ url })
  } else {
    chrome.tabs.create({ url })
  }
})

```

`description` 支持 `<match>`、`<dim>`、`<url>` 标签做高亮，文本需 XML 转义。

---

## DevTools 扩展

DevTools 扩展由 `devtools_page` 入口加载，在其中调用 `chrome.devtools.panels.create` 创建自定义面板。devtools\_page 自身不可见，只用于注册。

```ts
// src/manifest.ts 片段
devtools_page: "src/devtools/devtools.html"

```

```html
<!-- src/devtools/devtools.html -->
<!doctype html>
<html>
  <head><meta charset="utf-8" /></head>
  <body><script type="module" src="./devtools.ts"></script></body>
</html>

```

```ts
// src/devtools/devtools.ts —— 注册面板与监听网络
chrome.devtools.panels.create(
  "我的面板", // 标签标题
  "icons/panel-128.png", // 图标
  "src/devtools/panel.html", // 面板内容页（可放 Vue 应用）
  (panel) => {
    panel.onShown.addListener(() => {
      // 面板被打开
    })
  }
)

// 在被检查页面执行表达式，拿回结果
function evalInPage(expr: string) {
  return new Promise((resolve, reject) => {
    chrome.devtools.inspectedWindow.eval(expr, (result, error) => {
      if (error) reject(error)
      else resolve(result)
    })
  })
}

// 监听被检查页面的网络请求
chrome.devtools.network.onRequestFinished.addListener((request) => {
  // request 为 HAR 条目，getContent 取响应体
  request.getContent((body) => {
    if (request.request.url.includes("/api/")) {
      console.log("API:", request.request.url, body?.slice(0, 200))
    }
  })
})

```

```ts
// 取被检查标签页 ID（面板页里向后台请求该页数据时用）
const inspectedTabId = chrome.devtools.inspectedWindow.tabId

```

面板页（`panel.html`）可挂载完整 Vue 应用，通过 `chrome.devtools.inspectedWindow.eval` 读取页面状态，通过 `chrome.devtools.network` 聚合接口调用。

---

## 通知 notifications

`notifications` 需声明权限。基础通知、带按钮、进度条三类。

```ts
// src/manifest.ts 片段
permissions: ["notifications"]

```

```ts
// 基础通知
chrome.notifications.create("basic-1", {
  type: "basic",
  iconUrl: chrome.runtime.getURL("icons/128.png"), // 必填，须用完整 URL
  title: "任务完成",
  message: "数据已同步"
})

// 带按钮
chrome.notifications.create("with-buttons", {
  type: "basic",
  iconUrl: chrome.runtime.getURL("icons/128.png"),
  title: "发现更新",
  message: "是否立即查看？",
  buttons: [{ title: "查看" }, { title: "忽略" }]
})

chrome.notifications.onButtonClicked.addListener((id, btnIndex) => {
  if (id === "with-buttons" && btnIndex === 0) {
    chrome.tabs.create({ url: "https://www.example.com/changelog" })
  }
})

// 进度通知（type: "progress"，progress 0..100）
let p = 0
chrome.notifications.create("progress-1", {
  type: "progress",
  iconUrl: chrome.runtime.getURL("icons/128.png"),
  title: "下载中",
  message: "正在下载文件",
  progress: 0
})
const timer = setInterval(() => {
  p += 20
  chrome.notifications.update("progress-1", { progress: p })
  if (p >= 100) clearInterval(timer)
}, 500)

```

---

## 网络请求拦截 declarativeNetRequest

`declarativeNetRequest`（DNR）以声明式规则拦截/重定向/改 header，规则在浏览器内核执行，不暴露请求内容给扩展。分静态规则（打包进扩展的 JSON 文件）与动态规则（运行时增删）。

### 静态规则文件

```ts
// src/manifest.ts 片段
permissions: ["declarativeNetRequest"],
host_permissions: ["https://*/*"],
declarative_net_request: {
  rule_resources: [
    { id: "ruleset_1", enabled: true, path: "rules/static-rules.json" }
  ]
}

```

```json
// rules/static-rules.json
[
  {
    "id": 1,
    "priority": 1,
    "action": { "type": "block" },
    "condition": {
      "urlFilter": "||doubleclick.net",
      "resourceTypes": ["script", "image", "xmlhttprequest"]
    }
  },
  {
    "id": 2,
    "priority": 1,
    "action": {
      "type": "redirect",
      "redirect": { "url": "https://cdn.example.com/lib.js" }
    },
    "condition": {
      "urlFilter": "||old-cdn.com/lib.js",
      "resourceTypes": ["script"]
    }
  },
  {
    "id": 3,
    "priority": 1,
    "action": {
      "type": "modifyHeaders",
      "requestHeaders": [
        { "header": "User-Agent", "operation": "set", "value": "Mozilla/5.0 (CustomBot)" },
        { "header": "X-From-Ext", "operation": "set", "value": "1" }
      ]
    },
    "condition": {
      "requestDomains": ["api.example.com"],
      "resourceTypes": ["xmlhttprequest"]
    }
  }
]

```

规则结构字段：

| 字段                         | 类型         | 默认值 | 说明                                                                          |
| -------------------------- | ---------- | --- | --------------------------------------------------------------------------- |
| id                         | number     | 必填  | 规则唯一 ID（≥1）                                                                 |
| priority                   | number     | 1   | 优先级（≥1），高者先匹配                                                               |
| action.type                | string     | 必填  | block / redirect / allow / allowAllRequests / upgradeScheme / modifyHeaders |
| action.redirect.url        | string     | —   | 重定向目标，redirect 时用                                                           |
| action.requestHeaders      | object\[\] | —   | 改请求头，含 header/operation/value                                               |
| action.responseHeaders     | object\[\] | —   | 改响应头                                                                        |
| condition.urlFilter        | string     | —   | URL 匹配模式（\|| 锚定域名）                                                          |
| condition.regexFilter      | string     | —   | 正则匹配                                                                        |
| condition.resourceTypes    | string\[\] | —   | 资源类型过滤                                                                      |
| condition.requestDomains   | string\[\] | —   | 请求域名                                                                        |
| condition.initiatorDomains | string\[\] | —   | 发起方域名                                                                       |

`operation` 取值：`set`（设置/覆盖）、`append`（追加）、`remove`（删除）。改 User-Agent 即用 `modifyHeaders` 的 `requestHeaders` \+ `set`。

### 动态规则

运行时用 `updateDynamicRules` 增删，`addRules` 新增、`removeRuleIds` 删除。

```ts
// 用户开启"屏蔽某域名"时动态加规则
async function blockDomain(domain: string) {
  const id = 1000 + Math.abs(hashCode(domain)) % 10000
  await chrome.declarativeNetRequest.updateDynamicRules({
    removeRuleIds: [id], // 先删同 id，避免重复报错
    addRules: [
      {
        id,
        priority: 1,
        action: { type: "block" },
        condition: { requestDomains: [domain], resourceTypes: ["main_frame", "sub_frame"] }
      }
    ]
  })
}

function hashCode(s: string) {
  let h = 0
  for (let i = 0; i < s.length; i++) h = (h << 5) - h + s.charCodeAt(i)
  return h
}

// 查看当前动态规则
const rules = await chrome.declarativeNetRequest.getDynamicRules()

```

限制：动态规则上限 30000 条（其中 unsafe 规则 5000）、会话规则 5000、正则规则每类 1000；静态规则集最多声明 100 个、同时启用 50 个。

---

## 跨域请求：后台 SW fetch 绕过页面 CORS

页面内容脚本受页面 CORS 约束，跨域请求会被拦截。把请求放到 Service Worker 里发，扩展凭 `host_permissions` 拥有对应域名的跨域权限，不受页面 CORS 限制。

```ts
// src/manifest.ts 片段
host_permissions: ["https://api.thirdparty.com/*"] // 声明可跨域访问的域名

```

```ts
// src/background/index.ts —— 后台代发跨域请求
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
  if (msg.type === "FETCH") {
    fetch(msg.url, msg.init)
      .then((r) => r.json())
      .then((data) => sendResponse({ ok: true, data }))
      .catch((e) => sendResponse({ ok: false, error: String(e) }))
    return true // 关键：异步 sendResponse 必须 return true 保持通道
  }
})

```

```ts
// 内容脚本 / popup 侧调用
const res = await chrome.runtime.sendMessage({
  type: "FETCH",
  url: "https://api.thirdparty.com/data",
  init: { headers: { Authorization: "Bearer xxx" } }
})

```

`host_permissions` 的作用：它授予扩展对列出域名的跨域 fetch 权限和内容脚本注入权限。后台 fetch 能绕过页面 CORS，正是因为请求以扩展身份发出，而扩展对该域名有 host 权限。

---

## 徽章/图标状态机

按标签页状态切换 action 图标与角标（badge）。Badge 文本最多约 4 字符，需用 `tabId` 限定为单标签页状态。

```ts
// src/background/index.ts
type TabState = "off" | "active" | "error"

const BADGE: Record<TabState, { text: string; color: string; icon: string }> = {
  off: { text: "", color: "#888", icon: "icons/gray-128.png" },
  active: { text: "ON", color: "#16a34a", icon: "icons/green-128.png" },
  error: { text: "!", color: "#dc2626", icon: "icons/red-128.png" }
}

async function setTabState(tabId: number, state: TabState) {
  const s = BADGE[state]
  await chrome.action.setBadgeText({ tabId, text: s.text })
  await chrome.action.setBadgeBackgroundColor({ tabId, color: s.color })
  await chrome.action.setIcon({ tabId, path: { 128: s.icon } })
  // 错误：不传 tabId 会改全局状态，切到别的标签页也变
}

chrome.tabs.onUpdated.addListener((tabId, info, tab) => {
  if (info.status === "complete" && tab.url?.startsWith("https://example.com")) {
    setTabState(tabId, "active")
  }
})

```

---

## 数据持久化进阶

### storage 分区使用策略

| 分区              | 容量                              | 生命周期                   | 适用           |
| --------------- | ------------------------------- | ---------------------- | ------------ |
| storage.local   | 约 10MB（可申请 unlimitedStorage 提升） | 持久                     | 大量本地数据、缓存    |
| storage.sync    | 约 100KB（单项 8KB）                 | 随账号云同步                 | 用户偏好设置       |
| storage.session | 约 10MB                          | 浏览器会话内（SW 重启保留，关浏览器清空） | 临时 token、运行态 |
| storage.managed | —                               | 只读，由企业策略下发             | 受管配置         |

策略：用户设置放 `sync`（多设备一致）；缓存、日志、大对象放 `local`；敏感临时数据放 `session`（不落盘磁盘）。

```ts
await chrome.storage.sync.set({ theme: "dark" }) // 跟随账号
await chrome.storage.session.set({ token: "tmp" }) // 关浏览器即清

```

### IndexedDB 存大数据

storage.local 不适合存放数 MB 的结构化数据或需要索引查询的场景，用 IndexedDB。Service Worker 与扩展页面均可访问。

```ts
// src/background/db.ts
function openDB(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open("ext-db", 1)
    req.onupgradeneeded = () => {
      const db = req.result
      if (!db.objectStoreNames.contains("records")) {
        const store = db.createObjectStore("records", { keyPath: "id" })
        store.createIndex("byHost", "host", { unique: false })
      }
    }
    req.onsuccess = () => resolve(req.result)
    req.onerror = () => reject(req.error)
  })
}

export async function putRecord(rec: { id: string; host: string; data: unknown }) {
  const db = await openDB()
  return new Promise<void>((resolve, reject) => {
    const tx = db.transaction("records", "readwrite")
    tx.objectStore("records").put(rec)
    tx.oncomplete = () => resolve()
    tx.onerror = () => reject(tx.error)
  })
}

```

### 配置导入导出

把设置序列化为 JSON 文件下载（导出），用 file input 读回（导入）。

```ts
// 导出：在 options 页生成下载
async function exportSettings() {
  const all = await chrome.storage.local.get(null) // null 取全部
  const blob = new Blob([JSON.stringify(all, null, 2)], {
    type: "application/json"
  })
  const url = URL.createObjectURL(blob)
  await chrome.downloads.download({ url, filename: "ext-settings.json" })
  setTimeout(() => URL.revokeObjectURL(url), 5000)
}

// 导入：读文件写回 storage
async function importSettings(file: File) {
  const text = await file.text()
  const obj = JSON.parse(text)
  await chrome.storage.local.set(obj)
}

```

---

## 最佳实践

1. **内容脚本 UI 一律走 Shadow DOM 隔离。** 直接挂到页面 DOM 会双向污染样式。用 `attachShadow({ mode: "open" })` 并把编译后 CSS 以 `?inline` 注入 shadow root，确保扩展样式与页面样式互不影响。  
```ts  
const shadow = host.attachShadow({ mode: "open" })  
shadow.appendChild(Object.assign(document.createElement("style"), { textContent: styleText }))  
```
2. **跨域请求统一收口到 Service Worker。** 内容脚本与 popup 不直接 fetch 第三方接口，全部经后台代发，集中管理鉴权、重试与 host\_permissions，避免页面 CORS 与凭据散落。  
```ts  
const r = await chrome.runtime.sendMessage({ type: "FETCH", url })  
```
3. **状态相关的图标/角标必须带 tabId。** 不带 tabId 的 `setBadgeText`/`setIcon` 是全局状态，切标签页会串台。按标签页维护独立状态机。  
```ts  
chrome.action.setBadgeText({ tabId, text: "ON" })  
```
4. **contextMenus 在 onInstalled 创建一次。** Service Worker 会休眠重启，若在顶层或事件里反复 `create` 会因 id 重复抛错。集中放 `onInstalled`。  
```ts  
chrome.runtime.onInstalled.addListener(() => chrome.contextMenus.create({ id, title, contexts }))  
```
5. **default\_locale 的 messages.json 必须最全。** i18n 回退最终落到 default\_locale，缺 key 会显示空串。封装 `t()` 缺失时回退到 key，便于发现漏译。  
```ts  
export const t = (k: string, s?: string[]) => chrome.i18n.getMessage(k, s) || k  
```
6. **优先用 declarativeNetRequest 而非 webRequest 阻塞。** MV3 已移除阻塞式 webRequest，DNR 由内核执行性能更好、更隐私。静态规则放打包文件，用户可变规则用 `updateDynamicRules`，并先 `removeRuleIds` 再 `addRules`。
7. **大数据用 IndexedDB，偏好用 storage.sync。** 不要把 MB 级数据塞进 storage.local 反复整存整取；需索引查询、增量写入时用 IndexedDB。用户偏好放 sync 实现多设备同步。

---

## 常见陷阱

1. **MAIN world 脚本读不到 chrome API。**

  - 现象：在 `world: "MAIN"` 的脚本里调用 `chrome.runtime.sendMessage` 报 `undefined`。
  - 原因：MAIN world 运行在页面环境，没有扩展 API，只有 DOM 与页面变量。
  - 解决：MAIN world 只负责读页面变量并 `window.postMessage`；扩展 API 调用放在 ISOLATED world 的内容脚本里接收后转发。
2. **后台 fetch 跨域仍被拦截。**

  - 现象：在 Service Worker 里 fetch 第三方域名报 CORS 或网络错误。
  - 原因：未在 `host_permissions` 声明该域名，扩展没有对应跨域权限。
  - 解决：在 manifest `host_permissions` 加入目标域名（如 `https://api.thirdparty.com/*`），重新加载扩展。
3. **异步 sendResponse 收不到回复。**

  - 现象：`chrome.runtime.sendMessage` 的 Promise 永远 pending 或拿到 undefined。
  - 原因：`onMessage` 监听器里做异步操作后调用 `sendResponse`，但没 `return true`，消息通道被同步关闭。
  - 解决：监听器内有异步逻辑时必须 `return true` 保持通道开启，待异步完成再 `sendResponse`。
4. **扩展页面用 history 模式路由刷新 404。**

  - 现象：popup/options 用 `createWebHistory` 刷新或直达子路由白屏。
  - 原因：`chrome-extension://` 协议下没有服务端做 history fallback。
  - 解决：改用 `createWebHashHistory`，所有扩展内 SPA 一律 hash 模式。
5. **动态注入的脚本重复执行。**

  - 现象：同一标签页多次 `executeScript` 导致 UI 重复挂载。
  - 原因：每次注入都重新执行，未做幂等判断。
  - 解决：注入前检查标记元素（如 `document.getElementById("my-ext-root")`），或用 `registerContentScripts` 注册并以 `id` 去重，存在即跳过。

---

## 参见

- [浏览器插件开发完全指南](https://blog.vercanti.com/liu-lan-qi-cha-jian-kai-fa-wan-quan-zhi-nan-vite-vue-manifest-v3/)
- [浏览器插件-基础概念与架构模型](https://blog.vercanti.com/liu-lan-qi-cha-jian-ji-chu-gai-nian-yu-jia-gou-mo-xing/)
- [浏览器插件-API速查大全](https://blog.vercanti.com/liu-lan-qi-cha-jian-chrome-api-quan-liang-su-cha-da-quan/)
- [浏览器插件-高级开发指南](https://blog.vercanti.com/liu-lan-qi-cha-jian-gao-ji-kai-fa-zhi-nan-manifest-v3/)
- [浏览器插件-设计模式与优雅架构](https://blog.vercanti.com/liu-lan-qi-cha-jian-she-ji-mo-shi-yu-you-ya-jia-gou/)
- [浏览器插件-调试与排错手册](https://blog.vercanti.com/liu-lan-qi-cha-jian-diao-shi-yu-pai-cuo-shou-ce/)
- [Vue3入门](https://blog.vercanti.com/vue-3-ru-men-zhi-nan/)
- [Pinia完全指南](https://blog.vercanti.com/pinia-wan-quan-zhi-nan/)
- [Vue Router完全指南](https://blog.vercanti.com/vue-router-wan-quan-zhi-nan/)