Cover Image for 在 Apollo Client 中使用 Relay 风格解决 GraphQL 分页问题
[Apollo][GraphQL][Relay][React]
2024年6月26日

在 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 的步骤。

  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);
      }
    }
  },
});

更新缓存时去重消息

在接收新数据时,你可能会遇到缓存中重复消息的问题。为避免这种情况,我们将在 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: "",
          },
        };
      }
    • 获取边节点 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;
      }
  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. 创建新节点 ID incomingIds 的集合:

    在处理传入数据 incoming.edges 时,为每个边节点创建一个 ID 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. 合并不重复的数组:

    在从现有数据中删除重复项后,将数组 prefixincomingEdgessuffix 组合成一个新数组 edges,然后将其返回到缓存中。

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

因此,我们在更新缓存时避免了重复消息,保持了客户端应用程序中的数据一致性。

继续阅读

加入我们的社区