Skip to content

使用 Ant Design 的 Table 组件实现:同一列中相邻行数据相同时,自动合并单元格的功能 #19

@beichensky

Description

@beichensky

本文已收录在 Github: https://github.com/beichensky/Blog 中,欢迎 Star,欢迎 Follow!

前言

先简单介绍一下 当前需求组件库 Ant Design 中 Table 行合并的 API

  • 当前需求:在使用 Table 组件展示数据时,有一列或者多列,上下多个单元格需要进行合并,合并的规则是:相邻行单元格数据相同时,自动进行合并

  • Table 行合并的 API:column 的属性 onCell 接受一个函数,函数返回值的属性 rowSpan 会控制 Table 中单元格的展示

    1. 为 0 时,设置的单元格不会展示;

    2. 为 1 或者 undefined 时,设置的单元格正常展示;

    3. 大于 1 时,假如为 x,会合并当前行及当前行 以下、共 x 行 的单元格

    4. 因此在进行单元格合并时,若希望合并第 1 行到第 6 行,则第 1 行返回 rowSpan: 6,第 2 行到第 6 行都返回 rowSpan: 0

      const colSpanList = [6, 0, 0, 0, 0, 0]
      const columns = [
          {
              title: '年龄',
              dataIndex: 'age',
              onCell: (record, rowIndex) => {
                  return {
                      // 此时第 1 行数据会占据 6 行,第 2 行到 第 6 行展示,第 6 行之后会正常展示
                      rowSpan: colSpanList[rowIndex],
                  };
              },
          },
          // ...
      ]
  • 演示用的 Demo 文件内容:

    Demo.tsx

    import type { TableColumnType } from 'antd';
    import { Table } from 'antd';
    
    interface DataType {
        key: string;
        name: string;
        major: string;
        subject: string;
    }
    
    const columns: TableColumnType<DataType>[] = [
        {
            title: '姓名',
            dataIndex: 'name',
        },
        {
            title: '专业',
            dataIndex: 'major',
        },
        {
            title: '学科',
            dataIndex: 'subject',
        },
        {
            title: '操作',
            key: 'option',
            width: 100,
            render: () => {
                return <a>删除</a>;
            },
        },
    ];
    
    const dataSource: DataType[] = [
        {
            key: '1',
            name: 'Michael',
            major: '文科',
            subject: '语文',
        },
        {
            key: '2',
            name: 'Michael',
            major: '文科',
            subject: '历史',
        },
        {
            key: '3',
            name: 'Michael',
            major: '文科',
            subject: '地理',
        },
        {
            key: '4',
            name: 'Michael',
            major: '文科',
            subject: '数学',
        },
        {
            key: '5',
            name: 'Jack',
            major: '文科',
            subject: '数学',
        },
        {
            key: '6',
            name: 'Jack',
            major: '文科',
            subject: '语文',
        },
        {
            key: '7',
            name: 'Rose',
            major: '理科',
            subject: '物理',
        },
        {
            key: '8',
            name: 'Jack',
            major: '文科',
            subject: '英语',
        },
        {
            key: '9',
            name: 'Lily',
            major: '理科',
            subject: '英语',
        },
        {
            key: '10',
            name: 'Rose',
            major: '理科',
            subject: '物理',
        },
    ];
    
    function Demo() {
        return <Table columns={columns} dataSource={dataSource} />;
    }
    
    export default Demo;
    • 此时,页面中 Table 展示如下:
      初始展示效果

    • 需求预期的效果:【姓名】和 【专业】列,相邻行的数据相同时,期望自动合并对应行,操作列以人为维度,自动合并行,预期效果展示如下:
      预期展示效果

根据现在的需求,希望姓名和专业这两列,相邻行数据相同时,自动合并对应单元格;

下面我们书写代码,实现工具函数,修改 Table 所需要的 column 配置,来实现该需求

一、根据 DataSource 和 Columns 进行循环,当相邻行数据相同时,自动合并单元格

思路:

  • 给 column 添加类型定义:MergeTableColumnType,扩展 merge: boolean 属性,设置了 merge 属性的列,即认为需要支持自动行合并

  • 将所有需要合并的列筛选出来(根据 merge 属性进行过滤),找出对应列的 dataIndex

  • 声明一个 Map,用来存储当前列的 dataIndex 和 rowSpan 的列表(合并长度)

    • 举个例子:列的 dataIndex 作为 Map 的 key,Map 的 value 为 rowSpan 列表,rowSpan 列表长度 === DataSource 长度,例如:[4, 0, 0, 0, 2, 0],那么此时,该列的前四行会合并,后两行会合并
  • 对比相邻行对应 dataIndex 的值

    • 如果相同,找到 Map 中对应列的 rowSpan 列表,将当前行对应下标的值设置为 0,继续向下遍历

    • 如果不同,则将当前行对应下标的值设置为 0,从 rowSpan 列表中向前查找,找到前一个不为 0 的值的下标为止,假设为 x,将查找过程中的长度赋值给下标 x + 1 的元素即可

    • 如果没有找到,则将该值设置为 1

添加 table.ts 文件

import type { TableColumnType } from 'antd';
import { findLastIndex, get, isEqual } from 'lodash';

export interface MergeTableColumnType<T> extends TableColumnType<T> {
    merge?: boolean;
}

export const getColumnsByMerge = <T>(columns: MergeTableColumnType<T>[], dataSource: T[]): TableColumnType<T>[] => {
    const rowSpanMap = new Map<string | number | readonly (string | number)[], number[]>();

    const mergeKeyList = columns.filter((col) => !!col.merge).map((col) => col.dataIndex || '');
    const dataLength = dataSource?.length ?? 0;
    const mergeKeyLength = mergeKeyList.length ?? 0;

    // 遍历数据源,找到需要合并的列
    for (let sourceIndex = 0; sourceIndex < dataLength; sourceIndex += 1) {
        const currentData = dataSource[sourceIndex];
        const nextData = dataSource[sourceIndex + 1];

        // 遍历需要合并的列,此处的 key 相当于是 column 中配置的 dataIndex
        for (let keyIndex = 0; keyIndex < mergeKeyLength; keyIndex += 1) {
            const key = mergeKeyList[keyIndex]!;
            // 当前行的数据和下一行的数据对应 key 的 值 相同时,需要合并
            //    用 isEqual 的原因是,对象类型的数据,只要内容相同,也算是值相同
            if (currentData && nextData && isEqual(get(currentData, key), get(nextData, key))) {
                // 如果map中没有这个key,就初始化数组,第一个值为0,有这个 key ,则追加 0
                if (rowSpanMap.has(key)) {
                    rowSpanMap.set(key, [...rowSpanMap.get(key)!, 0]);
                } else {
                    rowSpanMap.set(key, [0]);
                }
            }
            // 如果map中有这个key,且当前行和下一行的数据对应属性 的 值 不相同,需要计算合并的行数
            else if (rowSpanMap.has(key)) {
                // 获取到当前 key 已经存储的 rowSpan 数组
                const lastRowSpanList = rowSpanMap.get(key) || [];
                /**
                 * 找最后一个不为0的下标
                 *  如果没有找到,说明从第一行开始就需要合并
                 *  找到了,就从这个下标开始,合并到当前行
                 * 举两个例子详细说明下:
                 *  1. 第 1 行到第 3 行需要合并,此时是第三行,那么 lastRowSpanList 现在是 [0, 0],需要修改的下标就是 0,
                 *      修改后是 [3, 0],然后再把当前行的 0 追加进去,变成 [3, 0, 0]
                 *  2. 第 4 行到第 6 行需要合并,此时是第六行,那么 lastRowSpanList 现在是 [3, 0, 0, 0, 0],
                 *      需要修改的下标就是 3,修改后是 [3, 0, 0, 3, 0],然后再把当前行的 0 追加进去,变成[3, 0, 0, 3, 0, 0]
                 */
                const notZeroIndex = findLastIndex(lastRowSpanList, (item) => item !== 0);
                const preMergeLength = lastRowSpanList[notZeroIndex] || 0;
                /**
                 * 需要修改的下标的计算方式:
                 *  1. 如果没有找到不为 0 的下标,说明从第一行开始就需要合并,那么需要修改值的下标就是 0
                 *  2. 如果找到了不为 0 的下标,假设为 x,那么此时,该下标对应的值为上次合并的行数 y,要修改值的下标就是 x + y
                 */
                const mergeStartIdx = notZeroIndex === -1 ? 0 : notZeroIndex + preMergeLength;

                /**
                 * 可能会出现这种情况:
                 *  第 6 行的数据和第 10 行的数据对应属性的值相同,但是这两行中间的行对应的值并不同,此时是不需要合并的。
                 *  比如此时的 lastRowSpanList 为 :[4, 0, 0, 0, 1, 1, 2, 0, 1],第 10 行对应的值和第 6 行对应的值相同,
                 *      所以进入 rowSpanMap.has(key) 这个判断里
                 *  按照上述逻辑,找到最后一个不为 0 的下标,是 8,那么需要修改的下标就是 8 + 1 = 9,
                 *      所以 mergeStartIdx = 9,这个值明显是不对的。
                 *
                 *  上面的情况下,mergeStartIdx 必定是大于 lastRowSpanList.length 的。所以下方做了判断:
                 *      mergeStartIdx >= lastRowSpanList.length 时,并不需要合并,则向数组中追加 1 即可
                 */
                if (mergeStartIdx < lastRowSpanList.length) {
                    // + 1 是因为数组最后还会追加一个 0
                    lastRowSpanList[mergeStartIdx] = lastRowSpanList.length - mergeStartIdx + 1;
                    rowSpanMap.set(key, [...lastRowSpanList, 0]);
                } else {
                    rowSpanMap.set(key, [...lastRowSpanList, 1]);
                }
            } else {
                // 如果当前行和下一行的数据对应属性的 值 不相同,且map中没有这个 key,就初始化数组,第一个值为1
                rowSpanMap.set(key, [1]);
            }
        }
    }

    const computedColumns: TableColumnType<T>[] = columns.map((column) => {
        if (rowSpanMap.has(column.dataIndex || '')) {
            // 根据 dataIndex 从 rowSpanMap 找到对应的 rowSpanList,在下方的 onCell 中会用到
            const rowSpanList = rowSpanMap.get(column.dataIndex || '') || [];
            return {
                ...column,
                onCell: (record, rowIndex) => {
                    /**
                     * 执行 columns 中已经存在的 onCell 方法,将返回值展开,再添加 rowSpan 属性
                     */
                    return {
                        ...column?.onCell?.(record, rowIndex),
                        rowSpan: rowSpanList[rowIndex ?? -1],
                    };
                },
            };
        }
        return column;
    });

    return computedColumns;
};

