首页
/ TanStack Query Angular:injectMutation 变更(Mutation)机制与实战全解

TanStack Query Angular:injectMutation 变更(Mutation)机制与实战全解

2026-09-06 16:44:53作者:乔或婵

在 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 状态的观察者,真正发起请求必须由你显式调用 mutatemutateAsync

下面是一个往服务端添加新 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 isIdlestatus === 'idle' 空闲,或刚被创建/重置
pending isPendingstatus === 'pending' 请求正在执行
error isErrorstatus === 'error' 请求失败
success isSuccessstatus === 'success' 请求成功,数据可用

在这些主状态之上,还会随状态提供更多信息:

  • error — 当 mutation 处于 error 状态时,错误对象通过 error 信号暴露;
  • data — 当 mutation 处于 success 状态时,返回数据通过 data 信号暴露。

这套状态机在 类型定义中有完整的类型化保障:isSuccessisErrorisPendingisIdle 都是 SignalFunction,既是信号又是类型谓词(type guard)——在模板或逻辑中判断 mutation.isSuccess() 之后,TypeScript 会把整个结果对象收窄为 success 状态的版本,使 data 变为非空类型。

状态流转行为可以在这份测试中验证,例如 inject-mutation.test.ts 中的三个用例分别断言了:

  • 初始时 isIdle: true、其余状态全为 false
  • 调用 mutate 后进入 isPending: true,数据与错误均为空;
  • 请求失败后 isError: trueerror()?.message 可取到 'Some error'

调用 mutate 时只需传入一个变量或对象作为 mutation 的入参,它会被原样传递给 mutationFn 和各个生命周期回调。

仅仅传变量时 mutation 还算不上"特别";但当你结合 onSuccess 回调与 QueryClient 的 invalidateQueries 方法QueryClient 的 setQueryData 方法 使用时,mutation 会成为维护缓存一致性的强力工具——这正是 从 mutation 触发失效乐观更新 两篇指南的基础。

关于 React 版文档的事件池注意:React 16 及以下版本因事件池化不能直接把 mutate 传给表单回调。Angular 没有对应问题——mutate 可以直接绑定到 (click) 或提交处理函数中,无需额外包装。

重置 Mutation 状态:reset

有时你需要清除 mutation 残留的 errordata(例如让用户"点击错误提示重新提交")。这时使用返回结果上的 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 响应式表单,toSignalvalueChanges 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!")
  },
}))

即使 onSettledonSuccess 在同一个代码块中定义,输出顺序也保证是 onSuccess 的 Promise 先解析、onSettled 后执行。

mutate 时追加一次性回调

除了定义在选项上的回调,还可以在调用 mutate 时传入组件/调用点专属的回调选项。支持的选项同样包括 onSuccessonErroronSettled。需要注意:如果组件在 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
        },
      })
    })
  }
}

提示:传给 injectMutationmutationFn 通常是异步的,因此各 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: 2retryDelay: 0),可以验证重试次数与延迟参数在 Angular 信号环境下的行为。

另外,如果 mutation 因设备离线而失败,重新联网后会按原顺序自动重试。

持久化 mutation:离线场景的脱水/复水

mutation 可以被持久化到存储中,在稍后恢复。流程分三步:

  1. 应用退出时,把暂停(paused)的 mutation 状态 dehydrate 到外部存储;
  2. 应用启动后,用 hydrate 把状态写回 QueryClient
  3. 调用 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 版的三个工程要点:

  1. 响应式选项:选项函数被包进 computedoptionsSignal),因此选项内读取的信号变化会自动触发 observer.setOptions 重新应用,这是"mutation key 响应式更新"的底层机制;
  2. 变更检测安全:对观察者的订阅通过 ngZone.runOutsideAngular 建立,状态变更再 ngZone.run 回到 Angular 变更检测——避免在 Zone 外高频触发不必要的检测;
  3. 状态以信号暴露resultSignal 是一个 computed,其最终值经 signal-proxy 处理后返回,因此 mutation.datamutation.isPending 等都是可调用信号,模板中才能写成 mutation.isPending()

这套结构让 mutation 状态在模板中天然具备响应性,无需 RxJS 订阅管理。

小结

Angular 版 injectMutation 与 React 版 useMutation 共享同一套核心机制(状态机、生命周期回调、重试、离线恢复),差异集中在 API 形态:useMutation 换作 injectMutation,状态属性换作信号函数(isPending()),组件"卸载"的概念换作"被销毁"。掌握了本文的状态机、回调执行顺序(选项回调先于 mutate 追加回调;异步回调依次 await)、resetmutateAsync 之后,再配合 从 mutation 触发失效乐观更新 两篇指南,就能覆盖 Angular 项目中绝大多数服务端状态写入场景。

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