欢迎光临
我们一直在努力

后台菜单总是扁平数据?2 种写法搞定列表转树,直接能用

做管理后台时,你一定遇到过这种数据:
后端从 MySQL 查出来是一张表。
前端拿到的是一个扁平列表。
但页面要渲染的却是:

  • 多级菜单
  • 地址省市区
  • 部门组织架构
  • 权限树
  • 分类树
  • 级联选择器
    这时候就绕不开一个高频问题:
    怎么把 list 转成 tree?
    这篇文章直接用一个真实场景讲清楚。
    你能学到:
  • 什么是扁平列表
  • 为什么 parentId 是树结构的关键
  • 如何用 Map 实现列表转树
  • 如何用 reduce 写出更简洁的版本
  • 实战中容易踩哪些坑
    代码都可以直接复制运行。

在这里插入图片描述

一、实战场景:后端返回的是列表,前端需要的是树

比如后台菜单。
数据库里一般不会直接存树。
它通常是一张表:

idnameparentId
1 一级菜单A 0
2 一级菜单B 0
3 二级A-1 1
4 三级A-1-1 3
5 二级B-1 2

后端查出来后,大概率长这样:

const flatList = [
{ id: 1, name: '一级菜单A', parentId: 0 },
{ id: 2, name: '一级菜单B', parentId: 0 },
{ id: 3, name: '二级A-1', parentId: 1 },
{ id: 4, name: '三级A-1-1', parentId: 3 },
{ id: 5, name: '二级B-1', parentId: 2 }
]

这是一个一维数组。
每一项都不是数组。
它只通过 parentId 表示父子关系。
但前端菜单组件想要的数据通常是这样:

[
{
id: 1,
name: '一级菜单A',
parentId: 0,
children: [
{
id: 3,
name: '二级A-1',
parentId: 1,
children: [
{
id: 4,
name: '三级A-1-1',
parentId: 3,
children: []
}
]
}
]
},
{
id: 2,
name: '一级菜单B',
parentId: 0,
children: [
{
id: 5,
name: '二级B-1',
parentId: 2,
children: []
}
]
}
]

这就是典型的列表转树。


二、核心思路:先建索引,再找爸爸

不要一上来就递归。

