- 著者
- app-hacks
- 公開日
TypeScript / Web Storage / Browser API
localStorageとsessionStorageの使い分けと安全なラッパークラスの作成
Web Storage APIをSSR環境でも安全に扱い、JSON破損や容量超過を分類するTypeScript実装を解説する。
Web Storage APIは小さなクライアント状態を保存するには便利だが、保存できたこと、読み込んだJSONが正しいこと、ブラウザ環境で実行されていることを自動では保証しない。localStorageとsessionStorageを直接呼ぶコードが各コンポーネントへ散ると、例外処理とデータ移行も散らばる。用途を決め、変換と失敗を一つのラッパーへ閉じ込める。
二つのストレージを用途で分ける
localStorageは同一オリジンでブラウザを閉じた後も残る。テーマ、表示密度、記事一覧の並び順など、次回訪問でも復元したい端末ローカル設定に向く。sessionStorageはタブ単位のページセッションで保持される。入力途中の一時値や、一連の画面遷移だけで必要な状態に向く。
どちらも文字列のkey-value保存であり、大きなキャッシュや検索可能なデータベースではない。認証トークンなど、JavaScriptから参照されると困る秘密情報の保管場所にもならない。XSSが起きれば同じオリジンのスクリプトから読めるためである。
SSRでwindowを直接参照しない
Astroコンポーネントのフロントマターはビルド時またはサーバー側で実行される。そこにはwindowもlocalStorageも存在しない。
---
// ここで localStorage.getItem(...) を呼ばない
const defaultTheme = 'light';
---
<button id="theme-toggle" type="button">テーマを切り替える</button>
<script>
const savedTheme = localStorage.getItem('theme');
document.documentElement.dataset.theme = savedTheme ?? 'light';
</script>
ブラウザ専用処理は<script>内、またはクライアントでのみ実行されるコンポーネントのライフサイクルへ置く。共用TypeScriptモジュールで環境を判定するなら、typeof window === 'undefined'を使う。if (!window)は識別子を評価した時点でReferenceErrorになるため安全ではない。
Result型で失敗を返す
Storageの読み書きは例外を投げ得る。容量超過、利用制限、壊れたJSONを呼び出し側が区別できる形にする。
type StorageError =
| { type: 'unavailable' }
| { type: 'quota-exceeded' }
| { type: 'serialize-failed'; cause: unknown }
| { type: 'parse-failed'; cause: unknown };
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: StorageError };
function isQuotaExceeded(error: unknown): boolean {
return (
error instanceof DOMException &&
(error.name === 'QuotaExceededError' || error.code === 22)
);
}
QuotaExceededErrorの判定をメッセージ文字列に依存させない。例外がDOMExceptionであることを確認し、標準のnameを優先する。code === 22は互換性の補助として扱う。ストレージが無効な環境では別名のDOMExceptionが発生することもあるため、それらをすべて容量超過と決めつけない。
型付きラッパーを実装する
読み込み時のvalidatorを必須にし、JSONをunknownから目的の型へ絞り込む。
type Validator<T> = (value: unknown) => value is T;
export class JsonStorage {
constructor(private readonly storage: Storage | undefined) {}
get<T>(key: string, validate: Validator<T>): Result<T | null> {
if (!this.storage) return { ok: false, error: { type: 'unavailable' } };
const raw = this.storage.getItem(key);
if (raw === null) return { ok: true, value: null };
try {
const value: unknown = JSON.parse(raw);
if (!validate(value)) {
return { ok: false, error: { type: 'parse-failed', cause: 'Invalid shape' } };
}
return { ok: true, value };
} catch (cause: unknown) {
return { ok: false, error: { type: 'parse-failed', cause } };
}
}
set<T>(key: string, value: T): Result<void> {
if (!this.storage) return { ok: false, error: { type: 'unavailable' } };
let serialized: string;
try {
serialized = JSON.stringify(value);
} catch (cause: unknown) {
return { ok: false, error: { type: 'serialize-failed', cause } };
}
try {
this.storage.setItem(key, serialized);
return { ok: true, value: undefined };
} catch (cause: unknown) {
return isQuotaExceeded(cause)
? { ok: false, error: { type: 'quota-exceeded' } }
: { ok: false, error: { type: 'unavailable' } };
}
}
}
JSON.stringify()も常に成功するわけではない。循環参照を含むオブジェクトやBigIntでは例外になり得るため、setItem()とは別のtryブロックで分類する。読み込み時は、JSONとして正しくても期待する構造とは限らない。古いバージョンのデータや手動で変更された値を考慮し、validatorを通す。
ブラウザ側でインスタンスを作る処理は次のようになる。
type Preferences = { theme: 'light' | 'dark'; compact: boolean };
const isPreferences = (value: unknown): value is Preferences => {
if (typeof value !== 'object' || value === null) return false;
const data = value as Record<string, unknown>;
return (
(data.theme === 'light' || data.theme === 'dark') &&
typeof data.compact === 'boolean'
);
};
const storage = new JsonStorage(
typeof window === 'undefined' ? undefined : window.localStorage,
);
const result = storage.get('preferences:v1', isPreferences);
if (result.ok && result.value) {
applyPreferences(result.value);
}
keyにはpreferences:v1のようにバージョンを含めると、構造変更時に旧データを誤読しにくい。移行が必要なら、旧keyを専用validatorで読み、新形式へ変換してから新keyへ保存する。失敗時に既定値へ戻すだけでよいデータと、ユーザーへ通知すべきデータも分ける。
Web Storageは同期APIなので、大量データを頻繁に読み書きするとメインスレッドを占有する。保存対象を小さな設定へ限定し、入力イベントごとの書き込みを避ける。大量データや複雑な検索が必要ならIndexedDBなど別の保存手段を検討する。
安全なラッパーの役割は、Storageを絶対に失敗しないAPIへ見せかけることではない。利用不可、容量超過、変換失敗を明示し、呼び出し側が既定値、通知、削除、再保存のどれを選ぶか判断できる状態にすることである。