Angular Query 乐观更新(Optimistic Updates)完整实战:UI 变量渲染与缓存回滚双路径
导读
本文围绕 Angular Query(@tanstack/angular-query-experimental)的乐观更新能力展开,讲解在 mutation 尚未完成时如何让界面"提前"呈现预期结果。读完本文你将掌握两种相互补充的实现路径:一是通过 mutation 返回的 variables 直接在组件模板中临时渲染新条目(不触碰缓存、无需回滚);二是通过 onMutate/onError/onSettled 直接操作 QueryClient 缓存,配合快照实现失败回滚与重取。文章以仓库示例 examples/angular/optimistic-updates 及 inject-mutation.ts、inject-mutation-state.ts 源码为佐证,保证每一步都可在真实代码中落地。
一、什么是乐观更新,为什么需要它
在 Angular Query 中,mutation 代表服务端副作用(增删改),它不会像 query 那样自动执行,而是由你调用 mutate() 触发(见 inject-mutation.ts 的注释:"Unlike queries, mutations are not run automatically")。
"乐观更新"(Optimistic Updates)指的是:在 mutation 真正完成之前,先让 UI 假设它一定会成功并立刻呈现结果。这样用户无需等待网络往返即可看到反馈,感知速度大幅提升;真正的服务器数据随后通过失效重取(refetch)落回界面。
Angular Query 提供两种乐观更新的实现方式:
- 通过 UI(
variables):不动缓存,仅根据 mutation 的当前状态在模板中临时渲染; - 通过缓存(
onMutate):直接改写 QueryClient 缓存,让所有订阅该查询的位置同时更新。
二、方式一:通过 UI 临时渲染(不碰缓存)
这是更简单的变体:不直接与缓存交互,因此也不需要处理回滚逻辑。核心思路是在 mutation 处于 pending 状态时,把它的入参 variables 当作"临时条目"渲染到列表中。
2.1 定义 mutation 并保证 pending 状态延续到重取结束
下面的 injectMutation 发起新增 todo 的请求,并在 onSettled 中失效 todos 查询触发重取:
addTodo = injectMutation(() => ({
mutationFn: (newTodo: string) => axios.post('/api/data', { text: newTodo }),
// 务必 _return_ 失效查询返回的 Promise,
// 这样 mutation 会一直保持 pending 状态,直到重取(refetch)完成
onSettled: async () => {
return await queryClient.invalidateQueries({ queryKey: ['todos'] })
},
}))
关键点在于注释强调的行为:onSettled 要 return invalidateQueries(...) 的 Promise。如果忽略返回值,mutation 会立即从 pending 变为 settled,乐观条目将过早消失,出现"闪烁"。
2.2 在模板中用 isPending() + variables() 渲染临时条目
由于 Angular Query 的 mutation 结果是一个 signal 代理(详见第五节),模板中需要用函数调用的方式读取这些信号。在渲染 todos 查询的列表里,当 mutation isPending 时追加一个半透明条目:
@Component({
template: `
@for (todo of todos.data(); track todo.id) {
<li>{{ todo.title }}</li>
}
@if (addTodo.isPending()) {
<li style="opacity: 0.5">{{ addTodo.variables() }}</li>
}
`,
})
class TodosComponent {}
只要 mutation 处于 pending,我们就渲染一个 opacity: 0.5 的临时条目。当 mutation 完成、失效重取成功之后,该条目会自动消失——因为界面上会出现来自服务器的"正式条目";你几乎不会察觉它曾经只是一个乐观占位。
2.3 mutation 失败时:variables 不会清空,可继续展示并重试
如果 mutation 出错,这个临时条目也会随之消失。但你依然可以通过 isError 状态把它保留下来,因为 mutation 失败时 variables 并不会被清空,我们仍能读取它,甚至为其提供一个重试按钮:
@Component({
template: `
@if (addTodo.isError()) {
<li style="color: red">
{{ addTodo.variables() }}
<button (click)="addTodo.mutate(addTodo.variables())">Retry</button>
</li>
}
`,
})
class TodosComponent {}
点击 Retry 再次调用 mutate(addTodo.variables()),用上一次失败的入参重新发起 mutation。
2.4 当 mutation 与 query 不在同一组件:injectMutationState
"通过 UI 变量渲染"的路径要求 mutation 和 query 处于同一组件内才能直接访问其状态。如果两者分散在不同组件,可以通过专用 API injectMutationState 读取应用内所有 mutation 的状态,配合 mutationKey 过滤效果最佳:
// 应用内的某处
addTodo = injectMutation(() => ({
mutationFn: (newTodo: string) => axios.post('/api/data', { text: newTodo }),
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
mutationKey: ['addTodo'],
}))
// 其他任何地方访问该 mutation 的 variables
mutationState = injectMutationState<string>(() => ({
filters: { mutationKey: ['addTodo'], status: 'pending' },
select: (mutation) => mutation.state.variables,
}))
注意返回的 mutationState 是一个 数组(Signal of Array),因为同一时刻可能有多个同 key 的 mutation 并发执行。如果需要在模板中为每个条目生成唯一的 track key,可以让 select 改选 mutation.state.submittedAt,这样甚至可以轻松同时展示多个并发乐观更新。
从源码看,inject-mutation-state.ts 的 getResult 正是通过 mutationCache.findAll(options.filters) 找出所有匹配的 mutation,再逐个执行 select;若未提供 select,则默认返回 mutation.state。同时它订阅了 mutationCache(inject-mutation-state.ts),任何 mutation 状态变化都会经 notifyManager.batchCalls 批量通知,并用 replaceEqualDeep 做深度相等比较后才更新信号,避免无谓的变更触发与模板重绘。
三、方式二:直接操作缓存(快照 + 回滚)
当你在 mutation 执行前就乐观地更新了缓存状态,mutation 存在失败的可能。大多数失败场景下,直接对乐观更新过的查询触发一次 refetch 就能让数据回归真实服务器状态。但某些情况下 refetch 并不奏效——例如错误代表某种无法重取的服务器问题——这时你就需要选择回滚自己的乐观更新。
为此,injectMutation 的 onMutate 处理器允许你返回一个值,该值稍后会作为最后一个参数同时传给 onError 与 onSettled 处理器。多数情况下最有用的做法是返回一个"快照"(如旧数据或回滚函数)。
注意:以下示例中无论是
cancelQueries、getQueryData还是setQueryData,使用的都是注入的queryClient = inject(QueryClient)(示例代码里亦通过context.client访问同一实例)。
3.1 新增 todo 时更新 todo 列表
queryClient = inject(QueryClient)
updateTodo = injectMutation(() => ({
mutationFn: updateTodo,
// 当 mutate 被调用时:
onMutate: async (newTodo, context) => {
// 取消所有进行中的 refetch
//(避免它们覆盖我们的乐观更新)
await context.client.cancelQueries({ queryKey: ['todos'] })
// 对旧值做快照
const previousTodos = context.client.getQueryData(['todos'])
// 乐观地更新为新值
context.client.setQueryData(['todos'], (old) => [...old, newTodo])
// 返回携带快照的结果对象
return { previousTodos }
},
// 如果 mutation 失败,
// 使用 onMutate 返回的结果进行回滚
onError: (err, newTodo, onMutateResult, context) => {
context.client.setQueryData(['todos'], onMutateResult.previousTodos)
},
// 无论成功或失败都重新 refetch:
onSettled: (data, error, variables, onMutateResult, context) => {
context.client.invalidateQueries({ queryKey: ['todos'] })
},
}))
这段代码构成了缓存型乐观更新的标准三段式范式:
onMutate(发起时):先cancelQueries取消可能正在进行的 refetch(否则迟到的响应会覆盖乐观写入)→ 用getQueryData快照旧数据 → 用setQueryData直接把新 todo 追加进列表缓存 → 把{ previousTodos }返回出去;onError(失败时):用onMutate返回的快照previousTodos覆盖回缓存,完成回滚;onSettled(收尾时):无论成败都失效todos查询,让真实服务器数据兜底落地。
3.2 更新单条 todo
如果目标是更新列表中的某一条记录,则只需把查询 key 细化到 ['todos', newTodo.id],快照与回滚都只针对该条目:
queryClient = inject(QueryClient)
updateTodo = injectMutation(() => ({
mutationFn: updateTodo,
// 当 mutate 被调用时:
onMutate: async (newTodo, context) => {
// 取消任何进行中的 refetch
//(避免覆盖乐观更新)
await context.client.cancelQueries({ queryKey: ['todos', newTodo.id] })
// 快照旧值
const previousTodo = context.client.getQueryData(['todos', newTodo.id])
// 乐观更新为新值
context.client.setQueryData(['todos', newTodo.id], newTodo)
// 返回包含旧值和新值的结果
return { previousTodo, newTodo }
},
// 如果失败,用上面返回的结果回滚
onError: (err, newTodo, onMutateResult, context) => {
context.client.setQueryData(
['todos', onMutateResult.newTodo.id],
onMutateResult.previousTodo,
)
},
// 无论成功或失败都重新 refetch:
onSettled: (newTodo, error, variables, onMutateResult, context) => {
context.client.invalidateQueries({ queryKey: ['todos', newTodo.id] })
},
}))
这里 setQueryData 直接写入具体的 newTodo 对象,回滚时则通过 onMutateResult.newTodo.id 精确定位被修改的 key 并还原 previousTodo。
3.3 用 onSettled 替代独立的 onError/onSuccess
如果你希望统一收口错误与成功处理,可以用单个 onSettled 取代分开的 onError/onSuccess,在回调内自行判断 error 是否存在:
injectMutation({
mutationFn: updateTodo,
// ...
onSettled: (newTodo, error, variables, onMutateResult, context) => {
if (error) {
// 出错时做点什么
}
},
})
onSettled 无论成败都会被调用,回调参数中第一个参数是新数据、第二个是 error,通过判空即可分流处理逻辑。
四、两种方式如何取舍(When to use what)
如果乐观结果只需要展示在一处,使用 variables + UI 临时渲染是更省代码、也更易于推理的方案——例如完全不需要处理回滚。
如果屏幕上有多处位置都需要感知这次更新(列表、计数徽标、详情页同时存在),那么直接操作缓存会自动让所有订阅了相关 query key 的位置同步更新,你只维护一份数据源。
五、源码视角:injectMutation 与 injectMutationState 如何工作
为了让上述 API 真正可用,Angular Query 在 query-core 的 MutationObserver 之上封装了 Angular 响应式层,理解它可以避免在使用时踩坑。
5.1 injectMutation:信号化的 mutation 结果
查看 inject-mutation.ts 可以看到 injectMutation 接收一个返回 mutation options 的函数(而非静态对象),这样 options 可以被 computed() 包裹而具备响应性。内部核心步骤包括:
- 创建基于 query-core 的
MutationObserver,并通过computed延迟实例化(inject-mutation.ts); mutate被封装为调用observer.mutate(...)并.catch(noop)(inject-mutation.ts),因此模板里(click)="addTodo.mutate(...)"不会因返回的 Promise 而报未处理异常;- 通过
effect订阅 observer,状态更新时:若isPending则注册 AngularPendingTasks计数器,避免 pending 期间被 Angular 判定为"无任务"而误触发 SSR/测试稳定判断(inject-mutation.ts); - 最终结果经
signalProxy包装后返回(inject-mutation.ts)。
这解释了为何模板中要写 addTodo.isPending()、addTodo.variables() 而不是 addTodo.isPending:signal 代理把响应式信号暴露为可调用函数,调用即读取当前值,且 Angular 变更检测时自动完成依赖追踪。同样的约定也适用于 injectQuery 返回的 .data()。
5.2 injectMutationState:跨组件读取 mutation
inject-mutation-state.ts 的实现展示了它为何能"跨组件工作":它不依赖任何组件树,而是直接取 QueryClient 的全局 MutationCache,用 findAll(filters) 查询匹配的 mutation,再配合 select 投影;通过订阅 mutationCache 变更并经过 replaceEqualDeep 去重后写入响应式信号,因此列表展示型组件可以安全地把返回值用于 @for 循环与 track。
5.3 示例工程中的完整闭环
仓库中提供了可运行的真实示例 examples/angular/optimistic-updates。其中 tasks.service.ts 用 mutationOptions 集中定义了带 mutationKey: ['tasks'] 的 addTask():
onMutate中先cancelQueries、再用getQueryData快照旧列表、随后setQueryData把新任务追加进缓存,最后返回previousTodos作为回滚快照;onError中把context(即返回的快照)写回缓存完成回滚;onSettled中总是失效tasks查询,从服务器重取真实列表。
组件的 OptimisticUpdatesComponent(optimistic-updates.component.ts)通过 injectQuery(() => this.#tasksService.allTasks()) 渲染列表,addItem() 调用 addMutation.mutate({ task, failMutation })。界面还提供了一个 "Fail Mutation" 复选框:勾选后请求会指向错误的 URL(参见 mock-api.interceptor.ts),从而可直观演示"乐观条目瞬间出现 → 失败 → 列表回滚到快照 → 再次重取"的完整链路。
六、实践要点小结
- 确保
onSettled返回invalidateQueries的 Promise,让 mutation 在 refetch 完成前始终处于pending,避免乐观条目闪烁后消失。 - 缓存型更新前务必
cancelQueries,否则飞行中的 refetch 响应可能覆盖乐观写入。 onMutate的返回值就是回滚的"保险单"——它会被透传到onError/onSettled的最后一个参数,务必在失败分支使用。- 错误发生后
variables不会清空,可借此渲染失败条目与 Retry 按钮。 - 并发乐观更新:
injectMutationState返回数组,配合mutationKey+select(m => m.state.submittedAt)可稳定生成唯一标识。 - 需要在多处同步更新就改缓存;只需单点展示就用 UI 变量,复杂度更低。
进阶阅读可参考原指南末尾指向的 TkDodo 关于并发乐观更新(Concurrent Optimistic Updates in React Query)的博客文章,其中讨论了多 mutation 并发时更精细的快照合并策略;Angular Query 下同一套 injectMutation/injectMutationState 语义同样适用。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00