陌上人如玉
公子世无双

1.14 DOM 测量与观察器

1.14 DOM 测量与观察器

一句话:在 React 里做测量有两个约束——「渲染阶段不能读 DOM」(否则破坏并发渲染的假设)与「读和写要分开」(混在一起会触发「强制同步布局」,也就是布局抖动);而「尺寸/可见性/结构/性能」四类变化分别由 ResizeObserver / IntersectionObserver / MutationObserver / PerformanceObserver 负责,都不要用 scroll/resize 事件硬扛。

一、测量的时机:useLayoutEffect 还是 useEffect

⭐⭐⭐ 三种「需要测量」的场景,对应三个不同的时机:

① 【需要在「浏览器绘制之前」用测量结果调整样式】
   例:Tooltip 定位、根据内容高度决定是否显示滚动条、虚拟列表首屏
   → 用 `useLayoutEffect`(在 DOM 变更后、浏览器绘制前同步执行)
   ⭐ 因为 `useEffect` 是「绘制之后」执行的 → 用它会「先看到错位再修正」(闪烁)
   ⚠️ 代价:`useLayoutEffect` 会**阻塞绘制**(同步执行),不要在里面做重活
   ⚠️ SSR 下会警告(服务端没有布局)→ 用 `useIsomorphicLayoutEffect` 兜底

② 【测量结果只用于「不影响首帧外观」的事】
   例:上报埋点、统计、非关键的懒加载预判
   → 用 `useEffect`(不阻塞绘制)

③ 【想「观测变化」而不是「测一次」】
   → 用 Observer(Resize/Intersection/...)+ 回调里更新状态
   ⭐ 这也应该在 `useLayoutEffect` 里注册(避免「注册前的第一帧没有观测」)
// ✅ SSR 安全的 useLayoutEffect
const useIsomorphicLayoutEffect =
  typeof window !== 'undefined' ? useLayoutEffect : useEffect;
// ❌ 常见错误:在渲染阶段读 DOM
function Bad() {
  const width = ref.current?.getBoundingClientRect().width;      // ❌ render 里读 DOM
  return 
{width}
; } // ⭐ 为什么错: // ① 首次渲染时 ref 还是 null(拿不到) // ② 并发渲染下,render 可能被「丢弃并重来」→ 读到的 DOM 与「这次渲染」不对应 // ③ 读 DOM 会「强制同步布局」→ 破坏「渲染可中断」的前提 // ✅ 在 useLayoutEffect 里读,写进 state(或用 ref + 直接改 style)

二、getBoundingClientRect 的四个坑

⭐⭐⭐ 它看起来最简单,但坑最多:

① 【它触发「强制同步布局(forced synchronous layout)」】
   浏览器平时会把「样式变更」攒起来批量处理(避免反复重排)。
   一旦你「读」布局属性(offsetTop/scrollTop/getBoundingClientRect/clientWidth...),
   ⚠️ 浏览器必须「立刻把所有待处理的变更算完」才能给你准确值。
   → ⭐ 在循环里交替「读-写-读-写」= 每次循环都重排一次(layout thrashing)

② 【返回的是「视口坐标」,不是「文档坐标」】
   `rect.top` 是「相对视口顶部」的(滚动会改变它)。
   要「文档坐标」得加滚动量:`rect.top + window.scrollY`
   ⭐ 这也是为什么「浮层用 position: fixed + 视口坐标」是自洽的(见 11.15)

③ 【`transform` 会体现在里面(但 `zoom` 之类不一定)】
   元素被 `transform: translateX(50px)` 后,`getBoundingClientRect().left` **会**包含这 50px
   ⭐ 所以「测量后再加 transform」要小心「基于已变换的位置再变换」
   ⚠️ 而且 `transform` 是「视觉位移」,不影响「布局位置」→
     与 `offsetLeft`(布局位置)可能不一致

④ 【返回的是「小数」且可能「四舍五入不一致」】
   `rect.width` 可能是 320.5;不同浏览器/DPR 下取整规则不同
   → ⭐ 比较尺寸时用「容差」而不是 `===`
// ✅ 「批量读 → 批量写」的经典模式(避免布局抖动)
// ❌ 反例
for (const el of elements) {
  const h = el.offsetHeight;          // 读(强制布局)
  el.style.height = h + 10 + 'px';    // 写(使布局失效)
}                                     // → 每次循环都重排

