1. 先搞清楚 rowSelection 到底替我们做了什么
antd 的 Table 组件里,rowSelection这个属性大概是使用频率排前几的一个配置项。只要你的业务里出现了"列表批量操作"——批量删除、批量导出、批量改状态、批量分配权限——那基本都绕不开它。我先说清楚它能干什么:给表格每一行前面加一个多选框(checkbox),维护一个"当前选中了哪些行"的集合,并把集合变化通过回调抛给使用者。就这么个事,看着简单,但真到项目里,回显、跨页、全选、禁用、性能,一个接一个地冒出来。
这篇东西适合谁看?如果你刚开始用 antd 的 Table,想快速把多选列表搭起来,前面几节能直接抄;如果你已经被"取消勾选后页面没更新""切换分页选中丢了""上千行数据一勾就卡"折磨过,那第四章和第五章应该更对你胃口。我不打算照着官方文档复述参数表,那种东西你打开文档就有,我想讲的是这些参数背后为什么要这么设计、什么时候该用哪个、以及我在真实项目里踩过的坑。
多选框(checkbox)本身是再基础不过的表单元素,但一旦和"表格 + 分页 + 服务端数据"组合起来,它的状态管理复杂度会指数级上升。核心矛盾就一个:谁持有选中的真值。是你自己的 state,还是 antd 内部?想明白这一点,后面所有问题都能顺着解。
2. rowSelection 的核心机制与参数取舍
2.1 selectedRowKeys 与 onChange 的配合逻辑
rowSelection最关键的三个东西是:selectedRowKeys、onChange、rowKey。它们三者的关系决定了整个多选行为是不是可控。
先说rowKey。这是 Table 每行的唯一标识,默认取record.key,但真实业务数据里往往没有key字段,于是很多人顺手写成rowKey="id"。这本身没问题,问题在于如果你不写 rowKey,也不给数据加 key,antd 会打印警告,并且选中状态会彻底错乱。因为组件内部是靠这个 key 去比对"这一行是不是被选中"的,key 重复或者不稳定(比如用了数组下标当 key),勾选就会出现"勾了 A 行,B 行也亮了"的诡异现象。这是我见过最高频的低级错误,没有之一。
再说受控模式。selectedRowKeys一旦传了,就是受控;不传,就是非受控,antd 自己维护内部状态。区别在哪?非受控模式下你拿不到实时选中的行数据,只能在onChange里通过第二个参数selectedRows拿到当前页选中的行。onChange的签名是(selectedRowKeys, selectedRows, info) => void,第三个参数info里有个type,值可能是'all'或'single',用来区分这次变化是点了全选还是点了单行。很多人不知道有这个info,结果在"全选"场景下写出了很绕的判断逻辑。
我个人的习惯是:只要涉及批量操作,一律走受控。理由很实在——非受控下你没法在删除成功后清空选中状态,也没法在数据刷新后回显历史选中,一旦业务加了这些需求,非受控模式就得推倒重写。
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]); <Table rowKey="id" rowSelection={{ selectedRowKeys, onChange: (keys, rows, info) => { setSelectedRowKeys(keys); }, }} dataSource={data} columns={columns} />上面这十几行就是最小可用版本,但请注意一个隐蔽点:setSelectedRowKeys(keys)里 keys 的类型是React.Key[],如果你后面用keys.includes(row.id)去判断,而row.id是 number,就会出现类型不一致导致判断永远为 false。解决办法是统一 todo 成 string,或者统一成 number,别混用。
2.2 getCheckboxProps:禁用某一行勾选的正确姿势
getCheckboxProps返回的对象会直接透传给 checkbox,所以你可以在这里设disabled、name等属性。最常见的需求是"某些行不允许被选中",比如已归档的记录、没有权限操作的记录。
rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys, getCheckboxProps: (record) => ({ disabled: record.status === 'archived', }), }}这里有个我踩过的坑:禁用行不会从 selectedRowKeys 里自动移除。假设用户先全选,然后某几行因为状态变化被禁用了,这时候 selectedRowKeys 里可能还残留着这些行的 key。你在提交批量操作时如果不做过滤,服务端就会收到不该操作的 ID。稳妥做法是在提交前用当前 dataSource 过滤一遍:
const validKeys = selectedRowKeys.filter((key) => data.some((item) => item.id === key && item.status !== 'archived') );另外,getCheckboxProps在数据量大的时候会被频繁调用,函数体里别放重计算,否则滚动和勾选都会明显变卡。我见过有人在这里面做字符串拼接 + 正则匹配,几百行数据一勾选整个页面卡住两秒,排查了半天才发现是这个函数在背锅。
2.3 preserveSelectedRowKeys:跨页保持选中到底开不开
antd 4.x 之后提供了preserveSelectedRowKeys,打开它之后,切换分页时勾选的 key 会保留在 selectedRowKeys 里,不会因为数据源变化而丢失。听起来很美好,但你要清楚它做了什么:它只是让你的 keys 数组不被组件内部清理,并不会帮你保留行数据。
这意味着什么呢?如果你在 onChange 里拿selectedRows,跨页后这个数组里对应的行数据可能已经是空的了。所以真正要跨页选中的业务,正确姿势是:自己维护一份"已选行的完整数据"的 Map,key 做索引,onChange 时增量更新,而不是依赖selectedRows。
跨页全选还有个语义问题值得掰扯:当前页全选,和全部数据全选,是两码事。antd 的全选只针对当前 dataSource(也就是当前页)。如果你要做"勾选后对全部符合条件的记录生效",那就不能只靠 rowSelection 了,得配合服务端的"全选标记",比如给后端传一个selectAll: true加上筛选条件,让后端去处理。前端硬扛全量勾选,数据量上万时直接把浏览器内存干爆。
3. 从零搭一个可落地的可选择列表
3.1 数据结构与 rowKey 设计的先后顺序
动手之前,先把三件事定下来,顺序不能反。
第一,确定唯一键。服务端返回的数据里一定有一个稳定不变的主键,哪怕字段名叫uuid或者code,都比用数组索引靠谱。永远不要用 index 当 rowKey,这是铁律。
第二,确定选中集合的类型。我建议统一用string[]。原因很朴素:URL 参数、localStorage、接口传参,最终都是字符串形态,提前统一能省掉后面到处String()的麻烦。
第三,确定"选中态"存在哪。如果只是当前组件用,useState就够;如果要跨组件、跨路由共享(比如列表页勾选后跳到另一个页面提交),那就得上状态管理或者 Context,别硬扛。
把这三点定完,代码其实就成功一半了。我见过太多项目是先写页面、后发现要跨页、再回头改 key 和 state 结构,返工成本极高。设计先行这句话在多选列表上体现得特别明显。
3.2 基础版实现:勾选 + 批量操作条
下面是一个可以直接拿去改的完整骨架,包含批量删除和选中数量展示。
import { useState } from 'react'; import { Table, Button, Space, Popconfirm, message } from 'antd'; export default function UserList() { const [data, setData] = useState(initialData); const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]); const columns = [ { title: '用户名', dataIndex: 'name' }, { title: '角色', dataIndex: 'role' }, { title: '状态', dataIndex: 'status' }, ]; const handleBatchDelete = async () => { await api.batchDelete(selectedRowKeys); setData((prev) => prev.filter((item) => !selectedRowKeys.includes(item.id))); setSelectedRowKeys([]); message.success('删除成功'); }; return ( <Space direction="vertical" style={{ width: '100%' }}> <Space> <span>已选 {selectedRowKeys.length} 项</span> <Popconfirm title="确认删除选中的记录?" onConfirm={handleBatchDelete}> <Button danger disabled={!selectedRowKeys.length}> 批量删除 </Button> </Popconfirm> </Space> <Table rowKey="id" rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }} columns={columns} dataSource={data} /> </Space> ); }注意几个细节:删除成功后要setSelectedRowKeys([])清空选中,否则页面会残留选中状态;按钮在没选中时要disabled,避免用户点了个寂寞;用 Popconfirm 二次确认是批量操作的标配,别省。
这里补一句操作心得:批量删除接口返回后,不要简单地把 data 里对应项删掉就完事,最好重新拉一次列表。原因在于分页数据是会错位的——你删掉当前页三条,后端第二页的第一条会补上来,前端如果只做本地删除,页码和数据就对不上了。当然如果数据量很小全量返回,本地删除也没问题,看场景取舍。
3.3 全选 / 半选状态的视觉与逻辑处理
antd 的全选框自带半选(indeterminate)状态:当选中数量大于 0 且小于当前页总数时,表头复选框会显示一条横线而不是对勾。这个行为是组件内建的,你不用自己算,但有个边界要注意——如果你手动设置了hideSelectAll: true隐藏全选框,那半选逻辑也就没了。
跨页场景下,半选状态的计算只针对当前页。也就是说,你在第一页全选,翻到第二页,表头是全不选的状态,这是符合直觉的。但如果你开了preserveSelectedRowKeys,用户可能会困惑:"我明明全选了,怎么第二页没勾上?"这时候通常的做法是在操作条上提示"已选 N 项",让用户清楚总数,而不是纠结表头的视觉状态。
还有一种进阶做法:把表头全选框替换成自定义的下拉菜单(用columnTitle或者onHeaderCell定制),提供"选中本页""选中全部"两个选项。这个实现起来稍微绕,需要自己接管全选逻辑,好处是语义清晰,适合后台管理系统这种对批量操作要求高的场景。
4. 常见问题与排查实录
4.1 勾选没反应、回显丢失的排查顺序
遇到"点了复选框但选中状态不更新",按下面顺序查,基本能命中。
| 排查项 | 典型症状 | 解决方式 |
|---|---|---|
| rowKey 未设置或重复 | 勾选串行、随机亮 | 指定稳定唯一主键 |
| selectedRowKeys 类型不一致 | 传了 keys 但无勾选 | 统一 string 或 number |
| onChange 未回写 state | 点击后无任何反应 | 确认 onChange 里调用了 setState |
| 数据源每页重复 key | 翻页后选中错乱 | 检查后端返回是否有重复 ID |
| 受控与非受控混用 | 初始有值后面失控 | 全程受控,不在中途切换 |
我印象最深的一次排查:列表用的是id作为 rowKey,但后端在某个接口里返回的id是 number,另一个接口返回的是 string,导致切换数据源后回显失败。这种问题用日志打一下typeof record.id就能定位,但如果不往类型上想,很容易在组件逻辑里绕圈。
4.2 与分页、筛选、排序联动时的键值陷阱
当表格开启分页,勾选和分页的关系必须想清楚。默认情况下,切换分页时 antd 会根据新的 dataSource 计算选中态,selectedRowKeys 里那些不在当前页的 key 不会显示勾选(这是对的),但它们还在数组里。如果你没开preserveSelectedRowKeys,在某些版本里切换数据源时这些 key 可能会被清掉,导致你切回来发现选中没了。
筛选和排序同理。筛选后当前页数据变了,但要保证选中集合语义明确:是"选中了这些具体的记录"还是"选中了符合某条件的所有记录"?前者用 keys 数组就够了,后者必须走后端。我强烈建议在 UI 上把这两者区分开,否则用户根本不知道自己删的是什么。
还有一个容易被忽略的点:服务端分页时,如果你用 keys 去接口查完整数据做导出,记得分批传参或者用 POST body,别把几百个 ID 拼在 URL 上,会超长被截断,表现为"部分记录导出丢失",坑得很隐蔽。
4.3 数据量大时勾选卡顿的性能处理
上千行数据勾选卡顿,原因通常有三个。
第一,rowKey不稳定导致 antd 每次都做全量 diff。第二,onChange里做了重计算,比如每次都遍历整份数据。第三,getCheckboxProps里逻辑太重,或者返回了一个每次新建的对象导致子组件无谓重渲染。
优化方向上,首先确保getCheckboxProps返回逻辑足够轻,理想情况下只读 record 上的一个布尔字段。其次,如果开启了虚拟滚动(antd 4.x 的virtual属性),要注意它和 rowSelection 的兼容性,部分版本下虚拟滚动 + 多选会有滚动时的勾选错位,建议升级到较新版本再使用。
一个实用技巧:把"选中数量"这种高频变化的展示抽到独立小组件里,用React.memo包一层,避免每次勾选都触发整个表格重新渲染。实测在两千行左右的场景下,这个小改动能把单次勾选的响应从肉眼可见的迟滞降到基本无感。
另外,如果业务允许,优先考虑"选择模式"而不是"常驻勾选框"。也就是说,平时表格没有多选框,用户点击"批量操作"按钮后才进入选择模式,选完退出。这样既减少视觉噪音,也能在选择模式下再做针对性优化,体验比常驻勾选好很多。这个交互模式在主流后台系统里越来越常见,值得借鉴。
4.4 一个高频报错的快速定位思路
最后啰嗦一句排查方法。多选相关的 bug,十有八九出在"key"上。当你遇到任何诡异现象——勾选错位、回显失败、切换分页丢选中、删除后状态残留——第一件事就是打开控制台打印selectedRowKeys和当前 dataSource 的 id 列表,对照着看类型和值是否匹配。
我通常会写这么一个临时调试:
console.log('keys:', selectedRowKeys, selectedRowKeys.map((k) => typeof k)); console.log('data ids:', data.map((d) => `${d.id}(${typeof d.id})`));一眼就能看出是类型问题还是值对不上。很多同学遇到问题就去翻组件文档、搜 issue,其实把这两行打印出来,五分钟定位,比翻半小时资料管用。
如果确认 key 没问题,再看受控状态是不是被别的逻辑覆盖了——比如父组件传下来的 props 有变化、或者你在 useEffect 里做了重置。多选问题的排查思路就一条:盯住真值的来源,确认它没有被意外改写。
5. 我踩过的坑和几条实战建议
说几个文档里不会写、但实际项目里特别容易翻车的点。
第一,批量操作成功后清空选中,这个顺序别搞反。要先把数据更新完,再清空 selectedRowKeys。如果先清空,某些框架下会触发一次空数组的渲染,虽然结果一样,但可能出现闪烁。
第二,selectedRows不要跨渲染周期使用。它是 onChange 那一刻的快照,你把它存到 state 里当"已选数据源",等用户切换分页后再用,很可能拿到过期的对象引用。要用就用 keys 重新查。
第三,二次确认弹窗里显示的数量,要和实际提交的数量一致。我见过一个 bug:弹窗文案写"删除 5 条",实际提交了 8 条,原因是半选状态下的父节点也被算进去了。这种问题一旦发生在生产环境,用户投诉会很直接。
第四,给多选加个"取消选择"的显式入口。用户勾了一堆之后想清空,如果只能一个个点掉,体验很差。一个"清空选择"的小按钮,成本极低,好评度很高。
第五,如果你的列表要和后端的分页、筛选、排序完全对齐,建议把选中状态和查询条件一起管理,甚至可以考虑把选中 key 同步到 URL 上(用 query 参数),这样用户刷新页面还能恢复选中,虽然 URL 会变长,但在审批、对账这类场景下很实用。
自定义内容后续还能这么扩展:把 rowSelection 和"操作日志"结合,记录每次批量操作的选中项;或者做"选择模板",把常用的筛选 + 选中组合保存下来,一键复用。这些都是在基础实现稳定之后,往上叠加的价值点,先把地基打牢再说。