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

# 浏览器插件：基础概念与架构模型
- URL: https://blog.vercanti.com/liu-lan-qi-cha-jian-ji-chu-gai-nian-yu-jia-gou-mo-xing/
- Published: 2026-08-28T14:35:30.000Z
- Updated: 2026-08-28T14:58:56.000Z
- Description: 浏览器插件不是一个网页，也不是一个普通应用，而是一组运行在浏览器内、彼此隔离、靠消息通信协作的独立脚本上下文。理解插件开发的关键，不在于记住某个 API，而在于建立正确的心智模型：把一个插件看作"浏览器里的微服务集群"——后台 Service Worker 是常驻调度服务（但会被随时回收），内容脚本（Content Script）是潜伏在每个网页里的探针，Popup/Options/Side Panel 是按需启动的前端进程，它们都没有共享内存，只能通过消息总线和共享存储交换数据。本文是该系列的概念篇，目标是让你在写第一行代码前就理解"谁能做什么、谁和谁
- Author: yellowdog
- Tags: 前端开发, 浏览器插件开发

> 官方文档：
> 
> - Chrome Extensions: <https://developer.chrome.com/docs/extensions>
> - MDN WebExtensions: <https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions>  
> 适用版本：Manifest V3 / Chrome 138+（Chromium 系通用）  
> 核实日期：2026-06-06

浏览器插件不是一个网页，也不是一个普通应用，而是一组运行在浏览器内、彼此隔离、靠消息通信协作的独立脚本上下文。理解插件开发的关键，不在于记住某个 API，而在于建立正确的心智模型：把一个插件看作"浏览器里的微服务集群"——后台 Service Worker 是常驻调度服务（但会被随时回收），内容脚本（Content Script）是潜伏在每个网页里的探针，Popup/Options/Side Panel 是按需启动的前端进程，它们都没有共享内存，只能通过消息总线和共享存储交换数据。本文是该系列的概念篇，目标是让你在写第一行代码前就理解"谁能做什么、谁和谁怎么说话、为什么不能这样写"。

## 概述

浏览器插件（Browser Extension）是用 HTML、CSS、JavaScript 编写、由浏览器加载并赋予额外权限（如读写跨域网络、修改任意网页、访问标签页和书签）的软件包。它运行在浏览器进程之内，因此能做到普通网页做不到的事，但同时被沙箱（Sandbox）和权限模型严格约束。

**Why（为什么用插件而非网页）**：网页受同源策略（Same-Origin Policy）限制，无法跨域读取数据、无法修改其他站点、无法常驻后台。插件通过声明权限突破这些边界，实现广告拦截、密码管理、页面增强、自动化采集等网页本身无法完成的功能。

**When（何时选择插件方案）**：当需求是"在用户浏览任意网页时介入"（注入 UI、改写请求、抓取内容），或"提供一个跨站点、随浏览器常驻的工具"时，插件是合适载体。若功能可以在自己的网站内完成，则不需要插件。

下面先建立四个核心模型——执行上下文、隔离、生命周期、通信，再讲权限与安全。

## 一、插件是什么，能做什么，不能做什么

### 能力边界（沙箱内 vs 沙箱外）

插件运行在浏览器授予的特权上下文中，但仍受沙箱约束。下表列出典型能力与禁区。

| 维度  | 插件能做                                                             | 插件不能做                               |
| --- | ---------------------------------------------------------------- | ----------------------------------- |
| 网络  | 跨域 fetch（声明 host\_permissions 后）；用 declarativeNetRequest 拦截/改写请求 | 用 MV3 阻塞式 webRequest 实时改包（企业策略外已禁用） |
| 页面  | 向任意匹配网页注入脚本与样式，读写其 DOM                                           | 直接读取页面 JS 的变量/函数（隔离世界阻断）            |
| 浏览器 | 操作标签页、书签、历史、Cookie、下载、上下文菜单                                      | 访问本地文件系统任意路径（仅限用户授权的下载/文件选择）        |
| 存储  | chrome.storage、IndexedDB、CacheStorage 持久化数据                      | 在 Service Worker 全局变量里长期保存状态（会被回收）  |
| 代码  | 执行打包进扩展、经审核的 JS                                                  | 加载并执行远程 JS（MV3 禁止远程代码）              |
| 进程  | 各上下文是独立 JS 环境                                                    | 在上下文间共享内存/全局对象                      |

