🫱

【Chrome拡張機能】メッセージパッシングについて

2023/10/18に公開
  • JSONオブジェクトをやり取りできる

  • ワンタイムリクエスト用のシンプルなAPI(runtime.sendMessage())

  • service_workerで複数のメッセージを区別するときは case を使って区別する

  • 長期接続用の複雑なAPI(runtime.connect()、)

  • 他の拡張機能のIDを知っていればその拡張機能にメッセージを送れる(runtime.onMessageExternal()runtime.onConnectExternal)

  • 例1のようにDOMの要素に対してイベントを追加する場合と、例2のようにChromeAPIのメッセージパッシングで使うaddEeventListenerはもちろん別の関数だ。前者は要素に対してイベントリスナーを定義している。後者はChromeAPIコンテキストでメッセージを受け取るためのイベントリスナーを設定している。どちらのaddEventListener()も、使われるコンテキストや目的が異なるが、どちらも特定のイベントが発火したときに指定したリスナー(コールバック関数)を実行するという共通の動作を持つ。

イベントの扱い方は Webサイトと、Node.jsと、Chrome拡張機能では記法が異なる。(ドキュメントでも言及されている。)

その他の例として、WebExtensions と呼ばれる技術を使って、クロスブラウザーアドオン (ブラウザーの機能拡張) を JavaScript で作成できます。
イベントモデルは Web イベントモデルと似ていますが、ほんの少し違いがあります
(イベントリスナーのプロパティはキャメルケース (例 onmessage でなく onMessage) で命名されていて、addListener 関数で結び付ける必要があります。
例として runtime.onMessage page を確認してください。
https://developer.mozilla.org/ja/docs/Learn/JavaScript/Building_blocks/Events

  • DOMの addEventListener()は引数の中にイベントとリスナー(コールバック関数)を定義する。一方、chrome.runtime.onMessage.addListener()の引数にはリスナー(コールバック関数)をいれ、リスナー(コールバック関数)の引数でイベントを取り出せる。DOMの場合のイベント名はDOMをクリックしたときに発生する"click"イベントや"blur"など、すでに定義されているイベントのみを取り扱う。一方、ChromeAPIではイベント名はこちらで指定することになっている。

イベントに関するdocument

サンプルコード

manifest.json
{
  "manifest_version": 3,
  "name": "Message Passing Sample",
  "version": "1.0",
  "description": "Sample extension for message passing between content script and service worker",
  "permissions": ["storage", "activeTab"],
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"]
    }
  ]
}
content.js
// コンテンツスクリプトでボタンを定義することで、コンテンツスクリプトとバックグラウンドだけでメッセージパッシングを実現してる。
const button = document.createElement("button");
button.textContent = "Click me!";
document.body.appendChild(button);

button.addEventListener("click", function () {
  chrome.runtime.sendMessage(
    { action: "storeData", data: "Sample Data" },
    function (response) {
      alert(response.data);
    }
  );
});

background.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.action === "storeDataa") {
    chrome.storage.local.set({ storedData: message.data }, function () {
      if (chrome.runtime.lastError) {
        sendResponse({ status: false, data: chrome.runtime.lastError });
      } else {
        sendResponse({ status: true, data: "データを保存しました!" });
      }
    });
  }
  return true;
});

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {}

メッセージの受信方法の説明をしたいのに一行目からとても長い。しかもChromeAPIばかりなので初心者には理解する手掛かりすらない。これはどうしようもないので、メッセージを受信するときの構文として考えてもいい。

onMessageはメッセージが送られてきたときに発生するイベントだ。下の図のようにコンテンツスクリプト、バックグラウンド、ポップアップのいずれかからメッセージが送られてきたときに発火する。

onMessageイベントが持つメソッドは次の3つがある。

メソッド名 概要
addListener() リスナーをこのイベントを定義する
removeListener() このイベントの受け取りを中止する
hasListener() リスナーがこのイベントに登録されているかどうかを確認する

メッセージを受け取って決められた処理を実行したいときに使うのがaddEventListener()だ。
addListener()は引数にコールバック関数をとり、そのコールバック関数は以下の3つの引数をとる。

構文: addListener()

chrome.runtime.onMessage.addListener(message, sender, sendResponse) -> {
  ... statement ...
}
引数名 概要
message object 送信側から送られてくるメッセージ。
sender MessageSender runtime.MessageSenderオブジェクト。メッセージの送信側を表す
sendResponse fuction 送信側に送るメッセージ。。1つのメッセージ内で一回しか実行できない。オブジェクトでも文字列でもいい。データクローンアルゴリズムというのを使っていて、柔軟に対処できるそうだ。MDNのドキュメント

