2026年8月3日
React useInfiniteScroll Hook:无限滚动轻松实现(2026)
每个信息流、每个聊天记录、每个搜索结果页面最终都会问同一个问题:用户滚到底部时怎么加载更多? 朴素的答案——一个 scroll 监听器、一些关于 scrollHeight 和 clientHeight 的算术、一个防止重复请求的布尔值——大约 30 行代码,而每一行都是陷阱。你忘了清理监听器。你比较了错误的尺寸。你在 mount 时触发了回调,那时候根本没有内容可以滚动。你硬编码了”底部”,然后产品要求做一个向上加载历史记录的聊天界面。你没做节流,于是用户把滚动位置停在阈值附近时回调每秒触发 60 次。
@reactuses/core 的 useInfiniteScroll 用一次调用替代了所有这些:把它指向一个可滚动元素,给它一个加载更多函数,剩下的它全包了——到达检测、方向、距离阈值、滚动位置保持和清理。这篇文章会走读真实实现、关键选项,以及信息流、聊天和水平轮播的实战模式。TypeScript 优先。
最简用法:滚到底部加载更多
import { useRef, useState } from 'react';
import { useInfiniteScroll } from '@reactuses/core';
function Feed() {
const containerRef = useRef<HTMLDivElement>(null);
const [items, setItems] = useState<string[]>(() =>
Array.from({ length: 20 }, (_, i) => `Item ${i + 1}`)
);
useInfiniteScroll(containerRef, async () => {
const newItems = await fetchMoreItems(items.length);
setItems(prev => [...prev, ...newItems]);
});
return (
<div ref={containerRef} style={{ height: 400, overflow: 'auto' }}>
{items.map(item => (
<div key={item} style={{ padding: 16, borderBottom: '1px solid #eee' }}>
{item}
</div>
))}
</div>
);
}
就这么多。滚到底部,fetchMoreItems 触发。用户滚走再滚回来之前不会重复触发。SSR 期间不会触发。卸载时自动清理监听器。容器可以是任何可滚动元素——一个带 overflow: auto 的 div、一个 <section>,只要 ref 指向它就行。
函数签名
useInfiniteScroll(target, onLoadMore, options?)
target— 可滚动 DOM 元素的 ref(RefObject<Element>)。onLoadMore— 用户到达滚动边缘时调用的函数(同步或异步)。它接收来自useScroll的完整滚动状态:[x, y, isScrolling, arrivedState, directions]。options—useScroll接受的所有选项,加上三个无限滚动专属字段。
关键选项
distance —— 提前触发
useInfiniteScroll(containerRef, loadMore, {
distance: 200, // 距底部 200px 时就触发
});
默认值是 0——只有滚到绝对边缘才触发回调。设置 distance 可以预加载:设为 200 时,在还有 200 px 内容可滚动的时候就开始请求下一页,这样网速够快的话用户永远看不到加载中。合适的数值取决于列表项高度和请求延迟——从一屏高度开始,往下调。
direction —— 不只是底部
useInfiniteScroll(containerRef, loadMore, {
direction: 'top', // 向上滚动时加载更早的消息
});
四个方向:'bottom'(默认)、'top'、'left'、'right'。聊天应用要 'top'——用户向上滚动加载历史消息。水平轮播要 'left' 或 'right'。hook 会自动把到达检测连接到正确的边缘。
preserveScrollPosition —— 留在原地
useInfiniteScroll(containerRef, loadMore, {
direction: 'top',
preserveScrollPosition: true,
});
当你在当前视口上方加载内容时(聊天历史、倒序信息流),新内容会把所有东西往下推,用户就丢失了位置。preserveScrollPosition: true 解决了这个问题:onLoadMore resolve 之后,hook 会把 scrollTop(水平方向则是 scrollLeft)精确偏移新插入内容的高度(或宽度)。用户看到的滚动位置不变,更早的消息出现在上方。
throttle —— 继承自 useScroll
useInfiniteScroll(containerRef, loadMore, {
throttle: 100, // 每 100ms 至多检测一次到达
});
这是 useScroll 的选项,useInfiniteScroll 直接透传。它节流底层的滚动事件处理器——当你的容器以 120 fps 滚动而你不需要亚帧级到达检测时很有用。
底层实现
实现只有 44 行。它做了这些事:
export const useInfiniteScroll = (target, onLoadMore, options = {}) => {
const savedLoadMore = useLatest(onLoadMore);
const direction = options.direction ?? 'bottom';
const state = useScroll(target, {
...options,
offset: {
[direction]: options.distance ?? 0,
...options.offset,
},
});
const di = state[3][direction]; // arrivedState[direction]
useUpdateEffect(() => {
const element = getTargetElement(target);
const fn = async () => {
const previous = {
height: element?.scrollHeight ?? 0,
width: element?.scrollWidth ?? 0,
};
await savedLoadMore.current(state);
if (options.preserveScrollPosition && element) {
element.scrollTo({
top: element.scrollHeight - previous.height,
left: element.scrollWidth - previous.width,
});
}
};
fn();
}, [di, options.preserveScrollPosition, target]);
};
三个关键部分让它工作:
-
useScroll做了所有重活。 它跟踪x、y、isScrolling、到达状态(四个边缘各一个布尔值)和滚动方向。offset选项移动到达阈值——useInfiniteScroll把它的distance选项映射到offset[direction],所以”到达底部”实际上是”到达距底部distance像素以内”。 -
useUpdateEffect防止了 mount 时触发。 普通useEffect会在 mount 时调用onLoadMore——那时容器还没有任何内容可滚动。useUpdateEffect跳过首次调用,只在di(所选方向的到达布尔值)实际变化时才触发。回调在每次到达时触发一次,而不是每次滚动事件触发一次。 -
useLatest消灭了闭包陈旧。onLoadMore回调大概率闭包了渲染间会变化的状态——当前页码、已累积的条目、游标。useLatest把它包在 ref 里,所以调用的始终是最新版本,而无需重建滚动机制。
preserveScrollPosition 的技巧
onLoadMore resolve 之后(新条目已经在 DOM 里了),hook 快照 scrollHeight/scrollWidth 的变化量,然后调用 element.scrollTo() 精确偏移那个差值。这是一个异步操作之后的同步 DOM 测量——它能工作是因为 onLoadMore 中的 React 状态更新在 await 恢复时已经刷新到 DOM 了。
实战模式
分页信息流
function PaginatedFeed() {
const ref = useRef<HTMLDivElement>(null);
const [page, setPage] = useState(1);
const [items, setItems] = useState<Item[]>([]);
const [hasMore, setHasMore] = useState(true);
useInfiniteScroll(ref, async () => {
if (!hasMore) return;
const data = await fetchPage(page);
setItems(prev => [...prev, ...data.items]);
setHasMore(data.hasNextPage);
setPage(prev => prev + 1);
}, { distance: 300 });
return (
<div ref={ref} style={{ height: '100vh', overflow: 'auto' }}>
{items.map(item => <Card key={item.id} item={item} />)}
{!hasMore && <p>没有更多了</p>}
</div>
);
}
用 hasMore 做守卫,API 说没有更多数据时回调变成空操作。hook 在边缘仍然会触发——守卫让触发的代价很低。
聊天历史(反向滚动)
function ChatHistory({ channelId }: { channelId: string }) {
const ref = useRef<HTMLDivElement>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
useInfiniteScroll(ref, async () => {
const data = await fetchMessages(channelId, cursor);
setMessages(prev => [...data.messages, ...prev]);
setCursor(data.nextCursor);
}, {
direction: 'top',
preserveScrollPosition: true,
distance: 100,
});
return (
<div ref={ref} style={{ height: 500, overflow: 'auto' }}>
{messages.map(msg => <MessageBubble key={msg.id} message={msg} />)}
</div>
);
}
direction: 'top' 在用户滚到顶部时触发。preserveScrollPosition: true 在旧消息前插之后保持视口停在同一条消息上。这就是 Slack、Discord 和所有聊天 UI 用的模式——也是手写最容易翻车的模式,因为滚动位置的计算必须在 DOM 更新之后、浏览器绘制之前执行。
水平轮播
function HorizontalGallery() {
const ref = useRef<HTMLDivElement>(null);
const [images, setImages] = useState<string[]>([]);
useInfiniteScroll(ref, async () => {
const moreImages = await fetchImages(images.length);
setImages(prev => [...prev, ...moreImages]);
}, {
direction: 'right',
distance: 200,
});
return (
<div ref={ref} style={{ display: 'flex', overflowX: 'auto', gap: 16 }}>
{images.map(src => <img key={src} src={src} style={{ width: 300 }} />)}
</div>
);
}
同一个 hook,不同的轴。direction: 'right' 监视 scrollLeft 相对于 scrollWidth 的位置。
useInfiniteScroll vs. useIntersectionObserver
两者都能触发”加载更多”。区别在于它们监视什么:
-
useIntersectionObserver监视一个哨兵元素——列表底部的一个 div。当哨兵进入视口时,加载更多。它适用于任何容器,包括 window 本身,并且能优雅地处理复杂布局(粘性头部、嵌套滚动容器),因为浏览器的交叉计算会考虑所有这些因素。 -
useInfiniteScroll监视特定容器的滚动位置。连接更简单(不需要管理哨兵元素),原生支持四个方向,并且内置preserveScrollPosition。
在以下情况选 useInfiniteScroll:你有一个单独的可滚动容器,想要最简单的配置。在以下情况选 useIntersectionObserver:你在 window 级别加载、有复杂的嵌套滚动上下文,或者需要对触发阈值做精细控制。
滚动家族
useScroll—— 基石:跟踪任何可滚动元素的x、y、isScrolling、到达状态和方向。useInfiniteScroll基于它构建。useWindowScroll—— 同样的跟踪,但专门针对window。useThrottle/useDebounce—— 对任何值做速率限制。useScroll内置了throttle支持,但如果你因为其他原因需要节流加载更多的输出,这两个就是你的工具。useElementSize—— 如果你需要知道容器的尺寸来计算每页该请求多少条目。
SSR 安全
useInfiniteScroll 在服务端渲染期间不创建任何订阅。滚动监听器在 useScroll 内部附加,而后者会检查 window 是否存在。useUpdateEffect 完全跳过首次渲染。在服务端,这个 hook 是一个不触碰任何浏览器全局变量的空操作——你的 Next.js / Remix 构建渲染初始条目并干净地 hydrate,无限滚动随客户端一起醒来。和 @reactuses/core 的每个 hook 一样,在构造上就是 SSR 安全的。
要点总结
- 一个 hook 替代了滚动监听器、计算和清理。
useInfiniteScroll接收一个 ref 和一个回调,其余全搞定。 distance预加载内容,让用户永远不用在底部等待。direction处理全部四个边缘 ——'bottom'用于信息流,'top'用于聊天历史,'left'/'right'用于轮播。preserveScrollPosition是聊天历史的救星 —— 前插内容后调整滚动偏移,让视口不跳动。- 基于
useScroll构建,意味着你免费获得了节流、到达状态跟踪和方向检测。 useUpdateEffect防止了 mount 时触发 —— 回调在用户实际滚动到边缘之前不会运行。- SSR 安全,无需配置 —— 客户端接管之前没有监听器,没有浏览器全局变量。
安装 @reactuses/core,把 useInfiniteScroll 指向你的列表容器,告别手写滚动算术。