深圳幻海软件技术有限公司 欢迎您!

Vue 里,多级菜单要如何设计才显得专业?

2023-02-28

老生常谈了!虽然我们是Java猿,但是写起来前端代码也不含糊!今天我想来和大家聊聊这个前端的动态菜单,要如何设计才显得专业!还是以我们的TienChin项目为例,大家一起来看看。先来一张截图看看效果:那么这样的菜单是如何设计出来的呢?今天我也不想和大家聊过多的技术细节,就聊聊这个路由是如何设计的,一

老生常谈了!

虽然我们是 Java 猿,但是写起来前端代码也不含糊!今天我想来和大家聊聊这个前端的动态菜单,要如何设计才显得专业!还是以我们的 TienChin 项目为例,大家一起来看看。

先来一张截图看看效果:

那么这样的菜单是如何设计出来的呢?

今天我也不想和大家聊过多的技术细节,就聊聊这个路由是如何设计的,一旦大家明白了路由是如何设计的,剩下的问题都是细枝末节的问题了。

1. 路由设计

有的小伙伴做过 vhr,知道 vhr 里的动态菜单实现方式,松哥和大家一样,也是在不断学习不断进步中,今天我想和大家探讨 TienChin 项目中动态菜单的实现方案,看看是否是一种更佳的解决方案。

1.1 菜单设计

先来和小伙伴们回顾下 vhr 中的方案:

在 vhr 中,权限的控制,只控制到二级菜单,也就是一级菜单和权限没关系。举个例子,现在有一级菜单 A 和 二级菜单 B,B 是 A 中的菜单,现在假设:

  • 如果当前用户权限可以查看 B 菜单,那么 A 菜单会自动显示出来。
  • 如果当前用户权限无法查看 B 菜单,且 A 菜单中也没有其他子菜单可以展示,那么 A 菜单就不会显示出来。

换言之,A 菜单显示与否,主要看它里边有没有子菜单需要展示,如果有,A 菜单就显示,如果没有,A 菜单就不显示。

vhr 中的思路是这样的。

在 TienChin 项目中,这一块有一些变化:

如果 A 中只有一个 B,那么似乎就没有必要再做一个两级菜单了,直接把 B 展示出来不就行了?用户操作也方便!

这是第一个不一样的地方。

1.2 路由数据

基于第一点,就涉及到一个问题,就是路由接口该如何设计?最主要是接口返回的数据格式应该是什么样子的?

首先有一点小伙伴们应该知道,这里的路由是一个嵌套路由,也就是一级菜单中嵌套着二级菜单。即使这个地方在展示的时候,不存在层级关系,例如上图中的促销活动,但是底层的数据结构也应该是嵌套路由。

好啦,不卖关子了,我们来看一段路由 JSON:

[{
 "name": "Monitor",
 "path": "/monitor",
 "hidden": false,
 "redirect": "noRedirect",
 "component": "Layout",
 "alwaysShow": true,
 "meta": {
  "title": "系统监控",
  "icon": "monitor",
  "noCache": false,
  "link": null
 },
 "children": [{
  "name": "Online",
  "path": "online",
  "hidden": false,
  "component": "monitor/online/index",
  "meta": {
   "title": "在线用户",
   "icon": "online",
   "noCache": false,
   "link": null
  }
 }, {
  "name": "Job",
  "path": "job",
  "hidden": false,
  "component": "monitor/job/index",
  "meta": {
   "title": "定时任务",
   "icon": "job",
   "noCache": false,
   "link": null
  }
 }]
}, {
 "path": "/",
 "hidden": false,
 "component": "Layout",
 "children": [{
  "name": "Role",
  "path": "role",
  "hidden": false,
  "component": "system/role/index",
  "meta": {
   "title": "角色管理",
   "icon": "peoples",
   "noCache": false,
   "link": null
  }
 }]
}]
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.

这里我举了两个菜单的例子,这两个例子比较具有代表性,这个菜单最终显示效果大概类似下面这样:

  • 系统监控

在线用户

定时任务

  • 角色管理

大概显示效果如上图。

接下来我就来说一下这里几个典型属性:

  • redirect:noRedirect 表示该路由在面包屑导航中不可被点击。
  • alwaysShow:如果这个属性设置为 false,那么当当前菜单只有一个子菜单的时候,默认情况下就只会显示子菜单,而忽略父菜单(如 1.1 小节所述),但是如果将该属性设置为 true,则无论当前菜单有几个子菜单,都会将当前菜单展示出来(这就类似于 vhr 中的效果了)。
  • 每一个父菜单都有自己的 path,每一个 children 也有自己的 path,父菜单的 path 加上每一个 children 的 path,共同组成每一个 children 的路径。
  • 再来看第二个角色管理这个菜单项,由于它的父菜单中只有一个子菜单项,并且父菜单中也没有 alwaysShow 属性,所以这个菜单项在最终展示的时候,就只展示里边的角色管理,父菜单则不会展示出来(正好,生成的 JSON 中也没说父菜单的名字、图标等属性)。

当然,不是说你的 JSON 这么写就自动这么显示,JSON 中的东西只是一个标记,最终怎么显示,还要看渲染:

