前言:一种名为"配置地狱"的组件
接上回。咱们用 React Query 把服务端状态剥离了,用 Context 把全局状态理顺了。现在你的数据流很干净。
但是,当你打开 components 文件夹,看着那个被你改了无数次的 Tabs 组件,是不是又想骂人了?
为了满足产品经理五彩斑斓的需求,你的组件 props 越加越多,最后变成了这样:
tsx
// ❌ 典型的"配置型"组件
}, { title: '设置', content: }]}
activeTab={currentTab}
onTabChange={setCurrentTab}
tabBarClassName="bg-gray-100" // 想改 Tab 栏背景?加个 prop
tabItemClassName="text-lg" // 想改文字大小?加个 prop
activeTabClassName="text-blue" // 想改选中态颜色?再加个 prop
renderTabBarExtraContent={新建} // 想在右边加个按钮?又要加 prop
tabPosition="top" // 想把 Tab 放左边?还得加逻辑
/>
这就叫**"配置地狱"**。 你试图通过 props 暴露出所有的 UI 细节,结果就是这个组件变得巨臃肿,且极难复用。如果我想给第二个 Tab 加个红点(Badge),你是不是还得给 items 数组的数据结构里加个字段?
兄弟,别再折磨自己了。今天我们来学学 Compound Components(复合组件模式) 。看看人家 HTML 原生标签是怎么教我们做人的。
灵感来源:向 `` 致敬
你仔细想想,原生的 `` 标签是怎么用的?
苹果
香蕉
你并没有传一个 options 数组给 ,而是直接把 塞到了 `` 里面。
- `` 负责管理状态(当前选了谁)。
负责渲染每一项,并且告诉"我被点了"。
这种**"父组件管状态,子组件管渲染,通过隐式契约通信"**的模式,就是复合组件模式。
实战重构:把 Tabs 拆开
我们要把那个臃肿的 Tabs 组件,拆成 Tabs, TabList, Tab, TabPanels, Panel 这一套乐高积木。
第一步:创建上下文 (Context)
父组件需要一个地方来告诉子组件:现在的 activeTab 是谁,以及提供一个 setActiveTab 的方法。
import
const TabsContext = createContext(null);
// 这是一个自定义 Hook,方便子组件拿数据,顺便做个错误检查
const useTabs = () => {
const context = useContext(TabsContext);
if (!context) throw new Error('Tabs 子组件必须包裹在 里面!');
return context;
};
第二步:父组件 (Tabs) ------ 状态的大管家
它不负责画 UI,只负责提供 Context。
const
const [selectedIndex, setSelectedIndex] = useState(defaultIndex);
return (
<div>{children}</div>
);
};
第三步:子组件 (Tab & Panel) ------ 真正的打工人
Tab 按钮:
const
const { selectedIndex, setSelectedIndex } = useTabs();
const isActive = selectedIndex === index;
return (
setSelectedIndex(index)}
>
{children}
);
};
Panel 内容区:
const
const { selectedIndex } = useTabs();
// 只有选中时才渲染
return selectedIndex === index ? <div>{children}</div> : null;
};
见证奇迹的时刻:调用方式
重构完之后,我们在页面里怎么用呢?
//
{/* 你可以在这里随便加 div,随便写样式,完全不受 props 限制 */}
<div>
<div>
用户管理
{/* 居然可以给单独某一个 Tab 加红点,甚至加 Tooltip,随你便! */}
系统设置 <span>●</span>
</div>
{/* 想在右边加个按钮?直接写啊!不用传什么 renderExtraContent */}
刷新
</div>
<div>
</div>
对比一下之前的代码,现在的优势在哪里?
- UI 结构完全解耦 :你想把
Tab列表放在下面?想把Panel放在上面?随便你怎么排版 HTML,组件逻辑完全不需要改。 - 内容随心所欲 :你想在 Tab 标题里加图标?加红点?加 loading 动画?直接在
children里写 JSX 也就是了,不需要去改组件源码。 - 没有 Props Drilling :状态通过 Context 隐式传递,你不用手动把
activeTab传给每一个 Tab。
进阶技巧:这就是 Headless UI 的雏形
聪明的你可能发现了,这种模式其实就是我在前几篇提到的 Headless UI 的一种实现方式。
像著名的 UI 库 Radix UI 或者 Headless UI (Tailwind) ,全是这个路子。
- ``
- ``
- ``
- ``
它们把组件拆得稀碎,把**"怎么组合"**的权力交还给了你。
当然,这种模式也有个小缺点:代码量变多了。 以前写个 `` 只要一行,现在要写十几行。
怎么解? 你可以基于这个复合组件,再封装一层"傻瓜式"组件给没特殊需求的场景用。但是底层的实现,一定要保持这种灵活性。
总结
当你发现你的组件需要接受 xxxStyle, xxxClassName, renderXxx 这种 props 的时候,请立刻停下来。
这说明你在试图控制你控制不了的事情(外部的 UI 展示)。
把控制权交出去。用 Compound Components 模式,让使用者像拼乐高一样组装你的组件。 你会发现,你再也不用因为设计稿改了一个 margin 或者加了一个 icon 而去改组件源码了。这才是真正的高内聚、低耦合。
好了,我要去把那个传了 20 个 props 的 Modal 组件给拆了,祝大家的组件 API 永远性感。

下期预告 :Tabs 切换是搞定了,但有个问题:用户刷新页面后,又回到了第一个 Tab,辛辛苦苦填的表单也没了。 还有,我想把"当前在第二个 Tab"这个状态分享给同事,怎么做? 下一篇,我们来聊聊 "URL 即状态 (URL as State)" 。教你如何把 React 状态同步到 URL 参数里,让你的应用拥有"记忆"。