listToTree 速通:一维数组怎么变出多级菜单?Map 和 reduce 两种全解

写在前面

写过管理后台的兄弟应该都遇到过这种活:

后端甩过来一个扁平数组,前端要把它渲染成多级菜单树。

地址三连弹、组织架构图、商品分类树...... 凡是带"层级"的功能,背后几乎都是同一个套路:listToTree

这篇文章把两种主流写法掰开揉碎讲一遍,顺便聊聊它们各自的脾气。


一、为什么后端给你的永远是扁平数组?

很多新人第一反应:后端为什么不直接给我树?给我个一维数组多不专业。

真不是后端懒。是因为 MySQL 的表结构本身就是一维的

维度 扁平数组 树形结构
存储方式 一行一记录,parentId 字段关联 嵌套对象,children 数组
数据库友好度 ✅ 直接 select * from ❌ 关系型数据库不擅长嵌套
传输体积 大(重复存储父节点信息)
前端处理 需要转树 拿来即用
实际项目 后端返回 前端组装

所以行业惯例就是:数据库存扁平、接口返扁平、前端转树。parentId 这个字段,就是扁平和树形之间的那座桥。


二、先看一眼原始数据

js

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

两个关键约定:

  1. id 是节点唯一标识
  2. parentId 指向父节点的 id;parentId: 0 通常表示"没有父节点",即根节点

也有项目用 parentId: nullparentId: -1 表示根,约定不同写法一致就行。


三、暴力写法 O(n²):先别学,知道有多烂就行

最直觉的写法是:对每个节点,再遍历一遍整个数组找它的父节点。

js

ini 复制代码
// 伪代码,别在生产里写
list.forEach(item => {
    const parent = list.find(n => n.id === item.parentId);
    if (parent) parent.children.push(item);
    else tree.push(item);
});

能跑,但每个节点找父节点都是 O(n),n 个节点就是 O(n²)

菜单 100 条无所谓,1000 条就开始卡,10000 条直接白屏。

写法 时间复杂度 空间复杂度 评价
暴力双循环 O(n²) O(n) 入门级,别用
哈希表两次遍历 O(n) O(n) 生产标配

四、O(n) 优化的核心思路:哈希表登场

暴力法慢在哪?找父节点这一步是 O(n)

如果父节点能 O(1) 拿到,整体就降到 O(n) 了。

什么数据结构查找是 O(1)?哈希表 。JS 里就是 Map 或普通对象 {}

两步走:

  1. 第一遍遍历:把每个节点塞进 Map,key 是 id,value 是节点本身(带个空 children 数组)
  2. 第二遍遍历:对每个节点,用 parentId 从 Map 里 O(1) 拿到父节点,把自己塞进父节点的 children;拿不到父节点(根节点)就推进 tree 数组

五、代码一:Map + forEach 写法

js

javascript 复制代码
function listToTree(list) {
    const map = new Map();       // ES6 新增数据结构 HashMap
    const tree = [];

    // 第一遍:建 Map,每个节点都带上 children
    list.forEach((item) => {
        map.set(item.id, {
            ...item,             // 展开原有字段
            children: []         // 预留 children
        });
    });

    // 第二遍:根据 parentId 挂载
    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;
}

逐行拆解:

作用 为什么这么写
new Map() 建哈希表 Map 查找 O(1)
...item 展开原字段 不污染原数据,浅拷贝
children: [] 预留子数组 后面要 push,得先有空数组
map.get(item.parentId) O(1) 找父节点 哈希表的灵魂
if (parent) 判断 区分根/子节点 parentId 为 0 时 get 返回 undefined

注意:Map.get(0) 在我们的数据里返回 undefined(因为没 id 为 0 的节点),所以根节点会走进 else 分支被 push 到 tree。


六、代码二:reduce 写法

同样的思路,换种风格:

js

ini 复制代码
function listToTree(list) {
    // 第一遍:reduce 建 map
    const nodeMap = list.reduce((map, item) => {
        map[item.id] = { ...item, children: [] };
        return map;
    }, {});

    // 第二遍:reduce 拼 tree
    return list.reduce((tree, item) => {
        const cur = nodeMap[item.id];
        const parent = nodeMap[item.parentId];
        if (parent) {
            parent.children.push(cur);
        } else {
            tree.push(cur);
        }
        return tree;
    }, []);
}

