2026年6月30日

React useIntersectionObserver Hook:懒加载与可见性检测(2026)

你想等一张图片快滚进视口时再加载它。或者在一张卡片真正被看到的第一时间上报一个埋点。又或者当用户滚到列表底部时触发「加载更多」。这些其实是同一个问题——这个元素进入屏幕了吗?——而多年来的答案,是一个一秒钟触发上百次的 scroll 监听器,每次都重新读一遍 getBoundingClientRect(),却还是会漏掉各种边界情况。

IntersectionObserver 就是正确回答这个问题的浏览器 API:异步、批量、跑在主线程之外。useIntersectionObserver 则是把它接进 React 的 hook——不用 useEffect/useRef/清理那一堆样板,也不会带上手写版本必然出现的卸载泄漏和过期闭包 bug。本文讲清楚真实的 @reactuses/core API、你真正会用到的三种模式,以及怎么调 thresholdrootMarginroot。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>;
}
  1. 忘了清理就会泄漏。return () => io.disconnect() 删掉——人们真的会删,尤其是重构的时候——observer 就会比组件活得还久。
  2. 它会捕获过期闭包。 一旦回调引用了某个 prop 或第二份 state,挂载时创建的 observer 就把它们冻结在了挂载那一刻的值上,而不是触发时的值。
  3. 它会扩散。 每个懒加载区块、每个「已浏览」追踪、每个无限滚动哨兵都在重写同一套 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 }} />}
    </>
  );
}

因为回调永远是最新的那个(没有过期闭包),loadMorehasMore 在哨兵每次相交时都被新鲜读取——咬住手写 useEffect 版本的那个 bug 在这里根本不存在。如果你想要打包好的整套模式,useInfiniteScroll 正是在这之上搭的,连滚动容器的管线都帮你接好了。

调参:threshold、rootMargin 和 root

第三个参数是原生的 IntersectionObserverInit,原样透传。三个旋钮,各自回答一个不同的问题:

useIntersectionObserver(ref, callback, {
  threshold: 0.5,        // 要露出多少才算数?
  rootMargin: '200px',   // 撑大/缩小触发边界
  root: containerRef.current, // 相对什么来测量?
});
  • threshold —— 一个从 01 的数字(或数组),表示目标必须露出多少回调才触发。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 监听器样板删掉吧。