React 用 IntersectionObserver 实现图片懒加载与无限滚动:封装一个 useInView Hook
列表页一次渲染几十张图,首屏还没露脸的图片全都在偷偷加载,首屏白屏时间被拖长;或者做「滚到底自动加载下一页」,你监听 scroll 事件算 scrollTop + clientHeight >= scrollHeight,结果滚动时卡顿、还得手动防抖。这两个需求其实是同一个问题:怎么知道某个元素进没进视口 。老办法(监听 scroll + getBoundingClientRect)又费性能又难写,现代做法是 IntersectionObserver。这篇带你封装一个可复用的 useInView Hook,再分别拿它做图片懒加载和无限滚动。
为什么别再用 scroll + getBoundingClientRect
先看看「朴素写法」为什么不好:
jsx
// ❌ 老办法:监听 scroll,每次都算位置
useEffect(() => {
const onScroll = () => {
const rect = ref.current.getBoundingClientRect();
if (rect.top < window.innerHeight) {
// 进视口了...
}
};
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, []);
两个问题:
- scroll 事件触发极其频繁 ,滚一下能触发几十上百次,每次都调
getBoundingClientRect------而getBoundingClientRect会强制浏览器同步重排(reflow),滚动时掉帧就是这么来的。你还得自己加节流,越写越复杂。 - 多个元素都要监听时,每个都挂一个 scroll listener,性能雪上加霜。
IntersectionObserver 是浏览器原生 API,专门解决「元素是否进入视口」。它异步、在浏览器空闲时批量回调,不占主线程,也不触发重排,几十个元素一起观察也很轻。
封装 useInView Hook
核心思路:传一个 ref 给要观察的元素,Hook 内部用 IntersectionObserver 盯着它,返回一个 inView 布尔值。
jsx
import { useEffect, useRef, useState } from "react";
function useInView(options) {
const ref = useRef(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return; // 元素还没挂载,直接跳过
const observer = new IntersectionObserver(([entry]) => {
// entry.isIntersecting 就是「有没有和视口相交」
setInView(entry.isIntersecting);
}, options);
observer.observe(el);
// 关键:卸载时断开观察,否则观察器和 DOM 引用泄漏
return () => observer.disconnect();
}, [options]);
return [ref, inView];
}
几个要点:
- 回调里的
entry.isIntersecting是布尔值,true = 元素和视口(或指定容器)有交集。 return () => observer.disconnect()不能省。组件卸载后不断开,观察器会一直持有 DOM 引用,既泄漏内存又可能对已卸载节点调 setState 报警告。- 依赖数组带
options:如果 options 是父组件每次渲染新建的对象,会导致 effect 反复重建。用法上要么把 options 定义在组件外,要么用useMemo包住(下面会提)。
options 支持三个常用字段:
root:相对哪个容器算「视口」,默认null即浏览器视口。做容器内滚动(不是整页滚)时传容器 ref。rootMargin:给视口「扩边」,如"200px"表示元素距视口还有 200px 时就算「进入」------懒加载提前触发、避免用户看到图还在转圈,全靠它。threshold:相交比例阈值,0表示露头就触发,1表示完全可见才触发。
场景一:图片懒加载
思路:图片没进视口时只渲染占位,inView 变 true 后才把真实 src 挂上去。
jsx
function LazyImage({ src, alt }) {
// rootMargin 200px:提前 200px 就开始加载,滚到时图已就位
const [ref, inView] = useInView({ rootMargin: "200px", threshold: 0 });
const [loaded, setLoaded] = useState(false);
return (
<div
ref={ref}
style={{ minHeight: 200, background: loaded ? "none" : "#f0f0f0" }}
>
{inView && (
<img
src={src}
alt={alt}
onLoad={() => setLoaded(true)}
style={{ opacity: loaded ? 1 : 0, transition: "opacity .3s" }}
/>
)}
</div>
);
}
inView 为 false 时渲染一个灰色占位(撑住高度,避免布局跳动),进视口后才渲染 <img> 真正发起图片请求;onLoad 后淡入。rootMargin: "200px" 让加载提前发生,用户几乎感觉不到懒加载的存在。
补充:现代浏览器其实原生支持
<img loading="lazy">,简单场景直接用它就够了。但当你需要**自定义占位、控制加载时机、或懒加载的不只是图片(比如整个重型组件)**时,IntersectionObserver才是通用解。
场景二:无限滚动
思路:在列表末尾放一个「哨兵」空元素,它一进视口就说明用户快滚到底了,触发加载下一页。
jsx
function InfiniteList() {
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
// 哨兵进视口就触发
const [sentinelRef, inView] = useInView({ rootMargin: "100px" });
useEffect(() => {
// 只有哨兵可见、不在加载中、还有更多,才加载
if (!inView || loading || !hasMore) return;
setLoading(true);
fetch(`/api/items?page=${page}`)
.then((r) => r.json())
.then((data) => {
setItems((prev) => [...prev, ...data.list]); // 追加,不是替换
setHasMore(data.hasMore);
setPage((p) => p + 1);
})
.finally(() => setLoading(false));
}, [inView, loading, hasMore, page]);
return (
<div>
{items.map((it) => (
<div key={it.id}>{it.title}</div>
))}
{hasMore && <div ref={sentinelRef} style={{ height: 1 }} />}
{loading && <p>加载中...</p>}
{!hasMore && <p>没有更多了</p>}
</div>
);
}
三个防坑点:
if (!inView || loading || !hasMore) return三重闸门缺一不可 。少了loading判断,哨兵持续可见时会连发好几次请求(第一页数据还没回来,哨兵还在视口里,effect 又跑一遍);少了hasMore,数据加载完后哨兵还在,会无限请求空数据。- 哨兵只在
hasMore时渲染 。数据到底后哨兵消失,observe的目标没了,自然不再触发,同时页面显示「没有更多了」。 setItems((prev) => [...prev, ...data.list])用函数式更新追加 ,别写成依赖闭包里的items,否则容易丢数据(连续加载时拿到旧的 items 快照)。
一个容易踩的坑:ref 变化不会重新 observe
上面的 useInView 有个隐含前提:被观察的 DOM 节点在组件生命周期里是稳定 的。如果你的元素是条件渲染、ref 指向的节点会中途更换,useEffect 的空依赖不会重新执行 observe,新节点就没被观察。稳妥做法是用回调 ref 或把节点作为依赖。这里给一个更健壮的版本骨架:
jsx
function useInView(options) {
const [node, setNode] = useState(null); // 用 state 存节点
const [inView, setInView] = useState(false);
const ref = useCallback((el) => setNode(el), []); // 回调 ref
useEffect(() => {
if (!node) return;
const observer = new IntersectionObserver(
([entry]) => setInView(entry.isIntersecting),
options
);
observer.observe(node);
return () => observer.disconnect();
}, [node, options]); // 节点变了就重新 observe
return [ref, inView];
}
用回调 ref 把节点存进 state,节点一变 effect 就重跑、重新 observe,条件渲染的元素也能正确观察。记得此时 options 最好用 useMemo 固定,否则每次渲染都重建 observer。
小结
- 判断「元素是否进视口」用
IntersectionObserver,别再监听 scroll +getBoundingClientRect------后者强制同步重排,滚动必卡。 - 封装
useInView的骨架:effect 里new IntersectionObserver→observe(el)→return () => observer.disconnect()(卸载断开,防泄漏)。 rootMargin给视口扩边,让懒加载/加载下一页提前触发 ;threshold控制露多少才算进入。- 无限滚动用末尾哨兵元素 +
inView,并加loading/hasMore三重闸门 防重复请求,追加数据用函数式更新。 - 一句话记忆点:懒加载和无限滚动是同一个问题------「元素进视口没有」,用 IntersectionObserver 一把 Hook 全解决,记得卸载时 disconnect。