💭

QRコードを作成

に公開

この節では現在開いているサイトのQRコードを作成し、それを新しいウィンドウで表示する拡張機能を作っていきます。いままでは自分で用意した機能しか使っていなかったのですが、QRコードの作成の処理は外部APIを使います。また、データを表示するUIとして今まではポップアップかコンテンツスクリプトを使っていたのですが、新たにタブを作成してHTMLページを表示してみます。さらに、右クリックで表示されるコンテキストメニューと呼ばれるものを使用してイベントを発生させる方法も学びます。まとめると、今回新たに学ぶ内容は次のようなものになります。

  • 外部APIを使う
  • タブを作成する
  • コンテキストメニューを使う

準備

qrcodeという名前の新しいフォルダを作成します。次にmanifest.jsonという名前のファイルを作成し、次のコードを追加します。

manifest.json
{
  "manifest_version": 3,
  "name": "QRコード",
  "version": "1.0.0",
  "description": "このページのURLをQRコードに変換",
  "permissions": [
    "contextMenus" // ①
  ],
  "background": {
    "service_worker": "service-worker.js"
  }
}

①コンテキストメニューの権限を得る

今回新たに使うコンテキストメニューはユーザーからの権限を得る必要があるので、"permissions"キーの中に"contextMenus"を追加する必要があります。コンテキストメニューはWebページ上で右クリックをしたときに現れるメニュー画面のことです。コンテキストメニューに表示される項目をメニュー項目と呼びます。


メニュー項目を追加する

次にメニュー項目を追加する処理を書きます。contextMenuesAPIはサービスワーカーでしか使えないのでサービスワーカーのファイルを作成します。servie-worker.jsという名前のファイルを作成し、次のコードを追加してください。

service-worker.js
chrome.runtime.onInstalled.addListener(() => { // ①
    chrome.contextMenus.create({ // ②
        id: 'pageQr',
        title: 'QRコードに変換',
        contexts: ['all']
    });
});

①インストール時のイベントリスナーを使う

拡張機能をインストールしたときにメニュー項目の追加を行います。拡張機能をインストールした直後は、runtime.onInstalledイベントがが発生するので、chrome.runtime.onInstalled.addListener()を使ってイベントハンドラーを登録します。

ちなみにインストール時にメニュー項目を追加していますが、インストール時に登録しなければならないという決まりはありません。公式ドキュメントで紹介されているサンプルコードではインストール時に登録するように書いてあったのでそれを参考にしました。私見ですがこのタイミングでメニュー項目を追加するのは合理的だと思います。インストール時に追加することで、不要なタイミングで何度もコードが実行されるのを防ぐことができ、追加するタイミングが遅くってしまい右クリックを押してもメニュー項目が表示されなくなることが防げます。

②メニュー項目を作成する

メニュー項目を作成するときはchrome.contextMenus.create()を使います。create()の第一引数にはメニュー項目を作成するための設定を、1つのオブジェクトとして渡します。このオブジェクトはcreatePropertiesで定義されており、ここではidtitlecontextsというプロパティーを利用します。createPropertiesには必須項目はありません。

idは作成されるメニュー項目を一意に識別するための文字列です。titleはユーザーがコンテキストメニューを開いたときに実際に表示されるメニュー項目のテキストです。contextsはどこを右クリックしたときにメニュー項目を表示するかを指定する配列です。allを指定するとページ上を右クリックするとメニュー項目が表示されます。"contexts"キーで指定できる項目は他にも項目があり、特定の箇所をクリックしたときにのみコンテキストメニューが表示されるように制限することができます。選択したテキスト上のときのみ表示するselection、画像上で右クリックをしたときのみ表示されるimage、それ以外の箇所をクリックしたときに表示されるpageなどがあります。

その他のプロパティはこちらをご覧ください。
https://developer.chrome.com/docs/extensions/reference/api/contextMenus#type-CreateProperties

動作確認