<div v-if="!item.hidden">
  <template v-if="hasOneShowingChild(item.children,item) && (!onlyOneChild.children||onlyOneChild.noShowingChildren)&&!item.alwaysShow">
    <app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path, onlyOneChild.query)">
      <el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{'submenu-title-noDropdown':!isNest}">
        <item :icon="onlyOneChild.meta.icon||(item.meta&&item.meta.icon)" :title="onlyOneChild.meta.title" />
      </el-menu-item>
    </app-link>
  </template>
  <el-submenu v-else ref="subMenu" :index="resolvePath(item.path)" popper-append-to-body>
    <template slot="title">
      <item v-if="item.meta" :icon="item.meta && item.meta.icon" :title="item.meta.title" />
    </template>
    <sidebar-item
      v-for="child in item.children"
      :key="child.path"
      :is-nest="true"
      :item="child"
      :base-path="resolvePath(child.path)"
      class="nest-menu"
    />
  </el-submenu>
</div>
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.

还有一个函数我就没有列出来了,反正我们看名字也大概知道每一个函数的含义。

大家看,这个 div 中实际上分为了两部分,上面 template 专门用来处理 children 中只有一项的情况(角色管理),具体处理方式就是把 children 拿出来显示,其他的则不考虑,具体执行的时候不一定是只有一个 children,也有可能压根就没有 children,此时直接显示 parent 即可(参考 1.3 小节)。

下面的 el-submenu 则处理 children 有多个的情况(系统监控)。

另外这里涉及到了一个 resolvePath,也是特别关键的一个方法,我们来大致看下:

resolvePath(routePath, routeQuery) {
  if (isExternal(routePath)) {
    return routePath
  }
  if (isExternal(this.basePath)) {
    return this.basePath
  }
  if (routeQuery) {
    let query = JSON.parse(routeQuery);
    return { path: path.resolve(this.basePath, routePath), query: query }
  }
  return path.resolve(this.basePath, routePath)
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.

这个函数的主要左右,就是处理菜单的路径问题。

我们来看下这个具体的判断逻辑:

  • 如果这个菜单的路径是一个外链(判断逻辑是查看这个 path 是否有 http 或者 https 等前缀),即 isExternal 返回 true,就把这个路径原封不动返回。
  • 如果这个菜单的父菜单的路径是一个外链,则将父菜单的 path 原封不懂返回。
  • 如果有查询参数,就把参数加上。
  • 最后通过 path.resolve 对路径进行一个简单运算。

有的小伙伴可能对 path.resolve 不熟悉,我简单说下:

path.resolve() 方法可以将多个路径解析为一个规范化的绝对路径,它的处理方式类似于对这些路径逐一进行 cd 操作,然而与 cd 操作不同的是,这些路径可以是文件,并且可不必实际存在(resolve() 方法不会利用底层的文件系统判断路径是否存在,而只是进行路径字符串操作)。例如:

path.resolve('foo/bar', '/tmp/file/', '..', 'a/../subfile')
  • 1.

相当于:

cd foo/bar
cd /tmp/file/
cd ..
cd a/../subfile
pwd
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

举个简单的例子:

path.resolve('/foo/bar', './baz') 
// 输出结果为 
'/foo/bar/baz' 

path.resolve('/foo/bar', '/tmp/file/') 
// 输出结果为 
'/tmp/file' 

path.resolve('wwwroot', 'static_files/png/', '../gif/image.gif') 
// 当前的工作路径是 /home/javaboy/node,则输出结果为 
'/home/javaboy/node/wwwroot/static_files/gif/image.gif'
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.

现在大家知道菜单跳转的路径是怎么来的了吧!

1.3 外链问题

在 TienChin 项目中,菜单还存在一个外链的问题。

这个外链有两种不同的显示思路:

  • 点击外链,直接打开一个新的选项卡,在新的选项卡中展示新的页面。
  • 点击外链,在当前项目中打开一个新的选项卡,选项卡中展示新的内容。

对于第一种情况我就不和大家演示了,对于第二种情况,我截个图给大家看下:

就是在当前项目的选项卡中,展示一个外部链接的内容。

我们先来看第一种情况。即点击菜单之后,就在一个新的选项卡中打开网页,这种菜单的 JSON 格式如下:

{
    "name": "Http://www.javaboy.org",
    "path": "http://www.javaboy.org",
    "hidden": false,
    "component": "Layout",
    "meta": {
        "title": "TienChin健身官网",
        "icon": "guide",
        "noCache": false,
        "link": "http://www.javaboy.org"
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.

这个大家看,也没有 children,因为不需要,这个显示的时候,就当成了只有一个 children 来处理,然后菜单项的 path 是一个 http 路径,一点击,自然就跳到新的选项卡了。

对于第二种情况,即点击外链,在当前项目中打开一个新的选项卡,选项卡中展示链接的内容,它的 JSON 结构类似下面这样:

{
    "name": "Http://www.javaboy.org",
    "path": "/",
    "hidden": false,
    "component": "Layout",
    "meta": {
        "title": "TienChin健身官网",
        "icon": "guide",
        "noCache": false,
        "link": null
    },
    "children": [
        {
            "name": "Www.javaboy.org",
            "path": "www.javaboy.org",
            "hidden": false,
            "component": "InnerLink",
            "meta": {
                "title": "TienChin健身官网",
                "icon": "guide",
                "noCache": false,
                "link": "http://www.javaboy.org"
            }
        }
    ]
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.

这个其实也没啥好说的,类似于上面系统监控的那种情况,但是只有一个子菜单,在菜单渲染的时候,也是只渲染一个子菜单。由于父子菜单的 path 都不是以 http 或者 https 之类的地址开头,所以这个链接最终生成的 path 是 /www.javaboy.org,然后这个路径的内容将展示在 InnerLink 组件上,最终就是大家上图中所看到的效果了。