// ✅ 正例:先全部读,再全部写
const heights = elements.map((el) => el.offsetHeight);       // 一批读
elements.forEach((el, i) => { el.style.height = heights[i]! + 10 + 'px'; });   // 一批写

// ✅ 更稳的:把「写」放到下一帧
function batchRead(reads: Array<() => number>): number[] {
  return reads.map((read) => read());        // 同一「读批次」
}
function scheduleWrite(write: () => void) {
  requestAnimationFrame(write);              // 在「下一帧的写阶段」执行
}

三、四个观察器:各自的职责与坑

3.1 ResizeObserver:元素尺寸变化

const ro = new ResizeObserver((entries) => {
  for (const entry of entries) {
    // ⭐ 两种拿尺寸的方式,用途不同:
    // ① contentRect:内容盒(不含 padding/border,但「不含滚动条」)
    const { width, height } = entry.contentRect;

    // ② borderBoxSize / contentBoxSize:数组(支持「一个元素多个盒子」的极端情况)
    const borderBox = entry.borderBoxSize?.[0];
    console.log(borderBox?.inlineSize, borderBox?.blockSize);

    // ⭐ target 是「被观察的元素」
    console.log(entry.target);
  }
});

ro.observe(el);                    // 开始观察
// ro.observe(el, { box: 'border-box' });   // ⭐ 指定「观察哪个盒子」:content-box(默认) | border-box | device-pixel-content-box
ro.unobserve(el);                  // 停止观察某个元素
ro.disconnect();                   // 全部停止
⭐⭐ `ResizeObserver` 的三个「必须知道」:

① 【「循环未送达通知」错误】
   控制台常见:
     "ResizeObserver loop completed with undelivered notifications."
   ⭐ 原因:在 RO 回调里「改了尺寸」→ 又触发 RO → 浏览器在「同一帧内」处理不完。
   ✅ 解法:
     · 把「改尺寸」的操作放进 `requestAnimationFrame`(推到下一帧)
     · 或者「值没变就不 setState」(消除无意义的重渲染)
     ⚠️ 这个错误通常**无害**(浏览器会重试),但会污染监控 → 建议在错误上报里忽略它

② 【它「不含滚动条」也不含「被 transform 缩放」】
   `contentRect` 是「布局尺寸」——所以「CSS transform 放大 2 倍」**不会**改变它
   ⭐ 需要「视觉尺寸」就用 `getBoundingClientRect()`(它含 transform)

③ 【首次 observe 一定会触发一次回调】
   ⭐ 这很方便(不用自己手动测一次),但要注意「初始值不是 0 而是真实尺寸」
   → 所以「用 0 表示未测量」的写法是错的(初始就是真实值)

3.2 IntersectionObserver:可见性(用它代替 scroll 事件)

const io = new IntersectionObserver(
  (entries) => {
    for (const entry of entries) {
      entry.isIntersecting;            // ⭐ 是否「相交」(可见)
      entry.intersectionRatio;         // 相交比例(0~1)
      entry.boundingClientRect;        // 目标元素的矩形
      entry.intersectionRect;          // 相交区域的矩形
      entry.rootBounds;                // 根(视口/指定容器)的矩形
      entry.target;                    // 目标元素
    }
  },
  {
    root: null,                        // ⭐ 默认是「视口」;也可以传「某个滚动容器」
    rootMargin: '0px 0px -10% 0px',    // ⭐ 类似 CSS margin:扩大/缩小「根」的判定区域
    threshold: [0, 0.25, 0.5, 1],      // ⭐ 相交比例每越过一个阈值就回调
  }
);

