react之通用表格组件最佳实践(TSX)

后台管理系统开发中,往往存在大量需要使用表格组件的地方,大部分只有接口和属性不一样,其余UI类似,可以使用同一个TSX文件来表示(tsx的react项目)

如果这两个表格的结构完全一样(比如都是展示列表,只是列名和数据类型不同),泛型 是最好的选择。它可以让你的组件像"模具"一样,根据传入的数据自动推断类型。

适用场景: 两个表格逻辑完全一致,只是数据类型不同(例如:用户表格 vs 订单表格)。

通用组件

javascript 复制代码
// TableComponent.tsx
import React from 'react';

// 1. 定义一个通用的 Props 接口,使用泛型 T
interface TableProps<T> {
  data: T[];
  columns: {
    title: string;
    dataIndex: keyof T; // 确保 dataIndex 必须是 T 中的字段
    render?: (val: any, record: T) => React.ReactNode;
  }[];
}

// 2. 使用泛型定义组件
export function GenericTable<T>({ data, columns }: TableProps<T>) {
  return (
    <table>
      <thead>
        <tr>
          {columns.map((col) => <th key={String(col.dataIndex)}>{col.title}</th>)}
        </tr>
      </thead>
      <tbody>
        {data.map((row, index) => (
          <tr key={index}>
            {columns.map((col) => (
              <td key={String(col.dataIndex)}>
                {col.render ? col.render(row[col.dataIndex], row) : String(row[col.dataIndex])}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

使用方式

javascript 复制代码
// 使用场景 A:用户表格
interface User { id: number; name: string; }
const users: User[] = [{ id: 1, name: 'Alice' }];
// TS 会自动推断 T 为 User,如果你填错了 dataIndex 会直接报错
<GenericTable data={users} columns={[{ title: '姓名', dataIndex: 'name' }]} />

// 使用场景 B:订单表格
interface Order { orderId: string; amount: number; }
const orders: Order[] = [{ orderId: 'A01', amount: 100 }];
// TS 会自动推断 T 为 Order
<GenericTable data={orders} columns={[{ title: '金额', dataIndex: 'amount' }]} />

核心建议

  1. 文件命名 :既然合并了,文件名就不要叫 UserTable.tsx,建议叫 BaseTable.tsxCommonTable.tsx
  2. 类型定义 :将各自的接口(Interface)放在单独的 types/ 文件夹或者文件顶部,保持清晰。
  3. Props 透传 :为了让组件更通用,记得继承 React.HTMLAttributesclassName / style 属性,这样你在外部使用时还能覆盖样式。

添加props透传后的通用组件

javascript 复制代码
// GenericTable.tsx
import React from 'react';

// 1. 定义列的配置接口
export interface ColumnType<T> {
  title: string;
  dataIndex: keyof T;
  width?: number | string;
  render?: (val: any, record: T) => React.ReactNode;
}

// 2. 定义组件 Props
// 关键点:使用 & 符号继承 React.HTMLAttributes<HTMLTableElement>
interface GenericTableProps<T> extends React.HTMLAttributes<HTMLTableElement> {
  data: T[];
  columns: ColumnType<T>[];
}

// 3. 组件实现
// 注意:这里解构了 className, style 和 ...restProps
export function GenericTable<T>({ 
  data, 
  columns, 
  className, 
  style, 
  ...restProps 
}: GenericTableProps<T>) {
  return (
    // 4. 将 className, style 和 restProps 透传给原生 table 标签
    <table 
      className={`my-custom-table ${className || ''}`} 
      style={{ borderCollapse: 'collapse', width: '100%', ...style }} 
      {...restProps}
    >
      <thead>
        <tr>
          {columns.map((col, index) => (
            <th 
              key={index} 
              style={{ width: col.width, borderBottom: '2px solid #ddd', padding: '12px', textAlign: 'left' }}
            >
              {col.title}
            </th>
          ))}
        </tr>
      </thead>
      <tbody>
        {data.map((row, rowIndex) => (
          // 假设数据里有 id,如果没有可以用 rowIndex
          <tr key={(row as any).id || rowIndex} style={{ borderBottom: '1px solid #eee' }}>
            {columns.map((col, colIndex) => (
              <td key={colIndex} style={{ padding: '12px' }}>
                {col.render 
                  ? col.render(row[col.dataIndex], row) 
                  : String(row[col.dataIndex])}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

调用示例

javascript 复制代码
import React from 'react';
import { GenericTable, ColumnType } from './GenericTable';

// --- 场景 1:用户表格 ---
interface User {
  id: number;
  name: string;
  email: string;
}

const UserList = () => {
  const users: User[] = [
    { id: 1, name: '张三', email: 'zhang@example.com' },
    { id: 2, name: '李四', email: 'li@example.com' },
  ];

  const columns: ColumnType<User>[] = [
    { title: '姓名', dataIndex: 'name' },
    { title: '邮箱', dataIndex: 'email', width: 200 },
  ];

  return (
    <div>
      <h3>用户管理</h3>
      {/* 
         1. 传入业务数据 
         2. 传入 className 覆盖样式 (比如加个背景色)
         3. 传入原生 onClick 事件
      */}
      <GenericTable 
        data={users} 
        columns={columns} 
        className="bg-gray-50 hover:bg-white transition"
        onClick={(e) => console.log('表格被点击了', e)}
        id="user-table-id" // 原生属性支持
      />
    </div>
  );
};

// --- 场景 2:订单表格 ---
interface Order {
  orderNo: string;
  amount: number;
  status: 'pending' | 'paid';
}

const OrderList = () => {
  const orders: Order[] = [
    { orderNo: 'ORD-001', amount: 199, status: 'paid' },
    { orderNo: 'ORD-002', amount: 50, status: 'pending' },
  ];

  const columns: ColumnType<Order>[] = [
    { title: '订单号', dataIndex: 'orderNo' },
    { 
      title: '金额', 
      dataIndex: 'amount',
      // 自定义渲染
      render: (val) => `¥${val.toFixed(2)}` 
    },
  ];

  return (
    <div>
      <h3>订单列表</h3>
      {/* 这里可以单独设置不同的内联样式 */}
      <GenericTable 
        data={orders} 
        columns={columns} 
        style={{ border: '1px solid red' }} 
      />
    </div>
  );
};

添加分页后的通用表格组件

为通用表格组件添加分页功能,最灵活的方式是将分页逻辑"受控化 "。这意味着组件本身不维护页码状态,而是通过 pagination 属性接收当前页码、总条数和每页大小,并通过 onChange 回调通知外部状态变更。这样既支持前端分页(传入所有数据,组件内部切片),也支持后端分页(外部根据页码请求接口)。

javascript 复制代码
// GenericTable.tsx
import React from 'react';

// --- 1. 类型定义 ---

// 列配置
export interface ColumnType<T> {
  title: string;
  dataIndex: keyof T;
  width?: number | string;
  render?: (val: any, record: T) => React.ReactNode;
}

// 分页配置
export interface PaginationConfig {
  current: number;       // 当前页码 (从 1 开始)
  pageSize: number;      // 每页条数
  total: number;         // 总数据条数
  onChange?: (page: number, pageSize: number) => void; // 页码改变时的回调
}

// 组件 Props
interface GenericTableProps<T> extends React.HTMLAttributes<HTMLDivElement> {
  data: T[];             // 原始全量数据(如果是前端分页)
  columns: ColumnType<T>[];
  pagination?: PaginationConfig | false; // 传入配置对象开启分页,传 false 关闭
}

// --- 2. 组件实现 ---

export function GenericTable<T extends { id?: string | number }>({ 
  data, 
  columns, 
  className, 
  style, 
  pagination,
  ...restProps 
}: GenericTableProps<T>) {
  
  // --- 分页逻辑处理 ---
  let displayData = data;
  let currentPage = 1;
  let pageSize = 10;

  if (pagination && typeof pagination === 'object') {
    currentPage = pagination.current;
    pageSize = pagination.pageSize;
    
    // 计算切片索引 (注意:current 通常从 1 开始,数组索引从 0 开始)
    const startIndex = (currentPage - 1) * pageSize;
    const endIndex = startIndex + pageSize;
    
    // 截取当前页数据
    displayData = data.slice(startIndex, endIndex);
  }

  // 处理页码切换
  const handlePageChange = (newPage: number) => {
    if (pagination && pagination.onChange) {
      pagination.onChange(newPage, pageSize);
    }
  };

  return (
    // 外层包裹 div,方便控制布局和分页器位置
    <div 
      className={`table-wrapper ${className || ''}`} 
      style={{ ...style }} 
      {...restProps}
    >
      <table style={{ borderCollapse: 'collapse', width: '100%', textAlign: 'left' }}>
        <thead>
          <tr>
            {columns.map((col, index) => (
              <th 
                key={index} 
                style={{ 
                  width: col.width, 
                  borderBottom: '2px solid #ddd', 
                  padding: '12px', 
                  backgroundColor: '#f9f9f9' 
                }}
              >
                {col.title}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {displayData.length === 0 ? (
            <tr>
              <td colSpan={columns.length} style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
                暂无数据
              </td>
            </tr>
          ) : (
            displayData.map((row) => (
              <tr key={row.id || (row as any).id || Math.random()} style={{ borderBottom: '1px solid #eee' }}>
                {columns.map((col, colIndex) => (
                  <td key={colIndex} style={{ padding: '12px' }}>
                    {col.render 
                      ? col.render((row as any)[col.dataIndex], row) 
                      : String((row as any)[col.dataIndex])}
                  </td>
                ))}
              </tr>
            ))
          )}
        </tbody>
      </table>

      {/* --- 3. 分页控件 UI --- */}
      {pagination && typeof pagination === 'object' && (
        <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '16px', gap: '8px' }}>
          <button
            onClick={() => handlePageChange(currentPage - 1)}
            disabled={currentPage === 1}
            style={{ padding: '6px 12px', cursor: currentPage === 1 ? 'not-allowed' : 'pointer' }}
          >
            上一页
          </button>
          
          <span style={{ padding: '6px 12px', border: '1px solid #ddd', display: 'flex', alignItems: 'center' }}>
            第 {currentPage} 页 / 共 {Math.ceil(pagination.total / pageSize)} 页
          </span>

          <button
            onClick={() => handlePageChange(currentPage + 1)}
            disabled={currentPage >= Math.ceil(pagination.total / pageSize)}
            style={{ padding: '6px 12px', cursor: currentPage >= Math.ceil(pagination.total / pageSize) ? 'not-allowed' : 'pointer' }}
          >
            下一页
          </button>
        </div>
      )}
    </div>
  );
}

实现

javascript 复制代码
const App = () => {
  const [tableData, setTableData] = useState([]);
  const [loading, setLoading] = useState(false);
  const [pagination, setPagination] = useState({
    current: 1,
    pageSize: 10,
    total: 0
  });

  // 模拟请求后端数据
  const fetchData = async (page: number) => {
    setLoading(true);
    // 模拟 API 调用: /api/users?page=1&size=10
    const res = await mockApiCall(page, 10); 
    setTableData(res.list); // 只有 10 条数据
    setPagination(prev => ({ ...prev, current: page, total: res.totalCount })); // 更新总条数
    setLoading(false);
  };

  // 初始化加载
  useEffect(() => {
    fetchData(1);
  }, []);

  return (
    <GenericTable 
      data={tableData} // 只传入当前页数据
      columns={userColumns}
      pagination={{
        ...pagination,
        onChange: (newPage) => fetchData(newPage) // 翻页时触发新的请求
      }}
    />
  );
};

CommonTable.vue

将React的通用表格组件迁移到Vue,并使用Element UI(或Element Plus)的 el-table 和 el-pagination,思路非常相似,但实现细节上利用了Vue的响应式特性(Reactivity)和计算属性(Computed)。

在Vue中,我们通常使用Props接收配置,使用Emits抛出事件,利用Computed处理数据切片。

以下是基于 Vue3 + Element Plus 的封装方案(Vue 2 + Element UI 逻辑基本一致,只需调整语法)。

javascript 复制代码
<!-- CommonTable.vue -->
<template>
  <div class="common-table-container">
    <!-- 1. 表格主体 -->
    <el-table
      :data="displayData"
      v-bind="$attrs" 
      style="width: 100%"
      v-loading="loading"
    >
      <!-- 默认插槽:允许外部自定义列 -->
      <slot />
      
      <!-- 如果没有插槽内容,且传了 columns 配置,可以使用默认渲染(可选) -->
      <template v-if="!$slots.default && columns">
        <el-table-column
          v-for="col in columns"
          :key="col.prop"
          :prop="col.prop"
          :label="col.label"
          :width="col.width"
        />
      </template>
    </el-table>

    <!-- 2. 分页控件 -->
    <div class="pagination-wrapper" v-if="pagination">
      <el-pagination
        v-model:current-page="currentPage"
        v-model:page-size="pageSize"
        :page-sizes="pageSizes"
        :total="total"
        layout="total, sizes, prev, pager, next, jumper"
        @size-change="handleSizeChange"
        @current-change="handleCurrentChange"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch } from 'vue';

// --- 1. 定义 Props ---
interface ColumnType {
  prop: string;
  label: string;
  width?: number | string;
}

interface PaginationConfig {
  currentPage: number;
  pageSize: number;
  total: number;
}

const props = withDefaults(defineProps<{
  data: any[];             // 全量数据(用于前端分页)
  columns?: ColumnType[];  // 可选的列配置
  pagination?: boolean | PaginationConfig; // 是否开启分页或配置
  loading?: boolean;
}>(), {
  pagination: false,
  loading: false
});

// --- 2. 定义 Emits ---
const emit = defineEmits<{
  // 当页码或每页条数改变时触发
  (e: 'page-change', current: number, size: number): void;
}>();

// --- 3. 响应式状态管理 ---
// 如果传入了 pagination 配置,则使用配置中的值,否则使用默认值
const currentPage = ref(props.pagination && typeof props.pagination === 'object' ? props.pagination.currentPage : 1);
const pageSize = ref(props.pagination && typeof props.pagination === 'object' ? props.pagination.pageSize : 10);
const pageSizes = [10, 20, 50, 100];

// 监听外部传入的 pagination 变化(例如外部重置了页码)
watch(() => props.pagination, (newVal) => {
  if (newVal && typeof newVal === 'object') {
    currentPage.value = newVal.currentPage;
    pageSize.value = newVal.pageSize;
  }
}, { deep: true });

// --- 4. 核心逻辑:计算属性 ---

// 总条数
const total = computed(() => {
  if (props.pagination && typeof props.pagination === 'object') {
    return props.pagination.total;
  }
  return props.data.length;
});

// 显示的数据(核心:前端分页切片)
const displayData = computed(() => {
  if (!props.pagination) {
    return props.data; // 不分页,返回全部
  }

  const start = (currentPage.value - 1) * pageSize.value;
  const end = start + pageSize.value;
  return props.data.slice(start, end);
});

// --- 5. 事件处理 ---
const handleCurrentChange = (val: number) => {
  emit('page-change', val, pageSize.value);
};

const handleSizeChange = (val: number) => {
  // 切换每页条数时,通常重置回第一页
  emit('page-change', 1, val);
};
</script>

<style scoped>
.common-table-container {
  width: 100%;
}
.pagination-wrapper {
  margin-top: 20px;
  display: flex;
  justify-content: flex-end; /* 分页靠右 */
}
</style>

后端分页

javascript 复制代码
<!-- ParentComponent.vue -->
<template>
  <CommonTable
    :data="tableData"
    :pagination="pageConfig"
    :loading="loading"
    @page-change="fetchData"
  >
    <el-table-column prop="name" label="姓名" />
  </CommonTable>
</template>

<script setup>
import { ref, reactive, onMounted } from 'vue';
import CommonTable from './CommonTable.vue';

const tableData = ref([]);
const loading = ref(false);

// 分页状态
const pageConfig = reactive({
  currentPage: 1,
  pageSize: 10,
  total: 0
});

// 模拟后端请求
const fetchData = async (current, size) => {
  loading.value = true;
  // 1. 更新当前页码状态
  pageConfig.currentPage = current;
  pageConfig.pageSize = size;

  // 2. 模拟 API 请求
  // const res = await api.getUsers({ page: current, size: size });
  
  // 模拟延迟
  setTimeout(() => {
    tableData.value = [/* 当前页的 10 条数据 */];
    pageConfig.total = 100; // 假设后端返回总条数
    loading.value = false;
  }, 500);
};

onMounted(() => {
  fetchData(1, 10);
});
</script>

总结

这是最"React + TypeScript"风格的做法,既复用了代码,又保证了类型安全。是 React 开发中非常推荐的**"高内聚、低耦合"**的最佳实践!

相关推荐
LZQ <=小氣鬼=>2 小时前
React 插槽(Slot)
前端·javascript·react.js
Z_Wonderful2 小时前
React 中基于 Axios 的二次封装(含请求守卫)
javascript·react.js·ecmascript
早點睡3902 小时前
ReactNative项目OpenHarmony三方库集成实战:react-native-shimmer-placeholder
javascript·react native·react.js
哈__2 小时前
ReactNative项目OpenHarmony三方库集成实战:react-native-splash-screen
javascript·react native·react.js
浮游本尊2 小时前
React 18.x 学习计划 - 第十五天:GraphQL 与实时应用实战
学习·react.js·graphql
qq_406176142 小时前
React 状态管理完全指南:从入门到选型
前端·javascript·react.js
终端鹿12 小时前
Vue3 模板引用 (ref):操作 DOM 与子组件实例 从入门到精通
前端·javascript·vue.js
蜡台13 小时前
Vue 打包优化
前端·javascript·vue.js·vite·vue-cli
卷帘依旧14 小时前
JavaScript中this绑定问题详解
前端·javascript