3. 菜单数据模型设计

菜单系统好不好用,数据模型是根基。我见过太多项目,菜单数据模型设计得乱七八糟,最后前端渲染卡顿、后端查询慢、权限控制一团糟。说白了,就是一开始没想清楚「菜单长什么样」。

这一章,咱们把菜单数据模型拆成三个层面来讲:JSON Schema(接口契约)、数据库表结构(持久化存储)、内存模型(运行时表现)。这三个层面各有各的讲究,但核心思想是一致的——树形结构 + 扁平化索引。

3.1 JSON Schema 设计:接口契约先行

我个人习惯,先定义 JSON Schema,再写代码。为什么?因为前后端需要一份「共同语言」。你想想看,前端说「我要一个 children 字段」,后端说「我返回的是 subMenus」,这不就打架了吗?

下面是我在项目中沉淀下来的一份菜单 JSON Schema。它覆盖了 90% 的企业级场景:

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "MenuItem",
  "type": "object",
  "required": ["id", "name", "path", "type"],
  "properties": {
    "id": {
      "type": "string",
      "description": "菜单唯一标识,建议用 UUID 或雪花ID"
    },
    "parentId": {
      "type": ["string", "null"],
      "description": "父菜单ID,根节点为 null"
    },
    "name": {
      "type": "string",
      "minLength": 1,
      "maxLength": 50,
      "description": "菜单显示名称"
    },
    "path": {
      "type": "string",
      "pattern": "^/",
      "description": "前端路由路径,必须以 / 开头"
    },
    "icon": {
      "type": "string",
      "default": "FolderOutlined",
      "description": "图标名称,参考 Ant Design Icons"
    },
    "type": {
      "type": "string",
      "enum": ["menu", "button", "link", "external"],
      "description": "菜单类型:菜单项、按钮权限、内部链接、外部链接"
    },
    "permission": {
      "type": "string",
      "description": "权限标识,如 'system:user:add'"
    },
    "sort": {
      "type": "integer",
      "minimum": 0,
      "default": 0,
      "description": "排序号,越小越靠前"
    },
    "visible": {
      "type": "boolean",
      "default": true,
      "description": "是否可见,false 表示隐藏菜单但保留路由"
    },
    "keepAlive": {
      "type": "boolean",
      "default": true,
      "description": "是否缓存页面组件"
    },
    "children": {
      "type": "array",
      "items": { "$ref": "#" },
      "description": "子菜单列表,递归结构"
    },
    "meta": {
      "type": "object",
      "properties": {
        "title": { "type": "string" },
        "target": { "type": "string", "enum": ["_self", "_blank"] },
        "badge": { "type": ["string", "null"] }
      },
      "description": "扩展元数据,用于特殊场景"
    }
  }
}

核心要点:

  • type 字段:区分菜单、按钮、链接。按钮权限不渲染路由,只做权限校验。
  • parentId + children:既支持扁平存储,又支持树形渲染。
  • permission 字段:与后端权限系统对接,格式建议用冒号分隔的层级结构。
  • meta 对象:放一些「不确定会不会用」的扩展属性,避免频繁改 Schema。

我的小技巧: 实际项目中,我会把 JSON Schema 转成 TypeScript 类型定义。这样前后端都能享受到类型提示,联调时少了很多「字段名写错了」的低级 bug。

3.2 数据库表结构设计:扁平化存储的艺术

数据库里存菜单,千万别存成树形结构。为什么?因为树形结构在数据库里查询起来太痛苦了——你要么递归查询,要么用 CTE(公用表表达式),性能堪忧。

我建议用邻接表模型,也就是每个节点只存一个 parentId。简单、高效、好维护。

下面是一份经过生产验证的 MySQL 表结构:

