Chrome 扩展 MV3 终极指南:破解 Background Service Worker 的生命周期与 Token 管理困局

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.commail.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.storage API 调用
  • 已建立的 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;
};

注意:重连逻辑应在 oncloseonerror 中触发,并且要清除旧的 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 管理的扩展,可以直接复制本文的代码骨架。欢迎留言讨论你的具体场景。


延伸阅读

相关推荐
ZC跨境爬虫4 小时前
跟着 MDN 学JavaScript day_10:数组——数据的有序集合
android·java·开发语言·前端·javascript
广州华水科技4 小时前
如何利用单北斗变形监测实现大坝安全监测?
前端
hxy06014 小时前
Flutter showModalBottomSheet等弹窗宽度问题
前端·flutter
Wireless_wifi64 小时前
IPQ9574 + WiFi 7: Building the Foundation for Scalable Edge AI Deployments
前端·人工智能·edge
晓13134 小时前
【Cocos Creator 2.x】篇——第五章 游戏常用关键技术
前端·javascript·vue.js·游戏引擎
英俊潇洒美少年5 小时前
前端全量资源预加载优化指南(React内置API + Vue实现 + prerender/prefetch深度对比)
前端·react.js·前端框架
道友可好5 小时前
3 个人,100 万行代码,一行都没人写:OpenAI 的 Harness Engineering 实验
前端·人工智能·后端
winfredzhang5 小时前
用 Node.js + SQLite + 原生前端写一个本地情绪急救 Web App:情绪降落伞 Mood Parachute
前端·sqlite·node.js·express·情绪管理
樱花的浪漫5 小时前
Typescript、Zod基础
前端·javascript·人工智能·语言模型·自然语言处理·typescript
Bigger5 小时前
记一次坑爹的 Cloudflare Pages 部署:Failed to load module script 是怎么把我的 SPA 搞挂的
前端·ci/cd·浏览器