
一句话:持久化的难点不是「怎么存」而是「怎么安全地读回来」——因为「你存下去的代码」与「读回来的代码」之间隔着「用户几个月没打开、版本升了 5 次」,所以必须有「版本号 + 迁移链 + 读取时校验 + 失败时降级」这四件套。
一、什么该持久化(四类,只有第一类必须)
⭐⭐⭐ 「要不要持久化」的判断(按四类):
① 【用户数据】(必须持久化)
登录态、草稿、收藏、偏好设置、未提交的表单
⭐ 判据:「**用户关掉浏览器再打开,期望它还在**」
② 【UI 状态】(⭐ 选择性持久化)
「侧边栏展开」「上次选的 Tab」「表格列宽」
✅ 持久化「会提升体验的」
❌ 不要持久化「一次性的」(如「Modal 是否打开」——用户希望刷新后是关的)
③ 【派生值】(⭐ 不要持久化)
「购物车合计」「筛选后的结果」——它们能「算出来」
⚠️ 持久化它们的代价:**要与源数据保持一致**(每次源变都要更新持久化)
⭐ 例外:「缓存」(如「上次的接口结果」)——那是「性能缓存」不是「状态」
④ 【敏感信息】(⚠️ 极度谨慎)
token / 个人信息 / 支付信息
⚠️ `localStorage` 里的东西**任何 XSS 都能读**(见 8.2)
✅ 「会话 token」优先用 **`HttpOnly` Cookie**(JS 读不到)
✅ 必须放前端时:加密 + 「尽量短的生命周期」+ 「刷新即失效」
二、存储介质:五个选项的取舍
| 介质 | 容量 | 同步/异步 | 特点 | 适合 |
|---|---|---|---|---|
localStorage |
~5MB | ⚠️ 同步(阻塞主线程) | 只存字符串、同源共享、隐私模式可能抛异常 | 小配置、偏好设置 |
sessionStorage |
~5MB | ⚠️ 同步 | ⭐ 每个标签页独立(不共享)、关标签页即清 | 「一次会话内的临时状态」 |
| ⭐ IndexedDB | 大(配额制,常几百 MB~GB) | ⭐ 异步(不阻塞) | 可存「结构化克隆」的类型(含 Blob/ArrayBuffer)、支持事务与索引 |
⭐ 大量数据 / 离线缓存 / 文件 |
| Cookie | 4KB | 同步 | ⚠️ 随每个请求发送(浪费带宽)、HttpOnly 时 JS 读不到 |
⭐ 会话凭证(HttpOnly + Secure + SameSite) |
| OPFS / Cache Storage | 大 | 同步(Worker 内) | 文件系统语义 | 大文件、离线资源(见 10.11) |
⭐⭐⭐ 四个「选介质」的硬约束:
① 【`localStorage` 是「同步 API」→ 会阻塞主线程】
⚠️ 在「大对象 + 高频写」时,「主线程卡顿」是真实可测的(见 5.14 的解析成本逻辑)
✅ 「高频写」要么「节流合并」(见 3.14),要么换 IndexedDB
⭐ 而且「读大 JSON + parse」也在主线程(首屏读 1MB 的 JSON 会有明显延迟)
② 【`localStorage` 容量约 5MB,且「按源共享」】
⚠️ 「同一个站的多个应用」共享这 5MB → 容易互相挤爆
✅ 大数据用 IndexedDB
③ 【隐私模式 / 禁用存储 会「抛异常」(不是「静默失败」)】
⚠️ `localStorage.setItem` 在 Safari 隐私模式(旧版本)会抛 `QuotaExceededError`
✅ **所有存储访问都要 `try/catch`**,并「降级到内存模式」
④ 【Cookie 是「随请求发送」的】
⚠️ 放 2KB 的偏好设置 → 每个请求都多 2KB(首屏资源请求也被拖累)
✅ Cookie 只放「服务端需要的小凭证」;其余放 localStorage/IndexedDB
// ✅ 一个「带降级」的存储访问封装
export interface KVStorage {
get(key: string): string | null;
set(key: string, value: string): void;
remove(key: string): void;
/** ⭐ 是否处于「降级(内存)模式」 */
readonly degraded: boolean;
}
export function createStorage(prefer: 'local' | 'session' = 'local'): KVStorage {
const memory = new Map<string, string>();
let backing: Storage | null = null;
try {
const candidate = prefer === 'local' ? window.localStorage : window.sessionStorage;
// ⭐ 探测「真的能写」(隐私模式下「能拿到对象但写入抛错」)
const probeKey = '__probe__';
candidate.setItem(probeKey, '1');
candidate.removeItem(probeKey);
backing = candidate;
} catch {
backing = null; // ⭐ 降级到内存
}
return {
degraded: backing === null,
get: (key) => {
try { return backing ? backing.getItem(key) : (memory.get(key) ?? null); }
catch { return null; }
},
set: (key, value) => {
try {
if (backing) backing.setItem(key, value);
else memory.set(key, value);
} catch (err) {
// ⭐ 配额满 → 降级到内存(⚠️ 但要「让调用方知道」,避免「以为存上了」)
console.warn('[storage] 写入失败,降级到内存', err);
memory.set(key, value);
}
},
remove: (key) => {
try { backing?.removeItem(key); } catch { /* 忽略 */ }
memory.delete(key);
},
};
}
三、序列化:JSON 的六个陷阱
// ⭐⭐⭐ JSON.stringify / parse 会「悄悄改变」你的数据
const original = {
date: new Date('2024-01-01'), // ❌ → 字符串 "2024-01-01T00:00:00.000Z"
map: new Map([['a', 1]]), // ❌ → {}(完全丢失)
set: new Set([1, 2]), // ❌ → {}
undef: undefined, // ❌ → 属性被「删除」
arr: [undefined], // ⚠️ → [null](数组里的 undefined 变成 null)
fn: () => {}, // ❌ → 被删除
bigint: 1n, // ❌ → 抛 TypeError
nan: NaN, // ❌ → null
inf: Infinity, // ❌ → null
regex: /a/g, // ❌ → {}
err: new Error('x'), // ❌ → {}(message 丢了)
};
JSON.parse(JSON.stringify(original));
// ⚠️ 结果:date 变成字符串、map/set/regex/err 变成 {}、undef/fn 消失、
// arr[0] 变成 null —— ⭐ 而且「没有任何报错」
| 方案 | 能处理 | 不能处理 |
|---|---|---|
JSON |
基本类型、普通对象/数组 | ⭐ 上表所有(Date/Map/Set/undefined/BigInt/NaN/Infinity/RegExp/Error/函数/循环引用) |
⭐ structuredClone |
Date/Map/Set/RegExp/ArrayBuffer/Blob/File/循环引用/undefined |
函数、DOM 节点、Proxy、类原型(⚠️ 类实例会变成普通对象)、Error 的部分属性 |
| 自定义编解码(superjson / devalue) | ⭐ 额外支持 Date/Map/Set/undefined/BigInt/RegExp(用「带类型的包装」) | 需要双方用同一套约定 |
flatted / circular-json |
循环引用 | 其它(仍是 JSON 语义) |
// ✅ 方案 A:structuredClone(能处理大多数「深拷贝」需求)
// ⚠️ 但「structuredClone 的结果」不能直接进 localStorage(localStorage 只接受字符串)!
// → structuredClone 适合「内存里的深拷贝」与「IndexedDB(它原生支持结构化克隆)」
// ✅ 方案 B:自定义「带类型包装」的序列化(可以存字符串)
type Tagged = { __t: string; v: unknown };
function replacer(_key: string, value: unknown): unknown {
if (value instanceof Date) return { __t: 'Date', v: value.toISOString() } satisfies Tagged;
if (value instanceof Map) return { __t: 'Map', v: [...value.entries()] } satisfies Tagged;
if (value instanceof Set) return { __t: 'Set', v: [...value.values()] } satisfies Tagged;
if (value instanceof RegExp) return { __t: 'RegExp', v: value.source } satisfies Tagged;
if (typeof value === 'bigint') return { __t: 'BigInt', v: value.toString() } satisfies Tagged;
if (typeof value === 'number') {
if (Number.isNaN(value)) return { __t: 'NaN' } satisfies Tagged;
if (value === Infinity) return { __t: 'Inf' } satisfies Tagged;
if (value === -Infinity) return { __t: '-Inf' } satisfies Tagged;
}
if (value === undefined) return { __t: 'Undefined' } satisfies Tagged;
return value;
}
function reviver(_key: string, value: unknown): unknown {
if (value && typeof value === 'object' && '__t' in value) {
const tagged = value as Tagged;
switch (tagged.__t) {
case 'Date': return new Date(tagged.v as string);
case 'Map': return new Map(tagged.v as Array<[unknown, unknown]>);
case 'Set': return new Set(tagged.v as unknown[]);
case 'RegExp': return new RegExp(tagged.v as string);
case 'BigInt': return BigInt(tagged.v as string);
case 'NaN': return NaN;
case 'Inf': return Infinity;
case '-Inf': return -Infinity;
case 'Undefined': return undefined;
default: return value;
}
}
return value;
}
export const typedJson = {
stringify: (value: unknown) => JSON.stringify(value, replacer),
parse: <T>(text: string) => JSON.parse(text, reviver) as T,
};
// ⚠️ 注意「同名冲突」:如果用户数据里真的有 `{ __t: 'Date', v: ... }` 就会被误解析
// → ⭐ 更稳的做法:用「不可能出现的 key」(如 `__$type`)或「整体包装」(superjson 的做法)
四、版本迁移:持久化的核心难点
⭐⭐⭐⭐ 「为什么持久化的数据一定会过期」:
时间线:
① v1.0 上线,用户的 localStorage 里存了 `{ items: [...], theme: 'light' }`
② v1.2 你把 `theme` 改名叫 `appearance`
③ v2.0 你把 `items` 从「数组」改成「{ byId, allIds }」结构
④ 用户三个月没打开——今天打开了(他的数据是 v1.0 的格式)
⭐ 此时你的代码期望 v2.0 的格式 → **崩溃 / 静默错乱**
⚠️ 更阴险的是「**部分迁移**」:
数据里有的字段是新格式、有的是旧格式 → 「看起来能用,但偶发出错」
⭐ 正确做法四件套:
① 【版本号】每次「数据结构变化」就把版本号 +1
② 【迁移链】为「每一个版本跨度」写一个 `migrate(old) => new` 函数
③ 【读取时校验】迁移后「用 schema 校验」(⭐ 因为旧数据可能损坏/被用户手改)
④ 【失败时降级】校验失败 → **用默认值**(而不是崩溃或抛错到用户脸上)
// ✅ 迁移链的实现(⭐ 关键:逐版本、可组合、有兜底)
export interface Migration<State> {
/** 从 `fromVersion - 1` 迁到 `fromVersion` */
(oldState: unknown): unknown;
}
export interface PersistConfig<State> {
key: string;
/** ⭐ 当前版本号(每次结构变化 +1) */
version: number;
/** ⭐ 迁移函数:`{ [目标版本]: (old) => new }` */
migrations?: Record<number, Migration<State>>;
/** ⭐ 读取后的校验(推荐用 zod) */
validate?: (value: unknown) => value is State;
/** 初始值 */
defaults: State;
/** 只持久化这些字段(⭐ 避免把「临时 UI 状态」也存进去) */
partialize?: (state: State) => Partial<State>;
/** 存储介质 */
storage?: KVStorage;
/** 写入节流(ms) */
throttleMs?: number;
/**
* ⭐ 合并策略:持久化的数据与默认值怎么合
* 默认「浅合并(持久化覆盖默认)」——
* ⚠️ 但这会导致「新版本新增的字段」被「旧数据」覆盖掉!
*/
merge?: (persisted: Partial<State>, defaults: State) => State;
}
interface Envelope {
/** ⭐ 版本号 */
v: number;
/** ⭐ 写入时间(用于调试与「陈旧判断」) */
t: number;
/** 数据 */
d: unknown;
}
export function loadPersisted<State>(config: PersistConfig<State>): {
state: State;
/** ⭐ 诊断信息(用于上报「迁移发生了什么」) */
meta: { migratedFrom: number | null; ok: boolean; reason?: string };
} {
const { key, version, migrations, validate, defaults } = config;
const storage = config.storage ?? createStorage('local');
const raw = storage.get(key);
if (raw === null) {
return { state: defaults, meta: { migratedFrom: null, ok: true, reason: 'empty' } };
}
let envelope: Envelope;
try {
envelope = typedJson.parse<Envelope>(raw);
} catch (err) {
// ⭐ ① 解析失败(数据损坏 / 被手改)→ 用默认值
return { state: defaults, meta: { migratedFrom: null, ok: false, reason: `parse:${String(err)}` } };
}
// ⭐ ② 兼容「没有 envelope 的裸数据」(早期版本可能直接存 state)
const rawVersion = typeof envelope?.v === 'number' ? envelope.v : 0;
let data: unknown = envelope && typeof envelope === 'object' && 'd' in envelope ? envelope.d : envelope;
// ⭐③ 向前兼容:数据比代码「新」(用户回退了版本 / 多标签页版本不一致)
if (rawVersion > version) {
// ⚠️ 不能用「低版本代码」去读「高版本数据」→ 用默认值(并上报)
return {
state: defaults,
meta: { migratedFrom: rawVersion, ok: false, reason: `future-version:${rawVersion}>${version}` },
};
}
// ⭐⭐⭐ ④ 逐版本迁移(⭐ 关键循环)
const migratedFrom = rawVersion < version ? rawVersion : null;
if (rawVersion < version) {
for (let target = rawVersion + 1; target <= version; target++) {
const migrate = migrations?.[target];
if (!migrate) {
// ⚠️ 缺失迁移函数 → 说明「你改了结构但忘了写迁移」
console.warn(`[persist] 缺少 v${target} 的迁移函数,将使用默认值`);
return {
state: defaults,
meta: { migratedFrom: rawVersion, ok: false, reason: `missing-migration:${target}` },
};
}
try {
data = migrate(data);
} catch (err) {
// ⭐ 迁移失败 → 用默认值(不崩溃)
return {
state: defaults,
meta: { migratedFrom: rawVersion, ok: false, reason: `migrate-fail:${target}:${String(err)}` },
};
}
}
}
// ⭐⑤ 校验(⭐ 因为旧数据可能「字段缺失 / 类型变了 / 被手改」)
if (validate && !validate(data)) {
return {
state: defaults,
meta: { migratedFrom, ok: false, reason: 'validate-failed' },
};
}
// ⭐⑥ 合并
const persisted = (data ?? {}) as Partial<State>;
const merged = config.merge
? config.merge(persisted, defaults)
: mergeDefault(persisted, defaults);
return { state: merged, meta: { migratedFrom, ok: true } };
}
/**
* ⭐⭐ 默认合并策略:「只覆盖『持久化数据里真的存在的键』」
* —— 这样「新版本新增的字段」能保留默认值
* (⚠️ 直接 `{...defaults, ...persisted}` 会让「旧数据里的 undefined」覆盖掉默认值)
*/
function mergeDefault<State extends object>(persisted: Partial<State>, defaults: State): State {
const out: Record<string, unknown> = { ...defaults };
for (const key of Object.keys(persisted)) {
const value = (persisted as Record<string, unknown>)[key];
if (value !== undefined) out[key] = value; // ⭐ 忽略 undefined
}
return out as State;
}
/** ============ 写入端:节流 + 部分持久化 + 配额处理 ============ */
export function createPersister<State extends object>(config: PersistConfig<State> & { getState: () => State }) {
const storage = config.storage ?? createStorage('local');
const throttleMs = config.throttleMs ?? 300;
let timer: ReturnType<typeof setTimeout> | null = null;
let lastError: string | null = null;
function writeNow() {
if (timer) { clearTimeout(timer); timer = null; }
const state = config.getState();
const data = config.partialize ? config.partialize(state) : state;
const envelope: Envelope = { v: config.version, t: Date.now(), d: data };
let text: string;
try {
text = typedJson.stringify(envelope);
} catch (err) {
lastError = `serialize:${String(err)}`;
console.warn('[persist] 序列化失败(可能有循环引用/不支持的类型)', err);
return;
}
try {
storage.set(config.key, text);
lastError = null;
} catch (err) {
lastError = `write:${String(err)}`;
console.warn('[persist] 写入失败', err);
}
}
return {
/** ⭐ 每次状态变化调用(内部节流) */
schedule() {
if (timer) return;
timer = setTimeout(writeNow, throttleMs);
},
/** ⭐ 立即写(页面隐藏/卸载时调用) */
flush: writeNow,
getLastError: () => lastError,
};
}
五、完整实现:一个带迁移的持久化 store(250 行)
// persisted-store.ts —— 「store + 持久化 + 迁移」的完整组装
import { useSyncExternalStore } from 'react';
import { z } from 'zod';
/** ============ 1. 定义 schema(⭐ 校验是「读回来时的信任边界」) ============ */
const CartItemSchema = z.object({
id: z.string(),
name: z.string(),
price: z.number(),
qty: z.number().int().positive(),
});
const PersistedCartSchema = z.object({
items: z.array(CartItemSchema),
coupon: z.string().nullable(),
/** ⭐ 注意:`currency` 是新版本加的字段(旧数据没有) */
currency: z.enum(['CNY', 'USD']).default('CNY'),
});
type PersistedCart = z.infer<typeof PersistedCartSchema>;
/** ============ 2. 迁移链(⭐ 每个版本一个函数,只关心「从上一版到这一版」) ============ */
const VERSION = 3;
const migrations: Record<number, (old: unknown) => unknown> = {
// v0 → v1:早期把购物车存成「对象 { id: qty }」
1: (old) => {
const o = old as { cart?: Record<string, number> } | null;
const cart = o?.cart ?? {};
return {
items: Object.entries(cart).map(([id, qty]) => ({ id, name: id, price: 0, qty })),
coupon: null,
};
},
// v1 → v2:`coupon` 从「string」改成「string | null」
2: (old) => {
const o = old as { coupon?: string | null };
return { ...o, coupon: o.coupon ?? null };
},
// v2 → v3:加了「货币」字段(⚠️ 也可以靠 schema 的 default 兜底,
// 但「显式写迁移」能表达「业务含义」——如「老用户的默认币种是 CNY」)
3: (old) => {
const o = old as Record<string, unknown>;
return { ...o, currency: 'CNY' };
},
};
/** ============ 3. 创建 store(复用 11.13 的 mini-zustand 思路,但这里手写精简版) ============ */
export function createPersistedStore() {
// ⭐ ① 读取 + 迁移 + 校验(⭐ 全部在这一步完成)
const loaded = loadPersisted<PersistedCart>({
key: 'cart',
version: VERSION,
migrations,
defaults: { items: [], coupon: null, currency: 'CNY' },
validate: (v): v is PersistedCart => PersistedCartSchema.safeParse(v).success,
// ⭐ 只持久化「数据」(不持久化「运行时状态」)
partialize: (s) => ({ items: s.items, coupon: s.coupon, currency: s.currency }),
});
// ⭐ ② 上报「迁移发生了什么」(⭐ 生产环境里这是很有价值的诊断)
if (loaded.meta.migratedFrom !== null) {
console.info(`[cart] 已从 v${loaded.meta.migratedFrom} 迁移到 v${VERSION}`);
}
if (!loaded.meta.ok) {
console.warn(`[cart] 持久化数据不可用(${loaded.meta.reason}),已重置为默认值`);
// ⭐ 生产里可以在这里上报:reportEvent('persist.reset', { reason: loaded.meta.reason })
}
let state = {
...loaded.state,
/** 运行时状态(不持久化) */
loading: false,
/** 最近一次的用户操作(用于「乐观 UI」) */
lastAction: null as string | null,
};
type State = typeof state;
const listeners = new Set<() => void>();
let version = 0;
const persister = createPersister<State>({
key: 'cart',
version: VERSION,
getState: () => state,
partialize: (s) => ({ items: s.items, coupon: s.coupon, currency: s.currency }),
throttleMs: 300,
});
const setState = (patch: Partial<State>, options?: { persist?: boolean }) => {
const next = { ...state, ...patch };
if (Object.is(next, state)) return;
state = next;
version++;
listeners.forEach((l) => l());
// ⭐ 只有「数据变化」才触发持久化(默认)
if (options?.persist !== false) persister.schedule();
};
// ⭐ 页面隐藏/卸载时「立刻写盘」(避免丢掉节流窗口里的最后一次修改)
const flushOnHide = () => {
if (document.visibilityState === 'hidden') persister.flush();
};
document.addEventListener('visibilitychange', flushOnHide);
window.addEventListener('pagehide', () => persister.flush());
return {
getState: () => state,
setState,
subscribe: (fn: () => void) => { listeners.add(fn); return () => { listeners.delete(fn); }; },
__version: () => version,
/** 业务操作 */
addItem(item: Omit<PersistedCart['items'][number], 'qty'>) {
const existing = state.items.find((i) => i.id === item.id);
setState({
items: existing
? state.items.map((i) => (i.id === item.id ? { ...i, qty: i.qty + 1 } : i))
: [...state.items, { ...item, qty: 1 }],
lastAction: `add:${item.id}`,
});
},
/** ⭐ 手动重置(用户点「清除本地数据」时) */
reset() {
setState({ items: [], coupon: null, currency: 'CNY', lastAction: null });
persister.flush();
},
/** ⭐ 导出/导入(用户手动备份) */
export: () => typedJson.stringify({ v: VERSION, t: Date.now(), d: {
items: state.items, coupon: state.coupon, currency: state.currency,
} satisfies Envelope }),
};
}
export const cartStore = createPersistedStore();
/** ============ 4. React 集成 ============ */
export function useCart<U>(selector: (s: ReturnType<typeof cartStore.getState>) => U): U {
const cacheRef = { current: { has: false, value: undefined as unknown as U, v: -1 } };
return useSyncExternalStore(
cartStore.subscribe,
() => {
const s = cartStore.getState();
const v = cartStore.__version();
if (cacheRef.current.has && cacheRef.current.v === v) return cacheRef.current.value;
const next = selector(s);
cacheRef.current = { has: true, value: next, v };
return next;
},
() => selector(cartStore.getState())
);
}
⚠️ 上面
useCart里的cacheRef = { current: ... }是示意——真实实现必须用useRef(否则每次渲染都新建缓存 → 失去意义)。这里为了缩短篇幅做了简写,落地时请改用useRef。
/** ============ 5. 使用示例 ============ */
export function CartPage() {
const items = useCart((s) => s.items);
const currency = useCart((s) => s.currency);
const coupon = useCart((s) => s.coupon);
return (
<div>
<p>币种:{currency}(⭐ 老用户迁移后默认 CNY)</p>
<ul>
{items.map((i) => (
<li key={i.id}>{i.name} × {i.qty} —— {i.price * i.qty} {currency}</li>
))}
</ul>
<button onClick={() => cartStore.addItem({ id: 'p1', name: '商品', price: 99 })}>加入</button>
<button onClick={() => cartStore.reset()}>清除本地数据</button>
</div>
);
}
/** ============ 6. 迁移的单元测试(⭐ 这是最该被测试的地方) ============ */
// persisted-store.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { loadPersisted } from './persisted-store';
describe('购物车持久化迁移', () => {
beforeEach(() => localStorage.clear());
it('v0(对象格式)能迁到 v3', () => {
localStorage.setItem('cart', JSON.stringify({ v: 0, t: Date.now(), d: { cart: { p1: 2 } } }));
const { state, meta } = loadPersisted({
key: 'cart', version: 3, migrations,
defaults: { items: [], coupon: null, currency: 'CNY' },
validate: (v): v is any => true,
});
expect(meta.migratedFrom).toBe(0);
expect(meta.ok).toBe(true);
expect(state.items).toEqual([{ id: 'p1', name: 'p1', price: 0, qty: 2 }]);
expect(state.currency).toBe('CNY'); // ⭐ v3 的迁移生效
});
it('数据损坏时「降级到默认值」而不是崩溃', () => {
localStorage.setItem('cart', '{ 这不是 JSON');
const { state, meta } = loadPersisted({ key: 'cart', version: 3, migrations, defaults: { items: [], coupon: null, currency: 'CNY' } });
expect(meta.ok).toBe(false);
expect(state.items).toEqual([]);
});
it('「未来版本」的数据不会被低版本代码读坏', () => {
localStorage.setItem('cart', JSON.stringify({ v: 99, t: Date.now(), d: { items: [{ id: 'x' }] } }));
const { state, meta } = loadPersisted({ key: 'cart', version: 3, migrations, defaults: { items: [], coupon: null, currency: 'CNY' } });
expect(meta.ok).toBe(false);
expect(meta.reason).toContain('future-version');
expect(state.items).toEqual([]);
});
it('校验失败(字段缺失/类型错)→ 用默认值', () => {
localStorage.setItem('cart', JSON.stringify({ v: 3, t: Date.now(), d: { items: [{ id: 123, name: null }] } }));
const { state, meta } = loadPersisted({
key: 'cart', version: 3, migrations,
defaults: { items: [], coupon: null, currency: 'CNY' },
validate: (v): v is any => PersistedCartSchema.safeParse(v).success,
});
expect(meta.reason).toBe('validate-failed');
expect(state.items).toEqual([]);
});
});
⭐⭐⭐ 这套实现的六个「关键设计」(这是在真实项目里打磨出来的):
① 【版本号 + 迁移链 + 校验 + 降级(四件套)】
⭐ 缺一个都会出线上问题:
· 没版本号 → 「改了结构就崩」
· 没迁移链 → 「老用户数据丢失」
· 没校验 → 「被手改的数据导致诡异 bug」
· 没降级 → 「一个坏数据让整个应用白屏」
② 【「未来版本」的保护】⭐
用户在「旧版本」的标签页里打开(或版本回退)→ 旧代码读到「新版本数据」
⚠️ 后果:旧代码「不认识新字段」,可能把它们扔掉并写回 → **数据永久损坏**
✅ 检测到「数据版本 > 代码版本」就「用默认值、不写回」(本实现的 `future-version` 分支)
③ 【「只覆盖持久化数据里存在的键」的合并策略】⭐
`{...defaults, ...persisted}` 的问题:
· 旧数据里「没有的字段」→ 保留默认值(✅ 对的)
· 但旧数据里「值是 undefined 的字段」→ **会覆盖掉默认值**(❌ 错)
✅ 本实现跳过 `undefined`(见 `mergeDefault`)
④ 【`partialize`(只持久化数据)】
不把「loading / lastAction / 打开的面板」写进去
⭐ 好处:持久化内容更小更稳定;也避免「刷新后 UI 状态莫名其妙」
⑤ 【节流写 + 页面隐藏时 flush】
⭐ 「每次变化都写」会阻塞主线程(localStorage 是同步 API);
「只在节流后写」又可能丢最后一次(用户直接关掉标签页)→
✅ 「节流 + `visibilitychange`/`pagehide` 时 flush」是正解。
⑥ 【把「迁移」当「可测试的模块」】
⭐ **迁移函数是纯函数** → 可以「用『老数据的 fixture』做单元测试」
(本实现里给了 4 个用例:v0 迁移、损坏数据、未来版本、校验失败)
⚠️ 真实经验:「**迁移代码的测试覆盖率**」往往比「业务代码」更重要——
因为「迁移只在老用户身上跑」,你本地永远测不到。
六、本篇特有的坑
// ① 直接 `{...defaults, ...persisted}` 合并(旧数据里的 undefined 覆盖默认值)
const merged = { ...defaults, ...persisted }; // ⚠️ 新增字段可能被覆盖
// ✅ 跳过 undefined(见 mergeDefault)
// ② 存「整个 state」(把 loading/isOpen 这类临时状态也存了)
localStorage.setItem('s', JSON.stringify(state)); // ⚠️
// ✅ partialize 只存数据
// ③ `JSON.stringify` 悄悄吃掉 Date/Map/Set/undefined/BigInt
// ⭐ 症状:「存的时候是 Date,读出来是字符串」→ 后续 `.getTime()` 报错
// ✅ 用「带类型包装」的序列化(typedJson)或 IndexedDB(原生结构化克隆)
// ④ 没写「版本号」(改了结构后老用户直接崩)
// ✅ 从第一天就带版本号(⭐ 加版本号是「零成本」的,不加才贵)
// ⑤ 有版本号但「没有迁移函数」(数据被丢弃)
// ✅ 每个版本跨度都要有 migrate(⭐ 缺失时本实现会「明确警告 + 用默认值」)
// ⑥ 「未来版本」的数据被低版本代码写坏
// ✅ 检测 `rawVersion > version` → 只读不写(用默认值)
// ⑦ 持久化数据「不校验」(用户手改 / 部分写入 / 跨版本残留)
const data = JSON.parse(raw); // ⚠️ 直接信任
// ✅ zod/schema 校验(⭐ 校验失败就重置)
// ⑧ 「解析失败」时直接抛错(整个应用白屏)
const data = JSON.parse(raw); // ❌ 抛错往上冒
// ✅ try/catch + 用默认值 + 上报
// ⑨ 在「隐私模式」下没做降级(setItem 抛异常 → 应用崩溃)
// ✅ try/catch + 内存模式(⭐ 并「让调用方知道当前是降级模式」)
// ⑩ 配额满(QuotaExceededError)
// ⚠️ 常见于「缓存图片/大列表到 localStorage」
// ✅ 大数据用 IndexedDB;并监听错误「清掉最旧的缓存再重试」
// ⑪ 「同步写 localStorage」在「高频/大对象」时阻塞主线程
// ✅ 节流合并 + IndexedDB(异步);⭐ 用 Performance 面板验证「写入耗时」
// ⑫ 「SSR 下访问 localStorage」(服务端没有 → 报错)
// ✅ 判断 `typeof window !== 'undefined'`;或「首屏用默认值、hydrate 后再读」
// ⑬ 「水合不一致」(SSR 用默认值、客户端用持久化值 → React 警告)
// ⭐ 症状:Hydration mismatch
// ✅ 「首屏(hydrate 前)用默认值」,然后在 `useEffect` 里「读盘并更新」
// (⭐ 这也是「为什么主题要在 `<head>` 里内联脚本处理」,见 10.8)
// ⑭ 存了「不可信/超大的用户输入」(XSS 的二次利用面)
// ⚠️ 从 localStorage 读出的 HTML **不能直接 dangerouslySetInnerHTML**
// ✅ 视作「不可信输入」,做转义/消毒
// ⑮ 把「token」明文放 localStorage 且不做其它防护
// ⚠️ 任何 XSS 都能读走(见 8.2)
// ✅ 「会话凭证」优先 HttpOnly Cookie + Short TTL
// ⑯ 「多个 store 各自持久化」但「key 撞名」
// ⚠️ 后果:互相覆盖
// ✅ 用命名空间(`app:cart`、`app:user`)
// ⑰ 「持久化」与「跨标签页同步」耦合(一个 key 既存又广播)
// ⚠️ 见 3.14:会「写放大 + 回声」
// ✅ 分开:「持久化用明确的 key」「广播用 BroadcastChannel」
// ⑱ 忘记「用户手动清除本地数据」的入口(出问题时无法自救)
// ✅ 提供「重置本地数据」按钮(⭐ 并配「导出/导入」作为兜底)
// ⑲ 「迁移」在「每个用户每次打开」都跑一遍(浪费)
// ⚠️ 如果迁移很贵(如遍历大数组)
// ✅ 迁移后「立即写回新版本」(这样下次就不用再迁)
// ⭐ 本实现没做这一步(`loadPersisted` 只读)→ 落地时应「在启动时 flush 一次」
// ⑳ 「测试」只测业务逻辑,不测迁移(⭐ 真实事故的温床)
// ✅ 用「老版本的 fixture JSON」测迁移;并把「迁移覆盖率」纳入 review
// ⑲ 的完整处理(迁移后立即写回)
function bootstrap() {
const loaded = loadPersisted<PersistedCart>({ ...config });
// ⭐ 如果发生了迁移,立刻「写回新版本」(避免「每次打开都迁一遍」)
if (loaded.meta.migratedFrom !== null && loaded.meta.ok) {
try {
localStorage.setItem(config.key, typedJson.stringify({
v: VERSION, t: Date.now(), d: loaded.state, // ⭐ 直接写「迁移后的完整数据」
} satisfies Envelope));
} catch (err) {
console.warn('[persist] 迁移后写回失败(下次打开会再迁一次)', err);
}
}
}
// ⑪ 的完整处理(测量「持久化写入」的耗时)
function measurePersistCost(state: unknown) {
const t0 = performance.now();
const text = JSON.stringify(state);
const t1 = performance.now();
localStorage.setItem('probe', text);
const t2 = performance.now();
localStorage.removeItem('probe');
const t3 = performance.now();
const sizeKB = (text.length / 1024).toFixed(1);
console.log(`序列化 ${(t1 - t0).toFixed(1)}ms | 写入 ${(t2 - t1).toFixed(1)}ms | 大小 ${sizeKB}KB`);
// ⭐ 判据:单次写入 > 5ms 或 大小 > 100KB → 应该「节流/换 IndexedDB/拆 key」
}
七、面试延伸
- 「什么该持久化,什么不该?」
四类:① ⭐ 用户数据(必须)——登录态、草稿、收藏、偏好,「关了浏览器再打开期望它还在」;② ⭐ UI 状态(选择性)——「侧边栏展开」「上次选的 Tab」「列宽」值得存,但「一次性的」(如「Modal 是否打开」)不该存(用户希望刷新后是关的);③ ⭐ 派生值(不存)——「购物车合计」「筛选结果」能算出来,存了反而要「与源数据保持一致」(⭐ 例外:作为「性能缓存」时可以存,但要能失效);④ ⭐ 敏感信息(极度谨慎)——localStorage里的东西任何 XSS 都能读(见 8.2),所以「会话 token」优先用HttpOnlyCookie。 -
「
localStorage/sessionStorage/IndexedDB/ Cookie 怎么选?」
⭐ 四个硬约束决定选型:①localStorage是同步 API → 会阻塞主线程(高频写/大对象时「主线程卡顿」真实可测;首屏读 1MB JSON + parse 也有明显延迟);②localStorage容量约 5MB 且「按源共享」 → 同一个站的多个应用会互相挤爆;③ ⭐ 隐私模式/禁用存储会「抛异常」(不是静默失败)→ 所有访问都要try/catch+ 降级到内存;④ ⭐ Cookie 随每个请求发送(浪费带宽)→ 只放「服务端需要的小凭证」(4KB 限制)。结论:「小配置/偏好 →localStorage;一次会话的临时状态 →sessionStorage;大量数据/离线缓存/文件 → IndexedDB;会话凭证 →HttpOnlyCookie」。 -
「
JSON.stringify序列化状态有什么坑?」
⭐ 它会悄悄改变数据且不报错:Date→ 字符串(读回来不再是 Date,.getTime()会报错);Map/Set/RegExp/Error→{}(内容全丢);undefined→ 对象属性被删除、数组元素变成null;函数/Symbol → 删除;BigInt→ 抛TypeError;NaN/Infinity→null;循环引用 → 抛错。解法:① 只存「能安全 JSON 化的纯数据」(最省心的做法);② 用structuredClone(能处理 Date/Map/Set/循环引用,但结果不能直接进 localStorage——它只接受字符串;⭐ IndexedDB 原生支持结构化克隆);③ 用「带类型包装的序列化」({ __t: 'Date', v: '...' },注意「key 撞名」问题);④ 用现成库(superjson / devalue)。 -
「为什么持久化的数据一定会『过期』?」
因为「存数据的代码」与「读数据的代码」之间隔着时间:① 用户可能几个月不打开,你的版本升了 5 次;② 你改了字段名(theme→appearance);③ 你改了数据结构(数组 →{ byId, allIds });④ 数据可能被用户手改、被扩展改、写入到一半(崩溃/配额满)。⭐ 最阴险的是「部分迁移」:数据里「有的字段是新格式、有的是旧格式」→ 看起来能用,但偶发出错(比「直接崩溃」更难查)。所以必须有「版本号 + 迁移链 + 读取时校验 + 失败时降级」这四件套。 -
「版本迁移怎么做才安全?」
五条纪律:① ⭐ 从第一天就带版本号(加它是零成本,不加才贵);② ⭐ 迁移链按「版本跨度」写({ 1: fn, 2: fn, 3: fn },每个函数只关心「上一版 → 这一版」),并在读取时逐版本执行(for (v = from + 1; v <= current; v++));③ ⭐ 迁移后必须校验(用 zod/schema —— 因为「旧数据可能损坏/被手改/迁移有 bug」);④ ⭐ 失败时降级到默认值(而不是崩溃或把错误抛到用户脸上),并上报诊断信息(migratedFrom/reason);⑤ ⭐ 保护「未来版本」——如果「数据版本 > 代码版本」(用户回退版本 / 多标签页版本不一致),只读不写(否则旧代码会把「不认识的字段」扔掉并写回,数据永久损坏)。 -
「合并持久化数据和默认值时有什么坑?」
⭐ 最常见的坑是「新版本新增的字段被旧数据覆盖」。用{ ...defaults, ...persisted }时:① 旧数据里没有这个字段 → 保留默认值(✅ 对的);② 但旧数据里有这个字段且值是undefined→ 会覆盖掉默认值(❌ 错,且很难查)。正确做法:「只覆盖『持久化数据里真的存在、且不是 undefined 的键』」。另外两个相关点:① 也别用「深合并(deep merge)」——它会让「用户主动删除的字段」无论如何都恢复(如「用户清空了数组,但默认值是[]之外的东西」);② 复杂场景下提供自定义merge(例如「只把『白名单字段』从持久化数据合并过来」)。 -
「隐私模式下持久化会怎样?」
⚠️ 会抛异常(不是静默失败):localStorage.setItem在「Safari 隐私模式(旧版本)」会抛QuotaExceededError;有些环境「连window.localStorage的访问都会抛」。所以:① ⭐ 所有存储访问都要try/catch;② ⭐ 提供「内存降级」(应用照常能用,只是「刷新后数据丢失」);③ ⭐ 让调用方知道当前是「降级模式」(可以在 UI 上提示「当前无法保存数据」)—— 否则用户以为「存上了」,实际没有。做法示例:先用「写一个探针 key 再删掉」的方式探测(比「靠 UA 判断」可靠),失败就切内存Map。 -
「持久化与「跨标签页同步」怎么配合?」
⭐ 两者应该「分开」而不是「耦合」:① 持久化用「明确的 key」(app:cart),负责「关闭浏览器后还在」;② 广播用BroadcastChannel,负责「其它标签页立刻知道」。不要「把广播挂在 localStorage 写入上」(那是 3.14 里讲的反模式)——因为:① 「写入触发广播」会「写放大」(每次广播都写盘 + 触发storage事件);②storage事件「自己改自己不触发」+「值相同不触发」两个反直觉行为会引入难查的 bug。正确架构:⭐ 「服务端是权威源 → 本地持久化是缓存 →BroadcastChannel是加速」,三者各司其职。
一句话速记
持久化的难点是「安全地读回来」(存数据的代码与读数据的代码之间隔着时间与版本),所以必须「版本号 + 迁移链 + 读取校验 + 失败降级」四件套;什么该存:用户数据(必须)/ UI 状态(选择性,⚠️ 一次性的不存)/ 派生值(不存)/ 敏感信息(⚠️ localStorage 任何 XSS 都能读 → 会话凭证优先
HttpOnlyCookie);介质选择四条硬约束:localStorage 是同步 API(阻塞主线程)+ ~5MB 且按源共享 + 隐私模式会抛异常(必须 try/catch + 内存降级)+ Cookie 随请求发送(所以「大数据用 IndexedDB、会话凭证用 HttpOnly Cookie」);⭐JSON的六个陷阱(Date→字符串、Map/Set/RegExp/Error→{}、undefined→属性被删/数组变 null、BigInt 抛错、NaN/Infinity→null、循环引用抛错且都不报错)→ 用纯数据 /structuredClone(⭐ 不能直接进 localStorage)/ 带类型包装的序列化;⭐ 迁移五条纪律:第一天就带版本号、按版本跨度写迁移链并逐版本执行、迁移后必须校验、失败降级到默认值 + 上报诊断、⭐ 保护「未来版本」(数据版本 > 代码版本时只读不写,否则旧代码会把新字段扔掉并永久损坏数据);⭐ 合并的坑是「新增字段被旧数据覆盖」({...defaults, ...persisted}会让undefined覆盖默认值 → 应「只覆盖存在且非 undefined 的键」,也别用深合并);写盘要「节流 +visibilitychange/pagehide时 flush」;持久化与跨标签页同步要分开(服务端是权威源 → 本地持久化是缓存 → BroadcastChannel 是加速);⭐ 「迁移代码」比业务代码更该被测试(用老版本 fixture 测,因为「迁移只在老用户身上跑」)。



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