Relay分页技术Skill relay-pagination

Relay 分页技术是一种基于游标的分页方法,用于在 GraphQL 和 React 前端应用中高效管理和加载大数据集,支持无限滚动、加载更多模式等场景。关键词:Relay 分页, GraphQL, 光标分页, 前端开发, 数据加载, 无限滚动, 缓存管理, React 钩子。

前端开发 0 次安装 0 次浏览 更新于 3/25/2026

name: relay-pagination user-invocable: false description: 使用基于游标的分页、无限滚动、加载更多模式和连接协议时使用。 allowed-tools:

  • Read
  • Write
  • Edit
  • Grep
  • Glob
  • Bash

Relay 分页

掌握 Relay 的基于游标的分页,用于高效加载和显示大数据集,支持无限滚动和加载更多模式。

概述

Relay 实现了 GraphQL 光标连接规范,用于高效分页。它提供了如 usePaginationFragment 的钩子,用于声明式分页,具有自动缓存更新和连接管理功能。

安装和设置

分页查询结构

# schema.graphql
type Query {
  posts(
    first: Int
    after: String
    last: Int
    before: String
  ): PostConnection!
}

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int
}

type PostEdge {
  cursor: String!
  node: Post!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

type Post {
  id: ID!
  title: String!
  body: String!
}

核心模式

1. 基础分页

// PostsList.jsx
import { graphql, usePaginationFragment } from 'react-relay';

const PostsListFragment = graphql`
  fragment PostsList_query on Query
  @refetchable(queryName: "PostsListPaginationQuery")
  @argumentDefinitions(
    first: { type: "Int", defaultValue: 10 }
    after: { type: "String" }
  ) {
    posts(first: $first, after: $after)
    @connection(key: "PostsList_posts") {
      edges {
        node {
          id
          ...PostCard_post
        }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
`;

function PostsList({ query }) {
  const {
    data,
    loadNext,
    loadPrevious,
    hasNext,
    hasPrevious,
    isLoadingNext,
    isLoadingPrevious,
    refetch
  } = usePaginationFragment(PostsListFragment, query);

  return (
    <div>
      <button
        onClick={() => refetch({ first: 10 })}
        disabled={isLoadingNext}
      >
        刷新
      </button>

      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}

      {hasNext && (
        <button
          onClick={() => loadNext(10)}
          disabled={isLoadingNext}
        >
          {isLoadingNext ? '加载中...' : '加载更多'}
        </button>
      )}
    </div>
  );
}

export default PostsList;

2. 无限滚动

// InfiniteScrollPosts.jsx
import { useEffect, useRef } from 'react';
import { graphql, usePaginationFragment } from 'react-relay';

const InfiniteScrollFragment = graphql`
  fragment InfiniteScrollPosts_query on Query
  @refetchable(queryName: "InfiniteScrollPostsQuery")
  @argumentDefinitions(
    first: { type: "Int", defaultValue: 20 }
    after: { type: "String" }
  ) {
    posts(first: $first, after: $after)
    @connection(key: "InfiniteScroll_posts") {
      edges {
        node {
          id
          ...PostCard_post
        }
      }
    }
  }
`;

function InfiniteScrollPosts({ query }) {
  const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment(
    InfiniteScrollFragment,
    query
  );

  const observerRef = useRef();
  const loadMoreRef = useRef();

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasNext && !isLoadingNext) {
          loadNext(20);
        }
      },
      { threshold: 0.5 }
    );

    const currentRef = loadMoreRef.current;
    if (currentRef) {
      observer.observe(currentRef);
    }

    observerRef.current = observer;

    return () => {
      if (currentRef) {
        observer.unobserve(currentRef);
      }
    };
  }, [hasNext, isLoadingNext, loadNext]);

  return (
    <div>
      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}

      {hasNext && (
        <div ref={loadMoreRef} className="load-more-trigger">
          {isLoadingNext && <Spinner />}
        </div>
      )}

      {!hasNext && <div>没有更多帖子</div>}
    </div>
  );
}

3. 双向分页

// BidirectionalPosts.jsx
const BidirectionalFragment = graphql`
  fragment BidirectionalPosts_query on Query
  @refetchable(queryName: "BidirectionalPostsQuery")
  @argumentDefinitions(
    first: { type: "Int" }
    after: { type: "String" }
    last: { type: "Int" }
    before: { type: "String" }
  ) {
    posts(first: $first, after: $after, last: $last, before: $before)
    @connection(key: "Bidirectional_posts") {
      edges {
        node {
          id
          ...PostCard_post
        }
      }
      pageInfo {
        hasNextPage
        hasPreviousPage
        startCursor
        endCursor
      }
    }
  }
`;

