> ## 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.

# 浏览器插件开发完全指南（Vite + Vue + Manifest V3）
- URL: https://blog.vercanti.com/liu-lan-qi-cha-jian-kai-fa-wan-quan-zhi-nan-vite-vue-manifest-v3/
- Published: 2026-08-28T14:35:33.000Z
- Updated: 2026-08-28T14:59:04.000Z
- Description: 浏览器插件（Browser Extension）是运行在浏览器内、用 HTML/CSS/JavaScript 编写的小程序，能读取和修改网页、增加浏览器 UI、在后台常驻处理事件。它解决的核心问题是：在不修改网站源码的前提下，向浏览器和任意网页注入自定义功能——广告拦截、密码管理、翻译、爬虫辅助、开发者工具增强都属于这一类。 为什么用 Vite + Vue 而不是裸写：插件本质是多个独立 HTML 入口（popup、options、side panel）加上后台脚本和内容脚本的组合。裸写需要手动维护构建、手动复制静态资源、改一行代码要重新加载整个插件。V
- Author: yellowdog
- Tags: 前端开发, 浏览器插件开发

> 官方文档：
> 
> - Chrome Extensions：<https://developer.chrome.com/docs/extensions>
> - MDN WebExtensions：<https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions>
> - CRXJS Vite Plugin：<https://crxjs.dev>
> 
> 适用版本：Manifest V3 / Chrome 138+ / `@crxjs/vite-plugin` 2.4.0 / Vite 6.x / Vue 3.5.x（2026-06-06 核实）

---

## 概述

浏览器插件（Browser Extension）是运行在浏览器内、用 HTML/CSS/JavaScript 编写的小程序，能读取和修改网页、增加浏览器 UI、在后台常驻处理事件。它解决的核心问题是：**在不修改网站源码的前提下，向浏览器和任意网页注入自定义功能**——广告拦截、密码管理、翻译、爬虫辅助、开发者工具增强都属于这一类。

为什么用 Vite + Vue 而不是裸写：插件本质是多个独立 HTML 入口（popup、options、side panel）加上后台脚本和内容脚本的组合。裸写需要手动维护构建、手动复制静态资源、改一行代码要重新加载整个插件。Vite 提供按入口打包和极快的冷启动，Vue 负责 popup/options 这类有状态的 UI；而 `@crxjs/vite-plugin`（下称 CRXJS）把二者粘合起来，自动从 `manifest.json` 推导构建入口，并为内容脚本和 HTML 页面提供真正的 HMR（热更新）。对比替代方案：`webpack` \+ `web-ext` 配置繁琐、HMR 弱；官方的 `chrome.runtime` 裸开发没有模块化；`WXT` 是更高层的全家桶框架（基于 Vite，约定优于配置），如果想要零配置可选 WXT，而 CRXJS 更贴近原生 Vite、可控性更强。

什么时候适合：需要常驻浏览器、跨网页、调用浏览器特权 API（标签页、书签、网络请求拦截、剪贴板）的场景。不适合：纯展示型工具（直接做网页更省事）、需要绕过浏览器沙箱的低层操作（插件仍受沙箱限制）、对 Safari 强依赖（Safari 用独立的 App Extension 体系，需 Xcode 转换）。

---

## 核心概念

一个 Manifest V3 插件由若干**相互隔离的执行上下文**组成，它们各自运行在不同的环境里，只能通过消息或 `chrome.storage` 通信，不能直接共享内存。

| 上下文                  | 运行环境               | 生命周期              | 能否访问 DOM  | 能否调用 chrome API        |
| -------------------- | ------------------ | ----------------- | --------- | ---------------------- |
| Service Worker（后台）   | 独立 Worker 线程，无 DOM | 事件驱动，空闲约 30s 后被回收 | 否         | 几乎全部                   |
| Content Script（内容脚本） | 注入到目标网页，与页面共享 DOM  | 跟随页面              | 是（页面 DOM） | 仅部分（runtime/storage 等） |
| Popup（弹窗）            | 独立 HTML 页面         | 打开时创建，关闭即销毁       | 是（自身 DOM） | 全部                     |
| Options（选项页）         | 独立 HTML 页面         | 打开时创建             | 是（自身 DOM） | 全部                     |
| Side Panel（侧边栏）      | 独立 HTML 页面         | 打开时创建，可常驻         | 是（自身 DOM） | 全部                     |
| DevTools 页面          | 嵌入开发者工具            | DevTools 打开时      | 是（自身 DOM） | 部分 + devtools.\*       |

理解三个关键隔离边界：

1. **Service Worker 不是持久后台**。MV2 的常驻后台页（background page）在 MV3 被换成事件驱动的 Service Worker，它会在空闲时被浏览器杀死，下次事件到来时重新启动。因此**不能在 Service Worker 顶层用全局变量保存状态**，状态必须落到 `chrome.storage`。
2. **内容脚本运行在"隔离世界"（Isolated World）**。它能看到页面的 DOM，但看不到页面 JS 的变量和函数（反之亦然）。两个 JS 上下文共享同一份 DOM，但各有独立的 `window`。要访问页面自身的 JS 变量，必须把脚本注入到 `MAIN` world（见 `chrome.scripting`）。
3. **CSP 限制**。MV3 禁止远程代码执行：不能 `eval`、不能从 CDN 加载并执行远程脚本、`content_security_policy` 中不允许 `unsafe-eval`。所有要执行的代码必须打包进插件。

```
浏览器
├── Service Worker（background.js）  ←─ chrome.runtime.onMessage ─┐
├── Popup（Vue 应用）               ←─ chrome.runtime.sendMessage ─┤
├── Options（Vue 应用）                                            │
├── Side Panel（Vue 应用）                                         │
└── 网页 Tab                                                       │
    └── Content Script（隔离世界）  ←─ chrome.tabs.sendMessage ────┘
        └── 页面 DOM ↑↓ 可读写

```

---

## 安装与项目搭建（CRXJS + Vite + Vue）

