43
38

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RUNTEQ Advent Calendar 2023Advent Calendar 2023

Day 17

Reactでシンプルなタイマーアプリを作りながら学ぶChrome拡張機能開発入門

Last updated at Posted at 2023-12-16

はじめに

この記事は「RUNTEQ Advent Calendar 2023」の17日目を担当しています。

現在、プログラミングスクールRUNTEQで未経験からWebエンジニア転職を目指して学習中のyushiと申します。

絶賛Webアプリを個人開発中で、その一貫としてChrome拡張機能を初めて開発しました。
その中で学んだ知見を共有したいと思い、比較的簡単に実装出来るタイマーアプリをChrome拡張機能として実装する流れをハンズオン形式で紹介します。

Chrome拡張機能は割と身近な存在だと思っているのですが、同時になんとなくとっつきづらいという印象を抱いている方も多いのではないでしょうか?
確かにややこしい部分もあり、私も最初はかなり苦労しました。

ですが、Chrome拡張機能はデプロイの必要がなく、小規模な構成から作ることが出来るため、Reactをキャッチアップした後のアウトプットの手段の1つとしてありだなと感じました。
是非一緒に手を動かしながら読んでいただけると嬉しいです!

2023/12/19 追記
Chrome拡張機能がリリースされたので、リンクを追記しました。

Chrome拡張機能の構成

Chrome拡張機能は大きく分けて4つの領域から成り立っています。

1. マニフェストファイル
Chrome拡張機能において唯一の必須ファイルです。
拡張機能の名前や説明、権限の許可や後述するポップアップやサービスワーカーにあたるのはどのファイルかをここで定義します。

2. サービスワーカー
ブラウザに対するイベントハンドラの役割を持ちます。
ブックマークの登録やタブを閉じるといったイベントを監視して、それに応じた処理を実行します。
バックグラウンド動作に特化しているため、DOM操作は後述のコンテンツスクリプトが担います。
ちなみに、サービスワーカーはChrome拡張機能特有の概念ではなくブラウザの機能の1つらしいです。

3. コンテンツスクリプト
表示中のページのDOM 要素を読み込んだり、変更したりすることが出来ます。
Googleの検索ボタンの色を変えたり、YouTubeで再生している動画の再生速度を変更するといったことが可能です。

4. ポップアップ・その他のページ
Chrome拡張機能をクリックした時に表示されるポップアップウィンドウやオプションページなど、様々なHTMLを含めることが出来ます。

manifest.json

今回作るタイマーアプリのmanifest.jsonは以下のようになっています。

manifest.json
{
	"name": "timer app",
	"description": "タイマーアプリ",
	"version": "0.0.1",
	"manifest_version": 3,
	"action": {
		"default_popup": "index.html"
	},
	"background": {
		"service_worker": "src/background.js",
		"type": "module"
	},
	"permissions": [
		"background",
		"storage"
	],
	"content_security_policy": {
    "extension_pages": "script-src 'self' ; object-src 'self'"
  }
}

重要な点だけ抜粋して説明します。

  • version
    Chrome拡張機能のマニフェストのバージョンを指定します。
    現在、セキュリティの強化などの理由からManifest V3への移行が進められています。
    記事を検索する時はバージョンの違いに気をつけてください。
  • permssions
    Chrome APIを使う場合には相当する権限をここに追加します。
    今回はサービスワーカーを使うためにbackground、ローカルストレージを使うためにstorageを追加しています。
    他には、Chromeにログインしているユーザー情報を取得するidentity、新しいタブを開いたり最後に開いたページの情報を取得するために使うtabsなど、様々な権限があります。

環境構築

環境構築と言っても特に難しいことはありません。
Chrome拡張機能開発用のテンプレートを公開されているのでそれを利用することで環境構築の手間を省くことが出来ます。

今回はこちらの記事に記載されているリポジトリを利用させていただきます。

