ブラウザ拡張機能をReactで開発する【WXT】

## はじめに
自分のタスク管理用として、ブラウザ拡張機能でタスク管理アプリを開発しようと考えました。React,TypeScriptで開発したいと考えていたところ以下の記事を見つけたので、一番最初に紹介されていたWXTというフレームワークを使用してみました。

https://zenn.dev/cybozu_frontend/articles/introduction-browser-extensions-tools

個人的にはなかなか使いやすかったので、備忘録として使い方など記事にしてみようと思います。

### WXTとは
公式サイトのトップには「Next-gen Web Extension Framework」と記載があります。

https://wxt.dev/

次世代のWeb拡張機能フレームワークということで、
+ 近年のモダンなフロントエンド開発に対応している（モダンな開発体験を目指している）
+ Chromeなど1つのブラウザに依存せず、複数のブラウザに対応している

といったことが売りなのかなと思います。

## 導入方法
[こちら](https://wxt.dev/get-started/installation.html)の「Bootstrap Project」を参考に導入してみます。なお、今回はnpmで構築していきます。
まず任意のディレクトリで以下のコマンドを実行します。
```sh
npx wxt@latest init <project-name>
```

:::note info
バージョンは以下で実施しています。
- node: v21.5.0
- npm: 10.2.4
- wxt: 0.19.1
:::

コマンド実行後はいくつか指示があるので、任意の値を入力・選択して進んでください。この記事ではReactを使用するので、templateはreactを選択しています。

```sh
ℹ Initalizing new project                                                                                                                                                                     9:38:55
? Choose a template › - Use arrow-keys. Return to submit.
    vanilla
    vue
❯   react  # reactを選択
    solid
    svelte

? Package Manager › - Use arrow-keys. Return to submit.
❯   npm  # npmを選択
    pnpm
    yarn
    bun (experimental)

```

上記が完了したら、作成されたプロジェクトのディレクトリに移動して以下のコマンドを実行してpackegeのインストールを行います。
```sh
npm install
```

以上で環境構築は完了です。

## 基本操作
### 開発用ブラウザの起動
```sh
npm run dev
```
開発する拡張機能を実行可能なブラウザを起動できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3637890/f92cd999-bf6a-2e81-0b7b-578e71958b16.png)

立ち上げたままにしておけばコードを保存したタイミングで即座に反映（ホットリロード）してくれますので、開発が非常に楽でした。

### ビルド
```sh
npm run build
npm run build:firefox # firefoxの場合
```
Reactで開発していた内容を、ブラウザの拡張機能として読み込ませるための形式に変換してくれます。
プロジェクトディレクトリに[.output/chrome-mv3]（Firefoxの場合は[.output/firefox-mv2]）というディレクトリが作成されますので、そのディレクトリを拡張機能として読み込ませることで開発した拡張機能を自分のブラウザで利用できるようになります。

拡張機能として読み込ませる方法は以下の記事などを参考にしてください。

https://qiita.com/komlabo/items/5b99d3598f6664621dae#%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB

### zip化
```sh
npm run zip
npm run zip:firefox # firefoxの場合
```
拡張機能を一般に公開するためにはディレクトリ一式をzip圧縮する必要がありますので、そのためのコマンドです。
.outputディレクトリに出力されます。

なお、私は試せていませんがリリースの自動化をするための機能なども用意されているようなので、公開して継続的にリリースする場合などにもかなり重宝しそうです。

https://wxt.dev/get-started/publishing.html