### 从零创建

```bash
# 1. 用 Vite 创建 Vue + TS 项目
npm create vite@latest my-extension -- --template vue-ts
cd my-extension

# 2. 安装 CRXJS（注意 @latest 当前为 2.4.0 稳定版）
npm i -D @crxjs/vite-plugin@latest

# 3. 安装类型定义（提供 chrome.* 的 TS 智能提示）
npm i -D @types/chrome

```

### vite.config.ts

```ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { crx } from '@crxjs/vite-plugin'
import manifest from './manifest.config' // 见下文

export default defineConfig({
  plugins: [
    vue(),
    crx({ manifest }), // crx 必须放在框架插件之后
  ],
  server: {
    // HMR 需要固定端口，避免每次刷新端口变化导致内容脚本连不上
    port: 5173,
    strictPort: true,
    hmr: { port: 5173 },
  },
})

```

`crx()` 的参数：

| 参数                        | 类型                    | 默认值      | 说明                                         |
| ------------------------- | --------------------- | -------- | ------------------------------------------ |
| manifest                  | ManifestV3Export      | 必填       | manifest 对象、返回对象的函数，或 defineManifest() 的结果 |
| browser                   | 'chrome' \| 'firefox' | 'chrome' | 目标浏览器，影响 HMR 与 polyfill 处理                 |
| contentScripts.injectCss  | boolean               | true     | 是否自动把内容脚本引入的 CSS 注入页面                      |
| contentScripts.hmrTimeout | number                | 5000     | 内容脚本等待 HMR 更新的超时毫秒数                        |

### manifest.config.ts（推荐用 TS 而非 JSON）

用 `defineManifest` 写 manifest 可获得类型校验和从 `package.json` 读取版本号的能力：

```ts
import { defineManifest } from '@crxjs/vite-plugin'
import pkg from './package.json'

export default defineManifest({
  manifest_version: 3,
  name: 'My Extension',
  version: pkg.version,            // 复用 package.json 版本，避免两处维护
  description: pkg.description,
  icons: {
    '16': 'icons/icon16.png',
    '48': 'icons/icon48.png',
    '128': 'icons/icon128.png',
  },
  action: {
    default_popup: 'src/popup/index.html', // CRXJS 直接用源码 HTML 路径
    default_icon: 'icons/icon48.png',
  },
  options_page: 'src/options/index.html',
  background: {
    service_worker: 'src/background/index.ts',
    type: 'module',                // 允许 Service Worker 用 ESM import
  },
  content_scripts: [
    {
      matches: ['https://*/*'],
      js: ['src/content/index.ts'],
      run_at: 'document_idle',
    },
  ],
  side_panel: { default_path: 'src/sidepanel/index.html' },
  permissions: ['storage', 'tabs', 'scripting', 'activeTab'],
  host_permissions: ['https://*/*'],
})

```

关键点：CRXJS 允许在 manifest 里**直接引用 `src/` 下的源码路径**（`.ts`、`.html`），它会在构建时替换为打包后的产物路径。这是与原始 Vite 多页应用最大的体验差异。

### 开发与构建

```bash
npm run dev    # 启动 HMR 开发服务器，产物输出到 dist/
npm run build  # 生产构建，输出到 dist/，用于打包上架

```

加载到浏览器：打开 `chrome://extensions` → 开启"开发者模式" → "加载已解压的扩展程序" → 选择 `dist/` 目录。`npm run dev` 模式下改 Vue 组件会即时 HMR；改 manifest 或 background 会触发插件自动重载。

### Vue 入口写法（每个 HTML 是一个独立 Vue 应用）

`src/popup/index.html`：

```html
<!doctype html>
<html>
  <head><meta charset="UTF-8" /></head>
  <body>
    <div id="app"></div>
    <script type="module" src="./main.ts"></script>
  </body>
</html>

```

`src/popup/main.ts`：

```ts
import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')

```

popup、options、sidepanel 各是一套独立的 `createApp`，它们不共享 Vue 响应式状态——跨页面共享状态要走 `chrome.storage` 或消息（见下文实战示例）。

---

## manifest.json 详解

`manifest.json` 是插件的清单文件，声明元数据、权限和各入口。以下是 Manifest V3 全部常用字段。

### 必填字段

| 字段                | 类型     | 说明                                      |
| ----------------- | ------ | --------------------------------------- |
| manifest\_version | number | 必须为 3；唯一合法值                             |
| name              | string | 插件名，≤ 75 字符，显示在商店和扩展管理页                 |
| version           | string | 版本号，1–4 段数字（如 1.0.0.3），每段 0–65535，仅数字和点 |

### 商店展示字段

| 字段              | 类型     | 默认值 | 说明                                        |
| --------------- | ------ | --- | ----------------------------------------- |
| description     | string | 无   | 简介，≤ 132 字符                               |
| icons           | object | 无   | 键为尺寸（16/32/48/128），值为 PNG 路径；商店要求至少 128   |
| default\_locale | string | 无   | 国际化默认语言（如 en、zh\_CN）；用 \_locales/ 时**必填** |
| author          | string | 无   | 作者邮箱                                      |
| homepage\_url   | string | 无   | 插件主页                                      |

### 入口字段

| 字段                     | 类型     | 说明                                                                          |
| ---------------------- | ------ | --------------------------------------------------------------------------- |
| action                 | object | 工具栏图标按钮配置。子字段：default\_popup（点击弹出的 HTML）、default\_icon、default\_title（悬停提示） |
| background             | object | 后台脚本。子字段：service\_worker（JS 路径，**只能一个文件**）、type（"module" 启用 ESM）            |
| content\_scripts       | array  | 内容脚本数组，见下表                                                                  |
| options\_page          | string | 选项页 HTML 路径（在新标签打开）                                                         |
| options\_ui            | object | 选项页配置。子字段：page（HTML 路径）、open\_in\_tab（boolean，false 则嵌入扩展管理页内显示）            |
| side\_panel            | object | 侧边栏。子字段：default\_path（HTML 路径）。需 sidePanel 权限                               |
| devtools\_page         | string | 注入 DevTools 的 HTML 路径                                                       |
| chrome\_url\_overrides | object | 覆盖浏览器内置页。键：newtab/bookmarks/history，值为 HTML 路径                              |

