在 Apollo Client 中使用 Relay 风格解决 GraphQL 分页问题
GraphQL 是一个功能强大的工具,具有许多优点。然而,像任何工具一样,它也有其弱点,其中之一就是分页。它并不总是开箱即用,需要自定义缓存合并函数和在变更后手动更新。在本文中,我们将使用聊天消息的示例来探索 relay 风格的分页。
在 Apollo Client 中实现 Relay 风格的分页
Apollo Client 具有内置的缓存合并函数,用于 relay 风格的分页。你可以在 这里 找到它。
要将其集成到你的项目中,请将 relay 风格的分页函数添加到你的 InMemoryCacheConfig
中的 typePolicies
配置中:
export const cache = new InMemoryCache({
typePolicies: {
Dialog: {
fields: {
messages: relayStylePagination([]),
},
},
},
});
处理 OptimisticResponse 和缓存更新
如果你想添加 optimisticResponse
并在 update
中更新缓存,该怎么办?如何将其与分页和游标结合?
乐观更新
optimisticResponse
允许你暂时将数据添加到缓存中,就好像请求已经成功一样。这对于创建流畅和响应迅速的用户界面非常有用。
使用 optimisticResponse 的示例:
const [sendMessage] = useMutation(SEND_MESSAGE_MUTATION, {
optimisticResponse: {
__typename: "Mutation",
sendMessage: {
__typename: "Message",
id: "temp-id", // 临时标识符
text: newMessageText,
createdAt: new Date().toISOString(),
sender: currentUser,
},
},
update(cache, { data: { sendMessage } }) {
// 变更后的缓存更新逻辑
},
});
为 update 编写 cache.modify 函数
我们将逐步描述我们的 cache.modify
用于 update
的步骤。
-
创建带有
update
参数的变更:const [mutation] = useMutation(SEND_MESSAGE_MUTATION, { update(cache, { data }) { if (data?.sendMessage) { // 缓存更新代码将在这里 } }, });
-
获取我们的消息对话框的
dataId
:const dataId = cache.identify({ __typename: dialog.__typename, id: dialog.id, });
-
检查是否获得了
dataId
:if (!dataId) return;
-
修改缓存:
cache.modify<Dialog>({ id: dataId, fields: { messages(existingMessages, { isReference, readField }) { // 消息更新逻辑将在这里 }, }, });
-
创建现有消息的副本:
const existingEdges = (existingMessages.edges || []).slice();
-
从第一条消息中删除游标以避免与游标混淆:
const lastMessage = { ...existingEdges[0] }; const cursor = isReference(existingEdges[0]) ? readField<string>("cursor", existingEdges[0]) : existingEdges[0].cursor; delete lastMessage.cursor; existingEdges[0] = lastMessage;
-
创建新消息并将其添加到列表的开头:
const edge = { __typename: "MessageEdge", cursor, node: sendMessage, }; existingEdges.unshift(edge);
-
返回更新后的消息列表:
return { ...existingMessages, edges: existingEdges, };
update 函数的完整示例
const [mutation] = useMutation(SEND_MESSAGE_MUTATION, {
update(cache, { data }) {
if (data?.sendMessage) {
try {
const dataId = cache.identify({
__typename: dialog.__typename,
id: dialog.id,
});
if (!dataId) return;
cache.modify<Dialog>({
id: dataId,
fields: {
messages(existingMessages, { isReference, readField }) {
const existingEdges = (existingMessages.edges || []).slice();
const lastMessage = { ...existingEdges[0] };
const cursor = isReference(existingEdges[0])
? readField<string>("cursor", existingEdges[0])
: existingEdges[0].cursor;
delete lastMessage.cursor;
existingEdges[0] = lastMessage;
const edge = {
__typename: "MessageEdge",
cursor,
node: sendMessage,
};
existingEdges.unshift(edge);
return {
...existingMessages,
edges: existingEdges,
};
},
},
});
} catch (error) {
console.error("更新缓存时出错:", error);
}
}
},
});
更新缓存时去重消息
在接收新数据时,你可能会遇到缓存中重复消息的问题。为避免这种情况,我们将在 relayStylePagination
函数中添加去重。
带去重的 relayStylePagination 函数
-
导入必要的模块并定义类型:
import { __rest } from "tslib"; import { FieldPolicy, Reference } from "@apollo/client"; import { RelayFieldPolicy, TExistingRelay, TRelayEdge, TRelayPageInfo, } from "@apollo/client/utilities/policies/pagination"; import { mergeDeep } from "@apollo/client/utilities"; import { ReadFieldFunction } from "@apollo/client/cache/core/types/common"; type KeyArgs = FieldPolicy<any>["keyArgs"];
-
定义辅助函数:
-
获取额外字段的函数:
const notExtras = ["edges", "pageInfo"]; const getExtras = (obj: Record<string, any>) => __rest(obj, notExtras);
-
创建空数据对象的函数:
function makeEmptyData(): TExistingRelay<any> { return { edges: [], pageInfo: { hasPreviousPage: false, hasNextPage: true, startCursor: "", endCursor: "", }, }; }
-
获取边节点 ID 的函数:
type IsReferenceFunction = (obj: any) => obj is Reference; type GetEdgeNodeIdPayload = { edge: TRelayEdge<Reference>; isReference: IsReferenceFunction; readField: ReadFieldFunction; idKey?: string; }; function getEdgeNodeId({ edge, isReference, readField, idKey, }: GetEdgeNodeIdPayload): string | undefined { const node = isReference(edge) ? readField<string>("node", edge) : edge.node; if (node) { return isReference(node) ? readField<string>(idKey || "id", node) : (node as any)?.id; } return undefined; }
-
-
带去重的
relayStylePagination
函数:export function relayStylePagination<TNode extends Reference = Reference>( keyArgs: KeyArgs = false, idKey?: string ): RelayFieldPolicy<TNode> { return { keyArgs, read(existing, { canRead, readField }) { if (!existing) return existing; const edges: TRelayEdge<TNode>[] = []; let firstEdgeCursor = ""; let lastEdgeCursor = ""; existing.edges.forEach((edge) => { if (canRead(readField("node", edge))) { edges.push(edge); if (edge.cursor) { firstEdgeCursor = firstEdgeCursor || edge.cursor || ""; lastEdgeCursor = edge.cursor || lastEdgeCursor; } } }); if (edges.length > 1 && firstEdgeCursor === lastEdgeCursor) { firstEdgeCursor = ""; } const { startCursor, endCursor } = existing.pageInfo || {}; return { ...getExtras(existing), edges, pageInfo: { ...existing.pageInfo, startCursor: startCursor || firstEdgeCursor, endCursor: endCursor || lastEdgeCursor, }, }; }, merge(existing, incoming, { args, isReference, readField }) { if (!existing) { existing = makeEmptyData(); } if (!incoming) { return existing; } const incomingEdges: typeof incoming.edges = []; const incomingIds = new Set(); if (incoming.edges) { incoming.edges.forEach((edge) => { if (isReference((edge = { ...edge }))) { edge.cursor = readField<string>("cursor", edge); } const nodeId = getEdgeNodeId({ edge, isReference, readField, idKey, }); if (nodeId) incomingIds.add(nodeId); incomingEdges.push(edge); }); } if (incoming.pageInfo) { const { pageInfo } = incoming; const { startCursor, endCursor } = pageInfo; const firstEdge = incomingEdges[0]; const lastEdge = incomingEdges[incomingEdges.length - 1]; if (firstEdge && startCursor) { firstEdge.cursor = startCursor; } if (lastEdge && endCursor) { lastEdge.cursor = endCursor; } const firstCursor = firstEdge && firstEdge.cursor; if (firstCursor && !startCursor) { incoming = mergeDeep(incoming, { pageInfo: { startCursor: firstCursor, }, }); } } let prefix: typeof existing.edges = []; let afterIndex = -1; let beforeIndex = -1; existing.edges.forEach((edge, index) => { const nodeId = getEdgeNodeId({ edge, isReference, readField, idKey, }); /** * 删除重复项 */ if (!(nodeId && incomingIds.has(nodeId))) prefix.push(edge); if (edge.cursor === args?.after) afterIndex = index; if (edge.cursor === args?.before) beforeIndex = index; }); let suffix: typeof prefix = []; if (args && args.after) { if (afterIndex >= 0) { prefix = prefix.slice(0, afterIndex + 1); } } else if (args && args.before) { suffix = beforeIndex < 0 ? prefix : prefix.slice(beforeIndex); prefix = []; } else if (incoming.edges) { prefix = []; } const edges = [...prefix, ...incomingEdges, ...suffix]; const pageInfo: TRelayPageInfo = { ...incoming.pageInfo, ...existing.pageInfo, }; if (incoming.pageInfo) { const { hasPreviousPage, hasNextPage, startCursor, endCursor, ...extras } = incoming.pageInfo; Object.assign(pageInfo, extras); if (!prefix.length) { if (void 0 !== hasPreviousPage) pageInfo.hasPreviousPage = hasPreviousPage; if (void 0 !== startCursor) pageInfo.startCursor = startCursor; } if (!suffix.length) { if (void 0 !== hasNextPage) pageInfo.hasNextPage = hasNextPage; if (void 0 !== endCursor) pageInfo.endCursor = endCursor; } } return { ...getExtras(existing), ...getExtras(incoming), edges, pageInfo, }; }, }; }
去重的工作原理
-
创建新节点 ID
incomingIds
的集合:在处理传入数据
incoming.edges
时,为每个边节点创建一个 IDnodeId
,并将其添加到集合incomingIds
中。const incomingEdges: typeof incoming.edges = []; const incomingIds = new Set(); if (incoming.edges) { incoming.edges.forEach((edge) => { if (isReference((edge = { ...edge }))) { edge.cursor = readField<string>("cursor", edge); } const nodeId = getEdgeNodeId({ edge, isReference, readField, idKey }); if (nodeId) incomingIds.add(nodeId); incomingEdges.push(edge); }); }
-
从现有数据
existing.edges
中删除重复项:在处理现有数据时,检查每个边节点是否存在于集合
incomingIds
中。如果节点已存在于新数据中,则不将其添加到结果数组prefix
中。let prefix: typeof existing.edges = []; existing.edges.forEach((edge, index) => { const nodeId = getEdgeNodeId({ edge, isReference, readField, idKey }); if (!(nodeId && incomingIds.has(nodeId))) prefix.push(edge); if (edge.cursor === args?.after) afterIndex = index; if (edge.cursor === args?.before) beforeIndex = index; });
-
合并不重复的数组:
在从现有数据中删除重复项后,将数组
prefix
、incomingEdges
和suffix
组合成一个新数组edges
,然后将其返回到缓存中。const edges = [...prefix, ...incomingEdges, ...suffix];
因此,我们在更新缓存时避免了重复消息,保持了客户端应用程序中的数据一致性。