2409 字
约 8 分钟
16
S14-A17 · 乐观更新的回滚:先改界面,错了怎么办

一个"收藏"按钮做成了乐观更新:点击立刻变红,然后发请求。某次服务端限流,请求 429——按钮还是红的,用户以为收藏成功。刷新后恢复,但用户已经在另一台设备上截图说"我明明收藏了"。

乐观更新的收益是把几百毫秒的等待从用户感知中删掉;代价是把"失败"这件事推迟到用户已经在心里确认成功之后。
这一点决定了它的适用范围:只有"失败后能解释清楚"的操作才适合乐观更新。如果一个操作失败后你无法用一句话说明白发生了什么,那它就不该被乐观处理。

所以它的核心不是"先改界面",而是失败时怎么让人相信变化没有发生。


1. 适合与不适合的场景

场景 是否适合
点赞、收藏、关注 适合(幂等、后果轻)
表单提交(创建订单) 不适合(后果重)
列表项删除 有时适合(可撤销)
消息发送 适合(有明确的失败态)
支付 绝不

第一项的共同特征是操作幂等、失败无害、重试便宜。而第二项与第五项的共同特征是"失败有真实后果",此时乐观更新只会让用户更早产生错误预期。

反过来,第五项(支付)之所以绝不乐观,是因为它的"失败"没有中间态:用户看到"已支付"却实际未支付,可能导致重复付款或错失订单,两种结果都比多等两秒糟得多。

第三项的关键是"有可撤销的界面":误删可以撤,所以可以先删再问。

2. 三种回滚形态

形态 说明
静默回滚 界面改回去,不提示
显式提示 改回去并告知失败
保留占位 + 重试 显示"未完成"状态

第一项最省事,也最伤信任:用户明明看到变红,后来变灰,会怀疑是自己点错了。第二项至少把因果说清楚。

第三项是体验最好的一种:把"待确认"做成一个可见状态(如灰色勾 + "同步中"),失败后变成"重试"按钮。这样既没有等待,也没有欺骗。

// 三种形态的状态机
// idle → pending(界面已改) → confirmed(服务端确认)
//                          ↘ failed(显式提示 + 可重试)
async function toggleFavorite(id) {
  setState(id, 'pending');                       // 界面立即变化
  try {
    await api.favorite(id);
    setState(id, 'confirmed');
  } catch (e) {
    setState(id, 'failed');                      // 不静默回滚
    toast('收藏未成功,点击重试');
  }
}

3. 回滚必须处理的四种情况

情况 处理
请求失败 回到原状态
请求超时(结果未知) 不能直接回滚,需查询确认
部分成功(批量) 逐项状态
并发修改(他处已改) 以服务端为准

第二项里还有一个更细的分支:幂等键缺失时,"未知"状态下重发请求可能造成重复执行。所以乐观更新的前提之一,是写接口本身支持幂等(如带客户端生成的唯一 ID)。

POST /api/favorite
Idempotency-Key: 8f14e45f-6b1a-4c2e-9f0a-2b3c4d5e6f70
{"item_id": 123}
// 同一 key 重发不会重复执行;超时后前端可以安全重试

第二项是乐观更新最容易被做错的地方:超时不等于失败,服务端可能已经成功。直接回滚会造成"界面说没收藏,实际已收藏"的错位。

正确做法是把超时归入"未知"状态,然后查一次真实状态(GET)再决定界面。

// 超时:先查后判,不直接回滚
} catch (e) {
  if (e.code === 'TIMEOUT') {
    const actual = await api.getState(id);   // 以服务端为准
    setState(id, actual.favorited ? 'confirmed' : 'idle');
    return;
  }
  setState(id, 'idle');
}

4. 一致性:界面与服务端的对齐

手段 作用
版本号 / ETag 检测并发修改
服务端返回权威值 覆盖本地推测
定期全量校准 兜底
页面可见时校准 切回标签页刷新

第三项(定期全量校准)的成本要注意:全量校准在列表页上会变成 N 次请求,所以校准应当只针对"当前可见的少量项",或者整页拉一次列表。

