React 用 IntersectionObserver 写 useInView 与无限滚动:列表滚动到底自动加载
为什么不直接监听 scroll 事件
做无限滚动最常见的写法是监听 window 的 scroll 事件,然后在回调里算 scrollTop、clientHeight、scrollHeight 判断有没有到底。这个做法有三个麻烦:回调触发极频繁,需要自己做节流;计算依赖具体容器的尺寸,列表换成内部滚动容器时就得重写;组件卸载时容易忘记解绑监听,留下内存泄漏。
IntersectionObserver 把"元素是否进入视口"这件事交给了浏览器,它只会在交叉状态真正变化时回调一次,天然节流,也不关心滚动发生在 window 还是某个 div。把它包成一个 hook,前端列表加载就能写得既稳又省。
先把 useInView 写出来
这个 hook 接收一个 ref 和一个配置项,返回元素当前是否在可视范围内。注意它返回的是 ref 本身和布尔值,组件把 ref 挂到目标元素即可。
import { useEffect, useRef, useState } from "react";
export function useInView<T extends HTMLElement = HTMLDivElement>(
options: IntersectionObserverInit = { rootMargin: "200px" }
) {
const ref = useRef<T | null>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(([entry]) => {
setInView(entry.isIntersecting);
}, options);
observer.observe(el);
return () => observer.disconnect();
}, []);
return { ref, inView } as const;
}
rootMargin 设为 200px,意味着元素距离视口还有 200 像素时就算"进入",可以提前发起请求,用户滚到底时数据多半已经就绪,体验更顺。这个 hook 是纯前端能力,不绑定任何数据来源,可以复用到懒加载图片、曝光埋点等场景。
用 sentinel 节点驱动无限加载
无限列表的关键是放一个"哨兵"节点在最底部,它进入视口就说明用户快滚到底了。用上面的 useInView 把 ref 挂到哨兵上,inView 变 true 时触发下一页请求。
type Item = { id: number; name: string };
export function InfiniteList() {
const [items, setItems] = useState<Item[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const sentinel = useInView<HTMLDivElement>();
useEffect(() => {
if (!sentinel.inView || loading || done) return;
setLoading(true);
fetch(`/api/items?page=${page}`)
.then((r) => r.json())
.then((data: Item[]) => {
setItems((prev) => [...prev, ...data]);
setPage((p) => p + 1);
if (data.length === 0) setDone(true);
})
.finally(() => setLoading(false));
}, [sentinel.inView, loading, done, page]);
return (
<div>
<ul>
{items.map((it) => (
<li key={it.id}>{it.name}</li>
))}
</ul>
<div ref={sentinel.ref}>
{loading ? "加载中…" : done ? "没有更多了" : "下拉加载更多"}
</div>
</div>
);
}
几个容易踩的坑
- inView 是布尔状态,进入和离开都会触发。加载逻辑必须靠 loading 和 done 两个标志位兜底,否则同一页会被重复请求多次。
- 依赖数组里要带上 page、loading、done,保证 effect 在这些值变化后重新评估,而不是只在 inView 翻转时跑一次。
- 如果列表项本身高度不确定,rootMargin 提前量可以调大些;若服务端返回空数组要立刻置 done,防止哨兵一直停在视口里循环请求。
- SSR 环境下 IntersectionObserver 不存在,需要在 useEffect 里访问(它本就只在客户端跑),首屏渲染不要依赖 inView 的值。
小结
把 IntersectionObserver 封进 useInView,无限滚动就从"算尺寸加节流加解绑"三件套,退化成一个布尔状态加一个 effect。这个模式在长列表、瀑布流、评论区加载里都能直接套用,也是现代前端替代 scroll 监听的标准做法。
test1
文章作者
为什么不直接监听 scroll 事件 做无限滚动最常见的写法是监听 window 的 scroll 事件,然后在回调里...
- 分类
- 技术
- 发布时间
- 2026-10-07
- 字数
- 约 2691 字
- 阅读
- 2 次