修改 Demo 文件中的代码,调用 getColumnsByMerge 函数以支持行合并:

Demo.tsx

import { Table } from 'antd';

+ import { type MergeTableColumnType, getColumnsByMerge } from './table';

interface DataType {
    key: string;
    name: string;
    major: string;
    subject: string;
}

+ const columns: MergeTableColumnType<DataType>[] = [
    {
        title: '姓名',
        dataIndex: 'name',
+        merge: true,
    },
    {
        title: '专业',
        dataIndex: 'major',
+        merge: true,
    },
    {
        title: '学科',
        dataIndex: 'subject',
    },
    {
        title: '操作',
        key: 'option',
        width: 100,
        render: () => {
            return <a>删除</a>;
        },
    },
];

const dataSource: DataType[] = [
    {
        key: '1',
        name: 'Michael',
        major: '文科',
        subject: '语文',
    },
    {
        key: '2',
        name: 'Michael',
        major: '文科',
        subject: '历史',
    },
    {
        key: '3',
        name: 'Michael',
        major: '文科',
        subject: '地理',
    },
    {
        key: '4',
        name: 'Michael',
        major: '文科',
        subject: '数学',
    },
    {
        key: '5',
        name: 'Jack',
        major: '文科',
        subject: '数学',
    },
    {
        key: '6',
        name: 'Jack',
        major: '文科',
        subject: '语文',
    },
    {
        key: '7',
        name: 'Rose',
        major: '理科',
        subject: '物理',
    },
    {
        key: '8',
        name: 'Jack',
        major: '文科',
        subject: '英语',
    },
    {
        key: '9',
        name: 'Lily',
        major: '理科',
        subject: '英语',
    },
    {
        key: '10',
        name: 'Rose',
        major: '理科',
        subject: '物理',
    },
];

function Demo() {
+    return <Table columns={getColumnsByMerge(columns, dataSource)} dataSource={dataSource} />;
}

export default Demo;

首次行合并

此时,为【姓名】 和 【专业】 列添加 merge 属性为 true,相同的数据,单元格自动合并了。

但是上面的找非 0 数据的逻辑,每次都要循环往前找(findLastIndex),有些复杂。如果能直接获取【需要合并的行的起始下标】,是不是会更好些呢?

二、优化对应列从 rowSpanList 中向前查找非 0 下标的逻辑

思路:

  • 使用一个值用来记录当前列需要合并的行的起始下标,这样后面就不需要再循环向前找非 0 下标,直接修改就即可

  • 由于外层循环是 dataSource,执行当前列的计算逻辑时,前一列的 rowSpanList 还没有全部计算出来,所以需要用一个数组,来存储所有列【需要合并的行的起始下标】

修改后的代码如下:

table.ts

import type { TableColumnType } from 'antd';
import { get, isEqual } from 'lodash';

export interface MergeTableColumnType<T> extends TableColumnType<T> {
    merge?: boolean;
}

export const getColumnsByMerge = <T>(columns: MergeTableColumnType<T>[], dataSource: T[]): TableColumnType<T>[] => {
    const rowSpanMap = new Map<string | number | readonly (string | number)[], number[]>();

    const mergeKeyList = columns.filter((col) => !!col.merge).map((col) => col.dataIndex || '');
    const dataLength = dataSource?.length ?? 0;
    const mergeKeyLength = mergeKeyList.length ?? 0;

    // 首先就进行初始化,根据需要合并的行,先生成需要合并行的起始下标的数组,内部值全部初始化为 -1
+   const mergeStartIndexList = mergeKeyList.map(() => -1);

    // 遍历数据源,找到需要合并的列
    for (let sourceIndex = 0; sourceIndex < dataLength; sourceIndex += 1) {
        const currentData = dataSource[sourceIndex];
        const nextData = dataSource[sourceIndex + 1];

        // 遍历需要合并的列,此处的 key 相当于是 column 中配置的 dataIndex
        for (let keyIndex = 0; keyIndex < mergeKeyLength; keyIndex += 1) {
            const key = mergeKeyList[keyIndex]!;
+            const mergeStartIdx = mergeStartIndexList[keyIndex] ?? -1;
            // 获取到当前 key 已经存储的 rowSpan 数组
            const lastRowSpanList = rowSpanMap.get(key) || [];

            if (currentData && nextData && isEqual(get(currentData, key), get(nextData, key))) {
                /**
                 * 当前行的数据和下一行的数据对应 key 的 值 相同时,需要合并,则向 rowSpanList 中追加 0 即可
                 *
                 * 此时找到当前列对应的需要合并的行的起始下标,将其保存到 keyIndexList 中
                 *  - 如果 mergeStartIdx 为 -1,说明当前行是第一行,直接将当前数据源的下标保存到 keyIndexList 中
                 *  - 如果 mergeStartIdx 不为 -1,说明当前行和上一行的数据相同,则不做修改
                 */
+                mergeStartIndexList[keyIndex] = mergeStartIdx === -1 ? sourceIndex : mergeStartIdx;
                rowSpanMap.set(key, [...lastRowSpanList, 0]);
+            } else if (rowSpanMap.has(key) && mergeStartIdx !== -1) {
                /**
                 * 如果 Map 中存在了这个key,且确实存在需要和当前行合并的上一行时,将当前行合并到上一行
                 */
+                lastRowSpanList[mergeStartIdx] = sourceIndex - mergeStartIdx + 1;
                rowSpanMap.set(key, [...lastRowSpanList, 0]);
                // 当前行的数据和下一行的数据对应 key 的值 不相同 时,将 keyIndexList 中当前列对应的值重置为 -1,方便下次使用
+                mergeStartIndexList[keyIndex] = -1;
            } else {
                /**
                 * 如果当前行和下一行的数据不相同,且 Map 中不存在这个 key,
                 *  或者存在这个 key 但是没有需要合并的上一行时,rowSpanList 中追加 1
                 */
                rowSpanMap.set(key, [...lastRowSpanList, 1]);
            }
        }
    }

    const computedColumns: TableColumnType<T>[] = columns.map((column) => {
        if (rowSpanMap.has(column.dataIndex || '')) {
            // 根据 dataIndex 从 rowSpanMap 找到对应的 rowSpanList,在下方的 onCell 中会用到
            const rowSpanList = rowSpanMap.get(column.dataIndex || '') || [];
            return {
                ...column,
                onCell: (record, rowIndex) => {
                    /**
                     * 执行 columns 中已经存在的 onCell 方法,将返回值展开,再添加 rowSpan 属性
                     */
                    return {
                        ...column?.onCell?.(record, rowIndex),
                        rowSpan: rowSpanList[rowIndex ?? -1],
                    };
                },
            };
        }
        return column;
    });

    return computedColumns;
};

