React 组件的组合模式之道 (Composition Pattern)

基于 React Universe Conf 2025 中 Fernando Rojo 的演讲《Composition is all you need》,以下是关于如何使用**组合模式(Composition Pattern)**重构复杂 React 组件的教程和代码总结。


React 组件的组合模式之道 (Composition Pattern)

1. 核心问题:单体组件的陷阱 (The Monolith Trap)

在开发初期,我们通常创建一个简单的组件(如 Composer 输入框)。随着需求增加(支持多态、编辑模式、转发模式等),我们往往通过添加 Boolean 属性来控制功能。

反模式代码示例:

jsx 复制代码
// ❌ 典型的"单体"组件,充满条件判断
function Composer({ 
  onSubmit, 
  isThread, 
  isEditingMessage, 
  initialText, 
  onCancel 
}) {
  return (
    <div className="composer">
      {/* 只有非编辑模式才支持拖拽 */}
      {!isEditingMessage && <DropZone />}
      
      <Header />
      <Input defaultValue={initialText} />
      
      {/* 线程模式下的额外选项 */}
      {isThread && <Checkbox label="Also send to channel" />}
      
      <Footer>
        {/* 只有非编辑模式才显示附件按钮 */}
        {!isEditingMessage && <AttachmentButton />}
        
        {/* 提交按钮逻辑复杂 */}
        {isEditingMessage ? (
           <>
             <Button onClick={onCancel}>Cancel</Button>
             <Button onClick={onSubmit}>Save</Button>
           </>
        ) : (
           <Button onClick={onSubmit}>Send</Button>
        )}
      </Footer>
    </div>
  );
}

缺点: 代码难以维护,条件渲染(Ternary Hell)泛滥,且容易出现不可能的状态组合。


2. 解决方案:组合模式 (Composition)

与其通过属性(Props)告诉组件做什么 ,不如通过子组件(Children)直接构建 组件。这种方式类似于 Radix UI 的设计理念。

我们将大组件拆分为多个小的、职责单一的子组件,并通过 Context 共享状态。

基础架构代码

jsx 复制代码
// 1. 创建 Context
const ComposerContext = createContext(null);

// 2. Provider 组件:管理状态和对外接口
const ComposerProvider = ({ children, state, actions, meta }) => {
  return (
    <ComposerContext.Provider value={{ state, actions, meta }}>
      {children}
    </ComposerContext.Provider>
  );
};

// 3. 子组件:消费 Context
const ComposerInput = () => {
  const { state, actions } = useContext(ComposerContext);
  return (
    <input 
      value={state.text} 
      onChange={(e) => actions.update(e.target.value)} 
    />
  );
};

// ... 其他子组件 (Composer.Header, Composer.Footer, etc.)

3. 实战重构:构建不同的 Composer

通过组合,我们可以在不修改内部逻辑的情况下,构建出完全不同的 UI 变体。

场景 A:基础频道输入框 (Channel Composer)

jsx 复制代码
function ChannelComposer() {
  // 使用自定义 Hook 获取全局频道逻辑
  const { state, actions } = useChannelLogic(); 

  return (
    <Composer.Provider state={state} actions={actions}>
      <Composer.DropZone />
      <Composer.Frame>
        <Composer.Header />
        <Composer.Input />
        <Composer.Footer>
          {/* 使用封装好的通用操作组 */}
          <Composer.CommonActions /> 
          <Composer.SubmitButton />
        </Composer.Footer>
      </Composer.Frame>
    </Composer.Provider>
  );
}

场景 B:编辑消息输入框 (Edit Message Composer)

需求差异:

  1. 不需要拖拽上传 (DropZone)。
  2. 底部按钮不同(取消/保存)。
  3. 某些操作按钮不可见(如附件)。

组合实现: 我们只需要不渲染 不需要的组件,并替换 底部的按钮即可,无需任何 Boolean 属性。

jsx 复制代码
function EditMessageComposer({ messageId, initialText, onCancel }) {
  const { state, actions } = useEditMessageLogic(messageId, initialText);

  return (
    <Composer.Provider state={state} actions={actions}>
      {/* 移除 DropZone */}
      <Composer.Frame>
        <Composer.Header />
        <Composer.Input />
        <Composer.Footer>
          {/* 手动列出需要的 Action,而不是用通用的 */}
          <Composer.FormatText />
          <Composer.Emoji />
          
          {/* 自定义底部按钮布局 */}
          <div className="flex gap-2">
            <Button onClick={onCancel}>Cancel</Button>
            <Button onClick={actions.submit}>Save</Button>
          </div>
        </Composer.Footer>
      </Composer.Frame>
    </Composer.Provider>
  );
}

