三个世界,一套消息
浏览器扩展看起来只是"写个页面加个脚本",实际难点全在上下文隔离上:后台拿不到 DOM、content script 拿不到页面变量、popup 一关就销毁。MV3 又把后台换成了会休眠的 Service Worker,很多老经验直接失效。
01先分清在哪个世界遇到"为什么拿不到这个"之前,先问自己这段代码跑在哪个上下文。
02记住会休眠Service Worker 随时被终止,是 MV3 一切问题的源头。
03权限就是产品申请得越多,用户越不敢装、审核越难过。这是一条产品约束。
1
MV3 到底改了什么★
不是加功能,是换了运行模型
MV3 最大的变化是把常驻后台页换成 Service Worker。它随时会被浏览器杀掉,所以"内存里存状态"这个老习惯必须改掉——一切要持久化的东西都得进 storage。
MV2 的习惯
- background page 是常驻页面,有 DOM
- 可以在内存里存全局变量当状态
- webRequest 可以拦截并修改请求
- 可以远程加载代码执行
MV3 的约束
- Service Worker 随时休眠,全局变量会丢
- 没有 DOM,不能用 document 和 XHR
- declarativeNetRequest 只能声明规则,不能改请求体
- 禁止远程代码,所有 JS 必须打包进来
MV3 的连锁反应后台会休眠
→ 状态必须放 chrome.storage
→ 唤醒后要重建状态
→ 消息监听必须注册在<b>文件顶层</b>
(写在异步回调里,唤醒时事件已经错过了)
webRequest 被限
→ 拦截改请求要用 declarativeNetRequest 声明式规则
→ 需要动态决策的场景变难,得靠规则 + 权限
不能远程加载代码
→ 所有逻辑随包发布
→ 热更新基本没了,改逻辑必须发新版本
面试怎么答MV3 把常驻后台页改成 Service Worker,它随时会被终止,所以状态必须存在 chrome.storage 里,事件监听要注册在文件顶层才能在唤醒时接住事件。webRequest 被 declarativeNetRequest 替代,只能声明规则不能改请求体。同时禁止远程代码,所有逻辑必须随包发布。
2
manifest.json 的骨架★
几个字段决定权限和入口
manifest_version 必须是 3;background.service_worker 指后台;content_scripts 指注入脚本;permissions 和 host_permissions 是两套权限,别混。- permissions 和 host_permissions 是分开的:前者是 API 权限(storage、tabs、scripting),后者是域名权限。MV3 把它们拆开,是为了让用户更清楚你在要什么。
- run_at 影响时机:
document_start最早(DOM 还没建)、document_endDOM 就绪、document_idle默认(浏览器自己决定)。 - action 取代了 browser_action:MV3 统一叫
action,同时用于工具栏按钮和弹窗。
面试怎么答manifest_version 必须是 3,background 用 service_worker 指定,content_scripts 声明注入规则和 run_at 时机,action 定义工具栏按钮和弹窗。权限分成 permissions(API 权限)和 host_permissions(域名权限)两类,要按最小权限原则申请。
3
三个运行环境,各自的隔离★
同一个扩展,三套上下文
Service Worker 管事件和全局逻辑,没有 DOM;content script 跑在页面里但被隔离;popup / options 是普通网页,有自己的 DOM 和生命周期。三者之间靠消息通信。
Service Worker事件驱动,无 DOM,随时休眠
→
Content Script在页面里,但 JS 环境隔离
→
Popup / Options普通页面,关掉就销毁
各自的限制
- SW 里没有
document、没有window - SW 里
XMLHttpRequest不可用,只能用 fetch - popup 一关就销毁,不能指望它存状态
- content script 拿不到页面的 JS 变量
各自能做什么
- SW 里发跨域请求(有 host 权限时不受 CORS 限制)
- content script 直接改页面 DOM
- popup 里展示 UI 和交互
- 三者都能用 chrome.storage 共享数据
- 跨域请求应该放在 SW 里发:content script 受页面的 CORS 限制,而 SW 在扩展源下、有 host 权限时可以直接请求。这是很多架构问题的根源。
- popup 关掉就没了:所以它不能承担"记住用户选了什么"的职责,那些要写进 storage。
- 三个上下文是三个进程/线程:内存不共享,只能通过消息和 storage 交换数据。
面试怎么答Service Worker 负责事件和全局逻辑但没有 DOM、随时休眠;content script 注入页面但 JS 环境隔离、拿不到页面变量;popup 和 options 是普通网页但关闭即销毁。跨域请求应该放在 Service Worker 里发,因为 content script 受页面 CORS 限制。三者之间靠消息通信和 storage 共享数据。
4
content script 的隔离世界★
为什么拿不到页面的变量
content script 和页面共享 DOM,但不共享 JS 执行环境。所以你能改页面的元素,却读不到页面定义的
window.foo。页面拿不到扩展的 API,反过来也一样content script 的世界里:
✅ 有 chrome.runtime、chrome.storage
❌ 没有页面的 window 变量
主世界(页面自己的 JS):
✅ 有页面的一切
❌ 没有 chrome.* API
<em>所以两边的通信要靠 window.postMessage 中转。</em>
- 隔离是为了安全和稳定性:否则扩展和页面会互相污染原型链,一个页面的 polyfill 可能把你的代码搞坏。
- CSS 也是隔离的:content script 注入的 CSS 会影响页面,所以类名要加前缀,避免撞车。
- 需要在主世界执行有新办法:
chrome.scripting.executeScript支持指定world: 'MAIN',不用再手动插 script 标签。
面试怎么答content script 与页面共享 DOM 但 JS 环境隔离,所以能改元素但读不到页面的全局变量,这样做是为了避免互相污染。需要访问页面变量时可以用 scripting.executeScript 指定 world 为 MAIN,或者注入脚本并通过 window.postMessage 中转通信。
5
消息通信的四种方式★
选错会很难受
最常用的是
runtime.sendMessage(发给后台/其他扩展页)和 tabs.sendMessage(发给指定标签页的 content script)。需要持续双向通信就用 connect 长连接。四种方式的选择runtime.sendMessage 扩展内部,一次性
tabs.sendMessage 发给某个标签页,一次性
runtime.connect 长连接,双向持续(适合流式数据)
window.postMessage 主世界 ↔ content script 之间
- 异步回复必须 return true:否则消息通道立刻关闭,sendResponse 调用无效。这是最常见的坑。
- onMessage 必须注册在顶层:写进 async 函数或回调里,Service Worker 唤醒时就接不到事件了。
- 发消息时接收方不在会抛错:content script 还没注入、标签页刚刷新都会导致 "Could not establish connection"。要 try/catch 并重试。
- 长连接适合推送场景:用
port.onMessage和port.postMessage,页面关闭时端口断开,要监听onDisconnect。
面试怎么答扩展内部用 runtime.sendMessage,发给指定标签页用 tabs.sendMessage,持续双向通信用 runtime.connect 长连接,主世界和 content script 之间用 window.postMessage。异步回复必须 return true,消息监听必须注册在文件顶层,接收方不在时要处理连接失败。
6
Service Worker 的生命周期★
30 秒没事件就会被杀
MV3 的 Service Worker 空闲约 30 秒就会被终止,有事件时再唤醒重新执行脚本。所以全局变量、定时器、内存中的状态全都不可靠。
◐ 可交互:发消息、等它被回收,看内存里还剩什么
active
剩余 12.0s(真实 30s,这里压缩演示)
内存里的计数0
chrome.storage 里的值0
点「发一条消息」,它会唤醒 Service Worker。
install装好,可预置默认数据
→
activate接管,清理旧数据
→
idle 30s被终止,内存清空
→
事件到来重新执行整个脚本再处理
会丢的东西
- 全局变量(重新执行后回到初始值)
- setTimeout / setInterval 的计时器
- 未完成的请求(会被中断)
- 内存里累积的缓存
可靠的东西
- chrome.storage 里的数据
- 事件监听(因为注册在顶层)
- chrome.alarms(专为长周期设计)
- IndexedDB(也可以,但要注意时序)
- "每次唤醒重新执行脚本"这点最关键:所以顶层代码要幂等,不能有副作用(比如每次执行都注册一次监听导致重复)。
- 不要在 SW 里做长连接:WebSocket 在休眠时会断。需要持续连接得考虑由 content script 或原生应用承担。
- 调试时可以手动唤醒:扩展管理页的 Service Worker 链接点进去,DevTools 打开后会保持活跃,容易造成"我这儿不会休眠"的错觉。
面试怎么答MV3 的 Service Worker 空闲约 30 秒会被终止,有事件时重新执行整个脚本。所以状态必须放在 chrome.storage,定时任务要用 chrome.alarms 而不是 setInterval,顶层代码必须幂等避免重复注册监听。调试时 DevTools 打开会保持活跃,不要因此误判。
7
权限模型与最小权限★
权限越多,审核越难,用户越不敢装
activeTab 是个好东西:用户点击扩展时临时获得当前页权限,不用在安装时吓人。<all_urls> 则会让审核和用户都警惕。权限的三个层次activeTab 用户点了才生效,临时授权当前页
← 首选,能覆盖大多数场景
optional_permissions 运行时再申请,用户同意才生效
← 高级功能用这个
host_permissions 指定域名,安装时就提示
<all_urls> 所有网站 ← 审核最严,尽量别用
- 权限会显示在安装提示里:申请"读取您在所有网站上的数据"会显著降低安装率,能用 activeTab 就别用 all_urls。
- 申请了但没用到会被审核质疑:商店审核会检查权限与功能的对应关系,多余的权限是常见的拒绝原因。
- 权限变更需要用户重新确认:新版本加权限,扩展会被自动禁用直到用户同意。所以权限要一次想清楚。
面试怎么答权限要按最小必要原则申请。activeTab 在用户主动点击时临时授权当前页,能覆盖多数场景且安装提示更友好。可选权限可以在运行时申请。all_urls 这类宽泛权限会显著影响安装率和审核通过率,多余的权限是常见拒绝原因。
8
脚本注入:声明式与编程式★
什么时候该用 scripting
在 manifest 里写 content_scripts 是声明式,页面一匹配就自动注入;用 chrome.scripting.executeScript 是编程式,可以按用户操作、按条件动态注入。
- executeScript 要 scripting 权限 + 目标页的 host 权限(或 activeTab)。少一个就静默失败或报错。
- func 里的代码是被序列化后注入的:不能闭包外部变量,需要的数据只能通过 args 传。
- 声明式适合"这个站点的功能",编程式适合"用户主动触发的动作"。混用很常见:声明式做基础注入,编程式做补充。
面试怎么答声明式注入写在 manifest 的 content_scripts 里,页面匹配就自动执行;编程式用 chrome.scripting.executeScript 在需要时注入,可以注入文件或函数,还能指定在 MAIN world 执行以访问页面变量。函数注入时不能闭包外部变量,数据要通过 args 传。
9
declarativeNetRequest 替代 webRequest★
广告拦截类扩展的核心变化
MV3 不允许扩展在请求过程中"看一眼再决定"。只能事先声明规则,由浏览器自己执行。这换来了隐私(扩展看不到你的流量),代价是灵活性。
webRequest 能做但 DNR 不能
- 根据请求内容动态决定拦不拦
- 修改请求体
- 读到完整的请求和响应内容
- 任意复杂的判断逻辑
DNR 能做的
- 按 URL、资源类型、域名拦截
- 重定向、修改请求头
- 动态增删规则(有数量上限)
- 分静态规则和动态规则两套
- 规则有数量上限:静态规则集和动态规则各有配额,复杂拦截场景要仔细规划,不能无限制生成。
- 规则按优先级匹配:
priority相同时,更具体的条件优先。写错容易导致"该拦的没拦"。 - 调试要看规则命中:
chrome://extensions里有 "Errors" 和规则查看入口,能看出哪条规则作用于哪个请求。
面试怎么答MV3 用 declarativeNetRequest 取代 webRequest,只能事先声明规则由浏览器执行,不能动态读取请求内容,换来的是扩展看不到用户流量。规则按优先级匹配、有数量上限,支持拦截、重定向和修改请求头。
10
storage:唯一可靠的持久层★
三块存储,用途不同
chrome.storage.local 存本地数据(默认 10MB,可申请更大),sync 跨设备同步但只有 100KB 左右,session 在浏览器会话内有效。三块存储怎么选local 10MB(可 unlimitedStorage) 设备本地,存缓存和大量数据
sync 约 100KB,单项 8KB 跨设备同步,只适合设置类小数据
session 会话期,浏览器关闭清空 临时状态,替代以前的内存变量
<em>以前放全局变量的东西,现在都放 session。</em>
- sync 有严格配额:单条 8KB、总量 100KB 左右,超了直接报错。别往里塞列表数据。
- onChanged 监听三个上下文都能用:所以"popup 改了设置,content script 自动响应"不需要额外发消息。
- storage 是异步的:不要和同步读取混用,早期版本还有过内存缓存行为差异,现在统一按异步处理。
面试怎么答chrome.storage 分 local、sync、session 三块。local 容量大适合本地缓存,sync 只有约 100KB 适合跨设备同步设置,session 在浏览器会话内有效,正好替代以前的内存全局变量。onChanged 事件在三个上下文都能监听,可以用来替代部分消息通信。
11
扩展的五个 UI 入口
别把功能全塞进 popup
popup 只是最显眼的一个。右键菜单适合页面上下文操作,sidePanel 适合需要常驻的场景,commands 是快捷键,omnibox 把地址栏变成输入框。
按场景选入口popup 轻量操作,点开就关,一关就销毁
contextMenus 页面右键,适合"对选中文本做什么"
sidePanel 需要和页面并行查看,常驻不销毁 ← MV3 新增
commands 高频操作,键盘快捷键
omnibox 关键词触发,适合搜索类
options 配置页,从扩展详情进入
- popup 关掉就销毁:所以别在里面做长时间任务,也别指望它保存状态。需要持续的用 sidePanel 或独立标签页。
- 侧边栏是 MV3 的重点新增:解决了"想一边看网页一边看扩展"这个长期诉求,适合翻译、笔记、AI 助手类。
- 快捷键会和浏览器冲突:manifest 里声明的快捷键如果被占用,用户可以在
chrome://extensions/shortcuts里改,你的代码不能假设一定生效。
面试怎么答扩展有 popup、右键菜单、侧边栏、快捷键和 omnibox 等多个 UI 入口,要按场景选择。popup 关闭即销毁不能承担长任务,需要常驻用 sidePanel,页面上下文操作用 contextMenus,高频操作配 commands 快捷键。
12
打包与上架审核
最常见的拒绝原因就那几个
上架审核主要看三件事:权限是否与功能匹配、功能是否描述清楚、有没有违规行为(远程代码、误导性描述、采集数据不声明)。
提交前的清单zip 打包(不要包含 node_modules 和源码,除非必要)
zip -r ext.zip . -x "node_modules/*" -x "*.map"
必备材料
图标 128×128
截图(至少一张,显示真实界面)
隐私说明:采集什么数据、怎么用、存哪
权限用途说明:每个敏感权限都要解释
常见拒绝原因
❌ 申请了用不到的权限
❌ 描述里堆关键词,功能说不清
❌ 远程加载代码(CDN 上的 JS 也算)
❌ 单一用途原则:一个扩展做太多不相关的事
❌ 隐私政策缺失或不一致
- "单一用途"是硬规则:一个扩展应该只解决一个问题。功能堆太多会被要求拆分——这也是很多工具类扩展被拒的原因。
- 权限说明要具体到"为什么":写"用于提升体验"不行,要写"读取当前页面 URL 以显示对应书签"。
- 审核周期不稳定:几天到几周都可能。预留时间,别指望上线前一天提交。
- 更新也要审核:所以别把关键逻辑设计成"必须频繁改"。这也呼应了 MV3 禁止远程代码带来的限制。
面试怎么答上架要按规范打包并准备图标、截图、隐私说明和每个敏感权限的用途解释。最常见的拒绝原因是申请多余权限、描述不清、远程加载代码、违反单一用途原则和隐私政策缺失。审核周期不稳定,更新也需要审核,要预留时间。
13
调试的几个实用技巧
很多坑是工具问题不是代码问题
四个地方要看:页面(content script 的日志)、扩展的 Service Worker(后台日志)、popup(自己的 DevTools)、chrome://extensions 的错误面板。
日志分别在哪看content script 在<b>页面</b>的 DevTools Console 里
(但要在 Console 的 context 下拉里选扩展的上下文)
Service Worker chrome://extensions → 点 "Service Worker" 链接
会打开独立的 DevTools
popup 右键 popup → 检查
manifest / 权限 chrome://extensions → Errors 按钮
- "改了代码没生效"通常是这三个原因:忘记重新加载扩展、content script 没重新注入(要刷新页面)、SW 没重启(点一下 Service Worker 链接里的 terminate)。
- SW 的 Console 会随着休眠断开:DevTools 关掉再开可能连不上,这是正常的,不是代码问题。
- 打包后的代码行号对不上:开发时开 sourcemap,但发布包通常不带,所以线上报错要靠版本对应关系排查。
面试怎么答content script 的日志在页面 DevTools 里、Service Worker 的日志要点扩展管理页里的链接单独打开、popup 右键检查、权限和 manifest 错误在 chrome://extensions 的 Errors 里看。改代码没生效通常是没重新加载扩展或没刷新页面。
14
速答清单
考前十分钟只看这页
01MV3 相比 MV2 最大的变化?
后台从常驻页面换成 Service Worker,会随时休眠,状态必须存 storage。
02Service Worker 什么时候被杀?
空闲约 30 秒就被终止,有事件时重新执行整个脚本,所以顶层代码要幂等。
03content script 为什么拿不到页面变量?
它和页面共享 DOM 但 JS 环境隔离。要访问得用 scripting 注入到 MAIN world。
04跨域请求该在哪发?
Service Worker 里。content script 受页面 CORS 限制,SW 有 host 权限时可跨域。
05异步回复消息要注意什么?
onMessage 里必须 return true,否则通道立刻关闭。监听必须注册在顶层。06定时任务用什么?
chrome.alarms,不能用 setInterval,因为 SW 会被终止。
07activeTab 有什么好处?
用户点击时才临时授权当前页,安装提示更友好,能覆盖多数场景。
08declarativeNetRequest 限制在哪?
只能声明规则由浏览器执行,不能动态读请求内容,也不能改请求体。
09三块 storage 怎么选?
local 存大量数据,sync 跨设备但只有约 100KB,session 存会话期临时状态。
10常见的审核拒绝原因?
多余权限、描述不清、远程加载代码、违反单一用途原则、隐私政策缺失。