二次行合并

此时展示效果是相同的。但美中不足的地方是:需要单独用一个数组来承载需要合并的起始行的下标。

有没有办法只使用一个变量就能记录全部列的【需要合并的起始行的下标】呢?

三、使用变量 mergeStartIdx,来取代 mergeStartIndexList 数组,进一步优化【查找需要合并的起始下标】的逻辑

思路:

  • 修改循环嵌套的顺序,先循环需要 merge 的 columnList,再循环 dataSource 数据源,这样可以确保每一列的所有合并数据计算结束,才会计算下一列

优化后修改后的代码
table.ts

import type { TableColumnType } from 'antd';
import { get, isEqual } from 'lodash';

export interface MergeTableColumnType<T> extends TableColumnType<T> {
    merge?: boolean;
}

export const getColumnsByMerge = <T>(columns: MergeTableColumnType<T>[], dataSource: T[]): TableColumnType<T>[] => {
    const rowSpanMap = new Map<string | number | readonly (string | number)[], number[]>();

    const mergeKeyList = columns.filter((col) => !!col.merge).map((col) => col.dataIndex || '');
    const dataLength = dataSource?.length ?? 0;
    const mergeKeyLength = mergeKeyList.length ?? 0;

    // 遍历需要合并的列,此处的 key 相当于是 column 中配置的 dataIndex
+    for (let keyIndex = 0; keyIndex < mergeKeyLength; keyIndex += 1) {
+        const key = mergeKeyList[keyIndex]!;

        // 需要合并行的起始下标,默认值为 -1
+        let mergeStartIdx = -1;

        // 遍历数据源,找到需要合并的列
+        for (let sourceIndex = 0; sourceIndex < dataLength; sourceIndex += 1) {
+            const currentData = dataSource[sourceIndex];
+            const nextData = dataSource[sourceIndex + 1];

            // 获取到当前 key 已经存储的 rowSpan 数组
            const lastRowSpanList = rowSpanMap.get(key) || [];

            if (currentData && nextData && isEqual(get(currentData, key), get(nextData, key))) {
                /**
                 * 当前行的数据和下一行的数据对应 key 的 值 相同时,需要合并,则向 rowSpanList 中追加 0 即可
                 *
                 * 此时找到当前列对应的需要合并的行的起始下标,将其保存到 keyIndexList 中
                 *  - 如果 mergeStartIdx 为 -1,说明当前行是第一行,直接将当前数据源的下标保存到 keyIndexList 中
                 *  - 如果 mergeStartIdx 不为 -1,说明当前行和上一行的数据相同,则不做修改
                 */
+                mergeStartIdx = mergeStartIdx === -1 ? sourceIndex : mergeStartIdx;
                rowSpanMap.set(key, [...lastRowSpanList, 0]);
+            } else if (rowSpanMap.has(key) && mergeStartIdx !== -1) {
                /**
                 * 如果 Map 中存在了这个key,且确实存在需要和当前行合并的上一行时,将当前行合并到上一行
                 */
                lastRowSpanList[mergeStartIdx] = sourceIndex - mergeStartIdx + 1;
                rowSpanMap.set(key, [...lastRowSpanList, 0]);
                // 当前行的数据和下一行的数据对应 key 的值 不相同 时,将 keyIndexList 中当前列对应的值重置为 -1,方便下次使用
+                mergeStartIdx = -1;
            } else {
                /**
                 * 如果当前行和下一行的数据不相同,且 Map 中不存在这个 key,
                 *  或者存在这个 key 但是没有需要合并的上一行时,rowSpanList 中追加 1
                 */
                rowSpanMap.set(key, [...lastRowSpanList, 1]);
            }
        }
    }

    const computedColumns: TableColumnType<T>[] = columns.map((column) => {
        if (rowSpanMap.has(column.dataIndex || '')) {
            // 根据 dataIndex 从 rowSpanMap 找到对应的 rowSpanList,在下方的 onCell 中会用到
            const rowSpanList = rowSpanMap.get(column.dataIndex || '') || [];
            return {
                ...column,
                onCell: (record, rowIndex) => {
                    /**
                     * 执行 columns 中已经存在的 onCell 方法,将返回值展开,再添加 rowSpan 属性
                     */
                    return {
                        ...column?.onCell?.(record, rowIndex),
                        rowSpan: rowSpanList[rowIndex ?? -1],
                    };
                },
            };
        }
        return column;
    });

    return computedColumns;
};

此时,修改后的展示效果也是完全相同的:
优化后行合并

至此,表格的合并逻辑书写完成。但功能还可以继续扩展:

  1. merge 的规则可以支持自定义,比如:只要是同一个人的不同昵称,也算是同一个人;

  2. 专业列进行合并行的时候,同一个人的专业相同时才能合并,不是同一个人,即使专业相同,也不合并

  3. 某些操作或者占位的列,是可以不设置 dataIndex 的,这时仅使用 dataIndex 作为 Map 的 key,就不唯一了,会导致展示有问题。

四、扩展功能一:支持数据的自定义合并规则

思路:

  • 修改 merge 属性的类型定义,支持接收自定义对比函数

  • 修改相邻行数据是否相同的对比规则

  • 筛选出需要 merge 的列之后,直接使用,不再重新 map 生成新的数组

修改之后的代码:

table.ts

import type { TableColumnType } from 'antd';
+ import { get, isEqual, isFunction, isNil } from 'lodash';

export interface MergeTableColumnType<T> extends TableColumnType<T> {
+    merge?: boolean | ((currentData?: T, nextData?: T) => boolean);
}

+const compareWithDataIndex = <T>(mergeColumn: MergeTableColumnType<T>, currentData?: T, nextData?: T) => {
+    if (!mergeColumn) {
+        return false;
+    }
+    /**
+     * 其中一个为 null 或者 undefined 时,且上下两行数据不相等时,一定是不会合并的
+     *  下面的判断同 (isNil(currentData) && !isNil(nextData)) || (!isNil(currentData) && isNil(nextData))
+     */
+    if (currentData !== nextData && (isNil(currentData) || isNil(nextData))) {
+        return false;
+    }
+    const { merge, dataIndex = '' } = mergeColumn;
+    const isSame = isFunction(merge) ? merge(currentData, nextData) : isEqual(get(currentData, dataIndex), get(nextData, dataIndex));
+
+    return isSame;
+};

