RevComm Tech Blog

コミュニケーションを再発明し 人が人を想う社会を創る

TypeScript で Chrome 拡張機能を開発する

この記事は、RevComm Advent Calender 16 日目の記事です。
フロントエンドチームに所属する関口です。フロントエンドエンジニアとして活動するかたわら、MiiTel の一部の製品のプロジェクトマネージャーを兼任しています。

なぜこのタイミングで Chrome 拡張機能がテーマなのかというと、最近 Manifest V3 への対応を弊社の MiiTel Phone Chrome 拡張機能に対して行い、知見ができたことがその理由です。各 Chrome 拡張機能の機能定義を行うフォーマットが Manifest ファイルです。このフォーマットの V2 から V3 への移行が Google から促されており、その移行猶予期間が来年の 1 月までとなっています。

この記事について

この記事のゴール

Chrome 拡張機能の開発手順を最低限確認した後、TypeScript が使えるシンプルな開発環境を作るチュートリアルを提供します。

この記事で説明しないこと

  • Chrome 拡張機能の配布方法
  • Node.js やパッケージのバージョンについて
  • Lint やユニットテスト、CI について
  • バージョン管理ツールの設定や操作

対象読者

TypeScript 開発経験のあるソフトウェアエンジニア

導入

Chrome 拡張機能は何ができるか

ブラウザが備える JavaScript API に加え、Chrome ブラウザのローカルな機能にアクセスするための Chrome API が利用できます。Chrome API を通じてブックマークのデータにアクセスしたり、ブラウザのコンテキストメニューを編集したり、といったことが可能になります。

Chrome 拡張機能のファイル構成

Chrome 拡張機能は Manifest、Service worker、Content scripts、HTML ページといったファイルで構成されています。オフィシャルドキュメントによるそれぞれの定義を見ていきます。

  • The manifest
    • ファイル名は manifest.json で、その拡張機能のルートディレクトリに置く必要がある。拡張機能の定義(利用したい API や構成するファイルなど)をここに書く。
  • The service worker
    • ブラウザのイベントを拾うイベントハンドラを定義する。Chrome API を利用できるが、Web ページのコンテンツには直接アクセスできない。
  • Content scripts
    • Chrome で閲覧中の Web ページにインジェクトされる。DOM にアクセスできることに加え、Chrome API の一部の機能が使える。拡張機能の Service worker とメッセージを送受信してやりとりができる。
  • The popup and other pages

基本的な開発方法についてはここでは説明しません。公式ドキュメントの Development Basics に簡潔に説明されているので、そちらをご参照ください。

モチベーションと技術選定方針

  • TypeScript を使って簡単な Chrome 拡張機能を作りたい。
  • 依存パッケージを最小限にし、ラーニングコストがかからない開発環境にしたい。

とにかくシンプルにしたいので、React や Vue といった JavaScript フレームワークも使わず、HTML も CSS も生で書くことにします。ここでは扱いませんが、もし React や Vue を使いたい場合、 CRXJS を使って環境構築するとよさそうです。

チュートリアル

Hello World

Development Basics の Hello World チュートリアルをベースに、簡単な拡張機能を作ってみましょう。まず拡張機能のために空のディレクトリを用意して、package.json を作ります。

$ mkdir extension-directory
$ cd extension-directory
$ npm init -y

空の manifest.json ファイルを作り、

$ touch manifest.json

エディタで開いて以下のように書き込みます。

{
  "manifest_version": 3,
  "name": "Hello Extensions",
  "description": "Base Level Extension",
  "version": "1.0",
  "action": {
    "default_popup": "hello.html",
    "default_icon": "hello_extensions.png"
  }
}

hello_extensions.png はこちらからダウンロードして、同じディレクトリに置きます。これがツールバーに表示される拡張機能のアイコンになります。 同ディレクトリに hello.html ファイルを作り、以下のように書き込みます。

<html>
  <body>
    <h1>Hello Extensions</h1>
  </body>
</html>

そして、Chrome の拡張機能ページを開き画面右上にある「デベロッパーモード」をオンにして表示される「パッケージ化されていない拡張機能を読み込む」ボタンをクリックすると、そのディレクトリの必要なファイルを読み込んで拡張機能として実行することができます。

パッケージ化されていない拡張機能を読み込む

Chrome のツールバーに Hi アイコンが表示されるはずですが、これをクリックすると、Hello Extensions というポップアップが表示されます。
ここまでは Development Basics のチュートリアルの内容です。

では、この hello.html に JavaScript を追加します。

