2026年5月13日

React Observer Hooks:7 种监听 DOM 而不写样板代码的方式

DOM 不会主动告诉 React 它变了。React 只掌控数据流的一个方向——state 进来,markup 出去——回程的路上基本是瞎的。如果第三方脚本插入了一个 banner、字体加载完成把布局往下推了 8 像素、用户调整了窗口大小或把一张卡片滚动进视口,React 根本不知道,除非你主动告诉它。浏览器为此提供了 4 个 *Observer API,再加上一次性读取用的 getBoundingClientRect 家族,它们几乎覆盖了真实应用里所有”对 DOM 做出反应”的需求。

麻烦在于:把 observer 接进 React 组件是个小型沼泽——useEffectuseRef、清理函数、SSR 守卫,还有那个臭名昭著的”observer 在挂载前就触发”的竞态。五行 API 变成三十行胶水,而且胶水代码在组件之间几乎一模一样——于是被复制粘贴、每次都稍微改一点,悄悄地积累 bug。ReactUse 提供了 7 个聚焦的 hooks,把胶水藏起来,把你真正想要的 API 表面还给你。

这篇文章会逐个介绍这 7 个 hook:各自观察什么、什么时候选哪个、如果你手写一遍会写成什么样。

1. useIntersectionObserver——“这个元素在屏幕里吗?”

IntersectionObserver 是现代懒加载的主力。它会在目标元素相对于视口(或滚动容器)越过某个阈值时报告,完全不需要老式 scroll 监听器那种连续触发的开销。懒加载图片、无限滚动触发器、用于埋点的”已浏览”追踪、进入视口时的淡入——都建在它之上。

手写版

import { useEffect, useRef, useState } from "react";

function ManualOnScreen({ 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);
      },
      { rootMargin: "0px", threshold: 0.1 },
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);

  return <div ref={ref}>{seen ? children : null}</div>;
}

能跑,于是你需要第二个懒加载块时就复制一份。到第五个组件你已经有五份微妙不同的 observer——三个用了错的 threshold,一个因为有人重构清理函数而漏了内存。形状是对的,重复是不对的。

ReactUse 版

useIntersectionObserver 接收 ref 和选项,返回元素当前是否相交:

import { useRef } from "react";
import { useIntersectionObserver } from "@reactuses/core";