最稳的思路是两步:

  • 先把所有节点放到一个字典里
  • 再遍历列表,通过 parentId 找父节点
  • 用一句话理解:

    每个节点先准备好自己的 children,然后把自己挂到父节点的 children 下面。

    关键就是这个字段:

    parentId

    如果一个节点的 parentId 能在字典里找到对应节点,说明它有父级。

    如果找不到,说明它就是顶层节点。

    在这个例子里:

    { id: 1, name: '一级菜单A', parentId: 0 }

    parentId 是 0。

    列表里没有 id = 0 的节点。

    所以它就是一级菜单。


    三、写法一:用 Map 实现,清晰好懂

    这是最推荐新手先掌握的写法。

    逻辑非常直观。

    const flatList = [
    { id: 1, name: '一级菜单A', parentId: 0 },
    { id: 2, name: '一级菜单B', parentId: 0 },
    { id: 3, name: '二级A-1', parentId: 1 },
    { id: 4, name: '三级A-1-1', parentId: 3 },
    { id: 5, name: '二级B-1', parentId: 2 }
    ]

    function listToTree(list) {
    const map = new Map()
    const tree = []

    list.forEach((item) => {
    map.set(item.id, {
    item,
    children: []
    })
    })

    list.forEach((item) => {
    const current = map.get(item.id)
    const parent = map.get(item.parentId)

    if (parent) {
    parent.children.push(current)
    } else {
    tree.push(current)
    }
    })

    return tree
    }

    console.log(JSON.stringify(listToTree(flatList), null, 2))

    这段代码做了什么?

    第一轮遍历:

    map.set(item.id, {
    item,
    children: []
    })

    把每个节点都放进 Map。
    并且给每个节点补一个 children。
    这样后面挂子节点时就不用判断 children 是否存在。
    第二轮遍历:

    const current = map.get(item.id)
    const parent = map.get(item.parentId)

    拿到当前节点。

    再通过 parentId 找父节点。

    如果父节点存在:

    parent.children.push(current)

    就把当前节点塞进父节点的 children。

    如果父节点不存在:

    tree.push(current)

    说明它是根节点。

    四、为什么不推荐直接嵌套循环?

    很多人第一次写会这样想:
    拿一个节点。
    再去数组里找它的子节点。
    再继续找孙子节点。
    这种写法不是不能做。
    但问题很明显:
    数据量一大,性能会变差。
    如果每个节点都要去列表里查一遍,复杂度很容易变成 O(n²)。
    而上面的 Map 写法只需要两次遍历。
    整体是:

    O(n)

    管理后台里菜单可能不多。
    但部门、地区、商品分类、权限点可能非常多。
    所以建议一开始就养成用索引表的习惯。


    五、写法二:用 reduce 实现,更适合封装工具函数

    如果你对 reduce 熟悉,可以写得更紧凑。

    const flatList = [
    { id: 1, name: '一级菜单A', parentId: 0 },
    { id: 2, name: '一级菜单B', parentId: 0 },
    { id: 3, name: '二级A-1', parentId: 1 },
    { id: 4, name: '三级A-1-1', parentId: 3 },
    { id: 5, name: '二级B-1', parentId: 2 }
    ]
    function listToTree(list) {
    const nodeMap = list.reduce((map, item) => {
    map[item.id] = {
    item,
    children: []
    }

    return map
    }, {})

    return list.reduce((tree, item) => {
    const current = nodeMap[item.id]
    const parent = nodeMap[item.parentId]

    if (parent) {
    parent.children.push(current)
    } else {
    tree.push(current)
    }

    return tree
    }, [])
    }

    这版和 Map 版思路一样。

    只是把 Map 换成了普通对象。

    第一段 reduce 负责建索引:

    const nodeMap = list.reduce((map, item) => {
    map[item.id] = {
    item,
    children: []
    }

    return map
    }, {})

    第二段 reduce 负责组装树:

    return list.reduce((tree, item) => {
    const current = nodeMap[item.id]
    const parent = nodeMap[item.parentId]

    if (parent) {
    parent.children.push(current)
    } else {
    tree.push(current)
    }

    return tree
    }, [])

    它的好处是:

    • 代码更短
    • 返回值更直接
    • 适合封装成公共方法

    六、完整可运行版本:复制就能跑

    下面给一个完整版本。

    你可以直接放到 node 环境里运行。

    const flatList = [
    { id: 1, name: '一级菜单A', parentId: 0 },
    { id: 2, name: '一级菜单B', parentId: 0 },
    { id: 3, name: '二级A-1', parentId: 1 },
    { id: 4, name: '三级A-1-1', parentId: 3 },
    { id: 5, name: '二级B-1', parentId: 2 }
    ]

    function listToTree(list) {
    const map = new Map()
    const tree = []

    for (const item of list) {
    map.set(item.id, {
    item,
    children: []
    })
    }

    for (const item of list) {
    const current = map.get(item.id)
    const parent = map.get(item.parentId)

    if (parent) {
    parent.children.push(current)
    } else {
    tree.push(current)
    }
    }

    return tree
    }

    const result = listToTree(flatList)

    console.log(JSON.stringify(result, null, 2))

    运行结果:

    [
    {
    "id": 1,
    "name": "一级菜单A",
    "parentId": 0,
    "children": [
    {
    "id": 3,
    "name": "二级A-1",
    "parentId": 1,
    "children": [
    {
    "id": 4,
    "name": "三级A-1-1",
    "parentId": 3,
    "children": []
    }
    ]
    }
    ]
    },
    {
    "id": 2,
    "name": "一级菜单B",
    "parentId": 0,
    "children": [
    {
    "id": 5,
    "name": "二级B-1",
    "parentId": 2,
    "children": []
    }
    ]
    }
    ]


    七、封装成通用方法:字段名不固定也能用

    真实项目里,字段名不一定叫 id 和 parentId。

    有些接口可能是:

    {
    menuId: 1,
    parentMenuId: 0,
    title: '系统管理'
    }

    所以可以封装一个更通用的版本。

    function listToTree(
    list,
    options = {}

    ) {
    const {
    idKey = 'id',
    parentKey = 'parentId',
    childrenKey = 'children',
    rootValue = 0
    } = options

    const map = new Map()
    const tree = []

    for (const item of list) {
    map.set(item[idKey], {
    item,
    [childrenKey]: []
    })
    }
    for (const item of list) {
    const current = map.get(item[idKey])
    const parentId = item[parentKey]
    const parent = map.get(parentId)

    if (parent && parentId !== rootValue) {
    parent[childrenKey].push(current)
    } else {
    tree.push(current)
    }
    }

    return tree
    }

    使用方式:

    const menuList = [
    { menuId: 1, parentMenuId: 0, title: '系统管理' },
    { menuId: 2, parentMenuId: 1, title: '用户管理' },
    { menuId: 3, parentMenuId: 1, title: '角色管理' }
    ]
    const tree = listToTree(menuList, {
    idKey: 'menuId',
    parentKey: 'parentMenuId',
    rootValue: 0
    })
    console.log(JSON.stringify(tree, null, 2))

    这样就不用每次根据接口字段重写逻辑。


    八、踩坑提醒:这几个点很常见

    1. parentId 的类型不一致

    这是最容易忽略的坑。
    比如:

    { id: 1, parentId: '0' }

    但是根节点判断用的是数字:

    rootValue = 0

    这时 '0' !== 0。
    结果就可能不符合预期。
    建议后端统一类型。
    或者前端处理前先转换:

    const normalizedList = list.map((item) => ({
    item,
    id: Number(item.id),
    parentId: Number(item.parentId)
    }))

    2. 不要直接改原数组

    如果你这样写:

    item.children = []

    会直接修改原始数据。
    在 Vue、React 项目里,这可能引发一些难排查的问题。
    更推荐:

    {
    item,
    children: []
    }

    这样是创建新对象。
    副作用更少。

    3. 孤儿节点要不要展示?

    有些数据可能长这样:

    { id: 10, name: '孤儿节点', parentId: 999 }

    它的父节点不存在。
    上面的基础写法会把它当成根节点。
    这不一定错。
    但要看业务。
    如果你希望过滤掉孤儿节点,可以加判断:

    if (parent) {
    parent.children.push(current)
    } else if (item.parentId === 0) {
    tree.push(current)
    }

    4. 空 children 是否需要删除

    有些组件接受:

    children: []

    有些组件希望叶子节点没有 children 字段。

    如果要删除空 children,可以最后再处理:

    function removeEmptyChildren(nodes) {
    nodes.forEach((node) => {
    if (node.children.length === 0) {
    delete node.children
    } else {
    removeEmptyChildren(node.children)
    }
    })

    return nodes
    }

    使用:

    const tree = removeEmptyChildren(listToTree(flatList))

    在这里插入图片描述

    5. 注意循环引用

    如果后端数据异常:

    { id: 1, parentId: 2 }
    { id: 2, parentId: 1 }

    这就是循环引用。
    简单列表转树不会主动解决这个问题。
    如果是权限、组织架构这种严肃场景,建议后端保证数据正确。
    前端也可以在入参阶段做校验。

    九、Map 版和 reduce 版怎么选?

    如果你是刚开始学:
    先写 Map 版。
    因为它更直观。
    每一步都很清楚。
    如果你要封装工具函数:
    可以写 reduce 版。
    因为它更紧凑。

    但不要为了炫技牺牲可读性。

    在团队项目里,清晰通常比少写几行更重要。

    简单对比一下:

    写法优点适合场景
    Map + forEach 逻辑清晰,查找稳定 新手学习、业务代码
    reduce + 对象 代码紧凑,便于封装 工具函数、熟悉 reduce 的团队
    嵌套循环 容易理解但性能差 小数据临时代码,不推荐长期使用

    十、总结

    列表转树本质上不是复杂算法。
    核心就两句话:
    先用 id 建索引。
    再用 parentId 找父节点。
    只要理解了这个思路,后台菜单、权限树、部门树、地址级联、分类树都能用同一套方法解决。
    实际项目里建议优先使用 Map 版本。
    它性能是 O(n)。
    代码也足够清晰。
    如果接口字段不固定,再封装成支持 idKey、parentKey、childrenKey 的通用函数。
    这类工具函数很适合放进自己的算法笔记或项目 utils 里。
    我这次也把基础版和 reduce 版都整理成了源码文件,想继续对照学习的话,可以直接看完整源码。

    赞(0)
    未经允许不得转载:171主机测评 » 后台菜单总是扁平数据?2 种写法搞定列表转树,直接能用
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址