🕌

TypeScript で Chrome/Firefox 両対応の拡張機能を書く

2021/01/11に公開

はじめに

これは TypeScript で Chrome/Firefox 両対応の拡張機能を書くために、自分の拡張機能 で実践している/いたことをまとめたものである。すなわち、JavaScript/CSS/HTML で構成される拡張機能を、いかにして型に守られた TypeScript の世界で開発するか、また Chrome/Firefox でいかにコードを共通化するか、その方法の一つが書かれている。
必ずしも最適解とは限らないし、そもそも TypeScript 化が手段でなく目的となっているような部分もある。

TypeScript

まず最新の TypeScript をインストールし、設定ファイル tsconfig.json を作成する。以下は一案。

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2019",
    "module": "commonjs",
    "sourceMap": true,
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "moduleResolution": "node",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}

最近の Chrome や Firefox をターゲットにするなら、"target": "ES2019" くらいは要求しても大丈夫か。不安なら下げてもよい。
出力するモジュールの形式は、後で webpack でバンドルするため "module": "commonjs""module": "ES2015" のどちらでもよい。webpack の設定を TypeScript で書くために "module": "commonjs" の設定ファイルが必要なので、設定ファイルを分けたくなければ "module": "commonjs" でいいだろう。
TypeScript 4.1 で加わった "noUncheckedIndexedAccess": true も是非入れておきたい。自分は入れていないが

webpack

拡張機能を開発するにあたり、バンドラは必須とまでは言えないがある方がよい。Content scripts からは ES modules を使えない点、CommonJS 形式の npm モジュールを使いたい点が主な理由。前者は <script type="module"> を動的に挿入すればいいとか、workarounds はなくはないが、何も考えずにバンドラに 1 ファイルにまとめてもらった方が楽。色々なカスタマイズもきく。

ここでは高機能な webpack を使う。現在 (2021/1) の最新版は 5。ts-loader (あるいは babel-loader) を設定することで TypeScript でコードを書けるようになる。
また TypeScript で書くと言った以上、当然 webpack の設定も TypeScript で書く (参考)。ts-node のインストールが必要、多くの場合 @types/node も必要。参考 URL では @types/webpack もインストールしているが、webpack 5 は型定義を内蔵しているので不要。TypeScript の設定は "module": "commonjs" にしておく。

webpack.config.ts
import webpack from 'webpack';

const config: webpack.Configuration = {
  module: {
    rules: [
      { test: /\.tsx?$/, use: 'ts-loader' },
    ],
  },
  // ...
};

export default config;

注意すべきは、現時点 (2021/1) では一部のプラグインが型定義を @types/webpack に依存していたり、独自の型定義を使っていたりして、webpack 本体と型が合わない場合があること。強引にキャストして対処する。

const config: webpack.Configuration = {
  plugins: [
    (new SomePlugin() as unknown) as webpack.WebpackPluginInstance,
  ],
  // ...
};

さて Chrome 用のビルドと Firefox 用のビルドを作るのだが、webpack の設定ファイルを分けることはせず、外からブラウザ名を変数として与えることにする。環境変数を使う方法と webpack-cli の引数を使う方法があるが、ここでは前者を採用する。

package.json
{
  "scripts": {
    "build:chrome": "cross-env BROWSER=chrome webpack",
    "build:firefox": "cross-env BROWSER=firefox webpack"
  },
  // ...
}
webpack.config.ts
const config: webpack.Configuration = {
  output: {
    path: path.join(__dirname, 'dist', process.env.BROWSER),
  },
  // ...
};

拡張機能の API

Chrome では chrome.* の形で、Firefox では browser.* の形で様々な拡張機能の API を使うことができる。かなり互換性は高いのだが、前者はコールバックベース、後者は Promise ベースであり、違いを吸収する層をかませる必要がある。

現時点では、おそらく webextensions-polyfill-ts が最適解である。自分は使っていないが

import { browser } from 'webextension-polyfill-ts';

async function doubleStorageValue(): Promise<void> {
  const value: number = await browser.storage.local.get('value');
  return browser.storage.local.set({ value: value * 2 });
}

