设计并实现可复用的表格组件(支持分页、排序和筛选)

@[toc]

一、组件设计分析

1.1 功能需求分解

  • 核心功能
    • 数据展示(基础表格)
    • 分页控制
    • 列排序(升序/降序)
    • 条件筛选
  • 扩展功能
    • 自定义列渲染
    • 响应式布局
    • 多选操作
    • 加载状态

1.2 组件接口设计

typescript 复制代码
interface TableProps {
  columns: ColumnDef[];          // 列配置
  data: any[];                   // 数据源
  pagination?: PaginationConfig; // 分页配置
  sortable?: boolean;            // 是否可排序
  filterable?: boolean;          // 是否可筛选
  loading?: boolean;             // 加载状态
  rowKey?: string;               // 行唯一标识
  onPageChange?: (page: number, size: number) => void;
  onSort?: (field: string, order: 'asc' | 'desc') => void;
  onFilter?: (filters: Record<string, any>) => void;
}

interface ColumnDef {
  field: string;                 // 字段名
  header: string | ReactNode;    // 表头
  width?: number | string;       // 列宽
  sortable?: boolean;            // 是否可排序
  filterType?: 'text' | 'select' | 'date'; // 筛选类型
  filterOptions?: { value: any; label: string }[]; // 筛选选项
  render?: (value: any, record: any) => ReactNode; // 自定义渲染
}

二、基础表格实现(React示例)

2.1 组件骨架

jsx 复制代码
import React, { useState } from 'react';
import PropTypes from 'prop-types';

const Table = ({
  columns,
  data,
  pagination = { current: 1, pageSize: 10 },
  sortable = false,
  filterable = false,
  loading = false,
  rowKey = 'id',
  onPageChange,
  onSort,
  onFilter
}) => {
  const [sortField, setSortField] = useState('');
  const [sortOrder, setSortOrder] = useState(null);
  const [filters, setFilters] = useState({});

  // 处理排序
  const handleSort = (field) => {
    let order = 'asc';
    if (sortField === field && sortOrder === 'asc') {
      order = 'desc';
    } else if (sortField === field && sortOrder === 'desc') {
      order = null;
      field = '';
    }
    setSortField(field);
    setSortOrder(order);
    onSort?.(field, order);
  };

  // 处理筛选
  const handleFilter = (field, value) => {
    const newFilters = { ...filters, [field]: value };
    setFilters(newFilters);
    onFilter?.(newFilters);
  };

  // 渲染表头
  const renderHeader = () => (
    <thead>
      <tr>
        {columns.map((column) => (
          <th 
            key={column.field} 
            style={{ width: column.width }}
            onClick={() => column.sortable && handleSort(column.field)}
          >
            <div className="header-content">
              {column.header}
              {sortable && column.sortable && (
                <span className="sort-icon">
                  {sortField === column.field && sortOrder === 'asc' && '↑'}
                  {sortField === column.field && sortOrder === 'desc' && '↓'}
                </span>
              )}
            </div>
            {filterable && column.filterType && renderFilter(column)}
          </th>
        ))}
      </tr>
    </thead>
  );

  // 渲染筛选器
  const renderFilter = (column) => (
    <div className="filter-control">
      {column.filterType === 'text' && (
        <input
          type="text"
          onChange={(e) => handleFilter(column.field, e.target.value)}
          placeholder={`筛选 ${column.header}`}
        />
      )}
      {column.filterType === 'select' && (
        <select onChange={(e) => handleFilter(column.field, e.target.value)}>
          <option value="">全部</option>
          {column.filterOptions?.map((opt) => (
            <option key={opt.value} value={opt.value}>
              {opt.label}
            </option>
          ))}
        </select>
      )}
    </div>
  );

  // 渲染分页器
  const renderPagination = () => (
    <div className="pagination">
      <button
        disabled={pagination.current === 1}
        onClick={() => onPageChange?.(pagination.current - 1, pagination.pageSize)}
      >
        上一页
      </button>
      <span>
        第 {pagination.current} 页 / 共 {Math.ceil(data.length / pagination.pageSize)} 页
      </span>
      <button
        disabled={pagination.current * pagination.pageSize >= data.length}
        onClick={() => onPageChange?.(pagination.current + 1, pagination.pageSize)}
      >
        下一页
      </button>
      <select
        value={pagination.pageSize}
        onChange={(e) => onPageChange?.(1, Number(e.target.value))}
      >
        {[10, 20, 50, 100].map((size) => (
          <option key={size} value={size}>
            每页 {size} 条
          </option>
        ))}
      </select>
    </div>
  );

  return (
    <div className="table-container">
      <div className="table-wrapper">
        <table>
          {renderHeader()}
          <tbody>
            {loading ? (
              <tr>
                <td colSpan={columns.length} className="loading">
                  加载中...
                </td>
              </tr>
            ) : data.length === 0 ? (
              <tr>
                <td colSpan={columns.length} className="empty">
                  暂无数据
                </td>
              </tr>
            ) : (
              data.map((record) => (
                <tr key={record[rowKey]}>
                  {columns.map((column) => (
                    <td key={`${record[rowKey]}-${column.field}`}>
                      {column.render
                        ? column.render(record[column.field], record)
                        : record[column.field]}
                    </td>
                  ))}
                </tr>
              ))
            )}
          </tbody>
        </table>
      </div>
      {pagination && renderPagination()}
    </div>
  );
};

