陌上人如玉
公子世无双

3.15 状态持久化与版本迁移

3.15 状态持久化与版本迁移

一句话:持久化的难点不是「怎么存」而是「怎么安全地读回来」——因为「你存下去的代码」与「读回来的代码」之间隔着「用户几个月没打开、版本升了 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」
}

七、面试延伸

  1. 「什么该持久化,什么不该?」
    四类:① ⭐ 用户数据(必须)——登录态、草稿、收藏、偏好,「关了浏览器再打开期望它还在」;② ⭐ UI 状态(选择性)——「侧边栏展开」「上次选的 Tab」「列宽」值得存,但「一次性的」(如「Modal 是否打开」)不该存(用户希望刷新后是关的);③ ⭐ 派生值(不存)——「购物车合计」「筛选结果」能算出来,存了反而要「与源数据保持一致」(⭐ 例外:作为「性能缓存」时可以存,但要能失效);④ ⭐ 敏感信息(极度谨慎)——localStorage 里的东西任何 XSS 都能读(见 8.2),所以「会话 token」优先用 HttpOnly Cookie。

  2. 「localStorage / sessionStorage / IndexedDB / Cookie 怎么选?」
    ⭐ 四个硬约束决定选型:① localStorage 是同步 API → 会阻塞主线程(高频写/大对象时「主线程卡顿」真实可测;首屏读 1MB JSON + parse 也有明显延迟);② localStorage 容量约 5MB 且「按源共享」 → 同一个站的多个应用会互相挤爆;③ ⭐ 隐私模式/禁用存储会「抛异常」(不是静默失败)→ 所有访问都要 try/catch + 降级到内存;④ ⭐ Cookie 随每个请求发送(浪费带宽)→ 只放「服务端需要的小凭证」(4KB 限制)。结论:「小配置/偏好 → localStorage;一次会话的临时状态 → sessionStorage;大量数据/离线缓存/文件 → IndexedDB;会话凭证 → HttpOnly Cookie」。

  3. 「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)。

  4. 「为什么持久化的数据一定会『过期』?」
    因为「存数据的代码」与「读数据的代码」之间隔着时间:① 用户可能几个月不打开,你的版本升了 5 次;② 你改了字段名(theme → appearance);③ 你改了数据结构(数组 → { byId, allIds });④ 数据可能被用户手改、被扩展改、写入到一半(崩溃/配额满)。⭐ 最阴险的是「部分迁移」:数据里「有的字段是新格式、有的是旧格式」→ 看起来能用,但偶发出错(比「直接崩溃」更难查)。所以必须有「版本号 + 迁移链 + 读取时校验 + 失败时降级」这四件套。

  5. 「版本迁移怎么做才安全?」
    五条纪律:① ⭐ 从第一天就带版本号(加它是零成本,不加才贵);② ⭐ 迁移链按「版本跨度」写({ 1: fn, 2: fn, 3: fn },每个函数只关心「上一版 → 这一版」),并在读取时逐版本执行(for (v = from + 1; v <= current; v++));③ ⭐ 迁移后必须校验(用 zod/schema —— 因为「旧数据可能损坏/被手改/迁移有 bug」);④ ⭐ 失败时降级到默认值(而不是崩溃或把错误抛到用户脸上),并上报诊断信息(migratedFrom / reason);⑤ ⭐ 保护「未来版本」——如果「数据版本 > 代码版本」(用户回退版本 / 多标签页版本不一致),只读不写(否则旧代码会把「不认识的字段」扔掉并写回,数据永久损坏)。

  6. 「合并持久化数据和默认值时有什么坑?」
    ⭐ 最常见的坑是「新版本新增的字段被旧数据覆盖」。用 { ...defaults, ...persisted } 时:① 旧数据里没有这个字段 → 保留默认值(✅ 对的);② 但旧数据里有这个字段且值是 undefined → 会覆盖掉默认值(❌ 错,且很难查)。正确做法:「只覆盖『持久化数据里真的存在、且不是 undefined 的键』」。另外两个相关点:① 也别用「深合并(deep merge)」——它会让「用户主动删除的字段」无论如何都恢复(如「用户清空了数组,但默认值是 [] 之外的东西」);② 复杂场景下提供自定义 merge(例如「只把『白名单字段』从持久化数据合并过来」)。

  7. 「隐私模式下持久化会怎样?」
    ⚠️ 会抛异常(不是静默失败):localStorage.setItem 在「Safari 隐私模式(旧版本)」会抛 QuotaExceededError;有些环境「连 window.localStorage 的访问都会抛」。所以:① ⭐ 所有存储访问都要 try/catch;② ⭐ 提供「内存降级」(应用照常能用,只是「刷新后数据丢失」);③ ⭐ 让调用方知道当前是「降级模式」(可以在 UI 上提示「当前无法保存数据」)—— 否则用户以为「存上了」,实际没有。做法示例:先用「写一个探针 key 再删掉」的方式探测(比「靠 UA 判断」可靠),失败就切内存 Map。

  8. 「持久化与「跨标签页同步」怎么配合?」
    ⭐ 两者应该「分开」而不是「耦合」:① 持久化用「明确的 key」(app:cart),负责「关闭浏览器后还在」;② 广播用 BroadcastChannel,负责「其它标签页立刻知道」。不要「把广播挂在 localStorage 写入上」(那是 3.14 里讲的反模式)——因为:① 「写入触发广播」会「写放大」(每次广播都写盘 + 触发 storage 事件);② storage 事件「自己改自己不触发」+「值相同不触发」两个反直觉行为会引入难查的 bug。正确架构:⭐ 「服务端是权威源 → 本地持久化是缓存 → BroadcastChannel 是加速」,三者各司其职。

一句话速记

持久化的难点是「安全地读回来」(存数据的代码与读数据的代码之间隔着时间与版本),所以必须「版本号 + 迁移链 + 读取校验 + 失败降级」四件套;什么该存:用户数据(必须)/ UI 状态(选择性,⚠️ 一次性的不存)/ 派生值(不存)/ 敏感信息(⚠️ localStorage 任何 XSS 都能读 → 会话凭证优先 HttpOnly Cookie);介质选择四条硬约束: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 测,因为「迁移只在老用户身上跑」)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 3.15 状态持久化与版本迁移

评论 抢沙发

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

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

微信扫一扫

支付宝扫一扫