Viteで始めるGoogleChrome拡張機能開発

2024/06/13に公開

はじめに

都内のSES企業で勤務しているスイと申します。

実務ではExcelやPowerPointと睨めっこするSEです。

今回はGoogle Chromeの拡張機能をVite + TypeScriptを使用して開発したので、初めて拡張機能を開発する人の第一歩になればよいなと思い書いていきます。

この記事で話すこと

  • TypeScript + Viteを使って拡張機能を開発する方法

なぜJavaScriptで開発しない?

Google Chrome拡張機能を開発するにはmanifest.jsoncontent_scriptとして必要なJavaScriptファイルのみで実行できます。

外部に公開しないミニマムなchrome拡張機能を作るのは1時間も使わずにできる - Qiita

上記の記事では以下さえ作成できれば拡張機能を作ることができます。

フォルダ
├ content_scripts.js
├ icon_32.png // アイコンはなくても動く
└ manifest.json

そんな状況の中、なぜTypeScriptで開発しようとしたのかを解説します。

any地獄で頭抱えたこと

JavaScriptでは型がないことによる影響としてバグを発見しづらいこともそうですが、一番しんどいのはエディタのコード補完が反応しないことです。

私が作成していた拡張機能では親のセレクター要素を取得して、あるメソッドの内部で親要素の中にある情報を返す仕様です。

const getProductData = (element: Element, count: number): ProductData => {
  const nameElement = element.querySelector('.content__97a42da1 > span');
  const imageElement = element.querySelector(
    '.merItemThumbnail.medium__a6f874a2.thumbnail__97a42da1 > figure > div.imageContainer__f8ddf3a2 > picture > img',
  );
  const cloneAndDeleteItemElement = element.querySelector('#clone-and-delete-item');

  return {
    name: nameElement ? nameElement.textContent : null,
    imageUrl: imageElement ? imageElement.getAttribute('src') : null,
    cloneAndDeleteItemSelector: cloneAndDeleteItemElement
      ? `#currentListing > div.merList.border__17a1e07b.separator__17a1e07b > div:nth-child(${count}) > div.content__884ec505 > a > div > div > #clone-and-delete-item`
      : null,
  };
};

上記のソースコードをJavaScriptで書いてしまうとほとんどany型となってしまい、弱弱エンジニアの私はぶっ倒れてしまいます。

リアルタイムで変更が反映されないこと

拡張機能を反映させるためには Chromeデベロッパーの更新を押さない限り、更新した内容が反映されません。
バグが起きる→ログ出す→拡張機能を更新する→処理を確認する→拡張機能を更新する…
こんなことやっていたら開発効率が非常によくないです😰

今回はCRXJS Vite pluginを使うことで、コードを保存するたびに拡張機能が再読み込みされるため、更新ボタンを手動で押す手間が省けます。

拡張機能開発のおさらい(contents,popup,backgroundの役割)

Google Chrome拡張機能には、3つの役割を持ったJavaScriptがあります。
(ご存知の人は「導入」までスキップしてOKです)

1. コンテンツスクリプト(content scripts)

コンテンツスクリプトは、ブラウザが表示しているウェブページの中で実行されるJavaScriptファイルです。

これにより、ウェブページの内容を直接操作したり、ページ内のデータを抽出したりできます。

できること

  • DOMの操作:ページ内のHTML要素を取得・変更・削除することができます。
  • CSSの変更:スタイルシートを追加・変更することができます。
  • イベントの設定:クリックやキーボード入力などのイベントを監視して処理することができます。
  • メッセージの送受信:バックグラウンドスクリプトや、ポップアップスクリプトとメッセージを送受信することができます。

できないこと

  • 拡張機能の全体的な設定変更:拡張機能の設定や、ストレージへの直接アクセスはバックグラウンドスクリプトが担当します。
  • 長時間の処理:コンテンツスクリプトはページのライフサイクルに依存するため、ページが閉じられると実行が終了します。

