学习路径 高级

组合模式

学习如何使用组合模式构建灵活、可复用的 React 组件

组合模式概述

React 强调组合而非继承。通过组合,你可以构建灵活且可复用的组件。本章将探讨多种组合模式:

  • 容器组件
  • 插槽(Slots)
  • 渲染属性(Render Props)
  • 高阶组件(HOC)
  • 自定义 Hooks
  • 组件组合最佳实践
组合 vs 继承

React 不推荐使用继承来构建组件。相反,推荐使用组合:

  • props 传递
  • children
  • 渲染属性
  • 自定义 Hooks

容器组件

容器组件是一种将逻辑与展示分离的模式。容器组件负责获取数据和处理业务逻辑,展示组件负责渲染 UI。

基础容器组件

TypeScript
import { useState, useEffect } from 'react';

// 容器组件 - 负责数据获取
function UserContainer({ children, userId }: { children: (user: User) => React.ReactNode; userId: number }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    fetchUser(userId)
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, [userId]);

  if (loading) return <div>加载中...</div>;
  if (error) return <div>错误: {error.message}</div>;
  if (!user) return null;

  return <>{children(user)}</>;
}

// 展示组件 - 负责 UI 渲染
function UserCard({ user }: { user: User }) {
  return (
    <div className="card">
      <img src={user.avatar} alt={user.name} />
      <h3>{user.name}</h3>
      <p>{user.email}</p>
    </div>
  );
}

// 使用
function App() {
  return (
    <UserContainer userId={1}>
      {(user) => <UserCard user={user} />}
    </UserContainer>
  );
}

可复用的列表容器

TypeScript
interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T) => string | number;
  emptyMessage?: string;
}

function List<T>({ items, renderItem, keyExtractor, emptyMessage = '无数据' }: ListProps<T>) {
  if (items.length === 0) {
    return <div className="text-text-secondary">{emptyMessage}</div>;
  }

  return (
    <ul className="space-y-2">
      {items.map((item, index) => (
        <li key={keyExtractor(item)}>
          {renderItem(item, index)}
        </li>
      ))}
    </ul>
  );
}

// 使用
function UserList({ users }: { users: User[] }) {
  return (
    <List
      items={users}
      keyExtractor={(user) => user.id}
      renderItem={(user) => (
        <div>
          <span>{user.name}</span>
          <span>{user.email}</span>
        </div>
      )}
      emptyMessage="暂无用户"
    />
  );
}

插槽模式 (Slots)

插槽模式允许组件通过 props 接收多个子元素,实现灵活的布局。

基础插槽

TypeScript
interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  header?: React.ReactNode;
  footer?: React.ReactNode;
  children: React.ReactNode;
}

function Modal({ isOpen, onClose, header, footer, children }: ModalProps) {
  if (!isOpen) return null;

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>
        {header && <div className="modal-header">{header}</div>}

        <div className="modal-body">
          {children}
        </div>

        {footer && <div className="modal-footer">{footer}</div>}
      </div>
    </div>
  );
}

// 使用
function App() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <Modal
      isOpen={isOpen}
      onClose={() => setIsOpen(false)}
      header={<h2>标题</h2>}
      footer={<button onClick={() => setIsOpen(false)}>关闭</button>}
    >
      <p>这是模态框内容</p>
    </Modal>
  );
}

命名插槽

TypeScript
interface LayoutProps {
  header?: React.ReactNode;
  sidebar?: React.ReactNode;
  content?: React.ReactNode;
  footer?: React.ReactNode;
}

function Layout({ header, sidebar, content, footer }: LayoutProps) {
  return (
    <div className="layout">
      {header && <header className="layout-header">{header}</header>}

      <div className="layout-body">
        {sidebar && <aside className="layout-sidebar">{sidebar}</aside>}
        <main className="layout-content">{content}</main>
      </div>

      {footer && <footer className="layout-footer">{footer}</footer>}
    </div>
  );
}

// 使用
function App() {
  return (
    <Layout
      header={<nav>导航栏</nav>}
      sidebar={<Menu />}
      content={<main>主要内容</main>}
      footer={<div>页脚</div>}
    />
  );
}

