乐观更新与缓存操作
乐观更新(Optimistic Update)是提升用户体验的关键技术:在服务端响应之前就更新 UI,如果失败再回滚。本章详解乐观更新的标准模式、QueryClient 的所有缓存操作方法,以及各种场景下的实现。
乐观更新的标准流程
mutate(variables)
│
▼
onMutate:
1. cancelQueries ← 取消进行中的查询,防止竞态
2. getQueryData ← 快照当前缓存
3. setQueryData ← 乐观地写入新数据
4. return context ← 返回快照用于回滚
│
▼
mutationFn 执行(网络请求)
│
├── 成功 ──→ onSettled: invalidateQueries ← 与服务端同步
│
└── 失败 ──→ onError: setQueryData(old) ← 回滚到快照
└──→ onSettled: invalidateQueriesQueryClient 缓存操作 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 闪烁 |
💡 最佳实践:乐观更新中使用的数据应尽量来自客户端的输入值(而非推测值)。例如用户提交了表单,你知道用户输入了什么,可以乐观地将其加入列表——因为即便服务端做了转换,差异通常较小。但不要乐观地推测服务端会如何转换数据——那不叫乐观,那叫猜测。