TanStack Query Angular:injectMutation 变更(Mutation)机制与实战全解
在 Angular 应用中使用 TanStack Query(@tanstack/angular-query-experimental)时,injectMutation 是执行"写操作"的核心入口:与 query 自动发起请求不同,mutation 只在调用 mutate 时执行,用于创建/更新/删除数据或触发任意服务端副作用。读完本文,你将掌握 mutation 的完整状态机(idle/pending/error/success)及其信号式访问方式、生命周期回调(onMutate/onError/onSuccess/onSettled)的编写规则、mutateAsync 的 Promise 用法、重试与离线持久化,以及乐观更新等进阶方案的实现路径。
什么是 Mutation:与 Query 的本质区别
TanStack Query 的 mutation 通常用于创建、更新、删除数据或执行服务端副作用。在 injectMutation 的源码实现中,这一点有明确的注释说明:
"Unlike queries, mutations are not run automatically."(与 query 不同,mutation 不会自动运行。)
因此 mutation 是一种**命令式(imperative)**能力:它在初始化时只建立一个处于 idle 状态的观察者,真正发起请求必须由你显式调用 mutate 或 mutateAsync。
下面是一个往服务端添加新 Todo 的最典型示例——组件内通过 injectMutation 创建 mutation,在模板中依据状态信号渲染不同分支:
// angular-ts
@Component({
template: `
<div>
@if (mutation.isPending()) {
<span>Adding todo...</span>
} @else if (mutation.isError()) {
<div>An error occurred: {{ mutation.error()?.message }}</div>
} @else if (mutation.isSuccess()) {
<div>Todo added!</div>
}
<button (click)="mutation.mutate(1)">Create Todo</button>
</div>
`,
})
export class TodosComponent {
todoService = inject(TodoService)
mutation = injectMutation(() => ({
mutationFn: (todoId: number) =>
lastValueFrom(this.todoService.create(todoId)),
}))
}
注意几个 Angular 特有的是用要点:
injectMutation的第一个参数是返回选项对象的函数,而非选项对象本身。这样可以在选项内部读取信号(signal),让 mutation 选项具备响应性;mutationFn可以返回任意 Promise,例中用 Angular HttpClient 的lastValueFrom把 Observable 桥接为 Promise;- 模板中所有状态(
isPending()、isError()、isSuccess()、error())都是信号函数,必须加()调用——这是与 React 版useMutation属性访问写法的关键差异。
四种互斥的状态:mutation 状态机
任意时刻,一个 mutation 只能处于以下四种状态之一:
| 状态 | 信号 | 语义 |
|---|---|---|
idle |
isIdle 或 status === 'idle' |
空闲,或刚被创建/重置 |
pending |
isPending 或 status === 'pending' |
请求正在执行 |
error |
isError 或 status === 'error' |
请求失败 |
success |
isSuccess 或 status === 'success' |
请求成功,数据可用 |
在这些主状态之上,还会随状态提供更多信息:
error— 当 mutation 处于error状态时,错误对象通过error信号暴露;data— 当 mutation 处于success状态时,返回数据通过data信号暴露。
这套状态机在 类型定义中有完整的类型化保障:isSuccess、isError、isPending、isIdle 都是 SignalFunction,既是信号又是类型谓词(type guard)——在模板或逻辑中判断 mutation.isSuccess() 之后,TypeScript 会把整个结果对象收窄为 success 状态的版本,使 data 变为非空类型。
状态流转行为可以在这份测试中验证,例如 inject-mutation.test.ts 中的三个用例分别断言了:
- 初始时
isIdle: true、其余状态全为false; - 调用
mutate后进入isPending: true,数据与错误均为空; - 请求失败后
isError: true且error()?.message可取到'Some error'。
调用 mutate 时只需传入一个变量或对象作为 mutation 的入参,它会被原样传递给 mutationFn 和各个生命周期回调。
仅仅传变量时 mutation 还算不上"特别";但当你结合 onSuccess 回调与 QueryClient 的 invalidateQueries 方法、QueryClient 的 setQueryData 方法 使用时,mutation 会成为维护缓存一致性的强力工具——这正是 从 mutation 触发失效 与 乐观更新 两篇指南的基础。
关于 React 版文档的事件池注意:React 16 及以下版本因事件池化不能直接把 mutate 传给表单回调。Angular 没有对应问题——mutate 可以直接绑定到 (click) 或提交处理函数中,无需额外包装。
重置 Mutation 状态:reset
有时你需要清除 mutation 残留的 error 或 data(例如让用户"点击错误提示重新提交")。这时使用返回结果上的 reset 函数即可把 mutation 打回 idle 状态:
// angular-ts
@Component({
selector: 'todo-item',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="todoForm" (ngSubmit)="onCreateTodo()">
@if (mutation.error()) {
<h5 (click)="mutation.reset()">{{ mutation.error() }}</h5>
}
<input type="text" formControlName="title" />
<br />
<button type="submit">Create Todo</button>
</form>
`,
})
export class TodosComponent {
mutation = injectMutation(() => ({
mutationFn: createTodo,
}))
fb = inject(NonNullableFormBuilder)
todoForm = this.fb.group({
title: this.fb.control('', {
validators: [Validators.required],
}),
})
title = toSignal(this.todoForm.controls.title.valueChanges, {
initialValue: '',
})
onCreateTodo = () => {
this.mutation.mutate(this.title())
}
}
示例中表单使用了 Angular 响应式表单,toSignal 把 valueChanges Observable 桥接为信号;提交时把当前标题传给 mutate。失败后错误条出现在表单上方,点击错误即调用 reset() 清除错误状态,允许重新提交。
生命周期副作用回调
injectMutation 提供了一组选项回调,可在 mutation 生命周期的各个阶段执行副作用,适用于在 mutation 后失效/重新获取查询、执行乐观更新等场景:
mutation = injectMutation(() => ({
mutationFn: addTodo,
onMutate: (variables, context) => {
// A mutation is about to happen!
// Optionally return a result containing data to use when for example rolling back
return { id: 1 }
},
onError: (error, variables, onMutateResult, context) => {
// An error happened!
console.log(`rolling back optimistic update with id ${onMutateResult.id}`)
},
onSuccess: (data, variables, onMutateResult, context) => {
// Boom baby!
},
onSettled: (data, error, variables, onMutateResult, context) => {
// Error or success... doesn't matter!
},
}))
各回调的签名要点:
onMutate(variables, context)— 请求发出前触发,常用于暂停相关查询、写入乐观数据;其返回值(onMutateResult)会被透传给后续回调,用于回滚乐观更新;onError(error, variables, onMutateResult, context)— 请求失败(含重试耗尽)后触发;onSuccess(data, variables, onMutateResult, context)— 请求成功后触发;onSettled(data, error, variables, onMutateResult, context)— 无论成功失败,结束时必定触发。
回调中的 Promise 会被依次 await
如果任一回调返回 Promise,框架会先 await 它,再调用下一个回调。例如:
mutation = injectMutation(() => ({
mutationFn: addTodo,
onSuccess: async () => {
console.log("I'm first!")
},
onSettled: async () => {
console.log("I'm second!")
},
}))
即使 onSettled 与 onSuccess 在同一个代码块中定义,输出顺序也保证是 onSuccess 的 Promise 先解析、onSettled 后执行。
mutate 时追加一次性回调
除了定义在选项上的回调,还可以在调用 mutate 时传入组件/调用点专属的回调选项。支持的选项同样包括 onSuccess、onError 和 onSettled。需要注意:如果组件在 mutation 完成前被销毁(gets destroyed),这些追加回调将不会执行。
mutation = injectMutation(() => ({
mutationFn: addTodo,
onSuccess: (data, variables, onMutateResult, context) => {
// I will fire first
},
onError: (error, variables, onMutateResult, context) => {
// I will fire first
},
onSettled: (data, error, variables, onMutateResult, context) => {
// I will fire first
},
}))
mutation.mutate(todo, {
onSuccess: (data, variables, onMutateResult, context) => {
// I will fire second!
},
onError: (error, variables, onMutateResult, context) => {
// I will fire second!
},
onSettled: (data, error, variables, onMutateResult, context) => {
// I will fire second!
},
})
执行顺序是:先选项中的回调,后 mutate 调用时传入的回调。
连续发起多个 mutation 时的差异
连续 mutation 场景下,两类回调的触发规则有微妙差别:传给 mutate 的回调只会触发一次(对应最后一次 mutate 调用),且要求组件仍然存活;而 injectMutation 选项中的回调会针对每次 mutate 调用各执行一次。从 源码结构看,mutate 函数本身是一个每次随 observerSignal 重建的 computed——mutation 观察者在每次 mutate 调用时会重新建立订阅,这解释了为什么调用点级回调只与最后一次 mutation 关联。
export class Example {
mutation = injectMutation(() => ({
mutationFn: addTodo,
onSuccess: (data, variables, onMutateResult, context) => {
// Will be called 3 times
},
}))
doMutations() {
;['Todo 1', 'Todo 2', 'Todo 3'].forEach((todo) => {
this.mutation.mutate(todo, {
onSuccess: (data, variables, onMutateResult, context) => {
// Will execute only once, for the last mutation (Todo 3),
// regardless which mutation resolves first
},
})
})
}
}
提示:传给
injectMutation的mutationFn通常是异步的,因此各 mutation 的完成顺序可能与mutate调用顺序不一致,编写回调时不要依赖固定的到达顺序。
使用 Promise:mutateAsync
当你需要用 try/catch/finally 组合副作用(例如在 mutation 成功后串行执行导航、提示等操作)时,使用 mutateAsync 代替 mutate:它返回一个 Promise,成功时 resolve 数据,失败时 throw 错误。
mutation = injectMutation(() => ({ mutationFn: addTodo }))
try {
const todo = await mutation.mutateAsync(todo)
console.log(todo)
} catch (error) {
console.error(error)
} finally {
console.log('done')
}
从 结果信号的组装逻辑可以看出两者的关系:mutate 是一个内部调用 observer.mutate(...).catch(noop) 的包装(吞掉 rejection,状态交给信号体系表达),而 mutateAsync 直接就是观察者原始的 mutate 函数,会把 rejection 抛给调用者。
重试(Retry)
默认情况下,TanStack Query 不会在 mutation 失败时重试,但可以通过 retry 选项开启:
mutation = injectMutation(() => ({
mutationFn: addTodo,
retry: 3,
}))
仓库测试中有一个同步重试的完整用例(inject-mutation.test.ts,使用 retry: 2 与 retryDelay: 0),可以验证重试次数与延迟参数在 Angular 信号环境下的行为。
另外,如果 mutation 因设备离线而失败,重新联网后会按原顺序自动重试。
持久化 mutation:离线场景的脱水/复水
mutation 可以被持久化到存储中,在稍后恢复。流程分三步:
- 应用退出时,把暂停(paused)的 mutation 状态
dehydrate到外部存储; - 应用启动后,用
hydrate把状态写回QueryClient; - 调用
queryClient.resumePausedMutations()恢复这些暂停的 mutation。
const queryClient = new QueryClient()
// Define the "addTodo" mutation
queryClient.setMutationDefaults(['addTodo'], {
mutationFn: addTodo,
onMutate: async (variables, context) => {
// Cancel current queries for the todos list
await context.client.cancelQueries({ queryKey: ['todos'] })
// Create optimistic todo
const optimisticTodo = { id: uuid(), title: variables.title }
// Add optimistic todo to todos list
context.client.setQueryData(['todos'], (old) => [...old, optimisticTodo])
// Return result with the optimistic todo
return { optimisticTodo }
},
onSuccess: (result, variables, onMutateResult, context) => {
// Replace optimistic todo in the todos list with the result
context.client.setQueryData(['todos'], (old) =>
old.map((todo) =>
todo.id === onMutateResult.optimisticTodo.id ? result : todo,
),
)
},
onError: (error, variables, onMutateResult, context) => {
// Remove optimistic todo from the todos list
context.client.setQueryData(['todos'], (old) =>
old.filter((todo) => todo.id !== onMutateResult.optimisticTodo.id),
)
},
retry: 3,
})
// Start mutation in some component:
class SomeComponent {
mutation = injectMutation(() => ({ mutationKey: ['addTodo'] }))
someMethod() {
this.mutation.mutate({ title: 'title' })
}
}
// If the mutation has been paused because the device is for example offline,
// Then the paused mutation can be dehydrated when the application quits:
const state = dehydrate(queryClient)
// The mutation can then be hydrated again when the application is started:
hydrate(queryClient, state)
// Resume the paused mutations:
queryClient.resumePausedMutations()
resumePausedMutations 的完整语义可参见 QueryClient 参考文档。
持久化离线 mutation 的坑:必须提供默认 mutationFn
如果配合 persistQueryClient 类持久化方案把离线 mutation 写入外部存储,页面刷新后 mutation 无法自动恢复,除非你提供了默认的 mutation 函数。这是技术限制:持久化到外部存储时只能序列化 mutation 的状态,函数(mutationFn)无法序列化。复水后触发 mutation 的组件可能尚未初始化,此时调用 resumePausedMutations 会抛出 No mutationFn found 错误。
因此正确做法是:把 mutationFn 注册为全局 mutation 默认项(queryClient.setMutationDefaults),并在持久化恢复成功(onSuccess)后再调用 resumePausedMutations()。仓库中有一个覆盖 query 与 mutation 的完整离线示例工程,位于 examples/react/offline,其 dehydrate/hydrate + resumePausedMutations 的组合方式可直接迁移到 Angular 项目参考。
让 mutation 选项可复用:mutationOptions
上面所有示例都直接在组件中内联定义选项。当多个组件需要共享同一 mutation(例如统一在 onSuccess 中更新缓存)时,仓库提供了配套的 mutationOptions 帮助函数:它本身只做类型化透传,真正的价值在于让"服务层返回选项、组件层注入执行"的模式获得完整类型推导。官方文档注释中给出的模式是:
export class QueriesService {
private http = inject(HttpClient)
private queryClient = inject(QueryClient)
updatePost(id: number) {
return mutationOptions({
mutationFn: (post: Post) => Promise.resolve(post),
mutationKey: ['updatePost', id],
onSuccess: (newPost) => {
this.queryClient.setQueryData(['posts', id], newPost)
},
})
}
}
class ComponentOrService {
queries = inject(QueriesService)
id = signal(0)
mutation = injectMutation(() => this.queries.updatePost(this.id()))
save() {
this.mutation.mutate({ title: 'New Title' })
}
}
注意 injectMutation 的回调返回的正是 mutationOptions(...) 的返回值——由于选项被包在函数里,id 信号变化时选项会重新计算,mutation 的 key 也随之响应式更新。相关用法详见 mutation-options 指南。
实现原理速览:信号代理与 NgZone 边界
从 inject-mutation.ts 的整体结构可以归纳出 Angular 版的三个工程要点:
- 响应式选项:选项函数被包进
computed(optionsSignal),因此选项内读取的信号变化会自动触发observer.setOptions重新应用,这是"mutation key 响应式更新"的底层机制; - 变更检测安全:对观察者的订阅通过
ngZone.runOutsideAngular建立,状态变更再ngZone.run回到 Angular 变更检测——避免在 Zone 外高频触发不必要的检测; - 状态以信号暴露:
resultSignal是一个 computed,其最终值经 signal-proxy 处理后返回,因此mutation.data、mutation.isPending等都是可调用信号,模板中才能写成mutation.isPending()。
这套结构让 mutation 状态在模板中天然具备响应性,无需 RxJS 订阅管理。
小结
Angular 版 injectMutation 与 React 版 useMutation 共享同一套核心机制(状态机、生命周期回调、重试、离线恢复),差异集中在 API 形态:useMutation 换作 injectMutation,状态属性换作信号函数(isPending()),组件"卸载"的概念换作"被销毁"。掌握了本文的状态机、回调执行顺序(选项回调先于 mutate 追加回调;异步回调依次 await)、reset 与 mutateAsync 之后,再配合 从 mutation 触发失效 与 乐观更新 两篇指南,就能覆盖 Angular 项目中绝大多数服务端状态写入场景。
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 StartedRust0627
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