私が環境構築をした時にエラーが発生したので、それを回避するための手順を記載しておきます。
ビルド後、コンテナ内にライブラリをインストールしてからコンテナを起動しています。

$ docker compose build
$ docker compose run -w /usr/src/app/ --rm extension yarn install
$ docker compose up -d

自作した拡張機能をChromeにインストールする

コンテナが無事起動したら$ docker compose run extension yarn buildでビルドを実行してdistフォルダを作成します。

chrome://extensions/にアクセスし、右上のデベロッパーモードをONにしてください。
すると、ページ上部に「パッケージ化されていない拡張機能を読み込む」というボタンが表示されるようになると思うので、それをクリックしてください。
フォルダの選択画面が表示されるので先ほど作成したdistフォルダを指定します。

ビルドやmanifest.jsonに不備がなければ無事にインストール出来るはずです!
開発中にすぐ開けるようにピン留めもしておきましょう!

ホットリロード

通常のChrome拡張機能開発ではコードを変更する度にパッケージをビルドし直してChromeで再読み込みしなければいけません。
これがかなりストレスになるのでChrome拡張機能を開発する場合はホットリロードがほぼ必須級だと感じました。

今回利用しているテンプレートにはホットリロードが備わっています。
ホットリロードをONにするにはdocker compose run extension yarn build:watchを実行してください。
コードの変更を検知して自動で再ビルドしてくれるようになるので、開発を始める時に実行しておきましょう。
たまにサービスワーカーのコード変更が反映されないことがあるので、その場合は拡張機能の設定ページから拡張機能自体をリロードしてください。

ハンズオン

今回はポップアップとサービスワーカーを使ってタイマーアプリを作っていきます。
完成してから気付いたのですが、今回作るタイマーアプリはポップアップだけで成立するのでサービスワーカーを使う必要はありませんでした...。
あくまで入門としてサービスワーカーについて学ぶためということでお許しください。

(個人的にはポップアップには画面表示を、サービスワーカーにはデータに関する処理をそれぞれ担ってもらう方が責任が分離出来て、コードの見通しがよくなる気がします。)

全体の流れ

  1. ポップアップでタイマーの開始・停止・終了を指示する仕組み
  2. サービスワーカーで指示を受け取り、タイマーの開始時間・経過時間を保持する
  3. ポップアップが閉じて後もタイマーの計測を可能にする

テキストとボタンを作る

手始めにボタンをクリックすると表示されるテキストが変わる仕組みを作っていきます。

popup.js
function Popup() {
  const [text, setText] = useState('タイマー');
  
  const handleClickStart = () => {
    setText('計測開始');
  };

  const handleClickStop = () => {
    setText('計測中断');
  };

  const handleClickEnd = () => {
	setText('計測終了');
  };

  return (
	<div className="App flex items-center justify-center" style={{ height: 150, width: 300 }}>
      <div className="flex flex-col items-center justify-center">
        <div className="text-lg text-center">{text}</div>
        <div className="flex justify-center absolute bottom-0 space-x-4">
          <button onClick={handleClickStart} className="rounded-full w-12 p-2 bg-gray-100 hover:bg-blue-400">start</button>
          <button onClick={handleClickStop} className="rounded-full w-12 p-2 bg-gray-100 hover:bg-blue-400">stop</button>
          <button onClick={handleClickEnd} className="rounded-full w-12 p-2 bg-gray-100 hover:bg-re-400">finish</button>
        </div>
      </div>
    </div>
	);
}

Reactに触れている方にはお馴染みの状態と呼ばれるページが持つ変数を定義して、その値を更新することで画面表示を変更しています。
状態const [状態, 更新関数] = useState(初期値);の形で定義され、更新関数に新しい値を渡すことで状態を変更します。

試しに状態を使わずにlet text;と定義して、setText('計測開始')text = '計測開始'と書き換えてみるとテキストが変わらないことが分かると思います。
だいぶざっくりな説明ですが、ページの中でユーザーの操作やイベントによって変更したいものについては状態を使わないといけない!ということが理解出来ればとりあえず大丈夫です。

