那么什么叫做 Layout 架构呢?我们来看这张图:
在这张图中,把页面分为了三个部分,分别是:
- 左侧的
Menu菜单 - 顶部的
NavBar - 中间的内容区
Main
看起来非常的简单,但是实际上有很多核心功能要完成:
- 用户退出方案
- 动态侧边栏方案
- 动态面包屑方案
除了这些核心内容之外,还有一些其他的小功能,比如:
- 退出的通用逻辑封装
- 伸缩侧边栏动画
vue3动画- 组件状态驱动的动态
CSS值等等
创建Layout 的基础架构
- 整个页面分为三部分,首先创建对应的三个组件:
layout/components/Sidebar/index.vuelayout/components/Navbar.vuelayout/components/AppMain.vue
- 完成对应的布局结构
<template>
<div class="app-wrapper">
<!-- 左侧 menu -->
<sidebar/>
<div class="main-container">
<div class="fixed-header">
<!-- 顶部的 navbar -->
<navbar />
</div>
<!-- 内容区 -->
<app-main />
</div>
</div>
</template>
-
在
styles中创建如下css文件:variables.scss: 定义常量mixin.scss:定义通用的csssidebar.scss:处理menu菜单的样式
-
为
variables.scss,定义如下常量并进行导出(:export可见 scss 与 js 共享变量):
// sidebar
$menuText: #bfcbd9;
$menuActiveText: #ffffff;
...
$subMenuHover: #001528;
$sideBarWidth: 210px;
// https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass
// JS 与 scss 共享变量,在 scss 中通过 :export 进行导出,在 js 中可通过 ESM 进行导入
:export {
menuText: $menuText;
...
sideBarWidth: $sideBarWidth;
}
- 在
index.scss中按照顺序导入以上样式文件
@import './variables.scss';
@import './mixin.scss';
@import './sidebar.scss';
- 在
layout/index.vue中写入如下样式
注意:css变量的写法:calc(100% - #{$sideBarWidth});
<style lang="scss" scoped>
@import '~@/styles/mixin.scss';
@import '~@/styles/variables.scss';
.app-wrapper {
@include clearfix;
...
}
.fixed-header {
...
width: calc(100% - #{$sideBarWidth});
}
</style>
获取并展示用户信息
请求拦截器
因为获取用户信息需要对应的 token ,所以我们可以利用 axios 的 请求拦截器 对 token 进行统一注入,在 utils/request.js 中写入如下代码:
import store from '@/store'
// 请求拦截器
service.interceptors.request.use(
config => {
// 在这个位置需要统一的去注入token
if (store.getters.token) {
// 如果token存在 注入token
config.headers.Authorization = `Bearer ${store.getters.token}`
}
return config // 必须返回配置
},
error => {
return Promise.reject(error)
}
)
在路由跳转过程中的路由拦截阶段触发动作:
在 permission.js 中写入以下代码:
if (to.path === '/login') {
...
} else {
// 判断用户资料是否获取
// 若不存在用户信息,则需要获取用户信息
if (!store.getters.hasUserInfo) {
// 触发获取用户信息的 action
await store.dispatch('user/getUserInfo')
}
next()
}
现在已经拿到了 用户数据,并且在 getters 中做了对应的快捷访问 ,那么接下来我们就可以根据数据渲染出 用户头像内容。
动态menu菜单处理
动态menu菜单处理方案解析
动态menu菜单 其实主要是和 动态路由表 配合来去实现 用户权限 的。用户权限处理后面会专门介绍, 在这里只处理 动态menu菜单。
所谓 动态menu菜单 指的是:根据路由表的配置,自动生成对应的 menu 菜单;当路由表发生变化时,menu 菜单自动发生变化。
实现方案:
- 定义 路由表 对应
menu菜单规则 - 根据规则制定 路由表
- 根据规则,依据 路由表 ,生成
menu菜单
根据实现方案可以发现,实现 动态menu菜单 最核心的关键点其实就在步骤一,也就是
定义 路由表 对应
menu菜单规则
规则是可以根据项目情况而定,这里采用的规则如下:
- 如果
meta && meta.title && meta.icon:则显示在menu菜单中,其中title为显示的内容,icon为显示的图标 - 如果存在
children:则以el-sub-menu(子菜单)展示 - 否则:则以
el-menu-item(菜单项)展示 - 否则:不显示在
menu菜单中
动态menu菜单处理方案实现
明确了对应的方案之后,下面来实现对应的代码逻辑。
根据我们的分析,想要完成动态的 menu,那么我们需要按照以下的步骤来去实现:
- 创建页面组件
- 生成路由表
- 解析路由表
- 生成
menu菜单
创建结构路由表
这是我们最终要实现的 menu 截图,可以知道两点内容:
- 创建的页面并没有全部进行展示
根据该规则,不满足meta && meta.title && meta.icon的条件,将不会展示,比如某些详情页或者错误页
menu菜单将具备父子级的结构
[
{
"title": "个人中心",
"path": ""
},
{
"title": "用户",
"children": [
{
"title": "员工管理",
"path": ""
},
{
"title": "角色列表",
"path": ""
},
{
"title": "权限列表",
"path": ""
}
]
},
{
"title": "文章",
"children": [
{
"title": "文章排名",
"path": ""
},
{
"title": "创建文章",
"path": ""
}
]
}
]
因为将来进行 用户权限处理,所以此时我们需要先对路由表进行一个划分:1. 私有路由表 privateRoutes :权限路由;2. 公有路由表 publicRoutes:无权限路由
根据以上理论,生成以下路由表结构:
// 私有路由表
const privateRoutes = [
{
path: '/user',
component: layout,
redirect: '/user/manage',
meta: {
title: 'user',
icon: 'personnel'
},
children: [
{
path: '/user/manage',
component: () => import('@/views/user-manage/index'),
meta: {
title: 'userManage',
icon: 'personnel-manage'
}
},
{
path: '/user/role',
component: () => import('@/views/role-list/index'),
meta: {
title: 'roleList',
icon: 'role'
}
},
{
path: '/user/permission',
component: () => import('@/views/permission-list/index'),
meta: {
title: 'permissionList',
icon: 'permission'
}
},
{
path: '/user/info/:id',
name: 'userInfo',
component: () => import('@/views/user-info/index'),
meta: {
title: 'userInfo'
}
},
{
path: '/user/import',
name: 'import',
component: () => import('@/views/import/index'),
meta: {
title: 'excelImport'
}
}
]
},
...
]
// 公开路由表
const publicRoutes = [
{
path: '/login',
component: () => import('@/views/login/index')
},
{
path: '/',
// 注意:带有路径"/"的记录中的组件"默认"是一个不返回 Promise 的函数
component: layout,
redirect: '/profile',
children: [
{
path: '/profile',
name: 'profile',
component: () => import('@/views/profile/index'),
meta: {
title: 'profile',
icon: 'el-icon-user'
}
},
{
path: '/404',
name: '404',
component: () => import('@/views/error-page/404')
},
{
path: '/401',
name: '401',
component: () => import('@/views/error-page/401')
}
]
}
]
const router = createRouter({
history: createWebHashHistory(),
routes: [...publicRoutes, ...privateRoutes]
})
在 layout/appMain 下设置路由出口
<template>
<div class="app-main">
<router-view></router-view>
</div>
</template>
解析路由表,获取结构化数据
想要获取路由表数据,那么有两种方式:
- router.options.routes:初始路由列表(新增的路由 无法获取到)
- router.getRoutes():获取所有 路由记录 的完整列表
所以,我们此时使用 router.getRoutes()
在 layout/components/Sidebar/SidebarMenu 下写入以下代码:
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
console.log(router.getRoutes())
</script>
得到返回的数据:
[
{
"path":"/user/info/:id",
"name":"userInfo",
"meta":{
"title":"userInfo"
},
"children":[
]
},
{
"path":"/article/editor/:id",
"meta":{
"title":"articleEditor"
},
"children":[
]
}
]
从返回的数据来看,它与我们想要的符合menu菜单数据结构相去甚远。出现这个问题的原因,是因为它返回的是一个 完整的路由表。
这个路由表距离我们想要的存在两个问题:
- 存在重复的路由数据
- 不满足该条件
meta && meta.title && meta.icon的数据不应该存在
创建两个方法分别处理对应的两个问题: filterRouters和generateMenus。
在 SidebarMenu 中调用该方法
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { filterRouters, generateMenus } from '@/utils/route'
const router = useRouter()
const routes = computed(() => {
const filterRoutes = filterRouters(router.getRoutes())
return generateMenus(filterRoutes)
})
console.log(JSON.stringify(routes.value))
</script>
生成动态 menu 菜单
整个 menu 菜单,我们将分成三个组件来进行处理
SidebarMenu:处理数据,作为最顶层menu载体SidebarItem:根据数据处理 当前项为el-submenu||el-menu-itemMenuItem:处理el-menu-item样式
首先是 SidebarMenu
<template>
<!-- 一级 menu 菜单 -->
<el-menu
...
>
<sidebar-item
v-for="item in routes"
:key="item.path"
:route="item"
></sidebar-item>
</el-menu>
</template>
创建 SidebarItem 组件,用来根据数据处理 当前项为 el-submenu || el-menu-item ,这里面存一个循环组件渲染的知识点。
<template>
<!-- 支持渲染多级 menu 菜单 -->
<el-submenu v-if="route.children.length > 0" :index="route.path">
<template #title>
<menu-item :title="route.meta.title" :icon="route.meta.icon"></menu-item>
</template>
<!-- 循环渲染 -->
<sidebar-item
v-for="item in route.children"
:key="item.path"
:route="item"
></sidebar-item>
</el-submenu>
<!-- 渲染 item 项 -->
<el-menu-item v-else :index="route.path">
<menu-item :title="route.meta.title" :icon="route.meta.icon"></menu-item>
</el-menu-item>
</template>
创建 MenuItem 用来处理 el-menu-item 样式
<template>
<i v-if="icon.includes('el-icon')" class="sub-el-icon" :class="icon"></i>
<svg-icon v-else :icon="icon"></svg-icon>
<span>{{ title }}</span>
</template>
至此,整个的 menu 菜单结构就已经完成了。但是还存在一个样式问题,因为后面我们需要处理 主题替换 ,所以此处我们不能把样式写死
在 store/getters 中创建一个新的 快捷访问
import variables from '@/styles/variables.scss'
const getters = {
...
cssVar: state => variables
}
export default getters
在 SidebarMenu 中写入如下样式
<el-menu
:background-color="$store.getters.cssVar.menuBg"
:text-color="$store.getters.cssVar.menuText"
:active-text-color="$store.getters.cssVar.menuActiveText"
>
动画逻辑,左侧菜单伸缩功能实现
左侧菜单伸缩 这个功能核心的点在于动画处理,样式的改变总是由数据进行驱动,所以首先我们去创建对应的数据
创建 store/app 模块,写入如下代码
export default {
namespaced: true,
state: () => ({
sidebarOpened: true
}),
mutations: {
triggerSidebarOpened(state) {
state.sidebarOpened = !state.sidebarOpened
}
},
actions: {}
}
在 SidebarMenu 中,控制 el-menu 的 collapse 属性
<el-menu
:collapse="!$store.getters.sidebarOpened"
...
在 layout/index 中指定 整个侧边栏的宽度和缩放动画
<div
class="app-wrapper"
:class="[$store.getters.sidebarOpened ? 'openSidebar' : 'hideSidebar']"
>
...
在 layout/index 中 处理 navbar 的宽度
<style lang="scss" scoped>
.fixed-header {
position: fixed;
top: 0;
right: 0;
z-index: 9;
width: calc(100% - #{$sideBarWidth});
transition: width 0.28s;
}
.hideSidebar .fixed-header {
width: calc(100% - #{$hideSideBarWidth});
}
</style>
在 styles/variables.scss 中指定 hideSideBarWidth
$hideSideBarWidth: 54px;
组件状态驱动的动态 CSS 值
在 vue 3.2 最新更新中,除了之前我们介绍的 响应式变化 之外,还有另外一个很重要的更新,那就是 组件状态驱动的动态 CSS 值 ,对应的文档也已经公布,大家可以 点击这里 查看
那么下面利用最新的特性,来为 logo-container 指定下高度:
<template>
<el-avatar :size="logoHeight"
</template>
<script setup>
...
const logoHeight = 44
</script>
<style lang="scss" scoped>
.logo-container {
height: v-bind(logoHeight) + 'px';
...
}
</style>
动态面包屑方案分析
动态面包屑:根据当前的 url 自动生成面包屑导航菜单
动态获取路由数据
无论之后路径发生了什么变化,动态面包屑 都会正确的进行计算,所以我们的关键是如何获取面包屑结构数据。
要动态的获取路由信息,需要使用route.match 属性来:**获取与给定路由地址匹配的[标准化的路由记录]。
const route = useRoute()
// 生成数组数据
const breadcrumbData = ref([])
const getBreadcrumbData = () => {
breadcrumbData.value = route.matched.filter(
item => item.meta && item.meta.title
)
}
// 监听路由变化时触发
watch(
route,
() => {
getBreadcrumbData()
},
{
immediate: true
}
)
</script>
面包屑的动画处理
vue3对 动画 进行了一些修改(vue 动画迁移文档)
主要的修改其实只有两个:
- 过渡类名
v-enter修改为v-enter-from - 过渡类名
v-leave修改为v-leave-from
那么依据修改之后的动画,我们来为面包屑增加一些动画样式:
- 在
Breadcrumb/index中增加transition-group
<template>
<el-breadcrumb class="breadcrumb" separator="/">
<transition-group name="breadcrumb">
...
</transition-group>
</el-breadcrumb>
</template>
- 新建
styles/transition样式文件
.breadcrumb-enter-active,
.breadcrumb-leave-active {
transition: all 0.5s;
}
.breadcrumb-enter-from {
opacity: 0;
transform: translateX(20px);
}
.breadcrumb-leave-to {
opacity: 0;
transform: translateX(-20px);
}
// 在动画过程中,会有重叠效果的影响,所以加上一个层级关系
.breadcrumb-leave-active {
position: absolute;
z-index: -1;
}
总结
本文围绕着layout 为核心,主要实现了三个大的业务方案:用户退出方案、动态侧边栏方案、动态面包屑方案。
除了这三块大的方案之后,还有一些小的功能,比如:
- 登出的通用逻辑封装
- 伸缩侧边栏动画
vue3动画- 组件状态驱动的动态
CSS值等等