Skip to content
乐观更新与缓存操作

乐观更新与缓存操作

乐观更新(Optimistic Update)是提升用户体验的关键技术:在服务端响应之前就更新 UI,如果失败再回滚。本章详解乐观更新的标准模式、QueryClient 的所有缓存操作方法,以及各种场景下的实现。


乐观更新的标准流程

mutate(variables)
    │
    ▼
onMutate:
  1. cancelQueries   ← 取消进行中的查询,防止竞态
  2. getQueryData    ← 快照当前缓存
  3. setQueryData    ← 乐观地写入新数据
  4. return context  ← 返回快照用于回滚
    │
    ▼
mutationFn 执行(网络请求)
    │
    ├── 成功 ──→ onSettled: invalidateQueries  ← 与服务端同步
    │
    └── 失败 ──→ onError: setQueryData(old)    ← 回滚到快照
                    └──→ onSettled: invalidateQueries

QueryClient 缓存操作 API 总览

方法 用途 典型场景
getQueryData(key) 读取缓存 快照当前数据
setQueryData(key, updater) 直接写入缓存 乐观更新、数据转换
getQueriesData(filters) 批量读取 失效前批量保存
setQueriesData(filters, updater) 批量写入 批量乐观更新
getQueryState(key) 获取查询状态(不含数据) 判断查询是否在 fetching
invalidateQueries(filters) 标记失效 → 后台重取 mutation 成功后同步
removeQueries(filters) 从缓存中删除 登出清理、数据不再需要
refetchQueries(filters) 强制重新获取 强制刷新
cancelQueries(filters) 取消进行中的请求 乐观更新前置步骤
resetQueries(filters) 重置为初始状态 重置表单查询
isFetching(filters) 检查是否有查询在 fetching 全局 loading 判断
isMutating(filters) 检查是否有 mutation 在进行 页面离开前确认

场景一:列表添加(最常用)

const queryClient = useQueryClient()

const addTodo = useMutation({
  mutationFn: (newTodo: CreateTodoInput) => api.createTodo(newTodo),

  onMutate: async (newTodo) => {
    // 1. 取消进行中的查询
    await queryClient.cancelQueries({ queryKey: ['todos'] })

    // 2. 快照当前数据
    const previousTodos = queryClient.getQueryData(['todos'])

    // 3. 乐观地添加新项(用临时 ID)
    queryClient.setQueryData(['todos'], (old: Todo[] = []) => [
      ...old,
      { id: 'temp-' + Date.now(), ...newTodo },
    ])

    // 4. 返回快照用于回滚
    return { previousTodos }
  },

  onError: (error, newTodo, context) => {
    // 回滚到快照
    queryClient.setQueryData(['todos'], context?.previousTodos)
    toast.error('添加失败,已回滚')
  },

  onSettled: () => {
    // 无论成败,最终与服务端同步
    queryClient.invalidateQueries({ queryKey: ['todos'] })
  },
})

场景二:列表删除

const deleteTodo = useMutation({
  mutationFn: (id: number) => api.deleteTodo(id),

  onMutate: async (deletedId) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] })
    const previousTodos = queryClient.getQueryData(['todos'])

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

    return { previousTodos }
  },

  onError: (error, deletedId, context) => {
    queryClient.setQueryData(['todos'], context?.previousTodos)
    toast.error('删除失败,已恢复')
  },

  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] })
  },
})

场景三:更新单条数据

const updateTodo = useMutation({
  mutationFn: (updated: Todo) => api.updateTodo(updated),

  onMutate: async (updatedTodo) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] })
    const previousTodos = queryClient.getQueryData(['todos'])

    queryClient.setQueryData(['todos'], (old: Todo[] = []) =>
      old.map(t => t.id === updatedTodo.id ? { ...t, ...updatedTodo } : t)
    )

    return { previousTodos }
  },

  onError: (error, variables, context) => {
    queryClient.setQueryData(['todos'], context?.previousTodos)
  },

  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] })
  },
})

场景四:详情页更新

const updateTodo = useMutation({
  mutationFn: (updated: Todo) => api.updateTodo(updated),

  onMutate: async (updatedTodo) => {
    // 同时取消列表缓存和详情缓存
    await queryClient.cancelQueries({ queryKey: ['todos'] })
    await queryClient.cancelQueries({ queryKey: ['todo', updatedTodo.id] })

    // 快照列表
    const previousTodos = queryClient.getQueryData(['todos'])
    // 快照详情
    const previousTodo = queryClient.getQueryData(['todo', updatedTodo.id])

    // 乐观更新列表
    queryClient.setQueryData(['todos'], (old: Todo[] = []) =>
      old.map(t => t.id === updatedTodo.id ? { ...t, ...updatedTodo } : t)
    )
    // 乐观更新详情
    queryClient.setQueryData(['todo', updatedTodo.id], updatedTodo)

    return { previousTodos, previousTodo }
  },

  onError: (error, variables, context) => {
    queryClient.setQueryData(['todos'], context?.previousTodos)
    queryClient.setQueryData(['todo', variables.id], context?.previousTodo)
  },

  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] })
    // 详情页如果有自己的查询键需要无效化
  },
})

