大多数前端代码将异步工作视为同一种形态。你启动一个 Promise,等待它 resolve,将结果存入本地状态,然后让框架去协调差异(diff)。这种模式非常诱人,因为它在任何地方都适用:REST 调用、表单提交、WebSocket 消息。它们都落在同一个 useEffect 或事件处理程序中,都通过 setState 进行流水线处理,看起来都是一模一样的异步底层逻辑。这种统一性是一个陷阱。在真实的应用程序中,并非所有的异步工作都是一样的。假装它们是一样的,会让你无意中把 UI 组件变成了数据架构师,只能靠 useEffect 钩子和祈祷来缝补。

异步操作实际上可以分为三个不同的类别。每一类与时间、缓存和所有权的关系都不同。学会区分它们,是保持前端快速、正确且理性的关键。

查询:带有地址的事实

查询不仅仅是获取数据。它是一个对可识别事实的请求。你是在请求 /user/123,而不是“某些用户数据”。这种区别至关重要,因为身份(identity)是实现缓存的基础。如果同一个屏幕上的两个组件都需要同一个用户记录,它们应该共享同一个答案。如果每个组件都在 useState 中保留自己的本地副本,你就会导致真相碎片化。页眉中的头像和侧边栏中的姓名会产生偏差,因为它们获取的时间不同,或者其中一个获取失败而另一个成功了。

把查询看作一种资源,而不是一种动作。它拥有缓存键(cache key)、新鲜度策略以及比任何单个组件寿命都长的生命周期。一个构建良好的查询层会理解,读取 /projects?page=2 与读取 /projects?page=3 是不同的。每个 URL 和参数集都构成了一个地址,而该地址下的数据可能是陈旧的、新鲜的或缺失的。UI 不应该负责这些记账工作。它应该向数据层请求 user:123 并接收一个快照。这个快照是两秒前从服务器获取的,还是两毫秒前从缓存获取的,都不关组件的事。

实际的影响是立竿见影的。当你把每一次读取都视为组件内部的一次命令式获取(imperative fetch)时,你会失去去重功能。你会失去后台刷新功能。你会失去在后台验证数据的同时立即显示缓存数据的能力。查询值得拥有一个位于 UI 树之外的家。

变更:改变世界

如果说查询是在询问世界,那么变更(Mutations)就是在改变世界。实际的网络请求——POSTPUTDELETE——通常是容易的部分。难点在于服务器返回“OK”之后发生的一切。

假设用户更新了他们的显示名称。变更本身是一个单一的请求。但其影响范围(blast radius)无处不在。个人资料页显示的是旧名称。导航栏显示的是旧名称。评论历史记录可能也会引用它。如果你的变更代码除了切换一个本地的 isLoading 标志并更新一个状态片段之外什么都不做,那么你的应用程序现在就是在自欺欺人。UI 的某些角落假装更改已经发生,而其他角落则完全不知道发生了任何变化。

变更必须声明它对数据图谱的影响。它应该告诉系统哪些查询现在已失效,哪些缓存键需要重新获取,以及哪些关系发生了变化。这与查询有着本质的区别。查询是只读且可共享的。变更则是以写入为中心,并且会对现有缓存产生破坏性。将它们扁平化为同一种抽象,意味着开发者最终不得不手动在随机的组件中调用 refetch(),或者更糟,在整个组件树中到处散布 useEffect 钩子,以试图将本地状态“同步”回与服务器一致的状态。

所有权模型也不同。查询通常由缓存所有。变更则由触发它的用户操作所有。它具有进行中状态(pending state)、错误状态,以及一个可能需要回滚的乐观值。