function BidirectionalPosts({ query }) {
  const {
    data,
    loadNext,
    loadPrevious,
    hasNext,
    hasPrevious,
    isLoadingNext,
    isLoadingPrevious
  } = usePaginationFragment(BidirectionalFragment, query);

  return (
    <div>
      {hasPrevious && (
        <button
          onClick={() => loadPrevious(10)}
          disabled={isLoadingPrevious}
        >
          {isLoadingPrevious ? '加载中...' : '加载前一个'}
        </button>
      )}

      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}

      {hasNext && (
        <button
          onClick={() => loadNext(10)}
          disabled={isLoadingNext}
        >
          {isLoadingNext ? '加载中...' : '加载下一个'}
        </button>
      )}
    </div>
  );
}

4. 过滤分页

// FilteredPosts.jsx
const FilteredPostsFragment = graphql`
  fragment FilteredPosts_query on Query
  @refetchable(queryName: "FilteredPostsQuery")
  @argumentDefinitions(
    first: { type: "Int", defaultValue: 10 }
    after: { type: "String" }
    status: { type: "PostStatus" }
    authorId: { type: "ID" }
  ) {
    posts(
      first: $first
      after: $after
      status: $status
      authorId: $authorId
    )
    @connection(key: "FilteredPosts_posts") {
      edges {
        node {
          id
          ...PostCard_post
        }
      }
    }
  }
`;

function FilteredPosts({ query }) {
  const [status, setStatus] = useState('PUBLISHED');
  const [authorId, setAuthorId] = useState(null);

  const { data, loadNext, hasNext, refetch } = usePaginationFragment(
    FilteredPostsFragment,
    query
  );

  const handleFilterChange = (newStatus, newAuthorId) => {
    setStatus(newStatus);
    setAuthorId(newAuthorId);

    refetch({
      first: 10,
      after: null,
      status: newStatus,
      authorId: newAuthorId
    });
  };

  return (
    <div>
      <FilterControls
        status={status}
        authorId={authorId}
        onChange={handleFilterChange}
      />

      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}

      {hasNext && (
        <button onClick={() => loadNext(10)}>加载更多</button>
      )}
    </div>
  );
}

5. 搜索分页

// SearchablePosts.jsx
const SearchablePostsFragment = graphql`
  fragment SearchablePosts_query on Query
  @refetchable(queryName: "SearchablePostsQuery")
  @argumentDefinitions(
    first: { type: "Int", defaultValue: 10 }
    after: { type: "String" }
    searchTerm: { type: "String" }
  ) {
    posts(first: $first, after: $after, searchTerm: $searchTerm)
    @connection(key: "SearchablePosts_posts") {
      edges {
        node {
          id
          ...PostCard_post
        }
      }
      totalCount
    }
  }
`;

function SearchablePosts({ query }) {
  const [searchTerm, setSearchTerm] = useState('');
  const { data, loadNext, hasNext, refetch, isLoadingNext } =
    usePaginationFragment(SearchablePostsFragment, query);

  const handleSearch = (term) => {
    setSearchTerm(term);
    refetch({
      first: 10,
      after: null,
      searchTerm: term
    });
  };

  return (
    <div>
      <SearchInput
        value={searchTerm}
        onChange={handleSearch}
        placeholder="搜索帖子..."
      />

      <div>
        显示 {data.posts.edges.length} 个帖子,共 {data.posts.totalCount} 个
      </div>

      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}

      {hasNext && (
        <button onClick={() => loadNext(10)} disabled={isLoadingNext}>
          {isLoadingNext ? '加载中...' : '加载更多'}
        </button>
      )}
    </div>
  );
}

6. 乐观分页更新

// OptimisticPaginationPosts.jsx
const CreatePostMutation = graphql`
  mutation OptimisticPaginationCreatePostMutation(
    $input: CreatePostInput!
    $connections: [ID!]!
  ) {
    createPost(input: $input) {
      postEdge @prependEdge(connections: $connections) {
        cursor
        node {
          id
          ...PostCard_post
        }
      }
    }
  }
`;

