为“管理后台”打造软硬结合的【路导菜】体系🚢
“路导菜”:本文所有提到的“路导菜”,就是 “路由”、“导航”、“菜单” 的简称。
它们如此重要,如此紧密结合,又如此需要清晰设计。
📖阅读本文,你将
-
学习到一种灵活成熟构建 “路导菜” 体系的实践方法
-
深入思考 “路导菜” 解耦的意义与必要性
-
透过 “反面教材” 思考当前项目结构的设计
-
理解
403
、404
错误页面在构建上差异
一、为什么值得专门讨论
大人,时代变了。
只靠 “文件夹” 和 “档案袋”,哪怕再精明能干的归纳员、记录员也绝对无法处理当前世界里浩瀚如烟的数据和业务。
“数字化转型” 成为了21世纪各行各业无法绕开的时代话题。
于是,除了人们耳熟能详的 “淘宝”、“抖音”、“微博” 等之外,组成互联网世界的另一种重要形态则貌不惊人的 “后台管理系统” 们。
“后台管理系统” 最经典的形象,莫过于这样:
眼熟吗?眼熟就对了。
左侧的菜单、地址栏中的路由、页面重定向及面包屑里所暗藏的页面导航,构成了 “后台管理系统” 的骨骼与经脉。它们支撑系统完成功能的分割、权限的控制、页面的跳转。
那么,如果 “路导菜” 设计不合理,会产生什么问题?
-
产品说:“列表页跳转到详情页,详情页的面包屑在列表页的后面;”
你居然发现:不太好实现。
-
产品说:“我们要调整一下菜单结构,A菜单和B菜单合并,C菜单拆成D和E。”
你发现:妈耶,怎么还和路由耦合了!和权限点也耦合了?
-
测试说:“我登录到/,为啥它跳转到了一个 403 页面。”
你想反驳说那是因为它要跳的页面你没授权,但是想了想,你又说不出口。
-
大BOSS说:“有用户管理、或者有单位管理权限二者之一的人,就能访问
区域管理页面
”。你无奈叹气,又要去写难以维护的订制代码。
合理的需求,因为设计问题而无法完成,实在让人难以启齿。
二、以 vue-element-admin
为例
vue-element-admin
(76K Star) 是社区的明星项目,非常多的小公司以它作为脚手架,开发出了茫茫多的项目。
不瞒您说,我也用它作为脚手架开发过多个项目,我非常喜欢它。拿它举例子是因为它容易访问,知名度高,且很多朋友都用过,还开源。
它用非常 简单易懂 的方式,实现了一套 “简单易懂” 的 “路导菜” 体系。
但或许是为了降低初学者接触的难度,它的 “路导菜” 体系设计也存在明显的缺陷。
为了避免 “空口批判” ,让我来简单列举几例:
大家可自行前往 demo
地址验证:panjiachen.github.io/vue-element…
2.1 显示 404
页居然是靠重定向实现的?
当我访问一个不存在的路由时,比如panjiachen.github.io/vue-element… 时,地址栏居然跳转到了 /404
路由上。
乍一想,貌似很寻常。
但这真的符合 “互联网用户习惯” 吗?
也许我辛辛苦苦输了一串地址,不小心输错一个字母,回车,地址栏重定向了??我……
那么,看看最常规的做法是什么?
当我访问 "juejin.cn/春哥没秃" 这个肯定不存在的地址时,得到的反馈是:
地址栏不变,但返回一个 404报错
页面,对比一下两种效果,后者是否让你的访问体验大大增强?
这就是一个典型 “路导菜” 设计上差别。
2.2 无权
和 无页
傻傻分不清楚
vue-element-admin
在生成路由时,采用的是 “按需动态生成” 的策略,简单说就是:
未登录用户只有几个基本路由,登录后,再根据你这个用户来生成路由。
乍一听,是这么回事。
可是问题接踵而来,这样的设计很难区分 “没有当前页” 和 “您没权限访问” 两种状态的差异。
比如:我在 vue-element-admin
页面中将角色调整为 editor
后再访问 panjiachen.github.io/vue-element… 这个管理员专属的页面。
你猜怎么着?又给我定位到 404
页面啦~~
问题来了:这符合 “互联网用户习惯” 吗?
答案不言自明,因为还有大家所熟知的 403
页面;
2.3 路由和菜单的耦合
在 vue-element-admin
项目中,菜单和路由是耦合的。
一级菜单的路由是 '/admin',那么二级菜单就是:'/admin/list'。
这会导致什么问题呢?本文的 “第三节” 会详细阐述对这方面的思考,不在此节细叙。
2.4 “不聪明” 的面包屑
试着在 demo
页里按如下方式操作:
- 角色选择为
editor
; - 访问 “权限测试页-权限指令” 页面;
“权限测试页” 这个一级菜单下面只有一个 “权限指令” 二级页,当我们点击 “权限测试页” 的面包屑时,最佳体验当然是依然定位到 “权限指令” 页。
但是实际呢?
实际是:又给我跳到 404
错误页了 🤣
PS:虽然我挑了很多刺,但我依然认为这个项目是优秀的项目;
三、三个概念:菜单、路由、站点地图
在正式开始设计我们的 “路导菜” 体系之前,让我们独立而抽离地认识一下 “菜单”、“路由”、“导航” 这三个概念。
3.1 菜单
我们所谈论的 “网页菜单”,通常情况下是一个树形选项合集 (列表可以算一维的树),它的核心作用是提供有限数量的链接,让用户可以通过点击来访问不同的页面;
因此,菜单的核心业务是:"展现" 与 "跳转"。
那么,按以上描述来看,菜单的核心属性,其实只有三个:
export type MenuItem = {
title: string, // 菜单文本
path: string, // 菜单链接
children: MenuItem[] // 子菜单,用以维持一个树
}
3.2 路由
路由是真正的功能入口。
当我们想访问某个页面、或者抵达某个功能时,可能我们并不需要菜单,只需要记得其 “路由” 就能完成页面的访问。
因此,路由的核心作用是:将 “地址栏的地址” 和 “页面” 链接起来,如图:
因此,一个典型的路由其实只需要两个属性:
type Route = {
path: string,
component: any // 指向某个路由组件
}
3.2 导航 (站点地图)
"导航" 这个词容易和菜单弄混,因此采用 "站点地图" 这个更为形象准确的词语来作为别名;
路由是访问页面的直接入口。
菜单则提供了可视化的能力,让用户不用在地址栏输入路由,即可访问部分入口。
没错,只是部分!
没有任何一个菜单可以引导你直接进入 id: 9572
这篇文章的编辑页,但你可以通过菜单进入 "文章列表页",再通过 "文章列表页" 上的链接抵达 id: 9572
这篇文章的编辑页。
"面包屑" 是站点地图最直观的表现之一,它代表了从 "首页" 抵达某个页面的标准路径。
之所以说是标准路径,是因为:
"文章列表" => "编辑某篇文章"
与
"文章列表" => "文章详情" => "编辑某篇文章"
以上两种访问路径,最终呈现在面包屑上的表现应该是一致的;只有是一致的,才能保证刷新页面之后面包屑不会出现变动;
否则,当你刷新页面或通过路由直接访问时,你的网站将无法定位你从何而来。
出于对 "标准路径" 的诉求,也就有了 "站点地图" (sitemap
) 这个概念。
站点地图的结构应该是清晰简单的:
type Sitemap = {
title: string, //某个页面的标题
path: string,
children: Sitemap[]
}
有同学可能会惊呼:“站点地图和菜单的结构是一致的耶!”
是的,它们在结构上是相似的,但显然站点地图所要表现的信息比 "菜单" 更为全面,它可不仅仅是提供部分功能的入口这样简单,它需要描述出所有页面的 "标准路径"。
以上三个概念 "菜单"、"路由"、"站点地图(导航)" 的概念都不是生造的概念,它们是来自实际生产中总结出的实体,是我们后续设计的关键。
四、耦合还是解耦?设计的艺术
让我们再次简短地梳理一下上面三个概念的司职:
- 路由(route):功能页面的直接入口。
- 菜单(menu):可视化树形结构,便捷地访问路由。
- 站点地图(sitemap):形成通往任何页面的标准路径。
回顾上一节,三种实体的属性,我们会发现:
- 三种实体都有
path
属性。 menu
和sitemap
都有title
属性。
如果分开维护,分开管理这些属性,显然是个 "灾难"。
为撒我点菜单上的 '组织管理',显示的面包屑却是 '组织结构管理'?
多个 path
和 title
的分开管理虽然完全符合解耦的原则,但光是想一想为了维护其一致性,我们将付出海量的精力来维持一致性。
况且,虽说 “路导菜” 是三个实体,各有司职,但他们并不是毫无关系的。
如图所示,三者之间互有关联,因此,为了便于维护管理,我们可以大胆地进行结构上的调整:
type Route = {
key: string, // 唯一标识
component: any,
children?: Route[]
}
type Menu = {
key: string, // 唯一标识
children: Menu[],
title?:string
}
type Sitemap = {
key: string, // 唯一标识
path: string,
title: string,
children: Sitemap[]
}
将三者通过 key
这一唯一标识进行关联。
这样虽然增加了实体之间的耦合程度,但好处也是肉眼可见的:
- 菜单(
Menu
)只需要专注于维护自身的树形结构。
为啥菜单有个可选
title
?因为有的菜单是外链,不具备对应路由;
- 路由(
Route
)只需要专注维护它和页面的关系;(当然还有布局)
为啥路由也有了个
Route
?因为无论是Vue
还是React
,都习惯使用嵌套来完成页面布局的处理;
-
站点地图(
Sitemap
)则用来维护页面所有能访问的页面的关系,维护title
、path
以及其他属性; -
这样设计,依然保证了每一个实体在结构上的完整性、独立性;
菜单结构和路由结构完全解耦、页面标准路径与路由地址完全解耦,它们只需要用一个
key
作为关联彼此的依据即可;
考虑实现以下场景:
人员列表页: /users
人员类别维护页:/user-types
菜单结构上:只需要 "人员列表页"
面包屑表现: 人员列表页 > 人员类别维护页
在 vue-element-admin
项目里,这似乎不太好实现,甚至不知道该怎么实现。
但如果按我们抽取的三个实体,这就显得很好描述:
const routes = [ // 两个页面在路由上没有从属关系
{
key: 'Users',
component: () => import('@/pages/users/index.vue')
},
{
key: 'UserTypes',
component: () => import('@/pages/user-types/index.vue')
},
]
const menus = [
{
key: 'Users', // 表示只需要一个菜单
}
]
const sitemaps = [
{
key: 'Users',
title: '人员列表页',
children: [
{
key: 'UserTypes',
title: '人员类别维护页' // 描述了两个页面的从属关系
}
],
meta: {
authPoint: 111 // 在meta里可以挂载所有你需要使用的其他信息
}
}
]
通过先解耦、再耦合,我们实现了一个核心目标:
" 结构互不牵制、内容不重复维护 !"
五、转换、生成
以上三个独立结构生成完之后,你可以生成大部分你想要的东西。
比如:vue-router
的相关配置:
/**
* 通过Key获取sitemap
**/
const getSitemapByKey = (key: string): Sitemap | undefined => {
// 省略实现
}
const vueRoutes = routes.map(t => {
const sitemap = getSitemapByKey(t.key)
if (!sitemap) {
return
}
return {
...t,
name: t.key,
path: sitemap.path,
meta: t.meta
}
})
如你所见,通过 key
的关联和 sitemaps
的中转,你可以获取各种你需要的结构,并在项目中得以使用;
包括但不限于:
- 可以直接提供于页面渲染的 “菜单树” 结构
- 可以直接提供给框架识别的 “路由列表”
- 可以直接提供给面包屑的 “页面路径”
- 等等...
六、“按需建楼” 和 “按需开房”
前文提到 vue-element-admin
对于 “无权限” 的页面采用了和 404
完全相同的策略。
导致这个问题的核心原因是 “路由和权限” 设计的两种不同的策略,我通过打比方给它们分别取了两个有趣的名字:“按需建楼” 和 “按需开房”。
需求:一位客户前来租赁办公室,他需要1楼、3楼、和5楼这三个楼层;此时你有两种策略完成任务:
-
按需建楼:根据客户的需要,只保留1、3、5这三个楼层的办公室,其他楼层全部拆除。因此每当客户前往2楼或者不存在的10楼时,都看到空空如也(
404
)。 -
按需开房:你不会去碰楼房的结构,而是给已有的楼层装上门,并提供给客户 “1、3、5” 这三层的钥匙。客户访问不存在的10楼时,看到
404
,访问被紧锁的2楼时,却看到403
。
当然,这个类比可能有些片面,对于一些过于庞大的项目,加载完整的路由是种负担,但 “加锁” 并告知 “无权访问” 确实是更优质的体验。
但是,你有思考过 404
和 403
在表现形式、以及实现上应该有所差异吗?
六、403
和 404
403 : 'Forbidden', //禁止
404 : 'Not Found', //没有找到
如果你没有想清楚这两个页面的差别,那么在实现时你可能会将它们混为一谈。
-
404:未找到目标页面;这是一个不用考虑 “用户态” 的页面;无论用户是否完成登录、存在便是存在,不存在便是不存在。因而它不适合被放在
导航-菜单-内容
布局之中,而是以 “全屏幕” 的方式进行显示; -
403: 拒绝访问;这是典型的具备 “用户态” 的页面,如果未曾登录,大概率会被直接重定向到 “登陆页”。当用户访问一个 “存在但无权限” 的页面时,更好的表现形式是 “仍然显示 导航&菜单”,但 “内容区” 却告知无权限并显示
403
。
基于上面的思考,可以发现这两个页面的实现存在较大区别:
- 404 通常是 “路由的兜底神器”。
[
...,// 前面是其他业务路由
{
path: '/:pathMatch(.*)*',
name: '404',
component: () => import('@/views/error-page/404')
}
]
其他路由未匹配上的路由,都会进入 404
的怀抱。
- 403 则因为需要嵌入 “导航、菜单布局”,更好的办法是:直接在布局文件中引用;
比如 Layout.vue
文件中:
// vue2.0 写法
<template>
<section class="app-main">
<page403 v-if="!pageAuth"></page403>
<router-view v-else> </router-view>
</section>
</template>
<script>
import page403 from '@/views/error-page/403'
export default {
components: { page403 },
computed: {
pageAuth() {
// 有权限返回 true, 反之 false
}
}
}
</script>
以上两种实现 404
和 403
的方法,可以完美解决 “重定向到 404
” 的糟糕体验。
七、更智能的“面包屑”和“首页跳转”
本文第 2.4 节描绘了一种不太聪明的面包屑。
但实际项目中,我们可能希望 “自动跳转” 这件事是 “更聪明” 一点的。
而最常见的跳转场景其实有两个:
- 非末端的面包屑
2.4节
已有详细描述,不再赘述 - 根路由
当你刚刚“完成登录”、或是直接在地址栏输入
/
、或是点击页面左上角的 “Logo
” 时,最佳的实践是访问根路由:/
但是/
根路由应该跳到哪里,也存在和 “面包屑” 同样的问题: “如果指定的页面没有权限,我该何去何从?”
vue-element-admin
选择了最简单,但是体验最差的办法:跳转到 404
。
仔细思考,这其实是一个 “递归查找可访问子节点” 的问题;
当我们 “点某个面包屑” 或者 “访问项目根节点” 时,我理解的过程应该是这样的:
- 它本身是否有指定路径、是否是合法页面?如果是、且有权限,直接访问。
- 它本身是否指定了重定向页面?如果是、且有权限,直接重定向。
- 递归它的子导航(sitemap.children)的每一项,依次按本过程判断。
注:所有页面都应该算
/
的子导航。
按这个思路,当指定路径没有授权时,通过不断向下寻找可访问页面,页面的重定向逻辑出奇的友好,绝不会轻易给用户报错。
八、这套理论适合在什么项目上使用?
答:管理后台 。
(作者没有什么 To C
的开发经验,因此以上概念、方法、理论的总结全部来自 To B
管理后台的,因此是否在 To C
项目上适用不下论断)
九、适合什么技术栈?
答:不限技术栈 。
至少,我已经在 vue@2.x
、vue@3.x
、React
、微前端
项目上都实践过了,能够充分且有力地支持各种项目的开发、落地。
十、有实践源码吗?
答:暂时只在项目中使用,没有产出开源作品。(会有的,在做了)
十一、结束语
我是春哥
。
大龄前端打工仔,依然在努力学习。
我的目标是给大家分享最实用、最有用的知识点,希望大家都可以早早下班,并可以飞速完成工作,淡定摸鱼🐟。
你可以在公众号里找到我:前端要摸鱼
。
转载自:https://juejin.cn/post/7101466998516744200