数据扁平化和层级化:为什么数据库和前端需要两副面孔

4 阅读5分钟

一、从一个日常需求说起

你做管理后台的时候,一定会遇到这样的功能:

  • 左侧的多级菜单:一级菜单 → 二级菜单 → 三级菜单
  • 地址三级联动:省 → 市 → 区
  • 组织架构树: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)存的是这样一张扁平表

idnameparentId
1一级菜单A0
2一级菜单B0
3二级A-11
4三级A-1-13
5二级B-12
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 数据库     │
└─────────────────┘

八、总结

  1. 数据扁平化是把嵌套的树形结构压成一层列表的过程
  2. parentId 是灵魂——它和 id 是同一套编号体系,构成外键关系
  3. 列表转树用两遍遍历(建映射表 → 挂载父子),保证不受数据顺序影响
  4. 树转列表就是深度优先递归遍历,去掉 children 存成平铺对象
  5. 扁平存,树形看——这两个转换函数是后端和前端的翻译官