はらへり日記

腹に弾丸

今さら聞けないChromeエクステンションの作り方

この記事は

JavaScriptアドベントカレンダーの1日目の記事です。

qiita.com

JavaScriptというよりはChromeの話かもしれませんが、最近エクステンションを作って楽しかったので簡単に作り方を書いてみます。

Chromeエクステンションとは

github-label-creater

私は普段、GitHubで開発するときにIssueのラベルを自分好みに変えてから使うのですが、

毎回作り直すのが面倒でブックマークレットを作ろうとしました。

ただ、ブックマークレットだとソースコードの量が限られてしまうので、Chromeエクステンションにしようと思い立ち、github-label-createrなるエクステンションを作成しました。

github.com

こんな感じで使えます。

エクステンションの種類

一言でエクステンションと言っても、いくつか種類があります。

とてもざっくり言うと以下の3種類のタイプが存在します。

Browser Action

追加するとURLのバーの横にアイコンが追加され、それでいろいろ機能が使えるタイプ。

はてブのエクステンションなんかはこれですね。

Page actions

特定のページでのみ動くエクステンションです。

Override Pages

Chromeの内部ページを書き換えるタイプのエクステンションです。

作りたいもの

今回作りたいものの要件は以下の通り。

  • https://github.com/{username}/{reponame}/labelsでのみ動けばよい
  • ページ上のDOMを操作してラベルを作り替える
  • アイコンをクリックして出てくるボタンを押すとラベルが作り替わってほしい

ということで、先ほどのPage ActionsとBrowser Actionの機能を組み合わせて作っていきます。

雑な実装イメージ図

構造的にはこんな感じで作っていきます

Content Scripts

まずは特定のページ上で読み込まれるContent Scriptsを作成します。

Chromeエクステンションではエクステンションの情報や読み込むスクリプトの情報をmanifest.jsonというファイル名で作成します。

{
  "manifest_version": 2,
  "name": "GitHub label creater",
  "version": "0.0.1",

  "description": "Create labels of GitHub.",

  "author": "sota1235"
}

ここにcontent_scriptsという名前で情報を追加します。

今回はhttps://github.com/*/*/labelsに一致するページでdist/index.jsを読み込ませたいので以下のように書きます。

URL matchの記法はドキュメントで確認できます。

{
  "manifest_version": 2,
  "name": "GitHub label creater",
  "version": "0.0.1",

  "description": "Create labels of GitHub.",

  "content_scripts": [
    {
      "matches": [
        "https://github.com/*/*/labels"
      ],
      "js": ["dist/index.js"]
    }
  ],

  "author": "sota1235"
}

これでGitHubのLabelページでdist/index.jsが読み込まれるようになりました。

Browser Action

今回は「アイコンをクリックして出てくるボタンをクリックしたらWebページにアクションを起こす」といった形で実装するので、Browser Actionのスクリプトと設定を追加します。

Browser Actionではアイコンがクリックされたときに表示するHTMLを設定できます。

今回はpopup.htmlを表示するようにします。

manifest.jsonに以下の設定を追加しましょう。

{
  "browser_action": {
    "default_icon": {
    },
    "default_title": "GitHub label creater",
    "default_popup": "popup.html"
  }
}