拡張機能を読み込み、適当なページ上で右クリックをしてみましょう。すると下の画像のように先ほど設定したメニュー項目が追加されました。メニュー項目のタイトルの左隣に画像が表示されていますが、これはmanifest.json"16"で設定した16x16ピクセルのアイコン画像です。create()でアイコンの表示を指定する項目はなく、自動的に項目メニューの左端に拡張機能のアイコンが表示されます。

現時点ではメニュー項目をクリックしても何も変化が起こりません。次はメニュー項目がクリックされたときの挙動を作成しましょう。

メニュー項目がクリックされたときの挙動を作成

contextMenusAPIが使えるのはサービスワーカーだけなので、引き続きservice-worker.jsを開いて、次のコードを追加してください。

service-worker.js
chrome.runtime.onInstalled.addListener(() => {
    ...
});
+
+chrome.contextMenus.onClicked.addListener(async (info, tab) => { //①
+    if (info.menuItemId === 'pageQr') { // ②
+        try {
+            const sourceText = info.pageUrl; // ②
+            const baseUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data='; // ③
+            const url = baseUrl + encodeURIComponent(sourceText); // ④
+        
+            await chrome.tabs.create({ url }); // ⑤
+         } catch(error) {
+             console.error(`エラーが発生: ${error.message}`);
+        }
+    }
+});

①メニュー項目がクリックされたときを検知する

メニュー項目がクリックされたときはcontextMenus.onClickedイベントが発生するので、chrome.contextMenus.onClicked.addListener()と書き、コールバック関数としてイベントハンドラーを登録します。コールバック関数のシグネチャは次のように定義されています。

(info: OnClickData, tab?: tabs.Tab) => void

onClickDataにはメニュー項目や開いているページの情報が含まれています。tabにはメニュー項目をクリックしたページのタブの情報が含まれています。

コールバック関数でawaitを使うChrome APIを使うので、コールバックの前にasyncを書いています。

infoからプロパティを取り出す

コールバック関数の第一引数のinfoからmenuItemIdpageUrlを取り出します。menuItemIdchrome.contextMenus.createで設定したIDです。menuItemIdを使ってどのメニュー項目がクリックされたかを区別し、条件分岐によってIDに応じた処理を行います。現時点では1つしかメニュー項目がないため条件分岐は必要ないですが、項目メニューが増えたときに条件分岐が必要になります。pageUrlはコンテキストメニューをクリックしたときに開いているページのURLが含まれています。拡張機能では現在開いているページをQRコードに変換する機能を作りたいので、APIにこのURLを渡すことになります。

③外部APIのURL

URLからQRコードの画像を作るために、QR Code Generatorというサイトが提供するAPIを使います。このAPIでは任意のテキストをQRコードに変換することができます。さらに画像のサイズ、拡張子、色なども指定することができます。https://api.qrserver.com/v1/create-qr-code/というURLにカスタマイズしたいこうおくのパラメーターを渡すことでQRコードの画像が返ってきます。ここではsize200x200を渡して200ピクセルの正方形のサイズを指定し、dataにURLを渡してURLのQRコードを作成します。

④URLのエンコード

URLの中に日本語や記号が含まれるとURLが機能しない場合があるのでエンコードしています。

⑤タブを作成してページを開く

ここで初めてchrome.tabsAPIが出てきました。拡張機能を使うと、タブの作成、削除、開いているタブ情報の読み取りを行うことができます。ここでは新しいタブを作成して、APIで取得した画像を表示するページを作成します。chrome.tabs.create()の引数は次のようなものになっています。

chrome.tabs.create(
  createProperties: object,
  callback?: function,
)

createPropertiesには作成するタブについての情報を設定するプロパティが含まれています。urlというタブで開くページのURLを指定するものや、pinnedというタブを固定にするかどうかなどがあります。今回はurlを使ってタブで開くURLを指定します。

callbackはタブ作成した後に処理を行いたいに使います。ここでは使わないので解説はしませんが、シグネチャは次のようになっています。

(tab: Tab) => void

chrome.tabs.create()はPromiseに対応しているのでasync/awaitを使いました。

動作確認