io.observe(el);
io.unobserve(el);
io.disconnect();
⭐⭐⭐ 为什么「用 IO 而不是 scroll 事件」(这是最该记住的一条):

   【scroll 事件的问题】
     · 每滚一点就触发(高频)→ 必须自己节流
     · 回调里读 `getBoundingClientRect()` → **强制同步布局**(每次滚动都重排)
     · 不与「渲染帧」对齐 → 容易抖动
     · 内部滚动容器要靠「监听每一个祖先」才能覆盖(或用 capture)

   【IntersectionObserver 的好处】
     · ⭐ **由浏览器在「合适的时机」批量回调**(不在滚动关键路径上)
     · ⭐ 不需要读布局属性(浏览器直接告诉你「相交了没」)
     · ⭐ 天然支持「任意滚动容器」(传 `root` 即可)+ 「视口裁剪」
   → ⭐ 结论:「懒加载、曝光埋点、进入视口播放动画、无限滚动」
     都应该用 IO(见 5.5、11.8、5.17)。

⭐⭐ 三个「IO 的注意事项」:
   ① 【`observe()` 后总会有一次初始回调】
      哪怕元素当前不可见(`isIntersecting: false`)→
      ⭐ 所以「首次回调」不能当作「已可见」
   ② 【`threshold` 是「相交比例的阈值」,不是「元素露出多少像素」】
      `threshold: 1` 只在「完全可见」时触发;用 `rootMargin` 调「提前/延后多少开始算」
   ③ 【零尺寸元素】宽高为 0 的元素 `intersectionRatio` 会异常(要么 0 要么 1)
      → 懒加载图片「占位撑高」不只是为了 CLS,也是为了 IO 判定正常

3.3 MutationObserver:DOM 结构变化(用于「与非 React 的代码共存」)

const mo = new MutationObserver((records) => {
  for (const record of records) {
    record.type;                  // 'childList' | 'attributes' | 'characterData'
    record.target;                // 变化的节点
    record.addedNodes;            // 新增的节点(childList)
    record.removedNodes;          // 移除的节点(childList)
    record.attributeName;         // 属性名(attributes)
    record.oldValue;              // 旧值(需要 attributeOldValue/characterDataOldValue: true)
  }
});

mo.observe(container, {
  childList: true,                // 观察子节点增删
  subtree: true,                  // ⭐ 也要观察后代
  attributes: true,
  attributeFilter: ['class', 'style'],   // ⭐ 只关心这几个属性(性能)
  characterData: true,
  attributeOldValue: true,
});
⭐⭐ `MutationObserver` 在 React 项目里的真实用途(它的定位很特殊):

   ✅ 【与非 React 的第三方代码/浏览器扩展/富文本编辑器共存】
      · 富文本编辑器(Monaco/Quill)会自己改 DOM → 用它同步到 React 状态
      · 浏览器扩展/翻译插件往页面里插东西 → 用它清理或忽略
      · 第三方 SDK 动态插入广告/客服按钮 → 用它调整布局

   ❌ 【不要用它「驱动 React 渲染」】
      它回调 → setState → 渲染 → 改 DOM → 又触发它 → 循环
      ⭐ 如果一定要用,必须「加变化来源标记」避免自触发

   ⚠️ 【时机】回调在「当前任务结束后的微任务」里执行(不是同步),
      而且浏览器会「批量合并」多次变化 → 不要假设「一次变化一个回调」

   ⭐ 【更常见的用法:调试】
      在开发期用它观察「谁动了这个 DOM 节点」(打调试点),
      可以快速定位「某个第三方脚本在改我的结构」。
// ✅ 「谁改了我的 DOM」——一个实用的开发期侦探
export function watchWhoMutates(el: Element) {
  if (process.env.NODE_ENV === 'production') return () => {};

  const mo = new MutationObserver((records) => {
    for (const r of records) {
      console.groupCollapsed(
        `[dom-spy] ${r.type} on`,
        r.target,
        r.type === 'attributes' ? `属性 ${r.attributeName}: ${r.oldValue} → ${(r.target as Element).getAttribute?.(r.attributeName!)}` : ''
      );
      console.trace('调用栈');           // ⭐ 直接指出「是谁在改」
      console.groupEnd();
    }
  });

  mo.observe(el, { childList: true, subtree: true, attributes: true, attributeOldValue: true, characterData: true });
  return () => mo.disconnect();
}

3.4 PerformanceObserver:性能条目

// ⭐ 完整内容见 5.11(RUM 与 Web Vitals 归因),这里只列「有哪些 entry 类型」
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    entry.entryType;      // 'longtask' | 'largest-contentful-paint' | 'layout-shift' | ...
  }
}).observe({ type: 'longtask', buffered: true });

