iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

Chrome拡張機能を自動リロードするVite Pluginを自作してみた

はじめに

本記事はiimon Advent Calendar 2025 8日目の記事となります。

SREチームに所属しています。hogeです。 普段はインフラまわりの業務が中心なのですが、時折プロダクト開発チームが進めているChrome拡張機能の開発を手伝うことがあります。

また、個人でも小さな拡張機能を作ることがあり、その中で開発体験をもっと良くしたいなと感じる場面が多くありました。

今回は、そんな思いからChrome拡張機能の自動リロード(ホットリロード)仕組みを自作してみたので、その内容を紹介します。 アドベントカレンダーに向けて作ったため、現時点では実プロジェクトにはまだ導入していません。今後導入していけたらと思っています。

ちなみに私は普段ガッツリとフロントエンドを触っているわけではなく、フロントエンド周りのエコシステムについてはまだまだ勉強中です(保険)。 もし改善点やアドバイスなどあれば、ぜひコメントいただけると嬉しいです。

成果物

demo

github.com

今回初めてnpm packageを公開してみました!

https://www.npmjs.com/package/vite-plugin-chrome-extension-reload

Chrome拡張機能開発の課題

Chrome拡張機能の開発で主に触るのは、Webページ上で動作する Content Scriptと、バックグラウンドで動くService Worker(Background)まわりです。

Chrome拡張機能を作ったことがある方ならこの苦痛をご存知でしょう。 コード変更の反映がとにかく面倒です。

通常、変更を反映するには次の3ステップが必要になります。

  1. ビルド(vite build --watch などで監視する)
  2. 拡張機能の再読み込み(Reload)
  3. 対象サイトの再読み込み(Content Script を差し替えたい場合)

つまり、コードを保存するたびにこの作業を繰り返すことになります。 これでは開発体験が良いとは言えません。血圧が上がってしまいます。

そこで今回は、Chrome拡張機能の開発中にコードを編集したら1〜3が自動で走る仕組みを検討してみました。

フレームワーク・ライブラリの検討

Chrome拡張機能にホットリロードを導入するにあたり、まずは既存のフレームワークやライブラリの利用を検討しました。代表的なものとしては次のようなツールがあります。

  • crxjs
  • Plasmo
  • WXT

いずれもホットリロード機能を備えており、導入できれば開発体験は大きく向上します。しかしChrome拡張向けのフレームワークはまだこれを使えば間違いないというデファクトスタンダードが固まっておらず、長期運用の観点では慎重に判断する必要があると考えています。

さらに社内で扱っている拡張機能は既にコードベースが大きく、独自実装も多い構成になっています。そのため、これらのフレームワークを導入するにはディレクトリ構成やビルド周り、リリースのワークフローを大きく組み替える必要があり、既存プロダクトへの影響も無視できません。(新規開発なら導入しやすいと考えています。)

以上の理由から、今回はホットリロードの仕組みそのものを自作するという方針に切り替えました。

個人開発では有志によるホットリロード機能を持つVite Pluginやスクリプト等も存在しますが、

  • 今後メンテナンスされるか不透明
  • セキュリティ面の不安
  • 問題が起きた際に自分たちで対応しづらい

といった理由から、プロダクションのプロジェクトに採用するのは避けたいと判断しています。

今回検討しているようなシンプルな仕組みであれば、自分たちで管理・育成していくほうが安全で柔軟だろうと考えました。

ホットリロードの仕組みについて

まず、一般的なフロントエンド開発における HMR(Hot Module Replacement) はどのように動いているのでしょうか。

以下の記事が非常にわかりやすく参考になりました。(viteを作っているチームのコアメンバーの方の記事)

bjornlu.com

簡略化すると以下の流れでHMRを実現しているようです。

  1. コードの変更をサーバ側が検知する

    開発者がコードを保存すると、開発サーバはファイル監視機能によって変更を検知する。

  2. 変更されたモジュールのみを再ビルドする

    サーバ側では、差分に該当するモジュールのみを対象にビルド処理が実行される。

    これにより、アプリケーション全体の再ビルドを行わずに更新内容を準備できる。

  3. サーバからブラウザへ更新通知が送信される

    ブラウザには HMR クライアント(ランタイム)が常駐しており、WebSocket などを通じてサーバと接続されている。

    サーバは更新対象のモジュール情報をブラウザへ通知する。

  4. ブラウザ側で変更モジュールを差し替える

    HMR クライアントは通知を受け取り、該当するモジュールを新しいコードに置き換える。

    必要に応じて、古いモジュールのクリーンアップ処理も実行される。

  5. ページ全体を再読み込みすることなく変更が反映される

    更新はモジュール単位で完結するため、アプリケーションの状態を維持したまま、変更点のみが即時に反映される。

ClientとServerの役割を整理すると以下のような感じです。

サーバ側の役割

  • ファイル変更を監視する
  • 変更があったモジュールを再ビルドする
  • HMR クライアントへ更新情報を通知する

