陌上人如玉
公子世无双

11.5 手写 useLocalStorage

11.5 手写 useLocalStorage

一句话:手写 useLocalStorage 要解决四件事——「惰性初始化(只读一次)」「序列化异常兜底」「跨标签页同步(storage 事件)」「SSR 兼容(没有 window)」。

一、需求分析

一个可用的 useLocalStorage 要处理:

① 读写一致(getItem / setItem 都走 JSON 序列化)
② 惰性初始化 —— 只在首次渲染读 localStorage(不要每次渲染都读)
③ 异常兜底 —— JSON.parse 失败、localStorage 满、被禁用(隐私模式)
④ 函数式更新 —— setValue(v => ...)(与 useState 保持一致的手感)
⑤ 跨标签页同步 —— 一个标签页改了,其它标签页要跟着变(storage 事件)
⑥ 跨渲染稳定 —— setValue 引用稳定(可以放进依赖)
⑦ SSR 兼容 —— 服务端没有 window(要判环境)
⑧ 可选 —— 删除(remove)、清空、带过期时间

二、基础实现

import { useCallback, useEffect, useState } from 'react';

export function useLocalStorage<T>(key: string, initialValue: T) {
  // ⭐ 惰性初始化:只在首次渲染读一次
  const [value, setValue] = useState<T>(() => {
    if (typeof window === 'undefined') return initialValue;      // SSR 兼容

    try {
      const raw = window.localStorage.getItem(key);
      return raw === null ? initialValue : (JSON.parse(raw) as T);
    } catch {
      return initialValue;                                        // ⭐ 解析失败 → 用初始值
    }
  });

  // 写入(同时更新 state 与 localStorage)
  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch (err) {
      console.warn(`[useLocalStorage] 写入 ${key} 失败`, err);      // ⚠️ 可能是「空间不足」
    }
  }, [key, value]);

  return [value, setValue] as const;
}
⭐ 基础版的三个要点:
① `useState(() => ...)` 惰性初始化 —— 只在首次渲染执行(避免每次渲染都 JSON.parse)
② 「先读 + 兜底」——JSON.parse 可能抛错(用户手动改过、旧版本数据格式不同)
③ 「值变化就写回」——用 useEffect 同步,而不是「在 setValue 里写」
   (这样即使 value 被「其它路径」改了,也会正确写回)
⚠️ 但基础版有三个问题:
① 没有「跨标签页同步」(另一个标签页改了,这边不知道)
② 没有「函数式更新」支持(`setValue(prev => ...)` 需要自己处理)
③ 「初始值变化」不生效(`initialValue` 只在首次渲染用)

三、完整实现(含跨标签同步)

import { useCallback, useEffect, useRef, useState } from 'react';

/** 序列化器(可以替换成「加密」或「压缩」实现) */
interface Serializer<T> {
  stringify: (value: T) => string;
  parse: (raw: string) => T;
}

const defaultSerializer: Serializer<unknown> = {
  stringify: (v) => JSON.stringify(v),
  parse: (raw) => JSON.parse(raw) as unknown,
};

export interface UseLocalStorageOptions<T> {
  serializer?: Serializer<T>;
  /** 是否监听「其它标签页」的变化(默认 true) */
  syncAcrossTabs?: boolean;
  /** 写入失败的兜底回调 */
  onError?: (error: unknown) => void;
}

export function useLocalStorage<T>(
  key: string,
  initialValue: T | (() => T),
  options: UseLocalStorageOptions<T> = {}
) {
  const { serializer = defaultSerializer as Serializer<T>, syncAcrossTabs = true, onError } = options;

  const initialRef = useRef(initialValue);
  const serializerRef = useRef(serializer);
  useEffect(() => {
    serializerRef.current = serializer;
  });

  /** 读取(带兜底) */
  const read = useCallback((): T => {
    const fallback = typeof initialRef.current === 'function' ? (initialRef.current as () => T)() : initialRef.current;

    if (typeof window === 'undefined') return fallback;           // SSR

    try {
      const raw = window.localStorage.getItem(key);
      return raw === null ? fallback : serializerRef.current.parse(raw);
    } catch (err) {
      onError?.(err);
      return fallback;
    }
  }, [key, onError]);

  const [storedValue, setStoredValue] = useState<T>(read);        // ⭐ 惰性初始化

  /** 写入(更新 state + localStorage) */
  const setValue = useCallback(
    (value: T | ((prev: T) => T)) => {
      try {
        setStoredValue((prev) => {
          const next = typeof value === 'function' ? (value as (p: T) => T)(prev) : value;

          if (typeof window !== 'undefined') {
            window.localStorage.setItem(key, serializerRef.current.stringify(next));
          }
          return next;
        });
      } catch (err) {
        onError?.(err);                                            // ⚠️ 存储满/被禁用
      }
    },
    [key, onError]
  );

  /** 删除该项(并重置为初始值) */
  const remove = useCallback(() => {
    try {
      window.localStorage.removeItem(key);
    } catch (err) {
      onError?.(err);
    }
    setStoredValue(typeof initialRef.current === 'function' ? (initialRef.current as () => T)() : initialRef.current);
  }, [key, onError]);

  // ⭐ 跨标签页同步
  useEffect(() => {
    if (!syncAcrossTabs || typeof window === 'undefined') return;

    const onStorage = (e: StorageEvent) => {
      // ⚠️ e.key 为 null 表示「整个 storage 被清空」(如 clear())
      if (e.key !== null && e.key !== key) return;
      if (e.storageArea !== window.localStorage) return;           // 忽略 sessionStorage

      try {
        if (e.newValue === null) {
          setStoredValue(typeof initialRef.current === 'function' ? (initialRef.current as () => T)() : initialRef.current);
        } else {
          setStoredValue(serializerRef.current.parse(e.newValue));
        }
      } catch (err) {
        onError?.(err);
      }
    };

    window.addEventListener('storage', onStorage);
    return () => window.removeEventListener('storage', onStorage);   // ⭐ 清理
  }, [key, syncAcrossTabs, onError]);

  return [storedValue, setValue, remove] as const;
}
⭐ 完整版的五个关键设计:

