进阶使用
复合组件模式
<Menu>
<Menu.Item>首页</Menu.Item>
<Menu.Item>设置</Menu.Item>
</Menu>这种模式在 React 中被称为 “复合组件模式” (Compound Components Pattern)。
它的核心思想是:父组件 (Menu) 和子组件 (Menu.Item) 像是一个团队,它们之间共享状态,但不需要你在使用时手动传递一堆 props。
<!-- 这就像 HTML 原生的 <select> 和 <option> 标签: -->
<select>
<option value="1">选项一</option>
<option value="2">选项二</option>
</select>1.为什么要这么写?
如果不使用这种模式,我们要实现一个菜单,可能得写成这样(很难看):
// ❌ 普通写法:所有数据都得通过 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>复合组件的优势:
- 代码更简洁:你不需要在每个子组件都写一堆 props,只需要写在父组件上。
- 更自然的 API:用户在使用时,就像在使用 HTML 标签一样自然。
- 更好的封装:父组件可以控制子组件的状态和行为,而不需要暴露太多的细节。
2.它是如何实现的?
实现的核心通常依赖 React Context API。
第一步:创建 Context (通信桥梁)
- 首先我们需要一个上下文,用来在 Menu 和 Item 之间传输数据。
// Menu.jsx
import React, { createContext, useState, useContext } from 'react';
// 创建一个 Context 对象
const MenuContext = createContext();第二步:实现父组件 Menu
- 父组件负责管理“哪个被选中了”这个状态,并通过
Context提供给子组件。
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里拿数据,判断自己是不是被选中了。
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 中,函数也是对象,可以给函数添加属性。
// 把 Item 组件挂载到 Menu 函数上,作为它的属性
Menu.Item = Item;
// 导出 Menu 即可
export default Menu;最终效果
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
MenuContext.Provider是哪来的?
- 它是 createContext() 自动生成的。
- Provider (提供者):
MenuContext.Provider—— 用来包裹父组件,负责广播数据。- Consumer (消费者):
MenuContext.Consumer—— (现在很少用了,现在都用 useContext 钩子来接收数据)。
所以,你不需要额外导入 Provider,它就挂在 MenuContext 这个对象下面。
React黑魔法
React.Children.map加上React.cloneElement—— 常被称为 React 的“黑魔法”。
它能让你做到:父组件可以直接修改子组件的 Props,哪怕子组件原本没有写这些 Props。
1.为什么要用这个?
想象你要做一个 RadioGroup(单选框组)。
// 普通写法(很啰嗦): 你必须给每个 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.它是如何实现的
这需要两个工具配合
React.Children.map(children, function)- 作用:安全地遍历
props.children - 为什么不用普通的
children.map? 因为children如果只有一个子元素,它就是个对象;如果有多个,它是数组;如果没有,它是null。React 的这个工具函数能自动处理所有这些情况,防止报错。
- 作用:安全地遍历
React.cloneElement(element, newProps)- 作用:克隆一个 React 元素,并混入新的 Props。
- 为什么不直接改
element.props? 记住,Props是只读的! 你不能直接改。你必须“克隆”一份新的,在克隆的时候把新数据塞进去。
具体实现 RadioGroup
- 我们要实现的功能:父组件拿到
name,把它强制塞给所有子组件。
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 有个大坑:它只能操作直接子元素。 如果你这样写,代码就失效了:
// ❌ 失效: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) -> 渲染篡改后的节点
- 核心价值:它是开发组件库的利器,能让用户写出极其简洁的代码(把脏活累活都藏在父组件里)。