Решение проблем с пагинацией в GraphQL с использованием relay-стиля в Apollo Client
GraphQL — это мощный инструмент с множеством плюсов и преимуществ. Однако у всего есть слабые места, и одно из слабых мест GraphQL — это пагинация. Она не всегда хорошо работает из коробки, поэтому приходится описывать функции слияния кэша и ручные обновления после мутаций. В этой статье мы рассмотрим пагинацию в стиле relay на примере сообщений в диалоге чата.
Реализация relay-style пагинации в Apollo Client
В Apollo Client есть реализация слияния кэша для relay-style пагинации. Вы можете найти ее здесь.
Чтобы интегрировать это в ваш проект, добавьте функцию relay-style pagination в конфигурацию 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 } }) {
// Логика обновления кэша после выполнения мутации
},
});
Написание функции cache.modify для update
Мы опишем наш 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 updating cache:", 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: "", }, }; }
-
Функция для получения идентификатора узла edge:
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, }; }, }; }
Как работает дедубликация
-
Создание множества идентификаторов новых узлов
incomingIds
:При обработке входящих данных
incoming.edges
, для каждого узлаedge
создается идентификаторnodeId
, который добавляется в множество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];
Таким образом, мы избегаем дублирования сообщений при обновлении кэша, сохраняя консистентность данных в клиентском приложении.