目录

React学习教程,从入门到精通,React-常用组件设计实例详解24

React学习教程,从入门到精通,React 常用组件设计实例详解(24)

📘 React 常用组件设计实例详解

适用 React 版本:18.x(函数组件 + Hooks)


一、按钮组件设计

✅ 语法知识点

  • props 传递配置(类型、尺寸、禁用、点击事件)
  • 使用 className 动态拼接样式
  • 使用 children 渲染按钮内容
  • 使用 forwardRef 支持外部 ref 控制
  • 使用 TypeScript 定义 Props 接口(可选)

🧩 案例代码:Button 组件

import React, { forwardRef, ButtonHTMLAttributes } from 'react';
import './Button.css'; // 假设你有对应的 CSS 文件

// 定义按钮类型
type ButtonType = 'primary' | 'secondary' | 'danger' | 'link';
type ButtonSize = 'small' | 'medium' | 'large';

// Props 接口
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  type?: ButtonType;
  size?: ButtonSize;
  disabled?: boolean;
  loading?: boolean;
  block?: boolean; // 是否块级按钮
}

// 使用 forwardRef 使外部能获取按钮 DOM
const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
  const {
    type = 'primary',
    size = 'medium',
    disabled = false,
    loading = false,
    block = false,
    children,
    className = '',
    ...rest
  } = props;

  // 动态类名拼接
  const classes = [
    'btn',
    `btn-${type}`,
    `btn-${size}`,
    disabled && 'btn-disabled',
    loading && 'btn-loading',
    block && 'btn-block',
    className
  ].filter(Boolean).join(' ');

  return (
    <button
      ref={ref}
      className={classes}
      disabled={disabled || loading}
      {...rest}
    >
      {loading ? (
        <span className="btn-loading-icon"></span>
      ) : null}
      {children}
    </button>
  );
});

Button.displayName = 'Button'; // 便于调试

export default Button;

📄 Button.css 示例

.btn {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.2s;
}

