陌上人如玉
公子世无双

7.6 乐观更新

7.6 乐观更新

一句话:乐观更新是「在请求返回之前先假设它会成功、立刻更新界面」,失败时再回滚——它显著提升「操作手感」,但只适合「大概率成功、可回滚」的操作。

一、什么是乐观更新

用户「点赞」:
❌ 悲观(默认):点击 → 按钮转圈 → 等接口返回 → 变「已赞」(几十~几百 ms 的等待)
✅ 乐观:        点击 → 立刻变「已赞」→ 后台请求 → 失败才变回来

用户感受:
· 悲观 → 「有点卡」(每次操作都要等)
· 乐观 → 「秒响应」(操作立刻生效)

代价:
· 需要「回滚」逻辑(失败时把界面改回去)
· 需要「提示失败」(否则用户不知道其实没成功)
· 只适合「成功率很高」的操作(否则回滚会频繁发生,体验更差)
⭐ 适用范围:
✅ 适合:点赞/收藏、开关切换、勾选完成、删除列表项、快捷编辑标题
   (特点:操作简单、成功率高、失败易回滚、幂等性好做)
❌ 不适合:支付、下单、转账、涉及金额或不可逆的操作
   (特点:失败代价大、需要明确确认与结果反馈)

二、手动实现(TanStack Query)

function useToggleLike() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (postId: string) => api.toggleLike(postId),

    // ① 请求发出【前】:乐观更新
    onMutate: async (postId) => {
      // ⭐ 关键:先取消「进行中的相关查询」,否则它们返回后会覆盖乐观数据
      await queryClient.cancelQueries({ queryKey: ['post', postId] });

      // ⭐ 保存旧数据,供失败时回滚
      const previous = queryClient.getQueryData<Post>(['post', postId]);

      // ⭐ 立刻写入「乐观结果」
      queryClient.setQueryData<Post>(['post', postId], (old) =>
        old ? { ...old, liked: !old.liked, likeCount: old.likeCount + (old.liked ? -1 : 1) } : old
      );

      // 返回值会作为 context 传给 onError
      return { previous };
    },

    // ② 失败时:回滚
    onError: (_err, postId, context) => {
      if (context?.previous) {
        queryClient.setQueryData(['post', postId], context.previous);
      }
      toast.error('操作失败,请重试');
    },

    // ③ 无论成败:以服务端为准,重新拉取一次(保证最终一致)
    onSettled: (_data, _err, postId) => {
      queryClient.invalidateQueries({ queryKey: ['post', postId] });
    },
  });
}
// 使用
function LikeButton({ post }: { post: Post }) {
  const { mutate, isPending } = useToggleLike();

  return (
    <button onClick={() => mutate(post.id)} aria-pressed={post.liked} disabled={isPending}>
      {post.liked ? '❤️' : '🤍'} {post.likeCount}
    </button>
  );
}
三个回调的职责(务必理解):

onMutate     → 取消进行中的查询 + 保存旧数据 + 写入乐观数据(返回用于回滚的 context)
onError      → 用 context 回滚
onSettled    → 无论成功失败都「失效并重新拉取」(最终以服务端为准)

⚠️ 为什么 onSettled 里还要 invalidate?
   因为乐观更新是「猜的」——服务端可能做了额外处理
   (如服务端返回的 likeCount 与本地计算不同)。
   invalidate 一次能保证「最终数据正确」。

三、「列表项」的乐观更新

