Bootstrap

为antd树形table加上连接线

antd: ^5.18.3

业务说:这个table缩进层级看不太出来,加个连接线吧,吭哧吭哧翻文档发现没有官方支持,又不想去换其他组件,只能自己实现,先看一下最终效果(demo):
添加了连接线的树形table
接下来讲一下详细思路和实现,也是记录一下,可能不是最优,欢迎讨论

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. 实现

入手

思路是有了,但是通过antdtable的哪个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>
    );
};

开始绘制

最最最重要的来了,我们要根据各种情况来将这些连接线组合显示,这里可以大概分为三种情况:

  1. 根节点:只需要展开时显示短线即可。
  2. 爷级是最后一个节点:展示L形连接线或T形连接线+表示层级缩进的I形线
  3. 爷级不是最后一个节点:仅展示L形连接线或T形连接线
    (应该没分错吧)
    因此,在每次展开的时候,我们都需要知道根节点到该节点的路径信息,包括路径上每个节点的nodeLevelnodeType,此时,我们就需要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
;