基于表格数据,动态合并 Antd Table 单元格

370 阅读10分钟

明确目标

如果要实现 动态合并,我们应该先思考以下几点:

  • Antd Table 组件是如何去合并指定单元格的,我们需要做哪些配置、数据结构有无要求

  • 了解第一点后,我们再思考下面两种情况

    • 数据是固定不变的(静态),应该怎样合并

    • 数据是随机不确定的(动态),又该如何合并

了解 Antd Table 合并单元格的配置

查阅文档后可知:合并单元格需要借助 columns 中的 onCell 函数来实现

具体的我们直接看下面这个例子:

如图所示,我们合并了 列1 的前三行 (rowIndex: 0 ,1 ,2 ) 以及 列2 的中间两行 (rowIndex: 1, 2)

代码如下:

// 表格数据
export const tableData = [
  {
    列1: 'A',
    列2: 'B',
    列3: 'C',
    列4: 'D',
  },
  {
    列1: 'A',
    列2: 'B',
    列3: 'C',
    列4: 'D',
  },
  {
    列1: 'A',
    列2: 'B',
    列3: 'C',
    列4: 'D',
  },
  {
    列1: 'A',
    列2: 'B',
    列3: 'C',
    列4: 'D',
  },
]

// 表格列配置
export const tableColumns = [
  {
    title: '列1',
    dataIndex: '列1',
    onCell(item, rowIndex) {
      let rowSpan = 0
      switch (rowIndex) {
        case 0:		// 第一行 rowIndex: 0
          rowSpan = 3
          break
        case 1:		// 第二行
          rowSpan = 0
          break
        case 2:		// 第三行
          rowSpan = 0
          break
        case 3:		// 第四行
          rowSpan = 1
          break
      }
      return { rowSpan }
    },
  },
  {
    title: '列2',
    dataIndex: '列2',
    onCell(item, rowIndex) {
      let rowSpan = 0
      switch (rowIndex) {
        case 0:   // 第一行 rowIndex: 0
          rowSpan = 1
          break
        case 1:	  // 第二行
          rowSpan = 2
          break
        case 2:		// 第三行
          rowSpan = 0
          break
        case 3:   // 第四行
          rowSpan = 1
          break
      }
      return { rowSpan }
    },
  },
  {
    title: '列3',
    dataIndex: '列3',
  },
  {
    title: '列4',
    dataIndex: '列4',
  },
]

<Table
   dataSource={tableData}
   columns={tableColumns}
   bordered
/> 			

可以看到:表格是按照 列 来渲染的。我们可以通过 每一列中的 onCell 函数来控制渲染结果。

第一列中,第一行至第四行 返回的结果依次分别是:
{ rowSpan: 3 }	{ rowSpan: 0 }  { rowSpan: 0 }  { rowSpan: 1 }

第二列中,第一行至第四行 返回的结果依次分别是:
{ rowSpan: 1 }	{ rowSpan: 2 }  { rowSpan: 0 }  { rowSpan: 1 }

规律是什么呢?

  • 返回 { rowSpan: n } 时,渲染 n 行、 返回 { rowSpan: 0 } 时,该行不渲染、 返回 { rowSpan: 1 } 时,不做处理,依旧渲染 1 行

  • 渲染 n 行时,需要控制其下方的 n - 1 行 不渲染。(合并逻辑可以理解为:在 列1 中 ,原本只有一行高度的第一行变成了三行,其实是占据了其下两行的空间,所以其下两行就不能再渲染了)

至此,我们知道了合并指定单元格的方式,并且发现,如果数据是静态的,我们完全可以通过在 columns 的 onCell 函数中根据 rowIndex 来硬编码 每行对应的 rowSpan 值,以达到预期目的。就像上面那个例子一样。

动态数据

那么当数据是动态的时候,我们又当如何应对呢?

还是一样的思路,我们最终肯定是要通过 onCell 中返回的 { rowSpan: n } 来实现单元格合并的,所以我们必须依次计算出 各列中 每一行的 rowSpan 值。

按照这个思路,我们需要考虑下面几个问题

  • 用什么数据形式来保存这些 rowSpan 值、存放在哪儿
  • 怎样确保在 onCell 中使用它们的时候,能正确地取到 对应位置上的 rowSpan 值
  • 如果出现  ['A', 'A', 'A', 'B', 'B', 'C', 'A', 'A', 'D', 'D', 'D'] 这种 有两个相同元素 A ,但是不连续的边界情况我们应该怎样处理

以下面 mock 的数据为例:

[
    {
        "category": "音频设备",
        "subItem": "调音台",
        "name": "数字调音台",
        "type": "YAMAHA TF5",
        "price": 3000,
        "count": 1
    },
    {
        "category": "音频设备",
        "subItem": "无线设备",
        "name": "无线话筒接收",
        "type": "SHURE UD4Q",
        "price": 200,
        "count": 1
    },
    {
        "category": "音频设备",
        "subItem": "无线设备",
        "name": "无线领夹话筒",
        "type": "SHURE ULXD1",
        "price": 200,
        "count": 1
    },
    {
        "category": "音频设备",
        "subItem": "音频监听",
        "name": "音频监听单元",
        "type": "BMD Audio Monitor 12G",
        "price": 500,
        "count": 1
    },
    {
        "category": "音频设备",
        "subItem": "音频监听",
        "name": "监听音箱",
        "type": "Genelec 8330A",
        "price": 400,
        "count": 2
    },
    {
        "category": "音频设备",
        "subItem": "音频监听",
        "name": "监听耳机",
        "type": "beyerdynamic DT770 Pro",
        "price": 200,
        "count": 1
    }
]

