Closed17

【TypeScript】ReactとCRXJS Vite Pluginで作るChrome拡張機能

NanaoNanao

サンプルアプリについて

今回サンプルとして Zenn の閲覧履歴を管理できる Chrome 拡張機能を作成しました。
メッセージパッシングやストレージなど基本的な Chrome 拡張 API を使用しているので学習の補助などにご活用ください。
ソースコードは MIT ライセンスで公開しています。

https://github.com/7oh2020/zenn-history

NanaoNanao

CRXJS Vite Plugin とは?

https://crxjs.dev/vite-plugin

CRXJS Vite Plugin は React や Vue などのフレームワークを使用してモダンな Chrome 拡張を開発するための Vite プラグインです。
高速な HMR(ホットリロード)や静的アセットのインポートなどによる快適な開発が可能です。

NanaoNanao

CRXJS を使うメリット

  • 開発サーバーの起動中はポップアップやサービスワーカーが HMR(ホットリロード)される。
  • React/Vue/SolidJS などのモダンなフレームワークと組み合わせるのが簡単なのでリッチなポップアップが作成できる。
  • ポップアップやサービスワーカーを TypeScript で開発できる。
  • コンテントスクリプトやサービスワーカーを ES Module として開発できる
  • TypeScript で manifest.json を書けるので記述ミスに気づける
NanaoNanao

インストール

今回は React + TypeScript で作成します。
公式ドキュメントには他にも Vue や SolidJS などと組み合わせる方法が記載されています。

最初にビルドツールの Vite をインストールします。
執筆時点ではバージョン 3 がインストールされました。

npm init vite@latest

いくつか質問されるので以下のように入力します:

  • プロジェクト名: crx_example(任意の名前)
  • フレームワーク: React
  • プログラミング言語: TypeScript

この時点ではまだパッケージがインストールされていないためプロジェクトへ移動して npm install を実行します。

cd crx_example
npm install

ここまでは普通の React アプリと同じ手順ですね。
続いて必要なパッケージをインストールしていきます。

最初に今回のメインである CRXJS Vite Plugin を追加します。
(執筆時点では Vite3 への対応がまだベータのようです。)

npm i -D @crxjs/vite-plugin@beta

続いて TypeScript 向けのパッケージを追加します。
@types/chromeは Chrome の API に型付けするためのパッケージです。
@extend-chrome/storageはストレージを扱う際に型付けをしたり async/await が使えるようにするための便利なパッケージです。

npm i @types/chrome
npm i @extend-chrome/storage

最後に vite.config.ts へ Chrome 拡張の Manifest を定義します。
manifest.json は本来 JSON ファイルですが、CRXJS を使用すると以下のように TypeScript で記述できます。
ポップアップを持つ最小の Manifest は以下のようになります。

vite.config.ts
import { crx, defineManifest } from "@crxjs/vite-plugin";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

const manifest = defineManifest({
  manifest_version: 3,
  name: "CRX Example",
  version: "1.0.0",
  action: {
    default_popup: "index.html",
  },
});

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react(), crx({ manifest })],
});

NanaoNanao

ビルドと Chrome への追加

以下のコマンドを実行するだけでビルドが完了します。
開発サーバーも起動するのでその後は HMR によりファイルの変更が即反映されます。
Chrome 拡張機能に必要なファイル一式は dist ディレクトリに出力されます。

npm run dev

続いて生成された dist ディレクトリを Chrome ブラウザへ追加します。

  1. Chrome ブラウザを使用して「Chrome の拡張機能ページ(chrome://extensions/)」へアクセスします。
  2. 「デベロッパーモード」をオンにします。
  3. 「パッケージ化されていない拡張機能を読み込む」を押下します。
  4. 選択ダイアログが開くので先程の dist ディレクトリを選択したら完了です。
NanaoNanao

ポップアップの作成

ポップアップは Chrome のツールバーにある拡張機能のアクションをクリックした時に表示される小さな画面です。
ポップアップを表示するには以下のように Manifest の action.default_popup に HTML ファイルを指定します。

vite.config.ts
import { crx, defineManifest } from "@crxjs/vite-plugin";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

const manifest = defineManifest({
  manifest_version: 3,
  name: "CRX Example",
  version: "1.0.0",
  // ツールバーのアクションをクリックした時の動作
  action: {
    default_popup: "index.html",
  },
});

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react(), crx({ manifest })],
});