// ✅ 场景:在列表里删除一项
function useDeleteTodo() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (id: string) => api.deleteTodo(id),

    onMutate: async (id) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });
      const previous = queryClient.getQueryData<Todo[]>(['todos']);

      queryClient.setQueryData<Todo[]>(['todos'], (old = []) => old.filter((t) => t.id !== id));

      return { previous };
    },
    onError: (_e, _id, ctx) => {
      queryClient.setQueryData(['todos'], ctx?.previous);
      toast.error('删除失败');
    },
    onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
  });
}
// ✅ 场景:新增一项(用临时 ID 占位)
function useAddTodo() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (text: string) => api.addTodo({ text }),

    onMutate: async (text) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });
      const previous = queryClient.getQueryData<Todo[]>(['todos']);

      const optimistic: Todo = {
        id: `temp-${crypto.randomUUID()}`,     // ⭐ 临时 ID
        text,
        done: false,
        pending: true,                          // ⭐ 标记「待确认」(UI 上可以半透明)
      } as Todo;

      queryClient.setQueryData<Todo[]>(['todos'], (old = []) => [...old, optimistic]);
      return { previous };
    },
    onError: (_e, _text, ctx) => {
      queryClient.setQueryData(['todos'], ctx?.previous);
      toast.error('添加失败');
    },
    onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
  });
}
// UI 上「标记待确认」的做法
{todos.map((t) => (
  <li key={t.id} style={{ opacity: t.pending ? 0.5 : 1 }}>
    {t.text}
  </li>
))}

四、React 19 的 useOptimistic

// ✅ React 19 提供了原生的乐观更新 Hook(配合 Actions)
import { useOptimistic, useActionState } from 'react';

function CommentList({ comments, addComment }: { comments: Comment[]; addComment: (text: string) => Promise<void> }) {
  const [optimisticComments, addOptimistic] = useOptimistic(
    comments,                                       // 真实数据
    (current, newText: string) => [                // 如何「乐观地」计算新状态
      ...current,
      { id: `temp-${Math.random()}`, text: newText, pending: true },
    ]
  );

  async function formAction(formData: FormData) {
    const text = formData.get('text') as string;

    addOptimistic(text);                            // ⭐ 立刻更新 UI
    await addComment(text);                          // 后台提交(失败时 React 自动回滚)
  }

  return (
    <>
      <ul>
        {optimisticComments.map((c) => (
          <li key={c.id} style={{ opacity: c.pending ? 0.5 : 1 }}>{c.text}</li>
        ))}
      </ul>
      <form action={formAction}>
        <input name="text" />
        <button>发送</button>
      </form>
    </>
  );
}
`useOptimistic` 的特点:
① 它基于「真实数据 + 乐观变换函数」——乐观状态是「临时叠加」在真实数据上的
② 「回滚」是【自动的】:一旦 action 结束(无论成败),乐观状态就被丢弃,
   界面回到「真实数据」的渲染结果
③ 必须在 Action(或 transition)内部调用「添加乐观更新」的函数
④ 与 `useActionState` / `<form action>` 配合最好

对比「手动实现」(TanStack Query):
· useOptimistic 更简洁(不用管回滚)
· TanStack Query 的 onMutate 更灵活(可以精细控制缓存与失效)
方案 回滚 适用
TanStack Query onMutate 手动(保存 previous +onError 回滚) 用 Query 管理数据的项目
React 19 useOptimistic 自动(action 结束即丢弃乐观状态) 用 Actions/表单的项目
手写(useState + 回滚) 手动 不想引入依赖的简单场景

五、失败处理策略

// ✅ 三层处理:回滚 → 提示 → 可选重试
onError: (err, variables, context) => {
  // ① 回滚
  queryClient.setQueryData(['todos'], context?.previous);

  // ② 明确提示(用户需要知道「刚才那下没成功」)
  toast.error({
    message: '操作失败,已恢复原状',
    action: { label: '重试', onClick: () => mutate(variables) },   // ③ 提供重试入口
  });
}
⚠️ 乐观更新的失败处理必须「让用户知道」:
· 因为界面已经「看起来成功了」,如果不提示,用户会以为操作生效了
· 常见做法:回滚 + toast「操作失败,已恢复」
· 更好的做法:toast 里带「重试」按钮
· 对于「列表项」:可以让它保留为「失败状态」(红色 + 重试按钮)而不是直接消失
// ✅ 更友好的做法:让「失败的那一项」留在列表里,标记失败并提供重试
// (而不是直接回滚消失——用户可能不知道刚才点了什么)
{todos.map((t) => (
  <li key={t.id}>
    {t.text}
    {t.status === 'failed' && (
      <span className="error">
        发送失败 <button onClick={() => retry(t.id)}>重试</button>
      </span>
    )}
  </li>
))}

六、常见误区