function OptimisticPaginationPosts({ query }) {
  const { data } = usePaginationFragment(PostsFragment, query);
  const [commit] = useMutation(CreatePostMutation);

  const connectionID = ConnectionHandler.getConnectionID(
    'client:root',
    'Posts_posts'
  );

  const handleCreate = (title, body) => {
    commit({
      variables: {
        input: { title, body },
        connections: [connectionID]
      },

      optimisticResponse: {
        createPost: {
          postEdge: {
            cursor: 'temp-cursor',
            node: {
              id: `temp-${Date.now()}`,
              title,
              body,
              createdAt: new Date().toISOString(),
              author: {
                id: currentUser.id,
                name: currentUser.name
              }
            }
          }
        }
      }
    });
  };

  return (
    <div>
      <CreatePostForm onSubmit={handleCreate} />
      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}
    </div>
  );
}

7. 分页标签页

// TabbedPosts.jsx
const TabbedPostsFragment = graphql`
  fragment TabbedPosts_user on User
  @refetchable(queryName: "TabbedPostsQuery")
  @argumentDefinitions(
    draftsFirst: { type: "Int", defaultValue: 10 }
    draftsAfter: { type: "String" }
    publishedFirst: { type: "Int", defaultValue: 10 }
    publishedAfter: { type: "String" }
  ) {
    draftPosts: posts(
      first: $draftsFirst
      after: $draftsAfter
      status: DRAFT
    )
    @connection(key: "TabbedPosts_draftPosts") {
      edges {
        node {
          id
          ...PostCard_post
        }
      }
    }

    publishedPosts: posts(
      first: $publishedFirst
      after: $publishedAfter
      status: PUBLISHED
    )
    @connection(key: "TabbedPosts_publishedPosts") {
      edges {
        node {
          id
          ...PostCard_post
        }
      }
    }
  }
`;

function TabbedPosts({ user }) {
  const [activeTab, setActiveTab] = useState('published');
  const { data } = usePaginationFragment(TabbedPostsFragment, user);

  const posts =
    activeTab === 'draft' ? data.draftPosts : data.publishedPosts;

  return (
    <div>
      <Tabs value={activeTab} onChange={setActiveTab}>
        <Tab value="published">已发布</Tab>
        <Tab value="draft">草稿</Tab>
      </Tabs>

      {posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}
    </div>
  );
}

8. 虚拟滚动分页

// VirtualizedPosts.jsx
import { useVirtualizer } from '@tanstack/react-virtual';
import { graphql, usePaginationFragment } from 'react-relay';

const VirtualizedPostsFragment = graphql`
  fragment VirtualizedPosts_query on Query
  @refetchable(queryName: "VirtualizedPostsQuery")
  @argumentDefinitions(
    first: { type: "Int", defaultValue: 50 }
    after: { type: "String" }
  ) {
    posts(first: $first, after: $after)
    @connection(key: "VirtualizedPosts_posts") {
      edges {
        node {
          id
          ...PostCard_post
        }
      }
    }
  }
`;

