QRコードを作成
この節では現在開いているサイトのQRコードを作成し、それを新しいウィンドウで表示する拡張機能を作っていきます。いままでは自分で用意した機能しか使っていなかったのですが、QRコードの作成の処理は外部APIを使います。また、データを表示するUIとして今まではポップアップかコンテンツスクリプトを使っていたのですが、新たにタブを作成してHTMLページを表示してみます。さらに、右クリックで表示されるコンテキストメニューと呼ばれるものを使用してイベントを発生させる方法も学びます。まとめると、今回新たに学ぶ内容は次のようなものになります。
- 外部APIを使う
- タブを作成する
- コンテキストメニューを使う
準備
qrcode
という名前の新しいフォルダを作成します。次に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ページ上で右クリックをしたときに現れるメニュー画面のことです。コンテキストメニューに表示される項目をメニュー項目と呼びます。
メニュー項目を追加する
次にメニュー項目を追加する処理を書きます。contextMenues
APIはサービスワーカーでしか使えないのでサービスワーカーのファイルを作成します。servie-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
で定義されており、ここではid
、title
、contexts
というプロパティーを利用します。createProperties
には必須項目はありません。
id
は作成されるメニュー項目を一意に識別するための文字列です。title
はユーザーがコンテキストメニューを開いたときに実際に表示されるメニュー項目のテキストです。contexts
はどこを右クリックしたときにメニュー項目を表示するかを指定する配列です。all
を指定するとページ上を右クリックするとメニュー項目が表示されます。"contexts"
キーで指定できる項目は他にも項目があり、特定の箇所をクリックしたときにのみコンテキストメニューが表示されるように制限することができます。選択したテキスト上のときのみ表示するselection
、画像上で右クリックをしたときのみ表示されるimage
、それ以外の箇所をクリックしたときに表示されるpage
などがあります。
その他のプロパティはこちらをご覧ください。
動作確認
拡張機能を読み込み、適当なページ上で右クリックをしてみましょう。すると下の画像のように先ほど設定したメニュー項目が追加されました。メニュー項目のタイトルの左隣に画像が表示されていますが、これはmanifest.json
で"16"
で設定した16x16ピクセルのアイコン画像です。create()
でアイコンの表示を指定する項目はなく、自動的に項目メニューの左端に拡張機能のアイコンが表示されます。
現時点ではメニュー項目をクリックしても何も変化が起こりません。次はメニュー項目がクリックされたときの挙動を作成しましょう。
メニュー項目がクリックされたときの挙動を作成
contextMenus
APIが使えるのはサービスワーカーだけなので、引き続き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
からmenuItemId
とpageUrl
を取り出します。menuItemId
はchrome.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コードの画像が返ってきます。ここではsize
に200x200
を渡して200ピクセルの正方形のサイズを指定し、data
にURLを渡してURLのQRコードを作成します。
④URLのエンコード
URLの中に日本語や記号が含まれるとURLが機能しない場合があるのでエンコードしています。
⑤タブを作成してページを開く
ここで初めてchrome.tabs
APIが出てきました。拡張機能を使うと、タブの作成、削除、開いているタブ情報の読み取りを行うことができます。ここでは新しいタブを作成して、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.json
のpermissions
に"storage"
を追記する必要があります。今回のように後から機能を追加するときは"permissions"
キーに権限が必要なAPIを追記するのを忘れがちで、何度もよくあるミスです。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形式で文字列にエンコードし、文字列に変換した画像をストレージに保存するコードを書いていきましょう。
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に変換する
Fetch
APIは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
に次のコードを追加します。
...
"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
という名前のファイルを作成し、次のコードを追加してください。
<!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
という名前のファイルを作り、次のコードを追加してください。
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] ストレージから全てのデータを取り出す
storage
APIはコンテンツスクリプトでは使えませんが、ポップアップとサービスワーカーで使うことができます。もし全てのデータを取り出したいのであれば引数に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
を開き次のようにコードを修正します。
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
FileReaderのPromise化
Discussion