ブラウザ(Client)側の役割

  • サーバと接続し、更新通知を受け取る
  • 更新対象のモジュールを新しいコードに差し替える
  • ページの再読み込みを行わずに変更を反映する

Chrome拡張機能の開発においては、ビルドしたファイルを拡張機能に読み込ませる必要があり、モジュールレベルの差し替えは難しそうです。

しかし、Web Socketを用いた以下のような簡易ホットリロードの仕組みであれば実現可能だと思いました。

  • ビルドシステム(サーバ)側でビルド完了を検知
  • WebSocketでブラウザ(Content Script や Background)へ通知
  • クライアント側で必要な再読み込み処理(extension reload / tab reload)を走らせる

イメージとしては、VSCode拡張機能のLive Serverが近いです。ちなみにLive Serverも裏側ではWeb Socketを使った更新の検知を行っていました。

システム構成

全体的な構成図としては以下のようになります。

実装の詳細

Vite Pluginの全体像

フロントエンドがReact/Vue/VanillaJSのどれで書かれていても導入できる作りにしています。

簡単に流れを説明します。

まず、Viteのビルドプロセス内でWebSocket サーバーを起動します。 同時に、Chrome拡張機能の各エントリーポイント(Content Script や Background)がWebSocket 経由で通知を受け取れるよう、リロード用のコードを自動で挿入します。

その後、ビルドが完了するとWebSocketサーバーがクライアントへ通知をブロードキャストし、通知を受け取ったクライアント側でリロード処理が実行されます。

これにより、Vite Plugin を組み込むだけでChrome拡張機能にホットリロード機能を付与できる仕組みになっています。

この一連の流れをまとめると、

ファイル変更検知 → 再ビルド → WebSocket通知 → Chrome拡張機能側でリロード

のようになります。

Vite Plugin内の各フックでは以下のように処理を行っています。

  1. buildStart: watchモードの場合、WebSocketサーバーを起動
  2. transform: Background scriptのソースコードにクライアントコードを注入
  3. generateBundle: Content Scriptのファイル名を.actual.jsにリネーム
  4. writeBundle: Content Script用のスタブファイルを生成し、スタブファイルにクライアントコードを埋め込み
  5. closeBundle: ビルド完了時に 変更内容を判定し、適切なリロード通知を送信
    • backgroundInputで指定されたファイルが変更された場合: reload-extensionを送信
    • その他のファイルが変更された場合: reload-tabを送信
    • watchChangeで変更されたファイルIDを判定

Web Socket Server

Viteプロセス内で起動するWebSocketサーバーは、ファイル変更通知をChrome拡張機能に送信する中継役です。

以下の3種類のメッセージを用意しています。

type HMRMessage = {
  type: 'reload-extension' | 'reload-tab' | 'keepalive'
}
  • サーバー → クライアント: reload-extensionChrome拡張機能リロード)、reload-tab(タブリロード)
  • クライアント → サーバー: keepalive(Service Worker維持用、サーバー側は無視)

buildが完了したら、全ての接続クライアントに通知をブロードキャストします。クライアント側はサーバから受け取ったメッセージタイプを判定してリロード処理を行います。

export class HMRServer {
  private clients: Set<WebSocket> = new Set()
  broadcast(message: HMRMessage): void {
    // すべての接続クライアントに同時送信
    for (const client of this.clients) {
      if (client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify(message))
      }
    }
  }
}

Content Script

Content Script 側に注入しているクライアントの挙動について説明します。

基本的な動作として、クライアントはWebSocket サーバーからメッセージを受信すると、window.location.reload() を実行し、タブをリロードします。

非表示のタブについては、 visibilitychange イベントを使用して、タブが表示された時点でリロードするように遅延処理を実装しています。

createReloadClient((message) => {
  switch (message.type) {
    case 'reload-tab':
      // Delay reload for non-visible tabs
      if (document.visibilityState === 'visible') {
        console.log(`[${PLUGIN_NAME}] Reloading page...`)
        location.reload()
      } else {
        const handleVisibilityChange = () => {
          if (document.visibilityState === 'visible') {
            document.removeEventListener('visibilitychange', handleVisibilityChange)
            console.log(`[${PLUGIN_NAME}] Tab became visible, reloading...`)
            location.reload()
          }
        }
        document.addEventListener('visibilitychange', handleVisibilityChange)
      }
      break
  }
})

Chrome拡張機能では、一度読み込まれたContent scriptは、拡張機能を再読込みするまでキャッシュされ続けます。 そのため、単にタブをリロードしても最新コードが反映されず、毎回 Chrome拡張機能の再読み込みが必要になります。

これを回避するために、Content Scriptをスタブファイルに差し替え、スタブファイル内で動的importをするようにしました。 この部分についてはgenerateBundle、writeBundle hook内で実装しています。

