Skip to content

无限滚动加载

概述

无限滚动(Infinite Scroll)是一种常见的用户体验模式,当用户滚动到页面底部时自动加载更多内容。TanStack Query提供了useInfiniteQuery Hook专门用于处理无限滚动场景。本文将详细介绍如何实现高性能的无限滚动加载。

useInfiniteQuery基础

基本用法

jsx
import { useInfiniteQuery } from '@tanstack/react-query';

async function fetchPosts({ pageParam = 0 }) {
  const response = await fetch(`/api/posts?page=${pageParam}&limit=10`);
  return response.json();
}

function InfinitePostList() {
  const {
    data,
    error,
    fetchNextPage,
    hasNextPage,
    isFetching,
    isFetchingNextPage,
    status,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    initialPageParam: 0,
    getNextPageParam: (lastPage, allPages) => {
      // 返回下一页的参数,或undefined表示没有更多页
      return lastPage.hasMore ? allPages.length : undefined;
    },
  });
  
  if (status === 'pending') return <div>Loading...</div>;
  if (status === 'error') return <div>Error: {error.message}</div>;
  
  return (
    <div>
      {data.pages.map((page, i) => (
        <div key={i}>
          {page.posts.map(post => (
            <div key={post.id}>
              <h3>{post.title}</h3>
              <p>{post.content}</p>
            </div>
          ))}
        </div>
      ))}
      
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage
          ? 'Loading more...'
          : hasNextPage
          ? 'Load More'
          : 'No more posts'}
      </button>
      
      {isFetching && !isFetchingNextPage && <div>Updating...</div>}
    </div>
  );
}

双向无限滚动

jsx
function BidirectionalScroll() {
  const {
    data,
    fetchNextPage,
    fetchPreviousPage,
    hasNextPage,
    hasPreviousPage,
    isFetchingNextPage,
    isFetchingPreviousPage,
  } = useInfiniteQuery({
    queryKey: ['messages'],
    queryFn: fetchMessages,
    initialPageParam: 0,
    
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    getPreviousPageParam: (firstPage) => firstPage.prevCursor,
  });
  
  return (
    <div>
      <button
        onClick={() => fetchPreviousPage()}
        disabled={!hasPreviousPage || isFetchingPreviousPage}
      >
        {isFetchingPreviousPage ? 'Loading...' : 'Load Previous'}
      </button>
      
      {data?.pages.map((page, i) => (
        <div key={i}>
          {page.messages.map(msg => (
            <div key={msg.id}>{msg.text}</div>
          ))}
        </div>
      ))}
      
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage ? 'Loading...' : 'Load Next'}
      </button>
    </div>
  );
}

分页策略

基于页码的分页

jsx
function PageBasedInfinite() {
  const fetchPosts = async ({ pageParam = 1 }) => {
    const response = await fetch(
      `/api/posts?page=${pageParam}&limit=10`
    );
    const data = await response.json();
    
    return {
      posts: data.posts,
      currentPage: pageParam,
      totalPages: data.totalPages,
    };
  };
  
  const {
    data,
    fetchNextPage,
    hasNextPage,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    initialPageParam: 1,
    
    getNextPageParam: (lastPage) => {
      if (lastPage.currentPage < lastPage.totalPages) {
        return lastPage.currentPage + 1;
      }
      return undefined;
    },
  });
  
  return (
    <div>
      {data?.pages.map((page) =>
        page.posts.map(post => (
          <PostCard key={post.id} post={post} />
        ))
      )}
      
      {hasNextPage && (
        <button onClick={() => fetchNextPage()}>Load More</button>
      )}
    </div>
  );
}

基于游标的分页

jsx
function CursorBasedInfinite() {
  const fetchPosts = async ({ pageParam }) => {
    const url = pageParam
      ? `/api/posts?cursor=${pageParam}&limit=10`
      : '/api/posts?limit=10';
    
    const response = await fetch(url);
    const data = await response.json();
    
    return {
      posts: data.posts,
      nextCursor: data.nextCursor,
    };
  };
  
  const {
    data,
    fetchNextPage,
    hasNextPage,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    initialPageParam: undefined,
    
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  });
  
  return (
    <div>
      {data?.pages.flatMap(page =>
        page.posts.map(post => (
          <PostCard key={post.id} post={post} />
        ))
      )}
      
      {hasNextPage && (
        <button onClick={() => fetchNextPage()}>Load More</button>
      )}
    </div>
  );
}