2. ポップアップスクリプト(popup scripts)

ポップアップスクリプトは、拡張機能のアイコンをクリックした際に表示されるポップアップウィンドウ内で実行されるJavaScriptファイルです。

ユーザーの操作に応じて処理を行います。

できること

  • UIの提供:HTMLとCSSを使ってポップアップ内のUIを構築することができます。
  • ユーザー入力の処理:フォームやボタンの操作を監視して処理することができます。
  • メッセージの送受信:コンテンツスクリプトや、バックグラウンドスクリプトとメッセージを送受信することができます。
  • ブラウザAPIの利用:ブラウザのAPI(例えばストレージAPIやタブAPI)にアクセスできます。

できないこと

  • 長時間の処理:ポップアップはユーザーがポップアップウィンドウを閉じると処理が終了します。
  • ページの直接操作:ポップアップスクリプトはウェブページのDOMに直接アクセスすることはできません。これはコンテンツスクリプトの担当です。

3. バックグラウンドスクリプト(background scripts)

バックグラウンドスクリプトは、拡張機能のライフサイクル全体で持続的に実行されるJavaScriptファイルです。

拡張機能の状態を管理し、長時間にわたるタスクを処理します。

できること

  • イベントの監視:ブラウザのイベント(例えば、タブの作成や削除、拡張機能のインストールなど)を監視して処理することができます。
  • データの保存と取得:ストレージAPIを使用してデータを保存および取得できます。
  • メッセージの送受信:コンテンツスクリプトや、ポップアップスクリプトとメッセージを送受信できます。
  • 外部APIとの通信:外部のAPIとやり取りすることができます。

できないこと

  • ページの直接操作:バックグラウンドスクリプトはウェブページのDOMに直接アクセスすることはできません。これはコンテンツスクリプトの担当です。
  • ユーザーインターフェースの提供:ポップアップやオプションページを持つことはできますが、バックグラウンドスクリプト自体はUIを持ちません。

導入

必要なパッケージのインストール

必要なパッケージとVite環境のセットアップをします。

Vanillaで開発していきます。

$ pnpm create vite
√ Project name: ... chrome-extension-vite-sample
√ Select a framework: » Vanilla
√ Select a variant: » TypeScript

Scaffolding project in C:\Users\xxxxx\Documents\dev\projects\chrome-extension-vite-sample...

Done. Now run:

  cd chrome-extension-vite-sample
  pnpm install
  pnpm run dev

作成したディレクトリに移動したあと、以下のコマンドを実行して必要なパッケージをインストールします。

@types/chromeはChrome拡張機能固有の型を追加することができます。

pnpm install
pnpm add -D @crxjs/vite-plugin @types/chrome

マニフェストファイルの設定

vite.config.tsを作成します。

vite.config.ts
import { defineConfig } from 'vite';
import { crx, defineManifest } from '@crxjs/vite-plugin';

const manifest = defineManifest({
  manifest_version: 3,
  name: 'Chrome拡張機能の練習',
  version: '1.0.0',
  description: 'Zenn投稿するChrome拡張機能のサンプルです。',
  action: {
    default_popup: 'index.html',
  },
});

export default defineConfig({
  plugins: [crx({ manifest })],
  build: {
    outDir: 'dist',
    emptyOutDir: true,
  },
  server: {
    port: 5173,
    strictPort: true,
    hmr: {
      port: 5173,
    },
  },
});

CRXJS Vite PluginのdefineManifestを使うことでmanifest.jsonをTypeScriptで書くことができます。

manifest.jsonは拡張機能固有のファイルです。拡張機能の名前やバージョン、パーミッションなどを書きます。

拡張機能を読み込む

vite.config.tsを書き換えた時点で拡張機能として動かすことができるため、試してみます。

以下のコマンドを実行して開発環境を起動します。

pnpm run dev

次に、拡張機能の管理ページを開きます。chrome://extensions/にアクセスするか、ブラウザ右上のメニューより「その他のツール>拡張機能」をクリックして拡張機能の管理ページで、デベロッパーモードをオンにします。