function OnScreen({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  const isVisible = useIntersectionObserver(ref, {
    threshold: 0.1,
  });

  return <div ref={ref}>{isVisible ? children : null}</div>;
}

Hook 自己管理 observer 的生命周期:卸载时 disconnect、选项变化时重建、SSR 安全。懒加载图片、第一次进入视口时埋点、把一个重型图表延迟到滚动进来再挂载——都是同一个 hook,不同的布尔值。

一个常见模式是无限滚动的”加载更多”触发器:在列表底部放一个哨兵 <div>,它进入视口时发起 fetch。这其实正是 useInfiniteScroll 的实现方式,它就建在这个原语之上。

2. useElementVisibility——通常你想要的那个布尔值

很多时候你根本不在乎 IntersectionObserverEntry——你只要一个布尔值,而且是相对于整个视口的,不是某个滚动容器。useElementVisibility 就是干这个的。

import { useRef } from "react";
import { useElementVisibility } from "@reactuses/core";

function FadeInOnView({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  const visible = useElementVisibility(ref);

  return (
    <div
      ref={ref}
      className={`fade ${visible ? "fade-in" : ""}`}
    >
      {children}
    </div>
  );
}

用它做滚动淡入、“已浏览”埋点、“视频滚出屏幕时暂停”。如果需要更细粒度的控制——自定义 root、小于 1 的阈值、多阈值——再降级到 useIntersectionObserver

3. useResizeObserver——追踪尺寸的正确方式

差不多十年来,“在 React 里追踪元素尺寸”意味着挂一个 window.resize 监听器,每次事件都重新读 clientWidth。这漏掉了最常见的情况——元素因为父级变化、相邻元素折叠、或下方 flex 项变大而被动 resize。ResizeObserver 不管原因,只要被观察的元素尺寸变了就触发。

手写版

import { useEffect, useRef, useState } from "react";

function ManualSize() {
  const ref = useRef<HTMLDivElement>(null);
  const [size, setSize] = useState({ width: 0, height: 0 });

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const ro = new ResizeObserver((entries) => {
      const cr = entries[0].contentRect;
      setSize({ width: cr.width, height: cr.height });
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  return (
    <div ref={ref}>
      {size.width.toFixed(0)} × {size.height.toFixed(0)}
    </div>
  );
}

隐藏成本:每次 entry 更新都会调 setState,从而触发渲染。快速拖动父元素,被观察的组件每秒能 rerender 60 次。大多数时候没问题,但如果这个 state 被一棵昂贵的子树消费,你就得节流更新,或者把它写进 ref 而不是 state。

ReactUse 版

useResizeObserver 接收 ref 和一个对每个 entry 触发的回调:

import { useRef, useState } from "react";
import { useResizeObserver } from "@reactuses/core";

function ResponsiveCard() {
  const ref = useRef<HTMLDivElement>(null);
  const [variant, setVariant] = useState<"narrow" | "wide">("narrow");

  useResizeObserver(ref, ([entry]) => {
    setVariant(entry.contentRect.width > 600 ? "wide" : "narrow");
  });

  return <div ref={ref} data-variant={variant}>…</div>;
}

这就是 15 行代码实现的容器查询:卡片根据自己的宽度(不是视口宽度)在窄布局和宽布局之间切换。把两个并排放在一个 flex 行里,它们各自独立选自己的布局。

4. useElementSize 与 useMeasure——尺寸的两种口味

如果你只需要宽高,回调形式有点过度。ReactUse 提供了两个包装 ResizeObserver 并直接返回 state 的便利 hook。

useElementSize 返回被观察元素的 { width, height }

import { useRef } from "react";
import { useElementSize } from "@reactuses/core";

function AutoFitGrid({ items }: { items: Item[] }) {
  const ref = useRef<HTMLDivElement>(null);
  const { width } = useElementSize(ref);
  const columns = Math.max(1, Math.floor(width / 240));

  return (
    <div
      ref={ref}
      style={{
        display: "grid",
        gridTemplateColumns: `repeat(${columns}, 1fr)`,
        gap: 16,
      }}
    >
      {items.map((it) => <Card key={it.id} item={it} />)}
    </div>
  );
}

容器每次 resize,grid 重新计算列数——不需要媒体查询、不需要猜视口、也不需要 JS 控制的 CSS 变量。

useMeasure 返回完整的 ResizeObserverEntry.contentRectwidthheighttopleft 等),外加一个 ref 用来附着。当你一次调用就想拿到尺寸和局部坐标时用它:

import { useMeasure } from "@reactuses/core";

function TooltipAnchor() {
  const [ref, rect] = useMeasure<HTMLButtonElement>();
  return (
    <>
      <button ref={ref}>Hover me</button>
      <Tooltip x={rect.left + rect.width / 2} y={rect.top} />
    </>
  );
}

useElementSizeuseMeasure 的差别主要是人体工学——挑那个返回值形状已经匹配你组件需要的那个。

5. useElementBounding——位置加尺寸,同步更新

useElementBounding 是在每次 scroll 和 resize 时调用 el.getBoundingClientRect() 的响应式等价物。它返回 toprightbottomleftwidthheightxy——完整的矩形——只要元素由于任何原因移动或调整大小就重新触发。

import { useRef } from "react";
import { useElementBounding } from "@reactuses/core";

function StickyShadow() {
  const ref = useRef<HTMLDivElement>(null);
  const { top } = useElementBounding(ref);
  const stuck = top <= 0;

  return (
    <header
      ref={ref}
      className={stuck ? "header header--stuck" : "header"}
    >

    </header>
  );
}

一个 position: sticky 的页头滚到视口顶部时,它的 top 变成 0;hook 捕获到这个变化,给页头加阴影。同样的模式适用于:浮动操作按钮在离开初始位置后改变外观,或者需要在布局变化时持续追踪锚点的 popover。

useElementBoundinguseMeasure 的区别:bounding 是相对视口的矩形(滚动会改变它),measure 是元素自身的内容矩形(滚动不会改变)。关心位置选 bounding,关心尺寸选 measure。

6. useMutationObserver——当 DOM 在你周围变化时

MutationObserver 是 4 个 observer API 里最重的一个,也是合法用例最窄的一个。它在目标元素的属性、子节点或文本内容变化时触发。在一个 React 优先的应用里你几乎从不需要它——React 拥有这些变更,所以 React 当然知道。你需要 useMutationObserver 是当React 以外的东西在改 DOM 时:

  • 第三方组件(Stripe Elements、嵌入的视频播放器、聊天气泡)往一个槽位里塞内容。
  • 用户在编辑一个 contentEditable 元素,你想在不轮询的情况下响应文本变化。
  • 某个脚本在你控制不到的元素上切换 aria-expandeddata-state,你想把它镜像到 React state。
import { useRef, useState } from "react";
import { useMutationObserver } from "@reactuses/core";

function ThirdPartyMount({ slot }: { slot: string }) {
  const ref = useRef<HTMLDivElement>(null);
  const [ready, setReady] = useState(false);

  useMutationObserver(
    ref,
    (mutations) => {
      const injected = mutations.some(
        (m) => m.type === "childList" && m.addedNodes.length > 0,
      );
      if (injected) setReady(true);
    },
    { childList: true, subtree: true },
  );

  return (
    <div ref={ref} data-third-party={slot}>
      {!ready && <Skeleton />}
    </div>
  );
}

Skeleton 一直渲染,直到第三方脚本把内容放进槽位,然后消失。没有 MutationObserver 时,你的选项是 setInterval 轮询,或者 MutationObserver 加手写生命周期——前者浪费,后者正是这个 hook 帮你省掉的。

一个常见陷阱:MutationObserver 很快但不是免费的,在繁忙元素上一个未限定范围的子树观察者每秒可能触发几十次。永远传你能给的最窄选项——如果你只关心 childList,就别开 attributes: true

7. 怎么选

7 个 hook 有重叠,重叠是故意的——不同形状适合不同消费者。速查表:

你想要……Hook
表示”在不在屏幕上”的布尔值useElementVisibility
自定义 root 或阈值的可见性useIntersectionObserver
以 state 形式拿到宽高useElementSize
以 state 形式拿到完整内容矩形useMeasure
相对视口的矩形(滚动会变)useElementBounding
每次 resize entry 的回调useResizeObserver
响应 React 以外的 DOM 变化useMutationObserver

一个有用的心智模型:visibility 类 hook 告诉你元素相对用户在哪;size 和 bounding 类告诉你元素有多大在布局里的什么位置;mutation 告诉你元素里面发生了什么

实战示例:一个会自适应的懒加载卡片

把其中 4 个拼起来——一张卡片在滚动进入后才挂载昂贵的图表、根据自己的宽度选布局、并把 tooltip 定位在自己上方:

import { useRef, useState } from "react";
import {
  useElementVisibility,
  useElementSize,
  useElementBounding,
} from "@reactuses/core";

function LazyChartCard({ data }: { data: ChartData }) {
  const cardRef = useRef<HTMLDivElement>(null);
  const visible = useElementVisibility(cardRef);
  const { width } = useElementSize(cardRef);
  const { top, left } = useElementBounding(cardRef);

  const [hovered, setHovered] = useState(false);
  const layout = width > 600 ? "horizontal" : "vertical";

  return (
    <>
      <div
        ref={cardRef}
        data-layout={layout}
        className="card"
        onMouseEnter={() => setHovered(true)}
        onMouseLeave={() => setHovered(false)}
      >
        {visible ? <Chart data={data} /> : <Skeleton />}
      </div>
      {hovered && (
        <Tooltip
          x={left + width / 2}
          y={top - 8}
          text={`${data.label}: ${data.value}`}
        />
      )}
    </>
  );
}

图表只有在进入视口后才构造。卡片根据自己的宽度切换布局,而不是页面宽度。Tooltip 通过追踪卡片的 bounding 矩形漂浮在卡片上方,所以在滚动和布局抖动中都能保持锚定。三个 hook、二十行胶水代码、零个 useEffect 块、零个 addEventListener/removeEventListener 对。

性能须知

Observer 不是免费的,但开销集中且可控:

  • 每个元素一个 observer 没问题;千行列表每行一个 observer 不行。 列表虚拟化时,给滚动容器观察一次,在回调里解析哪一行可见。浏览器有时会合并多个 IntersectionObserver 目标,但一个长列表里每行一个 observer 依然伤性能。
  • useResizeObserver 回调跑在独立任务里。 在回调里读布局(getBoundingClientRectoffsetWidth)很便宜;写布局也可以,但要注意写操作可能再次触发 resize entry。用防抖或者把写操作放进 requestAnimationFrame 来防止反馈循环。
  • MutationObserver 是 4 个里最贵的,特别是配合 subtree: true。范围尽量收窄。如果你发现自己在观察一棵大子树,考虑一下让嵌入代码自己抛出一个”第三方就绪”事件是不是更便宜。

总结

Observer API 是连接”React 知道什么”和”DOM 实际在做什么”的桥梁。用裸 useEffect 接它们会积累很多胶水和一长串微妙 bug。用这 7 个 hook 接它们,它们就变成可以自由组合的一行调用。

更多 hook 在 reactuse.com——如果你用其中一个替换掉一段笨重的 useEffect 加 observer 舞蹈,那今天键盘没白敲。