// 常见类型:
//   'navigation'                 → TTFB、DOMContentLoaded、load
//   'resource'                   → 每个资源的加载耗时
//   'longtask'                   → 超过 50ms 的任务(⭐ 卡顿元凶)
//   'event'                      → INP 相关(含 interactionId)
//   'largest-contentful-paint'   → LCP
//   'layout-shift'               → CLS
//   'paint'                      → FCP
//   'long-animation-frame'       → LoAF(更细粒度的长帧归因,Chrome 123+)

四、完整实现:一个「测量与观察」工具集(230 行)

// dom-hooks.tsx
import { useState, useRef, useEffect, useLayoutEffect, useCallback, useEffect as useE } from 'react';

/** SSR 安全的 layout effect */
const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;

/** ============ ① useMeasure:观察元素尺寸(含「避免 RO 循环」的处理) ============ */
export interface ElementSize {
  width: number;
  height: number;
  /** 内容盒(contentRect)尺寸 */
  contentWidth: number;
  contentHeight: number;
  /** 是否已经测量过(首次测量完成) */
  measured: boolean;
}

export function useMeasure(options: { box?: ResizeObserverBoxOptions } = {}) {
  const ref = useRef(null);
  const [size, setSize] = useState({
    width: 0, height: 0, contentWidth: 0, contentHeight: 0, measured: false,
  });

  const lastRef = useRef(null);

  useIsoLayoutEffect(() => {
    const el = ref.current;
    if (!el) return;

    /** ⭐ 只在「值真的变了」时才 setState(避免 RO 循环 + 无意义渲染) */
    const apply = (next: ElementSize) => {
      const last = lastRef.current;
      if (
        last &&
        Math.abs(last.width - next.width) < 0.5 &&
        Math.abs(last.height - next.height) < 0.5 &&
        Math.abs(last.contentWidth - next.contentWidth) < 0.5 &&
        Math.abs(last.contentHeight - next.contentHeight) < 0.5
      ) {
        return;                                  // ⭐ 容差比较(避免亚像素抖动)
      }
      lastRef.current = next;
      setSize(next);
    };

    // ⭐ 先同步测一次(避免「首帧尺寸为 0」导致闪烁)
    const rect = el.getBoundingClientRect();
    const initial: ElementSize = {
      width: rect.width,
      height: rect.height,
      contentWidth: el.clientWidth,
      contentHeight: el.clientHeight,
      measured: true,
    };
    lastRef.current = initial;
    setSize(initial);

    const ro = new ResizeObserver((entries) => {
      for (const entry of entries) {
        const borderBox = entry.borderBoxSize?.[0];
        const width = borderBox ? borderBox.inlineSize : entry.contentRect.width;
        const height = borderBox ? borderBox.blockSize : entry.contentRect.height;

        // ⭐ 用 rAF 推迟写入,规避 "ResizeObserver loop" 告警
        requestAnimationFrame(() => {
          apply({
            width,
            height,
            contentWidth: entry.contentRect.width,
            contentHeight: entry.contentRect.height,
            measured: true,
          });
        });
      }
    });

    ro.observe(el, options.box ? { box: options.box } : undefined);
    return () => ro.disconnect();
  }, [options.box]);

  return { ref, size };
}
/** ============ ② useInView:元素是否进入视口(IO) ============ */
export function useInView({
  root = null as Element | null,
  rootMargin = '0px',
  threshold = 0,
  /** 只触发一次(之后自动断开) */
  once = false,
}: {
  root?: Element | null;
  rootMargin?: string;
  threshold?: number | number[];
  once?: boolean;
} = {}) {
  const ref = useRef(null);
  const [inView, setInView] = useState(false);
  const [entry, setEntry] = useState(null);

  useIsoLayoutEffect(() => {
    const el = ref.current;
    if (!el) return;

    const io = new IntersectionObserver(
      (entries) => {
        const e = entries[entries.length - 1];
        if (!e) return;
        setInView(e.isIntersecting);
        setEntry(e);
        if (once && e.isIntersecting) io.disconnect();
      },
      { root, rootMargin, threshold }
    );

    io.observe(el);
    return () => io.disconnect();
  }, [root, rootMargin, once, JSON.stringify(threshold)]);   // ⚠️ 数组阈值要序列化比较

  return { ref, inView, entry };
}