その後「パッケージ化されていないされていない拡張機能を読み込む」のボタンを押します。

フォルダは先程プロジェクトのdist(manifest.jsonがあるフォルダ)を選択します。

ブラウザの右上に表示される拡張機能のアイコンをクリックすると、index.htmlが表示されます。

ここまでで拡張機能を動かすことは確認できました。

簡単な拡張機能を作ってみる

拡張機能が動くことを確認できたので、popup.ts,content.tsを使って拡張機能を作成していきましょう。
Zennのtopicsページから記事の一覧を取得して、Markdown形式に変換する拡張機能を作成してきます。
作成イメージはこのような記事タイトル、絵文字、URLを繰り返し表示するような内容です。

## 🍨Honoでランダムにアイスクリームを提案するLINE BOTを作る

link🔗 : https://zenn.dev/sui_water/articles/fac4334293a1c0

ファイルの整理

まずは大元のhtmlであるindex.htmlを修正していきます

index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Markdown Viewer</title>
  </head>
  <body>
    <div class="container">
      <h1>Markdown Generator</h1>
      <textarea class="markdown-output" readonly></textarea>
    </div>
    <script type="module" src="/src/popup.ts"></script>
  </body>
</html>

次に今回の処理で必要なファイルをvite.config.tsに書いていきます。

content_scriptsにはcontent.tsで処理をするページを記載します。

今回はZennのtopicsページが対象です。

vite.config.ts
import { defineConfig } from 'vite';
import { crx, defineManifest } from '@crxjs/vite-plugin';

const manifest = defineManifest({
  manifest_version: 3,
  name: 'Chrome拡張機能の練習',
  version: '1.0.0',
  description: 'Zenn投稿するChrome拡張機能のサンプルです。',
+ content_scripts: [
+   {
+     matches: ['https://zenn.dev/topics/*'],
+     js: ['src/content.ts'],
+   },
+ ],
  action: {
    default_popup: 'index.html',
  },
});

export default defineConfig({
  plugins: [crx({ manifest })],
  build: {
    outDir: 'dist',
    emptyOutDir: true,
  },
  server: {
    port: 5173,
    strictPort: true,
    hmr: {
      port: 5173,
    },
  },
});

popup.ts, content.tsの実装

popup.tsを作成し、処理を書いていきます。

やることは拡張機能を開いたときにcontent_scriptへメッセージを送信し、content_scriptは受け取ったメッセージをもとにZennの記事情報を取得します。

まずはpopup.tsにメッセージを送信する処理を書いていきます。

popup.ts
import { MessageActionsId, ResponseMessageData } from './types';

document.addEventListener('DOMContentLoaded', () => {
  // 現在のタブ情報を取得
  chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
    if (tabs.length === 0 || tabs[0].id === undefined) {
      throw new Error('アクティブなタブが見つかりませんでした。');
    }
    chrome.tabs.sendMessage<MessageActionsId>(
      tabs[0].id,
      { action: 'get-zenn-articles' },
      (response: ResponseMessageData | undefined) => {
        if (chrome.runtime.lastError) {
          throw new Error(chrome.runtime.lastError.message);
        }
      },
    );
  });
});

型情報がないので、types.tsを作成してcontent.tsでも使用する汎用的な型を作成します。

types.ts
export type MessageActionsId = {
  action: 'get-zenn-articles';
};

export type ZennArticleData = {
  title: string;
  url: string;
  emoji: string;
};

export type ResponseMessageData = {
  data: ZennArticleData[];
};

content.tsには記事情報取得を開始するメッセージであるget-zenn-articlesを受け取ったときに処理を開始します。

content.ts
import { MessageActionsId, ResponseMessageData, ZennArticleData } from './types';