第二项是最简单的:写接口直接返回新状态,前端用它覆盖本地推测。这样"推测"只存在几百毫秒,之后就是权威值。

第四项处理"用户在别的标签页或设备上改过":页面重新可见时拉一次状态。成本极低,能消除大部分"两边不一致"的投诉。

// 校准时机清单
1. 每次写操作后(用返回值覆盖)
2. 页面重新可见(visibilitychange)
3. 网络恢复(online)
4. 用户手动下拉刷新
// 四个时机覆盖了绝大多数不一致来源

5. 失败提示的措辞

措辞 问题
"操作失败" 不知道是哪一步
"网络错误" 可能不是网络
"请稍后重试" 没有说明重试什么
"收藏未成功,点击重试" 明确

还有一个与"重试"有关的措辞细节:能自动重试的就不要说"点击重试"。让用户点一次的成本,高于后台自动重试三次——前提是操作幂等。

第四项的要求是说清"哪件事没成"与"下一步做什么"。它比"操作失败"多的只是两个词,但用户能立刻决定要不要重试。

还有一个细节:提示应当出现在操作发生的位置附近,而不是页面顶部。因为用户的注意力还在那个按钮上。

界面与服务端还有一个"顺序"问题:连续两次乐观更新(先收藏再取消)在网络上可能乱序到达。若服务端按到达顺序处理,最终状态可能与用户最后点击相反。解法是带版本号或只保留最后一次意图。

// 乱序导致的最终状态错误
用户:收藏(1) → 取消(2)        期望:未收藏
网络:取消(2) 先到 → 收藏(1) 后到
服务端:先取消(无变化),再收藏 → 最终已收藏(与期望相反)
// 解决:请求带 client_seq,服务端丢弃旧 seq

6. 排错:五种"乐观更新出问题"

现象 成因 处理方向
按钮状态与服务端不一致 超时被当失败 超时先查后判
刷新后"撤销"了操作 本地推测未校准 用返回值覆盖
批量操作部分失败无人知 未做逐项状态 逐项标记
两台设备状态不同 无可见时校准 加 visibilitychange
用户重复点击产生重复请求 无 pending 态 明确 pending 状态

连点还有一个"视觉"层面的解法:pending 态下让按钮显示进度而不是保持原样。用户看到"正在处理"就不会再点,这比禁用按钮更少被误解(禁用看起来像坏了)。

第五项是副作用问题:按钮在 pending 状态仍可点击,用户会连点,于是产生多次请求。解决不是靠按钮禁用,而是靠服务端幂等——因为网络重试同样会重复。

7. 边界:什么情况不该乐观更新

场景 为什么 做法
有资金或权益变化 失败代价高 等服务端确认
不可撤销的操作 无退路 等确认
需要服务端计算的结果 结果未知 等返回
弱网环境 失败率高 等待 + 明确进度

最后一条与"可观测性"有关的边界:乐观更新的失败率必须被监控。它不上报成错误(因为界面"看起来成功"),所以很容易被忽略——直到用户投诉积累到一定程度。建议单独埋一个 optimistic_failed 事件。

判断口径一句话:这次失败会不会让用户"已经做了某件事"的认知变成错的。会,就不要乐观更新;不会(点赞、折叠面板),乐观更新几乎总是更好。


8. 自测

  1. 乐观更新的真正核心是什么?为什么说它的代价是"把失败推迟"?
  2. 三种回滚形态里,哪一种最伤信任?为什么"静默回滚"会被用户理解成自己点错了?
  3. 为什么超时不能直接回滚?正确做法是什么?
  4. 界面与服务端对齐的四个校准时机分别覆盖什么场景?
  5. 失败提示的措辞要求是什么?为什么提示要出现在操作位置附近?
  6. 判断"能否乐观更新"的那个问题是什么?
S14-A17 · 乐观更新的回滚:先改界面,错了怎么办
https://www.clxhxhhr.top/posts/6473/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。