初めてのchrome拡張機能開発の記録


先日、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を参照しても値が入っていない、ということが起こります。

これを踏まえると、ストレージから取得したデータの使い方は、以下の三通りに分かれると思います。

  1. コールバック関数内に処理を記述する。
  2. スコープの広い変数に代入してから、十分に時間を空けて参照する。
  3. 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.jsonmatchesでcontent scriptsの実行対象ページURLを指定しても、そのページで実行されないことがありました。

その原因は未だに不明ですが、backgroundでchrome.tabs.onUpdatedイベントのリスナーを定義してタブの更新を監視すると、上手くいきました。

具体的には、以下のように実装しました。

content_scripts.js
chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
    if (request.command=='updated') {
        // 実行させたい処理
    }
});
event.js
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実行終了後

ということが分かりました。

これに従えば、

  1. event.jsが実行され、onUpdatedのリスナーが定義される。
  2. content_scripts.jsが実行され、onMessageのリスナーが定義される。
  3. onUpdatedが二回目の発火をし、sendMessageが実行され、onMessageが発火する。

というプロセスを経て、特定のサイトで目的の処理が実行されることになります。


自分で用意した画像を表示する

ボタンのアイコン画像を表示するときなどに、自分で用意した画像を使いたいときがあると思います。

しかし、拡張機能のフォルダに画像を入れて、画像の相対パスをsrcに記述しても、画像を表示することはできません。

自分で用意した画像を表示したいときは、

  1. manifest.jsonのweb_accessible_resourcesに画像のパスを記述する。
  2. jsファイルでchrome.extension.getURL関数を使ってURLを取得する。
  3. 取得した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でリンクをクリックできない!

以下は、サムネイル画像に付けられた動画ページへのリンクを機能させるためのリスナー定義の例です(詳細は省きます)。

popup.html
<div id="video-list"></div>
popup.js
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.jsdiffメソッドを使って、ストレージから取得したデータを日付の降順になるようにソートしようとしましたが、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はこのフォーマットだと失敗する」という記述は見当たらなかったのですが、たまたま自分が指定したフォーマットがダメだったのか否かはわかりません。

Moment.js | Docs