export const getColumnsByMerge = <T>(columns: MergeTableColumnType<T>[], dataSource: T[]): TableColumnType<T>[] => {
    const rowSpanMap = new Map<string | number | readonly (string | number)[], number[]>();

+    const mergeColumnList = columns.filter((col) => !!col.merge);
    const dataLength = dataSource?.length ?? 0;
+    const mergeColumnLength = mergeColumnList.length ?? 0;

    // 遍历需要合并的列,此处的 key 相当于是 column 中配置的 dataIndex
    for (let keyIndex = 0; keyIndex < mergeColumnLength; keyIndex += 1) {
+        const mergeColumn = mergeColumnList[keyIndex];

+        const key = mergeColumn.dataIndex ?? '';

        // 需要合并行的起始下标,默认值为 -1
        let mergeStartIdx = -1;

        // 遍历数据源,找到需要合并的列
        for (let sourceIndex = 0; sourceIndex < dataLength; sourceIndex += 1) {
            const currentData = dataSource[sourceIndex];
            const nextData = dataSource[sourceIndex + 1];

            // 获取到当前 key 已经存储的 rowSpan 数组
            const lastRowSpanList = rowSpanMap.get(key) || [];

+            const currentDataValueIsSame = compareWithDataIndex(mergeColumn, currentData, nextData);

+            if (currentDataValueIsSame) {
                /**
                 * 当前行的数据和下一行的数据对应 key 的 值 相同时,需要合并,则向 rowSpanList 中追加 0 即可
                 *
                 * 此时找到当前列对应的需要合并的行的起始下标,将其保存到 keyIndexList 中
                 *  - 如果 mergeStartIdx 为 -1,说明当前行是第一行,直接将当前数据源的下标保存到 keyIndexList 中
                 *  - 如果 mergeStartIdx 不为 -1,说明当前行和上一行的数据相同,则不做修改
                 */
                mergeStartIdx = mergeStartIdx === -1 ? sourceIndex : mergeStartIdx;
                rowSpanMap.set(key, [...lastRowSpanList, 0]);
            } else if (rowSpanMap.has(key) && mergeStartIdx !== -1) {
                /**
                 * 如果 Map 中存在了这个key,且确实存在需要和当前行合并的上一行时,将当前行合并到上一行
                 */
                lastRowSpanList[mergeStartIdx] = sourceIndex - mergeStartIdx + 1;
                rowSpanMap.set(key, [...lastRowSpanList, 0]);
                // 当前行的数据和下一行的数据对应 key 的值 不相同 时,将 keyIndexList 中当前列对应的值重置为 -1,方便下次使用
                mergeStartIdx = -1;
            } else {
                /**
                 * 如果当前行和下一行的数据不相同,且 Map 中不存在这个 key,
                 *  或者存在这个 key 但是没有需要合并的上一行时,rowSpanList 中追加 1
                 */
                rowSpanMap.set(key, [...lastRowSpanList, 1]);
            }
        }
    }

    const computedColumns: TableColumnType<T>[] = columns.map((column) => {
        if (rowSpanMap.has(column.dataIndex || '')) {
            // 根据 dataIndex 从 rowSpanMap 找到对应的 rowSpanList,在下方的 onCell 中会用到
            const rowSpanList = rowSpanMap.get(column.dataIndex || '') || [];
            return {
                ...column,
                onCell: (record, rowIndex) => {
                    /**
                     * 执行 columns 中已经存在的 onCell 方法,将返回值展开,再添加 rowSpan 属性
                     */
                    return {
                        ...column?.onCell?.(record, rowIndex),
                        rowSpan: rowSpanList[rowIndex ?? -1],
                    };
                },
            };
        }
        return column;
    });

    return computedColumns;
};

修改 Demo 中 【姓名】列的对比规则:名字相同或者名字中包含 c 的都认为是可以进行行合并的数据

Demo.tsx

import { Table } from 'antd';

import { type MergeTableColumnType, getColumnsByMerge } from './table';

interface DataType {
    key: string;
    name: string;
    major: string;
    subject: string;
}

const columns: MergeTableColumnType<DataType>[] = [
    {
        title: '姓名',
        dataIndex: 'name',
+        merge: (cur, next) => cur?.name === next?.name || !!(cur?.name?.includes('c') && next?.name?.includes('c')),
    },
    {
        title: '专业',
        dataIndex: 'major',
        merge: true,
    },
    {
        title: '学科',
        dataIndex: 'subject',
    },
    {
        title: '操作',
        key: 'option',
        width: 100,
        render: () => {
            return <a>删除</a>;
        },
    },
];

const dataSource: DataType[] = [
    {
        key: '1',
        name: 'Michael',
        major: '文科',
        subject: '语文',
    },
    {
        key: '2',
        name: 'Michael',
        major: '文科',
        subject: '历史',
    },
    {
        key: '3',
        name: 'Michael',
        major: '文科',
        subject: '地理',
    },
    {
        key: '4',
        name: 'Michael',
        major: '文科',
        subject: '数学',
    },
    {
        key: '5',
        name: 'Jack',
        major: '文科',
        subject: '数学',
    },
    {
        key: '6',
        name: 'Jack',
        major: '文科',
        subject: '语文',
    },
    {
        key: '7',
        name: 'Rose',
        major: '理科',
        subject: '物理',
    },
    {
        key: '8',
        name: 'Jack',
        major: '文科',
        subject: '英语',
    },
    {
        key: '9',
        name: 'Lily',
        major: '理科',
        subject: '英语',
    },
    {
        key: '10',
        name: 'Rose',
        major: '理科',
        subject: '物理',
    },
];

function Demo() {
    return <Table columns={getColumnsByMerge(columns, dataSource)} dataSource={dataSource} />;
}

export default Demo;

效果图:

自定义行合并规则

此时可以看到【姓名】列中,前 6 行里相邻的 Michael 和 Jack 数据合并在了一起。

五、扩展功能二:支持设置基准列,其他列以此列为基准,当该列中对应的行进行了合并,其他列才能合并,否则不会合并

  • 添加 mergeBased 属性,支持设置 boolean 和 number 类型

    • 为 true 和 number 时,该列会作为其他需要合并的列的基准列

    • number 可以调整该列作为基准列的顺序,多个基准列时,会按照顺序依次比较

    • number 类型的值优先级高于 boolean 类型值

table.ts

import type { TableColumnType } from 'antd';
import { get, isBoolean, isEqual, isFunction, isNil, isNumber } from 'lodash';

export interface MergeTableColumnType<T> extends TableColumnType<T> {
    merge?: boolean | ((currentData?: T, nextData?: T) => boolean);
    mergeBased?: boolean | number;
}

const compareWithDataIndex = <T>(mergeColumn: MergeTableColumnType<T>, currentData?: T, nextData?: T) => {
    if (!mergeColumn) {
        return false;
    }
    /**
     * 其中一个为 null 或者 undefined 时,且上下两行数据不相等时,一定是不会合并的
     *  下面的判断同 (isNil(currentData) && !isNil(nextData)) || (!isNil(currentData) && isNil(nextData))
     */
    if (currentData !== nextData && (isNil(currentData) || isNil(nextData))) {
        return false;
    }
    const { merge, dataIndex = '' } = mergeColumn;
    const isSame = isFunction(merge) ? merge(currentData, nextData) : isEqual(get(currentData, dataIndex), get(nextData, dataIndex));

    return isSame;
};

+const sortByMergeBased = <T>(a: MergeTableColumnType<T>, b: MergeTableColumnType<T>) => {
+    // 如果 a 的 mergeBased 是数字,b 的 mergeBased 是布尔值,则 a 排在前面
+    if (isNumber(a.mergeBased) && isBoolean(b.mergeBased)) {
+        return -1;
+    }
+    // 如果 b 的 mergeBased 是数字,a 的 mergeBased 是布尔值,则 b 排在前面
+    if (isBoolean(a.mergeBased) && isNumber(b.mergeBased)) {
+        return 1;
+    }
+
+    /**
+     * 上面两个判断,可以确保,设置了数字的 mergeBased 的列,无论大小,都排在没有设置数字的 mergeBased 的列前面
+     */
+
+   // 否则根据 mergeBased 的值进行排序
+   return Number(a.mergeBased || Number.MAX_VALUE) - Number(b.mergeBased || Number.MAX_VALUE);
+};