.btn-primary { background: #007bff; color: white; }
.btn-secondary { background: #6c757d; color: white; }
.btn-danger { background: #dc3545; color: white; }
.btn-link { background: transparent; color: #007bff; text-decoration: underline; }

.btn-small { padding: 4px 8px; font-size: 12px; }
.btn-medium { padding: 8px 16px; font-size: 14px; }
.btn-large { padding: 12px 24px; font-size: 16px; }

.btn-disabled { opacity: 0.6; cursor: not-allowed; }
.btn-loading { opacity: 0.8; }
.btn-block { display: block; width: 100%; }

.btn-loading-icon { margin-right: 8px; }

二、模态对话框组件设计(Modal)

✅ 语法知识点

  • 使用 Portal 渲染到 body,避免父级样式干扰
  • 使用 useState 控制显示/隐藏
  • 使用 useEffect 监听 ESC 键关闭
  • 使用 Backdrop 遮罩层 + 点击遮罩关闭
  • 支持自定义标题、内容、底部按钮

🧩 案例代码:Modal 组件

import React, { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';

interface ModalProps {
  visible: boolean;
  title?: string;
  onClose: () => void;
  children?: React.ReactNode;
  footer?: React.ReactNode;
}

const Modal: React.FC<ModalProps> = ({
  visible,
  title = '提示',
  onClose,
  children,
  footer
}) => {
  const modalRef = useRef<HTMLDivElement>(null);

  // 监听 ESC 键关闭
  useEffect(() => {
    const handleEsc = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
    };
    if (visible) {
      document.addEventListener('keydown', handleEsc);
    }
    return () => {
      document.removeEventListener('keydown', handleEsc);
    };
  }, [visible, onClose]);

  // 点击遮罩关闭
  const handleBackdropClick = (e: React.MouseEvent) => {
    if (e.target === e.currentTarget) {
      onClose();
    }
  };

  if (!visible) return null;

  return createPortal(
    <div className="modal-backdrop" onClick={handleBackdropClick}>
      <div className="modal" ref={modalRef} onClick={(e) => e.stopPropagation()}>
        <div className="modal-header">
          <h3>{title}</h3>
          <button onClick={onClose} className="modal-close-btn">×</button>
        </div>
        <div className="modal-body">{children}</div>
        {footer && <div className="modal-footer">{footer}</div>}
      </div>
    </div>,
    document.body
  );
};

export default Modal;

📄 Modal.css

.modal-backdrop {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
}

.modal {
  background: white;
  border-radius: 8px;
  min-width: 300px;
  max-width: 90%;
  box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}

.modal-header {
  padding: 16px;
  border-bottom: 1px solid #eee;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.modal-close-btn {
  background: none;
  border: none;
  font-size: 20px;
  cursor: pointer;
}

.modal-body {
  padding: 16px;
}

.modal-footer {
  padding: 16px;
  border-top: 1px solid #eee;
  text-align: right;
}

三、树形组件设计(Tree)

✅ 语法知识点

  • 递归渲染树节点
  • 使用 useState 管理展开/折叠状态
  • 支持 checkbox 多选(可选)
  • 支持自定义节点渲染函数
  • 使用 key 优化性能

🧩 案例代码:Tree 组件

import React, { useState } from 'react';

interface TreeNode {
  key: string;
  title: string;
  children?: TreeNode[];
  disabled?: boolean;
}

interface TreeProps {
  treeData: TreeNode[];
  onSelect?: (node: TreeNode) => void;
  checkable?: boolean;
  defaultExpandedKeys?: string[];
}

const Tree: React.FC<TreeProps> = ({
  treeData,
  onSelect,
  checkable = false,
  defaultExpandedKeys = []
}) => {
  const [expandedKeys, setExpandedKeys] = useState<Set<string>>(
    new Set(defaultExpandedKeys)
  );
  const [checkedKeys, setCheckedKeys] = useState<Set<string>>(new Set());

  const toggleExpand = (key: string) => {
    const newSet = new Set(expandedKeys);
    if (newSet.has(key)) {
      newSet.delete(key);
    } else {
      newSet.add(key);
    }
    setExpandedKeys(newSet);
  };

  const toggleCheck = (key: string, checked: boolean, node: TreeNode) => {
    const newSet = new Set(checkedKeys);
    if (checked) {
      newSet.add(key);
      // 如果有子节点,递归选中(简单实现)
      const addChildrenKeys = (nodes?: TreeNode[]) => {
        nodes?.forEach(child => {
          newSet.add(child.key);
          addChildrenKeys(child.children);
        });
      };
      addChildrenKeys(node.children);
    } else {
      newSet.delete(key);
      // 递归取消子节点
      const removeChildrenKeys = (nodes?: TreeNode[]) => {
        nodes?.forEach(child => {
          newSet.delete(child.key);
          removeChildrenKeys(child.children);
        });
      };
      removeChildrenKeys(node.children);
    }
    setCheckedKeys(newSet);
  };

  const renderTreeNode = (node: TreeNode) => {
    const isExpanded = expandedKeys.has(node.key);
    const isChecked = checkedKeys.has(node.key);
    const hasChildren = node.children && node.children.length > 0;

    return (
      <div key={node.key} style={{ marginLeft: '20px' }}>
        <div
          style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
          onClick={() => onSelect?.(node)}
        >
          {checkable && (
            <input
              type="checkbox"
              checked={isChecked}
              onChange={(e) => toggleCheck(node.key, e.target.checked, node)}
              disabled={node.disabled}
              style={{ marginRight: '8px' }}
            />
          )}
          {hasChildren && (
            <span
              onClick={(e) => {
                e.stopPropagation();
                toggleExpand(node.key);
              }}
              style={{ marginRight: '4px', cursor: 'pointer' }}
            >
              {isExpanded ? '▼' : '▶'}
            </span>
          )}
          <span style={{ color: node.disabled ? '#ccc' : 'inherit' }}>
            {node.title}
          </span>
        </div>
        {hasChildren && isExpanded && (
          <div>
            {node.children.map(child => renderTreeNode(child))}
          </div>
        )}
      </div>
    );
  };

  return (
    <div className="tree">
      {treeData.map(node => renderTreeNode(node))}
    </div>
  );
};

export default Tree;

四、表格及分页组件设计

✅ 语法知识点

  • 表格头(Thead)与表格体(Tbody)分离
  • 使用 map 渲染数据行
  • 分页组件独立,通过 props 通信
  • 支持排序、选择、自定义列渲染
  • 使用 memo 优化性能

🧩 表格头组件(TableHeader)

import React from 'react';

interface TableHeaderProps {
  columns: { key: string; title: string; sortable?: boolean }[];
  onSort?: (key: string) => void;
  sortKey?: string;
  sortOrder?: 'asc' | 'desc';
}

const TableHeader: React.FC<TableHeaderProps> = ({
  columns,
  onSort,
  sortKey,
  sortOrder
}) => {
  return (
    <thead>
      <tr>
        {columns.map(col => (
          <th key={col.key} style={{ cursor: col.sortable ? 'pointer' : 'default' }}>
            <div onClick={() => col.sortable && onSort?.(col.key)}>
              {col.title}
              {col.sortable && sortKey === col.key && (
                <span style={{ marginLeft: '4px' }}>
                  {sortOrder === 'asc' ? '↑' : '↓'}
                </span>
              )}
            </div>
          </th>
        ))}
      </tr>
    </thead>
  );
};

export default TableHeader;

🧩 表格体组件(TableBody)

import React from 'react';

interface TableBodyProps<T> {
  data: T[];
  columns: { key: string; render?: (text: any, record: T) => React.ReactNode }[];
  rowKey: (record: T) => string;
  onRowClick?: (record: T) => void;
}

const TableBody = <T extends Record<string, any>>({
  data,
  columns,
  rowKey,
  onRowClick
}: TableBodyProps<T>) => {
  return (
    <tbody>
      {data.map(record => (
        <tr
          key={rowKey(record)}
          onClick={() => onRowClick?.(record)}
          style={{ cursor: onRowClick ? 'pointer' : 'default' }}
        >
          {columns.map(col => (
            <td key={col.key}>
              {col.render
                ? col.render(record[col.key], record)
                : record[col.key]}
            </td>
          ))}
        </tr>
      ))}
    </tbody>
  );
};

export default TableBody;

🧩 分页组件(Pagination)

import React from 'react';

interface PaginationProps {
  current: number;
  total: number;
  pageSize: number;
  onChange: (page: number) => void;
}

const Pagination: React.FC<PaginationProps> = ({
  current,
  total,
  pageSize,
  onChange
}) => {
  const totalPages = Math.ceil(total / pageSize);
  const maxVisiblePages = 5;
  const startPage = Math.max(1, current - Math.floor(maxVisiblePages / 2));
  const endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);

  const pages = [];
  for (let i = startPage; i <= endPage; i++) {
    pages.push(i);
  }

  return (
    <div className="pagination">
      <button
        onClick={() => onChange(Math.max(1, current - 1))}
        disabled={current === 1}
      >
        上一页
      </button>

      {pages.map(page => (
        <button
          key={page}
          onClick={() => onChange(page)}
          className={page === current ? 'active' : ''}
        >
          {page}
        </button>
      ))}

      <button
        onClick={() => onChange(Math.min(totalPages, current + 1))}
        disabled={current === totalPages}
      >
        下一页
      </button>

      <span style={{ marginLeft: '16px' }}>
         {total} 条,{totalPages} 
      </span>
    </div>
  );
};

export default Pagination;

🧩 表格组件(Table)整合

import React, { useState } from 'react';
import TableHeader from './TableHeader';
import TableBody from './TableBody';
import Pagination from './Pagination';
import './Table.css';

interface Column<T> {
  key: string;
  title: string;
  render?: (text: any, record: T) => React.ReactNode;
  sortable?: boolean;
}

interface TableProps<T> {
  columns: Column<T>[];
  data: T[];
  rowKey: (record: T) => string;
  pagination?: {
    current: number;
    pageSize: number;
    total: number;
    onChange: (page: number) => void;
  };
  onRowClick?: (record: T) => void;
}

const Table = <T extends Record<string, any>>({
  columns,
  data,
  rowKey,
  pagination,
  onRowClick
}: TableProps<T>) => {
  const [sortKey, setSortKey] = useState<string | null>(null);
  const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');

  const handleSort = (key: string) => {
    if (sortKey === key) {
      setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
    } else {
      setSortKey(key);
      setSortOrder('asc');
    }
    // 实际排序逻辑应在父组件处理,此处仅演示交互
  };

  return (
    <div className="table-container">
      <table className="table">
        <TableHeader
          columns={columns}
          onSort={handleSort}
          sortKey={sortKey || ''}
          sortOrder={sortOrder}
        />
        <TableBody
          data={data}
          columns={columns}
          rowKey={rowKey}
          onRowClick={onRowClick}
        />
      </table>

      {pagination && (
        <Pagination
          current={pagination.current}
          total={pagination.total}
          pageSize={pagination.pageSize}
          onChange={pagination.onChange}
        />
      )}
    </div>
  );
};

export default Table;

五、综合示例一:树表联动

点击树节点,表格数据联动刷新

import React, { useState } from 'react';
import Tree from './Tree';
import Table from './Table';

const treeData = [
  {
    key: '1',
    title: '部门A',
    children: [
      { key: '1-1', title: '小组A1' },
      { key: '1-2', title: '小组A2' }
    ]
  },
  {
    key: '2',
    title: '部门B',
    children: [
      { key: '2-1', title: '小组B1' }
    ]
  }
];

// 模拟数据源
const mockData: Record<string, { name: string; age: number; dept: string }[]> = {
  '1': [
    { name: '张三', age: 25, dept: '部门A' },
    { name: '李四', age: 28, dept: '部门A' }
  ],
  '1-1': [
    { name: '王五', age: 22, dept: '小组A1' }
  ],
  '1-2': [
    { name: '赵六', age: 30, dept: '小组A2' }
  ],
  '2': [
    { name: '钱七', age: 35, dept: '部门B' }
  ],
  '2-1': [
    { name: '孙八', age: 27, dept: '小组B1' }
  ]
};

const TreeTableLinkage = () => {
  const [selectedKey, setSelectedKey] = useState<string>('1');
  const [tableData, setTableData] = useState(mockData['1']);

  const handleTreeNodeSelect = (node: any) => {
    setSelectedKey(node.key);
    setTableData(mockData[node.key] || []);
  };

  const columns = [
    { key: 'name', title: '姓名' },
    { key: 'age', title: '年龄' },
    { key: 'dept', title: '所属部门' }
  ];

  return (
    <div style={{ display: 'flex', gap: '24px', padding: '20px' }}>
      <div style={{ width: '300px' }}>
        <h3>组织架构</h3>
        <Tree
          treeData={treeData}
          onSelect={handleTreeNodeSelect}
          defaultExpandedKeys={['1', '2']}
        />
      </div>

      <div style={{ flex: 1 }}>
        <h3>成员列表(当前:{selectedKey}</h3>
        <Table
          columns={columns}
          data={tableData}
          rowKey={(record) => record.name}
        />
      </div>
    </div>
  );
};

export default TreeTableLinkage;

六、综合示例二:消息管理综合示例

包含:表格展示、分页、模态框新增、按钮操作

import React, { useState } from 'react';
import Table from './Table';
import Pagination from './Pagination';
import Modal from './Modal';
import Button from './Button';

interface Message {
  id: string;
  title: string;
  content: string;
  status: 'read' | 'unread';
  createdAt: string;
}

const initialMessages: Message[] = Array.from({ length: 50 }, (_, i) => ({
  id: `msg-${i + 1}`,
  title: `消息标题 ${i + 1}`,
  content: `这是第 ${i + 1} 条消息的内容`,
  status: i % 3 === 0 ? 'unread' : 'read',
  createdAt: new Date(Date.now() - i * 86400000).toLocaleDateString()
}));

const MessageManager = () => {
  const [messages, setMessages] = useState<Message[]>(initialMessages);
  const [currentPage, setCurrentPage] = useState(1);
  const [modalVisible, setModalVisible] = useState(false);
  const [formData, setFormData] = useState({ title: '', content: '' });

  const pageSize = 10;
  const startIndex = (currentPage - 1) * pageSize;
  const paginatedData = messages.slice(startIndex, startIndex + pageSize);

  const handlePageChange = (page: number) => {
    setCurrentPage(page);
  };

  const handleDelete = (id: string) => {
    setMessages(messages.filter(msg => msg.id !== id));
  };

  const handleMarkRead = (id: string) => {
    setMessages(messages.map(msg =>
      msg.id === id ? { ...msg, status: 'read' } : msg
    ));
  };

  const handleFormSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!formData.title || !formData.content) return;

    const newMessage: Message = {
      id: `msg-${Date.now()}`,
      title: formData.title,
      content: formData.content,
      status: 'unread',
      createdAt: new Date().toLocaleDateString()
    };

    setMessages([newMessage, ...messages]);
    setFormData({ title: '', content: '' });
    setModalVisible(false);
    setCurrentPage(1); // 回到第一页
  };

  const columns = [
    {
      key: 'title',
      title: '标题',
      render: (text: string, record: Message) => (
        <strong style={{ color: record.status === 'unread' ? 'red' : 'inherit' }}>
          {text}
        </strong>
      )
    },
    { key: 'content', title: '内容' },
    { key: 'status', title: '状态', render: (text: string) => text === 'read' ? '已读' : '未读' },
    { key: 'createdAt', title: '创建时间' },
    {
      key: 'actions',
      title: '操作',
      render: (_: any, record: Message) => (
        <div>
          {record.status === 'unread' && (
            <Button size="small" onClick={() => handleMarkRead(record.id)}>
              标记已读
            </Button>
          )}
          <Button type="danger" size="small" onClick={() => handleDelete(record.id)} style={{ marginLeft: '8px' }}>
            删除
          </Button>
        </div>
      )
    }
  ];

  return (
    <div style={{ padding: '20px' }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '16px' }}>
        <h2>消息管理</h2>
        <Button type="primary" onClick={() => setModalVisible(true)}>
          新增消息
        </Button>
      </div>

      <Table
        columns={columns}
        data={paginatedData}
        rowKey={(record) => record.id}
      />

      <Pagination
        current={currentPage}
        total={messages.length}
        pageSize={pageSize}
        onChange={handlePageChange}
      />

      <Modal
        visible={modalVisible}
        title="新增消息"
        onClose={() => setModalVisible(false)}
        footer={
          <>
            <Button onClick={() => setModalVisible(false)}>取消</Button>
            <Button type="primary" onClick={handleFormSubmit} style={{ marginLeft: '8px' }}>
              提交
            </Button>
          </>
        }
      >
        <form onSubmit={handleFormSubmit}>
          <div style={{ marginBottom: '16px' }}>
            <label>标题:</label>
            <input
              type="text"
              value={formData.title}
              onChange={(e) => setFormData({ ...formData, title: e.target.value })}
              style={{ width: '100%', padding: '8px', marginTop: '4px' }}
            />
          </div>
          <div style={{ marginBottom: '16px' }}>
            <label>内容:</label>
            <textarea
              value={formData.content}
              onChange={(e) => setFormData({ ...formData, content: e.target.value })}
              rows={4}
              style={{ width: '100%', padding: '8px', marginTop: '4px' }}
            />
          </div>
        </form>
      </Modal>
    </div>
  );
};

export default MessageManager;

七、本章小结

✅ 本章涵盖组件:

组件核心技术点
Buttonprops、动态类名、forwardRef、状态管理
ModalPortal、事件冒泡控制、键盘监听
Tree递归渲染、状态提升、复选逻辑
Table泛型、插槽渲染、排序、分页联动
Pagination数学分页算法、边界控制
综合案例状态共享、组件通信、数据驱动 UI

💡 最佳实践建议

  • 所有组件尽量原子化、可复用
  • 使用 TypeScript 增强类型安全
  • 使用 React.memo + useCallback 优化性能
  • 使用 Context / Zustand / Redux 管理复杂状态
  • 样式建议使用 CSS Modules 或 Tailwind

🎯 学习路径建议

  1. 先掌握单个组件开发
  2. 再学习组件间通信(props / callback / context)
  3. 最后实战综合项目,如后台管理系统、数据看板等

📌 源码结构建议

src/
├── components/
│   ├── Button/
│   │   ├── index.tsx
│   │   └── Button.css
│   ├── Modal/
│   ├── Tree/
│   └── Table/
│       ├── Table.tsx
│       ├── TableHeader.tsx
│       ├── TableBody.tsx
│       └── Pagination.tsx
├── views/
│   ├── TreeTableLinkage.tsx
│   └── MessageManager.tsx
└── App.tsx

✅ 以上内容可直接用于项目开发,结构清晰、注释完整、功能实用。