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

做管理后台时,你一定遇到过这种数据:

后端从 MySQL 查出来是一张表。

前端拿到的是一个扁平列表

但页面要渲染的却是:

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

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

比如后台菜单。

数据库里一般不会直接存树。

它通常是一张表:

id name parentId
1 一级菜单A 0
2 一级菜单B 0
3 二级A-1 1
4 三级A-1-1 3
5 二级B-1 2

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

js 复制代码
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 表示父子关系。

但前端菜单组件想要的数据通常是这样:

js 复制代码
[
  {
    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: []
      }
    ]
  }
]

这就是典型的列表转树


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

不要一上来就递归。

最稳的思路是两步:

  1. 先把所有节点放到一个字典里
  2. 再遍历列表,通过 parentId 找父节点

用一句话理解:

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

关键就是这个字段:

js 复制代码
parentId

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

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

在这个例子里:

js 复制代码
{ id: 1, name: '一级菜单A', parentId: 0 }

parentId0

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

所以它就是一级菜单。


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

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

逻辑非常直观。

js 复制代码
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))

这段代码做了什么?

第一轮遍历:

js 复制代码
map.set(item.id, {
  ...item,
  children: []
})

把每个节点都放进 Map

并且给每个节点补一个 children

这样后面挂子节点时就不用判断 children 是否存在。

第二轮遍历:

js 复制代码
const current = map.get(item.id)
const parent = map.get(item.parentId)

拿到当前节点。

再通过 parentId 找父节点。

如果父节点存在:

js 复制代码
parent.children.push(current)

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

如果父节点不存在:

js 复制代码
tree.push(current)

说明它是根节点。

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

很多人第一次写会这样想:

拿一个节点。

再去数组里找它的子节点。

再继续找孙子节点。

这种写法不是不能做。

但问题很明显:

数据量一大,性能会变差。

如果每个节点都要去列表里查一遍,复杂度很容易变成 O(n²)

而上面的 Map 写法只需要两次遍历。

整体是:

txt 复制代码
O(n)

管理后台里菜单可能不多。

但部门、地区、商品分类、权限点可能非常多。

所以建议一开始就养成用索引表的习惯。


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

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

js 复制代码
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 负责建索引:

js 复制代码
const nodeMap = list.reduce((map, item) => {
  map[item.id] = {
    ...item,
    children: []
  }

  return map
}, {})

第二段 reduce 负责组装树:

js 复制代码
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 环境里运行。

js 复制代码
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))

运行结果:

json 复制代码
[
  {
    "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": []
      }
    ]
  }
]

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

真实项目里,字段名不一定叫 idparentId

有些接口可能是:

js 复制代码
{
  menuId: 1,
  parentMenuId: 0,
  title: '系统管理'
}

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

js 复制代码
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
}

使用方式:

js 复制代码
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 的类型不一致

这是最容易忽略的坑。

比如:

js 复制代码
{ id: 1, parentId: '0' }

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

js 复制代码
rootValue = 0

这时 '0' !== 0

结果就可能不符合预期。

建议后端统一类型。

或者前端处理前先转换:

js 复制代码
const normalizedList = list.map((item) => ({
  ...item,
  id: Number(item.id),
  parentId: Number(item.parentId)
}))

2. 不要直接改原数组

如果你这样写:

js 复制代码
item.children = []

会直接修改原始数据。

在 Vue、React 项目里,这可能引发一些难排查的问题。

更推荐:

js 复制代码
{
  ...item,
  children: []
}

这样是创建新对象。

副作用更少。

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

有些数据可能长这样:

js 复制代码
{ id: 10, name: '孤儿节点', parentId: 999 }

它的父节点不存在。

上面的基础写法会把它当成根节点。

这不一定错。

但要看业务。

如果你希望过滤掉孤儿节点,可以加判断:

js 复制代码
if (parent) {
  parent.children.push(current)
} else if (item.parentId === 0) {
  tree.push(current)
}

4. 空 children 是否需要删除

有些组件接受:

js 复制代码
children: []

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

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

js 复制代码
function removeEmptyChildren(nodes) {
  nodes.forEach((node) => {
    if (node.children.length === 0) {
      delete node.children
    } else {
      removeEmptyChildren(node.children)
    }
  })

  return nodes
}

使用:

js 复制代码
const tree = removeEmptyChildren(listToTree(flatList))

5. 注意循环引用

如果后端数据异常:

js 复制代码
{ id: 1, parentId: 2 }
{ id: 2, parentId: 1 }

这就是循环引用。

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

九、Map 版和 reduce 版怎么选?

如果你是刚开始学:

先写 Map 版。

因为它更直观。

每一步都很清楚。

如果你要封装工具函数:

可以写 reduce 版。

因为它更紧凑。

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

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

简单对比一下:

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

十、总结

列表转树本质上不是复杂算法。

核心就两句话:

先用 id 建索引。

再用 parentId 找父节点。

只要理解了这个思路,后台菜单、权限树、部门树、地址级联、分类树都能用同一套方法解决。

实际项目里建议优先使用 Map 版本。

它性能是 O(n)

代码也足够清晰。

如果接口字段不固定,再封装成支持 idKeyparentKeychildrenKey 的通用函数。

这类工具函数很适合放进自己的算法笔记或项目 utils 里。

我这次也把基础版和 reduce 版都整理成了源码文件,想继续对照学习的话,可以直接看完整源码。