`content_scripts` 数组中每一项的字段：

| 字段                  | 类型         | 默认值              | 说明                                                                                                  |
| ------------------- | ---------- | ---------------- | --------------------------------------------------------------------------------------------------- |
| matches             | string\[\] | 必填               | 匹配的 URL 模式（match pattern），如 \["https://\*.example.com/\*"\]、\["<all\_urls>"\]                       |
| exclude\_matches    | string\[\] | \[\]             | 排除的 URL 模式                                                                                          |
| js                  | string\[\] | \[\]             | 注入的 JS 文件，按数组顺序执行                                                                                   |
| css                 | string\[\] | \[\]             | 注入的 CSS 文件，在页面渲染前应用                                                                                 |
| run\_at             | string     | "document\_idle" | 注入时机：document\_start（DOM 构建前）/ document\_end（DOM 完成、资源未必加载）/ document\_idle（空闲时，介于 end 和 onload 之间） |
| all\_frames         | boolean    | false            | 是否注入到所有 iframe，而非仅顶层框架                                                                              |
| match\_about\_blank | boolean    | false            | 是否注入到 about:blank/about:srcdoc 框架                                                                   |
| world               | string     | "ISOLATED"       | 执行世界：ISOLATED（隔离）/ MAIN（页面主世界，可访问页面 JS）                                                             |

### 权限字段

| 字段                          | 类型         | 说明                                                                                                                                     |
| --------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| permissions                 | string\[\] | 安装时申请的 API 权限，如 "storage"、"tabs"、"scripting"、"alarms"、"contextMenus"、"notifications"、"cookies"、"webNavigation"、"declarativeNetRequest" |
| optional\_permissions       | string\[\] | 运行时按需申请的权限，用 chrome.permissions.request() 触发授权弹窗                                                                                       |
| host\_permissions           | string\[\] | 允许访问的网站 URL 模式（MV3 从 permissions 中独立出来），如 \["https://\*/\*"\]                                                                          |
| optional\_host\_permissions | string\[\] | 运行时按需申请的主机权限                                                                                                                           |

常见权限含义速查：

| 权限值                            | 含义                               | 是否触发安装警告           |
| ------------------------------ | -------------------------------- | ------------------ |
| activeTab                      | 用户点击插件图标时临时获得当前标签页访问权            | 否（最受推荐）            |
| storage                        | 使用 chrome.storage                | 否                  |
| tabs                           | 读取标签页 url/title/favIconUrl 等敏感属性 | 是                  |
| scripting                      | 用 chrome.scripting 动态注入脚本        | 否（实际注入受 host 权限约束） |
| host\_permissions: <all\_urls> | 访问所有网站                           | 是（最强警告，审核严格）       |
| unlimitedStorage               | 解除 storage.local 的 10MB 上限       | 否                  |
| declarativeNetRequest          | 声明式拦截/修改网络请求                     | 视规则而定              |

### 资源与安全字段

| 字段                         | 类型     | 说明                                                                                                        |
| -------------------------- | ------ | --------------------------------------------------------------------------------------------------------- |
| web\_accessible\_resources | array  | 声明可被网页访问的插件内资源。每项含 resources（路径数组）+ matches（允许访问的网页 URL）。MV3 必须显式声明，否则页面无法加载插件内图片/脚本                      |
| content\_security\_policy  | object | 子字段 extension\_pages（插件页面 CSP）、sandbox（沙箱页 CSP）。MV3 禁止 unsafe-eval 和远程脚本                                  |
| commands                   | object | 键盘快捷键。每个命令含 suggested\_key（如 { default: "Ctrl+Shift+Y" }）和 description。特殊命令 \_execute\_action 可绑定打开 popup |
| externally\_connectable    | object | 声明哪些网页/扩展可通过 runtime.sendMessage 与本插件通信。子字段 matches、ids                                                   |
| minimum\_chrome\_version   | string | 可安装的最低 Chrome 版本                                                                                          |

完整 manifest 示例（含快捷键和资源声明）：

```jsonc
{
  "manifest_version": 3,
  "name": "示例插件",
  "version": "1.0.0",
  "description": "演示完整 manifest 结构",
  "icons": { "16": "icons/16.png", "48": "icons/48.png", "128": "icons/128.png" },
  "action": { "default_popup": "popup.html", "default_title": "打开面板" },
  "background": { "service_worker": "background.js", "type": "module" },
  "content_scripts": [
    {
      "matches": ["https://*.example.com/*"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ],
  "permissions": ["storage", "scripting", "activeTab", "contextMenus"],
  "host_permissions": ["https://*.example.com/*"],
  "web_accessible_resources": [
    { "resources": ["inject.js", "images/*.png"], "matches": ["https://*.example.com/*"] }
  ],
  "commands": {
    "_execute_action": { "suggested_key": { "default": "Ctrl+Shift+Y" } },
    "toggle-feature": {
      "suggested_key": { "default": "Ctrl+Shift+U" },
      "description": "切换主功能"
    }
  },
  "content_security_policy": {
    "extension_pages": "script-src 'self'; object-src 'self'"
  }
}

```

---

## Chrome API 详解

所有 `chrome.*` API 在 MV3 中**默认返回 Promise**（也兼容回调写法）。本节用 Promise + `async/await` 风格。Firefox 用 `browser.*` 命名空间（见跨浏览器节）。

### chrome.runtime

插件自身的运行时信息与消息总线，**无需任何权限**即可使用。

常用成员：

