Skip to content

进阶使用

复合组件模式

jsx
<Menu>
    <Menu.Item>首页</Menu.Item>
    <Menu.Item>设置</Menu.Item>
</Menu>

这种模式在 React 中被称为 “复合组件模式” (Compound Components Pattern)。

它的核心思想是:父组件 (Menu) 和子组件 (Menu.Item) 像是一个团队,它们之间共享状态,但不需要你在使用时手动传递一堆 props。

html
<!-- 这就像 HTML 原生的 <select> 和 <option> 标签: -->
<select>
  <option value="1">选项一</option>
  <option value="2">选项二</option>
</select>

1.为什么要这么写?

如果不使用这种模式,我们要实现一个菜单,可能得写成这样(很难看):

jsx
// ❌ 普通写法:所有数据都得通过 props 传给父组件,或者父组件把函数一个个传给子组件
<Menu
  items={['首页', '设置']}
  activeItem="首页"
  onItemClick={(item) => console.log(item)}
/>

// 或者这样:

// ❌ 繁琐写法:需要手动透传 props
<Menu>
  <MenuItem active={current === 'home'} onClick={handleClick} label="首页" />
  <MenuItem active={current === 'settings'} onClick={handleClick} label="设置" />
</Menu>

复合组件的优势:

  1. 代码更简洁:你不需要在每个子组件都写一堆 props,只需要写在父组件上。
  2. 更自然的 API:用户在使用时,就像在使用 HTML 标签一样自然。
  3. 更好的封装:父组件可以控制子组件的状态和行为,而不需要暴露太多的细节。

2.它是如何实现的?

实现的核心通常依赖 React Context API。

第一步:创建 Context (通信桥梁)

  • 首先我们需要一个上下文,用来在 Menu 和 Item 之间传输数据。
jsx
// Menu.jsx
import React, { createContext, useState, useContext } from 'react';

// 创建一个 Context 对象
const MenuContext = createContext();

第二步:实现父组件 Menu

  • 父组件负责管理“哪个被选中了”这个状态,并通过 Context 提供给子组件。
jsx
function Menu({ children }) {
  // 1. 父组件管理状态:当前选中的是哪个?
  const [activeItem, setActiveItem] = useState("");

  // 2. 把状态和改变状态的方法,打包放进 context
  const contextValue = { activeItem, setActiveItem };

  return (
    <MenuContext.Provider value={contextValue}>
      <div className="menu-container">
        {children}
      </div>
    </MenuContext.Provider>
  );
}

第三步:实现子组件 Item

  • 子组件从 Context 里拿数据,判断自己是不是被选中了。
jsx
function Item({ children, name }) {
  // 1. 子组件通过 useContext 拿到父组件给的数据
  const { activeItem, setActiveItem } = useContext(MenuContext);

  // 2. 判断自己是否被选中
  const isActive = activeItem === name;

  return (
    <div
      onClick={() => setActiveItem(name)} // 点击时更新父组件状态
      style={{
        color: isActive ? "red" : "black",
        fontWeight: isActive ? "bold" : "normal",
        cursor: "pointer",
        padding: "5px"
      }}
    >
      {children}
    </div>
  );
}

第四步:挂载子组件 (实现 Menu.Item 语法)

  • 这一步是 JavaScript 的特性。在 JS 中,函数也是对象,可以给函数添加属性。
jsx
// 把 Item 组件挂载到 Menu 函数上,作为它的属性
Menu.Item = Item;

// 导出 Menu 即可
export default Menu;

最终效果

jsx
function App() {
  return (
    <Menu>
      {/* 这里的 name 对应状态值 */}
      <Menu.Item name="home">首页</Menu.Item>
      <Menu.Item name="settings">设置</Menu.Item>
      <Menu.Item name="profile">个人中心</Menu.Item>
    </Menu>
  );
}

发生了什么

  • <Menu> 初始化了 activeItem 状态。
  • <Menu.Item> 渲染时,自动从 MenuContext 里读取状态。
  • 当你点击“设置”,它调用 setActiveItem('settings')
  • React 检测到 Context 变了,所有 Item 重新渲染,“设置”变红,“首页”变黑。

实现原理:Context API (传递状态) + Function Properties (实现点语法 Menu.Item)。

FAQs

  1. MenuContext.Provider 是哪来的?
  • 它是 createContext() 自动生成的。
  • Provider (提供者): MenuContext.Provider —— 用来包裹父组件,负责广播数据。
  • Consumer (消费者): MenuContext.Consumer —— (现在很少用了,现在都用 useContext 钩子来接收数据)。

所以,你不需要额外导入 Provider,它就挂在 MenuContext 这个对象下面。

React黑魔法

React.Children.map 加上 React.cloneElement —— 常被称为 React 的“黑魔法”。

