關於vue的多頁面標籤功能,對於嵌套router-view緩存的最終無奈解決方法
- 2020 年 6 月 1 日
- 筆記
- keep-alive, vue多頁面標籤, vue緩存
最近寫我自己的後台開發框架,要弄一個多頁面標籤功能,之前有試過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框架預覽地址測試下,不要亂改菜單數據哦,會導致打不開的
用戶:superadmin
密碼:admin