前言
在 Vue 开发中,我们经常使用 <keep-alive> 来缓存组件状态,提升用户体验。然而,当结合 vue-router 使用动态路由(如参数路由 /user/:id)时,会遇到一些缓存不一致或缓存冲突的问题。有掘友留言希望了解 vue-router-better-view 是如何解决动态路由缓存问题的,本文就来详细讲讲它的原理与实现。
📌 问题背景
在使用 <keep-alive> 缓存路由组件时,通常我们会通过设置 include 属性来决定页面是否需要缓存以及何时应移除缓存,但是其匹配标识是由组件 name 决定,在动态参数路由时由于是相同的组件 name,所以我们无法把控在不同参数的场景下精准移除某个组件缓存。
例如:
// routes.ts
const routes = [
{
path: '/user/:id',
name: 'UserDetail', // 与组件 name 保持一致
component: () => import('@/views/User/Detail.vue'), // 组件 name 为 UserDetail
meta: {
keepAlive: true,
},
// ...
}
]
<!-- layout.vue -->
<script lang="ts" setup>
import { shallowReactive, watch } from 'vue'
import { useRoute } from 'vue-router'
const keepAliveValues = shallowReactive(new Set())
watch(() => route.fullPath, () => {
if (route.meta.keepAlive) {
// 将路由 name 添加到缓存列表中
keepAliveValues.add(route.name)
}
}, { immediate: true })
</script>
<template>
<main>
<router-view v-slot="{ Component }">
<keep-alive :include="[...keepAliveValues]">
<!-- 不设置 key 时组件将被复用 -->
<component :is="Component" />
</keep-alive>
</router-view>
</main>
</template>
此时,当你访问 /user/1 和 /user/2 时,组件的缓存实例会被复用,导致显示内容始终为首次加载的数据。
如果给
<component>增加key属性可以解决内容不变的问题,但是仍无法解决清除指定页面的缓存实例而不影响其他页面。
🔍 核心问题分析
<keep-alive>仅会子级第一个被渲染的路由视图组件进行缓存。- 动态路由切换时,
$route.params参数变化但是被缓存的视图组件name没有变化。
🛠 解决方案思路
我们的目标是:
- 支持自定义路由视图组件应该如何被缓存。
- 业务侧以最少的代码改动来降低适配成本。
✅ 关键点:
- 在路由视图组件被渲染前进行拦截处理
- 封装为特殊组件,开发者无感知
🧱 实现原理
// BetterRouterView.ts
// 定义 BetterRouterView 组件,包裹原 RouterView 组件并对路由视图渲染的组件实例进行拦截
const BetterRouterView = /* @__PURE__ */ defineComponent({
name: 'BetterRouterView',
inheritAttrs: false,
props: {
/**
* 根据当前路由自定义需要被缓存的键,未设置或返回假值时与原 RouterView 功能保持一致
* @param route 当前路由
* @returns 被缓存的键
*/
resolveViewKey: {
type: Function,
},
},
setup(props, { attrs, slots }) {
const app = getCurrentInstance().appContext.app
// 为当前 Vue 应用实例挂在一个 WeakMap,用于缓存被生成过的中间组件
const wrappers = getWrappers(app)
// 创建 RouterView 和 KeepAlive 关联的中间组件
function createViewWrapper({ route, Component: viewComponent }) {
// 获取需要被缓存的路由键
const name = props.resolveViewKey?.(route)
// 未设置时直接返回 RouterView 插槽提供的 vnode
if (!name) return viewComponent
if (!wrappers.has(name)) {
wrappers.set(
name,
defineComponent({
name,
inheritAttrs: false,
setup(_, { attrs, slots, expose }) {
// 由于添加了一层中间组件,这里需要内部转发原视图组件的实例引用
const inner$ = shallowRef()
expose({
inner: inner$,
viewKey: name,
})
// 渲染原视图组件
return () =>
h(
viewComponent,
{
...attrs,
ref: inner$,
},
slots,
)
},
}),
)
}
// 返回被包装后的组件
return wrappers.get(name)
}
return () => {
return h(RouterView, attrs, {
default: (data) => {
// 适配 RouterView 的插槽
const slot = slots.default
// 存在 data.Component 时进行包装处理
if (data.Component) {
// vue-router 内部设置的是 vnode,这里也进行处理
data.Component = h(createViewWrapper(data))
}
if (slot) {
return slot(data)
}
return data.Component
},
})
}
},
})
🧰 使用方式(示例)
<!-- layout.vue -->
<script lang="ts" setup>
import { shallowReactive, watch } from 'vue'
import { useRoute } from 'vue-router'
+import { BetterRouterView } from './BetterRouterView'
const keepAliveValues = shallowReactive(new Set())
watch(() => route.fullPath, () => {
if (route.meta.keepAlive) {
- // 将路由 name 添加到缓存列表中
- keepAliveValues.add(route.name)
+ // 根据 resolveViewKey 返回的规则进行添加
+ keepAliveValues.add(route.meta.singleton ? route.path : route.fullPath)
}
}, { immediate: true })
+const resolveViewKey = (route) => {
+ // 如果当前路由不需要缓存则返回假值
+ if (!route.meta.keepAlive) {
+ return null
+ }
+
+ // 如果 route.meta.singleton 为 true,则以路由的 path 作为视图组件标识
+ if (route.meta.singleton) {
+ return route.path
+ }
+
+ // 使用路由的 fullPath 作为视图组件标识
+ return route.fullPath
+}
</script>
<template>
<main>
- <router-view v-slot="{ Component }">
+ <better-router-view v-slot="{ Component }" :resolve-view-key>
<keep-alive :include="[...keepAliveValues]">
<!-- 不设置 key 时组件将被复用 -->
<component :is="Component" />
</keep-alive>
- </router-view>
+ </better-router-view>
</main>
</template>
🧪 效果验证
可以参考在线示例(需要梯子,也可自行验证)进行查看。
📦 插件开源地址
- 👉 GitHub
- 👉 国内 GitCode
🧠 结语
如果你也在开发中遇到类似问题,欢迎尝试这个插件或者参考其实现思路!同时也欢迎留言讨论你遇到的其他 Vue 相关问题,一起探索更多解决方案。