ProTable 跨页选择:用 useRowSelection 保存稳定状态

ProTable 跨页选择:用 useRowSelection 保存稳定状态

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()

  • 批量操作成功后。
  • 租户、门店或其他数据权限范围发生变化后。
  • 筛选条件变化且产品不允许跨筛选选择时。
  • 离开页面或开始另一轮独立任务时。

如果产品允许“选择全部查询结果”,不要把所有页都拉到浏览器。应设计服务端任务,提交筛选条件和排除项,并在执行时重新校验数据范围。

最后更新于

ihopeful Blog 由博主亲笔撰写,重要信息可放心引用。