アプリの状態を URL だけで持たせる
URL を状態の置き場所にした理由
身長比較画像メーカーとコード進行伴奏プレイヤーは、状態をすべて URL に持たせている。
サーバーもデータベースもログインもない。
アドレスバーをコピーすれば、いま見ている状態をそのまま人に渡せるし、ブックマークしておけばリロードしても同じ状態が表示される。
小さなクライアントサイドのツールにとって、URL は想像以上に実用的な状態の置き場所である。
最初から共有できてブックマークもできるため、「共有機能」をわざわざ別に作らずに済む。
リロードしても、「戻る」「進む」を押しても状態が保たれる。
ホスティング費用がかからず、サーバーが落ちることもない。
中身がユーザーから見えて直接編集できるのも、隠れた状態がないという意味では素直な設計である。
一方で、そこに入れたものはすべて公開され、扱える文字もほぼ ASCII の範囲内に限られ、長さにも上限がある。
この制約は最後にまとめる。
履歴を汚さないために replaceState を使う
状態はほぼ1文字打つたびに変化する。
その都度 history.pushState していたら、少しいじっただけでユーザーの「戻る」ボタンが何十回分もの履歴の向こうに埋もれることになる。
そのため、現在の履歴エントリをその場で書き換える history.replaceState を使う。
history.replaceState(null, "", "?" + params.toString());
両方のツールとも、入力イベントごとにこれを呼び出している。
ツールページの履歴は常に1段だけで、アドレスバーはいつでも最新の状態を指す。
値がいくつかなら URLSearchParams で十分
コード進行伴奏プレイヤーが持っているのは、独立した値が5つ(コード進行・テンポ・拍子・パターン・音量)だけである。
そのため URLSearchParams をそのまま使っており、特別な処理は必要ない。
// 書き込み
const q = new URLSearchParams();
q.set("c", chords.trim());
q.set("t", String(tempo));
q.set("m", meter);
history.replaceState(null, "", location.pathname + "?" + q.toString());
// 読み込み
const q = new URLSearchParams(location.search);
const chords = q.has("c") ? q.get("c") : DEFAULT_CHORDS;
コード進行のテキストには空白や |、#、改行が入るが、そのパーセントエンコードは URLSearchParams が処理する。
状態がいくつかの名前付きの値だけで足りるなら、これで十分完結する。
人物リストを1つのパラメータに詰める
身長比較画像メーカーは事情が異なる。
持っているのは人物が並んだリストで、それぞれがユーザーの打ち込んだ名前・身長・色を持つ。
これはフラットな key/value には収まらないため、リスト全体を独自の書式で1つのパラメータにまとめている。
?people=Alice:170:7c9eff,Bob:160:ffb86b
カンマで人物を区切り、コロンで各フィールドを区切る。
ただし、フィールドにユーザーが入力した任意のテキストが入るようになると、区切り文字と文字が衝突する問題が生じる。
名前が A, B だったらどうなるか。10:30 だったらどうなるか。
対策として、書き出すときは名前だけあらかじめパーセントエンコードしておき、読み込むときはデコードする前の生のクエリ文字列をそのまま , と : で分割する。
// 書き込み: 各名前をエンコードしてから、区切り文字でつなぐ
const value = people
.map((p) => `${encodeURIComponent(p.name)}:${p.height}:${p.color}`)
.join(",");
history.replaceState(null, "", value ? `?people=${value}` : location.pathname);
// 読み込み: 生の値を取り出して分割し、そのあとで名前だけデコードする
const raw = location.search.match(/[?&]people=([^&]*)/);
const people = (raw ? raw[1].split(",") : [])
.map((entry) => {
const [name, height, color] = entry.split(":");
const h = parseFloat(height);
if (!name || !isFinite(h) || h <= 0) return null;
return { name: decodeURIComponent(name), height: h, color: normalizeColor(color) };
})
.filter(Boolean);
名前に含まれるカンマやコロンは、URL 上では %2C や %3A に変換される。
そのため素の文字で分割しても、区切り位置を読み違えることはない。
デコードは分割が終わったあと、フィールドごとに1回だけ行う。
このコードがあえて行っていないのは、この値を URLSearchParams に通すことである。
通すと % が二重にエンコードされ、次に読み書きしたときに %252C になる。
独自のエンコードと URLSearchParams は併用できないため、パラメータごとにどちらを使うか明確に決める必要がある。
URL は信頼せず、読むたびに検証する
URL を通じてアクセスされるデータは、いつか必ず「途中で途切れたもの」「手動で書き換えられたもの」「何年も前の古い形式のもの」に遭遇する。
そのため、読み込むたびにすべてのフィールドを検証し、例外を投げるのではなく、安全な状態にフォールバックさせる。
- 数値はパースして
isFiniteを確認し、妥当な範囲に丸める。コード進行伴奏プレイヤーの場合は、テンポを 30〜300 の範囲に収め、不正な数値であれば 90 に戻す。 - 選択肢はホワイトリストと照合する。拍子やパターンはマップを参照し、一致するものがない場合は既定値にする。
- リストは要素ごとに検証し、不正なものだけを捨てる(上の
.filter(Boolean))。有効な要素が1件も残らない場合、空っぽのツールを表示する代わりに、組み込みの初期セットに戻す。
どんなに壊れた URL でアクセスされても画面がクラッシュせず、最悪でも初期状態に戻るというのが、この設計の狙いである。
機能を削っても古いリンクを壊さない
コード進行伴奏プレイヤーにはかつて、楽器を選ぶ機能と、そのための i パラメータが存在した。
この機能を廃止した際に行ったのは、読み込み側で i を参照する処理を消すことだけである。&i=2 が付いたままの古い共有リンクであっても、そのまま正常に動作する。
知らないパラメータは単に無視され、残りの設定が通常どおり読み込まれる。
これは URL に状態を持たせることの大きなメリットであり、読む側と書く側のライフサイクルを時間的に切り離せる。
「パラメータは足すだけ」「知らないものは無視するだけ」という原則を守っている限り、過去に配ったリンクはすべてそのまま機能し続ける。
URL に持たせないほうがいいもの
- 長さ。ブラウザ、サーバー、あるいはリンクをリッチプレビューとして展開するチャットアプリなどの制限を考えると、URL 全体で 2000 文字程度に収めるのが無難である。人物10人分や数小節のコード進行程度なら問題ないが、長文のドキュメント全体を収めるのには向かない。
- 機密情報。URL はブラウザの履歴、サーバーのアクセスログ、
Refererヘッダ、アクセス解析などに残るため、人に見られて困る情報は一切含めてはならない。 - 大容量データやバイナリ。
LZStringや Base64 の巨大な塊に手が伸びそうになったら、編集中のコピーはlocalStorageに保持し、URL はあくまで共有専用と割り切る方法を検討するのがよい。
状態が「数人分の人物データ」や「コード進行といくつかのつまみ」といった規模に収まるツールであれば、URL こそが必要十分なバックエンドの役割を果たす。