从列表生成树|从列表生成树 (JavaScript/TypeScript)

多数情况下,从服务端拿到用于树形显示的数据,本身是平面的,也就是列表。这是因为关系型数据库是以“行”为单位保存数据,所以它保存了每一个节点的数据,而这个数据中包含了它与父节点之间的联系(比如 parentId)。
前端要以树形显示这样的列表数据,需要把列表数据转换成树形结构数据。这个的树形结构是指:每个节点数据中都含有其子节点集(通常是 children 属性)。所以树形结节的数据结构主要需要包含如下信息(TypeScript 数据结构声明):

interface TreeNodeBase { id: TKey; parentId: TKey children?: TreeNodeBase[] }

这里使用了 TypeScript 的泛型语法来描述树节点结构。从自然语义不难明白:
  • 树节点的 id(包括 parentId)是 string 或者 number 类型,较为少见的情况下也可能是混合类型。
  • 树节点包含一个 parentId,由于这个 parentId 不是可选的(没用 ? 号声明),所以根节点通常会用一个特殊值,或者不应该存在的 ID 值,比如 0(如果是数据库自增字段,一般会从 1 开始)
  • 树节点包含一个可选的子节点集 children,其每个元素了当前元素是相同的类型
  • 定义 TKey 这个类型参数的作用就是为了约束子节点类型必须和父节点一致,避免父节点的 idstring 类型,子节点的 id 却搞成了 string 这种情况(混合类型 id 的情况不含在内)
