🛜

拡張機能の作り方(サービスワーカーとメッセージ受け渡しのチュートリアル)

に公開

3 つ目に作るチュートリアルではメモ帳を作ります。機能としてはポップアップに入力欄を作り、保存ボタンを押すと入力した内容をブラウザ内に保存できるようにします。ここでは「サービスワーカー」、「メッセージ受け渡し」、「Chrome API」という新しい概念がたくさん出てくるので、前回までの 2 つよりも少し難しい内容になっています。この節を通して以下について学びます。

  • サービスワーカーでイベントを処理する
  • storageAPI でデータを保存する
  • メッセージ受け渡しの使い方を知る

拡張機能を作成する

memo-popupという名前のフォルダを作成してください。

manifest.jsonを作成する

memo-popupフォルダの中にmanifest.jsonというファイルを作成し、次のコードを追加します。

manifest.json
{
  "manifest_version": 3,
  "name": "メモ帳",
  "escription": "ポップアップで使えるメモ帳",
  "version": "1.0.0",
    "permissions": [
    "storage"
  ],
  "action": {
    "default_popup": "popup.html"
  },
  "background": {
    "service_worker": "service-worker.js"
  },
  "icons": {
    "16": "images/icon-16.png"
  },
}

"background"キーはサービスワーカーを設定する項目です。その中の"service_worker"キーにサービスワーカーと使用する JavaScript ファイルのパスを書きます。サービスワーカーについての詳しい説明は次の章で行います。現時点では、イベントの発生を検知したら特定の処理を行う、イベントハンドラーとして機能する JavaScript ファイルだという認識で構いません。

"permissions"キーは権限が必要な Chrome API を使うときに記述する項目です。Chrome API とは、Chrome が持つ便利な機能を拡張機能開発に使えるように提供されているライブラリ群です。Chrome API は必ずしも無条件に使えるわけではありません。Chrome API の中でも特にユーザーのプライバシー情報にアクセスできるような機能、たとえばタブ情報、ストレージ、クリップボードなどにアクセスする場合にはユーザーからの許可を得ないと使うことができません。ユーザーからの許可が必要な Chrome API を利用する場合、"permissions"キーに記載する必要があります。今回は、許可が必要なstorageAPI を使用しているので"permissions"キーに記載しています。"storage"キーについても次の章で詳しく解説します。現時点では、Chrome に内蔵されたストレージを使うために必要な項目だという認識で構いません。

"action"キーの中の"default_popup"キーではポップアップに使用する HTML ファイルを指定しています。これは 1 つ目に作った拡張機能と同じです。

popup.htmlを作成

ユーザーがテキストを書き込む入力欄をポップアップに作ります。memo-popupの中にpopup.htmlというファイルを作成し、次のコードを追加します。

popup.html
<html>
  <head>
    <meta charset="UTF-8">  <!-- ① -->
  </head>
  <body>
    <div class="form-group">
      <label for="memo-title">タイトル</label>
      <input type="text" id="memo-title">
    </div>

    <div class="form-group">
      <label for="memo-body">本文</label>
      <textarea id="memo-body"></textarea>
    </div>

    <button id="save-button">保存する</button>
  </body>
</html>

ポップアップでは文字コードを UTF-8 に指定しています(①)。文字コードを指定したいと文字化けが発生してしまいます。

次に拡張機能をインストールして、拡張機能を固定表示し、動作を確認してみましょう。アイコンをクリックするとタイトルと本文の入力欄と保存ボタンがあるフォーム画面が表示されているはずです。

次にこのフォームに適当な文字を入力してみます。その後、ポップアップ以外の画面をクリックしてポップアップを閉じます。そしてもう一度アイコンをクリックしてポップアップを開いてみてください。するとポップアップに入力した文字は全て消えてしまいました。なぜ文字が消えてしまうのかというと、ポップアップは状態を持たない UI だからです。もうすこし詳しくいうと、ポップアップを閉じるとポップアップの実行コンテキストが終了して組み立てられている DOM もすべて初期化されてしまいます。再度開いたときには何も入力されていないまっさらなフォームが表示されているので、入力した文字が消えてしまったのです。

入力直後 閉じて再度開いたとき

書き込んだ内容を保持したい場合は入力した文字はブラウザ内のどこかに保存する必要があります。ブラウザ内でデータを保存する機能が、先ほど紹介したstorageAPI です。storageAPI はブラウザや PC 内部にデータを保存することができる機能を提供します。

storageAPI を含むすべての Chrome API はすべて JavaScript で書く必要があります。ところで拡張機能を開発するとき独特なルールがあり、HTML ファイルの中にインラインスクリプトで書くことはできませんstorageAPI を使うための JavaScript ファイルを新しく作る必要があります。そこでポップアップで利用する JavaScript ファイルのpopup.jsを作成しましょう。