① 对「不可逆操作」用乐观更新
   ❌ 支付、下单、转账、删除账号
   → 失败代价大,必须明确确认与结果反馈

② 只用 onMutate 不用 onSettled
   → 服务端可能与本地计算不一致(如 likeCount),数据会「漂移」
   → 必须 invalidate 一次保证最终一致

③ 忘记 cancelQueries
   → 进行中的查询返回后会「覆盖乐观数据」→ 界面闪回旧状态

④ 忘记保存 previous
   → 失败时无法回滚

⑤ 有多个「乐观更新的字段」时,只回滚其中一个
   → 要整体替换(用 previous 整个对象)

⑥ 在「乐观更新的同时」显示 loading 遮罩
   → 与乐观更新的意义矛盾(应该不阻塞用户)

⑦ 对「需要服务端生成的 ID/时间戳」的场景直接乐观
   → 要用「临时 ID + 标记 pending」,等服务端返回后替换

⑧ 并发点击多次(乐观状态叠加出错)
   → 用 `isPending` 禁用按钮,或用「基于当前乐观值计算」的变换函数

⑨ 认为「乐观更新 = 不用处理错误」
   → 恰恰相反:它把「错误处理」变成了必须项

⑩ 在列表里用「乐观更新 + index 作 key」
   → 临时项与真实项混淆(必须用稳定 ID,临时项也要有唯一 ID)

七、常见坑

// ① 没取消进行中的查询
onMutate: async () => {
  queryClient.setQueryData(['x'], optimistic);   // ⚠️ 可能在 100ms 后被旧请求覆盖
}
// ✅ await queryClient.cancelQueries({ queryKey: ['x'] });

// ② 没保存 previous
// ③ 没在 onSettled 里 invalidate(数据可能漂移)
// ④ 用「闭包里的旧数据」计算乐观值
onMutate: () => {
  queryClient.setQueryData(['todos'], [...todos, item]);   // ⚠️ todos 可能是旧快照
}
// ✅ 用函数式:setQueryData(['todos'], (old = []) => [...old, item])

// ⑤ 乐观值算成「非幂等」(重复点击导致重复添加)
// ⑥ 用 Math.random() 作临时 ID(可能与真实 ID 冲突/难以去重)
//    ✅ 用 `temp-${crypto.randomUUID()}`
// ⑦ 失败回滚后「不清除 pending 标记」(列表项一直半透明)
// ⑧ 对「带金额的操作」做乐观更新
// ⑨ 在 useOptimistic 里「不在 action 中调用 addOptimistic」
//    → React 19 要求在 action/transition 内调用
// ⑩ 把 useOptimistic 的数据「当成真实数据」用于后续计算(它随时会被丢弃)
// ⑪ 在「多页面共享同一份缓存」时只更新了当前页面的 key
//    → 用 invalidateQueries 的前缀匹配统一刷新
// ⑫ 忘记「失败时的可访问性提示」(role="alert")
// ⑤ 的修复:用「基于当前乐观值」的变换 + 禁用按钮
const { mutate, isPending } = useToggleLike();

<button onClick={() => mutate(post.id)} disabled={isPending}>   {/* ⭐ 防连点 */}
  {post.liked ? '❤️' : '🤍'}
</button>

