组件设计模式 —— 写出专业级React代码
前面几章我们学会了用组件搭建界面、用 Hooks 管理状态和副作用。但当项目规模增长,你会发现”能跑”和”好维护”是两码事。组件越写越大、逻辑和 UI 搅在一起、同样的功能在三个地方重复实现——这些问题的根源不是技术能力不够,而是缺少设计模式的指导。本章将带你掌握 React 社区经过多年实战沉淀下来的经典组件设计模式,让你从”能写组件”升级到”会设计组件”。
📋 开篇自测:你已经知道多少?
- 你能说出容器组件和展示组件的区别,以及为什么要这样拆分吗?
- 高阶组件(HOC)和 Render Props 各自解决什么问题?它们的优缺点分别是什么?
- 如果让你设计一个
<Tabs>组件,你会用什么模式让<Tabs>和<TabPanel>之间自动共享状态?
一、容器组件与展示组件 —— 逻辑与视图分离
1.1 一面墙里藏着电线和水管
想象你装修一间房子。从外面看,墙壁干干净净,刷着白漆;但墙壁内部藏着错综复杂的电线和水管。如果把电线直接钉在墙外面,虽然也能用,但既不好看也不好维护。
React 组件也是同样的道理。一个组件如果既负责请求数据、处理业务逻辑,又负责渲染 UI,就像是把电线钉在墙外面——功能上能跑,但耦合度太高。
展示组件(Presentational Component) 就是那面干净的墙:它只关心”东西长什么样”,所有数据都通过 props 接收,内部不做任何数据获取。
容器组件(Container Component) 就是墙后面的管线系统:它负责获取数据、管理状态、处理业务逻辑,然后把数据传给展示组件去渲染。
1.2 没有拆分 vs 拆分后
来看一个用户资料卡片的例子。先看”不拆分”的写法:
// 不拆分:逻辑和视图混在一起
function UserCard({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return <div className="skeleton" />;
return (
<div className="user-card">
<img src={user.avatar} alt={user.name} />
<h2>{user.name}</h2>
<p>{user.bio}</p>
<span className="badge">{user.role}</span>
</div>
);
}
这个组件有一个明显的问题:数据获取逻辑和 UI 渲染绑死在一起了。如果你想在另一个页面用同样的卡片样式展示不同来源的数据,你只能复制粘贴整个 UI 代码。
现在我们把它拆开:
// 展示组件:只负责渲染,数据全部来自 props
function UserCardView({ user, loading }) {
if (loading) return <div className="skeleton" />;
return (
<div className="user-card">
<img src={user.avatar} alt={user.name} />
<h2>{user.name}</h2>
<p>{user.bio}</p>
<span className="badge">{user.role}</span>
</div>
);
}
// 容器组件:只负责获取数据和管理状态
function UserCardContainer({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
return <UserCardView user={user} loading={loading} />;
}
拆分之后的好处一目了然:
UserCardView变成了一个纯粹的”画笔”,你可以传入任何数据源(API、本地 Mock、Storybook)来渲染它- 容器组件可以随时替换数据获取方式(从 REST 改成 GraphQL),展示组件完全不受影响
- 展示组件可以独立做单元测试和视觉回归测试,不需要 Mock 网络请求
1.3 在 Hooks 时代的演变
在 Hooks 出现之前,容器/展示模式几乎是”强制执行”的。但现在,你可以用自定义 Hook 来替代容器组件的角色:
// 自定义 Hook 承担了"容器"的职责
function useUser(userId) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
return { user, loading };
}
// 组件直接使用 Hook,不需要单独的容器组件
function UserCard({ userId }) {
const { user, loading } = useUser(userId);
return <UserCardView user={user} loading={loading} />;
}
自定义 Hook 让”逻辑复用”变得更轻量,你不需要再为每一份逻辑创建一个容器组件。但容器/展示的思维方式依然重要——它提醒你时刻思考:“这段代码是关于’做什么’还是’长什么样’?”
🤔 想一想 你手头的项目里,有没有某个组件既在请求数据,又在写复杂的渲染逻辑?试着把它拆成一个自定义 Hook + 一个纯展示组件,看看代码是不是更清晰了。
二、高阶组件(HOC)—— 组件增强的经典模式
2.1 什么是高阶组件
你一定听说过”高阶函数”——接收函数作为参数、返回新函数的函数,比如 Array.map、Array.filter。高阶组件的思路完全一样:接收一个组件作为参数,返回一个增强后的新组件。
你可以把它类比成手机壳:手机本身的功能不变,但套上壳之后它可能多了支架、防摔、卡包等附加功能。HOC 就是给组件”套壳”的工厂函数。
// HOC 的基本结构
function withSomething(WrappedComponent) {
return function EnhancedComponent(props) {
// 在这里添加额外的逻辑
const extraData = useSomeLogic();
// 把原始 props 和额外数据一起传给被包装的组件
return <WrappedComponent {...props} extraData={extraData} />;
};
}
2.2 实例一:withAuth —— 权限拦截
项目里有十几个页面需要登录才能访问。你不可能在每个页面组件里都写一遍”检查登录状态”的逻辑。HOC 就是为这种场景而生的:
// 不使用 HOC:每个页面都要写权限判断
function Dashboard() {
const { user } = useAuth();
if (!user) return <Navigate to="/login" />;
return <div>仪表盘内容...</div>;
}
function Settings() {
const { user } = useAuth();
if (!user) return <Navigate to="/login" />;
return <div>设置页面...</div>;
}
上面的代码有明显的重复。现在用 HOC 来消灭它:
// 定义 HOC:统一处理权限检查
// 注意:withAuth 本身不是组件,但它返回的 AuthenticatedComponent 是组件,
// 因此在 AuthenticatedComponent 内部调用 useAuth() 等 Hook 是完全合法的。
function withAuth(WrappedComponent) {
return function AuthenticatedComponent(props) {
const { user, loading } = useAuth();
if (loading) return <div className="spinner">验证登录状态...</div>;
if (!user) return <Navigate to="/login" />;
return <WrappedComponent {...props} user={user} />;
};
}
// 使用 HOC:一行代码搞定权限
const Dashboard = withAuth(function Dashboard({ user }) {
return <div>欢迎回来,{user.name}!这里是仪表盘。</div>;
});
const Settings = withAuth(function Settings({ user }) {
return <div>{user.name} 的个人设置</div>;
});
看到了吗?Dashboard 和 Settings 组件完全不需要关心权限逻辑——它们只管自己的业务渲染,权限检查由 withAuth 统一处理。
2.3 实例二:withLoading —— 加载状态封装
另一个常见场景是给异步组件加上 Loading 状态:
function withLoading(WrappedComponent) {
return function WithLoadingComponent({ isLoading, ...rest }) {
if (isLoading) {
return (
<div className="loading-wrapper">
<div className="spinner" />
<p>加载中,请稍候...</p>
</div>
);
}
return <WrappedComponent {...rest} />;
};
}
// 使用
const UserListWithLoading = withLoading(UserList);
function App() {
const { data, loading } = useFetchUsers();
return <UserListWithLoading isLoading={loading} users={data} />;
}
2.4 HOC 的注意事项
HOC 虽然强大,但有几个容易踩的坑:
第一,不要在渲染函数里创建 HOC。 每次渲染都会产生一个新的组件类型,React 会把旧组件整棵树卸载再重新挂载,导致状态丢失和性能浪费。
// 错误写法
function App() {
const EnhancedComp = withAuth(MyComponent); // 每次渲染都创建新组件!
return <EnhancedComp />;
}
// 正确写法:在模块顶层创建
const EnhancedComp = withAuth(MyComponent);
function App() {
return <EnhancedComp />;
}
第二,记得透传 props。 用 {...props} 把所有 props 传给被包装组件,否则调用方传的 props 会丢失。
第三,ref 不会自动透传。 如果需要让外部通过 ref 访问内部组件,要使用 React.forwardRef 来处理。
三、Render Props —— 把渲染逻辑交给调用者
3.1 换一个思路复用逻辑
HOC 的思路是”我包装你”,Render Props 的思路则是”你来告诉我怎么渲染”。
想象你去一家定制西装店。HOC 相当于”店里帮你选好布料、样式,直接给你一套成品”;Render Props 则是”店里只负责量体裁衣,布料和样式由你自己挑”。
Render Props 的核心思想:组件通过一个值为函数的 prop 来共享逻辑,调用者通过这个函数决定渲染什么。
3.2 经典案例:鼠标位置追踪
先看一个没有复用的版本:
// 鼠标位置逻辑和渲染逻辑耦合在一起
function MouseTracker() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMove = (e) => setPosition({ x: e.clientX, y: e.clientY });
window.addEventListener('mousemove', handleMove);
return () => window.removeEventListener('mousemove', handleMove);
}, []);
return (
<div>
<p>鼠标位置:({position.x}, {position.y})</p>
</div>
);
}
如果另一个组件也需要鼠标位置信息,但渲染方式完全不同(比如要画一个跟随鼠标的小圆点),怎么办?用 Render Props:
// Render Props 版本:逻辑由组件提供,渲染由调用者决定
function MouseSensor({ render }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMove = (e) => setPosition({ x: e.clientX, y: e.clientY });
window.addEventListener('mousemove', handleMove);
return () => window.removeEventListener('mousemove', handleMove);
}, []);
// 调用 render 函数,把数据交给调用者
return render(position);
}
// 用法一:展示坐标文字
function App() {
return (
<MouseSensor
render={({ x, y }) => (
<p>鼠标在 ({x}, {y})</p>
)}
/>
);
}
// 用法二:画一个跟随光标的圆点
function App2() {
return (
<MouseSensor
render={({ x, y }) => (
<div
style={{
position: 'fixed',
left: x - 10,
top: y - 10,
width: 20,
height: 20,
borderRadius: '50%',
background: 'red',
}}
/>
)}
/>
);
}
3.3 用 children 作为函数
Render Props 还有一种更常见的变体——用 children 代替自定义 prop 名称:
function MouseSensor({ children }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMove = (e) => setPosition({ x: e.clientX, y: e.clientY });
window.addEventListener('mousemove', handleMove);
return () => window.removeEventListener('mousemove', handleMove);
}, []);
return children(position);
}
// 使用时更简洁
function App() {
return (
<MouseSensor>
{({ x, y }) => <p>鼠标在 ({x}, {y})</p>}
</MouseSensor>
);
}
3.4 Hooks 时代的 Render Props
和容器/展示模式类似,Render Props 的大部分场景也可以用自定义 Hook 替代:
function useMouse() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMove = (e) => setPosition({ x: e.clientX, y: e.clientY });
window.addEventListener('mousemove', handleMove);
return () => window.removeEventListener('mousemove', handleMove);
}, []);
return position;
}
// 使用:最干净的方式
function App() {
const { x, y } = useMouse();
return <p>鼠标在 ({x}, {y})</p>;
}
但 Render Props 并没有被完全取代。在某些场景下——比如你需要在 JSX 的某个特定位置动态决定渲染内容——Render Props 仍然是最直观的方案。许多 UI 库的列表组件提供的 renderItem prop 就是典型例子。
🤔 想一想 你用过的组件库中,有哪些 API 采用了 Render Props 模式?比如 Ant Design 的 Table 组件的
columns配置中的render函数,算不算 Render Props?
四、组合模式(Compound Components)—— 像 HTML 一样设计组件
4.1 从 select/option 说起
你一定写过这样的 HTML:
<select>
<option value="apple">苹果</option>
<option value="banana">香蕉</option>
<option value="cherry">樱桃</option>
</select>
<select> 和 <option> 是两个独立的标签,但它们天生就知道如何协作——选中某个 <option> 会自动更新 <select> 的值。你不需要手动给每个 <option> 传回调函数或状态。
这种”多个组件协同工作、共享隐式状态”的设计,就是组合模式(Compound Components)。
4.2 一个糟糕的 API 设计 vs 组合模式
假设你要设计一个菜单组件。先看一种”配置式”的设计:
// 配置式 API:把所有数据塞进一个 prop
<Menu
items={[
{ label: '首页', icon: <HomeIcon />, onClick: () => navigate('/') },
{ label: '设置', icon: <SettingsIcon />, onClick: () => navigate('/settings') },
{ label: '退出', icon: <LogoutIcon />, onClick: handleLogout, danger: true },
]}
/>
这种方式的问题是:你很难灵活地控制每一项的渲染方式。如果某一项需要加一个分隔线怎么办?如果某一项需要嵌套子菜单怎么办?配置对象会越来越臃肿。
组合模式的设计则优雅得多:
// 组合模式 API:像 HTML 一样声明式使用
<Menu>
<Menu.Item icon={<HomeIcon />} onClick={() => navigate('/')}>
首页
</Menu.Item>
<Menu.Item icon={<SettingsIcon />} onClick={() => navigate('/settings')}>
设置
</Menu.Item>
<Menu.Divider />
<Menu.Item icon={<LogoutIcon />} onClick={handleLogout} danger>
退出
</Menu.Item>
</Menu>
看到区别了吗?组合模式让使用者拥有完全的排列自由:你可以在任意位置插入分隔线、嵌套子组件、有条件地渲染某一项。
4.3 用 Context 实现组合模式
组合模式的关键问题在于:Menu 和 Menu.Item 之间如何共享状态?答案是 Context。
import { createContext, useContext, useState } from 'react';
// 1. 创建共享的 Context
const MenuContext = createContext(null);
// 2. 父组件:提供共享状态
function Menu({ children, defaultActive = '' }) {
const [activeItem, setActiveItem] = useState(defaultActive);
return (
<MenuContext.Provider value={{ activeItem, setActiveItem }}>
<ul className="menu" role="menu">
{children}
</ul>
</MenuContext.Provider>
);
}
// 3. 子组件:消费共享状态
function MenuItem({ children, name, icon, onClick, danger = false }) {
const { activeItem, setActiveItem } = useContext(MenuContext);
const isActive = activeItem === name;
const handleClick = () => {
setActiveItem(name);
onClick?.();
};
return (
<li
className={`menu-item ${isActive ? 'active' : ''} ${danger ? 'danger' : ''}`}
onClick={handleClick}
role="menuitem"
>
{icon && <span className="menu-icon">{icon}</span>}
{children}
</li>
);
}
function MenuDivider() {
return <li className="menu-divider" role="separator" />;
}
// 4. 挂载子组件到父组件上
Menu.Item = MenuItem;
Menu.Divider = MenuDivider;
使用时,子组件不需要接收任何状态管理相关的 props,一切都通过 Context 隐式传递:
function Sidebar() {
return (
<Menu defaultActive="home">
<Menu.Item name="home" icon={<HomeIcon />}>
首页
</Menu.Item>
<Menu.Item name="settings" icon={<SettingsIcon />}>
设置
</Menu.Item>
<Menu.Divider />
<Menu.Item name="logout" icon={<LogoutIcon />} danger>
退出登录
</Menu.Item>
</Menu>
);
}
这种模式在 Ant Design、Radix UI、Headless UI 等主流组件库中被大量使用。
五、受控与非受控模式 —— 谁来掌控状态?
5.1 从 input 表单说起
这个问题其实在前面章节接触过,但在组件设计层面,它有更深的含义。
非受控模式: 组件自己管理状态,外部只设置初始值。就像你把闹钟设好时间后交给别人——你设了初始值,但之后闹钟走不走、怎么走,你管不了。
// 非受控:组件自己管理 value
<input defaultValue="hello" onChange={e => console.log(e.target.value)} />
受控模式: 组件的状态完全由外部控制。就像你用遥控器操控无人机——无人机的每一个动作都由你的遥控器指令决定。
// 受控:外部控制 value
const [value, setValue] = useState('hello');
<input value={value} onChange={e => setValue(e.target.value)} />
5.2 设计组件时同时支持两种模式
如果你在写一个基础组件库,你的组件应该同时支持受控和非受控模式——把选择权交给使用者。核心技巧是:通过判断 value 是否为 undefined 来区分两种模式。
function Toggle({ value, defaultValue = false, onChange }) {
// 内部状态(仅在非受控模式下使用)
const [internalValue, setInternalValue] = useState(defaultValue);
// 判断是受控还是非受控
const isControlled = value !== undefined;
const currentValue = isControlled ? value : internalValue;
const handleToggle = () => {
const newValue = !currentValue;
if (!isControlled) {
// 非受控模式:自己更新状态
setInternalValue(newValue);
}
// 无论哪种模式都触发 onChange
onChange?.(newValue);
};
return (
<button
className={`toggle ${currentValue ? 'on' : 'off'}`}
onClick={handleToggle}
>
{currentValue ? '开' : '关'}
</button>
);
}
// 非受控模式使用
<Toggle defaultValue={true} onChange={val => console.log('变了:', val)} />
// 受控模式使用
function App() {
const [on, setOn] = useState(false);
return <Toggle value={on} onChange={setOn} />;
}
Ant Design 的 Calendar、ColorPicker、InputNumber 等组件都采用这种设计。arco design 和 ahooks 也分别封装了 useMergeValue 和 useControllableValue 这样的 Hook 来统一处理受控/非受控的逻辑。
5.3 如何选择
| 场景 | 推荐模式 | 理由 |
|---|---|---|
| 只需获取用户输入 | 非受控 | 更简单,避免不必要的重新渲染 |
| 需要校验/转换输入后再设置 | 受控 | 必须由代码决定最终值 |
| 需要同步状态到 Form Store | 受控 | Form 需要统一管理所有表单项 |
| 基础组件库 | 两种都支持 | 让使用者根据场景自行选择 |
六、children 与 slots 模式 —— 灵活的内容插槽
6.1 children:最基本的插槽
children 是 React 内置的 props,它允许组件像 HTML 标签一样嵌套内容:
function Card({ children, title }) {
return (
<div className="card">
<div className="card-header">{title}</div>
<div className="card-body">{children}</div>
</div>
);
}
<Card title="用户信息">
<p>姓名:张三</p>
<p>年龄:25</p>
</Card>
6.2 具名插槽:多个内容区域
但很多时候,一个组件需要多个插槽。比如一个对话框需要 header、body、footer 三个区域。这时候光靠 children 就不够了。
React 没有像 Vue 那样内置”具名插槽”语法,但可以用多个 props 来模拟:
// 用 props 实现多插槽
function Dialog({ title, content, footer, onClose }) {
return (
<div className="dialog-overlay">
<div className="dialog">
<div className="dialog-header">
<h2>{title}</h2>
<button onClick={onClose}>X</button>
</div>
<div className="dialog-body">{content}</div>
<div className="dialog-footer">{footer}</div>
</div>
</div>
);
}
// 使用
<Dialog
title="确认删除"
content={<p>此操作不可撤销,确定要删除吗?</p>}
footer={
<>
<button onClick={onCancel}>取消</button>
<button onClick={onConfirm} className="danger">确认删除</button>
</>
}
onClose={onCancel}
/>
6.3 用 React.Children 处理 children
当你需要对 children 做一些”加工”——比如给每个子元素包一层样式容器——就需要用到 React.Children 的 API:
function EqualSpaceRow({ children, gap = 16 }) {
return (
<div style={{ display: 'flex', gap }}>
{React.Children.map(children, (child) => (
<div style={{ flex: 1 }}>{child}</div>
))}
</div>
);
}
// 使用:每个子元素自动等宽分布
<EqualSpaceRow gap={24}>
<Card>卡片一</Card>
<Card>卡片二</Card>
<Card>卡片三</Card>
</EqualSpaceRow>
React.Children.map 相比直接用数组方法有三个优势:它能正确处理单个元素(不需要 children 是数组)、会自动拍平嵌套数组、返回的元素是可操作的副本。主流组件库如 Ant Design、Semi Design 中都大量使用这个 API。
🤔 想一想 如果你想设计一个
<Layout>组件,它有 Header、Sidebar、Content、Footer 四个区域,你会选择用children+React.Children来识别子组件类型,还是用四个独立的 props(header、sidebar 等)来接收内容?各有什么优缺点?
七、模式对比与选择指南 —— 什么场景用什么模式
到这里,我们已经学了六种设计模式。它们各有侧重,不是”谁好谁坏”的关系,而是”不同工具解决不同问题”。
7.1 六大模式一览表
| 模式 | 核心思想 | 典型场景 | 优点 | 缺点 |
|---|---|---|---|---|
| 容器/展示 | 逻辑与视图分离 | 数据获取 + UI 渲染 | 关注点清晰,展示组件可复用 | 组件数量增多 |
| 高阶组件 | 包装增强 | 权限拦截、日志埋点、功能注入 | 不修改原组件,可嵌套多层 | 多层嵌套时 props 来源不清晰(“Wrapper Hell”) |
| Render Props | 调用者决定渲染 | 共享行为逻辑,渲染方式各异 | 渲染逻辑完全灵活 | JSX 嵌套层级深 |
| 组合模式 | 父子隐式状态共享 | Tab/Menu/Accordion 等复合 UI | API 声明式、优雅 | 实现较复杂,需要 Context |
| 受控/非受控 | 谁掌控状态 | 表单组件、输入类组件 | 灵活,满足不同使用场景 | 同时支持两种模式增加代码量 |
| children/slots | 内容投射 | 布局组件、容器组件 | 组合灵活,使用直观 | 多插槽时 API 稍显复杂 |
7.2 决策流程
面对一个组件设计问题时,你可以按以下流程思考:
- 需要复用逻辑但不关心 UI? 优先用自定义 Hook。这是 Hooks 时代最推荐的逻辑复用方式。
- 需要给一批组件统一加功能(权限、日志、主题)? 考虑 HOC。它特别适合”横切关注点”——那些跨越多个组件的通用需求。
- 需要在组件间共享行为,但渲染方式由调用者决定? 考虑 Render Props。比如一个列表组件提供排序/筛选逻辑,但每一行怎么渲染由外部控制。
- 多个组件需要协同工作,像 select/option 那样? 使用组合模式。用 Context 共享状态,让 API 保持声明式。
- 组件涉及用户输入? 思考是否需要支持受控和非受控模式。基础组件库两种都要支持。
- 组件需要灵活的内容区域? 使用 children/slots 模式。
7.3 模式的演变趋势
React 社区的设计模式一直在演变。一个明显的趋势是:自定义 Hook 正在替代 HOC 和 Render Props 的大部分使用场景。
- HOC 的”属性增强”功能 -> 自定义 Hook 可以直接返回数据
- Render Props 的”逻辑共享” -> 自定义 Hook 同样可以做到,而且没有嵌套问题
- 组合模式 -> 依然不可替代,因为它解决的是”组件间声明式协作”的问题,这不是 Hook 能解决的
所以在 2025 年的 React 开发中,自定义 Hook 是逻辑复用的首选,组合模式是复合组件设计的首选,而 HOC 和 Render Props 在特定场景下依然有用武之地。
八、实战:构建一个灵活的 Tabs 组件
理论说了这么多,现在我们把前面学到的模式综合运用,从零构建一个生产级的 Tabs 组件。
8.1 设计目标
我们期望的 API 长这样:
// 基本用法(非受控)
<Tabs defaultActiveKey="tab1">
<Tabs.Panel title="用户管理" tabKey="tab1">
<UserList />
</Tabs.Panel>
<Tabs.Panel title="权限设置" tabKey="tab2">
<PermissionSettings />
</Tabs.Panel>
<Tabs.Panel title="系统日志" tabKey="tab3">
<SystemLogs />
</Tabs.Panel>
</Tabs>
// 受控用法
const [activeKey, setActiveKey] = useState('tab1');
<Tabs activeKey={activeKey} onChange={setActiveKey}>
...
</Tabs>
这里我们同时用到了:
- 组合模式:
Tabs和Tabs.Panel通过 Context 隐式通信 - 受控/非受控模式:同时支持
defaultActiveKey和activeKey - children 模式:通过
React.Children从子组件中收集标签标题
8.2 实现代码
import { createContext, useContext, useState } from 'react';
// ========== 1. 创建 Context ==========
const TabsContext = createContext(null);
// ========== 2. Tabs 容器组件 ==========
function Tabs({ children, defaultActiveKey, activeKey, onChange }) {
const [internalKey, setInternalKey] = useState(defaultActiveKey);
// 判断受控/非受控
const isControlled = activeKey !== undefined;
const currentKey = isControlled ? activeKey : internalKey;
const handleChange = (key) => {
if (!isControlled) {
setInternalKey(key);
}
onChange?.(key);
};
// 从 children 中收集标签信息
const tabs = [];
React.Children.forEach(children, (child) => {
if (child && child.type === TabPanel) {
tabs.push({
key: child.props.tabKey,
title: child.props.title,
});
}
});
return (
<TabsContext.Provider value={{ activeKey: currentKey, onChange: handleChange }}>
<div className="tabs">
{/* 标签栏 */}
<div className="tabs-header" role="tablist">
{tabs.map((tab) => (
<button
key={tab.key}
className={`tabs-tab ${currentKey === tab.key ? 'active' : ''}`}
onClick={() => handleChange(tab.key)}
role="tab"
aria-selected={currentKey === tab.key}
>
{tab.title}
</button>
))}
</div>
{/* 内容区 */}
<div className="tabs-content">
{children}
</div>
</div>
</TabsContext.Provider>
);
}
// ========== 3. TabPanel 子组件 ==========
function TabPanel({ children, tabKey }) {
const { activeKey } = useContext(TabsContext);
if (activeKey !== tabKey) return null;
return (
<div className="tabs-panel" role="tabpanel">
{children}
</div>
);
}
// ========== 4. 挂载子组件 ==========
Tabs.Panel = TabPanel;
8.3 添加样式
// 简洁的 CSS(可以写在 CSS 文件或 CSS Module 中)
const styles = `
.tabs-header {
display: flex;
border-bottom: 2px solid #e8e8e8;
margin-bottom: 16px;
}
.tabs-tab {
padding: 8px 20px;
border: none;
background: none;
cursor: pointer;
font-size: 14px;
color: #666;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.2s;
}
.tabs-tab:hover {
color: #1890ff;
}
.tabs-tab.active {
color: #1890ff;
border-bottom-color: #1890ff;
font-weight: 500;
}
.tabs-panel {
padding: 16px 0;
}
`;
8.4 使用示例
function App() {
return (
<div style={{ padding: 24 }}>
<h2>系统管理后台</h2>
{/* 非受控用法:组件自己管理选中状态 */}
<Tabs defaultActiveKey="users">
<Tabs.Panel title="用户管理" tabKey="users">
<p>这里是用户列表,共 128 位用户。</p>
<button>新建用户</button>
</Tabs.Panel>
<Tabs.Panel title="权限设置" tabKey="permissions">
<p>角色权限配置面板。</p>
</Tabs.Panel>
<Tabs.Panel title="操作日志" tabKey="logs">
<p>最近 7 天的系统操作记录。</p>
</Tabs.Panel>
</Tabs>
</div>
);
}
8.5 回顾:这个 Tabs 用到了哪些模式
| 设计模式 | 在 Tabs 中的体现 |
|---|---|
| 组合模式 | Tabs + Tabs.Panel 通过 Context 隐式共享 activeKey |
| 受控/非受控 | 同时支持 defaultActiveKey(非受控)和 activeKey + onChange(受控) |
| children 模式 | 使用 React.Children.forEach 从子组件中收集标签标题信息 |
| 容器/展示思维 | Tabs 负责状态管理,TabPanel 只负责渲染对应内容 |
一个不到 80 行的组件,综合运用了四种设计模式。这就是模式的力量——当你掌握了这些模式,面对任何组件设计需求,你都能快速找到最合适的方案。
📝 掌握度自测
-
容器组件和展示组件的核心区别是什么?在 Hooks 时代,展示组件通常可以用什么替代容器组件来实现逻辑复用?
- A) 容器负责样式,展示负责逻辑;用 useReducer 替代
- B) 容器负责逻辑和数据,展示只负责渲染;用自定义 Hook 替代容器
- C) 容器是类组件,展示是函数组件;用 memo 替代
- D) 容器管理路由,展示管理状态;用 useContext 替代
-
以下关于 HOC 的说法,哪个是正确的?
- A) HOC 可以直接修改传入组件的源代码
- B) HOC 应该在组件的 render 函数内部调用以获得最新状态
- C) HOC 接收一个组件作为参数,返回一个增强后的新组件
- D) HOC 会自动转发 ref,不需要额外处理
-
组合模式(Compound Components)使用什么机制来实现父子组件之间的隐式状态共享?
- A) 全局变量
- B) Redux Store
- C) React Context
- D) props 逐层传递
-
设计一个同时支持受控和非受控模式的组件时,应该如何区分当前处于哪种模式?
- A) 检查 onChange 是否传入
- B) 检查 value 是否为 undefined
- C) 检查 defaultValue 是否存在
- D) 检查组件是否被 memo 包裹
-
以下场景中,哪个最适合使用组合模式而非自定义 Hook?
- A) 多个组件需要共享一段数据请求逻辑
- B) 设计一个 Accordion(手风琴)组件,多个 Panel 需要互斥展开
- C) 给多个页面统一添加埋点日志
- D) 封装一个 useFetch Hook 来复用数据请求
💡 自我评估
- 答对5题:你已经掌握了 React 组件设计模式的核心思想,可以开始在项目中灵活运用了。
- 答对3-4题:基础不错,建议把本章的 Tabs 实战代码动手敲一遍,加深对组合模式和受控/非受控的理解。
- 答对0-2题:不必着急,设计模式需要结合实践来消化。建议先理解容器/展示和受控/非受控这两种最常用的模式,再逐步扩展到 HOC 和组合模式。
参考答案: 1-B, 2-C, 3-C, 4-B, 5-B
购买课程解锁全部内容
从组件到架构:12 章系统掌握现代 React
¥29.90