popup.jsの作成

memo-popup内にpopup.jsを作成します。popup.jsstorageAPI を使ってデータを保存したいところですが、残念なことにstorageAPI はポップアップ上では直接使うことができません。というよりほとんどの便利な Chrome API はポップアップやコンテンツスクリプトで使うことができないのです。Chrome API の多くはサービスワーカーで使うことができます。そこでポップアップに入力されたデータをサービスワーカーへ送信し、サービスワーカーでstorageAPI を使ってデータを保存する必要があります。

データを送ると書きましたが、具体的にはどのようにすればいいのでしょうか。一見、2 つの JavaScript ファイルで値を共有するだけなので次のようにexportimportを使って書けば実現できそうな気がします。

popup.js(間違った例)
export const titleInput = document.querySelector('#memo-title');
export const bodyInput = document.querySelector('#memo-body');
service-worker.js(間違った例)
import { titleInput, bodyInput } from './popup.js';

しかしこれではうまくいきません。なぜかというと、ポップアップとサービスワーカーは実行コンテキストが異なるからです。実行コンテキストが異なるとは、単に同じフォルダにある別ファイルということだけではありません。グローバル変数やローカル変数の共有ができないことに加えて、ファイルが実行されて停止されるまでのライフサイクルまでも異なります。つまり実行コンテキストが異なる2つのファイルは完全に隔離されていると考えれます。実行コンテキストが異なるポップアップとサービスワーカーでデータを共有するにはexportimportを使ったデータのやり取りとは別の方法を使う必要があります。それがメッセージ受け渡しです。

メッセージ受け渡しとは、隔離された 2 つの実行コンテキスト同士が相互通信を可能にする仕組みです。メッセージ受け渡しを行うためには、実行コンテキストが異なる 2 つのファイルのうち、一方でメッセージのリクエストを送信するコードを書き、もう一方でメッセージを受信するコードを書きます。メッセージには、他のメッセージと区別するための ID としての役割を持つ文字列の他、データを送ることもできます。

以上のことを踏まえた上で、popup.jsに次のコードを追加します。

popup.js
const button = document.querySelector('.button');
const titleInput = document.querySelector('#memo-title');
const bodyInput = document.querySelector('#memo-body');

button.addEventListener('click', () => {
    const title = titleInput.value;
    const body = bodyInput.value;
    const req = {
        type: 'save',
        data: { title, body }
    };

    button.addEventListener('click', async () => {
        const title = titleInput.value;
        const body = bodyInput.value;

        // ②
        const req = {
            type: 'save',
            data: { title, body }
        };

        try {
            const res = await chrome.runtime.sendMessage(req); // ①
            alert(`レスポンス: ${res}`);
        } catch(error) {
            alert(`エラーが発生: ${error}`);
        }
    })
});

① はメッセージを送信するためのコードです。ここで本書で初めて Chrome API の実例が出てきました。chrome.から始まるコードはすべて Chrome API を表しています。ここではchrome.runtimeAPI のsendMessage()を利用してポップアップからメッセージを送信しています。Chrome API についても事象で詳しく解説します。繰り返しになりますが現時点では Chrome API をChrome が持つ便利な機能を拡張機能開発に使えるように提供されているライブラリ群という認識で構いません。

sendMessage()の第一引数では ② で定義したreqを渡しており、リクエストの内容を書いています。今回はreqをメッセージとデータを格納したオブジェクト型の値として定義しています。しかしreqと名付けている、sendMessage()の第一引数のに当たる部分はanyで定義されています。なのでどんな型の値を渡しても構いません。

現実的には受信側でどのメッセージが送られてきたかを識別するキーと、共有したいデータを記述するキーはよく使われるでしょう。そこで今回はメッセージを区別するため"type"キーと、共有したいデータを値にもつ"data"キーを持つオブジェクト型として定義しました。本書では一貫してオブジェクト型でメッセージを渡しますが、コードや解説記事によってキー名や型が異なる可能性があるので、そのときは頭の中で読み替えてください。

sendMessage()の返り値は、メッセージを受信側でsendResponse()の引数に設定した値です。メッセージを受信するサービスワーカーのファイルを書くときに再び説明します。

popup.htmlの編集

popup.jsファイルを作成したので、popup.jsで読み込みましょう。HTML ファイルに<script>タグで JavaScript ファイルを読み込むときは<body>タグの最後に書きます。<script>タグをこの位置に書くことで JavaScript が DOM 要素を確実にアクセスできるようになります。ブラウザは HTML ファイルを上から下に読み込んで画面に表示するので、まず DOM の組み立てを行い、その後 DOM の操作をする JavaScript ファイルを実行するように順番を指定できます。

