
一句话:在 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 };
}
六、面试延伸
- 「在 React 里怎么正确测量 DOM?」
三条纪律:① 不在渲染阶段读 DOM(首次渲染 ref 为 null;并发下 render 可能被丢弃重来;读布局会强制同步布局,破坏可中断渲染的前提);② 选择正确的时机——需要「浏览器绘制之前用测量结果调整样式」(避免闪烁)就用 useLayoutEffect(如 Tooltip 定位、虚拟列表首屏),只用于「不影响首帧外观」的事(埋点、统计)就用 useEffect;③ ⭐ 读写分离——「读布局属性」会强制浏览器立刻处理所有待定样式变更,所以在循环里「读-写-读-写」会导致每次循环都重排一次(layout thrashing),正确做法是「先批量读、再批量写」(或把写推到下一帧的 rAF)。
- 「
getBoundingClientRect有哪些坑?」
四个:① 它触发强制同步布局(在循环里用会造成布局抖动);② 返回的是「视口坐标」——要文档坐标得加 window.scrollY(⭐ 这也解释了为什么「浮层用 position: fixed + 视口坐标」是自洽的);③ 它包含 transform 的位移/缩放——所以「先变换再测量」会得到变换后的值(而 offsetLeft 是布局位置,两者可能不一致);④ 返回小数,不同浏览器/DPR 下取整不一致 → 比较尺寸要用容差(Math.abs(a - b) < 0.5)。
- 「
ResizeObserver的『loop』告警是什么?怎么解决?」
告警是 "ResizeObserver loop completed with undelivered notifications"。原因是「在 RO 回调里又改变了元素的尺寸」——于是又触发 RO,浏览器在同一帧内处理不完,于是报告「还有通知没送达」。解法:① 把「改尺寸」的操作推迟到 requestAnimationFrame(下一帧执行);② ⭐ 「值没变就不 setState」(用容差比较消除无意义的重渲染);③ 如果只是「(无害)告警」,可以在错误上报里忽略它。另外要注意:contentRect 是「布局尺寸」(不含 transform 缩放、不含滚动条)→ 需要视觉尺寸就用 getBoundingClientRect();而且 observe() 后一定会触发一次回调(所以「初始值是 0」的假设是错的)。
- 「为什么用
IntersectionObserver而不是scroll事件?」
因为 scroll 事件有三个致命问题:① 每滚一点就触发(高频,必须自己节流);② 回调里通常要读 getBoundingClientRect() → 强制同步布局(每次滚动都重排);③ 不与渲染帧对齐(容易抖动),且内部滚动容器要靠监听每个祖先才能覆盖。而 IntersectionObserver:⭐ 由浏览器在合适的时机批量回调(不在滚动关键路径上)、不需要读布局属性(浏览器直接告诉你「相交了没」)、天然支持任意滚动容器(传 root)。所以「懒加载、曝光埋点、进入视口播放动画、无限滚动」都应该用它(见 5.5、11.8、5.17)。
- 「
IntersectionObserver有哪些容易用错的地方?」
四点:① observe() 后总会有一次初始回调(哪怕元素当前不可见,isIntersecting: false)——所以「用首次回调当作『已可见』」是错的;② threshold 是「相交比例的阈值」,不是「露出多少像素」——❌ 用 threshold: 1 做懒加载(元素比视口高时永远到不了 1),✅ 懒加载用默认 threshold: 0 + rootMargin 提前量;③ root 必须是「产生滚动的那个容器」(null 表示视口)——写成「元素的父元素」是常见错误;④ 数组型 threshold 放进依赖数组会导致「每次渲染都重建观察器」(✅ 序列化比较或用稳定引用)。另外「零尺寸元素」的 intersectionRatio 会异常——这也是「图片占位撑高」的另一个理由。
- 「
MutationObserver在 React 项目里有什么用?」
它的定位很特殊:用于「与非 React 的代码共存」,而不是「驱动 React 渲染」。真实用途:① 富文本编辑器/图表库自己改 DOM → 用它同步回 React 状态;② 浏览器扩展、翻译插件、第三方 SDK(广告/客服)往页面插东西 → 用它清理或调整布局;③ ⭐ 开发期调试——观察「谁动了这个 DOM 节点」(在回调里打 console.trace 可以直接指出调用栈)。⚠️ 千万别用它驱动渲染(回调 setState → 渲染 → 改 DOM → 又触发它 → 循环);时机上它的回调在「当前任务结束后的微任务」执行,且浏览器会批量合并多次变化(不要假设「一次变化一个回调」)。
- 「『布局抖动(layout thrashing)』是什么?怎么避免?」
浏览器平时把「样式变更」攒起来批量处理(避免反复重排)。但一旦你读布局属性(offsetTop/scrollTop/getBoundingClientRect/clientWidth…),浏览器必须立刻把所有待处理的变更算完才能给你准确值(这就是「强制同步布局」)。于是「读-写-读-写」的循环会让每一次循环都触发一次重排。避免方式:① ⭐ 批量读、批量写(先 map 出所有读数,再统一写);② 把写推到 requestAnimationFrame;③ 需要频繁读值(如拖拽)时缓存读数(pointerdown 时测一次);④ 用 transform 做位移(不触发重排,见 5.17)。
- 「
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)用错就差几个像素。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!