消息通信:content ↔ service worker ↔ popup 三方对话

扩展不是一段代码,而是分处不同上下文的几个角色:网页里的 content script、按需唤醒的 service worker、点图标才出现的 popup、独立运行打开的 options 页。它们互相看不见彼此的变量,唯一的协作方式就是消息。这一篇把一次性的 sendMessage 和长连接的 Port 都讲透,并给出方向对照表。

方向与方法总表

发送方 目标 用什么
content script service worker / popup chrome.runtime.sendMessage
popup / options 当前标签页的 content script chrome.tabs.sendMessage
service worker content script chrome.tabs.sendMessage
任意两方长连接 双向多次 chrome.runtime.connect / tabs.connect

记忆锚点:发往「扩展内部」用 runtime,发往「某个标签页」用 tabs。content script 没有 tabs 权限概念,它只跟扩展内部通信,所以只有 runtime 一侧。

一次性请求:sendMessage

从 content script 发给后台,返回 Promise:

// content.js
const res = await chrome.runtime.sendMessage({ type: "query", text: "hello" });
console.log(res.answer);

后台监听并应答:

// background.js
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type === "query") {
    fetch(`https://api.example.com/search?q=${encodeURIComponent(msg.text)}`)
      .then(r => r.json())
      .then(data => sendResponse({ answer: data.summary }));
    return true; // 关键:声明将异步应答
  }
});

这里的 return true 是最经典的坑:onMessage 监听器若不同步调用 sendResponse,就必须返回字面量 true 保持消息通道开启,否则 Promise 永远悬空或收到 undefined。官方文档明确写着要用 return true 的字面量而非任何 truthy 值。反方向(后台发往标签页)先查到 tabId 再用 tabs.sendMessage,参数几乎一样。

应答的两种异步写法

除了 return true + 手动 sendResponse,还可以直接从监听器返回 Promise(Chrome 148 起逐步铺开)。两种写法都合法,但注意 async 函数的陷阱:async 监听器永远返回 Promise,哪怕你没 return——此时隐式 resolve 成 undefined,会被当作「已应答 null」,在多监听器场景下会抢占先注册监听器的应答机会。所以混用两种风格时,确保 async 监听器要么真返回结果,要么不要承担应答职责。

错误传播也有讲究:监听器抛出的 Error 会传导给发送方(Chrome 146 起逐步铺开),reject 一个非 Error 值则发送方只能收到泛化的错误信息。协议设计上建议应答统一为 { ok: true, data } 形状,把业务错误放进 data 而不是靠异常传播。

长连接:connect 与 Port

需要双向多轮对话(如进度推送、状态订阅)时,用长连接:

// content.js:发起
const port = chrome.runtime.connect({ name: "progress" });
port.postMessage({ step: "start" });
port.onMessage.addListener((msg) => {
  if (msg.type === "progress") console.log(msg.percent);
});

// background.js:接住
chrome.runtime.onConnect.addListener((port) => {
  if (port.name !== "progress") return;
  port.onMessage.addListener((msg) => {
    port.postMessage({ type: "progress", percent: 50 });
  });
});

Port 还有 onDisconnect 事件,是清理定时器、通知对端的好时机。另外一个隐藏知识点:从 Chrome 114 起,长连接端口上的消息往来会保持 service worker 存活,但仅打开端口不重置计时器——想靠长连接给后台续命,得真的在发消息。

实战建议

消息协议要约定形状,推荐 { type, payload } 两段式,type 用常量而非裸字符串,多了一层拼写保护。通信做多了你会发现和 HTTP API 设计思路一致:路由、负载、错误码一个不少。若你的扩展要和外部网页通信,那是另一条通道(externally_connectable 声明),本篇不展开。

调试消息通信的技巧(在两端分别打日志、观察 sender)见第五章 《调试技巧》 篇。官方文档:https://developer.chrome.com/docs/extensions/develop/concepts/messaging

一个完整的双向示例

把三个上下文串起来,做一个「popup 查询 → 后台取数 → content 展示」的最小闭环,这是最常见的中等复杂度拓扑:

// background.js:数据中介
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type === "fetch-summary") {
    fetch(`https://api.example.com/summary?url=${encodeURIComponent(msg.url)}`)
      .then(r => r.json())
      .then(data => sendResponse({ ok: true, summary: data.text }))
      .catch(() => sendResponse({ ok: false, summary: "" }));
    return true;
  }
  if (msg.type === "show-toast") {
    chrome.tabs.sendMessage(msg.tabId, { type: "toast", text: msg.text });
    sendResponse({ ok: true });
  }
});
// popup.js:发起方
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const res = await chrome.runtime.sendMessage({ type: "fetch-summary", url: tab.url });
if (res.ok) {
  await chrome.runtime.sendMessage({ type: "show-toast", tabId: tab.id, text: res.summary });
}
// content.js:展示方
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type === "toast") { renderToast(msg.text); sendResponse({ ok: true }); }
});

拓扑要点:popup 不直接调接口(它一关就死,请求可能中断),而是委托后台;后台是唯一的网络出口与路由中枢;content 只管展示。固定这个分层,消息拓扑永远不会乱。

消息协议的版本化

扩展要长期迭代,消息协议建议从一开始就带版本意识。最小做法是给 type 加前缀空间:v1:query、v1:translate。大改协议时新增 v2 类型,后台同时监听两版并做兼容,等老版本用户升级完毕再下线 v1。这套思路与 HTTP API 版本化一致,成本几乎为零,却能在协议演进时避免「新 popup 配旧后台」的错配报错。

未注册监听器时的报错(Receiving end does not exist)是常态而非异常——目标标签页可能还没注入。发送前用 chrome.tabs.sendMessage 的 try/catch 包住,失败则先 executeScript 再重发一次,是通用的健壮性写法。

调试与验收

消息问题的验收标准:发端拿到预期应答、收端日志完整、异常路径有兜底。调试技巧(两端打日志、观察 sender)见第五章 《调试技巧》 篇。官方文档:https://developer.chrome.com/docs/extensions/develop/concepts/messaging

消息里传 URL、文本时做清洗与匹配是常事,正则工具箱见 正则实战;多文件工程的消息常量定义与编辑器组织见 HBuilderX 效率技巧。

本章由 WorkBuddy 原创整理,API 行为依据 Chrome 扩展官方文档(Manifest V3)。