核心约束有两条贯穿全文：**MV3 禁止远程代码**（所有可执行 JS 必须随包提交、经 Chrome Web Store 审核），以及**Service Worker 无 DOM、会被回收**（不能依赖常驻内存）。

## 二、WebExtensions 标准与浏览器实现

WebExtensions 是各家浏览器趋同的扩展 API 标准，最初由 Chrome 的扩展模型演化而来，Firefox、Edge、Safari 都在此基础上实现，API 命名空间高度一致（`tabs`、`storage`、`runtime` 等）。

| 浏览器     | 引擎       | API 命名空间                             | Promise 支持        | Manifest 版本现状（2026-06） | 备注                                |
| ------- | -------- | ------------------------------------ | ----------------- | ---------------------- | --------------------------------- |
| Chrome  | Chromium | chrome.\*                            | 多数 API 支持 Promise | 仅 MV3                  | MV2 已在稳定版停用                       |
| Edge    | Chromium | chrome.\*（兼容 browser.\*）             | 同 Chrome          | 仅 MV3                  | 与 Chrome 包基本通用                    |
| Firefox | Gecko    | browser.\*（原生 Promise）/ 兼容 chrome.\* | 全面 Promise        | MV2 与 MV3 并行支持         | 后台用 Event Page，background.scripts |
| Safari  | WebKit   | browser.\*                           | 支持                | MV2/MV3                | 需用 Xcode 打包为 App 扩展               |

实践要点：写跨浏览器插件时，用 `browser.*` 命名空间（配合 `webextension-polyfill` 在 Chrome 上补齐）能获得统一的 Promise 风格 API；只做 Chromium 系则直接用 `chrome.*`。本文示例统一用 `chrome.*`。

## 三、Manifest V2 → V3 的演进

Manifest 是插件的入口清单文件 `manifest.json`，声明版本、权限、各上下文入口。Manifest V3（MV3）是当前唯一在 Chrome 稳定版受支持的版本，它对 V2 做了根本性重构。

### 三大根本变化

| 变化点  | Manifest V2                        | Manifest V3                         | Google 给出的理由                      |
| ---- | ---------------------------------- | ----------------------------------- | --------------------------------- |
| 后台   | 持久化 Background Page（常驻内存）          | 事件驱动的 Service Worker（空闲回收）          | 性能：常驻后台即使插件未工作也占用内存               |
| 远程代码 | 允许加载远程 JS（<script src=远程>、远程 eval） | 禁止，所有 JS 必须随包提交审核                   | 安全：远程代码绕过商店审核，是恶意更新的主要通道          |
| 网络拦截 | 阻塞式 webRequest（可同步改写/阻断请求）         | declarativeNetRequest（声明规则，浏览器代为执行） | 隐私+性能：旧模型让插件能看到全部流量，且每个请求都要往返插件代码 |
| 权限   | host 权限与 API 权限混在 permissions      | host 权限独立到 host\_permissions        | 透明：让用户更清楚插件能访问哪些站点                |
| CSP  | 字符串形式，可放宽                          | 对象形式，禁止 unsafe-eval、远程 script-src   | 安全：堵死注入入口                         |

### 对开发者的影响（结论先行）

迁移到 MV3 后，三件事会直接改变你的写法：

1. **后台不能存内存状态**。原来在 background page 里 `let cache = {}` 长期累积数据的写法失效，必须改用 `chrome.storage`。
2. **拦截器要重写为声明式规则**。广告拦截、请求改写类插件需把命令式逻辑翻译成 `declarativeNetRequest` 的 JSON 规则。
3. **动态代码被堵死**。不能再 `eval`、不能 `new Function`、不能加载 CDN 上的脚本，第三方库必须打包进扩展。

下面是 MV3 入口清单的最小骨架。

```json
{
  "manifest_version": 3,
  "name": "示例插件",
  "version": "1.0.0",
  "background": {
    "service_worker": "background.js",
    "type": "module"
  },
  "content_scripts": [
    {
      "matches": ["https://*.example.com/*"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ],
  "action": {
    "default_popup": "popup.html"
  },
  "permissions": ["storage", "tabs"],
  "host_permissions": ["https://api.example.com/*"]
}

```

## 四、执行上下文模型

这是整个心智模型的核心。一个插件由多个互相隔离的 JS 上下文（Execution Context）组成，每个有独立的全局环境、生命周期和能力边界。

