自定义缓存字段的行为
您可以为您的 Apollo Client 缓存中特定的 字段的自读和写行为进行自定义。要这样做,您为该 字段定义一个 字段策略。字段策略可以包括:
您将字段策略提供给 InMemoryCache
构造函数。每个 字段策略定义在每个与父类型对应的 TypePolicy
对象 内。
以下示例定义了一个 Person
类型中 name
字段的 字段策略:
const cache = new InMemoryCache({typePolicies: {Person: {fields: {name: {read(name) {// Return the cached name, transformed to upper casereturn name.toUpperCase();}}},},},});
此字段策略定义了一个read
函数,该函数指定了在查询Person.name
时缓存返回的内容。
read
函数
如果您为字段定义一个read
函数,当您的客户端查询该字段时,缓存将调用该函数。在查询响应中,该字段将使用read
函数的返回值填充,而不是字段的缓存值。
每个read
函数都会传递两个参数:
第一个参数是字段当前的缓存值(如果存在的话)。您可以使用此值来帮助计算要返回的值。
第二个参数是一个对象,它提供了对几个属性和辅助函数的访问权限,包括传递给字段的任何参数。
- 请参阅的FieldFunctionOptions类型的
FieldPolicy
API参考。
- 请参阅的FieldFunctionOptions类型的
以下read
函数在缓存中没有可用值时,为name
字段返回默认值UNKNOWN NAME
;如果可用,将返回未修改的缓存值。
const cache = new InMemoryCache({typePolicies: {Person: {fields: {name: {read(name = "UNKNOWN NAME") {return name;}},},},},});
处理字段参数
如果一个字段
接受参数,则read
函数的第二个参数包括一个args
对象,该对象包含为这些参数提供的值。
例如,以下 read
函数检查是否为 maxLength
参数 提供了 字段。如果提供了,该函数只返回个人的名字的前 maxLength
个字符。否则,返回个人的全名。
const cache = new InMemoryCache({typePolicies: {Person: {fields: {// If a field's TypePolicy would only include a read function,// you can optionally define the function like so, instead of// nesting it inside an object as shown in the previous example.name(name: string, { args }) {if (args && typeof args.maxLength === "number") {return name.substring(0, args.maxLength);}return name;},},},},});
如果一个 字段 需要多个参数,则必须将每个参数包装在一个 变量 中,然后对这个变量进行解构并返回。每个参数将作为独立的子字段可用。
以下 read
函数将该 fullName
字段的 firstName
子字段分配给 UNKNOWN FIRST NAME
,并将该 fullName
字段的 lastName
分配给 UNKNOWN LAST NAME
。
const cache = new InMemoryCache({typePolicies: {Person: {fields: {fullName: {read(fullName = {firstName: "UNKNOWN FIRST NAME",lastName: "UNKNOWN LAST NAME",}) {return { ...fullName };},},},},},});
以下 query
返回 fullName
字段中的 firstName
和 lastName
子字段:
query personWithFullName {fullName {firstNamelastName}}
您可以为在您的模式中甚至没有定义的字段定义一个 read
函数。例如,以下 read
函数使您能够查询总是包含本地存储数据的 userId
字段:
const cache = new InMemoryCache({typePolicies: {Person: {fields: {userId() {return localStorage.getItem("loggedInUserId");},},},},});
请注意,要查询仅在本地定义的字段,应在该字段上包含 包含 @client
指令 以确保 Apollo Client 不会将其包括在发送到您的 GraphQL 服务器 的请求中。
其他 read
函数的使用案例包括:
- 将缓存数据转换为满足客户需求的格式,例如将浮点数四舍五入到最接近的整数
- 从同一对象的一个或多个局部字段中推导出(例如,从一个
birthdate
字段推导出age
字段) - 从一个或多个跨越多个对象的规划字段中推导出局部字段
有关提供给read
函数的所有选项的完整列表,请参阅API参考。您几乎不需要使用所有这些选项,但每个选项在从缓存读取字段时都起着重要的作用。
合并函数
如果您为字段定义了一个merge
函数,则在字段即将用传入的值(例如,从您的GraphQL服务器)写入时,缓存会调用该函数。在写入发生时,字段的新的值会设置成该merge
函数返回的值,而不是原始的传入值。
合并数组
合并函数的一个常见用法是为存储数组的字段定义如何写入。默认情况下,字段的现有数组会被完全由传入的数组替换。在许多情况下,更倾向于将两个数组连接起来,如下所示:
const cache = new InMemoryCache({typePolicies: {Agenda: {fields: {tasks: {merge(existing = [], incoming: any[]) {return [...existing, ...incoming];},},},},},});
这种模式在处理分页列表时尤其常见。
注意,每次为字段的一个实例调用此函数时,existing
都是未定义的,因为缓存尚未包含该字段的数据。提供默认参数existing = []
是处理这种情况的便捷方式。
您的merge
函数不能将传入的数组直接推送到existing
数组上。它必须返回一个新的数组以防止潜在的错误。在开发模式下,Apollo Client通过使用Object.freeze
防止对existing
数据的无意修改。
合并非规范化对象
您可以使用合并函数智能地组合缓存中未规范化嵌套对象,前提是这些对象位于同一个规范化父对象中。
示例
假设我们的graph的schema包括以下类型:
type Book {id: ID!title: String!author: Author!}type Author { # Has no key fieldsname: String!dateOfBirth: String!}type Query {favoriteBook: Book!}
使用此模式,我们的缓存可以规范化Book
对象,因为它们有一个id
字段。然而,Author
对象没有id
字段,它们也没有其他字段来唯一标识某个特定实例。因此,缓存不能规范化Author
对象,并且它不能判断当两个不同的Author
对象实际上代表了相同的作者。
现在,假设我们的客户端依次执行以下两个查询
query BookWithAuthorName {favoriteBook {idauthor {name}}}query BookWithAuthorBirthdate {favoriteBook {idauthor {dateOfBirth}}}
当第一个查询返回时,Apollo Client将以下Book
对象写入缓存:
{"__typename": "Book","id": "abc123","author": {"__typename": "Author","name": "George Eliot"}}
记住,因为Author
对象无法规范化,它们直接嵌入到其父对象中。
现在,当第二个查询返回时,缓存的Book
对象更新为以下内容:
{"__typename": "Book","id": "abc123","author": {"__typename": "Author","dateOfBirth": "1819-11-22"}}
作者name
字段已删除!这是因为Apollo Client不能确定两个查询返回的Author
对象实际上指向的是同一位作者。因此,Apollo Client不是合并两个对象字段,而是完全覆盖对象(并记录一条警告)。
然而,我们有信心这两个对象代表同一作者,因为一本书的作者几乎不会改变。因此,我们可以告诉缓存将这些Book.author
对象视为如果它们属于同一Book
对象就是同一个对象。这允许缓存合并不同查询返回的name
和dateOfBirth
为了实现这一点,我们可以在Book
的类型策略中为author
字段定义一个自定义的merge
函数::
const cache = new InMemoryCache({typePolicies: {Book: {fields: {author: {merge(existing, incoming, { mergeObjects }) {return mergeObjects(existing, incoming);},},},},},});
这里,我们使用mergeObjects
辅助函数将来自现有对象和传入对象的existing
和incoming
Author
值合并。在这里使用mergeObjects
而不是对象扩展语法是很重要的,因为mergeObjects
确保调用子字段的任何merge
函数。
请注意,这个merge
函数中不包含任何Book
或Author
特定的逻辑!这意味着您可以将其用于任何数量的非归一化对象字段。并且因为这个merge
函数的定义如此通用,您也可以以下面的简写定义它:
const cache = new InMemoryCache({typePolicies: {Book: {fields: {author: {// Equivalent to options.mergeObjects(existing, incoming).merge: true,},},},},});
总之,上面的Book.author
策略允许缓存智能地合并与任何特定归一化Book
对象相关的所有author
对象。
请记住,为了使merge: true
合并两个非归一化对象,以下所有条件都必须成立:
- 这两个对象必须位于缓存的相同归一化对象的精确字段中。
- 这两个对象必须有相同的
__typename
。- 这对于返回接口或联合类型的结果的字段很重要,这些类型可能返回多种不同的对象类型。
如果您需要违反这些规则的行为,则需要编写一个自定义的merge
函数而不是使用merge: true
。
合并非归一化对象的数组
考虑以下情况,如果Book
可以有多个authors
:
query BookWithAuthorNames {favoriteBook {isbntitleauthors {name}}}query BookWithAuthorLanguages {favoriteBook {isbntitleauthors {language}}}
在favoriteBook.authors
字段中,包含一组非归一化的Author
对象。在这种情况下,我们需要定义一个更复杂的merge
函数,以确保上述两个查询返回的name
和language
字段能够正确关联。
const cache = new InMemoryCache({typePolicies: {Book: {fields: {authors: {merge(existing: any[], incoming: any[], { readField, mergeObjects }) {const merged: any[] = existing ? existing.slice(0) : [];const authorNameToIndex: Record<string, number> = Object.create(null);if (existing) {existing.forEach((author, index) => {authorNameToIndex[readField<string>("name", author)] = index;});}incoming.forEach(author => {const name = readField<string>("name", author);const index = authorNameToIndex[name];if (typeof index === "number") {// Merge the new author data with the existing author data.merged[index] = mergeObjects(merged[index], author);} else {// First time we've seen this author in this array.authorNameToIndex[name] = merged.length;merged.push(author);}});return merged;},},},},},});
此代码将现有authors
数组与传入数组连接起来,同时在检查重复的作者名称。发现重复名称时,将重复的Author
对象的字段合并。
相对于直接使用author.name
,readField
辅助函数更健壮,因为它容忍author
是一个引用到缓存中其他数据的Reference
对象。这对于Author
类型最终定义了keyFields
并因此变成了归一化类型的情况非常重要。
正如此示例所示,merge
函数可以变得非常复杂。当它发生时,您通常可以将通用逻辑提取到一个可重用的辅助函数中:
const cache = new InMemoryCache({typePolicies: {Book: {fields: {authors: {merge: mergeArrayByField<AuthorType>("name"),},},},},});
现在您已经将细节隐藏在了可重用的抽象背后,实现细节变得不再重要。这很有解放感,因为它允许您随着时间的推移逐步改进客户端业务逻辑,同时保持相关逻辑在整个应用程序中的连续性。
在类型级别定义merge
函数
在Apollo Client 3.3及之后的版本中,您可以为非归一化对象类型定义一个默认的merge
函数。如果您这样做,除非在字段级别进行覆盖,否则任何返回该类型的字段都会使用您的默认merge
函数。
您在非归一化类型的类型策略中定义此默认的merge
函数。以下是从合并非归一化对象中提取的非归一化Author
类型的示例:
const cache = new InMemoryCache({typePolicies: {Book: {fields: {// No longer required!// author: {// merge: true,// },},},Author: {merge: true,},},});
如上所示,对于Book.author
的字段级别的merge
函数不再需要。在这样一个基本示例中,最终结果是相同的,但是,这种策略会自动将默认merge
函数应用于您未来可能添加的任何其他返回Author的Book
字段(例如Essay.author
)。
处理分页
当一个 字段包含一个数组时,通常很有用对这个数组的输出进行分页,因为总的结果集可以非常大。
通常,查询包含分页参数,指定以下内容:
- 在数组中从何处开始,使用数值偏移量或起始ID
- 单页返回的最大元素数量
如果您为一个 字段实现分页,如果您随后实现该 read
和 merge
函数,则需要考虑分页 参数:
const cache = new InMemoryCache({typePolicies: {Agenda: {fields: {tasks: {merge(existing: any[], incoming: any[], { args }) {const merged = existing ? existing.slice(0) : [];// Insert the incoming elements in the right places, according to args.const end = args.offset + Math.min(args.limit, incoming.length);for (let i = args.offset; i < end; ++i) {merged[i] = incoming[i - args.offset];}return merged;},read(existing: any[], { args }) {// If we read the field before any data has been written to the// cache, this function will return undefined, which correctly// indicates that the field is missing.const page = existing && existing.slice(args.offset,args.offset + args.limit,);// If we ask for a page outside the bounds of the existing array,// page.length will be 0, and we should return undefined instead of// the empty array.if (page && page.length > 0) {return page;}},},},},},});
正如此示例所示,您的 read
函数通常需要与您的 merge
函数合作,通过反向处理相同的 arguments
。
如果您要使某个“页面”从特定实体ID之后开始,而不是从 args.offset
开始,则可以按照以下方式实现您的 merge
和 read
函数,使用 readField
辅助函数检查现有的任务ID:
const cache = new InMemoryCache({typePolicies: {Agenda: {fields: {tasks: {merge(existing: any[], incoming: any[], { args, readField }) {const merged = existing ? existing.slice(0) : [];// Obtain a Set of all existing task IDs.const existingIdSet = new Set(merged.map(task => readField("id", task)));// Remove incoming tasks already present in the existing data.incoming = incoming.filter(task => !existingIdSet.has(readField("id", task)));// Find the index of the task just before the incoming page of tasks.const afterIndex = merged.findIndex(task => args.afterId === readField("id", task));if (afterIndex >= 0) {// If we found afterIndex, insert incoming after that index.merged.splice(afterIndex + 1, 0, ...incoming);} else {// Otherwise insert incoming at the end of the existing data.merged.push(...incoming);}return merged;},read(existing: any[], { args, readField }) {if (existing) {const afterIndex = existing.findIndex(task => args.afterId === readField("id", task));if (afterIndex >= 0) {const page = existing.slice(afterIndex + 1,afterIndex + 1 + args.limit,);if (page && page.length > 0) {return page;}}}},},},},},});
注意,如果您调用 readField(fieldName)
,它返回当前对象中指定的 field 的值。如果您将对象作为第二个 参数 传递给 readField
,readField
将读取该对象指定的 field。
上面的分页代码可能很复杂,但您实施首选的分页策略后,可以将其重用于使用该策略的每个 field,而不管该字段的类型如何。
function afterIdLimitPaginatedFieldPolicy<T>() {return {merge(existing: T[], incoming: T[], { args, readField }): T[] {...},read(existing: T[], { args, readField }): T[] {...},};}const cache = new InMemoryCache({typePolicies: {Agenda: {fields: {tasks: afterIdLimitPaginatedFieldPolicy<Reference>(),},},},});
禁用 merge
函数
在某些情况下,您可能希望完全禁用某些 fields 的合并函数。merge: false
如此:
const cache = new InMemoryCache({typePolicies: {Book: {fields: {// No longer necessary!// author: {// merge: true,// },},},Author: {merge: false,},},});
指定键参数
如果 field 接受 arguments,则您可以通过在 field's FieldPolicy 中指定一个 keyArgs
数组来指定数组。 此数组表示哪些 arguments 是影响 field's 返回值的 键参数。 指定此数组可以帮助减少缓存中的重复数据量。
示例
假设您架构的 Query
类型包含一个 monthForNumber
字段。 此字段根据提供的 number
参数返回特定月份的详细信息(例如,1月为 1
,依此类推)。该 number
参数是该字段的键参数,因为它的值会影响字段的返回值:
const cache = new InMemoryCache({typePolicies: {Query: {fields: {monthForNumber: {keyArgs: ["number"],},},},},});
一个非关键参数的示例是一个访问令牌,它是用来授权查询的,但不是用来计算其结果的。如果monthForNumber
也接受accessToken
参数,那么这个参数的值不会影响返回的哪个月的详细信息。
默认情况下,字段的所有参数都是关键参数。这意味着缓存为查询特定字段时提供的每个参数值的唯一组合存储一个单独的值。
如果您指定一个字段的关键参数,缓存就会明白该字段的剩余参数不是关键参数。这意味着当非关键参数发生变化时,缓存不需要存储一个完全不同的值。
例如,假设您使用monthForNumber
字段执行了两次不同的查询,传递了相同的数字参数,但不同的accessToken
参数。在这种情况下,第二次查询的响应将覆盖第一次,因为这两个调用为唯一的键参数使用了相同的值。
提供keyArgs
函数
如果您需要更多地控制特定字段的keyArgs
,您可以用函数而不是参数名称的数组来传递。这个keyArgs
函数接受两个参数:
- 一个
args
对象,包含为该字段提供的所有参数值 - 一个
context
对象,提供其他相关信息
有关详细信息,请参阅下面的KeyArgsFunction
(API参考)。
FieldPolicy
API参考
以下是对FieldPolicy
类型及其相关类型的定义:
// These generic type parameters will be inferred from the provided policy in// most cases, though you can use this type to constrain them more precisely.type FieldPolicy<TExisting,TIncoming = TExisting,TReadResult = TExisting,> = {keyArgs?: KeySpecifier | KeyArgsFunction | false;read?: FieldReadFunction<TExisting, TReadResult>;merge?: FieldMergeFunction<TExisting, TIncoming> | boolean;};type KeySpecifier = (string | KeySpecifier)[];type KeyArgsFunction = (args: Record<string, any> | null,context: {typename: string;fieldName: string;field: FieldNode | null;variables?: Record<string, any>;},) => string | KeySpecifier | null | void;type FieldReadFunction<TExisting, TReadResult = TExisting> = (existing: Readonly<TExisting> | undefined,options: FieldFunctionOptions,) => TReadResult;type FieldMergeFunction<TExisting, TIncoming = TExisting> = (existing: Readonly<TExisting> | undefined,incoming: Readonly<TIncoming>,options: FieldFunctionOptions,) => TExisting;// These options are common to both read and merge functions:interface FieldFunctionOptions {cache: InMemoryCache;// The final argument values passed to the field, after applying variables.// If no arguments were provided, this property will be null.args: Record<string, any> | null;// The name of the field, equal to options.field.name.value when// options.field is available. Useful if you reuse the same function for// multiple fields, and you need to know which field you're currently// processing. Always a string, even when options.field is null.fieldName: string;// The FieldNode object used to read this field. Useful if you need to// know about other attributes of the field, such as its directives. This// option will be null when a string was passed to options.readField.field: FieldNode | null;// The variables that were provided when reading the query that contained// this field. Possibly undefined, if no variables were provided.variables?: Record<string, any>;// Easily detect { __ref: string } reference objects.isReference(obj: any): obj is Reference;// Returns a Reference object if obj can be identified, which requires,// at minimum, a __typename and any necessary key fields. If true is// passed for the optional mergeIntoStore argument, the object's fields// will also be persisted into the cache, which can be useful to ensure// the Reference actually refers to data stored in the cache. If you// pass an ID string, toReference will make a Reference out of it. If// you pass a Reference, toReference will return it as-is.toReference(objOrIdOrRef: StoreObject | string | Reference,mergeIntoStore?: boolean,): Reference | undefined;// Helper function for reading other fields within the current object.// If a foreign object or reference is provided, the field will be read// from that object instead of the current object, so this function can// be used (together with isReference) to examine the cache outside the// current object. If a FieldNode is passed instead of a string, and// that FieldNode has arguments, the same options.variables will be used// to compute the argument values. Note that this function will invoke// custom read functions for other fields, if defined. Always returns// immutable data (enforced with Object.freeze in development).readField<T = StoreValue>(nameOrField: string | FieldNode,foreignObjOrRef?: StoreObject | Reference,): T;// Returns true for non-normalized StoreObjects and non-dangling// References, indicating that readField(name, objOrRef) has a chance of// working. Useful for filtering out dangling references from lists.canRead(value: StoreValue): boolean;// A handy place to put field-specific data that you want to survive// across multiple read function calls. Useful for field-level caching,// if your read function does any expensive work.storage: Record<string, any>;// Instead of just merging objects with { ...existing, ...incoming }, this// helper function can be used to merge objects in a way that respects any// custom merge functions defined for their fields.mergeObjects<T extends StoreObject | Reference>(existing: T,incoming: T,): T | undefined;}