JS把二叉树展示在页面上,二叉树转DOM

241 阅读5分钟

目录

一、效果图

二、代码

1. 二叉树的节点类

2. 根据层序数组构造二叉树

3、二叉树生成dom

4. html与css

三、完整示例


一、效果图

支持空节点。

在连线的同时,把null的值也展示出来了,这样节点之间的关系也很清晰

二叉树初始数据: [3, 1, 4, null, 2, null, null, 1] 和 [3, 1, 4, 5, 6, 7, 8, 9, 10]

生成二叉树后展示为:

二、代码

        为了方便获取真正的二叉树,下面会给出 从层序数组构造二叉树,然后再从树生成展示在页面上的DOM树 的代码。

        这里的代码比较分散,如果想直接复制代码查看示例的,可以往下滑动到第三点

****原理:把二叉树转为层序数组,然后挂载在页面上,使用css的flex布局达成效果,然后再计算节点与其父节点的中心坐标,根据勾股定理算出角度,创建出盒子,利用css3的旋转达成斜线效果

1. 二叉树的节点类

        /** 二叉树节点类,与Leecode的定义方法相同
         * val: number  当前节点值
         * left: TreeNode 左节点
         * right: TreeNode 右节点
         */
        class TreeNode {
            val;
            left;
            right;
            constructor(val, left, right) {
                this.val = (val === undefined ? 0 : val);
                this.left = (left === undefined ? null : left);
                this.right = (right === undefined ? null : right);
            }
        }

2. 根据层序数组构造二叉树

        /** 根据层序数组构造二叉树 ,比如 [1,2,3] 变为  根1 左2 右3
         * @param arr 传入的数组
         * @returns TreeNode 这个树的根节点
         */
        const buildTree = (arr) => {
            if (arr.length === 0)
                return null;
            let i = 0; //i每次用完都需要自增1,因为层序构造依赖于数组的索引
            let root = new TreeNode(arr[i++]);
            let NodeList = [root];
            while (NodeList.length) {
                let node = NodeList.shift();
                if (arr[i] !== null) { //如果是空的就不创建节点
                    node.left = new TreeNode(arr[i]); //创建左节点
                    NodeList.push(node.left);
                }
                i++; //不管是不是空的,i都需要自增
                if (i == arr.length)
                    return root; //如果长度已经够了就返回,免得数组索引溢出
                if (arr[i] !== null) { //如果是空的就不创建节点
                    node.right = new TreeNode(arr[i]); //创建右节点
                    NodeList.push(node.right);
                }
                i++; //不管是不是空的,i都需要自增
                if (i == arr.length)
                    return root; //如果长度已经够了就返回,免得数组索引溢出
            }
            return root;
        };