Table.propTypes = {
  columns: PropTypes.arrayOf(
    PropTypes.shape({
      field: PropTypes.string.isRequired,
      header: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
      width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
      sortable: PropTypes.bool,
      filterType: PropTypes.oneOf(['text', 'select', 'date']),
      filterOptions: PropTypes.array,
      render: PropTypes.func
    })
  ).isRequired,
  data: PropTypes.array.isRequired,
  pagination: PropTypes.shape({
    current: PropTypes.number,
    pageSize: PropTypes.number
  }),
  sortable: PropTypes.bool,
  filterable: PropTypes.bool,
  loading: PropTypes.bool,
  rowKey: PropTypes.string,
  onPageChange: PropTypes.func,
  onSort: PropTypes.func,
  onFilter: PropTypes.func
};

export default Table;

三、关键功能实现细节

3.1 分页控制逻辑

javascript 复制代码
// 计算当前页数据(客户端分页)
const getPagedData = () => {
  if (!pagination) return data;
  
  const { current, pageSize } = pagination;
  const start = (current - 1) * pageSize;
  const end = start + pageSize;
  
  return data.slice(start, end);
};

// 分页器实现
const Pagination = ({ current, total, pageSize, onChange }) => {
  const totalPages = Math.ceil(total / pageSize);
  
  return (
    <div className="pagination">
      <button 
        disabled={current === 1} 
        onClick={() => onChange(1, pageSize)}
      >
        <<
      </button>
      {Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
        let pageNum;
        if (totalPages <= 5) {
          pageNum = i + 1;
        } else if (current <= 3) {
          pageNum = i + 1;
        } else if (current >= totalPages - 2) {
          pageNum = totalPages - 4 + i;
        } else {
          pageNum = current - 2 + i;
        }
        
        return (
          <button
            key={pageNum}
            className={current === pageNum ? 'active' : ''}
            onClick={() => onChange(pageNum, pageSize)}
          >
            {pageNum}
          </button>
        );
      })}
      <button 
        disabled={current === totalPages} 
        onClick={() => onChange(totalPages, pageSize)}
      >
        >>
      </button>
      <span>共 {total} 条</span>
      <select
        value={pageSize}
        onChange={(e) => onChange(1, Number(e.target.value))}
      >
        {[10, 20, 50, 100].map(size => (
          <option key={size} value={size}>{size} 条/页</option>
        ))}
      </select>
    </div>
  );
};

3.2 排序功能实现

javascript 复制代码
// 排序逻辑(客户端排序)
const getSortedData = () => {
  if (!sortField || !sortOrder) return data;
  
  return [...data].sort((a, b) => {
    const valA = a[sortField];
    const valB = b[sortField];
    
    if (typeof valA === 'string' && typeof valB === 'string') {
      return sortOrder === 'asc' 
        ? valA.localeCompare(valB)
        : valB.localeCompare(valA);
    }
    
    return sortOrder === 'asc' 
      ? (valA > valB ? 1 : -1)
      : (valB > valA ? 1 : -1);
  });
};

// 服务端排序处理
const handleServerSort = (field, order) => {
  fetchData({
    sortField: field,
    sortOrder: order,
    page: 1, // 排序后回到第一页
    pageSize: pagination.pageSize,
    filters
  });
};

3.3 筛选功能实现

