Chrome拡張のEventPageでchrome.contextMenusを正しく動かす

Chrome拡張開発において癖があるのがEvent Pageにかかわる処理になるかと思います。その中でも、ライフスパンの長さがことなるコンテキストメニューを扱う場合の注意点をご紹介します。

## Event Pageとは
*event page*(以下、イベントページ)とは`manifest.json`で`persistent`を`false`に設定された*background page*のことです。

```json
{
    ...
    "background": {
        "scripts": ["background.js"],
        "persistent": false
    }
    ...
}
```
### ライフサイクル
イベントページは特定のタイミングで読み込まれ、処理が終了すると閉じられます。これは再度読み込まれたときにはグローバル変数を含めてすべてリセットされることを意味します。

イベントページが読み込まれるタイミングは下記になります。

1. 拡張のインストールまたはアップデート時
2. イベントリスナーを登録したイベントが発火したとき
3. `getBackgroundPage`メソッドが他から呼ばれたとき

基本動作としては1でイベントリスナーを登録し、2で処理を行います。

## Event Pageでコンテキストメニューを扱う
イベントページについて理解したところで、本題であるコンテキストメニューを扱う上での注意点を紹介します。

### コンテキストメニューの生成はonInstalledイベントで
イベントページは先に説明したようにイベントが発生するたびに何度も読み込まれます。
このため、グローバルなコンテキストでメニュー生成を行うと、何度もメニューが生成されることになってしまいます。
これを回避するためには、`chrome.runtime.onInstalled`イベントに登録したイベントリスナーでコンテキストメニューの生成を行います。

```js
chrome.runtime.onInstalled.addListener(function () {
    chrome.contextMenus.create({
        type: 'normal',
        id: 'hello',
        title: 'Say hallo'
    });
});
```

### createProperties.onclickは指定してはならない
コンテキストメニュー作成時に呼び出す`chrome.contextMenus.creat`関数の第一引数の`createProperties`には、コンテキストメニューがクリックされた際のイベントリスナーを登録するプロパティ`onclick`が存在します。このプロパティーに関して[APIドキュメント](https://developer.chrome.com/extensions/contextMenus#method-create)には次のような記述があります。

> A function that will be called back when the menu item is clicked. Event pages cannot use this; instead, they should register a listener for chrome.contextMenus.onClicked.

要約するとイベントページでは使えないので代わりに`chrome.contextMenus.onClick`にイベントリスナーを用いてくださいということです。

イベントページでこの使われないこのプロパティに関数を与えると、一見無視されそうです。
しかし、実際に設定するとコンテキストメニューが表示されません。
もし、イベントページでコンテキストメニューが表示されない場合にはチェックする必要があります。

### イベント処理に使う変数をonInstalledイベントで生成してはいけない
前述のようにイベントページでコンテキストメニューを処理する場合、`chrome.contextMenus.onClick`イベントを用います。
このイベントは、開発している拡張で作成したコンテキストメニューのどれかがクリックされたタイミングで発火します。
そして、このイベントのイベントリスナーではどのコンテキストメニューが呼ばれたのかを`createProperties`で指定した`id`を用いてディスパッチする必要があります。

さて、ここでディスパッチ処理とコンテキストメニュー生成を分離するために次のクラスを用意しました。

```js
var ContextMenus = function () {
    var items = this.items = {};

    chrome.contextMenus.onClicked.addListener(function (info, tab) {
        items[info.menuItemId].onclick(info, tab);
    });
};

ContextMenus.prototype = {
    create: function (properties) {
        this.items[properties.id] = {
            onclick: properties.onclick
        };

        properties.onclick = null;
        chrome.contextMenus.create(properties);
    }
};
```

次に、コンテキストメニューの生成は`onInstalled`イベントで行うということだったので次の処理を追加します。

```js
chrome.runtime.onInstalled.addListener(function () {
    var contextMenus = new ContextMenus();

    contextMenus.create({
        type: 'normal',
        id: 'hello',
        title: 'Say hallo',
        onclick: sayHallo
    });
});

function sayHallo () {
    console.log('hello');
}
```

このコードを動かしてみるとバックグラウンドページのコンソールに"hello"と表示されます。
さて、しばらくたってからコンテキストメニューの"Say hallo"を選択してみましょう。
すると、なんと言うことでしょう、今度はコンソールに何も表示されないのです。

上記のコードの問題点は次の2点になります。

- `onClicked`イベントへのリスナー登録が`onInstalled`イベント時のみ行われる
- 各メニューのイベントハンドラを保存している変数の初期化が`onInstalled`イベント時のみ行われる

この結果`onClicked`イベントが処理できないのです。
この現象のいやらしいところは、開発中で頻繁に拡張の再読み込みをしていると`onInstalled`イベントが発生した直後であることが多く、イベントページが生き残っており、このタイミングに限り`onClicked`イベントが処理できてしまうことでしょう。

では、最後に先ほどのコードを修正したものをお見せします。

```js
var ContextMenus = new function () {
    var items = {};
    var callbacks = {};

    this.setItems = function (aItems) {
        aItems.forEach(function (item) {
            callbacks[item.id] = item.onclick;
            item.onclick = null;
            items[item.id] = item;
        });
    };

    this.create = function () {
        Object.keys(items).forEach(
            function (key) {
                chrome.contextMenus.create(items[key]);
            }
        );
    };

    chrome.contextMenus.onClicked.addListener(function (info, tab) {
        callbacks[info.menuItemId](info, tab);
    });
};

ContextMenus.setItems([
    {
        type: 'normal',
        id: 'hello',
        title: 'Say hallo',
        onclick: sayHallo
    }
]);

chrome.runtime.onInstalled.addListener(ContextMenus.create);
```