ポップアップを開くには Chrome ブラウザのツールバーの拡張機能アイコンをクリックします。
開発中は該当の拡張機能アイコンをツールバーに固定しておくと便利です。

React の Counter アプリが表示されたらポップアップの表示は成功です。

このままだとコンソール出力が確認できない上に拡張機能アイコンをいちいちクリックする必要があるためポップアップを開発する際は次に解説するポップアップの検証を使用します。

NanaoNanao

ポップアップの検証

「ポップアップの検証」を使用するとポップアップを専用ページで開きながら開発ができます。
ポップアップ専用の開発ツールも同時に起動するため通常の WEB 開発と同様にコンソール出力などが確認できます。

使用するには Chrome ブラウザのツールバーの拡張機能アイコンを右クリックして「ポップアップの検証」を選択します。

開発サーバーの起動中はソースコードを変更して保存するとすぐに画面に反映されます。
また今回の、ポップアップは React で作成されているためあとは React の知識だけで画面を開発できます。

NanaoNanao

コンテンツスクリプトの作成

コンテンツスクリプトは WEB ページ毎に実行されるスクリプトです。
ページやタブ同士で競合することなく任意の Javascript や CSS スクリプトを実行できます。
document オブジェクトが扱えるため WEB スクレイピングや WEB ページの書き換えが可能です。

コンテンツスクリプトを追加するには Manifest を以下のように変更します。
以下は「www.google.com」にマッチする URL へアクセスした時に script.ts ファイルを実行する例です。

vite.config.ts
import { crx, defineManifest } from "@crxjs/vite-plugin";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

const manifest = defineManifest({
  manifest_version: 3,
  name: "CRX Example",
  version: "1.0.0",
  content_scripts: [
    {
      matches: ["https://www.google.com/*"],
      js: ["src/contentScript/script.ts"],
    },
  ],
});

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react(), crx({ manifest })],
});

script.ts の例は以下の通りです。
document オブジェクトを操作してページの最下部にアラートを表示するボタンを追加しています。

src/contentScript/script.ts
const body = document.querySelector("body");
if (body) {
  const button = document.createElement("button");
  button.innerText = "アラートを表示";
  button.addEventListener("click", () => {
    alert("おめでとうございます!");
  });
  body.append(button);
}

export {};

開発サーバーを起動して「https://www.google.com」にアクセスするとページの最下部に「アラートを表示」ボタンが追加されています。
クリックして「おめでとうございます!」というダイアログが表示されれば成功です。

NanaoNanao

コンテンツスクリプトの検証

コンテンツスクリプトはページとコンテキストを共有しているので通常の WEB 開発と同様に Chrome 開発ツールを使用して検証できます。
例えばスクリプト内で console.log('Hello World')を呼び出すとページのコンソール出力と一緒に「Hello World」と出力されます。

NanaoNanao

サービスワーカーの作成

サービスワーカーはバックグラウンドで動作するスクリプトです。
多くの Chrome API を扱えるためページ遷移などのイベント検出や後述するストレージの管理などに適しています。
以前はバックグラウンドスクリプトと呼ばれていましたが Manifest V3 からサービスワーカーに移行しました。

サービスワーカーを追加するには以下のように Manifest の background.serviceWorker にスクリプトファイルを指定します。
スクリプトは 1 つのみ登録できます。

vite.config.ts
import { crx, defineManifest } from "@crxjs/vite-plugin";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

const manifest = defineManifest({
  manifest_version: 3,
  name: "CRX Example",
  version: "1.0.0",
  background: {
    service_worker: "src/background/index.ts",
  },
});

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react(), crx({ manifest })],
});

スクリプトの例は以下の通りです。
以下のスクリプトではタブのページ遷移を検知してコンソールに新しい URL を出力します。

src/background/index.ts
/// タブのページ遷移を監視するイベント
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
  // ページの読み込みが未完了の場合はスルー
  if (changeInfo.status !== "complete") {
    return;
  }

  // URLがChromeの管理ページの場合はスルー
  if (tab?.url?.startsWith("chrome://")) {
    return;
  }

  console.log(`Change URL: ${tab.url}`);
});

export {};