<html>
  <body>
    <h1>Hello Extensions</h1>
    <script src="popup.js"></script>
  </body>
</html>

popup.js ファイルの中身は以下のようにしましょう。

const $body = document.querySelector('body');
const $p = document.createElement('p');
$p.innerHTML = 'Hello Popup';
if ($body) {
  $body.appendChild($p);
}

拡張機能ページのリロードボタンをクリックすると

拡張機能ページのリロードボタン

拡張機能はこのように更新されるはずです。

この popup.js を少し最適化してみます。

+ import { HELLO } from './constants.js';
 
  const $body = document.querySelector('body');
  const $p = document.createElement('p');
- $p.innerHTML = 'Hello Popup';
+ $p.innerHTML = `${HELLO} Popup`;
  if ($body) {
    $body.appendChild($p);
  }

constants.js という新しいファイルを作り、

export const HELLO = 'Hello';

と書き込みます。
このように、一部のテキストを constants.js に定数として切り出しました。更新ボタンをクリックしてから拡張機能を表示すると、該当のテキストが表示されていません。拡張機能管理ページを確認すると「エラー」ボタンが表示されています。

拡張機能管理ページのエラー表示

エラーボタンをクリックすると、エラーの内容を見ることができます。

エラーの詳細

Uncaught SyntaxError: Cannot use import statement outside a module と表示されていますね。

import 構文を使うには script タグに type="module" 属性が必要です。 hello.html を修正します。

  <html>
    <body>
      <h1>Hello Extensions</h1>
-     <script src="popup.js"></script>
+     <script type="module" src="popup.js"></script>
    </body>
  </html>

拡張機能をリロードして確認してみましょう。今度は該当のテキストが表示されるはずです。
また、拡張機能管理ページのエラーボタンはそのまま表示されているはずです。いつの時点のエラーなのかを区別するため、エラー内容ページに遷移し、現時点でのエラー報告はすべて削除しておくとよいでしょう。

Content scripts を追加する

さらに、Webページにインジェクトされる Content scripts も書いてみます。content_scripts.js ファイルと content_styles.css を追加し、以下のように manifest.json を修正します。

  {
    "manifest_version": 3,
    "name": "Hello Extensions",
    "description": "Base Level Extension",
    "version": "1.0",
    "action": {
      "default_popup": "hello.html",
      "default_icon": "hello_extensions.png"
    },
+   "content_scripts": [
+     {
+       "matches": [
+         "http://*/*",
+         "https://*/*"
+       ],
+       "css": [ "content_styles.css" ],
+       "js": [ "content_scripts.js" ]
+     }
+   ]
}

content_scripts.js

const $body = document.querySelector('body');
const $helloContent = document.createElement('div');
$helloContent.className = 'hello-content';
$helloContent.innerHTML = 'Hello content scripts';
if ($body) {
  $body.appendChild($helloContent);
}

content_styles.css

.hello-content {
  position: fixed;
  z-index: 999;
  bottom: 0;
  right: 0;
  width: 10em;
  padding: 1em;
  background-color: white;
  box-shadow: 0 0 5px gray;
  text-align: center;
}

拡張機能を更新して適当な Web ページ(この例では https://www.google.com )を表示すると、右下に Hello content scripts というボックスが表示されるはずです。

Google トップページに挿入された Content scripts

TypeScript 化する

TypeScript で開発できるようにしていきます。
まず、必要なパッケージをインストールします。

$ npm install -D typescript @types/chrome

tsconfig.json も用意します。ここはお好きにどうぞ、と言いたいところですが、ここでは以下のように設定します。 src ディレクトリに .ts ファイルを置き、コンパイル後の出力は dist ディレクトリに行う設定です。

{
  "compilerOptions": {
    "target": "ESNext",
    "lib": ["ESNext", "DOM", "DOM.Iterable"],
    "useDefineForClassFields": true,
    "module": "ESNext",
    "rootDir": "./src",
    "outDir": "./dist",
    "moduleResolution": "node",
    "baseUrl": "src",
    "resolveJsonModule": true,
    "allowJs": true,
    "checkJs": true,
    "removeComments": true,
    "esModuleInterop": true,
    "strict": true,
    "noImplicitAny": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "skipLibCheck": true
    },
    "include": ["src"],
    "exclude": ["node_modules", "dist", "./tsconfig.json"]
}

ファイルを以下のように移動します。 .js ファイルは .ts にリネームします。

extension-directory/
├── node_modules
├── dist
│ ├── content_styles.css
│ ├── hello_extension.png
│ ├── hello.html
│ ├── manifest.json
├── src
│ ├── constants.ts
│ ├── content_scripts.ts
│ ├── popup.ts
├── packages.json
├── tsconfig.json

packages.json に build コマンドを追加します。

  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
+   "build": "tsc"
  },