拡張機能が下記のように動けばokです!
Image from Gyazo

ポップアップとサービスワーカーを繋げる

次は変更するテキストの値をポップアップ側から渡すようにしたいと思います。
そのためにはまずポップアップからサービスワーカーにメッセージを送信する必要があります。
ここで使われるのがchrome.runtimeAPIのsendMessageメソッドです。
読んだまんまですね。

構文
chrome.runtime.sendMessage({ type: 'タイプ' }, (res) => { ... });

sendMessageメソッドは第一引数にオブジェクト、第二引数にコールバック関数を取ります。
コールバック関数の引数としてサービスワーカーからのレスポンスが渡ってくるので、この値を使って状態を更新するというわけですね!

サービスワーカーでメッセージを受信するために使うのはonMessage.addListenerメソッドになります。
メッセージ限定のイベントリスナーと考えると分かりやすいでしょうか。

構文
chrome.runtime.onMessage.addListener((message, sender, sendResponse), () => {});

引数としてコールバック関数を取り、コールバック関数の引数が3つ渡ってきます。

  • message
    ポップアップから送られてきたオブジェクト。
  • sender
    どこから送られてきたかの情報が入っているruntime.MessageSenderオブジェクト。
    メッセージの送信側を表します。
  • sendResponse
    メッセージの送信元にレスポンスを返すための関数です。

今回の記事では触れませんがDOM操作を行うコンテンツスクリプトにポップアップやサービスワーカーから値を渡したい時も上記のchrome.runtimeAPIを使います。

少し説明が長くなってしまいましたが、sendMessageonMessage.addListenerの組み合わせることでポップアップとサービスワーカーという異なる領域でのやりとりを実現しているというわけです。

では、実際にこの2つを利用してサービスワーカーから変更するテキストを受け取るようにしていきます。

popup.js
const handleClickStart = () => {
  chrome.runtime.sendMessage({ type: 'start' }, (res) => {
    setText(res);
  });
};
background.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  switch (message.type) {
    case 'start':
      sendResponse('テキストを更新');
  }
});

正しく実装出来ていれば下の画像のようにテキストが変更されるようになっているのではないでしょうか。
Image from Gyazo

それでは解説していきます。

  • popup.js
    startボタンのイベントハンドラに設定しているhandleClickStart関数内からsendMessageメソッドを使ってメッセージを送信しています。
    送信されたメッセージオブジェクトはtypeプロパティを持っています。
    第二引数のコールバック関数の引数resにサービスワーカーからのレスポンスが渡ってくるので、それを更新関数にセットして状態を更新しています。

  • background.js
    サービスワーカーではポップアップから送信されたメッセージをchrome.runtime.onMessage.addListenerで受信しています。
    messageにポップアップから送信されたオブジェクト{ type: 'start' }が入っているので、どのメッセージか判別できるようにswitch文で分岐しています。
    そして、sendResponseに表示したい文字列を渡してあげると、handleClickStart関数内のchrome.runtime.sendMessageresにその値が渡っていくというわけです。

Chrome拡張機能開発の中でここがけっこうややこしい部分なのかなと思っているのですが、いかがでしたでしょうか?
ポップアップとサービスワーカーの間でメッセージの送受信を行うことで、処理を依頼したりデータを受け渡したりしているということが分かればOKです!

タイマー機能の実装

ようやく本題のタイマー機能ですね!
流れとしては下記のようになります。

  1. ポップアップから開始メッセージを送信
  2. サービスワーカーで開始時刻を記録
  3. タイマーの更新を開始
  4. 終了メッセージを受け取ったらタイマーの更新を終了する

タイマー機能

