Chromeエクステンションを作ろう：コンテントスクリプト編


説明で使用した拡張機能は[こちら](https://chrome.google.com/webstore/detail/redmine-lazy-applicant/dofmgcleeckgckfapnhookpocglpbmgp)からインストールできます. 一人でも多くの方に利用いただければ幸いです.
ソースコードは[こちら](https://bitbucket.org/dhun-h/lazy-applicant)から閲覧できます.


## コンテントスクリプトとは

特定のWebページに、JavascriptやCSSなどを挿入できます.
このプラグインで主に利用した機能になります.

コンテントスクリプトは、セキュリティの観点からいわゆるサンドボックスの中で実行されます.
コンテントスクリプトから対象WebページのDOMにはアクセスできますが、対象Webページのグローバルオブジェクト(グローバル変数やグローバル関数)にはアクセスできません.
また、対象Webページからコンテントスクリプトのグローバルオブジェクトにアクセスすることもできません.

コンテントスクリプトでは、[Chrome-Javascript-API](https://developer.chrome.com/extensions/api_index)の一部を利用することもできます.

### 具体例

```html:対象Webページ
<script>
$('#hoge').on('click', function() {
	console.log('atWebsite');
});
function funcWebsite() {}
$('#hoge').trigger('click'); // ★１
funcContentScript();         // Webページ側では未定義のためエラーになる
</script>

<body>
  <input type="button" id="hoge">
</body>
```

```js:コンテントスクリプト
$('#hoge').on('click', function() {
	console.log('atContentScript');
});
function funcContentScript() {}
$('#hoge').trigger('click');  // ★２
funcWebsite();	              // コンテントスクリプト側では未定義のためエラーになる
```

* ユーザ操作によるボタンクリック ... 'atWebsite'と'atContentScript'がコンソール出力されます
* ★１ ... 'atWebsite'だけがコンソール出力されます
* ★２ ... 'atContentScript'だけがコンソール出力されます

詳細は[公式サイト](https://developer.chrome.com/extensions/content_scripts)を参照してください.

## プラグインで実装したこと
* Redmineのチケット登録ページを表示した時に、、、
	* 既存のHTML要素を変更 (主にイベントハンドラを追加)
	* 新規のHTML要素を追加
	* ストレージの設定の読み込み
	* ストレージへ設定を保存
* Redmineのチケット登録ページで保存ボタンを押した時に、、、
	* ストレージへ設定を保存

### 具体例

#### プラグイン適用前
![inject-elements-pre.png](https://qiita-image-store.s3.amazonaws.com/0/74102/084384bc-9c72-e8d2-e320-4a32a88e844e.png "inject-elements-pre.png")

#### プラグイン適用後
![inject-elements-post.png](https://qiita-image-store.s3.amazonaws.com/0/74102/64b501bb-5919-51c3-97e9-76f5cf53b1f2.png "inject-elements-post.png")


赤枠のようなHTML要素を、チケット登録画面へ追加しています.


## マニフェストファイルの設定 (抜粋)

```json:manifest.json
   ,"content_scripts": [
        {
            "run_at": "document_end"
          , "matches": [
                "*://*/*/projects/*/issues"
               ,"*://*/*/projects/*/issues/new*"
               ,"*://*/projects/*/issues"
               ,"*://*/projects/*/issues/new*"
            ]
           ,"js": [
                "lib/jquery-ui-1.11.0/external/jquery/jquery.js"
               ,"lib/jquery-ui-1.11.0/jquery-ui.min.js"
               ,"lib/jquery-balloon-0.5.1/jquery.balloon.min.js"
               ,"js/env.js"
               ,"js/common/define.js"
               ,"js/common/functions.js"
               ,"js/common/db-functions.js"
               ,"js/common/ui-functions.js"
               ,"js/common/ga-functions.js"
               ,"js/content-scripts/func-for-form.js"
               ,"js/content-scripts/func-for-tracker.js"
               ,"js/content-scripts/func-for-description.js"
               ,"js/content-scripts/func-for-assigner.js"
               ,"js/content-scripts/func-for-applicant-days.js"
               ,"js/content-scripts/func-for-watcher.js"
               ,"js/content-scripts/func-for-date.js"
               ,"js/content-scripts/pre-submit.js"
               ,"js/content-scripts/post-load.js"
               ,"js/content-scripts/main.js"
            ]
           ,"css": [
                "lib/jquery-ui-1.11.0/jquery-ui.min.css"
               ,"css/lazy-applicant.css"
            ]
        }
    ]

   ,"web_accessible_resources": [
        "images/*"
       ,"js/option-page/options.html"
    ]
```

詳細は[公式サイト](https://developer.chrome.com/extensions/manifest)を参照してください.

### run_at

コンテントスクリプトが実行されるタイミングを指定します.

プラグインからテキストフィールドやボタンを挿入するため、DOMは構築済みである必要がありました.
ただし画像リソースやIFrameのロードを待つ必要はなかったため、`"document_end"`を指定しています.

### matches

コンテントスクリプトが実行されるURLパターンを指定します.
Chrome全体の処理遅延を発生させないため、および余計な不具合を混入しないため、URLパターンは最小限にする必要があります.

[URLパターン](https://developer.chrome.com/extensions/match_patterns)ではワイルドカードを利用できます.
ただしホスト名では「全体をワイルドカードにする」か「サブドメイン部分だけをワイルドカードする」かのどちらかしか指定できない. などいくつかの制限があるようです. 正規表現は利用できません.
不正なURLパターンを指定した場合、Chromeに拡張機能をインストールした時点でエラーとして扱ってくれます.

プラグインではチケット登録ページだけで実行させたかったため、前述した４つのURLパターンを指定しました.

### js

拡張機能で利用するJavascriptファイルを指定します.
これは指定した順番に適用されます.

プラグインでは、main.js以外は次のような実装で振る舞いの定義だけをしています.

```js
var lazyApp = lazyApp || {};
!function() {
    'use strict';
    
    // 外部呼び出しのあるメソッド
    lazyApp.xxx = {
        hoge: function() {
        	foo();
        },
        fuga: function() {
        	this.hoge();
        }
    };
    
    // 外部呼び出しのない内部関数
    function foo() {
    }
    function goo() {
    	lazyApp.xxx.fuga();
    }
}();
```


最後に読み込まれるmain.jsでは、次のような実装により必要な処理の呼び出しを制御しています.

```js:main.js
!function() {
    'use strict';

    lazyApp.UiFuncs.prepareBalloonDefaults();
    lazyApp.GaFuncs.prepareGaBackgroundCall();

    lazyApp.Cscript.PostLoad();
    document.addEventListener('submit', lazyApp.Cscript.PreSubmit.do, false);
}();
```


### css

拡張機能で利用するCSSファイルを指定します.
これは指定した順番に適用されます.

HTML要素を挿入する場合は、対象Webページで違和感のないような外観にするとよいでしょう.


### web_accessible_resources

対象Webページから、拡張機能が提供するリソースにアクセスする場合に指定します.

プラグインでは、チケット登録画面へ画像アイコンを追加するためimagesディレクトリ配下を指定しています.
またチケット登録画面からオプション画面へ遷移する動線を用意したため、オプション画面のhtmlファイルも指定しています.


### 公式ドキュメント

* [content_scripts](https://developer.chrome.com/extensions/content_scripts)
* [web_accessible_resources](https://developer.chrome.com/extensions/manifest/web_accessible_resources)


## Webページの編集

### HTML要素

対象WebページのDOMにはアクセスできるので、難しいことはありません.
例えばプラグインでは、トラッカーのセレクトボックスの後ろに、いくつかの要素を追加しています.

```html:対象Webページ
<p>
    <label />
    <select id="issue_tracker_id" />
</p>
```

```js:コンテントスクリプト
var parent = $('#issue_tracker_id').parent();

parent.append($('<select>'));
parent.append($('<input type="checkbox">'));
```

### 画像リソースなど

前述したように「マニフェストファイルのweb_accessible_resources属性」に使用するリソースを指定します.
画像へのURLは[chrome.extension.getURL](https://developer.chrome.com/extensions/runtime#method-getURL)にリソースへのパスを指定することで取得できます.

プラグインでは、画像アイコンを追加しています.

```js:コンテントスクリプト
var icon = $('<img>')
    .addClass('lazyApplicant_icon')
    .attr('src', chrome.extension.getURL('images/question.png'));
```

### Javascript

対象Webページに、Javascriptそのものを追加することができます.
追加したJavascriptは対象ページに組み込まれるため、対象ページ側のグローバルオブジェクトにアクセスすることができます.
ただしコンテントスクリプト側のグローバルオブジェクトにはアクセスできません.

プラグインでは利用していませんが、対象Webページのイベントハンドラを無効にする場合などで実装することになると思います.

```html:対象Webページ
<script>
$('#hoge').on('keypress', function() {
	// 何かしらの処理
});
</script>

<body>
  <input type="text" id="hoge">
</body>
```

```js:コンテントスクリプト
function injectElement(text) {
    var element = document.createElement('script');
    element.type = 'text/javascript';
    element.textContent = text;
    (document.head||document.documentElement).appendChild(element);
}

var script = "$('#hoge').off('keypress')";
injectElement(script);
```

#### 捕捉
動作確認はしていません.
もし動かなかったら、[ソースコード](https://bitbucket.org/dhun-h/lazy-applicant/src/372a152f9603529e87f4bd02f7e62216edeb0c0a/src/js/common/functions.js?at=master&fileviewer=file-view-default)を参照してください.

## tips

### DOM更新を監視してAjax通信の完了を検知する

Redmineでは、トラッカーを変更するとフォームの入力項目も変更されます.
変更後の入力項目を取得するために、Ajax通信を行っていました.

プラグインでは、トラッカーの状態を監視して選択されたトラッカーに応じた設定値を画面に反映する必要がありました.

当初はチケット登録画面のイベントハンドラを無効にして、Redmineと同じAjaxリクエストを発行. 完了時にプラグインの処理を実行するようにしていました.
しかしRedmine2.xと3.xでは、異なるAjaxリクエストを発行していました(コールバック関数名も違う).

Redmine側の処理には極力依存したくなかっっため、次のような実装にしました.

```js:post-load.js
var onReloadDocument = function() {
    lazyApp.Cscript.IssueForm.setup();
};

var timeoutId= null;
var changing = false;
var onChange = function(e) {
    changing = true;
};

$(ID_REPLACEMENT_ROOT).on('DOMNodeInserted', function(e) {
    if (changing == false) {
        return;
    }

    if (timeoutId) {
        clearTimeout(timeoutId);
    }
    timeoutId = setTimeout(function() {
        timeoutId= null;
        changing = false;

        onReloadDocument();
        $(ID_TRACKER).on('change', onChange);
    }, 50);
});
$(ID_TRACKER).on('change', onChange);
```

まずDOMの要素追加イベントを監視します.
トラッカーが変更されるとDOMの要素がいくつか削除／追加されていきます.
いくつあるかわからない連続した要素追加の最後の処理が完了してから、プラグイン側の処理を実行するようにしています.

普段なら絶対書かないトリッキーなコードですが、自分がリクエストしていないAjax通信の完了を検知するため、やむなく採用しました.

コンテントスクリプトでは、このような妥協も必要だと感じました.


## 説明で紹介したプラグインのソースコード
* [manifest.json](https://bitbucket.org/dhun-h/lazy-applicant/src/f371e1be0e9d66311bf81555e098b71c25886eb7/src/manifest.json?at=master&fileviewer=file-view-default)
* [main.js](https://bitbucket.org/dhun-h/lazy-applicant/src/f371e1be0e9d66311bf81555e098b71c25886eb7/src/js/content-scripts/main.js?at=master&fileviewer=file-view-default)
* [post-load.js](https://bitbucket.org/dhun-h/lazy-applicant/src/f371e1be0e9d66311bf81555e098b71c25886eb7/src/js/content-scripts/post-load.js?at=master&fileviewer=file-view-default)
* [functions.js](https://bitbucket.org/dhun-h/lazy-applicant/src/f371e1be0e9d66311bf81555e098b71c25886eb7/src/js/common/functions.js?at=master&fileviewer=file-view-default)