惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

酷 壳 – CoolShell
酷 壳 – CoolShell
Microsoft Security Blog
Microsoft Security Blog
Recent Announcements
Recent Announcements
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Last Week in AI
Last Week in AI
罗磊的独立博客
腾讯CDC
云风的 BLOG
云风的 BLOG
月光博客
月光博客
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
博客园 - 三生石上(FineUI控件)
宝玉的分享
宝玉的分享
U
Unit 42
I
InfoQ
D
DataBreaches.Net
Blog — PlanetScale
Blog — PlanetScale
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
V
V2EX
美团技术团队
IT之家
IT之家
Stack Overflow Blog
Stack Overflow Blog
F
Fortinet All Blogs
GbyAI
GbyAI
S
SegmentFault 最新的问题

蚊子的前端博客

微说 | 有的人觉得只要不考XX,一定能考好!-蚊子的前端博客 微说 | 春天来了-蚊子的前端博客 明天和意外不知道哪个先来-蚊子的前端博客 微说 | 既然错过了路口,就及时止损,重新规划路线上路-蚊子的前端博客 微说 | 2025年的出生人口数是792万-蚊子的前端博客 微说 | 工作年终总结,让写对接的接口的数量?-蚊子的前端博客 微说 | 温家宝:没有政治体制改革的成功 经济体制改革不可能进行到底-蚊子的前端博客 微说 | 违法犯罪了该不该被禁言?-蚊子的前端博客 微说 | 不明白为什么在推荐去俄罗斯旅游-蚊子的前端博客 微说 | 易中天论骗子-蚊子的前端博客 微说 | 一场大风吹散了秋天-蚊子的前端博客 微说 | 既要、又要、还要、更要!-蚊子的前端博客 又是一年的国庆雨季-蚊子的前端博客 微说 | 预估下2025年的出生人口数据-蚊子的前端博客 微说 | 可惜了我那些小时候的书本-蚊子的前端博客 微说 | 牛马有的是,驴不够了!-蚊子的前端博客 微说 | 能否有一条非户口也能高考的路-蚊子的前端博客 微说 | 小聊中医-蚊子的前端博客 微说 | 将要制作的一款新产品-蚊子的前端博客 微说 | 做人要有信-蚊子的前端博客 微说 | 好一个正义联盟-蚊子的前端博客 微说 | 都是见过吃过的主儿-蚊子的前端博客 微说 | 追求8小时工作制有错吗?-蚊子的前端博客 微说 | 如果尖锐的批评完全消失-蚊子的前端博客 微说 | 很好!-蚊子的前端博客 微说 | 封禁用户可以,但要告知具体原因-蚊子的前端博客 微说 | 面朝大海,春暖花开-蚊子的前端博客 微说 | 程序员的悲哀是什么?-蚊子的前端博客 微说 | 2025年出生人口的预测-蚊子的前端博客 前端在 LiveKit 中如何获取所有的参与者-蚊子的前端博客
如何将评论数据从扁平数组结构转为树形结构-蚊子的前端博客
author · 2022-02-28 · via 蚊子的前端博客

我们在之前的文章 如何实现一个楼中楼的评论系统,主要讲解了楼中楼评论系统的设计初衷、数据库表的结构,但没有讲解从数据库中拿到数据后,如何转成楼中楼的结构。

1. 定义下数据结构 #

我是使用的 mysql 数据库,mysql 数据库中是以为单位,来存储数据的。

我们从数据库中拿到的数据是一个数组结构,大致如下,我最初考虑楼中楼最多只嵌套一层,因此加了一个pid字段用来表示该评论在哪个评论里:

/**
 * 从数据库中查询到一篇文章里的所有评论
 * id: 当前评论的id
 * pid: 当前评论在哪个评论里,0表示是顶层评论
 * replyid: 该评论回复的是哪个评论,0表示是顶层评论
 * content: 内容
 */
const list = [
  { id: 1, pid: 0, replyid: 0, nick: '111', content: '' },
  { id: 2, pid: 0, replyid: 0, nick: '222', content: '' },
  { id: 3, pid: 2, replyid: 2, nick: '333', content: '' },
  { id: 4, pid: 2, replyid: 3, nick: '444', content: '' },
  { id: 5, pid: 2, replyid: 4, nick: '555', content: '' },
  { id: 6, pid: 1, replyid: 1, nick: '666', content: '' },
  { id: 7, pid: 2, replyid: 3, nick: '777', content: '' },
];

接下来,让我们通过代码来实现下数组转为树(楼中楼)的结构。

2. 嵌套一层的楼中楼 #

我们先实现一个只嵌套一层的树结构。

我们要实现的结构是这样的,若直接回复上层的评论,则省略该评论回复的是谁,若回复的是楼中楼的评论,则展示出回复的是哪条:

- 1
  |- 6
- 2
  |- 3
  |- 4 -> 3
  |- 5 -> 4
  |- 7 -> 3

我们分析下 list 中数据的特点:

  • 每条数据都有个pidreplyid两个字段,指向他的父级,但若 pid 和 replyid 为 0 时,则表示该评论自己就是最顶层的评论;
  • 后一条评论回复之前的评论,pid 和 replyid 两个字段的值一定是存在的(不可能回复一条不存在的评论);

