Chrome拡張機能開発 Vite + React + TS（CRXJSを使わない場合）

# CRXJS Vite Pluginを使わない Vite + React + TypeScriptでChrome拡張機能開発

以前投稿した [Vite + React + TSでChrome拡張機能開発：かんたん環境構築からHello Worldまで](https://qiita.com/mi___y/items/3f7bc09e9177e023110d) では、ViteプラグインのCRXJSを使用してChrome拡張機能の開発環境を構築しましたが、今回はCRXJSを使用しない場合のプロジェクト作成の手順をまとめています。

Popup、Content Script、Service Workerを含むプロジェクトテンプレートの作成とプレビュー方法について説明しています。

**この記事に含まれないこと:**
拡張機能を構成する概念（manifest, service worker, content scriptsなど）やReact、TSの詳しい説明は含まれません。

## 環境構築
### Viteプロジェクトの作成
```terminal
$ npm create vite@latest

// コマンドのダイアログに従いプロジェクトをセットアップ
✔ Project name: … my-chrome-extension
✔ Select a framework: › React
✔ Select a variant: › TypeScript

$ cd my-chrome-extension
 
$ npm i
```
プロジェクト名は my-chrome-extension とします。

### サーバー起動
```terminal
$ npm run dev
```
サーバーを起動すると`➜ Local: http://localhost:5173/` のように表示されているlocal serverのURLにアクセスしてVite + Reactのスタート画面が表示されることを確認します。

 ![スクリーンショット 2024-08-30 23.46.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142893/bf514baf-c3d1-ec35-fdd2-f0e1ea3be729.png)


### Chrome API 型定義ファイルのインストール
**@types/chrome**
chrome.types APIにはChromeの型定義が含まれています。
エディタにChrome APIの型を認識させたり、オートコンプリート機能を有効にするため、型定義ファイルのパッケージをインストールしておきます。
```terminal
$ npm install --save-dev @types/chrome
```

## ビルド設定
Popup、Content Script、Service Workerの各環境でHello worldするための設定をします。

### ディレクトリ構成
全体像をイメージしやすいように、最終的なディレクトリ構成を載せておきます。
```
.
├── dist/
├── public/
│   ├── images/
│   │   └── icon_16.png
│   ├── manifest.json
├── src/
│   ├── background/ ...service worker
│   │   └── index.tsx
│   ├── content/ ...content scripts ★
│   │   └── index.tsx ★
│   └── popup/ ... popup
│       ├── index.tsx
│       └── index.html
├── package.json
├── vite.config.content.ts ...content script用のvite config ★
├── vite.config.ts ...その他のvite config 
```
★ ... content scriptのビルド設定は一手間を加える必要があるため、この記事の最後の項目で説明します。


### 実装準備
Viteプロジェクトを生成した状態から、**srcディレクトリ**から不要なファイルを削除しておきます。（一部のファイルを省略しています）
```diff_html:
  .
  ├── dist/
  ├── public/
  ├── src/
- │   ├── App.css
- │   ├── App.tsx
- │   ├── index.css
- │   └── main.tsx
- ├── index.html
  ├── package.json
  ├── vite.config.ts
```

### Popupの実装
まずはPopupに"Hello Popup"と表示し、Chromeに拡張機能としてロードしプレビューしてみます。
![スクリーンショット 2024-08-31 23.11.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142893/5d5c0c14-3077-5a3a-607c-d3e338bbcbde.png)

ビルド後のファイル群は以下の様になるよう設定していきます。（関連ファイルのみ記載）
```:Built files
.
├── dist/
│   ├── assets/
│   │   └── popup.js
│   ├── popup/
│   │   └── index.html
│   ├── manifest.json
```

**Popup**
```diff_html:Source files
  .
  ├── public/
  ├── src/
+ │   ├── popup/
+ │   │   ├── index.tsx
+ │   │   └── index.html
```
```html: /src/popup/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My Chrome Extension</title>
  </head>
  <body>
    <script type="module" src="./index.tsx"></script>
  </body>
</html>
```
```typescript: /src/popup/index.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'

// 1. consoleにテキストを出力
console.log('Hello Popup')

const root = document.createElement('div')
root.id = 'crx-popup-root'
document.body.appendChild(root)

createRoot(root).render(
  <StrictMode>
    // 2. ポップアップ画面にテキストを表示
    <h1>Hello Popup!</h1>
  </StrictMode>
)
```

**vite.config.ts**
```diff_javascript:/vite.config.ts
import { defineConfig } from 'vite'
+ import { join, resolve } from 'path'
import react from '@vitejs/plugin-react'

// https://vite.dev/config/
export default defineConfig({
  root: resolve(__dirname, 'src'),
+ publicDir: resolve(__dirname, 'public'),
+ build: {
+   outDir: resolve(__dirname, 'dist'),
+   rollupOptions: {
+     input: {
+       popup: join(__dirname, 'src/popup/index.html'),
+     },
+     output: {
+       entryFileNames: 'assets/[name].js'
+     },
+   }
+ },
+ resolve: {
+   alias: {
+     '@src': resolve(__dirname, 'src')
+   }
+ }, 
  plugins: [react()],
})
```
path モジュールの型定義ファイルをインストールします。
```terminal
$ npm install --save @types/node
```
### UIコーディング時のプレビュー
![Screenshot 2025-03-08 at 21.33.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142893/85f76ebb-f373-4576-8709-b3b25bafd8bb.png)

```terminal
$ npm run dev
```
Devサーバーを起動し下記のパスにアクセスすると、Popupのコンテンツの表示を確認できます。
UIのコーディングをするときは拡張機能としてロードせずに、この方法でホットリロードを利用してレイアウト作成していくと効率が良いと思います。
http://localhost:5173/popup/index.html
*port番号はterminalの表示を確認してください。


## Chromeに拡張機能をロードする
次に拡張機能としてロードするための設定をします。

**manifest**
マニフェストファイルは静的ファイルとして追加します。
```diff_html:
  .
  ├── public/
+ │   └── manifest.json
  ├── src/
  │   ├── popup/
  │   │   ├── index.tsx
  │   │   ├── index.html
```
`manifest.json`に、拡張機能アイコンをクリックした時に表示するポップアップファイルを登録します。
```diff_json: /public/manifest.json
{
  "manifest_version": 3,
  "name": "My Chrome Extension",
  "version": "1.0.0",
  "action": {
     "default_popup": "/popup/index.html"
  }
}
```

ビルドして期待通りファイルが作成されていることを確認します。
```terminal
$ npm run build
```
```
.
├── dist/
│   ├── assets/
│   │   └── popup.js
│   ├── popup/
│   │   └── index.html
│   ├── manifest.json
```

**package.json**
`scripts` に拡張機能のプレビュー用のscriptを追加します。`--watch`オプションを有効にしてファイルを変更した際に再ビルドされるようにします。
```diff_json:/package.json
{
  // 省略
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
+   "build:watch": "vite build --watch",
    "lint": "eslint .",
    "preview": "vite preview"
  },
  // 省略
}
```

### Chromeで拡張機能としてプレビューする
```terminal
$ npm run build:watch
```
でウォッチャーを起動した状態で、下記の手順でプロジェクトファイルをロードすることで、ホットリロードを活用して開発ができます。
1. Google Chromeの拡張機能の管理画面にアクセス [chrome://extensions/](chrome://extensions/)
2. `Developer mode` / `デベロッパー モード`をONにする
3. `Load unpacked` / `パッケージ化されていない拡張機能を読み込む` を選択する
4. 作成したプロジェクトの`dist`フォルダを選択しロードする

`拡張機能の一覧` / `All Extensions` に"My Chrome Extension"が追加されたことを確認したら、新しいタブを開き、拡張機能アイコンをクリックします。
ポップアップが表示され、先ほどDevサーバーで確認したHello Popup!が表示されていれば、開発準備完了です。

![Screenshot 2025-03-08 at 22.14.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142893/e93e67f6-2fae-4569-ae07-fdb220fb2a82.png)

:::note warn
この設定ではpublicフォルダは監視の対象外なので、manifestファイル更新の都度`$ npm run build`を実行する必要があります。
:::
:::note warn
srcファイルに変更があると再ビルドされ、Chromeの拡張機能のリロードは不要ですが、Popupを表示させた状態では変更が反映されません。
一度Popupを閉じて、表示しなおす必要があります。
効率よくレイアウト作成するため、[DevサーバーでUIコーディング](#ui%E3%82%B3%E3%83%BC%E3%83%87%E3%82%A3%E3%83%B3%E3%82%B0%E6%99%82%E3%81%AE%E3%83%97%E3%83%AC%E3%83%93%E3%83%A5%E3%83%BC
)をするのがおすすめです。
:::

## Service Workerの追加 (background.js)
次に、Sevice workerのConsoleに"Hello Background"のテキストを出力します。

```diff_html:
  .
  ├── src/
+ │   ├── background/
+ │   │   └── index.tsx
  │   ├── popup/
  │   │   ├── index.tsx
  │   │   ├── index.html
```

```typescript: /src/background/index.tsx
console.log('Hello Background')
```
**vite.config.ts**
```diff_javascript:/vite.config.ts
import { defineConfig } from 'vite'
import { join, resolve } from 'path'
import react from '@vitejs/plugin-react'

// https://vite.dev/config/
export default defineConfig({
  root: resolve(__dirname, 'src'),
  publicDir: resolve(__dirname, 'public'),
  build: {
    outDir: resolve(__dirname, 'dist'),
    rollupOptions: {
      input: {
        popup: join(__dirname, 'src/popup/index.html'),
+       background: join(__dirname, 'src/background/index.tsx'),
      },
      output: {
        entryFileNames: 'assets/[name].js'
      },
    }
  },
  resolve: {
    alias: {
      '@src': resolve(__dirname, 'src')
    }
  }, 
  plugins: [react()],
})
```
```diff
 .
 ├── dist/
 │   ├── assets/
+│   │   ├── background.js
 │   │   └── popup.js
 │   ├── popup/
 │   │   └── index.html
 │   ├── manifest.json
```

```diff_json: /manifest.json
{
  "manifest_version": 3,
  "name": "My Chrome Extension",
  "version": "1.0.0",
+  "background": {
+    "service_worker": "/assets/background.js"
+  },
  "action": {
    "default_popup": "/popup/index.html"
  }
}
```
*manifestファイルを変更したら `$ npm run build`でpublicフォルダのファイルをビルドします。

### Service WorkerのConsole表示
拡張機能の管理画面にアクセスします [chrome://extensions/](chrome://extensions/)
リロードボタンをクリックすると`Inspect views: service worker`のテキストリンクが表示されます。クリックすると開発者ツールが表示され、service workerのConsoleを表示することができます。
![スクリーンショット 2024-08-31 23.24.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142893/e2659022-e38c-96b5-20c3-353329bfd4e7.png)
Consoleに"Hello Background"とログが出力されていれば成功です。

## Content Scriptビルド設定
最後に、ページコンテンツの操作をするContent Scriptのビルド設定と実装を行います。
閲覧中のページ（アクティブなタブ）に要素を追加し、"Hello Content"と表示します。
![スクリーンショット 2024-08-31 23.17.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142893/6d429db5-f5ee-8dc0-ab3b-96b5ebc13ec3.png)

```diff_html:
  .
  ├── src/
  │   ├── background/
  │   │   └── index.tsx
+ │   ├── content/
+ │   │   └── index.tsx
  │   ├── popup/
  │   │   ├── index.tsx
  │   │   ├── index.html
```

```typescript: /src/content/index.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'

// 1. consoleにテキストを出力
console.log('Hello Content')

const root = document.createElement('div')
root.id = 'crx-content-root'
document.body.appendChild(root)

createRoot(root).render(
  <StrictMode>
    {/* 2. アクティブなタブコンテンツにテキストを表示 */}
    <h1 style={{position: 'fixed', left: 0, top: 0}}>Hello Content!</h1>
  </StrictMode>
)
```

**ビルド後のファイル**
```diff
 .
 ├── dist/
 │   ├── assets/
 │   │   ├── background.js
+│   │   ├── content.js
 │   │   └── popup.js
 │   ├── popup/
 │   │   └── index.html
 │   ├── manifest.json
```

```diff_json: /manifest.json
{
  "manifest_version": 3,
  "name": "My Chrome Extension",
  "version": "1.0.0",
  "background": {
     "service_worker": "/assets/background.js"
  },
+ "content_scripts": [
+    {
+        "js": ["/assets/content.js"],
+        "matches": ["https://www.google.com/*"]
+    }
+ ],
  "action": {
    "default_popup": "/popup/index.html"
  }
}
```
*スクリプト実行の対象ページは`https://www.google.com/`ドメインのページとしました。
*manifestファイルを変更したら `$ npm run build`でpublicフォルダのファイルをビルドします。

---
**Content Scriptでimport構文は使用できない**
ここでbackgroundやpopupと同じようにvite.config.tsのinputにcontent scriptのファイルを追加してみます。
```diff_typescript:vite.config.ts
rollupOptions: {
  input: {
    popup: join(__dirname, 'src/popup/index.html'),
    background: join(__dirname, 'src/background/index.tsx'),
+   content: join(__dirname, 'src/content/index.tsx')
  },
//省略
}
```
次のようなエラーが表示されます。
```
Uncaught SyntaxError: Cannot use import statement outside a module
```
ビルドしたjsにimport構文が含まれているが、scriptがmoduleとして読み込まれていないために出ているエラーです。単純にhtmlにscriptタグで読み込む場合は、`type="module"`のようにscriptのtype属性を指定することができますが、Content Scriptsはアクティブなタブのコンテンツにインジェクトされるためそのような指定をすることができません。

---

### Content Script用のvite.configを追加
前の項目で説明した通り、Content Scriptに限ってはimport構文を含まない形でビルドする必要があるため、別途vite.configを用意します。

**vite.config.content.ts**
```diff_javascript:/vite.config.content.ts
import { defineConfig } from 'vite'
import { join, resolve } from 'path'
import react from '@vitejs/plugin-react'

// https://vite.dev/config/
export default defineConfig({
  root: resolve(__dirname, 'src'),
  publicDir: resolve(__dirname, 'public'),
  build: {
    outDir: resolve(__dirname, 'dist'),
    rollupOptions: {
      input: {
        content: join(__dirname, 'src/content/index.tsx')
      },
      output: {
        entryFileNames: 'assets/[name].js',
        inlineDynamicImports: true // import構文を使用しないためのオプションです
      },
    }
  },
  resolve: {
    alias: {
      '@src': resolve(__dirname, 'src')
    }
  }, 
  plugins: [
    react(),
  ],
})

```
*rollupのinlineDynamicImportsオプションを有効にする場合、inputファイルを複数指定することができません。
そのためcontent scriptとその他のconfigファイルを分けています。

二つのnpmコマンドを同時に実行するためパッケージconcurrentlyを追加します。
```terminal
$ npm install concurrently
```

content scriptとその他のconfigファイルに指定の通り、変更を監視してビルドを行います。
```diff_json:/package.json
  // 省略
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
-   "build:watch": "vite build --watch",
+   "build:watch": "concurrently \"npm run build:watch-content\" \"npm run build:watch-others\"",
+   "build:watch-content": "vite build --watch --config vite.config.content.ts",
+   "build:watch-others": "vite build --watch",
    "lint": "eslint .",
    "preview": "vite preview"
  },
  // 省略
```

Content Scriptを更新したら、拡張機能の管理画面にアクセスし、 [chrome://extensions/](chrome://extensions/)
リロードボタンをクリックします。
manifestでスクリプト実行の対象ページは`https://www.google.com/`ドメインのページとしたので、https://www.google.com/preferences などにアクセスします。

ページのソースに要素が追加され、ページ上にHello Content!が表示されていたら成功です。
![スクリーンショット 2024-08-31 23.17.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142893/6d429db5-f5ee-8dc0-ab3b-96b5ebc13ec3.png)

Watcherを有効にしているとファイルに変更を加える都度再ビルドはされるものの、ロードしている拡張機能に変更を反映させるためには、拡張機能の管理画面で拡張機能をリロードし、ページもリロードする必要があります。
変更の都度リロードの作業をするのは手間なので、メッセージパッシングなど拡張機能としてロードしなければ機能しないものの確認でなければ、`npm run dev` でホットリロードを利用してプレビューしながら開発しています。

## 最終ビルド
最後にパッケージ化する際のビルド設定を追加します。
distディレクトリをクリーンにするコマンドを追加し、content scriptとその他を実行します。
```diff_json:/package.json
  // 省略
  "scripts": {
    "dev": "vite",
-   "build": "tsc -b && vite build",
+   "build": "npm run clean && tsc -b && vite build && vite build --config vite.config.content.ts",
    "build:watch": "vite build --watch",
    "build:watch": "concurrently \"npm run build:watch-content\" \"npm run build:watch-others\"",
    "build:watch-content": "vite build --watch --config vite.config.content.ts",
    "build:watch-others": "vite build --watch",
+   "clean": "rimraf dist",
    "lint": "eslint .",
    "preview": "vite preview"
  },
  // 省略
```
rimrafパッケージの追加をします。
```terminal
$ npm install --D rimraf
```
buildコマンドを実行して、ビルド後のファイル構成を確認してください。
```terminal
$ npm run build
```
**ビルド後のファイル構成**
```diff
 .
 ├── dist/
 │   ├── assets/
 │   │   ├── background.js
 │   │   ├── content.js
 │   │   └── popup.js
 │   ├── popup/
 │   │   └── index.html
 │   └── manifest.json
```
manifestで指定した通りに各ファイルが生成されていることを確認し、Chromeに拡張機能としてロードし、動作確認をして開発完了です。

```json:/dist/manifest.json
{
    "manifest_version": 3,
    "name": "My Chrome Extension.",
    "version": "1.0.0",
    "background": {
        "service_worker": "/assets/background.js"
    },
    "content_scripts": [
        {
            "js": ["/assets/content.js"],
            "matches": ["https://www.google.com/*"]
        }
    ],
    "action": {
       "default_popup": "/popup/index.html"
    }
}
```