① `read()` 用 `useCallback` 封装 + 兜底
   → 同时服务「初始化」与「storage 事件」

② `setValue` 支持函数式更新
   → 与 useState 手感一致;且「在 setState 的 updater 里写 localStorage」
     (这样拿到的 next 一定是最新的 prev)

③ `useRef(initialValue)` 保存初始值
   → 因为「storage 事件」与「remove」都要用它,而它可能是函数

④ `serializerRef` 保存序列化器
   → 允许调用方传「自定义序列化」(如加密),且不会导致依赖变化

⑤ `storage` 事件监听 + 清理
   → ⭐ 跨标签页同步的关键
// ✅ 使用
function useThemePreference() {
  return useLocalStorage<'light' | 'dark' | 'system'>('theme', 'system');
}

function ThemeSwitcher() {
  const [theme, setTheme, resetTheme] = useThemePreference();

  return (
    <>
      <select value={theme} onChange={(e) => setTheme(e.target.value as 'light' | 'dark' | 'system')}>
        <option value="light">浅色</option>
        <option value="dark">深色</option>
        <option value="system">跟随系统</option>
      </select>
      <button onClick={resetTheme}>重置</button>
    </>
  );
}
// ✅ 使用:带函数式更新
function Counter() {
  const [count, setCount] = useLocalStorage('count', 0);

  return (
    <>
      {count}
      <button onClick={() => setCount((c) => c + 1)}>+1</button>     {/* ⭐ 函数式更新 */}
      <button onClick={() => setCount(0)}>重置</button>
    </>
  );
}

四、进阶:带「过期时间」

interface StoredWithExpiry<T> {
  value: T;
  expiry: number | null;         // 时间戳;null 表示「永不过期」
}

export function useLocalStorageWithExpiry<T>(key: string, initialValue: T, ttlMs?: number) {
  const serializer: Serializer<StoredWithExpiry<T>> = {
    stringify: (v) => JSON.stringify(v),
    parse: (raw) => JSON.parse(raw) as StoredWithExpiry<T>,
  };

  const [stored, setStored] = useLocalStorage<StoredWithExpiry<T>>(
    key,
    { value: initialValue, expiry: ttlMs ? Date.now() + ttlMs : null },
    { serializer }
  );

  // 读取时检查过期
  const isExpired = stored.expiry !== null && Date.now() > stored.expiry;
  const value = isExpired ? initialValue : stored.value;

  const setValue = (next: T | ((prev: T) => T)) => {
    setStored((prev) => {
      const v = typeof next === 'function' ? (next as (p: T) => T)(prev.value) : next;
      return { value: v, expiry: ttlMs ? Date.now() + ttlMs : null };
    });
  };

  return [value, setValue] as const;
}

⚠️ 注意:isExpired 只在渲染时判断(如果「页面一直开着」,过期后不会自动刷新)。需要「到期自动失效」时,应该用 setTimeout 定时检查(并在清理函数里清掉)。

五、常见坑

// ① 每次渲染都读 localStorage(性能 + 不一致)
const value = JSON.parse(localStorage.getItem(key) ?? 'null');    // ❌ 渲染中直接读
// ✅ 用 useState 惰性初始化只读一次

// ② 不处理 JSON.parse 异常(用户改过数据 → 白屏)
const value = JSON.parse(raw);                                    // ⚠️ 可能抛错
// ✅ try/catch + 兜底

