2026年6月30日
React useIntersectionObserver Hook:懒加载与可见性检测(2026)
你想等一张图片快滚进视口时再加载它。或者在一张卡片真正被看到的第一时间上报一个埋点。又或者当用户滚到列表底部时触发「加载更多」。这些其实是同一个问题——这个元素进入屏幕了吗?——而多年来的答案,是一个一秒钟触发上百次的 scroll 监听器,每次都重新读一遍 getBoundingClientRect(),却还是会漏掉各种边界情况。
IntersectionObserver 就是正确回答这个问题的浏览器 API:异步、批量、跑在主线程之外。useIntersectionObserver 则是把它接进 React 的 hook——不用 useEffect/useRef/清理那一堆样板,也不会带上手写版本必然出现的卸载泄漏和过期闭包 bug。本文讲清楚真实的 @reactuses/core API、你真正会用到的三种模式,以及怎么调 threshold、rootMargin 和 root。SSR 安全、带类型。
为什么不直接用 scroll 监听器?
以前判断一个元素是否可见的写法是这样的:监听 scroll,每次事件里把元素和视口量一遍。
useEffect(() => {
function onScroll() {
const rect = el.getBoundingClientRect();
if (rect.top < window.innerHeight) {
setVisible(true);
}
}
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
这里天生带着两个问题。第一,scroll 跑在主线程上,一秒钟触发几十次,而 getBoundingClientRect() 每次都会强制一次同步布局——这恰好是滚动卡顿的标准配方。第二,它只能抓到穿过视口的元素;一旦你的滚动发生在某个容器里,你就得手动重新推导几何关系。
IntersectionObserver 把这个模型反了过来。你把一个目标和一个阈值交给浏览器,由它来异步、批量、在滚动路径之外告诉你——元素什么时候越过了那个阈值。不用测量,不用监听器抖动。剩下唯一会写错的,就是它周围的 React 生命周期,而那部分正是这个 hook 替你管的。
下面是组件内最直觉的写法,它带着每个手写 observer 都有的那三个 bug:
function LazySection({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const [seen, setSeen] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) setSeen(true); // 🐛 见下文
}, { threshold: 0.1 });
io.observe(el);
return () => io.disconnect();
}, []);
return <div ref={ref}>{seen ? children : null}</div>;
}
- 忘了清理就会泄漏。 把
return () => io.disconnect()删掉——人们真的会删,尤其是重构的时候——observer 就会比组件活得还久。 - 它会捕获过期闭包。 一旦回调引用了某个 prop 或第二份 state,挂载时创建的 observer 就把它们冻结在了挂载那一刻的值上,而不是触发时的值。
- 它会扩散。 每个懒加载区块、每个「已浏览」追踪、每个无限滚动哨兵都在重写同一套
useRef+observe+disconnect的舞步,而每一份拷贝都是一次重新引入前两个 bug 的机会。
一个 hook 在一个地方把这三个都修了。
API
useIntersectionObserver 接收三个参数,返回一个 stop 函数:
const stop = useIntersectionObserver(target, callback, options?);
target—— 要观察什么。一个 React ref、一个原始元素,或者一个 getter() => element。(它也接受null/undefined,所以观察一个条件渲染的元素是安全的——hook 会直接等着。)callback—— 标准的IntersectionObserverCallback,即(entries, observer) => void。你拿到原始的IntersectionObserverEntry[],所以由你来决定可见对你的场景意味着什么。options—— 原生的IntersectionObserverInit:{ root, rootMargin, threshold }。全部可选。- 返回
stop()—— 调用它可以提前断开 observer(下面细讲)。hook 也会在卸载时帮你自动调用它。
这里刻意的设计选择是:hook 是基于回调的,而不是基于布尔值的。它不替你判定「相交」就等于可见——因为根据任务不同,它可能意味着「露出 10%」「完全露出」或者「距离视口 200px 以内」。你读 entry.isIntersecting(或 entry.intersectionRatio)然后做事。如果你只想要一个朴素的布尔值,有一个顺手的姊妹 hook 做这件事——见下文。
在内部,回调被存在一个 ref 里(通过 useLatest),所以它永远不会过期——即使你的回调闭包引用了 props,bug #2 也消失了。而且因为 observer 只会在 effect 内部被构造,这个 hook 是 SSR 安全的:渲染期间没有任何东西碰 IntersectionObserver。
模式一:懒加载图片
最经典的用法。先渲染一个占位,等容器快进入视口时再把真正的 <img> 换上去。注意那个 stop() 调用——一旦加载了,我们就再也不需要 observer 了,所以立刻断开它。
import { useRef, useState } from 'react';
import { useIntersectionObserver } from '@reactuses/core';
function LazyImage({ src, alt }: { src: string; alt: string }) {
const ref = useRef<HTMLDivElement>(null);
const [loaded, setLoaded] = useState(false);
const stop = useIntersectionObserver(
ref,
([entry]) => {
if (entry.isIntersecting) {
setLoaded(true);
stop(); // 一次性:决定加载后就停止观察
}
},
{ rootMargin: '200px' }, // 在它滚进来之前 200px 就开始加载
);
return (
<div ref={ref} style={{ minHeight: 200 }}>
{loaded ? <img src={src} alt={alt} /> : <div className="skeleton" />}
</div>
);
}
有两点让这个写法感觉对路。rootMargin: '200px' 把 observer 的「视口」每条边都撑大了 200px,所以请求会在图片真正可见之前就发出,用户基本看不到骨架屏。而回调里的 stop() 意味着一个 500 张图的懒加载列表,在全部加载完之后就剩零个活跃的 observer——你继续往下滚也不会有残留的工作。
模式二:「已浏览」埋点,只触发一次
追踪用户实际滚到了哪些区块是同一个形状——但这里你是真的想让它精确触发一次,所以 stop() 在干实事。
import { useRef } from 'react';
import { useIntersectionObserver } from '@reactuses/core';
function TrackedSection({ id, children }: { id: string; children: React.ReactNode }) {
const ref = useRef<HTMLElement>(null);
const stop = useIntersectionObserver(
ref,
([entry]) => {
if (entry.isIntersecting) {
analytics.track('section_viewed', { id });
stop(); // 每个区块只计一次,而不是每次滚过都计
}
},
{ threshold: 0.5 }, // 「已浏览」 = 至少露出一半
);
return <section ref={ref}>{children}</section>;
}
这里 threshold: 0.5 编码了一个产品决策——一个区块只有在露出 50% 之后才算「已浏览」,所以快速滚过顶边不会虚高你的数据。stop() 则保证每个区块每次页面加载只有一个事件,哪怕用户把它反复滚进滚出。
模式三:无限滚动触发器
在列表底部放一个空的哨兵 <div>,当它相交时就拉取下一页。注意这里我们没有调用 stop()——我们想让这个触发器对每一页都持续触发。
import { useRef } from 'react';
import { useIntersectionObserver } from '@reactuses/core';
function Feed({ items, loadMore, hasMore }: FeedProps) {
const sentinel = useRef<HTMLDivElement>(null);
useIntersectionObserver(sentinel, ([entry]) => {
if (entry.isIntersecting && hasMore) {
loadMore();
}
});
return (
<>
{items.map((it) => <Row key={it.id} item={it} />)}
{hasMore && <div ref={sentinel} style={{ height: 1 }} />}
</>
);
}
因为回调永远是最新的那个(没有过期闭包),loadMore 和 hasMore 在哨兵每次相交时都被新鲜读取——咬住手写 useEffect 版本的那个 bug 在这里根本不存在。如果你想要打包好的整套模式,useInfiniteScroll 正是在这之上搭的,连滚动容器的管线都帮你接好了。
调参:threshold、rootMargin 和 root
第三个参数是原生的 IntersectionObserverInit,原样透传。三个旋钮,各自回答一个不同的问题:
useIntersectionObserver(ref, callback, {
threshold: 0.5, // 要露出多少才算数?
rootMargin: '200px', // 撑大/缩小触发边界
root: containerRef.current, // 相对什么来测量?
});
threshold—— 一个从0到1的数字(或数组),表示目标必须露出多少回调才触发。0(默认)一个像素越界就触发;1要等元素完全进入屏幕。传一个像[0, 0.25, 0.5, 0.75, 1]这样的数组,你会在每一档都拿到一次回调——用entry.intersectionRatio驱动滚动联动动画时很有用。rootMargin—— 一个 CSS margin 字符串,在计算相交之前把 root 的包围盒撑大或缩小。正值('200px')提前触发——就是模式一里那个提前懒加载的小技巧。负值('-100px 0px')延后触发,比如「只有当它越过顶边 100px 之后才算已浏览」。root—— 你拿来测量的那个元素。默认是浏览器视口;当你的列表是在一个<div>里滚动而不是整页滚动时,把它设成那个滚动容器的元素。
stop() 返回值
返回的 stop() 会断开 observer。你通常用不到它——hook 会在卸载时自动断开——但它是表达一次性观察的干净方式,就像模式一和模式二那样:元素第一次相交时,做完事就不再观察。这既是正确性上的收益(事件精确触发一次),也是性能上的(一个长长的、已经加载完的列表后面不会拖着一个活跃的 observer)。
只想要一个布尔值?
有时你根本不在乎 entries 或阈值——你只想要一个针对整个视口的、响应式的 isVisible 标志。useElementVisibility 封装了 useIntersectionObserver,正好把它交给你,形式是一个带自己 stop 的元组:
import { useRef } from 'react';
import { useElementVisibility } from '@reactuses/core';
function FadeIn({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const [visible] = useElementVisibility(ref);
return (
<div ref={ref} className={visible ? 'fade fade-in' : 'fade'}>
{children}
</div>
);
}
当一个布尔值就够用时,用 useElementVisibility;一旦你想要自定义 root、非默认的 threshold、多个阈值,或者原始 entry,就降到 useIntersectionObserver。同一个引擎,两种手感。
SSR 安全
useIntersectionObserver 在服务端渲染是安全的。它只在 effect 内部构造 IntersectionObserver——而 effect React 在服务端从不执行——并且底层的元素查找在浏览器之外会返回 undefined,所以没有 typeof window 守卫要写,也没有 hydration mismatch 要追。原样丢进 Next.js、Remix 或 Astro 组件即可。(如果 SSR 安全在你的代码库里是个反复出现的主题,SSR 安全的 React Hooks 讲得更深。)
可见性与尺寸家族
useIntersectionObserver 是一个 DOM 观察 hook 家族里的底层原语。按你真正想要拿回什么来挑:
| Hook | 给你 | 什么时候用… |
|---|---|---|
useIntersectionObserver | 原始 entries、一个 stop() | 你想要完全的控制:自定义 root、阈值、一次性 |
useElementVisibility | [isVisible, stop] | 一个朴素的「它在屏幕上吗?」布尔值就够 |
useInfiniteScroll | 接好的 load-more 回调 | 你在搭一个分页/无限列表 |
useResizeObserver | 尺寸变化时的回调 | 重要的是元素的尺寸,而非可见性 |
useElementSize | { width, height } 状态 | 你只需要实时的宽高 |
useElementBounding | 完整的包围盒 rect | 你需要视口相对位置(滚动时会变) |
想看这些怎么组合的完整巡览,见 React 观察器 Hooks:监视 DOM 的 7 种方式。
要点回顾
- 一个
scroll监听器加getBoundingClientRect()是判断「这个在屏幕上吗」的错误工具——它折磨主线程,还是会漏掉滚动容器。IntersectionObserver正确地回答它:批量、在滚动路径之外。 useIntersectionObserver(target, callback, options?)把它接进 React:给它一个 ref、一个接收原始 entries 的回调,以及原生 options。它返回一个stop(),并在卸载时自动断开。- 它故意是基于回调的——你通过
entry.isIntersecting/entry.intersectionRatio来决定「可见」意味着什么。回调永远不会过期,所以它每次触发都读到新鲜的 props。 - 一次性的活儿(懒加载、只触发一次的埋点)就在回调里调
stop();重复触发的(无限滚动)就跳过它。 - 用
threshold(要露出多少)、rootMargin(提前/延后触发)和root(相对容器而非视口测量)来调。 - 只想要布尔值?
useElementVisibility返回[isVisible, stop]。两者都 SSR 安全。
从 @reactuses/core 取用,把你的 scroll 监听器样板删掉吧。