/** ============ ③ useBatchMeasure:批量读 → 批量写(避免布局抖动) ============ */
export function useBatchMeasure() {
  const queueRef = useRef void }>>([]);
  const rafRef = useRef(0);

  const flush = useCallback(() => {
    rafRef.current = 0;
    const queue = queueRef.current;
    queueRef.current = [];

    // ⭐ ① 一次性把「所有读」做完(此时还没有任何写)
    const rects = queue.map((job) => job.el.getBoundingClientRect());

    // ⭐ ② 再统一写(写不会触发「新的强制布局」)
    queue.forEach((job, i) => job.apply(rects[i]!));
  }, []);

  const measure = useCallback(
    (el: Element | null, apply: (rect: DOMRect) => void) => {
      if (!el) return;
      queueRef.current.push({ el, apply });
      if (!rafRef.current) rafRef.current = requestAnimationFrame(flush);
    },
    [flush]
  );

  useEffect(() => () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }, []);
  return measure;
}
/** ============ ④ useDomSpy:谁在改这个节点(开发期) ============ */
export function useDomSpy(enabled = process.env.NODE_ENV !== 'production') {
  const ref = useRef(null);

  useEffect(() => {
    const el = ref.current;
    if (!el || !enabled) return;

    const mo = new MutationObserver((records) => {
      for (const r of records) {
        const what =
          r.type === 'attributes'
            ? `属性 ${r.attributeName}`
            : r.type === 'characterData'
              ? '文本内容'
              : `子节点(+${r.addedNodes.length}/-${r.removedNodes.length})`;
        console.warn(`[dom-spy] ${what} 被修改:`, r.target);
      }
    });

    mo.observe(el, {
      childList: true, subtree: true,
      attributes: true, attributeOldValue: true,
      characterData: true,
    });
    return () => mo.disconnect();
  }, [enabled]);

  return ref;
}
/** ============ ⑤ useStickyLayout:一个「观测 + 布局修正」的完整场景 ============ */
/**
 * 场景:一个「吸顶头部」——测量头部高度,把它写到 CSS 变量上,
 * 供「内容区 padding-top」使用(避免内容被吸顶头遮住)。
 * ⭐ 用 useLayoutEffect + ResizeObserver(尺寸变化时要跟着更新)
 */
export function StickyHeader({ children, offset = 0 }: { children: React.ReactNode; offset?: number }) {
  const { ref, size } = useMeasure();

  // ⭐ 把测量结果写到 CSS 变量(而不是「用 state 改 style」——少一次渲染)
  useIsoLayoutEffect(() => {
    if (!size.measured) return;
    document.documentElement.style.setProperty('--sticky-header-height', `${size.height}px`);
    return () => {
      document.documentElement.style.removeProperty('--sticky-header-height');
    };
  }, [size.height, size.measured]);

  return (
    <>
      
{children}
{/* ⭐ 用 CSS 变量做占位(高度变了自动跟随,不需要额外渲染) */}
); }
/** ============ 使用示例 ============ */
export function Demo() {
  const { ref, size } = useMeasure();
  const { ref: viewRef, inView, entry } = useInView({ rootMargin: '0px 0px -20% 0px', threshold: 0.5 });

  return (
    
我是吸顶头
{/* ⭐ 拖动右下角改变尺寸,观察 useMeasure 的输出 */} 可调整尺寸的盒子 宽 {size.width.toFixed(1)} × 高 {size.height.toFixed(1)} 内容盒 {size.contentWidth} × {size.contentHeight}
{inView ? '我进入视口了' : '我还在视口外'} {entry && (相交比例 {entry.intersectionRatio.toFixed(2)})}
); }

五、本篇特有的坑

// ① 在渲染阶段读 DOM
const w = ref.current?.offsetWidth;                            // ❌ render 里读
// ✅ useLayoutEffect(需要「绘制前」)或 useEffect(不阻塞绘制)

// ② 「读-写」交替造成的布局抖动
els.forEach((el) => { el.style.width = el.offsetWidth + 'px'; });   // ❌ 每次都强制布局
// ✅ 先批量读、再批量写(见 useBatchMeasure)