我们预期中 合并单元格后的表格应该如下:

实现思路:

  • 首先遍历一遍所有数据,将其中每一列里 出现过的 值 记录下来,并保存到一个 Map 中,Map 结构如下图。 这个 Map 中的每个 key 都是一个列字段(也就是一列),value 中存放着的是该列中 每一行的值 及其 出现顺序(顺序很重要,因为后续要判断其是否连续)

  • 然后我们基于这个 Map 的 value(数组),来得到一个与之相对应的数组,这个数组里就是该列中 每一行对应的 rowSpan 值。这一步的实现方式具见下文 getSpanArr 部分

结合例子更加直观:

我们以 第二列 为例,它对应的字段名是: subItem

所以我们从 Map 中取到对应的数组就是: 
['调音台', '无线设备', '无线设备', '音频监听', '音频监听', '音频监听']

预期中得到的 rowSpan 数组应该是
[ 1, 2, 0, 3, 0, 0]

此时这个数组中的 rowSpan 对应的就是 第二列里每一行的 需要渲染的单元格数:
第一行:1 个
第二行:合并 2 个
第三行:被 第二行 合并了,不渲染
第四行:合并 3 个
第五行:被 第四行 合并了,不渲染
第六行:被 第四行 合并了,不渲染
  • 按照上步思路处理完所有列后,我们就能得到一个新的 Map(这儿为了能在控制台中更直观地看到数据展开后的结构,将 Map 转成了 Object)

现在我们已经得到了各列中 各行的rowSpan 值,接下来就是通过 onCell 函数来使用它们

Antd中对 onCell 函数的定义如下:

 onCell(item, rowIndex) {
 	return {} // 返回一个 {} 或者 { rowSpan: number }
 }

item: 表格渲染数据中的每一行
rowIndex: 该列中的 行索引

因此,我们可以像下面这样直接使用 Map 来达到目的,还是以第二列为例,其列配置应当如下 :

{
  title: '分项',
  dataIndex: 'subItem',
    
  onCell(item, rowIndex) {
  	const rowSpan = Map['subItem'][rowIndex];
    return { rowSpan }  
	}
}

至此,就能实现我们的目标功能了, 但还有一个不便之处,那就是设置每一列的列配置时,我们需要手动指定 列的 dataIndex(列字段),并且去取 rowSpan 值时,我们必须写死为 Map['subItem'][rowIndex]、Map['category'][rowIndex]、Map['name'][rowIndex] 等等。

因此,如果我们的表格有很多列的时候,即使我们可以忽略掉因这些硬编码带来的工作量,但当某一列的列字段需要更改时,我们还需要手动去更改 dataIndex 以及 Map['xxx'] 这两处,不易于维护

优化方式

下面是一个 在 Table columns 中 自动匹配 上述 Map 中对应 rowSpan 值的优化思路

将 rowSpan 存放到每一行的数据中

我们对表格数据进行处理后,得到的预期数据如下图:

转换过程的代码如下:

/**
 ** description: 处理表格数据,为每一项中 添加一个 rowSpanConfig 对象,其中存储着该行里每一列需要合并的 rowSpan 数量
 ** @params data 原始表格数据
 ** @returns 返回处理完的表格数据
 */
export const generateTableData = (data: TServiceListItem[]) => {
  const nonData = data ?? [];

  // 记录数据中每个字段下所有的值,即:每一列中所有出现过的值
  const map = new Map<string, (string | number)[]>();

  console.log('%c data>>>>>>', 'color:rgb(132, 75, 238)', data);
  nonData.forEach((item) => {
    for (const key in item) {
      const fieldValue = item[key as keyof TServiceListItem];
      const mapValue = map.get(key);
      if (!mapValue) {
        map.set(key, [fieldValue]);
      } else {
        map.set(key, mapValue.concat(fieldValue));
      }
    }
  });
  console.log(
    '%c 表格字段 -> 每一列值 的映射 >>>>>>',
    'color:rgb(16, 239, 72)',
    JSON.parse(JSON.stringify(Object.fromEntries(map)))
  );

  // 将 映射里的 字符串数组 转化为 记录着 合并单元格数量 的数组
  map.forEach((arr, key) => {
    const rowSpanInfo = getRowSpanArr(arr);
    map.set(key, rowSpanInfo);
  });

  console.log(
    '%c 数组转化完成后的映射,,,,,',
    'color:rgb(238, 75, 208)',
    map,
    Object.fromEntries(map)
  );

  // 最后 再按照 数据的行数(rowIndex) 去 reflect 里取得 该字段,该行数 的 rowSpan
  const targetTableData = nonData.map((item, rowIndex: number) => {
    const rowSpanConfig: Record<string, number> = {};
    for (const k in item) {
      const rowSpanCount = map.get(k)[rowIndex] as number;
      rowSpanConfig[k] = rowSpanCount;
    }
    return {
      ...item,
      rowSpanConfig,
    };
  });

  console.log(
    '%c 最终处理完的数据是',
    'color: #eea24b',
    targetTableData
  );

  return targetTableData;
};

