📞

chrome拡張のメッセージパッシングを理解する

2024/12/04に公開

レバウェル開発部アドベントカレンダー4日目担当のHirorinです!

ManifestV3更新のためにChrome拡張機能の開発に携わっていました。ManifestV3になってからServiceWorkerの使用が必須になり、windowオブジェクトやDOM操作が出来なくなりました。 必要なデータを取得するためにはメッセージパッシングで他のjsから受け取る必要がありますが、その方法を理解するまでに苦戦しました。
この記事では、jsが動作している場所に着目してメッセージパッシングについて解説したいと思います。

メッセージパッシングの送受信先

メッセージパッシングを理解する上で重要なのが、どこからどこにメッセージを送るか把握することです。
この記事ではPage、ServiceWorker、ContentScript、OffscreenDocumentの4つの場所でメッセージパッシングをするものとして解説します。
それぞれの用途は以下の通りです。
(他の拡張機能へのメッセージパッシングはしないものとして考えています)

  • Page
    • 拡張機能のアイコンを押した時に表示されるポップアップページで読み込んでいるjs
  • ContentScript
    • manifest.jsonで定義したURLにマッチしたwebページ内で動作するjs
  • ServiceWorker
    • バックグラウンドで動作するjs
    • webシステムに例えるとサーバサイドに該当する部分
      • なのでjsだけどDOM操作はできない
  • OffscreenDocument
    • ManifestV3からの新機能。新しいウィンドウを開いたりすることなくDOMを操作することが出来ます
      • runtimeAPI以外の機能は使用できない、2つ同時にOffscreenDocumentを開けないという制限があります
    • 詳しくはドキュメントを参照

送受信先を図にすると以下のようになります。
chrome-extension.png

注目してほしいのはContentScriptです。図にある通りContentScriptはmanifest.jsonで指定したwebページ内で動作するので明示的に分けています。 後述しますがメッセージを送信する際にはこの違いを意識する必要があります。

メッセージの送信方法

2パターンの使い分けをする必要があります。

パターン1. 送信先がPage, ServiceWorker, OffscreenDocument

runtimeAPIのsendMessageメソッドを使って送信します(ドキュメント)。

chrome.runtime.sendMessage({
  title: 'hoge',
  message: "送るメッセージ"
}, function(response) {
  // 送信先からデータが返された場合はここで受け取り
  console.log(response);
});

パターン2. 送信先がContentScript

送信側はtabsAPIのquery、sendMessageメソッドを使います(ドキュメント)。 使用しているAPIは異なりますが、パターン1とほぼ同じ形です。 Chrome拡張内ではなく開いているwebページ側の動作になるため、ブラウザのタブからwebページを指定します。

chrome.tabs.query({active: true, currentWindow: true}, function (tab) {
  chrome.tabs.sendMessage(tab[0].id, {
    title: 'hoge',
    message: "送るメッセージ"
  }, function(response) {
    // 送信先からデータが返された場合はここで受け取り
    console.log(response);
  });
});

メッセージの受信方法

メッセージを受信するにはonMessageを使ってリッスンします。 こちらは送信先によって処理は分かれません。

chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  console.log('メッセージを受信!')
  console.log(requset) // 送られたメッセージは 引数requestの中にある
  console.log(sender) // 送信元の情報はsenderの中にある
  sendResponse('返事を送る場合はsendResponseメソッドを使う')
});

受信時の注意点

送信側で扱うAPIが変わるだけの2パターンしかなくて単純そうですが、パターン1では送信側のコードで送信先を指定出来ません。 なので、受信側でメッセージを受け取ってどう処理するかを決める必要があります。

検索するとよく出てくるのは送られたデータから判定する方法です。 switch文でヒットした内容に応じて処理を決定し、ヒットしなければ何もしません。

// メッセージ送信
chrome.runtime.sendMessage({type: 'hoge'});

// メッセージ受信
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  // typeキーの内容で処理を決定する
  switch(request.type) {
    case 'hoge':
      hogeFunc();
      sendMessage('hoge');
      break;
    case 'fuga':
      fugaFunc();
      sendMessage('fuga');
      break;
  }
  return true;
});

メッセージパッシングで送るデータ

メッセージパッシングで送るデータはJSONにシリアライズできる形式でないといけません。シリアライズ出来ないものを送るとどうなるかテスト用のHogeクラスを使って確認してみます。Hogeクラスは一つのメンバ変数とメソッドがあるだけのクラスです。
このクラスのインスタンスをsendMessageで送ってみます。

/* 送信元js */

class Hoge {
  constructor() {
    this.message = "sample";
  }
  display(message) {
    console.log(message);
  }
}

const hoge = new Hoge();
hoge.display('hogehoge'); // => 送信元では問題なくメソッドが動く
chrome.runtime.sendMessage({hoge: hoge});

送信先でデータを受け取って同じメソッドを動かしてみます。

/* 送信先js */

chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  const hoge = request.hoge;
  hoge.display('fugafuga'); // 【エラー!】Uncaught (in promise) TypeError: hoge.display is not a function
  return true;
});

送信元では動いていたdisplayメソッドは送信先では動きません。
送信先で受け取っている request.hoge の中身を確認すると次のようになっています。

{message: "sample"}

メンバ変数はシリアライズされて受け取れていますが、メソッドは受け取れていないので呼び出してもエラーになります。 これは受信側がメッセージを返す時の sendResponseでも同じです。

厄介なのは送る時にはインスタンスを渡してもエラーにはならないので問題に気づきにくいです。 インスタンスを渡している前提で進めていると痛い目に遭うので注意してください。 特にSDKを使って別サービスから取得したデータを使う場合は、一度データの中身を確認した方が良いです。

async/awaitを使う場合

処理結果を変数hogeに格納しsendMessageで送り返していますが、送信元で中身を確認するとundefinedになります。非同期にすることでsendMessageが処理される前にreturn true;が動くため、適切な値を返してくれません。

chrome.runtime.onMessage.addListener(async (request, sender, sendResponse) => {
switch(request.type) {
  case 'hoge':
    const hoge = await hogeFunc();
    sendMessage(hoge);
    break;
  }
  return true;
});

対応1. thenを使う

async/awaitではなくthenを使います。thenの中でsendResponseを使うことで正しくメッセージパッシング出来ます。

chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  hogeFunc()
  .then((result) => {
    sendResponse(result);
  })
  return true;
});

対応2. async/awaitでやる

「他の場所でasync/await使っているから統一したい!」という方もいるでしょう。 その場合は別関数として切り出します。

const sampleFunc = async (request, sender, sendResponse) => {
  const hoge = await hogeFunc(request);
  sendResponse(hoge);
  return true;
};

chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  sampleFunc(request, sender, sendResponse);
  return true;
});

おわりに

メッセージパッシングでのデータ連携では以下のことを意識することでChrome拡張の全体像が見えて来ました。

  • jsが動作する場所
  • それぞれの場所の責務

拡張機能の規模が大きいほどファイル数も多くなるため、jsの動作する場所とその責務が曖昧になりやすいです。
この記事が参考になって、Chrome拡張のメッセージパッシング理解のお役に立てれば何よりです!

明日は、仕事仲間でもありボードゲーム仲間でもあるエンジニアさんの投稿です! お楽しみに👍️

GitHubで編集を提案

Discussion