Vue3后台管理系统标签页<KeepAlive>终极解决方案

1,730 阅读2分钟

前言

后台管理系统中都会存在一个需求:标签栏导航,顶部标签栏为一个个的<router-link>,再结合<keep-alive><router-view>实现:

<router-view v-slot="{ Component }">
    <keep-alive :include="cachedViews">
      <component :is="Component" :key="$route.fullPath" />
    </keep-alive>
</router-view>

然后我们再来监听$route,来判断当前页面是否需要重新加载或者已被缓存。

方案缺陷

该方案主要来自 vue-element-admin,但其项目中这个方案存在如下2个问题:

  1. 无法缓存三级以及三级以上路由的问题
  2. 动态路由页面,同时打开多个详情页(例:路由为/page/:id的两个详情页/page/1, /page/2),当你使用标签页的刷新功能,刷新/page/1页面时,/page/2的页面缓存也会被刷新清除

解决方案

问题1

该问题目前市面上大部分的后台都处理解决了,主要方案基本都是将三级以及三级以上的路由拍平成二级路由,但菜单展示仍然使用原本的路由层级。

// 原路由
const asyncRoutes = [...] 

// 降级后的路由,排除component组件字段的深拷贝,不然会导致keep-alive失效
const flatRoutes = getFlatRoutes(deepClone(asyncRoutes, ['component']))

// 三级以及三级以上的路由降级成二级路由
const formatRouter = (routes, basePath = '/', list = [], parent) => {
  routes.map(item => {
    item.path = path.resolve(basePath, item.path)
    const meta = item.meta || {}
    if (!meta.parent && parent) {
      meta.parent = parent.path
      item.meta = meta
    }
    if (item.redirect) item.redirect = path.resolve(basePath, item.redirect)
    if (item.children && item.children.length > 0) {
      const arr = formatRouter(item.children, item.path, list, item)
      delete item.children
      list.concat(arr)
    }
    list.push(item)
  })
  return list
}

// 路由降级
export const getFlatRoutes = (routes) => {
  return routes.map((child) => {
    if (child.children && child.children.length > 0) {
      child.children = formatRouter(child.children, child.path, [], child)
    }
    return child
  })
}

问题2

问题复现

这里以 Vben Admin 后台举例,在其 功能 > Tab带参页面下,进行如下操作:

  1. 打开 Tab带参1 菜单,在输入框中随便输入值,
  2. 打开 Tab带参2 菜单,也随便输入一个值,
  3. 在顶部标签页中来回切换这两个标签页,可以看到你输入的值都被缓存了下来
  4. 这时,我们右键其中一个标签页,选择重新加载
  5. 你会看到另一个标签页的值也被清空了
原因说明

这是因为 vue<keep-alive> 组件的 include 默认是优先匹配组件的 name,使得路由 router 的 name 和路由组件的 name 一一对应,来达到缓存效果,但是因为动态路由,他的 router name 都是一样的,所以你刷新其中一个详情页,另一个详情页缓存的内容也会被清空。

解决方案

vue2 中,vue 官方提供了一个vm.$destroy() api 可以手动销毁组件实例,让我们能在刷新某个标签页的时候单独处理该详情页的缓存,但这个api在vue3中被移除了,我找到其的替代api $unmounted,但该api是卸载了整个vue3实例,不再适用了。

要解决该问题,还是要回到 <keep-alive> 组件的 include 原理,要是我们能让组件的 name动态,变成 $routefullPath,那么就可以很优雅的处理这个问题了。

那么如何让组件的 name动态 呢?要知道,我们的组件 name 都是在组件中定义写死的。

很简单:利用vue的渲染函数,给每个组件包一层wrap就可以啦~

<template>
    <router-view v-slot="{ Component }">
        <keep-alive :include="cachedViews">
          <component :is="wrap(Component)" :key="$route.fullPath" />
        </keep-alive>
    </router-view>
</template>

<script setup>
const wrap = (fullPath, component) => {
  const wrapper = {
    name: fullPath,
    render() {
      return h('div', null, component)
    },
  }
  return h(wrapper)
}
</script>

这样就可以做到动态name了,但是,这里我们又遇到一个问题,在切换标签页时,vue会抛出错误:parentComponent.ctx.deactivate is not a function

为了解决这个问题,我们需要根据cachedViews数组,缓存多个wrap,而不是共用一个wrap,具体代码如下

<template>
    <router-view v-slot="{ Component }">
      <transition name="fade-transform" mode="out-in" appear>
        <keep-alive :include="cachedViews">
          <component :is="wrap($route.fullPath, Component)" :key="$route.fullPath" />
        </keep-alive>
      </transition>
    </router-view>
</template>


<script setup>
// 自定义name的壳的集合
const cachedWrapperComponents = new Map()

// 为keep-alive里的component接收的组件包上一层自定义name的壳
const wrap = (fullPath:, component) => {
  let wrapper

  if (cachedWrapperComponents.has(fullPath)) {
    wrapper = cachedWrapperComponents.get(fullPath)
  } else {
    wrapper = {
      name: fullPath,
      render() {
        return h('div', null, component)
      },
    }
    cachedWrapperComponents.set(fullPath, wrapper)
  }
  return h(wrapper)
}

// 监听cachedViews的变化,当清除标签页缓存时移除相应的 wapper components
watch(cachedViews, (fullPaths) => {
  cachedWrapperComponents.forEach((value, key) => {
    if (!fullPaths.includes(key)) {
      cachedWrapperComponents.delete(key)
    }
  })
})
</script>

关于 <keep-alive> 组件的问题就到这里结束了,但有心人可能看到了,渲染函数h('div', null, component),我多渲染了一个空的div,这是因为vue3虽然支持了多根节点元素,但 <transition> 组件要求必须只有1个根节点,不然动画将不会生效。现在你可以在组件中无所顾忌的使用vue3带来的新特性多根节点啦。

致谢

感谢你抽出宝贵的时间阅读这篇文章,以上说的代码已在我的项目vue-mushroom-admin实现了:

  1. wrap 相关代码位于src/layout/components/AppMain.vue
  2. 将三级以及三级以上的路由拍平成二级路由位于 src/store/permission.ts

最后,如果觉得这篇文章对你有帮助的话,请给个 star 再走~~~