サービスワーカーは単一のファイルですが、Vite は ES Module を扱えるので import/export を使用して他のファイルに処理を分離できます。
また、サービスワーカーは DOM を扱えませんが代わりにほぼ全ての Chrome API を使用できます。

NanaoNanao

サービスワーカーの検証

Chrome の拡張機能ページ](chrome://extensions/)の「Service Worker」を押下します。
すると専用の Chrome 開発ツールが開くのでコンソール出力などの確認ができます。

NanaoNanao

Chrome API の利用

公式ドキュメントには使用可能な Chrome API が全て記載されています。
ブックマークやタブの操作など Chrome ブラウザでできることの多くを API から操作できます。

https://developer.chrome.com/docs/extensions/reference/

Chrome API の中には権限を必要とするものがあります。
公式ドキュメントを確認して API に権限の記載がある場合は Manifest の permissions に権限を追記します。

例えばコードをスケジュール実行できる「chrome.alarms」は「alarms」という権限が必要です。
alarms 権限を追加した Manifest は以下のようになります。

vite.config.ts
import { crx, defineManifest } from "@crxjs/vite-plugin";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

const manifest = defineManifest({
  manifest_version: 3,
  name: "CRX Example",
  version: "1.0.0",
  // 権限を追加
  permissions: ["alarms"],
  background: {
    service_worker: "src/background/index.ts",
  },
});

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react(), crx({ manifest })],
});

全ての API を覚えるのは大変なので、必要になったらその都度公式ドキュメントで調べることをおすすめします。
次の見出しからはほぼ必須と言ってもいいほど使用頻度の高いメッセージパッシングとストレージを解説していきます。

NanaoNanao

メッセージパッシングの利用

ポップアップ・コンテンツスクリプト・サービスワーカーはそれぞれ独立しています。
それぞれの間でデータをやり取りしたい場合はメッセージングパッシングという仕組みを利用します。
(ちなみに今回作成したサンプルアプリではポップアップ → サービスワーカー、サービスワーカー → コンテンツスクリプトでメッセージパッシングを行っています。)

1 回メッセージを送信して結果を受け取るだけなら「chrome.runtime.sendMessage」と「chrome.tabs.sendMessage」という Chrome API を使用します。

どちらの API も受信側へ JSON オブジェクトを送信できます。
メッセージを送信して非同期で結果を受信するコード例は以下の通りです。

const handleClick = async () => {
  const res = await chrome.runtime.sendMessage({ action: 'getItems' });
  console.log(res);
};

chrome.tabs.sendMessage はコンテンツスクリプトに対するメッセージを送信するための Chrome API です。
送信先のタブ ID を指定する点が通常のメッセージと異なります。
タブ ID は「chrome.tabs.onUpdated」や「chrome.tabs.query」などの Chrome API から取得できます。

const getDocument = async (tabId: number) => {
  const res = await chrome.tabs.sendMessage(tabId, { action: 'getDocument' });
  console.log(res);
};

メッセージを受信するには「chrome.runtime.onMessage」という Chrome API を使用します。
これをサービスワーカー内に定義すればポップアップからのメッセージが受信できます。
もしくはコンテンツスクリプト内に定義すれば chrome.tabs.sendMessage で送信されたメッセージを受信できます。

コールバック関数の引数はそれぞれ送信データ、送信元情報、返信用の関数です。
戻り値は処理を同期的に待つかどうかを boolean で指定します。(基本的に true で大丈夫です。)

src/background/index.ts
chrome.runtime.onMessage.addListener((request, sender, respond) => {
  // 送信データの内容に応じて処理を分岐できる
  switch (request.action) {
    case "getItems": {
      const items = ["Apple", "Orange", "Melon"];
      // 送信元へデータを返す
      respond(items);
      break;
    }
    default: {
      throw new Error(`no action: ${request.action}`);
    }
  }
  return true;
});
NanaoNanao

メッセージパッシングをもっと便利に利用する

sendMessage 関数は送信データと受信データの型を受け取れます。
型引数を指定しないとどちらも any になってしまうので個人的には以下のような型を作っておくことをおすすめします。

src/types/Message.ts
/// 送信メッセージ
export type SendMessage = {
  action: string;
};

/// 送信メッセージ(値付き)
export type SendMessageWithValue<T> = {
  action: string;
  value: T;
};

使い方の例は以下の通りです。
送受信データが型付けされるので実行時エラーを未然に防げます。

