2. 数据结构基础

聊到菜单系统的数据结构,我得先坦白一件事——我早年做第一个后台管理系统时,直接用数组硬编码了三级菜单。结果呢?产品经理说要在第二层和第三层之间插一个新层级,我改代码改到凌晨三点。嗯,从那以后我彻底明白了:选对数据结构,比写多少行代码都重要。

今天咱们就把菜单系统里最常用的四种数据结构掰开揉碎讲清楚。你想想看,一个菜单本质上就是个「导航地图」,不同的数据结构决定了这张地图怎么画、怎么走、怎么改。

树形结构:菜单的天然形态

菜单天生就是树。为什么?因为菜单有层级关系——一级菜单下面挂二级,二级下面挂三级。这不就是树吗?

我个人习惯用「多叉树」来表示菜单。每个节点就是一个菜单项,节点里存着:菜单ID、名称、图标、路由地址,还有最重要的——子节点列表。

// 树形菜单节点的典型结构
class MenuNode {
  id: number;          // 菜单ID
  name: string;        // 菜单名称
  icon: string;        // 图标
  path: string;        // 路由地址
  children: MenuNode[]; // 子菜单列表
  parentId: number;    // 父级ID(用于回溯)
}

我在项目中遇到过一个问题:后端返回的菜单数据是扁平的列表,每个节点只记录了 parentId。这时候就需要我们自己构建树。说白了,就是把扁平数据「挂」到对应的父节点下面去。

核心要点:树形结构的优势在于「递归遍历」和「层级关系清晰」。你只需要一个递归函数,就能渲染出任意深度的菜单。
// 扁平列表转树形结构(经典写法)
function buildTree(flatList: MenuItem[]): MenuNode[] {
  const map = new Map<number, MenuNode>();
  const roots: MenuNode[] = [];

  // 先建立ID到节点的映射
  flatList.forEach(item => {
    map.set(item.id, { ...item, children: [] });
  });

  // 再挂载子节点
  flatList.forEach(item => {
    const node = map.get(item.id)!;
    if (item.parentId === 0) {
      roots.push(node); // 根节点
    } else {
      const parent = map.get(item.parentId);
      if (parent) {
        parent.children.push(node);
      }
    }
  });

  return roots;
}
小技巧:构建树的时候,记得先建立「ID → 节点」的映射。这样时间复杂度是 O(n),而不是 O(n²)。我见过有人用两层循环去匹配父子关系,数据量一大就卡死了。

链表结构:菜单的「兄弟连」

树形结构解决了「上下级」关系,但「同级菜单」之间的顺序怎么办?这时候链表就派上用场了。

其实,每个菜单项的兄弟节点天然就是一个链表。你想想看,「系统管理」下面有「用户管理」、「角色管理」、「权限管理」——这三个就是兄弟节点,它们之间有先后顺序。

我建议用「双向链表」来管理同级菜单。为什么是双向?因为菜单经常需要「上移」、「下移」、「置顶」这些操作。双向链表做这些操作,时间复杂度是 O(1)。

// 双向链表节点
class LinkedMenuItem {
  id: number;
  name: string;
  prev: LinkedMenuItem | null; // 前一个兄弟
  next: LinkedMenuItem | null; // 后一个兄弟
  children: LinkedMenuItem | null; // 第一个子节点(链表形式)
}

我曾经在一个权限管理系统中,用双向链表实现了菜单的拖拽排序。用户拖拽一个菜单项到新位置,我只需要修改前后节点的指针就行了。如果用数组来做,每次排序都要 splice 和重新索引,性能差很多。

注意:链表虽然操作灵活,但「随机访问」能力很差。如果你想通过索引找到第5个菜单项,链表需要从头遍历。所以实际项目中,我通常把链表和数组结合着用。

数组与哈希表:性能的「加速器」

树和链表解决了「结构」问题,但「查找」问题还得靠数组和哈希表。

你想想看,用户点击一个菜单项,系统需要快速找到这个菜单的详细信息——比如路由地址、权限标识。如果每次都要递归遍历整棵树,那性能就太差了。

我的做法是:在构建树的同时,维护一个「哈希表」。哈希表的 key 是菜单ID,value 是菜单节点的引用。这样查找任意菜单的时间复杂度就是 O(1)。

// 菜单系统的「双结构」设计
class MenuSystem {
  private tree: MenuNode[];          // 树形结构(用于渲染)
  private map: Map<number, MenuNode>; // 哈希表(用于查找)
  private flatList: MenuNode[];      // 数组(用于遍历)

  constructor(menuData: MenuItem[]) {
    this.flatList = menuData.map(item => ({ ...item, children: [] }));
    this.map = new Map();
    this.flatList.forEach(node => this.map.set(node.id, node));
    this.tree = this.buildTree();
  }

  // 根据ID快速查找菜单
  findById(id: number): MenuNode | undefined {
    return this.map.get(id);
  }

  // 获取所有叶子节点(没有子菜单的项)
  getLeafNodes(): MenuNode[] {
    return this.flatList.filter(node => node.children.length === 0);
  }
}
核心思想:用「树」维护结构,用「哈希表」加速查找,用「数组」支持遍历。三种数据结构各司其职,这才是专业级的做法。

数组还有一个妙用:做「菜单面包屑」。用户当前在三级菜单,面包屑需要显示「首页 > 系统管理 > 用户管理」。这时候,从当前节点沿着 parentId 一路回溯到根节点,把路径上的菜单名存到数组里,反转一下就是面包屑。

// 生成面包屑路径
function getBreadcrumb(menuId: number, map: Map<number, MenuNode>): string[] {
  const path: string[] = [];
  let current = map.get(menuId);

  while (current) {
    path.push(current.name);
    current = current.parentId ? map.get(current.parentId) : null;
  }

  return path.reverse(); // 从根到当前节点
}

四种数据结构的对比与选择

说了这么多,到底什么时候用哪种?我整理了一张表,你直接照着用就行。

数据结构 适用场景 优势 劣势
树 菜单渲染、层级展示 结构清晰,递归友好 查找慢,需要遍历
链表 同级菜单排序、拖拽 插入/删除快 随机访问慢
数组 遍历、面包屑、批量操作 索引访问快 插入/删除慢
哈希表 快速查找、权限校验 O(1) 查找 无序,无法排序
我的建议:实际项目中,别只用一种数据结构。我通常的做法是「树 + 哈希表」组合——树用来渲染,哈希表用来查找。如果菜单支持拖拽排序,再额外维护一个双向链表。

最后说一句,数据结构的选择没有银弹。你想想看,如果你的菜单只有两级、总共不到20个菜单项,用数组硬编码都行。但如果你在做企业级应用,菜单可能有五级、上千个节点,那树 + 哈希表 + 链表的组合就是必须的。

我曾经接手过一个老项目,菜单数据存在一个巨大的 JSON 对象里,每次查找都要递归遍历。用户切换页面时,菜单高亮要等几百毫秒才反应过来。后来我改成哈希表查找,瞬间就定位到了。嗯,有时候性能优化就是这么简单——选对数据结构就够了。