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 比只轉發功能所需三個欄位的那個更慢,攻擊面也更大。

參見