React+TypeScript+Rust(WebAssembly)でChrome拡張機能を作ってみる

9 min読了の目安(約8900字TECH技術記事
Likes22

Chromeの拡張機能はJavaScriptやHTML,CSSを使って作ることができるのですが、DOM操作などをしようとするとモダンなフロントエンド技術の力を借りたくなります。
今回は今自分が興味のあるReact+TypeScript、そしてRustをWebAssembly経由で使う構成で、Chrome拡張機能をビルドできるようにしてみます。

実装内容としてはThe Rust Wasm Bookのライフゲーム(Conway's Game of Life)を拡張機能としてポップアップメニューに表示するだけの単純なものです。
ライフゲーム
完成品はGitHubに公開しています。

実行環境

❯ node --version
v14.10.1
❯ yarn --version
1.22.10
❯ rustc --version
rustc 1.47.0 (18bf6b4f0 2020-10-07)
❯ cargo --version
cargo 1.47.0 (f3c7e066a 2020-08-28)
❯ wasm-pack --version
wasm-pack 0.9.1
❯ cargo-generate --version
cargo-generate 0.5.0

その他依存関係はpackage.jsonCargo.tomlの通りです。

まずはReact+TypeScriptでHello, Worldしてみる

Webサービス開発ではReact+TypeScriptの環境を構築するにはcreate-react-appを使うのが当たり前ですが、今回Chrome拡張機能を実装する際には使いません。
create-react-appのそのままのビルド設定だとChrome拡張機能には向かなく、webpackの設定をカスタマイズする必要が出てきます。create-react-appのwebpack設定はreact-app-rewiredで上書きすることができますが、これはcreate-react-app公式にサポートされているものでなくバージョンアップで容易に壊れてしまう可能性があります。
なので今回はゼロからwebpackの設定を書きます。ただしそんなに設定は複雑ではありません。

プロジェクトのディレクトリを作成するところから始めます。

# 必要なサブディレクトリもまとめて作ってしまいます。
mkdir -p react-rust-chrome-extension/src react-rust-chrome-extension/static

cd react-rust-chrome-extension
yarn init

そして、必要なパッケージをインストールします

先日webpack5がリリースされましたが、この記事ではwebpack4を前提に話を進めます

yarn add react react-dom
yarn add -D \
  @types/chrome @types/react @types/react-dom \
  'webpack@^4.44.2' 'webpack-cli@^3.3.12' typescript ts-loader \
  copy-webpack-plugin @wasm-tool/wasm-pack-plugin

@types/chromeは今回実際には使ってませんがTypeScriptでChrome APIを触るには欠かせません。
@wasm-tool/wasm-pack-pluginはwebpack経由でwasmをビルドするためのwebpackプラグインです。

次にポップアップを表示するためのmanifest.json, popup.html, Popup.tsxを用意します。
静的ファイルはstatic/へ、ts(tsx)のファイルはsrc/に置くことにします。

static/manifest.json

{
  "name": "React + TypeScript + Rust Extension",
  "description": "Extension made with React and TypeScript, Rust and WebAssembly.",
  "version": "1.0.0",
  "manifest_version": 2,
  "browser_action": {
    "default_popup": "popup.html"
  },
  "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'"
}

default_popupでポップアップのエントリーポイントになるhtmlファイルを指定しています。
tsxから変換されたjsファイルを動かせるようにするためにcontent_security_policyを設定しています。

static/popup.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>React + TypeScript + Rust Chrome Extension</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <script src="popup.js"></script>
  </body>
</html>

これはポップアップ画面用のindex.htmlです。スクリプトタグでpopup.jsを指定しています。
もし設定画面も作りたい場合は同じようなHTMLを別で用意してmanifest.jsonで指定する必要があります。

src/Popup.tsx

import React from "react";
import ReactDOM from "react-dom";

const Popup: React.FC = () => {
  return <div>Hello, React</div>;
};

ReactDOM.render(<Popup />, document.getElementById("root"));

webpack.config.js

const path = require("path");
const CopyWebpackPlugin = require("copy-webpack-plugin");

module.exports = {
  mode: process.env.NODE_ENV || "development",
  entry: {
    popup: "./src/Popup.tsx"
  },
  output: {
    path: path.resolve(__dirname, "dist/")
  },
  module: {
    rules: [
      { 
        test: /\.tsx?$/,
        loader: "ts-loader"
      }
    ]
  },
  plugins: [
    new CopyWebpackPlugin({
      patterns: [
        { from: "static", to: "." }
      ]
    })
  ],
  devtool: "inline-source-map",
  resolve: {
    extensions: [".tsx", ".ts", ".js"]
  }
};

entryでエントリーポイントとなるファイルを指定しています。
拡張機能の設定画面やバックグラウンドスクリプトを追加する場合は以下のように書きます。

entry: {
  popup: "./src/Popup.tsx",
  options: "./src/Options.tsx",
  background: "./src/background.ts"
}

outputで出力先のディレクトリをdist/に指定しています。このディレクトリをパッケージ化されていない拡張機能として読み込むことで開発中の拡張機能を動かすことができます。

ts-loaderでtsやtsxファイルの型チェックとトランスパイルをします。
CopyWebpackPluginでstatic/に置いた静的ファイルをそのままdist/にコピーするようにしています。

次にtsconfig.jsonを追加します。

{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react",
    "sourceMap": true
  },
  "include": ["src"]
}

この設定はcreate-react-appで生成されたtsconfigに少し手を加えたものです。
ts-loaderでトランスパイルまでを行うため"noEmit": trueを削除し、DevToolsで元のTypeScriptのソースを確認できるように"sourceMap": trueを追加しました。

最後にpackage.jsonのscriptsを追加してyarn buildするとビルドできます。

"scripts": {
  "build": "webpack",
},
yarn build
❯ tree dist
dist
├── manifest.json
├── popup.html
└── popup.js

chrome://extensions/ でデベロッパーモードをオンにし、dist/ディレクトリに対し「パッケージ化されていない拡張機能を読み込む」ことで拡張機能を追加できます。

Rust+WebAssemblyでHello, Worldしてみる

The Rust Wasm Book/4.2. Hello, World!の通りにRust+WebAssemblyのプロジェクトを作成します。
ディレクトリ構成は以下のようになるようにします。

❯ cargo generate --git https://github.com/rustwasm/wasm-pack-template -n wasm-game-of-life
❯ tree . -I node_modules -d
.
├── dist
├── src
├── static
└── wasm-game-of-life

wasm-game-of-life/src/lib.rsを見ると、既にjsのalertが呼び出せるように書かれています。
wasm-packでビルドしてみます。

❯ wasm-pack build wasm-game-of-life
# wasm-game-of-life/pkgにnode moudleとして出力されますls -1  wasm-game-of-life/pkg
README.md
package.json
wasm_game_of_life.d.ts
wasm_game_of_life.js
wasm_game_of_life_bg.js
wasm_game_of_life_bg.wasm
wasm_game_of_life_bg.wasm.d.ts

このモジュールをnpmに公開することもできますが、今回はyarn linkでシンボリックリンクを作成して参照することにします。

cd wasm-game-of-life/pkg
yarn link
cd ../..
yarn link wasm-game-of-life

WebAssemblyを依存関係に加えるには必ずダイナミックインポートでインポートする必要があります。
そのため、以下の内容のpopup.jsファイルを作成します(今度はこれをポップアップのエントリーポイントにします)。

src/popup.js

import("./Popup").catch((error) => console.error(`Failed to import: ${error}`));

src/Popup.tsx

  import React from "react";
  import ReactDOM from "react-dom";
+ import * as wasm from "wasm-game-of-life";

+ wasm.greet();

  const Popup: React.FC = () => {
    return <div>Hello, React</div>;
  };

  ReactDOM.render(<Popup />, document.getElementById("root"));

webpack.config.jsも書き換えます。

  const path = require("path");
  const CopyWebpackPlugin = require("copy-webpack-plugin");
+ const WasmPackPlugin = require("@wasm-tool/wasm-pack-plugin")
  
  module.exports = {
    mode: process.env.NODE_ENV || "development",
    entry: {
-      popup: "./src/Popup.tsx"    
+      popup: "./src/popup.js"
    },
    output: {
      path: path.resolve(__dirname, "dist/")
    },
    module: {
      rules: [
        { 
          test: /\.tsx?$/,
          loader: "ts-loader"
        }
      ]
    },
    plugins: [
+     new WasmPackPlugin({
+       crateDirectory: path.resolve(__dirname, "wasm-game-of-life"),
+       outDir: path.resolve(__dirname, "wasm-game-of-life/pkg"),
+       outName: "wasm_game_of_life"
+     }),
      new CopyWebpackPlugin({
        patterns: [
          { from: "static", to: "." }
        ]
      })
    ],
    devtool: "inline-source-map",
    resolve: {
-     extensions: [".tsx", ".ts", ".js"]
+     extensions: [".tsx", ".ts", ".js", ".wasm"]
    }
  };

WasmPackPluginを使うとwasm-pack buildをwebpack経由でやってくれます。
crateDirectoryでRustのクレートのディレクトリを指定します。
ourDirはデフォルトがプロジェクトルートのpkgディレクトリを指定するようになっているので、クレートの中のpkgディレクトリを指定しないと空のpkgディレクトリがプロジェクトルートに生成されてしまうようです。(wasm-tool/wasm-pack-pugin#93)
ourNameもデフォルトがindex.*になってしまうので、直接wasm-pack buildを実行したときと同じ名前になるように指定しています。

これでyarn buildすると.wasmファイルもdistディレクトリに書き出されるようになります。

❯ yarn build
❯ ls -1 dist
0.js
1.js
3251cfd6ad46c495e76d.module.wasm
manifest.json
popup.html
popup.js

拡張機能をリロードしてポップアップを表示されるとアラートメッセージが表示されるようになっています。
ここまででプロジェクトの設定はすべてできました。

ライフゲームの実装をする

RustとWebAssemblyを使ったライフゲームの実装はThe Rust Wasm Bookで詳しく解説されています。ほとんどそのまま実装するだけでChrome拡張機能でライフゲームが動いているところを表示できます。詳しくはThe Rust Wasm Book/4.4. Imprementing Lifeを読みながら実装してみるといいでしょう。(ほとんど内容が丸かぶりするのでこの記事では割愛します)

JavaScriptで素のDOMを操作している部分はcanvasのrefを受け取るようにして実装できます。実装例