先にタイマー機能についてざっと解説します。
タイマーは言い換えれば経過時間なので、現在時刻とタイマーを開始した時刻の差で表せます。
経過時間が増えるごとに、つまり1秒ごとに経過時間を計算してその値を更新し続ければタイマーが動いているかのように画面を表示することが出来そうです。
開始メッセージを受け取った時刻を記録し、現在時刻 - 開始時刻の結果を毎秒ポップアップに返して、ポップアップで状態を更新するというわけですね。

開始時刻は確実に保持しておかなければいけないので、chrome.storageAPIを使います。

構文
// 保存
chrome.storage.local.set({ key: value }, () => {});

// 取得
chrome.storage.local.get(['key'], (result) => {
  console.log(result.key);
});

保存にはsetメソッドを使います。
第一引数にオブジェクト形式でローカルストレージに保存するキーと値を指定します。
第二引数は保存が終わった後に実行されるコールバック関数です。

取得する時はgetメソッドを使います。
第一引数には配列形式でキーを渡します。複数のキーを同時に渡すことが可能です。
第二引数はコールバック関数で、引数としてresultオブジェクトが渡ってきます。
このresultオブジェクトのプロパティに第一引数で渡したキーが設定され、その値としてローカルストレージに保存した値を取得出来ます。

タイマー機能のコードはこのようになります。

background.js
const startTimer = () => {
  const startTimeValue = new Date().getTime();
  // ローカルストレージに開始時刻を保存する
  chrome.storage.local.set({startTime: startTimeValue}, () => {});
};

const finishTimer = () => {
  // ローカルストレージに保存した開始時刻を破棄する
  chrome.storage.local.remove(['startTime'], () => {});
}

// タイマー表示を更新する関数
const updateTimerDisplay = (sendResponse) => {
  chrome.storage.local.get(['startTime'], (result) => {
    if (result.startTime) {
      const startTime = result.startTime;
      const currentTime = new Date().getTime(); // 現在時刻を取得
      elapsedTime = currentTime - startTime; // 開始時刻からの経過時間を計算
      const seconds = Math.floor(elapsedTime / 1000); // ミリ秒を秒に変換
      sendResponse(formatTime(seconds));      
    }
  });
};

// 秒を時分秒の形式にフォーマットする関数
const formatTime = (time) => {
  const hours = Math.floor(time / 3600);
  const minutes = Math.floor((time % 3600) / 60);
  const seconds = time % 60;

  const displayHours = hours.toString().padStart(2, '0');
  const displayMinutes = minutes.toString().padStart(2, '0');
  const displaySeconds = seconds.toString().padStart(2, '0');
  return `${displayHours}:${displayMinutes}:${displaySeconds}`;
};

タイマーの挙動を実現するための処理は実装出来たので、次は開始・終了メッセージと毎秒更新するための処理を作りましょう!

ポップアップ

popup.js
import { useState, useRef } from "react";

function Popup() {
  const [timer, setTimer] = useState('00:00:00');
  const [isRunning, setIsRunning] = useState(false);
  const intervalId = useRef(null);

  // タイマー表示を更新する
  const updateTimer = () => {
	chrome.runtime.sendMessage({ type: 'update'}, (res) => {
	  setTimer(res);
	);
  };

  const handleClickStart = () => {
    setIsRunning(true);
	chrome.runtime.sendMessage({ type: 'start' }, (res) => {
      // 1秒おきにupdateTimerを実行するように設定
	  intervalId.current = setInterval(updateTimer, 1000);
    });
  };

  const handleClickFinish = () => {
    chrome.runtime.sendMessage({ type: 'finish'}, () => {
      // updateTimerの定期実行を破棄
      if (intervalId.current) clearInterval(intervalId.current);
	  setIsRunning(false);
    });

  return (
   // 省略
  );
}

typeを設定してメッセージを送信。返ってきたレスポンスで画面を更新。

サービスワーカー

background.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  switch (message.type) {
    case 'start':
      startTimer()
      sendResponse();
      return true;
    case 'update':
      updateTimerDisplay(sendResponse);
      return true;
    case 'finish':
      finishTimer();
      sendResponse();
      return true;
  }
});

