keep-alive 嵌套路由踩坑之旅
前言
在使用 keep-alive 缓存嵌套路由时,遇到了缓存不被销毁的问题,导致上线后用户重复打开页面时出现内存溢出,最终导致页面崩溃。
在 Vue 开发工具中,可以看到子组件被重复缓存。
我使用了 tag-view 退出视图,但由于将 BasicLayout 设置为常驻缓存,导致无法清理缓存。经过两天的排查,发现其实解决方案非常简单。
我的路由结构
router.js
const Layout = () => import('@/views/layout/Layout')
const BasicLayout = () => import('@/views/layout/BasicLayout')
{
path: '/spray',
redirect: '/spray/flavor',
component: Layout,
name: 'spray',
meta: { title: i18n.t('router.index.2hzd72'), icon: 'canping' },
children: [
{
path: 'flavor',
name: 'flavor',
meta: { title: i18n.t('router.index.5h54nw') },
redirect: '/spray/flavor/demands',
component: BasicLayout,
children: [
{
path: 'demands',
name: 'flavorDemands',
meta: { title: i18n.t('router.index.hrj6g5') },
component: () => import('@/views/spray/flavor/demands'),
id: 0,
},
]
}
]
}
AppMain.vue
<template>
<section class="app-main">
<transition name="fade-transform" mode="out-in">
<keep-alive
:include="['BasicLayout', 'Layout', ...cachedViews]"
:max="30"
>
<router-view :key="$route.fullPath" />
</keep-alive>
</transition>
</section>
</template>
BasicLayout.vue
<template>
<transition name="fade-transform" mode="out-in">
<keep-alive :include="['BasicLayout', 'Layout', ...cachedViews]" :max="30">
<router-view :key="$route.fullPath" />
</keep-alive>
</transition>
</template>
动态修改 BasicLayout 名称
BasicLayout.vue
const name =
window.location.hash
.split('/')
.slice(2)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join('') + 'Wrap'
export default {
name: name,
...
}
这种方法不可行,因为 BasicLayout 组件是共享的,只会加载一次,动态组件名的方式无法实现。同时,在 tag-view 退出时需要删除缓存。
一些坑
- 嵌套问题:
<keep-alive>不能嵌套,嵌套生效仅限于外层组件。 - 匹配机制:匹配首先检查组件自身的
name选项,如果name选项不可用,则匹配它的局部注册名称(父组件components选项的键值)。匿名组件不能被匹配。 - 直系子组件:只对直系子组件生效。如果在其中有
v-for,则不会工作。 - 最大缓存:
max设置相当于队列,最老的组件会被销毁,不再缓存。 - 组件名匹配:
include和exclude匹配时使用组件名,如果没有设置name,则不会缓存,和路由的名字无关。 - router-view 的 key:
router-view需要设置key。 - 子组件中的 router-view:子组件中的
router-view不需要设置key,如果设置了,父组件命中缓存时,子组件仍会被激活。 - 特殊处理:使用
BasicLayout时,Layout需要对子路由进行特殊处理。
修改后的代码
AppMain.vue
<template>
<section class="app-main">
<transition name="fade-transform" mode="out-in">
<keep-alive :include="[...hasBasicLayout, 'Layout', ...cachedViews]" :max="30">
<router-view :key="$route.fullPath" v-slot="{ Component }">
<component :is="Component" />
</router-view>
</keep-alive>
</transition>
</section>
</template>
<script>
import { asyncRouterMap } from '@/router/index'
// 找到以组件名 name 作为“中间件”的所有路由
const findRouterWith = (name) => {
const fn = (routes, newRoutes = []) => {
routes.forEach((item) => {
if (item.children && item.children.length > 0) {
if (getFileNameByFunContext(item.component.toString()) === name) {
newRoutes.push(item.children)
}
fn(item.children, newRoutes)
}
})
return newRoutes.flat(Infinity)
}
return fn
}
// 通过函数内容获取文件名
const getFileNameByFunContext = (str) => {
const [file = ''] = str.match(/".+"/)
return file.replace(/(.*\/)*([^.]+).*/gi, '$2')
}
// 获取需进行缓存的页面
const getCachesByRoutes = (routes = []) => {
const children = []
const caches = routes
.filter((o) => {
// 有children说明进行了路由嵌套,需记录“中间件”
if (o.children) {
children.push(o.component)
}
// 过滤掉“中间件”和不需要缓存的组件
return !o.children
})
.map((o) => o.name)
if (children.length > 0) {
// 路由嵌套的组件也需include
children.forEach((fun) => {
caches.push(getFileNameByFunContext(fun.toString()))
})
}
return [...new Set(caches)] // 排重
}
export default {
name: 'AppMain',
data() {
return {
specialCache: [], // 特殊缓存逻辑
}
},
computed: {
cachedViews() {
return this.$store.state.tagsView.cachedViews
},
hasBasicLayout() {
for (let i = 0; i < this.cachedViews.length; i++) {
if (this.specialCache.includes(this.cachedViews[i])) {
return ['BasicLayout']
}
}
return []
},
},
created() {
const target = findRouterWith('BasicLayout')(asyncRouterMap)
this.specialCache = getCachesByRoutes(target)
},
}
</script>
最终 getFileNameByFunContext 方法在UAT 不好用,名字被混淆了,所以 specialCache 直接写死了,虽然不太优雅,如果有更好方法,可以提醒下
BasicLayout.vue
<template>
<router-view />
</template>
在这里,router-view 不需要加 key。
优化后效果
总结
我最初定位问题的方向是错误的,经过反复测试和调整,最终找到了合适的解决方案。