javascript 复制代码
// 筛选逻辑(客户端筛选)
const getFilteredData = () => {
  if (Object.keys(filters).length === 0) return data;
  
  return data.filter(item => {
    return Object.entries(filters).every(([field, value]) => {
      if (!value) return true;
      
      const column = columns.find(col => col.field === field);
      if (!column) return true;
      
      // 文本筛选
      if (column.filterType === 'text') {
        return String(item[field]).toLowerCase().includes(value.toLowerCase());
      }
      
      // 选择器筛选
      if (column.filterType === 'select') {
        return item[field] === value;
      }
      
      // 日期筛选
      if (column.filterType === 'date') {
        return new Date(item[field]).toDateString() === new Date(value).toDateString();
      }
      
      return true;
    });
  });
};

// 复合处理:筛选 -> 排序 -> 分页
const processedData = getPagedData(getSortedData(getFilteredData()));

四、高级功能扩展

4.1 自定义单元格渲染

jsx 复制代码
const columns = [
  {
    field: 'status',
    header: '状态',
    render: (value) => (
      <span className={`status-badge status-${value}`}>
        {value === 'active' ? '活跃' : '禁用'}
      </span>
    )
  },
  {
    field: 'actions',
    header: '操作',
    render: (_, record) => (
      <div className="action-buttons">
        <button onClick={() => handleEdit(record)}>编辑</button>
        <button onClick={() => handleDelete(record)}>删除</button>
      </div>
    )
  }
];

4.2 多选功能

jsx 复制代码
const [selectedRows, setSelectedRows] = useState([]);

const toggleRowSelection = (rowId, checked) => {
  setSelectedRows(prev =>
    checked
      ? [...prev, rowId]
      : prev.filter(id => id !== rowId)
  );
};

const toggleAllSelection = (checked) => {
  setSelectedRows(checked 
    ? currentPageData.map(item => item[rowKey])
    : []);
};

// 在表头添加全选复选框
<th>
  <input
    type="checkbox"
    checked={selectedRows.length > 0 && 
            selectedRows.length === currentPageData.length}
    onChange={(e) => toggleAllSelection(e.target.checked)}
  />
</th>

// 在每行添加复选框
<td>
  <input
    type="checkbox"
    checked={selectedRows.includes(record[rowKey])}
    onChange={(e) => toggleRowSelection(record[rowKey], e.target.checked)}
  />
</td>

4.3 响应式设计

css 复制代码
/* 基础表格样式 */
.table-container {
  overflow-x: auto;
}

table {
  width: 100%;
  border-collapse: collapse;
}

/* 响应式处理 */
@media (max-width: 768px) {
  /* 将表格转为卡片布局 */
  table, thead, tbody, th, td, tr {
    display: block;
  }
  
  thead tr {
    position: absolute;
    top: -9999px;
    left: -9999px;
  }
  
  tr {
    margin-bottom: 1rem;
    border: 1px solid #ddd;
  }
  
  td {
    border: none;
    position: relative;
    padding-left: 50%;
  }
  
  td:before {
    position: absolute;
    left: 1rem;
    content: attr(data-label);
    font-weight: bold;
  }
}

/* 动态添加data-label属性 */
<td data-label={column.header}>
  {column.render ? column.render(record[column.field], record) : record[column.field]}
</td>

五、性能优化方案

5.1 虚拟滚动(大数据量优化)

jsx 复制代码
import { FixedSizeList as List } from 'react-window';

const VirtualizedTableBody = ({ data, columns, rowHeight }) => {
  const Row = ({ index, style }) => (
    <div style={style}>
      {columns.map(column => (
        <div key={column.field} style={{ width: column.width }}>
          {column.render
            ? column.render(data[index][column.field], data[index])
            : data[index][column.field]}
        </div>
      ))}
    </div>
  );

  return (
    <List
      height={500}
      itemCount={data.length}
      itemSize={rowHeight}
      width="100%"
    >
      {Row}
    </List>
  );
};

5.2 数据缓存与记忆化

jsx 复制代码
import { useMemo } from 'react';

const Table = ({ data, columns, /* other props */ }) => {
  const processedData = useMemo(() => {
    return getPagedData(getSortedData(getFilteredData(data)));
  }, [data, sortField, sortOrder, filters, pagination]);

  const memoizedColumns = useMemo(() => columns, [columns]);

  // ...
};

5.3 按需渲染列

jsx 复制代码
const [visibleColumns, setVisibleColumns] = useState(columns);