动态插槽

TypeScript
interface TabsProps {
  items: Array<{
    key: string;
    label: string;
    content: React.ReactNode;
  }>;
  defaultActiveKey?: string;
}

function Tabs({ items, defaultActiveKey }: TabsProps) {
  const [activeKey, setActiveKey] = useState(defaultActiveKey || items[0]?.key);

  const activeItem = items.find(item => item.key === activeKey);

  return (
    <div>
      <div className="tabs-nav">
        {items.map(item => (
          <button
            key={item.key}
            onClick={() => setActiveKey(item.key)}
            className={activeKey === item.key ? 'active' : ''}
          >
            {item.label}
          </button>
        ))}
      </div>

      <div className="tabs-content">
        {activeItem?.content}
      </div>
    </div>
  );
}

// 使用
function App() {
  return (
    <Tabs
      items={[
        {
          key: 'home',
          label: '首页',
          content: <div>首页内容</div>
        },
        {
          key: 'profile',
          label: '个人资料',
          content: <div>个人资料内容</div>
        },
        {
          key: 'settings',
          label: '设置',
          content: <div>设置内容</div>
        }
      ]}
      defaultActiveKey="home"
    />
  );
}

渲染属性 (Render Props)

渲染属性是一种通过函数传递 React 元素的技术。它允许组件共享状态逻辑,同时让调用者决定如何渲染。

基础渲染属性

TypeScript
interface MouseTrackerProps {
  render: (position: { x: number; y: number }) => React.ReactNode;
}

function MouseTracker({ render }: MouseTrackerProps) {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  const handleMouseMove = (e: React.MouseEvent) => {
    setPosition({
      x: e.clientX,
      y: e.clientY
    });
  };

  return (
    <div onMouseMove={handleMouseMove}>
      {render(position)}
    </div>
  );
}

// 使用
function App() {
  return (
    <MouseTracker
      render={({ x, y }) => (
        <div>
          鼠标位置: {x}, {y}
        </div>
      )}
    />
  );
}

使用 children 作为渲染属性

TypeScript
interface DataFetcherProps {
  url: string;
  children: (data: any) => React.ReactNode;
}

function DataFetcher({ url, children }: DataFetcherProps) {
  const [data, setData] = useState<any>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(data => {
        setData(data);
        setLoading(false);
      });
  }, [url]);

  if (loading) return <div>加载中...</div>;
  if (!data) return null;

  return <>{children(data)}</>;
}

// 使用
function App() {
  return (
    <DataFetcher url="/api/user/1">
      {(user) => (
        <div>
          <h1>{user.name}</h1>
          <p>{user.email}</p>
        </div>
      )}
    </DataFetcher>
  );
}
渲染属性的注意事项
  • 如果直接在 JSX 中写内联函数,每次渲染都会创建新函数,可能导致子组件不必要的重渲染
  • 解决方案: 将渲染函数提取为组件方法或使用 useCallback
  • 考虑使用自定义 Hooks 替代渲染属性

高阶组件 (HOC)

高阶组件是参数为组件,返回值为新组件的函数。HOC 用于复用组件逻辑。

基础 HOC

TypeScript
interface WithLoadingProps {
  loading?: boolean;
}

function withLoading<P extends object>(
  WrappedComponent: React.ComponentType<P>
): React.ComponentType<P & WithLoadingProps> {
  return function WithLoadingComponent({ loading, ...props }: P & WithLoadingProps) {
    if (loading) {
      return <div className="spinner">加载中...</div>;
    }

    return <WrappedComponent {...(props as P)} />;
  };
}

// 使用
function UserList({ users }: { users: User[] }) {
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

const UserListWithLoading = withLoading(UserList);

function App() {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetchUsers().then(data => {
      setUsers(data);
      setLoading(false);
    });
  }, []);

  return <UserListWithLoading users={users} loading={loading} />;
}

添加数据获取逻辑

TypeScript
interface WithDataProps<T> {
  data: T | null;
  error: Error | null;
  loading: boolean;
  refetch: () => void;
}

