🧩 vue-router 动态页缓存异常处理方案解析

477 阅读2分钟

前言

在 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>

🧪 效果验证

可以参考在线示例(需要梯子,也可自行验证)进行查看。

📦 插件开源地址

🧠 结语

如果你也在开发中遇到类似问题,欢迎尝试这个插件或者参考其实现思路!同时也欢迎留言讨论你遇到的其他 Vue 相关问题,一起探索更多解决方案。

🙋‍♂️ 参考资料