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,然后把 activeKeysetActiveKey 塞进 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,自己判断 isActivesetActiveKey 也一样,tab 的点击事件由 TabPane 内部调用 setActiveKey(key),不需要使用者写 onClick

第三步:把子组件挂到父组件上作为静态属性

Tabs.TabPane = TabPane;
export default Tabs;

这一步让 Tabs.TabPane 这个写法成立。本质上 TabPane 就是个独立的函数组件,只是被挂到了 Tabs 的静态属性上。使用者 import 的时候只需要引入 TabsTabPane 自动可用。

完整的最小实现不到 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;

键盘导航的逻辑全在父组件里,子组件只负责渲染自己的高亮状态——它从 activeIndexoptions 数组判断自己是不是当前活跃项。

注册模式的问题与替代方案

上面 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 直接报错。