// ③ 不处理「写入失败」(隐私模式/空间不足 → 抛错)
localStorage.setItem(key, JSON.stringify(value));                 // ⚠️ QuotaExceededError
// ✅ try/catch + 上报/降级(内存里保留 state)

// ④ 在 setValue 里直接用「闭包里的 value」做函数式更新
const setValue = (fn) => setState(fn(value));                      // ⚠️ value 可能是旧快照
// ✅ 用 setState((prev) => ...) 的 updater 形式

// ⑤ 忘记「跨标签同步」(或监听了但没清理监听器)
// ⑥ storage 事件里忘记判断「e.key 为 null」(clear() 的情况)
// ⑦ 忘记判断「e.storageArea」(sessionStorage 也会触发?其实不会,但要严谨)
// ⑧ SSR 时访问 window(报错)→ 必须在「惰性初始化」里判环境
// ⑨ 存「大对象」(超过 5MB 会写失败)
// ⑩ 存「敏感数据」(token/密码)→ localStorage 可被 XSS 读取
//    ✅ 用 HttpOnly Cookie(最重要的安全实践)
// ⑪ 存「函数 / undefined / Symbol」→ JSON 序列化会丢失
JSON.stringify({ fn: () => {} });                                  // → "{}"(函数被丢)
JSON.stringify({ a: undefined });                                  // → "{}"
// ⑫ 用「对象字面量」当 key(每次渲染都变 → 反复读写)
useLocalStorage({ a: 1 }, 0);                                      // ❌ key 必须稳定
// ⑬ 忘记「多标签页同时写」的冲突(最后写入者胜)
// ⑭ 用 localStorage 存「服务端数据的缓存」(应该用 TanStack Query)
// ⑮ 认为「localStorage 是同步的所以很快」→ 它是同步 API,会阻塞主线程
//    (大对象读写会卡;用 IndexedDB 处理大数据,或用 Web Worker)
// ⑯ 在「初始化」时读了一个「很大的值」→ 首屏变慢
// ⑰ 用 localStorage 做「跨标签页通信」的唯一手段(优先 BroadcastChannel)
// ⑱ 忘记「清理」(用户登出时应清掉相关 key)
// ② 的完整兜底写法
function safeParse<T>(raw: string | null, fallback: T): T {
  if (raw === null) return fallback;
  try {
    return JSON.parse(raw) as T;
  } catch {
    return fallback;
  }
}
// ⑰ 的替代方案:BroadcastChannel(更适合「通信」而不是「存储」)
useEffect(() => {
  const channel = new BroadcastChannel('app-sync');
  channel.onmessage = (e) => setData(e.data);
  return () => channel.close();
}, []);

const broadcast = (data: unknown) => {
  new BroadcastChannel('app-sync').postMessage(data);       // ⚠️ 记得 close(或用同一个实例)
};

六、sessionStorage / IndexedDB 的选择

存储 容量 生命周期 同步性 适合
localStorage ~5MB 永久(除非清除) 同步(阻塞) 小配置、偏好
sessionStorage ~5MB 标签页关闭即清 同步 临时表单、单次流程
IndexedDB 很大(几百 MB+) 永久 异步 大对象、离线数据、文件
Cookie ~4KB 可设过期 随请求发送 鉴权 token(HttpOnly)
内存(state/store) 受内存限制 页面生命周期 同步 会话内数据
⭐ 一句话选型:
· 「小配置、需要跨刷新保留」→ localStorage
· 「临时、关掉标签页就不要了」→ sessionStorage
· 「大量数据、离线」→ IndexedDB(用 idb / Dexie 封装)
· 「鉴权」→ **HttpOnly Cookie**(不要放 localStorage)