基于偏移量的分页

jsx
function OffsetBasedInfinite() {
  const fetchItems = async ({ pageParam = 0 }) => {
    const response = await fetch(
      `/api/items?offset=${pageParam}&limit=20`
    );
    const data = await response.json();
    
    return {
      items: data.items,
      nextOffset: data.items.length === 20 ? pageParam + 20 : undefined,
    };
  };
  
  const {
    data,
    fetchNextPage,
    hasNextPage,
  } = useInfiniteQuery({
    queryKey: ['items'],
    queryFn: fetchItems,
    initialPageParam: 0,
    
    getNextPageParam: (lastPage) => lastPage.nextOffset,
  });
  
  return (
    <div>
      {data?.pages.map((page, i) => (
        <div key={i}>
          {page.items.map(item => (
            <ItemCard key={item.id} item={item} />
          ))}
        </div>
      ))}
      
      {hasNextPage && (
        <button onClick={() => fetchNextPage()}>Load More</button>
      )}
    </div>
  );
}

自动加载

滚动触发加载

jsx
import { useInView } from 'react-intersection-observer';

function AutoLoadOnScroll() {
  const { ref, inView } = useInView();
  
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    initialPageParam: 0,
    getNextPageParam: (lastPage, pages) => {
      return lastPage.hasMore ? pages.length : undefined;
    },
  });
  
  useEffect(() => {
    if (inView && hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
  
  return (
    <div>
      {data?.pages.map((page, i) => (
        <div key={i}>
          {page.posts.map(post => (
            <PostCard key={post.id} post={post} />
          ))}
        </div>
      ))}
      
      <div ref={ref} className="load-trigger">
        {isFetchingNextPage && <LoadingSpinner />}
        {!hasNextPage && <div>No more posts</div>}
      </div>
    </div>
  );
}

距离底部触发

jsx
function LoadOnScroll() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    initialPageParam: 0,
    getNextPageParam: (lastPage, pages) => {
      return lastPage.hasMore ? pages.length : undefined;
    },
  });
  
  useEffect(() => {
    const handleScroll = () => {
      const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
      
      // 距离底部200px时触发加载
      if (scrollTop + clientHeight >= scrollHeight - 200) {
        if (hasNextPage && !isFetchingNextPage) {
          fetchNextPage();
        }
      }
    };
    
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, [fetchNextPage, hasNextPage, isFetchingNextPage]);
  
  return (
    <div>
      {data?.pages.flatMap(page =>
        page.posts.map(post => (
          <PostCard key={post.id} post={post} />
        ))
      )}
      
      {isFetchingNextPage && <LoadingSpinner />}
      {!hasNextPage && <div>No more posts</div>}
    </div>
  );
}

性能优化

虚拟滚动

jsx
import { useVirtualizer } from '@tanstack/react-virtual';

function VirtualInfiniteScroll() {
  const parentRef = useRef(null);
  
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    initialPageParam: 0,
    getNextPageParam: (lastPage, pages) => {
      return lastPage.hasMore ? pages.length : undefined;
    },
  });
  
  // 扁平化所有posts
  const allPosts = data?.pages.flatMap(page => page.posts) ?? [];
  
  const virtualizer = useVirtualizer({
    count: hasNextPage ? allPosts.length + 1 : allPosts.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 100,
    overscan: 5,
  });
  
  useEffect(() => {
    const [lastItem] = [...virtualizer.getVirtualItems()].reverse();
    
    if (!lastItem) return;
    
    if (
      lastItem.index >= allPosts.length - 1 &&
      hasNextPage &&
      !isFetchingNextPage
    ) {
      fetchNextPage();
    }
  }, [
    hasNextPage,
    fetchNextPage,
    allPosts.length,
    isFetchingNextPage,
    virtualizer.getVirtualItems(),
  ]);
  
  return (
    <div
      ref={parentRef}
      style={{ height: '600px', overflow: 'auto' }}
    >
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          position: 'relative',
        }}
      >
        {virtualizer.getVirtualItems().map(virtualRow => {
          const isLoaderRow = virtualRow.index > allPosts.length - 1;
          const post = allPosts[virtualRow.index];
          
          return (
            <div
              key={virtualRow.index}
              style={{
                position: 'absolute',
                top: 0,
                left: 0,
                width: '100%',
                height: `${virtualRow.size}px`,
                transform: `translateY(${virtualRow.start}px)`,
              }}
            >
              {isLoaderRow
                ? hasNextPage
                  ? 'Loading more...'
                  : 'No more posts'
                : <PostCard post={post} />}
            </div>
          );
        })}
      </div>
    </div>
  );
}

