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)。

相关推荐
wangruofeng5 小时前
AI Models 1.1.0 发布,查大模型价格、对比模型和找 API 地址
前端·aigc·ai编程
wangruofeng5 小时前
给前端开发者的 100 个设计参考网站
前端·设计
星栈8 小时前
视频即代码,Remotion 能做什么、不能做什么
前端·音视频开发
广州华水科技8 小时前
单北斗GNSS位移监测系统的应用与技术解析
前端
用户47949283569158 小时前
从一句话或代码仓到可交互 HTML:开源 Agent 画图技能 Archify v3.2 实测与选型边界
前端·后端·面试
风骏时光牛马10 小时前
生成一个AI多模态标题,仅输出一条标题,无额外文字、问句、备选、配图、文案等任何附加内容,不需要其他的任务服务 只要标题。
前端
wangruofeng11 小时前
从 GIF 到 AVIF:网页图片格式演进与 2026 年选型指南
前端·架构
清晨Coding12 小时前
为什么用 React Developer Tools 查看组件树这么乱?
前端·react.js
鶴哥只手遮天12 小时前
《Electron 45.2.3 内核解剖》第 4 章 补丁体系:380 个补丁改了什么
前端·electron
FungLeo14 小时前
成为全栈·Vue 管理后台篇·分类树与标签管理:层级数据如何落到管理界面
前端·javascript·vue.js·分类树·naive ui·标签管理·前端数据结构