| 成员                                     | 类型                  | 说明                                             |
| -------------------------------------- | ------------------- | ---------------------------------------------- |
| runtime.id                             | string              | 插件 ID                                          |
| runtime.getURL(path)                   | (string) => string  | 把插件内相对路径转为 chrome-extension://<id>/path 完整 URL |
| runtime.getManifest()                  | () => object        | 返回解析后的 manifest 对象                             |
| runtime.sendMessage(message, options?) | \=> Promise<any>    | 发送一次性消息给插件内其他上下文（不发给内容脚本）                      |
| runtime.onMessage                      | Event               | 监听消息，回调 (message, sender, sendResponse)        |
| runtime.connect(connectInfo?)          | \=> Port            | 建立长连接端口                                        |
| runtime.onConnect                      | Event               | 监听长连接                                          |
| runtime.onInstalled                    | Event               | 安装/更新时触发，回调含 reason（install/update）            |
| runtime.onStartup                      | Event               | 浏览器启动时触发                                       |
| runtime.lastError                      | object \| undefined | 回调风格下读取错误；Promise 风格用 try/catch                |

`onMessage` 回调参数：

| 参数           | 类型                 | 说明                                                                |
| ------------ | ------------------ | ----------------------------------------------------------------- |
| message      | any                | 发送方传来的消息（JSON 可序列化）                                               |
| sender       | MessageSender      | 发送方信息：sender.tab（来自内容脚本时含标签页）、sender.id、sender.url、sender.frameId |
| sendResponse | (response) => void | 回复函数；**异步回复必须 return true 保持通道开启**                                |

```ts
// background/index.ts —— 接收消息并异步回复
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type === 'FETCH_DATA') {
    // 正确：异步操作时 return true，保持消息通道开启
    fetch(msg.url)
      .then((r) => r.json())
      .then((data) => sendResponse({ ok: true, data }))
      .catch((e) => sendResponse({ ok: false, error: String(e) }))
    return true
  }
  // 错误：异步里没 return true，通道会立即关闭，sendResponse 失效
})

```

```ts
// popup 或 content script —— 发送消息（Promise 风格）
const res = await chrome.runtime.sendMessage({ type: 'FETCH_DATA', url: '/api' })
if (res.ok) console.log(res.data)

```

### chrome.tabs

操作浏览器标签页。读取 `url`/`title` 等敏感属性需 `tabs` 权限或对应 host 权限。

| 方法                                          | 签名                   | 说明                               |
| ------------------------------------------- | -------------------- | -------------------------------- |
| tabs.query(queryInfo)                       | \=> Promise<Tab\[\]> | 按条件查标签页                          |
| tabs.get(tabId)                             | \=> Promise<Tab>     | 按 ID 取标签页                        |
| tabs.create(createProperties)               | \=> Promise<Tab>     | 新建标签页                            |
| tabs.update(tabId?, updateProperties)       | \=> Promise<Tab>     | 更新（导航、激活等）                       |
| tabs.remove(tabIds)                         | \=> Promise<void>    | 关闭标签页                            |
| tabs.sendMessage(tabId, message, options?)  | \=> Promise<any>     | 向**指定标签页的内容脚本**发消息               |
| tabs.captureVisibleTab(windowId?, options?) | \=> Promise<string>  | 截图当前可见区域，返回 dataURL              |
| tabs.onUpdated                              | Event                | 标签页状态变化（loading/complete、URL 变更） |
| tabs.onActivated                            | Event                | 切换激活标签页                          |

`tabs.query` 的 `queryInfo` 常用字段：

| 字段            | 类型                   | 说明                           |
| ------------- | -------------------- | ---------------------------- |
| active        | boolean              | 是否为窗口内激活标签                   |
| currentWindow | boolean              | 是否在当前窗口                      |
| url           | string \| string\[\] | 按 URL 模式过滤（需 tabs 或 host 权限） |
| status        | string               | "loading" 或 "complete"       |
| pinned        | boolean              | 是否固定                         |

```ts
// 获取当前激活标签页（最常用模式）
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
if (tab?.id) {
  await chrome.tabs.sendMessage(tab.id, { type: 'HIGHLIGHT' })
}

```

### chrome.storage

插件专用的异步持久化存储，**所有上下文（含内容脚本）共享**，是跨上下文共享状态的首选。需 `storage` 权限。

四个存储区及配额：

| 存储区             | 作用域                   | 配额                                             | 清除时机             | 典型用途                |
| --------------- | --------------------- | ---------------------------------------------- | ---------------- | ------------------- |
| storage.local   | 本机                    | 10 MB（加 unlimitedStorage 权限后不限）                | 卸载插件时            | 大量本地数据、缓存           |
| storage.sync    | 跨设备同步（登录同一 Google 账号） | 总 100 KB、单项 8 KB、最多 512 项；写入限 120 次/分、1800 次/时 | 卸载时              | 用户设置                |
| storage.session | 内存（不落盘）               | 10 MB                                          | 浏览器重启、插件重载/更新/禁用 | Service Worker 临时状态 |
| storage.managed | 企业策略下发，**只读**         | —                                              | 由策略控制            | 企业配置                |

每个存储区方法相同（`StorageArea` 接口）：

| 方法                      | 签名                  | 说明                                |
| ----------------------- | ------------------- | --------------------------------- |
| get(keys?)              | \=> Promise<object> | keys 可为字符串、数组、含默认值的对象，或 null（取全部） |
| set(items)              | \=> Promise<void>   | items 为键值对象                       |
| remove(keys)            | \=> Promise<void>   | 删除指定键                             |
| clear()                 | \=> Promise<void>   | 清空该存储区                            |
| getBytesInUse(keys?)    | \=> Promise<number> | 已用字节数                             |
| setAccessLevel(options) | \=> Promise<void>   | 控制内容脚本能否访问（session 区默认对内容脚本不可见）   |

```ts
// 写入与读取（带默认值）
await chrome.storage.local.set({ theme: 'dark', count: 1 })
const { theme, count } = await chrome.storage.local.get({ theme: 'light', count: 0 })

// 监听变化（任意上下文都能收到，是跨页面同步的关键）
chrome.storage.onChanged.addListener((changes, areaName) => {
  if (areaName === 'local' && changes.theme) {
    console.log('主题从', changes.theme.oldValue, '变为', changes.theme.newValue)
  }
})

```

