background.jsのsetIntervalが、いつの間にか止まっている
Chrome拡張のbackground.jsでsetIntervalを使って定期処理を組んだつもりが、リリースしてしばらく経つと通知が全然来ない。ログを見ても特にエラーは出ていない。Manifest V3(MV3)の拡張を書いていれば一度はハマるやつだと思います。私も最初これで丸一日溶かしました。
原因はManifest V2までの前提を引きずっていたことです。MV2まではbackground.htmlのような常時起動しているページを裏に1枚持たせておけて、そこでsetIntervalを回しておけば延々とポーリングし続けられました。ところがMV3ではこの永続ページ自体が廃止されています。
service workerは寝る
MV3のbackground処理は「service worker」というイベント駆動の仕組みに置き換わっています。イベントが来たときだけ起動し、何もイベントがなければ数十秒ほどで自動的に終了(terminate)してしまいます。常に生きている前提でコードを書くと、ここで静かに壊れます。
// これはMV3では動作が保証されない
setInterval(() => {
checkForNewMessages();
}, 30000);
service workerが起動している間はこのタイマーも動くのですが、数十秒操作がないとservice worker自体が終了し、setIntervalで仕込んだタイマーも一緒に消えてしまいます。次に何らかのイベントでservice workerが再起動しても、setIntervalはもう一度呼び直さない限り復活しません。テスト中は一応動いているように見えるので気づきにくく、リリース後にしばらく放置していたら発覚する、という厄介なバグです。
chrome.alarmsで確実に起こしてもらう
MV3で定期処理をやりたいなら、chrome.alarms APIを使いましょう。OS側のアラーム機構でservice workerを指定した間隔で起こしてくれる仕組みで、service workerが休止していても指定時刻になればChromeがきちんと叩き起こしてくれます。
ここで注意です。chrome.alarmsの最短周期は30秒で、それより短い間隔を指定しても30秒に丸められます。数秒おきのリアルタイム処理には向きませんが、「一定間隔でサーバーを確認する」程度のポーリング用途なら十分です。
まずmanifest.jsonでalarms権限とservice workerを宣言します。コピペで大丈夫です。
{
"manifest_version": 3,
"background": {
"service_worker": "background.js"
},
"permissions": ["alarms", "storage"]
}
background.js側では、拡張のインストール時にアラームを登録しておきます。
chrome.runtime.onInstalled.addListener(() => {
chrome.alarms.create("poll-check", { periodInMinutes: 0.5 });
});
periodInMinutes: 0.5が30秒周期にあたります。これでChromeが30秒ごとにservice workerを起こし、chrome.alarms.onAlarmイベントを発火してくれます。chrome://extensionsのservice workerのログに30秒おきの起動が出ていれば成功です。
service workerはグローバル変数を覚えていてくれない
ここで見落としがちなのが、service workerはグローバル変数に状態を持たせても、休止のたびに消えてしまう点です。前回のチェックでどこまで処理したかというカーソルやタイムスタンプをただの変数に入れていると、次に起こされたときには初期化された状態からスタートしてしまいます。
これを避けるには、保持したい状態は毎回chrome.storage.localに書き込んでおき、起こされるたびにそこから読み直す設計にする必要があります。service worker自身は「状態を持たない実行環境」だと割り切って、状態の置き場所はstorageに寄せてしまいましょう。
実例:一定間隔でサーバーの新着を確認する
自作のChrome拡張「QR Send」(https://qrsend.app)でも、この構成をそのまま使っています。スマホから送られてきたURLを中継サーバー経由で受け取る仕組みで、PC側の拡張がservice workerとして30秒おきに起こされ、新着がないか確認し、あれば新しいタブとして開くという流れです。
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name !== "poll-check") return;
const { lastCheckedAt } = await chrome.storage.local.get("lastCheckedAt");
const since = lastCheckedAt || 0;
const res = await fetch(`https://example.com/messages?since=${since}`);
const messages = await res.json();
for (const message of messages) {
chrome.tabs.create({ url: message.url });
}
await chrome.storage.local.set({ lastCheckedAt: Date.now() });
});
ポイントは、アラームが発火するたびにchrome.storage.localから前回チェック時刻を読み直し、処理が終わったら次回のために書き戻しているところです。service worker自体がその間に何度休止と再起動を繰り返していても、状態はstorageの中にあるので影響を受けません。新着があればタブが開く、というところまで確認できたら成功です。
通知を出したい場合はchrome.notificationsを、新着をバッジで示したい場合はchrome.action.setBadgeTextを、同じonAlarmリスナーの中に足していくだけで済みます。
まとめ
MV3のbackgroundはイベント駆動のservice workerに変わり、数十秒で自動終了する。setIntervalはその休止に巻き込まれて消えるため定期処理には使えず、休止していても指定間隔で起こしてくれるchrome.alarms(最短30秒)で代替する。あわせて、グローバル変数に持たせた状態も休止のたびに消えるので、保持したい値はchrome.storage.localに書き込んで起こされるたびに読み直す設計にしておく。この2つを押さえておけば、ポーリング系の処理はMV3でも普通に組める。


コメント