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

状态管理 — 自己写一个库

挡住竞态的通道

在 TT Lab 中继续学习

目标

亲手防住搜索框、点赞、列表刷新中会破坏状态的异步竞态。在一个文件中实现四个函数。

为什么重要

在同步的世界里,“最后调用的最后被采用”是免费的。在异步中就不是免费的。先发出的请求可能后到达,这时如果修改状态的地方没有顺序的概念,新值就会被旧值悄悄覆盖。在开发者的机器上,响应太快而无法复现,所以这类缺陷只有用户才看得到。

回滚也是同样。如果把乐观更新用逆运算回滚,这期间进来的其他更新也会一并消失。回滚不是减法,而是从待定列表中移除并重新计算。本实验让你亲手做出这种差别。

要创建的内容

在 /root/work/race/lane.mjs 中导出下面这些。

export 契约
createLane() run(task)——只采用最新的请求。新的 run 会切断前一个 run 的信号
createDedupe() run(key, task)——正在进行的相同键共享同一个 Promise
createOptimistic(base) begin、rollback、settle、getState
createCache() set(key, value, now)、get(key, now, staleMs)

步骤

  1. 最新优先 lane
  2. 取消信号
  3. 按键去重
  4. 乐观更新与回滚
  5. 用服务器响应替换基准值
  6. 过期判定
  7. 亲自测量并写下——/root/work/race/07-measure.txt
  8. 总结——/root/work/race/08-notes.md

参考

最新优先 lane

在 /root/work/race/lane.mjs 中导出 createLane()。它返回的对象的 run(task) 会等待 task() 的结果,如果在此期间已经有更新的 run 启动,就返回 {applied: false},否则返回 {applied: true, value}。

mkdir -p /root/work/race。在 lane 内部放一个递增的编号,run 启动时记住自己的编号,await 之后再与当前编号比较。扩展名是 .mjs,所以不需要 package.json。

取消信号

让 run(task) 以 task(signal) 的形式传入 AbortSignal。新的 run 启动时,之前传给前一个 run 的信号必须变成 aborted === true,而最近一次 run 的信号不能被切断。

每个 run 创建一个 new AbortController(),由 lane 保存最后一个即可。丢弃结果(第 1 步)与停止工作是两回事——只靠编号的话,网络和服务器仍在继续工作。

按键去重

再导出 createDedupe()。如果相同 key 的任务正在进行,run(key, task) 就不能再次调用 task,而必须返回同一个 Promise。结束之后再调用,就重新开始。

把正在进行的 Promise 按键放进 Map 中,结束时(无论成功还是失败)删掉。finally 就是放这个的地方。如果留下失败的 Promise,之后的调用就会永远收到同样的失败。

回滚不是减法

导出 createOptimistic(base)。begin(patch) 会增加一个待定更新并返回凭据,rollback(표)(占位符为凭据)则只去掉那个更新,并把剩下的更新重新叠加到基准值之上来计算状态。getState() 返回的就是这个结果。

不要用逆运算来回滚。单独保存基准值和待定列表,每次查询状态时用 pending.reduce((s, p) => p(s), base) 来计算,即使是有顺序的更新,结果也是对的。当把标签改成 A 的更新和改成 B 的更新同时在等待时,回滚前一个,标签必须是 B。

服务器响应替换基准值

给 createOptimistic 增加 settle(표, 서버상태)(占位符依次为凭据与服务器状态)。把该更新从待定列表中去掉,并把基准值替换为服务器给出的状态,然后把仍然留着的待定更新按顺序重新叠加上去。

settle 与 rollback 只有一点不同——它也会改变基准值。其他尚未得到答复的乐观更新,这时不能消失。评分器会检查两个待定更新中只有一个收到了服务器响应的情况。

过期与丢弃是不同的刻度

导出 createCache()。用 set(key, value, now) 放入,get(key, now, staleMs) 返回 {value, stale}。放入之后经过了 staleMs 或更久,stale 必须为真,而不存在的键返回 undefined。

不要只放入值,必须同时放入放入的时刻,才能查询是否过期。边界是“或更久”——恰好经过 staleMs 的那一刻也算过期。不要因为过期就丢弃值。要点是在显示正在看的内容的同时,在后台重新获取。

亲自测量并写下

亲自运行下面的场景,把得到的数字以 이름=값(占位符依次为名称与值)的格式写成三行,保存到 /root/work/race/07-measure.txt。dedupe_calls——用同一个键同时 run 5 次时,task 实际被执行的次数。latest_applied、latest_discarded——在一个 lane 上连续 run 5 次时,applied 为真的次数和为假的次数。

写一个简短的 .mjs 运行一下,把得到的值抄下来即可。评分器会用你的 lane.mjs 重新运行同一个场景,并与写下的数字核对,所以凭眼睛估算的值无法通过。请先确认三行之和是否正确。

是什么守住了顺序

在 /root/work/race/08-notes.md 中写三行以上。没有最新优先时界面上会留下什么,取消为什么与编号比较是两回事,把回滚写成减法会让什么一起消失。

正文中必须包含 최신(韩文,意为“最新”)、취소(韩文,意为“取消”)、되돌리기(韩文,意为“回滚”)。无论 TanStack Query、SWR、RTK Query 长得多么不同,这三点都是它们以同样方式解决的问题。