前端向架构突围系列 - 模块化 [4 - 3]:复杂组件的通信与组合模式

116 阅读5分钟

写在前面

在封装复杂组件时,习惯使用**“配置对象驱动”**的模式。

比如写一个 Tabs 组件,他们会定义一个 items 属性,让用户传入一个数组:[{ title: 'A', content: '...' }]。 这种写法看似简洁,实则是架构的死胡同。一旦用户说:“我想在第二个 Tab 的标题旁边加个红点”,或者“我想让第三个 Tab 的内容懒加载”,你的组件 API 就会瞬间爆炸,变成 renderTitlelazyLoad 等无数个补丁属性。

直觉告诉我们:好的组件 API 应该是声明式的,而不是配置式的。

本篇我们将探讨如何通过复合组件(Compound Components)模式重建父子关系,利用隐式状态共享消灭 Props Drilling,并最终通过控制反转实现组件能力的无限扩展。

image.png


一、 拒绝巨型配置:复合组件 (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,向下广播 activeIndexsetActiveIndex。子组件自动订阅,无需用户显式传递。


二、 隐形纽带: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

  1. 检查用户是否传入了 value 属性。
  2. 如果传入了 value,则判定为受控模式,内部状态直接引用 valuesetState 只触发 onChange
  3. 如果没传,则判定为非受控模式,内部维护 useStatesetState 既更新内部状态也触发 onChange

TypeScript

// 完美的组件接口
<Tabs /> // 模式 A: 内部自己玩,非受控
<Tabs value={index} onChange={setIndex} /> // 模式 B: 外部完全控制,受控

这种兼容性设计,是区分“玩具组件”和“工程级组件”的重要分水岭。


五、 结语:组件设计的冰山之下

当我们谈论组件化时,不要只盯着 UI 看。 Headless UI 解决了垂直方向的逻辑剥离,而 Compound Components 和 State Reducer 解决了水平方向的通信与扩展。

一个优秀的架构师,在设计组件时,脑海里不应该只有 DOM 结构,而应该是一张张状态流转图

至此,我们已经搭建好了组件的“骨架”。但一个活的系统,还需要血液的流动——即模块与模块之间如何解耦通信? 除了 props 和 context,我们是否还有更强大的武器来应对跨模块的复杂联动?

Next Step: 下一节,我们将进入“灵魂”篇,探讨前端设计模式中最经典、也最容易被滥用的模式。 请看**《第四篇:灵魂(上)——解耦的神器:前端核心设计模式之观察者与发布订阅》**。