依托于 js 中的对象引用的特性:在不同的地方操作相同的对象,所有使用该对象的数据都会发生变化。其实这并不是一个好的特性,但在这里特别好使。我们可以直接把后面的数据添加到前面的数据字段中。

const listToTree = (list) => {
  const newList = JSON.parse(JSON.stringify(list)); // 避免影响外层的数组
  const map = new Map();
  const result = [];

  newList.forEach((comment) => {
    map.set(comment.id, comment);

    if (comment.pid) {
      // 楼中楼的评论
      const parentComment = map.get(comment.pid); // 回复的该评论,该评论是一定存在的

      if (!parentComment.children) {
        parentComment.children = []; // 通过对象引用,可以直接修改之前的数据
      }
      if (comment.pid !== comment.replyid) {
        comment.replyNick = map.get(comment.replyid).nick;
      }
      parentComment.children.push(comment);
    } else {
      result.push(comment);
    }
  });
  return result;
};

最终实现的效果的示意图:

list2tree

3. 不限制层数的树结构 #

若我们不限制树结构的层数,该怎么实现呢?

我们在这里把 list 数组的结构稍微改下,去掉 pid 的干扰,并再添加几条数据:

/**
 * 从数据库中查询到一篇文章里的所有评论
 * id: 当前评论的id
 * replyid: 该评论回复的是哪个评论,0表示是顶层评论
 * content: 内容
 */
const list = [
  { id: 1, replyid: 0, nick: '111', content: '' },
  { id: 2, replyid: 0, nick: '222', content: '' },
  { id: 3, replyid: 2, nick: '333', content: '' },
  { id: 4, replyid: 3, nick: '444', content: '' },
  { id: 5, replyid: 4, nick: '555', content: '' },
  { id: 6, replyid: 1, nick: '666', content: '' },
  { id: 7, replyid: 3, nick: '777', content: '' },
  { id: 8, replyid: 5, nick: '888', content: '' },
  { id: 9, replyid: 8, nick: '999', content: '' },
  { id: 10, replyid: 9, nick: 'aaa', content: '' },
  { id: 11, replyid: 10, nick: 'bbb', content: '' },
];

其实不限制树结构的层数,要比固定的层数简单一些,不用判断是否要折叠树结构,一直追加下去即可。

3.1 使用循环的方式 #

我们顺着上面循环的方式,来实现下不限制层数的树结构。

const listToTreeDeep = (list) => {
  const newList = JSON.parse(JSON.stringify(list)); // 避免影响之前的数组
  const map = new Map();
  const result = [];

  newList.forEach((comment) => {
    map.set(comment.id, comment);

    if (comment.replyid) {
      // 楼中楼的评论
      const parentComment = map.get(comment.replyid); // 回复的该评论,该评论是一定存在的

      if (!parentComment.children) {
        parentComment.children = [];
      }
      // 这里按照层级来表示回复关系,不再获取要回复的哪个评论
      // if (comment.pid !== comment.replyid) {
      //   comment.replyNick = map.get(comment.replyid).nick;
      // }
      parentComment.children.push(comment);
    } else {
      result.push(comment);
    }
  });
  return result;
};

最终实现的效果示意图:

listToTreeDeep

具体效果可以查看实现的 demo:【 数组转树结构的自定义层数的循环实现方式 】。

3.2 使用递归的方式 #

无限嵌套的结构也可以使用递归的方式,但这并不是最好的方式,因为每次递归都要用当前 id 循环查找一次。

我们在这里用代码实现一次,但不推荐这种方式。

/**
 * @param {any[]} list 评论列表
 * @param {number} replyid 回复的那个评论的id
 **/
const listToTreeRecursion = (list, replyid = 0) => {
  const newList = JSON.parse(JSON.stringify(list)); // 避免影响之前的数组
  const result = [];

  newList.forEach((comment) => {
    if (comment.replyid === replyid) {
      comment.children = listToTreeRecursion(list, comment.id);
      result.push(comment);
    }
  });
  return result;
};

递归中我们不使用额外的数据来存储数据,是因为每次的执行,都会从头循环一遍。

4 自定义层数的树结构 #

若再复杂一点,可以自定义层数 n,前 n-1 层一直向内嵌套,最后的第 n 层采用平铺的方式。即把前面的两种展示方式进行结合。

这里我们依然分成两部分来讲解。

4.1 使用循环的方式 #

上面不限制层数时,直接往其父级的 children 属性中追加即可。但现在可以自定义层数后,就要在循环时,判断当前节点的深度,若该节点的深度小于 设定的层数 n ,则添加到其父级的 children 中;否则就得添加到其深度为 n-1 的祖先节点的 children 里。

我在这里使用了两个字段来进行标记:

  1. deep: 当前节点的深度;
  2. pid: 该节点所属的父级节点是哪个;

具体的代码实现:

/**
 * @param {any[]} list 评论列表
 * @param {number} maxPath 自定义的层数
 **/
