用一个 Hook 实现 React 无限滚动
无限滚动让用户在向下滚动页面时可以加载更多内容,用无缝的浏览体验取代传统的分页。它无处不在:社交媒体信息流、图片画廊、搜索结果和商品列表。然而,在 React 中正确实现它比看起来要难得多。
什么是无限滚动?
无限滚动会在用户到达(或接近)当前列表末尾时自动获取并追加新内容。用户不需要点击”下一页”,只需继续滚动即可。做得好,感觉毫不费力。做得不好,则会导致重复请求、内存泄漏和界面卡顿。
使用 IntersectionObserver 的手动方式
标准的 DIY 方法是使用 IntersectionObserver API 来检测哨兵元素何时进入视口:
import { useEffect, useRef, useState } from "react";
function Feed() {
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
const sentinelRef = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setPage((p) => p + 1);
}
},
{ threshold: 1.0 }
);
if (sentinelRef.current) observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, []);
useEffect(() => {
fetch(`/api/items?page=${page}`)
.then((res) => res.json())
.then((data) => setItems((prev) => [...prev, ...data]));
}, [page]);
return (
<div>
{items.map((item) => (
<div key={item.id}>{item.title}</div>
))}
<div ref={sentinelRef} />
</div>
);
}
这对演示来说可以工作,但在生产环境中很快就会暴露问题。
手动方式的问题
- 清理容易出错。 你必须断开 observer、取消进行中的请求、处理组件卸载。遗漏任何一项都会导致内存泄漏或在已卸载组件上更新状态。
- 竞态条件。 快速滚动可能在第一次请求完成之前多次触发 observer 回调,导致重复或乱序的数据。
- 加载状态。 滚动检测和异步请求之间没有内置的协调机制。你最终需要在多个 effect 之间传递
isLoading标志。 - 滚动方向。 支持向上无限滚动(如聊天记录)需要完全不同的计算方式。
- 滚动位置保持。 在当前视口上方加载内容时,除非你手动测量和恢复,否则滚动位置会跳动。
每次你将这个模式复制粘贴到新组件中,就会重新引入同样的风险。
更好的方式:useInfiniteScroll
ReactUse 提供了 useInfiniteScroll,一个处理滚动检测、回调调用和上述所有边界情况的 Hook:
import { useInfiniteScroll } from "@reactuses/core";
import { useRef, useState } from "react";
function Feed() {
const ref = useRef(null);
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
useInfiniteScroll(
ref,
async () => {
const res = await fetch(`/api/items?page=${page}`);
const data = await res.json();
setItems((prev) => [...prev, ...data]);
setPage((p) => p + 1);
}
);
return (
<div ref={ref} style={{ height: 500, overflow: "auto" }}>
{items.map((item) => (
<div key={item.id}>{item.title}</div>
))}
</div>
);
}
该 Hook 监控目标元素的滚动位置。当用户滚动到足够接近边缘时,它会调用你的 onLoadMore 函数。无需设置 observer,无需清理代码,无需哨兵元素。
带 API 加载的完整示例
这是一个更完整的示例,包含加载指示器和列表末尾检查:
import { useInfiniteScroll } from "@reactuses/core";
import { useRef, useState } from "react";
function ProductList() {
const containerRef = useRef(null);
const [products, setProducts] = useState([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
useInfiniteScroll(
containerRef,
async () => {
if (!hasMore) return;
const res = await fetch(`/api/products?page=${page}&limit=20`);
const data = await res.json();
if (data.length < 20) setHasMore(false);
setProducts((prev) => [...prev, ...data]);
setPage((p) => p + 1);
},
{ distance: 200 }
);
return (
<div ref={containerRef} style={{ height: "80vh", overflow: "auto" }}>
{products.map((product) => (
<div key={product.id}>
<h3>{product.name}</h3>
<p>{product.price}</p>
</div>
))}
{!hasMore && <p>You have reached the end.</p>}
</div>
);
}
distance 选项会在用户到达底部之前 200 像素就触发加载,这样新内容会在用户滚完当前内容之前就出现。
自定义:触发距离和方向
触发距离
设置 distance 来控制加载触发的提前量。值为 0(默认值)会等到用户滚到最底部。更高的值通过预加载内容提供更流畅的体验:
useInfiniteScroll(ref, loadMore, { distance: 300 });
滚动方向
默认情况下,Hook 监视 bottom 边缘。对于像聊天这样的倒序信息流,切换到 top 并启用 preserveScrollPosition,这样在插入新消息后视口会保持原位:
useInfiniteScroll(ref, loadOlderMessages, {
direction: "top",
preserveScrollPosition: true,
});
你也可以使用 left 或 right 来支持水平滚动布局,如轮播图或时间线。
不适合使用无限滚动的场景
无限滚动并不总是最佳选择:
- 用户需要重新找到的内容。 如果用户想要收藏或返回到特定项目,分页的 URL 更可靠。
- 小型、有限的数据集。 如果你只有 20 个项目,直接全部渲染即可。
- 依赖页脚的页面。 无限滚动使得页脚无法到达,这会让期望在那里找到链接或法律信息的用户感到沮丧。
- 无障碍要求。 屏幕阅读器和键盘导航配合显式分页控件效果更好。如果你使用无限滚动,请提供一个备选的”加载更多”按钮。
在使用这种模式之前,请考虑这些权衡。
安装
npm i @reactuses/core
相关 Hooks
- useInfiniteScroll 文档 — 交互式演示和完整 API 参考
- useScroll — 响应式滚动位置和方向追踪
ReactUse 提供了 100 多个 React Hooks。查看全部 →