动态生成 列配置:

通过使用 defaultColumnFields 生成配置列的方式,我们就将 每一列的 列字段(key)给抽离出来了。后续我们就不用单独给每列指定对应的 列字段了,只需要在每一列的  onCell (item, rowIndex) { ... } 中, 直接使用 item.rowSpanConfig[key] 即可

经过我们上一步的处理,表格的数据如下(只展示了 第一列),其中 rowSpanConfig 结构如下:

[
  // 整个大对象对应的是  第一行  的数据
  {
    "category": "音频设备",
    "subItem": "调音台",
    "name": "数字调音台",
    "type": "YAMAHA TF5",
    "price": 3000,
    "count": 1,
    "rowSpanConfig": {
        "category": 6, // 第一列 对应的 rowSpan 值
        "subItem": 1,	 // ...
        "name": 1,		 // ...
        "type": 1,		 // ...
        "price": 1,		 // ...
        "count": 4		 // 第六列 对应的 rowSpan 值
    }
	},
  // 剩余行数据
  ...
]

用以生成 列配置 的 列名 和 列字段:

// 其中每一项的 key 对应着 列字段 
const defaultColumnFields = [
  { label: '类别', key: 'category' },
  { label: '分项', key: 'subItem' },
  { label: '名称', key: 'name' },
  { label: '型号', key: 'type' },
  { label: '数量', key: 'count' },
];
// 仅生成最基础的 列配置,如果想要自定义每列的其他属性,
// 比如 render(){},可以另外实现

type TColumnFields = Record<'label' | 'key', string>[];

/**
 ** description: 构造可 合并单元格 的 column 配置(可选部分列)
 ** @params columnFields
 ** @params needMergeColumnName 如果不传,视为:所有列都支持合并单元格
 */
export const generateTableColumns = (
  columnFields: TColumnFields,
  needMergeColumnNames?: string[]
) => {
  const targetColumns = columnFields.map((item) => {
    const { label, key } = item;

    const isNeedMerge =
      !needMergeColumnNames?.length || needMergeColumnNames.includes(key);

    const columnConfig: TableColumnProps = {
      title: label,
      dataIndex: key,
      key: key,
      //render(text, record) {
      //	return record[key];
      //},
    };

    // 为每一列 设置 onCell 函数
    if (isNeedMerge) {
      columnConfig.onCell = (record, rowIndex) => {
        const typeStep = record.rowSpanConfig?.[key];
        return { rowSpan: typeStep };
      };
    }
    return columnConfig;
  });
  
  return targetColumns;
};

最关键的一环:getRowSpanArr

在 generateTableData 我们用到了一个工具方法:getRowSpanArr

它就是完成下面这步转化的关键:(图一竖着看是列,图二横着看是列,看前三列即可)

图一:

图二:

我们可以将这个问题抽象成:

统计一个数组中某个元素连续出现的次数,并且将其连续出现的次数记录到其首次出现位置的索引处,同时其他位置的索引置为 0。(该元素可重复出现,但只要不连续,下次再出现时,视作一个新的元素)

如何将 ['A', 'A', 'A', 'B', 'B', 'C', 'A', 'A', 'D', 'D', 'D'] 
转换为 [ 3,   0,   0,   2,   0,   1,   2,   0,   3,   0,   0 ]

我的方法是使用 双指针,并且因为在实际的需求中,原来的数组(图一)后续不需要再用到了,所以直接在原数组上进行转换即可。代码如下:

/**
 ** description: 遍历表格中的每一列,并按照其出现的值是否连续,转化为该行对应的 rowSpan 数量
 ** Ex: 以 ['A', 'A', 'A', 'B', 'B', 'C', 'A', 'A', 'D', 'D', 'D'] 为例,那么转化后应该是
 **        [ 3, 0, 0, 2, 0, 1, 2, 0, 3, 0, 0]
 */
type TGetRowSpanArr = (string | number)[];
export const getRowSpanArr = (arr: TGetRowSpanArr) => {
  // 指向 当前连续值 的指针
  let startIndex = 0,
    recordItem = '' as TGetRowSpanArr[number]; // 记录当前连续值,后续用以比较

  for (let i = 0; i < arr.length; i++) {
    const item = arr[i];
    // 因为 recordItem 初始值为 '', 所以首次遍历的时候也能进入这儿的逻辑
    if (recordItem !== item) {
      recordItem = item;
      startIndex = i;
      arr[startIndex] = 1;
    } else {
      arr[i] = 0;
      // 如果 recordItem === item 了,那么 arr[startIndex] 肯定就是 number 类型了
      (arr[startIndex] as number) += 1;
    }
  }
  return arr;
};

最终使用: