🏍️

CRXJSを使ってReact(Vite+Docker)でChrome拡張を開発してみる

2023/05/16に公開

初めに

ReactでChromeの拡張機能を開発する際、CRXJというライブラリが使いやすかったので実際にこのライブラリを使った開発の流れを紹介したいと思います。

CRXJSはviteを使って拡張機能を高速に開発できるライブラリです。

manifest.jsonにファイル名を書いておけば、ビルドディレクトリが編集中のファイルを直接参照するため、各ファイルの更新が即座に反映されます。

実際の開発の流れを見てみましょう。

GitHubリポジトリ

ディレクトリ構成

.
├── Dockerfile
├── docker-compose.yml
├── index.html
├── manifest.config.ts
├── manifest.json
├── options.html
├── package.json
├── public
│   └── vite.svg
├── src
│   ├── assets
│   │   ├── favicon.svg
│   │   └── logo.svg
│   ├── background.ts
│   ├── components
│   │   └── Button.tsx
│   ├── content_scripts
│   │   └── content_script.tsx
│   ├── options.tsx
│   ├── popup.tsx
│   └── vite-env.d.ts
├── tsconfig.json
└── vite.config.ts

React + CRXJS + Viteでプロジェクトを作成

CRXJSのドキュメントを参照しながら進めます。

このドキュメントの手順を、自分の環境に合うように修正しつつ進めていきます。
Create a project|CRXJS Vite Plugin

プロジェクト作成

npm init vite@latest

$ npm init vite@latest
Need to install the following packages:
  create-vite@4.3.1
Ok to proceed? (y) y
✔ Project name: … vite-project
✔ Select a framework: › React
✔ Select a variant: › TypeScript

CRXJSをインストール

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

SVGRのインストール (必要な人だけ)

React componentsでsvgを使いたい場合はインストール
vite-plugin-svgr.

npm i vite-plugin-svgr

Change vite-env.d.ts

/// <reference types="vite/client" />
/// <reference types="vite-plugin-svgr/client" />

Vite configの修正

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { crx, ManifestV3Export } from "@crxjs/vite-plugin";
import manifest from "./manifest.json";
import svgr from "vite-plugin-svgr";

export default defineConfig({
	plugins: [
		svgr(),
		react(),
		crx({ manifest: manifest as unknown as ManifestV3Export }),
	],
});

manifest.jsonファイルをルートディレクトリに作成します。

{
	"name": "Extension App",
	"description": "",
	"version": "0.0.1",
	"manifest_version": 3,
        "action": {
		"default_popup": "index.html",
		"default_title": "Open Extension App"
	}
}

tsconfigをまとめる(必要な人だけ)

構成を簡潔にしたかったので、tsconfig.node.jsontsconfig.jsonに統合しました。

{
	"compilerOptions": {
		"composite": true,
		"module": "ESNext",
		"moduleResolution": "Node",
		"allowSyntheticDefaultImports": true,
		"target": "ESNext",
		"useDefineForClassFields": true,
		"lib": ["DOM", "DOM.Iterable", "ESNext"],
		"allowJs": false,
		"skipLibCheck": true,
		"esModuleInterop": true,
		"strict": true,
		"forceConsistentCasingInFileNames": true,
		"resolveJsonModule": true,
		"isolatedModules": true,
		"noEmit": true,
		"jsx": "react-jsx"
	},
	"include": ["src", "vite.config.ts", "*.json"]
}

プロジェクトを動かしてみる

npm run dev

Manage Extensionsページを開きます。
chrome://extensions/

右上にあるdveloper modeスイッチをオンにします。

左上にあるLoad unpackedボタンをクリックして、distディレクトリを選択。

Build

プロジェクトを実際にアップロードする時は、buildする必要があります。

npm run build

Dockerfileを作成

開発環境をすぐに構築する為にDockerを構築します。

Dockerfile

FROM node:18.15.0-alpine3.16

WORKDIR /usr/src/app

COPY package*.json ./

RUN yarn install

COPY . .

docker-compose.yml

version: '3'

services:
  extension:
    container_name: extension
    hostname: extension
    restart: always
    tty: true
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - 5173:5173
    volumes:
      - .:/usr/src/app
    command: yarn dev --host
    networks:
      - default
    platform: linux/amd64

networks:
  default:

私はM1のMacBookを使っているので、docker-compose.ymlファイルに platform: linux/amd64 の記述を追加して、Dockerの設定の Use Rosetta for x86/amd64 emulation of Apple Silicon をONにしました。

Run Docker

docker compose up -d --build

Popupnの修正

ファイル名と機能が一致せず、ちょっとわかりにくいので、少し修正します。
App.txを削除して、main.txのファイル名をpopup.txに変更しました。

次に、popup.tsxを修正します。

import React, { useState } from "react";
import ReactDOM from "react-dom/client";
import logo from "./assets/logo.svg";

