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

状态管理 — 自己写一个库

先发出的请求后到达

在 TT Lab 中继续学习

一句话总结

在异步更新中,并不保证后发出的请求后到达。 因此,在修改状态的地方,必须有一种机制来询问“这个响应是否仍然有效”。

为什么需要它

在搜索框中依次输入 서、서울、서울역(韩文,依次是“首尔”一词的第一个音节、“首尔”、“首尔站”)。发出三次请求,屏幕上应该留下最后一次的结果。可是有的时候,留在屏幕上的是 서 的结果。

原因很简单。第一个请求没有命中缓存,花了 800ms,而第三个请求只用了 40ms 就返回了。第三个结果先进入界面,800ms 之后第一个响应到达,把它覆盖掉了。 从代码上看,似乎没有任何错误。

const res = await fetch("/search?q=" + q)
setResults(await res.json())    // 누가 언제 도착했는지 아무도 묻지 않는다

这个缺陷最讨厌的地方在于,在开发者的机器上无法复现。 本地的响应只要 5ms,顺序没有机会被颠倒。只有在地铁里使用的用户才会遇到。

同类的事故还有三种。界面上的四个地方各自请求同一份数据,导致同一个请求发出四次;回滚写得不对,导致连别人的更新也一并回滚;没有取消的请求持续触碰已经消失的界面的状态。

工作原理

最新优先——加上序号

最便宜也最可靠的办法,是给每个请求编号,并在采用响应之前,检查这个编号是否仍然是最新的。

let seq = 0
async function run(task) {
  const my = ++seq
  const value = await task()
  if (my !== seq) return { applied: false }   // 나는 이미 낡았다
  return { applied: true, value }
}

重要的是以丢弃为默认。要写成“只有我是最新的时候才采用”,而不是“忽略迟到的”,这样无论有三个请求还是十个,都适用同一条规则。

取消——丢弃结果与停止工作是两回事

用编号来过滤,界面是对的,但网络和服务器仍在继续工作。浏览器标准为此提供了 AbortController。MDN 写道,abort() 会中止“fetch 请求、对响应正文的消耗以及流”(AbortController)。在发起新请求时,切断前一个请求的 signal,就能同时解决两件事——过时的响应不会再来,服务器上的位置也被归还了。

去重——相同的键只做一次

如果界面上的四个地方都需要同一个 user/42,请求也会发出四次。把正在进行的请求以键记下来,再共享同一个 Promise,就能减少到一次。TanStack Query 默认做的就是这件事,同一份文档还写道,失败的查询会以指数退避重试三次(Important Defaults)。

乐观更新——回滚不是减法

点击点赞按钮时,不等待服务器响应,先改变界面。失败了再回滚。在这里,几乎所有人都犯同一个错误——把回滚写成逆运算。

like()            // count + 1
unlike()          // 실패하면 count - 1

如果在这期间别人点了赞,服务器的值发生了变化,减法就会得出一个莫名其妙的值。如果是有顺序的更新,情况更糟。当把标签改成 A 的更新和改成 B 的更新都在等待时,回滚前一个,标签本应是 B,而逆运算却会把它退回到 A 之前。

正确的做法是单独保存已确认的基准值(base)和待定更新列表,界面则始终在基准值之上,把待定列表按顺序重新叠加起来计算。回滚就是把那一项从列表中去掉,服务器响应到达时,先替换基准值,再把剩下的待定列表重新叠加上去。React 的 useOptimistic 也是同样的形态——给它确认值和 reducer,它会把待定更新叠加在上面展示出来(useOptimistic)。

在现场相遇的样子

在标签页切换很快的仪表板上,收到了“偶尔会看到旧标签页的数据”的反馈。每个标签页都会发出请求,并把响应直接放进状态,而快速在两个标签页之间切换时,第一个标签页的响应后到达了。因为无法复现,拖了一个多月,最后是在把网络调慢的浏览器里一下子复现的。修复的是三行序号比较。

另一个团队把点赞回滚写成了减法,在服务器返回 500 的那几秒钟里,连其他用户的点赞也被减了下去,计数变成了负数。如果回滚不是“减法”,而是“从待定列表中移除并重新计算”,就不会发生这种事。

这两起事故,都不是界面代码的问题,而是修改状态的地方没有顺序的概念。即使换了库,只要那个地方没有顺序,问题依然会跟着来。

下一项实验要做什么

在一个 lane.mjs 中亲手实现四样东西——最新优先 lane、取消信号、按键去重、以基准值和待定列表来计算的乐观更新。最后,把同一个场景亲自运行一遍并写下数字,评分器会当场重新测量并核对。编造的数字无法通过。