首页 / 浏览器扩展开发入门教程 / 各上下文通信模式

浏览器扩展开发入门教程

各上下文通信模式

本教程共 56 篇 · 第 22 篇 · 更新于 2026-08-13 · 约 7 分钟阅读

上下文通信popup与backgroundcontent与background中转方案消息路由MV3

本节目标:学完能针对「弹出页、后台、内容脚本」三种组件,选出正确的通信方向并写出可运行的对接代码。

前两章讲了两种武器:一次性消息和长连接。但新手最容易懵的是——「我到底该从哪发到哪?」本章把扩展里真实存在的上下文摆出来,逐个讲清楚它们怎么互通,并重点解决最麻烦的「内容脚本和弹出页如何通信」。

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。用 MaptabId 映射到对应的「回复函数」会更稳。

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_scriptsmatches,或者页面还没加载完),就会报这个。排查时先确认目标页确实在 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 小结

本章把三种组件摆在一起:弹出页和后台能直连,内容脚本和后台能双向直连,弹出页给内容脚本发消息也能直连;只有「内容脚本主动找弹出页」这一方向没有直连通道,得借后台中转。中转时,一次性消息靠「记下回复函数再转发」,长连接靠「两个端口互相桥接」。再配上前面两章的武器和本节排查清单,扩展内部任何一对组件你都能搭起稳定通道。