popup.html
  <body>
...
    <div class="form-group">
      <label for="memo-body">本文</label>
      <textarea id="memo-body"></textarea>
    </div>
    <button class="button">保存する</button>

+   <script src="popup.js"></script>
  </body>
 </html>

動作確認

動作を確認するため、この時点で拡張機能をブラウザに読み込んでみましょう。その後chrome://extensions/へ移動し、拡張機能ページを確認すると「エラー」と書かれたボタンが出ています。

「エラー」ボタンをクリックしてみるとエラーの詳細文と、エラーが発生したファイルが書かれています。エラー文は次のように書かれています。

Unchecked runtime.lastError: Could not establish connection. Receiving end does not exist.

メッセージを受信するコードが見つからなかったというものです。メッセージを送信するコードと受信するコードはセットで書かなければエラーが出ます。次はサービスワーカーのファイルを作り、メッセージを受信してみましょう。

service-worker.jsの作成

memo-popup内にservice-worker.jsというファイルを作り、次のコードを追加します。

service-worker.js
// ①
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
    if (message.type === 'save') { // ②③
        const title = message.data.title; // ②
        const body = message.data.body; // ②
        sendResponse(`タイトル:${title}, 本文: ${body}`);// ④
    }
})

runtimeAPI は Chrome API の 1 つです。chrome.runtime.onMessage.addListener()によりメッセージを受信することができます。onMessage.addListener()の引数にはコールバック関数を入れます。このコールバックはメッセージを受信した後に実行されます。

メッセージ送信にasync/awaitが使えるのに対して、メッセージ受信にはasync/awaitは使えません。つまり次のようにしてメッセージを受信する処理はかけません。

間違った例
const res = await chrome.runtime.onMessage()

メッセージ受信にはコールバック関数で対処する必要があります。なお、コールバック関数自体にはasync/awaitは使えます。コールバック関数にasync/awaitを使う例はこのあとに出てきます。

コールバック関数には 3 つの引数があります。第一引数のmessageにはpopup.jsでメッセージを送信したときに引数として渡したreqがそのまま格納されています。第二引数のsenderにはタブ ID や URL などのメッセージを送信した側に関する情報が格納されています。第三引数のsendResponseはメッセージの送信元にデータを返却する関数です。

② ではメッセージとして受け取ったオブジェクトを取り出してmessageに格納しています。メッセージ送信側では次のような値をメッセージとして渡しました。

popup.js
const req = {
    type: 'save',
    data: { title, body }
};

受信側ではこのメッセージをそのまま第一引数のmessageで受け取ることができます。"type"キーを取り出すときにはmessage.typeのようにかき、"title"キーを取り出すときはmessage.data.titleのように書きます。

③ ではメッセージの"type"キーを取り出して、メッセージの種類がsaveであるかを確認しています。現時点ではメッセージ送信は一箇所しか実装していないため、このようなメッセージを特定する場合わけは必要ありません。しかし複数のメッセージ送信を実装する場合、受け取ったメッセージをタイプ分けし、それらに対応した処理をする必要があります。そこでメッセージ送信の実装が 1 つしかない時点で場合分けすることをお勧めします。


メッセージは複数定義できるので識別できるように一意にする

④ で使っているsendResponse()は、コールバック関数の第三引数です。sendResponse()の引数に指定した値はメッセージ受信側で使用したsendMessage()の返り値になります。メッセージ受信側であるpopup.jsで記述したとおり、sendResponseに渡した値を返り値として受け取ってresに格納しています。

popup.js
const res = await chrome.runtime.sendMessage(req);

動作確認

これでメッセージ受信側の処理が書けたので先ほど出たエラーが解決しました。拡張機能を再度読み込み、ポップアップを起動し、タイトルと本文にテキストを入力し、最後に「保存する」をクリックしてください。すると入力した内容がアラートに表示されました。入力した内容はpopup.jsを通してサービスワーカーに送られ、その後処理を行った後popup.jsに返却されました。メッセージ受け渡しを通して、データが 2 つの JavaScript ファイルを行き来していることが確認できました。

servie-worker.jsの編集

service-worker.js
chrome.runtime.onMessage.addListener((req, sender, sendResponse) => {
  if (req.type === 'save') {
    const title = req.data.title;
    const body = req.data.body;
-    sendResponse(`タイトル:${title}, 本文: ${body}`);// 

    // ①
+    chrome.storage.local.set({ memo: memoData }, () => {
+      if (chrome.runtime.lastError) { // ②
+        sendResponse({ status: 'error', message: chrome.runtime.lastError.message }); // ③
+        return;
+      }
+
+      sendResponse({ status: 'success' }); // ③
+    });
+    return true; // ④
  }
});