### chrome.scripting

MV3 中动态注入脚本/样式的 API（取代 MV2 的 `tabs.executeScript`）。需 `scripting` 权限，且对目标页有 host 权限或 `activeTab`。

| 方法                                             | 签名                                       | 说明                   |
| ---------------------------------------------- | ---------------------------------------- | -------------------- |
| scripting.executeScript(injection)             | \=> Promise<InjectionResult\[\]>         | 注入函数或文件，返回每个框架的结果    |
| scripting.insertCSS(injection)                 | \=> Promise<void>                        | 注入 CSS（字符串或文件）       |
| scripting.removeCSS(injection)                 | \=> Promise<void>                        | 移除之前注入的 CSS（参数需完全匹配） |
| scripting.registerContentScripts(scripts)      | \=> Promise<void>                        | 动态注册持久内容脚本           |
| scripting.updateContentScripts(scripts)        | \=> Promise<void>                        | 更新已注册脚本              |
| scripting.unregisterContentScripts(filter?)    | \=> Promise<void>                        | 注销动态脚本               |
| scripting.getRegisteredContentScripts(filter?) | \=> Promise<RegisteredContentScript\[\]> | 查询已注册脚本              |

`executeScript` 的 `injection` 字段：

| 字段                | 类型         | 默认值        | 说明                                                       |
| ----------------- | ---------- | ---------- | -------------------------------------------------------- |
| target            | object     | 必填         | { tabId, frameIds?, allFrames? }；allFrames 与 frameIds 互斥 |
| func              | Function   | 无          | 要注入执行的函数（与 files 二选一）。**注意：函数体在页面侧序列化执行，不能闭包捕获外部变量**     |
| args              | any\[\]    | \[\]       | 传给 func 的参数，必须 JSON 可序列化                                 |
| files             | string\[\] | 无          | 要注入的 JS 文件路径                                             |
| world             | string     | "ISOLATED" | ISOLATED 或 MAIN（访问页面 JS）                                 |
| injectImmediately | boolean    | false      | 是否尽快注入而不等待页面就绪                                           |

```ts
// 注入函数并传参，读取返回值
const [{ result }] = await chrome.scripting.executeScript({
  target: { tabId },
  func: (selector: string) => document.querySelectorAll(selector).length,
  args: ['a'], // 通过 args 传入，不能用闭包
})
console.log('页面链接数：', result)

// 错误：func 内引用了外部变量 sel，注入后页面侧拿不到，会报 undefined
const sel = 'a'
await chrome.scripting.executeScript({
  target: { tabId },
  func: () => document.querySelectorAll(sel).length, // sel 在页面侧未定义
})

```

### chrome.action

控制工具栏图标按钮（徽章、图标、弹窗、启用状态）。无需权限。

| 方法                                      | 签名                | 说明                                    |
| --------------------------------------- | ----------------- | ------------------------------------- |
| action.setBadgeText(details)            | \=> Promise<void> | 设置图标角标文字，如 { text: '5', tabId? }      |
| action.setBadgeBackgroundColor(details) | \=> Promise<void> | 角标背景色                                 |
| action.setIcon(details)                 | \=> Promise<void> | 动态换图标                                 |
| action.setPopup(details)                | \=> Promise<void> | 动态设置点击弹出的 HTML（设为 '' 则点击触发 onClicked） |
| action.setTitle(details)                | \=> Promise<void> | 悬停提示文字                                |
| action.onClicked                        | Event             | **仅当没有 default\_popup 时**点击图标才触发      |

```ts
// 在角标显示未读数
await chrome.action.setBadgeText({ text: '3' })
await chrome.action.setBadgeBackgroundColor({ color: '#d33' })

```

### chrome.contextMenus

添加右键菜单项。需 `contextMenus` 权限。

| 方法                              | 签名                   | 说明               |
| ------------------------------- | -------------------- | ---------------- |
| contextMenus.create(props, cb?) | \=> number \| string | 创建菜单项，返回 ID      |
| contextMenus.update(id, props)  | \=> Promise<void>    | 更新菜单项            |
| contextMenus.remove(id)         | \=> Promise<void>    | 删除指定项            |
| contextMenus.removeAll()        | \=> Promise<void>    | 删除全部             |
| contextMenus.onClicked          | Event                | 点击回调 (info, tab) |

`create` 的 `props` 常用字段：

| 字段       | 类型               | 默认值        | 说明                                        |
| -------- | ---------------- | ---------- | ----------------------------------------- |
| id       | string           | 自动生成       | 菜单项 ID（用事件区分时建议手动设）                       |
| title    | string           | 无          | 显示文字；%s 会被选中文本替换                          |
| contexts | string\[\]       | \["page"\] | 出现场景：page/selection/link/image/editable 等 |
| parentId | string \| number | 无          | 父菜单 ID（做二级菜单）                             |
| type     | string           | "normal"   | normal/checkbox/radio/separator           |

```ts
// 必须在 onInstalled 中创建，避免 Service Worker 重启重复创建报错
chrome.runtime.onInstalled.addListener(() => {
  chrome.contextMenus.create({
    id: 'search-selection',
    title: '搜索 "%s"',
    contexts: ['selection'],
  })
})
chrome.contextMenus.onClicked.addListener((info, tab) => {
  if (info.menuItemId === 'search-selection') {
    chrome.tabs.create({ url: `https://www.google.com/search?q=${info.selectionText}` })
  }
})

