原文链接:Elements & Children Props
各位 React 开发者,大家好!
在上一篇文章中,我们探讨了重渲染问题以及状态下推的设计模式。当我们能将有状态的业务逻辑抽离到叶子组件中时,这个模式的效果会非常好。但在实际的项目架构中,有时并不能做到这一点。
如果状态必须放在顶层组件中管理,同时又不想让应用的性能大幅下降,该如何处理呢?
我们结合一个实际的开发场景来分析。假设你正在开发一个带可调整宽度侧边栏的仪表盘,侧边栏的宽度通过拖动滑块控制,整个内容区域需要根据侧边栏的宽度变化做出响应。
实际开发中的问题
本次开发的核心需求如下:
- 实现可拖动的分隔条,实时更新侧边栏宽度;
- 侧边栏中包含
ExpensiveChart、DataGrid和AnalyticsPanel这几个组件; - 拖动分隔条时,页面布局能流畅适配宽度变化。
最直观的实现方式,是将拖动相关的状态放在顶层组件中(也有人会尝试把状态隐藏在自定义 Hook 中,但本质上没有任何区别),代码如下:
jsx
const Dashboard = () => {
const [sidebarWidth, setSidebarWidth] = useState(300);
const handleDrag = (e) => {
setSidebarWidth(e.clientX);
};
return (
<div className="dashboard-layout">
<div className="sidebar" style={{ width: sidebarWidth }}>
<DragHandle onDrag={handleDrag} />
{/* 拖动时,这些组件会持续重渲染! */}
<ExpensiveChart />
<DataGrid />
<AnalyticsPanel />
</div>
<MainContent />
</div>
);
};

核心问题:所有组件都被触发重渲染
上述实现方式会让页面的拖动体验变得非常卡顿。鼠标的每一次移动都会触发状态更新,进而导致Dashboard组件重渲染;而Dashboard重渲染时,其内部嵌套的所有子组件也会跟着重渲染。
此时状态下推 的模式不再适用,因为包裹这些高性能开销组件的sidebar容器本身需要获取并使用宽度状态。
你可能会想到用React.memo来做性能优化,但还有一种更优雅的组件组合方案,能充分利用 React 处理元素的原生特性来解决这个问题。
解决方案:基于 Children 的组件组合
我们可以将侧边栏的尺寸调整逻辑抽离为一个独立的组件,让这个组件通过children属性接收需要展示的内容,代码实现如下:
jsx
const ResizableSidebar = ({ children }) => {
const [width, setWidth] = useState(300);
const handleDrag = (e) => {
setWidth(e.clientX);
};
return (
<div className="sidebar" style={{ width }}>
<DragHandle onDrag={handleDrag} />
{children}
</div>
);
};
接着重构Dashboard组件,通过上述封装的组件来实现布局:
jsx
const Dashboard = () => {
return (
<div className="dashboard-layout">
<ResizableSidebar>
{/* 作为属性传入,而非直接在该作用域定义 */}
<ExpensiveChart />
<DataGrid />
<AnalyticsPanel />
</ResizableSidebar>
<MainContent />
</div>
);
};
现在再拖动分隔条时,页面的交互就会变得十分流畅。即便这些高性能开销的组件在视觉上处于一个每秒更新数十次的组件内部,其自身也不会被触发重渲染。
底层实现原理
这种优化效果的实现,源于 React 中组件(Component) 和元素(Element) 这两个概念的本质区别。
- 组件 :指的是函数本身(比如
Dashboard、ResizableSidebar); - 元素 :是 JSX 执行后生成的对象(格式如
{ type: ExpensiveChart, props: {...} })。
接下来我们一步步拆解整个执行流程:
Dashboard组件仅执行一次渲染,同时为<ExpensiveChart />、<DataGrid />和<AnalyticsPanel />创建对应的元素对象;- 这些元素对象通过
children属性传入ResizableSidebar组件; - 用户开始拖动分隔条,
ResizableSidebar内部的width状态被反复更新; ResizableSidebar组件重新执行,返回一个包含更新后宽度的新侧边栏容器元素;- React 会检查
ResizableSidebar的children属性:其引用是否发生了变化? - 答案是没有 。因为
Dashboard组件从未被触发重渲染,所以children属性指向的始终是内存中同一个元素对象; - React 会直接跳过对整个
children子树的协调过程(Reconciliation)。
这里的核心关键点是:React 比较的是元素的引用,而非元素在组件树中的视觉位置。
理解 JSX 的转换逻辑
需要牢记的是,children只是一个普通的组件属性,JSX 中的嵌套写法只是一种语法糖。由于元素本身是表达式,因此以下两种写法的执行结果完全一致:
写法 1:嵌套式
jsx
<Wrapper>
<Content />
</Wrapper>
写法 2:显式传参
jsx
<Wrapper children={<Content />} />
你甚至可以将属性名换成任意名称,比如content、slot、body等。只要元素对象是在不会重渲染的作用域中创建,再传递给有状态的组件,这种性能优化的方式就同样有效。
总结
上一篇文章中,我们介绍了将状态下推到子组件 的方式,来避免不必要的重渲染;而本文则展示了其反向操作:将静态的 UI 内容通过属性的方式,从有状态组件中抽离出来。
这两种设计模式的核心目标是一致的:将发生变化的部分与保持稳定的部分解耦。