Select优化

jsx
function OptimizedSelect() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    initialPageParam: 0,
    getNextPageParam: (lastPage, pages) => {
      return lastPage.hasMore ? pages.length : undefined;
    },
    
    // 只提取需要的字段
    select: (data) => ({
      pages: data.pages.map(page => ({
        posts: page.posts.map(post => ({
          id: post.id,
          title: post.title,
          author: post.author.name,
        })),
        hasMore: page.hasMore,
      })),
      pageParams: data.pageParams,
    }),
  });
  
  return (
    <div>
      {data?.pages.flatMap(page =>
        page.posts.map(post => (
          <div key={post.id}>
            <h3>{post.title}</h3>
            <p>By {post.author}</p>
          </div>
        ))
      )}
      
      {hasNextPage && (
        <button onClick={() => fetchNextPage()}>Load More</button>
      )}
    </div>
  );
}

数据更新

添加新项目

jsx
function AddToInfinite() {
  const queryClient = useQueryClient();
  
  const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    initialPageParam: 0,
    getNextPageParam: (lastPage, pages) => {
      return lastPage.hasMore ? pages.length : undefined;
    },
  });
  
  const { mutate: addPost } = useMutation({
    mutationFn: createPost,
    
    onSuccess: (newPost) => {
      // 添加到第一页
      queryClient.setQueryData(['posts'], (old) => {
        if (!old) return old;
        
        return {
          ...old,
          pages: old.pages.map((page, index) => {
            if (index === 0) {
              return {
                ...page,
                posts: [newPost, ...page.posts],
              };
            }
            return page;
          }),
        };
      });
    },
  });
  
  return (
    <div>
      <AddPostForm onSubmit={addPost} />
      
      {data?.pages.flatMap(page =>
        page.posts.map(post => (
          <PostCard key={post.id} post={post} />
        ))
      )}
      
      {hasNextPage && (
        <button onClick={() => fetchNextPage()}>Load More</button>
      )}
    </div>
  );
}

删除项目

jsx
function RemoveFromInfinite() {
  const queryClient = useQueryClient();
  
  const { data } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    initialPageParam: 0,
    getNextPageParam: (lastPage, pages) => {
      return lastPage.hasMore ? pages.length : undefined;
    },
  });
  
  const { mutate: deletePost } = useMutation({
    mutationFn: removePost,
    
    onMutate: async (postId) => {
      await queryClient.cancelQueries({ queryKey: ['posts'] });
      
      const previousData = queryClient.getQueryData(['posts']);
      
      // 乐观删除
      queryClient.setQueryData(['posts'], (old) => {
        if (!old) return old;
        
        return {
          ...old,
          pages: old.pages.map(page => ({
            ...page,
            posts: page.posts.filter(post => post.id !== postId),
          })),
        };
      });
      
      return { previousData };
    },
    
    onError: (err, postId, context) => {
      queryClient.setQueryData(['posts'], context.previousData);
    },
    
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
  });
  
  return (
    <div>
      {data?.pages.flatMap(page =>
        page.posts.map(post => (
          <div key={post.id}>
            <PostCard post={post} />
            <button onClick={() => deletePost(post.id)}>Delete</button>
          </div>
        ))
      )}
    </div>
  );
}

更新项目

