本文详细记录了在 Vue 3 + Ant Design Vue 项目中,优化表格行合并(rowSpan)性能的完整过程,特别是如何正确处理分页场景下的行合并问题。
🎯 背景介绍
在企业级应用中,我们经常需要展示主从结构的数据,比如:
- 订单及其明细
- 项目及其任务
- 分类及其子项
这类数据在表格中展示时,通常需要对主记录的列进行行合并,以提升可读性。
业务场景
假设我们有这样的数据结构:
interface MainRecord {
id: string;
name: string;
type: string;
status: string;
details: DetailRecord[]; // 子记录列表
}
interface DetailRecord {
id: string;
description: string;
amount: number;
// ... 其他字段
}
在表格中展示时:
- 主记录的列(name、type、status)需要合并显示
- 子记录的列(description、amount)每行独立显示
🔍 问题分析
初始问题
在实现表格行合并时,我们遇到了以下问题:
- 性能问题:数据量大时(500+ 条记录),表格渲染卡顿
- 重复计算:每个需要合并的单元格都会触发 rowSpan 计算
- 分页问题:跨页的数据行合并显示错误
💻 优化前的实现
数据扁平化
// 将主从结构展平为表格数据
const flatTableData = computed(() => {
const result: any[] = [];
filteredData.value.forEach((mainRecord) => {
if (!mainRecord.details || mainRecord.details.length === 0) {
// 没有子记录,添加空行
result.push({
...mainRecord,
id: `${mainRecord.id}-empty`,
isEmpty: true,
});
} else {
// 有子记录,展开
mainRecord.details.forEach((detail, index) => {
result.push({
mainRecord, // 保存主记录引用
detailRecord: detail,
...mainRecord,
...detail,
id: `${mainRecord.id}-${detail.id}`,
isFirstEntry: index === 0, // 标记是否为第一条
});
});
}
});
return result;
});
rowSpan 计算(问题版本)
// ❌ 问题:每次都遍历整个数据源
const getRowSpan = (record: any) => {
if (!record || !record.mainId) return 0;
const mainId = record.mainId;
// 问题1:findIndex 遍历整个数组
const firstIndex = dataSource.findIndex(
(item) => item.mainId === mainId
);
// 只有第一行才显示合并
if (record.id === dataSource[firstIndex]?.id) {
// 问题2:filter 再次遍历整个数组
const count = dataSource.filter(
(item) => item.mainId === mainId
).length;
return count;
}
return 0;
};
表格列定义
const columns = [
{
title: '主记录名称',
dataIndex: 'name',
customCell: (record) => {
// 每个单元格渲染时都会调用
return { rowSpan: getRowSpan(record) };
},
},
{
title: '类型',
dataIndex: 'type',
customCell: (record) => {
return { rowSpan: getRowSpan(record) };
},
},
// ... 其他需要合并的列
];
性能问题
场景:100 条主记录,每条 5 个子记录 = 500 行数据
分页:每页 100 条
计算次数:
- 4 列需要合并
- 每页显示 100 行
- 每列 100 个单元格
- 每个单元格调用 getRowSpan()
- 每次 getRowSpan() 执行:
- findIndex: 遍历全部 500 行
- filter: 遍历全部 500 行
总计算量(每页):
4 × 100 × (500 + 500) = 400,000 次数组操作
结果:
- 首次渲染耗时:800ms - 1.2s
- 翻页时卡顿明显
- 用户体验差
🚀 优化过程
第一次优化尝试:预计算 rowSpan
思路:在数据扁平化时就计算好 rowSpan,避免渲染时重复计算。
const flatTableData = computed(() => {
const result: any[] = [];
filteredData.value.forEach((mainRecord) => {
const detailCount = mainRecord.details?.length || 1;
mainRecord.details.forEach((detail, index) => {
result.push({
...mainRecord,
...detail,
// 预计算 rowSpan
rowSpan: index === 0 ? detailCount : 0,
});
});
});
return result;
});
// 直接使用预计算的值
const getRowSpan = (record: any) => {
return record?.rowSpan ?? 0;
};
效果:
- ✅ 性能大幅提升,从 O(n²) 降低到 O(n)
- ✅ 渲染流畅,无卡顿
问题:
- ❌ 分页场景下行合并错误!
分页问题详解
假设数据如下:
- 主记录 A:5 个子记录
- 每页显示 100 条
第一页(记录 1-100) :
- 主记录 A 的前 3 个子记录(第 98-100 行)
第二页(记录 101-200) :
- 主记录 A 的后 2 个子记录(第 1-2 行)
问题:
// 预计算的 rowSpan = 5
// 但第一页只显示 3 条记录
// 导致尝试合并 5 行,但实际只有 3 行
// 结果:显示错误,甚至可能合并到下一页
示意图:
第一页(预期合并3行,实际尝试合并5行):
┌─────────┬──────────┐
│ 主记录A │ 子记录1 │ ← rowSpan=5(错误!)
├─────────┼──────────┤
│ × │ 子记录2 │ ← 被合并
├─────────┼──────────┤
│ × │ 子记录3 │ ← 被合并
└─────────┴──────────┘
(尝试继续合并到第二页...)
第二页:
┌─────────┬──────────┐
│ × │ 子记录4 │ ← 应该独立显示,但被合并了
├─────────┼──────────┤
│ × │ 子记录5 │ ← 应该独立显示,但被合并了
└─────────┴──────────┘
第二次优化:支持分页的动态计算
核心思路:
- 追踪当前页码和每页大小
- 计算当前页的数据范围
- 只在当前页数据中计算 rowSpan
✨ 优化后的实现
方案一:基础优化版本
import { computed, ref } from 'vue';
// 当前页码和每页大小
const currentPage = ref(1);
const pageSize = ref(100);
// 计算当前页数据
const currentPageData = computed(() => {
const start = (currentPage.value - 1) * pageSize.value;
const end = start + pageSize.value;
return props.dataSource.slice(start, end);
});
// 构建主记录ID到当前页第一条记录的映射
const mainRecordFirstMap = computed(() => {
const map = new Map<string, string>();
currentPageData.value.forEach((record) => {
const mainId = record.mainId;
if (!map.has(mainId)) {
// 记录该主记录在当前页的第一条记录ID
map.set(mainId, record.id);
}
});
return map;
});
/**
* 计算行合并的 rowSpan - 支持分页
*
* 关键点:
* 1. 只在当前页数据中查找
* 2. 只合并当前页内的记录
* 3. 跨页的记录在每页独立显示
*/
const getRowSpan = (record: any) => {
if (!record?.mainId) return 0;
const mainId = record.mainId;
const firstRecordId = mainRecordFirstMap.value.get(mainId);
// 如果是当前页该主记录的第一条记录
if (record.id === firstRecordId) {
// 计算该主记录在当前页有多少条记录
const countInCurrentPage = currentPageData.value.filter(
(item) => item.mainId === mainId
).length;
return countInCurrentPage;
}
return 0;
};
// 处理分页变化
const handleTableChange = (pagination: any) => {
currentPage.value = pagination.current || 1;
pageSize.value = pagination.pageSize || 100;
};
方案二:进一步优化版本(推荐)
方案一中的 getRowSpan 仍然需要在每次调用时执行 filter 操作,会导致额外的遍历。我们可以在构建 Map 时同时缓存计数:
import { computed, ref } from 'vue';
// 当前页码和每页大小
const currentPage = ref(1);
const pageSize = ref(100);
// 计算当前页数据
const currentPageData = computed(() => {
const start = (currentPage.value - 1) * pageSize.value;
const end = start + pageSize.value;
return props.dataSource.slice(start, end);
});
// 构建主记录信息映射:包含第一条记录ID和该主记录在当前页的数量
const mainRecordInfoMap = computed(() => {
const map = new Map<string, { firstId: string; count: number }>();
currentPageData.value.forEach((record) => {
const mainId = record.mainId;
if (!mainId) return;
if (!map.has(mainId)) {
map.set(mainId, { firstId: record.id, count: 0 });
}
map.get(mainId)!.count++;
});
return map;
});
/**
* 计算行合并的 rowSpan - 优化版
*
* 优势:
* 1. 无需在每次调用时执行 filter
* 2. 直接从缓存的 Map 中获取信息
* 3. 时间复杂度从 O(n) 降低到 O(1)
*/
const getRowSpan = (record: any) => {
if (!record?.mainId) return 0;
const info = mainRecordInfoMap.value.get(record.mainId);
// 如果是当前页该主记录的第一条记录,返回计数
if (info && record.id === info.firstId) {
return info.count;
}
return 0;
};
// 处理分页变化
const handleTableChange = (pagination: any) => {
currentPage.value = pagination.current || 1;
pageSize.value = pagination.pageSize || 100;
};
表格配置
<template>
<a-table
:columns="columns"
:data-source="dataSource"
:pagination="{
current: currentPage,
pageSize: pageSize,
total: dataSource.length,
showSizeChanger: true,
showTotal: (total) => `共 ${total} 条`,
}"
@change="handleTableChange"
>
<!-- 表格内容 -->
</a-table>
</template>
边界情况处理
/**
* 完善的 getRowSpan 实现,包含边界情况处理
*/
const getRowSpan = (record: any) => {
// 边界情况1:record 不存在
if (!record) return 0;
// 边界情况2:mainId 不存在或为空
if (!record.mainId) return 0;
// 边界情况3:当前页数据为空
if (currentPageData.value.length === 0) return 0;
const info = mainRecordInfoMap.value.get(record.mainId);
// 边界情况4:Map 中找不到对应的主记录信息
if (!info) return 0;
// 如果是当前页该主记录的第一条记录,返回计数
if (record.id === info.firstId) {
return info.count;
}
return 0;
};
数据流程图
用户操作(切换页码/改变每页大小)
↓
@change 事件触发
↓
handleTableChange 执行
↓
currentPage/pageSize 更新
↓
currentPageData 重新计算 (computed)
↓
mainRecordInfoMap 重新计算 (computed)
↓
getRowSpan 使用新数据计算
↓
表格重新渲染
↓
行合并正确显示
📊 性能对比
测试场景
- 数据量:100 条主记录,每条 5 个子记录 = 500 行
- 需要合并的列:4 列
- 分页设置:每页 100 条
优化前
数组操作次数(每页):
- 当前页渲染:100 行 × 4 列 = 400 个单元格
- 每个单元格调用 getRowSpan:
- findIndex: 遍历全部 500 行
- filter: 遍历全部 500 行
- 总计:400 × (500 + 500) = 400,000 次数组操作
首次渲染时间:800ms - 1.2s
翻页时间:800ms - 1.2s
滚动性能:卡顿明显
优化后(方案一:基础优化)
数组操作次数(每页):
- slice 切片:访问 100 个元素
- 构建 mainRecordFirstMap:遍历 100 行
- getRowSpan 调用:100 行 × 4 列 = 400 次
- 每次 getRowSpan 的 filter:遍历 100 行
- 总计:100 + 100 + (400 × 100) = 40,200 次
首次渲染时间:80-120ms
翻页时间:80-120ms
性能提升:约 10 倍
优化后(方案二:进一步优化 - 推荐)
数组操作次数(每页):
- slice 切片:访问 100 个元素
- 构建 mainRecordInfoMap:遍历 100 行(同时计数)
- getRowSpan 调用:100 行 × 4 列 = 400 次(但每次是 O(1) 的 Map 查找)
- 总计:100 + 100 + 400 = 600 次
首次渲染时间:< 50ms
翻页时间:< 50ms
性能提升:约 667 倍(400,000 ÷ 600)
实际效果对比
| 指标 | 优化前 | 方案一 | 方案二(推荐) | 提升倍数 |
|---|---|---|---|---|
| 数组操作次数 | 400,000 | 40,200 | 600 | 667x |
| 首次渲染 | 800-1200ms | 80-120ms | < 50ms | 16-24x |
| 翻页时间 | 800-1200ms | 80-120ms | < 50ms | 16-24x |
| 滚动流畅度 | 卡顿 | 较流畅 | 流畅 | - |
| 内存占用 | 高 | 中 | 低 | - |
🕳️ 踩坑记录
坑1:预计算 rowSpan 在分页场景失效
问题:
// ❌ 错误做法
result.push({
...record,
rowSpan: index === 0 ? totalCount : 0, // 使用总数
});
原因:
- 分页时,一个主记录的子记录可能分布在多个页面
- 预计算的 rowSpan 是总数,但每页只显示部分数据
- 导致行合并跨页,显示错误
解决:
- 动态计算当前页的 rowSpan
- 每页独立合并,不跨页
坑2:忘记监听分页变化
问题:
// ❌ 没有 handleTableChange
// currentPage 永远是 1
// currentPageData 永远是第一页的数据
现象:
- 切换页码后,行合并仍然基于第一页数据计算
- 显示完全错误
解决:
// ✅ 必须监听分页变化
const handleTableChange = (pagination: any) => {
currentPage.value = pagination.current || 1;
pageSize.value = pagination.pageSize || 100;
};
坑3:computed 依赖追踪问题
问题:
// ❌ start 和 end 不是响应式的
const start = (currentPage.value - 1) * pageSize.value;
const end = start + pageSize.value;
const currentPageData = computed(() => {
return props.dataSource.slice(start, end);
});
原因:
start和end在 computed 外部计算- 当
currentPage或pageSize变化时,它们不会自动更新 - computed 无法追踪到这些变化
解决:
// ✅ 在 computed 内部计算
const currentPageData = computed(() => {
const start = (currentPage.value - 1) * pageSize.value;
const end = start + pageSize.value;
return props.dataSource.slice(start, end);
});
坑4:Map vs Object 的选择
容易产生的误解:
// ❌ 认为对象的 key 转字符串会导致问题
const map = {};
map['123'] = 'value'; // 其实这样是正常的
实际情况:
- 如果 key 本身就是字符串或数字,用对象也可以
- 真正的问题在于对象 key 是对象时会被转换为
[object Object]
Map 的真正优势:
// ✅ 使用 Map 的好处
const map = new Map<string, any>();
// 1. 支持任意类型的 key
map.set({id: 1}, 'value'); // 对象作为 key
// 2. 更好的性能(大数据量时)
// 3. 内置方法更方便
map.size; // 获取大小
map.has(key); // 检查是否存在
map.delete(key); // 删除
// 4. 避免原型链污染
const obj = {};
obj['__proto__'] = 'dangerous'; // 可能污染原型链
map.set('__proto__', 'safe'); // 不会有问题
// 5. 迭代更方便
for (const [key, value] of map) {
console.log(key, value);
}
坑5:边界情况未处理导致的错误
问题场景:
// ❌ 没有处理边界情况
const getRowSpan = (record: any) => {
const info = mainRecordInfoMap.value.get(record.mainId);
return record.id === info.firstId ? info.count : 0;
// 如果 record 是 null 或 info 是 undefined,会报错
};
可能的错误:
Cannot read property 'mainId' of nullCannot read property 'firstId' of undefined
解决:
// ✅ 完善的边界处理
const getRowSpan = (record: any) => {
if (!record?.mainId) return 0;
const info = mainRecordInfoMap.value.get(record.mainId);
if (!info) return 0;
return record.id === info.firstId ? info.count : 0;
};
🎓 总结
核心要点
- 性能优化的本质:减少不必要的计算和遍历
- 分页场景的特殊性:必须考虑数据分布在多个页面的情况
- 响应式系统的利用:使用 computed 自动追踪依赖变化
- 空间换时间:使用 Map 缓存映射关系和计数
- 边界情况处理:确保代码的健壮性
优化思路总结
第一步:识别性能瓶颈
- 使用 Chrome DevTools Performance 分析
- 发现 getRowSpan 被大量调用且每次都遍历全部数据
↓
第二步:尝试预计算(空间换时间)
- 在数据扁平化时计算 rowSpan
- 性能大幅提升
↓
第三步:发现分页问题
- 预计算的值在分页时不准确
- 需要动态计算
↓
第四步:改为动态计算(但只计算当前页)
- 只对当前页数据进行计算
- 避免跨页问题
↓
第五步:使用 Map 优化查找
- 缓存第一条记录的 ID
- 减少重复查找
↓
第六步:同时缓存计数(进一步优化)
- 避免每次 getRowSpan 时执行 filter
- 将时间复杂度从 O(n) 降到 O(1)
↓
第七步:完善边界情况处理
- 处理 null/undefined
- 处理空数据等情况
适用场景
这个优化方案适用于:
- ✅ 主从结构的数据展示
- ✅ 需要行合并的表格
- ✅ 有分页需求的场景
- ✅ 数据量较大(100+ 条)
- ✅ 需要频繁切换页面的场景
不适用于:
- ❌ 数据量很小(< 50 条)
- ❌ 不需要分页的场景
- ❌ 不需要行合并的表格
- ❌ 静态数据不会变化的场景
性能优化的通用原则
- 测量先行:使用工具量化性能问题,不要凭感觉
- 找准瓶颈:用 80/20 法则,找到影响最大的 20% 代码
- 针对性优化:根据具体场景选择合适的优化策略
- 验证效果:优化后必须验证是否真的提升了性能
- 关注副作用:确保优化没有引入新的问题(如内存泄漏、逻辑错误)
- 代码可维护性:不要为了性能牺牲太多可读性
💡 最后的话
性能优化是一个持续的过程,需要:
- 测量:使用工具量化性能问题
- 分析:找到真正的瓶颈
- 优化:针对性地解决问题
- 验证:确保优化有效且没有引入新问题
- 迭代:根据实际情况不断改进
记住几个重要原则:
- 过早优化是万恶之源:不要在没有性能问题时就过度优化
- 该优化时不要犹豫:当性能问题影响用户体验时,要果断优化
- 用数据说话:优化效果要用实际测量数据来验证
- 平衡取舍:性能、可读性、可维护性需要综合考虑
希望这篇文章能帮助你在类似场景中快速定位和解决性能问题!