ここでは送られてきたメッセージをmessage.actionと、message.dataの2箇所で取り出している。送信側で{action: 'storeData', data: 'Sample Data'}というオブジェクトを送ったからだ。メッセージはobject型であればキーとバリューは自由に決めていいので、仮に{ syurui: "データ保存", okurudata: "保存するデータ" }のようなデータを送ったら、受信側ではmessage.shuruimessage.okurudataと書いて取り出す。

if (message.action === "storeDataa") {

ここで、message.actionで送られてきたメッセージの種類を取り出して、実行する処理を分けている。今回は1つしかメッセージを送っていないのでif文は必要ないが、説明のために書いている。
if文で分岐をするのは、複数メッセージの受信処理に対応するためだ。データを保存する以外でも、APIを使って外部サイトから情報を取ってきたり、訪れているサイトの認識など、メッセージパッシングを使うケースは出てくる。受け取るメッセージごとに chrome.runtime.onMessage.addListener()を書くのは冗長になる。そこでif文やswitch文を使い、メッセージの種類ごとに実行するコードを分けることによってコードが簡略化される。

 if (chrome.runtime.lastError) 

この箇所ではエラーが起こった場合の処理を書いている。runtime.lastErrorプロパティはコールバック関数のある非同期APIでエラーメッセージを伝えるときに使う。chrome.storage.local.set()はコールバック関数を持つのでエラーの分岐にはruntime.lastErrorを用いる。エラーがなければこの値はundefinedとなっているのでif文では偽と評価され、else文のブロックが実行される。
ちなみにPromiseを使った非同期APIはruntime.lastErrorプロパティを使わず、エラーハンドラーを使う。
runtime.lastError のドキュメント

sendResponse({ status: false, data: chrome.runtime.lastError });
return true

これはChromeAPI固有のバグだそう。
onmessage.addlistener return true;で検索したら出てくる。
こんなエラーが出てくる

Unchecked runtime.lastError: The message port closed before a response was received.

MDNによると

非同期的に応答するには、次のどちらかを実行します。

sendResponse() に対する参照を保持したままリスナー関数から true を返す。そうすると、リスナー関数から復帰した後でも sendResponse() が実行できます。
リスナー関数から Promise を返して、応答の準備ができたときにその Promise を解決する。こちらがより好ましい方法です。
https://developer.mozilla.org/ja/docs/Mozilla/Add-ons/WebExtensions/API/runtime/onMessage#引数

と書いているのだが、addListener()内部でPromiseを使っている人でも、return true をしている。わからん。async, await は使えないのははっきりしている(ドキュメントに書いてるし、ブログでの失敗例はほとんどasync await を使おうとしているのが原因)

https://www.mitsuru-takahashi.net/blog/chrome-extension-response/

  • 同期的に応答するか、非同期で応答するかによって書くことは変わる
  • 返り値は Promiseboolean

onMessage.addListener 構文

同期か非同期により書き方が変わる

余談: イベント登録は「イベント名+イベントハンドラー」の2点セット!

そもそもJavaScriptでのon*という命名規則はイベントハンドラープロパティ名で使われる。たとえば、要素をクリックしたときに実行されるのがonclickプロパティで、画面をスクロールした際に実行されるのがonscrollプロパティである。(イベント名はそれぞれclickscroll)
なのでonMessageはイベント名なのにon*という名前がついていて命名規則に反する。
これをどう受け止めたらいいだろう。イベント登録は「イベント名+イベントハンドラー」の2点セットだと考えることにしている。

というのも、onMessageの直後にイベントハンドラーを定義するaddListener()が書かれるから
だ。
上で引用したようにイベント処理は、Web、Node.js、拡張機能で書き方がバラバラだ。
しかしいずれの分野であっても、イベント処理は「イベント名+イベントハンドラーの2点セット」で成り立っている。
拡張機能の場合は先ほど書いたようにonMessage.addListener()と、「イベント名+イベントハンドラー」の2点セットそのものだ。
要素にイベントハンドラーを登録するときelement.addEventListener("click", () => {})と書くが、これも「イベント名+イベントハンドラー」そのもの。
Node.js も eventEmitter.on("イベント名", イベントハンドラー)という記法で、やはり「イベント名+イベントハンドラー」という書き方をしている
よってイベント処理は「イベント名+イベントハンドラー」の2点セットと考えるのが辻褄に合う。

Discussion