// 送信データの型を指定
const handleClick1 = async () => {
  await chrome.runtime.sendMessage<SendMessage>({ action: 'getItems' });
};

// さらに受信データの型も指定
const handleClick2 = async () => {
  const res = await chrome.runtime.sendMessage<SendMessage, string[]>({
    action: 'getItems',
  });
  console.log(res);
};

// 送信データに値を追加
const handleClick3 = async () => {
  const res = await chrome.runtime.sendMessage<SendMessageWithValue<string>, boolean[]>({
    action: 'addItem',
    value: 'Banana',
  });
  console.log(res);
};
NanaoNanao

ストレージの利用

スクリプト内で宣言した変数や状態は残念ながら長くは保持されません。
ポップアップを閉じたりサービスワーカーがスリープ状態になったタイミングで状態は失われます。
ブラウザを再起動した時も当然データは失われてしまいます。

そこで Chrome API が提供するストレージ APIを利用してデータを保存します。
ストレージにデータを保存しておけばポップアップやサービスワーカーがリセットされてもブラウザを再起動してもデータが失われることはありません。

ストレージ APIを利用するには Manifest の permissions に storage 権限を追加します。

vite.config.ts
import { crx, defineManifest } from "@crxjs/vite-plugin";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

const manifest = defineManifest({
  manifest_version: 3,
  name: "CRX Example",
  version: "1.0.0",
  // 権限を追加
  permissions: ["storage"],
  action: {
    default_popup: "index.html",
  },
  background: {
    service_worker: "src/background/index.ts",
  },
});

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react(), crx({ manifest })],
});

ストレージへの入出力は基本的に「chrome.storage.local.get」または「chrome.storage.local.set」という Chrome API を使用します。
全てのデータはキー文字列で管理されます。以下は items というキー文字列でデータを入出力する例です。

// ストレージを管理するキー文字列
const storageKey = 'items';

// ストレージからデータを取得
chrome.storage.local.get([storageKey]).then((res) => {
  const items = res[storageKey];
  console.log(items);
});

// ストレージにデータをセット
const value = ['Apple', 'Orange', 'Melon'];
chrome.storage.local.set({ [storageKey]: value });

「chrome.storage.sync.get」や「chrome.storage.sync.set」のように local を sync に置き換えると Chrome ブラウザにログインしているユーザー間でデータが共有されます。
一方 local の場合は拡張機能をインストールしたマシンのみにデータが保存されます。

またデータの不整合を防ぐため、Chrome 拡張のインストール時にストレージの初期値をセットしておくことをおすすめします。
chrome.runtime.onInstalled」という Chrome API を使用するとインストールやバージョンアップ時の処理を定義できます。

chrome.runtime.onInstalled.addListener((details) => {
  // ここでストレージに初期値をセットする
});
NanaoNanao

ストレージをさらに便利に利用する

標準のストレージ API はデータを any として扱うため少し不便です。
また、async/await に対応していないためコールバック関数のネストが深くなりがちです。

そこで「@extend-chrome/storage」というパッケージを使うことをおすすめします。
このパッケージを使うメリットは以下の通りです:

  • 入出力データに型付けできる
  • async/await を使用した非同期呼び出しができる
  • データを Bucket という単位でまとめて扱える

インストールするには以下のコマンドを実行します。

npm i @extend-chrome/storage

使い方は以下の通りです。
itemsBucket という名前の Bucket を作成して、それ移行はその Bucket に対して操作しています。

import { getBucket } from '@extend-chrome/storage';

// Bucketの構造を定義
type ItemBucket = {
  items: string[];
};

// Bucketを取得する
const bucket = getBucket<ItemBucket>('itemsBucket');

// Bucketからデータを取得する
const getItems = async () => {
  const res = await bucket.get('items');
  console.log(res.items);
};

// ストレージにデータをセットする
const setItems = async (value: string[]) => {
  await bucket.set({ items: value });
};

// 既存のデータを使用して新しいデータをセットする
const addItem = async (value: string) => {
  await bucket.set(({ items }) => ({ items: [...items, value] }));
};

ちなみに今回作成したサンプルアプリでもこのパッケージを使用しています。
入出力データに型付けしたり async/await が使えるので標準のストレージ API よりも扱いやすいです。

このスクラップは2023/01/10にクローズされました