和代码一的区别:

对比项 代码一 代码二
哈希表 new Map() 普通对象 {}
遍历方式 forEach reduce
key 访问 map.get(id) / map.set(id, v) map[id] / map[id] = v
风格 命令式,分步清晰 函数式,链式更紧凑
可读性 高,新手友好 中等,要懂 reduce

两种写法思路一模一样,性能也基本一致,纯粹是风格之争。


七、Map vs 普通对象:哈希表到底用谁?

这是个高频面试题,也是写 listToTree 时最先要做的选择。

维度 Map 普通对象 {}
key 类型 任意(包括对象、数字) 只能字符串/Symbol
遍历顺序 插入顺序 整数 key 优先排序
大小获取 map.size Object.keys(obj).length
性能 频繁增删略优 静态访问略优
原型链污染 ❌ 不会 ⚠️ 可能(__proto__ 等)
序列化 ❌ 不支持 JSON JSON.stringify

listToTree 这种场景,id 一般是数字,两种都行。但用 Map 更"现代"、更安全(不怕 __proto__ 这种 key),生产代码推荐 Map


八、forEach vs reduce:风格之争

维度 forEach reduce
返回值 undefined 累加器(任意类型)
副作用 改外部变量 把状态藏进累加器
可读性 直白,像 for 循环 函数式,有点烧脑
适合场景 多步流程、纯遍历 累加、归并、建表
链式调用 ✅ 可接 .filter().map()

我个人更倾向 forEach 写法,因为 listToTree 是"两步走"的命令式流程,forEach 更直白。reduce 版本适合追求函数式风格的团队。


九、复杂度分析

指标 复杂度 说明
时间 O(n) 两次遍历,每次 O(n),常数 2
空间 O(n) Map 存了所有节点;tree 也存了所有节点

对比暴力法的 O(n²),当 n = 10000 时,O(n) 大约快 10000 倍。这就是哈希表的威力。


十、提醒

坑 1:parentId 没有根节点标识

如果数据里 parentId 是 null 而不是 0map.get(null) 同样返回 undefined,逻辑能跑。但如果某条数据 parentId 指向一个不存在的 id,这条数据会被误判为根节点。生产环境记得加校验。

坑 2:引用问题

第二遍遍历 push 进 children 的不是拷贝,是 Map 里的同一个引用 。所以 treemap 里的节点是同一份对象,改一个两边都变。这通常是我们想要的,但要意识到这一点。

坑 3:顺序依赖

如果父节点在子节点之后出现( parentId 指向后面才出现的 id),两次遍历法依然能正常工作------因为第一遍先把所有节点都进了 Map,第二遍才挂载。这也是"两遍遍历"比"一遍递归挂载"更稳的原因。

坑 4:根节点判断条件

if (parent) 这个判断依赖 parentId: 0 在 Map 里找不到。如果你用 parentId: null,行为一样;但如果有人手贱写了 id: 0 的节点,根节点判断就废了。约定清楚再写。

相关推荐
玉宇夕落1 小时前
受控与非受控组件以及一些表单的简单业务逻辑的理解
前端
何时梦醒1 小时前
React 进阶必修:彻底搞懂受控组件与非受控组件
前端·javascript·react.js
无糖可可果1 小时前
React 性能优化利器:深入理解 `useCallback`、`useMemo` 与 `React.memo`
前端
今日无bug1 小时前
JS 同步与异步:从单线程到 Promise
javascript·promise
Yoram1 小时前
JavaScript 错误处理的一种整理与实践
前端
你听得到111 小时前
排查 App 问题:别只盯着报错,把前后发生的事情串起来
android·前端·flutter
lllsure1 小时前
Vue&React Router
前端·vue.js·react.js
weixin_431600441 小时前
为什么 Agent REPL 要上 Ink:好处、用法与内部设计
前端·学习·ai·agent·ai编程
IT_陈寒2 小时前
Java 8的stream让我debug了一整天,气笑了
前端·人工智能·后端