浏览器插件:基础概念与架构模型
浏览器插件不是一个网页,也不是一个普通应用,而是一组运行在浏览器内、彼此隔离、靠消息通信协作的独立脚本上下文。理解插件开发的关键,不在于记住某个 API,而在于建立正确的心智模型:把一个插件看作"浏览器里的微服务集群"——后台 Service Worker 是常驻调度服务(但会被随时回收),内容脚本(Content Script)是潜伏在每个网页里的探针,Popup/Options/Side Panel 是按需启动的前端进程,它们都没有共享内存,只能通过消息总线和共享存储交换数据。本文是该系列的概念篇,目标是让你在写第一行代码前就理解"谁能做什么、谁和谁
官方文档:
- 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 后,三件事会直接改变你的写法:
- 后台不能存内存状态。原来在 background page 里
let cache = {}长期累积数据的写法失效,必须改用chrome.storage。 - 拦截器要重写为声明式规则。广告拦截、请求改写类插件需把命令式逻辑翻译成
declarativeNetRequest的 JSON 规则。 - 动态代码被堵死。不能再
eval、不能new Function、不能加载 CDN 上的脚本,第三方库必须打包进扩展。
下面是 MV3 入口清单的最小骨架。
{
"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 查询 |
否 |
// 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 堆是隔离的。 - 页面也篡改不了内容脚本注入的函数——这正是隔离要保护的安全边界(防止恶意页面劫持插件逻辑)。
正确与错误用法对比
// 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。
// 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 顶层代码。
为什么不能用全局变量保存状态
// background.js
// 错误:用全局变量累积状态——SW 一旦被回收,counter 归零,数据丢失
let counter = 0;
chrome.action.onClicked.addListener(() => {
counter += 1; // 30s 空闲后 SW 终止,再点击时 counter 又是 0
console.log(counter);
});
// 正确:状态写入 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);
});
另一个高频坑:事件监听器必须在顶层同步注册,不能放进异步回调里——因为唤醒时只会重新执行顶层代码,晚注册的监听器会错过本次唤醒事件。
// 错误:在异步回调里注册监听器——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速查大全。
一次性消息 (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 权限 |
最小权限原则
// 错误:申请 <all_urls> 会让所有站点都触发权限警告,审核与信任成本陡增
{
"permissions": ["tabs", "webNavigation", "cookies"],
"host_permissions": ["<all_urls>"]
}
// 正确:只声明真正需要的站点,配合 activeTab 处理"用户点击时才介入"的场景
{
"permissions": ["storage", "activeTab"],
"host_permissions": ["https://api.example.com/*"]
}
activeTab 是降低权限的利器:用户点击插件图标的那一刻,浏览器临时授予当前标签页的脚本注入与读取权限,无需声明广域 host 权限,安装时也不弹出吓人的"读取你所有网站数据"警告。
运行时申请可选权限:
// 在用户触发某功能时再申请,而不是一上来全要
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 必须随扩展包提交、经商店审核。这意味着:
// 错误:从 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();
构建打包方案见 浏览器插件-技术栈与脚手架。
web_accessible_resources
默认情况下,扩展内的文件(图片、脚本、字体)不能被网页直接引用。若要让页面或内容脚本注入的元素引用扩展资源,必须显式声明。
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
resources |
string[] | [] |
可被访问的资源路径(支持通配) |
matches |
string[] | [] |
允许访问这些资源的页面匹配模式 |
extension_ids |
string[] | [] |
允许访问的其它扩展 ID |
use_dynamic_url |
boolean | false |
true 时用随会话变化的动态 URL,降低被指纹识别的风险 |
// 让 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 | 探针与宿主(页面)隔离,防互相污染 |
由此推导出三条贯穿全系列的设计准则:
- 无状态优先:任何上下文随时可能被销毁,状态必须落到存储层。
- 消息即接口:上下文间不要假设能共享对象,所有协作走消息。
- 最小信任面:每个上下文只拿它必需的权限与资源,隔离边界不轻易打破。
最佳实践
-
一句话原则:状态一律落 storage,不依赖 SW 内存。
Service Worker 会在 30 秒空闲后被回收,全局变量随之清空。所有需要跨事件存活的数据都写入chrome.storage,唤醒后再读回。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 区随浏览器会话存活 }); -
一句话原则:事件监听器在顶层同步注册。
SW 唤醒时只重跑顶层代码,监听器若在异步回调里注册会错过当次事件。把所有addListener放在模块顶层。// 顶层同步注册,确保每次唤醒立即就位 chrome.runtime.onInstalled.addListener(handleInstall); chrome.alarms.onAlarm.addListener(handleAlarm); -
一句话原则:默认用 ISOLATED 世界,仅在必须 hook 页面时才进 MAIN。
隔离世界保护插件逻辑不被页面篡改。除非要改写页面的fetch/全局函数,否则不要用world: "MAIN"。// 仅对确需进入页面世界的脚本单独声明 world: "MAIN",其余保持默认 await chrome.scripting.executeScript({ target: { tabId }, world: "MAIN", func: () => { /* hook 页面 fetch */ }, }); -
一句话原则:用 activeTab + 最窄 host_permissions 替代
<all_urls>。
广域权限触发严苛审核与用户警告。多数"点击后处理当前页"的需求用activeTab即可。{ "permissions": ["activeTab", "scripting"] } -
一句话原则:第三方库打包进扩展,绝不远程加载。
MV3 禁止远程代码,CSP 会拦截 CDN 脚本与eval。用打包工具把依赖编译进本地文件。import dayjs from "dayjs"; // 由构建工具打包进扩展产物 -
一句话原则:DOM 相关后台任务交给 Offscreen Document,并复用单例。
SW 无 DOM,需要DOMParser、剪贴板、音频时创建 offscreen 文档,且全局只允许一个,创建前先检测。if (!(await chrome.offscreen.hasDocument())) { await chrome.offscreen.createDocument({ url: "offscreen.html", reasons: ["CLIPBOARD"], justification: "写入系统剪贴板", }); } -
一句话原则:跨世界消息必须校验来源。
window.postMessage会被同页任意 iframe/脚本收到,处理前校验event.source与自定义标识,防止伪造。window.addEventListener("message", (e) => { if (e.source !== window || e.data?.source !== "page-app") return; // 可信消息 });
常见陷阱
陷阱一:后台用全局变量缓存,过一会儿数据"自己丢了"
-
现象:插件运行一段时间后,后台维护的计数器、缓存、定时器状态突然清零或失效。
-
原因:Service Worker 在 30 秒空闲后被浏览器回收,进程内存(含全局变量、
setTimeout句柄)全部释放;下次事件触发时是全新进程。 -
解决:状态写入
chrome.storage;定时任务改用chrome.alarms(持久、可跨回收触发),不要用setTimeout/setInterval。// 用 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回隔离世界。// inject-main.js(world: MAIN):在页面世界取值后回传 window.postMessage({ source: "page-app", payload: window.__DATA__ }, "*");
陷阱三:onMessage 里异步回复,结果回调收到 undefined
-
现象:内容脚本
sendMessage后拿到的响应是undefined,明明后台算出了结果。 -
原因:
onMessage监听器若做异步工作(如await fetch),同步函数已返回,消息通道默认关闭,sendResponse失效。 -
解决:在监听器里
return true显式保持通道开启,待异步完成后再调用sendResponse。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。const url = chrome.runtime.getURL("icon.png"); // 生成合法的扩展资源 URL img.src = url;