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

先日、chrome拡張機能『[YouTube Pin Button](https://chrome.google.com/webstore/detail/youtube-pin-button/jiniehblanaeejjdbdnlhhihikoocfmj)』をリリースしました。

JavaScript＆chrome拡張機能初心者だった私が、開発時に得た学びを雑多に書き記していこうと思います。

私と同じような初心者の方の役に立てれば幸いです。

初心者でない方は、間違いがあればコメントで指摘して頂けるとうれしいです。

※以下の本文では、私が作った拡張機能のソースコードを引用している所があります。全てを載せると長くなる部分は省いていますが、詳細を見たい方は[Chrome extension source viewer](https://chrome.google.com/webstore/detail/chrome-extension-source-v/jifpbeccnghkjeaalbbjmodiffmgedin)などのツールを使うか、拡張機能をインストールしてローカルストレージのファイルを見るなどして下さい（GitHubのリポジトリは公開してないので）。

### storage APIについて
データを保存したいときに役に立つのが、`chrome storage API`です。
Key-Value形式でデータを保存します。

これに似たものに`Web Storage`がありますが、こちらは文字列しか保存できないのに対し、`chrome storage API`ではオブジェクトをそのまま保存でき、そのまま取り出せます。

また、`chrome storage API`では拡張機能ごとにデータが保持されるので、キーの衝突をあまり意識する必要がありません。
拡張機能が削除されると同時に、保存していたデータも自動的に消えてくれます。
開発中に「データを一旦すべて消したい」というときにも、`remove`メソッドの呼び出し一つで消せるので便利です。

（その他、詳しくは[公式文書](https://developer.chrome.com/extensions/storage)を参照して下さい）

しかし、JavaScript初心者の私は、取り扱いに苦労しました。

（以下では`chrome.storage.local`を扱いますが、恐らく`chrome.storage.sync`でも共通する内容だと思います。）

#### コールバック関数は非同期処理

ストレージからデータを取り出すメソッドである`chrome.storage.local.get`は、取り出したデータを呼び出し元に**返しません**。取り出したデータは、そのデータを引数に取る**コールバック関数**の中から参照することになります。

例えば以下のコードでは、キーが`hoge`であるデータをストレージから取得し、取得したデータが第二引数のコールバック関数の引数に指定され、実行されます。

```JavaScript
chrome.storage.local.get('hoge', function(items) {
    // itemsの値は、例えば{'hoge': 'hogeValue'}のようになる。
    console.log(items[key]);
});
```

しかし、「データを扱えるのがコールバック関数内だけ」というのは些か不自由に感じます。
以下のように変数に代入して扱いたいと思う人は多いのではないでしょうか。私はそうでした。

```JavaScript
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`に入っているキーの値でストレージのデータを取得したいとき、次のように書いても取得できません。

```JavaScript
chrome.storage.local.get(hoge_key, function(items) {
    // 省略
});
```

なぜなら、「`hoge_key`という**文字列**がキーの値として指定された」と解釈されてしまうからです。
これはJavaScript全般における仕様らしいです。

変数を文字列として解釈されないためには、以下のように[]（ブラケット）で囲みます。

```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`イベントのリスナーを定義してタブの更新を監視すると、上手くいきました。

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

```JavaScript:content_scripts.js
chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
    if (request.command=='updated') {
        // 実行させたい処理
    }
});
```

```JavaScript: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`に記述する。

という手順を踏まなければなりません。

---

### ストレージから取得したデータをソートする

ストレージから取得したデータは辞書形式になっていますが、辞書のままではソートできないので、配列にする必要があります。

```JavaScript
let items_array =  Object.keys(items).map(key => items[key]);
items_array.sort(function(a, b) {
    // 省略
});
```

このとき、キーの情報が失われることに注意して下さい。
キーの情報を保持していたい場合は、以下のように辞書にキーの値を追加しておくといいかもしれません。

```JavaScript
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でリンクをクリックできない！](https://teratail.com/questions/93432)

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

```HTML:popup.html
<div id="video-list"></div>
```

```JavaScript: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`プロパティを持たないので、キーの配列を取得して、その長さを調べます。

```JavaScript
Object.keys(dict).length==0
```

---

### 日時でソートする

`moment.js`の`diff`メソッドを使って、ストレージから取得したデータを日付の降順になるようにソートしようとしましたが、`diff`の実行時にエラーが出て上手くいきませんでした（以下のコードは例です）。

```JavaScript
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);
});
```

その原因は、ストレージに保存した日付文字列のフォーマットでした。

私は最初、「日付のフォーマットはわかりやすければいいや」と思い、以下のように指定していました。

```JavaScript
moment().format("YYYY-MM-DD HH:mm:SS")
```

しかし、これでソート時にエラーが出たので、以下のようにフォーマットを「ISO 8601」に変えてみると、エラーが出なくなりました。

```JavaScript
moment().toISOString()
```

公式の文書を見ても、特に「`diff`はこのフォーマットだと失敗する」という記述は見当たらなかったのですが、たまたま自分が指定したフォーマットがダメだったのか否かはわかりません。

[Moment.js | Docs](https://momentjs.com/docs/#/displaying/difference/)