cancelQueries 的重要性

// ❌ 没有 cancelQueries:竞态风险
onMutate: async (newTodo) => {
  const previous = queryClient.getQueryData(['todos'])
  queryClient.setQueryData(['todos'], (old) => [...old, newTodo])
  // 如果此时有一个进行中的 fetchTodos 完成,
  // 它会用服务端数据覆盖我们的乐观更新!
  return { previous }
}

// ✅ 有 cancelQueries:安全
onMutate: async (newTodo) => {
  await queryClient.cancelQueries({ queryKey: ['todos'] })
  // 确保不会有进行中的请求覆盖乐观数据
  const previous = queryClient.getQueryData(['todos'])
  queryClient.setQueryData(['todos'], (old) => [...old, newTodo])
  return { previous }
}

🚨 陷阱:忘记 cancelQueries 是最常见的乐观更新 Bug。进行中的查询会在乐观写入后返回,用服务端数据覆盖乐观数据,导致 UI 闪烁(乐观数据闪现后消失)。


批量操作

const batchDelete = useMutation({
  mutationFn: (ids: number[]) => api.deleteTodos(ids),

  onMutate: async (ids) => {
    // cancel 列表查询
    await queryClient.cancelQueries({ queryKey: ['todos'] })
    const previousTodos = queryClient.getQueryData(['todos'])

    // 乐观删除
    queryClient.setQueryData(['todos'], (old: Todo[] = []) =>
      old.filter(t => !ids.includes(t.id))
    )

    // 同时乐观删除每条详情缓存
    const previousDetails = new Map()
    for (const id of ids) {
      previousDetails.set(id, queryClient.getQueryData(['todo', id]))
      queryClient.removeQueries({ queryKey: ['todo', id] })
    }

    return { previousTodos, previousDetails }
  },

  onError: (error, ids, context) => {
    if (context) {
      queryClient.setQueryData(['todos'], context.previousTodos)
      // 恢复详情缓存
      context.previousDetails.forEach((data, id) => {
        if (data) queryClient.setQueryData(['todo', id], data)
      })
    }
  },

  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] })
  },
})

getQueriesData / setQueriesData — 批量操作

当需要同时操作多个相关查询时:

// 项目名称修改后,所有项目相关的查询都要更新
const renameProject = useMutation({
  mutationFn: ({ id, name }: { id: number; name: string }) =>
    api.renameProject(id, name),

  onMutate: async ({ id, name }) => {
    // 批量获取所有项目相关查询
    const previousData = queryClient.getQueriesData({
      queryKey: ['projects'],
    })

    // 批量更新
    queryClient.setQueriesData({ queryKey: ['projects'] }, (old: any) => {
      // 根据数据结构判断并更新项目名
      if (Array.isArray(old)) {
        return old.map(p => p.id === id ? { ...p, name } : p)
      }
      if (old?.id === id) {
        return { ...old, name }
      }
      return old
    })

    return { previousData }
  },

  onError: (error, variables, context) => {
    // 批量恢复
    context?.previousData.forEach(([key, data]) => {
      queryClient.setQueryData(key, data)
    })
  },

  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['projects'] })
  },
})

不做乐观更新的方案

并非所有 mutation 都需要乐观更新。简单场景直接 invalidateQueries 就够:

const createTodo = useMutation({
  mutationFn: api.createTodo,
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] })
    toast.success('创建成功')
  },
})

💡 最佳实践:决策树 — 如果操作需要即时反馈(如点赞、拖拽排序),用乐观更新;如果操作慢但有明确进度(如文件上传),用普通 mutation + loading 状态;如果是后台操作(如同步配置),invalidateQueries 足够了。


乐观更新的风险与权衡

优势 风险
即时 UI 反馈 实现复杂、容易出错
用户感知速度快 乐观数据可能与服务端不一致
减少用户焦虑 回滚可能导致 UI 闪烁

💡 最佳实践:乐观更新中使用的数据应尽量来自客户端的输入值(而非推测值)。例如用户提交了表单,你知道用户输入了什么,可以乐观地将其加入列表——因为即便服务端做了转换,差异通常较小。但不要乐观地推测服务端会如何转换数据——那不叫乐观,那叫猜测。