大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
客户端
这一课你将能做到
- 用 Apollo Client 完成「课时列表 + 提交代码」的查询、变更与缓存更新
- 解释归一化缓存的工作原理,并说出三大客户端(Apollo / urql / Relay)的选型逻辑
- 配置代码生成(GraphQL Code Generator)让查询获得端到端类型安全
为什么要学
直接用 fetch 调 GraphQL 完全可行——它就是个 POST。但真实应用马上会遇到三个问题:同一个 Lesson 在列表和详情各查一次、数据不一致;Mutation 返回后要手动同步七八处 UI;查询字符串和 TypeScript 类型永远对不上。客户端库就是为这三件事而生的:归一化缓存、缓存自动更新、类型生成。
人话:fetch 是自行车,客户端库是汽车——都能到,但长途你会累死在半路上。
先建立图像
归一化缓存的核心思想——按 类型:id 拆平存储:
# 查询返回
{ "track": { "id": "t1", "title": "GraphQL",
"lessons": [ { "id": "101", "title": "概览" }, ... ] } }
缓存内部(不是嵌套树,是平铺表):
Track:t1 → { title: "GraphQL", lessons: [Ref(Lesson:101), ...] }
Lesson:101 → { title: "概览", ... } ← 全局唯一一份
Lesson:102 → { ... }
Mutation 返回 Lesson:101 的新数据时,所有引用它的 UI 自动更新——这是归一化缓存的杀手锏。
跟着做
步骤 1:最小接入 Apollo Client
npm install @apollo/client graphql
import { ApolloClient, InMemoryCache, HttpLink, gql } from '@apollo/client'
const client = new ApolloClient({
link: new HttpLink({ uri: '/graphql', credentials: 'include' }),
cache: new InMemoryCache()
})
步骤 2:查询课时列表
const LESSONS = gql`
query Lessons($first: Int!, $after: String) {
lessons(first: $first, after: $after) {
edges { node { id title difficulty } }
pageInfo { hasNextPage endCursor }
}
}
`
function LessonList() {
const { data, loading, error, fetchMore } = useQuery(LESSONS, {
variables: { first: 20 }
})
if (loading) return <Skeleton />
if (error) return <ErrorBox code={error.graphQLErrors?.[0]?.extensions?.code} />
return (
<>
{data.lessons.edges.map(e => <LessonCard key={e.node.id} lesson={e.node} />)}
{data.lessons.pageInfo.hasNextPage &&
<button onClick={() => fetchMore({ variables: { after: data.lessons.pageInfo.endCursor } })}>加载更多</button>}
</>
)
}
步骤 3:Mutation + 缓存更新
提交代码后让「我的进度」自动刷新——Payload 回读的字段若带 id 且已在缓存中,Apollo 自动合并:
const SUBMIT = gql`
mutation SubmitCode($input: SubmitCodeInput!) {
submitCode(input: $input) {
submission { id status }
progress { id status percent } # 带 id → 命中归一化缓存,UI 自动更新
userErrors { message }
}
}
`
const [submit] = useMutation(SUBMIT)
await submit({
variables: { input: { lessonId, language: 'JAVA', code } },
optimisticResponse: { # 乐观更新:先假装成功渲染
submitCode: {
submission: { id: 'temp', status: 'PENDING' },
progress: { id: progressId, status: 'IN_PROGRESS', percent: 50 },
userErrors: []
}
}
})
当返回对象没有 id 或需要手工干预(如往列表插入新项)时用 update(cache, { data }) 手动写缓存。
步骤 4:类型生成(graphql-codegen)
npm install -D @graphql-codegen/cli @graphql-codegen/typescript-operations
# codegen.yml
schema: http://localhost:8080/graphql
documents: 'src/**/*.graphql'
generates:
src/gql/:
preset: client
.graphql 文件里的每个操作生成精确 TypeScript 类型:改 Schema 后 npm run codegen 一跑,所有类型不匹配的地方编译期爆红——Schema 变更的影响面变成编译错误清单。
步骤 5:三大客户端对比实验
同一个查询,三家的差异在「缓存哲学」:
Apollo → 归一化缓存(默认开),生态最全,包体积最大
urql → 文档缓存(默认)可换归一化插件,轻量、中间件式 exchanges
Relay → 归一化 + 编译期优化(查询预编译、数据与组件绑定),约束最强性能上限最高
讲清楚要点
- 归一化缓存 = 按 Type:id 平铺存储:所有查询结果拆解进同一张表,UI 引用而非拷贝数据,一处更新处处生效。前提是类型有稳定
id。 - keyFields 自定义:没有
id的类型用keyFields: ["slug"]告诉缓存怎么归一。 - fetchPolicy 决定读缓存策略:
cache-first(默认)/network-only/cache-and-network(先给缓存快照再网络校正,列表页最佳)。 - Mutation 的缓存三板斧:Payload 带 id 自动合并 > optimisticResponse 乐观渲染 > update 函数手工改列表(增删项必须手工,自动合并只会更新已存在对象)。
- 代码生成是类型安全的最后一公里:查询文档 → TS 类型,Schema 变更即时暴露编译错误;fragment 与组件一一对应(Relay 强制,Apollo 推荐)。
- 选型逻辑:要快、团队经验少 → Apollo;要轻、定制链路 → urql;超大型前端、极致性能与约束 → Relay。
- 错误处理双通道:
networkError(传输层)与graphQLErrors(规范层)分开判断,配合上一课的 extensions.code。
你可能会懵的地方
- 没有 id 的对象怎么办?
配置
keyFields(如["slug"]);实在无唯一键的对象会嵌入父对象存储,失去跨查询共享能力——所以 Schema 设计时给重要对象配 id。 - 乐观更新失败了会怎样?
Apollo 自动回滚到真实响应。前提是你的 optimisticResponse 结构必须和真实 Payload 完全同形(含
__typename)。 - 为什么列表新增项不能自动更新?
缓存里
lessons(first:20)这个字段值是一个引用数组,Apollo 不知道新对象该插哪个位置(排序、过滤条件都是服务端逻辑)——所以增删必须 update 手工干预或 refetchQueries 重查。 - __typename 是哪来的?
客户端自动给每个对象加
__typename元字段查询,归一化缓存靠它确定类型——别手动删。 - 缓存会不会泄露别的用户数据?
缓存是标签页内存级的,换账号登录要
client.clearStore()(或 resetStore),否则上个用户的 viewer 数据还在。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 每次查询 network-only | 缓存白买了 | cache-first 默认,特殊场景再放开 |
| Mutation 后 refetch 整个页面 | 多余请求、闪烁 | Payload 带 id 回读 + update 精准改列表 |
| optimisticResponse 缺 __typename | 回滚异常、缓存错乱 | 与真实 Payload 同形,codegen 可生成 |
| 手写查询返回类型 interface | 和 Schema 漂移无人发现 | graphql-codegen 从文档生成 |
| 退出登录不清缓存 | 数据串号 | logout 里调 client.clearStore() |
| 列表 key 用数组下标 | 分页追加后渲染错乱 | key 用 node.id |
卡点急救
- UI 没更新但网络有响应:返回对象缺
id或__typename,归一化失败;开 Apollo Devtools 看缓存面板确认对象是否入表。 - 报 "Missing field ... in result":查询写的字段服务端没返回(或大小写不符),对照 Schema。
- 分页追加后顺序错乱/重复:fetchMore 的 updateQuery 没写好合并逻辑;用 Apollo 的 relayStylePagination 字段策略自动处理。
面试常问
- Apollo 归一化缓存原理?
响应按
__typename:id(或 keyFields)拆解成平铺记录表,查询结果存引用;Mutation/后续查询返回同 id 对象时自动合并,所有引用该对象的 UI 联动更新,无需手动状态同步。 - Apollo / urql / Relay 怎么选? Apollo:生态最全、上手最快、归一化缓存默认;urql:轻量、exchanges 中间件机制可定制、文档缓存起步;Relay:编译期查询优化 + fragment 与组件强绑定,性能上限最高但约束最严、学习成本大。
- Mutation 后更新缓存的几种方式? ①Payload 返回带 id 对象自动合并;②update 函数用 cache.modify/writeFragment 精准改(列表增删);③refetchQueries 指定重查;④optimisticResponse 做乐观 UI,失败自动回滚。
自测三问
- 归一化缓存靠什么两个元信息把对象拆进平铺表?
- 为什么「往列表新增一项」不能靠自动合并完成?
cache-and-network策略的渲染过程是怎样的?
小结一句话
客户端库的价值不在发请求而在缓存:归一化缓存让同一对象全局一份、处处联动,Mutation Payload 带 id 自动合并,配合 codegen 让 Schema 变更变成编译错误清单。
延伸
- Apollo Client 官方文档
- urql 官方文档
- 下一课:服务端库——graphql-js、Apollo Server、Yoga 与 Java 生态的落地姿势。