嘿,各位前端工程师、React 痴迷者,还有那些每天对着 setState 和 useEffect 哭喊“为什么我的状态总是乱套?”的兄弟姐妹们,大家好!
今天我们不聊 useMemo,不聊 React.memo,也不聊如何用 CSS Grid 做一个完美的响应式布局。今天我们要聊点更“硬核”的,聊点能让你在深夜debug时,感觉自己像个幕后黑手——也就是 GraphQL 和 Apollo Client 的缓存策略。
特别是我们今天的主角:规范化(Normalization)。
如果你觉得 props 传参传得头晕,觉得 React 的数据流像一锅煮沸的粥,那么 Apollo Client 就是你的那把勺子。而规范化,就是这把勺子的核心魔法。学会它,你就能从手动管理状态的泥潭里拔出腿来,站在云端俯视你的应用数据。
准备好了吗?我们要开始这场“数据重构”之旅了。
第一章:GraphQL 的甜头与痛处
咱们先来聊聊 GraphQL。说实话,GraphQL 这玩意儿一出来,大家都疯了。为什么?因为它承诺了“按需获取”。前端再也不用跟后端吵架:“哎,我只要个用户名!”后端:“那我给你整个 JSON 吧!”前端:“不要那个 profile 对象!烦死人了!”
你拿到了数据。很好。你把它塞进 React 的 useState 里,然后在组件里用 map 渲染出来。看起来很完美,对吧?
但等等,生活没那么简单。
想象一下,你有一个“评论系统”。
- 用户 A 发了一条评论。
- 你把它读出来,渲染在屏幕上。
- 用户 B 给这条评论点了个赞。
这时候,问题来了。你怎么更新它?
如果用普通的 useState,你可能会这么做:setComments([...comments, newComment])。但如果用户 B 点赞了,你需要把 comments 数组里的那个对象找出来,把 likeCount 加一。这意味着你每次渲染都要重新创建整个数组。如果你的评论列表很长,这就不仅仅是性能问题,而是会导致你的 React 组件疯狂卸载和挂载,像坐过山车一样。
更糟糕的是,数据结构。
GraphQL 返回的数据通常像这样:
{
"data": {
"article": {
"id": "123",
"title": "我为什么讨厌 Redux",
"comments": [
{
"id": "c1",
"text": "同意",
"user": { "id": "u1", "name": "Alice" },
"likes": 5
},
{
"id": "c2",
"text": "同感",
"user": { "id": "u2", "name": "Bob" },
"likes": 2
}
]
}
}
}
你看,这里的数据是扁平的(如果忽略 JSON 的层级,它的结构是线性的)。如果你试图更新 Alice 的评论(把 likes +1),你实际上是在修改一个被嵌在 Article 里面的 Comment 里。如果你再查一下这个 Article,你发现 c1 那个对象里的 user 对象(Alice)又出现在另一个地方。
这就是“数据漂移”和“重复数据”。
React 的状态管理(尤其是 Context API 或 Redux)喜欢把数据放在一个中心化的地方。但 GraphQL 返回的数据是散落在各个嵌套结构里的。如果不做处理,你在组件里维护这个状态,简直就是跟上帝作对——上帝(后端 API)每次返回的数据都不一样,或者结构稍微变一点,你的代码就崩了。
这时候,Apollo Client 的规范化缓存登场了。它就像一个严苛的图书管理员,把所有散落的数据都收集起来,整理成一张清晰的索引表。
第二章:规范化——把散落的珍珠串成项链
规范化的核心思想很简单:把数据对象变成图(Graph)。
在 Apollo 缓存中,数据不是一坨 JSON,而是一张规范化缓存对象(NormalizedCacheObject)。它长什么样?看着吓人,其实很简单。
它本质上是这样一个结构:
{
"Article:123": { ...articleData },
"User:u1": { ...userData },
"Comment:c1": { ...commentData }
}
注意到了吗?每一个对象都有了一个唯一的键(Key)。
- Article 用 ID
123。 - User 用 ID
u1。 - Comment 用 ID
c1。
这就是规范化。它把所有零散的数据提取出来,放在顶层,用 ID 索引。原本嵌套的 article.comments 数组里的对象,现在变成了引用。
{
"Article:123": {
"id": "123",
"title": "...",
"comments": [
{ "__ref": "Comment:c1" },
{ "__ref": "Comment:c2" }
]
},
"Comment:c1": {
"id": "c1",
"text": "同意",
"user": { "__ref": "User:u1" }
}
}
为什么要这么做?
因为现在,无论你的数据在 Article 里,还是在 Sidebar 里,或者在一个弹窗里,它们都是同一个对象。
如果你在 Article 里给评论点了赞,修改的是 Comment:c1。当你再去读取 Article 时,Apollo 会自动知道:“哦,c1 这个对象变了,它被更新了。”然后 React 组件会重新渲染。而且,它只更新受影响的部分。
这就好比,你把一本书分成了很多页。以前,你改一页,可能得把整本书(整个组件树)撕了重印。现在,你只是把那一页纸换了,然后告诉大家:“那一页变了,其他人看的时候去换那一页。”
第三章:ID 的灵魂——DataIdFromObject
光有概念不行,还得干活。怎么让 Apollo 认识到这些 ID?默认情况下,Apollo 会自动给每个对象一个 __ref,比如 Item:5。但这太傻了,因为 Item 是什么?是 User?是 Post?是 Comment?
我们需要告诉 Apollo 如何生成 ID。这主要通过 DataIdFromObject 函数实现。
假设我们的 Schema 是这样的(想象一下 .graphql 文件):
type User @key(fields: "id") {
id: ID!
name: String!
posts: [Post!]!
}
type Post @key(fields: "id") {
id: ID!
title: String!
author: User!
comments: [Comment!]!
}
type Comment @key(fields: "id") {
id: ID!
text: String!
author: User!
}
注意看 @key(fields: "id")。这是 GraphQL 的新特性,叫 Entitiy Key。它告诉 Apollo:“嘿,这个对象有个唯一的 id 字段,你可以用它来索引我。”
好了,现在我们在 ApolloClient 的配置里加上这个翻译官:
import { ApolloClient, InMemoryCache } from '@apollo/client';
const client = new ApolloClient({
uri: 'https://api.example.com/graphql',
cache: new InMemoryCache({
// 告诉 Apollo 怎么生成 ID
dataIdFromObject: object => {
// 1. 如果有 ID 字段,直接用(通常是字符串或数字)
if (object.id) return object.id;
// 2. 稍微有点特殊的处理,比如 ObjectID
if (object._id) return object._id;
// 3. 如果没有 ID,就别管了,让 Apollo 自己生成一个 __ref
return null;
}
})
});
如果这样配置了,当你从 API 获取到一个 User 对象 { id: 'u1', name: 'Alice' } 时,Apollo 会自动把它存入缓存,键名为 'u1'。
当你后续获取 Post 数据时,发现里面的 author 是 { __ref: 'u1' },Apollo 会立刻去缓存里把 u1 对应的数据拿出来,合并进去。
这速度,那是相当快! 就像浏览器本地数据库,没有网络请求,全在内存里飞。
第四章:更新缓存的“核武器”——cache.modify
现在,我们回到了最开始的痛点:用户点赞了评论。
在 Apollo 里,我们通常不直接去修改 React 的 state,因为那样会破坏缓存的完整性。我们应该直接修改缓存。这叫“写入到缓存”。
Apollo 提供了几个 API:writeQuery(写查询)、writeFragment(写片段)、modify(修改)。
1. writeFragment:温和的更新
如果你知道你要更新哪个对象,而且这个对象有明确的 ID,用这个。
假设你想更新评论 c1,把点赞数加 1。
import { gql, useMutation } from '@apollo/client';
const UPDATE_LIKE = gql`
mutation IncrementLike($id: ID!) {
incrementLike(id: $id) {
id
likes
}
}
`;
function LikeButton({ commentId }) {
const [updateLike] = useMutation(UPDATE_LIKE, {
update(cache, { data }) {
// data.incrementLike 是后端返回的新数据
const { likes } = data.incrementLike;
// writeFragment 需要知道数据在哪,以及如何更新
cache.writeFragment({
id: `Comment:${commentId}`, // 注意这个 ID 的写法,根据 DataIdFromObject 的配置
fragment: gql`
fragment CommentLikes on Comment {
likes
}
`,
data: {
__typename: 'Comment', // 必须指定类型
likes: likes
}
});
}
});
return <button onClick={() => updateLike({ variables: { id: commentId } })}>👍 点赞</button>;
}
这很安全,也很优雅。但如果你不知道 commentId 怎么获取怎么办?或者你需要在修改一个引用对象时,顺带修改它的父级对象?
这就轮到 cache.modify 登场了。
2. cache.modify:上帝模式
cache.modify 允许你基于缓存中的现有数据,以编程的方式修改整个图。它就像是在你的数据森林里修路。
我们还是用上面的场景。这次,我们不依赖后端返回的新数据,我们直接读取缓存,计算新值,然后写入。
function LikeButton({ commentId }) {
const [updateLike] = useMutation(UPDATE_LIKE, {
update(cache, { variables }) {
// 这是一个回调函数,它接收当前节点
cache.modify({
id: `Comment:${variables.id}`,
// 1. read:从缓存读取当前值
read(existingData) {
if (!existingData) return null;
return existingData;
},
// 2. merge:合并数据(或者直接返回新数据)
merge(existingData, incomingData) {
// 这里直接覆盖是最简单的,高级点可以做累加
return {
...incomingData,
__typename: existingData.__typename
};
}
});
}
});
// ...
}
等等,这好像也没体现出多强大的能力。来个更猛的。
场景:你修改了用户的名字。
User u1 现在叫 “Alice”。
但是,”Alice” 写了 5 篇文章,每篇文章里都引用了她的名字。还有 100 个评论都提到了 “Alice”。
如果不规范化,你改个名字得改 106 个地方。用了 Apollo 的规范化缓存,你只需要改缓存里的 User:u1 节点。React 组件读取数据时,会自动从缓存拿到最新的 “Alice”。
这是自动的!这就是规范化的魔力。
第五章:实战演练——构建一个“动态购物车”
光说不练假把式。咱们来模拟一个稍微复杂点的场景:购物车结算。
我们的数据结构大概是:
Product:商品,有 ID、价格、库存。Cart:购物车,包含一个列表items,每个 item 指向一个Product。CartTotal:购物车总额。
如果我们要从购物车里“移除”一个商品,会发生什么?
- 如果用简单的 JSON 更新,你可能会不小心删掉了整个购物车。
- 但有了规范化,我们只需要从
Cart.items数组里移除那个引用即可。
让我们看看代码。
场景:移除商品
import { gql, useMutation } from '@apollo/client';
const REMOVE_FROM_CART = gql`
mutation RemoveFromCart($productId: ID!) {
removeFromCart(productId: $productId) {
id
items {
__ref # 引用
}
}
}
`;
function CartItem({ productId }) {
// 我们定义这个 mutation
const [removeItem] = useMutation(REMOVE_FROM_CART, {
update(cache, { variables }) {
// 1. 找到购物车对象
// 假设购物车的 ID 是 'Cart:1'
const cartId = 'Cart:1';
// 2. 读取当前的购物车状态
// 我们需要用 readQuery 来读取缓存
const existingCart = cache.readQuery({
query: gql`
query GetCart {
cart(id: "Cart:1") {
id
items {
__ref
}
}
}
`
});
if (!existingCart || !existingCart.cart) return;
const { cart } = existingCart;
const items = cart.items || [];
// 3. 筛选出不包含我们要移除的商品的 ID
const newItems = items.filter(item => {
// item 是 { __ref: "Product:101" }
// 我们需要提取出实际的 ID
const idParts = item.__ref.split(':');
return idParts[1] !== variables.productId;
});
// 4. 写回缓存
cache.writeQuery({
query: gql`
query GetCart {
cart(id: "Cart:1") {
id
items {
__ref
}
}
}
`,
data: {
cart: {
...cart, // 保持原有的其他数据
items: newItems
}
}
});
// 5.(可选)原子化更新总额
// 这里我们可以计算一下新总额,然后更新一个专门的 CartTotal 节点
// 这展示了规范化的好处:我们可以在不重绘整个购物车的情况下更新总额
const totalNodeId = `CartTotal:${cart.id}`;
// 简单的逻辑演示:重新计算总额
// 实际中可能需要读取出所有商品的价格相加
const newTotal = newItems.reduce((sum, item) => {
// 这里模拟从 Product 节点读取价格
// 在真实场景中,你可能需要 traverse cache
return sum + 10;
}, 0);
cache.modify({
id: totalNodeId,
read(existing) {
return existing || {};
},
merge(existing, incoming) {
return { ...existing, total: newTotal };
}
});
}
});
return (
<div>
<span>商品 ID: {productId}</span>
<button onClick={() => removeItem({ variables: { productId } })}>
移除
</button>
</div>
);
}
看懂了吗?这就是规范化的力量。
我们在 update 回调里做了一切。我们读取了数据,我们进行了计算,我们写回了数据。
关键点在于: Cart 对象和 Product 对象是分开存储的。
当我们修改 Cart 里的引用列表时,Product 对象本身(它的价格、库存)并没有被改变。只有当我们真正下单(调用后端 API)时,库存才会扣减。
如果我们在缓存里直接操作 Product 的库存,而没有调用后端 API,那我们就在欺骗系统。规范化的数据流确保了我们遵循“读取-计算-写入”的原则。
第六章:高级技巧——readFragment 与 merge
有时候,仅仅修改 ID 是不够的。我们需要深入到对象的内部去。
readFragment:从缓存深挖
readFragment 允许你直接从缓存中读取一个片段的数据,不管它现在挂载在哪个父对象上。
const authorName = cache.readFragment({
fragment: gql`
fragment AuthorName on User {
name
}
`,
id: 'User:u1'
});
这很有用,比如你想在 UI 上显示“作者”,但你没有查询 User 的完整字段,你只是查了 Post。
merge:自定义合并策略
writeFragment 默认会覆盖旧数据。但在某些复杂场景下,我们需要合并,而不是覆盖。比如,后端更新了 createdAt,但前端想要保留 localNote。
虽然这通常是 readFragment + writeFragment 的组合,但更高级的是在 writeQuery 时自定义 merge 函数。
cache.writeQuery({
query: GET_USER,
data: { user: { ...incomingData, localNote: "我之前留的备注" } },
// 这是一个高级技巧,用来合并嵌套对象
fragmentMatcher: fragmentMatcher,
});
第七章:处理“孤儿数据”与 evict
规范化缓存虽然强大,但也会变“胖”。如果你在 useState 里存了一堆数据,然后通过 client.writeQuery 写入了缓存,但以后你不想要了,这些数据就会像垃圾一样留在缓存里。
这就是“孤儿数据”。
解决方案:cache.evict
cache.evict({
id: 'User:u1', // 指定要删除的 ID
broadcast: true // 强制所有订阅该 ID 的组件重新渲染
});
cache.gc(); // 触发垃圾回收
记住,evict 是有副作用的。它会导致依赖这个 ID 的组件重新渲染。所以,当你批量更新很多数据时,尽量不要频繁使用 evict,除非必要。
第八章:常见陷阱与最佳实践
讲了这么多好话,我们再泼点冷水。规范化缓存虽然好,但用不好会让人抓狂。
陷阱 1:忘记 __typename
当你手动构造数据写入缓存时,一定要带上 __typename。这是 Apollo 识别类型的关键。如果你漏了,缓存可能会把一个 User 误认为是 Product。
陷阱 2:ID 生成冲突
如果你没有配置 DataIdFromObject,Apollo 会用默认的引用计数(Item:1, Item:2)。但这在多个页面、多次请求之间可能会产生冲突。永远建议配置 DataIdFromObject。
陷阱 3:过度使用 modify
modify 是强大的,但它是低级的 API。它直接操作内存对象,容易写错,也容易破坏缓存的一致性。优先使用 writeFragment 或 writeQuery,只有在极少数需要原子操作或复杂条件更新时,才使用 modify。
陷阱 4:客户端缓存与服务器同步
这是 GraphQL 最难的地方。客户端缓存是离线的、实时的,但服务器缓存是异步的。
比如,用户在本地缓存里删除了一条评论。但如果服务器那边还没同步,用户刷新页面,评论又回来了。
最佳实践: 使用 Apollo Link 或 Resolvers 来处理这些同步逻辑。通常,我们会在 update 回调里调用 refetchQueries 或者手动通知服务器。
第九章:React 组件与缓存的完美握手
现在,让我们把所有的拼图放在一起。
一个典型的 React 组件在 Apollo 中的生命周期是这样的:
- 挂载:
useQuery触发。Apollo 检查缓存。如果有缓存,直接返回缓存数据(极快)。如果没有,发起网络请求。 - 更新:组件挂载后,如果
variables变了(比如搜索词变了),Apollo 会更新查询条件,重新读取缓存。 - 网络响应:后端返回新数据。Apollo 执行 规范化。
- 新数据进入缓存。
- 如果缓存里已有该 ID 的数据,进行合并。
- 触发所有订阅了该 ID 或相关 ID 的组件重新渲染。
- 交互:用户点击按钮。Mutation 触发。
- 调用
update回调。 - 修改缓存(修改 ID 或修改引用)。
- 触发重新渲染。
- 调用
这个过程是声明式的。你不需要去操作 DOM,不需要去手动 appendChild,你只需要告诉 Apollo:“嘿,我要这个数据,如果它变了,请告诉我。”
这就是 React + Apollo 的威力。
结语:拥抱混乱
想象一下,如果你的应用有成千上万条评论,成千上万个用户。如果你还在用 useState 去维护一个巨大的 JSON 对象,每次修改都要 filter、map、深拷贝。你的应用性能会随着数据量的增加呈指数级下降,而且你会因为数据的漂移而写出无数个 bug。
Apollo Client 的规范化缓存,就像是给混乱的数据流安装了一个秩序的路由器。它将复杂的关系网变成了高效的内存索引。
记住: GraphQL 解决了“获取”的问题,Apollo 解决了“存储”的问题。而规范化,就是连接这两者的桥梁。
当你学会了利用 DataIdFromObject 管理你的 ID,学会了用 writeFragment 精确更新数据,学会了用 modify 在必要时进行微操,你就会发现,React 的开发不再是与 DOM 的搏斗,而是一种与数据结构的优雅共舞。
不要害怕缓存,去拥抱它,去控制它。当你开始以“图”的思维去思考数据,而不是“数组”的时候,你就真正掌握了前端高级开发的精髓。
好了,今天的讲座就到这里。下次当你遇到“我的数据怎么没更新”这种问题时,别急着写 useEffect,先去看看你的缓存,它是你的老朋友,它在默默帮你扛着数据的世界。
祝编码愉快,保持缓存干净!