Решение проблем с пагинацией в GraphQL с использованием relay-стиля в Apollo Client

Cover Image for Решение проблем с пагинацией в 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 пошагово.

  1. Создаем мутацию с параметром update:

    const [mutation] = useMutation(SEND_MESSAGE_MUTATION, {
      update(cache, { data }) {
        if (data?.sendMessage) {
          // код обновления кэша будет здесь
        }
      },
    });
    
  2. Получаем dataId для нашего диалога сообщений:

    const dataId = cache.identify({
      __typename: dialog.__typename,
      id: dialog.id,
    });
    
  3. Проверяем, удалось ли получить dataId:

    if (!dataId) return;
    
  4. Изменяем кэш:

    cache.modify<Dialog>({
      id: dataId,
      fields: {
        messages(existingMessages, { isReference, readField }) {
          // логика обновления сообщений будет здесь
        },
      },
    });
    
  5. Создаем копию существующих сообщений:

    const existingEdges = (existingMessages.edges || []).slice();
    
  6. Удаляем курсор из первого сообщения, чтобы избежать путаницы с курсорами:

    const lastMessage = { ...existingEdges[0] };
    const cursor = isReference(existingEdges[0])
      ? readField<string>("cursor", existingEdges[0])
      : existingEdges[0].cursor;
    delete lastMessage.cursor;
    existingEdges[0] = lastMessage;
    
  7. Создаем новое сообщение и добавляем его в начало списка:

    const edge = {
      __typename: "MessageEdge",
      cursor,
      node: sendMessage,
    };
    existingEdges.unshift(edge);
    
  8. Возвращаем обновленный список сообщений:

    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 с дедубликацией

  1. Импорт необходимых модулей и определение типов:

    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"];
    
  2. Определение вспомогательных функций:

    • Функция для получения дополнительных полей:

      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;
      }
      
  3. Функция 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,
          };
        },
      };
    }
    

Как работает дедубликация

  1. Создание множества идентификаторов новых узлов 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);
      });
    }
    
  2. Удаление дубликатов из существующих данных 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;
    });
    
  3. Слияние массивов без дубликатов:

    После удаления дубликатов из существующих данных, массивы prefix, incomingEdges и suffix объединяются в новый массив edges, который и возвращается в кэш.

    const edges = [...prefix, ...incomingEdges, ...suffix];
    

Таким образом, мы избегаем дублирования сообщений при обновлении кэша, сохраняя консистентность данных в клиентском приложении.