① でようやくstorageAPI が出てきました。storageAPI ではデータを読み書きすることができます。ただし保存できるデータの形式はキーと値のペアであるオブジェクト型に限られます。オブジェクト型はそのまま JSON に対応するので、要するにストレージとは JSON 保管庫のようなものだと考えるといいです。

storageAPI を扱うときは「保存領域」と「操作」に分けて考えるとわかりやすいです。storageAPI で提供される保存場所はlocalsessionmanagedsyncの 4 種類があります。操作はデータを保存するset、データを読み込むget、データを削除するremoveなどがあります。今回はデータをlocal保存するためchrome.storage.local.set()と書きました。逆にデータをlocalから読み込む場合はchrome.storage.local.get()と書きます。ここで利用している保存領域のlocallocalStorageとは別物であることに注意してください。4 つの保存領域については次の章で詳しく解説します。

②ではストレージを扱うときにエラーが発生したときにキャッチしています。runtime.lastErrorはコールバック関数のある非同期APIでエラーメッセージを伝えるときに使われます。今回の例ではストレージに値を保存するときにエラーが発生したらruntime.lastErrorErrorオブジェクトが代入されます。このErrorオブジェクトにはmessageプロパティがあるので、エラーが発生したときにchrome.runtime.lastError.messageを使いエラー文を取り出しています。もしエラーが発生していない場合はruntime.lastErrornullが格納されていますので、if文は実行されません。エラーハンドリングについては次章で詳しく解説します。

③でsendResponse()にメッセージを格納しています。sendResponse()の引数に格納した値はそのままメッセージ送信側で返り値として受け取れます。ここではstorageAPIを使い、エラーが発生した場合のことも考える必要があります。そこで"status"キーを持たせて、サービスワーカーで処理が成功したか失敗したかを、メッセージ呼び出し側でもわかるようにしています。エラー発生時にsendResponse()を呼び出さずにreturnだけを書くと、エラーが投げられ、呼び出し元のcatch文が実行されます。本書ではsendResponse()を使いエラーを投げず、メッセージにエラーが発生したことを含むように実装します。このエラーの発生については次章で検証します。

④でコールバック関数内部でsendResponse()を使用するときはreturn true;と書く必要があります。これはブラウザにコードバック関数が終了してもポートを閉じないようにするためです。sendResponse()を使わないときはreturn true;を書く必要はありません。これについても詳しくは次章のメッセージパッシングで解説します。現時点ではメッセージパッシングのポートを閉じないために必要なコードだという認識で構いません。

メッセージ受信側の編集

次にpopup.jsの内容を編集します。

動作確認

入力したデータがブラウザに保存されているか確認します。

まずはツールバーからアイコンをクリックします。次にポップアップ上で右クリックし、「検証」をクリックします。

すると DevTools が現れるので「アプリケーション」パネルをクリックします。もし「アプリケーション」パネルが表示されてなければ、タブを横に広げると隠れているパネル名が出てきます。次にサイドバーに表示される「拡張機能ストレージ」をダブルクリックし、「ローカル」をクリックします。すると拡張機能で保存した値が表示されていることが確認できます。

現在までで、メッセージ受け渡しと、storageを用いたデータの保存が実装できました。しかしポップアップを閉じてしまうと入力したテキストは消えてしまいます。次にstorageAPI を使ってデータを取得してポップアップを開いたら保存したデータを表示させるようにしましょう。

popup.jsの編集

ブラウザに保存したデータを取り出すためにはstorageAPI を使う必要があります。しかしstorageAPI はサービスワーカーでしか使用することができません。なのでポップアップとサービスワーカーでメッセージ受け渡しを行う必要があります。

まずはポップアップでメッセージを送信する処理を書いていきます。ページの読み込みが完了したときにメッセージを送信したいので、画面読み込みが完了したことを知らせるDOMContentLoadedイベントが発生したとき処理を行います。popup.jsに次のコードを追加してください。

popup.js
const button = document.querySelector(".button");
const titleInput = document.querySelector("#memo-title");
const bodyInput = document.querySelector("#memo-body");

+document.addEventListener('DOMContentLoaded', async () => {
+  const res = await chrome.runtime.sendMessage({ type: "load" }); // ①
+})

...

sendMessage()の引数がそのままメッセージの内容になるのですが、今回は{type: "load"}というメッセージ ID しか含んでいません。いま行いたい処理はストレージからデータを持ってくることであり、なにかデータを処理したり保存したいわけではありません。このメッセージではサービスワーカーに処理を行うタイミングを伝えるのが目的なので、メッセージの内容としてメッセージ ID だけで十分です。

次にサービスワーカーでメッセージを受信する処理を書いていきます。

Discussion