export const getColumnsByMerge = <T>(columns: MergeTableColumnType<T>[], dataSource: T[]): TableColumnType<T>[] => {
    const rowSpanMap = new Map<string | number | readonly (string | number)[], number[]>();

+    const mergeColumnList = columns.filter((col) => !!col.merge).sort(sortByMergeBased);
    const dataLength = dataSource?.length ?? 0;
    const mergeColumnLength = mergeColumnList.length ?? 0;

    // 遍历需要合并的列,此处的 key 相当于是 column 中配置的 dataIndex
    for (let keyIndex = 0; keyIndex < mergeColumnLength; keyIndex += 1) {
        const mergeColumn = mergeColumnList[keyIndex]!;

        const key = mergeColumn.dataIndex ?? '';

        // 需要合并行的起始下标,默认值为 -1
        let mergeStartIdx = -1;

        // 遍历数据源,找到需要合并的列
        for (let sourceIndex = 0; sourceIndex < dataLength; sourceIndex += 1) {
            const currentData = dataSource[sourceIndex];
            const nextData = dataSource[sourceIndex + 1];

            // 获取到当前 key 已经存储的 rowSpan 数组
            const lastRowSpanList = rowSpanMap.get(key) || [];

            const currentDataValueIsSame = compareWithDataIndex(mergeColumn, currentData, nextData);

+            /**
+             * 以之前所有 mergeBased 的列作为基准,判断当前行和上一行是否有相同的值
+             */
+            let preDataValueIsSame = true;
+            // 小优化:只有当前列的上下两行数据相同时,才会去判断之前的列是否相同
+            if (keyIndex > 0 && currentDataValueIsSame) {
+                const allLastKeySame = mergeColumnList
+                    .slice(0, keyIndex)
+                    .filter((mc) => !isNil(mc.mergeBased))
+                    .every((mc) => compareWithDataIndex(mc, currentData, nextData));
+                preDataValueIsSame = allLastKeySame;
+           }

+            if (currentDataValueIsSame && preDataValueIsSame) {
                /**
                 * 当前行的数据和下一行的数据对应 key 的 值 相同时,需要合并,则向 rowSpanList 中追加 0 即可
                 *
                 * 此时找到当前列对应的需要合并的行的起始下标,将其保存到 keyIndexList 中
                 *  - 如果 mergeStartIdx 为 -1,说明当前行是第一行,直接将当前数据源的下标保存到 keyIndexList 中
                 *  - 如果 mergeStartIdx 不为 -1,说明当前行和上一行的数据相同,则不做修改
                 */
                mergeStartIdx = mergeStartIdx === -1 ? sourceIndex : mergeStartIdx;
                rowSpanMap.set(key, [...lastRowSpanList, 0]);
            } else if (rowSpanMap.has(key) && mergeStartIdx !== -1) {
                /**
                 * 如果 Map 中存在了这个key,且确实存在需要和当前行合并的上一行时,将当前行合并到上一行
                 */
                lastRowSpanList[mergeStartIdx] = sourceIndex - mergeStartIdx + 1;
                rowSpanMap.set(key, [...lastRowSpanList, 0]);
                // 当前行的数据和下一行的数据对应 key 的值 不相同 时,将 keyIndexList 中当前列对应的值重置为 -1,方便下次使用
                mergeStartIdx = -1;
            } else {
                /**
                 * 如果当前行和下一行的数据不相同,且 Map 中不存在这个 key,
                 *  或者存在这个 key 但是没有需要合并的上一行时,rowSpanList 中追加 1
                 */
                rowSpanMap.set(key, [...lastRowSpanList, 1]);
            }
        }
    }

    const computedColumns: TableColumnType<T>[] = columns.map((column) => {
        if (rowSpanMap.has(column.dataIndex || '')) {
            // 根据 dataIndex 从 rowSpanMap 找到对应的 rowSpanList,在下方的 onCell 中会用到
            const rowSpanList = rowSpanMap.get(column.dataIndex || '') || [];
            return {
                ...column,
                onCell: (record, rowIndex) => {
                    /**
                     * 执行 columns 中已经存在的 onCell 方法,将返回值展开,再添加 rowSpan 属性
                     */
                    return {
                        ...column?.onCell?.(record, rowIndex),
                        rowSpan: rowSpanList[rowIndex ?? -1],
                    };
                },
            };
        }
        return column;
    });

    return computedColumns;
};

修改 Demo 代码,设置 【姓名】列为 基准列

Demo.tsx

import { Table } from 'antd';

import { type MergeTableColumnType, getColumnsByMerge } from './table';

interface DataType {
    key: string;
    name: string;
    major: string;
    subject: string;
}

const columns: MergeTableColumnType<DataType>[] = [
    {
        title: '姓名',
        dataIndex: 'name',
+        merge: true,
+        mergeBased: 1,
    },
    {
        title: '专业',
        dataIndex: 'major',
+        merge: true,
    },
    {
        title: '学科',
        dataIndex: 'subject',
    },
    {
        title: '操作',
        key: 'option',
        width: 100,
        render: () => {
            return <a>删除</a>;
        },
    },
];

const dataSource: DataType[] = [
    {
        key: '1',
        name: 'Michael',
        major: '文科',
        subject: '语文',
    },
    {
        key: '2',
        name: 'Michael',
        major: '文科',
        subject: '历史',
    },
    {
        key: '3',
        name: 'Michael',
        major: '文科',
        subject: '地理',
    },
    {
        key: '4',
        name: 'Michael',
        major: '文科',
        subject: '数学',
    },
    {
        key: '5',
        name: 'Jack',
        major: '文科',
        subject: '数学',
    },
    {
        key: '6',
        name: 'Jack',
        major: '文科',
        subject: '语文',
    },
    {
        key: '7',
        name: 'Rose',
        major: '理科',
        subject: '物理',
    },
    {
        key: '8',
        name: 'Jack',
        major: '文科',
        subject: '英语',
    },
    {
        key: '9',
        name: 'Lily',
        major: '理科',
        subject: '英语',
    },
    {
        key: '10',
        name: 'Rose',
        major: '理科',
        subject: '物理',
    },
];

function Demo() {
    return <Table columns={getColumnsByMerge(columns, dataSource)} dataSource={dataSource} />;
}

export default Demo;

可以看到,之前的前 6 行 文科 进行了合并,现在【姓名】列添加 mergeBased 属性后,前 4 行和 5、6 行分开合并了。
效果图:

设置基准列

目前基本逻辑已经完成。

但是在查找 mergeBased 列时,判断前置 mergeBased 列是否已经合并的逻辑,稍显复杂,需要循环进行判断。

尝试进行以下优化。

5.1、优化1:修改前置 mergeBased 列当前行是否可以和下一行进行合并 的判断逻辑

  • 思路: 不再针对前置的所有基准列进行 currentData 和 nextData 对比,只针对前一个基准列进行判断即可

    • 前一个基准列相邻行是合并的,再判断当前列相邻行是否合并即可

    • 循环向前查找,一直找到前一个基准列为止,判断该基准列 currentData 和 nextData 对应属性的值是否相同即可

table.ts

import type { TableColumnType } from 'antd';
import { get, isBoolean, isEqual, isFunction, isNil, isNumber } from 'lodash';

export interface MergeTableColumnType<T> extends TableColumnType<T> {
    merge?: boolean | ((currentData?: T, nextData?: T) => boolean);
    mergeBased?: boolean | number;
}

const compareWithDataIndex = <T>(mergeColumn: MergeTableColumnType<T>, currentData?: T, nextData?: T) => {
    if (!mergeColumn) {
        return false;
    }
    /**
     * 其中一个为 null 或者 undefined 时,且上下两行数据不相等时,一定是不会合并的
     *  下面的判断同 (isNil(currentData) && !isNil(nextData)) || (!isNil(currentData) && isNil(nextData))
     */
    if (currentData !== nextData && (isNil(currentData) || isNil(nextData))) {
        return false;
    }
    const { merge, dataIndex = '' } = mergeColumn;
    const isSame = isFunction(merge)
        ? merge(currentData, nextData)
        : isEqual(get(currentData, dataIndex), get(nextData, dataIndex));

    return isSame;
};

const sortByMergeBased = <T>(a: MergeTableColumnType<T>, b: MergeTableColumnType<T>) => {
    // 如果 a 的 mergeBased 是数字,b 的 mergeBased 是布尔值,则 a 排在前面
    if (isNumber(a.mergeBased) && isBoolean(b.mergeBased)) {
        return -1;
    }
    // 如果 b 的 mergeBased 是数字,a 的 mergeBased 是布尔值,则 b 排在前面
    if (isBoolean(a.mergeBased) && isNumber(b.mergeBased)) {
        return 1;
    }

    /**
     * 上面两个判断,可以确保,设置了数字的 mergeBased 的列,无论大小,都排在没有设置数字的 mergeBased 的列前面
     */

    // 否则根据 mergeBased 的值进行排序
    return Number(a.mergeBased || Number.MAX_VALUE) - Number(b.mergeBased || Number.MAX_VALUE);
};