八、面试延伸

  1. 「什么是乐观更新?」
    乐观更新指「在请求返回之前就假设它会成功、立刻更新界面」——用户点击后界面立即响应(而不是等接口返回),如果请求失败再把界面回滚并提示用户。它的价值是显著提升操作手感(把「几十到几百毫秒的等待」变成「瞬间响应」),代价是「需要回滚逻辑 + 必须处理失败提示」。它只适合「成功率很高、失败可回滚、操作不可逆性低」的场景(如点赞、收藏、勾选、删除列表项)。

  2. 「TanStack Query 里怎么实现乐观更新?」
    用 useMutation 的三个回调:① onMutate——先 await queryClient.cancelQueries(...)(取消进行中的查询,否则它们返回后会覆盖乐观数据)、保存旧数据(getQueryData,供回滚)、写入乐观数据(setQueryData),并 return 旧数据作为 context;② onError——用 context 里保存的旧数据回滚,并提示用户;③ onSettled——无论成败都 invalidateQueries(重新拉取,保证与服务端最终一致,因为乐观值是「猜的」)。

  3. 「为什么 onMutate 里要 cancelQueries?」
    因为「乐观更新」是「在本地缓存上先改一步」,但可能有正在进行的查询(如「组件挂载时触发的重新验证」)。如果那个请求在你乐观更新之后返回,它会用旧数据覆盖你刚写入的乐观数据——界面会「闪回旧状态」。所以标准做法是在 onMutate 里先 await cancelQueries(...)(把进行中的请求取消掉),再写乐观数据。

  4. 「为什么 onSettled 里还要 invalidateQueries?」
    因为乐观更新的数据是「客户端猜的」——服务端可能做了额外处理(如服务端计算的 likeCount、生成的 id、时间戳、排序)。如果只依赖乐观值,数据会「漂移」直到下次刷新。所以在 onSettled(无论成败)里做一次 invalidateQueries,能保证最终以服务端数据为准。这是「乐观更新 + 最终一致」的标准组合。

  5. 「React 19 的 useOptimistic 有什么不同?」
    它把「回滚」变成自动的:useOptimistic(realData, updateFn) 返回「乐观状态」——它是「临时叠加在真实数据上的」。你在 Action 里调用「添加乐观更新」的函数后,界面立刻更新;一旦 action 结束(无论成功失败),乐观状态就被丢弃,界面自动回到「真实数据」的渲染结果(失败时就是「回滚」)。它比手动方案更简洁(不用管 previous 与 cancelQueries),但灵活性不如 TanStack Query 的 onMutate(后者能精细控制缓存与失效时机)。

  6. 「什么操作不适合乐观更新?」
    四类:① 不可逆/高价值操作——支付、下单、转账、删除账号(失败代价大,必须明确确认与结果反馈);② 结果不确定或需要服务端决策的操作——如「抢购」「审批」(服务端可能拒绝);③ 成功率低的操作(回滚会频繁发生,反而更乱);④ 需要「服务端生成的关键数据」才能继续的操作(如创建后要跳转到生成的 ID 页面)。判断标准是「假设它成功,风险可接受吗」。

  7. 「乐观更新失败时,怎么让用户知道?」
    三层:① 回滚(把界面改回真实状态);② 明确提示(toast「操作失败,已恢复原状」——必须提示,因为用户看到过「成功」的样子);③ 提供重试入口(toast 里带「重试」按钮,或让「失败的那一项」留在列表里并标记失败 + 重试)。对于「列表项」,更友好的做法是「不直接回滚消失,而是标记为「发送失败」+ 重试按钮」——因为用户可能不记得刚才点了什么。

  8. 「怎么处理『乐观更新 + 并发点击』?」
    两个手段:① 禁用按钮——用 isPending(TanStack Query)或在提交中禁用(React 19 的 useFormStatus/useActionState 的 isPending),避免重复提交;② 用「基于当前值」的变换函数——写乐观数据时用函数形式 setQueryData(key, (old) => f(old)) 而不是「闭包里的旧快照」,这样多次调用会依次叠加而不是互相覆盖。此外「临时项的 ID」要用唯一值(如 temp-${randomUUID()}),避免去重时冲突。

一句话速记

乐观更新 = 「先假设成功、立刻更新界面,失败再回滚」(提升手感,但需回滚 + 必须提示失败);只适合「成功率高、可回滚、低风险」的操作(点赞/收藏/勾选/删除),不适合支付/下单/转账;TanStack Query 的标准实现是「onMutate:await cancelQueries → 保存 previous → setQueryData 写乐观值 → onError 回滚 → onSettled invalidateQueries 保证最终一致」;React 19 的 useOptimistic 回滚是自动的(action 结束即丢弃乐观状态);失败要「回滚 + toast(带重试)」,列表项可「保留为失败态 + 重试」;并发点击要禁用按钮并用「函数式 setQueryData」避免覆盖。

赞(0) 打赏
未经允许不得转载:陌上寒 » 7.6 乐观更新

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