项目背景: 采集文件管理页面,基于 Ant Design Table(SzTable 封装)实现批量下载功能。开发过程中连续遇到三个 Bug,表面看各不相同,深层分析后发现它们背后存在高度共性的问题模式。本文将逐一复盘,并提炼出可复用的经验教训。
一、三个 Bug 回顾
Bug 1:全选时部分行未被选中(rowKey 重复)
现象: 第一页有 10 条数据,点击表头全选复选框,但只有部分行被高亮选中(如 6 行),其余行的复选框未勾选。
根因: 后端返回的 fileId 不是唯一的。同一个文件可以被多次执行,产生多条记录,它们共享相同的 fileId。而 SzTable 的 rowKey 直接使用了 fileId,导致 Ant Design Table 内部无法区分这些行。
typescript
// ❌ 有问题:fileId 不唯一
<SzTable rowKey="fileId" ... />
当 Table 遇到重复的 rowKey 时,它只会认为是"同一行",因此:
selectedRowKeys中只有一个 key,但 UI 上有多行对应该 key- 全选时只有第一个匹配的行被视为选中
修复: 使用复合 key(fileId + runTime)作为 rowKey,确保唯一性。
typescript
// ✅ 修复:复合 key
const getRowKey = (record: ListOneBackTypeConnector) =>
`${record.fileId || ''}_${record.runTime || ''}`;
<SzTable rowKey={getRowKey} ... />
Bug 2:跨页全选后,批量下载数量不足
现象: 在第 1 页全选 10 条,翻到第 2 页全选 10 条,表头显示已选 20 条。点击"批量下载"后,只有约 10 个文件进入下载队列。
根因: rowSelection.onChange 回调的 selectedRows 参数,对于不在当前 dataSource 中的行 (即其他页的行),可能返回 undefined。
typescript
// ❌ 有问题:直接使用 onChange 的 rows 参数
onChange: (keys, rows) => {
setSelectedRowKeys(keys); // keys 是完整的(preserveSelectedRowKeys 保留)
setSelectedRows(rows); // rows 中其他页的行是 undefined!
}
preserveSelectedRowKeys: true 只保证 key 被保留 ,不保证行数据对象 被保留。翻页后 dataSource 被替换,Ant Design 无法找到旧页行数据。
数据流:
第1页全选 → onChange(10 keys, 10 rows) ✅
翻到第2页 → dataSource 替换为第2页数据
第2页全选 → onChange(20 keys, [undefined×10, row11..row20])
↑ 第1页数据丢失
批量下载 → selectedRows.filter(row => row.fileId)
→ undefined 被过滤 → 只剩 10 个
修复: 使用 useRef<Map> 缓存所有曾选中的行数据,不依赖 onChange 的 rows 参数。
typescript
// ✅ 修复:缓存选中行数据
const selectedRowsMapRef = useRef<Map<string, ListOneBackTypeConnector>>(new Map());
onChange: (keys, rows) => {
// 将当前页有效行数据写入缓存
rows.forEach((row) => {
if (row && row.fileId) {
selectedRowsMapRef.current.set(getRowKey(row), row);
}
});
// 根据 keys 从缓存中提取所有选中行
const validRows = keys
.map((key) => selectedRowsMapRef.current.get(key as string))
.filter(Boolean) as ListOneBackTypeConnector[];
setSelectedRowKeys(keys);
setSelectedRows(validRows);
}
Bug 3:单页 20 份全选下载,状态全部"已完成"但实际只下载约 10 份
现象: 同一页全选 20 条,点击批量下载,操作列状态全部显示"已完成"(绿色),但浏览器下载目录中实际只有约 10 个文件。
根因(双重):
原因 A --- Chrome 自动下载保护: Chrome 浏览器内置了多文件自动下载保护机制。当网页通过 a.click() 编程方式连续触发下载时,超过阈值(通常约 10 个)后,Chrome 会静默阻止后续下载,不抛出任何 JS 异常。
由于我们的文件只有 6KB,API 调用极快(毫秒级),20 个 a.click() 在几秒内密集触发,正好命中 Chrome 的保护阈值。
原因 B --- URL.revokeObjectURL 调用过早:
typescript
// ❌ 有问题
a.click(); // 触发下载(浏览器异步处理)
URL.revokeObjectURL(objectURL); // 立即释放 → 排队中的下载可能读不到数据
a.click() 通知浏览器开始下载,但实际读取 Blob 数据是异步的。被 Chrome 排队的下载在实际执行时,Blob URL 可能已被释放。
关键问题: 代码中的 try-catch 捕获不到浏览器层面的下载拦截,因此状态仍被标记为 COMPLETED,造成"状态欺骗"。
修复: 添加下载间隔 + 延迟释放 Blob URL。
typescript
// ✅ 修复
a.click();
// 延迟释放 Blob URL,确保浏览器有足够时间读取数据
setTimeout(() => URL.revokeObjectURL(objectURL), 5000);
onStatusChange(task.recordKey, DownloadStatusEnum.COMPLETED);
// 每个文件下载后等待 800ms,避免触发 Chrome 多文件下载保护
await new Promise((resolve) => setTimeout(resolve, 800));
二、共性问题提炼
回顾这三个 Bug,它们看似不同(选中异常、跨页丢失、下载拦截),但背后存在四个高度共性的问题模式:
模式 1:隐式假设 ------ "我以为 API 返回的就是对的"
| Bug | 隐式假设 | 实际情况 |
|---|---|---|
| Bug 1 | fileId 是唯一的,可以做 rowKey |
后端同一文件多次执行会产生相同 fileId |
| Bug 2 | onChange 的 rows 包含所有选中行的完整数据 |
跨页行的数据是 undefined |
| Bug 3 | a.click() 一定会成功触发下载 |
Chrome 会静默拦截超限的自动下载 |
教训: 永远不要对第三方 API / 框架回调的行为做隐式假设。应该:
- 阅读文档中的边界行为描述(而不只是看 happy path 的示例)
- 对关键入参做防御性校验(唯一性、空值、完整性)
- 不信任"一定成功" ------ 浏览器、网络、操作系统都可能有自己的保护机制
模式 2:单元可用 ≠ 批量可用
三个 Bug 都在单个操作 时表现正常,批量操作时才暴露问题:
| 场景 | 单个操作 | 批量操作 |
|---|---|---|
| 选中行 | 单个勾选正常 | 全选时 rowKey 重复导致部分行不可选 |
| 跨页选中 | 单页选中正常 | 翻页后 selectedRows 丢失 |
| 文件下载 | 单文件下载正常 | 20 个连续下载触发浏览器保护 |
教训: 实现批量功能时,必须验证:
- 唯一性约束在大量数据下是否仍然成立
- 回调参数在跨页 / 跨状态时是否仍然完整
- 浏览器限制在高频操作时是否会触发保护机制
一条简单的规则:凡是循环执行的操作,都要考虑"第 N 次执行时是否和第 1 次行为一致"。
模式 3:框架帮你做了一半,剩下一半是坑
Ant Design Table 提供了 preserveSelectedRowKeys,但:
- 它只保留了 keys ,没保留 rows
- 文档中有说明,但容易忽略
浏览器提供了 a.click() 下载,但:
- 它不保证在自动下载保护模式下仍然工作
- 失败时不抛异常,不返回错误码
教训: 使用框架的"便捷功能"时:
- 弄清楚它帮你做了什么,没有帮你做什么
- 对于关键流程,考虑自行管理状态而不是完全依赖框架
- 在框架行为不可控的地方(如浏览器安全策略),添加主动防御(延迟、重试、确认)
模式 4:状态与现实不同步
| Bug | 代码中的状态 | 用户看到的现实 |
|---|---|---|
| Bug 1 | selectedRowKeys 有 6 个 key | 但 UI 上有些行明明没被选中 |
| Bug 2 | selectedRowKeys 有 20 个 key | 但 selectedRows 只有 10 个有效对象 |
| Bug 3 | downloadStatusMap 全部 COMPLETED | 但只下载了 10 个文件 |
教训: 当系统维护多个"应该同步"的状态时,它们一定会在某些边界条件下失去同步。应该:
- 单一数据源(Single Source of Truth):用一个主状态派生其他状态
- 不信任中间状态:在关键操作前做最终校验
- 让用户可感知真实状态:如果无法确认下载成功,就不要显示"已完成"
三、防御性编码 Checklist
基于以上经验,整理一份面向 Ant Design Table 批量操作场景的 Checklist:
rowKey 相关
-
rowKey是否在所有数据中严格唯一? - 后端是否可能返回重复的 ID?(同一实体多次出现的场景)
- 如果不唯一,是否使用了复合 key?
- 复合 key 中的每个字段是否做了空值兜底?
跨页选中相关
- 是否启用了
preserveSelectedRowKeys: true? -
onChange回调中是否自行管理 selectedRows?(不依赖第二个参数) - 是否有缓存机制保存跨页行数据?
- 清除选中时是否同时清除缓存?
批量下载相关
- 是否考虑了浏览器的自动下载保护?
- 连续下载之间是否有适当间隔(建议 ≥ 500ms)?
-
URL.revokeObjectURL是否做了延迟释放? - 下载状态是否反映了真实结果而非"代码执行完毕"?
- 大量下载时是否需要替代方案(如后端打包 zip)?
通用
- 功能在单个操作 和批量操作下是否都测试了?
- 是否测试了翻页后再操作的场景?
- 是否测试了边界数量(如恰好 10 个、20 个、50 个)?
- 关键第三方回调的参数是否做了防御性校验(null、undefined、空数组)?
四、修复方案速查表
| 问题类型 | 问题 | 修复方案 | 改动量 |
|---|---|---|---|
| rowKey 重复 | 后端 ID 不唯一 | 复合 key(id + timestamp) |
小 |
| 跨页 selectedRows 丢失 | Ant Design 不缓存跨页行数据 | useRef<Map> 自行缓存 |
小 |
| Chrome 拦截批量下载 | a.click() 频率过高 |
下载间隔 800ms + 延迟释放 Blob URL | 小 |
| 状态欺骗 | 浏览器拦截不抛异常 | 考虑后端打 zip 或 File System Access API | 中 |
五、延伸思考
5.1 批量下载的终极方案
当前的逐个下载(Blob + a.click())方案存在天然缺陷:
- 受浏览器安全策略限制
- 无法准确感知下载是否成功
- 大量文件时用户体验差(等待时间长)
更优方案:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 后端打包 zip | 一次下载、不受浏览器限制 | 后端需实现打包逻辑,大文件耗时 | 文件数量多、总大小适中 |
| File System Access API | 可直接写入用户选择的目录 | 兼容性差(仅 Chromium) | 内部系统、Chromium 限定 |
| Service Worker 拦截 | 可控制下载行为 | 实现复杂 | 需要精细控制下载流程 |
5.2 从 Bug 模式看团队协作
这三个 Bug 的共性根源:前后端对数据契约的理解不一致。
- 前端假设
fileId唯一 → 后端没有这个保证 - 前端假设 Ant Design 回调数据完整 → 框架没有这个保证
- 前端假设浏览器会执行所有下载 → 浏览器没有这个保证
建议:
- 接口文档明确字段唯一性约束:后端 API 文档应标注哪些字段是唯一的
- 前端对关键字段做唯一性校验:不信任后端数据,防御性编程
- Code Review 关注批量场景:Review 时专门检查循环/批量操作的边界行为
- 自测用例覆盖"翻页 + 全选 + 批量操作"组合
六、总结
| 核心经验 | 一句话 |
|---|---|
| 不做隐式假设 | 对 API 返回值、框架回调、浏览器行为做防御性校验 |
| 单元 ≠ 批量 | 批量场景下要重新验证所有假设 |
| 框架只做了一半 | 弄清框架帮你做了什么、没做什么 |
| 状态要反映现实 | 代码层面的"完成"不等于用户层面的"成功" |
这三个 Bug 每个都不大(修复代码不超过 20 行),但它们暴露的问题模式具有高度普遍性。在任何涉及"列表 + 分页 + 批量操作"的场景中,这些坑几乎一定会出现。希望这篇总结能帮助团队在未来的开发中提前规避。