1. 问题背景
项目中有一个可复用的 React 组件 YlCollapse,它实现了一个点击标题可以展开/收起内容的折叠面板。其动画效果是通过 CSS transition 和 max-height 属性实现的。
遇到的问题是: 当该组件包裹的内容(例如一个 antd Table 组件)在展开状态下,因用户操作(如切换分页)导致其自身高度突然增加时,YlCollapse 组件的容器没有随之适应新的高度,导致新增的内容被截断,无法完整显示。
2. 根源分析
问题的核心在于 ResizeObserver 的观察目标与 max-height 动画机制之间产生了逻辑冲突。
-
动画机制:组件利用
overflow: hidden和max-height从0px到content.scrollHeight的transition来实现平滑的展开动画。 -
ResizeObserver的用法:原有代码使用ResizeObserver来侦测内容容器div.content本身的尺寸变化,期望在内容高度变化时自动更新max-height。 -
核心冲突点(死循环):
ResizeObserver侦测的是元素最终渲染的盒子尺寸(height)。然而,div.content的渲染高度已经被max-height属性“锁死”了。当内部的Table变高时,div.content的scrollHeight(可滚动高度)确实增加了,但它的height(渲染高度)并未改变。因此,ResizeObserver不会触发回调,max-height的值也就无法得到更新。这是一个典型的“死循环”陷阱。 -
次要问题:代码中使用了
document.querySelector来获取 DOM 节点。在 React 中,这是一种指令式的反模式(anti-pattern),它破坏了组件的封装性,并且在复杂的 DOM 结构或多个组件实例共存时可能导致意想不到的 bug。
3. 解决方案与思路
我们采用**“职责分离”**的原则来重构组件逻辑,彻底解决此问题。
-
分离动画与测量:我们引入一个额外的内层
div作为包裹层。-
外层容器 (
div.content):只负责动画。它拥有max-height和overflow: hidden样式。 -
内层包裹层 (
wrapper):只负责测量。它的高度不受任何限制,会随着子内容自由伸缩。
-
-
修正观察目标:将
ResizeObserver的观察目标从外层容器改为这个不受约束的内层包裹层。这样,只要内部Table高度变化,内层的高度就会变化,ResizeObserver就能被正确触发。 -
拥抱 React Hooks:使用
useRefHook 来替代document.querySelector,以声明式、更安全的方式获取 DOM 节点的引用。 -
优化渲染性能:在
ResizeObserver的回调中,使用requestAnimationFrame(rAF) 来包裹更新max-height的操作。这可以将 DOM 的更新操作与浏览器的渲染周期同步,避免因连续的“读/写”操作引发的“布局抖动”(Layout Thrashing),确保动画在内容频繁变化时依然保持流畅。
4. 代码对比
4.1 原有问题代码
JavaScript
import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
import css from './index.module.less';
import classNames from 'classnames';
import { createUidKey } from '@/utils';
const YlCollapse = (props: CollapseProps, ref: any) => {
const { title, defaultActive, children } = props;
const [active, setActive] = useState(defaultActive);
const id = createUidKey(title);
useEffect(() => {
const containerDiv: HTMLElement | null = document.querySelector(`#${id}`);
const contentDiv: HTMLElement | null = containerDiv?.querySelector(`.${css.content}`) || null;
if (!contentDiv) return;
const observer = new ResizeObserver(() => {
contentDiv.style.maxHeight = active ? `${contentDiv.scrollHeight}px` : '0px';
});
observer.observe(contentDiv);
contentDiv.style.maxHeight = active ? `${contentDiv.scrollHeight}px` : '0px';
return () => observer.disconnect();
}, [active, id]);
useImperativeHandle(ref, () => ({
toggle(status: boolean) {
setActive(status);
}
}));
return (
<div className={css.collapse} id={id}>
<div className={css.board} onClick={() => setActive(!active)}>
{/* ... */}
</div>
<div className={css.content}>{children}</div>
</div>
);
}
export default forwardRef(YlCollapse);
4.2 优化后的最终代码
JavaScript
import React, { forwardRef, useEffect, useImperativeHandle, useState, useRef } from 'react';
import css from './index.module.less';
// 假设的 Props 类型定义
interface CollapseProps {
title: React.ReactNode;
defaultActive?: boolean;
children: React.ReactNode;
}
const YlCollapse = forwardRef((props: CollapseProps, ref: any) => {
const { title, defaultActive = false, children } = props;
const [active, setActive] = useState(defaultActive);
// 使用 useRef 替代 querySelector
const contentRef = useRef<HTMLDivElement>(null); // 指向执行动画的外部容器
const wrapperRef = useRef<HTMLDivElement>(null); // 指向内部尺寸可变的包裹层
useEffect(() => {
const contentEl = contentRef.current;
const wrapperEl = wrapperRef.current;
if (!contentEl || !wrapperEl) return;
// 1. 创建 ResizeObserver 观察内部包裹层的尺寸变化
const observer = new ResizeObserver(() => {
if (active) {
// 2. 使用 rAF 优化性能,防止布局抖动
requestAnimationFrame(() => {
if (contentRef.current && wrapperRef.current) {
contentRef.current.style.maxHeight = `${wrapperRef.current.scrollHeight}px`;
}
});
}
});
observer.observe(wrapperEl);
// 3. 根据 active 状态设置初始 maxHeight
contentEl.style.maxHeight = active ? `${wrapperEl.scrollHeight}px` : '0px';
return () => {
observer.disconnect();
};
}, [active]);
useImperativeHandle(ref, () => ({
toggle(status: boolean) {
setActive(status);
}
}));
return (
<div className={css.collapse}>
<div className={css.board} onClick={() => setActive(!active)}>
<div>{title}</div>
<div className={active ? css.moreIconActive : css.moreIcon} />
</div>
{/* 外层容器:负责动画和裁剪 */}
<div className={css.content} ref={contentRef}>
{/* 内层包裹层:负责被观察尺寸 */}
<div ref={wrapperRef}>
{children}
</div>
</div>
</div>
);
});
export default YlCollapse;
5. 总结与最佳实践
此次修复不仅解决了眼前的 bug,也为我们沉淀了关于动态内容与 CSS 动画交互的最佳实践。
-
掌握
max-height动画模式:当使用max-height制作内容自适应的动画时,必须采用“外层动画,内层测量”的嵌套结构,这是解决此类问题的标准模式。 -
坚持 React 的声明式思想:在组件内部,应始终优先使用
useRef来操作和引用 DOM 元素,避免使用document.querySelector等指令式 API,以保证组件的健壮性和可维护性。 -
关注渲染性能:对于涉及 DOM 尺寸读取和样式写入的交互,尤其是那些可能被频繁触发的场景(如
ResizeObserver,onScroll),应习惯性地使用requestAnimationFrame进行包裹,将性能优化作为编码习惯的一部分。 -
保持组件封装:一个好的组件应该管理好自己内部的复杂性。此次修复将所有逻辑都封装在
YlCollapse内部,对父组件完全透明,父组件无需关心其内部实现细节,这符合高内聚、低耦合的设计原则。