```

### chrome.alarms

定时任务，替代在 Service Worker 中用 `setTimeout`/`setInterval`（后者会随 Worker 回收而失效）。需 `alarms` 权限。

| 方法                              | 签名                     | 说明           |
| ------------------------------- | ---------------------- | ------------ |
| alarms.create(name?, alarmInfo) | \=> Promise<void>      | 创建定时器        |
| alarms.get(name?)               | \=> Promise<Alarm>     | 获取           |
| alarms.getAll()                 | \=> Promise<Alarm\[\]> | 获取全部         |
| alarms.clear(name?)             | \=> Promise<boolean>   | 清除           |
| alarms.onAlarm                  | Event                  | 触发回调 (alarm) |

`alarmInfo` 字段：

| 字段              | 类型     | 说明                    |
| --------------- | ------ | --------------------- |
| when            | number | 首次触发的绝对时间戳（毫秒）        |
| delayInMinutes  | number | 多少分钟后首次触发             |
| periodInMinutes | number | 重复周期（分钟）；**最小为 1 分钟** |

```ts
chrome.runtime.onInstalled.addListener(() => {
  chrome.alarms.create('sync', { periodInMinutes: 30 }) // 每 30 分钟
})
chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === 'sync') syncData()
})

```

### chrome.sidePanel

Chrome 114+ 的侧边栏 API。需 `sidePanel` 权限 + manifest 中 `side_panel`。

| 方法                                   | 签名                | 说明                                          |
| ------------------------------------ | ----------------- | ------------------------------------------- |
| sidePanel.open(options)              | \=> Promise<void> | 打开侧边栏，需在用户手势回调中调用                           |
| sidePanel.setOptions(options)        | \=> Promise<void> | 设置某标签页的侧边栏路径/启用状态                           |
| sidePanel.setPanelBehavior(behavior) | \=> Promise<void> | { openPanelOnActionClick: true } 让点击图标即开侧边栏 |

```ts
// 点击工具栏图标直接打开侧边栏
chrome.runtime.onInstalled.addListener(() => {
  chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true })
})

```

### chrome.declarativeNetRequest

声明式拦截/重定向/修改网络请求，MV3 取代了 MV2 的阻塞式 `webRequest`（出于性能和隐私，MV3 不再允许阻塞式 `webRequest`）。需 `declarativeNetRequest` 权限。

规则用静态 JSON 文件（manifest 中 `declarative_net_request.rule_resources` 声明）或运行时动态规则：

| 方法                                                | 签名                    | 说明         |
| ------------------------------------------------- | --------------------- | ---------- |
| declarativeNetRequest.updateDynamicRules(options) | \=> Promise<void>     | 增删动态规则     |
| declarativeNetRequest.getDynamicRules()           | \=> Promise<Rule\[\]> | 查询动态规则     |
| declarativeNetRequest.updateSessionRules(options) | \=> Promise<void>     | 会话级规则（不持久） |

```ts
// 拦截所有对 ads.example.com 的请求
await chrome.declarativeNetRequest.updateDynamicRules({
  removeRuleIds: [1],
  addRules: [{
    id: 1,
    priority: 1,
    action: { type: 'block' },
    condition: { urlFilter: '||ads.example.com', resourceTypes: ['script', 'image'] },
  }],
})

```

---

## 消息通信详解

四种通信路径，按场景选择：

| 场景                 | 发送方                                | 接收方                       | API                       |
| ------------------ | ---------------------------------- | ------------------------- | ------------------------- |
| popup/options → 后台 | runtime.sendMessage                | 后台 runtime.onMessage      | 一次性                       |
| 后台/popup → 内容脚本    | tabs.sendMessage(tabId, ...)       | 内容脚本 runtime.onMessage    | 一次性                       |
| 内容脚本 → 后台          | runtime.sendMessage                | 后台 runtime.onMessage      | 一次性                       |
| 需要持续双向通信           | runtime.connect / tabs.connect     | runtime.onConnect         | 长连接 Port                  |
| 网页 ↔ 插件            | 网页 runtime.sendMessage(extId, ...) | runtime.onMessageExternal | 需 externally\_connectable |

一次性消息的约束（来自官方）：

- 最大消息体 **64 MiB**。
- 消息用 **JSON 序列化**（不是 structured clone），不能传函数、DOM、`Map`/`Set`。
- 多个监听器时，**只有第一个调用 `sendResponse` 的回复生效**。
- 异步回复必须 `return true`（字面量 true，不是 truthy 值）。Chrome 148+ 也支持监听器返回 Promise。

长连接（Port）适合频繁交互或流式数据：

```ts
// 内容脚本侧：建立连接
const port = chrome.runtime.connect({ name: 'stream' })
port.postMessage({ type: 'START' })
port.onMessage.addListener((msg) => console.log('收到', msg))
port.onDisconnect.addListener(() => console.log('连接断开'))

// 后台侧：接收连接
chrome.runtime.onConnect.addListener((port) => {
  if (port.name !== 'stream') return
  port.onMessage.addListener((msg) => {
    if (msg.type === 'START') port.postMessage({ tick: 1 })
  })
})

```

---

## 完整示例（End-to-End）

一个真实可跑的插件：**popup（Vue）里点按钮 → 让当前页面所有图片加边框 → 计数存到 storage → 角标显示计数**。涉及 popup、后台、内容脚本三方协作。

### 目录结构

```
src/
├── popup/
│   ├── index.html
│   ├── main.ts
│   └── App.vue
├── background/
│   └── index.ts
└── content/
    └── index.ts
manifest.config.ts

```

### manifest.config.ts

```ts
import { defineManifest } from '@crxjs/vite-plugin'

export default defineManifest({
  manifest_version: 3,
  name: '图片高亮器',
  version: '1.0.0',
  action: { default_popup: 'src/popup/index.html' },
  background: { service_worker: 'src/background/index.ts', type: 'module' },
  content_scripts: [
    { matches: ['<all_urls>'], js: ['src/content/index.ts'], run_at: 'document_idle' },
  ],
  permissions: ['storage', 'tabs', 'activeTab'],
})

```

### src/popup/App.vue

```vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'

const count = ref(0)

