antd 的 Table 里加一列多选框,乍看是后台开发里最没技术含量的事——给rowSelection传个对象就完事了。但我这些年接手过的中后台项目里,真正反复翻车的恰恰就是这列多选框:跨页勾选丢状态、全选把禁用行也选上、批量删除完selectedRowKeys里还留着幽灵 ID、点一次删除发出去十个重复请求、切换分页回来发现勾选全没了。这些问题单个修起来都不难,难的是它们通常在同一张表里同时出现,而且往往在上线后才被业务方发现。
这篇内容我想把 antd table 的 rowSelection 多选框从头到尾拆一遍。它适合三类人:刚接手后台表格、还在照着文档抄配置的同学;已经写过受控selectedRowKeys但被跨页选中、条件禁用搞晕的同学;还有准备做批量导出、批量审核、批量删除这类"选择列表"功能的同学。我会先讲清楚这列多选框背后的真实需求,再逐项拆解核心配置,然后给一份可复现的完整实现,最后把我踩过的坑整理成速查表。
1. 从需求出发:这列多选框到底要解决什么
很多人写 rowSelection 是"照着别人代码抄",抄来的配置能跑,但一旦产品经理加一句"已锁定的数据不能勾选"或者"换页之后要保持选中",代码就崩了。根子在需求没拆清楚。
1.1 一列多选框背后的四类真实需求
多选框看起来只是"选中若干行",但拆开看,实际是四件独立的事:
第一件是选择本身。用户点击复选框、点击表头全选、按住 Shift 连选,这三种交互在 antd 里走的是不同回调,onSelect、onSelectAll、onSelectMultiple各管一段,onChange只是它们的汇总出口。很多人只挂onChange,结果发现 Shift 连选拿不到具体哪些行是"新增选中",做不了增量埋点。
第二件是权限与状态约束。企业后台里几乎不会出现"所有行都能选"这种情况。常见约束包括:已归档的数据不能选、不是本人负责的数据不能选、审核中的记录不能选、当前登录人自己不显示复选框。这类需求全部压在getCheckboxProps上。
第三件是跨页保持。分页表格天然是"每次只拿一页数据",但业务方的心理预期是"我勾了 8 条,翻到第 2 页再勾 2 条,那就是 10 条"。这中间的状态要保持住,靠的是preserveSelectedRowKeys加上你自己维护的一份选中数据池。
第四件是批量动作。选中是为了做事:批量删除、批量导出、批量改状态、批量派单。这里最容易被忽略的是"选中数量与操作的关系"——选 1 条和选 2000 条,产品上应该是完全不同的两个流程,前者可以直接做,后者必须二次确认甚至限制上限。
把这四件事分开看,你会发现 rowSelection 的配置项其实是一一对应的,不是一堆可以随便拼的参数。
1.2 为什么我建议一开始就用受控写法
antd 的 rowSelection 有非受控和受控两种形态。非受控就是不传selectedRowKeys,只传onChange,Table 内部自己维护选中状态;受控就是selectedRowKeys由你通过useState提供,onChange里把它 set 回去。
文档里的示例大多是非受控,因为看着简洁。但我强烈建议后台项目一律用受控,理由有三个。
第一,批量操作需要读状态。顶部工具栏通常长这样:左边"已选中 3 项",右边"批量删除""批量导出"按钮。这个数字和按钮的禁用状态,必然要从selectedRowKeys.length来。非受控模式下你只能在onChange里另存一份,等于绕一圈又回到受控。
第二,数据刷新后需要主动清理。用户勾选 3 条,点了删除,接口返回成功,你的dataSource更新了。如果此时selectedRowKeys里还留着那 3 个已删除的 id,再点一次删除就会发出无效请求,服务端可能直接报错,也可能静默成功,但前端选中数显示是错的。受控模式下,你在setDataSource的同时setSelectedRowKeys(prev => prev.filter(...))就行,干净利落。
第三,便于抽成公共 Hook。一个中后台项目少说有十几张需要批量选择的表。受控写法可以把选中逻辑、跨页保持、批量确认全部封进一个useRowSelection,十几张表复用同一套行为,改一处全局生效。非受控写法做不到这一点,因为状态藏在 Table 内部,你摸不到。
代价是代码多写十几行。我认为这十几行值得。
2. rowSelection 核心配置逐项拆解
我把 rowSelection 的配置项分成三组:必填三件套、行为控制项、外观项。先看最关键的必填部分。
2.1 三件套:selectedRowKeys、onChange、rowKey
先给出一张对照表,把最核心的三个属性的作用、取值和常见错误说清楚:
| 属性 | 作用 | 容易错在哪 |
|---|---|---|
rowKey | 声明每行的唯一标识 | 不写时默认取每行的key字段,数据里没有key就会用索引兜底,导致选中错位 |
selectedRowKeys | 受控的选中集合 | 声明了却不写onChange,界面点不动 |
onChange | 选中集合变化时的回调 | 只更新 keys 不更新行的完整数据,导出时拿不到字段 |
rowKey是最容易被忽略的一个。antd Table 的默认rowKey是"key",如果你的接口返回的数据里字段叫id而不是key,就必须显式写rowKey="id"。否则 antd 会退化成用行索引当 key,页面上看不出问题,但选中行为会变得很诡异:删除第一行之后,原本选中的第二行会莫名其妙变成选中状态,因为你"选中的"其实是"索引 1"这个位置,而数据整体前移了一位。
onChange的签名是(selectedRowKeys, selectedRows, info) => void。第三个参数info在 antd 4.4 之后才有,里面有个info.type,取值是'all' | 'none' | 'invert' | 'single' | 'multiple',分别对应表头全选、取消全选、反选、单行勾选、Shift 连选。如果你需要区分用户是"点了一行"还是"点了全选"(比如全选时不做单个埋点,只做一次汇总埋点),就得读这个字段。
selectedRows这个参数有个坑需要单独说:它只包含当前渲染出来的行。配合preserveSelectedRowKeys做跨页选中时,已翻页离开的行不在selectedRows里。所以千万不要把selectedRows直接存下来当作"用户选中的全部数据",导出时你会发现少了一大半。正确做法是另存一份Map<key, row>,在onChange里增量更新。
2.2 getCheckboxProps:条件禁用的唯一入口
getCheckboxProps接收一行数据,返回该行复选框的原生属性,最常用的是disabled。
getCheckboxProps: (record) => ({ disabled: record.status === 'archived', name: `row-${record.id}`, }),需要特别注意两点。
第一,被禁用的行不应该出现在selectedRowKeys里。如果你是通过接口恢复历史选中状态,或者做"全选当前页"的自定义逻辑,务必先过滤掉禁用行的 key。否则会出现一个非常尴尬的现象:一行显示为灰色不可点,但顶部工具条写着"已选中 5 项",用户完全不知道那 5 项是从哪来的。
第二,表头全选会自动跳过禁用行。这点 antd 处理得很好:如果当前页有 10 行、其中 2 行禁用,点表头全选只会选中那 8 行。但如果你自己实现了"跨页全选全部数据",就必须在拼 key 数组时手动排除禁用行,因为那时代码已经不走 antd 的默认逻辑了。
还有一个高阶用法:给getCheckboxProps返回的name传值。这个属性会渲染到 input 上,方便做自动化测试或者配合原生表单。在需要做端到端测试的项目里很有用,测试脚本可以直接按name="row-1024"定位到具体某行的复选框。
2.3 preserveSelectedRowKeys:跨页保持的关键开关
preserveSelectedRowKeys: true的作用是:当某一行从dataSource中消失时(通常是翻页或筛选导致),antd 不会把它的 key 从选中集合里剔除。
不开这个开关会怎样?用户在第 1 页勾了 3 条,点下一页,dataSource换成了第 2 页的数据,第 1 页那 3 条的 key 因为"找不到对应的行"被自动清掉,选中数变成 0。用户会觉得"这破系统选不住东西"。
开了之后,选中集合会保留。但请记住我在 2.1 里说的:selectedRows依然只有当前页的。所以完整的跨页选中方案是:
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]); // 单独维护一份完整数据池,key 为行的唯一标识 const [rowPool, setRowPool] = useState<Map<React.Key, UserRow>>(new Map()); const handleChange = (keys: React.Key[], rows: UserRow[], info: any) => { setSelectedRowKeys(keys); setRowPool((prev) => { const next = new Map(prev); // 先把当前页能拿到的行补进去 rows.forEach((row) => next.set(row.id, row)); // 再删掉已经被取消选中的 Array.from(next.keys()).forEach((k) => { if (!keys.includes(k)) next.delete(k); }); return next; }); };这段逻辑看着绕,但它是跨页批量导出的基础。没有这个rowPool,导出按钮点下去只能导出当前页的选中行,业务方一定会来找你。
2.4 外观与布局:columnWidth、fixed、hideSelectAll
多选框列默认宽度是 32px(antd 5 里会随主题微调),视觉上比较窄。如果表头文字比较长或者你的设计稿要求更宽,可以调columnWidth。
fixed: true会把选择列固定在左侧。表格列数超过 8 列、横向出现滚动条时,强烈建议加上。不然用户滚到右侧想取消勾选,得先滚回最左边,体验很差。注意fixed要和 Table 的scroll={{ x: ... }}配合使用,只写fixed不写scroll.x是不生效的。
hideSelectAll: true会隐藏表头的全选框,只保留每行的复选框。什么时候用?当你的表格是"单选语义"但用了 checkbox 样式时,或者当全选在业务上没有意义(比如每行代表一个独立的、互不相关的任务)时。我见过一个误用:某项目因为后端接口不支持批量操作,前端就把表头全选隐藏了,结果用户还是能通过 Shift 连选绕过,最后批量接口报错。隐藏 UI 挡不住逻辑,该禁用的地方要在getCheckboxProps或提交前校验上做。
另外还有一个selections配置,用来开启表头旁边那个小三角下拉菜单,提供"全选""反选""取消选择"三个快捷操作:
import { Table } from 'antd'; selections: [Table.SELECTION_ALL, Table.SELECTION_INVERT, Table.SELECTION_NONE],这三个常量的文案走的是locale里的Table.selectionAll等字段,做国际化时记得一并配。反选(Invert)在数据量小的场景下很好用,比如"除了这三条,其他全部处理",选中这三条再点反选即可。
3. 从零实现一个可跨页选择的数据列表
前面拆的是零件,这一节把它们装成一台能跑的机器。我按"状态设计 → 完整代码 → 全选语义 → 批量动作"的顺序来。
3.1 状态设计:存 key 还是存整行
这是最容易做错的一个决策。我的结论是:两个都存,key 用于渲染,行数据用于提交。
为什么不能只存 key?因为批量提交时你通常需要行的完整字段。比如批量导出需要name、createdAt,批量派单需要assigneeId,只把 id 传给后端,后端可能要求你带上所有字段做反查校验。有些团队选择让后端只用 id 反查,这确实更省事,但前提是后端接口就是这么设计的。如果后端要求传全量对象,你只存 key 就得在提交前重新请求一遍这些 id 的详情,凭空多一次网络往返。
为什么不能只存整行对象?因为 antd 的selectedRowKeys只接受 key 数组,你最终还是得把对象数组 map 成 key 数组。而且对象存在 state 里,如果接口返回的数据被外部修改(比如某个字段被就地改了),你存的对象和表格里显示的对象可能不一致,排查起来很痛苦。
所以推荐的结构就是上面那段代码里的双状态:selectedRowKeys给 antd 用,rowPool(一个 Map)给自己用。用 Map 而不是数组,是因为 Map 的增删查是 O(1),几千条选中数据做过滤时性能差别很明显,用数组filter嵌套includes会退化成 O(n²)。
3.2 完整可复现的实现
下面这份代码可以直接复制到项目里改为你的字段名。我用 React 函数组件 + TypeScript 写,antd 版本是 v5,v4 的 API 基本兼容,info.type也是有的。
import React, { useCallback, useMemo, useState } from 'react'; import { Button, Modal, Space, Table, Tag, message } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import type { TableRowSelection } from 'antd/es/table/interface'; export interface RecordRow { id: number; name: string; owner: string; status: 'normal' | 'reviewing' | 'archived'; createdAt: string; } interface Props { dataSource: RecordRow[]; total: number; loading?: boolean; page: number; pageSize: number; onPageChange: (page: number, pageSize: number) => void; onBatchDelete: (ids: number[]) => Promise<void>; } const STATUS_TEXT: Record<RecordRow['status'], string> = { normal: '正常', reviewing: '审核中', archived: '已归档', }; export default function SelectableRecordTable(props: Props) { const { dataSource, total, loading, page, pageSize, onPageChange, onBatchDelete, } = props; const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]); const [pool, setPool] = useState<Map<React.Key, RecordRow>>(new Map()); const [deleting, setDeleting] = useState(false); // 所有可选的 key(跨页全选时会用到) const selectableKeys = useMemo( () => dataSource.filter((r) => r.status !== 'archived').map((r) => r.id), [dataSource], ); const handleChange = useCallback( (keys: React.Key[], rows: RecordRow[]) => { setSelectedRowKeys(keys); setPool((prev) => { const next = new Map(prev); rows.forEach((r) => next.set(r.id, r)); Array.from(next.keys()).forEach((k) => { if (!keys.includes(k)) next.delete(k); }); return next; }); }, [], ); const rowSelection: TableRowSelection<RecordRow> = useMemo( () => ({ type: 'checkbox', selectedRowKeys, preserveSelectedRowKeys: true, columnWidth: 48, fixed: true, onChange: handleChange, getCheckboxProps: (record) => ({ disabled: record.status === 'archived', name: `row-checkbox-${record.id}`, }), }), [selectedRowKeys, handleChange], ); const columns: ColumnsType<RecordRow> = [ { title: '名称', dataIndex: 'name', width: 220 }, { title: '负责人', dataIndex: 'owner', width: 140 }, { title: '状态', dataIndex: 'status', width: 120, render: (s: RecordRow['status']) => <Tag>{STATUS_TEXT[s]}</Tag>, }, { title: '创建时间', dataIndex: 'createdAt', width: 200 }, ]; const doDelete = async () => { const ids = Array.from(pool.values()).map((r) => r.id); setDeleting(true); try { await onBatchDelete(ids); message.success(`已删除 ${ids.length} 条`); setSelectedRowKeys([]); setPool(new Map()); } finally { setDeleting(false); } }; const confirmDelete = () => { const count = pool.size; if (count === 0) { message.warning('请先选择要删除的数据'); return; } Modal.confirm({ title: '确认删除', content: `即将删除 ${count} 条数据,删除后不可恢复。`, okText: '确认删除', okButtonProps: { danger: true }, onOk: doDelete, }); }; return ( <div> <Space style={{ marginBottom: 16 }}> <span>已选中 {pool.size} 项</span> <Button danger disabled={!pool.size} loading={deleting} onClick={confirmDelete}> 批量删除 </Button> <Button disabled={!selectableKeys.length} onClick={() => { // 当前页追加选中(仅可选行) const merged = Array.from(new Set([...selectedRowKeys, ...selectableKeys])); setSelectedRowKeys(merged); setPool((prev) => { const next = new Map(prev); dataSource.forEach((r) => { if (r.status !== 'archived') next.set(r.id, r); }); return next; }); }} > 选中本页可选行 </Button> <Button disabled={!selectedRowKeys.length} onClick={() => { setSelectedRowKeys([]); setPool(new Map()); }} > 清空 </Button> </Space> <Table<RecordRow> rowKey="id" size="middle" loading={loading} columns={columns} dataSource={dataSource} rowSelection={rowSelection} scroll={{ x: 900 }} pagination={{ current: page, pageSize, total, showSizeChanger: true, showTotal: (t) => `共 ${t} 条`, onChange: onPageChange, }} /> </div> ); }几个值得停下来看的地方。
rowSelection用useMemo包起来,依赖只有selectedRowKeys和handleChange。这不是过度优化,而是有实际意义:每次父组件渲染都会创建一个新的 rowSelection 对象,如果 antd 内部用引用比较来判断是否要重算选择列,频繁换新对象会带来不必要的重渲染。数据量大、表格列多的时候这点开销会被放大。
handleChange里用setPool(prev => ...)的函数式更新,是因为它可能在短时间内被高频调用(用户 Shift 连选时会连续触发),用闭包里的旧pool会丢更新。
删除成功后同时清空selectedRowKeys和pool。这一步是必须的,我在 1.2 里说过原因。
3.3 全选语义:当前页全选 vs 全部选中
这是产品层面必须提前确认清楚的一件事,否则一定返工。
当前页全选是 antd 的默认行为:用户点表头复选框,只影响当前渲染的这一页。上面代码里"选中本页可选行"按钮就是这个语义,只是因为我用了preserveSelectedRowKeys,它变成了"追加到已有选中"。
**全部选中(跨页)**是另一回事:用户想要选中符合当前筛选条件的全部数据(可能是 5000 条)。这个需求不能靠前端遍历实现,因为前端只有当前页的 20 条数据。
常见的做法有三种,各有取舍:
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 服务端全选标记 | 传selectedAll: true+ 筛选条件给后端 | 数据准确、性能好 | 后端要改造接口 |
| 前端分页轮询拉取 | 循环请求所有页,拼出全量 key | 不改后端 | 数据量大时慢,容易超时 |
| 只支持当前页 | 明确告诉用户"跨页需逐页勾选" | 实现最简单 | 体验差,用户会抱怨 |
我一般推荐第一种。如果后端实在不配合,退而求其次的做法是:当用户在本页全选后,在工具条上方显示一条提示——"已选中本页 20 条,是否选择全部 5000 条?"点"是"就走服务端全选标记,点"否"就只保留这 20 条。这个交互模式在很多云控制台里都能看到,用户接受度高。
3.4 批量动作的确认、上限与失败回滚
选中之后就是动手。这一段的细节决定了功能是"能用"还是"好用"。
数量分层。选中几行时可以直接执行并给个 toast;选中数量超过某个阈值(我习惯设 200)时,必须弹二次确认,并在弹窗里明确写出数量。用户对"批量"这个词的感知是模糊的,写清楚"即将删除 348 条数据"能显著降低误操作。
设置上限。批量接口通常都有服务端限制,比如单次最多 500 条。前端应该在提交前做一次切分,把 348 条拆成两批发送,或者直接提示用户"单次最多操作 500 条,请分批处理"。我见过一个更聪明的做法:批量导入/更新的场景下,前端按 200 条一批自动切分,串行发送,每批完成后更新进度条。用户看到"已完成 400/1000",心里的焦虑感会小很多。
失败回滚与选中状态的关系。这里有个容易忽略的细节:批量操作如果部分成功、部分失败,选中状态该怎么处理?我的做法是:成功的 key 从selectedRowKeys和pool里移除,失败的 key 保留,并在页面上用rowClassName把失败行标红,同时在工具条上提示"3 条处理失败,已保留选中状态"。这样用户可以直接对失败的那几条重试,不用重新勾一遍。
防止重复提交。批量按钮一定要有loading,而且要在doDelete开头就设loading: true。更稳妥的做法是加一个useRef的提交锁,因为setState是异步的,用户手速快的话有可能在 loading 状态生效前点第二下。这个坑我在真实项目里踩过:批量删除点了两下,第一发的请求还没回来,第二发又发出去了,后端日志里能看到两批一模一样的 id。
4. 踩坑实录与排查速查表
这一节是我这些年实际遇到的问题,按现象组织,方便你对照排查。
4.1 选中错位:key 缺失或重复
现象:用户勾选第 3 行,界面显示第 2 行被选中;或者删除一行后,选中状态整体前移了一位。
根因:rowKey没有正确设置,或者数据里的 id 有重复。
先说前者,前面提过,不写rowKey时 antd 会去读每行的key字段,读不到就用索引兜底。索引是最不可靠的身份标识,任何一次数据增删都会让它指向别的行。
再说后者,这个更隐蔽。如果接口返回的数据里存在重复 id(比如 JOIN 查询没去重、或者是多对多关系的中间表数据),用这个 id 当rowKey会导致 antd 认为是同一行,勾一个等于勾了全部。排查方法很简单,在dataSource赋值前跑一遍去重检查:
if (process.env.NODE_ENV !== 'production') { const seen = new Set(); dataSource.forEach((r) => { if (seen.has(r.id)) { console.error('[SelectableTable] 发现重复 rowKey:', r.id, r); } seen.add(r.id); }); }我一般会把这段检查常驻在开发环境里,上线前再决定是否保留。成本几乎为零,但能救你一次线上事故。
4.2 受控了但点不动:忘了写 onChange
现象:复选框能点,点击有动画,但松开后立刻弹回未选中状态。
根因:传了selectedRowKeys却没有在onChange里更新它。受控组件的本质是"状态由外部决定",antd 收到点击后调用onChange,你如果不把这个变化写回到selectedRowKeys,React 重渲染时用的还是旧值,视觉上就是"点了没反应"。
这个错误的迷惑之处在于,它和"某个 CSS 覆盖了 checkbox 的 pointer-events"长得一模一样。排查顺序建议是:先在onChange里打一行console.log(keys),如果日志不打,说明是样式或事件被拦截;如果日志打了但界面不变,就是没 setState。
4.3 幽灵 ID:删除后没清理选中集合
现象:删完数据后,工具条还显示"已选中 3 项",点批量删除弹出确认框说"即将删除 3 条数据"。
根因:selectedRowKeys是独立的一份状态,dataSource更新不会自动同步它。除非你开了preserveSelectedRowKeys(开了反而更不容易发现,因为它会明确保留这些 key)。
固定解法:所有会改变dataSource的操作后面,都跟一句清理。写法上我推荐统一用一个方法收口,避免遗漏:
const removeFromSelection = (ids: React.Key[]) => { setSelectedRowKeys((prev) => prev.filter((k) => !ids.includes(k))); setPool((prev) => { const next = new Map(prev); ids.forEach((k) => next.delete(k)); return next; }); };删除、归档、状态流转完成之后,都调这个方法。比每次手写setSelectedRowKeys([])更精确——后者会把用户在其他页辛苦勾选的数据也清掉。
4.4 分页切换后选中丢失
现象:第 1 页勾了 3 条,翻到第 2 页再翻回来,勾选没了。
根因:没开preserveSelectedRowKeys。antd 在dataSource变化时会校验选中集合里的 key 是否还能在数据里找到,找不到就剔除。
注意一个反直觉的点:开了preserveSelectedRowKeys之后,即使你翻到第 5 页,"已选中 3 项"依然显示。这符合用户预期,但如果这 3 条是从筛选前的数据集里选的,用户可能会困惑"这些是哪来的"。解决办法是在工具条上加一个"查看已选"的入口,弹个抽屉列出pool里的数据,支持逐条移除。数据量大的后台,这个小功能能省掉很多客服工单。
4.5 常见问题速查表
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| 勾选一行,另一行也亮了 | rowKey 重复 | 排查数据唯一性,或改用复合 key |
| 点击复选框无反应 | 受控但未在 onChange 里 setState | 补上setSelectedRowKeys(keys) |
| 删除后选中数不变 | 未同步清理选中集合 | 删除成功后调用统一清理方法 |
| 翻页后选中清空 | 未开 preserveSelectedRowKeys | 开启并自行维护行数据池 |
| 表头全选选中了禁用行 | 自定义全选逻辑未过滤 | 拼 key 时排除 disabled 行 |
| 导出只有当前页数据 | 直接用了 onChange 第二参数 | 改用自维护的 Map 数据池 |
| 批量按钮点两次发两次请求 | 缺少提交锁 | 加 loading 或 useRef 锁 |
| 深色主题下选中行看不出选中 | 背景色被主题覆盖 | 覆写.ant-table-row-selected > td背景 |
表格最后一行值得多说一句。antd 默认的选中行底色是#e6f7ff这类浅蓝,如果你的项目套了一层深色主题或者自定义了Table的components,这个底色很可能被覆盖成和普通行一样,用户完全看不出来哪些行被选中了。这种情况下的样式修复要注意选择器优先级,直接写.ant-table-row-selected > td通常比.ant-table-row-selected更稳,因为背景实际是画在 td 上的。顺手一句:如果设计稿要求复选框在单元格里水平居中,给选择列加align: 'center'或者覆写.ant-table-selection-column { text-align: center }都行,但columnWidth要给足,否则居中之后左右留白会显得很挤。
5. 大数据量与进阶场景
前面讲的是常规场景,这一节说说数据量上去之后、或者业务变复杂之后要注意什么。
5.1 上千行表格的选中性能
selectedRowKeys变化会触发整个 Table 重渲染。如果表格同时渲染 1000 行、每行 15 列,一次勾选可能就是上万次组件渲染,在低端机上手感会明显发涩。
几个实测有效的优化点:
第一,columns用useMemo固定住。很多人写的是内联数组,每次渲染都创建新数组,antd 会认为列配置变了,触发列级别的重算。
第二,行的渲染函数尽量轻。单元格里的render不要做深拷贝、不要在里面 new 对象、不要在里面做重计算。这些工作在 1000 行 × 15 列的规模下会被放大 15000 倍。
第三,如果数据量真的很大(超过 2000 行),考虑开启 antd 5.9 之后提供的虚拟滚动。开了之后只有视口内的行会被渲染,选中状态变化的影响范围大幅缩小。代价是虚拟表格和固定列、展开行、行合并这些特性有兼容限制,用之前先跑一遍你的真实场景。
第四,如果只是需要记录选中而完全不关心中途的渲染,可以把selectedRowKeys存到useRef里,只在提交那一刻读。但这种做法会让顶部"已选中 N 项"的显示失效,所以要配合一个节流的状态更新,实现复杂度不低,不建议一开始就上。
5.2 树形表格的 checkStrictly
树形 Table 在多选框上有个额外开关:checkStrictly。
默认checkStrictly: false,也就是父子联动:勾选父节点,下面所有子节点跟着选中;子节点只勾一部分,父节点显示半选状态。
设成true之后,父子完全独立,勾父不勾子。这个模式适合"文件夹和文件"这类语义——用户给文件夹授权不等于给里面所有文件授权。
需要注意的是,联动模式下的selectedRowKeys包含了所有被联动选中的子节点 key,数量可能远超用户的心理预期。有个项目里用户勾了 3 个父节点,结果选中数显示 487,直接把人吓到了。处理办法是在工具条上区分显示:"已选 3 个分组(含 487 个子项)"。
5.3 与固定列、横向滚动配合
选择列固定在第 4 节说过,这里补一个细节:当选择列fixed: true且表格有横向滚动时,滚动到右侧固定列区域,选择列的表头单元格会和相邻固定列产生一条投影分割线(antd 用box-shadow实现)。如果你们的主题覆盖了box-shadow,这条线会消失,用户搞不清楚哪几列是固定的。排查时打开 DevTools 看.ant-table-cell-fix-left-last::after这个伪元素是否还在。
另外,选择列固定时columnWidth建议不要小于 40px。太小的话,在部分浏览器上复选框会被裁掉一两个像素,看着很别扭。
5.4 选中数据的导出与二次消费
批量导出是最常见的选中消费场景,有几个经验点。
导出的顺序最好和表格里显示的顺序一致,而不是按勾选顺序。用户勾选的顺序是随机的,导出的 Excel 如果顺序乱七八糟,对账时会很痛苦。实现上就是遍历dataSource(或全量数据)时过滤出在pool里的行,而不是遍历pool。
导出前做一次数量校验。如果pool.size是 0,禁用按钮;如果是 1,可以直接导出;如果超过 10000,建议给出提示并改为后台异步生成。同步导出几万行会把浏览器卡住好几秒,用户体验很差。
导出的字段要显式声明,不要JSON.stringify整行。接口返回里经常带着一堆前端不用的内部字段(比如各种关联 id、审计字段),全导出去不仅文件臃肿,还可能泄露不该给用户看的信息。显式列一个字段映射表,改起来也方便。
说说我自己在这套东西上的一些体会。最开始我也觉得 rowSelection 就是个配置项,抄文档示例就够了,直到有次线上出问题:一个运营同学在批量审核页面勾了 60 条数据,中途去接了个电话,回来点了审核,结果因为翻过页,前端拿到的是当前页数据和上一批残留的 key 混在一起,审核了错误的对象。那次之后我就把"选中状态和操作数据的强一致性"当成了硬要求,所有pool和selectedRowKeys的更新都必须走同一个方法,不允许在业务代码里直接 setState。
还有一个小技巧分享给做后台的同学:在开发环境里给选中的 key 数量加个 console 输出,但不要每次渲染都打,只在pool.size变化时打。这样联调阶段你一眼就能看出是"状态没同步"还是"接口传错了"。等上线前把这段代码用环境变量关掉,成本几乎为零,但排查效率能提高不少。这套 Hook 我后来抽成了项目里的公共模块,十几张表复用下来,关于多选框的 bug 基本清零了。