前言
上一篇文章:Nextjs 使用 graphql,并且接入多个节点 介绍了如何接入 graphql,并且使用 Apollo client
来请求和操作数据。后面深入了解了一下其缓存策略,想着有必要整理出来,作为后续学习。有任何问题还请批评指正。
Apollo Client 的缓存策略
Apollo Client 实现了一个缓存,将其查询结果存储在浏览器中,从而避免了不必要的网络调用。而具体是使用缓存中的数据还是直接网络请求后端数据,这取决于具体的产品需求。而我们将了解其支持的缓存策略,从而根据实际需求场景进行选择。
缓存策略
cache-first
Apollo 的默认获取策略是 cache-first
。如果您没有自行设置获取策略,则将使用此策略。它更倾向于快速响应查询,而不是获取最新的数据。如果您不希望数据发生变化,或者您希望在发生更改时明确更新缓存,那么这是一个不错的选择。使用缓存优先获取策略:
- 您查询一些数据。Apollo 检查缓存中的数据。如果所有数据都存在于缓存中,则直接跳至步骤 4。
- 如果缓存缺少您要求的某些数据,Apollo 将向您的 API 发出网络请求。
- API 用数据进行响应,Apollo 使用它来更新缓存。
- 返回了请求的数据。
cache-and-network
cache-and-network
是显示频繁更新的数据的一个好选择。
- 您查询一些数据。Apollo 检查缓存中的数据。
- 如果数据在缓存中,则返回该缓存数据。
- 无论在第二步是否找到任何数据,将查询传递给 API 以获取最新的数据。
- 使用来自 API 的任何新数据更新缓存。
- 返回更新后的 API 数据。
network-only
如果您不想缓存任何数据,那么使用network-only
策略来获取数据。此策略倾向于显示最新信息而不是快速响应。与cache-and-network
不同,此策略永远不会先从缓存中获取可能过时的数据,但是它将更新缓存,保证始终返回最新的数据状态。
- Apollo 不会检查缓存而向您的数据发出网络请求。
- 服务器响应您的数据并且缓存被更新。
- 数据已返回。
no-cache
no-cache
策略与network-only
类似,但它跳过了更新缓存的步骤。如果您根本不想在缓存中存储某些信息,这可能是合适的。
cache-only
与no-cache
相反 ,此获取策略避免发出任何网络请求。如果您查询的数据在缓存中不可用,它将抛出错误。如果您想要向用户显示一致的信息,而忽略任何服务器端更改,这会很有用。如果您想要让应用程序的某些部分能够离线工作,这也很有用。
- Apollo 检查缓存中是否存在查询的数据。
- 如果所有数据都存在于缓存中,则返回该数据(否则,抛出错误)。
设置策略
您可以为整个应用程序设置一个获取策略,也可以为给定查询设置单独的获取策略。
统一策略
在初始化 Apollo client 时,传递配置参数即可:
import { ApolloClient, InMemoryCache } from '@apollo/client';
const cache = new InMemoryCache();
const client = new ApolloClient({
// Provide required constructor fields
cache: cache,
// ...other configuration
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-and-network',
},
},
});
您还可以指定查询的 nextFetchPolicy
。如果这样做,fetchPolicy
将用于查询的第一次执行,然后 nextFetchPolicy
用于确定查询如何响应未来的缓存更新:
import { ApolloClient, InMemoryCache } from '@apollo/client';
const cache = new InMemoryCache();
const client = new ApolloClient({
// Provide required constructor fields
cache: cache,
// ...other configuration
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-and-network',
nextFetchPolicy: 'cache-first',
},
},
});
例如,如果您希望查询始终发出初始网络请求,但之后您可以轻松地从缓存中读取数据。
独立策略
默认情况下,useQuery
会采用默认的策略 缓存优先 检查 Apollo 客户端缓存,以查看您请求的所有数据是否已在本地可用。如果所有数据都在本地可用,则 useQuery
返回该数据并且不会查询您的 GraphQL 服务器。
您可以为给定查询指定不同的获取策略。为此,请在对 useQuery
的调用中包含 fetchPolicy
选项:
const { loading, error, data } = useQuery(GET_TODO, {
fetchPolicy: 'network-only', // Doesn't check cache before making a network request
});
同样的,单个请求也可以配置 nextFetchPolicy
:
const { loading, error, data } = useQuery(GET_TODO, {
fetchPolicy: 'network-only', // Used for first execution
nextFetchPolicy: 'cache-first', // Used for subsequent executions
});
Apollo 缓存策略原理
上面的策略提过,Apollo 客户端将 GraphQL 查询的结果存储在本地标准化内存缓存中。这使得 Apollo Client 几乎可以立即响应对已缓存数据的查询,甚至无需发送网络请求。
例如,当您第一次对 id 为 5 的 Book
对象执行 GetBook
查询时,流程如下所示:
以后每次您为同一对象执行 GetBook
时,流程将如下所示:
那么数据是如何缓存的呢?
Apollo 客户端的 InMemoryCache
将数据存储为可以相互引用的对象的扁平查找表。这些对象对应于 GraphQL 查询返回的对象。如果多个查询获取同一对象的不同字段,则单个缓存对象可能包含多个查询返回的字段。
缓存是扁平化的,但 GraphQL 查询返回的对象通常不是平坦的!事实上,它们的嵌套可以是任意深度。看一下这个示例查询响应:
{
"data": {
"person": {
"__typename": "Person",
"id": "cGVvcGxlOjE=",
"name": "Luke Skywalker",
"homeworld": {
"__typename": "Planet",
"id": "cGxhbmV0czox",
"name": "Tatooine"
}
}
}
}
此响应包含一个 Person
对象,该对象又在其 homeworld
字段中包含一个 Planet
对象。那么 InMemoryCache
如何在平面查找表中存储嵌套数据呢?在存储这些数据之前,缓存需要对其进行规范化。
数据标准化
每当 Apollo 客户端缓存收到查询响应数据时,它都会执行以下操作:
1. 识别对象
首先,缓存识别查询响应中包含的所有不同对象。在上面的例子中,有两个对象:
- A
Person
with idcGVvcGxlOjE=
- A
Planet
with idcGxhbmV0czox
2. 生成缓存 ID
识别出所有对象后,缓存会为每个对象生成一个缓存 ID。缓存 ID 唯一标识 InMemoryCache
中的特定对象。
默认情况下,对象的缓存 ID 是对象的 __typename
和 id
(或 _id
)字段的串联,并用冒号 (:
) 分隔。在这里插入代码片
因此,上例中对象的默认缓存 ID 为:
Person:cGVvcGxlOjE=
Planet:cGxhbmV0czox
3. 用引用替换对象字段
接下来,缓存获取包含对象的每个字段,并将其值替换为对适当对象的引用。
例如,下面是上面示例中引用替换之前的 Person
对象:
{
"__typename": "Person",
"id": "cGVvcGxlOjE=",
"name": "Luke Skywalker",
"homeworld": {
"__typename": "Planet",
"id": "cGxhbmV0czox",
"name": "Tatooine"
}
}
这是替换后的同一个对象:
{
"__typename": "Person",
"id": "cGVvcGxlOjE=",
"name": "Luke Skywalker",
"homeworld": {
"__ref": "Planet:cGxhbmV0czox"
}
}
homeworld
字段现在包含对适当标准化 Planet
对象的引用。
规范化可以显着减少数据重复,还可以帮助您的本地数据与服务器保持同步。
4. 存储标准化对象
最后,得到的对象全部存储在缓存的扁平查找表中。
每当传入对象与现有缓存对象具有相同的缓存 ID 时,这些对象的字段就会合并:
- 如果传入对象和现有对象共享任何字段,则传入对象将覆盖这些字段的缓存值。
- 仅保留现有对象或仅传入对象中出现的字段。
自定义 fields
那么如果数据对象不存在 id
或者 _id
时,Apollo 的标准化又该如何实现呢?
您可以自定义 InMemoryCache
如何为架构中的各个类型生成缓存 ID。这非常有用,尤其是当类型使用 id
或 _id
之外的字段(或多个字段!)作为其唯一标识符时。
为了实现这一点,您需要为每个要自定义的类型定义一个 TypePolicy
。您可以在提供给 InMemoryCache
构造函数的选项对象中指定所有缓存的 typePolicies
。
在相关 TypePolicy 对象中包含 keyFields 字段,如下所示:
const cache = new InMemoryCache({
typePolicies: {
Product: {
// In an inventory management system, products might be identified
// by their UPC.
keyFields: ["upc"],
},
Person: {
// In a user account system, the combination of a person's name AND email
// address might uniquely identify them.
keyFields: ["name", "email"],
},
Book: {
// If one of the keyFields is an object with fields of its own, you can
// include those nested keyFields by using a nested array of strings:
keyFields: ["title", "author", ["name"]],
},
AllProducts: {
// Singleton types that have no identifying field can use an empty
// array for their keyFields.
keyFields: [],
},
},
});
此示例显示了具有不同 keyField
的各种 typePolicies
:
Product
类型使用其upc
字段作为其标识字段Person
类型使用其name
和email
字段的组合Book
类型包含一个子字段作为其缓存 ID 的一部分- [
name
] 项表示数组中前一个字段(author
)的name
字段是缓存 ID 的一部分。Book
的author
字段必须是一个包含name
字段的对象,此字段才有效。 Book
类型的有效缓存 ID 具有以下结构:Book:{"title":"Fahrenheit 451","author":{"name":"Ray Bradbury"}}
- [
AllProducts
类型说明了单例类型的特殊策略。如果缓存仅包含一个AllProducts
对象,并且该对象没有标识字段,则可以为其keyFields
提供一个空数组。
更新 cache
那么我们如何更新 cache 呢?
Apollo 自动更新
Apollo 的每次 useQuery 请求,都会自动更新相同 __ref 的对象。而当我们 useMutation 更新数据后,如果返回的为修改后的实体对象,则会自动更新缓存。
const EDIT_TODO = gql`
mutation EditTodo ($id: Int!, $text: String!) {
editTodo (id: $id, text: $text) {
success
todo {
id
text
completed
}
}
}
`
无论是什么操作,只要我们返回一个包含 id 和更改字段的新对象,Apollo Client 就可以自动更新缓存中的项并触发 UI 的重新渲染。
手动修改缓存
Apollo client 支持不同的方式来手动更新缓存,我这边仅介绍我在开发中最常用的一种方式,其他的可以通过查看 InMemoryCache
对象类型来学习更详细的方法。
cache.modify
InMemoryCache
的modify
方法使您可以直接修改单个缓存字段的值,甚至完全删除字段。
需要注意的是:
modify
会绕过您定义的任何 merge 函数,这意味着字段始终会准确地用您指定的值覆盖。modify
无法写入缓存中尚不存在的字段。
modify
方法采用以下参数:
- 要修改的缓存对象的ID(建议使用cache.identify获取)
- 要执行的修饰符函数的映射(每个要修改的字段都有一个)
- 可选的
broadcast
和optimistic
布尔值来自定义行为
修饰符函数适用于单个字段。它将关联字段的当前缓存值作为参数,并返回应替换它的任何值。这也就是为何上述提到,无法写入缓存中不存在的字段,它仅针对已存在的字段进行修改。
以下是修改name
字段以将其值转换为大写的示例:
cache.modify({
id: cache.identify(myObject),
fields: {
name(cachedName) {
return cachedName.toUpperCase();
},
},
});
fields
这个字段为一个映射,指定要为缓存对象的每个修改字段调用的修饰符函数。
1. 修改字段
fields: {
[key]: (value, secondParams) => {
// modify the value
return modifiedValue;
}
}
如上述伪代码所示,key 为我们要修改的字段名称,其值为针对每个字段需要做的操作的函数。
2. 修饰符函数
其修饰符函数的第一个参数是正在修改的字段的当前缓存值。
第二个参数是一个辅助对象,包含以下实用程序:
字段 | 类型 | 描述 |
---|---|---|
fieldName | string | 正在修改的字段的名称。 |
storeFieldName | string | 内部使用的字段的完整键,包括序列化的键参数。。 |
readField | function | 用于读取作为第一个参数传递给修饰符函数的对象上的其他字段的辅助函数。 |
canRead | function | 对于非标准化 StoreObject 和非悬空References 返回 true 的辅助函数。这表明 readField(name, objOrRef) 有机会工作。对于从列表中过滤悬空引用很有用。 |
isReference | function | 如果特定对象是对缓存实体的引用,则返回 true 的辅助函数。 |
DELETE | object | 您可以从修饰符函数返回一个哨兵对象,以指示应完全删除该字段。 |
示例
1. 从列表中删除项
假设我们有一个Post
应用程序,其中每个帖子都有一组Comments
。以下是我们如何从分页的 Post.comments 数组中删除特定Comment
:
const idToRemove = 'abc123';
cache.modify({
id: cache.identify(myPost),
fields: {
comments(existingCommentRefs, { readField }) {
return existingCommentRefs.filter(
commentRef => idToRemove !== readField('id', commentRef)
);
},
},
});
- 在
id
字段中,我们使用cache.identify
来获取要从中删除评论的缓存Post
对象的缓存 ID。 - 在
fields
字段中,我们提供了一个列出修饰符函数的对象。在本例中,我们定义一个修饰符函数(用于comments
字段)。 comments
修饰符函数将我们现有的缓存评论数组作为参数(existingCommentRefs
)。它还使用readField
实用函数,它可以帮助您读取任何缓存字段的值。- 修饰符函数返回一个数组,该数组过滤掉 ID 与
idToRemove
匹配的所有注释。返回的数组将替换缓存中现有的数组。
2. 添加项到列表
现在让我们看看向帖子(Post
)添加评论(Comment
):
const newComment = {
__typename: 'Comment',
id: 'abc123',
text: 'Great blog post!',
};
cache.modify({
id: cache.identify(myPost),
fields: {
comments(existingCommentRefs = [], { readField }) {
const newCommentRef = cache.writeFragment({
data: newComment,
fragment: gql`
fragment NewComment on Comment {
id
text
}
`
});
// Quick safety check - if the new comment is already
// present in the cache, we don't need to add it again.
if (existingCommentRefs.some(
ref => readField('id', ref) === newComment.id
)) {
return existingCommentRefs;
}
return [...existingCommentRefs, newCommentRef];
}
}
});
当调用 comments
字段修饰符函数时,它首先调用 writeFragment
将我们的 newComment
数据存储在缓存中。 writeFragment
函数返回一个指向新缓存评论的引用 (newCommentRef
)。
作为安全检查,我们随后扫描现有评论引用数组 (existingCommentRefs
),以确保我们的新评论不在列表中。如果不在列表中,我们将新的评论引用添加到引用列表中,返回要存储在缓存中的完整列表。
3. 从缓存对象中删除字段
修饰符函数的可选第二个参数是一个对象,其中包含几个有用的实用程序,例如 canRead
和 isReference
函数。它还包括一个名为 DELETE
的哨兵对象。
要从特定缓存对象中删除字段,请从字段的修饰符函数返回 DELETE
哨兵对象,如下所示:
cache.modify({
id: cache.identify(myPost),
fields: {
comments(existingCommentRefs, { DELETE }) {
return DELETE;
},
},
});
4. 使缓存对象中的字段无效
通常,更改或删除字段的值也会使该字段无效,从而导致监视的查询(如果它们之前使用了该字段)被重新读取。
使用cache.modify
,还可以通过返回INVALIDATE
标记来使字段无效而不更改或删除其值:
cache.modify({
id: cache.identify(myPost),
fields: {
comments(existingCommentRefs, { INVALIDATE }) {
return INVALIDATE;
},
},
});
如果需要使给定对象中的所有字段无效,可以传递修饰符函数作为 fields
选项的值:
cache.modify({
id: cache.identify(myPost),
fields(fieldValue, details) {
return details.INVALIDATE;
},
});
总结
上述是我在使用 Apollo Client 过程中对其缓存策略的深入学习。可以总结为 Apollo 帮忙处理了每个对象,生成唯一标识,并且在请求或修改时,处理缓存数据。
而其提供针对缓存的各种操作方法,可以根据具体场景使用。可以在用户页面操作后,直接修改缓存,更快地响应到页面上。
Apollo 缓存策略文档
Understanding Apollo Fetch Policies
Demystifying Cache Normalization