各上下文通信模式
本教程共 56 篇 · 第 22 篇 · 更新于 2026-08-13 · 约 7 分钟阅读
本节目标:学完能针对「弹出页、后台、内容脚本」三种组件,选出正确的通信方向并写出可运行的对接代码。
前两章讲了两种武器:一次性消息和长连接。但新手最容易懵的是——「我到底该从哪发到哪?」本章把扩展里真实存在的上下文摆出来,逐个讲清楚它们怎么互通,并重点解决最麻烦的「内容脚本和弹出页如何通信」。
22-1 先认清三个常通信的组件
在 MV3 里,你最常让它们说话的是这三者:
- 后台服务工作者(Service Worker):全扩展的逻辑中枢,平时休眠、事件来了才被唤醒,所有上下文都能直接找它。
- 内容脚本(content script):注入到网页里运行,和后台不共享内存,只能靠消息对话。
- 弹出页(popup):点开工具栏图标才出现的页面,生命周期很短,关掉就没了。
先明确一个核心事实:弹出页给内容脚本发消息可以直连(chrome.tabs.sendMessage 谁都能调);反过来,内容脚本想找弹出页才麻烦——弹出页没有常驻的监听者,这一方向必须经后台中转。下面分三种情况讲。
22-2 弹出页与后台:直接聊
弹出页和后台同属扩展自身上下文,所以 popup 直接用 chrome.runtime.sendMessage 就能找到后台,后台用 onMessage 收,这和第一章讲的一模一样。
// popup.js
(async () => {
const settings = await chrome.runtime.sendMessage({ type: "get-settings" });
console.log(settings);
})();
// background.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === "get-settings") {
return chrome.storage.local.get("settings");
}
});
Note弹出页存在时间极短,它「问一句答一句」很自然,所以一次性消息最合适。弹出页很少需要长连接,除非你要实时把后台进度显示到弹出页上——那种情况弹出页得自己
connect后台。
22-3 内容脚本与后台:两种直连方向
内容脚本和后台之间有两个方向,API 名字不一样,别记混。
方向 A:内容脚本主动找后台。 内容脚本用 chrome.runtime.sendMessage,后台用 chrome.runtime.onMessage 收。这条链路和弹出页找后台完全对称。
// content_script.js
(async () => {
const result = await chrome.runtime.sendMessage({ type: "fetch-title" });
console.log(result);
})();
方向 B:后台主动找内容脚本。 后台不能用 runtime.sendMessage 指定某个页面,它得用 chrome.tabs.sendMessage(tabId, message),告诉浏览器「把这条消息发到第几个标签页的内容脚本去」。
// background.js
async function pingContentScript(tabId) {
const response = await chrome.tabs.sendMessage(tabId, { type: "ping" });
console.log(response); // 内容脚本的回复
}
// content_script.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === "ping") {
return { pong: true };
}
});
Tip后台要发消息给内容脚本,必须知道
tabId。通常从chrome.tabs.query拿到当前标签页,或从上一个事件(比如sender.tab.id)里取。内容脚本没注入成功的标签页,sendMessage会报Receiving end does not exist,记得try/catch。
22-4 内容脚本与弹出页:一个方向直连,一个方向中转
这是本章重点,也是很多人卡住的地方。先给结论,分两个方向看。
方向一:弹出页 → 内容脚本,可以直接发。 弹出页里调 chrome.tabs.sendMessage(tabId, ...),消息直接送到指定标签页的内容脚本,后台不用参与。这和 22-3 后台发内容脚本用的是同一个 API,只是调用方换成了弹出页。
// popup.js:拿到当前标签页,直连它的内容脚本
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
chrome.tabs.sendMessage(tab.id, { type: "give-me-title" });
// content_script.js 照常收
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === "give-me-title") {
return { title: document.title };
}
});
方向二:内容脚本 → 弹出页,必须走后台中转。 弹出页点开才存在、关掉就销毁,没有常驻的监听者,内容脚本发过去的消息没有接收人。唯一办法是「内容脚本 ↔ 后台 ↔ 弹出页」两段拼接:弹出页打开时先把「回复函数」留给后台,内容脚本把消息发给后台,后台再用这个函数转交给弹出页。
// background.js 记下弹出页留下的回复函数,收到内容脚本消息时转交
let popupResolver = null;
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === "popup-online") {
popupResolver = sendResponse; // 弹出页打开时先来报到
return true;
}
if (message.type === "content-title") {
if (popupResolver) {
popupResolver({ title: message.title });
popupResolver = null;
}
}
});
// popup.js 打开时先报到,留下接收通道
chrome.runtime.sendMessage({ type: "popup-online" }, () => {});
// content_script.js 把消息发给后台即可,后台会转给弹出页
chrome.runtime.sendMessage({ type: "content-title", title: document.title });
Note一句话记法:「发给弹出页」都要借后台,因为弹出页不常驻;「弹出页发给谁」可以直接用
tabs.sendMessage。如果你有多标签页、多个内容脚本,中转时要带上tabId做路由,别把 A 标签页的回复错发给 B。用Map把tabId映射到对应的「回复函数」会更稳。
22-5 用长连接做中转更高效
上一节的例子是一次性消息中转。如果弹出页和内容脚本要反复聊(比如弹出页实时显示页面滚动位置),用长连接更顺:内容脚本和弹出页各自 connect 后台,后台在两个端口之间互相 postMessage 转发。这样不用每段都重新建立一次性消息,后台只做「双端口桥接」。
// background.js 桥接两个长连接
let popupPort = null;
let contentPort = null;
chrome.runtime.onConnect.addListener((port) => {
if (port.name === "from-popup") {
popupPort = port;
port.onMessage.addListener((m) => contentPort && contentPort.postMessage(m));
}
if (port.name === "from-content") {
contentPort = port;
port.onMessage.addListener((m) => popupPort && popupPort.postMessage(m));
}
});
Tip中转桥接很好用,但要小心端口断开。任何一端
onDisconnect,记得把对应的popupPort/contentPort置空,否则转发时会向已死的连接发消息而报错。
22-6 选型速查
最后给你一张判断表,照着选就不会错:
- 弹出页问后台要数据:
runtime.sendMessage,一次性。 - 内容脚本上报后台:
runtime.sendMessage给后台,一次性或长连接都行。 - 后台推消息给内容脚本:
chrome.tabs.sendMessage(tabId, ...)。 - 弹出页要跟内容脚本通信:弹出页给内容脚本发消息,
chrome.tabs.sendMessage(tabId, ...)直连;内容脚本要主动找弹出页,走后台中转,要么一次性拼接,要么双端口桥接长连接。
把这张表和前两章两种通信方式合起来看,扩展内部「谁能跟谁说话」就算彻底打通了。后续章节讲 storage、tabs 等具体 API 时,你都会回头用到这里的通信骨架。
22-7 常见报错与排查
通信代码写出来容易,调通往往要花点功夫。下面几个错你大概率会撞上,提前知道能省不少时间。
第一个最常见:Could not establish connection. Receiving end does not exist. 意思是「我发了,但对方没在听」。后台给内容脚本 tabs.sendMessage 时,如果那个标签页根本没注入内容脚本(比如网址不匹配 content_scripts 的 matches,或者页面还没加载完),就会报这个。排查时先确认目标页确实在 matches 范围内,必要时等 document_idle 再发,或者 try/catch 兜底。
第二个:Could not establish connection. Receiving end does not exist. 也常出现在弹出页刚打开、后台服务工作者却还在「睡」的时候。弹出页发 runtime.sendMessage 一般会自动唤醒后台,但若后台顶层还没注册好监听器,消息就丢了。确保监听器同步写在顶层,别包在异步后面。
第三个:长连接中途断掉,对端再 postMessage 会抛 Attempting to use a disconnected port object。这就是 21 章说的生命周期问题,务必在 onDisconnect 里清空引用,发消息前先判断端口是否还在。
Tip所有通信异常都建议顺手读一下
chrome.runtime.lastError。很多报错不会自动打到控制台,只在lastError.message里,不查就看不见。养成「发完查 lastError」的习惯,排查效率翻倍。
22-8 小结
本章把三种组件摆在一起:弹出页和后台能直连,内容脚本和后台能双向直连,弹出页给内容脚本发消息也能直连;只有「内容脚本主动找弹出页」这一方向没有直连通道,得借后台中转。中转时,一次性消息靠「记下回复函数再转发」,长连接靠「两个端口互相桥接」。再配上前面两章的武器和本节排查清单,扩展内部任何一对组件你都能搭起稳定通道。