Appearance
无限滚动加载
概述
无限滚动(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>
);
}总结
无限滚动加载核心要点:
- useInfiniteQuery:专门的无限查询Hook
- 分页策略:页码、游标、偏移量分页
- 自动加载:滚动触发、距离底部触发
- 性能优化:虚拟滚动、select优化
- 数据更新:添加、删除、更新项目
- 重置刷新:重置到第一页、刷新所有页
合理使用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. 错误处理
✅ 重试机制
✅ 部分失败处理
✅ 用户友好提示高性能的无限滚动需要综合考虑性能、用户体验和错误处理,合理运用这些技术能够构建流畅的滚动体验。