Manifest V3 架构详解:浏览器扩展的各个部分如何协作
从运行环境和一次按钮点击讲起,理解 Manifest V3 的 service worker、内容脚本、消息通信、权限、数据保存和声明式网络规则,建立清晰的扩展架构思路。
文章目录
第一次开发浏览器扩展,很多人会先找一份 manifest.json,把版本填成 3,再写几个 JavaScript 文件。按钮能点,网页也能修改,看起来已经入门了。
真正让人困惑的通常发生在后面:为什么后台变量过一会儿就没了?为什么内容脚本拿不到网页里的变量?为什么有了权限,某些页面还是不能注入脚本?为什么弹窗关闭以后,任务结果找不到了?
这些问题有一个共同原因:扩展由多个运行环境组成,各自拥有不同的能力和生命周期。理解它们如何协作,比记住一堆 API 更有用。
本文以 Chrome 的 Manifest V3 为主。示例使用 chrome.* API;其他浏览器即使也支持 MV3,具体 API 和后台机制仍需要核对各自文档。
一、先认识 Manifest:扩展的能力清单
Manifest 是浏览器读取的扩展配置,负责说明名字、版本、入口文件和需要的权限。manifest_version: 3 表示使用第三版清单规范,和产品自己的 version 是两件事。
它像一份入口地图:按钮打开哪个界面,后台从哪个文件启动,哪些页面可以执行内容脚本,都由这里声明。实际业务逻辑仍然写在 JavaScript、HTML 和其他资源中。
理解架构时,可以先把一个扩展拆成五部分:
| 部分 | 主要职责 | 需要记住的边界 |
|---|---|---|
| Manifest | 声明入口、能力和资源 | 配置本身不执行业务逻辑 |
| 扩展界面 | 按钮弹窗、选项页、侧边栏等 | 生命周期由对应界面决定 |
| Service worker | 响应事件、协调任务、调用后台 API | 可以被停止,没有网页 DOM |
| Content script | 读取或修改目标网页 | 默认与网页脚本隔离,API 能力有限 |
| 存储与浏览器规则 | 保存状态、执行已声明的规则 | 不依赖某个界面一直打开 |
并非每个扩展都需要五部分齐全。一个简单的界面工具可以不设置后台;一个只注册网络规则的扩展,也不必给每次请求安排 JavaScript 回调。按功能选择组件,比先搭一个庞大框架更合适。
二、三个运行环境,别把代码放错地方
扩展界面:用户看得见的操作层
Popup 是点击工具栏图标后出现的小窗口;Options page 用于配置;Side panel 可以提供持续可见的操作区域。它们是扩展自己的页面,可以使用 DOM 构建界面。
Popup 很适合输入参数、展示状态,但关闭后页面会被销毁。如果用户输入了一半就点击网页,下次打开时能否恢复,应由存储逻辑保证。
因此,按钮弹窗负责发起操作和展示结果比较合适。需要脱离弹窗继续执行的工作,应交给后台或适合该任务的平台能力,并把结果保存下来。
Service worker:按事件启动的协调层
Chrome MV3 使用扩展 service worker 承接后台职责。它可以监听按钮点击、消息、安装更新和定时事件,调用扩展 API,但没有 window、document,也不能直接读取某个标签页的 DOM。
它与网站里的 service worker 都使用 worker 运行环境,但职责不完全相同。扩展后台常见的任务是协调浏览器能力,不能将它理解成一个能随意拦截所有标签页请求的代理。
这里最关键的是生命周期。Chrome 通常会在后台空闲约 30 秒后停止它;具体事件、API 和浏览器版本会影响运行时长。因此,设计时要接受“下一次处理事件,可能是一个全新的执行环境”。全局变量适合临时计算,不适合作为长期状态来源。详细条件见官方生命周期说明。
Content script:进入网页的操作层
如果要提取文章标题、标记关键词、添加阅读按钮,就需要在网页中执行代码。这是内容脚本的工作。
它默认运行在 isolated world,也就是隔离的 JavaScript 环境。它可以操作同一份页面 DOM,但不能直接读取网页脚本里的普通变量。页面里的 window.appState,不会自动成为内容脚本可访问的应用状态。
内容脚本只直接开放部分扩展 API,其他能力通常通过消息请求后台完成。有特殊需求时可以选择 MAIN world,但此时会进入网页脚本的环境,要重新考虑干扰和信任边界。共享 DOM 也不代表网页提供的数据可信。相关机制见内容脚本文档。
三、跟着一次点击,看完整执行流程
假设我们做一个小工具:点击扩展按钮,保存当前网页的标题、网址和前二十个二级标题。
流程可以写成:
用户点击扩展按钮
→ Chrome 派发 action.onClicked 事件
→ service worker 处理事件
→ scripting 在当前网页执行提取函数
→ 提取结果返回 worker
→ worker 写入 storage.local
→ 按钮角标提示完成
这能清楚地看到职责分工:网页里的代码接触 DOM,worker 负责安排执行和保存结果,浏览器负责权限检查与事件派发。
一个最小的两文件示例
下面的示例用于说明架构,没有弹窗,也没有完整的收藏管理界面。把两个文件放进同一目录即可作为未打包扩展加载;请在普通 HTTPS 网页上试用。
manifest.json:
{
"manifest_version": 3,
"name": "网页目录记录",
"version": "1.0.0",
"permissions": ["activeTab", "scripting", "storage"],
"background": {
"service_worker": "worker.js"
},
"action": {
"default_title": "保存当前网页目录"
}
}
worker.js:
chrome.action.onClicked.addListener(async (tab) => {
if (typeof tab.id !== "number") return;
try {
const [injection] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => ({
title: document.title,
url: location.href,
headings: Array.from(document.querySelectorAll("h2"))
.slice(0, 20)
.map((node) => node.textContent.trim())
})
});
if (!injection?.result) throw new Error("未获得网页数据");
await chrome.storage.local.set({
lastCapture: {
...injection.result,
savedAt: new Date().toISOString()
}
});
await chrome.action.setBadgeText({ tabId: tab.id, text: "OK" });
} catch (error) {
console.error("保存失败:", error);
await chrome.action.setBadgeText({ tabId: tab.id, text: "ERR" })
.catch(() => {});
}
});
注意,func 虽然写在 worker 文件里,真正执行的位置却是目标网页。因此里面的 document 可以使用,而 worker 本身仍然没有 DOM。这个函数不能依赖定义在 worker 外层的闭包变量;需要参数时使用 args 传递。见脚本注入 API。
另一个细节是这里没有设置 default_popup。如果给按钮配置了 popup,点击会打开弹窗,这次点击不会再触发同一 action 的 onClicked。需要由弹窗里的代码发起后续操作。见action API 的弹窗说明。
在 chrome://extensions 打开开发者模式,选择“加载已解压的扩展程序”,加载目录后将扩展固定到工具栏。打开一个有标题的普通网页,点击按钮,然后在该扩展的 service worker 检查窗口中执行:
await chrome.storage.local.get("lastCapture")
预期能看到保存的数据。这个示例每次覆盖上一条记录,方便理解流程;若要发展成收藏产品,需要另行设计多条记录、列表界面和并发写入逻辑。
四、权限:能调用什么,与能访问哪里
很多权限问题都来自把两种授权混在一起。
permissions 通常声明 API 能力,例如 storage、scripting;host_permissions 声明允许访问的网站范围。执行网页脚本时,除了 scripting 能力,还需要相应的网站授权,或符合条件的临时授权。某些 API 是否产生权限提示,也要看具体声明。
上面的示例选择 activeTab。用户主动点击扩展按钮后,它会临时获得当前标签页的相关访问能力。关闭标签页或离开该来源会撤销授权,同来源内的导航可继续保留。这样不必一开始就请求所有网站权限。见activeTab 说明。
如果产品需要进入特定网站后自动工作,可以根据需求声明匹配范围;可选功能也可以使用 optional permissions,等用户使用时再申请。API 权限、网站权限与内容脚本匹配规则应分别检查,不能认为加上 <all_urls> 就解决了一切。完整字段见权限声明文档。
浏览器内部页面、扩展商店等受限页面依然不能按普通网页处理;文件网址还涉及单独的访问设置。遇到注入失败,先检查页面类型与授权,再检查业务代码。
五、消息通信:让模块合作,而不是共享变量
当扩展加入弹窗、侧边栏或常驻内容脚本后,各部分需要交换信息。常见的单次消息是 runtime.sendMessage();向指定标签页里的内容脚本发消息可以用 tabs.sendMessage();持续交互可以使用 Port。
消息最好围绕任务设计。例如:
{
"type": "SAVE_CAPTURE",
"requestId": "capture-001",
"payload": {
"title": "示例文章",
"url": "https://example.com/article"
}
}
type 说明要做什么,requestId 方便对应响应,payload 携带数据。不要把函数、DOM 节点或整个复杂对象当成普通消息发送。接收端还应检查消息类型、字段和发送方,让明确的处理器执行明确的操作。
一个常见问题是异步响应太晚,消息通道已经关闭。采用回调形式时,监听函数可以同步返回字面量 true,稍后调用 sendResponse();同时处理失败路径,避免调用方一直等待。
Chrome 的 Promise 响应支持存在版本差异,不能将“直接把监听器写成 async”当作所有目标版本都可用的做法。本文采用上述兼容思路,具体能力以消息通信文档为准。Port 也不能被当成让 worker 永远常驻的保证。
六、状态保存:分清临时数据和长期数据
用户关掉弹窗、worker 休眠、浏览器重启,是三个不同的事件。你要先回答:数据需要跨越哪一种生命周期?
| 位置 | 适合保存什么 | 生命周期特点 |
|---|---|---|
| 组件变量、worker 全局变量 | 当前计算与临时界面状态 | 对应运行环境销毁后丢失 |
storage.session | 当前浏览器会话中的中间状态 | 可跨 worker 重启,浏览器重启等情况会清除 |
storage.local | 本地设置、记录和任务结果 | 可长期保存,扩展卸载时清除 |
storage.sync | 体积较小、需要同步的偏好设置 | 有配额,同步受浏览器账户和设置影响 |
| IndexedDB | 较复杂的结构化数据 | 可使用事务组织数据读写 |
存储接口是异步的,“读取数组、追加一项、写回数组”并不自动成为事务。多个任务同时操作时可能覆盖彼此,需要设计记录键、写入顺序,或使用适当的事务存储。扩展 API 的存储区域与限制见storage 文档。
长期任务还需要保存进度。例如批量处理二十个页面,应记录已经完成哪些、下一步是什么、失败后如何重试。worker 被停止后,下一次事件可以从持久状态恢复,而不是依赖上一次还留在内存中的循环。
监听器要在脚本顶层同步注册,初始化数据可以在事件处理函数内部读取。需要跨休眠调度的任务,应考虑 chrome.alarms,不要只依赖一个几分钟后的 setTimeout()。这些调整见后台迁移指南。定时触发也不是精确计时承诺,处理逻辑仍要检查实际状态。
七、网络规则:把决定交给浏览器执行
MV3 的另一项重要变化,是普通扩展处理阻断、重定向等网络行为时,更多采用 declarativeNetRequest。你先提交“什么请求,执行什么动作”的规则,匹配和执行由浏览器完成。
例如,规则可以表达:当某类请求匹配一个指定地址模式时阻断它。这里没有“每个请求都先唤醒 worker,再让你的 JavaScript 返回判断结果”的必要。
规则大致分为三类:随扩展打包的 static rules、可在运行时更新并跨会话保存的 dynamic rules,以及浏览器会话结束后清除的 session rules。具体动作、匹配条件和数量限制需要查看声明式网络请求 API。
这也不意味着 webRequest 整个消失了:观察网络事件仍有相应能力;阻塞式使用受到限制,政策安装的企业扩展存在例外。见阻塞请求迁移说明。
选型时先分清需求:是读取网页内容,观察请求事件,还是改变网络行为?它们分别涉及不同接口。DNR 不能当作任意读取响应正文、执行自定义算法的通用网络代理。
八、远程代码与 offscreen:两个容易误解的地方
可以请求远程数据,不等于可以远程下发执行逻辑
MV3 在 Chrome Web Store 的远程代码要求下,扩展的可执行逻辑应随安装包交付。日常开发中,不应把远程 JavaScript 下载后再执行,也不能把核心逻辑放在外部 CDN 的脚本里,绕开扩展更新和审核。
访问服务端接口、读取 JSON 配置和文章数据是另一回事。不过,如果把“配置”变成一套能解释执行任意程序的语言,仍需要考虑远程代码要求。判断重点是它有没有成为可执行逻辑,不能只看文件后缀。边界与例外见远程代码说明。
Offscreen document 解决特定 DOM 需求
某些后台功能确实需要文档环境,例如特定的媒体或剪贴板操作。chrome.offscreen 可以创建不可见的扩展文档,并通过消息与 worker 协作。
它需要声明权限、创建理由和用途说明,扩展 API 支持也受到限制,主要通过 runtime 通信。它适合特定任务,不宜当成恢复旧式常驻后台页面的办法。任务完成后应管理好关闭和资源释放。见offscreen API 文档。
九、开发与调试,沿着执行链逐段检查
一个“按钮没反应”的问题,可能发生在权限、事件、注入、消息或存储的任何一段。分别检查比盯着一个控制台更有效。
- 在扩展管理页检查 Manifest 和加载错误。
- 在 service worker 的检查窗口看后台事件与错误。
- 在网页开发者工具选择内容脚本对应的执行环境,检查 DOM 操作。
- 对 popup 单独检查,确认点击处理器是否真正执行。
- 检查存储结果,而不只看一个成功提示。
还要测试“关闭后再打开”:关闭 popup,停止 worker,再次触发事件,看看状态能否恢复。打开调试器可能改变 worker 的生命周期,测试休眠行为时要关闭检查窗口,避免调试状态掩盖问题。
如果从 MV2 迁移,先列出原后台承担的每项职责,再决定放到哪里:DOM 操作归文档环境,网页操作归内容脚本,状态归存储,事件协调归 worker,适合声明的网络行为归规则系统。只替换配置字段,往往不能完成真正的迁移。
十、从小功能开始,逐步建立架构
学习时可以先做本文的“点击、提取、保存”流程,再加一个读取结果的 popup。接着把提取逻辑拆成内容脚本,通过消息通信完成同一个功能;最后测试 worker 重启、网页跳转和权限拒绝后的行为。
等这些边界理解清楚,再加入自动注入、定时任务、网络规则或服务端同步,每一步都有明确的落点。
设计新功能时,先问自己三个问题:这段代码需要在哪个环境执行?这个结果需要保存多久?用户授权是否足够支持这次操作?把答案写清楚,Manifest V3 的组件就会从一堆陌生名词,变成一条可以逐段检查的执行链。