アプリの状態を 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 は共有専用にするのを考えたほうがいいです。
状態がまるごと「人物数人」や「コード進行とつまみ4つ」で収まるようなツールなら、URL が必要なバックエンドのすべてです。