尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

React 用 IntersectionObserver 实现图片懒加载与无限滚动:封装一个 useInView Hook

React 用 IntersectionObserver 实现图片懒加载与无限滚动:封装一个 useInView Hook 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 loadinglazy,简单场景直接用它就够了。但当你需要**自定义占位、控制加载时机、或懒加载的不只是图片(比如整个重型组件)**时,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。
返回列表