React 用 IntersectionObserver 实现图片懒加载与无限滚动:封装一个 useInView Hook
列表页一次渲染几十张图,首屏还没露脸的图片全都在偷偷加载,首屏白屏时间被拖长;或者做「滚到底自动加载下一页」,你监听scroll事件算scrollTop + clientHeight >= scrollHeight,结果滚动时卡顿、还得手动防抖。这两个需求其实是同一个问题:怎么知道某个元素进没进视口。老办法(监听 scroll + getBoundingClientRect)又费性能又难写,现代做法是IntersectionObserver。这篇带你封装一个可复用的useInViewHook,再分别拿它做图片懒加载和无限滚动。
为什么别再用 scroll + getBoundingClientRect
先看看「朴素写法」为什么不好:
// ❌ 老办法:监听 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布尔值。
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挂上去。
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才是通用解。
场景二:无限滚动
思路:在列表末尾放一个「哨兵」空元素,它一进视口就说明用户快滚到底了,触发加载下一页。
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 或把节点作为依赖。这里给一个更健壮的版本骨架:
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。