const listToTreeSelf = (list, maxPath = 4) => {
  const newList = JSON.parse(JSON.stringify(list));
  const map = new Map();
  const result = [];

  newList.forEach((comment) => {
    map.set(comment.id, comment);

    if (comment.replyid) {
      // 楼中楼的评论
      const parentComment = map.get(comment.replyid);
      comment.deep = parentComment.deep + 1; // 当前深度是父级节点的深度+1

      if (comment.deep >= maxPath) {
        // 若当前节点的深度超过了设定的最大深度
        // 则该节点不能挂载在其父节点了,
        // 通过父节点的pid查找父节点挂载在哪个节点上,
        // 该节点也挂载上面
        const ancestorComment = map.get(parentComment.pid);

        comment.replyNick = parentComment.nick;
        if (!ancestorComment.children) {
          ancestorComment.children = [];
        }

        // 当前节点挂载的节点
        comment.pid = ancestorComment.id;
        ancestorComment.children.push(comment);
      } else {
        // 没有超过设定的最大深度
        // 挂载在其父节点上
        if (!parentComment.children) {
          parentComment.children = [];
        }
        comment.pid = parentComment.id;
        parentComment.children.push(comment);
      }
    } else {
      comment.deep = 0;
      result.push(comment);
    }
  });
  return result;
};

假设我们设定的最大层数为 4,最终的效果是:

listToTreeSelf

4.2 使用递归的方式 #

在使用递归的方式来构建自定义层数的树形结构时,当深度达到设定的最大层级时,就需要转为循环了。然后开始查找该节点下所有的子节点和子孙节点。

注意:这里不要遗漏子孙节点。

const listToTreeSelfRecursion = (list, maxPath = 4, replyid = 0) => {
  const newList = JSON.parse(JSON.stringify(list)); // 避免影响之前的数组
  const result = [];

  newList.forEach((comment) => {
    if (maxPath <= 1) {
      // 到达最内层
      // 查找最直接的子节点
      if (comment.replyid === replyid) {
        result.push(comment);
      } else {
        // 查找所有的子孙节点
        const child = result.find((child) => child.id === comment.replyid);
        if (child) {
          comment.replyNick = child.nick;
          result.push(comment);
        }
      }
    } else {
      // 还可以继续递归
      if (comment.replyid === replyid) {
        comment.children = listToTreeSelfRecursion(list, maxPath - 1, comment.id);
        result.push(comment);
      }
    }
  });
  return result;
};

5. 不规则的数组 #

我们在上面所有的循环实现中,都默认了父节点在前面,毕竟对评论系统而言,肯定现有的父级评论,才能有当前评论。但比如我们把场景扩展到无限嵌套的部门列表中时,就不一定有序了。

最近新创建了一个中间部门,然后把下面所有的部门都归属到这个中间部门里。就会出现父级节点在后面的情况。

这里我们只以不限层数的循环实现方式为例,看下若父级节点在后面,如何实现:

// 把数据调换下位置
const list = [
  { id: 6, pid: 1, replyid: 1, nick: '666', content: '' },
  { id: 7, pid: 2, replyid: 3, nick: '777', content: '' },
  { id: 8, pid: 2, replyid: 5, nick: '888', content: '' },
  { id: 9, pid: 2, replyid: 8, nick: '999', content: '' },
  { id: 10, pid: 2, replyid: 9, nick: 'aaa', content: '' },
  { id: 11, pid: 2, replyid: 10, nick: 'bbb', content: '' },
  { id: 1, pid: 0, replyid: 0, nick: '111', content: '' },
  { id: 2, pid: 0, replyid: 0, nick: '222', content: '' },
  { id: 3, pid: 2, replyid: 2, nick: '333', content: '' },
  { id: 4, pid: 2, replyid: 3, nick: '444', content: '' },
  { id: 5, pid: 2, replyid: 4, nick: '555', content: '' },
];

const listToTreeDeep = (list) => {
  const newList = JSON.parse(JSON.stringify(list)); // 避免影响之前的数组
  const map = new Map();
  const result = [];

  newList.forEach((comment) => {
    // 可能先把该节点的children存储起来
    // 这里把之前存储的数据合并下,然后再存储起来
    comment = { ...comment, ...map.get(comment.id) };
    map.set(comment.id, comment);

    if (comment.replyid) {
      // 楼中楼的评论
      const parentComment = map.get(comment.replyid) || {}; // 以空的Object兜底

      if (!parentComment.children) {
        parentComment.children = [];
      }
      parentComment.children.push(comment);
      map.set(comment.replyid, parentComment);
    } else {
      result.push(comment);
    }
  });
  return result;
};

可见在乱序的情况下,逻辑就要多一些。在可行的情况,我们把父级节点放在前面,但若实现起来比较困难,上面的那种方式也是可行的。

6. 总结 #

我们从最开始的二层楼中楼结构,扩展到了无限层级,最终扩展到了可以自定义层级。就目前递归的实现方式来看,并没有循环的性能好,因为每次递归都要循环一遍数据,若在数组节点比较多时,递归的性能就会直线下降。

下一篇文章,我们再讨论下,如何把树形结构转成数组结构