背景
团队某个小项目切换 UI 框架,要将 Element 换成 TinyVue。期间遇到一个树表形式的业务表格,支持多级下钻,且第一列有合并行。当初用 Element 实现这个表格时费了一些周折,料想 TinyVue 上场应该也不轻松,谁曾想一上手才知道------这比 Element 实现容易多了!
先上最终效果图(表格内容已脱敏处理):
显示树表
TinyVue 的表格组件支持树表(详情),我们只需将业务数据按约定格式处理好,再简单进行配置即可。用到的表格数据如下:
const rawData = [
{ area: '华北', province: '北京', city: '北京', store: '密云店', sales: 72 },
{ area: '华东', province: '山东', city: '淄博', store: '蓝翔NO.1', sales: 57 },
{ area: '华中', province: '湖北', city: '武汉', store: '北区', sales: 21 },
{ area: '华北', province: '北京', city: '北京', store: '朝阳总店', sales: 123 },
{ area: '华东', province: '山东', city: '青岛', store: '蜊叉泊分店', sales: 12 },
{ area: '华中', province: '湖北', city: '武汉', store: '南区', sales: 42 },
{ area: '华北', province: '北京', city: '北京', store: '香山店', sales: 72 },
{ area: '华南', province: '广东', city: '广州', store: '花城店', sales: 90 },
{ area: '华南', province: '广西', city: '桂林', store: '甲天下', sales: 15 },
{ area: '华中', province: '湖北', city: '武汉', store: '城中区', sales: 54 },
{ area: '华南', province: '广东', city: '东莞', store: '万象汇', sales: 35 },
{ area: '华东', province: '山东', city: '青岛', store: '金沙滩分店', sales: 39 },
{ area: '华南', province: '广东', city: '深圳', store: '天虹', sales: 85 },
{ area: '华东', province: '浙江', city: '绍兴', store: '鲁镇店', sales: 87 },
{ area: '华南', province: '广东', city: '东莞', store: '大润发', sales: 26 },
{ area: '华北', province: '内蒙古', city: '乌兰察布', store: '乌拉旗舰店', sales: 15 },
{ area: '华南', province: '广东', city: '广州', store: '越秀店', sales: 60 }
];
我们先把它处理成树表约定的格式:
const mergeKeys = ['area', 'province', 'city']; // 需要合并的字段名
// 根据合并字段进行排序,方便后续处理
rawData.sort((a: any, b: any) => {
for (let key of mergeKeys) {
if (a[key] !== b[key]) {
return a[key].localeCompare(b[key]);
}
}
return 0;
});
// 生成树表所需格式的数据
const tableData = rawData.reduce((arr, item) => {
const provinceNode = initNodeData(arr, item, 'province');
const cityNode = initNodeData(provinceNode.children, item, 'city');
cityNode.children.push({
place: item.store,
store: 1,
sales: item.sales
});
return arr;
}, [] as any[]);
function initNodeData(arr: any[], row: any, placeKey: string) {
const placeValue = row[placeKey];
let matched = arr.find(v => v.place === placeValue);
if (!matched) {
matched = {
area: row.area,
place: placeValue,
store: 0,
sales: 0,
children: [] // 子级
};
arr.push(matched);
}
// 统计当前行的 store 数量和 sales
matched.store += 1;
matched.sales += row.sales;
return matched;
}
处理好的 tableData
格式如下:
[
{
"area": "华北",
"place": "北京",
"store": 3,
"sales": 267,
"children": [
{
"area": "华北",
"place": "北京",
"store": 3,
"sales": 267,
"children": [
{
"place": "密云店",
"store": 1,
"sales": 72,
"_RID": "row_3"
},
...
]
}
]
},
...
]
模板的配置非常方便,只需为需要展开的列配置 tree-node
即可。
Element 中的树表默认只能通过第一列的单元格来控制展开和折叠,如果想要在其他列中实现同样的功能,就需要使用一点黑魔法 hack。这一点要为 TinyVue 的方便好用点赞!
<template>
<tiny-grid :data="tableData" :tree-config="{ children: 'children' }" border>
<tiny-grid-column title="大区" field="area" width="120" />
<tiny-grid-column title="地区" field="place" tree-node /> <!-- 这列控制树表展开/折叠 -->
<tiny-grid-column title="店铺" field="store" />
<tiny-grid-column title="销售额" field="sales" />
</tiny-grid>
</template>
效果如下图:
跨行合并首列单元格
TinyVue 表格组件提供了 row-span
和 span-method
两种单元格合并的方法,但前者不支持嵌套树表,所以这里选择后者。span-method
这个方法是不是很眼熟?对了,Element Table 中也有这个方法,两者在使用上几乎一模一样。
<template>
<tiny-grid span-method="spanMethod" ... >...</tiny-grid>
</template>
function spanMethod({ columnIndex, row }: { columnIndex: number, row: any}) {
if (columnIndex === 0) { // 首列跨行合并
return {
rowspan: 1, // 如何求跨行合并数值?
colspan: 1
}
}
}
接下来的问题就是如何求出 spanMethod
方法返回值中的 rowspan
的值。
我们先来看表格,在初始状态下,表格首列需要跨行合并的单元格如下图红框处所示:
不难看出每个"大区"(area)的跨行合并数就是其相邻且内容相同单元格的个数,例如"大区"一列中相邻的"华北"单元格有2个,则它的跨行合并数就是2。我们可以通过定义一个 rowspanMapping
变量来保存这些"大区"分别对应的跨行合并数:
// 跨行合并数,Map key 为 area 的值,Map value 为跨行合并数
const rowspanMapping: Map<string, number> = new Map();
// 统计不同大区的默认跨行合并数
tableData.forEach((item) => {
const spanValue = rowspanMapping.get(item.area) ?? 0;
rowspanMapping.set(item.area, spanValue + 1);
});
然后我们就得到了表格初始形态首列的跨行合并数信息:
Map(4) {'华北' => 2, '华东' => 2, '华南' => 2, '华中' => 1}
再应用到 spanMethod
方法中:
function spanMethod({ columnIndex, row }: { columnIndex: number, row: any}) {
if (columnIndex === 0) { // 首列跨行合并
return {
rowspan: rowspanMapping.get(row.area), // 获取当前单元格跨行合并数
colspan: 1
}
}
}
然而,实际的渲染效果与我们预期的完全不同,表格布局出现了严重的错位。如下图所示:
经过分析,我们发现在进行单元格合并时,合并组中的起始单元格会占据对应的行数,而其余单元格则需要被移除,以保证表格的结构不会出现错位。在 TinyVue 表格组件的 spanMthod
方法中,我们通过将 rowspan
设为 0 来实现这一点。例如,"华北"的 rowspan
为 2,则第一个"华北"单元格的 rowspan
值就为2,而第二个"华北"单元格则应该设为0,即移除不渲染。
为此,我们需要改进我们统计大区默认跨行合并数的方法。我们可以通过定义一个私有属性来记录当前"大区"是否首次出现:
tableData.forEach((item) => {
const spanValue = rowspanMapping.get(item.area) ?? 0;
// 当前 area 值首次出现(用于设定 rowspan 的作用行)
item._isAppearFirst = !rowspanMapping.has(item.area);
rowspanMapping.set(item.area, spanValue + 1);
});
合并方法也要随之调整:
function spanMethod({ columnIndex, row }: { columnIndex: number, row: any}) {
if (columnIndex === 0) { // 首列跨行合并
return {
// 当前大区若首次出现,则应用跨行合并数,否则设为0(不渲染)
rowspan: row._isAppearFirst ? rowspanMapping.get(row.area) : 0,
colspan: 1
}
}
}
完成后效果如下图所示:
合并行动态适配树表展开与折叠
目前为止,我们已经实现了树表和表格初始化状态首列的行合并,接下来要将两者结合起来完成最终效果。
当展开树表中的任意一行时,当前行下方会显示新的表格行;相反,折叠任意一行时,下方对应的表格行也会隐藏。换句话说,随着树表的展开和折叠,表格的总行数会发生变化,因此需要动态调整对应首列的跨行合并数,以确保表格布局不发生错位。
举例说明:
"华东"的跨行合并数默认为2
当展开"山东"时,会显示下级的"青岛"和"淄博",此时"华东"的跨行合并数应该加上这2个新出现的表格行,变为4,才能确保表格布局恢复正常。
同理,当继续展开"青岛"时,会显示2个下级行,此时"华东"的跨行合并数应该再加上这2个新出现的表格行,变为6
树表的折叠操作与展开类似,只是将新增的表格行替换为减少的表格行,这里不再赘述。
原理搞清了,下面分享代码实现。
既然需要根据树表的展开和折叠来计算跨行合并数,那么首先就要监听树表切换事件 toggle-tree-change
:
<template>
<tiny-grid @toggle-tree-change="handleExpand" ... >
...
</tiny-grid>
function handleExpand({ row }: { row: any }) {
// ?
}
在表格组件底层,当树表展开或折叠后,会重新获取单元格合并数信息以进行布局。换句话说,我们需要在 handleExpand
方法中计算出树表行操作后的合并数,以便 spanMethod
方法能够获取到最新的单元格合并数。
为了计算操作后的合并数,我们需要知道当前操作是"展开"还是"折叠",以便进行针对性的处理:
-
当前为折叠操作,需要减去待折叠的行数
-
当前为展开操作,需要加上待展开的行数
我们为每行数据添加一个私有属性_treeExpanded
来存储展开/折叠状态。
function initNodeData(arr: any[], row: any, placeKey: string) {
...
if (!matched) {
matched = {
...
_treeExpanded: false // 自定义属性:当前树表行是否展开
};
}
...
}
这样我们就可以在 handleExpand
方法中进行展开或折叠的合并行数处理了:
function handleExpand({ row }: { row: any }) {
const curRowspan = rowspanMapping.get(row.area) ?? 0; // 当前 area 的跨行合并数
const changedRowspan = row.children.length; // 当前行的子级数量
let spanValue;
if (row._treeExpanded) { // 当前行已展开:当前为折叠操作,需要减去待折叠的行数
spanValue = curRowspan - changedRowspan;
} else { // 当前行已折叠:当前为展开操作,需要加上待展开的行数
spanValue = curRowspan + changedRowspan;
}
row._treeExpanded = !row._treeExpanded; // 更新状态
rowspanMapping.set(row.area, spanValue);
}
目前的效果已经非常接近了,但通过测试我们发现:当树表按层级顺序逐步展开或折叠时,一切正常,然而,若直接跨层级操作,就会出现布局错位。正如下图所示:
这是因为跨层级操作时,我们的代码中只考虑了当前行子级的变动数量,而没有考虑子级的子级,以及更深层级的展开/折叠状态。因此,我们需要将当前行的所有子级都纳入考虑。下面是最终调整后的代码:
function handleExpand({ row }: { row: any }) {
const curRowspan = rowspanMapping.get(row.area) ?? 0;
let changedRowspan = row.children.length;
// 当前行已展开:当前为折叠操作,需要减去待折叠的行数(含后代)
// 当前行已折叠:当前为展开操作,需要加上待展开的行数(含后代)
const deepExpandedCount = (arr: any[]) => {
if (!Array.isArray(arr)) {
return;
}
arr.forEach((item: any) => {
if (item._treeExpanded) {
// 递归累加已经展开的子级个数
changedRowspan += item.children.length;
deepExpandedCount(item.children);
}
});
}
deepExpandedCount(row.children);
const spanValue = curRowspan + changedRowspan * (row._treeExpanded ? -1 : 1);
row._treeExpanded = !row._treeExpanded; // 更新状态
rowspanMapping.set(row.area, spanValue);
}
要点总结
1、为需要控制树表展开/折叠的列配置 tree-node
属性;
2、span-method
方法返回当前单元格的合并数,其中 rowspan
为跨行合并数,当设为0时,当前单元格不渲染;
3、定义一个变量以存储表格的跨行合并数信息;
4、对于相邻且内容相同的同组单元格,仅设置该组首个单元格的 rowspan
为对应的跨行合并数,其余单元格均设为 0;
5、监听表格的 toggle-tree-change
事件,区分树表的展开、折叠操作,动态修改对应的跨行合并数信息。
关于OpenTiny
欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:https://opentiny.design/
OpenTiny 代码仓库:https://github.com/opentiny/
TinyVue 源码:https://github.com/opentiny/tiny-vue
TinyEngine 源码:https://github.com/opentiny/tiny-engine
欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI~
如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~