使用递归遍历并转换树形数据(以TypeScript为例)

开发 前端
树形数据的遍历有两种方法,大家都知道:广度遍历和深度遍历。一般情况下,广度遍历是采用队列来实现,而深度遍历刚更适合使用递归来实现。

一个朋友问我应该怎么从一个树的 JSON 数组生成 HTML,使用 <ul> 和 <li> 来构建页面元素。于是我简单的画了个树型结构图

 

然后写了对应的模拟数据(JavaScript 对象)

  1. const data = { 
  2.     name"A"
  3.     nodes: [ 
  4.         { name"B", nodes: [{ name"F" }] }, 
  5.         { name"C" }, 
  6.         { 
  7.             name"D"
  8.             nodes: [ 
  9.                 { name"G" }, 
  10.                 { name"H" }, 
  11.                 { name"I", nodes: [{ name"J" }, { name"K" }] } 
  12.             ] 
  13.         }, 
  14.         { name"E" } 
  15.     ] 
  16. };  

***写了一个递归,生成了 HTML 的树型结构。原本是用 JavaScript ES6 写的,为了表明数据结构,这里改用 TypeScript 来写:

  1. interface INode { 
  2.     name: string; 
  3.     nodes?: INode[]; 
  4.  
  5. function makeTree(roots: INode[]): JQuery<HTMLElement> { 
  6.     function makeNode(node: INode): JQuery<HTMLElement> { 
  7.         const $div = $("<div>").text(node.name || ""); 
  8.         const $li = $("<li>").append($div); 
  9.         if (node.nodes && node.nodes.length) { 
  10.             $li.append(makeNodeList(node.nodes)); 
  11.         } 
  12.         return $li; 
  13.     } 
  14.  
  15.     function makeNodeList(nodes: INode[]): JQuery<HTMLElement> { 
  16.         return nodes 
  17.             .map(child => makeNode(child)) 
  18.             .reduce(($ul, $li) => { 
  19.                 return $ul.append($li); 
  20.             }, $("<ul>")); 
  21.     } 
  22.  
  23.     return makeNodeList(roots); 
  24.  

效果还是蛮不错的

 

看看源码(转译成 JS 之后的):http://jsfiddle.net/y7bw4yj2/

然后朋友说没看明白,好吧,那我从头讲起

遍历方法

树形数据的遍历有两种方法,大家都知道:广度遍历和深度遍历。一般情况下,广度遍历是采用队列来实现,而深度遍历刚更适合使用递归来实现。

 

广度遍历

从图上大致可以理解广度遍历的过程:

  1. 准备一个空队列;
  2. 将根(单根或多根均可)节点放到队列中;
  3. 从队列中取出一个节点
  4. 处理(比如打印)这个节点
  5. 检查节点的子节点,如果有,全部依次添加到队列中
  6. 回到第 3 步开始处理,直到队列为空(处理完成)
  1. function travelWidely(roots: INode[]) { 
  2.     const queue: INode[] = [...roots]; 
  3.     while (queue.length) { 
  4.         const node = queue.shift()!; 
  5.         // 打印节点名称及其子节点数 
  6.         console.log(`${node.name} ${node.nodes && node.nodes.length || ""}`); 
  7.         if (node.nodes && node.nodes.length) { 
  8.             queue.push(...node.nodes); 
  9.         } 
  10.     } 
  11.  
  12. // 开始遍历 
  13. travelWidely([data]);  

const node = queue.shift()!,这后面的 ! 后缀表示声明其结果不为 undefined 或 null。这是一个 TypeScript 语法。由于 .shift() 在数组中没有元素时会返回 undefined,所以其返回类型被声明为 INode | undefined,由于从逻辑可以保证 .shift() 一定会返回一个节点对象,所以这里用 ! 后缀忽略类型中的 undefined 部分,使 node 的类型被推导为 INode。

代码里稍难理解一点的是要注意 queue 的内容和长度随时在变化。如果想使用 for 代替 while 循环,节点序号会因 .shift() 而不断变化,所以 i < queue.length 这样的判断是错误的。

深度遍历

深度遍历是一个递归过程,递归一直是编程的难点。

递归是一个循环往复的处理过程,它有两个点需要注意:

  • 递归调用点,递归调用自己(或另一个可能会调用自己的函数)
  • 递归结束点,退出当前函数

以树节点为例,我们期望处理过程是处理(打印)一个树结点,即 printNode(node: INode)。那么它的

  • 递归调用点:如果该节点有子节点,依次对子节点调用 printNode(children[i])
  • 递归结束点:处理完所有子节点(子节点数量是有限的,所以一定会结束)

用一段伪代码描述这一过程

  1. function printNode(node: INode) { 
  2.     // 处理该节点 
  3.     console.log(node.name); 
  4.      
  5.     // 递归调用点:循环对子节点调用 printNode 
  6.     node.nodes!.forEach(child => printNode(child)); 
  7.      
  8.     // 递归结束点:循环完成,return 
  9.  

上面两句代码就完成了递归过程,但实际上情况还要复杂些,因为要处理入口和容错。

  1. // 注意参数支持传入单根或多根, 
  2. // 如果像 travelWidely 那样只支持多根(单根是特例)也是可以的 
  3. function travelDeeply(roots: INode | INode[]) { 
  4.     function printNode(node: INode) { 
  5.         console.log(`${node.name} ${node.nodes && node.nodes.length || ""}`); 
  6.         if (node.nodes && node.nodes.length) { 
  7.             // 依次对子节点递归调用 printNode 
  8.             node.nodes.forEach(child => printNode(child)); 
  9.         } 
  10.     } 
  11.  
  12.     // 这里 printNode 和 node => printNode(node) 等价 
  13.     (Array.isArray(roots) ? roots : [roots]).forEach(printNode); 
  14.  
  15. // 开始遍历 
  16. travelDeeply(data);  

关于递归,我正好在慕课网上讲生成数据解决方案的时候讲到了,有兴趣可以看看。

遍历还没讲完

上面两种遍历都讲到了,但是还没讲完——因为两种遍历都是以打印为例,而我们的目的是要生成 DOM 树。生成 DOM 树与纯打印信息的不同之处在于,我们不仅要使用节点信息,还要从节点信息生成 DOM 返回出来。

深度遍历生成节点

这次先讲深度遍历,因为递归更容易实现。递归本身具有层次信息,每进入一个递归调用点,就会深入一层,每离开一个递归结束点,就会减少一层。所以这个算法本身能够保留结构信息,相应代码也会更容易实现。而且在本文一开始,就已经实现出来了。

需要注意的一点是那段代码用了两个函数来完成递归过程:

  • makeNode 处理单个节点,它调用 makeNodeList 处理子节点列表
  • makeNodeList 遍历节点列表,分别对其调用 makeNode 来进行处理

makeNode 和 makeNodeList 的相互调用形成了递归,上述两条都是递归调用点,而递归结束点同样也有两条:

  • makeNode 处理的节点没有子节点时,不会调用 makeNodeList
  • makeNodeList 中的循环结束时,不会再调用 makeNode

广度遍历生成节点

广度遍历的过程是把所有节点扁平化到一个队列中了,这个过程是不可逆 的,换句话说,我们在处理过程中丢掉了树形结构信息。然后我们要生成的 DOM 树,是需要结构信息的——因此,需要将结构信息附加在每个节点上。这里我们把生成的 DOM 和数据节点绑定起来,由 DOM 保存结构信息。为此,需要修改一下节点类型

  1. interface INode { 
  2.     name: string; 
  3.     nodes?: INode[]; 
  4.     dom: JQuery;    // 附加生成的 DOM 
  5.  
  1. function makeTreeWidely(roots: INode[]): JQuery { 
  2.     // 从一组节点生成 <ul>,为每个节点生成并附加 <li>, 
  3.     // 同时将 <li> 到到 <ul> 中保存结构信息 
  4.     function makeUl(nodes: INode[]) { 
  5.         return nodes 
  6.             .map(node => { 
  7.                 const $li = $("<li>"
  8.                     .append($("<div>").text(node.name || "")); 
  9.                 node.dom = $li; 
  10.                 return $li; 
  11.             }) 
  12.             .reduce(($ul, $li) => $ul.append($li), $("<ul>")); 
  13.     } 
  14.  
  15.     const $rootUl = makeUl(roots); 
  16.  
  17.     const queue: INode[] = [...roots]; 
  18.     while (queue.length) { 
  19.         const node = queue.shift()!; 
  20.  
  21.         if (node.nodes && node.nodes.length) { 
  22.             const $ul = makeUl(node.nodes); 
  23.             node.dom.append($ul); 
  24.             queue.push(...node.nodes); 
  25.         } 
  26.     } 
  27.     return $rootUl; 
  28.  

虽然这里和上面讲递归遍历 printNode 的时候一样定义了局部函数表达式 makeUl,但这里没有递归,因为 makeUl 内部没有调用自身,或者某个会调用 makeUl 的函数。

但问题还是再深入一点,因为上面的代码改变了原数据。而一般情况下,我们应该尽量避免这样的副作用

没有副作用的广度遍历生成节点

  1. // 声明一个新结构,它把 INode 和 DOM 组合在一起。 
  2. // 这个结构将代替 INode 作为队列的元素类型 
  3. interface IDomNode { 
  4.     node: INode; 
  5.     dom: JQuery; 
  6.  
  7. function makeTreeWidely(roots: INode[]): JQuery { 
  8.     // convert 将节点数组转换为 IDomNode 数组, 
  9.     // 同时还干了原来 makeUl 干的事情,返回一个 $ul 
  10.     function convert(nodes: INode[]) { 
  11.         const domNodes = nodes 
  12.             .map(node => { 
  13.                 const $li = $("<li>"
  14.                     .append($("<div>").text(node.name || "")); 
  15.                 return { 
  16.                     node, 
  17.                     dom: $li 
  18.                 }; 
  19.             }); 
  20.  
  21.         const $ul = domNodes 
  22.             .reduce(($ul, dn) => $ul.append(dn.dom), $("<ul>")); 
  23.  
  24.         // 将两个数组组成一个元组(对象)返回 
  25.         return { 
  26.             domNodes, 
  27.             $ul 
  28.         }; 
  29.     } 
  30.  
  31.     // 解析元组,声明变量 queue 和 $rootUl, 
  32.     // 并分别将 domNodes 和 $ul 的值赋值给 queue 和 $rootUl 两个变量 
  33.     const { domNodes: queue, $ul: $rootUl } = convert(roots); 
  34.  
  35.     while (queue.length) { 
  36.         const { node, dom } = queue.shift()!; 
  37.  
  38.         if (node.nodes && node.nodes.length) { 
  39.             const { domNodes, $ul } = convert(node.nodes); 
  40.             dom.append($ul); 
  41.             queue.push(...domNodes); 
  42.         } 
  43.     } 
  44.     return $rootUl; 
  45.  

看疗效:http://jsfiddle.net/y7bw4yj2/1/

责任编辑:庞桂玉 来源: segmentfault
相关推荐

2022-01-10 12:23:00

TypeScript ESLint前端

2011-07-08 09:55:02

数据中心防震

2011-08-04 09:57:03

dbmonsterMySQL

2021-08-02 09:50:47

Vetur源码SMART

2016-12-20 12:34:46

存储MySQL流程

2018-08-22 16:40:51

前端JavascriptVue

2021-05-31 08:00:00

消息队列架构Rabbit MQ

2023-06-26 08:43:57

OracleTRACE叶节点

2020-03-03 09:17:27

图形数据库数据库

2018-01-02 09:00:51

大数据营销王者荣耀

2022-10-14 08:03:46

图形数据库系统

2009-03-02 16:57:34

LinuxUbuntu配置完全方案

2021-01-14 09:00:00

开发FedoraUbuntu

2020-03-06 10:05:15

2022-02-14 14:28:57

驱动开发鸿蒙系统

2021-04-16 08:20:00

Flink CEP直播监控

2018-03-08 18:00:21

数据库MySQL数据库军规

2019-05-20 08:20:40

数据集数据可视化数据

2015-06-25 13:01:28

大数据星巴克海底捞

2015-05-07 11:24:13

DockerIT技术评价新技术
点赞
收藏

51CTO技术栈公众号