长连接通信
本教程共 56 篇 · 第 21 篇 · 更新于 2026-08-13 · 约 6 分钟阅读
本节目标:学完能用
runtime.connect在扩展组件间建立一条可反复收发的长连接,并正确处理断开与错误。
上一章的一次性消息像「寄一封信」。但有些场景,你希望两端一直连着,你来我往地聊很多轮:比如内容脚本要持续把页面上的变化告诉后台,或者后台要把一个进度条的状态反复推给弹出页。这种时候就该上长连接了。
21-1 什么时候用长连接
判断标准很简单:如果一条消息不够,你打算在两端之间多次双向收发,就选长连接。常见例子有:
- 后台在抓取或处理一个耗时任务,要把进度一段段推给弹出页。
- 内容脚本监听页面的实时事件(滚动、输入、节点变化),持续上报给后台。
- 弹出页和后台需要来回协商,比如「再给我一点数据」「停,我够了」。
一次性消息每聊一句都要重新走一遍发送-监听,效率不高。长连接建立一次,之后随便发。
21-2 建立连接:connect 与 onConnect
长连接的两端靠一个 Port(端口)对象说话。发起方调 chrome.runtime.connect 拿到端口,接收方在 chrome.runtime.onConnect 里接住这个端口。
// 发起方(比如 popup.js 或 content_script.js)
const port = chrome.runtime.connect({ name: "example" });
name 是这条连接的名字,接收方可以用它来区分不同的连接。接收方这样接:
// background.js
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== "example") {
return; // 不是我关心的连接,忽略
}
// 从这里开始用 port 通信
});
Note长连接也支持跨扩展(
connectExternal)和跨网页,但本教程主线只在扩展内部组件之间讲,概念完全一样,只是换个 API 名字。
21-3 Port 对象与持续通信
拿到 port 后,两端是对称的:都用 port.postMessage() 发送,都用 port.onMessage.addListener() 接收。下面用一个经典的小对话演示两端如何你来我往。
// 发起方
const port = chrome.runtime.connect({ name: "knockknock" });
port.onMessage.addListener((msg) => {
if (msg.question === "Who's there?") {
port.postMessage({ answer: "Madame" });
} else if (msg.question === "Madame who?") {
port.postMessage({ answer: "Madame... Bovary" });
}
});
port.postMessage({ joke: "Knock knock" });
// background.js 接收方
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== "knockknock") return;
port.onMessage.addListener((msg) => {
if (msg.joke === "Knock knock") {
port.postMessage({ question: "Who's there?" });
} else if (msg.answer === "Madame") {
port.postMessage({ question: "Madame who?" });
} else if (msg.answer === "Madame... Bovary") {
port.postMessage({ question: "I don't get it." });
}
});
});
注意这里两端代码是配对的:发起方先甩一个 joke,后台回 question,发起方再回 answer,来回好几轮,全程只用同一条 port。这就是长连接的价值——通道一直在,随发随收。
Tip
port是一个普通对象,你可以把它存进变量、放进数组,甚至用一个Map管理多条连接。比如内容脚本每条标签页都连一个端口时,用tabId当 key 存起来很方便。
21-4 断开连接与错误处理
连接不会永远在。页面关了、扩展被停用、或者任一方主动调用 port.disconnect(),通道都会断。你要监听 port.onDisconnect,在断开时做清理,比如从你的连接表里把这个端口删掉。
// background.js
const ports = new Map();
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== "example") return;
ports.set(port.sender.tabId, port);
port.onMessage.addListener((msg) => {
// 处理消息
});
port.onDisconnect.addListener(() => {
// 连接断了,清掉记录
ports.delete(port.sender.tabId);
});
});
port.onDisconnect 里的 chrome.runtime.lastError 能告诉你断开原因。比如对方已经不存在了,读 lastError.message 会拿到报错信息。养成习惯:在收发后顺手判断一下 chrome.runtime.lastError,避免静默失败。
port.onDisconnect.addListener(() => {
if (chrome.runtime.lastError) {
console.log("连接异常断开:", chrome.runtime.lastError.message);
}
});
Note主动断开很简单:在任何一方调用
port.disconnect()即可,对端会随之触发onDisconnect。不需要再发一条「再见」消息。
21-5 MV3 下 Port 的生命周期陷阱
这是本章最典型的坑。后台是服务工作者,它平时可能「睡着」。问题来了:如果你从后台 connect 出去连一个内容脚本,这条端口并不会把后台唤醒保持在活跃状态。换句话说,后台自己挂了,它连出去的端口也就断了。
反过来,从内容脚本或弹出页连到后台,通常能正常把后台唤醒。但在后台这一侧,如果你只是拿着 port 等消息,而没有其他活跃事件,服务工作者仍可能在空闲一段时间后被终止,端口随之关闭。
应对办法有几条:
- 让连接从「需要持久的那一侧」发起。要后台持续接收内容脚本上报,就让内容脚本去
connect后台,而不是后台去连内容脚本。 - 后台里不要在
onConnect里做一长串await才去监听,要把port.onMessage的注册写在同步顶层逻辑里,保证醒来立刻能接消息。 - 把端口引用保存在一个模块级变量或
Map中,别让它被垃圾回收。端口对象一旦丢了引用,连接也可能被关。
Tip如果你发现长连接时断时续、后台日志显示服务工作者频繁重启,八成就是生命周期问题。先确认连接方向对不对,再检查后台有没有持续的活动在「续命」。
还有一点容易被忽略:当后台因空闲被终止、端口随之关闭后,内容脚本那一端会立刻触发 port.onDisconnect。如果你的内容脚本没处理这个断开,它手里的 port 就成了废引用,再 postMessage 会直接抛 Attempting to use a disconnected port object。所以两端都该写 onDisconnect,一端断了另一端要优雅收尾,而不是假装连接还在。
21-6 长连接 vs 一次性消息怎么选
最后给个判断口诀。只有一问一答,用 sendMessage;要反复多轮、要推流、要双向协商,用 connect。长连接代价是端口要管理、断开要清理,但换来的是流畅的实时通信。下章我们会把这些通信方式,按扩展里真实存在的上下文(弹出页、后台、内容脚本)逐个串起来,给你完整的搭配方案。
21-7 管理多条长连接
实际扩展里,内容脚本常常不止一条。比如你的扩展在三个标签页都注入了脚本,每条都和后台建了端口。这时候后台得把端口管起来,否则你根本不知道消息该回给谁。最省事的做法是用一个 Map,以 tabId 为键存端口。
// background.js
const portsByTab = new Map();
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== "example") return;
const tabId = port.sender.tabId;
portsByTab.set(tabId, port);
port.onDisconnect.addListener(() => {
portsByTab.delete(tabId);
});
});
// 想给某个标签页的内容脚本发消息时
function pushToTab(tabId, msg) {
const port = portsByTab.get(tabId);
if (port) port.postMessage(msg);
}
Note
port.postMessage走的和一次性消息是同一套 JSON 序列化规则,能传纯数据,Date、Map这类内建类型同样不保真,函数、DOM 节点照样传不了。端口只是「通道」,对能装什么货没有额外放宽。