TypeScript で Chrome/Firefox 両対応の拡張機能を書く
はじめに
これは TypeScript で Chrome/Firefox 両対応の拡張機能を書くために、自分の拡張機能 で実践している/いたことをまとめたものである。すなわち、JavaScript/CSS/HTML で構成される拡張機能を、いかにして型に守られた TypeScript の世界で開発するか、また Chrome/Firefox でいかにコードを共通化するか、その方法の一つが書かれている。
必ずしも最適解とは限らないし、そもそも TypeScript 化が手段でなく目的となっているような部分もある。
TypeScript
まず最新の TypeScript をインストールし、設定ファイル 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"
にしておく。
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 の引数を使う方法があるが、ここでは前者を採用する。
{
"scripts": {
"build:chrome": "cross-env BROWSER=chrome webpack",
"build:firefox": "cross-env BROWSER=firefox webpack"
},
// ...
}
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 の例を示す。
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 を使うことができる。
// ...
{
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 の型に守ってもらいたい。
{
"extensionName": {
"message": "hogepiyo",
},
"extensionDescription": {
"message": "My excellent extension!",
},
// 多数のメッセージが続く
}
JSON の TypeScript 化のため、val-loader と file-loader を使うのは一案である。
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,
},
},
],
},
],
},
// ...
};
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()
して、出力をすげ替える。
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),
);
}
}
},
);
});
},
},
],
// ...
}
exportAsJSON = {
options_ui: {
/// #if BROWSER === 'chrome'
chrome_style: false,
/// #else
browser_style: false,
/// #endif
page: 'options.html',
},
// ...
};
出力
{
"options_ui": {
"chrome_style": false,
"page": "options.html"
},
// ...
}
{
"options_ui": {
"browser_style": false,
"page": "options.html"
},
// ...
}
HTML → TypeScript
拡張機能の開発では、オプション画面やポップアップを作るのに HTML を書く必要がある。だが React のようなライブラリを使うことで、それらの大部分を TypeScript (TSX) に移すことができ、また仮想 DOM などの恩恵を受けることもできる。個人的には、Preact が小さく読みやすいので勧めたい。
const Options: FunctionComponent = () => {
return (
// ...
);
);
render(<Options />, document.body);
<html>
<head>
<meta charset="utf-8">
<title>Options</title>
<script defer src="./options.js"></script>
</head>
<body></body>
</html>
こうなると HTML ファイルは実質空っぽである。HTMLWebpackPlugin で生成するようにすればソースから HTML は消滅する。
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