const toggleColumnVisibility = (field) => {
  setVisibleColumns(prev =>
    prev.some(col => col.field === field)
      ? prev.filter(col => col.field !== field)
      : [...prev, columns.find(col => col.field === field)]
  );
};

// 在表格顶部添加列选择器
<div className="column-selector">
  {columns.map(column => (
    <label key={column.field}>
      <input
        type="checkbox"
        checked={visibleColumns.some(col => col.field === column.field)}
        onChange={() => toggleColumnVisibility(column.field)}
      />
      {column.header}
    </label>
  ))}
</div>

六、完整实现流程图

graph TD A[开始] --> B[初始化表格配置] B --> C{是否服务端模式?} C -->|是| D[监听参数变化请求数据] C -->|否| E[处理本地数据] E --> F[应用筛选条件] F --> G[应用排序规则] G --> H[应用分页逻辑] H --> I[渲染表格] I --> J[渲染表头] J --> K[添加排序交互] K --> L[添加筛选控件] L --> M[渲染表体] M --> N[自定义单元格渲染] N --> O[渲染分页器] O --> P[结束] D --> Q[组合请求参数] Q --> R[发送API请求] R --> S[接收响应数据] S --> I

七、组件使用示例

7.1 基础用法

jsx 复制代码
const App = () => {
  const [data, setData] = useState([]);
  const [loading, setLoading] = useState(false);
  const [pagination, setPagination] = useState({ current: 1, pageSize: 10 });

  const columns = [
    { field: 'id', header: 'ID', width: 80 },
    { field: 'name', header: '姓名', sortable: true, filterType: 'text' },
    { field: 'age', header: '年龄', sortable: true },
    { field: 'email', header: '邮箱', filterType: 'text' },
    { 
      field: 'status', 
      header: '状态', 
      filterType: 'select',
      filterOptions: [
        { value: 'active', label: '活跃' },
        { value: 'inactive', label: '禁用' }
      ],
      render: (value) => (
        <span className={`status-${value}`}>
          {value === 'active' ? '✔' : '✖'}
        </span>
      )
    }
  ];

  const fetchData = async (params = {}) => {
    setLoading(true);
    try {
      const response = await api.get('/users', { params });
      setData(response.data);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    fetchData({
      page: pagination.current,
      pageSize: pagination.pageSize
    });
  }, [pagination]);

  const handlePageChange = (page, pageSize) => {
    setPagination({ current: page, pageSize });
  };

  const handleSort = (field, order) => {
    fetchData({
      ...pagination,
      sortField: field,
      sortOrder: order
    });
  };

  const handleFilter = (filters) => {
    fetchData({
      ...pagination,
      ...filters
    });
  };

  return (
    <Table
      columns={columns}
      data={data}
      pagination={{
        ...pagination,
        total: data.length
      }}
      loading={loading}
      onPageChange={handlePageChange}
      onSort={handleSort}
      onFilter={handleFilter}
    />
  );
};

7.2 服务端分页示例

jsx 复制代码
const ServerPagedTable = () => {
  const [state, setState] = useState({
    data: [],
    loading: false,
    pagination: { current: 1, pageSize: 10, total: 0 },
    filters: {},
    sort: {}
  });

  const columns = [/* 列定义 */];

  const fetchData = async () => {
    setState(prev => ({ ...prev, loading: true }));
    
    try {
      const params = {
        page: state.pagination.current,
        pageSize: state.pagination.pageSize,
        ...state.filters,
        ...state.sort
      };
      
      const res = await api.get('/data', { params });
      
      setState(prev => ({
        ...prev,
        data: res.data.items,
        pagination: {
          ...prev.pagination,
          total: res.data.total
        },
        loading: false
      }));
    } catch (error) {
      setState(prev => ({ ...prev, loading: false }));
    }
  };

  useEffect(() => {
    fetchData();
  }, [state.pagination.current, state.pagination.pageSize, state.filters, state.sort]);

  const handleTableChange = (pagination, filters, sort) => {
    setState(prev => ({
      ...prev,
      pagination: {
        ...prev.pagination,
        current: pagination.current,
        pageSize: pagination.pageSize
      },
      filters,
      sort: {
        field: sort.field,
        order: sort.order
      }
    }));
  };

  return (
    <Table
      columns={columns}
      data={state.data}
      pagination={state.pagination}
      loading={state.loading}
      onPageChange={(page, size) => handleTableChange(
        { current: page, pageSize: size },
        state.filters,
        state.sort
      )}
      onSort={(field, order) => handleTableChange(
        state.pagination,
        state.filters,
        { field, order }
      )}
      onFilter={(filters) => handleTableChange(
        state.pagination,
        filters,
        state.sort
      )}
    />
  );
};