なお、Chrome でも Manifest V3 で Promise ベースの API が実装されつつある (参考)。

条件コンパイル

API の呼び出しを共通化しても、Chrome と Firefox で処理を分けたい部分は出てくる。一部は DefinePlugin あるいは EnvironmentPlugin で対応可能である。EnvironmentPlugin の例を示す。

webpack.config.ts
const config: webpack.Configuration = {
  plugins: [
    new webpack.EnvironmentPlugin(['BROWSER']),
  ],
  // ...
};

拡張機能のコード

if (process.env.BROWSER === 'chrome') {
  console.log('chrome');
} else {
  console.log('firefox');
}

上記の場合は問題ないが、ブラウザによりインポートするモジュールを分けたい場合、あるいは TypeScript の型定義を分けたい場合には、if 文で分けることは難しい (前者はある程度 dynamic import で逃げられるかもしれないが)。

そういう場合は、ifdef-loader を使うことができる。

webpack.config.ts
// ...
{
  test: /\.tsx?$/,
  use: [
    'ts-loader',
    {
      loader: 'ifdef-loader',
      options: {
        BROWSER: process.env.BROWSER,
      },
    },
  ],
},
// ...

拡張機能のコード

/// #if BROWSER === 'chrome'
import { awesomeFunction } from './awesome-module-chrome';
/// #else
import { awesomeFunction } from './awesome-module-firefox';
/// #endif

これはコードから予想される通りに動く。

だが VSCode で上記コードを書いたら、TypeScript の language server か eslint あたりに怒られるだろう (awesomeFunction が重複している)。それが我慢ならない場合は、ifdef-loader が /* ... */ 形式のコメントを解しないことを利用し、次のように書く。

/// #if BROWSER === 'chrome'
import { awesomeFunction } from './awesome-module-chrome';
/*
/// #else
import { awesomeFunction } from './awesome-module-firefox';
/// #endif
/// #if BROWSER === 'chrome'
*/
/// #endif

あるいは自分で loader を書いてしまってもよい。Source map がずれないような工夫は要る。

// #if BROWSER === 'chrome'
import { awesomeFunction } from './awesome-module-chrome';
/* #else
import { awesomeFunction } from './awesome-module-firefox';
*/
// #endif

JSON → TypeScript

(これ以降の節は、TypeScript 化が目的と化している節がある)

拡張機能の開発では、少なくとも 1 つの JSON ファイルを書く必要がある。manifest.json である。これはブラウザにより書くべき項目が微妙に違う。できれば TypeScript 化して、上記の条件コンパイルの手法を使いたい。

また、国際化のためにはロケールごとに以下のような messages.json を書く必要があるが、多数のメッセージがある状態でキーのタイプミスをしない保証はなく、キーのタイプミスがあってもデフォルトロケールのメッセージが代わりに使われるだけなので気づきにくい。ここは TypeScript の型に守ってもらいたい。

_locales/en/messages.json
{
  "extensionName": {
    "message": "hogepiyo",
  },
  "extensionDescription": {
    "message": "My excellent extension!",
  },
  // 多数のメッセージが続く
}

JSON の TypeScript 化のため、val-loaderfile-loader を使うのは一案である。

webpack.config.ts
const config: webpack.Configuration = {
  entries: {
    'manifest.json': './manifest.json.ts',
  },
  module: {
    rules: [
      {
        test: /\.json.ts$/,
        use: [
          {
            loader: 'file-loader',
            options: {
              name: '[path][name]',
            },
          },
          'val-loader',
          'ts-loader',
          {
            loader: 'ifdef-loader',
            options: {
              BROWSER: process.env.BROWSER,
            },
          },
        ],
      },
    ],
  },
  // ...
};
manifest.json.ts
const manifest = {
  options_ui: {
    /// #if BROWSER === 'chrome'
    chrome_style: false,
    /// #else
    browser_style: false,
    /// #endif
    page: 'options.html',
  },
  // ...
};

export default () => ({
  cacheable: true,
  code: JSON.stringify(manifest, null, 2),
});