4. 进阶技巧:状态提升与解耦 (Lift Your State)

这是该演讲最核心的观点。状态管理应该与 UI 组件解耦。

Composer 的 UI 组件(Input, Footer 等)不应该知道状态是来自于 useState(本地状态)还是 useGlobalStore(全局同步状态)。它们只负责渲染 Provider 提供的数据。

场景 C:转发消息 (Forward Message)

复杂点:

  1. 这是一个模态框(Modal)。
  2. 提交按钮在 Composer 外部(Modal 的 Footer)。
  3. 状态是临时的(Ephemeral),不需要同步到服务器。

代码实现:

jsx 复制代码
function ForwardMessageDialog() {
  // 1. 状态提升:在父组件控制状态
  const [text, setText] = useState("");
  const inputRef = useRef(null);

  // 定义符合 Provider 接口的 state 和 actions
  const state = { text };
  const actions = { 
    update: setText, 
    submit: () => console.log("Forwarding:", text) 
  };

  return (
    <Dialog>
      {/* 2. 将本地状态注入 Provider */}
      <Composer.Provider state={state} actions={actions} meta={{ inputRef }}>
        
        {/* UI 部分 */}
        <Composer.Frame>
          <Composer.Input /> 
          <Composer.Footer>
             {/* 只有少量的操作按钮 */}
             <Composer.Emoji />
          </Composer.Footer>
        </Composer.Frame>

        {/* 3. 外部按钮也可以消费同一个 Context */}
        <Dialog.Footer>
           <CopyLinkButton />
           {/* 这个按钮在 Composer 外部,但能触发提交 */}
           <ForwardButton /> 
        </Dialog.Footer>

      </Composer.Provider>
    </Dialog>
  );
}

// 外部按钮实现
const ForwardButton = () => {
  // 因为被包在 Composer.Provider 内,依然可以访问 context
  const { actions } = useContext(ComposerContext);
  return <Button onClick={actions.submit}>Forward</Button>;
}

总结:为什么要这样做?

  1. 消除"布尔地狱": 不再需要传递 isEditing={true}isForwarding={true} 并在组件深处做判断。需要什么功能,就渲染什么组件。
  2. 状态灵活性: 同一套 UI 组件可以配合 useState(本地)、Redux/Zustand(全局)甚至 Ref 一起工作,只要通过 Provider 传入即可。
  3. 可维护性: 当需要在"转发"功能中修改按钮样式时,你只需要修改 ForwardMessageDialog,完全不会影响到"频道聊天"的代码。
  4. AI 友好: 这种结构化、声明式的代码更容易被 AI 理解和生成,减少了 AI 产生幻觉(Hallucination)或逻辑错误的概率。

一句话总结: 不要把所有的逻辑塞进一个组件里。提升你的状态 (Lift your state),组合你的内部组件 (Compose your internals)。

相关推荐
晚烛2 分钟前
实战前瞻:构建高可靠、低延迟的 Flutter + OpenHarmony 智慧交通出行平台
前端·javascript·flutter
WHOVENLY25 分钟前
【javaScript】- 作用域[[scope]]
前端·javascript
来杯三花豆奶30 分钟前
Vue3 Pinia 从入门到精通
前端·javascript·vue.js
卡布叻_星星1 小时前
Docker之Nginx前端部署(Windows版-x86_64(AMD64)-离线)
前端·windows·nginx
LYFlied1 小时前
【算法解题模板】-解二叉树相关算法题的技巧
前端·数据结构·算法·leetcode
weibkreuz1 小时前
React的基本使用@2
前端·javascript·react.js
于是我说1 小时前
前端JavaScript 项目中 获取当前页面滚动位置
开发语言·前端·javascript
GISer_Jing1 小时前
AI在前端开发&营销领域应用
前端·aigc·音视频
Hao_Harrision1 小时前
50天50个小项目 (React19 + Tailwindcss V4) ✨ | DragNDrop(拖拽占用组件)
前端·react.js·typescript·tailwindcss·vite7
来杯三花豆奶2 小时前
Vue 2.0 Mixins 详解:从原理到实践的深度解析
前端·javascript·vue.js