1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

確信度を選ぶ前に離脱しても回答を失わない:visibilitychange・pagehide・sendBeacon の使い方

1
Last updated at Posted at 2026-10-25

学習アプリで「回答 → 確信度を選ぶ(自信あり/たぶん/勘)」の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 に任せます

参考

1
0
0

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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?