大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
Mutation
这一课你将能做到
- 用
input+payload范式为本站「提交代码」「标记课时完成」设计 Mutation - 在 Mutation 的返回里直接查询变更后的数据,让前端免发第二次请求
- 说清楚 Mutation 与 Query 在执行语义上的关键差异(串行 vs 并行)
为什么要学
读操作写错,最坏是页面渲染错;写操作设计错,是脏数据、是重复提交、是用户点了三次按钮创建了三条记录。GraphQL 的 Mutation 语法上和 Query 几乎一样,但工程含义完全不同:它要有幂等设计、要有业务错误通道、要让客户端拿到「变更后的世界」。这一课学的不是语法,是写操作的纪律。
人话:Query 是「问」,Mutation 是「办」——办就要给回执,回执上写清办成了没有、办成了什么样。
先建立图像
本站「提交代码」的完整数据流:
客户端 服务端
│ mutation SubmitCode($input: SubmitCodeInput!) │
│ ──────────────────────────────────────────────> │
│ │ 校验 → 幂等检查 → 落库 → 触发判题
│ <────────────────────────────────────────────── │
│ payload: { submission { id status } │
│ progress { percent } ← 顺带回新变化 │
│ userErrors [] ← 业务错误通道 │
│ } │
Payload 三段式是社区事实标准:
type SubmitCodePayload {
submission: Submission # 1. 变更的核心对象
progress: Progress # 2. 受影响的关联对象(按需)
userErrors: [UserError!]! # 3. 业务错误(区别于 transport 层 errors)
}
type UserError { message: String!, field: String, code: String }
跟着做
步骤 1:第一个 Mutation——标记课时完成
# Schema
input MarkLessonDoneInput {
lessonId: ID!
clientMutationId: String # 幂等键,Relay 惯例
}
type MarkLessonDonePayload {
progress: Progress
userErrors: [UserError!]!
}
type Mutation {
markLessonDone(input: MarkLessonDoneInput!): MarkLessonDonePayload!
}
调用:
mutation MarkDone($input: MarkLessonDoneInput!) {
markLessonDone(input: $input) {
progress { status percent }
userErrors { message code }
}
}
// variables
{ "input": { "lessonId": "101", "clientMutationId": "uuid-abc" } }
步骤 2:提交代码——回读变更后状态
提交记录创建后状态是 PENDING,同时我的进度条可能变化。一次 Mutation 全带回:
mutation SubmitCode($input: SubmitCodeInput!) {
submitCode(input: $input) {
submission {
id
status # PENDING / ACCEPTED / REJECTED
submittedAt
}
progress { # 同一个 Mutation 顺带回查进度
status
percent
}
userErrors { field message }
}
}
关键认知:Mutation 的返回就是一个查询,Payload 里的每个字段都走正常 resolver,你可以像 Query 一样任意嵌套选取。
步骤 3:串行执行——一个请求里连发两个写操作
mutation Onboarding($trackSlug: String!, $lessonId: ID!) {
enroll: enrollTrack(input: { trackSlug: $trackSlug }) {
track { title }
userErrors { message }
}
done: markLessonDone(input: { lessonId: $lessonId }) {
progress { status }
userErrors { message }
}
}
规范保证:同一 Mutation 操作里的多个根字段按书写顺序串行执行。这里先报名路线、再标记完成,顺序是有语义保证的。
步骤 4:业务错误走 userErrors,不走 errors
对比一下两种失败:
// ① 系统错误:走顶层 errors,data 对应字段为 null
{ "data": { "submitCode": null },
"errors": [{ "message": "数据库连接超时" }] }
// ② 业务错误:走 userErrors,HTTP 200,调用方能精确展示
{ "data": { "submitCode": {
"submission": null,
"userErrors": [{ "field": "code", "message": "代码不能为空", "code": "EMPTY_CODE" }]
} } }
判据:调用者能自愈的错误(改输入重试)放 userErrors;不能自愈的(服务挂了、没权限)放 errors。
步骤 5:更新操作的部分输入
更新课时草稿,只传要改的字段。input 字段全可空 + 服务端「null = 不动」约定:
input UpdateLessonDraftInput {
lessonId: ID!
title: String # null = 不修改
content: String # null = 不修改
difficulty: Difficulty
}
注意陷阱:这样设计后「把某字段清空」无法表达。需要清空语义时,引入显式指令如 clearContent: Boolean,别用 null 兼任。
讲清楚要点
- Mutation 根字段串行:这是规范承诺,也是和 Query 的唯一执行差异;跨操作的顺序则要客户端自己 await。
- input 单一入参惯例:
mutation xxx(input: XxxInput!)而不是平铺参数,扩展时不破坏旧客户端。 - Payload 回读变更:Mutation 返回值是完整查询入口,把受影响的关联对象(进度、计数)一并带回,消除「写完再查」的二次往返。
- userErrors 是业务错误的一等公民:校验失败不该让 HTTP 报错或 data 变 null,结构化错误让表单精确标红。
- 幂等靠客户端凭证:
clientMutationId+ 服务端去重表,解决网络重试/双击造成的重复写。 - Mutation 不该有「读」的副作用以外的 GET 语义:名字用动词(
submitCode、enrollTrack),不用名词;读请回 Query。
你可能会懵的地方
- 为什么 GraphQL 不直接用 HTTP 动词区分读写? 因为传输只有 POST(多数情况),读写区分在操作类型上:query/mutation/subscription。这让语义不依赖传输层。
- 串行执行慢怎么办? 真正无依赖的写操作拆成两个请求并行发;有依赖的本来就该串行。别为省一个往返把无因果关系的东西捆一起。
- userErrors 和 errors 混用会不会让客户端困惑? 约定先行:团队规约写明「业务失败永远走 userErrors 且 HTTP 200」,客户端统一处理路径,就不困惑。
- Payload 里塞太多回读字段会不会浪费? 不会。客户端只声明自己要的字段,Payload 类型设计得丰富不等于传输量大——这正是 GraphQL 的优势。
- 删除操作返回什么?
返回被删对象的 id(让客户端清缓存)+ userErrors。如
deleteSubmission { deletedSubmissionId userErrors { message } }。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
平铺参数 markDone(lessonId: ID!, flag: Boolean) | 加参数就是破坏性变更 | 单 input 对象 |
| 校验失败 throw 异常走 errors | 表单无法定位字段 | userErrors 携带 field/message/code |
| Mutation 只返回 Boolean | 客户端必须再查一次拿新状态 | Payload 带回变更对象与关联对象 |
| 用 Query 字段偷偷做写操作 | 破坏语义、缓存层被污染 | 写一律走 Mutation |
| 依赖客户端传时间戳当主键判重 | 时钟不可靠、可伪造 | 服务端生成 ID,clientMutationId 仅做幂等 |
| null 同时表示「不更新」和「清空」 | 二义性 bug 温床 | 显式布尔开关或单独 clearXxx 字段 |
卡点急救
- Mutation 返回 400 "Unknown argument":参数被包进了 input 但你按平铺传(或反之),对照 Schema 的 Mutation 签名。
- 双击创建了两条记录:检查服务端有没有按
clientMutationId或业务唯一键(userId+lessonId)做去重约束。 - 写成功了但页面没更新:Payload 里没声明回读字段,或客户端缓存没按返回对象归一化更新——带
id返回并更新缓存。
面试常问
- Query 和 Mutation 的执行语义区别? 同一操作内:Query 根字段可并行,Mutation 根字段严格按顺序串行。这让一个请求内的多个写操作有确定顺序。跨操作都不保证。
- Mutation 的错误处理怎么设计? 双层:transport/系统错误走顶层 errors(data 对应字段为 null);可预期的业务校验失败走 payload 内的 userErrors 数组,HTTP 恒 200,调用方按 field 精确提示。
- GraphQL 怎么做幂等写? 协议不内置幂等,工程惯例是 input 携带 clientMutationId,服务端对已处理的凭证直接重放首次结果;同时数据库层加业务唯一约束兜底。
自测三问
- 一个 Mutation 里写了
a: doA和b: doB两个根字段,它们的执行顺序有保证吗? - 「代码为空」这种校验失败,应该出现在 errors 还是 userErrors?为什么?
- 为什么更新类 input 里「null = 不修改」的设计会导致「清空字段」无法表达?
小结一句话
Mutation 的语法是 Query,纪律是事务:单 input 入参、Payload 回读变更、userErrors 承载业务失败、clientMutationId 保证幂等、根字段串行保证顺序。
延伸
- 官方文档:Mutations
- 下一课:Subscription——实时推送、WebSocket 协议与增量交付 @defer/@stream。