跳到主要内容
预计阅读 56 分钟

组件设计模式 —— 写出专业级React代码

前面几章我们学会了用组件搭建界面、用 Hooks 管理状态和副作用。但当项目规模增长,你会发现”能跑”和”好维护”是两码事。组件越写越大、逻辑和 UI 搅在一起、同样的功能在三个地方重复实现——这些问题的根源不是技术能力不够,而是缺少设计模式的指导。本章将带你掌握 React 社区经过多年实战沉淀下来的经典组件设计模式,让你从”能写组件”升级到”会设计组件”。

📋 开篇自测:你已经知道多少?

  1. 你能说出容器组件和展示组件的区别,以及为什么要这样拆分吗?
  2. 高阶组件(HOC)和 Render Props 各自解决什么问题?它们的优缺点分别是什么?
  3. 如果让你设计一个 <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.mapArray.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>;
});

看到了吗?DashboardSettings 组件完全不需要关心权限逻辑——它们只管自己的业务渲染,权限检查由 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 实现组合模式

组合模式的关键问题在于:MenuMenu.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 也分别封装了 useMergeValueuseControllableValue 这样的 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 等复合 UIAPI 声明式、优雅实现较复杂,需要 Context
受控/非受控谁掌控状态表单组件、输入类组件灵活,满足不同使用场景同时支持两种模式增加代码量
children/slots内容投射布局组件、容器组件组合灵活,使用直观多插槽时 API 稍显复杂

7.2 决策流程

面对一个组件设计问题时,你可以按以下流程思考:

  1. 需要复用逻辑但不关心 UI? 优先用自定义 Hook。这是 Hooks 时代最推荐的逻辑复用方式。
  2. 需要给一批组件统一加功能(权限、日志、主题)? 考虑 HOC。它特别适合”横切关注点”——那些跨越多个组件的通用需求。
  3. 需要在组件间共享行为,但渲染方式由调用者决定? 考虑 Render Props。比如一个列表组件提供排序/筛选逻辑,但每一行怎么渲染由外部控制。
  4. 多个组件需要协同工作,像 select/option 那样? 使用组合模式。用 Context 共享状态,让 API 保持声明式。
  5. 组件涉及用户输入? 思考是否需要支持受控和非受控模式。基础组件库两种都要支持。
  6. 组件需要灵活的内容区域? 使用 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>

这里我们同时用到了:

  • 组合模式TabsTabs.Panel 通过 Context 隐式通信
  • 受控/非受控模式:同时支持 defaultActiveKeyactiveKey
  • 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 行的组件,综合运用了四种设计模式。这就是模式的力量——当你掌握了这些模式,面对任何组件设计需求,你都能快速找到最合适的方案。


📝 掌握度自测

  1. 容器组件和展示组件的核心区别是什么?在 Hooks 时代,展示组件通常可以用什么替代容器组件来实现逻辑复用?

    • A) 容器负责样式,展示负责逻辑;用 useReducer 替代
    • B) 容器负责逻辑和数据,展示只负责渲染;用自定义 Hook 替代容器
    • C) 容器是类组件,展示是函数组件;用 memo 替代
    • D) 容器管理路由,展示管理状态;用 useContext 替代
  2. 以下关于 HOC 的说法,哪个是正确的?

    • A) HOC 可以直接修改传入组件的源代码
    • B) HOC 应该在组件的 render 函数内部调用以获得最新状态
    • C) HOC 接收一个组件作为参数,返回一个增强后的新组件
    • D) HOC 会自动转发 ref,不需要额外处理
  3. 组合模式(Compound Components)使用什么机制来实现父子组件之间的隐式状态共享?

    • A) 全局变量
    • B) Redux Store
    • C) React Context
    • D) props 逐层传递
  4. 设计一个同时支持受控和非受控模式的组件时,应该如何区分当前处于哪种模式?

    • A) 检查 onChange 是否传入
    • B) 检查 value 是否为 undefined
    • C) 检查 defaultValue 是否存在
    • D) 检查组件是否被 memo 包裹
  5. 以下场景中,哪个最适合使用组合模式而非自定义 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