挡住竞态的通道
目标
亲手防住搜索框、点赞、列表刷新中会破坏状态的异步竞态。在一个文件中实现四个函数。
为什么重要
在同步的世界里,“最后调用的最后被采用”是免费的。在异步中就不是免费的。先发出的请求可能后到达,这时如果修改状态的地方没有顺序的概念,新值就会被旧值悄悄覆盖。在开发者的机器上,响应太快而无法复现,所以这类缺陷只有用户才看得到。
回滚也是同样。如果把乐观更新用逆运算回滚,这期间进来的其他更新也会一并消失。回滚不是减法,而是从待定列表中移除并重新计算。本实验让你亲手做出这种差别。
要创建的内容
在 /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) |
步骤
- 最新优先 lane
- 取消信号
- 按键去重
- 乐观更新与回滚
- 用服务器响应替换基准值
- 过期判定
- 亲自测量并写下——
/root/work/race/07-measure.txt - 总结——
/root/work/race/08-notes.md
参考
- 不能使用 npm install。 没有网络,也不需要。
AbortController和queueMicrotask在 node 22 中是内置的。 - 评分器会 import 你的
lane.mjs,只确认契约。既不使用界面,也不使用框架。 - 第 7 步中,评分器会把你写下的数字当场重新测量并核对。编造的数字无法通过。
最新优先 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 长得多么不同,这三点都是它们以同样方式解决的问题。