拡張機能一覧(chrome://extensions/)へ移動し、「再読み込み」アイコンをクリックして、拡張機能を再度読み込みします。好きなページに移動し、ページ上を右クリックしてコンテキストメニューを表示させ、「QRコードを生成」メニューをクリックします。すると画像のように新しいタブが開きQRコードがページに表示されました。

ストレージに保存する

ここまではQRコードを別のタブで表示するだけでした。ここからさらに機能を追加していきます。前回学習したストレージに画像を保存していきましょう。QRコードを保存するだけなら、先ほど使用したAPIのURLを保存するのもいいのですが、今回は学習のためQRコードの画像を保存してみましょう。

ストレージを使うときにはmanifest.jsonpermissions"storage"を追記する必要があります。今回のように後から機能を追加するときは"permissions"キーに権限が必要なAPIを追記するのを忘れがちで、何度もよくあるミスです。manifest.jsonに次のコードを追記してください。

manifest.json
{
  "manifest_version": 3,
  "name": "QRコード",
  "version": "1.0.0",
  "description": "このページのURLをQRコードに変換",
  "permissions": [
+   "storage",
    "contextMenus"
  ],
  "background": {
    "service_worker": "service-worker.js"
  }
}

ところでストレージにはPNGやJPEGなどの画像ファイルを直接保存することができません。同様に画像をバイナリーデータとして扱えるBlob型も保存することができません。なぜならストレージ保存できるデータ型はJSONで保存できるデータ型(文字列、数値、オブジェクト、配列、真偽値、null)に限られているためです。

そこで登場するのがBase64です。Base64は画像のようなバイナリデータを、安全に文字列として表現するためのエンコード方式です。Base64を使って画像を文字列に変換することで、JSONで扱えるようになり、結果としてストレージに画像を保存することができます。

それでは画像をBlobに変換し、BlobをBase64形式で文字列にエンコードし、文字列に変換した画像をストレージに保存するコードを書いていきましょう。

service-worker.js
 chrome.contextMenus.onClicked.addListener(async (info, tab) => {
     if (info.menuItemId === 'pageQr') {
         try {
             const sourceText = info.pageUrl;
             const baseUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=200x200 &data=';
             const url = baseUrl + encodeURIComponent(sourceText);
-
-            await chrome.tabs.create({ url });
+
+            const response = await fetch(url);
+            if (!response.ok) {
+                throw new Error('API使用中にエラー');
+            }
+
+            const imageBlob = await response.blob(); // [1]
+            
+            const base64Image = await new Promise((resolve, reject) => { // [3]
+                const reader = new FileReader(); // [2]
+                reader.addEventListener('load', () => resolve(reader.result)); // [3]
+                reader.addEventListener('error', () => reject(reader.error)); // [3]
+
+                reader.readAsDataURL(imageBlob); // [2]
+            });
+
+            const storageKey = `qrCode_${Date.now()}`; // [4]
+            await chrome.storage.local.set({ [storageKey]: base64Image }); // [4]
          } catch(error) {
             console.error(`エラーが発生: ${error.message}`);
         }
     }
 });

[1] レスポンスをBlobに変換する

FetchAPIはURLから取得したレスポンスのボディ部を取得するとき、一般的にはtext()json()を使いますが、blob()を使うとBlobオブジェクトを取得することができます。blob()ではBlobオブジェクトで解決するPromiseを返します。

[2] Base64エンコードで文字列に変換する

[2]で書かれているコードはBase64エンコードによりBlobから文字列へ変換する定型的な処理です。もし仮にBlobオブジェクトにtoBase64()みたいなメソッドがあれば、blob.toBase64()のようにしてBase64エンコーディングが1行でできて便利なのですがそういったメソッドは存在しません。

Base64エンコーディングを行うためにはFileReaderオブジェクトを使う必要があります。これはBlobからデータを読み込むことのみを目的としたオブジェクトです。FileReaderが持つreadAsDataURL()メソッドを使うとBase64エンコーディングを行うことができます。

readAsDataURL()という名前に違和感を感じるかもしれません。この変数名の由来を知るにはデータURLという機能を知る必要があります。データURLとは、<img>タグなどのURLを指定する部分にBase64エンコーディングされた文字列を書くことで、画像を表示することができる機能です。次の文字列をブラウザのURL部分にコピペしてページ移動をしてみてください。

data:image/png;base64,R0lGODlhDAAMAKIFAF5LAP/zxAAAANyuAP/gaP///wAAAAAAACH5BAEAAAUALAAAAAAMAAwAAAMlWLPcGjDKFYi9lxKBOaGcF35DhWHamZUW0K4mAbiwWtuf0uxFAgA7

すると次のように画像が表示されます。URLから画像ファイルを持ってこなくても、エンコーディングされた文字列をURLとして扱うことで画像を表示させることができます。与えられたBlobを「データURL」として「読み込む」」ことから、readAsDataURL()というメソッド名になっています。


画像が小さいため拡大して表示しています

[3] イベントハンドラーをPromiseにする

FileReaderは画像ような大きい容量のファイルを扱うためreadAsDataURLは非同期処理になっています。FileReaderのライフサイクルは次の図のようになります。

ファイルのロードに成功したとき(load)とエラーが発生したとき(error)の2つにイベントハンドラーを登録するしますが、そのまま書こうとするとコードが読みづらくなってしまいます。そこでPromise化をします。loadイベントのハンドラーにresolve()を登録し、errorイベントのハンドラーにreject()を登録します。

また、ファイルのロードが完了し、成功した場合はreader.resultでエンコードされた文字列にアクセスすることができ、失敗した場合はreader.errorでエラーにアクセスできます。

MDNのドキュメントを読むとaddEventListener()を使用した後は、イベントリスナーを解除するように書かれています。

FileReader が使用されなくなったら、メモリーリークを防ぐために removeEventListener() でイベントリスナーを取り外してください。

FileReaderはメモリにバイナリーデータを読み込むので大きなファイルを扱う場合はメモリリークが発生する可能性があります。しかし今回の例ではFileReaderのインスタンスreaderはPromiseのコールバック関数の中で定義されており、この関数の実行した後ではガベージコレクションによりreaderが破棄されバイナリーデータを格納していたメモリは解放されます。なのでこの場合はメモリにバイナリーデータが格納される期間は、ファイルの読み込みが完了されるまでの間に限られているので、必要な時間だけ保持されて不要になったら解放されます。なのでメモリリークの心配は必要なく、明示的にremoveListener()でイベントリスナーを解除する必要はありません。

[4] 変数を使ったストレージのキーの登録

オブジェクトのプロパティ名を変数を使って動的に決めることができます。この構文を「計算されたプロパティ名」といいます。計算されたプロパティ名を使いたいときは、[変数名]のように変数名を[]で囲みます。ストレージに保存するのはオブジェクト型なので、ストレージに保存するキー名に変数を使いたい場合も同様に[変数名]と書くことで動的に決めることができます。じゃあバリューに変数で決めたいときはどうなるのかというと、そのまま変数名を書くだけです。オブジェクトのプロパティにアクセスするときusers[id]と書けるように、保存するときもアクセスするときと同様に、プロパティ名に変数が使えることを知っておいてください。

また、3つ目に作った拡張機能では"title"キーと"body"キーの2つしか使いませんでしたが、この例のようにストレージに保存するキーは後から追加することも可能です。ただしキー名は一意にする必要があります。ここではキー名に現在の日時を使って重複がないように工夫をしています。

動作確認

コードが機能しているか確認してみましょう。まずはコードの変更を反映するため、拡張機能一覧(chrome://extensions/)へ移動し、再読み込みアイコンをクリックします

次にgoogle.comへ移動し、画面上で右クリックし、「QRコードに変換」メニュークリックします。画面上では何も反応はありませんが、これでURLのQRコードを保存ができました。

再び拡張機能一覧(chrome://extensions/)へ移動し、「ビューを検証」という文字の隣にある「Service Worker」をクリックします。するとサービスワーカーをデバッグできるDevToolsが開きます。

その後、「アプリケーション」パネルをクリックし、「拡張機能ストレージ」をダブルクリックし、「ローカル」をクリックします。するとこのようにストレージにデータURLが保存されていることが確認できます。

ポップアップで表示する

ポップアップを使用するため、manifest.jsonに次のコードを追加します。

manifest.json
  ...
  "icons": {
    "16": "images/icon-16.png",
    "32": "images/icon-32.png",
    "48": "images/icon-48.png",
    "128": "images/icon-128.png"
  },
+ "action": {
+   "default_popup": "popup.html"
+ },
  "permissions": [
    "storage",
    "contextMenus"
  ],
  ...

次にポップアップの画面を作成します。popup.htmlという名前のファイルを作成し、次のコードを追加してください。

popup.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Saved QR Codes</title>
<body>
    <h1>保存されたQRコード</h1>
    <div id="qr-list"></div>
    <script src="popup.js"></script>
</body>
</html>

次にポップアップで実行するJavaScriptファイルを作成します。popup.jsという名前のファイルを作り、次のコードを追加してください。

popup.js
document.addEventListener('DOMContentLoaded', async () => {
    const qrContainer = document.querySelector('#qr-container');
    
    try {
        const qrCodes = await chrome.storage.local.get(null); // [1]
        const qrCodeKeys = await chrome.storage.local.getKeys(); // [2]

        // [3]
        if (qrCodeKeys.length === 0) {
            return;
        }

        for (const key of qrCodeKeys) {
            const base64Image = qrCodes[key];
            const imgElement = document.createElement('img');
            imgElement.src = base64Image; // [4]
            qrContainer.appendChild(imgElement);
        }
    } catch (error) {
        console.error('エラーが発生:', error);
    }
});

[1] ストレージから全てのデータを取り出す

storageAPIはコンテンツスクリプトでは使えませんが、ポップアップとサービスワーカーで使うことができます。もし全てのデータを取り出したいのであれば引数にnullを指定すればよいです。

[2] キーの一覧を取り出す

getKeys()を使うとストレージに保存されているデータの全てのキーを取得することができます。

[3] データが0だったときの処理

このチュートリアルではすでにQRコードを保存しているので問題ありませんが、QRコードを保存していない場合はreturnをして処理を終了します。

[4] データURLを指定する

ストレージに保管されているデータURLを<img>タグのsrcプロパティに代入しています。よく見慣れた実装ではここにサーバーに保管されている画像のURLを指定します。データURLを扱うときも同じで、特別なことをしなくてもURLを指定するプロパティに代入するだけで画像を表示できます。

動作確認

それでは拡張機能を動かして動作確認を行いましょう。

拡張機能一覧ページ(chrome://extensions/)へ移動します。拡張機能の再読み込みアイコンをクリックします。その後、拡張機能アイコンをクリックします。すると次のように、QRコードがポップアップに表示されていることがわかります。他のページに移動して同じようにQRコードを保存すると、2個3個とQRコードが下に続けて表示されるので試してみてください。

ストレージの改善

いまのところ動くものは作れているのですが、ストレージの設計に不満があります。このまま保存したQRコードが際限なく増えていくとストレージが散らかってしまいます。この拡張機能ではQRコードしか保存していませんが、他にも保存する項目が増えるといろんなデータが順不同で混ざってしまい、取得したり集計をとるときに非常に不便です。


無尽蔵に増えていくデータ

そこでオブジェクト型の性質を利用して、QRコードのデータを1つのプロパティにまとめてみましょう。つまり、次のようなオブジェクトをストレージに保存するわけです。こうすることで整理された扱いやすいストレージになります。

{
  "qrCode": {
    "1678886400000": "data:image/png;base64,...(QRコードのエンコードデータ)...",
    "1678886405000": "data:image/png;base64,...(QRコードのエンコードデータ)...",
    // ... 
  }
}

QRコードの保存時の処理を変更するため、service-worker.jsを開き次のようにコードを修正します。

service-worker.js
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
    if (info.menuItemId === 'pageQr') {
        try {
            ...
            const base64Image = await new Promise((resolve, reject) => {
                ...
            });

-           const storageKey = `qrCode_${Date.now()}`;
-           await chrome.storage.local.set({ [storageKey]: base64Image });
+           const created_at = Date.now().toString();
+           const qrCodesObj = await chrome.storage.local.get('qrCodes');
+           // [1]
+           let qrCodes;
+           if (Object.keys(qrCodesObj).length === 0) {
+               qrCodes = {};
+           } else {
+               qrCodes = qrCodesObj['qrCodes'];
+           }
+           
+           qrCodes[created_at] = base64Image;
+            
+           await chrome.storage.local.set({ qrCodes }); // [2]
        } catch (error) {
            console.error('エラーが発生:', error.message);
        }
    }
});

[1]キーが存在しなかった場合の処理

ユーザーが初めてQRコードを生成するとき、ストレージにはまだqrCodesキーが存在しません。この「キーが存在しない」場合を正しく処理しないとエラーの原因になります。chrome.storage.local.get()の仕様では、指定したキーが存在しない場合、エラーを返すのではなく、空のオブジェクト{}を返すのでした。

初回時のアクセスではまだストレージにqrCodesキーは存在しないので、存在しないと判定された場合はQRコードの情報を格納するためのqrCodesと言う名前の空のオブジェクトを作ることにします。

qrCodes = {};

2回目以降のアクセスではストレージにqrCodesキーが存在しているので、qrCodesと言う変数に代入します。qrCodesキーにはオブジェクトが格納されています。

qrCodes = qrCodesObj['qrCodes']

ストレージのデータの取得の注意点ですが、chrome.storage.local.get(['キー名'])で取得したデータは{キー名: データ}という1つのオブジェクトが返ってきますchrome.storage.local.get('qrCodes')で取得したデータからQRコード一覧のオブジェクトを取得するためには、もう一度qrCodeObj['qrCodes']のようにしてキー名を再び指定する必要があります。

[2] キーを上書きして保存している

chrome.storage.local.set()はRDBのように特定のフィールドを更新する機能はなく、指定されたキーに紐づく値を丸ごと新しい値で置き換えることしかできません。前回のチュートリアルでは"title"キーと"body"キーの値を更新するとき新しい値で置き換えてました。このことは値がオブジェクト型の場合も同じです。ストレージに保存されたオブジェクトは、その一部分(特定のキーだけ)を直接変更することができず、丸ごと新しい値で置き換える必要があります。

ここではqrCodesキーに紐づく値としてQRコードの情報を持つオブジェクトqrCodesに新しいフィールドを追加しています。しかし上記の理由からqrCodesに紐づく値を新しいqrCodesで置き換えています。

オブジェクトを使ってデータを扱っているとORMを使っている感覚になりますが、オブジェクトの特定のキーだけを更新することができないのはRDBとストレージに違いです。

動作確認

それではストレージの構造が更新されているか確認してみましょう。拡張機能一覧(chrome://extensions)へ移動し、「Service Worker」をクリックしてDevToolsを開きます。次にアプリケーションパネルをクリックし、「拡張機能ストレージを」ダブルクリック、「ローカル」をクリックします。

現在はストレージにQRコードの情報が溜まっているので一度ストレージを空にします。右上にある「すべて削除」アイコンがあるのでこちらをクリックします。

その後、適当なページを開いて、右クリックをし、「QRコードに変換」をクリックします。DevToolsのストレージはリアルタイムで更新されないので、手動で更新します。アプリケーションパネルを開いたまま、左上にある「更新」アイコンをクリックします。

するとQRコードを保存するストレージが更新され、整理された構造になったことがわかります。

参考サイト

FileReader
https://developer.mozilla.org/ja/docs/Web/API/FileReader
FileReaderのPromise化
https://ikuma-t.com/blog/file-reader-with-sjis/FileReaderのイベントリスナーの解除について
https://stackoverflow.com/questions/56497788/do-i-need-to-remove-event-listeners-for-filereader-after-im-done-with-it

Discussion