Ant Design Vue 表格分页场景下的行合并性能优化实战

72 阅读8分钟

本文详细记录了在 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)每行独立显示

🔍 问题分析

初始问题

在实现表格行合并时,我们遇到了以下问题:

  1. 性能问题:数据量大时(500+ 条记录),表格渲染卡顿
  2. 重复计算:每个需要合并的单元格都会触发 rowSpan 计算
  3. 分页问题:跨页的数据行合并显示错误

💻 优化前的实现

数据扁平化

// 将主从结构展平为表格数据
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  │ ← 应该独立显示,但被合并了
└─────────┴──────────┘

第二次优化:支持分页的动态计算

核心思路

  1. 追踪当前页码和每页大小
  2. 计算当前页的数据范围
  3. 只在当前页数据中计算 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,00040,200600667x
首次渲染800-1200ms80-120ms< 50ms16-24x
翻页时间800-1200ms80-120ms< 50ms16-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);
});

原因

  • startend 在 computed 外部计算
  • currentPagepageSize 变化时,它们不会自动更新
  • 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 null
  • Cannot 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;
};

🎓 总结

核心要点

  1. 性能优化的本质:减少不必要的计算和遍历
  2. 分页场景的特殊性:必须考虑数据分布在多个页面的情况
  3. 响应式系统的利用:使用 computed 自动追踪依赖变化
  4. 空间换时间:使用 Map 缓存映射关系和计数
  5. 边界情况处理:确保代码的健壮性

优化思路总结

第一步:识别性能瓶颈
  - 使用 Chrome DevTools Performance 分析
  - 发现 getRowSpan 被大量调用且每次都遍历全部数据
  ↓
第二步:尝试预计算(空间换时间)
  - 在数据扁平化时计算 rowSpan
  - 性能大幅提升
  ↓
第三步:发现分页问题
  - 预计算的值在分页时不准确
  - 需要动态计算
  ↓
第四步:改为动态计算(但只计算当前页)
  - 只对当前页数据进行计算
  - 避免跨页问题
  ↓
第五步:使用 Map 优化查找
  - 缓存第一条记录的 ID
  - 减少重复查找
  ↓
第六步:同时缓存计数(进一步优化)
  - 避免每次 getRowSpan 时执行 filter
  - 将时间复杂度从 O(n) 降到 O(1)
  ↓
第七步:完善边界情况处理
  - 处理 null/undefined
  - 处理空数据等情况

适用场景

这个优化方案适用于:

  • ✅ 主从结构的数据展示
  • ✅ 需要行合并的表格
  • ✅ 有分页需求的场景
  • ✅ 数据量较大(100+ 条)
  • ✅ 需要频繁切换页面的场景

不适用于:

  • ❌ 数据量很小(< 50 条)
  • ❌ 不需要分页的场景
  • ❌ 不需要行合并的表格
  • ❌ 静态数据不会变化的场景

性能优化的通用原则

  1. 测量先行:使用工具量化性能问题,不要凭感觉
  2. 找准瓶颈:用 80/20 法则,找到影响最大的 20% 代码
  3. 针对性优化:根据具体场景选择合适的优化策略
  4. 验证效果:优化后必须验证是否真的提升了性能
  5. 关注副作用:确保优化没有引入新的问题(如内存泄漏、逻辑错误)
  6. 代码可维护性:不要为了性能牺牲太多可读性

💡 最后的话

性能优化是一个持续的过程,需要:

  1. 测量:使用工具量化性能问题
  2. 分析:找到真正的瓶颈
  3. 优化:针对性地解决问题
  4. 验证:确保优化有效且没有引入新问题
  5. 迭代:根据实际情况不断改进

记住几个重要原则:

  • 过早优化是万恶之源:不要在没有性能问题时就过度优化
  • 该优化时不要犹豫:当性能问题影响用户体验时,要果断优化
  • 用数据说话:优化效果要用实际测量数据来验证
  • 平衡取舍:性能、可读性、可维护性需要综合考虑

希望这篇文章能帮助你在类似场景中快速定位和解决性能问题!


📚 参考资料