
一句话:手写
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)
七、面试延伸
- 「手写一个
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表示清空),并在清理函数里移除监听。 -
「
storage事件什么时候触发?」
它在「其它标签页(或窗口)修改了同一个localStorage」时触发——当前标签页自己的修改不会触发(这是设计如此,避免自己通知自己)。要点:①e.key是「被修改的键名」;②e.key === null表示「整个 storage 被clear()」;③e.newValue === null表示「该项被删除」;④ 它在「同源的其它标签页/窗口」中触发(同标签页内 iframe 也会触发)。这也是为什么需要「跨标签同步」要用它,而不是靠useState自己更新。 -
「为什么
setValue里的localStorage.setItem要放在setState的 updater 里?」
为了保证「写入的值」与「state 的值」一致。如果写成「先用闭包里的value算,再setState再setItem」,那么在「快速连续调用」或「并发更新」时可能出问题(闭包里的value是当次渲染的快照)。把写入放在 updater 内部,可以:① 保证next基于最新的prev计算(函数式更新正确);② 保证「写入的值」与「最终 state」一致;③ 副作用发生在「状态计算时」(虽然 updater 理论上应该是纯的,但这是实践中广泛采用的折中)。 -
「
localStorage有什么限制?」
五点:① 容量约 5MB(超出会抛QuotaExceededError);② 同步 API——读写会阻塞主线程(存大对象会明显卡顿);③ 只能存字符串(要JSON.stringify,而函数、undefined、Symbol会丢失);④ 同源共享——同一域名下所有页面都能读写(包括第三方脚本/XSS);⑤ 隐私模式下可能不可用(访问会抛错)。所以:小配置用它,大数据用 IndexedDB,敏感数据用 HttpOnly Cookie。 -
「为什么不把 token 放
localStorage?」
因为「任何 JavaScript 都能读它」——一旦页面存在 XSS(哪怕只是第三方依赖里的一个漏洞),攻击者就能直接localStorage.getItem('token')拿到凭证并冒用。正确做法是放HttpOnlyCookie——它无法被 JS 读取(因此 XSS 拿不到),但会「自动随请求发送」(所以要注意 CSRF,需要配SameSite+ CSRF token)。这也是「为什么现代鉴权方案推荐 HttpOnly Cookie(+ 刷新令牌)而不是 localStorage」。 -
「
localStorage、sessionStorage、IndexedDB、Cookie 怎么选?」
按「容量 / 生命周期 / 同步性 / 是否随请求发送」选:①localStorage——小配置、偏好、需要跨刷新保留(永久);②sessionStorage——临时数据、关标签页就清(如表单中间态);③IndexedDB——异步、容量大,适合「大对象、离线数据、文件缓存」;④ Cookie——随请求发送、容量小(~4KB),鉴权 token 应该用它(且 HttpOnly);⑤ 内存(state/store)——会话内的数据(不需要跨刷新)。 -
「怎么用 localStorage 做『跨标签页同步』?」
两种方式:①storage事件(最标准)——监听window.addEventListener('storage', handler),在「其它标签页修改」时更新本地 state(注意它不会在修改的标签页自身触发,所以要「同时在本页 setState」);②BroadcastChannel(更适合「通信」)——new BroadcastChannel('name'),postMessage/onmessage,支持「同源的任何上下文」(含 iframe/Worker),语义更清晰(不依赖「存储」这个副作用)。选择:需要「持久化 + 同步」用storage事件或「localStorage + BroadcastChannel」;只需要「通知」用BroadcastChannel。 -
「这个 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。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!