function VirtualizedPosts({ query }) {
  const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment(
    VirtualizedPostsFragment,
    query
  );

  const parentRef = useRef();
  const posts = data.posts.edges.map(e => e.node);

  const virtualizer = useVirtualizer({
    count: posts.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 200,
    overscan: 5
  });

  useEffect(() => {
    const [lastItem] = [...virtualizer.getVirtualItems()].reverse();

    if (!lastItem) return;

    if (
      lastItem.index >= posts.length - 1 &&
      hasNext &&
      !isLoadingNext
    ) {
      loadNext(50);
    }
  }, [
    hasNext,
    loadNext,
    isLoadingNext,
    posts.length,
    virtualizer.getVirtualItems()
  ]);

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          position: 'relative'
        }}
      >
        {virtualizer.getVirtualItems().map(virtualItem => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              transform: `translateY(${virtualItem.start}px)`
            }}
          >
            <PostCard post={posts[virtualItem.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

9. 分页状态管理

// PaginationStateManager.jsx
function PaginationStateManager({ query }) {
  const {
    data,
    loadNext,
    hasNext,
    isLoadingNext,
    refetch
  } = usePaginationFragment(PostsFragment, query);

  const [paginationState, setPaginationState] = useState({
    currentPage: 1,
    itemsPerPage: 10,
    totalLoaded: 0
  });

  const handleLoadMore = () => {
    const itemsToLoad = paginationState.itemsPerPage;
    loadNext(itemsToLoad);

    setPaginationState(prev => ({
      ...prev,
      currentPage: prev.currentPage + 1,
      totalLoaded: prev.totalLoaded + itemsToLoad
    }));
  };

  const handleChangePageSize = (newSize) => {
    setPaginationState(prev => ({
      ...prev,
      itemsPerPage: newSize
    }));

    refetch({
      first: newSize,
      after: null
    });
  };

  return (
    <div>
      <div>
        第 {paginationState.currentPage} 页 -
        已加载 {paginationState.totalLoaded} 个项目
      </div>

      <select
        value={paginationState.itemsPerPage}
        onChange={(e) => handleChangePageSize(Number(e.target.value))}
      >
        <option value={10}>每页 10 条</option>
        <option value={25}>每页 25 条</option>
        <option value={50}>每页 50 条</option>
      </select>

      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}

      {hasNext && (
        <button onClick={handleLoadMore} disabled={isLoadingNext}>
          加载更多
        </button>
      )}
    </div>
  );
}

10. 自定义分页钩子

// hooks/usePagination.js
import { useState, useCallback } from 'react';
import { usePaginationFragment } from 'react-relay';

export function usePagination(fragment, fragmentRef, options = {}) {
  const {
    onLoadMore,
    onLoadPrevious,
    onRefetch,
    pageSize = 10
  } = options;

  const {
    data,
    loadNext,
    loadPrevious,
    hasNext,
    hasPrevious,
    isLoadingNext,
    isLoadingPrevious,
    refetch
  } = usePaginationFragment(fragment, fragmentRef);

  const [page, setPage] = useState(1);

  const handleLoadNext = useCallback(() => {
    loadNext(pageSize);
    setPage(p => p + 1);
    onLoadMore?.();
  }, [loadNext, pageSize, onLoadMore]);

  const handleLoadPrevious = useCallback(() => {
    loadPrevious(pageSize);
    setPage(p => Math.max(1, p - 1));
    onLoadPrevious?.();
  }, [loadPrevious, pageSize, onLoadPrevious]);

  const handleRefetch = useCallback((variables) => {
    refetch(variables);
    setPage(1);
    onRefetch?.();
  }, [refetch, onRefetch]);

  return {
    data,
    page,
    hasNext,
    hasPrevious,
    isLoadingNext,
    isLoadingPrevious,
    loadNext: handleLoadNext,
    loadPrevious: handleLoadPrevious,
    refetch: handleRefetch
  };
}

// 使用示例
function PostsList({ query }) {
  const {
    data,
    page,
    hasNext,
    loadNext,
    refetch
  } = usePagination(PostsFragment, query, {
    pageSize: 20,
    onLoadMore: () => console.log('已加载更多'),
    onRefetch: () => console.log('已重新获取')
  });

  return (
    <div>
      <div>第 {page} 页</div>
      <button onClick={() => refetch({ first: 20 })}>刷新</button>

      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}

      {hasNext && <button onClick={loadNext}>加载更多</button>}
    </div>
  );
}

最佳实践

  1. 使用 @connection 指令 - 确保正确的缓存更新
  2. 实现加载状态 - 在分页过程中显示反馈
  3. 处理边界情况 - 空状态、无更多数据
  4. 优化页面大小 - 平衡用户体验和性能
  5. 明智使用无限滚动 - 对于大型列表考虑虚拟滚动
  6. 实现搜索/过滤 - 允许用户缩小结果范围
  7. 缓存分页状态 - 保持滚动位置
  8. 优雅处理错误 - 重试失败的分页请求
  9. 彻底测试分页 - 边界情况、网络故障
  10. 监控性能 - 跟踪分页指标

常见陷阱

  1. 缺少 @connection 指令 - 缓存更新失败
  2. 光标管理错误 - 重复或缺失项目
  3. 没有加载状态 - 用户体验差
  4. 过度获取 - 每页请求太多项目
  5. 内存泄漏 - 未清理观察者
  6. 缺少错误处理 - 失败请求中断分页
  7. 页面大小不一致 - 用户体验混乱
  8. 未处理空状态 - 无结果时的差体验
  9. 竞态条件 - 多个并发分页请求
  10. 缺少可访问性 - 键盘导航、屏幕阅读器支持

何时使用

  • 显示大数据列表
  • 构建无限滚动界面
  • 创建基于 feed 的应用
  • 实现搜索结果
  • 构建电商产品列表
  • 创建社交媒体时间线
  • 开发评论线程
  • 构建管理仪表板
  • 创建数据表
  • 实现文件浏览器

资源