default_iconのところにはアイコンに表示したい画像を入れることができますが、無い場合は空でも動きます(誰かアイコン作って…|ω・`))。

popup.html上ではボタンがクリックしたときの処理をしたいので<script>タグを使ってdist/popup.jsを読み込んでいます。

Browser ActionとContent Scriptsで通信する

さて、ここから少しChromeエクステンションならではの処理が出てきます。

仕様上、Browser Actionからページ上のDOMに直接アクセスすることはできません。

なのでBrowser Actionからイベントを発火し、Content Script側でそれを監視する形で実装を行います。

Browser Actionでのイベント発火

API等のドキュメントはこちら

import domready from 'domready';
import $ from 'jquery';

domready(async () => {
  // 作成するラベルデータ一覧
  const labels = await get('labels');

  $('button.create-labels').on('click', () => {
    
    // 
    chrome.tabs.query({active: true, currentWindow: true}, tabs => {
      chrome.tabs.sendMessage(tabs[0].id, { type: 'CLICK_POPUP', labels });
    });
  });
});

ただ、あらかじめmanifest.jsonchrome.tabsAPIの使用を許可する必要があります。

以下のように追記しましょう。

{
  "permissions": [
    "tabs", "https://github.com/*/*/labels"
  ]
}

これで現在表示されてるタブのContent ScriptにイベントをPublishできます。

Content Scriptでのイベント監視

Content Script側でのイベント監視はこんな感じ。

import domready from 'domready';
import { createNewLabels, deleteLabels } from './label-creater';

domready(() => {
  // popup.jsからのイベント監視
  chrome.runtime.onMessage.addListener(message => {
    // コールバックでpopup.jsからの値を受け取れる
    if (message.type !== 'CLICK_POPUP') {
      return;
    }

    // ここでページ上のDOMを書き換える
    if (window.confirm('All labels will be overwritten. Are you OK?')) {
      deleteLabels();
      createNewLabels(message.labels);
    }
  });
});

これでアイコン上のボタンを押すとContent Scriptが実行される流れが作れました!

History APIの罠

ただ、このままだと特定の条件で動作することができません。

Content Scriptはそのページに対してHTTPリクエストが飛んだ際に読み込まれますが、History APIによるURL書き換えの際は読み込まれません。

GitHubではIssueページからLabelページに行くタイミング等ではHistory APIによる遷移を行っているため、Content Scriptが読み込まれません。

そこでBackground Scriptを使って少しハックします。

Chromeエクステンションではchrome.webNavigationというAPIが用意されており、これに生えてるonHistoryStateUpdated.addListenerでURL履歴が変更された際のイベントをcatchできます。

なのでこれを利用し、History update時にContent Scriptを読み込むBackground Scriptを作成します。

まずmanifest.jsonに以下を書き足します。

{
  "background": {
    "scripts": ["background.js"]
  }
}

webNavigatoinAPIの使用を許可するため、permissionsに値を足しましょう。

{
  "permissions": [
    "https://github.com/*/*/labels", "tabs", "webNavigation"
  ]
}

そして以下のようなシンプルなBackground Scriptを作成します。

/** @var {boolean} Is the content script always executed. */
let isExecuted = false;

chrome.webNavigation.onHistoryStateUpdated.addListener(() => {
  if (!isExecuted) {
    chrome.tabs.executeScript(null, { file: "dist/index.js"} );
  }

  isExecuted = true;
});

ページ遷移するたびにContent Scriptを実行するとイベントが多重に登録されてしまうので、変数でイベント登録済みかどうか判定しています。

これでHTTPリクエストを伴わないページ遷移時にもContent Scriptが呼ばれるようになりました!めでたし!

開発の時に便利だったもの

Content Scriptはページで読み込まれるのでconsole.logデバッグできますが、Browser Actionでは同じようにデバッグができません。

そこで、chromeにエクステンションを読み込ませた際に発行されるextension IDを利用して直接、popup.htmlにアクセスすることができます。

下記画像のID部分を

chrome-extension://{エクステンションID}/popup.htmlといった形式でアクセスします。

これでデバッグ作業ができます!

まとめ

独自APIとか仕様に振り回されそうな印象が強く、今まで避けてたエクステンション開発でしたが思ったより簡単だなというのが僕の感想です。

古い情報とかも多いので困ったら公式ドキュメントを読めばだいたい解決するのでみなさんもぜひ何か作ってみてください。

ブックマークレットと違ってnpmライブラリを使えたりするので幅は広いんじゃないかなと思います。

参考サイト

Google Chrome Extensionを作ってみた-その2(デバッグ)- | Developers.IO

Chromeのオリジナル拡張機能を開発しよう(ソースコードあり) | 株式会社LIG

javascript - Chrome extension Content Script not loaded until page is refreshed - Stack Overflow