```
                    浏览器进程
   +-------------------------------------------------------------+
   |                                                             |
   |   +------------------+        +------------------------+    |
   |   | Service Worker   |<------>|  Popup / Options /     |    |
   |   | (后台调度，无DOM,|  消息   |  Side Panel / DevTools |    |
   |   |  会被回收)       |  总线   |  (有 DOM, 按需启停)    |    |
   |   +--------+---------+        +------------------------+    |
   |            ^                                                |
   |            | sendMessage / Port                            |
   |   +--------+--------------------------------------------+   |
   |   |        |          网页标签页 (Tab)                  |   |
   |   |  +-----+----------+        +---------------------+  |   |
   |   |  | Content Script |        |   页面自身的 JS     |  |   |
   |   |  | (Isolated World|<--DOM--→|   (MAIN World)      |  |   |
   |   |  |  隔离世界)     |postMessage                   |  |   |
   |   |  +----------------+        +---------------------+  |   |
   |   +--------------------------------------------------+   |   |
   |                                                          |   |
   |   +------------------+   (DOM 但不可见, 替 SW 干 DOM 活) |   |
   |   | Offscreen Doc    |<--------------------------------+    |
   |   +------------------+                                      |
   +-------------------------------------------------------------+
        共享层: chrome.storage（所有上下文可读写，作为"数据库"）

```

### 各上下文对比

| 上下文                  | 运行环境                  | 有 DOM      | 生命周期               | 主要能力                                    | 主要限制                                         |
| -------------------- | --------------------- | ---------- | ------------------ | --------------------------------------- | -------------------------------------------- |
| Service Worker（后台）   | 独立 Worker 线程，无 window | 否          | 事件驱动，空闲约 30s 回收    | 几乎全部 chrome.\* API；监听全局事件；调度            | 无 DOM；不能用全局变量存状态；不能用 XMLHttpRequest（用 fetch） |
| Content Script（内容脚本） | 注入到网页的隔离世界            | 是（页面 DOM）  | 随所在页面，页面卸载即销毁      | 读写页面 DOM；chrome.storage/runtime/i18n 子集 | 不能直接访问页面 JS 变量；多数 chrome.\* 需通过消息转给后台        |
| Popup（弹出页）           | 独立 HTML 页面            | 是（自己的 DOM） | 打开时创建，关闭即销毁        | 完整 chrome.\* API；做 UI                   | 关闭即丢内存状态；不能跨标签持久                             |
| Options（选项页）         | 独立 HTML 页面            | 是          | 用户打开时存在            | 完整 chrome.\* API；偏好设置 UI                | 同 Popup                                      |
| Side Panel（侧边栏）      | 独立 HTML 页面            | 是          | 用户开启侧栏期间存在，可常驻     | 完整 chrome.\* API；常驻侧边 UI                | Chrome 114+ 才有                               |
| DevTools 页面          | 挂在开发者工具内的页面           | 是          | 随 DevTools 面板存在    | chrome.devtools.\*、被检查页面信息              | 仅在 DevTools 打开时运行                            |
| Offscreen Document   | 不可见的隐藏文档              | 是（隐藏）      | 按 reason 创建，可能自动关闭 | 用 DOM/Web API 替无 DOM 的 SW 干活            | 同时只能存在一个；仅支持 chrome.runtime API              |

### Offscreen Document：为 Service Worker 补上 DOM

Service Worker 没有 DOM，因此无法用 `DOMParser` 解析 HTML、无法用 `<audio>` 播放声音、无法访问 `navigator.clipboard`。Offscreen Document 是一个不可见的隐藏 HTML 文档，专门替后台执行这些依赖 DOM/Web API 的任务。

创建时必须声明 `reason`，取值如下表（列全文档定义的枚举）。

| reason 枚举         | 用途               | 自动关闭            |
| ----------------- | ---------------- | --------------- |
| AUDIO\_PLAYBACK   | 播放音频             | 无音频播放 30s 后自动关闭 |
| CLIPBOARD         | 访问剪贴板 API        | 否               |
| DOM\_PARSER       | 使用 DOMParser 解析  | 否               |
| DOM\_SCRAPING     | 抓取 iframe 内 DOM  | 否               |
| IFRAME\_SCRIPTING | 嵌入并脚本化 iframe    | 否               |
| BLOBS             | 处理 Blob 对象       | 否               |
| USER\_MEDIA       | getUserMedia 媒体流 | 否               |
| DISPLAY\_MEDIA    | 屏幕/窗口捕获流         | 否               |
| WEB\_RTC          | WebRTC 功能        | 否               |
| LOCAL\_STORAGE    | 访问 localStorage  | 否               |
| WORKERS           | 派生 Web Worker    | 否               |
| GEOLOCATION       | 地理定位             | 否               |
| BATTERY\_STATUS   | 电池状态             | 否               |
| MATCH\_MEDIA      | matchMedia 查询    | 否               |

