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

0 阅读6分钟

写在前面

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

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

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

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


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

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

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

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

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


二、先看一眼原始数据

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 }
]

两个关键约定:

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

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


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

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

js

// 伪代码,别在生产里写
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

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

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()普通对象 {}
遍历方式forEachreduce
key 访问map.get(id) / map.set(id, v)map[id] / map[id] = v
风格命令式,分步清晰函数式,链式更紧凑
可读性高,新手友好中等,要懂 reduce

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


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

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

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

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


八、forEach vs reduce:风格之争

维度forEachreduce
返回值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 里的同一个引用。所以 tree 和 map 里的节点是同一份对象,改一个两边都变。这通常是我们想要的,但要意识到这一点。

坑 3:顺序依赖

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

坑 4:根节点判断条件

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