はじめに
この記事は「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
は以下のようになっています。
{
"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
を実行してください。
コードの変更を検知して自動で再ビルドしてくれるようになるので、開発を始める時に実行しておきましょう。
たまにサービスワーカーのコード変更が反映されないことがあるので、その場合は拡張機能の設定ページから拡張機能自体をリロードしてください。
ハンズオン
今回はポップアップとサービスワーカーを使ってタイマーアプリを作っていきます。
完成してから気付いたのですが、今回作るタイマーアプリはポップアップだけで成立するのでサービスワーカーを使う必要はありませんでした...。
あくまで入門としてサービスワーカーについて学ぶためということでお許しください。
(個人的にはポップアップには画面表示を、サービスワーカーにはデータに関する処理をそれぞれ担ってもらう方が責任が分離出来て、コードの見通しがよくなる気がします。)
全体の流れ
- ポップアップでタイマーの開始・停止・終了を指示する仕組み
- サービスワーカーで指示を受け取り、タイマーの開始時間・経過時間を保持する
- ポップアップが閉じて後もタイマーの計測を可能にする
テキストとボタンを作る
手始めにボタンをクリックすると表示されるテキストが変わる仕組みを作っていきます。
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 = '計測開始'
と書き換えてみるとテキストが変わらないことが分かると思います。
だいぶざっくりな説明ですが、ページの中でユーザーの操作やイベントによって変更したいものについては状態
を使わないといけない!ということが理解出来ればとりあえず大丈夫です。
ポップアップとサービスワーカーを繋げる
次は変更するテキストの値をポップアップ側から渡すようにしたいと思います。
そのためにはまずポップアップからサービスワーカーにメッセージを送信する必要があります。
ここで使われるのがchrome.runtime
APIのsendMessage
メソッドです。
読んだまんまですね。
chrome.runtime.sendMessage({ type: 'タイプ' }, (res) => { ... });
sendMessage
メソッドは第一引数にオブジェクト、第二引数にコールバック関数を取ります。
コールバック関数の引数としてサービスワーカーからのレスポンスが渡ってくるので、この値を使って状態
を更新するというわけですね!
サービスワーカーでメッセージを受信するために使うのはonMessage.addListener
メソッドになります。
メッセージ限定のイベントリスナーと考えると分かりやすいでしょうか。
chrome.runtime.onMessage.addListener((message, sender, sendResponse), () => {});
引数としてコールバック関数を取り、コールバック関数の引数が3つ渡ってきます。
-
message
ポップアップから送られてきたオブジェクト。 -
sender
どこから送られてきたかの情報が入っているruntime.MessageSender
オブジェクト。
メッセージの送信側を表します。 -
sendResponse
メッセージの送信元にレスポンスを返すための関数です。
今回の記事では触れませんがDOM操作を行うコンテンツスクリプトにポップアップやサービスワーカーから値を渡したい時も上記のchrome.runtime
APIを使います。
少し説明が長くなってしまいましたが、sendMessage
とonMessage.addListener
の組み合わせることでポップアップとサービスワーカーという異なる領域でのやりとりを実現しているというわけです。
では、実際にこの2つを利用してサービスワーカーから変更するテキストを受け取るようにしていきます。
const handleClickStart = () => {
chrome.runtime.sendMessage({ type: 'start' }, (res) => {
setText(res);
});
};
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
switch (message.type) {
case 'start':
sendResponse('テキストを更新');
}
});
正しく実装出来ていれば下の画像のようにテキストが変更されるようになっているのではないでしょうか。
それでは解説していきます。
-
popup.js
startボタンのイベントハンドラに設定しているhandleClickStart
関数内からsendMessage
メソッドを使ってメッセージを送信しています。
送信されたメッセージオブジェクトはtype
プロパティを持っています。
第二引数のコールバック関数の引数res
にサービスワーカーからのレスポンスが渡ってくるので、それを更新関数にセットして状態
を更新しています。 -
background.js
サービスワーカーではポップアップから送信されたメッセージをchrome.runtime.onMessage.addListener
で受信しています。
message
にポップアップから送信されたオブジェクト{ type: 'start' }
が入っているので、どのメッセージか判別できるようにswitch
文で分岐しています。
そして、sendResponse
に表示したい文字列を渡してあげると、handleClickStart
関数内のchrome.runtime.sendMessage
のres
にその値が渡っていくというわけです。
Chrome拡張機能開発の中でここがけっこうややこしい部分なのかなと思っているのですが、いかがでしたでしょうか?
ポップアップとサービスワーカーの間でメッセージの送受信を行うことで、処理を依頼したりデータを受け渡したりしているということが分かればOKです!
タイマー機能の実装
ようやく本題のタイマー機能ですね!
流れとしては下記のようになります。
- ポップアップから開始メッセージを送信
- サービスワーカーで開始時刻を記録
- タイマーの更新を開始
- 終了メッセージを受け取ったらタイマーの更新を終了する
タイマー機能
先にタイマー機能についてざっと解説します。
タイマーは言い換えれば経過時間なので、現在時刻とタイマーを開始した時刻の差で表せます。
経過時間が増えるごとに、つまり1秒ごとに経過時間を計算してその値を更新し続ければタイマーが動いているかのように画面を表示することが出来そうです。
開始メッセージを受け取った時刻を記録し、現在時刻 - 開始時刻
の結果を毎秒ポップアップに返して、ポップアップで状態
を更新するというわけですね。
開始時刻は確実に保持しておかなければいけないので、chrome.storage
APIを使います。
// 保存
chrome.storage.local.set({ key: value }, () => {});
// 取得
chrome.storage.local.get(['key'], (result) => {
console.log(result.key);
});
保存にはset
メソッドを使います。
第一引数にオブジェクト形式でローカルストレージに保存するキーと値を指定します。
第二引数は保存が終わった後に実行されるコールバック関数です。
取得する時はget
メソッドを使います。
第一引数には配列形式でキーを渡します。複数のキーを同時に渡すことが可能です。
第二引数はコールバック関数で、引数としてresult
オブジェクトが渡ってきます。
このresult
オブジェクトのプロパティに第一引数で渡したキーが設定され、その値としてローカルストレージに保存した値を取得出来ます。
タイマー機能のコードはこのようになります。
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}`;
};
タイマーの挙動を実現するための処理は実装出来たので、次は開始・終了メッセージと毎秒更新するための処理を作りましょう!
ポップアップ
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
を設定してメッセージを送信。返ってきたレスポンスで画面を更新。
サービスワーカー
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
が返ってくるので、タイマーを終了したい場合はこれを破棄します。
ここでポイントなのが戻り値のid
をuseRef
を使って保持しています。
useRefとは
useRef
とはReact Hooksの1つで、レンダリング間で値を保持するために使われます。
useRef
を使うと、生成されたref
オブジェクトはそのコンポーネントのライフサイクル全体で同じオブジェクトを保持し続けることができるようになります。
useRef
は主にDOM要素に直接アクセスしたい時にuseRef
を使ってその参照を保持するために使われる印象です。
今回はタイマー表示を更新する度にポップアップが再レンダリングされるのでintervalId
が変わってしまわないようにuseRef
を使って同じ参照を保持出来るようにしています。
useState
を使ったり、let
やconst
など普通の変数定義で管理するとコンポーネントがレンダリングされる度に新しいintervalId
が設定されてしまうので、handleClickStart
関数とhandleClickFinish
関数で同じintervalId
が参照出来なくなってしまいます。
コンポーネントとは
コンポーネントというのは、ヘッダー・サイドバー・コンテンツ・ボタンなど、ページ内のUIを独立した再利用可能な単位として表すものです。
それぞれのコンポーネントが独自の状態
とライフサイクルを持つことが出来るため、画面を更新する時に全体を更新するのではなく、変更が必要な要素だけをピックアップして更新することが可能になります。
これによってページの表示速度が向上したり、ネイティブアプリのようにヌルヌル動くページを作成することが出来ます。
今回の拡張機能ではポップアップ(popup.js
)が唯一のコンポーネントになるので、タイマー表示が更新される度に画面全体が再レンダリングされるので、intervalId
を保持し続ける必要があるというわけです。
サービスワーカー
少し妙な書き方をしている部分について解説します。
case: 'start'
case: 'start'
とcase: 'finish'
では、メッセージの送信元に値を返していないのにsendResponse
を実行しています。
sendResponse
を実行しなかった場合、メッセージの送信元のコールバック関数が実行されなくなってしまいます。
なので、あえて空のレスポンスを返しています。
また、メッセージを送信した後の処理は非同期で行われるため、返すべき値がなくてもsendResponse
を実行しておくべきだと思います。
こうすることで、送信元で実行する後続の処理がサービスワーカーでの処理が終わった後に実行される形になるので、非同期処理に惑わされる可能性が減るのではないでしょうか。
case: 'update'
updateTimerDisplay
の引数としてレスポンスを返すためのsendResponse
関数を渡しています。
これはupdateTimerDisplay
内で使っているchrome.storage
APIが非同期処理であるためです。
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
はうってつけというわけですね!
というわけで、タイマーが起動しているかどうかを検知する仕組みを作っていきます。
流れは以下のようになります。
- ポップアップが開かれた時に毎回実行される
useEffect
を利用して、サービスワーカーに問い合わせる - サービスワーカーはローカルストレージに
startTime
が存在すれば、起動中だとレスポンスを返す - ポップアップで起動中のレスポンスを受け取った場合は、
updateTimer
関数の定期処理を走らせる
ポップアップ
useEffect(() => {
chrome.runtime.sendMessage({ type: 'init' }, (res) => {
if (res) {
intervalId.current = setInterval(updateTimer, 1000);
setIsRunning(true);
}
}, []); // ポップアップを開いた時だけ実行すればいいので、依存配列は空
サービスワーカー
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
の定期処理を実行するという形です。
完成
無事にタイマーが完成していたら下の画像のように動いてくれるのではないでしょうか?
(ポップアップを閉じてもタイマーが動いていることを確認したかったのですが、私のブックマークバーでお目汚ししてしまいごめんなさい。。)
以上で超シンプルではありますが、バックグラウンドで計測し続けてくれるタイマーアプリの完成です。
かなり長くなってしまいましたが、いかがでしたでしょうか?
メッセージの送受信の部分が厄介ではありますが、「思ったより簡単にChrome拡張機能作れるじゃん」と感じていただけたら嬉しいです!
終わりに
プログラミング学習を始めた当初からブラウザ上で手軽に使えるタイマーアプリがないかなと思っていたので、今回のアドベントカレンダーを機に自分で開発することが出来て楽しかったです!
今回のハンズオンで作ったアプリに停止処理と履歴を10件まで保持しておく機能を追加した拡張機能がリリース審査待ちので、気が向いたら使ってみてください!
<2023/12/19 追記>
拡張機能がリリースされたので、リンクを追記します。
使ってくださると嬉しいです!