
Chrome 扩展 MV3 终极指南:破解 Background Service Worker 的生命周期与 Token 管理困局
从 CORS 隔离到 WebSocket 保活,一份写给前端架构师的实战笔记
引言:当"前端"遇见"扩展"
你是一个熟悉 JavaScript 与 Web 前端开发的老手,但第一次接触 Chrome 扩展(Extension)时,你可能会被一个看似简单的架构决策卡住:我的 API Token 该放在哪里?
更让人头疼的是,你刚学会 Manifest V3 中"后台脚本"变成了"Service Worker",就听说 Chrome 会在闲置 30 秒后把它杀掉。那我的 WebSocket 长连接怎么办?我的 Token 会不会丢?
本文将从 MV3 的实际约束出发,逐步推导出唯一可行的架构 ,并深入剖析 Background Service Worker 的生命周期管理,最终给出企业级的长连接保活方案。所有图示均使用 Mermaid 绘制,可直接复现。
一、先认识三个核心角色(类比网页开发)
| 角色 | 运行环境 | 可访问 DOM | 可绕过 CORS | 类比 |
|---|---|---|---|---|
| Content Script | 宿主网页(如 web.whatsapp.com) |
✅ | ❌ | 油猴脚本 |
| Background Service Worker | 扩展独立后台 | ❌ | ✅(通过 host_permissions) |
微型 BFF |
| Popup / Options | 独立页面 | ✅(自身页面) | ❌ | 普通网页 |
关键记忆:Content Script 住在别人家里,Background SW 住在扩展自己的房间里。
二、MV3 的 CORS 铁律:为什么你绕不开 Background
2.1 常见的误解
很多新手会写这样的代码:
javascript
// content_script.js
const token = await chrome.storage.local.get('token');
fetch('https://api.crm.com/user', {
headers: { Authorization: `Bearer ${token}` }
});
然后在控制台看到一个熟悉的报错:
Access to fetch at 'https://api.crm.com/user' from origin 'https://web.whatsapp.com' has been blocked by CORS policy
2.2 背后的原理
Content Script 的 fetch() 遵循宿主页面的源(Origin) 。当它请求一个跨域 API 时,浏览器会发送 CORS 预检,并要求目标服务器返回允许该宿主域的 Access-Control-Allow-Origin 头。绝大多数情况下,你无法控制 CRM 后端去允许所有可能的宿主页面(如 web.whatsapp.com、mail.google.com 等)。
而 Background Service Worker 不同 :它在扩展自己的上下文(chrome-extension://<extension-id>)中运行,并且你在 manifest.json 中声明了 host_permissions:
json
"host_permissions": ["https://api.crm.com/*"]
此时 Background 发起的请求 完全绕过 CORS 检查------浏览器信任扩展,如同信任本地系统服务。
#mermaid-svg-aY3cbgF2SujtfG3h{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-aY3cbgF2SujtfG3h .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-aY3cbgF2SujtfG3h .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-aY3cbgF2SujtfG3h .error-icon{fill:#552222;}#mermaid-svg-aY3cbgF2SujtfG3h .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-aY3cbgF2SujtfG3h .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-aY3cbgF2SujtfG3h .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-aY3cbgF2SujtfG3h .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-aY3cbgF2SujtfG3h .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-aY3cbgF2SujtfG3h .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-aY3cbgF2SujtfG3h .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-aY3cbgF2SujtfG3h .marker{fill:#333333;stroke:#333333;}#mermaid-svg-aY3cbgF2SujtfG3h .marker.cross{stroke:#333333;}#mermaid-svg-aY3cbgF2SujtfG3h svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-aY3cbgF2SujtfG3h p{margin:0;}#mermaid-svg-aY3cbgF2SujtfG3h .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-aY3cbgF2SujtfG3h .cluster-label text{fill:#333;}#mermaid-svg-aY3cbgF2SujtfG3h .cluster-label span{color:#333;}#mermaid-svg-aY3cbgF2SujtfG3h .cluster-label span p{background-color:transparent;}#mermaid-svg-aY3cbgF2SujtfG3h .label text,#mermaid-svg-aY3cbgF2SujtfG3h span{fill:#333;color:#333;}#mermaid-svg-aY3cbgF2SujtfG3h .node rect,#mermaid-svg-aY3cbgF2SujtfG3h .node circle,#mermaid-svg-aY3cbgF2SujtfG3h .node ellipse,#mermaid-svg-aY3cbgF2SujtfG3h .node polygon,#mermaid-svg-aY3cbgF2SujtfG3h .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-aY3cbgF2SujtfG3h .rough-node .label text,#mermaid-svg-aY3cbgF2SujtfG3h .node .label text,#mermaid-svg-aY3cbgF2SujtfG3h .image-shape .label,#mermaid-svg-aY3cbgF2SujtfG3h .icon-shape .label{text-anchor:middle;}#mermaid-svg-aY3cbgF2SujtfG3h .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-aY3cbgF2SujtfG3h .rough-node .label,#mermaid-svg-aY3cbgF2SujtfG3h .node .label,#mermaid-svg-aY3cbgF2SujtfG3h .image-shape .label,#mermaid-svg-aY3cbgF2SujtfG3h .icon-shape .label{text-align:center;}#mermaid-svg-aY3cbgF2SujtfG3h .node.clickable{cursor:pointer;}#mermaid-svg-aY3cbgF2SujtfG3h .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-aY3cbgF2SujtfG3h .arrowheadPath{fill:#333333;}#mermaid-svg-aY3cbgF2SujtfG3h .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-aY3cbgF2SujtfG3h .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-aY3cbgF2SujtfG3h .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-aY3cbgF2SujtfG3h .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-aY3cbgF2SujtfG3h .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-aY3cbgF2SujtfG3h .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-aY3cbgF2SujtfG3h .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-aY3cbgF2SujtfG3h .cluster text{fill:#333;}#mermaid-svg-aY3cbgF2SujtfG3h .cluster span{color:#333;}#mermaid-svg-aY3cbgF2SujtfG3h div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-aY3cbgF2SujtfG3h .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-aY3cbgF2SujtfG3h rect.text{fill:none;stroke-width:0;}#mermaid-svg-aY3cbgF2SujtfG3h .icon-shape,#mermaid-svg-aY3cbgF2SujtfG3h .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-aY3cbgF2SujtfG3h .icon-shape p,#mermaid-svg-aY3cbgF2SujtfG3h .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-aY3cbgF2SujtfG3h .icon-shape .label rect,#mermaid-svg-aY3cbgF2SujtfG3h .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-aY3cbgF2SujtfG3h .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-aY3cbgF2SujtfG3h .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-aY3cbgF2SujtfG3h :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 扩展
宿主页面
受 CORS 限制
也受 CORS 限制
拥有 host_permissions, 绕过 CORS
消息通信
网页 JS
CRM API
Content Script
Background SW
结论 :无论 Token 放在哪里,所有 CRM API 调用都必须经由 Background 代理------这不是一个选项,而是一个强制约束。
三、Token 管理三种方案对比(附架构图)
方案 A:Token 隔离在 Background(推荐)
CRM API chrome.storage Background SW Content Script CRM API chrome.storage Background SW Content Script #mermaid-svg-IrZAATaW6vVcuQzb{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-IrZAATaW6vVcuQzb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-IrZAATaW6vVcuQzb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-IrZAATaW6vVcuQzb .error-icon{fill:#552222;}#mermaid-svg-IrZAATaW6vVcuQzb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-IrZAATaW6vVcuQzb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-IrZAATaW6vVcuQzb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-IrZAATaW6vVcuQzb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-IrZAATaW6vVcuQzb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-IrZAATaW6vVcuQzb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-IrZAATaW6vVcuQzb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-IrZAATaW6vVcuQzb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-IrZAATaW6vVcuQzb .marker.cross{stroke:#333333;}#mermaid-svg-IrZAATaW6vVcuQzb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-IrZAATaW6vVcuQzb p{margin:0;}#mermaid-svg-IrZAATaW6vVcuQzb .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-IrZAATaW6vVcuQzb text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-IrZAATaW6vVcuQzb .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-IrZAATaW6vVcuQzb .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-IrZAATaW6vVcuQzb .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-IrZAATaW6vVcuQzb .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-IrZAATaW6vVcuQzb #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-IrZAATaW6vVcuQzb .sequenceNumber{fill:white;}#mermaid-svg-IrZAATaW6vVcuQzb #sequencenumber{fill:#333;}#mermaid-svg-IrZAATaW6vVcuQzb #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-IrZAATaW6vVcuQzb .messageText{fill:#333;stroke:none;}#mermaid-svg-IrZAATaW6vVcuQzb .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-IrZAATaW6vVcuQzb .labelText,#mermaid-svg-IrZAATaW6vVcuQzb .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-IrZAATaW6vVcuQzb .loopText,#mermaid-svg-IrZAATaW6vVcuQzb .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-IrZAATaW6vVcuQzb .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-IrZAATaW6vVcuQzb .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-IrZAATaW6vVcuQzb .noteText,#mermaid-svg-IrZAATaW6vVcuQzb .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-IrZAATaW6vVcuQzb .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-IrZAATaW6vVcuQzb .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-IrZAATaW6vVcuQzb .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-IrZAATaW6vVcuQzb .actorPopupMenu{position:absolute;}#mermaid-svg-IrZAATaW6vVcuQzb .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-IrZAATaW6vVcuQzb .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-IrZAATaW6vVcuQzb .actor-man circle,#mermaid-svg-IrZAATaW6vVcuQzb line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-IrZAATaW6vVcuQzb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 启动时 init() 读取 token 返回 token 存入内存变量 sendMessage(PROXY_REQUEST) 从内存取 token fetch(url, token) 响应数据 sendResponse(data)
- Token 持久化 :存在
chrome.storage(跨上下文、可同步) - 内存缓存:SW 存活时直接使用内存变量
- 冷启动恢复:SW 被终止后重新启动时,从 storage 重新读取
方案 B:Token 在 Content Script 直接使用(不可行)
- ❌ CORS 拦截(见第二节)
- ❌ 即使忽略 CORS,Token 会在消息中明文传递(理论上可被其他扩展监听)
方案 C:混合方案(Token 每次由 CS 传给 BG)
chrome.storage Background SW Content Script chrome.storage Background SW Content Script #mermaid-svg-xxeUKLxh4RwTV2rl{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-xxeUKLxh4RwTV2rl .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-xxeUKLxh4RwTV2rl .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-xxeUKLxh4RwTV2rl .error-icon{fill:#552222;}#mermaid-svg-xxeUKLxh4RwTV2rl .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-xxeUKLxh4RwTV2rl .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-xxeUKLxh4RwTV2rl .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-xxeUKLxh4RwTV2rl .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-xxeUKLxh4RwTV2rl .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-xxeUKLxh4RwTV2rl .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-xxeUKLxh4RwTV2rl .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-xxeUKLxh4RwTV2rl .marker{fill:#333333;stroke:#333333;}#mermaid-svg-xxeUKLxh4RwTV2rl .marker.cross{stroke:#333333;}#mermaid-svg-xxeUKLxh4RwTV2rl svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-xxeUKLxh4RwTV2rl p{margin:0;}#mermaid-svg-xxeUKLxh4RwTV2rl .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-xxeUKLxh4RwTV2rl text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-xxeUKLxh4RwTV2rl .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-xxeUKLxh4RwTV2rl .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-xxeUKLxh4RwTV2rl .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-xxeUKLxh4RwTV2rl .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-xxeUKLxh4RwTV2rl #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-xxeUKLxh4RwTV2rl .sequenceNumber{fill:white;}#mermaid-svg-xxeUKLxh4RwTV2rl #sequencenumber{fill:#333;}#mermaid-svg-xxeUKLxh4RwTV2rl #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-xxeUKLxh4RwTV2rl .messageText{fill:#333;stroke:none;}#mermaid-svg-xxeUKLxh4RwTV2rl .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-xxeUKLxh4RwTV2rl .labelText,#mermaid-svg-xxeUKLxh4RwTV2rl .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-xxeUKLxh4RwTV2rl .loopText,#mermaid-svg-xxeUKLxh4RwTV2rl .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-xxeUKLxh4RwTV2rl .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-xxeUKLxh4RwTV2rl .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-xxeUKLxh4RwTV2rl .noteText,#mermaid-svg-xxeUKLxh4RwTV2rl .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-xxeUKLxh4RwTV2rl .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-xxeUKLxh4RwTV2rl .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-xxeUKLxh4RwTV2rl .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-xxeUKLxh4RwTV2rl .actorPopupMenu{position:absolute;}#mermaid-svg-xxeUKLxh4RwTV2rl .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-xxeUKLxh4RwTV2rl .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-xxeUKLxh4RwTV2rl .actor-man circle,#mermaid-svg-xxeUKLxh4RwTV2rl line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-xxeUKLxh4RwTV2rl :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 读取 token token sendMessage(token, url) 透传请求
- 可行但啰嗦:每次请求都要从 storage 异步读取,增加延迟
- Token 在消息中暴露(安全性略低于方案 A)
安全性收益的理性评估
| 威胁模型 | 方案 A 是否更安全 | 说明 |
|---|---|---|
| 宿主页面 JS 窃取 token | 不相关 | Content Script 与页面 JS 运行在隔离世界,页面无法访问 CS 的变量 |
| 其他扩展上下文(popup)读取 storage | ❌ 无效 | 无论 token 在哪,只要存 storage,其他扩展页面都能读取 |
| Content Script 被 XSS 注入 | ✅ 有效 | 攻击者只能拿到 CS 内存,无法触及 Background 中的 token |
结论 :安全性只是额外收益,不是主要原因。真正的原因是 CORS 强制代理 + 架构简洁性。
四、Background Service Worker 生命周期与"30秒魔咒"
4.1 什么是"闲置"?
Chrome 为了节省资源,会在 Service Worker 没有任何待处理事件 约 30 秒后将其终止。所谓"待处理事件"包括:
chrome.runtime.onMessage监听器fetch事件(如果注册了)chrome.alarms.onAlarm- WebSocket 消息收发
chrome.storageAPI 调用- 已建立的
runtime.Port连接
一旦所有事件处理完毕,计时器开始倒计时。30 秒后,Service Worker 被杀死,内存中的变量全部消失。
#mermaid-svg-Vj481PoHI7xBEPB7{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Vj481PoHI7xBEPB7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Vj481PoHI7xBEPB7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Vj481PoHI7xBEPB7 .error-icon{fill:#552222;}#mermaid-svg-Vj481PoHI7xBEPB7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Vj481PoHI7xBEPB7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Vj481PoHI7xBEPB7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Vj481PoHI7xBEPB7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Vj481PoHI7xBEPB7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Vj481PoHI7xBEPB7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Vj481PoHI7xBEPB7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Vj481PoHI7xBEPB7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Vj481PoHI7xBEPB7 .marker.cross{stroke:#333333;}#mermaid-svg-Vj481PoHI7xBEPB7 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Vj481PoHI7xBEPB7 p{margin:0;}#mermaid-svg-Vj481PoHI7xBEPB7 defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-Vj481PoHI7xBEPB7 g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-Vj481PoHI7xBEPB7 g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-Vj481PoHI7xBEPB7 g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-Vj481PoHI7xBEPB7 g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-Vj481PoHI7xBEPB7 g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-Vj481PoHI7xBEPB7 .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-Vj481PoHI7xBEPB7 .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-Vj481PoHI7xBEPB7 .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-Vj481PoHI7xBEPB7 .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-Vj481PoHI7xBEPB7 .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-Vj481PoHI7xBEPB7 .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-Vj481PoHI7xBEPB7 .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-Vj481PoHI7xBEPB7 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Vj481PoHI7xBEPB7 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Vj481PoHI7xBEPB7 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Vj481PoHI7xBEPB7 .edgeLabel .label text{fill:#333;}#mermaid-svg-Vj481PoHI7xBEPB7 .label div .edgeLabel{color:#333;}#mermaid-svg-Vj481PoHI7xBEPB7 .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-Vj481PoHI7xBEPB7 .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-Vj481PoHI7xBEPB7 .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-Vj481PoHI7xBEPB7 .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-Vj481PoHI7xBEPB7 .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-Vj481PoHI7xBEPB7 .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Vj481PoHI7xBEPB7 .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Vj481PoHI7xBEPB7 #statediagram-barbEnd{fill:#333333;}#mermaid-svg-Vj481PoHI7xBEPB7 .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Vj481PoHI7xBEPB7 .cluster-label,#mermaid-svg-Vj481PoHI7xBEPB7 .nodeLabel{color:#131300;}#mermaid-svg-Vj481PoHI7xBEPB7 .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-Vj481PoHI7xBEPB7 .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-Vj481PoHI7xBEPB7 .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-Vj481PoHI7xBEPB7 .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-Vj481PoHI7xBEPB7 .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-Vj481PoHI7xBEPB7 .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-Vj481PoHI7xBEPB7 .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-Vj481PoHI7xBEPB7 .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-Vj481PoHI7xBEPB7 .note-edge{stroke-dasharray:5;}#mermaid-svg-Vj481PoHI7xBEPB7 .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-Vj481PoHI7xBEPB7 .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-Vj481PoHI7xBEPB7 .statediagram-note text{fill:black;}#mermaid-svg-Vj481PoHI7xBEPB7 .statediagram-note .nodeLabel{color:black;}#mermaid-svg-Vj481PoHI7xBEPB7 .statediagram .edgeLabel{color:red;}#mermaid-svg-Vj481PoHI7xBEPB7 #dependencyStart,#mermaid-svg-Vj481PoHI7xBEPB7 #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-Vj481PoHI7xBEPB7 .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Vj481PoHI7xBEPB7 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 扩展启动 / 唤醒
所有事件处理完成,启动30秒计时器
收到新事件(消息/定时器/WebSocket)
30秒内无任何事件
下次需要时重新启动(冷启动)
运行中
空闲
终止
4.2 对你设计的影响
- 不能依赖内存持久化 :
let token = 'xxx'会丢失 - 必须将关键状态写入
chrome.storage - 长连接(WebSocket)要主动"心跳"以重置计时器
五、如何优雅地避免闲置?三种正确姿势
5.1 方案一:WebSocket 长连接 + 心跳(推荐)
从 Chrome 116 开始,WebSocket 的任何活动(发送、接收、甚至心跳 ping/pong)都会重置 30 秒闲置计时器。因此,如果你需要实时推送,WebSocket 本身就是完美的"保活"机制。
最佳实践:建立心跳,每 20~25 秒发送一次空消息(或业务 ping)。
javascript
// background.js
let ws = null;
let heartbeatInterval = null;
function connectWebSocket() {
ws = new WebSocket('wss://your.push.server');
ws.onopen = () => {
console.log('WebSocket connected');
// 启动心跳:每 20 秒发送一次 ping
heartbeatInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 20000);
};
ws.onmessage = (event) => {
// 收到消息也会重置计时器,无需额外动作
handleMessage(event.data);
};
ws.onclose = () => {
clearInterval(heartbeatInterval);
// 指数退避重连
scheduleReconnect();
};
}
let reconnectAttempts = 0;
function scheduleReconnect() {
const delay = Math.min(30000, Math.pow(2, reconnectAttempts) * 1000);
setTimeout(() => {
reconnectAttempts++;
connectWebSocket();
}, delay);
}
5.2 方案二:chrome.alarms 周期性任务
如果你不需要实时推送,只是想定期执行后台任务(如同步数据、清理缓存),不要用 setInterval (会被冻结),请使用 chrome.alarms。
javascript
// 创建一个每 20 秒触发一次的闹钟
chrome.alarms.create('keepAlive', { periodInMinutes: 0.333 });
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'keepAlive') {
console.log('Doing periodic task...');
// 你的业务逻辑
}
});
alarms.onAlarm 事件本身会唤醒 Service Worker 并重置闲置计时器。
5.3 方案三:利用 chrome.storage API 做"轻量心跳"
javascript
// 同样配合 chrome.alarms
chrome.alarms.create('storageKeepAlive', { periodInMinutes: 0.3 });
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === 'storageKeepAlive') {
await chrome.storage.local.get('dummy'); // 仅此一句即可重置计时器
}
});
5.4 什么做法是 无效 或 有害 的?
| 做法 | 结果 | 原因 |
|---|---|---|
在 SW 中写 setInterval(() => {}, 1000) |
❌ 无效 | SW 闲置后定时器被冻结,不产生事件 |
后台一直 while(true) 循环 |
❌ 会被杀 | 单任务运行超过 5 分钟强制终止 |
| 仅建立 WebSocket 但不发送任何数据 | ⚠️ 不可靠 | 若网络静默,仍可能被判定闲置(取决于 Chrome 版本) |
使用 offscreen 文档保活 |
✅ 有效但重 | 资源消耗更大,仅当其他方案无法满足时使用 |
六、WebSocket 在 Background SW 中的两个"天坑"及解法
坑一:假死状态
现象 :设备休眠后唤醒,ws.readyState === WebSocket.OPEN 但实际收不到任何消息,也没有 onclose 触发。
原因:TCP 连接已被网络中间设备切断,但 WebSocket 对象未检测到。
解法 :业务层实现主动健康检查。
javascript
let lastMessageTime = Date.now();
ws.onmessage = (event) => {
lastMessageTime = Date.now();
// 处理消息
};
// 每隔 30 秒检查一次
setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN && (Date.now() - lastMessageTime > 45000)) {
console.warn('No message for 45s, reconnect');
ws.close();
// 触发重连逻辑
}
}, 30000);
坑二:重连风暴
现象:网络不稳定时,SW 反复被终止/启动,每次启动都立即尝试重连,导致服务器瞬间涌入大量连接请求。
解法 :指数退避 + 最大延迟上限。
javascript
let reconnectDelay = 1000; // 初始 1 秒
const MAX_DELAY = 30000; // 最大 30 秒
function reconnect() {
setTimeout(() => {
connectWebSocket();
reconnectDelay = Math.min(reconnectDelay * 2, MAX_DELAY);
}, reconnectDelay);
}
// 在成功连接后将 reconnectDelay 重置
ws.onopen = () => {
reconnectDelay = 1000;
};
注意:重连逻辑应在
onclose或onerror中触发,并且要清除旧的heartbeatInterval。
七、完整的架构落地清单
#mermaid-svg-WQwN9ecWVkWKA6f4{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-WQwN9ecWVkWKA6f4 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-WQwN9ecWVkWKA6f4 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-WQwN9ecWVkWKA6f4 .error-icon{fill:#552222;}#mermaid-svg-WQwN9ecWVkWKA6f4 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WQwN9ecWVkWKA6f4 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-WQwN9ecWVkWKA6f4 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WQwN9ecWVkWKA6f4 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WQwN9ecWVkWKA6f4 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-WQwN9ecWVkWKA6f4 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WQwN9ecWVkWKA6f4 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WQwN9ecWVkWKA6f4 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WQwN9ecWVkWKA6f4 .marker.cross{stroke:#333333;}#mermaid-svg-WQwN9ecWVkWKA6f4 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WQwN9ecWVkWKA6f4 p{margin:0;}#mermaid-svg-WQwN9ecWVkWKA6f4 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-WQwN9ecWVkWKA6f4 .cluster-label text{fill:#333;}#mermaid-svg-WQwN9ecWVkWKA6f4 .cluster-label span{color:#333;}#mermaid-svg-WQwN9ecWVkWKA6f4 .cluster-label span p{background-color:transparent;}#mermaid-svg-WQwN9ecWVkWKA6f4 .label text,#mermaid-svg-WQwN9ecWVkWKA6f4 span{fill:#333;color:#333;}#mermaid-svg-WQwN9ecWVkWKA6f4 .node rect,#mermaid-svg-WQwN9ecWVkWKA6f4 .node circle,#mermaid-svg-WQwN9ecWVkWKA6f4 .node ellipse,#mermaid-svg-WQwN9ecWVkWKA6f4 .node polygon,#mermaid-svg-WQwN9ecWVkWKA6f4 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-WQwN9ecWVkWKA6f4 .rough-node .label text,#mermaid-svg-WQwN9ecWVkWKA6f4 .node .label text,#mermaid-svg-WQwN9ecWVkWKA6f4 .image-shape .label,#mermaid-svg-WQwN9ecWVkWKA6f4 .icon-shape .label{text-anchor:middle;}#mermaid-svg-WQwN9ecWVkWKA6f4 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-WQwN9ecWVkWKA6f4 .rough-node .label,#mermaid-svg-WQwN9ecWVkWKA6f4 .node .label,#mermaid-svg-WQwN9ecWVkWKA6f4 .image-shape .label,#mermaid-svg-WQwN9ecWVkWKA6f4 .icon-shape .label{text-align:center;}#mermaid-svg-WQwN9ecWVkWKA6f4 .node.clickable{cursor:pointer;}#mermaid-svg-WQwN9ecWVkWKA6f4 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-WQwN9ecWVkWKA6f4 .arrowheadPath{fill:#333333;}#mermaid-svg-WQwN9ecWVkWKA6f4 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-WQwN9ecWVkWKA6f4 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-WQwN9ecWVkWKA6f4 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WQwN9ecWVkWKA6f4 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-WQwN9ecWVkWKA6f4 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WQwN9ecWVkWKA6f4 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-WQwN9ecWVkWKA6f4 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-WQwN9ecWVkWKA6f4 .cluster text{fill:#333;}#mermaid-svg-WQwN9ecWVkWKA6f4 .cluster span{color:#333;}#mermaid-svg-WQwN9ecWVkWKA6f4 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-WQwN9ecWVkWKA6f4 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-WQwN9ecWVkWKA6f4 rect.text{fill:none;stroke-width:0;}#mermaid-svg-WQwN9ecWVkWKA6f4 .icon-shape,#mermaid-svg-WQwN9ecWVkWKA6f4 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WQwN9ecWVkWKA6f4 .icon-shape p,#mermaid-svg-WQwN9ecWVkWKA6f4 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-WQwN9ecWVkWKA6f4 .icon-shape .label rect,#mermaid-svg-WQwN9ecWVkWKA6f4 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WQwN9ecWVkWKA6f4 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-WQwN9ecWVkWKA6f4 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-WQwN9ecWVkWKA6f4 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
否
是
用户安装扩展
Background SW 首次启动
从 chrome.storage 读取 token
token 存在?
存入内存变量
跳转 OAuth 授权
建立 WebSocket 长连接
启动心跳定时器
Content Script 发来代理请求
Background 用内存 token 请求 API
返回结果给 Content Script
30秒无任何事件?
SW 被终止
下次请求时重新执行 B
代码结构建议
javascript
// background.js
// ----- 状态持久化 -----
let cachedToken = null;
async function getToken() {
if (cachedToken) return cachedToken;
const result = await chrome.storage.local.get('token');
cachedToken = result.token || null;
return cachedToken;
}
async function setToken(token) {
await chrome.storage.local.set({ token });
cachedToken = token;
}
// ----- WebSocket 管理(含心跳 + 指数退避) -----
let ws = null;
let heartbeatTimer = null;
let reconnectDelay = 1000;
function startHeartbeat() {
if (heartbeatTimer) clearInterval(heartbeatTimer);
heartbeatTimer = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 20000);
}
function connectWS() {
ws = new WebSocket('wss://your.push.server');
ws.onopen = () => {
console.log('WS connected');
reconnectDelay = 1000; // 重置退避
startHeartbeat();
};
ws.onmessage = (e) => { /* 处理推送 */ };
ws.onclose = () => {
if (heartbeatTimer) clearInterval(heartbeatTimer);
setTimeout(connectWS, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
};
ws.onerror = (err) => {
console.error('WS error', err);
ws.close();
};
}
// ----- 代理 API 请求 -----
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.type === 'PROXY_API') {
(async () => {
const token = await getToken();
const res = await fetch(request.url, {
method: request.method,
headers: { Authorization: `Bearer ${token}`, ...request.headers },
body: request.body
});
const data = await res.json();
sendResponse({ success: true, data });
})();
return true; // 异步响应
}
});
// ----- 启动时恢复 WebSocket -----
(async () => {
await getToken(); // 预加载 token
connectWS();
})();
八、总结:一句话记住整个架构
MV3 中,CORS 强制要求所有 API 调用必须经过 Background SW 代理;SW 会被 30 秒闲置杀死,因此必须用 WebSocket + 心跳或 chrome.alarms 主动保活,并将 token 持久化到 chrome.storage。
你的前端背景足以让你快速上手 Chrome 扩展开发------只要记住 Content Script 是客,Background SW 是主 ,以及 Service Worker 不是常驻进程,而是事件驱动的短暂生命体。
希望这份指南能帮你避开我踩过的所有坑。如果你正在开发一个需要长连接 + 安全 Token 管理的扩展,可以直接复制本文的代码骨架。欢迎留言讨论你的具体场景。
延伸阅读: