antd: ^5.18.3
业务说:这个table
缩进层级看不太出来,加个连接线吧,吭哧吭哧翻文档发现没有官方支持,又不想去换其他组件,只能自己实现,先看一下最终效果(demo
):
接下来讲一下详细思路和实现,也是记录一下,可能不是最优,欢迎讨论
1. 前置准备
数据准备
随便编的一组数据,足够了
const treeData = [
{
uid: 25,
name: "Node 1",
age: 18,
children: [
{
uid: 34,
name: "Node 2",
age: 20,
children: [
{
uid: 12,
name: "Node 3",
age: 30,
children: [
{
uid: 26,
name: "Node 4",
age: 40,
children: []
}
]
},
{
uid: 28,
age: 60,
name: "Node 5",
children: []
}
]
},
{
uid: 27,
age: 70,
name: "Node 6",
children: [
{
uid: 50,
age: 60,
name: "Node 15",
children: []
},
{
uid: 23,
age: 60,
name: "Node 16",
children: []
},
{
uid: 45,
age: 60,
name: "Node 17",
children: []
}
]
}
]
},
{
uid: 88,
name: "Node 7",
age: 18,
children: [
{
uid: 90,
name: "Node 8",
age: 20,
children: [
{
uid: 68,
name: "Node 9",
age: 30,
children: [
{
uid: 77,
name: "Node 10",
age: 40,
children: []
},
{
uid: 72,
age: 60,
name: "Node 13",
children: []
}
]
},
{
uid: 73,
age: 60,
name: "Node 11",
children: []
}
]
},
{
uid: 41,
age: 70,
name: "Node 12",
children: []
},
{
uid: 91,
age: 60,
name: "Node 14",
children: []
}
]
}
];
export default treeData
树形数据结构操作
GPT真好用!
const findPathToNode = (dataTmp, id, path = []) => {
for (let i = 0; i < dataTmp.length; i++) {
if (dataTmp[i]?.uid === id) {
return [...path, dataTmp[i]]; // 返回包含当前节点的路径数组
// return path;
}
if (dataTmp[i]?.children) {
const foundPath = findPathToNode(dataTmp[i]?.children, id, [
...path,
dataTmp[i],
]);
if (foundPath) {
return foundPath; // 如果在子节点中找到路径,则返回该路径
}
}
}
return null; // 如果未找到匹配的节点,则返回 null
};
2. 分析
红色框: 展开时展开图标下显示的连接线(展开时才出现)
黄色框: 子节点(但不是同一父级的最后一个子节点)前缀,T形连接线
蓝色框: 同一父级最后一个子节点,L形连接线
绿色框: 父级不是爷级(父亲的父亲)最后一个节点时其子节点才会显示I形连接线,就比如开篇第一张图中Node 6
节点的父级就是爷级的最后一个节点,因此其子节点没有前缀
ok,至此分析完毕,接下来就是实现。
3. 实现
入手
思路是有了,但是通过antd
的table
的哪个api
来实现呢?看了一圈,似乎只有一个选择expandIcon
,这个api
可以设置树形table
的展开图标,根据官方示例,可以看到它接收一个函数,可以传递过来expanded
(是否展开boolean
), onExpand
(展开事件), record
(要展开行的数据)这几个值(好像还有其他,但这里我们就用到这几个就够了)。如果仅仅修改图标,以下代码就够了:
const handleExpandedIcon = ({expanded, onExpand, record})=>{
const expandedIcon = (
<Button
size="small"
type="text"
icon={<CaretDownOutlined/>}
onClick={(e) => {
e.stopPropagation();
onExpand(record, e);
}}
/>
);
const unExpandedIcon = (
<Button
// loading={record?.isLoadingChildren}
size="small"
type="text"
icon={<CaretRightOutlined/>}
onClick={(e) => {
e.stopPropagation();
onExpand(record, e);
}}
/>)
return expanded ? expandedIcon : unExpandedIcon
}
但我们还需要添加额外连接线,因此还需要加上额外的内容。
开始
根据我们的分析,只需要在合适的位置绘制合适的连接线就可以了,不过在此之前,我们先预设几个自定义值:
// 连接线粗细
const thickness = 0.5;
// T和L形线与图标的距离
const iconDis = 12;
// 偏移量(用于对齐) = 单元格内边距(16) + 展开图标大小一半(12),
// antd单元格都有内边距,好像v4里边内边距是8,v5内边距是16,需要自己确定一下便于计算偏移
// 因为使用绝对定位让连接线从单元格最左侧开始绘制,因此为了和展开图标对齐,需要先让连接线有一个基础偏移
const disOffset = 28;
// 同expandable值indentSize
const indentSize = 30
// 连接线使用border来实现,之后也可以自定义线条颜色,线型,以及粗细
const borderStyle = `rgba(0,0,0,.45) dashed ${thickness}px`;
通过这些变量可以进行一些个性化设置。
此外,我们还需要对输入进来的数据打标记,以便于后续的计算:
const markNodes = (nodes, nodeLevel = 0) => {
return nodes.map((item, index, array) => {
const isLastChild = index === array.length - 1;
if (item.children && item.children.length > 0) {
const markedChildren = markNodes(item.children, nodeLevel + 1);
return {
...item,
// 标记是否为同一父级的最后一个子节点
nodeType: isLastChild ? 2 : 1,
// 该节点所在深度,用于计算缩进,根节点为0
nodeLevel,
// 展开需要远程请求数据时的loading状态
isLoadingChildren: false,
children: markedChildren,
};
} else {
return {
...item,
isLoadingChildren: false,
nodeType: isLastChild ? 2 : 1,
nodeLevel,
};
}
});
};
短线(图标下连接线)
就是展开状态显示的线条,这条线最好绘制,啥也不说了,都在代码里了,我先看为敬!
// level就是每个节点的nodeLevel
const fixedExpandLine = (level) => {
return (
<div
style={{
display: "flex",
height: "100%",
alignItems: "end",
// 为了让其脱标,从最左侧开始偏移,F12看图标前元素,
// 其实antd是通过一个具有一定长度的元素来实现缩进的,
// 所以为了“自由”地布局我们的连接线就需要脱标
position: "absolute",
top: 0,
// 除了基础的偏移量disOffset,这个短线具有与图标一样的缩进距离level * indentSize,
//并且为了能让线条中间对齐那个三角尖尖,需要减去线条粗细的一半
left: level * indentSize + disOffset - thickness / 2,
}}
>
<div
style={{
// 这个地方可以修改展开状态下部连接线到图标距离
height: "calc(50% - 12px)",
borderRight: borderStyle,
}}
></div>
</div>
);
};
L形连接线
L形连接线应该是第二个比较好绘制的连接线了,它添加到同一父级最后一个子节点的前面,它通过一个具有单元格高度一半的div
的左下边框绘制而成,同样啥也不说了,都在代码里!
// 同样是nodeLevel
const fixedL = (level) => {
return (
<div
style={{
display: "flex",
height: "100%",
position: "absolute",
top: 0,
// 这里使用level - 1是因为该连接线绘制在图标前面,它的宽度占了一个缩进层级
left: (level - 1) * indentSize + disOffset - thickness / 2,
}}
>
<div
style={{
height: "50%",
// 为了不让这根线紧贴图标,让其短了一点
width: indentSize - iconDis,
borderLeft: borderStyle,
borderBottom: borderStyle,
}}
></div>
</div>
);
};
T形连接线
T形连接线比较麻烦一点,L形可以通过一个盒子的左下边框实现,但T形由于有一根线在高度一半的地方,因此需要增加另外一个盒子来显示,都在代码里!
// 参数同样是nodeLevel
const fixedT = (level) => {
return (
<div
style={{
display: "flex",
// 这个才能让具有下边框的盒子位于中间
alignItems: "center",
height: "100%",
top: 0,
position: "absolute",
left: (level - 1) * indentSize + disOffset - thickness / 2,
}}
>
<div
style={{
height: "100%",
borderLeft: borderStyle,
}}
></div>
<div
style={{
width: indentSize - iconDis,
borderBottom: borderStyle,
}}
></div>
</div>
);
};
开始绘制
最最最重要的来了,我们要根据各种情况来将这些连接线组合显示,这里可以大概分为三种情况:
- 根节点:只需要展开时显示短线即可。
- 爷级是最后一个节点:展示L形连接线或T形连接线+表示层级缩进的I形线
- 爷级不是最后一个节点:仅展示L形连接线或T形连接线
(应该没分错吧)
因此,在每次展开的时候,我们都需要知道根节点到该节点的路径信息,包括路径上每个节点的nodeLevel
和nodeType
,此时,我们就需要findPathToNode
函数来获取:
let prefix = null;
const parentPath = findPathToNode(data, record?.uid);
// 前面的函数我们都是level - 1,此时我们不需要从根节点的level开始
prefix = showPrefix(parentPath.slice(1));
其中showPrefix
函数如下:
const showPrefix = (parentPath) => {
return parentPath.map((item, index) => {
if (index === parentPath.length - 1) {
// 情况3
if (item?.nodeType === 1) {
return fixedT(item?.nodeLevel);
} else if (item?.nodeType === 2) {
return fixedL(item?.nodeLevel);
}
} else {
// 情况2,绘制竖线
if (item?.nodeType === 1) {
return (
<div
style={{
display: "flex",
alignItems: "center",
height: "100%",
top: 0,
position: "absolute",
left:
(item?.nodeLevel - 1) * indentSize +
disOffset -
thickness / 2,
}}
>
<div
style={{
height: "100%",
borderLeft: borderStyle,
}}
></div>
</div>
);
}
}
});
};
最后我们将这些绘制结果组合在一起:
<>
{prefix}
// 情况1
{expanded ? fixedExpandLine(record?.nodeLevel) : null}
{expanded ? expandedIcon : unExpandedIcon}
</>
注意: 最后这些绘制结果必须使用<></>
来包围,因为它是虚拟节点,子元素最终都是作为单元格的亲子元素存在,才能用绝对定位从最左侧开始布局
4. 大功告成
至此,连接线就绘制完成了,在打标记的时候我额外打了isLoadingChildren
字段,这可以用于在展开行时需要请求获取子节点数据的情况,这也是图标使用Button
来实现的原因之一,可以直接使用loading
来显示加载状态,在点击展开时,将数据中该节点的isLoadingChildren
置为true
,加载完毕再置为false
可能还需要深度遍历来更新,最后放一下整体的示例代码吧,可能会有一些不一样,但无伤大雅
/*
带有连接线的树形结构table
*/
import {useEffect, useState} from "react";
import {Button, message, Table} from "antd";
import treeData from "../../assets/data/tree";
import {findAllDescendants, findPathToNode, markNodes} from "./const";
import {CaretDownOutlined, CaretRightOutlined} from '@ant-design/icons'
const TreeTable = (props) => {
const [data, setData] = useState(treeData)
const [expandedRowKeys, setExpandedRowKeys] = useState<React.Key[]>([])
useEffect(() => {
// 标记节点,不同节点需要展示的连接线不同
setData(markNodes(data))
}, []);
const columns = [
// {
// title: '编号',
// dataIndex: 'uid'
// },
{
title: '名称',
dataIndex: 'name'
},
{
title: '年龄',
dataIndex: 'age'
}
]
const handleExpanded = (expanded, record) => {
if (expanded) {
if (record?.children.length) {
setExpandedRowKeys([...expandedRowKeys, record?.uid]);
} else {
message.info('已经是最后一级')
}
} else {
// 将子孙节点都折叠
setExpandedRowKeys((preKeys) => {
const children = findAllDescendants(record).map((item) => item?.uid);
return preKeys.filter((item) => !children.includes(item));
});
}
}
const handleExpandIconChange = ({expanded, onExpand, record}) => {
const expandedIcon = (
<Button
size="small"
type="text"
icon={<CaretDownOutlined/>}
onClick={(e) => {
e.stopPropagation();
onExpand(record, e);
}}
/>
);
const unExpandedIcon = (
<Button
// loading={record?.isLoadingChildren}
size="small"
type="text"
icon={<CaretRightOutlined/>}
onClick={(e) => {
e.stopPropagation();
// updateNodeLoadingStatus(record, true);
onExpand(record, e);
}}
/>)
// 线条粗细
const thickness = 0.5;
// 横线距离图标距离
const iconDis = 12;
// 偏移量 = 单元格内边距(16) + 展开图标大小一半(12)
const disOffset = 28;
const indentSize = 30
const borderStyle = `rgba(0,0,0,.45) dashed ${thickness}px`;
// 非叶子节点的T形结构
const showPrefix = (parentPath) => {
return parentPath.map((item, index) => {
if (index === parentPath.length - 1) {
// 当前节点根据节点类型显示不同的连接线:最后一个节点显示L形,非最后一个节点T形
if (item?.nodeType === 1) {
return fixedT(item?.nodeLevel);
} else if (item?.nodeType === 2) {
return fixedL(item?.nodeLevel);
}
} else {
// 当父节点(包括祖先节点)不是最后一个节点时该行前才会显示竖线
if (item?.nodeType === 1) {
return (
<div
style={{
display: "flex",
alignItems: "center",
height: "100%",
top: 0,
position: "absolute",
left:
(item?.nodeLevel - 1) * indentSize +
disOffset -
thickness / 2,
}}
>
<div
style={{
height: "100%",
borderLeft: borderStyle,
}}
></div>
</div>
);
}
}
});
};
const fixedT = (level) => {
return (
<div
style={{
display: "flex",
alignItems: "center",
height: "100%",
top: 0,
position: "absolute",
left: (level - 1) * indentSize + disOffset - thickness / 2,
}}
>
<div
style={{
height: "100%",
borderLeft: borderStyle,
}}
></div>
<div
style={{
width: indentSize - iconDis,
borderBottom: borderStyle,
}}
></div>
</div>
);
};
const fixedL = (level) => {
return (
<div
style={{
display: "flex",
height: "100%",
position: "absolute",
top: 0,
left: (level - 1) * indentSize + disOffset - thickness / 2,
}}
>
<div
style={{
height: "50%",
width: indentSize - iconDis,
borderLeft: borderStyle,
borderBottom: borderStyle,
}}
></div>
</div>
);
};
const fixedExpandLine = (level) => {
return (
<div
style={{
display: "flex",
height: "100%",
alignItems: "end",
position: "absolute",
top: 0,
left: level * indentSize + disOffset - thickness / 2,
}}
>
<div
style={{
// 这个地方可以修改展开状态下部连接线到图标距离
height: "calc(50% - 12px)",
borderRight: borderStyle,
}}
></div>
</div>
);
};
if (record) {
let prefix = null;
// 找到父节点,如果父节点是最后一个节点就不需要显示前边的竖线
const parentPath = findPathToNode(data, record?.uid);
console.log(parentPath)
console.log(parentPath.slice(1))
prefix = showPrefix(parentPath.slice(1));
return (
<>
{prefix}
{expanded ? fixedExpandLine(record?.nodeLevel) : null}
{expanded ? expandedIcon : unExpandedIcon}
</>
);
}
}
return (
<div style={{width:800,height:600,margin:'0 auto'}}>
<Table
rowKey='uid'
columns={columns}
dataSource={data}
expandable={{
expandedRowKeys,
indentSize: 30,
onExpand: handleExpanded,
expandIcon: handleExpandIconChange,
}}
/>
</div>
)
}
export default TreeTable