八、测试方案

8.1 单元测试要点

javascript 复制代码
describe('Table Component', () => {
  it('正确渲染数据', () => {
    const data = [{ id: 1, name: 'Alice' }];
    const columns = [{ field: 'name', header: 'Name' }];
    render(<Table columns={columns} data={data} />);
    expect(screen.getByText('Alice')).toBeInTheDocument();
  });

  it('分页功能正常', () => {
    const data = Array.from({ length: 25 }, (_, i) => ({ id: i + 1 }));
    const { container } = render(
      <Table 
        columns={[{ field: 'id', header: 'ID' }]} 
        data={data} 
        pagination={{ current: 1, pageSize: 10 }}
      />
    );
    
    // 第一页应显示10条数据
    expect(container.querySelectorAll('tbody tr').length).toBe(10);
  });

  it('排序回调触发', () => {
    const onSort = jest.fn();
    render(
      <Table
        columns={[{ field: 'name', header: 'Name', sortable: true }]}
        data={[{ id: 1, name: 'Alice' }]}
        onSort={onSort}
      />
    );
    
    fireEvent.click(screen.getByText('Name'));
    expect(onSort).toHaveBeenCalledWith('name', 'asc');
  });

  it('筛选功能正常', async () => {
    const onFilter = jest.fn();
    render(
      <Table
        columns={[
          { 
            field: 'status', 
            header: 'Status', 
            filterType: 'select',
            filterOptions: [{ value: 'active', label: 'Active' }]
          }
        ]}
        data={[{ id: 1, status: 'active' }]}
        onFilter={onFilter}
      />
    );
    
    fireEvent.change(screen.getByRole('combobox'), { target: { value: 'active' } });
    await waitFor(() => {
      expect(onFilter).toHaveBeenCalledWith({ status: 'active' });
    });
  });
});

九、总结与最佳实践

9.1 设计原则总结

  1. 单一职责:表格组件只负责展示和交互,数据获取由父组件控制
  2. 受控组件:通过props完全控制组件状态
  3. 可扩展性:通过render props支持自定义渲染
  4. 性能优先:虚拟滚动、记忆化等技术优化性能

9.2 最佳实践建议

  1. 服务端模式:大数据量时使用服务端分页、排序和筛选
  2. 按需加载:初始只加载可见区域数据
  3. 可访问性:添加ARIA属性支持屏幕阅读器
  4. 主题定制:通过CSS变量支持主题定制
  5. 文档完善:提供详细的API文档和使用示例

9.3 扩展方向

  1. 树形表格:支持嵌套数据展示
  2. 单元格合并:复杂表头支持
  3. 拖拽排序:行和列的拖拽调整
  4. 导出功能:支持导出CSV/Excel
  5. 图表集成:在单元格中嵌入微型图表

通过以上设计和实现,我们创建了一个功能强大、可复用性高的表格组件,能够满足大多数业务场景的需求,同时也为后续扩展留下了充足的空间。

相关推荐
Net蚂蚁代码1 小时前
Angular入门的环境准备步骤工作
前端·javascript·angular.js
小着3 小时前
vue项目页面最底部出现乱码
前端·javascript·vue.js·前端框架
lichenyang4536 小时前
React ajax中的跨域以及代理服务器
前端·react.js·ajax
呆呆的小草6 小时前
Cesium距离测量、角度测量、面积测量
开发语言·前端·javascript
一 乐7 小时前
民宿|基于java的民宿推荐系统(源码+数据库+文档)
java·前端·数据库·vue.js·论文·源码
testleaf7 小时前
前端面经整理【1】
前端·面试
好了来看下一题7 小时前
使用 React+Vite+Electron 搭建桌面应用
前端·react.js·electron
啃火龙果的兔子7 小时前
前端八股文-react篇
前端·react.js·前端框架
小前端大牛马7 小时前
react中hook和高阶组件的选型
前端·javascript·vue.js
刺客-Andy7 小时前
React第六十二节 Router中 createStaticRouter 的使用详解
前端·javascript·react.js