const setupMessageListener = () => {
  chrome.runtime.onMessage.addListener(
    async (
      request: MessageActionsId,
      _sender,
      sendResponse: (response: ResponseMessageData) => void,
    ) => {
      if (request.action === 'get-zenn-articles') {
        const details: ZennArticleData[] = [];
        let count = 1;
        let element: Element | null;

        // 記事情報が取得できなくなるまで繰り返す
        while (
          (element = document.querySelector(
            `#__next > div.View_contents__azal2 > div > section > div.View_itemsContainer__srSwj > div > div:nth-child(${count})`,
          )) !== null
        ) {
          try {
            const article = getArticleData(element);
            details.push(article);
          } catch (error) {
            console.error(`記事の取得中にエラーが発生しました : ${error}`);
            continue;
          }
          count++;
        }
				
        console.log(details);
        sendResponse({ data: details });
      }
      return true;
    },
  );
};

const getArticleData = (element: Element): ZennArticleData => {
  const titleElemnt = element.querySelector<HTMLElement>('article > div > a > h2');
  const urlElement = element.querySelector<HTMLElement>('article > div > a');
  const emojiElement = element.querySelector<HTMLElement>(
    'article > a > span.Emoji_nativeEmoji__GMBzX',
  );

  if (!titleElemnt || !urlElement || !emojiElement) {
    throw new Error('必要な商品情報の要素が見つかりませんでした。');
  }

  return {
    title: titleElemnt.textContent || '',
    url: urlElement.getAttribute('href') || '',
    emoji: emojiElement.textContent || '',
  };
};

if (typeof chrome !== 'undefined' && chrome.runtime) {
  setupMessageListener();
}

これらを記載したあとに画面右上の拡張機能を押すとpopup.tsからcontent.tsに取得メッセージが送信され、記事情報を取得できていることが確認できます。

記事情報を取得することができたので、あとはpopup.tsで取得したデータをindex.htmlに反映させていきましょう。

popup.ts
import { MessageActionsId, ResponseMessageData, ZennArticleData } from './types';

document.addEventListener('DOMContentLoaded', () => {
  // 現在のタブ情報を取得
  chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
    if (tabs.length === 0 || tabs[0].id === undefined) {
      throw new Error('アクティブなタブが見つかりませんでした。');
    }
    chrome.tabs.sendMessage<MessageActionsId>(
      tabs[0].id,
      { action: 'get-zenn-articles' },
      (response: ResponseMessageData | undefined) => {
        if (chrome.runtime.lastError) {
          throw new Error(chrome.runtime.lastError.message);
        }
        if (!response) {
          throw new Error('記事情報の取得に失敗しました。');
        }

        const markdown = createArticleMarkdown(response.data);
        const textarea = document.querySelector<HTMLTextAreaElement>('.markdown-output');
        if (!textarea) {
          throw new Error('テキストエリア要素が見つかりませんでした。');
        }
        textarea.value = markdown;
      },
    );
  });
});

const createArticleMarkdown = (articles: ZennArticleData[]) => {
  return articles
    .map((article) => {
      return `## ${article.emoji}${article.title}\n\nlink🔗 : https://zenn.dev${article.url}\n`;
    })
    .join('\n');
};

これでconten_scriptで取得したZennの記事をMarkdown形式で貼り付けることが出来ました。

実装は以上になります!

ビルド

distフォルダの内容が開発環境用になっているため、一度フォルダごと削除しましょう。

rm -rf dist/

削除後に以下のコマンドを実行します。

pnpm run build

問題なければビルド後のファイルが作成されていると思いますが、私はこのようなエラーが発生しました。

$ pnpm run build

> chrome-extension-vite-sample@0.0.0 build C:\Users\xxxx\Documents\dev\projects\chrome-extension-vite-sample
> tsc && vite build

