
上周帮一个做后台管理的团队排查一个批量发货的线上问题运营勾了三单点发货结果系统把另外三单发了出去两边的客户都被打扰了一遍。翻日志、查接口最后定位到的原因跟后端没关系是这个页面 antd table 的 rowSelection 配置里少写了一个 rowKey翻页之后多选框的选中状态和数据行整体错位了一格。这种问题在多选表格里极其常见也是我从第一次用 antd 的 Table 组件到现在见过最多的一类事故。这篇内容就围绕 antd table 的 rowSelection 多选框也就是可选择列表来讲。它看起来只是给 Table 加一个rowSelection{...}属性的事但真正落到业务里会牵出选中状态由谁持有、rowKey 怎么选、跨页要不要保留、禁用行怎么处理、树形数据怎么联动、几万行的时候卡不卡、选中结果怎么变成业务动作这一整条链路。不管你是刚接触 antd 的前端还是已经写了几年后台的老手这里面的坑基本都会踩到至少两三个。我按自己实际项目里的顺序把这些东西一个个拆开说。1. 多选框的第一步配置项背后的数据流到底是什么1.1 从勾选一批数据做批量操作倒推需要哪些配置后台系统里凡是有批量删除批量导出批量审核的地方几乎都会用到这里说的可选择列表。它的本质是Table 渲染每一行时在最左侧插入一列 Checkbox同时组件内部维护一份哪些行被勾了的集合并且把这份集合的变化通过回调暴露给使用方。最小可跑的配置大概长这样import { useState } from react; import { Table } from antd; import type { TableRowSelection } from antd/es/table/interface; const [selectedRowKeys, setSelectedRowKeys] useStateReact.Key[]([]); const rowSelection: TableRowSelectionOrderItem { selectedRowKeys, onChange: (keys) setSelectedRowKeys(keys), }; TableOrderItem rowKeyid columns{columns} dataSource{list} rowSelection{rowSelection} /就这十来行一个可选择列表就出来了表头有全选框每行有单选框勾选后selectedRowKeys会更新。但请注意这里我特意写了两个东西——selectedRowKeys和rowKey。很多人的第一个多选表格是这么写的Table columns{columns} dataSource{list} rowSelection{{}} /不传selectedRowKeys也不传rowKey。在当时那个页面看起来一切正常勾选能勾取消能取消控制台也不报错。问题会在两个场景里集中爆发一是分页切换二是列表数据刷新。原因后面第 2 节详细讲这里先记住一个结论——只要这张表的数据会变翻页、筛选、轮询刷新、行内编辑后重查你就应该把选中状态接管过来。为什么建议接管因为非受控模式下选中状态藏在 Table 组件内部你在写批量提交按钮的时候拿到不了一份可靠的选中数据。你得靠onChange的回调参数往下传或者再开一个 state 存一份等于绕了一圈还是受控不如一开始就写受控。1.2 受控与非受控谁来持有这份选中状态antd 的 Table 在这块的设计和其他表单控件是一致的你传了selectedRowKeys它就以你的值为准这叫受控你不传它自己内部存一份这叫非受控。模式判断依据选中状态存在哪适用场景风险非受控不传selectedRowKeysTable 组件内部纯展示、选中即触发跳转翻页/刷新后状态不可预期外部拿不到数据受控传selectedRowKeys业务侧的 state 或 store批量操作、跨页选择、需要回显需要自己处理数据刷新时的同步受控带来的第一个现实问题是列表重新请求之后选中的行可能已经不在数据里了。比如用户在第 1 页勾了三行切换筛选条件列表变成完全另一批数据那之前那三个 key 还有效吗从业务上讲大部分场景应该清空少数场景比如加入待处理清单要保留。这个决策只能由业务侧来做所以状态放在业务侧本身就是合理的。我一般的处理是筛选条件、页码、每页条数这几个查询参数发生变化时清空selectedRowKeys单纯的数据重新拉取比如轮询保留。写法上用一个useEffect监听筛选条件触发setSelectedRowKeys([])就行别偷懒省这几行。1.3 onChange、onSelect、onSelectAll 三个回调谁管什么rowSelection 提供了三个回调很多人只知道onChange其实另外两个在特定场景下更好用。const rowSelection: TableRowSelectionOrderItem { selectedRowKeys, onChange: (keys, rows, info) { // keys: 当前所有选中的 key // rows: 当前所有选中的行数据受 preserveSelectedRowKeys 影响 // info.type: all | none | invert标识这次是哪种批量操作 setSelectedRowKeys(keys); console.log(本次操作类型, info.type); }, onSelect: (record, selected, rows, nativeEvent) { // 单个复选框被点击selected 表示是选中还是取消 }, onSelectAll: (selected, rows, changeRows) { // 表头全选框被点击changeRows 是本页发生变化的那部分行 }, };三者的分工是这样的onSelect拿得到具体是哪一行被点了适合做单行的联动比如勾选某一行时自动把它的关联行也勾上onSelectAll的第三个参数changeRows只会给出本次真正发生状态变化的行而不是当前页所有行这在做全选日志或者全选时的性能优化时有用onChange是最通用的任何选中变化都会触发参数里直接给你完整的 key 数组日常用这个就够了。有个细节值得注意onChange第三个参数里的type只有在点击表头全选框时才会是all或none单行点击时它是single之类的值在不同版本里表现略有差异所以别用它来判断用户是不是点了全选需要这个判断时用onSelectAll更可靠。提示如果这三个回调里都写了setSelectedRowKeys注意别重复更新antd 不会帮你自动去重实际结果虽然一样但会多一次渲染在几万行的表里这个开销能看得出来。2. rowKey 选错多选框的选中状态就开始漂移2.1 为什么用下标当 key 在翻页后必然出事回到开头那个发货发错的案例。这个页面的 Table 长这样Table columns{columns} dataSource{list} rowSelection{rowSelection} /没有rowKey。antd 在这种情况下会去找每条数据里的key字段找不到就退化成用数组下标来生成内部标识。第一页有 10 条数据下标 0 到 9切成第二页下标又从 0 开始。而selectedRowKeys里存的恰好是这些下标。于是运营在第一页勾了下标 0、1、2 三行点下一页Table 一看当前有 0、1、2 这三号被选中就把第二页的前三行也打上了勾。运营看到的是我第一页勾的还在第二页好像也自动勾了三个如果此时不去细看直接点批量发货发出去的就是第二页那三单。这就是整个事故的完整链路——不是组件有 bug是 key 没有唯一性选中状态在不同数据集之间被错误地匹配上了。修法很简单Table rowKeyid ... /同时保证dataSource里每条记录都有唯一的id。这个id建议直接来自后端主键别用前端拼的Math.random()或者时间戳否则每次刷新列表 key 都在变选中状态又对不上了。2.2 复合 key 的拼接与类型统一有些场景后端给的不是单一主键比如用户 ID 角色 ID才能唯一确定一行。这时候rowKey可以传函数Table rowKey{(record) ${record.userId}-${record.roleId}} ... /这种拼接 key 有两个注意点。第一分隔符要挑一个不会出现在字段值里的字符如果用户 ID 里可能带-那就换成#或者|。第二拼接结果要保证和selectedRowKeys里的比较逻辑一致——React.Key实际是string | number如果某处拼出来的是字符串1001另一处拿的是数字1001includes判断就会失败表现为明明在选中数组里复选框却不打勾。这种问题极其难查因为它不报错只是偶尔不显示。我踩过一次更隐蔽的列表接口返回的id是 number但我从另一个接口补齐数据时用的是字符串两批数据混在dataSource里导致同一行一会儿能勾一会儿不能勾。后来统一在请求层做了转换所有主键一律String(record.id)问题消失。所以我的建议是在项目里定一个死规矩参与多选的 key 一律转成字符串rowKey用函数转selectedRowKeys初始化和赋值时也转能让后面省掉大量排查时间。2.3 数据刷新后选中项对不上数据怎么办即使 rowKey 写对了还有一种情况用户选中了某几行然后触发了刷新后端返回的数据里这几行已经不满足当前筛选条件了比如状态从待处理变成了已处理数据从列表消失了但selectedRowKeys里还留着它们的 key。这时的表现是界面上没有任何一行打勾但已选 3 项的文字还在点批量操作还会把这 3 个已经不存在的 key 传给后端。后端要么报错要么成功地处理了三条它以为存在的记录。处理办法有两层。第一层选中项以 key 为准没问题但界面上显示选中数量时最好只算当前数据里真实存在的行避免误导。第二层提交前做一次校验把不在当前数据里的 key 挑出来提示用户或者直接过滤掉。我一般在提交函数开头写这么一段const submit async () { const validKeys selectedRowKeys.filter((key) list.some((item) String(item.id) String(key)) ); if (validKeys.length ! selectedRowKeys.length) { Modal.confirm({ title: 部分选中项已不在当前列表中, content: 将忽略 ${selectedRowKeys.length - validKeys.length} 条失效数据是否继续, onOk: () doSubmit(validKeys), }); return; } doSubmit(validKeys); };这几行代码在某次真实事故里救过我一次用户开着页面去吃了顿饭回来接着点提交中间数据已经被别人改过了如果没有这道校验就会对一堆过期数据执行操作。3. getCheckboxProps让不该勾的行根本勾不上3.1 禁用行的判断条件该写在哪一层业务里总有那么几行是不允许被选中的已完成的订单不能再驳回没有权限的记录不能批量删除已经锁定的配置项不能勾选。这时候用getCheckboxPropsconst rowSelection: TableRowSelectionOrderItem { selectedRowKeys, onChange: setSelectedRowKeys, getCheckboxProps: (record) ({ disabled: record.status ! pending, name: String(record.id), }), };这里返回的对象会被直接展开到每一个 Checkbox 上所以disabled、name、title这些原生属性都能用非标准的自定义属性也可以带antd 会透传。关键是判断条件写在哪一层。我见过有同事把判断写在onSelect里用户点了不满足条件的行弹一个提示然后 return。这种做法的体验其实不差——用户至少知道发生了什么。但它的成本更高用户点了、状态闪了一下、再被拦回来视觉上是假勾选而且如果某处代码直接调用了onChange之外的逻辑拦截就失效了。所以我的做法是双保险getCheckboxProps负责让不该选的行根本点不动这是主防线onSelect里的提示只负责解释原因这是辅助。禁用状态是组件层面的硬约束比在回调里做判断可靠得多。3.2 禁用行和全选框之间的联动表现这里有个很多人第一次遇到会以为是 bug 的行为如果当前页有 10 行其中 3 行被禁用用户点表头全选框结果是 7 行被选中而不是 10 行——antd 会自动跳过禁用行。这个行为是对的不用改。但另一个行为就需要注意了全选框的全选状态判断是看当前页所有可选行是否都被选中。如果禁用行不参与计算那没问题如果你的判断逻辑写得不对把禁用行也算进去了就会出现所有能勾的都勾了但表头还是半选状态的怪异现象。实操中还有个更棘手的场景禁用条件会随其他行的选中状态变化。比如勾选了主订单之后它的子订单就不能再单独勾。这种情况下getCheckboxProps里要读取selectedRowKeysgetCheckboxProps: (record) { const isChildPicked record.parentId selectedRowKeys.includes(String(record.parentId)); return { disabled: record.status ! pending || Boolean(isChildPicked), }; },注意这个函数在每次渲染时都会对每一行调用一次。如果表里有几千行而函数内部又做了selectedRowKeys.includes(...)这种 O(n) 查找整体就是 O(n²)在某些机器上会明显卡顿。优化方式是把selectedRowKeys先转成Set查找降到 O(1)const selectedSet useMemo(() new Set(selectedRowKeys.map(String)), [selectedRowKeys]);这个改动在小数据量下看不出差别数据量上来之后很关键第 6 节还会再提到。3.3 给用户一个为什么勾不了的解释禁用了不解释是所有后台系统里最招骂的设计之一。用户看到一个灰色的框第一反应是这系统坏了。所以在启用getCheckboxProps的同时必须把原因透出到界面上。我常用的三种做法第一给禁用的 Checkbox 加title属性鼠标悬停出现原生 tooltip成本最低getCheckboxProps: (record) ({ disabled: record.status ! pending, title: record.status ! pending ? 当前状态不可批量操作 : undefined, }),第二在表格列里加一列操作提示把不能选的原因作为文案直接展示。适合状态种类多、原因复杂的场景。第三如果禁用逻辑比较反直觉比如要先选主订单在全选框旁边加一行说明文字或者在用户点击禁用行时用 Message 提示一下。注意禁用状态下点击事件本身不触发所以想弹提示得在行上绑onRow的 onClick而不是依赖 Checkbox。做法实现成本用户体验适用场景Checkbox 加 title低中需要悬停原因单一、用户群体熟练单独一列展示原因中好直观状态多、有新人使用行点击弹提示中好但打断操作禁用逻辑反直觉4. 跨页保留选中preserveSelectedRowKeys 的取舍4.1 默认行为为什么会让用户觉得系统把我的选择弄丢了先说默认行为用户在首页勾了 5 行翻到第 2 页再翻回第 1 页勾选消失了。因为翻页后dataSource变了内部维护的选中行数据也被清掉了。这个行为在很多场景下是合理的比如选择一批数据加入某个分组用户期望每次操作只针对当前页看到的东西。但在另一些场景下就完全不能接受比如从一万条订单里挑出 30 条做特殊处理用户需要一页页翻着挑翻页丢选中等于这个功能没法用。所以第一件事是判断清楚你的场景属于哪一种。判断方法很简单如果操作对象可能跨越多个页面就必须保留如果操作对象永远只在单页内就别保留免得用户误以为选中了很多。4.2 打开 preserveSelectedRowKeys 之后数据从哪来antd 在 4.4 版本之后给了preserveSelectedRowKeys一行配置解决跨页保留const rowSelection: TableRowSelectionOrderItem { selectedRowKeys, preserveSelectedRowKeys: true, onChange: setSelectedRowKeys, };开了之后翻页不会清空选中状态用户体验直接对了。但它带来三个需要处理的问题。第一个问题是看不见的选中项。用户翻到第 2 页勾选数量显示 8 项但当前页只勾了 3 行剩下 5 行在第 1 页。用户如果没有清晰的提示很容易以为自己只选了 3 项。解决办法是在批量操作栏旁边明确写上已选 8 项含其他页 5 项或者提供一个查看已选项的弹窗把跨页选中的行列出来允许逐条移除。这个弹窗看着不起眼但在跨页选择的产品里几乎是必需的——用户需要能检查和修正自己的选择。第二个问题是onChange的第二个参数selectedRows。开启保留后翻页过去的那些行的数据是 antd 从内部缓存里取的理论上能拿到但它依赖你之前确实渲染过这些行。如果用户是通过 URL 直接跳到第 5 页的前 4 页从没渲染过缓存里就没有对应数据。所以永远不要把selectedRows当成可信数据源去提交用它做展示可以提交一律用selectedRowKeys让后端按 id 查。第三个问题是选中项数量上限。用户如果一路全选下去selectedRowKeys可能积累到几千甚至几万个 key每次渲染都要拿它去和当前页的每一行比对还要传给Set做转换性能会掉。所以我会在超过一定数量时给提示或者在业务上就把批量操作的上限定死比如单次最多处理 200 条超过就拦住。业务上的约束比技术上的优化更有效。4.3 后端分页场景下自己维护一份 selectedRows 更稳妥如果表格是后端分页数据量大一次只请求 20 条并且跨页选择是核心功能我更倾向于不用preserveSelectedRowKeys而是自己维护两份状态const [selectedRowKeys, setSelectedRowKeys] useStatestring[]([]); const [selectedRowsMap, setSelectedRowsMap] useStateMapstring, OrderItem(new Map()); const onSelectionChange (keys: React.Key[], rows: OrderItem[]) { const nextMap new Map(selectedRowsMap); const keySet new Set(keys.map(String)); // 先删掉被取消的 nextMap.forEach((_, key) { if (!keySet.has(key)) nextMap.delete(key); }); // 再补上新增的 rows.forEach((row) { const key String(row.id); if (keySet.has(key) !nextMap.has(key)) nextMap.set(key, row); }); setSelectedRowKeys(keys.map(String)); setSelectedRowsMap(nextMap); };这么做的好处是选中的行数据是完整可控的不依赖 antd 内部缓存做已选清单弹窗时可以完整展示每一行的关键字段订单号、客户名、金额用户能核对。代价是代码量多一点内存占用高一点需要自己处理数据一致性。注意无论用哪种方式提交给后端的都应该是 id 数组而不是整行对象数组。原因有两个——整行数据可能携带大量无关字段甚至敏感字段网络开销大更重要的是前端缓存的整行数据可能是旧版本后端按 id 重新查出来的才是权威数据。5. 树形表格的多选父子联动和半选状态5.1 checkStrictly 决定了两套完全不同的交互树形表格比如组织架构、分类目录加多选第一件要决定的事是勾父节点要不要连带勾上所有子节点。antd 通过rowSelection.checkStrictly控制checkStrictly: true父子完全独立勾父不影响子勾子不影响父。这种模式适合只操作当前节点本身的场景比如给某个角色单独授权。不设置或者设为false默认父子联动勾父节点会自动把全部后代加进selectedRowKeys勾满所有子节点时父节点自动变成选中只勾了一部分子节点时父节点显示半选indeterminate。绝大多数权限配置、批量操场的场景用的是默认的联动模式因为用户的心智就是我勾了这个文件夹里面的东西我当然都要选上。5.2 半选状态是怎么算出来的半选并不是你手动设置的是 antd 根据选中集合和树形结构自己推导的一个父节点的所有子节点里如果有部分在selectedRowKeys里、部分不在就渲染成半选。这带来一个非常容易踩的坑如果你从后端拿到了一个只有父节点 id 的选中列表页面上会出现父节点半选、子节点全都没勾的诡异状态。因为 antd 认为你只选了父节点本身而子节点一个都没选。这时候用户点一下父节点想全选反而会先把所有子节点选上、再点一下变成全不选来回折腾。正确做法是把选中列表补全成完整的叶子节点集合。要么让后端返回时展开要么在前端做一次补全const expandKeys (checkedKeys: string[], tree: TreeNode[]): string[] { const result new Set(checkedKeys); const walk (nodes: TreeNode[]) { nodes.forEach((node) { if (result.has(String(node.id)) node.children?.length) { node.children.forEach((child) { result.add(String(child.id)); walk([child]); }); } else if (node.children?.length) { walk(node.children); } }); }; walk(tree); return Array.from(result); };提示如果树是异步加载子节点的点开才请求那未加载的子节点的 key 前端根本不知道这时候联动就是个伪命题。这种情况建议用checkStrictly: true让父子独立把级联逻辑放到点击后按需请求处理别硬做前端联动。5.3 提交树形选中结果时的过滤策略树形多选提交给后端时通常是两种约定之一只提交叶子节点或者提交选中节点减去其全选后代的最小集合。只提交叶子节点最简单后端拿到所有 id 直接处理不需要理解层级关系缺点是当整棵大树的子节点特别多时请求体会很长。最小集合的方式是经典的树形勾选压缩如果一个节点的所有后代都被选中了就只提交这个节点本身。做起来稍微绕一点但在目录树这类层级深、节点多的场景下能显著减小传输量。我一般优先选叶子节点方案因为它不会因为层级语义的歧义导致后端理解错。只有在节点总数量确实很大比如上万个分类时才去做压缩。6. 上万行数据下的多选性能与交互细节6.1 全选到底选了什么这件事必须先跟产品确认大表格里最容易出问题的功能是全选。当前页 20 条用户点了全选到底是选中这 20 条还是选中符合当前筛选条件的全部 5000 条这两个语义完全不同实现成本也差一个数量级。技术上antd 的表头全选框只管当前渲染出来的数据行。如果要做全选所有查询结果常见的交互是用一个 Alert 提示已选中本页 20 条是否选中全部 5000 条点了之后前端把它变成一个全选模式的标记提交时不传 id 列表而是把当前的筛选条件传给后端让后端自己执行。我最推荐的做法是默认只选当前页同时给一个显式的选择全部结果入口。因为用户点全选时的预期其实是不确定的把选择权交给他比替他猜要安全得多尤其是在删除、改状态这种不可逆操作上。6.2 getCheckboxProps 和 rowClassName 里的隐性开销前面提过getCheckboxProps会对每行调用。除此之外还有几个同样会被逐行调用的函数rowClassName、onRow、expandIcon。这些函数里如果有数组find、对象深比较、JSON.stringify之类的操作在几千行的规模下会直接把页面拖垮。我做过的优化里收益最大的三个优化点做法效果选中查找用Set替代数组includesO(n²) 降到 O(n)禁用判断把判断提前算好挂到数据行上渲染时只读字段行 key 计算rowKey用固定字段不用函数省掉每行一次函数调用第三条尤其值得说rowKey如果写成函数React 每次渲染都要对每一行调用一次。如果数据里本来就有唯一主键直接写字段名没必要用函数。还有一个更彻底的方案把getCheckboxProps里用到的判断结果在数据请求回来之后就算好作为一条普通字段挂在记录上。渲染时函数只做disabled: record.__disabled这样的读取开销几乎为零。代价是数据结构被污染了团队里要约定好这个字段的命名避免和后端返回的字段冲突。6.3 几个看着小但很影响体验的细节行点击整行选中。antd 默认只有点复选框才能选中。后台用户的实际习惯是点整行这时候可以用onRow把点击事件转发出去onRow{(record) ({ onClick: () { if (record.status ! pending) return; const key String(record.id); const next selectedRowKeys.includes(key) ? selectedRowKeys.filter((k) k ! key) : [...selectedRowKeys, key]; setSelectedRowKeys(next); }, })}注意行点击要绕开禁用行的判断不然点了禁用行也会被选中和 Checkbox 的表现不一致。另外如果单元格里有按钮、链接、可展开区域要在这些元素上stopPropagation否则点个查看详情顺手把行选中了。选中数量要显示在用户视线范围内。表格横向滚动的时候左侧的多选框会跟着滚走右侧的操作按钮和已选 X 项的文案也要保证始终可见。我的做法是把批量操作栏固定在表格上方的工具栏里不放在表格内部。刷新按钮要和选中状态联动。用户手动点了刷新如果数据行还在保留选中如果选中的行已经从数据里消失给个提示。这个细节不做用户会觉得系统时不时把我的选择清掉。7. 从选中到操作把 key 变成真正干活的逻辑7.1 提交前的二次确认和数量校验多选操作的破坏力跟选中数量成正比所以二次确认是必需的而且确认弹窗里要把数量和关键信息写清楚。我见过太多确定要删除吗这种没有信息量的提示用户点确定的时候根本不知道自己删了多少条。我习惯这么写const handleBatchDelete () { if (!selectedRowKeys.length) { message.warning(请先选择要删除的数据); return; } if (selectedRowKeys.length MAX_BATCH_SIZE) { message.warning(单次最多处理 ${MAX_BATCH_SIZE} 条当前已选 ${selectedRowKeys.length} 条); return; } Modal.confirm({ title: 确认删除, content: 本次将删除 ${selectedRowKeys.length} 条数据${ selectedRowKeys.length currentPageCount ? 含其他页选中项 : }删除后不可恢复。, okButtonProps: { danger: true }, onOk: async () { await deleteOrders(selectedRowKeys); setSelectedRowKeys([]); await refreshList(); }, }); };三个要点空选校验、数量上限校验、明确说明是否包含其他页。最后这条特别容易漏跨页选中时用户看到的页面和实际操作的集合不一致弹窗里的这句提示就是最后一道防线。操作成功后要清空选中并刷新列表这两步的顺序不能反。先刷新再清空的话新数据可能在瞬间和旧的选中 key 匹配上闪一下错误的勾选状态虽然时间很短 (很短但在屏幕上能看出来)。7.2 只传 id 还是传整行数据前面提过要传 id这里补充一个例外如果这个操作需要用户确认逐条的内容比如批量导出要按行生成 Excel那前端确实需要行数据。这时候不要指望onChange的selectedRows参数因为在跨页和异步加载的场景下它不可靠。正确做法是用第 4.3 节说的selectedRowsMap自己维护提交导出时按 key 从 map 里取。如果某些 key 在 map 里找不到用户直接跳页导致的就针对这些 key 单独请求一次详情接口补齐。这段兜底逻辑平时跑不到但只要出一次问题用户拿到的就是缺数据的文件比报错还难发现。7.3 导出选中项时的一个细节导出功能有个隐藏的坑用户选中了 500 条前端把 500 条数据拼成文件下载这在数据量大时会把浏览器卡住而且用户没法感知进度。更好的做法是把 id 列表和筛选条件发给后端由后端生成文件前端只负责下载。这样前端只传几百个 id压力小后端也能保证导出的是权威数据。如果必须前端生成比如没有后端配合至少要加一个 loading 状态和进度提示并且在导出过程中禁用选中状态的变更避免用户在文件生成的中途又改动了选择导致最终结果和界面显示不一致。8. 我在实际项目里沉淀下来的几条规矩这么多年做下来关于 antd table 的多选框我现在基本形成了几个固定动作写新页面的时候不用再想。第一rowKey一定显式写一定用后端主键一定转成字符串。这行代码的成本是一秒钟省下的是几天的排查时间。第二选中状态一律受控放在页面组件的 state 里不依赖组件的内部状态。需要跨页就打开preserveSelectedRowKeys需要行数据就自己维护 map。第三所有涉及选中行的批量操作函数开头三件事空选判断、数量上限判断、二次确认。这三件事写完八成的线上事故就挡在门外了。第四禁用逻辑用getCheckboxProps做硬拦截同时在界面上给出原因。灰掉的框必须有人解释它为什么灰。第五进到几千行以上的表格先检查getCheckboxProps、rowClassName这些逐行调用的函数里有没有数组查找和深比较有就换成 Set 或提前计算。这个检查花五分钟能避免上线之后被投诉表格卡得没法用。后台系统里多选表格是用户每天要点几十次的东西。它不出问题的时候没人夸一旦出问题就是数据层面的损失。上面这些细节基本都是我在真实项目里踩过之后才记住的希望对正在写这块的你有点用处。