他にもいくつかCLIが用意されていますので、[公式サイト](https://wxt.dev/api/cli/wxt.html)をご確認ください。

## コードの記述方法
### WXT API
WXT独自のAPIが用意されています。
これらのAPIを上手に活用することで、任意の設定や独自の機能の開発をブラウザ間での差異等を考慮せずに開発できます。

https://wxt.dev/api/reference/wxt/

### ディレクトリ構成
初期のディレクトリ構成としてはおおよそ以下ようになります。

```sh
.
├── assets
├── entrypoints
│   ├── background.ts # Background scripts
│   ├── content.ts    # Content scripts
│   └── popup/        # Popups
├── public
├── package.json
├── tsconfig.json
└── wxt.config.ts  # 設定ファイル
```

:::note warn
なお、WXTではTypeScriptがデフォルトで採用されています。JavaScriptで書きたい場合には[自身で拡張子を変更する](https://wxt.dev/get-started/installation.html#bootstrap-project)必要があるようです。
> All templates default to TypeScript. Rename the file extensions to to use JavaScript instead..js
:::

ディレクトリ構成の中で、コードを書いていくのは基本的に[entrypoints]ディレクトリとなります。
初期の[entrypoints]ディレクトリには以下の3つのファイル、もしくはディレクトリが存在することとなりますが、これがブラウザ拡張機能で開発できる3つの機能に対応することとなります。
- [popup](https://developer.chrome.com/docs/extensions/develop/ui/add-popup?hl=ja)
    - 拡張機能のボタンをクリックした際に表示される画面や機能
    - 開いているWebページのDOMにアクセスすることはできない
- [content](https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts?hl=ja)
    - Webページのコンテキストで実行される
    - WebページのDOMにアクセス可能（読み取りや変更・追加など）
- [background](https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/basics?hl=ja)
    - バックエンドで実行される機能
    - 今のChromeなどの拡張機能（マニフェストv3）ではService Workerと呼ばれる機能

このあとは上記3つの機能の開発方法を見てみます。
なお、これらの機能は単独で使用することも組み合わせて使用することも可能です。

### popup
[導入方法](#導入方法)で作成されたpopupデフォルトのディレクトリ構成は以下の通りとなっています。Reactを触ったことがある方であれば、見慣れたファイルかなと思います。

```sh
popup
  ├── App.css
  ├── App.tsx
  ├── index.html
  ├── main.html
  └── style.css
```

CSS以外の内容は以下の通りです。
```html:index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Default Popup Title</title>
    <meta name="manifest.type" content="browser_action" />
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="./main.tsx"></script>
  </body>
</html>
```

```tsx:main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import './style.css';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
);
```

```tsx:App.tsx
import { useState } from 'react';
import reactLogo from '@/assets/react.svg';
import wxtLogo from '/wxt.svg';
import './App.css';

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

  return (
    <>
      <div>
        <a href="https://wxt.dev" target="_blank">
          <img src={wxtLogo} className="logo" alt="WXT logo" />
        </a>
        <a href="https://react.dev" target="_blank">
          <img src={reactLogo} className="logo react" alt="React logo" />
        </a>
      </div>
      <h1>WXT + React</h1>
      <div className="card">
        <button onClick={() => setCount((count) => count + 1)}>
          count is {count}
        </button>
        <p>
          Edit <code>src/App.tsx</code> and save to test HMR
        </p>
      </div>
      <p className="read-the-docs">
        Click on the WXT and React logos to learn more
      </p>
    </>
  );
}

export default App;
```

一般的なReactの記法、React hooksなども利用できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3637890/f92cd999-bf6a-2e81-0b7b-578e71958b16.png)

通常のReactの開発のように、コンポーネントを作成して組み合わせることももちろん可能です。
```tsx:TestComponent.tsx
function TestComponent() {
  return (
    <>
      <h2>Hello World</h2>
    </>
  )
}

export default TestComponent;

```
```tsx:App.tsx
import { useState } from 'react';
import reactLogo from '@/assets/react.svg';
import wxtLogo from '/wxt.svg';
import TestComponent from './TestComponent';
import './App.css';

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

  return (
    <>
      <div>
        <a href="https://wxt.dev" target="_blank">
          <img src={wxtLogo} className="logo" alt="WXT logo" />
        </a>
        <a href="https://react.dev" target="_blank">
          <img src={reactLogo} className="logo react" alt="React logo" />
        </a>
      </div>
      <h1>WXT + React</h1>
      <div className="card">
        <button onClick={() => setCount((count) => count + 1)}>
          count is {count}
        </button>
        <TestComponent />
        <p>
          Edit <code>src/App.tsx</code> and save to test HMR
        </p>
      </div>
      <p className="read-the-docs">
        Click on the WXT and React logos to learn more
      </p>
    </>
  );
}

export default App;
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3637890/7464e72f-d1d8-7551-729f-7ff7d8a3db53.png)

### content
contentはentrypoints直下にcontent.ts(x)を配置するか、contentディレクトリを作成してその中にファイルを作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3637890/94f6683f-0407-c7b1-55e3-f7a00ea585c0.png)
※[参考](https://wxt.dev/guide/directory-structure/entrypoints/content-scripts.html#filenames)

なお、開発する際にはwxt.config.tsにruunerパラメータを追加して、どのWebサイトをもとに開発するかを指定した方が良いです。
contentはWebページ上で実行されるものなので、この指定がないと開発ブラウザでうまく動作確認ができません。
```ts:wxt.config.ts
import { defineConfig } from 'wxt';

// See https://wxt.dev/api/config.html
export default defineConfig({
  modules: ['@wxt-dev/module-react'],
  // 以下を追加
  runner: {
    startUrls: ["https://google.com"],
  },
});
```

ここからcontentの書き方のサンプルを紹介します。
contentでもpopupと同じように[UIをReactで構築する](https://wxt.dev/guide/key-concepts/content-script-ui)ことができます。いくつか方法があるのですが、WebサイトのCSSに左右されないShadowRootを使用した方法を紹介します。

以下のディレクトリ構成とします。
```sh
entrypoints
  └── content
      ├── index.tsx
      ├── App.tsx
      └── style.css
```

コードの内容です。[こちら](https://github.com/wxt-dev/examples/tree/main/examples/react-content-script-ui)を参考にしています。
```tsx:index.tsx
import "./style.css";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";

export default defineContentScript({
  matches: ["*://*/*"],
  cssInjectionMode: "ui",

  async main(ctx) {
    const ui = await createShadowRootUi(ctx, {
      name: "wxt-react-example",
      position: "inline",
      anchor: "body",
      append: "first",
      onMount: (container) => {
        const wrapper = document.createElement("div");
        container.append(wrapper);

        const root = ReactDOM.createRoot(wrapper);
        root.render(<App />);
        return { root, wrapper };
      },
      onRemove: (elements) => {
        elements?.root.unmount();
        elements?.wrapper.remove();
      },
    });

    ui.mount();
  },
});
```
この中の[createShadowRootUi](https://wxt.dev/api/reference/wxt/client/functions/createShadowRootUi.html)はWXT側で用意されている関数です。
ui.mount()でShadow DOMに対してAppコンポーネントをマウントしています。


```tsx:App.tsx
import { useState } from "react";
import "./style.css";

function App() {
  const [count, setCount] = useState(1);
  const increment = () => setCount((count) => count + 1);

  return (
    <div>
      <h2>Content Script Sample</h2>
      <p>This is React. {count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
};

export default App;
```

```css:style.css
* {
  padding: 0;
  margin: 0;
}

body {
  background-color: #f0f8ff;
  padding: 16px;
}

h2 {
  color: #1e90ff;
}

p {
  color: #2c3e50;
  margin: 8px;
}

button {
  background-color: #4169e1;
  color: white;
  margin: 8px;
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: background-color 0.3s ease;
}

button:hover {
  background-color: #1e90ff;
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3637890/ebc6aefb-e01f-5923-e1f2-db4b74b14644.png)
画面上の方に作成されている部分がcontentで構築したものです。
popupやbackgroundと組み合わせることで、表示/非表示の制御なども可能になります。

### background
backgroundもcontentやpopup同様、entrypoints直下にbackground.tsを配置するか、backgroundディレクトリを作成してその中にファイルを作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3637890/617113be-3d3c-498d-308f-6926185de1f7.png)
※[参考](https://wxt.dev/guide/directory-structure/entrypoints/background.html#filenames)

今回はbackground.tsを作成してみます。
あんまり使い道はないかもしれませんが、一定期間でリロードを行う機能の例です。
```ts:background.ts
export default defineBackground(() => {
  const reloadEveryMinute = () => {
    browser.tabs.query({ active: true, currentWindow: true }).then((tabs) => {
      if (tabs[0]) {
        browser.tabs.reload(tabs[0].id);
      }
    });
  };

  setInterval(reloadEveryMinute, 5000);  // 5秒ごとにリロード
});

```


### その他
以下からWXTのサンプルを確認できますので興味のある方はご覧ください。

https://wxt.dev/examples

## おわりに
WXTはブラウザ拡張機能でモダンな開発をするための1つの選択肢となるかと思います。個人的には今後も使ってみたいなと感じました。
この記事が何か皆様の参考となれば嬉しく思います。ここまでご覧いただきありがとうございました。