CREATE TABLE `sys_menu` (
  `id`          VARCHAR(32)  NOT NULL COMMENT '主键ID',
  `parent_id`   VARCHAR(32)  DEFAULT NULL COMMENT '父菜单ID',
  `name`        VARCHAR(50)  NOT NULL COMMENT '菜单名称',
  `path`        VARCHAR(200) NOT NULL COMMENT '路由路径',
  `icon`        VARCHAR(50)  DEFAULT 'FolderOutlined' COMMENT '图标',
  `type`        TINYINT(1)   NOT NULL DEFAULT 0 COMMENT '类型:0菜单 1按钮 2链接',
  `permission`  VARCHAR(100) DEFAULT NULL COMMENT '权限标识',
  `sort`        INT          DEFAULT 0 COMMENT '排序号',
  `visible`     TINYINT(1)   DEFAULT 1 COMMENT '是否可见',
  `keep_alive`  TINYINT(1)   DEFAULT 1 COMMENT '是否缓存',
  `status`      TINYINT(1)   DEFAULT 1 COMMENT '状态:0禁用 1启用',
  `create_time` DATETIME     DEFAULT CURRENT_TIMESTAMP,
  `update_time` DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_parent_id` (`parent_id`),
  KEY `idx_sort` (`sort`),
  KEY `idx_type` (`type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜单表';

我曾经踩过的坑: 早期项目里,我把菜单表设计成了「无限级分类」的嵌套集模型(Nested Set)。虽然查询子树很快,但增删改一个节点要更新一堆 left/right 值,维护成本极高。后来我全换成了邻接表,配合内存缓存,性能一点不差。

表结构设计好了,怎么查?我一般用两步走:

  1. 全量查询:一次性查出所有菜单(菜单表数据量通常不超过 5000 条,全量查询完全 OK)。
  2. 内存构建树:在应用层用 parentId 构建树形结构。

这样做的好处是:数据库查询简单,树形构建灵活,而且方便做缓存。

3.3 内存模型设计:运行时的高效组织

数据从数据库查出来,不能直接用。你得在内存里把它组织成前端需要的结构。我通常维护三份数据:

模型名称 数据结构 用途
FlatMap Map<string, MenuItem> 通过 ID 快速查找任意菜单
TreeList MenuItem[] (嵌套结构) 前端渲染侧边栏、面包屑
PermissionSet Set<string> 权限校验,O(1) 时间复杂度

具体实现思路是这样的:

// 伪代码,展示核心逻辑
class MenuStore {
  private flatMap: Map<string, MenuItem> = new Map();
  private treeList: MenuItem[] = [];
  private permissionSet: Set<string> = new Set();

  // 从数据库原始数据构建内存模型
  build(rawMenus: RawMenu[]) {
    // 第一步:构建 FlatMap
    rawMenus.forEach(menu => {
      this.flatMap.set(menu.id, {
        ...menu,
        children: []
      });
    });

    // 第二步:构建 TreeList
    this.flatMap.forEach(menu => {
      if (menu.parentId) {
        const parent = this.flatMap.get(menu.parentId);
        if (parent) {
          parent.children.push(menu);
        }
      } else {
        this.treeList.push(menu);
      }
    });

    // 第三步:排序
    this.sortTree(this.treeList);

    // 第四步:构建权限集合
    this.flatMap.forEach(menu => {
      if (menu.permission) {
        this.permissionSet.add(menu.permission);
      }
    });
  }

  // 根据角色过滤菜单
  getMenusByPermissions(permissions: string[]): MenuItem[] {
    // 递归过滤,只保留有权限的菜单
    return this.filterTree(this.treeList, permissions);
  }
}

为什么这么设计?

  • FlatMap:让你能在 O(1) 时间内找到任意菜单的父节点或子节点,构建面包屑、查找当前激活菜单时特别有用。
  • TreeList:直接给前端渲染,省去前端自己构建树的麻烦。
  • PermissionSet:权限校验时不用遍历,直接 set.has(permission) 搞定。

我的经验之谈: 内存模型构建好之后,一定要做缓存。菜单数据变化频率极低,完全可以用 Redis 或本地缓存存起来。我一般设置 5 分钟的过期时间,配合「菜单变更时主动刷新缓存」的机制,既保证了实时性,又减少了数据库压力。

3.4 三个模型之间的关系

说白了,这三个模型是同一份数据在不同阶段的「投影」:

  • JSON Schema:定义数据长什么样,是前后端沟通的契约。
  • 数据库表:把数据存下来,用扁平结构保证查询效率。
  • 内存模型:把数据用起来,用多份索引满足不同场景的需求。

嗯,这里要注意:不要试图用一个模型解决所有问题。我见过有人把树形结构直接存到数据库里,结果查询时各种递归,性能惨不忍睹。也见过有人在前端做树形构建,结果每次页面刷新都要等几百毫秒。正确的做法是——各司其职,各取所长。