关于vue的多页面标签功能,对于嵌套router-view缓存的最终无奈解决方法
菜工 人气:0最近写我自己的后台开发框架,要弄一个多页面标签功能,之前有试过vue-element-admin的多页面,以为很完美,就按它的思路重新写了一个,但发现还是有问题的。
vue-element-admin它用的是在keep-alive组件上使用include属性,绑定$store.state.tagsView.cachedViews,当点击菜单时,往$store.state.tagsView.cachedViews添加页面的name值,在标签卡上点击关闭后就从$store.state.tagsView.cachedViews里面把缓存的name值删除掉,这样听似乎没什么问题。但它无法很好的支持无限级别的子菜单的缓存。
目前vue-element-admin官方预览地址的菜单结构大多是一级菜单分类,下面是二级子菜单。如下图所示,它只能缓存二级子菜单,三级子菜单它缓存不了。为什么会出现这个情况呢。因为嵌套router-view的问题。
按vue-element-admin的路由结构,它的一级菜单,其实对应的是一个layout组件,layout里面有个router-view(称它为一级router-view)它有用keep-alive包裹着,用来放二级菜单对应的页面,所以对于二级菜单来说,它都是用同一个router-view。如果我需要创建三级菜单的话,那就需要在二级菜单目录里创建一个包含router-view(称它为二级router-view)的index.vue文件,用来放三级菜单对应的页面,那么你就会发现这个三级菜单的页面怎么也缓存不了。
因为只有一级router-view被keep-alive包裹起着缓存作用,下面的router-view它不缓存。当然我们也可以在二级的router-view也包一个keep-alive,也用include属性,但你会发现也用不了,因为还要匹配name值,就是说二级router-view的文件也得写上name值,写上name值后你发现还是用不了,因为include数组里面没有这个二级router-view的name值,所以你还得在tabsView里的addView里面做手脚,把路由所匹配到的所有路由的name值都添加到cachedViews里,然后还要在关闭时再进行处理。天啊。我想想都头痛,理论是应该是可以实现的,但会增加了很多前端代码量。
请注意!下面的方法也是有Bug的,请重点看下面的BUT开始部分
还好keep-alive还有另一个属性exclude,我马上就有思路了,而且非常简洁,默认全部页面进行缓存,所有的router-view都包一层keep-alive,只有在点击标签卡上的关闭按钮时,往$store.state.sys.excludeViews添加关闭页面的name值,下次打开后再从excludeViews里面把页面的name值删除掉就行了,非常地简单易懂,不过最底层的页面,仍然需要写上跟路由定义时完全匹配的name值。这一步我仍然想不到有什么办法可以省略掉。
为方便代码,我写了一个组件aliveRouterView组件,并合局注册,这个组件用来代替router-view组件,如下面代码所示,$store.state.sys.config.PAGE_TABS这个值是是否开户多页面标签功能参数
<template> <keep-alive :exclude="exclude"> <router-view /> </keep-alive> </template> <script> export default { computed: { exclude() { if (this.$store.state.sys.config.PAGE_TABS) { return this.$store.state.sys.excludeViews; } else { return /.*/; } } } }; </script>
多页面标签组件viewTabs.vue,如下面代码所示
<template> <div class="__common-layout-tabView"> <el-scrollbar> <div class="__tabs"> <div class="__tab-item" :class="{ '__is-active':item.name==$route.name }" v-for="item in viewRouters" :key="item.path" @click="onClick(item)" > {{item.meta.title}} <span class="el-icon-close" @click.stop="onClose(item)" :style="viewRouters.length<=1?'width:0;':''" ></span> </div> </div> </el-scrollbar> </div> </template> <script> export default { data() { return { viewRouters: [] }; }, watch: { $route: { handler(v) { if (!this.viewRouters.some(item => item.name == v.name)) { this.viewRouters.push(v); } }, immediate: true } }, methods: { onClick(data) { if (this.$route.fullPath != data.fullPath) { this.$router.push(data.fullPath); } }, onClose(data) { let index = this.viewRouters.indexOf(data); if (index >= 0) { this.viewRouters.splice(index, 1); if (data.name == this.$route.name) { this.$router.push(this.viewRouters[index < 1 ? 0 : index - 1].path); } this.$store.dispatch("excludeView", data.name); } } } }; </script> <style lang="scss"> .__common-layout-tabView { $c-tab-border-color: #dcdfe6; position: relative; &::before { content: ""; border-bottom: 1px solid $c-tab-border-color; position: absolute; left: 0; right: 0; bottom: 2px; height: 100%; } .__tabs { display: flex; .__tab-item { white-space: nowrap; padding: 8px 6px 8px 18px; font-size: 12px; border: 1px solid $c-tab-border-color; border-left: none; border-bottom: 0px; line-height: 14px; cursor: pointer; transition: color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), padding 0.3s cubic-bezier(0.645, 0.045, 0.355, 1); &:first-child { border-left: 1px solid $c-tab-border-color; border-top-left-radius: 2px; margin-left: 10px; } &:last-child { border-top-right-radius: 2px; margin-right: 10px; } &:not(.__is-active):hover { color: #409eff; .el-icon-close { width: 12px; margin-right: 0px; } } &.__is-active { padding-right: 12px; border-bottom: 1px solid #fff; color: #409eff; .el-icon-close { width: 12px; margin-right: 0px; margin-left: 2px; } } .el-icon-close { width: 0px; height: 12px; overflow: hidden; border-radius: 50%; font-size: 12px; margin-right: 12px; transform-origin: 100% 50%; transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1); vertical-align: text-top; &:hover { background-color: #c0c4cc; color: #fff; } } } } } </style>
贴上我的sys的store文件,后面我发现,我把页面name添加到excludeViews后,在下一帧中再从excludeViews中把name删除后,这样也能有效果。如下面excludeView所示。这样就更加简洁。我只需在关闭标签卡时处理一下就行了。
const sys = { state: { permissionRouters: [],//权限路由表 permissionMenus: [],//权限菜单列表 config: null, //系统配置 excludeViews: [] //用于多页面选项卡 }, getters: { }, mutations: { SET_PERMISSION_ROUTERS(state, routers) { state.permissionRouters = routers; }, SET_PERMISSION_MENUS(state, menus) { state.permissionMenus = menus; }, SET_CONFIG(state, config) { state.config = config; }, ADD_EXCLUDE_VIEW(state, viewName) { state.excludeViews.push(viewName); }, DEL_EXCLUDE_VIEW(state, viewName) { let index = state.excludeViews.indexOf(viewName); if (index >= 0) { state.excludeViews.splice(index, 1); } } }, actions: { //排除页面 excludeView({ state, commit, dispatch }, viewName) { if (!state.excludeViews.includes(viewName)) { commit("ADD_EXCLUDE_VIEW", viewName); Promise.resolve().then(() => { commit("DEL_EXCLUDE_VIEW", viewName); }) } } } } export default sys
效果如下图所示,记得一点,就是得在你的页面上填写name值,需要跟定义路由时完全一致
BUT!!当我截完上面的动图后,我就发现了问题了,而且是一个无法解决的问题,按我上面的方法,如果我点一下首页,再点回原来的用户管理,再关闭用户管理,再打开用户管理,你会发现缓存一直都在。
这是为什么呢?究根诘底还是这个嵌套router-view的问题,不同的router-view的缓存是独立的,首页页面是缓存在一级router-view下面,而用户管理页面是缓存在二级router-view下面,当我关闭用户管理页面后,只是往excludeViews添加了用户管理页面的name(sys.anme),所以只会删除二级router-view下面name值为sys.user的页面,二级router-view的name值为sys,它还缓存在一级router-view,所以导致用户管理一直缓存着。
当然我也想过在关闭页面时,把页面父级的所有router-view的name值都添加到excludeViews里面,这样的话,也会出现问题,就是当我关闭用户管理页面后,同样在name值为sys的二级router-view下面的页面缓存都删除掉了。
当我测试了一晚上,我发现这真的是无解的,中间我也试过网上说的暴力删除cache方法(方法介绍),也是因为这个嵌套router-view的问题导致失败。
其实网上有人提出的解决方法是把框架改成只有一个一级router-view,一开始我觉得这是个下策,后面发现这也是唯一的方法了。
无奈,我确实不想扔弃这个多页面标签功能。那就改吧,其实改起来也不复杂,就是将菜单跟路由数组分为两成数组,各自独立。路由全部同级,均在layout布局组件的children里面。
只使用一级router-view后面,这个多页面标签功能就非常好解决了,用include或exclude都可以,没有什么问题,但这两种方法都得在页面上写name值,我是一个懒惰的程序员,总是写这种跟业务无关系的name值显得特别多余。幸运的是,我之前在网上有找到一种暴力删除缓存的方法,经过我的测试后,发现只有一个小问题(下面会提到),其它方面几乎完美,而且跟include、exclude相比,还能完美支持同个页面可以根据不同参数同时缓存的功能。(在vue-element-admin里面也有说到include是没法支持这种功能的,如下图)
思想是这样的,在store里创建一个openedPageRouters(已打开的页面路由数组),我watch路由的变化,当打开一个新页面时,往openedPageRouters里面添加页面路由,当我关闭页面标签时,到openedPageRouters里面删除对应的页面路由,而上面提到的暴力删除缓存,是在页面的beforeRouterLeave事件中进行删除中,所以我注册一个全局mixin的beforeRouterLeave事件,检测离开的页面如果不存在于openedPageRouters数组里面,那就进行缓存删除。
思路很完美,当然里面还有一个小问题,就是删除不是当前激活的页面,怎么处理,因为beforeRouterLeave必须在要删除页面的生命周期才能触发的,这个我用了点小手段,我先跳转到要删除的页面,然后往openedPageRouters里删除这个页面路由,然后再跳回原来的页面,这样就能让它触发beforeRouterLeave了。哈哈,不过这个会导致一个小问题,就是地址栏的闪动一下,也就是上面提到的小问题。
下面是我的pageTabs.vue多页面标签组件的代码
<template> <div class="__common-layout-pageTabs"> <el-scrollbar> <div class="__tabs"> <div class="__tab-item" v-for="item in $store.state.sys.openedPageRouters" :class="{ '__is-active': item.meta.canMultipleOpen?item.fullPath==$route.fullPath:item.path==$route.path }" :key="item.fullPath" @click="onClick(item)" > {{item.meta.title}} <span class="el-icon-close" @click.stop="onClose(item)" :style="$store.state.sys.openedPageRouters.length<=1?'width:0;':''" ></span> </div> </div> </el-scrollbar> </div> </template> <script> export default { watch: { $route: { handler(v) { this.$store.dispatch("openPage", v); }, immediate: true } }, methods: { //点击页面标签卡时 onClick(data) { if (this.$route.fullPath != data.fullPath) { this.$router.push(data.fullPath); } }, //关闭页面标签时 onClose(route) { if (route.fullPath == this.$route.fullPath) { let index = this.$store.state.sys.openedPageRouters.indexOf(route); this.$store.dispatch("closePage", route); //删除页面后,跳转到上一页面 this.$router.push( this.$store.state.sys.openedPageRouters[index < 1 ? 0 : index - 1] .path ); } else { let lastPath = this.$route.fullPath; //先跳转到要删除的页面,再删除页面路由,再跳转回来原来的页面 this.$router.replace(route).then(() => { this.$store.dispatch("closePage", route); this.$router.replace(lastPath); }); } } } }; </script> <style lang="scss"> .__common-layout-pageTabs { $c-tab-border-color: #dcdfe6; position: relative; &::before { content: ""; border-bottom: 1px solid $c-tab-border-color; position: absolute; left: 0; right: 0; bottom: 2px; height: 100%; } .__tabs { display: flex; .__tab-item { white-space: nowrap; padding: 8px 6px 8px 18px; font-size: 12px; border: 1px solid $c-tab-border-color; border-left: none; border-bottom: 0px; line-height: 14px; cursor: pointer; transition: color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), padding 0.3s cubic-bezier(0.645, 0.045, 0.355, 1); &:first-child { border-left: 1px solid $c-tab-border-color; border-top-left-radius: 2px; margin-left: 10px; } &:last-child { border-top-right-radius: 2px; margin-right: 10px; } &:not(.__is-active):hover { color: #409eff; .el-icon-close { width: 12px; margin-right: 0px; } } &.__is-active { padding-right: 12px; border-bottom: 1px solid #fff; color: #409eff; .el-icon-close { width: 12px; margin-right: 0px; margin-left: 2px; } } .el-icon-close { width: 0px; height: 12px; overflow: hidden; border-radius: 50%; font-size: 12px; margin-right: 12px; transform-origin: 100% 50%; transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1); vertical-align: text-top; &:hover { background-color: #c0c4cc; color: #fff; } } } } } </style>
以下是store代码
const sys = { state: { menus: [],// permissionRouters: [],//权限路由表 permissionMenus: [],//权限菜单列表 config: null, //系统配置 openedPageRouters: [] //已打开原页面路由 }, getters: { }, mutations: { SET_PERMISSION_ROUTERS(state, routers) { state.permissionRouters = routers; }, SET_PERMISSION_MENUS(state, menus) { state.permissionMenus = menus; }, SET_MENUS(state, menus) { state.menus = menus; }, SET_CONFIG(state, config) { state.config = config; }, //添加页面路由 ADD_PAGE_ROUTER(state, route) { state.openedPageRouters.push(route); }, //删除页面路由 DEL_PAGE_ROUTER(state, route) { let index = state.openedPageRouters.indexOf(route); if (index >= 0) { state.openedPageRouters.splice(index, 1); } }, //替换页面路由 REPLACE_PAGE_ROUTER(state, route) { for (let key in state.openedPageRouters) { if (state.openedPageRouters[key].path == route.path) { state.openedPageRouters.splice(key, 1, route) break; } } } }, actions: { //打开页面 openPage({ state, commit }, route) { let isExist = state.openedPageRouters.some( item => item.fullPath == route.fullPath ); if (!isExist) { //判断页面是否支持不同参数多开页面功能,如果不支持且已存在path值一样的页面路由,那就替换它 if (route.meta.canMultipleOpen || !state.openedPageRouters.some( item => item.path == route.path )) { commit("ADD_PAGE_ROUTER", route); } else { commit("REPLACE_PAGE_ROUTER", route); } } }, //关闭页面 closePage({ state, commit }, route) { commit("DEL_PAGE_ROUTER", route); } } } export default sys
以下是暴力删除页面缓存的代码,我写成了一个全局的mixin
import Vue from 'vue' Vue.mixin({ beforeRouteLeave(to, from, next) { //限制只有在我写的那个父类里才可能会用这个缓存删除功能 if (!this.$parent || this.$parent.$el.className != "el-main __common-layout-main" || !this.$store.state.sys.config.PAGE_TABS) { next(); return; } let isExist = this.$store.state.sys.openedPageRouters.some(item => item.fullPath == from.fullPath) if (!isExist) { let tag = this.$vnode.tag; let cache = this.$vnode.parent.componentInstance.cache; let keys = this.$vnode.parent.componentInstance.keys; let key; for (let k in cache) { if (cache[k].tag == tag) { key = k; break; } } if (key) { if (cache[key] != null) { delete cache[key]; let index = keys.indexOf(key); if (index > -1) { keys.splice(index, 1); } } } } next(); } })
然后router-view这样使用,根据我的配置$store.state.sys.config.PAGE_TABS(是否启用多页面标签)进行判断 ,对了,我相信有不少人肯定会想到,路由不嵌套了,没有matched数组了,怎么弄面包屑,可以看我下面代码的处理,$store.state.sys.permissionMenus这个数组是我从后台传过来的,是一个根据当前用户的权限获取到的所有有权限访问的菜单数组,都是一级数组,没有嵌套关系,我的菜单数组跟路由都是根据这个permissionMenus进行构建的。而我的面包屑数组就是从这个数组递归出来的。
<template> <el-main class="__common-layout-main"> <page-tabs class="c-mg-t-10p" v-if="$store.state.sys.config.PAGE_TABS" /> <div class="c-pd-20p"> <el-breadcrumb separator="/"> <el-breadcrumb-item v-for="m in breadcrumbItems" :key="m.id">{{m.name}}</el-breadcrumb-item> </el-breadcrumb> <div class="c-h-15p"></div> <keep-alive v-if="$store.state.sys.config.PAGE_TABS"> <router-view :key="$route.fullPath" /> </keep-alive> <router-view v-else /> </div> </el-main> </template> <script> import pageTabs from "./pageTabs"; export default { components: { pageTabs }, data() { return { viewNames: ["role"] }; }, computed: { breadcrumbItems() { let items = []; let buildItems = id => { let b = this.$store.state.sys.permissionMenus.find( item => item.id == id ); if (b) { items.unshift(b); if (b.parentId) { buildItems(b.parentId); } } }; buildItems(this.$route.meta.id); return items; } } }; </script> <style lang="scss"> $c-tab-border-color: #dcdfe6; .__common-layout-main.el-main { padding: 0px; overflow: unset; .el-breadcrumb { font-size: 12px; } } </style>
演示一个最终效果,哎,弄了我整整两天时间,不过我改成不嵌套路由后,发现代码量也少了很多,也是因祸得福啊。这更符合我的Less框架的理念了。哈哈哈!
对了,我之前有说到个小问题,大家可以仔细看一下,下图的地址栏,当我关闭非当前激活的页面标签时,你会发现地址栏会闪现一下。好吧,下面这个动图还不太明显。
大家可以到我的LessAdmin框架预览地址测试下,不要乱改菜单数据哦,会导致打不开的
http://test.caijt.com:9001
用户:superadmin
密码:admin
加载全部内容