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

@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. 图表集成:在单元格中嵌入微型图表

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

相关推荐
To_OC1 小时前
别再串行写 await 了,Promise.all 才是并行请求的正确打开方式
前端·javascript·promise
vipbic2 小时前
中后台越做越乱后,我用插件化把它救回来了
前端·vue.js
Hyyy2 小时前
Computer Use 适合做什么,不适合做什么——一次真实使用后的思考
前端
小和尚同志2 小时前
前端 AI 单元测试思考与落地
前端·人工智能·aigc
invicinble3 小时前
c端系统,其实更像一个信息展示平台
前端
李姆斯4 小时前
管理是否可以被完全量化
前端·产品经理·团队管理
名字还没想好☜5 小时前
Next.js 中间件实战:鉴权、重定向与 A/B 分流
开发语言·前端·javascript·中间件·react·next.js
广州灵眸科技有限公司5 小时前
瑞芯微RV1126B开发板(EASY-EAI-PI2) INI文件操作
java·前端·javascript·网络·人工智能
江华森6 小时前
Web 开发基础:HTTP 与 REST
前端·网络协议·http
IT_陈寒6 小时前
Redis的KEYS命令把我搞崩溃了,改用SCAN才活过来
前端·人工智能·后端