它能让你做到:父组件可以直接修改子组件的 Props,哪怕子组件原本没有写这些 Props。

1.为什么要用这个?

想象你要做一个 RadioGroup(单选框组)。

jsx
// 普通写法(很啰嗦): 你必须给每个 Radio 都传一遍 name,否则它们就不属于同一组了。
// ❌ 烦人:每个都要写 name="gender"
<RadioGroup>
  <Radio name="gender" value="male">男</Radio>
  <Radio name="gender" value="female">女</Radio>
</RadioGroup>

// 我们想要的效果(优雅): 只在父组件写一次 name,自动传给所有子组件。
// ✅ 优雅:父组件把 name="gender" 偷偷塞给子组件
<RadioGroup name="gender">
  <Radio value="male">男</Radio>
  <Radio value="female">女</Radio>
</RadioGroup>

要实现这个,父组件必须拦截子组件,给它们打上标记(注入 Props),然后再渲染出来。

2.它是如何实现的

这需要两个工具配合

  1. React.Children.map(children, function)

    • 作用:安全地遍历 props.children
    • 为什么不用普通的 children.map? 因为 children 如果只有一个子元素,它就是个对象;如果有多个,它是数组;如果没有,它是 null。React 的这个工具函数能自动处理所有这些情况,防止报错。
  2. React.cloneElement(element, newProps)

    • 作用:克隆一个 React 元素,并混入新的 Props。
    • 为什么不直接改 element.props? 记住,Props 是只读的! 你不能直接改。你必须“克隆”一份新的,在克隆的时候把新数据塞进去。

具体实现 RadioGroup

  • 我们要实现的功能:父组件拿到 name,把它强制塞给所有子组件。
jsx
import React, { useState } from 'react';

// 1. 子组件 Radio (非常简单,只是个接收 props 的 UI)
// 注意:它这里接收了 name,但我们在使用时并不会手动传给它
function Radio({ children, name, value, onChange, checked }) {
  return (
    <label style={{ display: "block", margin: "5px" }}>
      <input
        type="radio"
        name={name}      // 这里的 name 将来自父组件的注入
        value={value}
        checked={checked}
        onChange={onChange}
      />
      {children}
    </label>
  );
}

// 2. 父组件 RadioGroup (黑魔法发生的地方)
function RadioGroup({ children, name }) {
  const [selectedValue, setSelectedValue] = useState("");

  // --- 关键步骤开始 ---
  
  // 遍历所有的子组件 (children)
  const childrenWithProps = React.Children.map(children, (child) => {
    // 检查 child 是不是一个有效的 React 元素,防止有人传了文本进来报错
    if (React.isValidElement(child)) {
      // 克隆这个 child,并混入新的 props
      return React.cloneElement(child, {
        name: name,                             // 注入 name
        checked: child.props.value === selectedValue, // 注入 checked 状态
        onChange: (e) => setSelectedValue(e.target.value) // 注入点击事件
      });
    }
    return child;
  });

  // --- 关键步骤结束 ---

  return <div>{childrenWithProps}</div>;
}

// 3. 使用 (看,多么简洁!)
export default function App() {
  return (
    <RadioGroup name="gender">
      <Radio value="male">男</Radio>
      <Radio value="female">女</Radio>
      <Radio value="alien">外星人</Radio>
    </RadioGroup>
  );
}

Context vs cloneElement

你可能会问:“刚才那个 Menu 组件用 Context 也能做啊,为什么要学这个?”

特 性Context 模式cloneElement 模式
灵活性高。子组件可以嵌套很深(比如包在 div 里)。低。子组件必须是直接子节点 (Direct Children)。
显式程度隐式。数据在 Context 里“隔空投送”。显式。像流水线一样处理子组件。
适用场景复杂的组件树,跨越多层级。简单的父子强绑定,如 List/Item, RadioGroup。

警示 ⚠️: React.Children.map 有个大坑:它只能操作直接子元素。 如果你这样写,代码就失效了:

jsx
// ❌ 失效:React.Children.map 只能操作直接子元素,不能递归到更深层。
<RadioGroup name="gender">
  <div> {/* 多套了一层 div,RadioGroup 就抓不到 Radio 了 */}
    <Radio value="male">男</Radio>
  </div>
</RadioGroup>

这时候 RadioGroup 遍历到的 child 是 div,它给 div 注入了 name 和 onChange,而里面的 Radio 啥也没收到。这种情况就必须用 Context。

  • 黑魔法口诀:遍历子节点 (Children.map) -> 复制并篡改 (cloneElement) -> 渲染篡改后的节点
  • 核心价值:它是开发组件库的利器,能让用户写出极其简洁的代码(把脏活累活都藏在父组件里)。