React 中的乐观更新:我是在一次评论交互改造里真正把它用明白的

0 阅读

我第一次认真把乐观更新用进 React 项目,不是在点赞按钮这种玩具级例子里,而是在一个内部内容系统的评论区。

那个页面表面上不复杂:用户输入评论,点击发送,评论出现在列表里。需求也很普通,谁都觉得不难。真正把它做进业务之后,问题很快就出来了。

最开始的实现很直白。用户点击“发送”后,按钮进入 loading,等接口返回成功,再把新评论插进列表。逻辑一点都不花哨,但体验非常差。公司内网环境本来就不稳定,再加上评论接口还会顺手做审核、敏感词和通知,正常情况下一次请求经常要几百毫秒,偶尔还会上 1 秒。结果就是用户每次点完发送,页面都像卡了一下。

最烦的是,这种迟钝不是“性能慢”的那种大问题,它更像一种持续的小摩擦。用户会不自觉地点第二次,会怀疑刚刚有没有发成功,会盯着按钮看。你从埋点里也能看到,评论提交后的二次点击率很高,但接口明明没有真正失败。

一开始我用的是最常见的临时状态写法

我最早的处理方式其实很传统:

  1. 本地先维护一份 comments
  2. 再维护一份 isSubmitting
  3. 提交时手动把一条临时评论插进去
  4. 请求回来后用真实评论替换掉临时项
  5. 如果失败,再把临时项删掉

这套方案不是不能用,问题在于它很快就会长出一堆边角逻辑。

比如:

  • 临时评论的 id 用什么生成
  • 用户连续发两条时,哪条先回来
  • 如果接口失败,输入框里的文字要不要恢复
  • 列表重新拉取时,临时项和真实项怎么合并

这些东西单看都不大,但它们会让“提交一条评论”这件事越来越像在修一套同步系统,而不是写一个交互。

那次改造到后面,我最大的感受不是“乐观更新可以让页面更快”,而是如果你继续手搓两套状态,迟早会把自己绕进去。

真正的问题不是快不快,而是状态到底该由谁来表达

我后来回头看,当时最别扭的点在于:我一边想让界面先表现得像成功了,一边又不想把真实状态提前写死。

这其实是两种状态:

  • 用户当前应该看到什么
  • 服务器最终确认了什么

以前我把这两层混在一起处理,所以写到后面只能不断补丁。React 的 useOptimistic 给我的帮助,不是“提供一个新 Hook”,而是把这两层状态硬拆开了。

你可以先明确地告诉 React:提交中的界面长什么样。然后等真实请求回来,再把真实状态落进去。这个分工一旦清楚,代码会安静很多。

我后来是怎么改的

改造之后,我把评论提交拆成两段:

第一段负责立即反馈。用户点发送的那一刻,先用 useOptimistic 往列表里加一条带 pending 标记的评论,这样界面立刻变化,输入框也能马上清空。

第二段负责异步收口。接口成功后,把服务端返回的真实评论写回去;失败的话,就把这条临时评论回滚掉,并把输入内容恢复出来。

核心写法和官方文档思路差不多,但我在业务里更在意的是三件事:

1const [optimisticComments, addOptimisticComment] = useOptimistic(
2  comments,
3  (currentComments, newComment) => [
4    ...currentComments,
5    { ...newComment, pending: true }
6  ]
7);
8
9function handleSubmit(text) {
10  const tempComment = {
11    id: crypto.randomUUID(),
12    text
13  };
14
15  startTransition(async () => {
16    addOptimisticComment(tempComment);
17    await saveComment(tempComment);
18  });
19}

第一,临时评论必须能被稳定识别。否则后面失败回滚、成功替换都会很难做。

第二,失败路径不能是“以后再说”。我一开始就是在这件事上吃亏,导致偶发失败时列表里会留下幽灵评论。

第三,乐观更新最好局部做,不要一上来改整个页面的共享状态。那次我只把评论列表这一段先改了,没有顺手去动评论数、消息中心、右侧推荐这些联动区域。事实证明这是对的,因为局部先跑通以后,边界会清楚很多。

startTransition 在这里帮我的,不是“异步”,而是优先级

很多人第一次看到 startTransition,会下意识把它理解成异步包装器。实际开发里我更把它当成“让 React 知道这次更新不必抢最高优先级”。

像评论提交这种交互,用户最在意的是:

  • 按钮点下去立刻有反馈
  • 输入框别卡
  • 列表看起来已经变了

至于后面那次真实状态写回,并不是最强实时的那种更新。startTransition 的好处就在这里,它不会把所有状态更新一股脑地压到最前面。页面整体的手感会更平顺,不是那种“逻辑都对,但总觉得哪里发沉”的状态。

不是所有交互都该乐观更新

那次改造之后,我有一段时间很上头,觉得很多操作都可以乐观一点。后来很快就收住了,因为有些地方用起来确实不合适。

我现在基本用这几个标准判断:

  • 成功率高不高
  • 失败后能不能自然回滚
  • 即时反馈值不值那点复杂度

点赞、收藏、关注、评论追加,这些通常适合。

支付、库存扣减、权限切换、关键配置保存,这些我就会谨慎很多。因为一旦失败,用户看到的不是“界面闪了一下”,而是对系统信任直接下降。

这类改造最后给我的一个感受

我后来越来越觉得,前端很多交互问题表面上像是“等待太久”,其实根子在状态建模。

如果你的代码里只有“提交前”和“提交后”两种状态,那你只能让用户等。可真实交互里明明还有一种状态很重要,就是“用户已经做了动作,系统也应该先给反应,但后端还没最终确认”。

useOptimistic 的价值就在这里。它不是为了让页面看起来花哨一点,而是让这层状态终于有了正式写法。

写在最后

我现在看乐观更新,已经不太把它当作“体验优化技巧”了,更像是一种交互建模方式。

当你明确区分“用户现在应该看到什么”和“服务器最终确认了什么”之后,很多原来看起来只能靠补丁处理的逻辑,都会顺下来。React 这两个 API 好用的地方,不是新,而是它们终于把这件事讲清楚了。