```javascript
// background.js —— 在 Service Worker 中创建唯一的 offscreen 文档
async function ensureOffscreen() {
  // 同时只能有一个 offscreen 文档，先判断是否已存在
  const existing = await chrome.offscreen.hasDocument();
  if (existing) return;
  await chrome.offscreen.createDocument({
    url: "offscreen.html",
    reasons: ["DOM_PARSER"], // 必填：声明用途
    justification: "解析抓取到的 HTML 字符串", // 必填：给审核者看的理由
  });
}

```

## 五、隔离模型：Isolated World vs MAIN World

内容脚本默认运行在隔离世界（Isolated World），这是理解"为什么我读不到页面变量"的关键。

```
       同一个标签页内
  +---------------------------------------------+
  |                                             |
  |   Isolated World            MAIN World      |
  |  (Content Script)          (页面自己的 JS)  |
  |  +----------------+       +---------------+  |
  |  | 独立的 JS 堆   |       | 页面的 JS 堆  |  |
  |  | window.foo=1   |  ✗    | window.foo=2  |  |
  |  | (互不可见)     |<------| (互不可见)    |  |
  |  +-------+--------+       +-------+-------+  |
  |          |                       |          |
  |          +------ 共享同一份 ------+          |
  |                   DOM 树                     |
  |          (两边都能读写 document)            |
  +---------------------------------------------+
       跨世界通信只能走: DOM 事件 / window.postMessage

```

**原理**：内容脚本与页面 JS 运行在两个独立的 JS 执行环境，各有各的全局对象（`window`）、各有各的原型链，但它们**共享同一棵 DOM 树**。所以：

- 内容脚本能 `document.querySelector(...)` 改 DOM——DOM 是共享的。
- 内容脚本读不到页面里 `window.someLib`——JS 堆是隔离的。
- 页面也篡改不了内容脚本注入的函数——这正是隔离要保护的安全边界（防止恶意页面劫持插件逻辑）。

### 正确与错误用法对比

```javascript
// content.js（运行在 Isolated World）

// 错误：试图直接读取页面的 JS 变量——隔离世界看不到页面的 window 属性
console.log(window.__APP_STATE__); // undefined（不是页面里的那个值）

// 正确：通过共享 DOM 用 postMessage 与页面通信
window.postMessage({ source: "my-ext", type: "PING" }, "*");
window.addEventListener("message", (event) => {
  // 必须校验来源，否则任意页面/iframe 都能伪造消息
  if (event.source !== window) return;
  if (event.data?.source !== "page-app") return;
  console.log("收到页面回复:", event.data.payload);
});

```

若确实需要进入页面世界（例如改写页面里的 `fetch`），用 `world: "MAIN"` 注入。代价是失去隔离保护，且不能用任何 `chrome.*` API。

```javascript
// manifest.json 片段：声明一个运行在 MAIN world 的内容脚本
{
  "content_scripts": [
    {
      "matches": ["https://*.example.com/*"],
      "js": ["inject-main.js"],
      "world": "MAIN",        // 进入页面世界
      "run_at": "document_start"
    }
  ]
}

```

| 维度                | ISOLATED（默认） | MAIN                      |
| ----------------- | ------------ | ------------------------- |
| 能否访问页面 JS 变量/函数   | 否            | 是                         |
| 能否用 chrome.\* API | 能（受限子集）      | 否                         |
| 适用的 CSP           | 扩展的 CSP      | 页面自身的 CSP                 |
| 安全性               | 高（与页面隔离）     | 低（与页面同环境，易被页面影响）          |
| 典型用途              | 读写 DOM、注入 UI | hook 页面函数、改写 window.fetch |

## 六、Service Worker 的事件驱动生命周期

MV3 后台是 Service Worker（SW），它不是常驻进程，而是按事件唤醒、空闲即回收的"无状态函数集合"。这是 MV3 最容易踩坑的地方。

