学習アプリで「回答 → 確信度を選ぶ(自信あり/たぶん/勘)」の2段階で記録する画面を作ると、回答した直後、確信度を選ぶ前にユーザーが画面を離れる場面への備えが要ります。スマホではホームボタン、アプリの切り替え、画面ロックで、ページはいつでも裏に回り、そのまま閉じられます。
この記事では、確信度を選ぶ前の「保留中の回答」を失わないように、ページが裏に回った瞬間に端末へ確定させて送る設計を、動くコードで説明します。
この記事の要点
- ページを離れた合図には
visibilitychange(hiddenになったとき)とpagehideを使う。beforeunloadはスマホでは発火しないことがある - 合図を受けたら、保留中の回答を 同期的に
localStorageへ書く →navigator.sendBeaconで送る の順にする -
sendBeaconがtrueを返しても「送信の予約ができた」だけ。届いた保証ではないので、端末の控えは次回起動時に再送して、サーバー側で重複を除く - 2つの合図は両方発火することがある。保留を取り出した時点で空にして、二重に確定させない
なぜ確信度を選ぶ前の回答が消えるのか?
2段階の記録では、回答はいったんメモリ上の変数(保留)に置かれ、確信度が選ばれた時点で1件の記録として保存されます。ここでページが閉じられると、メモリの内容はそのまま消えます。
「閉じる前に保存する」には beforeunload が思い浮かびますが、Chrome for Developers の Page Lifecycle API の解説にあるとおり、スマホではアプリの切り替えから閉じられる流れで beforeunload や unload が発火しないことがあります。確実に受け取れる最後の合図は、ページが見えなくなったときの visibilitychange(document.visibilityState === 'hidden')と、ページから離れるときの pagehide です。
どう確定させるのか?
合図を受けたら、次の2つを この順番で 行います。
| 順番 | やること | 理由 |
|---|---|---|
| 1 | 保留中の回答を localStorage の送信待ちの列に同期で書く |
同期処理なので、ページが閉じる前に確実に終わる |
| 2 |
navigator.sendBeacon でサーバーへ送る |
ページが閉じた後もブラウザが送信を続けてくれる |
確信度はまだ選ばれていないので、null(未選択)として記録します。「選ばれなかった」こと自体も学習の記録として意味があるため、捨てずに残します。
const QUEUE_KEY = 'answerQueue'
let pending = null // { questionId, choice, answeredAt }
// 回答した直後:確信度を聞く前に、いったん保留に置く
function onAnswer(questionId, choice) {
pending = { questionId, choice, answeredAt: Date.now() }
}
// 確信度が選ばれた:保留を1件の記録にする
function onConfidence(level) {
if (!pending) return null
const entry = { ...pending, confidence: level }
pending = null
enqueue(entry)
return entry
}
// ページを離れる合図:確信度は未選択のまま確定させる
function flushPending(reason) {
if (!pending) return null // 2回目の合図では何もしない
const entry = { ...pending, confidence: null, flushedBy: reason }
pending = null
enqueue(entry)
return entry
}
// 1) 端末に同期で残す → 2) 送信を予約する
function enqueue(entry) {
const queue = JSON.parse(localStorage.getItem(QUEUE_KEY) || '[]')
queue.push(entry)
localStorage.setItem(QUEUE_KEY, JSON.stringify(queue))
const body = new Blob([JSON.stringify(queue)], { type: 'application/json' })
return navigator.sendBeacon('/api/answers', body)
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flushPending('hidden')
})
window.addEventListener('pagehide', () => flushPending('pagehide'))
ポイントは flushPending の先頭で保留を取り出し、すぐ pending = null にしていることです。スマホでは visibilitychange と pagehide が続けて発火することがありますが、2回目は保留が空なので何もしません。
送信できたかは、どう確かめるのか?
navigator.sendBeacon の戻り値は、送信をブラウザの列に積めたかどうかだけを表します(MDN)。サーバーに届いたかは分かりません。そこで、端末の送信待ちの列は消さずに残し、次に起動したときに通常の fetch で送り直して、成功したら空にします。
async function resendQueue() {
const queue = JSON.parse(localStorage.getItem(QUEUE_KEY) || '[]')
if (queue.length === 0) return 0
const res = await fetch('/api/answers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(queue),
})
if (res.ok) localStorage.removeItem(QUEUE_KEY)
return queue.length
}
同じ記録が sendBeacon と再送の両方で届くことがあるので、サーバー側は questionId と answeredAt の組で重複を除きます。
二重に確定しないことを確かめる
ブラウザの部品を最小限の作り物に置き換えると、Node でも動きを確かめられます。
// 上のコードの前に置く、最小限の作り物
// (Node 21 以降は navigator などが読み取り専用で用意されているため defineProperty で置き換える)
const fake = (name, value) => Object.defineProperty(globalThis, name, { value, configurable: true, writable: true })
const store = {}
fake('localStorage', {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = String(v) },
removeItem: (k) => { delete store[k] },
})
let beacons = 0
fake('navigator', { sendBeacon: () => { beacons++; return true } })
const listeners = {}
fake('document', { visibilityState: 'visible', addEventListener: (t, f) => { listeners[t] = f } })
fake('window', { addEventListener: (t, f) => { listeners[t] = f } })
// (ここに上の記事のコードを置く)
onAnswer('q1', 3)
document.visibilityState = 'hidden'
listeners.visibilitychange() // hidden
listeners.pagehide() // 続けて pagehide
const queue = JSON.parse(localStorage.getItem('answerQueue'))
console.log(queue.length, queue[0].confidence, queue[0].flushedBy, beacons)
// → 1 null hidden 1
合図が2回来ても、記録は1件・送信の予約も1回で、確信度は未選択(null)として残ります。
注意点
-
sendBeaconで送れる量には、ブラウザごとに上限があります。送信待ちが溜まりすぎないよう、再送が成功したら列を空にします -
localStorageは同期処理です。合図の中で大量の書き込みをすると、閉じるまでの短い時間に収まらないことがあります。1件ずつ小さく積みます -
unloadを登録すると、ブラウザの「戻る・進む」のキャッシュ(bfcache)が使われなくなります(Page Lifecycle API の解説)。離脱の検知はvisibilitychangeとpagehideに任せます
参考
- MDN「Document: visibilitychange イベント」 https://developer.mozilla.org/ja/docs/Web/API/Document/visibilitychange_event
- MDN「Window: pagehide イベント」 https://developer.mozilla.org/ja/docs/Web/API/Window/pagehide_event
- MDN「Navigator.sendBeacon()」 https://developer.mozilla.org/ja/docs/Web/API/Navigator/sendBeacon
- Chrome for Developers「Page Lifecycle API」 https://developer.chrome.com/docs/web-platform/page-lifecycle-api