3、二叉树生成dom

         会先把二叉树转为层序数组,然后再生成DOM,然后再连线。在转为层序数组的过程中,会保留其中为null的节点,这样方便我们生成dom来展示

        其中treeToDom函数,需要传递的第二个参数是 想挂载在html的哪个节点的id,不传递时将直接挂载在body上。

        注:outTree 函数的参数,二叉树中节点值不能为0,否则该节点展示在页面上时会变为null,原因为:res.push(node.left?.val || null)这里使用了 || 运算符 来判断应该填充哪个值进入结果数组,你可以修改这里的逻辑,使得二叉树的节点值能为0(我懒得改了)

        /** 层序遍历二叉树,输出数组   特化版:保留null */
        const outTree = (root) => {
            let res = [];
            let queue = [];
            if (root) {
                res.push(root.val);
                queue.push(root);
            }
            let len;
            while (queue.length) {
                len = queue.length;
                // console.log(JSON.parse(JSON.stringify(queue)));
                for (let i = 0; i < len; i++) {
                    let node = queue.shift();
                    if (node.left)
                        queue.push(node.left);
                    res.push(node.left?.val || null); //当前节点的左边没数值就放入null,否则放入左节点值
                    if (node.right)
                        queue.push(node.right);
                    res.push(node.right?.val || null); //当前节点的右边没数值就放入null,否则放入右节点值
                }
            }
            // 最后一层的左右子树虽为空,但是也放入了null,需要删除数组末尾无用的null
            // 方法一: 只要是末尾的null都剔除
            for (let i = res.length - 1; i >= 0; i--) {
                if (res[i] === null)
                    res.length--; //遇到null就长度-1
                else
                    break; //一旦遇到了不是null的,就立马退出
            }
            //方法二:根据最后一层的节点个数,剔除 len * 2个null
            // res.length -= len * 2 //这个会保留最后一层的null,比如[50, 30, 70, 8, 40, 60],在最后一层其实是 8 40 60 null,这个方法会保留这个null,上面的不会
            return res;
        };
        /** 二叉树生成dom,传入层序数组和要挂载的节点id,并返回原本的层序数组便于打印 不传入nodeId或id不存在时,将挂载在body上 */
        const treeToDom = (arr, nodeId) => {
            try {
                //#region 先把数组截取为二维数组,每个一维数组代表一层
                let spiltArr_Arr = [];
                let index = 0;
                while (index < arr.length) {
                    let endIndex = index * 2 + 1; //要截取的结尾索引
                    let spiltArr = arr.slice(index, endIndex);
                    index = endIndex; //设置下一个起点 
                    spiltArr_Arr.push(spiltArr);
                }
                // console.log('spiltArr_Arr', jsonDeep(spiltArr_Arr));
                //#endregion
                //#region 然后每一层都转为dom,并插入页面
                let dom = ``;
                let line = ``;
                let item = ``;
                for (let i = 0; i < spiltArr_Arr.length; i++) {
                    const element = spiltArr_Arr[i];
                    //第二层遍历:遍历每一层并生成dom   
                    for (let j = 0; j < element.length; j++) {
                        if (i - 1 >= 0 && element[j]) { //当该元素不为null时,判断父亲是否为null,如果是的话说明不是真正的父亲,需要往后顺延
                            let parentNode = spiltArr_Arr[i - 1][parseInt((j / 2) + '')];
                            // console.log(j, spiltArr_Arr[i - 1], parseInt((j / 2) + ''), parentNode);
                            if (parentNode === null)
                                element.splice(j, 0, null);
                        }
                        const element2 = element[j];
                        item += `<div class="node node${j + (2 ** i) - 1} ${i === 0 ? 'root' : ''} ${element2 === null ? 'null' : ''}">${element2}</div>`;
                        if (j === element.length - 1 && element.length < 2 ** i) { //如果j为最后一个了,发现最后一层长度不满,就需要填充null 
                            while (element.length < 2 ** i)
                                element.push(null); //防止有的末尾没元素,导致页面变形,把数组填充为满二叉树,只不过是填充null 
                        }
                    }
                    line = `<div class="line">${item}</div>`;
                    dom += line;
                    item = '';
                    line = '';
                }
                let _node = document.getElementById(nodeId);
                if (_node === null) {
                    _node = document.createElement('div');
                    document.querySelector('body').append(_node);
                }
                _node.className = 'tree';
                let _treedoc = new DOMParser().parseFromString(dom, 'text/html'); //将字符串转换为document文档对象 
                let _root = _treedoc.querySelectorAll('.line');
                for (let i = 0; i < _root.length; i++)
                    _node.appendChild(_root[i]); //把所有节点都挂载上去  
                //#endregion
                //#region 连线
                let newArr = spiltArr_Arr.flat(); //这里使用前面处理好了的层序数组,拍平,可以实现: 当前正常节点没有父亲时往后顺延 
                for (let i = newArr.length - 1; i > 0; i--) {
                    //找自己节点和父节点,因为自己肯定有父节点,但自己不一定有子节点,所以还是需要从子找父亲
                    let node = _node.querySelector(`.node${i}`);
                    let parentNode = _node.querySelector(`.node${parseInt((i - 1) / 2 + '')}`);
                    // console.log([node, parentNode]);
                    if (newArr[i] !== null) { //当前节点不是null才画线  
                        drawLine(node, parentNode);
                    }
                }
                //#endregion
                return arr;

            } catch (error) {
                console.warn('请检查二叉树是否正确! ');
                console.error(error);
            }
        };
        /**获得该元素的中心位置
         * @param element dom元素
         * @returns 返回该元素的中心坐标对象 left,top
         */
        const getCenter = (element) => {
            const rect = element.getBoundingClientRect();
            //初始中心位置
            const center = {
                left: rect.left + (rect.right - rect.left) / 2,
                top: rect.top + (rect.bottom - rect.top) / 2
            };
            //加上屏幕滚动偏移量
            const scrollLeft = document.body.scrollLeft || document.documentElement.scrollLeft;
            const scrollTop = document.body.scrollTop || document.documentElement.scrollTop;
            //最终的 左 和 上位置
            center.left = scrollLeft + center.left;
            center.top = scrollTop + center.top;
            return center;
        };
        /**两点之间连线,传入元素会自动计算中心点
         * @param startElement 开始点的dom元素
         * @param endElement 结束点的dom元素
         */
        const drawLine = (startElement, endElement) => {
            // 起点元素中心坐标
            const start = getCenter(startElement);
            const startY = start.top;
            const startX = start.left;
            // 终点元素中心坐标
            const end = getCenter(endElement);
            const endY = end.top;
            const endX = end.left;
            // 用勾股定律计算出斜边长度及其夹角(即连线的旋转角度)
            const lx = endX - startX;
            const ly = endY - startY;
            // 计算连线长度
            const length = Math.sqrt(lx * lx + ly * ly);
            // 弧度值转换为角度值
            const c = 360 * Math.atan2(ly, lx) / (2 * Math.PI);
            // 连线中心坐标
            const midX = (endX + startX) / 2;
            const midY = (endY + startY) / 2;
            const deg = c <= -90 ? (360 + c) : c; // 负角转换为正角
            const dom = `<div class="drawLine" style="top:${midY}px;left:${midX - length / 2}px;width: ${length}px;transform: rotate(${deg}deg);"></div>`;
            let _treedoc = new DOMParser().parseFromString(dom, 'text/html'); //将字符串转换为document文档对象 
            let _root = _treedoc.querySelector('.drawLine');
            startElement.appendChild(_root);
        };

