在 React 中构建 Tabs 选项卡时,如果每次都要手动把 active、onSelect 等属性传给每一个 Tab,组件库的调用方会很快感到繁琐,而且容易出现状态不同步的问题。复合组件模式提供了一种更优雅的组织方式:把 Tabs 设计成状态容器,Tab 作为语义子组件,两者之间通过隐式通道共享状态和回调,调用方只需按照结构声明即可。这种模式的核心在于父组件拥有状态,子组件通过某种机制读取这些状态,而不需要显式写在 props 里。

一、复合组件模式解决的核心问题
普通的 Tabs 实现通常有两种:一种是将 tabs 数据作为数组传入,由父组件内部渲染;另一种是完全受控,把当前选中索引和切换函数传给每个 Tab。前者在需要自定义每个 Tab 内容时不够灵活,后者会暴露太多内部实现细节,调用方需要关心索引管理和事件绑定。复合组件介于两者之间,既能保持语义化嵌套,又能把状态管理封装在 Tabs 内部。
以常见的组件库为例,Tabs 组件允许我们写 <Tabs> 内嵌 <TabPane>,每个 TabPane 不需要传 active 属性。这种 API 设计让组件库可以在不破坏用户代码的情况下调整内部状态逻辑,比如换成受控或非受控模式。对使用方来说,只需要表达界面结构,不必理解状态如何流动。这就是隐式关联的价值:接口简单、封装性好、扩展性强。
二、基于 Context 的隐式关联实现
Context 是 React 提供的一种跨层级数据传递机制,非常契合复合组件的需求。在 Tabs 组件内部创建一个 Context,把 activeIndex 和 setActiveIndex 放进 value,然后通过 Provider 包裹子组件。Tab 组件通过 useContext 拿到这个 context,判断自己的 index 是否等于 activeIndex,并绑定点击事件调用 setActiveIndex。由于 Context 不受组件层级限制,Tab 不一定是 Tabs 的直接子节点,即使中间包了一层 div 或自定义容器也能正常工作。
下面给出一个最小实现。需要定义 Tabs、TabList、Tab、TabPanel 四个组件,其中 Tabs 管理状态,TabList 负责渲染标签栏,Tab 负责单个标签,TabPanel 负责内容区域。为了简化,Tab 和 TabPanel 可以共用同一个 Context。注意在 React 18 中可以使用 React.useContext 消费 Context,而在类组件中需要使用 Context.Consumer。
import React, { createContext, useContext, useState } from 'react';
const TabsContext = createContext(null);
function Tabs({ children, defaultActive = 0 }) {
const [activeIndex, setActiveIndex] = useState(defaultActive);
const value = { activeIndex, setActiveIndex };
return (
<TabsContext.Provider value={value}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
function TabList({ children }) {
return <div role="tablist">{children}</div>;
}
function Tab({ index, children }) {
const { activeIndex, setActiveIndex } = useContext(TabsContext);
const isActive = activeIndex === index;
return (
<button
role="tab"
aria-selected={isActive}
className={isActive ? 'tab active' : 'tab'}
onClick={() => setActiveIndex(index)}
>
{children}
</button>
);
}
function TabPanel({ index, children }) {
const { activeIndex } = useContext(TabsContext);
if (activeIndex !== index) return null;
return <div role="tabpanel">{children}</div>;
}
export { Tabs, TabList, Tab, TabPanel };
Context 实现的优势是灵活,适合组件层级深、存在包装组件的场景。但每个复合组件都需要创建并导出 Context,代码量相对较多,而且 Context 的 value 变化会导致所有消费方重渲染。在实际项目中,如果子组件数量较多、频繁切换,可以考虑将 Context 拆分成状态和更新函数两个 Context,减少无关重渲染。
三、使用 cloneElement 实现轻量级隐式注入
如果 Tabs 的子组件始终是直接写在自己的 children 里,没有额外包装,那么可以使用 React.cloneElement 在遍历 children 时注入 props。这种方式不需要创建 Context,父组件在渲染时通过 React.Children.map 遍历子元素,判断元素类型是否为 Tab,然后调用 React.cloneElement 给 Tab 传入 active、onSelect 等属性。
下面是一个基于 cloneElement 的 Tabs 实现。由于直接操作的是 ReactElement,注入的 props 不会显示在调用方的 JSX 中,达到隐式关联效果。这种方式很直观,代码更少,在小型组件库或内部系统中非常实用。但它的明显限制是只能处理直接 children,如果用户把 Tab 包进一个函数组件,Tabs 就无法识别并注入。
import React, { Children, cloneElement, isValidElement, useState } from 'react';
function Tabs({ children, defaultActive = 0 }) {
const [activeIndex, setActiveIndex] = useState(defaultActive);
const tabs = Children.toArray(children).filter(
(child) => isValidElement(child) && child.type === Tab
);
const current = tabs[activeIndex] || tabs[0];
return (
<div className="tabs">
<div role="tablist">
{tabs.map((tab, index) =>
cloneElement(tab, {
key: index,
active: index === activeIndex,
onSelect: () => setActiveIndex(index)
})
)}
</div>
<div className="tab-panels">
{current && current.props.children}
</div>
</div>
);
}
function Tab({ active, onSelect, children }) {
return (
<button
role="tab"
aria-selected={active}
className={active ? 'tab active' : 'tab'}
onClick={onSelect}
>
{children}
</button>
);
}
Tab.displayName = 'Tab';
Tabs.Tab = Tab;
export default Tabs;
为了让 cloneElement 正确识别 Tab,通常需要给 Tab 设置静态属性 Tab.displayName = 'Tab' 或比较元素类型。也可以将 Tab 作为 Tabs 的静态属性导出,比如 Tabs.Tab = Tab,这样调用方写 <Tabs><Tabs.Tab> 更加清晰,同时方便在 Tabs 内部判断子组件类型。
四、可访问性与边界情况处理
复合组件不只是状态共享,还需要考虑键盘导航和 ARIA 属性。Tabs 容器应设置 role="tablist",每个 Tab 设置 role="tab" 和 aria-selected,内容面板设置 role="tabpanel"。这些属性可以放在 Context 的 value 中,让 Tab 和 TabPanel 自动读取,避免调用方手动添加。键盘事件如左右方向键切换标签,可以在 TabList 上监听 onKeyDown,根据当前 activeIndex 计算下一个索引并调用 setActiveIndex。
在过滤 children 时,需要忽略非 Tab 的节点,比如字符串、注释或条件渲染产生的 null。可以使用 React.Children.toArray 过滤有效元素,并给每个 Tab 分配一个顺序索引。如果调用方传入动态 children 且数量变化,activeIndex 可能超出范围,需要在 Tabs 内部做边界保护,例如使用 Math.min(activeIndex, children.length - 1) 或在 effect 中进行调整。
完整的复合组件还会处理默认激活项、受控模式、懒加载面板等。实现这些能力时,要保持隐式关联的 API 不变,只扩展 Context 或注入的属性。这样调用方可以在不修改业务代码的情况下享受新功能,这正是复合组件模式在大型组件库中被广泛采用的原因。

