这四个库都旨在解决 React 应用中长列表渲染的性能问题,但侧重点不同。react-virtualized 和 react-window 专注于“窗口化”(Virtualization),即只渲染可视区域内的 DOM 节点,适合成千上万条数据的场景。react-infinite-scroll-component 侧重于“无限滚动”交互,通常配合数据分页加载,不一定包含虚拟化优化。react-tiny-virtual-list 则是一个极简的虚拟化方案,主打体积小巧。理解它们的底层机制差异,对于构建高性能数据密集型应用至关重要。
在开发 React 数据密集型应用时,列表渲染性能是一个常见瓶颈。当数据量达到几百甚至几千条时,直接渲染所有 DOM 节点会导致页面卡顿。react-virtualized、react-window、react-tiny-virtual-list 和 react-infinite-scroll-component 是四种常见的解决方案,但它们的底层原理和适用场景有很大不同。
理解这些库的第一步是区分“虚拟化”和“无限滚动”。
react-window 和 react-virtualized 采用虚拟化技术。它们只渲染用户当前能看到的部分元素,其余部分用空白占位。即使有 10 万条数据,DOM 节点数也保持在几十个小范围内。
// react-window: 只渲染可视区域
import { FixedSizeList } from 'react-window';
const Row = ({ index, style }) => (
<div style={style}>Row {index}</div>
);
const List = () => (
<FixedSizeList height={200} itemCount={10000} itemSize={35} width={300}>
{Row}
</FixedSizeList>
);
// react-virtualized: 类似的虚拟化原理,但 API 较旧
import { List } from 'react-virtualized';
const Row = ({ index, key, style }) => (
<div key={key} style={style}>Row {index}</div>
);
const List = () => (
<List
height={200}
rowCount={10000}
rowHeight={35}
width={300}
rowRenderer={Row}
/>
);
react-tiny-virtual-list 同样采用虚拟化,但实现更精简,功能较少。
// react-tiny-virtual-list: 极简虚拟化
import VirtualList from 'react-tiny-virtual-list';
const List = ({ items }) => (
<VirtualList
width={300}
height={200}
itemHeight={35}
items={items}
renderItem={({ index, style }) => (
<div key={index} style={style}>Row {index}</div>
)}
/>
);
react-infinite-scroll-component 默认不进行虚拟化。它随着滚动不断将新数据追加到 DOM 中。如果数据量过大,页面会变慢。
// react-infinite-scroll-component: 追加 DOM 节点
import InfiniteScroll from 'react-infinite-scroll-component';
const List = ({ items, loadMore }) => (
<InfiniteScroll
dataLength={items.length}
next={loadMore}
hasMore={true}
loader={<h4>Loading...</h4>}
height={400}
>
{items.map((item, i) => (
<div key={i}>Item {i}</div>
))}
</InfiniteScroll>
);
API 的设计直接影响开发效率和可维护性。
react-window 使用现代 React 模式,组件作为子元素传递,支持 Hooks 友好。
// react-window: 现代 Component 作为 Children
<FixedSizeList itemCount={100} itemSize={50}>
{({ index, style }) => <div style={style}>{index}</div>}
</FixedSizeList>
react-virtualized 使用较旧的 rowRenderer 回调模式,代码略显繁琐。
// react-virtualized: 回调函数模式
<List
rowCount={100}
rowHeight={50}
rowRenderer={({ index, key, style }) => (
<div key={key} style={style}>{index}</div>
)}
/>
react-tiny-virtual-list 类似 react-virtualized,使用 renderItem 回调。
// react-tiny-virtual-list: 回调函数模式
<VirtualList
items={data}
itemHeight={50}
renderItem={({ index, style }) => (
<div key={index} style={style}>{index}</div>
)}
/>
react-infinite-scroll-component 最接近原生写法,直接渲染 children,但需要手动管理数据状态。
// react-infinite-scroll-component: 直接渲染 Children
<InfiniteScroll dataLength={data.length} next={fetchMore} hasMore={true}>
{data.map(item => <div key={item.id}>{item.name}</div>)}
</InfiniteScroll>
当列表项高度不固定时,处理难度会增加。
react-window 提供 VariableSizeList,但需要手动通知布局更新。
// react-window: 可变高度
import { VariableSizeList } from 'react-window';
const listRef = useRef();
const getItemSize = (index) => heights[index];
<VariableSizeList
ref={listRef}
itemCount={items.length}
itemSize={getItemSize}
onItemsRendered={() => { /* handle render */ }}
>
{({ index, style }) => (
<div style={style}>{items[index]}</div>
)}
</VariableSizeList>
react-virtualized 提供 CellMeasurer 来自动测量高度,但配置复杂。
// react-virtualized: 自动测量高度
import { CellMeasurer, CellMeasurerCache, List } from 'react-virtualized';
const cache = new CellMeasurerCache({ fixedWidth: true, minHeight: 50 });
<List
rowHeight={cache.rowHeight}
rowRenderer={({ index, key, style, parent }) => (
<CellMeasurer cache={cache} index={index} key={key} parent={parent}>
{({ measure }) => (
<div style={style} onLoad={measure}>{items[index]}</div>
)}
</CellMeasurer>
)}
/>
react-tiny-virtual-list 对可变高度支持有限,通常建议固定高度或使用估算值。
// react-tiny-virtual-list: 通常建议固定高度
// 若需可变高度,需自行计算 itemHeight 数组传入
<VirtualList
items={items}
itemHeight={index => estimatedHeights[index]}
renderItem={({ index, style }) => <div style={style}>{items[index]}</div>}
/>
react-infinite-scroll-component 天然支持可变高度,因为它渲染真实 DOM,但性能会随数据量增加而下降。
// react-infinite-scroll-component: 天然支持可变高度
<InfiniteScroll dataLength={items.length} next={loadMore} hasMore={true}>
{items.map(item => (
<div key={item.id} style={{ height: item.content.length * 10 }}>
{item.content}
</div>
))}
</InfiniteScroll>
选择库时,维护状态是关键考量。
react-window 处于活跃维护状态。作者是 react-virtualized 的同一人,明确表示新项目应优先使用 react-window。它体积更小,性能更好。
react-virtualized 处于维护模式。虽然稳定,但不再添加新功能。官方文档建议新用户迁移到 react-window。仅在需要其特有功能(如复杂网格)时考虑。
react-infinite-scroll-component 处于活跃维护状态。它是解决“加载更多内容”交互的标准方案之一,但需注意它不解决大数据量渲染卡顿问题。
react-tiny-virtual-list 社区活跃度较低。适合特定场景,但遇到问题时可能难以找到解决方案或更新。
| 特性 | react-window | react-virtualized | react-infinite-scroll | react-tiny-virtual-list |
|---|---|---|---|---|
| 虚拟化 | ✅ 是 | ✅ 是 | ❌ 否 (默认) | ✅ 是 |
| 维护状态 | 🟢 活跃 | 🟡 维护模式 | 🟢 活跃 | 🟡 一般 |
| 包体积 | ⚡ 小 | 🐘 大 | ⚡ 小 | ⚡ 极小 |
| 可变高度 | ✅ 支持 (需手动) | ✅ 支持 (自动测量) | ✅ 天然支持 | ⚠️ 有限支持 |
| 无限加载 | ⚠️ 需配合 loader | ⚠️ 需配合 loader | ✅ 内置支持 | ⚠️ 需配合 loader |
| 推荐场景 | 新项目大数据列表 | 旧项目/复杂网格 | 信息流/分页加载 | 体积极度敏感 |
react-window 是大多数现代 React 应用的最佳选择。它在性能和易用性之间取得了很好的平衡,并且有长期的维护保障。如果你的列表数据量很大,不要犹豫,直接使用它。
react-infinite-scroll-component 适合内容型产品,如新闻流或社交媒体,其中数据是分页加载的,且用户很少一次性滚动到底部。如果数据量可能累积到几千条,建议将其与 react-window 结合使用。
react-virtualized 仅建议在维护旧项目时使用。如果遇到性能问题,计划迁移到 react-window 是更明智的长期策略。
react-tiny-virtual-list 适合对包体积极度敏感的项目,但在功能丰富度和社区支持上需要做出妥协。
总结:虚拟化解决的是“渲染性能”问题,无限滚动解决的是“数据加载”问题。很多时候,你需要同时解决这两个问题 —— 这时 react-window 配合无限加载逻辑是最佳组合。
如果你只需要简单的“滚动到底部加载下一页”功能,且数据总量不会极大(例如几千条以内),这个库最合适。它不强制进行 DOM 虚拟化,实现简单,适合内容流、社交媒体动态等场景。如果数据量极大,需配合虚拟化库使用。
如果你的构建包体积限制非常严格,且列表需求简单(固定高度、无复杂交互),可以考虑此库。它提供了基本的虚拟化功能,但社区生态和高级功能支持不如 react-window。适合对 bundle size 极度敏感的小型项目。
如果你的项目已经在使用 react-virtualized,或者需要某些 react-window 尚未提供的复杂功能(如特定的网格布局或复杂的表头),可以继续使用。但在新项目中不建议引入,因为它处于维护模式,体积较大,且 API 设计较旧。
如果你的项目是新启动的,且需要处理大量数据列表,首选 react-window。它是 react-virtualized 作者推出的现代替代品,体积更小,API 更简洁,且处于活跃维护状态。适合大多数标准的虚拟滚动场景,如聊天记录、长表格或下拉选择器。
Infinite scroll for React. Zero runtime dependencies, IntersectionObserver-based, TypeScript-first. ~4 kB gzipped.
Works with window scroll, fixed-height containers, and custom scrollable parents. Pull-to-refresh and inverse (chat) scroll included. React 17, 18, and 19 compatible.
npm install react-infinite-scroll-component
# or
yarn add react-infinite-scroll-component
# or
pnpm add react-infinite-scroll-component
| API | When to use |
|---|---|
InfiniteScroll component | Most cases, handles loader, endMessage, pull-to-refresh, inverse scroll UI |
useInfiniteScroll hook | Custom UI, you own the markup, the hook manages the observer |
InfiniteScroll componentimport { useState } from 'react';
import InfiniteScroll from 'react-infinite-scroll-component';
type Item = { id: number; name: string };
function Feed() {
const [items, setItems] = useState<Item[]>(initialItems);
const [hasMore, setHasMore] = useState(true);
const fetchMore = async () => {
const next = await api.getItems({ offset: items.length });
if (next.length === 0) {
setHasMore(false);
return;
}
setItems((prev) => [...prev, ...next]);
};
return (
<InfiniteScroll
dataLength={items.length}
next={fetchMore}
hasMore={hasMore}
loader={<p>Loading...</p>}
endMessage={<p style={{ textAlign: 'center' }}>All items loaded.</p>}
>
{items.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</InfiniteScroll>
);
}
<div id="scrollableDiv" style={{ height: 400, overflow: 'auto' }}>
<InfiniteScroll
dataLength={items.length}
next={fetchMore}
hasMore={hasMore}
loader={<p>Loading...</p>}
scrollableTarget="scrollableDiv"
>
{items.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</InfiniteScroll>
</div>
Pass a ref value directly instead of a string id:
const containerRef = useRef<HTMLDivElement>(null);
<div ref={containerRef} style={{ height: 400, overflow: 'auto' }}>
<InfiniteScroll
dataLength={items.length}
next={fetchMore}
hasMore={hasMore}
loader={<p>Loading...</p>}
scrollableTarget={containerRef.current}
>
{items.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</InfiniteScroll>
</div>;
<div
id="chatBox"
style={{
height: 500,
overflow: 'auto',
display: 'flex',
flexDirection: 'column-reverse',
}}
>
<InfiniteScroll
dataLength={messages.length}
next={loadOlderMessages}
hasMore={hasMore}
loader={<p>Loading older messages...</p>}
inverse={true}
scrollableTarget="chatBox"
style={{ display: 'flex', flexDirection: 'column-reverse' }}
>
{messages.map((msg) => (
<div key={msg.id}>{msg.text}</div>
))}
</InfiniteScroll>
</div>
<InfiniteScroll
dataLength={items.length}
next={fetchMore}
hasMore={hasMore}
loader={<p>Loading...</p>}
pullDownToRefresh
pullDownToRefreshThreshold={50}
refreshFunction={refreshList}
pullDownToRefreshContent={
<h3 style={{ textAlign: 'center' }}>↓ Pull down to refresh</h3>
}
releaseToRefreshContent={
<h3 style={{ textAlign: 'center' }}>↑ Release to refresh</h3>
}
>
{items.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</InfiniteScroll>
useInfiniteScroll hookFor when you need full control over your markup. Place the sentinelRef div at the end of your list, the hook fires next() when it enters the viewport.
import { useState } from 'react';
import { useInfiniteScroll } from 'react-infinite-scroll-component';
type Item = { id: number; name: string };
function CustomFeed() {
const [items, setItems] = useState<Item[]>(initialItems);
const [hasMore, setHasMore] = useState(true);
const { sentinelRef, isLoading } = useInfiniteScroll({
next: async () => {
const more = await api.getItems({ offset: items.length });
if (more.length === 0) {
setHasMore(false);
return;
}
setItems((prev) => [...prev, ...more]);
},
hasMore,
dataLength: items.length,
});
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
<li ref={sentinelRef} aria-hidden="true" />
{isLoading && <li>Loading...</li>}
{!hasMore && <li>All items loaded.</li>}
</ul>
);
}
InfiniteScroll is a client component. Fetch initial data in a Server Component, pass it down.
// app/feed/page.tsx, Server Component
import { FeedClient } from './feed-client';
import { db } from '@/lib/db';
export default async function FeedPage() {
const initialItems = await db.items.findMany({
take: 20,
orderBy: { id: 'desc' },
});
return <FeedClient initialItems={initialItems} />;
}
// app/feed/feed-client.tsx, Client Component
'use client';
import { useState } from 'react';
import InfiniteScroll from 'react-infinite-scroll-component';
type Item = { id: string; title: string };
export function FeedClient({ initialItems }: { initialItems: Item[] }) {
const [items, setItems] = useState(initialItems);
const [hasMore, setHasMore] = useState(true);
const fetchMore = async () => {
const res = await fetch(`/api/items?cursor=${items[items.length - 1].id}`);
const next: Item[] = await res.json();
if (next.length === 0) {
setHasMore(false);
return;
}
setItems((prev) => [...prev, ...next]);
};
return (
<InfiniteScroll
dataLength={items.length}
next={fetchMore}
hasMore={hasMore}
loader={<p>Loading...</p>}
endMessage={<p>You have seen everything.</p>}
>
{items.map((item) => (
<article key={item.id}>{item.title}</article>
))}
</InfiniteScroll>
);
}
import { useInfiniteQuery } from '@tanstack/react-query';
import InfiniteScroll from 'react-infinite-scroll-component';
function PostFeed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 0 }) => fetchPosts(pageParam),
getNextPageParam: (lastPage, pages) =>
lastPage.length === 20 ? pages.length : undefined,
});
const posts = data?.pages.flat() ?? [];
return (
<InfiniteScroll
dataLength={posts.length}
next={fetchNextPage}
hasMore={!!hasNextPage}
loader={isFetchingNextPage ? <p>Loading...</p> : null}
endMessage={<p>All posts loaded.</p>}
>
{posts.map((post) => (
<article key={post.id}>{post.title}</article>
))}
</InfiniteScroll>
);
}
import useSWRInfinite from 'swr/infinite';
import InfiniteScroll from 'react-infinite-scroll-component';
const PAGE_SIZE = 20;
function PostList() {
const { data, size, setSize } = useSWRInfinite(
(index) => `/api/posts?page=${index}&limit=${PAGE_SIZE}`,
fetcher
);
const posts = data ? data.flat() : [];
const hasMore = data ? data[data.length - 1].length === PAGE_SIZE : true;
return (
<InfiniteScroll
dataLength={posts.length}
next={() => setSize(size + 1)}
hasMore={hasMore}
loader={<p>Loading...</p>}
>
{posts.map((post) => (
<div key={post.id}>{post.title}</div>
))}
</InfiniteScroll>
);
}
| Mode | How to use | Use case |
|---|---|---|
| Window scroll | Omit height and scrollableTarget | Social feeds, blogs, product listings |
| Fixed-height container | Pass height prop | Embedded lists, sidebars |
| Custom scrollable parent | Pass scrollableTarget (element or id) | Existing overflow containers |
InfiniteScroll| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
dataLength | number | yes | - | Current count of rendered items. The component resets its load guard each time this value changes, which allows next() to fire again on the next scroll. |
next | () => void | yes | - | Called once when the sentinel enters the viewport. Append new items to your list state inside this callback; do not replace the existing items. |
hasMore | boolean | yes | - | When false, the observer is disconnected and next() will not be called again. Set it to false when your data source has no more pages. |
loader | ReactNode | yes | - | Rendered below the list while the next page is loading. Displayed between the last item and the bottom sentinel. |
endMessage | ReactNode | no | - | Rendered below the list when hasMore is false. Use it for an "all caught up" or "no more items" message. |
height | number | string | no | - | Creates a fixed-height scroll container wrapping the list. Accepts a pixel number or any CSS length string. Omit this prop to scroll the window instead. |
scrollableTarget | HTMLElement | string | null | no | - | The scrollable ancestor that already provides overflow scrollbars. Pass the element's id string or a direct HTMLElement reference. Required when the scroll container is neither the window nor the height wrapper. |
scrollThreshold | number | string | no | 0.8 | How close to the bottom the user must scroll before next() is called. A fraction like 0.8 means 80% scrolled; a string like "200px" means within 200 px of the bottom edge. |
inverse | boolean | no | false | Reverse scroll direction for chat or messaging UIs. The sentinel moves to the top of the list. Use together with flexDirection: column-reverse on the scroll container. |
pullDownToRefresh | boolean | no | false | Enable pull-to-refresh gesture on touch and mouse. Requires refreshFunction to also be set. |
refreshFunction | () => void | no | - | Called once when the user pulls down past pullDownToRefreshThreshold pixels and releases. Only active when pullDownToRefresh is true. |
pullDownToRefreshThreshold | number | no | 100 | How many pixels the user must pull down before refreshFunction is triggered on release. |
pullDownToRefreshContent | ReactNode | no | - | Content shown inside the pull-to-refresh area while the user is pulling but has not yet reached the threshold. |
releaseToRefreshContent | ReactNode | no | - | Content shown inside the pull-to-refresh area once the threshold is passed and the user can release to refresh. |
onScroll | (e: UIEvent) => void | no | - | Callback fired on every scroll event on the container. Receives the native UIEvent. Useful for syncing UI state with scroll position. |
className | string | no | '' | CSS class name applied to the inner scroll container div. |
style | CSSProperties | no | - | Inline style object applied to the inner scroll container div. Merged with the component's default layout styles. |
role | AriaRole | no | - | Semantic role for the scroll container. Use "list" for item lists, "feed" for activity streams. |
tabIndex | number | no | - | Makes the scroll container focusable. Pass 0 to include it in the natural tab sequence. |
id | string | no | - | DOM id for the container. Useful when other elements reference it via aria-labelledby. |
aria-* | AriaAttributes | no | - | Any React aria-* prop (aria-label, aria-labelledby, aria-describedby, etc.) forwarded to the scroll container. |
hasChildren | boolean | no | - | Set to true when children is a single element or a fragment rather than an array. Helps the component detect whether visible content exists to determine scroll state. |
initialScrollY | number | no | - | Scrolls the window to this Y offset on mount. Useful for restoring a user's scroll position when navigating back to a page. |
Pass role and a label so screen readers can announce the container and its item count correctly:
<InfiniteScroll
role="list"
aria-label="Search results"
dataLength={items.length}
next={fetchMore}
hasMore={hasMore}
loader={<p>Loading...</p>}
>
{items.map((item) => (
<div role="listitem" key={item.id}>
{item.name}
</div>
))}
</InfiniteScroll>
Or reference an existing heading via aria-labelledby:
<h2 id="results-heading">Search results</h2>
<InfiniteScroll
role="list"
aria-labelledby="results-heading"
dataLength={items.length}
next={fetchMore}
hasMore={hasMore}
loader={<p>Loading...</p>}
>
useInfiniteScroll| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
dataLength | number | yes | - | Current count of rendered items. The hook resets its load guard whenever this value changes, allowing next() to fire again on the next intersection. |
next | () => void | yes | - | Called once when the sentinel enters the viewport. Append new items to your list state inside this callback; do not replace the existing items. |
hasMore | boolean | yes | - | When false, the IntersectionObserver is disconnected and next() will not be called again. Set it to false when your data source has no more pages. |
scrollThreshold | number | string | no | 0.8 | How close to the edge the sentinel must be before next() fires. A fraction like 0.8 means 80% scrolled; a string like "200px" means within 200 px of the edge. |
scrollableTarget | HTMLElement | string | null | no | - | The scrollable ancestor to use as the observer root. Pass a DOM id string or an HTMLElement reference. When omitted, the observer uses the browser viewport. |
inverse | boolean | no | false | When true, the rootMargin is applied to the top edge instead of the bottom. Place the sentinel at the top of your list and use flexDirection: column-reverse for chat UIs. |
Returns { sentinelRef, isLoading }.
next() fires once when the sentinel enters the viewport, not on every scroll tick. No missed triggers, better performance.useInfiniteScroll hook, low-level hook for building fully custom UIs.throttle-debounce removed.scrollableTarget accepts HTMLElement, pass a ref value directly, not just a string id.scrollableTarget
Thanks goes to these wonderful people (emoji key):
This project follows the all-contributors specification. Contributions of any kind are welcome!