后台管理系统开发中,往往存在大量需要使用表格组件的地方,大部分只有接口和属性不一样,其余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' }]} />
核心建议
- 文件命名 :既然合并了,文件名就不要叫
UserTable.tsx,建议叫BaseTable.tsx或CommonTable.tsx。 - 类型定义 :将各自的接口(Interface)放在单独的
types/文件夹或者文件顶部,保持清晰。 - Props 透传 :为了让组件更通用,记得继承
React.HTMLAttributes或className/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 开发中非常推荐的**"高内聚、低耦合"**的最佳实践!