ポップアップから送られてきたmessage.typeに応じた処理をそれぞれ実行。sendResponseで返信する。
なんとなく流れが掴めてきたでしょうか?

解説

ポップアップ

JavaScriptのsetInterval関数を使って1秒おきにタイマー表示を更新します。
setInterval関数の戻り値としてidが返ってくるので、タイマーを終了したい場合はこれを破棄します。
ここでポイントなのが戻り値のiduseRefを使って保持しています。

useRefとは
useRefとはReact Hooksの1つで、レンダリング間で値を保持するために使われます。
useRefを使うと、生成されたrefオブジェクトはそのコンポーネントのライフサイクル全体で同じオブジェクトを保持し続けることができるようになります。
useRefは主にDOM要素に直接アクセスしたい時にuseRefを使ってその参照を保持するために使われる印象です。

今回はタイマー表示を更新する度にポップアップが再レンダリングされるのでintervalIdが変わってしまわないようにuseRefを使って同じ参照を保持出来るようにしています。
useStateを使ったり、letconstなど普通の変数定義で管理するとコンポーネントがレンダリングされる度に新しいintervalIdが設定されてしまうので、handleClickStart関数とhandleClickFinish関数で同じintervalIdが参照出来なくなってしまいます。

コンポーネントとは
コンポーネントというのは、ヘッダー・サイドバー・コンテンツ・ボタンなど、ページ内のUIを独立した再利用可能な単位として表すものです。
それぞれのコンポーネントが独自の状態とライフサイクルを持つことが出来るため、画面を更新する時に全体を更新するのではなく、変更が必要な要素だけをピックアップして更新することが可能になります。
これによってページの表示速度が向上したり、ネイティブアプリのようにヌルヌル動くページを作成することが出来ます。

今回の拡張機能ではポップアップ(popup.js)が唯一のコンポーネントになるので、タイマー表示が更新される度に画面全体が再レンダリングされるので、intervalIdを保持し続ける必要があるというわけです。

サービスワーカー

少し妙な書き方をしている部分について解説します。

case: 'start'

case: 'start'case: 'finish'では、メッセージの送信元に値を返していないのにsendResponseを実行しています。
sendResponseを実行しなかった場合、メッセージの送信元のコールバック関数が実行されなくなってしまいます。
なので、あえて空のレスポンスを返しています。

また、メッセージを送信した後の処理は非同期で行われるため、返すべき値がなくてもsendResponseを実行しておくべきだと思います。
こうすることで、送信元で実行する後続の処理がサービスワーカーでの処理が終わった後に実行される形になるので、非同期処理に惑わされる可能性が減るのではないでしょうか。


case: 'update'

updateTimerDisplayの引数としてレスポンスを返すためのsendResponse関数を渡しています。
これはupdateTimerDisplay内で使っているchrome.storageAPIが非同期処理であるためです。
chrome.storage.local.getメソッドのコールバック関数内であれば同期的に処理を実行することが出来るため、あえてupdateTimerDisplayの引数にsendResponseを渡しています。
こうすることで、非同期処理によって処理の順番がズレてsendResponseで送る値がundefinedになるといった事態を避けられます。

また、case: 'update'ブロックでreturn true;を記述することで、非同期でレスポンスを返すことを明示しています。
これを書いておかないとsendResponseで値を送る前にメッセージチャネルが閉じてしまい、sendMessageメソッド側のコールバック関数の引数resに値が渡ってこないというエラーが発生してしまうので注意が必要です。


finishTimer

終了メッセージを受け取ったらstartTimeを破棄しておきます。
今回作ったタイマーアプリでは破棄する必要がないのですが、停止処理を追加する場合はstartTimeがあれば計測中、なければ停止中というようにタイマーの起動状況の確認に使えるので破棄しておきました。
これは次の処理で使います。