function withData<T, P extends object>(
  fetcher: () => Promise<T>
) {
  return function (WrappedComponent: React.ComponentType<P & WithDataProps<T>>) {
    return functionWithData(props: P) {
      const [data, setData] = useState<T | null>(null);
      const [error, setError] = useState<Error | null>(null);
      const [loading, setLoading] = useState(true);

      const fetchData = useCallback(async () => {
        try {
          setLoading(true);
          const result = await fetcher();
          setData(result);
          setError(null);
        } catch (err) {
          setError(err as Error);
        } finally {
          setLoading(false);
        }
      }, [fetcher]);

      useEffect(() => {
        fetchData();
      }, [fetchData]);

      return (
        <WrappedComponent
          {...(props as P)}
          data={data}
          error={error}
          loading={loading}
          refetch={fetchData}
        />
      );
    };
  };
}

// 使用
function UserProfile({ data, error, loading }: WithDataProps<User>) {
  if (loading) return <div>加载中...</div>;
  if (error) return <div>错误: {error.message}</div>;
  if (!data) return null;

  return (
    <div>
      <h1>{data.name}</h1>
      <p>{data.email}</p>
    </div>
  );
}

const UserProfileWithData = withData(() =>
  fetch('/api/user/1').then(res => res.json())
)(UserProfile);
HOC 最佳实践
  • 不要修改原始组件,应该组合
  • 传递所有 props 到包装组件
  • 最大化可组合性
  • 显示 displayName 方便调试
  • 考虑使用自定义 Hooks 替代 HOC

组件组合最佳实践

1. 单一职责原则

TypeScript
// ❌ 不好:组件做太多事情
function UserPage() {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);
  const [comments, setComments] = useState([]);

  // 获取用户、文章、评论...
  // 渲染所有内容...
}

// ✅ 好:拆分为小组件
function UserPage() {
  return (
    <>
      <UserInfo />
      <UserPosts />
      <UserComments />
    </>
  );
}

2. 控制输入 vs 受控输出

TypeScript
// 组件控制输入,父组件控制输出
interface CheckboxProps {
  checked?: boolean;
  defaultChecked?: boolean;
  onChange?: (checked: boolean) => void;
  children: React.ReactNode;
}

function Checkbox({ checked, defaultChecked = false, onChange, children }: CheckboxProps) {
  const [internalChecked, setInternalChecked] = useState(defaultChecked);

  const isChecked = checked !== undefined ? checked : internalChecked;

  const handleChange = () => {
    const newChecked = !isChecked;
    if (checked === undefined) {
      setInternalChecked(newChecked);
    }
    onChange?.(newChecked);
  };

  return (
    <label>
      <input
        type="checkbox"
        checked={isChecked}
        onChange={handleChange}
      />
      {children}
    </label>
  );
}

3. 使用 TypeScript 提升类型安全

TypeScript
interface Props<T> {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
  keyExtractor: (item: T) => string | number;
}

function GenericList<T>({ items, renderItem, keyExtractor }: Props<T>) {
  return (
    <ul>
      {items.map(item => (
        <li key={keyExtractor(item)}>
          {renderItem(item)}
        </li>
      ))}
    </ul>
  );
}

// 使用时类型自动推断
<GenericList
  items={users}
  keyExtractor={user => user.id}
  renderItem={user => <UserCard user={user} />}
/>

4. 避免过度组合

TypeScript
// ❌ 不好:过度组合,难以理解
<Container>
  <Wrapper>
    <Box>
      <Card>
        <Header>
          <Title>
            <Text>内容</Text>
          </Title>
        </Header>
      </Card>
    </Box>
  </Wrapper>
</Container>

// ✅ 好:简洁明了
<Card title="内容">
  内容...
</Card>

如何选择组合方式

场景推荐方式原因
共享 UI 逻辑自定义 Hooks简洁、类型安全
共享状态逻辑自定义 Hooks 或 Context灵活、可组合
条件渲染渲染属性或 children调用者控制渲染
增强组件功能HOC 或自定义 Hooks横切关注点
灵活布局插槽模式多个插入点

相关资源