小程序页面天然是"多异步请求并发 + 用户随时切走"的环境。数据回写旧值、错误提示被清掉、旧账号的响应盖住新账号的输入——这类 bug 有一个共同形态:代码没错,时序错了。本文从 HealthLoop 的四次真实故障里,把竞态守卫从第一代演进到第 N 代的过程整理出来。

第一代:布尔锁(防重复点击)

最原始的守卫是一个 isSaving 布尔值:保存中不响应第二次点击。

它能解决"双击提交",但解决不了"两次请求先后返回"。用户连点「前一天」翻三次日期,三次请求返回顺序可能乱——8 月 1 日的数据盖住当前选中的 8 月 3 日。布尔值只能回答"是不是在忙",回答不了"我是不是最新的那个请求"。

第二代:单调递增 key(只认最后一次)

把布尔换成单调递增的序号:

const myKey = ++this.activeRefinementKey;
const result = await recognizeInput(...);
if (myKey !== this.activeRefinementKey) return; // 我过期了,丢弃

发起时记住"我是第几个",回来时核对"我还是不是最新的"。这不是新发明——日期翻页的 token 比对(if (this.data.selectedDate !== token) return;)就是同一思路的早期形态。

但粒度问题很快暴露:保存是幂等写(重复点击应忽略),重估是非幂等读(应只认最后一次),两者不能用同一个标志。更隐蔽的是同一个页面里有多个异步区域(录入表单 + 详情弹层),共用一个 key 会在两个 sheet 同时打开时互相干扰——每个区域要各自单调递增、各自判断。

规律:守卫的粒度跟着"请求的性质"走。幂等写用布尔锁,非幂等读用递增 key;每个独立的异步区域一套自己的序号。

第三代:发起身份固化(账号隔离)

日期和 key 都解决了"同账号内"的问题,切账号场景又炸了一次:

训练首页发起排期请求后用户切换了账号,迟到的旧响应写进了新账号的页面;更糟的是"按排期开练"的异步函数在 await 查完计划之后,从可变的 activeUserId 里取账号——等它醒来时全局账号已经换了,旧计划被用新账号启动。

修法两条:

  1. 请求增加递增代次,页面隐藏时整体失效;切账号立即清空旧数据并在加载完成前隐藏;
  2. 开练前把发起账号和请求代次固化为局部变量,await 之后只用发起时的身份,再用它和当前身份核对一次。

规律:账号隔离要守住异步任务的"发起身份",绝不能在 await 之后读取可变的当前身份。

第四代:守卫要下沉到存储层

前三代守卫都在页面层:页面活着时守得住,页面之外的存储没人守。资料编辑页暴露了这个问题——编辑页先读本机缓存再拉云端,迟到的云端旧响应会直接写进本机缓存;用户没保存的输入被覆盖,「我的」页快速返回时旧请求还能回滚刚保存的昵称和头像。

页面守卫只能保护展示,缓存也要守住写入。最终把判定下沉到服务层:按账号、缓存修订号和内容更新时间拒绝旧值回写,删除后的旧请求也不能让缓存"复活"。

两个配套细节

错误提示不能被辅助状态清掉。饮食页回看历史日期失败时,页面设置了"记录暂不可用",随后一次无条件的页面状态持久化又把提示清空了。规则:持久化只在"失败且当前没有更重要提示"时写状态错误;失败视图必须携带用户选择的查询上下文(不能跳回今天)。

加载态也要处理旧数据可见性。请求发出到返回之间,上一轮的旧数据还在屏幕上——它可能属于另一个账号或另一个日期。要么隐藏,要么标注,不能裸奔。

总结

代际 守卫 回答的问题
一 布尔锁 是不是在忙(防双击)
二 递增 key 我是不是最新的那次(防覆盖)
三 发起身份固化 我是不是替对的人发起的(防串号)
四 存储层校验 旧值能不能落进缓存(防复活)

竞态 bug 的特点是"偶发",所以必须写成回归用例去模拟——模拟跨账号旧请求、快速重进、编辑中输入时收到旧响应。没有这些 fixture,守卫修好了也守不住未来。