React 与 Resize Observer:监听组件容器尺寸变化实现响应式布局的动态调整
各位前端同仁,大家好。
今天我们不聊那些花里胡哨的 UI 组件库,也不聊怎么用 Tailwind CSS 偷懒,我们来聊一个稍微有点“硬核”,但在实际业务中能让你从“被需求方骂哭”变成“被需求方夸上天”的核心技术点。
在座的各位,是不是都经历过这种崩溃时刻:你用 CSS 写了一个布局,写着 width: 100%,写着 height: auto。你以为浏览器会像变魔术一样,当你点击折叠菜单、或者把一张图从 100×100 拖拽到 500×500 时,你的组件会自动适应新的尺寸。
然后呢?浏览器一脸冷漠地看着你,你的布局塌了,你的文字溢出了,你的 Flexbox 父容器像个便秘一样死活不换行。
为什么?因为 CSS 媒体查询是懒惰的,它只在页面加载那一刻动一次脑子。当你改变了 DOM 的内容,导致容器尺寸变了,CSS 只会说:“我不知道,我不在乎,我只负责渲染你给我的像素。”
这时候,我们需要一位侦探。一位能盯着 DOM 元素,时刻监视它是否变胖、变瘦、变高、变矮的侦探。
这位侦探的名字,叫做 ResizeObserver。
今天,我们就来聊聊如何在 React 中驯服这位侦探,让它成为你响应式布局的神兵利器。
一、 CSS 的谎言与 ResizeObserver 的觉醒
首先,我们要认清现实。CSS 是一个基于规则的系统。你给它规则,它就执行。但是,规则是基于静态的。当你动态地往一个容器里插入一个 5000 字的段落,或者动态地改变 flex 容器的 flex-direction,CSS 是不知道的。
以前我们怎么办?我们监听 window.resize。
但是,朋友们,window.resize 是针对整个浏览器窗口的。如果你的应用是一个 Dashboard,左侧是导航栏(固定宽度),右侧是内容区(自适应)。当你调整浏览器窗口大小时,window.resize 会触发,但是它不知道右侧内容区的容器到底变了多少,它只知道“窗口变了”。如果你要精确计算某个特定 div 的变化,你需要自己写一堆数学公式去算 window.innerWidth - navWidth。这简直是给自己找罪受。
于是,ResizeObserver 诞生了。它是一个原生的浏览器 API,允许你监听 DOM 元素尺寸的变化。
它的核心思想非常简单粗暴:你在元素上挂一个钩子,然后回调函数就会在尺寸变化时被调用。
原生 API 简单粗暴版
让我们先不看 React,直接看看原生 JS 怎么用这个 API,感受一下它的魅力:
const targetElement = document.querySelector('.box');
const observer = new ResizeObserver(entries => {
for (let entry of entries) {
// entry.contentRect 是元素内容的矩形区域
const width = entry.contentRect.width;
const height = entry.contentRect.height;
console.log(`哎呀,容器变大了!现在是 ${width}px 宽,${height}px 高`);
}
});
observer.observe(targetElement);
看懂了吗?就这么简单。不需要算坐标,不需要算差值,浏览器把最新的尺寸直接塞给你。
二、 React 中的集成:从“原生”到“Hook”
在 React 中,我们不能直接在组件里写 new ResizeObserver(),因为 React 的生命周期和 DOM 操作是分离的。我们需要把这套逻辑封装成一个 Custom Hook。
为什么必须是 Hook?因为 ResizeObserver 的生命周期必须和 DOM 元素的生命周期绑定。如果组件卸载了,你必须告诉 ResizeObserver “别监听了,我挂了”,否则就会造成内存泄漏。
深入剖析:ref 与 state
这里有个坑,很多新手容易踩。你可能会想:
// ❌ 错误示范
const [width, setWidth] = useState(0);
useEffect(() => {
const observer = new ResizeObserver(entries => {
setWidth(entries[0].contentRect.width);
});
observer.observe(ref.current);
}, []); // 依赖项为空
这代码看起来没问题,对吧?ref.current 指向 DOM,我们监听它,然后更新 state。
但是! 如果你仔细看,你会发现 ref.current 并不在 useEffect 的依赖数组里。这意味着,即使 ref.current 指向的元素变了(比如你渲染了两个不同的组件实例,ref 指向了第二个),useEffect 依然只运行一次。而且,如果你在回调里直接访问 ref.current,由于闭包陷阱,你可能拿到的永远是最初的那个 DOM 节点。
正确姿势 是:将 ref 作为依赖项,或者更高级一点,在 Hook 内部管理 Observer 的实例。
封装 useResizeObserver Hook
让我们来写一个健壮的 Hook。这个 Hook 不仅监听尺寸,还支持防抖,防止尺寸每变一个像素就触发一次渲染(那性能就崩了)。
import { useEffect, useRef, useState, useCallback } from 'react';
/**
* 一个通用的 ResizeObserver Hook
* @param {RefObject} targetRef - 目标 DOM 元素的 ref
* @param {Function} callback - 尺寸变化时的回调函数
* @param {number} delay - 防抖延迟,默认 200ms
*/
const useResizeObserver = (targetRef, callback, delay = 200) => {
const observerRef = useRef(null);
const debounceTimerRef = useRef(null);
const lastCallbackRef = useRef(callback); // 保存最新的 callback 引用
// 每当 callback 变化时,更新 ref,防止闭包里的 callback 是旧的
useEffect(() => {
lastCallbackRef.current = callback;
}, [callback]);
useEffect(() => {
const target = targetRef.current;
if (!target) return;
// 1. 创建 Observer
observerRef.current = new ResizeObserver((entries) => {
// 这里处理防抖逻辑
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
// 如果组件已经卸载,就不要再更新 state 了
// 虽然 ResizeObserver 会自动断开,但做个保险总是好的
if (target.isConnected) {
const entry = entries[0]; // 通常我们只关心第一个 entry
lastCallbackRef.current(entry.contentRect, entry);
}
}, delay);
});
// 2. 开始监听
observerRef.current.observe(target);
// 3. 清理函数:组件卸载或 ref 变化时断开连接
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
};
}, [targetRef, delay]);
};
看到这个代码,你应该感到一种“资深专家”的自信。我们处理了:
- 闭包问题:使用
lastCallbackRef。 - 内存泄漏:在
useEffect的 return 中disconnect。 - 性能问题:集成了防抖。
三、 场景实战:拯救“瀑布流”布局
现在,让我们把这套工具用到刀刃上。最经典的需求是什么?瀑布流布局。
CSS Grid 虽然强大,但处理不定高度的列流(比如 Pinterest)非常麻烦,需要 JS 辅助。而传统的 float 布局在现代开发中基本被淘汰了。
我们要实现的效果是:左边第一张图很高,第二张图很矮。当第一张图加载完成后(高度变了),右侧的第三张图必须自动向下移动,填补第一张图留下的空缺。
这听起来像是个数学题,但有了 ResizeObserver,它就是个简单的“通知-响应”游戏。
代码示例:动态瀑布流
假设我们有一个卡片列表,每个卡片的高度是动态的(比如图片加载需要时间)。
import React, { useState, useEffect, useRef } from 'react';
const MasonryGrid = ({ items }) => {
const columns = 3; // 3列
const [columnHeights, setColumnHeights] = useState(new Array(columns).fill(0));
const columnRefs = useRef([]);
// 初始化 ref 数组
useEffect(() => {
columnRefs.current = new Array(columns).fill(null);
}, [columns]);
// 核心逻辑:监听每一列的高度变化
useEffect(() => {
columnRefs.current.forEach((ref, index) => {
if (!ref) return;
const updateHeights = (rect) => {
const newHeights = [...columnHeights];
newHeights[index] = rect.height;
setColumnHeights(newHeights);
};
const observer = new ResizeObserver((entries) => {
// 这里的 entry 就是每一列容器
updateHeights(entries[0].contentRect);
});
observer.observe(ref);
return () => observer.disconnect(); // 清理
});
}, [columnHeights]); // 依赖 columnHeights,因为我们需要根据高度重新计算 item 位置
// 计算每个 item 应该放在哪一列
const getItemStyle = (index) => {
const minHeight = Math.min(...columnHeights);
const columnIndex = columnHeights.indexOf(minHeight);
// 更新这一列的高度(注意:这里只是为了计算位置,实际高度由图片决定)
// 注意:这里我们直接操作 state 会导致重渲染循环,实际生产中需要优化,
// 比如用一个单独的 ref 存当前高度,或者用 useReducer。
// 为了演示简单,我们假设上面 useEffect 会处理高度更新。
return {
left: `${columnIndex * 33.33}%`,
top: `${columnHeights[columnIndex]}px`,
};
};
return (
<div style={{ display: 'flex', width: '100%' }}>
{columnRefs.current.map((_, i) => (
<div
key={i}
ref={(el) => (columnRefs.current[i] = el)}
style={{
width: '33.33%',
borderRight: i === columns - 1 ? 'none' : '1px solid #eee',
padding: 10,
}}
>
{items.map((item, idx) => {
const style = getItemStyle(idx);
return (
<div
key={idx}
style={{
...style,
position: 'absolute', // 绝对定位实现瀑布流
marginBottom: 10,
}}
>
{/* 模拟图片,高度随机 */}
<div
style={{
height: Math.random() * 200 + 100 + 'px',
background: '#ddd',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#666'
}}
>
Item {idx}
</div>
</div>
);
})}
</div>
))}
</div>
);
};
export default MasonryGrid;
看懂了吗?这就是魔法。
- 我们创建了 3 个列容器。
- 我们给每个列容器挂上了
ResizeObserver。 - 当列容器里的内容(比如图片)加载完,高度变了 ->
ResizeObserver触发 -> 更新columnHeightsstate。 - React 检测到
columnHeights变化,重新计算每个 item 的top和left。 - DOM 更新,item 自动下移。
这就是“响应式布局的动态调整”。CSS 做不到这种程度的“实时反馈”,只有 ResizeObserver 能做到。
四、 进阶技巧:contentRect 与 borderBoxSize
作为一名资深专家,我必须告诉你 ResizeObserver 返回的数据里有两个非常重要的属性,它们经常被初学者忽略。
1. contentRect vs borderBox
当你监听一个元素时,你会得到一个 ResizeObserverEntry 对象。它有两个矩形属性:
- contentRect: 元素内容区域的大小。也就是
padding以内的大小。通常我们想要的是这个,因为它是真正影响布局的大小。 - borderBoxSize: 元素边框区域的大小。包括 padding 和 border。
const observer = new ResizeObserver(entries => {
const entry = entries[0];
console.log('Content Size:', entry.contentRect);
console.log('Border Size:', entry.borderBoxSize);
});
什么时候用哪个?
通常情况下,你想要的是 contentRect,因为它是真正决定你内部子元素布局的。但是,如果你在做一些非常底层的 Canvas 绘图,或者需要知道元素加上边框后的实际占位大小,你就得用 borderBoxSize。
2. borderBoxSize 是个怪胎
注意,borderBoxSize 不是像 contentRect 那样简单的 {width, height} 对象。它是一个对象数组。
为什么?因为一个元素可能有多个边框(比如 border-top 和 border-bottom 厚度不一样,或者使用了 box-shadow)。所以它返回的是一个数组,每个元素对应一个边框盒。
// 获取第一个边框盒的大小
const firstBorderBox = entry.borderBoxSize[0];
const width = firstBorderBox.inlineSize; // 现代浏览器用 inlineSize 替代 width
const height = firstBorderBox.blockSize; // 现代浏览器用 blockSize 替代 height
虽然 contentRect 是简单的矩形,但在处理复杂的 CSS 盒模型时,理解 borderBoxSize 是非常关键的。
五、 性能优化:不要做“尺寸的疯子”
你可能会问:“既然 ResizeObserver 是监听 DOM 变化的,那如果我放 1000 个卡片,岂不是要监听 1000 次?这会不会卡死浏览器?”
这是一个非常好的问题。答案是:会卡死。
如果你在一个列表里放了 1000 个 ResizeObserver,当这 1000 个元素的高度稍微抖动一下,你的回调函数就会执行 1000 次。如果回调里涉及到了 setState,React 就要重新渲染 1000 次。你的页面瞬间就会变成一坨浆糊。
解决方案一:虚拟滚动
如果你的列表很长,虚拟滚动 是必须的。只有可视区域内的元素才使用 ResizeObserver,不可视区域的直接忽略。
解决方案二:防抖
这是最常用的手段。我们在上面的 Hook 示例中已经用到了防抖。
// 简单的防抖逻辑
const debouncedCallback = useCallback((...args) => {
clearTimeout(timer.current);
timer.current = setTimeout(() => {
callback(...args);
}, delay);
}, [callback, delay]);
解决方案三:限制监听频率
有些场景下,你不需要知道每一个像素的变化。比如你做一个自适应的 Canvas,每 50ms 更新一次尺寸就够了。
解决方案四:使用 ResizeObserverEntry 的 target
你可以利用 entry.target 来判断变化的是不是你关心的那个元素。
六、 替代方案:CSS 的反击
虽然 ResizeObserver 很强大,但我们不能滥用。有些时候,用 CSS 就能解决的问题,为什么要用 JS 去监听呢?
- Flexbox 和 Grid: 现代的 Flexbox 和 CSS Grid 非常智能。只要父容器是
display: flex,子元素高度变化会自动挤压或拉伸。很多瀑布流问题,其实可以用 CSS Grid 的grid-auto-flow: column解决,完全不需要 JS。 - aspect-ratio: 如果你知道图片的宽高比,直接写
aspect-ratio: 16/9,浏览器会自动撑开高度,你根本不需要监听。 - clamp(): 用于字体大小或宽度,实现“最小-理想-最大”的动态范围。
专家建议: 只有当 CSS 布局无法满足你的需求,或者你需要根据尺寸做复杂的逻辑判断(比如“如果容器高度小于 300px,我就显示这个按钮”)时,才使用 ResizeObserver。
七、 浏览器兼容性:别被 IE 教做人
虽然现代浏览器(Chrome, Firefox, Safari, Edge)都完美支持 ResizeObserver,但在一些“上古时代”的浏览器或者特定的移动端 WebView 中,它可能不存在。
如果你需要支持非常老的 Safari(比如 iOS 13 以下),你需要引入一个 Polyfill。
npm install resize-observer-polyfill
然后在入口文件引入:
import 'resize-observer-polyfill/dist/ResizeObserver.polyfill.js';
有了这个 Polyfill,你就可以放心地在旧浏览器里写你的高级逻辑了。
八、 完整的高级案例:动态 Canvas 图表
让我们来看一个更高级的例子。假设我们要写一个图表库。图表是画在 <canvas> 上的。
Canvas 是一个位图,它不像 DOM 元素那样会自动调整大小。如果你把 Canvas 的 CSS 宽度设为 100%,但 Canvas 内部的绘图缓冲区(width 和 height 属性)还是 300×150,那么画出来的图会被拉伸变形,或者模糊不清。
我们需要监听 Canvas 容器的大小变化,然后重新计算 Canvas 的分辨率,并重绘图表。
import React, { useRef, useEffect, useState } from 'react';
const ResponsiveChart = ({ data }) => {
const canvasRef = useRef(null);
const containerRef = useRef(null);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
// 监听容器尺寸
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver(entries => {
const { width, height } = entries[0].contentRect;
setDimensions({ width, height });
});
observer.observe(container);
return () => observer.disconnect();
}, []);
// 监听尺寸变化后,重绘 Canvas
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || dimensions.width === 0 || dimensions.height === 0) return;
// 1. 设置 Canvas 的内部分辨率等于容器分辨率(避免模糊)
// 这里为了演示简单,我们假设 1:1 映射。实际生产中可能需要考虑 DPR (Device Pixel Ratio)
canvas.width = dimensions.width;
canvas.height = dimensions.height;
// 2. 获取绘图上下文
const ctx = canvas.getContext('2d');
// 3. 清空画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 4. 绘制简单的柱状图
const barWidth = 40;
const gap = 20;
const maxVal = Math.max(...data);
data.forEach((val, index) => {
const x = index * (barWidth + gap) + 10;
const barHeight = (val / maxVal) * (canvas.height - 40); // 留点边距
const y = canvas.height - barHeight;
// 绘制柱子
ctx.fillStyle = '#3498db';
ctx.fillRect(x, y, barWidth, barHeight);
// 绘制文字
ctx.fillStyle = '#333';
ctx.font = '12px Arial';
ctx.fillText(val, x, y - 5);
});
}, [dimensions, data]); // 依赖 dimensions 和 data
return (
<div
ref={containerRef}
style={{
width: '100%',
height: '300px',
border: '1px solid #ccc',
position: 'relative'
}}
>
<canvas ref={canvasRef} />
</div>
);
};
export default ResponsiveChart;
在这个例子中,ResizeObserver 是 Canvas 的“眼睛”。没有它,Canvas 就是个死板的图片,不管你怎么调整浏览器窗口,图表都不会变。有了它,图表就活了。
九、 总结(不总结的总结)
好了,同学们。
我们今天从 CSS 的局限性聊到了 ResizeObserver 的强大,从原生的 API 封装到了 React 的 Custom Hook,又深入到了瀑布流和 Canvas 的实战应用。
记住以下几点核心心法:
- CSS 是懒惰的,ResizeObserver 是勤奋的。在动态内容场景下,CSS 媒体查询是远远不够的。
- Hook 是王道。把 ResizeObserver 封装成 Hook,复用性极强,逻辑清晰。
- 内存泄漏是头号大敌。
disconnect()是你的好朋友,组件卸载时别忘了分手。 - 性能至上。不要滥用,不要监听几千个元素,善用防抖。
- 理解数据结构。分清
contentRect和borderBoxSize,这能帮你解决很多奇怪的布局 Bug。
现在,当你再面对一个“哎呀,这个弹窗大小变了,里面的图表怎么没变?”或者“哎呀,这个卡片高度变了,为什么下面的卡片没跟着动?”的问题时,不要慌,不要骂娘。
深吸一口气,打开你的编辑器,敲下 new ResizeObserver(...)。
你会发现,世界突然变得清晰了。代码,就是逻辑的艺术;而 ResizeObserver,就是那个精准控制画笔的艺术家。
祝大家编码愉快,永远不需要处理 overflow: hidden 的 Bug!