npm run build コマンドを実行してみましょう。
dist ディレクトリ以下に、コンパイルされた constants.js、content_script.js、popup.js の 3 つができたら成功です。 manifest.json の置いてあるディレクトリが dist に変わったので、拡張機能を読み込み直します。 拡張機能ページで該当の拡張機能をいったん「削除」し、再度「パッケージ化されていない拡張機能を読み込む」で dist ディレクトリを指定します。

パッケージを削除してから読み込み直す

Content scripts の最適化

content_script.ts も import を使って共通のモジュール(constants.ts)を利用するように最適化します。

+ import { HELLO } from './constants';
+
  const $body = document.querySelector("body");
  const $helloContent = document.createElement("div");
  $helloContent.className = "hello-content";
- $helloContent.innerHTML = "Hello content scripts";
+ $helloContent.innerHTML = `${HELLO} content scripts`;
  if ($body) {
    $body.appendChild($helloContent);
  }

npm run build でビルドしてから、拡張機能をリロードします。適当な Web ページを表示してみると…画面右下の拡張機能が表示されません。Chrome DevTools の Console を見ると、 Uncaught SyntaxError: Cannot use import statement outside a module エラーメッセージが表示されています。

Content scripts のエラー表示

Content scripts は自動的にドキュメントにインジェクトされるため、 script タグに type="module" をつけるといった対処は行えません。
…となると、 import 構文を使わないように書くか、バンドラーを使うしかない、ということになってきます。今後の拡張性も考慮して、バンドラーを導入することにします。

Vite の導入

バンドラーは Webpack でも Rollup でもなんでも構いませんが、洗練された開発環境を提供し、日本語ドキュメントもある Vite をここでは採用します。

まずはインストール

$ npm i -D vite

vite.config.js ファイルをルートディレクトリに作り、以下のように設定を書きます。

import { resolve } from 'node:path';
import { defineConfig } from 'vite';
 
export default defineConfig((opt) => {
  return {
    root: 'src',
    build: {
      outDir: '../dist',
      rollupOptions: {
        input: {
          content_scripts: resolve(__dirname, 'src/content_scripts.ts'),
          popup: resolve(__dirname, 'src/hello.html')
        },
        output: {
          entryFileNames: '[name].js',
        },
      },
    },
  };
});

既存のファイルも以下のように移動します。

extension-directory/
├── node_modules
├── dist
├── src
│ ├── public
│ │ ├── content_styles.css
│ │ ├── hello_extension.png
│ | ├── manifest.json
│ ├── constants.ts
│ ├── content_scripts.ts
│ ├── hello.html
│ ├── popup.ts
├── packages.json
├── tsconfig.json
├── vite.config.js

packages.json の build コマンドを以下のように書き換えます。

  "scripts": {
  "test": "echo \"Error: no test specified\" && exit 1",
-   "build": "tsc"
+   "build": "tsc && vite build"
  },

vite は TypeScript のトランスパイルを行うので、 tsc でのトランスパイルは不要になります。一方、vite は型のチェックを行わないため、 tsc を型チェックのためだけに利用するように動作を変えます。 tsconfig.json を以下のように修正します。

      "rootDir": "./src",
-     "outDir": "./dist",
      "moduleResolution": "node",
      ...
      "removeComments": true,
+     "noEmit": true,
+     "isolatedModules": true,
      "esModuleInterop": true,

noEmit を指定することで型チェックだけ行ってファイルを出力しないようにします。また、 isolatedModules の指定は vite にとって必要な設定です。
hello.html も調整が必要です。読み込むファイルを .js から .ts に変更します。

    <body>
      <h1>Hello Extensions</h1>
-     <script type="module" src="popup.js"></script>
+     <script type="module" src="popup.ts"></script>
    </body>

ここまで設定したら npm run build を実行してみましょう。
ルートの dist ディレクトリに以下のようなファイルが出力されたら成功です。

extension-directory/
├── dist
│ ├── assets
│ │ ├── constants.xxxxxxxx.js
│ ├── content_scripts.js
│ ├── content_styles.css
│ ├── hello_extensions.png
│ ├── hello.html
│ ├── manifest.json
│ ├── popup.js