// ③ 用 `useEffect` 做「需要绘制前完成」的测量(闪一下)
useEffect(() => setPos(measure()), []);                        // ⚠️ 会先画出错位的一帧
// ✅ useLayoutEffect

// ④ `getBoundingClientRect()` 当成「文档坐标」
const top = el.getBoundingClientRect().top;                     // ⚠️ 这是视口坐标
const docTop = top + window.scrollY;                            // ✅ 文档坐标

// ⑤ 以为 `transform` 不影响 `getBoundingClientRect`
// ⭐ 它包含 transform 的位移/缩放(所以「先变换再测量」会得到变换后的值)

// ⑥ ResizeObserver 里直接改会引发尺寸变化的样式(loop 告警)
const ro = new ResizeObserver(() => { el.style.height = `${el.scrollHeight}px`; });   // ⚠️
// ✅ 放进 requestAnimationFrame + 「值没变不写」

// ⑦ 忘了断开观察器(内存泄漏 + 卸载后回调)
ro.observe(el);                                                // ❌ 没有 cleanup
// ✅ useEffect 里 return () => ro.disconnect()

// ⑧ 用 `scroll` 事件做「是否进入视口」(高频 + 强制布局)
// ✅ IntersectionObserver

// ⑨ 用 `resize` 事件做「元素尺寸变化」
window.addEventListener('resize', ...);                        // ⚠️ 只对「窗口」有效
// ✅ ResizeObserver(元素尺寸)

// ⑩ 用 `MutationObserver` 驱动 React 渲染(无限循环)
mo = new MutationObserver(() => setState(...));                 // ❌ 可能自触发
// ✅ 只用于「与非 React 代码同步」,并加「来源标记」

// ⑪ 以为 IO 的「首次回调」表示「已可见」
const io = new IntersectionObserver(([e]) => setInView(e.isIntersecting));
// ⭐ 首次回调总会触发(哪怕元素不可见)→ 「初始为 false」的写法通常是对的,
//    但「用首次回调当作『已进入』」是错的

// ⑫ 用 `threshold: 1` 做「懒加载」(几乎不触发)
// ⚠️ 元素比视口高时永远到不了 1
// ✅ 「懒加载」用默认 threshold: 0 + rootMargin 提前量

// ⑬ IO 的 `root` 写错(写成「元素的父元素」而不是「滚动容器」)
// ⚠️ root 必须是「产生滚动的那个容器」;null 表示视口
// ✅ 内部滚动列表:root = 滚动容器本身(或干脆用默认视口 + 正确 rootMargin)

// ⑭ 数组型 `threshold` 放进依赖数组(每次新数组 → 反复重建观察器)
}, [threshold]);                                                // ⚠️ 每次渲染都变
// ✅ 序列化(`JSON.stringify(threshold)`)或用「稳定引用」

// ⑮ 在 Observer 回调里「同步改布局」(强制同步布局)
const io = new IntersectionObserver(([e]) => { document.body.style.height = e.boundingClientRect.height + 'px'; });
// ⚠️ 回调里读写的值已是「上一帧的快照」,再同步改布局容易造成抖动
// ✅ 把「写」放 rAF

// ⑯ 忘了「元素被卸载后」观察器还在(回调里 setState 到已卸载组件)
// ⭐ React 18 起「卸载后 setState」不再警告,但仍是浪费
// ✅ cleanup 里 disconnect

// ⑰ 用 `clientHeight` / `offsetHeight` 但不知道区别
// · `clientHeight`:内容 + padding(不含 border/滚动条)
// · `offsetHeight`:内容 + padding + border + 滚动条
// · `scrollHeight`:内容总高度(含溢出部分)
// · `getBoundingClientRect().height`:视觉尺寸(含 transform)
// ⭐ 选错会得到「差几个像素」的诡异结果

// ⑱ SSR 下用 `useLayoutEffect`(警告)
// ✅ useIsomorphicLayoutEffect

// ⑲ 依赖 `entry.boundingClientRect` 做「实时位置」(它是快照)
// ⚠️ 它只在「回调触发的那一刻」准确
// ✅ 需要实时位置就在事件/rAF 里读

