此篇文章主要介绍如何利用vuex实现多标签保持页面管理功能,效果如图所示:
希望本篇文章对有需要实现类似功能的小伙伴,有一点点借鉴效果,好了废话不多说一步一步来介绍。
关于vue-router应用参考: https://blog.csdn.net/sunshouyan/article/details/107016023
关于vuex应用参考:https://blog.csdn.net/sunshouyan/article/details/107020699
vue的components组件参数配置以及keep-alive的综合运用;
首先具体实现功能之前,我们先对自己想要实现的前端交互样式有一个简单的规划,当然这个可以根据具体设计UI图调整,但基本思路不会变。
如下图:
a、顶部是公用的头部展示区域;
b、左侧是权限菜单展示区域;
c、右侧上部分是多标签展示区域;
d、右侧下部分是动态路由页面切换区域;
操作链:
正向操作:b菜单操作->产生c和d
反向操作:c标签操作-> 联动b和d
根据以上交互思路,我们简单把主框架拆分成不同的小组件,组成整体布局Layout;
Layout组成包括:
index.vue // 框架主入口组件
navBar.vue // 左侧动态菜单展示组件
tagNav.vue // 标签展示和操作组件
scrollBar.vue // 标签打开过多的情况,负责标签滚动计算和滚动
Layout / index.vue代码结构如下:
<template>
<div class="layout">
<!-- 公用头部区域 -->
<div class="layout-top">
<div class="logo">{{sysName}}</div>
<div class="info">
<span><i class="el-icon-s-custom"></i> {{userName}}</span>
<span><i class="el-icon-bell"></i> 消息</span>
<span><i class="el-icon-setting"></i> 设置</span>
<span @click="closeAllTags"><i class="el-icon-delete"></i> 清空标签</span>
</div>
</div>
<div class="layout-main">
<!-- 动态菜单展示区域 -->
<div class="layout-main-left">
<NavBar :isCollapse='menuCollapse'></NavBar>
</div>
<div class="layout-main-right">
<!-- 标签展示区域 -->
<div class="right-nav">
<TagNav></TagNav>
</div>
<!-- 标签动态路由区域 -->
<div class="right-inner">
<keep-alive :include="tagNavList">
<router-view></router-view>
</keep-alive>
</div>
</div>
</div>
</div>
</template>
<script>
/**
* @description 框架入口组件
* @author sunsy
* @createTime 2020/07/11
*/
import NavBar from "./navBar"
import TagNav from "./tagNav";
export default {
name: "layout",
components: {
TagNav,
NavBar,
},
data() {
return {
// 系统名称信息
sysName: "砖农吃瓜平台",
// 登录的用户信息
userName: "sunsy",
// 菜单栏是否是展开状态
menuCollapse: false,
};
},
computed: {
// 读取缓存的标签页面
tagNavList() {
return this.$store.state.tagNav.cachedPageName;
}
},
methods: {
// 清空之前缓存的所有标签以及缓存数据
closeAllTags() {
this.$store.dispatch('tagNav/clearAllTag');
}
},
created() {
//let user = localStorage.getItem("userInfo");
//if (user) {
// this.userInfo = JSON.parse(user);
// }
},
}
准备好主框架后,我们需要先准备动态菜单和路由,因为只有了菜单和路由的基础,才能产生我们的标签数据。
本文中示例项目中所有的请求都采用Mock模拟后端返回,所以如果有按照本文流程测试的小伙伴记得安装Mock依赖;
菜单里存在显性菜单和隐性菜单两种:
1.显性菜单指菜单名称需要在菜单列表显示出来;
2.隐性菜单指菜单名称不需要在菜单列表展示出来,但是又属于用户权限里的一个,一般出现在详情二级页面的访问的情况;
3.在以上Json数据中,type:0 指显性菜单; type:2 指隐性菜单; 可以根据自己情况配置测试。
ps: 其实还有一种菜单权限是指操作菜单type:1;(例如新增、删除、编辑按钮)但是这个牵扯到用户权限系统的时才使用,演示项目并不牵扯到用户权限,所以本文中不做具体介绍。
动态菜单采取了vuex状态管理,首次请求本地做缓存,以提高访问性能和速度。
strore/modules/auth/index.js文件代码:
/**
* @description 用户菜单权限状态管理模块
* @author sunsy
* @createTime 2019/08/19
*/
import axios from '@/util/ajax'
const state = {
navList: [],
}
const mutations = {
setNavList: (state, data) => {
state.navList = data
},
}
const actions = {
// 获取该用户的菜单列表
getNavList({ commit }) {
return axios.get({
url: '/api/menu',
data:''
}).then((res) => {
let data = res.data || [];
commit("setNavList", data);
});
},
// 将菜单列表扁平化形成权限列表
getPermissionList({ state }) {
return new Promise((resolve) => {
let permissionList = []
// 将菜单数据扁平化为一级
function flatNavList(arr) {
for (let v of arr) {
if (v.children && v.children.length) {
flatNavList(v.children)
} else {
permissionList.push(v)
}
}
}
flatNavList(state.navList)
resolve(permissionList)
})
},
// 清空缓存的菜单数据
clearAllMenu: ({ commit }) => {
commit("setNavList", []);
},
}
export default {
namespaced: true,
state,
mutations,
actions
}
模拟准备好菜单数据后,接下来就是准备菜单对应的组件以及路由和菜单映射关系配置。
router/staticRoute.js文件:
需要注意点:
1.需要用标签打开的路由,组件加载都是基于layout组件,所以组件引入的时候父组件记得配置为layout;
2.不需要标签打开的路由,不需要如此配置;
菜单和对应的组件配置准备好后,接下来就是初始化路由的时候引入菜单并做相关的业务操作处理。
router/index.js 文件代码:
import Vue from 'vue'
import VueRouter from 'vue-router'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import store from '../store'
import staticRoute from './staticRoute'
import { Message } from 'element-ui'
var permissionList = [];
function initRoute(router) {
return new Promise((resolve) => {
// 首次进入加载菜单信息,以后读取store中数据
if(store.state.auth.navList.length == 0){
store.dispatch('auth/getNavList').then(() => {
// 数据扁平化处理
store.dispatch('auth/getPermissionList').then((res) =>{
permissionList = res
res.forEach(function (v) {
// 设置已点击菜单返回数据
let routeItem = router.match(v.path)
if (routeItem) {
routeItem.meta.permission = v.permission ? v.permission : []
routeItem.meta.name = v.name
}
})
resolve()
})
});
} else {
// 数据扁平化处理
store.dispatch('auth/getPermissionList').then((res) =>{
permissionList = res
res.forEach(function (v) {
// 设置已点击菜单返回数据
let routeItem = router.match(v.path)
if (routeItem) {
routeItem.meta.permission = v.permission ? v.permission : []
routeItem.meta.name = v.name
}
})
resolve()
})
}
})
}
NProgress.configure({ showSpinner: false });
Vue.use(VueRouter)
const originalPush = VueRouter.prototype.push
VueRouter.prototype.push = function push(location) {
return originalPush.call(this, location).catch(err => err)
}
const router = new VueRouter({
mode: 'hash',
routes: staticRoute
})
// 路由跳转前验证
router.beforeEach((to, from, next) => {
// 开启进度条
NProgress.start();
if (to.path.indexOf("/error") >= 0) {
// 防止因重定向到error页面造成beforeEach死循环
next()
} else {
initRoute(router).then(() => {
let isPermission = false
permissionList.forEach((v) => {
// 判断跳转的页面是否在权限列表中
if (v.path == to.path) {
isPermission = true
}
})
// 没有权限时跳转到401页面
if (!isPermission) {
if (whiteList.indexOf(to.path) >= 0) {
next()
} else {
Message.error('请求错误,请确认访问地址是否正确!')
next({ path: "/error/404", replace: true })
}
} else {
next()
}
})
}
})
// 结束Progress
router.afterEach(() => {
NProgress.done();
})
//全局混入组件内路由守卫
// Vue.mixin({
// //在当前路由改变,但是该组件被复用时调用
// beforeRouteUpdate:function(to, from, next){
// //读取已打开标签数据列表
// let openedPageList = store.state.tagNav.openedPageList;
// //判断已打开标签数据列表中是否存在当前访问路由
// if(openedPageList.some(v => v.path === to.path)){
// //过滤标签数据列表中当前访问的路由标签缓存数据
// let toPageNav = openedPageList.filter(v => { if(v.path === to.path) return true });
// //判断标签存储数据全路径是否发生变化
// if(toPageNav.length >0 && toPageNav[0].fullpath !== to.fullPath){
// //循环缓存的keepAlive页面数据
// this.$children.forEach(v=>{
// if(v.$vnode.tag){
// let tagList = v.$vnode.tag.split('-');
// let tag = tagList[tagList.length-1];
// //判断缓存的keepAlive列表中等于当前访问标签页面的
// if(toPageNav[0].name === tag){
// //判断此页面是否为keepAlive保持状态
// if (v.$vnode && v.$vnode.data.keepAlive){
// //判断缓存数据值存在的有效性
// if (v.$vnode.parent && v.$vnode.parent.componentInstance && v.$vnode.parent.componentInstance.cache) {
// if (v.$vnode.componentOptions) {
// //删除的keepAlive缓存的Key值
// var key = v.$vnode.key == null
// ? v.$vnode.componentOptions.Ctor.cid + (v.$vnode.componentOptions.tag ? `::${v.$vnode.componentOptions.tag}` : '')
// : v.$vnode.key;
// //keepAlive的缓存列表
// var cache = v.$vnode.parent.componentInstance.cache;
// //keepAlive的键值列表
// var keys = v.$vnode.parent.componentInstance.keys;
// //强制从keepAlive缓存中删除
// if (cache[key]) {
// if (keys.length) {
// var index = keys.indexOf(key);
// if (index > -1) {
// keys.splice(index, 1);
// }
// }
// delete cache[key];
// }
// }
// }
// }
// return;
// }
// }
// });
// }
// }
// next();
// },
// });
export default router
初始化路由的业务操作主要有如下几个内容:
1.前置路由守卫的时候,初始化菜单并处理为扁平化数据判断当前访问路由页面是否存在于用户权限的菜单列表中;
2.前置路由开启页面进度加载状态;
3.后置路由结束页面进度加载状态;
4.请注意上面注释掉的混合混入路由守卫代码块,这一块后面会强调如何使用,是否开启根据自己的情况来。当时为了解决问题,全局混入这块的逻辑代码是参考某位大神提供的思路,结合多标签稍作调整,大神的是哪位时间有点久忘记地址了。总之感谢大神的无私分享。
完成上面3步后,在main.js分别 引入store和router即可。
Layout/navBar.vue文件代码:
<template>
<div class="side-nav" :class="layout">
<el-menu router ref="navbar" :default-active="defActive" :mode="navMode" :collapse="tem_Collapse"
v-for="(item, n) in menuList" :key="n" class="el-menu-vertical" :default-openeds="defaultOpeneds">
<el-submenu v-if="item.children && item.children.length !==0 " :index="item.path">
<template slot="title">
<i v-if="item.icon" :class="item.icon"></i>
<span slot="title">{{ item.name }}</span>
</template>
<el-menu-item v-for="(subItem,m) in item.children" :index="subItem.path" :route="{path: subItem.path}" :key="m" v-show="subItem.type == 0 ">
<i v-if="subItem.icon" :class="subItem.icon"></i>
{{ subItem.name }}
</el-menu-item>
</el-submenu>
<el-menu-item v-else :index="item.path" :route="{path: item.path}" v-show="item.type == 0">
<i v-if="item.icon" :class="item.icon"></i>
<span>{{ item.name }}</span>
</el-menu-item>
</el-menu>
</div>
</template>
<script>
/**
- @description 左侧菜单展示组件
- @author sunsy
- @createTime 2020/07/11
*/
import { mapState } from 'vuex'
export default {
data() {
return {
// 菜单列表数组
navList: [],
// 菜单是否折叠
tem_Collapse: false,
// 默认展开的菜单栏数组
defaultOpeneds: [],
}
},
props: {
layout: {
type: String,
default: 'left'
},
isCollapse: {
type: Boolean,
default: false
}
},
computed:{
...mapState({
menuList: state => state.auth.navList
}),
defActive(){
return this.$route.path
},
navMode(){
if(this.layout == "left"){
return "vertical"
} else if(this.layout == "left1"){
return "vertical"
} else if(this.layout == "top"){
return "horizontal"
}
},
},
watch: {
isCollapse(newVal,oldVal){
if(newVal != oldVal) {
this.tem_Collapse = newVal;
}
},
},
}
至此,主框架页面左侧菜单展示和点击菜单在右侧切换路由页面初步实现,接下来就是我们的重点内容,多标签的实现。
基本思路:
1、菜单点击的时候,需要在把访问的路由信息加入到标签列表;这样就需要定义一个state树来存储标签列表;
2、标签访问基本路径相同,但是出现动态带参数的?去重判断的时候判断全路径是否相等;
3、相同的菜单重复点击出现多个相同标签?那就需要在存储标签列表的时候判断去重;
4、可是如何在切换标签的时候保持页面内容呢?这个时候就想起了keep-alive,它的include可以定义哪些不活动的组件做缓存的,是不是突然感觉很简单了。这样我们只需要把需要缓存的路由对应的组件名称存储在state树里并监听变化赋值给keep-alive不就可以了。
keep-alive使用参考官方文档:https://cn.vuejs.org/v2/api/#keep-alive
5、新增了标签,肯定也需要删除标签,删除标签简单就是从state树的存储标签列表中移除就可以了;
6、当然清空标签列表也是需要的,试想用户关闭了浏览器或退出登陆再打开还是昨天打开的那么多标签列表是不是也有点闹心,毕竟每天都是新的开始。所以也需要考虑关闭浏览器或退出登陆的时候清空标签缓存数据;
7、标签列表中如何突出显示当前访问的标签呢?当然根据当前访问路由路径和标签列表一个对比也可以实现,但是不是有点低效率了, 这个时候在state里定义一个当前访问标签的数据存储,这样对比起来效率是不是更高点。
好了,以上思路有了那根据思路一步一步的来实现代码逻辑就可以了。
store/modules/tagNav/index.js文件代码:
/**
* @description 多标签保持状态管理模块
* @author sunsy
* @createTime 2019/08/21
*/
const state = {
//是否要缓存页面,false不缓存, true缓存
cachePage: true,
//已经打开的页面
openedPageList: [],
//当前打开的标签页面
currentTagNav: {},
//缓存的页面
cachedPageName: []
}
const mutations = {
//打开标签页面
addTagNav(state, data){
//判断全路径完全相等不执行
if (state.openedPageList.some(v => v.fullpath === data.fullpath)) return
//判断组件路径是否已经存在,存在更新原有数据
if (state.openedPageList.some(v => v.path === data.path)) {
state.openedPageList.forEach((v,i) => {
if(v.path === data.path){
state.openedPageList[i] = data;
return;
}
});
} else {
if(state.cachedPageName.includes(data.name)){
console.error(`${data.name} 组件出现命名重复,请检查组件中的name字段。当前组件所在的路由地址为:${data.path}`)
return
}
state.openedPageList.push(data)
if(state.cachePage){
if(data.name){
state.cachedPageName.push(data.name);
localStorage.setItem('cachedPageName',JSON.stringify(state.cachedPageName));
}
}
}
if(state.cachePage){
localStorage.setItem('openedPageList',JSON.stringify(state.openedPageList));
}
},
//删除标签页面
removeTagNav(state, data){
if(data){
for(let [i, v] of state.openedPageList.entries()){
if(v.path === data.path){
state.openedPageList.splice(i, 1)
}
}
if(state.cachePage){
let index = state.cachedPageName.indexOf(data.name)
if(index >= 0){
state.cachedPageName.splice(index, 1)
}
}
} else{
state.openedPageList = []
state.cachedPageName = []
}
//判断是否开启浏览器刷新保留已打开标签
if(state.cachePage){
localStorage.setItem('openedPageList',JSON.stringify(state.openedPageList));
localStorage.setItem('cachedPageName',JSON.stringify(state.cachedPageName));
}
},
//更新当前访问标签页面
setCurrentTagNav(state, data) {
state.currentTagNav = data;
},
//更新打开标签数据列表
setOpenedPageList(state, data) {
if(data){
state.openedPageList = data;
}
},
//更新标签页面缓存数据列表
setCachedPageName(state, data) {
if(data){
state.cachedPageName = data;
}
},
}
const actions = {
//初始化页面数据缓存数据
initPageList: ({ commit, state }) => {
if(state.cachePage){
//读取打开标签列表数据
let dataList = JSON.parse(localStorage.getItem('openedPageList'));
commit('setOpenedPageList', dataList);
//读取缓存标签页面数据
let dataCache = JSON.parse(localStorage.getItem('cachedPageName'));
if(dataCache) {
dataCache = dataCache.filter(v=>{
if(v !==null)
return true;
});
commit('setCachedPageName', dataCache);
}
}
},
//快捷关闭当前访问标签
closeCurrentTag: ({ commit, state }) =>{
if(state.currentTagNav){
const data = state.currentTagNav;
commit('removeTagNav',data)
}
},
//快捷关闭所有标签
clearAllTag: ({ commit }) => {
commit('removeTagNav');
},
}
export default {
namespaced: true,
state,
mutations,
actions
}
容我歇会。。。。。
分享内容真不容易,写到这里我都快写吐了。哎码字比码代码累多了… 跪谢以前那些无私分享整理的前辈和大神们呐!
好吧,继续写。。。
Layout/tagNav.vue文件代码:
<template>
<div class="tag-nav">
<scroll-bar ref="scrollBar" style="height:100%">
<router-link ref="tag" class="tag-nav-item" :class="isActive(item) ? 'cur' : ''" v-for="(item, index) in tagNavList" :to="{path:item.path,query:item.query}" :key="index" style="display:inline-block; ">
{{item.title}}
<span v-if="item.name!=='home'" @click.prevent.stop="closeTheTag(item, index)"><i class="el-icon-close"></i></span>
</router-link>
</scroll-bar>
</div>
</template>
<script>
/**
* @description 多标签展示组件
* @author sunsy
* @createTime 2020/07/11
*/
import ScrollBar from './scrollBar'
export default {
components: {
ScrollBar
},
data(){
return {
defaultPage: '/home',
}
},
computed: {
tagNavList(){
return this.$store.state.tagNav.openedPageList;
},
},
mounted(){
// 首次加载时将默认页面加入缓存
this.addTagNav()
},
watch: {
$route(){
this.addTagNav()
this.scrollToCurTag()
}
},
methods: {
addTagNav(){
this.$store.dispatch('tagNav/initPageList');
// 如果需要缓存则必须使用组件自身的name,而不是router的name
this.$store.commit("tagNav/addTagNav", {
name: this.$router.getMatchedComponents()[1].name,
path: this.$route.path,
title: this.$route.meta.name,
fullpath: this.$route.fullPath,
query: this.$route.query,
})
},
isActive(item){
// 根据当前路由访问更新当前访问标签页面数据
if(item.path == this.$route.path){
this.$store.commit("tagNav/setCurrentTagNav",item);
}
return item.path === this.$route.path
},
closeTheTag(item, index){
// 当关闭当前页面的Tag时,则自动加载前一个Tag所属的页面
// 如果没有前一个Tag,则加载默认页面
this.$store.commit("tagNav/removeTagNav", item)
if(this.$route.path == item.path){
// 去掉当前页面之后,还有已打开的tab页面时
if(this.tagNavList.length != 0){
// 关闭的页面不为已打开页面的首个tab页面
if(index !=0){
this.$router.push({path:this.tagNavList[index-1].path,query:this.tagNavList[index-1].query})
} else {
// 关闭首个tab页面
this.$router.push({path:this.tagNavList[index].path,query:this.tagNavList[index].query})
}
} else {
// 已经没有已打开的tab页面,默认打开首页
this.$router.push(this.defaultPage)
}
}
this.scrollToCurTag();
},
// 根据当前访问页面滚动到相应标签位置
scrollToCurTag(){
this.$nextTick(() =>{
for (let item of this.$refs.tag) {
if (item.to.path === this.$route.path) {
this.$refs.scrollBar.scrollToCurTag(item.$el)
break
}
}
})
}
},
}
</script>
Layout/scrollBar.vue文件代码:
<template>
<div class="scroll-wrap" ref="scrollWrap" @wheel.prevent="scroll">
<div class="scroll-cont" ref="scrollCont" :style="{left: left + 'px'}">
<slot></slot>
</div>
</div>
</template>
<script>
/**
* @description 多标签滚动组件
* @author sunsy
* @createTime 2020/07/11
*/
import { parse } from 'path'
export default {
data(){
return {
// 滚动距离
left: 0,
// 滚动速度
wheelSpeed: 30,
}
},
created(){
// 从缓存中获取当前left的值,避免页面刷新,不保持问题
this.left = parseInt(sessionStorage.getItem('leftScroll'))
},
methods: {
// 滚轮滚动定义标签位置
scroll(e){
const scrollWrapWidth = this.$refs.scrollWrap.offsetWidth - (this.$refs.scrollWrap.offsetWidth*0.15)
const scrollContWidth = this.$refs.scrollCont.offsetWidth
if(scrollContWidth > scrollWrapWidth){
// 统一不同浏览器下wheel事件的滚动值
// chrome/FF/Edge/IE11/IE10/IE9
// e.deltaY > 0 即鼠标滚轮向下滚动,则该条向右滚动,left值变负
const scrollSpace = e.deltaY > 0 ? -1 * this.wheelSpeed : this.wheelSpeed
if(e.deltaY > 0){
if(Math.abs(this.left + scrollSpace) <= (scrollContWidth - scrollWrapWidth)){
this.left += scrollSpace
sessionStorage.setItem('leftScroll',this.left);
}
} else {
if(this.left + scrollSpace < 0){
this.left += scrollSpace
sessionStorage.setItem('leftScroll',this.left);
} else {
this.left = 0
sessionStorage.setItem('leftScroll',this.left);
}
}
} else {
return
}
},
// 定位滚动到当前访问页面标签
scrollToCurTag(tar){
const scrollWrapWidth = this.$refs.scrollWrap.offsetWidth - (this.$refs.scrollWrap.offsetWidth*0.15)
const tarWidth = tar.offsetWidth
const tarLeft = tar.offsetLeft
if(tarLeft < -1 * this.left){
this.left = -tarLeft
sessionStorage.setItem('leftScroll',this.left);
} else if(tarLeft + tarWidth > scrollWrapWidth){
this.left = -(tarLeft + tarWidth - scrollWrapWidth)
sessionStorage.setItem('leftScroll',this.left);
}
}
}
}
</script>
整个多标签保持页面的实现代码和逻辑就如上面代码和思路,至此功能效果基本已经实现完成。
累了累了…我相信小伙伴肯定都是比我聪明百倍的人一定看得懂,写到这里我已经飘飘然了,请原谅我不一一解释代码逻辑了,有问题的到时候可以私信问我,当然我指不定什么时候才能看见回复抱歉。
缓存的页面当切换标签后,再回到此标签页面后之前页面的操作状态和内容依然保持;
不缓存页面,当切换标签后,再回到此标签页面后组件重新加载,内容不保持;
本项目多标签实现keep-alive缓存是根据组件的名称来的,所以如果想设置某个页面缓存保持只需要在组件里设置组件名称就可以。
export defautl {
name: "xxxx"
}
个人观点:虽然是多标签页面功能,但是也不是说所有页面都需要设置为缓存保持的必要,如下几种我觉得有设置的必要:
1、表单操作,操作了一半离开了,切换标签再回到此页面希望用户之前填写的内容还依然存在这种情况可以保持。
2、两个标签内容页面数据需要来回对比的情况也可以保持;
当然还是根据自己实际使用情况来设置就可以。
设置缓存的页面,再次更新数据会有点问题,问题如下图:
问题点:动态请求路由参数虽然变化了,但是缓存标签页面内容却没有更新。
这个问题就是上文中第2步准备动态菜单数据和路由配置>>c菜单和路由初始化里提到全局混入组件内路由守卫的使用。
全局混入路由守卫逻辑:
get访问path路径,query传参:
this.$router.push({path:'xxxx',query:{id:id}})
post访问name组件名称,params传参:
this.$router.push({name:"xxxx",params:{id:id}})
ps:post访问一定记得是name,并且这个name是指components组件定义的时候定义的name如下图:
1、样式定义尽量避免重复名称,防止标签页面同时打开的时候,相同样式名称受影响;
2、ref标识定义尽量避免重名,防止标签页面同时打开的时候,调用出现问题;
以上内容就是多标签页面保持功能的实现的步骤和逻辑,完整的演示demo我稍后会上传,方便大家下载参考。
补充下载地址:https://download.csdn.net/download/sunshouyan/12649594
懒得不能懒得自己,先给自己挖个坑,下一篇更新vuex,g6,实现在线绘制流程图功能。