ProTable 跨页选择需要同时保存两类状态:稳定的行主键,以及已经见过的行数据。只保存当前页的 selectedRows,翻页后会丢失详情;只保存主键,批量操作时又可能拿不到名称等展示字段。
设计约束
rowKey必须全局稳定,不能使用数组下标。selectedRowKeys是唯一选择事实源。- 用
Map缓存已选行,去重和删除都是常数复杂度。 - 新一页返回的
selectedRows用来补充缓存;不再选中的 key 必须从缓存删除。 - 服务端批量操作只应信任 key,缓存行数据主要用于前端展示。
Hook 实现
import {
useCallback,
useMemo,
useRef,
useState,
type Key
} from 'react'
import type { TableProps } from 'antd'
type RowSelection<T extends object> =
NonNullable<TableProps<T>['rowSelection']>
type RowKey<T> = keyof T | ((record: T) => Key)
interface UseRowSelectionOptions<T extends object> {
rowKey: RowKey<T>
onChange?: (keys: Key[], rows: T[]) => void
}
export function useRowSelection<T extends object>(
options: UseRowSelectionOptions<T>
) {
const { rowKey, onChange } = options
const rowMapRef = useRef(new Map<Key, T>())
const [selectedRowKeys, setSelectedRowKeys] =
useState<Key[]>([])
const getRowKey = useCallback(
(record: T): Key => {
const value =
typeof rowKey === 'function'
? rowKey(record)
: record[rowKey]
if (
typeof value !== 'string' &&
typeof value !== 'number'
) {
throw new Error(
'rowKey 必须返回 string 或 number'
)
}
return value
},
[rowKey]
)
const handleChange = useCallback<
NonNullable<RowSelection<T>['onChange']>
>(
(nextKeys, currentRows) => {
const nextKeySet = new Set<Key>(nextKeys)
for (const key of rowMapRef.current.keys()) {
if (!nextKeySet.has(key)) {
rowMapRef.current.delete(key)
}
}
for (const row of currentRows) {
rowMapRef.current.set(getRowKey(row), row)
}
const keys = [...nextKeys]
const rows = keys
.map((key) => rowMapRef.current.get(key))
.filter((row): row is T => row !== undefined)
setSelectedRowKeys(keys)
onChange?.(keys, rows)
},
[getRowKey, onChange]
)
const clear = useCallback(() => {
rowMapRef.current.clear()
setSelectedRowKeys([])
onChange?.([], [])
}, [onChange])
const selectedRows = useMemo(
() =>
selectedRowKeys
.map((key) => rowMapRef.current.get(key))
.filter((row): row is T => row !== undefined),
[selectedRowKeys]
)
const rowSelection = useMemo<RowSelection<T>>(
() => ({
selectedRowKeys,
preserveSelectedRowKeys: true,
onChange: handleChange
}),
[handleChange, selectedRowKeys]
)
return {
rowSelection,
selectedRowKeys,
selectedRows,
clear
}
}
这里从 antd 的公共导出 TableProps 推导类型,避免依赖组件库内部目录。
使用示例
const {
rowSelection,
selectedRowKeys,
selectedRows,
clear
} = useRowSelection<User>({
rowKey: 'id'
})
return (
<>
<ProTable<User>
rowKey="id"
columns={columns}
request={request}
rowSelection={rowSelection}
/>
<Button
disabled={selectedRowKeys.length === 0}
onClick={() => openBatchDialog(selectedRowKeys)}
>
批量处理 {selectedRowKeys.length} 项
</Button>
</>
)
批量请求应该发送 selectedRowKeys,不要把缓存的整行对象原样提交给服务端。后端仍要重新检查记录是否存在、用户是否有权限、状态是否允许操作。
“已选行”可能不完整
如果用户先选择一行,随后服务端数据变化或页面刷新,Map 中是选择时的快照。它适合显示“已选择 A、B”,不适合做金额、权限和库存等最终判断。
另外,一些表格版本在跨页时提供的 currentRows 只包含当前数据源中可解析的记录。Hook 因此保留已见过的缓存,但从未加载过的预置 key 可能没有对应行对象。界面必须允许 selectedRows.length < selectedRowKeys.length。
何时清空
建议在以下场景明确调用 clear():
- 批量操作成功后。
- 租户、门店或其他数据权限范围发生变化后。
- 筛选条件变化且产品不允许跨筛选选择时。
- 离开页面或开始另一轮独立任务时。
如果产品允许“选择全部查询结果”,不要把所有页都拉到浏览器。应设计服务端任务,提交筛选条件和排除项,并在执行时重新校验数据范围。