manifest.json 出发,为 Chrome、Edge、Firefox 和 Safari 构建并运行浏览器扩展,而下面的消息传递 API 都是浏览器自身的 API。要试验这些示例,请用 npx extension@latest create my-extension 搭建一个项目。
传递消息的三种方式
chrome.runtime.sendMessage。 一次性。它会到达每一个带有 runtime.onMessage 监听器的扩展上下文,也就是 service worker、popup、options 页面,以及任何其他已打开的扩展页面。它不会到达 content script。
chrome.tabs.sendMessage。 一次性,瞄准一个标签页。从 service worker 或另一个扩展页面调用它,去到达那个标签页里的 content script,还可以通过 frameId 只到达一个 frame。content script 必须已经在那里运行。
chrome.runtime.connect 与 chrome.tabs.connect。 一个保持打开的具名 port。两端可以随时投递消息,两端也都通过 port.onDisconnect 得知拆除。
在你自己的上下文之间传消息不需要额外权限。它需要的是对面有一个活着的监听器。
异步响应规则
runtime.onMessage 监听器默认是同步答复的。它一返回,通道就关闭,之后再调用 sendResponse 会被丢弃。返回 true 才是让通道保持打开的做法:
async 监听器函数返回的是 Promise,而 Promise 不是 true,因此在 Chromium 上,一个稍后调用 sendResponse 的 async 监听器仍然会关闭通道。请像上面那样在监听器内部做异步工作并返回 true。在调用方一侧,省略回调时 chrome.runtime.sendMessage 会返回 Promise,所以那里直接 await 即可,不涉及以上这些。
给每条消息一个明确的 type,在处理之前校验载荷,并在做特权工作之前检查 sender。content script 是你的代码,但它转发的数据来自你无法控制的页面。
校验 content script 与后台之间的消息
content script 运行在一个你无法控制的页面里。页面可以通过 DOM 或window.postMessage 把任何东西交给它,而 content script 再把这些数据转发给后台。正因为如此,Chrome 的文档把 content script 列为比 service worker 更不可信的一方。请把到达后台的每一条消息都当作不可信输入来对待,就像服务器对待请求体那样。
检查是谁在调用。 每个监听器都会收到一个 sender 对象,即 runtime.MessageSender 类型。以下是真正重要的字段:
runtime.onMessage 只会因你自己扩展的上下文而触发,所以在那里 sender.id 永远是你自己的 id。来自另一个扩展、或来自 externally_connectable 放行的网页的消息,会改为到达 runtime.onMessageExternal,而那里才是由 sender.id 和 sender.url 决定一切的地方。
先校验形状,再采取行动。 维护一份消息类型白名单,检查每个字段的类型和大小,其余一律拒绝。绝不要把消息里的字符串变成代码:不要 eval,不要 new Function,不要 innerHTML,也不要让 chrome.scripting.executeScript 执行来自消息的代码。写入文本请用 textContent。
externally_connectable 键时,其他每个扩展都可以向你的扩展发消息,而任何网页都不行。声明这个键来收窄或放宽这一点。ids 列出允许连接的扩展,"*" 放行全部扩展。matches 列出可以带着你的扩展 id 调用 runtime.sendMessage 的网页。这些消息会落到 runtime.onMessageExternal 和 runtime.onConnectExternal 上,绝不会落到 onMessage 上,因此请给它们单独的监听器,并把 sender.url 与你声明的模式做比较。
runtime.onConnect 和 runtime.onConnectExternal 交付的 port 上会设置 port.sender,因此在 port 打开时检查一次 port.name 和发送方即可。此后 port 的身份是固定的,但到达 port.onMessage 的每个载荷仍然是页面数据,所以要让它们经过同一个类型守卫。收到第一条坏消息时就调用 port.disconnect()。
Firefox 的不同之处:
sender.origin从 Firefox 126 起才存在。请从sender.url推导 origin,一行代码同时覆盖旧版本和 Chrome。- Firefox 不支持
externally_connectable,因此网页永远无法向 Firefox 扩展发消息,runtime.onMessageExternal在那里只会因另一个扩展而触发。Safari 15.4 支持这个键,但只支持matches。 browser.runtime.onMessage会采纳返回的 Promise,下面的表格有说明。
extension dev 下,重载桥会通过一个名为 __extjs-bridge-log__ 的 port 把控制台输出转发到 service worker。devtools 页面则改用 runtime.sendMessage 转发,消息是一个带 __extjsBridgeLog 键的对象。一个会检查 port.name 的 runtime.onConnect 监听器,以及一个会拒绝没有已知 type 的消息的 onMessage 监听器,会把两者都忽略掉。extension build 的产物不包含这些内容。
各浏览器差异
在 Safari 上,在你授予扩展网站访问权限之前 content script 不会运行,因此在那之前
tabs.sendMessage 没有接收方。参见 Safari。
如果你的源码基于 browser.* 编写,同时也要构建 Chromium 目标,请传入 --polyfill。参见 跨浏览器兼容。
你会看到的控制台报错
把你看到的那一行复制去搜索。每一行对应一个原因。Unchecked runtime.lastError: Could not establish connection. Receiving end does not exist.
没有任何一方在监听。要么目标上下文没有 runtime.onMessage 监听器,要么对 tabs.sendMessage 而言,还没有 content script 被注入那个标签页。在扩展加载之前就已打开的标签页在重新加载前没有 content script,而像 chrome:// 或扩展商店这样的受限页面永远不会有。请先用 chrome.scripting.executeScript 注入,或者处理这次失败,做法见 在运行时注入脚本。
Unchecked runtime.lastError: The message port closed before a response was received.
监听器收到了消息,却在没有让通道保持打开的情况下返回了。请从监听器返回 true,或者同步作答。
Uncaught Error: Extension context invalidated.
扩展重新加载时,旧的 content script 还在页面里运行。那段代码现在指向一个不复存在的运行时,它发出的每个 chrome.* 调用都会抛错。请重新加载标签页。在 extension dev 期间,这发生在一次被归类为完整重载的改动之后,重载与 HMR 对此有说明。
Attempting to use a disconnected port object
在 onDisconnect 触发之后,仍有人向这个 port 投递消息。请在 onDisconnect 处理器里清掉你的引用,需要时再打开一个新 port。
Extension.js 的做法
Extension.js 编译调用这些 API 的代码,并不包装它们。协议是你自己的。工具链改变的是开发循环:- 一次被归类为
content-scripts的改动会重新注入受影响的条目并拆除上一次的挂载,因此监听器是被替换而不是被叠加。一次被归类为full的改动会重载扩展,而重载前就留在页面上的 content script 会变成上面那种Extension context invalidated情况。请重新加载标签页。 - 在
extension dev下,从scripts/文件夹注入的脚本会在编辑后被重放,因此你在被注入脚本里打开的 port 会由新的副本重新打开。参见 特殊文件夹。 - service worker 与 content script 的控制台输出会被转发到同一个通道,因此跨越边界的消息更容易在单一流里跟踪。

