明确目标
如果要实现 动态合并,我们应该先思考以下几点:
-
Antd Table 组件是如何去合并指定单元格的,我们需要做哪些配置、数据结构有无要求
-
了解第一点后,我们再思考下面两种情况
-
数据是固定不变的(静态),应该怎样合并
-
数据是随机不确定的(动态),又该如何合并
-
了解 Antd Table 合并单元格的配置
查阅文档后可知:合并单元格需要借助 columns 中的 onCell 函数来实现
具体的我们直接看下面这个例子:
如图所示,我们合并了 列1 的前三行 (rowIndex: 0 ,1 ,2 ) 以及 列2 的中间两行 (rowIndex: 1, 2)
代码如下:
// 表格数据
export const tableData = [
{
列1: 'A',
列2: 'B',
列3: 'C',
列4: 'D',
},
{
列1: 'A',
列2: 'B',
列3: 'C',
列4: 'D',
},
{
列1: 'A',
列2: 'B',
列3: 'C',
列4: 'D',
},
{
列1: 'A',
列2: 'B',
列3: 'C',
列4: 'D',
},
]
// 表格列配置
export const tableColumns = [
{
title: '列1',
dataIndex: '列1',
onCell(item, rowIndex) {
let rowSpan = 0
switch (rowIndex) {
case 0: // 第一行 rowIndex: 0
rowSpan = 3
break
case 1: // 第二行
rowSpan = 0
break
case 2: // 第三行
rowSpan = 0
break
case 3: // 第四行
rowSpan = 1
break
}
return { rowSpan }
},
},
{
title: '列2',
dataIndex: '列2',
onCell(item, rowIndex) {
let rowSpan = 0
switch (rowIndex) {
case 0: // 第一行 rowIndex: 0
rowSpan = 1
break
case 1: // 第二行
rowSpan = 2
break
case 2: // 第三行
rowSpan = 0
break
case 3: // 第四行
rowSpan = 1
break
}
return { rowSpan }
},
},
{
title: '列3',
dataIndex: '列3',
},
{
title: '列4',
dataIndex: '列4',
},
]
<Table
dataSource={tableData}
columns={tableColumns}
bordered
/>
可以看到:表格是按照 列 来渲染的。我们可以通过 每一列中的 onCell 函数来控制渲染结果。
第一列中,第一行至第四行 返回的结果依次分别是:
{ rowSpan: 3 } { rowSpan: 0 } { rowSpan: 0 } { rowSpan: 1 }
第二列中,第一行至第四行 返回的结果依次分别是:
{ rowSpan: 1 } { rowSpan: 2 } { rowSpan: 0 } { rowSpan: 1 }
规律是什么呢?
-
返回 { rowSpan: n } 时,渲染 n 行、 返回 { rowSpan: 0 } 时,该行不渲染、 返回 { rowSpan: 1 } 时,不做处理,依旧渲染 1 行
-
渲染 n 行时,需要控制其下方的 n - 1 行 不渲染。(合并逻辑可以理解为:在 列1 中 ,原本只有一行高度的第一行变成了三行,其实是占据了其下两行的空间,所以其下两行就不能再渲染了)
至此,我们知道了合并指定单元格的方式,并且发现,如果数据是静态的,我们完全可以通过在 columns 的 onCell 函数中根据 rowIndex 来硬编码 每行对应的 rowSpan 值,以达到预期目的。就像上面那个例子一样。
动态数据
那么当数据是动态的时候,我们又当如何应对呢?
还是一样的思路,我们最终肯定是要通过 onCell 中返回的 { rowSpan: n } 来实现单元格合并的,所以我们必须依次计算出 各列中 每一行的 rowSpan 值。
按照这个思路,我们需要考虑下面几个问题
- 用什么数据形式来保存这些 rowSpan 值、存放在哪儿
- 怎样确保在 onCell 中使用它们的时候,能正确地取到 对应位置上的 rowSpan 值
- 如果出现 ['A', 'A', 'A', 'B', 'B', 'C', 'A', 'A', 'D', 'D', 'D'] 这种 有两个相同元素 A ,但是不连续的边界情况我们应该怎样处理
以下面 mock 的数据为例:
[
{
"category": "音频设备",
"subItem": "调音台",
"name": "数字调音台",
"type": "YAMAHA TF5",
"price": 3000,
"count": 1
},
{
"category": "音频设备",
"subItem": "无线设备",
"name": "无线话筒接收",
"type": "SHURE UD4Q",
"price": 200,
"count": 1
},
{
"category": "音频设备",
"subItem": "无线设备",
"name": "无线领夹话筒",
"type": "SHURE ULXD1",
"price": 200,
"count": 1
},
{
"category": "音频设备",
"subItem": "音频监听",
"name": "音频监听单元",
"type": "BMD Audio Monitor 12G",
"price": 500,
"count": 1
},
{
"category": "音频设备",
"subItem": "音频监听",
"name": "监听音箱",
"type": "Genelec 8330A",
"price": 400,
"count": 2
},
{
"category": "音频设备",
"subItem": "音频监听",
"name": "监听耳机",
"type": "beyerdynamic DT770 Pro",
"price": 200,
"count": 1
}
]
我们预期中 合并单元格后的表格应该如下:
实现思路:
- 首先遍历一遍所有数据,将其中每一列里 出现过的 值 记录下来,并保存到一个 Map 中,Map 结构如下图。 这个 Map 中的每个 key 都是一个列字段(也就是一列),value 中存放着的是该列中 每一行的值 及其 出现顺序(顺序很重要,因为后续要判断其是否连续)
- 然后我们基于这个 Map 的 value(数组),来得到一个与之相对应的数组,这个数组里就是该列中 每一行对应的 rowSpan 值。这一步的实现方式具见下文
getSpanArr部分
结合例子更加直观:
我们以 第二列 为例,它对应的字段名是: subItem
所以我们从 Map 中取到对应的数组就是:
['调音台', '无线设备', '无线设备', '音频监听', '音频监听', '音频监听']
预期中得到的 rowSpan 数组应该是
[ 1, 2, 0, 3, 0, 0]
此时这个数组中的 rowSpan 对应的就是 第二列里每一行的 需要渲染的单元格数:
第一行:1 个
第二行:合并 2 个
第三行:被 第二行 合并了,不渲染
第四行:合并 3 个
第五行:被 第四行 合并了,不渲染
第六行:被 第四行 合并了,不渲染
- 按照上步思路处理完所有列后,我们就能得到一个新的 Map(这儿为了能在控制台中更直观地看到数据展开后的结构,将 Map 转成了 Object)
现在我们已经得到了各列中 各行的rowSpan 值,接下来就是通过 onCell 函数来使用它们
Antd中对 onCell 函数的定义如下:
onCell(item, rowIndex) {
return {} // 返回一个 {} 或者 { rowSpan: number }
}
item: 表格渲染数据中的每一行
rowIndex: 该列中的 行索引
因此,我们可以像下面这样直接使用 Map 来达到目的,还是以第二列为例,其列配置应当如下 :
{
title: '分项',
dataIndex: 'subItem',
onCell(item, rowIndex) {
const rowSpan = Map['subItem'][rowIndex];
return { rowSpan }
}
}
至此,就能实现我们的目标功能了, 但还有一个不便之处,那就是设置每一列的列配置时,我们需要手动指定 列的 dataIndex(列字段),并且去取 rowSpan 值时,我们必须写死为 Map['subItem'][rowIndex]、Map['category'][rowIndex]、Map['name'][rowIndex] 等等。
因此,如果我们的表格有很多列的时候,即使我们可以忽略掉因这些硬编码带来的工作量,但当某一列的列字段需要更改时,我们还需要手动去更改 dataIndex 以及 Map['xxx'] 这两处,不易于维护
优化方式
下面是一个 在 Table columns 中 自动匹配 上述 Map 中对应 rowSpan 值的优化思路
将 rowSpan 存放到每一行的数据中
我们对表格数据进行处理后,得到的预期数据如下图:
转换过程的代码如下:
/**
** description: 处理表格数据,为每一项中 添加一个 rowSpanConfig 对象,其中存储着该行里每一列需要合并的 rowSpan 数量
** @params data 原始表格数据
** @returns 返回处理完的表格数据
*/
export const generateTableData = (data: TServiceListItem[]) => {
const nonData = data ?? [];
// 记录数据中每个字段下所有的值,即:每一列中所有出现过的值
const map = new Map<string, (string | number)[]>();
console.log('%c data>>>>>>', 'color:rgb(132, 75, 238)', data);
nonData.forEach((item) => {
for (const key in item) {
const fieldValue = item[key as keyof TServiceListItem];
const mapValue = map.get(key);
if (!mapValue) {
map.set(key, [fieldValue]);
} else {
map.set(key, mapValue.concat(fieldValue));
}
}
});
console.log(
'%c 表格字段 -> 每一列值 的映射 >>>>>>',
'color:rgb(16, 239, 72)',
JSON.parse(JSON.stringify(Object.fromEntries(map)))
);
// 将 映射里的 字符串数组 转化为 记录着 合并单元格数量 的数组
map.forEach((arr, key) => {
const rowSpanInfo = getRowSpanArr(arr);
map.set(key, rowSpanInfo);
});
console.log(
'%c 数组转化完成后的映射,,,,,',
'color:rgb(238, 75, 208)',
map,
Object.fromEntries(map)
);
// 最后 再按照 数据的行数(rowIndex) 去 reflect 里取得 该字段,该行数 的 rowSpan
const targetTableData = nonData.map((item, rowIndex: number) => {
const rowSpanConfig: Record<string, number> = {};
for (const k in item) {
const rowSpanCount = map.get(k)[rowIndex] as number;
rowSpanConfig[k] = rowSpanCount;
}
return {
...item,
rowSpanConfig,
};
});
console.log(
'%c 最终处理完的数据是',
'color: #eea24b',
targetTableData
);
return targetTableData;
};
动态生成 列配置:
通过使用 defaultColumnFields 生成配置列的方式,我们就将 每一列的 列字段(key)给抽离出来了。后续我们就不用单独给每列指定对应的 列字段了,只需要在每一列的 onCell (item, rowIndex) { ... } 中, 直接使用 item.rowSpanConfig[key] 即可
经过我们上一步的处理,表格的数据如下(只展示了 第一列),其中 rowSpanConfig 结构如下:
[
// 整个大对象对应的是 第一行 的数据
{
"category": "音频设备",
"subItem": "调音台",
"name": "数字调音台",
"type": "YAMAHA TF5",
"price": 3000,
"count": 1,
"rowSpanConfig": {
"category": 6, // 第一列 对应的 rowSpan 值
"subItem": 1, // ...
"name": 1, // ...
"type": 1, // ...
"price": 1, // ...
"count": 4 // 第六列 对应的 rowSpan 值
}
},
// 剩余行数据
...
]
用以生成 列配置 的 列名 和 列字段:
// 其中每一项的 key 对应着 列字段
const defaultColumnFields = [
{ label: '类别', key: 'category' },
{ label: '分项', key: 'subItem' },
{ label: '名称', key: 'name' },
{ label: '型号', key: 'type' },
{ label: '数量', key: 'count' },
];
// 仅生成最基础的 列配置,如果想要自定义每列的其他属性,
// 比如 render(){},可以另外实现
type TColumnFields = Record<'label' | 'key', string>[];
/**
** description: 构造可 合并单元格 的 column 配置(可选部分列)
** @params columnFields
** @params needMergeColumnName 如果不传,视为:所有列都支持合并单元格
*/
export const generateTableColumns = (
columnFields: TColumnFields,
needMergeColumnNames?: string[]
) => {
const targetColumns = columnFields.map((item) => {
const { label, key } = item;
const isNeedMerge =
!needMergeColumnNames?.length || needMergeColumnNames.includes(key);
const columnConfig: TableColumnProps = {
title: label,
dataIndex: key,
key: key,
//render(text, record) {
// return record[key];
//},
};
// 为每一列 设置 onCell 函数
if (isNeedMerge) {
columnConfig.onCell = (record, rowIndex) => {
const typeStep = record.rowSpanConfig?.[key];
return { rowSpan: typeStep };
};
}
return columnConfig;
});
return targetColumns;
};
最关键的一环:getRowSpanArr
在 generateTableData 我们用到了一个工具方法:getRowSpanArr
它就是完成下面这步转化的关键:(图一竖着看是列,图二横着看是列,看前三列即可)
图一:
图二:
我们可以将这个问题抽象成:
统计一个数组中某个元素连续出现的次数,并且将其连续出现的次数记录到其首次出现位置的索引处,同时其他位置的索引置为 0。(该元素可重复出现,但只要不连续,下次再出现时,视作一个新的元素)
如何将 ['A', 'A', 'A', 'B', 'B', 'C', 'A', 'A', 'D', 'D', 'D']
转换为 [ 3, 0, 0, 2, 0, 1, 2, 0, 3, 0, 0 ]
我的方法是使用 双指针,并且因为在实际的需求中,原来的数组(图一)后续不需要再用到了,所以直接在原数组上进行转换即可。代码如下:
/**
** description: 遍历表格中的每一列,并按照其出现的值是否连续,转化为该行对应的 rowSpan 数量
** Ex: 以 ['A', 'A', 'A', 'B', 'B', 'C', 'A', 'A', 'D', 'D', 'D'] 为例,那么转化后应该是
** [ 3, 0, 0, 2, 0, 1, 2, 0, 3, 0, 0]
*/
type TGetRowSpanArr = (string | number)[];
export const getRowSpanArr = (arr: TGetRowSpanArr) => {
// 指向 当前连续值 的指针
let startIndex = 0,
recordItem = '' as TGetRowSpanArr[number]; // 记录当前连续值,后续用以比较
for (let i = 0; i < arr.length; i++) {
const item = arr[i];
// 因为 recordItem 初始值为 '', 所以首次遍历的时候也能进入这儿的逻辑
if (recordItem !== item) {
recordItem = item;
startIndex = i;
arr[startIndex] = 1;
} else {
arr[i] = 0;
// 如果 recordItem === item 了,那么 arr[startIndex] 肯定就是 number 类型了
(arr[startIndex] as number) += 1;
}
}
return arr;
};