七、面试延伸

  1. 「手写一个 useLocalStorage」
    四步:① 惰性初始化——用 useState(() => ...),只在首次渲染读 localStorage(不要每次渲染都读);② 兜底——JSON.parse 要 try/catch(用户可能手动改过数据),setItem 也要 try/catch(隐私模式/空间不足会抛错);③ 同步写入——在 setState 的 updater 内部写 localStorage(这样拿到的 next 一定基于最新的 prev),并支持函数式更新(与 useState 手感一致);④ 跨标签页同步——监听 window.addEventListener('storage', handler)(注意判断 e.key、e.newValue === null 表示删除、e.key === null 表示清空),并在清理函数里移除监听。

  2. 「storage 事件什么时候触发?」
    它在「其它标签页(或窗口)修改了同一个 localStorage」时触发——当前标签页自己的修改不会触发(这是设计如此,避免自己通知自己)。要点:① e.key 是「被修改的键名」;② e.key === null 表示「整个 storage 被 clear()」;③ e.newValue === null 表示「该项被删除」;④ 它在「同源的其它标签页/窗口」中触发(同标签页内 iframe 也会触发)。这也是为什么需要「跨标签同步」要用它,而不是靠 useState 自己更新。

  3. 「为什么 setValue 里的 localStorage.setItem 要放在 setState 的 updater 里?」
    为了保证「写入的值」与「state 的值」一致。如果写成「先用闭包里的 value 算,再 setState 再 setItem」,那么在「快速连续调用」或「并发更新」时可能出问题(闭包里的 value 是当次渲染的快照)。把写入放在 updater 内部,可以:① 保证 next 基于最新的 prev 计算(函数式更新正确);② 保证「写入的值」与「最终 state」一致;③ 副作用发生在「状态计算时」(虽然 updater 理论上应该是纯的,但这是实践中广泛采用的折中)。

  4. 「localStorage 有什么限制?」
    五点:① 容量约 5MB(超出会抛 QuotaExceededError);② 同步 API——读写会阻塞主线程(存大对象会明显卡顿);③ 只能存字符串(要 JSON.stringify,而函数、undefined、Symbol 会丢失);④ 同源共享——同一域名下所有页面都能读写(包括第三方脚本/XSS);⑤ 隐私模式下可能不可用(访问会抛错)。所以:小配置用它,大数据用 IndexedDB,敏感数据用 HttpOnly Cookie。

  5. 「为什么不把 token 放 localStorage?」
    因为「任何 JavaScript 都能读它」——一旦页面存在 XSS(哪怕只是第三方依赖里的一个漏洞),攻击者就能直接 localStorage.getItem('token') 拿到凭证并冒用。正确做法是放 HttpOnly Cookie——它无法被 JS 读取(因此 XSS 拿不到),但会「自动随请求发送」(所以要注意 CSRF,需要配 SameSite + CSRF token)。这也是「为什么现代鉴权方案推荐 HttpOnly Cookie(+ 刷新令牌)而不是 localStorage」。

  6. 「localStorage、sessionStorage、IndexedDB、Cookie 怎么选?」
    按「容量 / 生命周期 / 同步性 / 是否随请求发送」选:① localStorage——小配置、偏好、需要跨刷新保留(永久);② sessionStorage——临时数据、关标签页就清(如表单中间态);③ IndexedDB——异步、容量大,适合「大对象、离线数据、文件缓存」;④ Cookie——随请求发送、容量小(~4KB),鉴权 token 应该用它(且 HttpOnly);⑤ 内存(state/store)——会话内的数据(不需要跨刷新)。

  7. 「怎么用 localStorage 做『跨标签页同步』?」
    两种方式:① storage 事件(最标准)——监听 window.addEventListener('storage', handler),在「其它标签页修改」时更新本地 state(注意它不会在修改的标签页自身触发,所以要「同时在本页 setState」);② BroadcastChannel(更适合「通信」)——new BroadcastChannel('name'),postMessage / onmessage,支持「同源的任何上下文」(含 iframe/Worker),语义更清晰(不依赖「存储」这个副作用)。选择:需要「持久化 + 同步」用 storage 事件或「localStorage + BroadcastChannel」;只需要「通知」用 BroadcastChannel。

  8. 「这个 Hook 在 SSR 中要注意什么?」
    三点:① 服务端没有 window——读取时必须判断环境(typeof window === 'undefined' 就用初始值),否则会「报错」;② 首屏渲染必须一致——服务端渲染时用「初始值」,客户端首次渲染也用「初始值」(然后在 useEffect 里再读 localStorage 更新),否则会 hydration mismatch(见 9.4);③ 更好的做法是「用 Cookie + 服务端读取」——这样服务端输出的 HTML 就带正确的值(如主题),既无闪烁也无 mismatch(见 10.7 的主题实现)。

一句话速记

手写 useLocalStorage 的四件核心事情:惰性初始化(useState(() => ...),只读一次,SSR 判 window)、双向兜底(JSON.parse 与 setItem 都要 try/catch,处理「被改过的数据/隐私模式/空间不足」)、支持函数式更新(把写入放在 setState 的 updater 内部,保证与 state 一致)、跨标签页同步(监听 storage 事件,注意 e.key === null(清空)与 e.newValue === null(删除),并在清理里移除监听;注意「本页的修改不触发自己的事件」);进阶可加「remove、自定义 serializer(加密/压缩)、带过期时间」;敏感数据(token)不要放 localStorage(XSS 可读)——用 HttpOnly Cookie;大数据用 IndexedDB(localStorage 是同步 API 会阻塞主线程且仅 ~5MB);SSR 下首屏要用「初始值」避免 mismatch。

赞(0) 打赏
未经允许不得转载:陌上寒 » 11.5 手写 useLocalStorage

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