function Popup() {
	const [count, setCount] = useState(0);

	return (
		<div className="App" style={{ height: 300, width: 300 }}>
			<header className="App-header">
				<img
					src={chrome.runtime.getURL(logo)}
					className="App-logo"
					alt="logo"
				/>
				<p>Hello Vite + React!</p>
				<p>
					<button type="button" onClick={() => setCount((count) => count + 1)}>
						count is: {count}
					</button>
				</p>
				<p>
					Edit <code>App.tsx</code> and save to test HMR updates.
				</p>
				<p>
					<a
						className="App-link"
						href="https://reactjs.org"
						target="_blank"
						rel="noopener noreferrer"
					>
						Learn React
					</a>
					{" | "}
					<a
						className="App-link"
						href="https://vitejs.dev/guide/features.html"
						target="_blank"
						rel="noopener noreferrer"
					>
						Vite Docs
					</a>
				</p>
			</header>
		</div>
	);
}

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
	<React.StrictMode>
		<Popup />
	</React.StrictMode>
);

また、index.htmlファイルのscriptタグのsrc属性を変更したファイル名に修正します。

<script type="module" src="/src/popup.tsx"></script>

Content Scriptsの作成

manifest.jsoncontent_scriptsの項目を追加。

{
	"name": "Extension App",
	"description": "",
	"version": "0.0.1",
	"manifest_version": 3,
	"action": {
		"default_popup": "index.html",
		"default_title": "Open Extension App"
	},
	"content_scripts": [
		{
			"matches": ["<all_urls>"],
			"js": ["src/content_scripts/content_script.tsx"]
		}
	]
}

content_scriptsディレクトリをsrcディレクトリの中に作成して、サンプルのcontent_scriptコンポーネントを作ります。

src/content_scripts/content_script.tsx.

import React from "react";
import ReactDOM from "react-dom/client";
import Button from "../components/Button";

function ContentScript() {
	return (
		<div className="App">
			<header className="App-header">
				<h1>ContentScript</h1>
				<Button>button</Button>
			</header>
		</div>
	);
}

const index = document.createElement("div");
index.id = "content-script";
document.body.appendChild(index);

ReactDOM.createRoot(index).render(
	<React.StrictMode>
		<ContentScript />
	</React.StrictMode>
);

同時にcomponentディレクトリとその中にButton.tsxファイルを作成しました。

import React from "react";

const Button = (props: any) => <button {...props} />;

export default Button;

Backgroundの作成

manifest.jsonbackgroundの項目を定義します。

注: クロームAPIの特定の機能を利用したい場合は、permissionsに機能に対応した特定のパーミッションを追加する必要がある(今回はbackgroundを作成するためbackgroundを追加してます)。以下参考。
Chrome Extensions Declare permissions

{
	"name": "Extension App",
	"description": "",
	"version": "0.0.1",
	"manifest_version": 3,
	"action": {
		"default_popup": "index.html",
		"default_title": "Open Extension App"
	},
	"content_scripts": [
		{
			"matches": ["<all_urls>"],
			"js": ["src/content_scripts/content_script.tsx"]
		}
	],
	"background": {
		"service_worker": "src/background.ts",
		"type": "module"
	},
	"permissions": [
		"background",
		"contextMenus",
		"bookmarks",
		"tabs",
		"storage",
		"history"
	]
}

background.tsファイルをsrcディレクトリ配下に作成。

以下のbackground.tsのサンプルコードには、タブを変更を検知する機能と、ブックマークを取得する機能を加えました。

chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
	console.log(`Change URL: ${tab.url}`);
});

chrome.bookmarks.getRecent(10, (results) => {
	console.log(`bookmarks:`, results);
});

console.log(`this is background service worker`);

export {};

Optionsの作成

オプションページを作成していきます。
オプションページとは、ブラウザのツールバーの拡張機能アイコンを右クリックし、「オプション」を選択することでアクセスできるページです。

manifest.jsonoptions_page`セクションを追加します。

{
	"name": "Extension App",
	"description": "",
	"version": "0.0.1",
	"manifest_version": 3,
	"action": {
		"default_popup": "index.html",
		"default_title": "Open Extension App"
	},
	"content_scripts": [
		{
			"matches": ["<all_urls>"],
			"js": ["src/content_scripts/content_script.tsx"]
		}
	],
	"background": {
		"service_worker": "src/background.ts",
		"type": "module"
	},
	"options_page": "options.html",
	"permissions": [
		"background",
		"contextMenus",
		"bookmarks",
		"tabs",
		"storage",
		"history"
	]
}

options.html を作成します。
中身はindex.htmlとほぼ同じですが、scriptタグのsrc`属性を変更する必要があります。

<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<link rel="icon" type="image/svg+xml" href="/vite.svg" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>Extension App</title>
	</head>
	<body>
		<script type="module" src="/src/options.tsx"></script>
	</body>
</html>

scriptタグの参照先のoptions.tsxファイルをsrcディレクトリに作成します。

import React from "react";
import ReactDOM from "react-dom/client";
import Button from "./components/Button";

function Options() {
	console.log(`this is options page`);

	return (
		<div className="App">
			<header className="App-header">
				<h1>Title</h1>
				<Button>button</Button>
			</header>
		</div>
	);
}

const index = document.createElement("div");
index.id = "options";
document.body.appendChild(index);

ReactDOM.createRoot(index).render(
	<React.StrictMode>
		<Options />
	</React.StrictMode>
);

最後に

拡張機能の開発は通常のwebアプリと違ってクセがあるので開発手法に悩みますが、今回はCRXJSを使うことでReactによる拡張機能の開発体験を大幅に向上させることができました。

Developerツールのパネルや他の機能の作成にも取り組もうと思っています。他機能に関しては随時記事を更新する予定です。

お読み頂きありがとうございました。

参考

GitHubリポジトリ

Discussion