jsx
function UpdateInInfinite() {
  const queryClient = useQueryClient();
  
  const { mutate: updatePost } = useMutation({
    mutationFn: ({ id, updates }) => {
      return fetch(`/api/posts/${id}`, {
        method: 'PUT',
        body: JSON.stringify(updates),
      }).then(r => r.json());
    },
    
    onSuccess: (updatedPost) => {
      queryClient.setQueryData(['posts'], (old) => {
        if (!old) return old;
        
        return {
          ...old,
          pages: old.pages.map(page => ({
            ...page,
            posts: page.posts.map(post =>
              post.id === updatedPost.id ? updatedPost : post
            ),
          })),
        };
      });
    },
  });
  
  return <PostList onUpdate={updatePost} />;
}

重置和刷新

重置到第一页

jsx
function ResetInfinite() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    refetch,
  } = useInfiniteQuery({
    queryKey: ['posts', filter],
    queryFn: fetchPosts,
    initialPageParam: 0,
    getNextPageParam: (lastPage, pages) => {
      return lastPage.hasMore ? pages.length : undefined;
    },
  });
  
  const handleReset = () => {
    // 重置到第一页
    refetch({ refetchPage: (page, index) => index === 0 });
  };
  
  return (
    <div>
      <button onClick={handleReset}>Reset</button>
      
      {data?.pages.flatMap(page =>
        page.posts.map(post => (
          <PostCard key={post.id} post={post} />
        ))
      )}
      
      {hasNextPage && (
        <button onClick={() => fetchNextPage()}>Load More</button>
      )}
    </div>
  );
}

刷新所有页

jsx
function RefreshAll() {
  const { data, refetch, isFetching } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    initialPageParam: 0,
    getNextPageParam: (lastPage, pages) => {
      return lastPage.hasMore ? pages.length : undefined;
    },
  });
  
  const handleRefreshAll = () => {
    // 刷新所有页
    refetch();
  };
  
  return (
    <div>
      <button onClick={handleRefreshAll} disabled={isFetching}>
        {isFetching ? 'Refreshing...' : 'Refresh All'}
      </button>
      
      {data?.pages.flatMap(page =>
        page.posts.map(post => (
          <PostCard key={post.id} post={post} />
        ))
      )}
    </div>
  );
}

总结

无限滚动加载核心要点:

  1. useInfiniteQuery:专门的无限查询Hook
  2. 分页策略:页码、游标、偏移量分页
  3. 自动加载:滚动触发、距离底部触发
  4. 性能优化:虚拟滚动、select优化
  5. 数据更新:添加、删除、更新项目
  6. 重置刷新:重置到第一页、刷新所有页

合理使用useInfiniteQuery可以轻松实现高性能的无限滚动体验。

第四部分:高级无限滚动技术

4.1 虚拟滚动优化

jsx
import { useVirtualizer } from '@tanstack/react-virtual';

