写在前面
写过管理后台的兄弟应该都遇到过这种活:
后端甩过来一个扁平数组,前端要把它渲染成多级菜单树。
地址三连弹、组织架构图、商品分类树...... 凡是带"层级"的功能,背后几乎都是同一个套路: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 }
]
两个关键约定:
id是节点唯一标识parentId指向父节点的 id;parentId: 0通常表示"没有父节点",即根节点
也有项目用
parentId: null或parentId: -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 或普通对象 {}。
两步走:
- 第一遍遍历:把每个节点塞进 Map,key 是 id,value 是节点本身(带个空 children 数组)
- 第二遍遍历:对每个节点,用 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 而不是 0,map.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 的节点,根节点判断就废了。约定清楚再写。