さて、出力された content_scripts.js を開くと…

import{H as n}from"./assets/constants.xxxxxxxx.js";const t=document.querySelector("body"),e=document.createElement("div");e.className="hello-content";e.innerHTML=`${n} content scripts`;t&&t.appendChild(e);

このような内容になっているはずです。あれ… import が使われていますね。これは意図している出力結果ではありません。
モジュールのファイル内容を展開して出力結果から import 構文を無くすには、rollup の inlineDynamicImports オプションを使うと実現ができます。ただし、このオプションはインプットとなるファイルが 1 つの場合のみ利用できるという制約があります。今回のインプットは content_scripts と popup の 2 つがあるので、このままでは使えません。さてどうするか。

Vite の設定を工夫する

vite.config.js を 2 つに分割してしまうことで、この問題を回避します。
vite.config.content_scripts.js ファイルを追加し、以下のような設定にします。 format: 'iife' は即時実行関数( "immediately-invoked function expression" )のフォーマットでファイルを出力する設定で、script タグで読み込まれるスクリプトに適しています。

import { resolve } from 'node:path';
import { defineConfig } from 'vite';
 
export default defineConfig((opt) => {
  return {
    root: 'src',
    build: {
      outDir: '../dist',
      emptyOutDir: false,
      rollupOptions: {
        input: {
          content_scripts: resolve(__dirname, 'src/content_scripts.ts'),
        },
        output: {
          entryFileNames: '[name].js',
          inlineDynamicImports: true,
          format: 'iife',
        },
      },
    },
  };
});

これに合わせて vite.config.js も修正します。

      build: {
        outDir: '../dist',
+       emptyOutDir: true,
        rollupOptions: {
          input: {
-           content_scripts: resolve(__dirname, 'src/content_scripts.ts'),
            popup: resolve(__dirname, 'src/hello.html')
          },
          output: {
            entryFileNames: '[name].js',
          },
        },
      },

emptyOutDir は出力先のディレクトリを実行前に空にするかどうかの設定です。
最後に packages.json のコマンドを修正しましょう。

    "scripts": {
      "test": "echo \"Error: no test specified\" && exit 1",
-     "build": "tsc && vite build"
+     "build": "tsc && vite build && vite build --config=vite.config.content_scripts.js"
    },

config ファイル違いでビルドを 2 回実行します。前に実行する設定では emptyOutDir: trueにし、後に実行する設定ではemptyOutDir: false にするのが肝です。後の実行で emptyOutDir: true になっていると、先に実行した出力ファイルが削除されてしまうためです。
今回は vite.config.js でビルドしているのが popup.js(インプットは hello.html)だけですが、今後 Service worker スクリプトやオプションページで利用するスクリプトを追加したくなったら、この設定ファイルのインプットに足していきましょう。これらのスクリプトでは先に説明したように import 構文が使えます。Content scripts だけは、vite.config.content_scripts.js でビルドするようにします。

これで準備はできました。 npm run build します。出力されたファイルは設定変更前とほぼ同じですが、 content_scripts.js の中身を見てみましょう。

(function(){"use strict";const n="Hello",t=document.querySelector("body"),e=document.createElement("div");e.className="hello-content",e.innerHTML=`${n} content scripts`,t&&t.appendChild(e)})();

constants.ts の内容が展開されています。意図どおりの出力です。

拡張機能をリロードして確認していきます。エラーも表示されず、Popup、Content scripts それぞれ正しく動作していることが確認できたらこのチュートリアルは完了です。

チュートリアルの要点

  • Content scripts では import 構文が使えない。tsc でトランスパイルすると、この問題でつまづく。
  • import 構文は使いつつ上記の問題を回避するにはバンドラーを使う必要がある。
  • Vite でうまいことやるには vite.config.js ファイルを 2 つに分けて、片方を Contents scripts のビルド専用にする。そちらでは inlineDynamicImports: true を設定することで出力後のファイルを 1 つにまとめる。

今回のチュートリアルの最終的な成果物は "TypeScript で Chrome 拡張機能を開発する" チュートリアル ソースコードから参照可能です。

References

記事を読んでくれた方へ

Web サイトや Web アプリケーションの開発に比べると、Chrome 拡張機能を開発するのはニッチな機会ですし、情報も決して多くはないジャンルです。そんな中、よりよい開発体験を得ようと試行錯誤してこの記事にたどりついたあなたのようなエンジニアを、弊社では必要としています。
弊社の採用情報も是非チェックしてみてください。

www.revcomm.co.jp