// 1. 虚拟无限滚动
function VirtualInfiniteScroll() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage
  } = useInfiniteQuery({
    queryKey: ['items'],
    queryFn: ({ pageParam = 0 }) => fetchItems(pageParam, 50),
    getNextPageParam: (lastPage, pages) => lastPage.nextCursor
  });

  const allItems = data?.pages.flatMap(page => page.items) ?? [];
  const parentRef = useRef(null);

  const virtualizer = useVirtualizer({
    count: hasNextPage ? allItems.length + 1 : allItems.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 100,
    overscan: 5
  });

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

    if (!lastItem) return;

    if (
      lastItem.index >= allItems.length - 1 &&
      hasNextPage &&
      !isFetchingNextPage
    ) {
      fetchNextPage();
    }
  }, [
    hasNextPage,
    fetchNextPage,
    allItems.length,
    isFetchingNextPage,
    virtualizer.getVirtualItems()
  ]);

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          width: '100%',
          position: 'relative'
        }}
      >
        {virtualizer.getVirtualItems().map(virtualItem => {
          const isLoaderRow = virtualItem.index > allItems.length - 1;
          const item = allItems[virtualItem.index];

          return (
            <div
              key={virtualItem.index}
              style={{
                position: 'absolute',
                top: 0,
                left: 0,
                width: '100%',
                height: `${virtualItem.size}px`,
                transform: `translateY(${virtualItem.start}px)`
              }}
            >
              {isLoaderRow ? (
                hasNextPage ? 'Loading more...' : 'Nothing more to load'
              ) : (
                <div>{item.name}</div>
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
}

// 2. 动态高度虚拟滚动
function DynamicHeightVirtualScroll() {
  const {
    data,
    fetchNextPage,
    hasNextPage
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: ({ pageParam = 0 }) => fetchPosts(pageParam),
    getNextPageParam: (lastPage) => lastPage.nextCursor
  });

  const allPosts = data?.pages.flatMap(page => page.posts) ?? [];
  const parentRef = useRef(null);

  const virtualizer = useVirtualizer({
    count: allPosts.length,
    getScrollElement: () => parentRef.current,
    estimateSize: useCallback((index) => {
      const post = allPosts[index];
      // 根据内容估算高度
      return post?.content ? Math.max(100, post.content.length / 2) : 100;
    }, [allPosts]),
    measureElement:
      typeof window !== 'undefined' && navigator.userAgent.indexOf('Firefox') === -1
        ? element => element?.getBoundingClientRect().height
        : undefined
  });

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

4.2 多向滚动

jsx
// 1. 双向无限滚动
function BidirectionalInfiniteScroll() {
  const {
    data,
    fetchNextPage,
    fetchPreviousPage,
    hasNextPage,
    hasPreviousPage
  } = useInfiniteQuery({
    queryKey: ['messages'],
    queryFn: async ({ pageParam }) => {
      if (!pageParam) {
        return fetchMessages({ limit: 20 });
      }
      return fetchMessages(pageParam);
    },
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    getPreviousPageParam: (firstPage) => firstPage.previousCursor,
    initialPageParam: undefined
  });

  const containerRef = useRef(null);
  const [scrollPosition, setScrollPosition] = useState('middle');

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const handleScroll = () => {
      const { scrollTop, scrollHeight, clientHeight } = container;
      
      // 接近顶部
      if (scrollTop < 100 && hasPreviousPage) {
        const prevScrollHeight = scrollHeight;
        fetchPreviousPage().then(() => {
          // 保持滚动位置
          container.scrollTop = container.scrollHeight - prevScrollHeight + scrollTop;
        });
      }
      
      // 接近底部
      if (scrollTop + clientHeight > scrollHeight - 100 && hasNextPage) {
        fetchNextPage();
      }
    };

    container.addEventListener('scroll', handleScroll);
    return () => container.removeEventListener('scroll', handleScroll);
  }, [hasNextPage, hasPreviousPage, fetchNextPage, fetchPreviousPage]);

  const allMessages = data?.pages.flatMap(page => page.messages) ?? [];

  return (
    <div ref={containerRef} style={{ height: '600px', overflow: 'auto' }}>
      {hasPreviousPage && <div>Loading older messages...</div>}
      {allMessages.map(message => (
        <div key={message.id}>{message.text}</div>
      ))}
      {hasNextPage && <div>Loading newer messages...</div>}
    </div>
  );
}

// 2. 网格无限滚动
function GridInfiniteScroll() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage
  } = useInfiniteQuery({
    queryKey: ['grid-items'],
    queryFn: ({ pageParam = 0 }) => fetchGridItems(pageParam, 20),
    getNextPageParam: (lastPage, pages) => lastPage.nextCursor
  });

  const observerTarget = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      entries => {
        if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
          fetchNextPage();
        }
      },
      { threshold: 0.1 }
    );

    if (observerTarget.current) {
      observer.observe(observerTarget.current);
    }

    return () => {
      if (observerTarget.current) {
        observer.unobserve(observerTarget.current);
      }
    };
  }, [fetchNextPage, hasNextPage, isFetchingNextPage]);

  const allItems = data?.pages.flatMap(page => page.items) ?? [];

  return (
    <div>
      <div style={{
        display: 'grid',
        gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
        gap: '16px'
      }}>
        {allItems.map(item => (
          <div key={item.id} style={{ height: '200px', background: '#eee' }}>
            {item.title}
          </div>
        ))}
      </div>
      <div ref={observerTarget} style={{ height: '20px' }} />
      {isFetchingNextPage && <div>Loading more...</div>}
    </div>
  );
}

4.3 智能预加载