悪くはない。だが、この方法には *.json.ts から他の TypeScript モジュールをインポートできないという欠点がある (細かい説明は省く)。また *.json.js というゴミ出力が残る (webpack-fix-style-only-entries で消すことは可能)。

自前でプラグインを書くことが最終的な解決となる。大ざっぱに言うと、*.json.ts は通常通り webpack にトランスパイル→バンドルしてもらい、その後プラグインで eval()JSON.stringify() して、出力をすげ替える。

webpack.config.ts
const config: webpack.Configuration = {
  entry: {
    'manifest.json': './manifest.json.ts',
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: [
          'ts-loader',
          {
            loader: 'ifdef-loader',
            options: {
              BROWSER: process.env.BROWSER,
            },
          },
        ],
      },
    ],
  },
  plugins: [
    {
      apply(compiler: webpack.Compiler): void {
        compiler.hooks.compilation.tap('JsonPlugin', compilation => {
          compilation.hooks.processAssets.tap(
            {
              name: 'JsonPlugin',
              stage: webpack.Compilation.PROCESS_ASSETS_STAGE_PRE_PROCESS,
            },
            assets => {
              for (const [name, source] of Object.entries(assets)) {
                if (name.endsWith('.json.js')) {
                  delete assets[name];
                  let exportAsJSON = {};
                  eval(source.source().toString());
                  assets[name.slice(0, -3)] = new webpack.sources.RawSource(
                    JSON.stringify(exportAsJSON, null, 2),
                  );
                }
              }
            },
          );
        });
      },
    },
  ],
  // ...
}
manifest.json.ts
exportAsJSON = {
  options_ui: {
    /// #if BROWSER === 'chrome'
    chrome_style: false,
    /// #else
    browser_style: false,
    /// #endif
    page: 'options.html',
  },
  // ...
};

出力

dist/chrome/manifest.json
{
  "options_ui": {
    "chrome_style": false,
    "page": "options.html"
  },
  // ...
}
dist/firefox/manifest.json
{
  "options_ui": {
    "browser_style": false,
    "page": "options.html"
  },
  // ...
}

HTML → TypeScript

拡張機能の開発では、オプション画面やポップアップを作るのに HTML を書く必要がある。だが React のようなライブラリを使うことで、それらの大部分を TypeScript (TSX) に移すことができ、また仮想 DOM などの恩恵を受けることもできる。個人的には、Preact が小さく読みやすいので勧めたい。

options.tsx
const Options: FunctionComponent = () => {
  return (
    // ...
  );
);

render(<Options />, document.body);
options.html
<html>
  <head>
    <meta charset="utf-8">
    <title>Options</title>
    <script defer src="./options.js"></script>
  </head>
  <body></body>
</html>

こうなると HTML ファイルは実質空っぽである。HTMLWebpackPlugin で生成するようにすればソースから HTML は消滅する。

webpack.config.ts
const config: webpack.Configuration = {
  entry: {
    options: './options.tsx',
    popup: './popup.tsx',
  },
  plugins: [
    new HtmlWebpackPlugin({
      chunks: ['options'],
      filename: 'options.html',
      title: 'Options',
    }),
    new HtmlWebpackPlugin({
      chunks: ['popup'],
      filename: 'popup.html',
      title: 'Popup',
    }),
  ],
  // ...
};

なお現時点 (2021/1) では、HTMLWebpackPlugin を webpack 5 と共に使うには、html-webpack-plugin@next をインストールする必要がある。

CSS → TypeScript

styled-components などの CSS-in-JS を実現するライブラリを使うことで、CSS も TypeScript に移すことが可能である。それによりコンポーネントのコードが 1 カ所に集まり、扱いやすくなる。VSCode と stylelint のサポートも悪くない。

個人的には goober が小さくて好きだが、Firefox の content scripts で使うと <style> 要素が増殖するという分かりにくい問題がある。解決策だけ書くと、以下である。

import * as goober from 'goober';

const css = goober.css.bind({ target: document.head });
const glob = goober.css.bind({ g: 1, target: document.head });
const styled = goober.styled.bind({ target: document.head });

おわりに

TypeScript で Chrome/Firefox 両対応の拡張機能を書く方法の一つをまとめた。

Discussion