export const getColumnsByMerge = <T>(columns: MergeTableColumnType<T>[], dataSource: T[]): TableColumnType<T>[] => {
    const rowSpanMap = new Map<string | number | readonly (string | number)[], number[]>();

    const mergeColumnList = columns.filter((col) => !!col.merge).sort(sortByMergeBased);
    const dataLength = dataSource?.length ?? 0;
    const mergeColumnLength = mergeColumnList.length ?? 0;

    // 遍历需要合并的列,此处的 key 相当于是 column 中配置的 dataIndex
    for (let keyIndex = 0; keyIndex < mergeColumnLength; keyIndex += 1) {
        const mergeColumn = mergeColumnList[keyIndex]!;

        const key = mergeColumn.dataIndex ?? '';

        // 需要合并行的起始下标,默认值为 -1
        let mergeStartIdx = -1;

        // 遍历数据源,找到需要合并的列
        for (let sourceIndex = 0; sourceIndex < dataLength; sourceIndex += 1) {
            const currentData = dataSource[sourceIndex];
            const nextData = dataSource[sourceIndex + 1];

            // 获取到当前 key 已经存储的 rowSpan 数组
            const lastRowSpanList = rowSpanMap.get(key) || [];

            const currentDataValueIsSame = compareWithDataIndex(mergeColumn, currentData, nextData);

            /**
             * 以之前所有 mergeBased 的列作为基准,判断当前行和上一行是否有相同的值
             */
            let preDataValueIsSame = true;
            // 小优化:只有当前列的上下两行数据相同时,才会去判断之前的列是否相同
            if (keyIndex > 0 && currentDataValueIsSame) {
+                let lastIdx = keyIndex - 1;
+                while (lastIdx >= 0) {
+                    const lastMergeColumn = mergeColumnList[lastIdx];
+                    if (lastMergeColumn.mergeBased || lastMergeColumn.mergeBased === 0) {
+                        const lastColumnRowSpanList = rowSpanMap.get(lastMergeColumn.dataIndex || '') || [];
+                        // 前一列的当前行和下一行数据相同时,preDataValueIsSame 值为 true
+                        preDataValueIsSame = lastColumnRowSpanList[sourceIndex + 1] === 0;
+                        // 找到前一列为 基准列时,进行判断后,即可退出循环
+                        break;
+                    }
+                    lastIdx -= 1;
+                }
            }

            if (currentDataValueIsSame && preDataValueIsSame) {
                /**
                 * 当前行的数据和下一行的数据对应 key 的 值 相同时,需要合并,则向 rowSpanList 中追加 0 即可
                 *
                 * 此时找到当前列对应的需要合并的行的起始下标,将其保存到 keyIndexList 中
                 *  - 如果 mergeStartIdx 为 -1,说明当前行是第一行,直接将当前数据源的下标保存到 keyIndexList 中
                 *  - 如果 mergeStartIdx 不为 -1,说明当前行和上一行的数据相同,则不做修改
                 */
                mergeStartIdx = mergeStartIdx === -1 ? sourceIndex : mergeStartIdx;
                rowSpanMap.set(key, [...lastRowSpanList, 0]);
            } else if (rowSpanMap.has(key) && mergeStartIdx !== -1) {
                /**
                 * 如果 Map 中存在了这个key,且确实存在需要和当前行合并的上一行时,将当前行合并到上一行
                 */
                lastRowSpanList[mergeStartIdx] = sourceIndex - mergeStartIdx + 1;
                rowSpanMap.set(key, [...lastRowSpanList, 0]);
                // 当前行的数据和下一行的数据对应 key 的值 不相同 时,将 keyIndexList 中当前列对应的值重置为 -1,方便下次使用
                mergeStartIdx = -1;
            } else {
                /**
                 * 如果当前行和下一行的数据不相同,且 Map 中不存在这个 key,
                 *  或者存在这个 key 但是没有需要合并的上一行时,rowSpanList 中追加 1
                 */
                rowSpanMap.set(key, [...lastRowSpanList, 1]);
            }
        }
    }

    const computedColumns: TableColumnType<T>[] = columns.map((column) => {
        if (rowSpanMap.has(column.dataIndex || '')) {
            // 根据 dataIndex 从 rowSpanMap 找到对应的 rowSpanList,在下方的 onCell 中会用到
            const rowSpanList = rowSpanMap.get(column.dataIndex || '') || [];
            return {
                ...column,
                onCell: (record, rowIndex) => {
                    /**
                     * 执行 columns 中已经存在的 onCell 方法,将返回值展开,再添加 rowSpan 属性
                     */
                    return {
                        ...column?.onCell?.(record, rowIndex),
                        rowSpan: rowSpanList[rowIndex ?? -1],
                    };
                },
            };
        }
        return column;
    });

    return computedColumns;
};

效果图:

设置基准列2

查看 Demo 页面,可以看到效果不变,说明优化后的代码,逻辑是正常的

5.2、优化2:优化前置 mergeBased 列当前行是否可以和下一行进行合并 的判断逻辑

进行优化 1 的修改后,确实不需要每个都判断了,但是还是要向前循环查找前一个基准列。

有没有办法可以不循环,直接就能知道前一基准列是否已经行合并了呢?

思路:

  • 在获取 mergeColumnList 时,已经对具备 mergeBased 的列进行了排序,因此设置了 mergeBased 的列一定是在未设置的列之前

  • 修改 dataSource 和 mergeColumnList 数组循环的顺序。先循环 dataSource,就可以确认,同一行数据,前一列是否进行过行合并

  • 由于之前先循环 dataSource 时,想找到 mergeStartIndex,需要用一个数组来存储所有列的 mergeStartIndex,所以这段逻辑,需要还原

table.js:

import type { TableColumnType } from 'antd';
import { get, isBoolean, isEqual, isFunction, isNil, isNumber } from 'lodash';

export interface MergeTableColumnType<T> extends TableColumnType<T> {
    merge?: boolean | ((currentData?: T, nextData?: T) => boolean);
    mergeBased?: boolean | number;
}

const compareWithDataIndex = <T>(mergeColumn: MergeTableColumnType<T>, currentData?: T, nextData?: T) => {
    if (!mergeColumn) {
        return false;
    }
    /**
     * 其中一个为 null 或者 undefined 时,且上下两行数据不相等时,一定是不会合并的
     *  下面的判断同 (isNil(currentData) && !isNil(nextData)) || (!isNil(currentData) && isNil(nextData))
     */
    if (currentData !== nextData && (isNil(currentData) || isNil(nextData))) {
        return false;
    }
    const { merge, dataIndex = '' } = mergeColumn;
    const isSame = isFunction(merge)
        ? merge(currentData, nextData)
        : isEqual(get(currentData, dataIndex), get(nextData, dataIndex));

    return isSame;
};

const sortByMergeBased = <T>(a: MergeTableColumnType<T>, b: MergeTableColumnType<T>) => {
    // 如果 a 的 mergeBased 是数字,b 的 mergeBased 是布尔值,则 a 排在前面
    if (isNumber(a.mergeBased) && isBoolean(b.mergeBased)) {
        return -1;
    }
    // 如果 b 的 mergeBased 是数字,a 的 mergeBased 是布尔值,则 b 排在前面
    if (isBoolean(a.mergeBased) && isNumber(b.mergeBased)) {
        return 1;
    }

    /**
     * 上面两个判断,可以确保,设置了数字的 mergeBased 的列,无论大小,都排在没有设置数字的 mergeBased 的列前面
     */

    // 否则根据 mergeBased 的值进行排序
    return Number(a.mergeBased || Number.MAX_VALUE) - Number(b.mergeBased || Number.MAX_VALUE);
};