jsx
// 1. 预测性预加载
function PredictivePreload() {
  const [scrollSpeed, setScrollSpeed] = useState(0);
  const lastScrollTop = useRef(0);
  const lastScrollTime = useRef(Date.now());

  const {
    data,
    fetchNextPage,
    hasNextPage
  } = useInfiniteQuery({
    queryKey: ['items'],
    queryFn: ({ pageParam = 0 }) => fetchItems(pageParam),
    getNextPageParam: (lastPage) => lastPage.nextCursor
  });

  useEffect(() => {
    const handleScroll = () => {
      const currentScrollTop = window.scrollY;
      const currentTime = Date.now();
      
      const scrollDistance = currentScrollTop - lastScrollTop.current;
      const timeDelta = currentTime - lastScrollTime.current;
      const speed = scrollDistance / timeDelta;

      setScrollSpeed(speed);
      lastScrollTop.current = currentScrollTop;
      lastScrollTime.current = currentTime;

      // 根据滚动速度调整预加载距离
      const preloadDistance = speed > 2 ? 1000 : 500;
      const bottom = document.documentElement.scrollHeight - window.innerHeight;
      
      if (currentScrollTop > bottom - preloadDistance && hasNextPage) {
        fetchNextPage();
      }
    };

    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, [hasNextPage, fetchNextPage]);

  const allItems = data?.pages.flatMap(page => page.items) ?? [];

  return (
    <div>
      <div>Scroll Speed: {scrollSpeed.toFixed(2)} px/ms</div>
      {allItems.map(item => (
        <div key={item.id}>{item.name}</div>
      ))}
    </div>
  );
}

// 2. 批量预加载
function BatchPreload() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage
  } = useInfiniteQuery({
    queryKey: ['items'],
    queryFn: ({ pageParam = 0 }) => fetchItems(pageParam, 10),
    getNextPageParam: (lastPage, pages) => lastPage.nextCursor
  });

  const loadMultiplePages = async (count = 3) => {
    for (let i = 0; i < count && hasNextPage; i++) {
      await fetchNextPage();
    }
  };

  const observerTarget = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      entries => {
        if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
          // 一次加载3页
          loadMultiplePages(3);
        }
      },
      { threshold: 0.1 }
    );

    if (observerTarget.current) {
      observer.observe(observerTarget.current);
    }

    return () => {
      if (observerTarget.current) {
        observer.unobserve(observerTarget.current);
      }
    };
  }, [hasNextPage, isFetchingNextPage]);

  const allItems = data?.pages.flatMap(page => page.items) ?? [];

  return (
    <div>
      {allItems.map(item => (
        <div key={item.id}>{item.name}</div>
      ))}
      <div ref={observerTarget} />
      {isFetchingNextPage && <div>Loading...</div>}
    </div>
  );
}

4.4 滚动状态持久化

jsx
// 1. 保存和恢复滚动位置
function useScrollRestoration(key) {
  const location = useLocation();
  
  useEffect(() => {
    // 保存滚动位置
    const saveScrollPosition = () => {
      const scrollData = {
        x: window.scrollX,
        y: window.scrollY,
        timestamp: Date.now()
      };
      sessionStorage.setItem(`scroll-${key}`, JSON.stringify(scrollData));
    };

    window.addEventListener('beforeunload', saveScrollPosition);
    window.addEventListener('pagehide', saveScrollPosition);

    return () => {
      saveScrollPosition();
      window.removeEventListener('beforeunload', saveScrollPosition);
      window.removeEventListener('pagehide', saveScrollPosition);
    };
  }, [key]);

  useEffect(() => {
    // 恢复滚动位置
    const savedScroll = sessionStorage.getItem(`scroll-${key}`);
    if (savedScroll) {
      const { x, y, timestamp } = JSON.parse(savedScroll);
      
      // 只恢复5分钟内的滚动位置
      if (Date.now() - timestamp < 5 * 60 * 1000) {
        requestAnimationFrame(() => {
          window.scrollTo(x, y);
        });
      }
    }
  }, [key, location]);
}

function InfiniteScrollWithRestoration() {
  useScrollRestoration('items-list');

  const {
    data,
    fetchNextPage,
    hasNextPage
  } = useInfiniteQuery({
    queryKey: ['items'],
    queryFn: ({ pageParam = 0 }) => fetchItems(pageParam),
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    // 保持之前的数据
    keepPreviousData: true
  });

  const allItems = data?.pages.flatMap(page => page.items) ?? [];

  return (
    <div>
      {allItems.map(item => (
        <div key={item.id}>{item.name}</div>
      ))}
    </div>
  );
}

