2957 字
约 9 分钟
18
S14-A05 · 前端状态管理决策:状态该放在哪一层

一开始只有一个 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. 自测

  1. 一个列表数据既要在详情页用,也要在首页用,它该进全局 store 吗?判据是什么?
  2. 服务端数据被放进 store 后,通常会缺哪四项能力?
  3. 什么状态值得进 URL,什么状态进 URL 会变成负担?
  4. 单一 store 出现什么症状时,说明域边界已经消失?
  5. 为什么"组件只靠 props 能否渲染正确"是耦合度的有效检验?
  6. 迁移大 store 时,为什么要从服务端数据开始?
S14-A05 · 前端状态管理决策:状态该放在哪一层
https://www.clxhxhhr.top/posts/6121/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。