export const getColumnsByMerge = <T>(columns: MergeTableColumnType<T>[], dataSource: T[]): TableColumnType<T>[] => {
    const rowSpanMap = new Map<string | number | readonly (string | number)[], number[]>();

    const mergeColumnList = columns.filter((col) => !!col.merge).sort(sortByMergeBased);
    const dataLength = dataSource?.length ?? 0;
    const mergeColumnLength = mergeColumnList.length ?? 0;

+    // 首先就进行初始化,根据需要合并的行,先生成需要合并行的起始下标的数组,内部值全部初始化为 -1
+    const mergeStartIndexList = mergeColumnList.map(() => -1);

+    // 遍历数据源,找到需要合并的列
+    for (let sourceIndex = 0; sourceIndex < dataLength; sourceIndex += 1) {
+        const currentData = dataSource[sourceIndex];
+        const nextData = dataSource[sourceIndex + 1];

+        /**
+         * 上一基准列是否相同,默认值为 true
+         */
+        let preDataValueIsSame = true;

+        // 遍历需要合并的列,此处的 key 相当于是 column 中配置的 dataIndex
+        for (let keyIndex = 0; keyIndex < mergeColumnLength; keyIndex += 1) {
+            const mergeColumn = mergeColumnList[keyIndex]!;

+            const key = mergeColumn.dataIndex ?? '';

+            // 需要合并行的起始下标,默认值为 -1
+            const mergeStartIdx = mergeStartIndexList[keyIndex] ?? -1;

            // 获取到当前 key 已经存储的 rowSpan 数组
            const lastRowSpanList = rowSpanMap.get(key) || [];

+            // 小优化:前一个基准列当前行和下一行数据相同时,再计算当前列是否需要合并
+            const currentAndPreDataIsSame = preDataValueIsSame && compareWithDataIndex(mergeColumn, currentData, nextData);

+            if (currentAndPreDataIsSame) {
                /**
                 * 当前行的数据和下一行的数据对应 key 的 值 相同时,需要合并,则向 rowSpanList 中追加 0 即可
                 *
                 * 此时找到当前列对应的需要合并的行的起始下标,将其保存到 keyIndexList 中
                 *  - 如果 mergeStartIdx 为 -1,说明当前行是第一行,直接将当前数据源的下标保存到 keyIndexList 中
                 *  - 如果 mergeStartIdx 不为 -1,说明当前行和上一行的数据相同,则不做修改
                 */
+                mergeStartIndexList[keyIndex] = mergeStartIdx === -1 ? sourceIndex : mergeStartIdx;
                rowSpanMap.set(key, [...lastRowSpanList, 0]);
            } else if (rowSpanMap.has(key) && mergeStartIdx !== -1) {
                /**
                 * 如果 Map 中存在了这个key,且确实存在需要和当前行合并的上一行时,将当前行合并到上一行
                 */
                lastRowSpanList[mergeStartIdx] = sourceIndex - mergeStartIdx + 1;
                rowSpanMap.set(key, [...lastRowSpanList, 0]);
                // 当前行的数据和下一行的数据对应 key 的值 不相同 时,将 keyIndexList 中当前列对应的值重置为 -1,方便下次使用
+                mergeStartIndexList[keyIndex] = -1;

+                /**
+                 * 如果当前列是基准列,并且当前行和下一行的数据对应 key 的值不相同,preDataValueIsSame 值为 false
+                 */
+                if (mergeColumn.mergeBased || mergeColumn.mergeBased === 0) {
+                    preDataValueIsSame = false;
+                }
            } else {
                /**
                 * 如果当前行和下一行的数据不相同,且 Map 中不存在这个 key,
                 *  或者存在这个 key 但是没有需要合并的上一行时,rowSpanList 中追加 1
                 */
                rowSpanMap.set(key, [...lastRowSpanList, 1]);

+                /**
+                 * 如果当前列是基准列,并且当前行和下一行的数据对应 key 的值不相同,preDataValueIsSame 值为 false
+                 */
+                if (mergeColumn.mergeBased || mergeColumn.mergeBased === 0) {
+                    preDataValueIsSame = false;
+                }
            }
        }
    }

    const computedColumns: TableColumnType<T>[] = columns.map((column) => {
        if (rowSpanMap.has(column.dataIndex || '')) {
            // 根据 dataIndex 从 rowSpanMap 找到对应的 rowSpanList,在下方的 onCell 中会用到
            const rowSpanList = rowSpanMap.get(column.dataIndex || '') || [];
            return {
                ...column,
                onCell: (record, rowIndex) => {
                    /**
                     * 执行 columns 中已经存在的 onCell 方法,将返回值展开,再添加 rowSpan 属性
                     */
                    return {
                        ...column?.onCell?.(record, rowIndex),
                        rowSpan: rowSpanList[rowIndex ?? -1],
                    };
                },
            };
        }
        return column;
    });

    return computedColumns;
};

效果图:

设置基准列3

查看 Demo 页面,可以看到,此时合并展示的效果依然是相同的

六、扩展功能三:支持某些未设置 dataIndex 的列进行行合并

假如此时有两列,是需要对数据进行操作,都没有设置 dataIndex,就会展示异常,因为 Map 中的键重复了,都为 '' 空字符串。
导致操作列合并后的展示异常。

异常效果图

可以看到,最后 4 行的删除和查看按钮消失了

Demo 中的代码配置如下:

Demo.tsx

import { Table } from 'antd';

import { type MergeTableColumnType, getColumnsByMerge } from './table';

interface DataType {
    key: string;
    name: string;
    major: string;
    subject: string;
}

const columns: MergeTableColumnType<DataType>[] = [
    {
        title: '姓名',
        dataIndex: 'name',
        merge: true,
        mergeBased: 1,
    },
    {
        title: '专业',
        dataIndex: 'major',
        merge: true,
    },
    {
        title: '学科',
        dataIndex: 'subject',
    },
    {
+        title: '操作一',
+        key: 'option1',
        width: 100,
+        merge: true,
        render: () => {
            return <a>删除</a>;
        },
    },
+    {
+        title: '操作二',
+        key: 'option2',
+        width: 100,
+        merge: true,
+        render: () => {
+            return <a>查看</a>;
+        },
+    },
];

const dataSource: DataType[] = [
    {
        key: '1',
        name: 'Michael',
        major: '文科',
        subject: '语文',
    },
    {
        key: '2',
        name: 'Michael',
        major: '文科',
        subject: '历史',
    },
    {
        key: '3',
        name: 'Michael',
        major: '文科',
        subject: '地理',
    },
    {
        key: '4',
        name: 'Michael',
        major: '文科',
        subject: '数学',
    },
    {
        key: '5',
        name: 'Jack',
        major: '文科',
        subject: '数学',
    },
    {
        key: '6',
        name: 'Jack',
        major: '文科',
        subject: '语文',
    },
    {
        key: '7',
        name: 'Rose',
        major: '理科',
        subject: '物理',
    },
    {
        key: '8',
        name: 'Jack',
        major: '文科',
        subject: '英语',
    },
    {
        key: '9',
        name: 'Lily',
        major: '理科',
        subject: '英语',
    },
    {
        key: '10',
        name: 'Rose',
        major: '理科',
        subject: '物理',
    },
];

function Demo() {
    return <Table columns={getColumnsByMerge(columns, dataSource)} dataSource={dataSource} />;
}

export default Demo;

因此需要完善 getColumnsByMerge 函数。

思路:

  • dataIndex 未设置时,使用 column 配置的 key 属性作为 Map 的键

table.ts 文件修改后的代码:

import type { TableColumnType } from 'antd';
import { get, isBoolean, isEqual, isFunction, isNil, isNumber } from 'lodash';

export interface MergeTableColumnType<T> extends TableColumnType<T> {
    merge?: boolean | ((currentData?: T, nextData?: T) => boolean);
    mergeBased?: boolean | number;
}

const compareWithDataIndex = <T>(mergeColumn: MergeTableColumnType<T>, currentData?: T, nextData?: T) => {
    if (!mergeColumn) {
        return false;
    }
    /**
     * 其中一个为 null 或者 undefined 时,且上下两行数据不相等时,一定是不会合并的
     *  下面的判断同 (isNil(currentData) && !isNil(nextData)) || (!isNil(currentData) && isNil(nextData))
     */
    if (currentData !== nextData && (isNil(currentData) || isNil(nextData))) {
        return false;
    }
    const { merge, dataIndex = '' } = mergeColumn;
    const isSame = isFunction(merge)
        ? merge(currentData, nextData)
        : isEqual(get(currentData, dataIndex), get(nextData, dataIndex));

    return isSame;
};

const sortByMergeBased = <T>(a: MergeTableColumnType<T>, b: MergeTableColumnType<T>) => {
    // 如果 a 的 mergeBased 是数字,b 的 mergeBased 是布尔值,则 a 排在前面
    if (isNumber(a.mergeBased) && isBoolean(b.mergeBased)) {
        return -1;
    }
    // 如果 b 的 mergeBased 是数字,a 的 mergeBased 是布尔值,则 b 排在前面
    if (isBoolean(a.mergeBased) && isNumber(b.mergeBased)) {
        return 1;
    }

    /**
     * 上面两个判断,可以确保,设置了数字的 mergeBased 的列,无论大小,都排在没有设置数字的 mergeBased 的列前面
     */

    // 否则根据 mergeBased 的值进行排序
    return Number(a.mergeBased || Number.MAX_VALUE) - Number(b.mergeBased || Number.MAX_VALUE);
};