// 2. 滚动进度持久化
function useScrollProgress() {
  const [progress, setProgress] = useState(0);

  useEffect(() => {
    const updateProgress = () => {
      const windowHeight = document.documentElement.scrollHeight - window.innerHeight;
      const scrolled = (window.scrollY / windowHeight) * 100;
      setProgress(Math.min(100, scrolled));
      
      // 保存进度
      localStorage.setItem('scroll-progress', scrolled.toString());
    };

    window.addEventListener('scroll', updateProgress);
    updateProgress();

    return () => window.removeEventListener('scroll', updateProgress);
  }, []);

  const restoreProgress = () => {
    const saved = localStorage.getItem('scroll-progress');
    if (saved) {
      const windowHeight = document.documentElement.scrollHeight - window.innerHeight;
      const scrollTo = (parseFloat(saved) / 100) * windowHeight;
      window.scrollTo(0, scrollTo);
    }
  };

  return { progress, restoreProgress };
}

4.5 错误处理与重试

jsx
// 1. 分页错误处理
function InfiniteScrollWithErrorHandling() {
  const {
    data,
    error,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isError
  } = useInfiniteQuery({
    queryKey: ['items'],
    queryFn: async ({ pageParam = 0 }) => {
      const response = await fetchItems(pageParam);
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      return response.json();
    },
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    retry: (failureCount, error) => {
      // 5xx 错误重试
      if (error.message.includes('500')) {
        return failureCount < 3;
      }
      return false;
    },
    retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000)
  });

  const allItems = data?.pages.flatMap(page => page.items) ?? [];

  if (isError) {
    return (
      <div>
        <p>Error: {error.message}</p>
        <button onClick={() => fetchNextPage()}>Retry</button>
      </div>
    );
  }

  return (
    <div>
      {allItems.map(item => (
        <div key={item.id}>{item.name}</div>
      ))}
      
      {isFetchingNextPage && <div>Loading...</div>}
      
      {hasNextPage && !isFetchingNextPage && (
        <button onClick={() => fetchNextPage()}>
          Load More
        </button>
      )}
    </div>
  );
}

// 2. 部分失败处理
function PartialFailureHandling() {
  const {
    data,
    fetchNextPage,
    hasNextPage
  } = useInfiniteQuery({
    queryKey: ['items'],
    queryFn: async ({ pageParam = 0 }) => {
      try {
        return await fetchItems(pageParam);
      } catch (error) {
        // 返回部分数据
        return {
          items: [],
          nextCursor: null,
          error: error.message
        };
      }
    },
    getNextPageParam: (lastPage) => lastPage.nextCursor
  });

  const allItems = data?.pages.flatMap(page => page.items) ?? [];
  const errors = data?.pages
    .filter(page => page.error)
    .map(page => page.error) ?? [];

  return (
    <div>
      {errors.length > 0 && (
        <div style={{ background: '#fee', padding: '10px' }}>
          {errors.map((error, i) => (
            <div key={i}>Page load error: {error}</div>
          ))}
        </div>
      )}
      
      {allItems.map(item => (
        <div key={item.id}>{item.name}</div>
      ))}
      
      {hasNextPage && (
        <button onClick={() => fetchNextPage()}>Load More</button>
      )}
    </div>
  );
}

无限滚动最佳实践总结

1. 虚拟化
   ✅ 使用虚拟滚动减少DOM
   ✅ 支持动态高度
   ✅ 优化大数据集渲染

2. 多向滚动
   ✅ 双向无限滚动
   ✅ 网格布局支持
   ✅ 保持滚动位置

3. 智能预加载
   ✅ 预测性预加载
   ✅ 批量加载策略
   ✅ 根据速度调整

4. 状态持久化
   ✅ 保存滚动位置
   ✅ 恢复滚动进度
   ✅ 缓存已加载数据

5. 错误处理
   ✅ 重试机制
   ✅ 部分失败处理
   ✅ 用户友好提示

高性能的无限滚动需要综合考虑性能、用户体验和错误处理,合理运用这些技术能够构建流畅的滚动体验。