4. html与css

// 可以指定挂载节点,也可以不指定挂载节点,详见下面示例 
<div id="tree"></div>  
        body {
            background-color: antiquewhite;
        }

        .tree {
            z-index: 66;
        }

        .tree .line {
            display: flex;
            justify-content: space-around;
            margin-top: 40px;
        }

        .tree .line .node {
            width: 50px;
            height: 50px;
            line-height: 50px;
            text-align: center;
            border: 2px solid black;
            border-radius: 50%;
            background-color: white;
        }

        .tree .line .node .drawLine {
            position: absolute;
            border: 1px solid black;
            z-index: -1;
        }

        .tree .line .null {
            opacity: 0.1;
            border: 2px dashed black;
        }

        * {
            margin: 0;
            padding: 0;
        }

三、完整示例

        复制粘贴即可使用,在script标签的最下面可以查看示例数据

        注:outTree 函数的参数,二叉树中节点值不能为0,否则该节点展示在页面上时会变为null,原因为:res.push(node.left?.val || null)这里使用了 || 运算符来判断应该填充哪个值进入结果数组,你可以修改这里的逻辑,使得二叉树的节点值能为0 (我懒得改了)

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>生成二叉树</title>
    <style>
        body {
            background-color: antiquewhite;
        }

        .tree {
            z-index: 66;
        }

        .tree .line {
            display: flex;
            justify-content: space-around;
            margin-top: 40px;
        }

        .tree .line .node {
            width: 50px;
            height: 50px;
            line-height: 50px;
            text-align: center;
            border: 2px solid black;
            border-radius: 50%;
            background-color: white;
        }

        .tree .line .node .drawLine {
            position: absolute;
            border: 1px solid black;
            z-index: -1;
        }

        .tree .line .null {
            opacity: 0.1;
            border: 2px dashed black;
        }

        * {
            margin: 0;
            padding: 0;
        }
    </style>
</head>

