先日、chrome拡張機能『YouTube Pin Button』をリリースしました。
JavaScript&chrome拡張機能初心者だった私が、開発時に得た学びを雑多に書き記していこうと思います。
私と同じような初心者の方の役に立てれば幸いです。
初心者でない方は、間違いがあればコメントで指摘して頂けるとうれしいです。
※以下の本文では、私が作った拡張機能のソースコードを引用している所があります。全てを載せると長くなる部分は省いていますが、詳細を見たい方はChrome extension source viewerなどのツールを使うか、拡張機能をインストールしてローカルストレージのファイルを見るなどして下さい(GitHubのリポジトリは公開してないので)。
storage APIについて
データを保存したいときに役に立つのが、chrome storage API
です。
Key-Value形式でデータを保存します。
これに似たものにWeb Storage
がありますが、こちらは文字列しか保存できないのに対し、chrome storage API
ではオブジェクトをそのまま保存でき、そのまま取り出せます。
また、chrome storage API
では拡張機能ごとにデータが保持されるので、キーの衝突をあまり意識する必要がありません。
拡張機能が削除されると同時に、保存していたデータも自動的に消えてくれます。
開発中に「データを一旦すべて消したい」というときにも、remove
メソッドの呼び出し一つで消せるので便利です。
(その他、詳しくは公式文書を参照して下さい)
しかし、JavaScript初心者の私は、取り扱いに苦労しました。
(以下ではchrome.storage.local
を扱いますが、恐らくchrome.storage.sync
でも共通する内容だと思います。)
コールバック関数は非同期処理
ストレージからデータを取り出すメソッドであるchrome.storage.local.get
は、取り出したデータを呼び出し元に返しません。取り出したデータは、そのデータを引数に取るコールバック関数の中から参照することになります。
例えば以下のコードでは、キーがhoge
であるデータをストレージから取得し、取得したデータが第二引数のコールバック関数の引数に指定され、実行されます。
chrome.storage.local.get('hoge', function(items) {
// itemsの値は、例えば{'hoge': 'hogeValue'}のようになる。
console.log(items[key]);
});
しかし、「データを扱えるのがコールバック関数内だけ」というのは些か不自由に感じます。
以下のように変数に代入して扱いたいと思う人は多いのではないでしょうか。私はそうでした。
let hoge;
chrome.storage.local.get('hoge', function(items) {
hoge = items[key];
});
console.log(hoge);
ところがこのコードを実行すると、コンソールへの出力はundefined
――つまり「変数hoge
に値が代入されていない」という結果になってしまいます。これはなぜか。
結論を言うと、「コールバック関数は非同期処理」だからです。
get
メソッドがストレージから値を取得し終えると、コールバック関数の実行が開始され、その終了を待たずに次の処理console.log(hoge);
に進んでしまいます。
なので、get
メソッドを実行してすぐにhoge
を参照しても値が入っていない、ということが起こります。
これを踏まえると、ストレージから取得したデータの使い方は、以下の三通りに分かれると思います。
- コールバック関数内に処理を記述する。
- スコープの広い変数に代入してから、十分に時間を空けて参照する。
-
async/await
を使って、コールバック関数の実行終了を待つ。
3つ目の方法を使っても上手くいかなかったので、私は1つ目と2つ目の方法を使いました。
setメソッドは上書き保存
chrome.storage.local.set
メソッドは、すでに同じキーの値が存在する場合、上書き保存します。
どこかのサイトで「上書き保存しない」と目にしてそれを信じ込み、毎回「remove
メソッドで値を削除し、set
メソッドで値を追加し直す」という処理をしていましたが、完全に無駄でした。
キーを変数の値で指定したいときは、[](ブラケット)で囲む
例えば、hoge_key
という変数にキーの値が入っているとします。
hoge_key
に入っているキーの値でストレージのデータを取得したいとき、次のように書いても取得できません。
chrome.storage.local.get(hoge_key, function(items) {
// 省略
});
なぜなら、「hoge_key
という文字列がキーの値として指定された」と解釈されてしまうからです。
これはJavaScript全般における仕様らしいです。
変数を文字列として解釈されないためには、以下のように[](ブラケット)で囲みます。
chrome.storage.local.get([hoge_key], function(items) {
// 省略
});
ドット演算子でアクセスできないときは、[](ブラケット)で囲む
JavaScriptでは、辞書dict
とキーkey
を使って、dict.key
のように値にアクセスできるらしいのですが、get
メソッド内でitems.key
とするとエラーが出ることがありました。
そのようなときは、items[key]
とすることでアクセスできるようになりました。
※これはあくまで対症療法で、原因はまだ分かっていません。
content scriptsを特定のサイトで確実に実行させる
manifest.json
のmatches
でcontent scriptsの実行対象ページURLを指定しても、そのページで実行されないことがありました。
その原因は未だに不明ですが、backgroundでchrome.tabs.onUpdated
イベントのリスナーを定義してタブの更新を監視すると、上手くいきました。
具体的には、以下のように実装しました。
chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
if (request.command=='updated') {
// 実行させたい処理
}
});
chrome.tabs.onUpdated.addListener(function(tabId, info, tab) {
if (info.status==='complete') {
if (tab.url.indexOf('https://hoge.com')!==-1){
chrome.tabs.sendMessage(tabId, {'command': 'updated'});
}
}
});
私が検証したところによると、
- backgroundはブラウザを起動したタイミングで、content scriptsよりも先に実行される。
-
onUpdated
イベントは、タブの読み込み時(loading)と読み込み完了時(complete)の二回発火するが、読み込み完了時の発火はcontent scripts実行終了後。
ということが分かりました。
これに従えば、
-
event.js
が実行され、onUpdated
のリスナーが定義される。 -
content_scripts.js
が実行され、onMessage
のリスナーが定義される。 -
onUpdated
が二回目の発火をし、sendMessage
が実行され、onMessage
が発火する。
というプロセスを経て、特定のサイトで目的の処理が実行されることになります。
自分で用意した画像を表示する
ボタンのアイコン画像を表示するときなどに、自分で用意した画像を使いたいときがあると思います。
しかし、拡張機能のフォルダに画像を入れて、画像の相対パスをsrc
に記述しても、画像を表示することはできません。
自分で用意した画像を表示したいときは、
- manifest.jsonの
web_accessible_resources
に画像のパスを記述する。 - jsファイルで
chrome.extension.getURL
関数を使ってURLを取得する。 - 取得したURLを
img
タグのsrc
に記述する。
という手順を踏まなければなりません。
ストレージから取得したデータをソートする
ストレージから取得したデータは辞書形式になっていますが、辞書のままではソートできないので、配列にする必要があります。
let items_array = Object.keys(items).map(key => items[key]);
items_array.sort(function(a, b) {
// 省略
});
このとき、キーの情報が失われることに注意して下さい。
キーの情報を保持していたい場合は、以下のように辞書にキーの値を追加しておくといいかもしれません。
let items_array = Object.keys(items).map(key => {
items[key].hogeKey = key;
return items[key];
});
popup.htmlのリンクを機能させる
popup.html
では、a
リンクを設置しても、そのままでは機能しません。
リンクを機能させるにはclick
リスナーを追加する必要があります。
ただし、forループなどで動的にa
タグをページに追加する場合には、さらに注意が必要です。
なぜなら、タグ追加処理の後にリスナー追加処理を記述したとしても、リスナー追加処理を実行する際にタグが存在するかどうかは分からないからです。
なので、**イベントバブリング(イベント伝播)**という仕組みを使う必要があります。
その仕組みを使って、確実に存在する親要素にリスナーを追加し、子要素で発生したイベントを親要素でキャッチします。
参考文献:chrome拡張機能のpopupでリンクをクリックできない!
以下は、サムネイル画像に付けられた動画ページへのリンクを機能させるためのリスナー定義の例です(詳細は省きます)。
<div id="video-list"></div>
for (/* 略 */) {
let videoUrl = /* 略 */;
let imgUrl = /* 略 */;
let elem_str = `
<a class="video-url" href="${videoUrl}">
<img src="${imgUrl}">
</a>
`;
$(elem_str).appendTo('#video-list');
}
$('#video-list').on('click', '.video-url', function(e) {
chrome.tabs.create({url: $(e.currentTarget).attr('href')});
});
参考文献の例とページの構造が違うため、target
ではなくcurrentTarget
を使っています。
辞書が空か否かを判定する
辞書が空か否かを判定する一般的な方法は、以下の通りです。
辞書はlength
プロパティを持たないので、キーの配列を取得して、その長さを調べます。
Object.keys(dict).length==0
日時でソートする
moment.js
のdiff
メソッドを使って、ストレージから取得したデータを日付の降順になるようにソートしようとしましたが、diff
の実行時にエラーが出て上手くいきませんでした(以下のコードは例です)。
items_array.sort(function(a, b) {
// 日付文字列からmomentインスタンスを生成する
let a_m = moment(a["lastModified"]);
let b_m = moment(b["lastModified"]);
return b_m.diff(a_m);
});
その原因は、ストレージに保存した日付文字列のフォーマットでした。
私は最初、「日付のフォーマットはわかりやすければいいや」と思い、以下のように指定していました。
moment().format("YYYY-MM-DD HH:mm:SS")
しかし、これでソート時にエラーが出たので、以下のようにフォーマットを「ISO 8601」に変えてみると、エラーが出なくなりました。
moment().toISOString()
公式の文書を見ても、特に「diff
はこのフォーマットだと失敗する」という記述は見当たらなかったのですが、たまたま自分が指定したフォーマットがダメだったのか否かはわかりません。