Tabs、Select 这种复合组件,声明式组合和隐式状态共享怎么跑通的——Compound Pattern 实现拆解
复合组件的本质问题不是 UI 渲染,而是“一组组件需要共享同一份状态,但这份状态对使用者完全不可见”。Compound Pattern 解决的就是这件事——父组件持有状态,子组件通过上下文隐式获取,使用者只需要关心声明式的结构组合。
Compound Pattern 到底在解决什么
拿 Tabs 举例。你希望这样用:
<Tabs defaultActiveKey="tab1">
<Tabs.TabPane tab="Tab 1" key="tab1">内容1</Tabs.TabPane>
<Tabs.TabPane tab="Tab 2" key="tab2">内容2</Tabs.TabPane>
</Tabs>
这个写法里,Tabs.TabPane 没有接收任何 active 状态,也没传 onClick 回调,但它就是能知道自己什么时候该显示、点击 tab 时该怎么通知父组件。状态去哪了?
答案:状态全在 Tabs 内部,通过 React Context 向下分发,TabPane 从 context 里取,使用者完全感知不到。
如果你不用 Compound Pattern,你大概率会写成这样:
const [activeKey, setActiveKey] = useState('tab1');
<Tabs activeKey={activeKey} onChange={setActiveKey}>
<TabPane active={activeKey === 'tab1'}>内容1</TabPane>
</Tabs>
每个子组件都要显式传状态,状态管理从组件内部泄漏到了使用者的代码里。组件越多,使用者的心智负担越重。Compound Pattern 把状态“关回组件内部”,对外只暴露声明式的结构。
核心实现:Context + 静态子组件挂载
实现一个最小可用的 Tabs,分三步。
第一步:创建 Context,父组件持有状态
import React, { createContext, useContext, useState, useCallback } from 'react';
interface TabsContextValue {
activeKey: string;
setActiveKey: (key: string) => void;
}
const TabsContext = createContext<TabsContextValue | null>(null);
interface TabsProps {
defaultActiveKey: string;
children: React.ReactNode;
}
const Tabs: React.FC<TabsProps> = ({ defaultActiveKey, children }) => {
const [activeKey, setActiveKey] = useState(defaultActiveKey);
const contextValue: TabsContextValue = {
activeKey,
setActiveKey,
};
return (
<TabsContext.Provider value={contextValue}>
{children}
</TabsContext.Provider>
);
};
Tabs 只做两件事:用 useState 管理当前激活的 key,然后把 activeKey 和 setActiveKey 塞进 context。它不关心 children 具体是什么,也不管子组件怎么消费这些值。
第二步:子组件从 context 消费状态
interface TabPaneProps {
tab: string;
key: string;
children: React.ReactNode;
}
const TabPane: React.FC<TabPaneProps> = ({ tab, key, children }) => {
const context = useContext(TabsContext);
if (!context) {
throw new Error('TabPane must be used within Tabs');
}
const { activeKey, setActiveKey } = context;
const isActive = activeKey === key;
return (
<div style={{ display: isActive ? 'block' : 'none' }}>
{children}
</div>
);
};
TabPane 不接收 active prop,它从 context 里取 activeKey,自己判断 isActive。setActiveKey 也一样,tab 的点击事件由 TabPane 内部调用 setActiveKey(key),不需要使用者写 onClick。
第三步:把子组件挂到父组件上作为静态属性
Tabs.TabPane = TabPane;
export default Tabs;
这一步让 Tabs.TabPane 这个写法成立。本质上 TabPane 就是个独立的函数组件,只是被挂到了 Tabs 的静态属性上。使用者 import 的时候只需要引入 Tabs,TabPane 自动可用。
完整的最小实现不到 60 行代码,核心逻辑就一个 Context + 一个 useState。
进阶:Select 组件的选项映射与键盘导航
Select 的情况比 Tabs 复杂。Tabs 是“切换显示”,Select 是“选值 + 展开收起 + 键盘导航 + 选项过滤”。但底层模式完全一样。
先看使用方式:
<Select defaultValue="apple" onChange={(value) => console.log(value)}>
<Select.Option value="apple">苹果</Select.Option>
<Select.Option value="banana">香蕉</Select.Option>
<Select.Option value="orange" disabled>橘子(不可选)</Select.Option>
</Select>
Select.Option 不需要知道当前选中了什么、下拉框是否展开、自己高不高亮——这些全由父组件 Select 通过 context 提供。
实现上,Select 的 context 比 Tabs 复杂一些,需要传递的信息更多:
interface SelectContextValue {
selectedValue: string;
onSelect: (value: string) => void;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
activeIndex: number;
setActiveIndex: (index: number) => void;
options: Array<{ value: string; label: string; disabled: boolean }>;
}
注意这里有个 options 数组。Select 需要收集所有 Option 子组件的信息(value、label、disabled 状态),因为键盘导航时需要知道有多少个可选项、上下箭头该跳到哪个。
Option 组件在挂载时需要把自己注册到父组件:
const Option: React.FC<OptionProps> = ({ value, children, disabled = false }) => {
const context = useContext(SelectContext);
if (!context) throw new Error('Select.Option must be used within Select');
const { selectedValue, onSelect, activeIndex, options } = context;
// 注册自身:在 useEffect 中把自己加入 options 列表
useEffect(() => {
const optionIndex = options.findIndex(opt => opt.value === value);
if (optionIndex === -1) {
// 通过 context 提供的方法注册
context.registerOption({ value, label: children as string, disabled });
}
return () => context.unregisterOption(value);
}, [value, children, disabled]);
const isSelected = selectedValue === value;
const isActive = options[activeIndex]?.value === value;
return (
<li
role="option"
aria-selected={isSelected}
aria-disabled={disabled}
className={clsx({
'select-option': true,
'select-option-selected': isSelected,
'select-option-active': isActive,
'select-option-disabled': disabled,
})}
onClick={() => !disabled && onSelect(value)}
>
{children}
</li>
);
};
这里引入了 registerOption / unregisterOption 方法,这是 Compound Pattern 中常见的“子组件反向通知父组件”机制。父组件通过 context 暴露注册方法,子组件在挂载时调用,父组件收集所有子组件的信息。
Select 父组件的简化实现:
const Select: React.FC<SelectProps> & { Option: typeof Option } = ({
defaultValue,
onChange,
children
}) => {
const [selectedValue, setSelectedValue] = useState(defaultValue);
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const [options, setOptions] = useState<OptionInfo[]>([]);
const registerOption = useCallback((option: OptionInfo) => {
setOptions(prev => [...prev, option]);
}, []);
const unregisterOption = useCallback((value: string) => {
setOptions(prev => prev.filter(opt => opt.value !== value));
}, []);
const onSelect = useCallback((value: string) => {
setSelectedValue(value);
setIsOpen(false);
onChange?.(value);
}, [onChange]);
const handleKeyDown = (e: React.KeyboardEvent) => {
// 只处理非 disabled 的选项
const enabledOptions = options.filter(opt => !opt.disabled);
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex(prev => (prev + 1) % enabledOptions.length);
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex(prev => (prev - 1 + enabledOptions.length) % enabledOptions.length);
break;
case 'Enter':
case ' ':
e.preventDefault();
if (activeIndex >= 0 && enabledOptions[activeIndex]) {
onSelect(enabledOptions[activeIndex].value);
}
break;
case 'Escape':
setIsOpen(false);
break;
}
};
return (
<SelectContext.Provider value={{
selectedValue,
onSelect,
isOpen,
setIsOpen,
activeIndex,
setActiveIndex,
options,
registerOption,
unregisterOption,
}}>
<div className="select-container" onKeyDown={handleKeyDown}>
{/* trigger 部分 */}
<button onClick={() => setIsOpen(!isOpen)}>
{options.find(opt => opt.value === selectedValue)?.label || '请选择'}
</button>
{isOpen && (
<ul role="listbox" className="select-dropdown">
{children}
</ul>
)}
</div>
</SelectContext.Provider>
);
};
Select.Option = Option;
键盘导航的逻辑全在父组件里,子组件只负责渲染自己的高亮状态——它从 activeIndex 和 options 数组判断自己是不是当前活跃项。
注册模式的问题与替代方案
上面 Option 用 useEffect + registerOption 收集子组件信息,这有个明显的问题:options 数组的更新是异步的(依赖 useEffect 的执行时机),第一次渲染时 options 可能是空的,导致键盘导航的第一次按键不生效。
两种解决办法:
方案一:用 useMemo 同步收集,不走 state
在渲染期间直接从 children 中解析 Option 的 props:
const options = React.Children.map(children, (child) => {
if (React.isValidElement(child) && child.type === Option) {
return {
value: child.props.value,
label: child.props.children,
disabled: child.props.disabled || false,
};
}
return null;
})?.filter(Boolean) || [];
这样做的好处是同步的,没有延迟。缺点是只能解析直接子组件,如果 Option 被包了一层 div 或者其他组件就解析不到。
方案二:保持注册模式,但用 ref 存储避免重渲染
const optionsRef = useRef<OptionInfo[]>([]);
const registerOption = useCallback((option: OptionInfo) => {
optionsRef.current = [...optionsRef.current, option];
}, []);
键盘导航时直接读 optionsRef.current,不走 state,避免异步问题。但这样 option 的变化不会触发重渲染,activeIndex 的计算需要额外处理。
实际项目中,Ant Design 5.x 的 Select 用的就是方案一的变体——通过 React.Children 遍历并配合内部节点树来同步收集选项信息。Radix UI 则倾向于方案二,用 ref + 强制更新来处理。
常见问题
复合组件和 Render Props 模式什么关系?什么时候该用哪个?
两者解决不同问题。Compound Pattern 解决“一组相关组件共享隐式状态”,Render Props 解决“把渲染逻辑交给使用者控制”。如果你的组件有固定的子组件关系(比如 Tabs 必须有 TabPane),用 Compound Pattern;如果子组件的渲染方式完全不可预测,用 Render Props。实际项目中两者经常混用——父组件用 Compound Pattern 管理状态,同时暴露一个 renderTab 之类的 render prop 让使用者自定义 tab 的渲染。
为什么不用直接遍历 children 然后 cloneElement 注入 props?
React.cloneElement 注入 props 确实能做到隐式传参,但它有两个硬伤:只能影响直接子组件,嵌套一层就失效;会破坏子组件的 ref 和 memo 优化。Context 方案没有层级限制,任何深度的子组件都能消费,而且不会干扰组件的其他 props。
多个独立的 Tabs 同时存在会互相干扰吗?
不会。每个 Tabs 组件创建自己的 Context Provider,有独立的状态作用域。即使嵌套使用——外层 Tabs 包含内层 Tabs——内层的 TabPane 只会消费最近的 Provider,这是 React Context 的默认行为。需要注意的只有不要在同一个 Tabs 下混用不同 Tabs 的子组件,那会导致取不到正确的 context 直接报错。