generateBundle(_outputOptions, bundle: OutputBundle) {
 // 省略
   if (isContentScript) {
     // Rename to .actual.js
     const dir = dirname(fileName)
     const baseName = basename(fileName, '.js')
     const actualFileName = `${baseName}.actual.js`
     const actualFilePath = dir && dir !== '.' ? `${dir}/${actualFileName}` : actualFileName
writeBundle(outputOptions) {
 // 省略
    const hmrClientCode = getClientCode('runtime', resolvedOptions.port)
    const actualFileName = basename(actualFilePath)
    const stubCode = `${wrapClientCode(hmrClientCode)}import('./${actualFileName}');`

例えば、以下のようにマニフェストファイルにContent Scriptを指定しているとします。

"content_scripts": [{ "js": ["content_scripts.js"] }]

マニフェストには変わらずcontent_scripts.jsを指定しますが、ビルドプロセス内でこのファイルは実際のコードではなく、以下のようなスタブファイルに置き換えられます。

このファイルにはリロード関連の処理とContent Scriptの動的importをするだけの処理があります。

// content_scripts.js(スタブファイル)
  
// リロード用クライアントコード
;(function() {
"use strict";(()=>{function c(n
...

// 実コードのimportコード
import('./content_scripts.actual.js');

実際の処理は content_scripts.actual.js にあり、スタブが常に動的 importする形になります。 こうすることによって、タブを読み込んだ際に、スタブファイルが最新のcontent scriptをとってくることができ、Chrome拡張機能をリロードする必要がなくなります。

Background Script

続いて、Background Script 側に注入しているクライアントの挙動について説明します。

Content Scriptと異なる点は次の 2 つです。

  • WebSocketからreload-extensionを受け取ったらchrome.runtime.reload()を実行する

  • 20秒おきにWebSocketサーバーへkeepalive リクエストを送る

開発中、しばらく作業を止めてから再ビルドすると、Background Script が更新されない場合があることに気づきました。

調べたところ、MV3のService Workerは30 秒間イベントが発生しないと停止される仕様のようでした。

Chrome 拡張機能: Service Worker の停止をテストする方法  |  Blog  |  Chrome for Developers

Service Workerが停止してしまうと、backgroundのコード変更の通知を受け取れずホットリロードが動作しない場合があり、結果として変更が反映されたりされなかったりする不安定な状態を招きます。

これを避けるために、WebSocket 経由で定期的にkeep-aliveを送ることで、 できるだけService Workerが停止しないようにしています。 (一応公式にも紹介されているやり方です)

Service Worker で WebSocket を使用する  |  Chrome Extensions  |  Chrome for Developers

// Keep Service Worker alive by sending periodic messages
// Based on Chrome Extension docs: https://developer.chrome.com/docs/extensions/how-to/web-platform/websockets
setInterval(() => {
  const ws = client.getWs()
  if (ws?.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify({ type: 'keepalive' }))
  }
}, 20000)

導入方法

npmレジストリにパッケージを公開しているので、以下の方法でインストールします。

https://www.npmjs.com/package/vite-plugin-chrome-extension-reload?activeTab=readme

npm install -D vite-plugin-chrome-extension-reload
# または
pnpm add -D vite-plugin-chrome-extension-reload
# または
yarn add -D vite-plugin-chrome-extension-reload

viteの設定ファイルにプラグインの設定を入れます。

// vite.config.ts
import { defineConfig } from 'vite'
import chromeExtensionReload from 'vite-plugin-chrome-extension-reload'

export default defineConfig({
  plugins: [
    chromeExtensionReload({
      port: 8789,                                  // WebSocket ポート(デフォルト: 8789)
      backgroundInput: 'src/background/index.ts',  // バックグラウンドスクリプトの入力パス
      contentScriptOutputs: ['src/content/index.js'], // コンテンツスクリプトの出力パス(manifest.json と一致させる)
      log: true,                                   // ログ有効化(デフォルト: false)
    }),
  ],

watchモードでviteを実行します。

vite build --watch

拡張機能をリロードすれば自動リロードが有効になっているはずです!

今後の展望

今までChrome拡張機能の開発でSidePanelやPopup等のUIをほとんど触ってこなかったため、この辺のリロードの仕組みについては検討していませんでした。現状これらのコードを触ったらタブがリロードされる仕様になっているため、UIのリロード方法についても検討したいと思っています。

最後に

今回、Chrome拡張機能向けのホットリロード機能を自作するにあたり、 HMRの仕組みを調べたり、他のVite Pluginの実装を読んだりと、非常に良い学びになりました。 これからもいろいろ改善しつつ、自分が関わっているプロジェクトにも 少しずつ導入していけたらいいなと思っています。 今回作成したVite Pluginのパッケージはnpmレジストリに公開しているので、試してみたい方がいらっしゃったらインストールしていただけると嬉しいです!!

弊社ではエンジニアを募集しております。 ご興味がありましたらカジュアル面談も可能ですので、下記リンクより是非ご応募ください! iimon採用サイト / Wantedly

次回はCTO森さんの記事です!どんな記事か楽しみです!!