// ⑳ 「测量 → setState → 渲染改变尺寸 → 再测量」形成循环
// ⚠️ 尺寸稳定后应停止;不稳定说明「你的样式依赖了测量结果,而测量结果又依赖样式」
// ✅ 加「容差 + 最大迭代次数」保护,或改用 CSS 原生方案(如 aspect-ratio / fit-content)
// ⑳ 的完整处理(带保护的「自适应高度」测量)
function useFitHeight(maxIterations = 3) {
  const ref = useRef(null);
  const { ref: measureRef, size } = useMeasure();
  const [height, setHeight] = useState(null);
  const iterationsRef = useRef(0);

  useIsoLayoutEffect(() => {
    if (!size.measured) return;
    if (iterationsRef.current >= maxIterations) return;

    setHeight((prev) => {
      // ⭐ 容差保护:变化小于 1px 就不再迭代
      if (prev !== null && Math.abs(prev - size.height) < 1) return prev;
      iterationsRef.current++;
      return size.height;
    });
  }, [size.height, size.measured, maxIterations]);

  return { ref: ref as never, measureRef, height };
}

六、面试延伸

  1. 「在 React 里怎么正确测量 DOM?」

三条纪律:① 不在渲染阶段读 DOM(首次渲染 ref 为 null;并发下 render 可能被丢弃重来;读布局会强制同步布局,破坏可中断渲染的前提);② 选择正确的时机——需要「浏览器绘制之前用测量结果调整样式」(避免闪烁)就用 useLayoutEffect(如 Tooltip 定位、虚拟列表首屏),只用于「不影响首帧外观」的事(埋点、统计)就用 useEffect;③ ⭐ 读写分离——「读布局属性」会强制浏览器立刻处理所有待定样式变更,所以在循环里「读-写-读-写」会导致每次循环都重排一次(layout thrashing),正确做法是「先批量读、再批量写」(或把写推到下一帧的 rAF)。

  1. 「getBoundingClientRect 有哪些坑?」

四个:① 它触发强制同步布局(在循环里用会造成布局抖动);② 返回的是「视口坐标」——要文档坐标得加 window.scrollY(⭐ 这也解释了为什么「浮层用 position: fixed + 视口坐标」是自洽的);③ 它包含 transform 的位移/缩放——所以「先变换再测量」会得到变换后的值(而 offsetLeft 是布局位置,两者可能不一致);④ 返回小数,不同浏览器/DPR 下取整不一致 → 比较尺寸要用容差(Math.abs(a - b) < 0.5)。

  1. 「ResizeObserver 的『loop』告警是什么?怎么解决?」

告警是 "ResizeObserver loop completed with undelivered notifications"。原因是「在 RO 回调里又改变了元素的尺寸」——于是又触发 RO,浏览器在同一帧内处理不完,于是报告「还有通知没送达」。解法:① 把「改尺寸」的操作推迟到 requestAnimationFrame(下一帧执行);② ⭐ 「值没变就不 setState」(用容差比较消除无意义的重渲染);③ 如果只是「(无害)告警」,可以在错误上报里忽略它。另外要注意:contentRect 是「布局尺寸」(不含 transform 缩放、不含滚动条)→ 需要视觉尺寸就用 getBoundingClientRect();而且 observe() 后一定会触发一次回调(所以「初始值是 0」的假设是错的)。

  1. 「为什么用 IntersectionObserver 而不是 scroll 事件?」

因为 scroll 事件有三个致命问题:① 每滚一点就触发(高频,必须自己节流);② 回调里通常要读 getBoundingClientRect() → 强制同步布局(每次滚动都重排);③ 不与渲染帧对齐(容易抖动),且内部滚动容器要靠监听每个祖先才能覆盖。而 IntersectionObserver:⭐ 由浏览器在合适的时机批量回调(不在滚动关键路径上)、不需要读布局属性(浏览器直接告诉你「相交了没」)、天然支持任意滚动容器(传 root)。所以「懒加载、曝光埋点、进入视口播放动画、无限滚动」都应该用它(见 5.5、11.8、5.17)。

  1. 「IntersectionObserver 有哪些容易用错的地方?」

