localStorage でベストスコアを保存するときに気をつけた4つのこと
たった数行に見える処理に、プライベートブラウジング・容量超過・データ破損・競合という4つの落とし穴があります。全部握りつぶすのが正解でした。
ベストスコアを保存する。ただそれだけの機能です。素直に書けばこうなります。
const best = Number(localStorage.getItem('best'));
if (score > best) localStorage.setItem('best', String(score));
3行。これで動きます。ただし、うまくいく環境では。
実際にこのサイトで使っている実装は、もう少し長くなりました。何を足したのか、なぜ足したのかを書きます。
落とし穴1:localStorage が使えない環境がある
いちばん重要なのがこれです。localStorage へのアクセスは例外を投げることがあります。
代表的なのは、プライベートブラウジングやシークレットモードです。ブラウザや設定によっては、localStorage にアクセスした時点で SecurityError が飛びます。また、サイトのデータ保存をブロックする設定にしている人もいます。
これを放置するとどうなるか。ゲームの結果画面で例外が出て、スコアが表示されなくなります。ベストスコアという「あれば嬉しい」程度の機能のために、ゲーム本体が壊れるわけです。
なので、読み書きの両方を try/catch で包んで、失敗したら黙って諦めるようにしました。
export function getBestScore(gameSlug: string, metric: string): number | null {
try {
const raw = localStorage.getItem(buildKey(gameSlug, metric));
if (raw === null) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
} catch {
return null;
}
}
例外を握りつぶすのは一般的には良くない習慣ですが、ここでは正しい判断だと考えています。呼び出し側にできることが何もないからです。「保存できませんでした」と伝えても、プレイヤーは設定を変えるつもりはないでしょう。
保存できない環境の人は、ベストスコアが出ないだけで、ゲームは普通に遊べる。これが目指した状態です。
落とし穴2:保存した値が数値とは限らない
localStorage に入るのは文字列だけです。取り出すときに数値へ戻す必要があります。
ここで素朴に Number(raw) とすると、壊れたデータが入っていたときに NaN が返ります。そして NaN はやっかいで、どんな比較も false になります。
NaN > 100 // false
NaN < 100 // false
ベストスコアが NaN になると、何をしても記録が更新されなくなります。しかも画面には「NaN」と表示される。
データが壊れる経路はいくつもあります。以前のバージョンで違う形式で保存していた、ユーザーが開発者ツールから書き換えた、拡張機能が触った、など。自分が書き込んだ値がそのまま返ってくるとは限らないと考えておくべきでした。
そこで Number.isFinite() で検査し、数値として扱えなければ null(=記録なし)として返しています。壊れた値は、次の更新時に正しい値で上書きされます。
落とし穴3:書き込みも失敗する
読み込みだけでなく、書き込みも例外を投げます。
localStorage には容量の上限があります。ブラウザによって違いますが、だいたい5MBから10MB程度。これを超えると QuotaExceededError が飛びます。
このサイトが保存しているのは数値がいくつかだけなので、自分のせいで上限に達することはまずありません。ただ、同じドメインの他の機能が容量を使っている可能性はあります。
そして書き込み失敗のときも、やることは同じです。諦めて、失敗したことを返す。
try {
localStorage.setItem(buildKey(gameSlug, metric), String(value));
return { isNewBest: true, previousBest };
} catch {
return { isNewBest: false, previousBest };
}
ここで isNewBest: false を返しているのがポイントです。保存できていないのに「自己ベスト更新!」と表示してしまうと、次のプレイで記録が消えていて混乱します。保存できたときだけ祝うべきです。
落とし穴4:読んでから書くまでの間
これは細かい話ですが、入れておきました。
「現在のベストを読む → 比較する → 新しい値を書く」という処理は、読んでから書くまでの間に別の場所で値が変わる可能性があります。同じゲームを2つのタブで開いている、といった状況です。
そこで、書き込む直前にもう一度読み直して、まだ自分の値のほうが良いかを確認しています。
const latest = getBestScore(gameSlug, metric);
const stillBeats = latest === null ||
(higherIsBetter ? value > latest : value < latest);
if (!stillBeats) {
return { isNewBest: false, previousBest: latest };
}
正直に書くと、localStorage は同期APIなので、ひとつのタブの中では割り込みは起きません。この確認が本当に効くのは、別タブが書き込んだ直後という限られた場面だけです。
それでも入れたのは、コストがほぼゼロだからです。読み込みは一瞬ですし、コードも数行。「たぶん大丈夫」で済ませるより、確認してから書くほうが後から考えなくて済みます。
キーの設計
保存するキーは、こういう形にしています。
himasugiru:best:calc-rush:score
サイト名 : 用途 : ゲーム名 : 指標 の4段構成です。
サイト名で始めるのは、同じドメインで動く他の機能と衝突しないようにするためです。best のような一般的な名前をそのまま使うと、あとで必ず困ります。
指標を最後に持たせたのは、1つのゲームが複数の記録を持つことがあるからです。スコアだけでなく、最大コンボ、到達レベル、最短時間など。キーの形を最初に決めておいたおかげで、あとから記録を増やすときに悩まずに済みました。
「高いほうが良い」とは限らない
もうひとつ、地味ですが効いた設計があります。更新の判定に higherIsBetter という引数を持たせたことです。
export function updateBestScore(
gameSlug: string,
metric: string,
value: number,
higherIsBetter = true,
): UpdateResult
スコアは高いほうが良いのですが、タイムは短いほうが良い。この2つを別々の関数にすると、共通部分(例外処理、検証、キーの組み立て)が丸ごと重複します。
引数ひとつで済むなら、そのほうが良い。既定値を true にしてあるので、ほとんどの呼び出し側は何も渡さずに使えます。
結論:小さな機能ほど、失敗しても平気にしておく
この実装を通して確認できたのは、「なくても困らない機能」は、失敗しても本体に波及しないようにすべきということでした。
ベストスコアが表示されないのは残念です。でも、ゲームが遊べなくなるのはもっと困ります。優先順位がはっきりしているなら、迷わず握りつぶしていい。
逆に言えば、握りつぶしていいかどうかを判断するには、その機能がどれくらい重要かをはっきりさせる必要があります。そこを曖昧にしたまま try/catch を書くと、本当に困るエラーまで隠してしまいます。
なおリアクションタイムだけは、この仕組みを意図的に使っていません。反応速度は体調で大きくぶれるので、「その日いちばん良かった1回」を残すと次から超えられなくなるからです。保存できることと、保存すべきことは別、という話でもあります。