一个点赞按钮,点下去要等 400ms 才变红,用户会觉得它坏了。可如果它瞬间变红、请求失败后又悄悄变灰,那比坏更糟——这是在骗人。
乐观 UI 的做法是:在服务器确认之前,先把成功状态画出来。真正难的不是画,是处理"服务器没确认"的那一刻。整套模式的价值,全压在这一步上。
先算一笔延迟账。同区域一次网络往返大约 30ms 到 80ms,再加上服务端处理、数据库读取、序列化,一个很普通的写操作经常落在 200ms 到 500ms。这个数字没到"慢"的程度,但已经越过了 100ms 那条线——过了这条线,交互就不再"跟手"。
于是用户点一下,三分之一秒内什么都没发生,然后心形才填满。这段空档会训练用户点第二次:他以为第一下没点中。结果就是两个请求、一个竞态条件,计数显示 2,而正确答案是 1。
乐观 UI 就是来填这段空档的。点击落下的瞬间更新本地状态,下一帧就渲染出来(60fps 下大约 16ms),服务器调用在后台跑。成功的话,界面什么都不用变,因为已经显示对了;失败的话,再收拾干净。
但诚实版本有三部分,大多数人只做了第一部分——画了乐观更新,跳过了回滚和错误提示。那正是会撒谎的版本。
关键的一行是"暂存"。在覆盖状态之前,先把旧值存进一个常量。请求抛错,就把它放回去。不用重新拉取,不用刷新页面,也不用猜原来的值是什么。
回滚才是真正的功能。演示里大家都爱展示乐观那半段,回滚这半段才是诚实所在,也恰恰是临近截止日期时最容易被砍掉的部分。
想想没有回滚时用户看到什么:他点了赞,心形填满,然后划走,根本没注意到请求已经静默失败。过一会儿回来,心是空的。从此他不再信任这个按钮——有时灵有时不灵,而且找不到规律。
好的回滚在同一个 tick 里做两件事:把旧值恢复,让屏幕和现实一致;告诉用户为什么。
恢复必须精确。原来是 41,乐观地加到 42,回滚就是回到 41,而不是在当前值上减一。因为中间可能已经有别的更新落地了。安全的做法是暂存完整值,而不是暂存增量。
列表操作要暂存的东西更多。乐观地删掉一项、删除请求却失败了,你得把这一项放回原来的位置。做法是在 splice 之前把这一项和它的索引一起存下来,失败时再 splice 回去。
还有一个踩过坑的细节:回滚的动画不能和成功的动画一样。如果删除一行是滑出,失败时别让它滑回来。直接瞬间弹回,再闪一下错误提示。滑回来读起来像"来了个新东西",会让人困惑;瞬间弹回读起来像"刚才那事没发生",这才是事实。
错误提示写不好,整个模式就塌了。"出错了"什么都没告诉用户。"无法保存,请检查网络"至少指了个方向。服务端的结构化日志能告诉你哪些失败是真的。
单次请求失败,很多时候不是真失败。是网络抖动、服务端冷启动、地铁 WiFi 掉了个包。如果一回滚就放弃,等于让用户替网络背锅。所以在回滚之前先重试——不是无限重试,也不是立刻重试。
朴素的立即重试会猛砸一个可能已经吃力的服务器。500 个客户端在同一毫秒重试,就是惊群,原本只是慢的服务器直接被打挂。解法是带抖动的退避:每次多等一会儿,再加点随机,让重试散开而不是同步落地。
把 API 调用包进重试里,乐观更新就一直挂在屏幕上。用户看不到重试过程,只看到心变红,而且一直是红的,因为第二次请求成功了。三次都失败,才回滚并显示错误。
有个前提:只重试可以安全重复的操作。点赞如果发送的是目标状态(liked: true)而不是命令(toggle),它就是幂等的——两次重试都落地,只是把它设成 true 两次,没问题。发两次 toggle 就会翻回去,那是 bug。把 API 设计成重试不会造成伤害,整个模式才更安全。
自动重试都失败之后,还要给手动重试。错误提示本身就该是重试按钮,"点击重试"重新跑一遍完整的乐观流程,不用刷新页面,死路变成第二次机会。
速度不是永远值得冒"显示假象"的风险。有些操作就该等服务器,假装不用等,比任何加载圈都更快地消耗信任。
钱是最明显的例子。永远不要乐观地显示"支付完成"。如果在你显示成功之后扣款失败,你等于告诉一个人他付过了,而他没有。等真实确认。结账按钮上转 600ms 的圈是可以接受的,因为人们本来就预期付钱要等一下。
不可逆的操作同理:注销账号、发送无法撤回的消息、公开发布内容。如果回滚没法完全撤销可见效果,就别做乐观更新。
合适的区间是小的、可逆的、低风险的动作:点赞、收藏、列表排序、开关设置、加入购物车。失败了回滚,用户耸耸肩再点一次,没人因此毁掉一天。
还有一个计数问题要盯住。如果你乐观更新的数字别人也在改,本地视图会漂移。一条帖子有 100 个赞,你加一个显示 101,但同一秒里另外三个人也点了,真实计数是 104。你的 101 错了,而且回滚抓不到——因为请求成功了。
处理办法是:服务器响应到达时,把它当作唯一真相。乐观值只是猜测,真实计数回来就替换掉猜测,即使请求成功也替换。用户看到 101 闪一下变成 104,读起来像"哦,别人也点了",而不是 bug。
读操作是另一回事。如果列表巨大、瓶颈在渲染而不在网络,乐观 UI 帮不上忙,那要解决的是渲染策略,不是请求时机。先搞清楚自己面对的是哪个问题:乐观 UI 治的是写操作的感知延迟,对慢渲染毫无作用。
三部分要么都建,要么别用这个模式。乐观的那一笔是最容易的 20%;暂存加回滚、加上具体的错误提示,是让屏幕在网络掉包时依然诚实的另外 80%。
默认策略可以很固定:小的可逆写操作走乐观,失败前带退避重试三次,服务器真实响应永远覆盖我的猜测。涉及钱和不可逆操作,就等确认,老老实实转圈。
从一个按钮开始。加上暂存、回滚、错误提示。把这一个做诚实,再把同样的形状复制到别处。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.