四点:① observe() 后总会有一次初始回调(哪怕元素当前不可见,isIntersecting: false)——所以「用首次回调当作『已可见』」是错的;② threshold 是「相交比例的阈值」,不是「露出多少像素」——❌ 用 threshold: 1 做懒加载(元素比视口高时永远到不了 1),✅ 懒加载用默认 threshold: 0 + rootMargin 提前量;③ root 必须是「产生滚动的那个容器」(null 表示视口)——写成「元素的父元素」是常见错误;④ 数组型 threshold 放进依赖数组会导致「每次渲染都重建观察器」(✅ 序列化比较或用稳定引用)。另外「零尺寸元素」的 intersectionRatio 会异常——这也是「图片占位撑高」的另一个理由。

  1. 「MutationObserver 在 React 项目里有什么用?」

它的定位很特殊:用于「与非 React 的代码共存」,而不是「驱动 React 渲染」。真实用途:① 富文本编辑器/图表库自己改 DOM → 用它同步回 React 状态;② 浏览器扩展、翻译插件、第三方 SDK(广告/客服)往页面插东西 → 用它清理或调整布局;③ ⭐ 开发期调试——观察「谁动了这个 DOM 节点」(在回调里打 console.trace 可以直接指出调用栈)。⚠️ 千万别用它驱动渲染(回调 setState → 渲染 → 改 DOM → 又触发它 → 循环);时机上它的回调在「当前任务结束后的微任务」执行,且浏览器会批量合并多次变化(不要假设「一次变化一个回调」)。

  1. 「『布局抖动(layout thrashing)』是什么?怎么避免?」

浏览器平时把「样式变更」攒起来批量处理(避免反复重排)。但一旦你读布局属性(offsetTop/scrollTop/getBoundingClientRect/clientWidth…),浏览器必须立刻把所有待处理的变更算完才能给你准确值(这就是「强制同步布局」)。于是「读-写-读-写」的循环会让每一次循环都触发一次重排。避免方式:① ⭐ 批量读、批量写(先 map 出所有读数,再统一写);② 把写推到 requestAnimationFrame;③ 需要频繁读值(如拖拽)时缓存读数(pointerdown 时测一次);④ 用 transform 做位移(不触发重排,见 5.17)。

  1. 「clientHeight/offsetHeight/scrollHeight/getBoundingClientRect 有什么区别?」

四个值对应四个不同的盒子/语义:① clientHeight = 内容 + padding(不含 border 与滚动条);② offsetHeight = 内容 + padding + border + 滚动条(整数);③ scrollHeight = 内容的总高度(含溢出的部分,即使不可见);④ getBoundingClientRect().height = 视觉尺寸(含 transform 的缩放/位移,小数)。⭐ 选错就得到「差几个像素」的诡异结果,典型场景:① 「判断是否滚动到底」用 scrollTop + clientHeight >= scrollHeight - 1(用 offsetHeight 会被滚动条宽度干扰);② 「测量内容真实高度」用 scrollHeight;③ 「浮层定位」用 getBoundingClientRect(视口坐标 + 含 transform)。

一句话速记

测量的三条纪律:渲染阶段不读 DOM、需要「绘制前」用结果就用 useLayoutEffect(否则 useEffect)、读写分离(批量读 → 批量写,或把写推到 rAF),因为「读布局属性」会强制同步布局,在循环里「读-写-读-写」就是布局抖动;getBoundingClientRect 四个坑:触发强制布局、返回视口坐标(文档坐标要 + scrollY)、包含 transform、返回小数(比较要容差);四个观察器各司其职——ResizeObserver(元素尺寸,注意「loop 告警」要用 rAF + 「值没变不 setState」规避,contentRect 不含 transform,首次必回调)、IntersectionObserver(可见性,⭐ 替代 scroll 事件:浏览器批量回调、不读布局、支持任意 root;注意「首次回调总会触发」「threshold 是比例不是像素」「root 必须是滚动容器」)、MutationObserver(只用于与非 React 代码共存/调试,别驱动渲染)、PerformanceObserver(性能条目,见 5.11);最后:clientHeight(内容+padding)/offsetHeight(+border+滚动条)/scrollHeight(内容总高)/getBoundingClientRect(视觉尺寸含 transform)用错就差几个像素。

赞(0) 打赏
未经允许不得转载:陌上寒 » 1.14 DOM 测量与观察器

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