Blog
Blog copied to clipboard
使用 Ant Design 的 Table 组件实现:同一列中相邻行数据相同时,自动合并单元格的功能
本文已收录在 Github: https://github.com/beichensky/Blog 中,欢迎 Star,欢迎 Follow!
前言
先简单介绍一下 当前需求 和 组件库 Ant Design 中 Table 行合并的 API
-
当前需求:在使用 Table 组件展示数据时,有一列或者多列,上下多个单元格需要进行合并,合并的规则是:相邻行单元格数据相同时,自动进行合并
-
Table 行合并的 API:column 的属性 onCell 接受一个函数,函数返回值的属性 rowSpan 会控制 Table 中单元格的展示
-
为 0 时,设置的单元格不会展示;
-
为 1 或者 undefined 时,设置的单元格正常展示;
-
大于 1 时,假如为 x,会合并当前行及当前行 以下、共 x 行 的单元格
-
因此在进行单元格合并时,若希望合并第 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;
};
此时,修改后的展示效果也是完全相同的:
至此,表格的合并逻辑书写完成。但功能还可以继续扩展:
merge 的规则可以支持自定义,比如:只要是同一个人的不同昵称,也算是同一个人;
专业列进行合并行的时候,同一个人的专业相同时才能合并,不是同一个人,即使专业相同,也不合并;
某些操作或者占位的列,是可以不设置 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;
};
效果图:
查看 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;
};
效果图:
查看 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 函数时,单元格自动行合并,并且分别为 第一、二、三 基准列
-