vite v5.2.13 building for production...
✓ 7 modules transformed.
[crx:content-script-resources] Error: vite manifest is missing
    at Object.renderCrxManifest (file:///C:/Users/xxxx/Documents/dev/projects/chrome-extension-vite-sample/node_modules/.pnpm/@crxjs+vite-plugin@1.0.14_vite@5.2.13/node_modules/@crxjs/vite-plugin/dist/index.mjs:3240:21)
    at Object.generateBundle (file:///C:/Users/xxxx/Documents/dev/projects/chrome-extension-vite-sample/node_modules/.pnpm/@crxjs+vite-plugin@1.0.14_vite@5.2.13/node_modules/@crxjs/vite-plugin/dist/index.mjs:2922:60)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async Bundle.generate (file:///C:/Users/xxxx/Documents/dev/projects/chrome-extension-vite-sample/node_modules/.pnpm/rollup@4.18.0/node_modules/rollup/dist/es/shared/node-entry.js:18153:9)
    at async file:///C:/Users/xxxx/Documents/dev/projects/chrome-extension-vite-sample/node_modules/.pnpm/rollup@4.18.0/node_modules/rollup/dist/es/shared/node-entry.js:20692:27    
    at async catchUnfinishedHookActions (file:///C:/Users/xxxx/Documents/dev/projects/chrome-extension-vite-sample/node_modules/.pnpm/rollup@4.18.0/node_modules/rollup/dist/es/shared/node-entry.js:20119:16)
    at async build (file:///C:/Users/xxxx/Documents/dev/projects/chrome-extension-vite-sample/node_modules/.pnpm/vite@5.2.13/node_modules/vite/dist/node/chunks/dep-DEPSZ3SS.js:68740:22)
    at async CAC.<anonymous> (file:///C:/Users/xxxx/Documents/dev/projects/chrome-extension-vite-sample/node_modules/.pnpm/vite@5.2.13/node_modules/vite/dist/node/cli.js:842:9)     
x Build failed in 80ms
error during build:
[crx:manifest-post] Error in crx:content-script-resources.renderCrxManifest
    at Object.generateBundle (file:///C:/Users/xxxx/Documents/dev/projects/chrome-extension-vite-sample/node_modules/.pnpm/@crxjs+vite-plugin@1.0.14_vite@5.2.13/node_modules/@crxjs/vite-plugin/dist/index.mjs:2933:19)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async Bundle.generate (file:///C:/Users/xxxx/Documents/dev/projects/chrome-extension-vite-sample/node_modules/.pnpm/rollup@4.18.0/node_modules/rollup/dist/es/shared/node-entry.js:18153:9)
    at async file:///C:/Users/xxxx/Documents/dev/projects/chrome-extension-vite-sample/node_modules/.pnpm/rollup@4.18.0/node_modules/rollup/dist/es/shared/node-entry.js:20692:27    
    at async catchUnfinishedHookActions (file:///C:/Users/xxxx/Documents/dev/projects/chrome-extension-vite-sample/node_modules/.pnpm/rollup@4.18.0/node_modules/rollup/dist/es/shared/node-entry.js:20119:16)
    at async build (file:///C:/Users/xxxx/Documents/dev/projects/chrome-extension-vite-sample/node_modules/.pnpm/vite@5.2.13/node_modules/vite/dist/node/chunks/dep-DEPSZ3SS.js:68740:22)
    at async CAC.<anonymous> (file:///C:/Users/xxxx/Documents/dev/projects/chrome-extension-vite-sample/node_modules/.pnpm/vite@5.2.13/node_modules/vite/dist/node/cli.js:842:9)     
 ELIFECYCLE  Command failed with exit code 1.

検索すると同じような内容で困っている人がいたので、内容を確認していきます。

https://github.com/crxjs/chrome-extension-tools/issues/846

I am also seeing this on Vite 5. It is because Vite 5 renamed the manifest file to .vite/manifest.json, but @crxjs/vite-plugin tries to look for it at manifest.json.

Here is a workaround that appears to work (in your vite.config.ts):

訳:Vite 5でもこの現象が見られます。Vite 5がマニフェストファイルを.vite/manifest.jsonにリネームしたためですが、@crxjs/vite-pluginはmanifest.jsonでマニフェストを探そうとします。

以下は、(vite.config.tsで)動作するように見える回避策です

どうやらcrxjs/vite-pluginの問題だそうで解決策を追加して再ビルドしていきます。

記事と異なる部分はPluginではなくPluginOptionを使用しています。

vite.config.ts
import { PluginOption, defineConfig } from 'vite';
import { crx, defineManifest } from '@crxjs/vite-plugin';

// IssueではPluginだが非推奨のため、PluginOptionを使う
const viteManifestHackIssue846: PluginOption & {
  renderCrxManifest: (manifest: any, bundle: any) => void;
} = {
  // Workaround from https://github.com/crxjs/chrome-extension-tools/issues/846#issuecomment-1861880919.
  name: 'manifestHackIssue846',
  renderCrxManifest(_manifest, bundle) {
    bundle['manifest.json'] = bundle['.vite/manifest.json'];
    bundle['manifest.json'].fileName = 'manifest.json';
    delete bundle['.vite/manifest.json'];
  },
};

const manifest = defineManifest({
  manifest_version: 3,
  name: 'Chrome拡張機能の練習',
  version: '1.0.0',
  description: 'Zenn投稿するChrome拡張機能のサンプルです。',
  content_scripts: [
    {
      matches: ['https://zenn.dev/topics/*'],
      js: ['src/content.ts'],
    },
  ],
  action: {
    default_popup: 'index.html',
  },
});

export default defineConfig({
  plugins: [viteManifestHackIssue846, crx({ manifest })],
  build: {
    outDir: 'dist',
    emptyOutDir: true,
  },
  server: {
    port: 5173,
    strictPort: true,
    hmr: {
      port: 5173,
    },
  },
});

$ pnpm run build

> chrome-extension-vite-sample@0.0.0 build C:\Users\NOkud\Documents\dev\projects\chrome-extension-vite-sample
> tsc && vite build

vite v5.2.13 building for production...
✓ 7 modules transformed.
dist/assets/content-script-loader.content.ts--y4Rf8cA-BpTtEgtd.js  0.20 kB
dist/manifest.json                                                 0.58 kB │ gzip: 0.36 kB 
dist/index.html                                                    0.95 kB │ gzip: 0.48 kB 
dist/assets/content.ts--y4Rf8cA.js                                 0.74 kB │ gzip: 0.58 kB 
dist/assets/index.html-_Dl5P9dK.js                                 1.29 kB │ gzip: 0.83 kB 
✓ built in 89ms

無事にビルドすることができました!

作成されたのを確認後、再度拡張機能が実行できるか確認します。

無事実行できていますね!

もし読み込めなかったり拡張機能でデータが取得できなかったら、一度chrome://extensions/を開いて拡張機能を削除したあと、manifest.jsonがあるフォルダを選択し直してください。

今回はChrome Web Storeへ作成した拡張機能を登録するところまでは行いませんが、気になる人はこちらの記事が分かりやすく書かれてたためご確認ください!

Chrome拡張機能の申請手順 - Qiita

終わりに

今回のソースコードや参考になった記事を掲載します。

ソースコード

https://github.com/Suntory-Y-Water/chrome-extension-vite-sample

参考になった記事

https://zenn.dev/7oh/scraps/98d5cdcceb9bd8

https://dev.classmethod.jp/articles/eetann-chrome-extension-by-crxjs/

https://github.com/crxjs/chrome-extension-tools/issues/846

https://qiita.com/sasao3/items/0606b67da01948ae58b7

追記(2024/11/09)

この記事を公開したとき@crxjs/vite-pluginのバージョンが2.0.0-beta.23でした。
下記issueでもある通り、拡張機能が動かなくなってしまいます。
https://github.com/crxjs/chrome-extension-tools/issues/946

同様の事象が起きているのであれば、パッケージのバージョンを^2.0.0-beta.28にすることで解消することができます。

Discussion