平时做后台管理系统,Ant Design Pro 的 ProTable 用得特别多,但原生组件缺少列宽拖拽调整 和点击行选中这两个高频功能,每次都要重复写逻辑。
所以我把这两个功能封装成了通用组件,后续项目直接复用,这里整理成笔记方便自己查阅。
一、整体思路
把功能拆成两部分解耦:
- 列宽拖拽核心逻辑:独立封装可调整表头组件,无业务侵入
- ProTable 业务封装:集成列宽拖拽 + 点击行选中 + 选中状态受控/非受控 + 暴露清空选中方法
两个文件配合使用,开箱即用,支持 TypeScript,兼容 ProTable 所有原生属性。
二、列宽拖拽表头封装(ResizableTitle.tsx)
这是列宽拖拽的核心,基于原生 th 实现鼠标按下、移动、抬起的完整拖拽逻辑,最小宽度限制 80px,右侧有拖拽触发区,体验接近 Excel。
tsx
import React, { useState, useCallback } from 'react';
// 表格列配置类型
export interface TableColumnType {
width?: number;
title?: React.ReactNode;
dataIndex?: string;
key?: string;
[key: string]: any;
}
// 表头组件
interface ResizableTitleProps {
width?: number;
onResize?: (width: number) => void;
[key: string]: any;
}
const ResizableTitle: React.FC<ResizableTitleProps> = (props) => {
const { width, onResize, ...restProps } = props;
const [isResizing, setIsResizing] = useState(false);
// 鼠标按下开始拖拽
const handleMouseDown = useCallback((e: React.MouseEvent) => {
const thRect = e.currentTarget.getBoundingClientRect();
// 只在右侧 10px 区域触发拖拽
const isOnEdge = e.clientX > thRect.right - 10;
if (!isOnEdge) return;
e.preventDefault();
setIsResizing(true);
const startX = e.clientX;
const startWidth = thRect.width;
// 拖拽中实时更新宽度
const handleMouseMove = (moveEvent: MouseEvent) => {
const diff = moveEvent.clientX - startX;
const newWidth = Math.max(80, startWidth + diff);
onResize?.(newWidth);
};
// 松开鼠标结束拖拽
const handleMouseUp = () => {
setIsResizing(false);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}, [onResize]);
return (
<th
{...restProps}
onMouseDown={handleMouseDown}
style={{
width,
position: 'relative',
paddingRight: '10px',
cursor: isResizing ? 'col-resize' : undefined,
userSelect: 'none',
}}
>
{/* 拖拽触发区域 */}
<span
style={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: '10px',
cursor: 'col-resize',
backgroundColor: 'transparent',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'rgba(22, 119, 255, 0.1)';
}}
onMouseLeave={(e) => {
if (!isResizing) {
e.currentTarget.style.backgroundColor = 'transparent';
}
}}
/>
{props.children}
</th>
);
};
// 注入到 ProTable 表头
export const components = {
header: {
cell: ResizableTitle,
},
};
// 处理列配置,绑定拖拽回调
export const getMergeColumns = (
columns: TableColumnType[],
setColumns: React.Dispatch<React.SetStateAction<TableColumnType[]>>
) => {
return columns.map((col, index) => ({
...col,
onHeaderCell: (column: TableColumnType) => ({
width: column.width,
onResize: (newWidth: number) => {
setColumns((prev: TableColumnType[]) => {
const next = [...prev];
next[index] = {
...next[index],
width: newWidth,
};
return next;
});
},
}),
}));
};
export default ResizableTitle;
核心要点
- 拖拽只触发在表头右侧 10px 区域,不影响正常点击
- 最小宽度 80px,防止列被缩没
- 鼠标悬浮拖拽区有淡蓝色提示,体验更好
- 对外暴露
components和getMergeColumns供 ProTable 集成
三、ProTable 业务封装(MyProTable.tsx)
在 ProTable 基础上集成:
- 列宽拖拽
- 点击行选中(支持单选/多选)
- 选中状态支持外部受控 / 内部非受控
- 暴露
clearSelected方法清空选中 - 搜索栏按钮顺序调整(查询在前,重置在后)
- 完全兼容 ProTable 原有属性
tsx
import { ProTable, type ProTableProps } from '@ant-design/pro-components';
import React, { forwardRef, useImperativeHandle, useState } from 'react';
import { components, getMergeColumns } from '../ResizableTitle';
// 暴露给父组件的方法
export interface MyProTableRef {
clearSelected: () => void;
}
// 扩展 ProTable 属性
export type MyProTableProps<
T extends Record<string, any>,
U extends Record<string, any> = Record<string, any>,
ValueType = 'text'
> = ProTableProps<T, U, ValueType> & {
enableRowSelect?: boolean; // 是否开启点击选中
selectedRowKeys?: React.Key[]; // 外部受控选中key
onSelectedChange?: (keys: React.Key[], rows: T[]) => void; // 选中变化回调
multiple?: boolean; // 是否多选
};
const MyProTableInner = <
T extends Record<string, any>,
U extends Record<string, any> = Record<string, any>,
ValueType = 'text'
>(
props: MyProTableProps<T, U, ValueType>,
ref: React.ForwardedRef<MyProTableRef>
) => {
const {
enableRowSelect = true,
selectedRowKeys,
onSelectedChange,
multiple = false,
rowKey = 'id' as keyof T,
columns = [],
...restProps
} = props;
// 内部选中状态(非受控模式)
const [innerKeys, setInnerKeys] = useState<React.Key[]>([]);
const finalKeys = selectedRowKeys ?? innerKeys;
// 列宽拖拽状态
const [renderColumns, setRenderColumns] = useState<any[]>(columns);
const resizeColumns = getMergeColumns(renderColumns, setRenderColumns as any);
// 选中变化统一处理
const handleChange = (keys: React.Key[], rows: T[]) => {
if (selectedRowKeys === undefined) setInnerKeys(keys);
onSelectedChange?.(keys, rows);
};
// 获取行唯一 key
const getRowKey = (record: T): React.Key => {
if (typeof rowKey === 'function') return rowKey(record);
return record[rowKey] as React.Key;
};
// 点击行触发选中
const handleClick = (record: T) => {
if (!enableRowSelect) return;
const key = getRowKey(record);
let newKeys: React.Key[];
if (multiple) {
// 多选:切换当前行选中状态
newKeys = finalKeys.includes(key)
? finalKeys.filter((k) => k !== key)
: [...finalKeys, key];
} else {
// 单选:只保留当前行或清空
newKeys = finalKeys.includes(key) ? [] : [key];
}
// 匹配选中行数据
const selectedRows = newKeys
.map((k) => restProps.dataSource?.find((item) => getRowKey(item) === k))
.filter((item): item is T => !!item);
handleChange(newKeys, selectedRows);
};
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
clearSelected: () => handleChange([], []),
}));
return (
<ProTable<T, U, ValueType>
{...restProps}
rowKey={rowKey}
columns={resizeColumns as any}
components={components} // 注入可拖拽表头
onRow={(record) => ({
...restProps.onRow?.(record),
onClick: () => handleClick(record), // 绑定点击行事件
})}
rowClassName={(record, index, indent) => {
const key = getRowKey(record);
const isSelected = finalKeys.includes(key);
let customClass = '';
// 兼容外部传入的 className
if (typeof restProps.rowClassName === 'function') {
customClass = restProps.rowClassName(record, index, indent);
} else if (typeof restProps.rowClassName === 'string') {
customClass = restProps.rowClassName;
}
return isSelected ? `table-row-selected ${customClass}` : customClass;
}}
// 搜索栏:查询按钮在前,重置按钮在后
search={{
...restProps.search,
optionRender: (_searchConfig, _formProps, dom) => {
if (!dom || dom.length < 2) return dom;
const [resetBtn, submitBtn] = dom;
return [submitBtn, resetBtn];
},
}}
/>
);
};
// 转发 ref,支持泛型
const MyProTable = forwardRef(MyProTableInner) as <
T extends Record<string, any>,
U extends Record<string, any> = Record<string, any>,
ValueType = 'text'
>(
props: MyProTableProps<T, U, ValueType> & { ref?: React.ForwardedRef<MyProTableRef> }
) => React.ReactElement;
export default MyProTable;
样式补充(全局加一行即可)
选中行高亮样式,在全局 global.less 中添加:
less
.table-row-selected {
background-color: rgba(22, 119, 255, 0.1) !important;
}
四、使用示例
封装完后使用非常简单,和普通 ProTable 用法几乎一致:
tsx
import React, { useRef } from 'react';
import MyProTable, { MyProTableRef } from './MyProTable';
const DemoPage = () => {
const tableRef = useRef<MyProTableRef>(null);
const columns = [
{ title: 'ID', dataIndex: 'id', width: 100 },
{ title: '姓名', dataIndex: 'name', width: 150 },
{ title: '年龄', dataIndex: 'age', width: 120 },
{ title: '地址', dataIndex: 'address' },
];
// 选中回调
const handleSelect = (keys, rows) => {
console.log('选中keys:', keys);
console.log('选中数据:', rows);
};
return (
<div>
<button onClick={() => tableRef.current?.clearSelected()}>
清空选中
</button>
<MyProTable
ref={tableRef}
columns={columns}
rowKey="id"
multiple={true} // 开启多选
onSelectedChange={handleSelect}
request={async () => {
// 模拟接口
return {
data: [
{ id: 1, name: '张三', age: 20, address: '北京' },
{ id: 2, name: '李四', age: 25, address: '上海' },
],
total: 2,
};
}}
/>
</div>
);
};
export default DemoPage;
五、使用心得
- 列宽拖拽和业务完全解耦,后续想用到普通 Table 也能快速抽离
- 点击行选中支持单选/多选,满足大部分列表场景
- 选中状态支持受控/非受控,适配表单联动或独立使用
- 完全兼容 ProTable 原生属性,老项目改造成本极低
- 封装后项目里只需要引入
MyProTable,不用再写重复逻辑
这个封装基本覆盖了后台系统表格 90% 的常用场景,后续直接复制这两个文件就能用。