Skip to main content
一个扩展运行在多个彼此隔离的上下文里。service worker 持有特权代码,content script 看得见页面,popup 或 options 页面承载界面。它们不共享内存,因此每个跨越边界的值都是以消息的形式跨越的。本页说明每个方向该用哪个调用、决定异步答复能否到达的规则,以及一次失败的交互会打印哪些控制台行。 本页属于 Extension.js 文档。Extension.js 从一个 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 是你的代码,但它转发的数据来自你无法控制的页面。
长时间的交互改用 port,port 带有名字,因此一个监听器可以分辨它的调用方:

校验 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 与你声明的模式做比较。
port 改变的是检查的时机,而不是检查的内容。 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.js 按原样编译这些监听器,不添加任何自己的检查,所以这部分是纯粹的浏览器 API。有一个开发期的细节。在 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 的控制台输出会被转发到同一个通道,因此跨越边界的消息更容易在单一流里跟踪。
把特权工作留在 service worker 里,让 content script 保持狭窄,让载荷保持小。转发整页快照的 content script 比只转发功能所需三个字段的那个更慢,攻击面也更大。

参见