一个"收藏"按钮做成了乐观更新:点击立刻变红,然后发请求。某次服务端限流,请求 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. 自测
- 乐观更新的真正核心是什么?为什么说它的代价是"把失败推迟"?
- 三种回滚形态里,哪一种最伤信任?为什么"静默回滚"会被用户理解成自己点错了?
- 为什么超时不能直接回滚?正确做法是什么?
- 界面与服务端对齐的四个校准时机分别覆盖什么场景?
- 失败提示的措辞要求是什么?为什么提示要出现在操作位置附近?
- 判断"能否乐观更新"的那个问题是什么?