export const getColumnsByMerge = <T>(columns: MergeTableColumnType<T>[], dataSource: T[]): TableColumnType<T>[] => {
    const rowSpanMap = new Map<bigint | string | number | readonly (string | number)[], number[]>();

    const mergeColumnList = columns.filter((col) => !!col.merge).sort(sortByMergeBased);
    const dataLength = dataSource?.length ?? 0;
    const mergeColumnLength = mergeColumnList.length ?? 0;

    // 首先就进行初始化,根据需要合并的行,先生成需要合并行的起始下标的数组,内部值全部初始化为 -1
    const mergeStartIndexList = mergeColumnList.map(() => -1);

    // 遍历数据源,找到需要合并的列
    for (let sourceIndex = 0; sourceIndex < dataLength; sourceIndex += 1) {
        const currentData = dataSource[sourceIndex];
        const nextData = dataSource[sourceIndex + 1];

        /**
         * 上一基准列是否相同,默认值为 true
         */
        let preDataValueIsSame = true;

        // 遍历需要合并的列,此处的 key 相当于是 column 中配置的 dataIndex
        for (let keyIndex = 0; keyIndex < mergeColumnLength; keyIndex += 1) {
            const mergeColumn = mergeColumnList[keyIndex]!;

+            // 支持 dataIndex 或者 key 作为 rowSpanMap 的键
+            const key = (mergeColumn.dataIndex || mergeColumn.key) ?? '';

            // 需要合并行的起始下标,默认值为 -1
            const mergeStartIdx = mergeStartIndexList[keyIndex] ?? -1;

            // 获取到当前 key 已经存储的 rowSpan 数组
            const lastRowSpanList = rowSpanMap.get(key) || [];

            // 小优化:前一个基准列当前行和下一行数据相同时,再计算当前列是否需要合并
            const currentAndPreDataIsSame = preDataValueIsSame && compareWithDataIndex(mergeColumn, currentData, nextData);

            if (currentAndPreDataIsSame) {
                /**
                 * 当前行的数据和下一行的数据对应 key 的 值 相同时,需要合并,则向 rowSpanList 中追加 0 即可
                 *
                 * 此时找到当前列对应的需要合并的行的起始下标,将其保存到 keyIndexList 中
                 *  - 如果 mergeStartIdx 为 -1,说明当前行是第一行,直接将当前数据源的下标保存到 keyIndexList 中
                 *  - 如果 mergeStartIdx 不为 -1,说明当前行和上一行的数据相同,则不做修改
                 */
                mergeStartIndexList[keyIndex] = mergeStartIdx === -1 ? sourceIndex : mergeStartIdx;
                rowSpanMap.set(key, [...lastRowSpanList, 0]);
            } else if (rowSpanMap.has(key) && mergeStartIdx !== -1) {
                /**
                 * 如果 Map 中存在了这个key,且确实存在需要和当前行合并的上一行时,将当前行合并到上一行
                 */
                lastRowSpanList[mergeStartIdx] = sourceIndex - mergeStartIdx + 1;
                rowSpanMap.set(key, [...lastRowSpanList, 0]);
                // 当前行的数据和下一行的数据对应 key 的值 不相同 时,将 keyIndexList 中当前列对应的值重置为 -1,方便下次使用
                mergeStartIndexList[keyIndex] = -1;

                /**
                 * 如果当前列是基准列,并且当前行和下一行的数据对应 key 的值不相同,preDataValueIsSame 值为 false
                 */
                if (mergeColumn.mergeBased || mergeColumn.mergeBased === 0) {
                    preDataValueIsSame = false;
                }
            } else {
                /**
                 * 如果当前行和下一行的数据不相同,且 Map 中不存在这个 key,
                 *  或者存在这个 key 但是没有需要合并的上一行时,rowSpanList 中追加 1
                 */
                rowSpanMap.set(key, [...lastRowSpanList, 1]);

                /**
                 * 如果当前列是基准列,并且当前行和下一行的数据对应 key 的值不相同,preDataValueIsSame 值为 false
                 */
                if (mergeColumn.mergeBased || mergeColumn.mergeBased === 0) {
                    preDataValueIsSame = false;
                }
            }
        }
    }

    const computedColumns: TableColumnType<T>[] = columns.map((column) => {
+        const mapKey = (column.dataIndex || column.key) ?? '';
+        if (rowSpanMap.has(mapKey)) {
+            // 根据 dataIndex 或者 key 从 rowSpanMap 找到对应的 rowSpanList,在下方的 onCell 中会用到
+            const rowSpanList = rowSpanMap.get(mapKey) || [];
            return {
                ...column,
                onCell: (record, rowIndex) => {
                    /**
                     * 执行 columns 中已经存在的 onCell 方法,将返回值展开,再添加 rowSpan 属性
                     */
                    return {
                        ...column?.onCell?.(record, rowIndex),
                        rowSpan: rowSpanList[rowIndex ?? -1],
                    };
                },
            };
        }
        return column;
    });

    return computedColumns;
};

此时,操作列也都可以正常合并。效果如下:

操作列行合并

至此,Table 中对应列、相邻行数据相同时,自动进行单元格合并的逻辑基本实现。

总结

为了在使用 AntDesign 的 Table 组件,对应列、相邻行数据相同时、自动进行单元格合并的功能,考虑了以下可能会使用的一些能力

  • 功能点

    • 相邻行数据有值时,都是基础类型,直接判断是否相同

    • 引用类型,进行深比较,引用不同,但内容完全相同,也认为是相同数据

    • 支持设置对比函数,将上下两行数据交给外部,自定义对比规则

    • 支持设置基准列,设置后,其他行合并时均以该行作为基准

    • 基准列支持设置多行,根据数字大小和顺序进行正序对比

    • 支持操作等占位列进行行合并

  • 思路

    • 只对 需要合并的列 进行操作,根据 column 配置中是否设置了 merge 属性进行过滤

    • 需要 merge 的列,按照 mergeBased 属性值进行排序,设置 mergeBased 的列放在未设置的列之前

    • 根据需要 merge 的列的长度定义一个数组,用来记录每一列中,每次需要进行【行合并时的起始下标】

    • 定义一个 Map,用来记录需要 merge 的每一列,对应行合并的 rowSpan 集合

    • 当前行需要合并时,向 Map 中该列对应的行的下标追加 0

      • 判断当前行是不是最后一行,如果是的话,修改起始 merge 的下标,修改其值

      • 如果不是的话,继续向后遍历

    • 当前行不需要合并时,向 Map 中该列对应的行的下标追加 1

    • 遍历完成后,根据 Map 中存储的数据,修改对应列的 onCell 函数 rowSpan 返回值即可

  • 在 column 中的配置方式

    • merge: true

      • 该列相邻行的数据相同时,单元格自动行合并
    • merge: true, mergeBased: true

      • 该列相邻行的数据相同时,单元格自动行合并,并且该列为 基准列,其他列会在该列已经行合并的情况下,才会判断是否行合并
    • merge: true, mergeBased: 1

    • merge: true, mergeBased: 2

    • merge: true, mergeBased: 3

      • 上述三列,相邻行的数据相同时,单元格自动行合并,并且分别为 第一、二、三 基准列
    • merge: (currentData: any, preData: any) => { return currentData?.field?.[0] === preData?.field?.[0]; }

      • 该列相邻行的数据满足 merge 函数时,单元格自动行合并
    • merge: (currentData: any, preData: any) => { return currentData?.field?.[0] === preData?.field?.[0]; }, mergeBased: 1

    • merge: (currentData: any, preData: any) => { return currentData?.field?.[0] === preData?.field?.[0]; }, mergeBased: 2

    • merge: (currentData: any, preData: any) => { return currentData?.field?.[0] === preData?.field?.[0]; }, mergeBased: 3

      • 上述三列,相邻行的数据满足 merge 函数时,单元格自动行合并,并且分别为 第一、二、三 基准列

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions