React 与 Resize Observer:监听组件容器尺寸变化实现响应式布局的动态调整

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]);
};

看到这个代码,你应该感到一种“资深专家”的自信。我们处理了:

  1. 闭包问题:使用 lastCallbackRef
  2. 内存泄漏:在 useEffect 的 return 中 disconnect
  3. 性能问题:集成了防抖。

三、 场景实战:拯救“瀑布流”布局

现在,让我们把这套工具用到刀刃上。最经典的需求是什么?瀑布流布局

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;

看懂了吗?这就是魔法。

  1. 我们创建了 3 个列容器。
  2. 我们给每个列容器挂上了 ResizeObserver
  3. 当列容器里的内容(比如图片)加载完,高度变了 -> ResizeObserver 触发 -> 更新 columnHeights state。
  4. React 检测到 columnHeights 变化,重新计算每个 item 的 topleft
  5. 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-topborder-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 去监听呢?

  1. Flexbox 和 Grid: 现代的 Flexbox 和 CSS Grid 非常智能。只要父容器是 display: flex,子元素高度变化会自动挤压或拉伸。很多瀑布流问题,其实可以用 CSS Grid 的 grid-auto-flow: column 解决,完全不需要 JS。
  2. aspect-ratio: 如果你知道图片的宽高比,直接写 aspect-ratio: 16/9,浏览器会自动撑开高度,你根本不需要监听。
  3. 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 的实战应用。

记住以下几点核心心法:

  1. CSS 是懒惰的,ResizeObserver 是勤奋的。在动态内容场景下,CSS 媒体查询是远远不够的。
  2. Hook 是王道。把 ResizeObserver 封装成 Hook,复用性极强,逻辑清晰。
  3. 内存泄漏是头号大敌disconnect() 是你的好朋友,组件卸载时别忘了分手。
  4. 性能至上。不要滥用,不要监听几千个元素,善用防抖。
  5. 理解数据结构。分清 contentRectborderBoxSize,这能帮你解决很多奇怪的布局 Bug。

现在,当你再面对一个“哎呀,这个弹窗大小变了,里面的图表怎么没变?”或者“哎呀,这个卡片高度变了,为什么下面的卡片没跟着动?”的问题时,不要慌,不要骂娘。

深吸一口气,打开你的编辑器,敲下 new ResizeObserver(...)

你会发现,世界突然变得清晰了。代码,就是逻辑的艺术;而 ResizeObserver,就是那个精准控制画笔的艺术家。

祝大家编码愉快,永远不需要处理 overflow: hidden 的 Bug!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注