Blog icon indicating copy to clipboard operation
Blog copied to clipboard

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

Open beichensky opened this issue 6 months ago • 0 comments

本文已收录在 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 函数时,单元格自动行合并,并且分别为 第一、二、三 基准列

beichensky avatar Aug 05 '24 15:08 beichensky