// popup 打开时从 storage 读历史计数
onMounted(async () => {
  const { highlightCount = 0 } = await chrome.storage.local.get('highlightCount')
  count.value = highlightCount
})

async function highlight() {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
  if (!tab?.id) return
  // 向当前标签页的内容脚本发消息，等待它返回本次高亮的图片数
  const res = await chrome.tabs.sendMessage(tab.id, { type: 'HIGHLIGHT' })
  count.value = res.total
}
</script>

<template>
  <div style="width: 200px; padding: 12px">
    <button @click="highlight">高亮本页图片</button>
    <p>累计高亮：{{ count }} 张</p>
  </div>
</template>

```

### src/content/index.ts

```ts
// 内容脚本：运行在页面里，能直接操作 DOM
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
  if (msg.type === 'HIGHLIGHT') {
    const imgs = document.querySelectorAll('img')
    imgs.forEach((img) => {
      ;(img as HTMLElement).style.outline = '3px solid #e11'
    })
    // 把计数转交后台累加并更新角标
    chrome.runtime.sendMessage({ type: 'ADD_COUNT', delta: imgs.length })
    sendResponse({ total: imgs.length })
  }
  // 同步回复，无需 return true
})

```

### src/background/index.ts

```ts
// 后台 Service Worker：累计计数、更新角标
chrome.runtime.onMessage.addListener((msg) => {
  if (msg.type === 'ADD_COUNT') {
    // 状态必须落 storage，不能用全局变量（Worker 会被回收）
    chrome.storage.local.get({ highlightCount: 0 }).then(({ highlightCount }) => {
      const next = highlightCount + msg.delta
      chrome.storage.local.set({ highlightCount: next })
      chrome.action.setBadgeText({ text: String(next) })
    })
  }
})

// 安装时初始化角标
chrome.runtime.onInstalled.addListener(() => {
  chrome.action.setBadgeBackgroundColor({ color: '#333' })
})

```

运行 `npm run dev`，在 `chrome://extensions` 加载 `dist/`，打开任意网页点击插件图标即可看到效果，改 `App.vue` 会即时 HMR。

---

## 跨浏览器开发

Chrome、Edge、Firefox、Opera 都支持 WebExtensions，但有两处差异：**API 命名空间**和**Promise 支持**。

- Chrome/Edge：`chrome.*`，MV3 已原生返回 Promise。
- Firefox：`browser.*` 原生返回 Promise；也提供 `chrome.*` 但只支持回调。

统一写法用官方 `webextension-polyfill`：

```bash
npm i webextension-polyfill
npm i -D @types/webextension-polyfill

```

```ts
import browser from 'webextension-polyfill'

// 之后统一用 browser.*，在 Chrome 和 Firefox 都返回 Promise
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })

```

Firefox 的额外注意点：

| 差异点   | Chrome          | Firefox                                      |
| ----- | --------------- | -------------------------------------------- |
| 后台    | service\_worker | MV3 也支持 service\_worker（较新版本），旧版用 scripts 数组 |
| 扩展 ID | 自动生成            | 需在 browser\_specific\_settings.gecko.id 显式声明 |
| 打包    | 直接传 zip         | 用 web-ext 工具打包、签名                            |

CRXJS 可通过 `crx({ manifest, browser: 'firefox' })` 输出 Firefox 兼容产物。若要彻底零心智负担地跨浏览器，可考虑 **WXT** 框架，它内置多浏览器构建和 `browser` 全局注入。

---

## 最佳实践

**用 `activeTab` 替代宽泛的 host 权限**：尽量不申请 `<all_urls>`。`activeTab` 在用户点击图标时临时授予当前页访问权，不触发安装警告，商店审核更快。只有确实需要后台无人值守地访问特定站点时才申请 host 权限，并尽量收窄到具体域名。

```jsonc
// 正确：最小权限
"permissions": ["activeTab", "scripting"]
// 错误：无必要地索要全站访问，吓退用户、拖慢审核
"host_permissions": ["<all_urls>"]

```

**Service Worker 中状态一律落 storage**：MV3 的 Worker 会在空闲约 30 秒后被回收，顶层变量随之丢失。任何需要跨事件保留的状态都写 `chrome.storage`（短期高频可用 `storage.session`）。

```ts
// 正确：从 storage 读取
const { token } = await chrome.storage.session.get('token')
// 错误：全局变量在 Worker 重启后变 undefined
let token = '' // 下次事件触发时可能已被回收清空

```

**定时任务用 `chrome.alarms` 而非 `setInterval`**：Worker 被回收后 `setTimeout`/`setInterval` 全部失效；`alarms` 由浏览器托管，能可靠唤醒 Worker。注意周期最小 1 分钟。

**在 `onInstalled` 中做一次性初始化**：创建右键菜单、写入默认配置、建立 alarms 都应放在 `runtime.onInstalled` 里。直接在 Worker 顶层创建 `contextMenus` 会因 Worker 反复重启而抛"重复 ID"错误。

**消息体保持扁平且可 JSON 序列化**：不要试图通过消息传 DOM 节点、函数、`Map`/`Set` 或循环引用对象——会静默丢失或报错。需要传复杂结构时先 `JSON.parse(JSON.stringify(x))` 验证可序列化。

**用 TypeScript + `@types/chrome` 约束 API**：插件 API 面广、易记错参数形状，类型提示能在编译期拦住"`tabs.sendMessage` 少传 tabId"这类错误。给消息定义联合类型（discriminated union）后，`onMessage` 里按 `msg.type` 收窄能彻底消除字段拼写错误。

```ts
type Message =
  | { type: 'HIGHLIGHT' }
  | { type: 'ADD_COUNT'; delta: number }

```

---

## 常见陷阱

### 陷阱：sendResponse 异步回复收不到

**现象：** `onMessage` 里发起 `fetch` 后调用 `sendResponse`，但发送方 `await sendMessage` 拿到的是 `undefined`，控制台偶尔报 "The message port closed before a response was received"。

