首页
/ Angular Query 乐观更新(Optimistic Updates)完整实战:UI 变量渲染与缓存回滚双路径

Angular Query 乐观更新(Optimistic Updates)完整实战:UI 变量渲染与缓存回滚双路径

2026-09-07 14:42:12作者:董宙帆

导读

本文围绕 Angular Query(@tanstack/angular-query-experimental)的乐观更新能力展开,讲解在 mutation 尚未完成时如何让界面"提前"呈现预期结果。读完本文你将掌握两种相互补充的实现路径:一是通过 mutation 返回的 variables 直接在组件模板中临时渲染新条目(不触碰缓存、无需回滚);二是通过 onMutate/onError/onSettled 直接操作 QueryClient 缓存,配合快照实现失败回滚与重取。文章以仓库示例 examples/angular/optimistic-updatesinject-mutation.tsinject-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 提供两种乐观更新的实现方式:

  1. 通过 UI(variables:不动缓存,仅根据 mutation 的当前状态在模板中临时渲染;
  2. 通过缓存(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'] })
  },
}))

关键点在于注释强调的行为:onSettledreturn 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.tsgetResult 正是通过 mutationCache.findAll(options.filters) 找出所有匹配的 mutation,再逐个执行 select;若未提供 select,则默认返回 mutation.state。同时它订阅了 mutationCacheinject-mutation-state.ts),任何 mutation 状态变化都会经 notifyManager.batchCalls 批量通知,并用 replaceEqualDeep 做深度相等比较后才更新信号,避免无谓的变更触发与模板重绘。

三、方式二:直接操作缓存(快照 + 回滚)

当你在 mutation 执行前就乐观地更新了缓存状态,mutation 存在失败的可能。大多数失败场景下,直接对乐观更新过的查询触发一次 refetch 就能让数据回归真实服务器状态。但某些情况下 refetch 并不奏效——例如错误代表某种无法重取的服务器问题——这时你就需要选择回滚自己的乐观更新。

为此,injectMutationonMutate 处理器允许你返回一个值,该值稍后会作为最后一个参数同时传给 onErroronSettled 处理器。多数情况下最有用的做法是返回一个"快照"(如旧数据或回滚函数)。

注意:以下示例中无论是 cancelQueriesgetQueryData 还是 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 的位置同步更新,你只维护一份数据源。

五、源码视角:injectMutationinjectMutationState 如何工作

为了让上述 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 则注册 Angular PendingTasks 计数器,避免 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.tsmutationOptions 集中定义了带 mutationKey: ['tasks']addTask()

  • onMutate 中先 cancelQueries、再用 getQueryData 快照旧列表、随后 setQueryData 把新任务追加进缓存,最后返回 previousTodos 作为回滚快照;
  • onError 中把 context(即返回的快照)写回缓存完成回滚;
  • onSettled 中总是失效 tasks 查询,从服务器重取真实列表。

组件的 OptimisticUpdatesComponentoptimistic-updates.component.ts)通过 injectQuery(() => this.#tasksService.allTasks()) 渲染列表,addItem() 调用 addMutation.mutate({ task, failMutation })。界面还提供了一个 "Fail Mutation" 复选框:勾选后请求会指向错误的 URL(参见 mock-api.interceptor.ts),从而可直观演示"乐观条目瞬间出现 → 失败 → 列表回滚到快照 → 再次重取"的完整链路。

六、实践要点小结

  1. 确保 onSettled 返回 invalidateQueries 的 Promise,让 mutation 在 refetch 完成前始终处于 pending,避免乐观条目闪烁后消失。
  2. 缓存型更新前务必 cancelQueries,否则飞行中的 refetch 响应可能覆盖乐观写入。
  3. onMutate 的返回值就是回滚的"保险单"——它会被透传到 onError/onSettled 的最后一个参数,务必在失败分支使用。
  4. 错误发生后 variables 不会清空,可借此渲染失败条目与 Retry 按钮。
  5. 并发乐观更新injectMutationState 返回数组,配合 mutationKey + select(m => m.state.submittedAt) 可稳定生成唯一标识。
  6. 需要在多处同步更新就改缓存;只需单点展示就用 UI 变量,复杂度更低。

进阶阅读可参考原指南末尾指向的 TkDodo 关于并发乐观更新(Concurrent Optimistic Updates in React Query)的博客文章,其中讨论了多 mutation 并发时更精细的快照合并策略;Angular Query 下同一套 injectMutation/injectMutationState 语义同样适用。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
916
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388