一、从一个日常需求说起
你做管理后台的时候,一定会遇到这样的功能:
- 左侧的多级菜单:一级菜单 → 二级菜单 → 三级菜单
- 地址三级联动:省 → 市 → 区
- 组织架构树:CEO → VP → 总监 → 经理
这些功能,前端需要的是一个树形结构:
[
{
id: 1,
name: '一级菜单A',
children: [
{
id: 3,
name: '二级A-1',
children: [
{ id: 4, name: '三级A-1-1', children: [] }
]
}
]
},
{ id: 2, name: '一级菜单B', children: [...] }
]
但后端数据库(MySQL)存的是这样一张扁平表:
| id | name | parentId |
|---|---|---|
| 1 | 一级菜单A | 0 |
| 2 | 一级菜单B | 0 |
| 3 | 二级A-1 | 1 |
| 4 | 三级A-1-1 | 3 |
| 5 | 二级B-1 | 2 |
SELECT * FROM menu; -- 查出来的就是这样一个扁平列表
于是就有了列表 → 树和树 → 列表这一对互逆操作。这不仅仅是面试八股文,而是每天都在真实发生的场景。
二、什么是"扁平化"?一维又是什么意思?
"维度"在这里衡量的不是数组的长度,而是它的嵌套深度。
| 几何类比 | 数据结构 | 定位方式 |
|---|---|---|
| 一条线(一维) | [a, b, c, d] | 1 个索引就能定位任意元素 |
| 一张纸(二维) | [[1,2], [3,4]] | 需要 2 个索引定位 |
| 一个立方体(三维) | [[[1]]] | 需要 3 个索引定位 |
树形结构本质是不确定维度的 —— 深度取决于树的层级:
// 到达 id=4 需要钻 3 层
tree[0].children[0].children[0] // 路径长度 = 层级深度
而扁平化之后,无论数据有多少个,到达任意一个只需要一步:
// 到达 id=4:直接找到
list.find(item => item.id === 4)
一维 = 所有数据平铺在同一层,没有父子嵌套。
三、为什么要扁平和树分开?不能全用树吗?
存成扁平列表的好处
| 场景 | 说明 |
|---|---|
| 关系型数据库 | MySQL 的表就是行和列,天然匹配扁平结构。每行一条记录,增删改查直接写 SQL |
| 翻页 / 分页 | SELECT ... LIMIT 20 OFFSET 0 就能翻页。树形 JSON 没法 SQL 翻页 |
| 模糊搜索 | WHERE name LIKE '%关键词%' 直接扫全表,搜到的每一条都是精确命中 |
| 单条增删改 | 改一个菜单名,只需 UPDATE menu SET name='xxx' WHERE id=5 |
渲染成树形的好处
| 场景 | 说明 |
|---|---|
| 递归渲染 | 前端组件可以自然地递归:Menu → MenuItem → SubMenu |
| 展开 / 折叠 | 父节点下挂 children,折叠就是隐藏 children |
| 拖拽排序 | 把一个节点拖到另一个下,改的就是 parentId |
| 面包屑导航 | 从当前节点一路找爹,组成 根 > 二级 > 三级 路径 |
核心原则:扁平存,树形看。 数据在传输和存储时是扁平的,在渲染时是树的。这两个转换函数就是"数据库"和"用户界面"之间的翻译官。
四、列表转树的两种写法
给定扁平列表:
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 },
];
写法一:Map + 两次 forEach
function listToTree(list) {
const map = new Map();
const tree = [];
// 第一遍:建映射表 —— 给每个节点预分配 children 空数组
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); // parentId 在 map 里找不到 → 根节点
}
});
return tree;
}
写法二:两次 reduce(函数式版本)
function listToTree(list) {
// 第一遍:建映射表
const nodeMap = list.reduce((map, item) => {
map[item.id] = { ...item, children: [] };
return map;
}, {});
// 第二遍:挂载父子关系
return list.reduce((tree, item) => {
const parent = nodeMap[item.parentId];
if (parent) {
parent.children.push(nodeMap[item.id]);
} else {
tree.push(nodeMap[item.id]);
}
return tree;
}, []);
}
为什么必须两遍遍历,不能合为一遍?
因为数据顺序不确定。如果子节点在父节点之前出现在列表里:
const badOrder = [
{ id: 4, parentId: 3 }, // ← 子先出现,此时 id=3 还没登记
{ id: 3, parentId: 1 }, // ← 父在后面才来
];
单次遍历的话,处理 id=4 时 map 里还没有 id=3,就会错误地把它当成根节点。两遍遍历的智慧就是:第一遍先给所有人登记在案,第二遍再放心认领——无论数据顺序如何,结果都正确。
算法复杂度:O(2n) = O(n),性能无差别。
五、parentId 为什么能建立起父子关系?
关键在于:parentId 是和 id 用的是同一套编号体系。
const parent = map[item.parentId]; // 不是去找"第 parentId 个"
// 而是找 "id 等于 parentId 的那个对象"
这和关系型数据库里的外键是同一个概念 —— parentId 引用的是另一行的 id。
那么 parentId: 0 是什么意思?它只是一个约定——"没有父节点"。因为 map 里没有 key=0 的项,map[0] 返回 undefined(falsy),走 else 分支就会被识别为根节点。换成 null、-1 也一样有效。
六、树转列表(扁平化的实现)
这是另一方向的转换,本质上就是一个深度优先遍历:
function treeToList(tree) {
const list = [];
function traverse(nodes) {
nodes.forEach(node => {
const { children, ...rest } = node; // 解构:去掉 children
list.push(rest); // 只存扁平数据
if (children && children.length > 0) {
traverse(children); // 递归处理子节点
}
});
}
traverse(tree);
return list;
}
这里有一个常见技巧:解构出
children后只 push 其余字段,因为 parentId 本身就记录了父子关系,存到数据库时不需要children字段。
七、真实场景中的链路
┌─────────────────┐
│ MySQL 数据库 │ ← 扁平行存储
│ (id, name, pid) │
└────────┬────────┘
│ listToTree() 后端查出来 → 转成树 → 返回给前端
▼
┌─────────────────┐
│ 前端内存 │ ← 树形对象,用于递归渲染
│ (嵌套 children) │
└────────┬────────┘
│ treeToList() 用户编辑完 → 拍平 → 批量写回数据库
▼
┌─────────────────┐
│ MySQL 数据库 │
└─────────────────┘
八、总结
- 数据扁平化是把嵌套的树形结构压成一层列表的过程
parentId是灵魂——它和id是同一套编号体系,构成外键关系- 列表转树用两遍遍历(建映射表 → 挂载父子),保证不受数据顺序影响
- 树转列表就是深度优先递归遍历,去掉 children 存成平铺对象
- 扁平存,树形看——这两个转换函数是后端和前端的翻译官