```
   注册/安装              空闲              再次有事件
  +--------+   install   +--------+   30s无事件   +--------+   事件到达   +--------+
  | 安装中 |-----------> | 激活/  |------------->| 已终止 |----------->| 重新启动|
  |        | onInstalled | 运行中 |   被回收     | (内存清空)         | (重跑顶层)|
  +--------+             +--------+              +--------+            +--------+
                            ^                                              |
                            +----------------------------------------------+
                                 每次唤醒都从头执行 SW 顶层代码

```

### 关键事实（来自官方文档，核实于 2026-06-06）

- SW 在以下情况被终止：**30 秒无活动**；单个请求超过 5 分钟；`fetch` 响应超过 30 秒。
- 以下活动会**重置空闲计时器**：收到任意事件或 API 调用（Chrome 110+）、WebSocket 收发（116+）、长连接 Port 活动（114+）、Offscreen 消息（109+）、原生消息连接（105+）、最小周期 30s 的 alarm（120+）。
- `chrome.runtime.onInstalled` 用于一次性初始化（建上下文菜单、写默认配置）。
- `chrome.runtime.onStartup` 在浏览器配置启动时触发。
- **被回收后，所有全局变量丢失**，下次事件唤醒会重新执行 SW 顶层代码。

### 为什么不能用全局变量保存状态

```javascript
// background.js

// 错误：用全局变量累积状态——SW 一旦被回收，counter 归零，数据丢失
let counter = 0;
chrome.action.onClicked.addListener(() => {
  counter += 1;            // 30s 空闲后 SW 终止，再点击时 counter 又是 0
  console.log(counter);
});

```

```javascript
// 正确：状态写入 chrome.storage，唤醒后从存储恢复
chrome.action.onClicked.addListener(async () => {
  const { counter = 0 } = await chrome.storage.local.get("counter");
  const next = counter + 1;
  await chrome.storage.local.set({ counter: next }); // 持久化，跨回收存活
  console.log(next);
});

```

另一个高频坑：事件监听器必须在顶层同步注册，不能放进异步回调里——因为唤醒时只会重新执行顶层代码，晚注册的监听器会错过本次唤醒事件。

```javascript
// 错误：在异步回调里注册监听器——SW 唤醒时来不及注册，事件丢失
chrome.storage.local.get("config").then(() => {
  chrome.runtime.onMessage.addListener(handler); // 注册太晚
});

// 正确：监听器在文件顶层同步注册
chrome.runtime.onMessage.addListener(handler);
function handler(msg, sender, sendResponse) {
  // 在这里再做异步工作
  return true; // 异步回复时必须 return true 保持通道开启
}

```

## 七、通信模型概览