科普完树节点的数据结构,再来说转换过程。一般来说可能会在三个阶段进行转换:
  • 后端送出来之前先处理好
  • 前端拿到之后自己转换,再用转换后数组去渲染页面
  • 前端使用的 UI 组件自带转换功能,不需要开发者去操心(比如 zTree
这里就以 JS/TS 为例来说说如何进行转换。语言不重要,重要的是该如何来思考,以及使用什么方法进行转换。这里同时使用 JS 和 TS 的原因在于:带类型的 TS 可以清晰地描述数据结构,而 JS 代码可能多数人看起来更没有压力。
一、准备示例数据(随机产生) 以列表表示的树形数据,其每一条(行)一定需要清楚的描述这个节点的三个要素:
  1. 自身标识(ID),通常用 idkeyname 等属性名,它能唯一标识一个节点
  2. 与父节点的关系,通过使用 parentIdupstreamId 等名称,清楚的指明其父节点
  3. 节点自己携带的数据,比如显示的文本,titlelabel 等,和一些其他数据。
为了快速准备示例数据,我们使用一个最简单,属性意义也非常明确的数据结构。注意,这个结构是与数据表相匹配的平面结构,不含 children 子集。
// TypeScript interface TreeNode { id: number; parentId: number; label: string; }

然后写一段代码来随机生成数据。在这之前,我们约定,有效节点的 id1 开始。如果某个节点的 parentId === 0,则表示该节点没有父节点。思路:
  • 循环产生一组节点,每个节点的 id 就是 序号 + 1(序号是从 0 开始的)
    // JavaScript const nodes = []; count nodesCount = 20; for (let i = 0; i < nodesCount; i++) { nodes.push({ id: i + 1, }) }

  • 接下来,parentId 是之前已经产生的节点,其 ID 范围在区间 [0, i](封闭区间,如果不懂,请复习一下高中数学)。我们随机从这个范围内一个作为其父节点。这里我们需要产生一个随机整数,所以先写一个 randomInt(max)
    // JavaScript function randomInt(max) { return Math.floor(Math.random()); }

    注意 Math.random() 的取值范围是 [0, 1)(左闭右开区间)内的浮点数,所以 randomInt() 的结果集在 [0, max) 范围内。为了保证需要的 [0, i] 内的整数,即 [0, i + 1) 间的整数,需要调用时给参数为 i + 1randomInt(i + 1)
    继续完善上面 node.push( ... ) 中的 parentId 部分:
    { id: i + 1, parentId: randomInt(i + 1) }

  • 下一步,是产生随机的 label。也不定太复杂的规矩,就产生一个由大小写字母及数字组成的,长度在 [5, 15) 范围的随机字符串。由于字符串本身是可以迭代(遍历)的,所以可以用扩展 (Spread) 运算符来转换成数组,从而得到字符集,代码如下:
    // JavaScript const CHARS = ((chars, nums) => { return [...`${chars}${chars.toLowerCase()}${nums}`]; })("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "0123456789");

    其实直接给一个包含所有字符的字符串就可以,但是不想把 a~z 再写一遍,所以用了一个 IIFE 来复用 A~Z
    另外有一点需要注意:字符串本身可以使用 [] 索引运算符来取字符,所以不预先转换成字符数组也是可以的。
    接下来是随机产生字符串。根据长度随机选择 n 个字符,再连接起来即可:
    // JavaScript function randomString(length) { return Array.from( Array(length), () => CHARS[randomInt(CHARS.length)] ).join(""); }

    randomString() 可以产生一个指定长度的随机字符串。这里 Array(length) 会产生一个长度为 length 但不含任何元素的数组,可以用 Array.from() 把它变成含有元素(默认是 undefined)的数组。在转换的过程中,Array.from() 的第二个参数是一个映射函数,跟 Array.prototype.map() 的参数一样。
    现在,我们可以继续完善 node.push(...) 中的 label 部分:
    { id: i + 1, parentId: randomInt(i + 1), label: randomString(5 + randomInt(10))// 长度在 [5, 15) 区间的随机字符串 }

到目前为上,准备示例数据的关键代码都有了,来个完整的
// TypeScriptinterface TreeNode { id: number; parentId: number; label: string; }const CHARS = ((chars, nums) => { return [...`${chars}${chars.toLowerCase()}${nums}`]; })("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "0123456789"); function randomInt(max: number): number { return Math.floor(Math.random() * max); }function randomString(length: number = 10): string { return Array.from( Array(length), () => CHARS[randomInt(CHARS.length)] ).join(""); }function randomTreeNodes(count: number = 20): TreeNode[] { return [...Array(count).keys()] .map(i => ({ id: i + 1, parentId: randomInt(i + 1),// 从已经产生的节点中去随机找一个 label: randomString(5 + randomInt(10))// 随机产生长度为 [5, 15) 的字符串 })); }

完整代码是 TypeScript 写的。如果需要 JavaScript,可以根据上面每一步的关键代码拼出来。或者拿 TypeScript 代码到 TypeScript Playground 中去转换一下。
最后可以直接调用 randomTreeNodes() 来生成我们需要的树形结构:
// JavaScript | TypeScript const treeNodes = randomTreeNodes();

得到的 treeNodes 会用于下面生成树演示代码的数据源。下面是其中一次运行的结果:
[ { id: 1, parentId: 0, label: '8WUg35y' }, { id: 2, parentId: 1, label: 'Pms1S5Mx' }, { id: 3, parentId: 1, label: 'RUTKSF' }, { id: 4, parentId: 1, label: 'IYkxXlhmU12x' }, { id: 5, parentId: 4, label: 'p2Luabg9mK2' }, { id: 6, parentId: 0, label: 'P6mtcgfCD' }, { id: 7, parentId: 1, label: 'yluJgpnqKthR' }, { id: 8, parentId: 6, label: 'm6o5UsytQ0' }, { id: 9, parentId: 2, label: 'glcR5yGx' }, { id: 10, parentId: 0, label: 'lhDGTNeeSxLNJ' }, { id: 11, parentId: 1, label: 'r7ClxBCQS6' }, { id: 12, parentId: 7, label: '5W6vy0EuvOjN' }, { id: 13, parentId: 5, label: 'LbpWq' }, { id: 14, parentId: 6, label: 'ysYwG8EFLAu1a' }, { id: 15, parentId: 8, label: 'R2PmAh1' }, { id: 16, parentId: 10, label: 'RKuQs4ki65wo' }, { id: 17, parentId: 10, label: 'YN88ixWO1PY7f4' }, { id: 18, parentId: 13, label: '03X6e4UT' }, { id: 19, parentId: 7, label: 'LTJTeF' }, { id: 20, parentId: 19, label: '3rqUqE3MLShh' } ]

如果用图形来表示就是:
flowchart LR %%{ init: { "theme": "forest" } }%%S(("Virtual\nRoot")) --> N1 S --> N6 S --> N10N1("1 | 8WUg35y") --> N2("2 | Pms1S5Mx") N1 --> N3("3 | RUTKSF") N1 --> N4("4 | IYkxXlhmU12x") N4 --> N5("5 | p2Luabg9mK2") N6("6 | P6mtcgfCD") N1 --> N7("7 | yluJgpnqKthR") N6 --> N8("8 | m6o5UsytQ0") N2 --> N9("9 | glcR5yGx") N10("10 | lhDGTNeeSxLNJ") N1 --> N11("11 | r7ClxBCQS6") N7 --> N12("12 | 5W6vy0EuvOjN") N5 --> N13("13 | LbpWq") N6 --> N14("14 | ysYwG8EFLAu1a") N8 --> N15("15 | R2PmAh1") N10 --> N16("16 | RKuQs4ki65wo") N10 --> N17("17 | YN88ixWO1PY7f4") N13 --> N18("18 | 03X6e4UT") N7 --> N19("19 | LTJTeF") N19 --> N20("20 | 3rqUqE3MLShh")

Mermaid 是个好东西,思否支持哦!
二、从演示数据生成树
在思路没有完全形成之前,拿起键盘就开始敲代码 —— 这种行为一般算作“实验”。不过即使是实验,也应该先捋捋思路。
目前已知,每个节点上已经包括了关键数据:用于识别节点的 id,用于识别其父级关系的 parentId。那么,只需要在处理某个节点时,根据其 parentId 找到父节点,并在父节点的 children[] 数组中加入当前节点即可生成树形结构的数据。这里还要考虑几个相关问题:
  1. 由于一个节点只有一个 parentId,所以它最终只会添加到某一个节点的 children[] 中,不可能出现在多个节点的 children[] 中;
  2. 对于没有 parentId 或者 parentId0 的节点,我们认为是根节点。但它有可能不是唯一根节点,所以我们需要一个额外的 roots[] 数组来保存所有根节点。
  3. 思考:该怎样根据 parentId 来找到对应的节点数据?
前两个问题好理解,第 3 个问题需要思考算法。由于节点列表中存在父节点,所以可以直接拿 parentId 在节点列表中去查找
// JavaScript const parentNode = treeNodes.find(node => node.id === parentId);

在遍历处理节点的过程中,根据上面生成数据的逻辑,可以断定当前节点的父节点一定在它之前。如果知道父节点和子节点之间关系较近,可以优化为逆序查找。这个过程可以定义成一个函数 findParent()
// JavaScript /** * @param id 要查找的父节点 id * @param index 当前节点在 treeNodes 中的序号 */ const findParent = (id, index) => { for (let i = index - 1; i >= 0; i--) { if (treeNodes[i].id === id) { return treeNodes[i]; } } };

实际上,多数情况下并不清楚要查找到父节点到底是离起始位置近还是离子节点近,所以完全没必要去写个逆序查找,用 Array.prototype.find() 就好了。
找到父节点之后,在把当前节点 node 加入到 parentNode 子节点集之前,特别要注意其子节点集是否存在。可以使用 Logical nullish assignment (??=) 运算符来简化代码,一句搞定:
(parentNode.children ??= []).push(node);

这里还有一个性能相关的问题。在数据量较大的情况下,不管顺序还是逆序查找都可能扫过非常多的节点,使用 Map 可以大大提高查找效率。在节点有序(即父节点一定在前面)的情况下,Map 可以在遍历的同时生成。相对完整的代码示例:
// JavaScriptfunction makeTree(treeNodes) { const nodesMap = new Map(); const roots = []; treeNodes.forEach((node, i) => { nodesMap.set(node.id, node); if (!node.parentId) { roots.push(node); return; }const parent = nodesMap.get(node.parentId); (parent.children ??= []).push(node); }); return roots; }

上面这段 JavaScript 代码,如果改成 TypeScript 代码,在补充了类型声明的情况下,仍然会有一个问题:接近结尾处的 parent.children 会标红 parent 并报告“Object is possibly 'undefined'.”。
这个问题说明根据 parentId 在 Map 中查找父节点时,存在找不到的可能性。
在这个示例中,由于我们生成 treeNodes 的代码可以保证一定找得到,可以忽略编译器的担忧,直接改为 parent!.children 隐藏掉这个风险提示即可。但是,真实从后台过来的数据并不能保证 nodesMap.get(node.parentId) 不会返回 undefined。至少存在两种造成这个问题的情况:
  1. 节点顺序并不是按先父后子的顺序(多数是发生在移动过节点之后)。这种情况下,由于父节点在子节点之后,还没加入 Map 就要从里面查找,当然是找不到的。要解决这个问题,只需要提交遍历所有节点生成完整的 Map 就好。
  2. 由于后端失误或业务需要,未能把所有节点都送回来。那前端除了报错之外,还有两种容错处理方式:
    1. 丢弃没有找到父节点的节点。这种容错方式处理起来不难,就不多说了。
    2. 把没有父节点的节点当作根节点 —— 既然数据都来了,多数情况下会采用这种容错方式
接下来就是加入容错处理的 makeTree
三、从列表生成树的完整 TypeScript 代码
interface TreeNode { id: number; parentId: number; label: string; children?: TreeNode[] }function makeTree(treeNodes: TreeNode[]): TreeNode[] { // 提前生成节点查找表。 // 如果明确节点是顺序可以保证先父后子,可以省去这次遍历,在后面边遍历过程中填充查找表 const nodesMap = new Map( treeNodes.map(node => [node.id, node]) ); // 引入虚拟根节点来统一实现 parent 始终有效,避免空判断 const virtualRoot = { } as Partial; treeNodes.forEach((node, i) => { const parent = nodesMap.get(node.parentId) ?? virtualRoot; (parent.children ??= []).push(node); }); return virtualRoot.children ?? []; }

是的,这段代码并不长。但是,
  • 层层递进的分析和处理过程有没有 Get 到?
  • TypeScript 的类型检查有没有打动到你?
来我的课堂:TypeScript从入门到实践 【2021 版】 - 思否编程,你可以
  • 深入理解 TypeScript 语言特性,编写高质量代码
  • 掌握基于 TypeScript 的 Vue 前端、 Koa 后端技术运用
  • 掌握前后端分离的开发模式、设计模式和发布方法
  • 将类型系统融入编程思维,提升理解能力和设计能力
【从列表生成树|从列表生成树 (JavaScript/TypeScript)】从列表生成树|从列表生成树 (JavaScript/TypeScript)
文章图片

    推荐阅读