一开始只有一个 loading 开关放进全局 store。半年后,筛选条件、分页、弹窗态、用户信息、草稿内容全在里面,改一个筛选逻辑要动三个文件,某处忘了重置,另一个页面就带着脏值进来。问题不是"状态库选错了",而是"什么状态被允许进全局"从来没被定义过。状态放错层的代价不会当场出现,它表现为后续每次改动的半径变大。
1. 六类状态与各自的归属
| 状态类别 | 例子 | 生命周期 | 该待的地方 |
|---|---|---|---|
| 服务端数据 | 列表、详情、统计 | 由服务端决定,可重新获取 | 数据获取库的缓存 |
| 表单临时态 | 输入值、校验错误、脏标记 | 与表单同生共死 | 组件内部 / 表单库 |
| UI 开关 | 展开、选中 tab、侧栏 | 与组件或视图同生共死 | 就近的组件状态 |
| 跨页共享 | 当前项目、筛选上下文 | 跨路由存活 | 作用域 store 或 URL |
| 会话身份 | token、权限、用户信息 | 整个会话 | 认证模块 + 内存为主 |
| URL 状态 | 页码、排序、搜索词 | 一次导航 | 路由参数 / query |
判据一句话:谁产生它、谁需要它、它活多久。三个答案都在组件里,就别往上抬;答案跨路由,才考虑 store;答案是"浏览器地址栏本该记住",就进 URL。
2. 服务端数据不是全局状态
判断依据三条同时成立,它就不属于全局状态:
- 有来源:数据由某个接口产生,前端只是副本;
- 有失效:会过期、会被别人改,需要重新拉取;
- 可重新获取:丢了不致命,重新请求即可。
三者都成立时,把它塞进全局 store 等于自己实现一个残缺的缓存:没有去重、没有重试、没有失效、没有并发合并。
// 反例:手写"全局缓存",本质是复制了服务端状态却不管失效
const useStore = create(() => ({ orders: [], loading: false, error: null }));
// 正例:把缓存职责交给数据获取库,组件只声明需求
const { data, isPending } = useQuery({
queryKey: ['orders', { page, status }],
queryFn: () => api.orders({ page, status }),
staleTime: 30_000,
});
代价对比:手写 store 的初始成本低,但每加一个接口就多一套 loading/error/刷新逻辑;数据获取库的初始成本是学习 key 设计与失效规则,之后每次接入是声明式的。
3. URL 承载状态
// 状态进 URL:可分享、可回退、刷新不丢
const [params, setParams] = useSearchParams();
const page = Number(params.get('page') ?? 1);
setParams({ page: '2', sort: 'created_desc' });
| 收益 | 代价 |
|---|---|
| 可分享,链接即视图 | 长度受限,复杂筛选会撑爆 |
| 前进/后退天然可用 | 敏感信息会进历史记录与服务端日志 |
| 刷新、直接打开都正确 | 每次改动触发一次导航,需要防抖 |
| 便于埋点与复现问题 | 需要序列化/反序列化,类型易失真 |
判据:这个状态"值得被链接分享"吗。值得 → URL;只是当前页面的临时选择 → 组件或 store。敏感值(token、手机号)永远不进 URL。
4. 提升还是下放
| 判据 | 往上抬 | 往下放 |
|---|---|---|
| 谁需要 | 多个兄弟/远房组件都要读 | 只有一个子树用 |
| 活多久 | 跨路由存活 | 随组件卸载消失 |
| 会不会并发修改 | 多处同时写,需要统一入口 | 单一写入者 |
| 调试成本 | 抽走后调用链仍清晰 | 抽走后要靠"猜"才知道谁改的 |
决策顺序:
1. 只有一个组件用 → 留在组件内
2. 父子共用 → 抬到最近公共父级
3. 跨路由但只在一个功能域 → 功能域 store
4. 全站都要读、且写入者唯一(如主题、语言)→ 全局
"怕以后要用"是最常见的过度提升理由,而抽上去的成本是即时的,收益是假设的。
5. 单一 store 与多 store
| 维度 | 单一 store | 按域多 store |
|---|---|---|
| 引用关系 | 一目了然,容易变成"上帝对象" | 边界清晰,跨域要显式引入 |
| 变更影响面 | 任一字段变化都可能触发无关订阅 | 订阅范围天然收敛 |
| 拆包 | 难以按路由分包 | 可按页面懒加载 |
| 迁移 | 拆出来成本高 | 后续新增域独立 |
单一 store 在团队小于五人、域少于三个时是合理选择;一旦出现"改 A 域的 action 导致 B 域重渲染",就说明边界已经不存在了。多 store 的隐性代价是跨域事务:两个域要一起改时,只能靠在调用方顺序调用,中间没有原子性。
6. 数据获取库与状态库的分工
| 职责 | 归数据获取库 | 归状态库 |
|---|---|---|
| 请求去重与并发合并 | ✅ | 手写易错 |
| 重试、退避、取消 | ✅ | 通常缺失 |
| 缓存失效与后台刷新 | ✅ | 需自建 |
| 跨页共享的 UI 选择 | ❌ | ✅ |
| 表单草稿与校验 | ❌ | 表单库 / 组件 |
// 失效边界写在数据层,而不是散落在组件里
queryClient.invalidateQueries({ queryKey: ['orders'] }); // 精确失效
queryClient.setQueryData(['order', id], optimistic); // 乐观更新
分工错位的典型症状:store 里塞着 fetchedAt、isStale 这类字段,说明在做缓存该做的事。
7. 不可变更新与响应式改写
// 不可变更新:路径上的对象全部新建,浅比较才能发现变化
const next = { ...state, filters: { ...state.filters, status } };
// 直接改写:依赖 Proxy 的深层追踪,省了展开但带来新约束
state.filters.status = status; // 解构/展开会丢响应性
| 方式 | 优点 | 代价 |
|---|---|---|
| 不可变更新 | 变更可比较、易做时间旅行 | 深层结构要逐层展开,代码冗长 |
| 响应式改写 | 写法短、贴近直觉 | 丢失引用会断开追踪;快照需显式深拷贝 |
选型不看偏好,看是否需要"变化可比较":要做撤销/重做、差分同步、审计,就不可变;只是本地交互,响应式更省代码。
8. 持久化与跨标签
| 载体 | 容量量级 | 存活范围 | 风险 |
|---|---|---|---|
| 内存 | 无上限(受内存约束) | 刷新即失 | 刷新后状态回到初始 |
sessionStorage |
数 MB | 单标签、单会话 | 多标签各自一份,易不一致 |
localStorage |
数 MB | 跨会话 | 需处理版本与脏数据 |
| IndexedDB | 数百 MB 起 | 跨会话 | 异步 API,需处理升级 |
// 跨标签同步:storage 事件只在"其他标签"触发
window.addEventListener('storage', (e) => {
if (e.key === 'cart') store.setState(JSON.parse(e.newValue ?? '{}'));
});
判据:这份状态丢了,用户体验损失多大。草稿、主题、引导进度值得持久化;服务端数据的副本不值得——它本就该重新拉。
9. 耦合度与可测试性
// 反例:组件直接读全局 store,测试必须搭整个 store
function List() { const rows = useStore(s => s.rows); ... }
// 正例:数据从参数进来,组件只依赖 props
function List({ rows }: { rows: Row[] }) { ... }
状态耦合度决定测试成本:组件直接读 store,每个用例都要构造 store 初值;数据从 props 进入,用例只需给一个数组。"这个组件能不能只靠 props 渲染出正确结果"是耦合度的可操作检验。
10. 失控的信号
| 信号 | 含义 | 先做什么 |
|---|---|---|
| 说不清某个值从哪来 | 写入者过多 | 列出所有 dispatch 点 |
| 改一处崩三处 | 订阅范围过宽 | 收窄 selector |
需要 useEffect 同步两份状态 |
同一事实存了两份 | 删掉其中一份 |
store 里有 loading/error 数组 |
在做缓存的事 | 迁到数据获取库 |
| 测试要初始化大段 store | 耦合过深 | 改成 props 注入 |
11. 迁移顺序
| 步骤 | 动作 | 风险控制 |
|---|---|---|
| 1 | 先把服务端数据迁出 store | 页面级验证,不动 UI 逻辑 |
| 2 | 表单态下沉到表单库/组件 | 逐个表单切,保留旧路径 |
| 3 | 把可分享状态搬进 URL | 加默认值兼容旧链接 |
| 4 | 剩余全局态按域拆分 | 先加 selector,再拆文件 |
| 5 | 删掉空壳 store | 用 lint 规则阻止回流 |
顺序的关键是从职责最清晰的一类开始:服务端数据有明确的失效语义,迁移完立刻能删掉一批手写逻辑,收益可见。
12. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 状态改了 UI 不更新 | 直接改写后丢失引用 | 逐层新建,或确认 Proxy 追踪 |
| 刷新后回到初始 | 状态只在内存 | 需要续存的显式持久化 |
| 两个标签数据不一致 | storage 事件未监听 |
监听并合并,或改用服务端为准 |
| 路由切换后残留 | store 未按路由清理 | 用作用域 store 或卸载时重置 |
| 循环更新 | 派生状态用 useEffect 回写 |
改为计算属性/selector |
| 首屏闪烁 | 依赖异步持久化水合 | 先渲染骨架,水合后再定稿 |
13. 边界
| 场景 | 建议 | 理由 |
|---|---|---|
| 页面少、复用少 | 组件状态足够 | store 的收益来自共享,没有共享就没有收益 |
| 单团队小项目 | 不引入状态库 | 约定成本高于收益 |
| 状态几乎全来自接口 | 只上数据获取库 | 全局 store 会变成第二份缓存 |
| 已有成熟表单库 | 不把表单塞进全局 | 校验与脏标记有专属语义 |
| SSR 场景 | 谨慎持久化 | 服务端无 localStorage,水合易不一致 |
一句话收尾:状态管理的本质是决定"谁是唯一写入者"。写入者唯一、位置就近,库的选择反而次要。
14. 自测
- 一个列表数据既要在详情页用,也要在首页用,它该进全局 store 吗?判据是什么?
- 服务端数据被放进 store 后,通常会缺哪四项能力?
- 什么状态值得进 URL,什么状态进 URL 会变成负担?
- 单一 store 出现什么症状时,说明域边界已经消失?
- 为什么"组件只靠 props 能否渲染正确"是耦合度的有效检验?
- 迁移大 store 时,为什么要从服务端数据开始?