<body>
    <div id="tree"></div>
    <script>
        //————测试数据在最下面 
        /** 二叉树节点类,与Leecode的定义方法相同
         * val: number  当前节点值
         * left: TreeNode 左节点
         * right: TreeNode 右节点
         */
        class TreeNode {
            val;
            left;
            right;
            constructor(val, left, right) {
                this.val = (val === undefined ? 0 : val);
                this.left = (left === undefined ? null : left);
                this.right = (right === undefined ? null : right);
            }
        }
        /** 根据层序数组构造二叉树 ,比如 [1,2,3] 变为  根1 左2 右3
         * @param arr 传入的数组
         * @returns TreeNode 这个树的根节点
         */
        const buildTree = (arr) => {
            if (arr.length === 0)
                return null;
            let i = 0; //i每次用完都需要自增1,因为层序构造依赖于数组的索引
            let root = new TreeNode(arr[i++]);
            let NodeList = [root];
            while (NodeList.length) {
                let node = NodeList.shift();
                if (arr[i] !== null) { //如果是空的就不创建节点
                    node.left = new TreeNode(arr[i]); //创建左节点
                    NodeList.push(node.left);
                }
                i++; //不管是不是空的,i都需要自增
                if (i == arr.length)
                    return root; //如果长度已经够了就返回,免得数组索引溢出
                if (arr[i] !== null) { //如果是空的就不创建节点
                    node.right = new TreeNode(arr[i]); //创建右节点
                    NodeList.push(node.right);
                }
                i++; //不管是不是空的,i都需要自增
                if (i == arr.length)
                    return root; //如果长度已经够了就返回,免得数组索引溢出
            }
            return root;
        };
        /** 层序遍历二叉树,输出数组   特化版:保留null */
        const outTree = (root) => {
            let res = [];
            let queue = [];
            if (root) {
                res.push(root.val);
                queue.push(root);
            }
            let len;
            while (queue.length) {
                len = queue.length;
                // console.log(JSON.parse(JSON.stringify(queue)));
                for (let i = 0; i < len; i++) {
                    let node = queue.shift();
                    if (node.left)
                        queue.push(node.left);
                    res.push(node.left?.val || null); //当前节点的左边没数值就放入null,否则放入左节点值
                    if (node.right)
                        queue.push(node.right);
                    res.push(node.right?.val || null); //当前节点的右边没数值就放入null,否则放入右节点值
                }
            }
            // 最后一层的左右子树虽为空,但是也放入了null,需要删除数组末尾无用的null
            // 方法一: 只要是末尾的null都剔除
            for (let i = res.length - 1; i >= 0; i--) {
                if (res[i] === null)
                    res.length--; //遇到null就长度-1
                else
                    break; //一旦遇到了不是null的,就立马退出
            }
            //方法二:根据最后一层的节点个数,剔除 len * 2个null
            // res.length -= len * 2 //这个会保留最后一层的null,比如[50, 30, 70, 8, 40, 60],在最后一层其实是 8 40 60 null,这个方法会保留这个null,上面的不会
            return res;
        };
        /** 二叉树生成dom,传入层序数组和要挂载的节点id,并返回原本的层序数组便于打印 不传入nodeId或id不存在时,将挂载在body上 */
        const treeToDom = (arr, nodeId) => {
            try {
                //#region 先把数组截取为二维数组,每个一维数组代表一层
                let spiltArr_Arr = [];
                let index = 0;
                while (index < arr.length) {
                    let endIndex = index * 2 + 1; //要截取的结尾索引
                    let spiltArr = arr.slice(index, endIndex);
                    index = endIndex; //设置下一个起点 
                    spiltArr_Arr.push(spiltArr);
                }
                // console.log('spiltArr_Arr', jsonDeep(spiltArr_Arr));
                //#endregion
                //#region 然后每一层都转为dom,并插入页面
                let dom = ``;
                let line = ``;
                let item = ``;
                for (let i = 0; i < spiltArr_Arr.length; i++) {
                    const element = spiltArr_Arr[i];
                    //第二层遍历:遍历每一层并生成dom   
                    for (let j = 0; j < element.length; j++) {
                        if (i - 1 >= 0 && element[j]) { //当该元素不为null时,判断父亲是否为null,如果是的话说明不是真正的父亲,需要往后顺延
                            let parentNode = spiltArr_Arr[i - 1][parseInt((j / 2) + '')];
                            // console.log(j, spiltArr_Arr[i - 1], parseInt((j / 2) + ''), parentNode);
                            if (parentNode === null)
                                element.splice(j, 0, null);
                        }
                        const element2 = element[j];
                        item += `<div class="node node${j + (2 ** i) - 1} ${i === 0 ? 'root' : ''} ${element2 === null ? 'null' : ''}">${element2}</div>`;
                        if (j === element.length - 1 && element.length < 2 ** i) { //如果j为最后一个了,发现最后一层长度不满,就需要填充null 
                            while (element.length < 2 ** i)
                                element.push(null); //防止有的末尾没元素,导致页面变形,把数组填充为满二叉树,只不过是填充null 
                        }
                    }
                    line = `<div class="line">${item}</div>`;
                    dom += line;
                    item = '';
                    line = '';
                }
                let _node = document.getElementById(nodeId);
                if (_node === null) {
                    _node = document.createElement('div');
                    document.querySelector('body').append(_node);
                }
                _node.className = 'tree';
                let _treedoc = new DOMParser().parseFromString(dom, 'text/html'); //将字符串转换为document文档对象 
                let _root = _treedoc.querySelectorAll('.line');
                for (let i = 0; i < _root.length; i++)
                    _node.appendChild(_root[i]); //把所有节点都挂载上去  
                //#endregion
                //#region 连线
                let newArr = spiltArr_Arr.flat(); //这里使用前面处理好了的层序数组,拍平,可以实现: 当前正常节点没有父亲时往后顺延 
                for (let i = newArr.length - 1; i > 0; i--) {
                    //找自己节点和父节点,因为自己肯定有父节点,但自己不一定有子节点,所以还是需要从子找父亲
                    let node = _node.querySelector(`.node${i}`);
                    let parentNode = _node.querySelector(`.node${parseInt((i - 1) / 2 + '')}`);
                    // console.log([node, parentNode]);
                    if (newArr[i] !== null) { //当前节点不是null才画线  
                        drawLine(node, parentNode);
                    }
                }
                //#endregion
                return arr;

            } catch (error) {
                console.warn('请检查二叉树是否正确! ');
                console.error(error);
            }
        };
        /**获得该元素的中心位置
         * @param element dom元素
         * @returns 返回该元素的中心坐标对象 left,top
         */
        const getCenter = (element) => {
            const rect = element.getBoundingClientRect();
            //初始中心位置
            const center = {
                left: rect.left + (rect.right - rect.left) / 2,
                top: rect.top + (rect.bottom - rect.top) / 2
            };
            //加上屏幕滚动偏移量
            const scrollLeft = document.body.scrollLeft || document.documentElement.scrollLeft;
            const scrollTop = document.body.scrollTop || document.documentElement.scrollTop;
            //最终的 左 和 上位置
            center.left = scrollLeft + center.left;
            center.top = scrollTop + center.top;
            return center;
        };
        /**两点之间连线,传入元素会自动计算中心点
         * @param startElement 开始点的dom元素
         * @param endElement 结束点的dom元素
         */
        const drawLine = (startElement, endElement) => {
            // 起点元素中心坐标
            const start = getCenter(startElement);
            const startY = start.top;
            const startX = start.left;
            // 终点元素中心坐标
            const end = getCenter(endElement);
            const endY = end.top;
            const endX = end.left;
            // 用勾股定律计算出斜边长度及其夹角(即连线的旋转角度)
            const lx = endX - startX;
            const ly = endY - startY;
            // 计算连线长度
            const length = Math.sqrt(lx * lx + ly * ly);
            // 弧度值转换为角度值
            const c = 360 * Math.atan2(ly, lx) / (2 * Math.PI);
            // 连线中心坐标
            const midX = (endX + startX) / 2;
            const midY = (endY + startY) / 2;
            const deg = c <= -90 ? (360 + c) : c; // 负角转换为正角
            const dom = `<div class="drawLine" style="top:${midY}px;left:${midX - length / 2}px;width: ${length}px;transform: rotate(${deg}deg);"></div>`;
            let _treedoc = new DOMParser().parseFromString(dom, 'text/html'); //将字符串转换为document文档对象 
            let _root = _treedoc.querySelector('.drawLine');
            startElement.appendChild(_root);
        };

        /**二叉树测试数据 */
        const inData = [3, 1, 4, null, 2, null, null, 1];
        const inData2 = [3, 1, 4, 1, 23, 4, 5, 6, 7, 8, 9, null, 10, null, 11];
        treeToDom(inData, 'tree');//指定挂载节点
        treeToDom(inData2);//不指定挂载节点
    </script>
</body>

</html>