🌐

ブラウザ拡張機能をReact + TypeScript + esbuildで作成する

2021/12/28に公開

Reactを用いてブラウザ拡張機能(Firefox Add-on、Chrome拡張機能)を作成する方法を紹介します。Firefox, Chrome両対応です。

本稿では開発する仕組みづくりを主な目的とし、WebExtensions APIの解説は対象外とします。

成果物

以下のテンプレートを作成しました。

https://github.com/hiterm/web-ext-react-template

構成

View部分のフレームワークはReact、言語はTypeScript、bundlerにはesbuildを使っています。

選定理由

esbuildを選んだ理由は以下の通りです。

ブラウザ拡張機能開発においては、popupやcontents_scriptsなど、複数の出力ファイルが必要になります。例えばCreate React App (CRA) は、デフォルト設定では1つの出力ファイルしか扱えません。そのためCRAでは設定の変更が必要になり、no configurationという最大の利点が失われてしまいます。他にも似たようなReact環境構築ツールは多数存在しますが、そもそもSPA構築ツールは大抵の場合多数の出力ファイルを扱うことを目的としていません。

またブラウザ拡張機能開発においては、動作確認のたびに結果をビルドして、ブラウザに読み込ませる必要があります。そのため通常のSPA開発で用いられる、開発用WebサーバーやHMRとはあまり相性がよくありません (ViteHMRを利用している例もなくはないようですが、結構トリッキーな手法だと思います)。この点でもSPA構築ツールは適していません。

さらに、contents_scriptsやbackground_scriptsのために、Reactではない素のTypeScriptも扱う必要があります。

以上のことから、素のモジュールバンドラー(Webpackなど)を直接設定する方が適していると判断しました。中でもビルドそのものが高速なesbuildを採用することにしました。

選択肢として、高速さを謳っているViteも検討しました。これはどちらかというとSPA構築ツールなので、上記理由から適していません。それに加え、bundleにはesbuildではなくRollupを用いているということで、多少速度面でも劣りそうだったので採用を見送りました。

https://vitejs.dev/guide/why.html#why-not-bundle-with-esbuild

While esbuild is blazing fast and is already a very capable bundler for libraries, some of the important features needed for bundling applications are still work in progress - in particular code-splitting and CSS handling. For the time being, Rollup is more mature and flexible in these regards. That said, we won't rule out the possibility of using esbuild for production build when it stabilizes these features in the future.

またwebpackとesbuild-loaderを用いる方法も検討しましたが、こちらも速度面で劣るようだったので採用を見送りました。

https://github.com/privatenumber/esbuild-loader#why-am-i-not-getting-a-100x-speed-improvement-as-advertised

Running esbuild as a standalone bundler vs esbuild-loader + Webpack are completely different:
(後略)

使い方

コマンドなど、基本的な使い方の説明はREADMEに譲ります。

https://github.com/hiterm/web-ext-react-template

しかし、開発を進める上では以下で説明する内部構成を知っておいた方が便利です。

内部解説

build.tsが何をやっているのかを解説します。

esbuildの設定 L55-L61

ここがesbuildの設定です。popup/index.tsxをビルドして、ビルド先のディレクトリに出力しています。watchやsourcemapはコマンドラインオプションによって有効無効を制御します。

  build({
    entryPoints: ['popup/index.tsx'],
    bundle: true,
    outdir: distPath('popup', targetBrowser),
    watch: watchOption,
    sourcemap: devFlag ? 'inline' : false,
  });

staticファイルコピー L64-L89

staticファイルのコピーを行っています。

watchフラグが無効なら単純コピー、有効化されていればchokidarで監視しつつコピーします。

manifest.jsonの処理については後述します。

  if (watchFlag) {
    chokidar.watch('popup/popup.html').on('all', (event, path) => {
      console.log(event, path);
      fs.copyFile(path, distPath('popup/popup.html', targetBrowser));
    });
    chokidar
      .watch(['manifest.json', 'firefox.json'])
      .on('all', (event, path) => {
        console.log(event, path);
        makeManifestFile(targetBrowser);
      });
    chokidar.watch('icons/*').on('all', (event, filepath) => {
      console.log(event, filepath);
      fs.copyFile(
        filepath,
        distPath(path.join('icons', path.basename(filepath)), targetBrowser)
      );
    });
  } else {
    fs.copyFile(
      'popup/popup.html',
      distPath('popup/popup.html', targetBrowser)
    );
    makeManifestFile(targetBrowser);
    fs.cp('icons', distPath('icons', targetBrowser), { recursive: true });
  }

