TT Lab
开始
学习 学习路径 课程

状态管理 — 自己写一个库

服务端状态是缓存,不是状态

在 TT Lab 中继续学习

一句话总结

服务器掌握真相的值,不是状态,而是缓存。 一旦把这两者放进同一个容器,代码里就没有任何地方可以写下“它什么时候过期”这个问题了。

为什么需要它

你大概往 store 里放过这样的形状。

{ user: null, orders: [], loading: false, error: null }

这里混着两种不同的东西。user 和 orders 是由服务器做主的值。即使我不去碰,别人也会修改它们。而模态框有没有打开、正在看哪个标签页,则是只有这个浏览器才知道的值。没有人会在背后修改它们。

如果把两者放进同一个容器,只有服务器端的值才需要的那些问题——什么时候该重新获取、现在看到的已经过期多久了、两个界面看到的是不是同一份内容——就没有地方可写了。于是这些问题就散落到界面代码里。useEffect 里的 fetch、“刷新”按钮、路由切换时的手动重置。没有哪一个遵循相同的规则,所以会出现只有一个界面显示着过期的值这种情况。

React 官方文档也从另一个角度讲了同样的事情。能够由渲染结果计算出来的值,不要放进状态(You Might Not Need an Effect),还有,构建状态时要避免重复(构建状态结构)。把服务器响应原样复制一份存起来,就是这种重复的最大形态。

工作原理

划分的标准是“谁来修改”

只需要一个问题。除了我之外,还有谁能修改这个值?

问题 答案是“是” 答案是“否”
名称 服务器状态 客户端状态
示例 订单列表、余额、别人的评论 模态框是否打开、选中的标签页、正在输入的文字
需要什么 键、过期判定、失效 只要一个值
存放位置 查询缓存 store 或局部状态

服务器状态除了值之外,还必须有键(key)和获取时间。如果没有这两样,就无法询问“是否需要重新获取”。

过期(stale)与回收(gc)是不同的刻度

很多人把这两者看成一回事。实际上有两条轴。

TanStack Query 的默认值很好地展示了这种区分。获取到的数据默认立刻视为过期(staleTime 为 0),所以当界面重新挂载或窗口重新获得焦点时,会在后台重新获取。而没有人在看的查询结果,则在 5 分钟后清除(gcTime 默认值为 1000 * 60 * 5)(Important Defaults)。

重要的是,这个默认值为什么这样设定。如果默认设为“已过期”,最坏的情况是多一次不必要的请求,而如果默认设为“新鲜”,最坏的情况则是一直显示错误的值。这两种失败哪一种代价更低,根本不用纠结。

失效不是“删掉”,而是“重新询问”

取消了一个订单。如果把列表缓存删掉,界面就会先闪成空白,再重新被填上。相反,如果只做过期标记而保留值,就能一边原样显示正在看的内容,一边在后台获取新值并悄悄替换。正如同一份文档所写,结果会以结构共享的方式保留,如果实际上没有任何变化,引用也会原样保持——在 st-core 中做的引用比较和记忆化,在这里就派上了用场。

在现场相遇的样子

有一个团队把登录用户的信息放进了 Redux,只在应用启动时获取一次。用户在另一个标签页里修改了名字,这个标签页却好几个小时都还在显示旧名字,“请退出登录后再重新进入”成了官方的提示。这就是把服务器状态当作客户端状态处理的代价。

反方向的事故也很常见。把正在输入的文字放进了查询缓存,结果后台重新请求运行时,把用户正在输入的文字覆盖了。把没有人会在背后修改的值当成服务器状态处理,就会这样。

在划分的界线模糊时,有一条实战标准。如果打开两个标签页,在一边修改之后,另一边需要跟着变化,那就是服务器状态。如果不需要跟着变化,就是客户端状态。这一句话就能划分大多数情况。

也有处在边界上的值。购物车在登录前是客户端状态,登录后就变成服务器状态。这时答案不是“两者都是”,而是确定其中一方做主,把另一方迁移过去。如果把两份副本同时当作真相,就会出现没有人能解释哪一边获胜的合并代码,而这种代码必然会丢失条目。

下一项测验要确认什么

通过六道题,确认划分哪些值是服务器状态的标准、过期与回收为什么是不同的刻度,以及把失效写成“删除”时界面上会看到什么。