各上下文无共享内存，协作只能靠三种机制。本节只讲模型，具体 API 见 [浏览器插件-API速查大全](https://blog.vercanti.com/liu-lan-qi-cha-jian-chrome-api-quan-liang-su-cha-da-quan/)。

```
     一次性消息 (sendMessage / onMessage)
  Content Script  ────────请求/响应───────▶  Service Worker
        ▲                                          │
        └──────────────回复─────────────────────────┘

     长连接 (Port: connect / onConnect)
  Popup  ◀═══════════ 双向持续通道 ═══════════▶  Service Worker
        （适合频繁/流式通信，连接期间保活 SW）

     共享存储 (chrome.storage + onChanged)
  任意上下文 ──写──▶ [ chrome.storage ] ──onChanged 广播──▶ 其它上下文
        （把存储当数据库，用 onChanged 做跨上下文的"事件广播"）

```

| 机制       | 形态                          | 适用场景                 | 特点                   |
| -------- | --------------------------- | -------------------- | -------------------- |
| 一次性消息    | sendMessage / onMessage     | 请求-响应式调用             | 简单；异步回复需 return true |
| 长连接 Port | connect / onConnect         | 频繁、流式、有状态会话          | 连接期间保活 SW；可双向推送      |
| 共享存储     | chrome.storage \+ onChanged | 跨上下文共享数据/广播变更        | 可持久；不直接点对点，靠监听变更     |
| DOM 桥接   | window.postMessage          | 内容脚本 ↔ 页面 MAIN world | 跨世界唯一通道；必须校验来源       |

选型原则：一问一答用一次性消息；高频或需服务端主动推送用 Port；要让多个上下文同步同一份数据用 storage + onChanged。

## 八、权限模型

插件能力来自声明的权限。MV3 把权限分为安装时（install-time）与运行时（runtime）两类，并把 host 权限单独列出。

| 字段                          | 类型                | 默认值  | 说明                                               |
| --------------------------- | ----------------- | ---- | ------------------------------------------------ |
| permissions                 | string\[\]        | \[\] | API 权限，如 storage、tabs、alarms，安装时一次性授予            |
| optional\_permissions       | string\[\]        | \[\] | 可选 API 权限，运行时经 chrome.permissions.request 动态申请   |
| host\_permissions           | string\[\]        | \[\] | 安装时申请的主机访问权限，按匹配模式列出，如 https://\*.example.com/\* |
| optional\_host\_permissions | string\[\]        | \[\] | 运行时动态申请的主机权限                                     |
| activeTab                   | （在 permissions 中） | 无    | 用户主动点击插件时，临时授予当前标签页访问权，无需广域 host 权限              |

### 最小权限原则

```json
// 错误：申请 <all_urls> 会让所有站点都触发权限警告，审核与信任成本陡增
{
  "permissions": ["tabs", "webNavigation", "cookies"],
  "host_permissions": ["<all_urls>"]
}

```

```json
// 正确：只声明真正需要的站点，配合 activeTab 处理"用户点击时才介入"的场景
{
  "permissions": ["storage", "activeTab"],
  "host_permissions": ["https://api.example.com/*"]
}

```

`activeTab` 是降低权限的利器：用户点击插件图标的那一刻，浏览器临时授予当前标签页的脚本注入与读取权限，无需声明广域 host 权限，安装时也不弹出吓人的"读取你所有网站数据"警告。

运行时申请可选权限：

```javascript
// 在用户触发某功能时再申请，而不是一上来全要
document.querySelector("#enable-feature").addEventListener("click", async () => {
  const granted = await chrome.permissions.request({
    origins: ["https://extra-site.com/*"],
  });
  if (!granted) return; // 用户拒绝则降级处理
  // 已授权，继续
});

```

## 九、安全模型

MV3 的安全基线由三条规则组成：强制 CSP、禁止远程代码、显式声明可被网页访问的资源。

### 内容安全策略（CSP）

MV3 用对象形式声明 CSP，且对扩展页面强制禁止 `unsafe-eval` 与远程脚本源。

| 字段                                         | 类型     | 默认值                                  | 说明                                          |
| ------------------------------------------ | ------ | ------------------------------------ | ------------------------------------------- |
| content\_security\_policy.extension\_pages | string | script-src 'self'; object-src 'self' | 扩展自身页面（popup/options 等）的 CSP，不允许放宽到远程或 eval |
| content\_security\_policy.sandbox          | string | （无沙箱默认值）                             | 沙箱页面（如需 eval 的场景）的 CSP                      |

### 禁止远程代码

MV3 下所有可执行 JS 必须随扩展包提交、经商店审核。这意味着：

```javascript
// 错误：从 CDN 动态加载脚本——MV3 禁止执行远程代码，CSP 会拦截
const s = document.createElement("script");
s.src = "https://cdn.example.com/lib.js";
document.head.appendChild(s);

// 错误：eval / new Function 动态执行字符串——被扩展页 CSP 禁止
eval("doSomething()");

// 正确：把第三方库通过构建工具打包进扩展，作为本地资源 import
import { doSomething } from "./vendor/lib.js"; // 随包提交，受审核
doSomething();

```

构建打包方案见 [浏览器插件-技术栈与脚手架](https://blog.vercanti.com/liu-lan-qi-cha-jian-ji-zhu-zhan-yu-jiao-shou-jia/)。

### web\_accessible\_resources

默认情况下，扩展内的文件（图片、脚本、字体）不能被网页直接引用。若要让页面或内容脚本注入的元素引用扩展资源，必须显式声明。

| 字段                | 类型         | 默认值   | 说明                             |
| ----------------- | ---------- | ----- | ------------------------------ |
| resources         | string\[\] | \[\]  | 可被访问的资源路径（支持通配）                |
| matches           | string\[\] | \[\]  | 允许访问这些资源的页面匹配模式                |
| extension\_ids    | string\[\] | \[\]  | 允许访问的其它扩展 ID                   |
| use\_dynamic\_url | boolean    | false | true 时用随会话变化的动态 URL，降低被指纹识别的风险 |

```json
// 让 example.com 的页面能引用扩展里的 inject.js 和图标
{
  "web_accessible_resources": [
    {
      "resources": ["inject.js", "icons/*.png"],
      "matches": ["https://*.example.com/*"],
      "use_dynamic_url": true
    }
  ]
}

```

暴露最少的资源、限定最窄的 `matches`，避免把扩展内部脚本暴露给任意站点（否则可能被用于指纹识别或被恶意页面利用）。

## 十、心智模型：把插件当作"浏览器里的微服务集群"

把整套架构映射到微服务，能快速建立直觉：

| 微服务概念         | 插件对应                         | 说明                       |
| ------------- | ---------------------------- | ------------------------ |
| 调度/网关服务       | Service Worker               | 中枢逻辑，路由消息，但"无状态、会被缩容到 0" |
| 边车探针（Sidecar） | Content Script               | 部署到每个网页"实例"旁，采集/改写本地 DOM |
| 前端进程          | Popup / Options / Side Panel | 按需启停，关闭即销毁               |
| 共享数据库         | chrome.storage / IndexedDB   | 所有服务读写的唯一持久层             |
| 消息队列/RPC      | sendMessage / Port           | 服务间唯一通信方式，无共享内存          |
| 离线 worker     | Offscreen Document           | 替无 DOM 的调度服务跑需要 DOM 的批处理 |
| 服务网格隔离        | Isolated World               | 探针与宿主（页面）隔离，防互相污染        |

由此推导出三条贯穿全系列的设计准则：

1. **无状态优先**：任何上下文随时可能被销毁，状态必须落到存储层。
2. **消息即接口**：上下文间不要假设能共享对象，所有协作走消息。
3. **最小信任面**：每个上下文只拿它必需的权限与资源，隔离边界不轻易打破。

## 最佳实践

1. **一句话原则：状态一律落 storage，不依赖 SW 内存。**  
Service Worker 会在 30 秒空闲后被回收，全局变量随之清空。所有需要跨事件存活的数据都写入 `chrome.storage`，唤醒后再读回。  
```javascript  
chrome.runtime.onMessage.addListener(async (msg) => {  
  const { sessions = {} } = await chrome.storage.session.get("sessions");  
  sessions[msg.tabId] = msg.data;  
  await chrome.storage.session.set({ sessions }); // session 区随浏览器会话存活  
});  
```
2. **一句话原则：事件监听器在顶层同步注册。**  
SW 唤醒时只重跑顶层代码，监听器若在异步回调里注册会错过当次事件。把所有 `addListener` 放在模块顶层。  
```javascript  
// 顶层同步注册，确保每次唤醒立即就位  
chrome.runtime.onInstalled.addListener(handleInstall);  
chrome.alarms.onAlarm.addListener(handleAlarm);  
```
3. **一句话原则：默认用 ISOLATED 世界，仅在必须 hook 页面时才进 MAIN。**  
隔离世界保护插件逻辑不被页面篡改。除非要改写页面的 `fetch`/全局函数，否则不要用 `world: "MAIN"`。  
```javascript  
// 仅对确需进入页面世界的脚本单独声明 world: "MAIN"，其余保持默认  
await chrome.scripting.executeScript({  
  target: { tabId },  
  world: "MAIN",  
  func: () => { /* hook 页面 fetch */ },  
});  
```
4. **一句话原则：用 activeTab + 最窄 host\_permissions 替代 `<all_urls>`。**  
广域权限触发严苛审核与用户警告。多数"点击后处理当前页"的需求用 `activeTab` 即可。  
```json  
{ "permissions": ["activeTab", "scripting"] }  
```
5. **一句话原则：第三方库打包进扩展，绝不远程加载。**  
MV3 禁止远程代码，CSP 会拦截 CDN 脚本与 `eval`。用打包工具把依赖编译进本地文件。  
```javascript  
import dayjs from "dayjs"; // 由构建工具打包进扩展产物  
```
6. **一句话原则：DOM 相关后台任务交给 Offscreen Document，并复用单例。**  
SW 无 DOM，需要 `DOMParser`、剪贴板、音频时创建 offscreen 文档，且全局只允许一个，创建前先检测。  
```javascript  
if (!(await chrome.offscreen.hasDocument())) {  
  await chrome.offscreen.createDocument({  
    url: "offscreen.html",  
    reasons: ["CLIPBOARD"],  
    justification: "写入系统剪贴板",  
  });  
}  
```
7. **一句话原则：跨世界消息必须校验来源。**  
`window.postMessage` 会被同页任意 iframe/脚本收到，处理前校验 `event.source` 与自定义标识，防止伪造。  
```javascript  
window.addEventListener("message", (e) => {  
  if (e.source !== window || e.data?.source !== "page-app") return;  
  // 可信消息  
});  
```

## 常见陷阱

### 陷阱一：后台用全局变量缓存，过一会儿数据"自己丢了"

- 现象：插件运行一段时间后，后台维护的计数器、缓存、定时器状态突然清零或失效。
- 原因：Service Worker 在 30 秒空闲后被浏览器回收，进程内存（含全局变量、`setTimeout` 句柄）全部释放；下次事件触发时是全新进程。
- 解决：状态写入 `chrome.storage`；定时任务改用 `chrome.alarms`（持久、可跨回收触发），不要用 `setTimeout`/`setInterval`。  
```javascript  
// 用 alarms 替代 setInterval，回收后仍能按时唤醒 SW  
chrome.alarms.create("sync", { periodInMinutes: 5 });  
chrome.alarms.onAlarm.addListener((alarm) => {  
  if (alarm.name === "sync") doSync();  
});  
```

### 陷阱二：内容脚本里 `console.log(window.页面变量)` 全是 undefined

- 现象：明明页面控制台能打印出 `window.__DATA__`，内容脚本里却读到 `undefined`。
- 原因：内容脚本运行在隔离世界，拥有独立的 `window`，与页面 JS 堆隔离，只共享 DOM，不共享 JS 变量。
- 解决：经共享 DOM 用 `window.postMessage` 与页面通信；或注入一段 `world: "MAIN"` 脚本去页面世界取值后再 `postMessage` 回隔离世界。  
```javascript  
// inject-main.js（world: MAIN）：在页面世界取值后回传  
window.postMessage({ source: "page-app", payload: window.__DATA__ }, "*");  
```

### 陷阱三：`onMessage` 里异步回复，结果回调收到 undefined

- 现象：内容脚本 `sendMessage` 后拿到的响应是 `undefined`，明明后台算出了结果。
- 原因：`onMessage` 监听器若做异步工作（如 `await fetch`），同步函数已返回，消息通道默认关闭，`sendResponse` 失效。
- 解决：在监听器里 `return true` 显式保持通道开启，待异步完成后再调用 `sendResponse`。  
```javascript  
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {  
  (async () => {  
    const data = await fetch(msg.url).then((r) => r.json());  
    sendResponse(data);  
  })();  
  return true; // 关键：保持消息通道开启直到 sendResponse 被调用  
});  
```

### 陷阱四：页面引用扩展内文件报 net::ERR\_BLOCKED\_BY\_CLIENT 或 404

- 现象：内容脚本往页面插入 `<img src="chrome-extension://.../icon.png">` 或注入脚本，浏览器拒绝加载。
- 原因：扩展资源默认不对网页开放，未在 `web_accessible_resources` 声明的资源无法被页面上下文引用。
- 解决：在 manifest 的 `web_accessible_resources` 中声明该资源及允许的 `matches`，并用 `chrome.runtime.getURL` 生成正确 URL。  
```javascript  
const url = chrome.runtime.getURL("icon.png"); // 生成合法的扩展资源 URL  
img.src = url;  
```

## 参见

- [浏览器插件开发完全指南](https://blog.vercanti.com/liu-lan-qi-cha-jian-kai-fa-wan-quan-zhi-nan-vite-vue-manifest-v3/)
- [浏览器插件-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-ji-zhu-zhan-yu-jiao-shou-jia/)
- [浏览器插件-设计模式与优雅架构](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-zhong-ji-kai-fa-zhi-nan-manifest-v3-vite-vue/)
- [浏览器插件-高级开发指南](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-diao-shi-yu-pai-cuo-shou-ce/)
- [浏览器插件-避坑与开发技巧](https://blog.vercanti.com/liu-lan-qi-cha-jian-bi-keng-yu-kai-fa-ji-qiao/)
- [Vite初级指南](https://blog.vercanti.com/vite-chu-ji-zhi-nan/)
- [Vue3入门](https://blog.vercanti.com/vue-3-ru-men-zhi-nan/)