LocalStorage應用
LocalStorage 應用與限制
TL;DRkey-value string-only,容量 5-10MB,跨分頁共享但跟瀏覽器綁。適合 使用者偏好 / 快取 / 離線標記,不適合 session token / 個人敏感資料(XSS 可讀)。過期機制要自己實作(LocalStorage 沒有 TTL)。
何時用 LocalStorage
適合
- 使用者偏好 (theme: dark/light、語言、字體大小)
- 非敏感快取 (上次看到的文章列表、表單草稿)
- 功能 flag (是否看過 onboarding tutorial)
- 離線狀態標記 (PWA 同步狀態)
不適合
- Session token / Auth token — XSS 攻擊可竊取(用 httpOnly cookie)
- 大量結構化資料 — 5-10MB 上限,且查詢只能 by key
- 跨網域共享資料 — Same-Origin Policy 限制
- 過期需求 — 沒原生 TTL,要自己加 logic
何時被清除
| 情境 | 是否清除 |
|---|---|
| 重新整理頁面 | ❌ 不會 |
| 關掉分頁 / 瀏覽器 | ❌ 不會 |
| 切換到其他網站 / 網頁 | ❌ 不會 |
| 用戶 清除瀏覽器資料 | ✅ 清掉 |
| 用戶 清除特定網站資料 | ✅ 清掉 |
開發者 localStorage.clear() | ✅ 清掉 |
| 隱身模式關閉視窗 | ✅ 清掉 |
| 空間不足 瀏覽器自動清 | ✅ 可能 清最舊的 |
所以對「永久存在」的依賴是錯的。要當作 best-effort cache,有就快、沒有就重 fetch。
加 TTL 的 wrapper
LocalStorage 本身 沒有 expire 機制,自己包一層:
function setWithExpiry<T>(key: string, value: T, ttl: number) {
const item = {
value,
expiry: Date.now() + ttl,
};
localStorage.setItem(key, JSON.stringify(item));
}
function getWithExpiry<T>(key: string): T | null {
const itemStr = localStorage.getItem(key);
if (!itemStr) return null;
try {
const item = JSON.parse(itemStr);
if (Date.now() > item.expiry) {
localStorage.removeItem(key); // 過期自動清掉
return null;
}
return item.value as T;
} catch {
localStorage.removeItem(key); // JSON 壞了也清掉
return null;
}
}
// 使用
setWithExpiry('userPrefs', { theme: 'dark' }, 7 * 24 * 60 * 60_000); // 7 天
const prefs = getWithExpiry<{ theme: string }>('userPrefs');
過期清除的時機上面的實作是 懶清除(get 時才檢查),簡單但 過期 key 會留在 localStorage 裡占空間。如果在意可以加個 startup cleanup:// app 啟動時清掉所有過期的 function purgeExpired() { for (const key of Object.keys(localStorage)) { try { const item = JSON.parse(localStorage.getItem(key)!); if (item.expiry && Date.now() > item.expiry) { localStorage.removeItem(key); } } catch {/* 不是 wrapper 格式,跳過 */} } }
React Hook 包裝
import { useState, useEffect, useCallback } from 'react';
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
if (typeof window === 'undefined') return initialValue; // SSR safe
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setStoredValue = useCallback((newValue: T | ((prev: T) => T)) => {
setValue((prev) => {
const next = typeof newValue === 'function' ? (newValue as any)(prev) : newValue;
try {
localStorage.setItem(key, JSON.stringify(next));
} catch {/* quota exceeded etc */}
return next;
});
}, [key]);
// 跨分頁同步
useEffect(() => {
const handler = (e: StorageEvent) => {
if (e.key === key && e.newValue) {
try { setValue(JSON.parse(e.newValue)); } catch {/* ignore */}
}
};
window.addEventListener('storage', handler);
return () => window.removeEventListener('storage', handler);
}, [key]);
return [value, setStoredValue] as const;
}
// 使用
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
這 hook 處理 :SSR safe + JSON 序列化 + 跨分頁同步(storage event)+ 函式 update。
或直接用 react-use 的 useLocalStorage,功能類似。
SSR 必踩的雷
// ❌ Next.js 直接這樣寫會炸:server 沒 localStorage
function App() {
const stored = localStorage.getItem('theme'); // ReferenceError!
// ...
}
// ✅ 用 useEffect 包(只在 client 跑)
function App() {
const [theme, setTheme] = useState<string | null>(null);
useEffect(() => {
setTheme(localStorage.getItem('theme'));
}, []);
}
// ✅ 或 typeof window 判斷
function getTheme() {
if (typeof window === 'undefined') return 'light';
return localStorage.getItem('theme') ?? 'light';
}
Hydration mismatch:server 渲染時 theme 是 'light',client 立刻讀到 'dark' → React 警告。解法:加 mounted flag,first paint 用 default:
function ThemeButton() {
const [mounted, setMounted] = useState(false);
const [theme, setTheme] = useLocalStorage('theme', 'light');
useEffect(() => setMounted(true), []);
if (!mounted) return <div className="size-10" />; // skeleton
return <button>{theme === 'dark' ? '🌙' : '☀️'}</button>;
}
LocalStorage vs 其他選項
| 機制 | 容量 | 跨分頁 | 過期 | 跟 server | 適合 |
|---|---|---|---|---|---|
| LocalStorage | 5-10MB | ✅ | 自己加 | 不會 | 使用者偏好、快取 |
| SessionStorage | 5-10MB | ❌ | 關分頁清 | 不會 | 單分頁臨時資料 |
| Cookie | 4KB | ✅ | 可設 | 每次 request 帶 | Auth token、tracking |
| IndexedDB | 上 GB | ✅ | 自己加 | 不會 | 大量結構化資料 |
| Cache API | 大 | ✅ | 自己加 | 不會 | Service Worker / PWA |
| httpOnly Cookie | 4KB | ✅ | 可設 | 自動 | 安全 session token |
安全性鐵則:Auth token 別放 LocalStorageXSS 攻擊一旦得手,可以 localStorage.getItem('token') 直接偷走。Auth token 應該放 httpOnly cookie(JS 讀不到 → XSS 偷不走)。
常見應用場景範例
1. 主題切換
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
useEffect(() => {
document.documentElement.dataset.theme = theme;
}, [theme]);
2. 表單草稿(避免使用者手滑關掉沒存)
const [draft, setDraft] = useLocalStorage('draft-post', { title: '', body: '' });
// 提交後清掉
async function handleSubmit() {
await api.createPost(draft);
localStorage.removeItem('draft-post');
}
3. 看過某 onboarding
const [seen, setSeen] = useLocalStorage('seen-onboarding-v2', false);
if (!seen) {
return <OnboardingTutorial onComplete={() => setSeen(true)} />;
}
-v2 suffix 重要:未來改 onboarding 想再讓老用戶看一次,改成 v3 即可。
4. 避免重 fetch(短期快取)
async function getNewsList() {
const cached = getWithExpiry<NewsItem[]>('news-cache');
if (cached) return cached;
const fresh = await fetch('/api/news').then((r) => r.json());
setWithExpiry('news-cache', fresh, 5 * 60_000); // 5 分鐘
return fresh;
}
但其實這類 caching 2026 年用 TanStack Query 更好,內建快取 + revalidation + stale-while-revalidate,不用自己 wrap。LocalStorage 留給 真正要持久化 的事。
隔離性
每個網站獨立空間LocalStorage scoped to origin(scheme + host + port),https://example.com 跟 http://example.com 是不同的。同 origin 的所有路徑共享(
/page1跟/page2看到同一份)。子網域app.example.com跟example.com是 不同 origin,不共享。
這個隔離模型其實是個很重要的安全特性,讓你不用擔心 example.com/login 存的東西被 evil.com 讀到。