**原因：** 消息监听器同步返回后，Chrome 默认立即关闭回复通道。异步操作（fetch、storage）的回调在通道关闭之后才执行，`sendResponse` 失效。

**解决：** 在监听器里 `return true`（字面量），告诉 Chrome 保持通道开启直到 `sendResponse` 被调用。

```ts
chrome.runtime.onMessage.addListener((msg, _s, sendResponse) => {
  fetch(msg.url).then((r) => r.json()).then((d) => sendResponse(d))
  return true // 关键：缺这一行回复必丢
})

```

### 陷阱：tabs.sendMessage 报 "Could not establish connection. Receiving end does not exist"

**现象：** 从 popup 用 `chrome.tabs.sendMessage` 给页面发消息，报"接收端不存在"。

**原因：** 目标标签页里没有内容脚本在监听。常见于三种情况：(1) 当前页 URL 不匹配 `content_scripts.matches`；(2) 页面是 `chrome://`、Chrome 商店、PDF 等受保护页，禁止注入；(3) 内容脚本注册后页面尚未刷新（新装插件不会自动注入到已打开的旧标签页）。

**解决：** 刷新目标页让内容脚本注入；对受保护页做 URL 白名单判断后再发消息；或改用 `chrome.scripting.executeScript` 主动注入后再通信，并用 try/catch 兜底。

```ts
try {
  await chrome.tabs.sendMessage(tab.id, { type: 'PING' })
} catch {
  // 内容脚本未就绪，主动注入
  await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['content.js'] })
}

```

### 陷阱：内容脚本访问不到页面 JS 的变量

**现象：** 内容脚本里 `window.someLibFromPage` 是 `undefined`，但在页面控制台能正常访问。

**原因：** 内容脚本运行在"隔离世界"（Isolated World），与页面共享 DOM 但**不共享 JS 上下文**。两者各有独立 `window`，看不到对方的变量、函数、原型修改。

**解决：** 把需要触达页面 JS 的脚本注入到 `MAIN` world（manifest 的 `content_scripts.world: "MAIN"`，或 `scripting.executeScript({ world: 'MAIN' })`）；或者由内容脚本动态创建 `<script>` 标签注入页面（脚本文件需登记到 `web_accessible_resources`），再通过 `window.postMessage` 在两个世界间传数据。

```ts
// 内容脚本：注入到页面主世界的脚本（inject.js 须在 web_accessible_resources 声明）
const s = document.createElement('script')
s.src = chrome.runtime.getURL('inject.js')
;(document.head || document.documentElement).appendChild(s)

```

### 陷阱：Vue Router 的 history 模式在 popup 里白屏

**现象：** popup/options 用了 `createWebHistory()` 的 Vue Router，打开后空白或路由跳转 404。

**原因：** 插件页面的 URL 是 `chrome-extension://<id>/popup.html`，HTML5 history 模式依赖服务器对路径回退，而插件页无服务器；刷新或深链接会找不到文件。

**解决：** 插件内一律用 `createWebHashHistory()`（hash 路由），URL 变为 `popup.html#/path`，不依赖服务端回退，刷新也正常。

```ts
import { createRouter, createWebHashHistory } from 'vue-router'
const router = createRouter({ history: createWebHashHistory(), routes })

```

---

## 发布到 Chrome Web Store

1. **打包**：`npm run build` 后把 `dist/` 目录压缩为 zip（注意是压缩目录内容，不要多套一层文件夹）。
2. **注册开发者账号**：访问 [Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/devconsole)，一次性缴纳 5 美元注册费。
3. **上传与填写**：上传 zip，填写名称、描述、分类、至少一张 1280×800 截图、128×128 图标，以及隐私政策（凡申请敏感权限必须提供）。
4. **权限说明**：每项敏感权限需在表单里说明用途，宽泛权限（如 `<all_urls>`）会触发人工审核、耗时更久。
5. **审核与发布**：提交后进入审核，通常数小时到数天。可选"私有""仅限指定人员""公开"发布范围。
6. **版本更新**：每次更新须提升 manifest `version`，重新上传 zip。

Edge 插件发布到 [Microsoft Edge Add-ons](https://partner.microsoft.com/dashboard/microsoftedge)（免费），Firefox 发布到 [AMO](https://addons.mozilla.org)（用 `web-ext sign` 签名）。

---

## 参见

- [Vite初级指南](https://blog.vercanti.com/vite-chu-ji-zhi-nan/)、[Vite中级指南](https://blog.vercanti.com/vite-zhong-ji-zhi-nan/)、[Vite高级指南](https://blog.vercanti.com/vite-gao-ji-zhi-nan/) — 构建工具基础与多入口配置
- [Vue3入门](https://blog.vercanti.com/vue-3-ru-men-zhi-nan/)、[Vue3 + TypeScript完全指南](https://blog.vercanti.com/vue-3-typescript-wan-quan-zhi-nan/) — popup/options 的 Vue 应用编写
- [Pinia完全指南](https://blog.vercanti.com/pinia-wan-quan-zhi-nan/) — 插件页面内的状态管理（跨上下文仍需配合 storage）
- [Vue Router完全指南](https://blog.vercanti.com/vue-router-wan-quan-zhi-nan/) — 配合 hash 模式在插件内做多页导航
- [TypeScript完全指南](https://blog.vercanti.com/typescript-wan-quan-zhi-nan/)、[TypeScript最佳实践](https://blog.vercanti.com/typescript-zui-jia-shi-jian/) — 消息类型、API 类型约束
- [JS模块系统](https://blog.vercanti.com/js-mjs-ts-wen-jian-qu-bie-yu-zui-jia-shi-jian/) — Service Worker 的 ESM（`type: "module"`）背景
- [Axios完全指南](https://blog.vercanti.com/axios-wan-quan-zhi-nan/) — 在后台脚本里发起 HTTP 请求（注意 CORS 与 host 权限）
- [WebSocket完全指南](https://blog.vercanti.com/websocket-wan-quan-zhi-nan/) — 后台与服务端的长连接通信