无不同步问题的优化用户界面:快照、回滚与已取消的请求
学会构建始终正确的乐观更新:记录状态快照、即时执行更新、失败时回滚、取消过期的请求,并了解何时不应使用该模式。
在高速网络连接下,点击“点赞”按钮后的往返请求几乎感觉不到延迟。但在信号时好时坏的LTE与勉强能用的3G网络之间,同样的往返请求可能需要五到十秒的时间,在此期间用户会感到焦虑,不确定点击操作是否真的被记录下来。乐观更新模式通过先修改界面、再向服务器请求来缩小这一延迟差距。本指南将详细介绍如何分三步实现该模式,如何防止并发请求带来的问题,以及如何判断其在何种场景下适用、何时会带来弊端。
一次点击背后隐藏的问题
想象一下,某篇帖子的点赞数显示为999,并带有心形图标。在最严格的实现方式中,点击心形图标会向可能位于另一个大陆的服务器发送请求。服务器会对请求者进行身份验证,然后写入数据库并回复,只有这时心形图标才会变红,点赞数也会增加到1,000。几百毫秒的等待尚可接受,但五秒钟的延迟则令人难以忍受。看到没有反应的人会再次点击,甚至第三次点击,最终放弃使用该应用,这就是为什么这种延迟是流失用户的有效手段。
点击操作还需要执行比简单切换布尔值更多的工作。典型的平台会在后端完成三项操作:
- 调用API接口,为该帖子的点赞数加1。
- 将该帖子添加到用户账户中的收藏列表中,以便日后查找。
这三者都依赖于网络。但在前端,点击操作只需更改两个状态:心形图标是否被填满,以及其旁边的数字。如果界面在修改这些数值之前都要等待服务器端的响应,那么即使应用运行正常,看起来也会出问题。
作为刻意且可修复的谎言的乐观机制
乐观更新通过让前端立即更改自身状态来解决这一问题,前提是它认为刚刚发送的请求一定会成功。手指离开屏幕的瞬间,心形图标就会变成红色,计数也会显示为1,000,尽管此时还没有任何数据传送到服务器。正是这个小小的谎言让交互体验显得即时无比。
只有当有应对假设失败的计划时,这种做法才是可接受的。列车可能会进入隧道导致连接中断,或者服务器可能会返回错误信息。当数据库显示的数值仍是999,而界面却仍显示1,000时,就会产生数据不同步的情况:界面显示与真实数据不一致。此时前端必须恢复到真实状态,即显示带轮廓的心形图标及数值999,并通常通过弹窗向用户提示,即短暂出现后又自动消失的简短消息,例如“无法保存。请再试一次。”
分三步实现该模式
仅仅改变变量并期望请求能成功只是猜测,而非真正的设计思路。可靠的乐观更新会将三项职责分开:首先捕获当前状态,接着显示预期的结果,最后再与服务器的反馈进行比对。
第一步:捕获快照
在屏幕上的任何内容发生变化之前,先记录用户当前能够看到的数值,比如帖子是否被点赞以及当前的点赞数。处理函数会接收该帖子、一个状态设置函数和一个提示消息函数,同时导入它稍后会使用的 API 调用:
// likeButton.js
import { updateLikeOnServer } from "./api.js";
async function handleLikeClick(post, setPost, showToast) {
const previousState = { liked: post.liked, likeCount: post.likeCount };
// act two goes here
// act three goes here
}
完成这一步后,你就得到了一个完整且未被修改的对象,它精确描述了屏幕上的内容,一旦出现异常就可以用它来恢复状态。将这两个字段复制到新对象中非常重要:如果你保留了对可变对象的引用,而该对象后来发生了变化,那么“快照”也会随之改变。
步骤 2:应用预期结果
接下来,在网络请求开始之前,直接更新用户界面:
async function handleLikeClick(post, setPost, showToast) {
const previousState = { liked: post.liked, likeCount: post.likeCount };
setPost({ liked: true, likeCount: post.likeCount + 1 });
// act three goes here
}
此时屏幕上显示的是用户所请求的内容——一颗红色心形图标以及更高的点赞数,而服务器还一无所知。在编写自己的代码时需要注意一点:此示例传递的是一个仅包含liked和likeCount属性的全新对象。如果你的设置方法直接替换整个数据对象而非合并字段,那么应将原有对象的属性(如id)也同步到新对象中,以避免丢失这些信息。
步骤3:与服务器同步
最后,发送真正的请求,并决定是保持当前的乐观状态还是将其重置。该请求被放在try块中;如果请求出错,处理程序会恢复之前的数据状态并显示提示信息:
async function handleLikeClick(post, setPost, showToast) {
const previousState = { liked: post.liked, likeCount: post.likeCount };
setPost({ liked: true, likeCount: post.likeCount + 1 });
try {
await updateLikeOnServer(post.id);
} catch (error) {
setPost(previousState);
showToast("Couldn't save that. Try again.");
}
}
该API辅助函数使用了浏览器的fetch方法。需要注意的是,fetch仅在网络故障时才会拒绝请求,而不会因HTTP错误状态码而拒绝,因此该辅助函数会检查response.ok并显式抛出异常。如果没有这一检查,500状态码的响应也会被视为成功,从而导致回滚操作永远不会执行:
// api.js
export async function updateLikeOnServer(postId) {
const response = await fetch(`/api/posts/${postId}/like`, {
method: "POST",
});
if (!response.ok) {
throw new Error("Like request failed");
}
return response.json();
}
完成第三步后只有两种可能的结果:要么请求成功且乐观状态变为真,要么请求失败且用户界面恢复到第一步时的状态快照。在这两种情况下,界面都不会显示后端在请求失败前无法处理的内容。
乐观更新的优势所在
只有当回滚操作成本较低且速度对用户体验至关重要时,额外的结构设计才是有价值的。有三类场景始终符合这一特点:
- 社交互动。点赞、关注和回复的代价较低。如果每天多次点击中有一次失败,用户只需再次点击即可。
- 消息发送。在WhatsApp或Slack等聊天应用中,网络不佳时发送的消息会立即显示在对话中,但通常颜色较浅或带有小的灰色时钟图标。这是一种乐观的提示,表明消息在到达服务器之前就已显示在屏幕上。
- 拖放式看板。如果在像Jira或Trello这样的看板中将卡片拖入“已完成”列时,卡片需要等待确认,则操作体验会如同在湿沙中拖动一般。理想情况下,卡片会立即到位;如果操作失败,则会回到原来的列中。
消息通知功能中的一个有用改进就是待处理状态指示器。它能够显示某项操作尚未确认,这样既能让你快速响应,又能保持诚实,从而在出现回滚时减少用户的意外感。
乐观主义何时会变得危险
现在想象一个银行应用。租户点击“发送”将五千美元的租金转给房东。如果采用乐观式的更新方式,屏幕会立即显示转账成功且余额已归零,用户便以为租金已支付而关闭应用。
在幕后,该请求实际上已经失败;无论是由于资金不足还是转账被拒都无关紧要。用户会一直以为钱已经到账,直到房东打电话询问租金问题。这至少会导致需要提交支持工单以及信任关系受损,而问题的根源在于原本为低成本、可撤销的操作设计的流程被应用到了那些出错会带来实际后果的场景中。
同样的谨慎态度也适用于以下几类相关场景:
- 金融交易。应显示加载中提示。这种等待状态能表明系统在确认之前确实已经验证了该操作。
- 不可撤销的删除操作。当有人删除自己的账户时,屏幕不应立即清空,而要等到服务器确认数据确实已消失为止。
决定性的标准并非某个操作看起来有多重要,而是出错会带来多大的代价。让用户多等待三秒查看加载状态,远比在资金实际未转移时却告知他们已转账要好。
当两次点击同时发生时
还有一种故障模式,只有当用户在第一次请求完成前再次操作时才会出现。以那趟慢车为例,用户先给帖子点赞,将其评分从999改为1,000,随即又点踩,评分变回999。假设点赞需要五秒完成,而点踩只需两秒,那么点踩的操作可能会先执行完毕。
服务器随后会处理那次未被视为“已点赞”的“不点赞”操作,因此不会发生任何变化;几秒钟后它又会处理“点赞”操作。最终服务器端的账号状态显示为已点赞,而屏幕上却显示出与用户上次操作相反的结果。当最终状态是根据请求到达的顺序而非用户操作的顺序来决定的时候,就会出现竞态条件。
使用AbortController取消过期的请求
常见的解决办法是取消操作:当有针对同一资源的 newer 请求开始时,告诉浏览器放弃之前的那个请求。内置的AbortController可以取消正在传输中的fetch请求。示例代码会保留对当前控制器的引用,在发起新请求之前先取消它,然后根据期望的状态选择使用POST还是DELETE:
// likeButton.js
let activeRequest = null;
async function toggleLike(postId, shouldLike) {
if (activeRequest) {
activeRequest.abort();
}
const controller = new AbortController();
activeRequest = controller;
await fetch(`/api/posts/${postId}/like`, {
method: shouldLike ? "POST" : "DELETE",
signal: controller.signal,
});
}
每次新的点击都会取消之前的请求,因此只有最新的操作才会生效。在仅依赖此机制之前,有两点需要注意:
- 中止请求会阻止浏览器等待响应;但这并不能保证服务器从未接收或处理过该请求。若需更强的保障,应围绕期望的最终状态(是否点赞)设计接口,使重复或顺序错乱的请求最终能达成一致,或者添加序列号以便服务器忽略过时的写入。
- 被中止的
fetch请求会抛出AbortError错误。如果将此与第三步中的回滚操作结合使用,需检查该错误并跳过回滚步骤,否则取消过时的请求可能会错误地撤销最新的乐观状态。
在 React 应用中,数据库处理了大部分相关逻辑。负责管理服务器状态缓存和请求生命周期的 TanStack Query,以及用于编写 Redux 逻辑的官方工具集 Redux Toolkit,都提供了对乐观更新和请求取消的内置支持,因此你很少需要手动编写 AbortController 相关代码。请查阅它们的文档,了解每种情况下会被取消的具体内容;理解其背后的机制仍然很有价值。若想更深入地了解 React 自带钩子中回滚功能出错的原因,可参阅 useOptimisticRollback 的五种失败模式。
关键要点
- 网络往返的时间可能足够长,导致原本正常运行的应用看起来出现故障,因此乐观更新会先显示预期的结果,稍后再进行确认。
fetch函数不会因HTTP错误而拒绝请求,因此应在!response.ok时抛出异常,并在回滚时通过提示信息告知用户。AbortError,并优先使用能设置最终状态而非仅切换状态的接口。速度并非网络会免费提供的功能。乐观型界面承认自己无法控制连接,但仍会自信地回应用户,并在发现错误时立即撤回该响应。