manifest.jsonの出し分け L34-L48

先程のstaticファイルコピー部分で呼ばれている関数です。

Firefoxに署名なしAdd-onをインストールする場合、自分でidを指定する必要があります。
https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/browser_specific_settings

ただし、この設定はChromeでは不要なばかりか、存在しているとChromeにインストールできず弾かれてしまいます。そのためfirefoxのみで必要な設定はfirefox.jsonに分離し、ターゲットブラウザによってmanifest.jsonを出し分けています。

const makeManifestFile = async (targetBrowser: Browser) => {
  const baseManifestJson = JSON.parse(
    await fs.readFile('manifest.json', 'utf8')
  );
  if (targetBrowser === 'firefox') {
    const firefoxJson = JSON.parse(await fs.readFile('firefox.json', 'utf8'));
    const manifestJson = { ...baseManifestJson, ...firefoxJson };
    fs.writeFile(
      distPath('manifest.json', targetBrowser),
      JSON.stringify(manifestJson, null, 1)
    );
  } else {
    fs.copyFile('manifest.json', distPath('manifest.json', targetBrowser));
  }
};

その他Tips

Firefox, Chromeに両対応する

FirefoxとChromeのAPIにはある程度互換性がありますが、やはりそれなりに差異があります。

https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Chrome_incompatibilities

この差異をある程度まで埋めてくれる、webextension-polyfillというライブラリが存在します。テンプレートではあらかじめ導入されています。

https://github.com/mozilla/webextension-polyfill

Popupがサンプル実装になっているので参考にしてください。以下のような内容です。example.comを開くボタンを実装しています。

import React from 'react';
import browser from 'webextension-polyfill';

export const Popup: React.VFC = () => {
  const handleClick = () => {
    browser.tabs.create({ url: 'https://example.com/' });
  };

  // a button to open example.com
  return <button onClick={handleClick}>Button</button>;
};

https://github.com/hiterm/web-ext-react-template/blob/0a77b41edeb79b30dbc077a45499e0cd4f6ea968/popup/Popup.tsx

開発時の読み込み

web-extというツールで簡単に拡張機能を読み込んで試すことができます。こちらも導入済みで、以下コマンドで実行できます。

yarn run run:firefox
yarn run run:chrome

事前にビルドが完了している必要があることに注意してください。yarn run build:<browser>--watchオプションと併用すると便利です。自動再読込もしてくれるようです。

ファイルを追加する場合

今回作成したテンプレートにはPopupのみが存在しています。Popupとはアイコンをクリックした際に表示される小さな画面枠のことです。
https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/user_interface/Popups

ブラウザ拡張機能においては、他にもcontents_scripts, background_scriptsなど様々な機能を利用することができ、用途ごとにファイルを作成する必要があります。
https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions

background_scriptsを追加するような場合にも、Popupと同じような設定を追加すれば良いです。
例えばbackgroundScripts/backgroundScript.tsを追加した上で、以下のように変更します。

  build({
    entryPoints: ['popup/index.tsx'],
    bundle: true,
    outdir: distPath('popup'),
    watch: watchOption,
    sourcemap: devFlag ? 'inline' : false,
  });
  build({
    entryPoints: ['backgroundScripts/backgroundScript.ts'],
    bundle: true,
    outdir: distPath('backgroundScripts'),
    watch: watchOption,
    sourcemap: devFlag ? 'inline' : false,
  });

manifest.jsonも書き換える必要があります。

diff --git a/manifest.json b/manifest.json
index 112072c..66cb24e 100644
--- a/manifest.json
+++ b/manifest.json
@@ -15,5 +15,10 @@
     },
     "default_title": "default_title",
     "default_popup": "popup/popup.html"
+  },
+  "background": {
+    "scripts": [
+      "backgroundScripts/backgroundScript.js"
+    ]
   }
 }

manifest.jsonにはビルド後のファイルパスを記述します。

最後に

ブラウザ拡張機能は結構簡単に作れるので、ぜひ一度作ってみてください。

参考文献

Discussion