写在前面
在封装复杂组件时,习惯使用**“配置对象驱动”**的模式。
比如写一个 Tabs 组件,他们会定义一个
items属性,让用户传入一个数组:[{ title: 'A', content: '...' }]。 这种写法看似简洁,实则是架构的死胡同。一旦用户说:“我想在第二个 Tab 的标题旁边加个红点”,或者“我想让第三个 Tab 的内容懒加载”,你的组件 API 就会瞬间爆炸,变成renderTitle、lazyLoad等无数个补丁属性。直觉告诉我们:好的组件 API 应该是声明式的,而不是配置式的。
本篇我们将探讨如何通过复合组件(Compound Components)模式重建父子关系,利用隐式状态共享消灭 Props Drilling,并最终通过控制反转实现组件能力的无限扩展。
一、 拒绝巨型配置:复合组件 (Compound Components) 的哲学
复合组件模式的核心思想是:组件不应该是一个黑盒,而应该是一组协同工作的零件。
1.1 什么是复合组件?
看看 HTML 原生的 <select> 和 <option>,这就是世界上最古老且完美的复合组件:
<select>
<option value="1">Option A</option>
<option value="2">Option B</option>
</select>
它们分开写,但共享同一个状态(当前选中的值)。
1.2 实战:重构 Tabs 组件
错误示范(配置式):
// 扩展性极差,只能通过增加 props 来修补
<Tabs
items={[{ title: 'Tab 1', content: 'Content 1' }]}
activeTabColor="red"
renderTitle={(title) => <span>{title}</span>} // 丑陋的补丁
/>
架构级示范(复合式):
// 声明式,结构清晰,用户拥有完全的渲染控制权
<Tabs defaultIndex={0} onChange={console.log}>
<Tabs.List>
<Tabs.Tab>Tab 1</Tabs.Tab>
<Tabs.Tab disabled>Tab 2</Tabs.Tab>
<Tabs.Tab>
<Badge>Tab 3</Badge> {/* 用户可以随意组合 */}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panels>
<Tabs.Panel>Content 1</Tabs.Panel>
<Tabs.Panel>Content 2</Tabs.Panel>
<Tabs.Panel>Content 3</Tabs.Panel>
</Tabs.Panels>
</Tabs>
底层实现原理: 这不仅仅是把组件切开那么简单。为了让 <Tabs.Tab> 知道自己是否被选中,我们需要使用 Context 进行隐式状态通信。 父组件 <Tabs> 创建一个 Context,向下广播 activeIndex 和 setActiveIndex。子组件自动订阅,无需用户显式传递。
二、 隐形纽带:Context Module Pattern (模块化上下文)
在复合组件中,Context 是胶水。但架构师要注意:不要把 Context 暴露给全世界。
2.1 作用域污染的风险
如果你在全局定义了一个 TabContext 并导出,很可能会被其他组件误用,或者在嵌套的 Tabs 中发生冲突(内层 Tab 消费了外层 Tab 的 Context)。
2.2 最佳实践:创建自定义 Scope
参考 Radix UI 或 React Spectrum 的设计,我们应该为每个复合组件创建独立的 Context Scope。
// 伪代码:构建受保护的 Context
const [TabsProvider, useTabsContext] = createContextScope("Tabs");
function TabsRoot({ children }) {
// ... 状态逻辑
return <TabsProvider value={state}>{children}</TabsProvider>;
}
function Tab({ children }) {
// 只能在 TabsRoot 内部使用,否则报错
const context = useTabsContext("Tab");
return ...;
}
这种模式保证了组件的自洽性。用户不需要关心 Tabs 内部是怎么通信的,他们只管像拼乐高一样组合组件。
三、 终极控制权:State Reducer (状态归约器)
当你封装了一个通用组件,总会遇到这种需求:
- “我想让 Modal 点击遮罩层关闭,但按下 ESC 键时不关闭。”
- “我想让 Switch 开关只能打开,不能关闭(一次性锁死)。”
初级做法是加 Props:closeOnEsc={false}, preventToggleOff={true}。 高级做法是:Inversion of Control (控制反转)。
借鉴 Redux 的思想,我们可以允许用户传入一个 stateReducer,拦截并篡改组件内部的状态更新。
JavaScript
// 组件内部实现
function useToggle({ reducer = (state, action) => action.changes }) {
const [on, setOn] = useState(false);
const toggle = () => {
// 在更新前,先问问用户的 reducer
const changes = { on: !on };
const finalState = reducer(on, { type: 'TOGGLE', changes });
setOn(finalState.on);
};
// ...
}
// 用户使用:彻底改变组件行为,而无需修改组件源码
<Toggle
stateReducer={(state, action) => {
// 拦截:如果是点击操作且想要关闭,则阻止
if (action.type === 'TOGGLE' && state.on === true) {
return { on: true }; // 强制保持开启
}
return action.changes; // 否则默认行为
}}
/>
架构价值: stateReducer 模式将**“状态更新的逻辑”**从组件内部剥离出来,交给了使用者。这使得组件能够适应极其边缘的业务场景,而无需增加任何 API 负担。
四、 哲学思辨:受控与非受控的完美共存
这是组件设计中最经典的难题:State 到底应该由组件自己管(非受控),还是由父组件管(受控)?
- 非受控 (Uncontrolled):
<input defaultValue="hello" />。简单,不需要写 onChange,但父组件很难干预。 - 受控 (Controlled):
<input value={val} onChange={setVal} />。灵活,但父组件必须维护状态,哪怕是很简单的场景。
架构师的答案:Hybrid (混合模式)。 优秀的组件库(如 AntD, MUI)都支持“双模式”。
4.1 混合 Hook 的实现机制
你需要实现一个 useControllableState:
- 检查用户是否传入了
value属性。 - 如果传入了
value,则判定为受控模式,内部状态直接引用value,setState只触发onChange。 - 如果没传,则判定为非受控模式,内部维护
useState,setState既更新内部状态也触发onChange。
TypeScript
// 完美的组件接口
<Tabs /> // 模式 A: 内部自己玩,非受控
<Tabs value={index} onChange={setIndex} /> // 模式 B: 外部完全控制,受控
这种兼容性设计,是区分“玩具组件”和“工程级组件”的重要分水岭。
五、 结语:组件设计的冰山之下
当我们谈论组件化时,不要只盯着 UI 看。 Headless UI 解决了垂直方向的逻辑剥离,而 Compound Components 和 State Reducer 解决了水平方向的通信与扩展。
一个优秀的架构师,在设计组件时,脑海里不应该只有 DOM 结构,而应该是一张张状态流转图。
至此,我们已经搭建好了组件的“骨架”。但一个活的系统,还需要血液的流动——即模块与模块之间如何解耦通信? 除了 props 和 context,我们是否还有更强大的武器来应对跨模块的复杂联动?
Next Step: 下一节,我们将进入“灵魂”篇,探讨前端设计模式中最经典、也最容易被滥用的模式。 请看**《第四篇:灵魂(上)——解耦的神器:前端核心设计模式之观察者与发布订阅》**。