ポップアップを開き直してもタイマーを更新する

このままだとポップアップを開いた状態ではタイマーが更新されるのですが、ポップアップを閉じてしまうとタイマーが初期表示のまま動かなくなってしまいます。
startボタンを押さないとupdateTimer関数の定期処理が実行されないからです。

では、ポップアップが開く度にタイマーが起動しているかどうかを検知して、タイマーが起動している場合はupdateTimerの定期処理が実行されるようにしていきましょう。
ここで登場するのがuseEffectです。

useEffectとは
useEffectはReact Hooksの一つで、コンポーネントのレンダリング後に特定の処理を実行するために使います。
特定の処理とはデータの取得、購読、手動でのDOMの変更など、コンポーネントがレンダリングされた後に実行したい処理を指し、これを副作用と呼びます。
useEffectはコンポーネントの初回レンダリング時と、第二引数の依存配列に含まれる変数が変更された場合に実行されます。
依存配列が空の場合は、コンポーネントの初回レンダリング時のみ実行されます。

今回のタイマーアプリでは初回レンダリング時にタイマーが起動中がどうかをサービスワーカーに問い合わせたいので、useEffectはうってつけというわけですね!

というわけで、タイマーが起動しているかどうかを検知する仕組みを作っていきます。
流れは以下のようになります。

  1. ポップアップが開かれた時に毎回実行されるuseEffectを利用して、サービスワーカーに問い合わせる
  2. サービスワーカーはローカルストレージにstartTimeが存在すれば、起動中だとレスポンスを返す
  3. ポップアップで起動中のレスポンスを受け取った場合は、updateTimer関数の定期処理を走らせる

ポップアップ

popup.js
useEffect(() => {
  chrome.runtime.sendMessage({ type: 'init' }, (res) => {
	if (res) {
	  intervalId.current = setInterval(updateTimer, 1000);
	  setIsRunning(true);
	}
}, []); // ポップアップを開いた時だけ実行すればいいので、依存配列は空

サービスワーカー

background.js
const initApp = (sendResponse) => {
  chrome.storage.local.get(['startTime'], (result) => {
    sendResponse(result.startTime ? true : false);
  });
};

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  switch (message.type) {
    case 'init':
      initApp(sendResponse);
      return true;
    case 'start':
    // 省略
  }
});

やってることとしては単純ですね。
ポップアップを開いた時にinitメッセージが送信され、サービスワーカーでinitApp関数が実行されてstartTimeがローカルストレージにあるかどうかを確認します。
startTimeが存在する場合はtrue、存在しない場合はfalseが返ってくるので、trueが返ってきた時だけupdateTimerの定期処理を実行するという形です。

完成

無事にタイマーが完成していたら下の画像のように動いてくれるのではないでしょうか?
(ポップアップを閉じてもタイマーが動いていることを確認したかったのですが、私のブックマークバーでお目汚ししてしまいごめんなさい。。)
Image from Gyazo

以上で超シンプルではありますが、バックグラウンドで計測し続けてくれるタイマーアプリの完成です。
かなり長くなってしまいましたが、いかがでしたでしょうか?
メッセージの送受信の部分が厄介ではありますが、「思ったより簡単にChrome拡張機能作れるじゃん」と感じていただけたら嬉しいです!

終わりに

プログラミング学習を始めた当初からブラウザ上で手軽に使えるタイマーアプリがないかなと思っていたので、今回のアドベントカレンダーを機に自分で開発することが出来て楽しかったです!
今回のハンズオンで作ったアプリに停止処理と履歴を10件まで保持しておく機能を追加した拡張機能がリリース審査待ちので、気が向いたら使ってみてください!

<2023/12/19 追記>
拡張機能がリリースされたので、リンクを追記します。
使ってくださると嬉しいです!

参考文献

43
38
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
43
38

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?