はじめに
Chrome 拡張機能(Manifest V3)は、役割の異なるいくつかの部品で構成されます。この記事では、そのうち content script・popup・Service Worker の3つを扱います。3つは動く場所が異なります。
それぞれがいつ始まり、いつ止まるのかを、検証用の拡張機能で確かめました。
検証環境:Chrome 155.0.8059.86(arm64)、macOS 14.7
この記事で扱う3つの部品
| 部品 | 役割 | manifest.json のキー |
|---|---|---|
| content script | Web ページの中で動き、ページの DOM を読み書きするスクリプト | content_scripts |
| popup | ツールバーのアイコンをクリックすると開く画面 | action.default_popup |
| Service Worker | イベントを受け取って処理する、画面を持たないスクリプト | background.service_worker |
検証用の拡張機能を用意する
3つの部品が動いたタイミングをそれぞれログに記録し、popup に一覧で表示する拡張機能を作ります。
3つの部品のログを1か所で確認する
3つの部品は、console.log の出力先がそれぞれ異なります。
- content script:そのページの DevTools
- popup:popup を右クリックして「検証」で開く DevTools
- Service Worker:
chrome://extensionsの「ビューを検証」から開く DevTools
Service Worker の DevTools を開いている間は、Service Worker が停止しません。そこで、DevTools を開かずにログを確認できるよう、ログを chrome.storage.local に保存して popup に一覧で表示します。content script も chrome.storage を直接使えるため、3つの部品のログを1か所で確認できます。
ファイル構成
lifecycle-demo/
├── manifest.json
├── log.js
├── content.js
├── popup.html
├── popup.js
└── service-worker.js
log.js は3つの部品で共通して使うログの関数、それ以外は部品ごとのファイルです。
manifest.json
manifest.json
{
"manifest_version": 3,
"name": "Lifecycle Demo",
"version": "1.0",
"permissions": ["storage"],
"content_scripts": [
{
"matches": ["https://developer.chrome.com/*"],
"js": ["log.js", "content.js"]
}
],
"action": {
"default_popup": "popup.html"
},
"background": {
"service_worker": "service-worker.js"
}
}
content_scripts・action・background で3つの部品を登録し、permissions でログの保存に使う storage 権限を宣言しています。content script は、matches で指定した https://developer.chrome.com/ のページにだけ挿入します。
log.js
3つの部品で共通して使う log 関数を、log.js に置きます。第1引数には、どの部品の記録かを表すラベル(CS:content script、popup、SW:Service Worker)を渡します。
let seq = 0;
function log(label, text) {
const time = new Date().toLocaleTimeString();
const order = String(seq++).padStart(5, '0');
const key = `log:${Date.now()}:${order}:${crypto.randomUUID()}`;
chrome.storage.local.set({ [key]: `${time} [${label}] ${text}` });
}
複数の部品が同時に書き込んでもログが失われないように、ログを1件ずつ別のキーで保存する関数です。キーは「記録した時刻(ミリ秒)・スクリプト内での連番・ランダムな ID」をつないだ文字列です。連番で同じミリ秒の記録の順番を保ち、ランダムな ID で部品どうしのキーの重なりを防いでいます。表示するときはキーの順に並べます。
log.js は、部品ごとに以下の方法で読み込みます。
- content script:manifest.json の
jsで、content.js より先に並べる - popup:popup.html で、popup.js より先に
<script>タグで読み込む - Service Worker:service-worker.js の先頭で
importScripts('log.js')を呼ぶ
content.js
let count = 0;
count++;
log('CS', `ページに挿入された(count: ${count})`);
ページに挿入されるたびに、グローバル変数 count を1増やして記録するスクリプトです。
popup.html
popup.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body style="width: 320px">
<button id="send">Service Worker にメッセージを送る</button>
<button id="timer">5秒後に記録する</button>
<button id="clear">ログを消す</button>
<pre id="logs"></pre>
<script src="log.js"></script>
<script src="popup.js"></script>
</body>
</html>
popup.js
popup.js
async function render() {
const items = await chrome.storage.local.get(null);
const lines = Object.keys(items)
.filter((key) => key.startsWith('log:'))
.sort()
.map((key) => items[key]);
document.getElementById('logs').textContent = lines.join('\n');
}
log('popup', '開いた');
render();
chrome.storage.onChanged.addListener(render);
document.getElementById('send').addEventListener('click', () => {
chrome.runtime.sendMessage({ type: 'count' });
});
document.getElementById('timer').addEventListener('click', () => {
log('popup', '5秒タイマーを開始した');
setTimeout(() => log('popup', '5秒たった'), 5000);
});
document.getElementById('clear').addEventListener('click', () => {
chrome.storage.local.clear();
});
popup を開いたときに「開いた」と記録し、保存されているログを時刻順に表示します。chrome.storage.onChanged で保存内容の変化を受け取り、表示を更新します。ボタンを押すと、Service Worker へのメッセージ送信、5秒後に記録するタイマーの開始、ログの削除をそれぞれ行います。
service-worker.js
importScripts('log.js');
let count = 0;
log('SW', '起動した');
chrome.runtime.onMessage.addListener((message) => {
if (message.type === 'count') {
count++;
log('SW', `メッセージを受信した(count: ${count})`);
}
});
先頭の importScripts('log.js') で log 関数を読み込みます。Service Worker が起動したときに「起動した」と記録し、メッセージを受け取るたびにグローバル変数 count を1増やして記録します。イベントリスナーをトップレベルで登録している理由は、「寿命を前提にした書き方」で説明します。
拡張機能を読み込む
chrome://extensions でデベロッパーモードを有効にし、「パッケージ化されていない拡張機能を読み込む」から lifecycle-demo フォルダを読み込みます。各実験の前には、popup の「ログを消す」でログを空にします。
content script はページを読み込むたびに作り直される
ページを再読み込みしてログを確認する
- STEP 1:popup の「ログを消す」を押す
- STEP 2:新しいタブで
https://developer.chrome.com/を開く - STEP 3:
https://developer.chrome.com/のタブで、ブラウザの再読み込みボタンを2回押す - STEP 4:popup を開いてログを確認する
実行結果は以下のとおりです。
14:10:01 [CS] ページに挿入された(count: 1)
14:10:05 [CS] ページに挿入された(count: 1)
14:10:08 [CS] ページに挿入された(count: 1)
14:10:12 [popup] 開いた
最初の読み込みと2回の再読み込みで、「ページに挿入された」が3回記録されています。count は3回とも 1 で、再読み込みのたびにグローバル変数が初期化されています。
content script が挿入されるタイミング
content script は、manifest.json の matches に一致するページが読み込まれるたびに挿入されます。挿入のタイミングは run_at で指定でき、既定値の document_idle では DOM の構築が終わった後に実行されます。
ページを再読み込みすると、新しく読み込まれたページに content script が改めて挿入されます。そのため、グローバル変数の値は前のページから引き継がれません。
popup を閉じると実行中のタイマーも止まる
タイマーの途中で popup を閉じる
- STEP 1:popup を開き、「ログを消す」を押す
- STEP 2:「5秒後に記録する」を押して、5秒たつ前に popup の外をクリックして閉じる
- STEP 3:10秒ほど待ってから popup を開き直す
- STEP 4:「5秒後に記録する」を押して、popup を開いたまま5秒待つ
実行結果は以下のとおりです。
14:15:01 [popup] 5秒タイマーを開始した
14:15:15 [popup] 開いた
14:15:17 [popup] 5秒タイマーを開始した
14:15:22 [popup] 5秒たった
STEP 2 では popup を閉じたため、「5秒たった」が記録されていません。popup を開いたままにした STEP 4 だけ、5秒後に記録されています。
popup が閉じるタイミング
popup は、アイコンのクリックなどで開き、popup の外にフォーカスが移ると自動で閉じます。公式ドキュメントでは、外をクリックした後も popup を開いたままにする方法はないと説明されています。
popup が閉じると、popup の JavaScript は実行中のタイマーも含めて消えます。開き直すと popup.js が最初から実行されるため、「開いた」がもう一度記録されます。
Service Worker は何もしないと30秒で停止する
1分おいてからメッセージを送る
- STEP 1:Service Worker の DevTools を閉じておく
- STEP 2:popup の「ログを消す」を押し、popup を閉じて1分ほど待つ
- STEP 3:popup を開き、「Service Worker にメッセージを送る」を2回押す
- STEP 4:popup を閉じて1分ほど待つ
- STEP 5:popup を開き、「Service Worker にメッセージを送る」を1回押す
Service Worker は拡張機能を読み込んだときにも起動するため、STEP 2 で1分待ち、いったん停止させています。
実行結果は以下のとおりです。
14:20:01 [popup] 開いた
14:20:03 [SW] 起動した
14:20:03 [SW] メッセージを受信した(count: 1)
14:20:05 [SW] メッセージを受信した(count: 2)
14:21:10 [popup] 開いた
14:21:12 [SW] 起動した
14:21:12 [SW] メッセージを受信した(count: 1)
STEP 5 のメッセージで「起動した」がもう一度記録され、count が 1 に戻っています。STEP 4 で待っている間に Service Worker が停止し、グローバル変数の値が消えたためです。
STEP 3 から STEP 5 までの Service Worker の状態を表した図です。停止までの30秒は公式ドキュメントの値です。
Service Worker が停止する条件
Chrome は通常、以下のいずれかに当てはまると Service Worker を停止します。
- イベントの受信も拡張機能の API 呼び出しもない状態が30秒続く場合
- 1つのイベントや API 呼び出しの処理に5分以上かかる場合
-
fetch()のレスポンスが届くまでに30秒以上かかる場合
停止した Service Worker は、イベントが届くと再び起動し、スクリプトを最初から実行します。STEP 3 と STEP 5 では、メッセージが届いたことで Service Worker が起動し、トップレベルの log('SW', '起動した') が実行されてから onMessage のリスナーが呼ばれています。
公式ドキュメントでは、Service Worker が停止するとグローバル変数は失われるため、値は chrome.storage などに保存するよう説明されています。
3つの部品の寿命の比較
| 部品 | 始まるとき | 終わるとき |
|---|---|---|
| content script | 対象のページを読み込んだとき | ページを再読み込みしたとき |
| popup | popup を開いたとき | popup を閉じたとき |
| Service Worker | 拡張機能を読み込んだとき、停止中にイベントを受け取ったとき | 30秒間イベントも API 呼び出しもなかったときなど |
3つの部品はどれも、グローバル変数の値を保ち続けられません。
寿命を前提にした書き方
実験で確かめた寿命と公式ドキュメントの説明から、実装では以下の点を押さえておく必要があります。
- 3つの部品はどれも終わるとグローバル変数の値が消えるため、残しておきたい値は
chrome.storageに保存する - 停止中の Service Worker に届いたイベントを取りこぼさないように、イベントリスナーは Promise やコールバックの中ではなく、スクリプトのトップレベルに書く
- Service Worker が停止すると実行中のタイマーも消えるため、時間をおいて実行する処理や定期的な処理には、
setTimeoutやsetIntervalではなく、Chrome が時間を管理するchrome.alarmsを使う
まとめ
content script はページを再読み込みしたとき、popup は閉じたとき、Service Worker は何もしない状態が30秒続いたときに止まります。どの部品も動き続けるとは限らないため、残しておきたい値は chrome.storage に保存しておく必要があります。
参考

