首頁
學習紀錄
遊戲心得影視Life書單案件檔案
Side Projects委託作品與二創互動實驗場
Kurau
百百 BLOG
首頁
學習紀錄
遊戲心得影視Life書單案件檔案
Side Projects委託作品與二創互動實驗場
Kurau

Kurau Blog

「隨心而寫,真真假假,都是我」

一個記錄生活、輸出興趣的個人空間。
遊戲、影視、閱讀、學習……每一段體驗都值得留下文字。

頁面導覽

  • 學習紀錄
  • 遊戲心得
  • 影視Life
  • 書單
  • 委託作品與二創
  • Kurau
  • 合作邀請

找到我

歡迎來 Discord 找我聊天!

“曾經發生的事不可能忘記,只是暫時想不起來而已。”-《神隱少女》

© 2026 Kurau All rights reserved

後端與資料庫

LocalStorage應用

By Kurau·2023-02-09·Updated 2026-05-09·6 分鐘閱讀

LocalStorage 應用與限制

TL;DR
key-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');
typescript
過期清除的時機
上面的實作是 懶清除(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 格式,跳過 */}
  }
}
typescript

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');
typescript

這 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';
}
typescript

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>;
}
tsx

LocalStorage vs 其他選項

機制容量跨分頁過期跟 server適合
LocalStorage5-10MB✅自己加不會使用者偏好、快取
SessionStorage5-10MB❌關分頁清不會單分頁臨時資料
Cookie4KB✅可設每次 request 帶Auth token、tracking
IndexedDB上 GB✅自己加不會大量結構化資料
Cache API大✅自己加不會Service Worker / PWA
httpOnly Cookie4KB✅可設自動安全 session token
安全性鐵則:Auth token 別放 LocalStorage
XSS 攻擊一旦得手,可以 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]);
typescript

2. 表單草稿(避免使用者手滑關掉沒存)

const [draft, setDraft] = useLocalStorage('draft-post', { title: '', body: '' });

// 提交後清掉
async function handleSubmit() {
  await api.createPost(draft);
  localStorage.removeItem('draft-post');
}
typescript

3. 看過某 onboarding

const [seen, setSeen] = useLocalStorage('seen-onboarding-v2', false);

if (!seen) {
  return <OnboardingTutorial onComplete={() => setSeen(true)} />;
}
typescript

-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;
}
typescript

但其實這類 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 讀到。

目錄

    ◆ 相關文章

    • Firebase 驗證 儲存到useContext

      2026-05-09
    • Firebase 前端API 是安全的

      2026-05-09
    • CORS firebase

      2026-05-09
    • application-octet-stream

      2026-05-09
    ← 上一篇Webpack遇到的問題下一篇 →MVC是什麼

    ◆ 關於作者

    Kurau

    個人寫作 / 創作的 SoT,記錄遊戲、影視、學習與生活。

    更多 Kurau 的文章