Code前端首页关于Code前端联系我们

Vue3用了keep-alive为什么组件状态还是没保存?

terry 36分钟前 阅读数 4 #Vue

前段时间帮朋友调Vue3项目的标签页切换时,明明套了keep-alive,每次切回标签还是重新触发onMounted、数据全清空,折腾了半天才找到3个不太起眼但必踩的坑,今天索性把所有我遇到过、或者群里小伙伴常问的keep-alive无效情况整理成一套完整的排查思路,希望能帮到同样踩坑的人。

排查第一步:先确认组件是不是真的被keep-alive包裹住了

很多同学一开始会犯这个低级错误,但排查的时候总容易忽略,先从最基础的看起。 Vue3官方文档里明确提过,keep-alive要包裹动态组件()或者带v-if/v-else-if/v-else切换的静态组件才行,如果你的静态组件是用v-show切换的,那keep-alive完全没用——v-show只是把display改成none,组件根本没有被销毁重建,自然也不需要缓存状态,每次还是从DOM里显示出来,但数据如果在切换前被重置了(比如朋友一开始的错误,v-show的同时又手动清空了表单数据),那确实会觉得“没缓存”。 还有一种低级包裹错误是层级没对齐,比如v-if写在keep-alive外面了,举个反例:

<!-- 错误写法:keep-alive每次v-if为true都会重新创建,包裹的动态组件当然也会重建 -->
<template>
  <keep-alive>
    <component v-if="isShowTabA" :is="TabA" />
  </keep-alive>
  <keep-alive>
    <component v-if="isShowTabB" :is="TabB" />
  </keep-alive>
</template>

正确的写法应该是把v-if放在动态组件里,或者把所有需要缓存的静态组件都放在同一个keep-alive标签内,v-if/v-else-if/v-else嵌套在里面:

<!-- 正确写法1:动态组件带v-if或者不带v-if都可以,取决于切换逻辑 -->
<template>
  <keep-alive>
    <component v-if="currentTab === 'A'" :is="TabA" />
    <component v-else :is="TabB" />
  </keep-alive>
</template>
<!-- 正确写法2:多个keep-alive包裹静态组件?不,多个静态组件要放同一个里 -->
<template>
  <keep-alive>
    <TabA v-if="currentTab === 'A'" />
    <TabB v-else-if="currentTab === 'B'" />
    <TabC v-else />
  </keep-alive>
</template>

检查包裹层级的一个小技巧:打开Vue DevTools,切换一下组件,看Elements标签里组件对应的DOM是不是真的只是隐藏(如果是用动态组件但实际DOM消失了),或者看Components标签里keep-alive下面的组件列表,切换的时候组件会不会从列表里消失——真缓存的话,组件会一直留在列表里,只是状态从Active变成Inactive,反之如果消失了,就是没被正确包裹或者缓存规则没匹配到。

排查第二步:检查缓存规则include/exclude/max是不是写反了或者写错了

Vue3的keep-alive新增了vnode key匹配功能(虽然之前Vue2也支持,但很多人没用过或者没注意匹配逻辑的细节),不过不管是用组件name属性匹配还是用vnode key匹配,规则写错了都会导致组件不被缓存。

首先确认组件有没有写name属性(除非你只靠vnode key匹配)

很多同学用Vue3的<script setup>语法糖,组件默认是没有暴露name属性的!这是Vue3 keep-alive无效最最常见的坑,没有之一——如果include/exclude是靠组件名匹配的,<script setup>里的组件没有显式声明name,就会匹配失败,自然不被缓存。 那怎么给<script setup>里的组件加name?有三种方法: 第一种是用组合式API里的defineOptions宏(Vue 3.3+版本官方推荐,不需要引入任何库):

<script setup>
// Vue 3.3+新增,直接在这里声明name、inheritAttrs这些选项
defineOptions({
  name: 'UserTab',
  inheritAttrs: false
})
// 其他业务代码...
</script>

第二种是用一个单独的<script>标签(非setup模式)来声明name:

<script>
// 单独的script标签,默认导出name
export default {
  name: 'ProductTab'
}
</script>
<script setup>
// 业务代码...
</script>

第三种是用unplugin-vue-define-options插件(Vue 3.3以下版本用这个,功能和官方的defineOptions一样),不过现在Vue 3.4都普及了,尽量用官方的吧,不用额外配置插件,太麻烦。

然后确认include/exclude的匹配规则

include/exclude可以是字符串、正则表达式或者数组,注意字符串匹配是完全匹配组件的name属性,不是文件名!比如你文件名是UserDetail.vue,但name写的是UserDetailTab,那include里写UserDetail就匹配不到,必须写UserDetailTab。 举个常见的错误匹配例子:

<!-- 错误写法:正则表达式如果用在template里的v-bind,不能直接写斜杠开头的字面量,需要用RegExp构造函数或者data/computed里的变量 -->
<template>
  <keep-alive include="/^User/">
    <component :is="currentTab" />
  </keep-alive>
</template>

上面的写法在Vue3 template里会把/^User/当成普通字符串,完全匹配不到任何组件,正确的写法是把正则放在computed里:

<script setup>
import { computed } from 'vue'
// 把需要缓存的组件名的正则放在computed里
const cachedNamePattern = computed(() => /^User/)
</script>
<template>
  <!-- v-bind绑定computed变量 -->
  <keep-alive :include="cachedNamePattern">
    <component :is="currentTab" />
  </keep-alive>
</template>

如果用数组的话,数组里的每个元素都要是字符串(完全匹配),不要混其他类型:

<!-- 正确的数组匹配写法 -->
<template>
  <keep-alive :include="['UserTab', 'ProductTab', 'OrderTab']">
    <component :is="currentTab" />
  </keep-alive>
</template>

exclude的规则和include完全相反,匹配到的组件不会被缓存,如果两个都写了,exclude的优先级更高,比如include写了UserTab,exclude也写了UserTab,那UserTab就不会被缓存,这点要注意。

最后检查max属性是不是限制太死了

max属性可以限制keep-alive缓存的组件数量,超过这个数量的话,最久没被使用的组件会被销毁(LRU策略),如果你的max写得太小,比如只有2,但你同时开了3个标签页,那切到第三个标签页的时候,第一个标签页的组件就会被销毁,再切回第一个就会重新触发onMounted,看起来就是“keep-alive偶尔失效”。 这种情况一般不是代码逻辑问题,就是max设置的问题,或者标签页数量没控制好,比如有些同学的标签页是无限新增的,又设置了max=5,那开第6个标签的时候最早的那个就会被清掉,这时候可以考虑调整max的大小,或者实现标签页关闭时主动销毁keep-alive缓存里的对应组件(后面会讲怎么主动销毁)。

排查第三步:检查路由是不是用了router-view的嵌套或者key导致缓存失效

如果你的项目用了Vue Router,那keep-alive一般是包裹在router-view外面的,这时候路由相关的配置也会导致缓存失效。

第一个路由相关的坑:嵌套router-view的keep-alive包裹层级

很多同学的项目有嵌套路由,比如/user对应UserLayout组件,UserLayout里又有/user/info/user/order两个子路由,这时候如果只在App.vue里包裹了UserLayout的router-view,但子路由的router-view没包裹keep-alive,那子组件UserInfo和UserOrder当然不会被缓存。 举个嵌套路由的正确包裹例子:

<!-- App.vue:包裹父路由的router-view,缓存UserLayout -->
<template>
  <div class="app-container">
    <keep-alive>
      <router-view v-slot="{ Component }">
        <component :is="Component" />
      </router-view>
    </keep-alive>
  </div>
</template>
<!-- UserLayout.vue:包裹子路由的router-view,缓存UserInfo和UserOrder -->
<template>
  <div class="user-layout">
    <div class="user-sidebar">侧边栏</div>
    <div class="user-content">
      <keep-alive :include="['UserInfo', 'UserOrder']">
        <router-view v-slot="{ Component }">
          <component :is="Component" />
        </router-view>
      </keep-alive>
    </div>
  </div>
</template>

注意嵌套路由里的每个需要缓存的router-view层级都要单独包裹keep-alive,不能指望父级的keep-alive缓存子级的组件——因为子级的组件是渲染在父级组件的router-view里的,父级的keep-alive只会缓存父级组件本身,子级组件的销毁重建由父级组件里的router-view控制。

第二个路由相关的坑:router-view绑定了错误的key

很多同学为了解决路由参数变化但组件不重新渲染的问题,会给router-view绑定一个key="$route.fullPath",这确实能解决参数变化的问题,但如果同时用了keep-alive,就会导致组件每次路由变化(包括切换到其他同层级路由)都重新创建——因为$route.fullPath每次变化都会改变key,Vue会认为是不同的组件,直接销毁旧的创建新的,不管keep-alive有没有包裹。 举个错误的例子:

<!-- 错误写法:绑定了$route.fullPath作为key,即使有keep-alive,每次路由变也会重建 -->
<template>
  <keep-alive>
    <router-view v-slot="{ Component }">
      <component :key="$route.fullPath" :is="Component" />
    </router-view>
  </keep-alive>
</template>

那怎么解决“路由参数变化不重新渲染”和“keep-alive缓存”的矛盾?有两种方法: 第一种是区分需要参数变化重建和不需要的组件,如果是不需要重建的组件(比如列表页,有搜索筛选但可以用watch监听参数变化来刷新数据),就不要绑定key;如果是需要重建的组件(比如详情页,参数完全不同的话需要重新加载内容),可以在路由元信息里加个配置,比如meta.needRefreshOnParamChange,然后在router-view的key里根据这个配置来决定用什么:

<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
// 根据路由元信息决定key
const routerViewKey = computed(() => {
  if (route.meta.needRefreshOnParamChange) {
    return route.fullPath
  } else {
    // 用路由名称或者空字符串?空字符串的话Vue会自动复用组件
    return route.name
  }
})
</script>
<template>
  <keep-alive :include="['ListTab']">
    <router-view v-slot="{ Component, route: currentRoute }">
      <component :key="routerViewKey" :is="Component" />
    </router-view>
  </keep-alive>
</template>

第二种方法是对需要参数变化重建但又想缓存历史状态的组件,用组合式API里的onActivatedonDeactivated钩子,或者用provide/inject结合Vuex/Pinia来保存历史状态,比如详情页可以把之前打开的ID和数据存在Pinia里,下次打开同一个ID的时候直接从Pinia里取,不同的ID再重新请求,这样既解决了参数变化的问题,又能缓存历史状态。

排查第四步:检查组件内部的钩子函数或者逻辑有没有手动重置状态

有些时候keep-alive明明生效了,组件也留在了Vue DevTools的Components列表里,状态从Active变成Inactive再变回来,但数据还是清空了——这时候就要检查组件内部的代码了。

第一个内部逻辑的坑:在onMounted里重置了所有数据

很多同学习惯在onMounted里把所有数据初始化为默认值,比如表单页面在onMounted里把formData清空,搜索页面在onMounted里把searchParams重置——但keep-alive缓存的组件,第一次加载的时候会触发onMounted,之后切换回来只会触发onActivated,不会再触发onMounted,哦不对,等一下,是不是搞反了?不,刚才说的朋友的情况是v-show切换手动清空,但如果是keep-alive包裹的组件,要是在onActivated里重置了数据,那每次切回来都会清空,看起来就是“没缓存”!对,onActivated是每次组件从Inactive变成Active的时候都会触发的,千万不要在里面把所有数据都重置成默认值,除非你确实有这个需求(比如每次打开标签页都要重新填写表单)。 举个正确的例子:

<script setup>
import { ref, onMounted, onActivated } from 'vue'
const formData = ref({
  username: '',
  email: '',
  phone: ''
})
// 第一次加载的时候从localStorage或者接口获取初始数据
onMounted(() => {
  const savedData = localStorage.getItem('userFormData')
  if (savedData) {
    formData.value = JSON.parse(savedData)
  }
})
// 每次切回来的时候只做一些必要的操作,比如聚焦输入框,不要重置数据
onActivated(() => {
  document.querySelector('#username-input')?.focus()
})
// 每次切出去的时候保存数据到localStorage
onDeactivated(() => {
  localStorage.setItem('userFormData', JSON.stringify(formData.value))
})
</script>

第二个内部逻辑的坑:用了shallowRef或者shallowReactive但没注意更新方式?不,这个和keep-alive无关,主要是有些同学用了v-if在组件内部切换子组件,子组件没有被包裹keep-alive,所以每次子组件切换都会重建,看起来像是父组件的keep-alive没生效——这种情况只要给内部的子组件也加上keep-alive就行。

额外技巧:如何主动销毁keep-alive缓存里的组件?

有时候我们需要主动销毁某个缓存的组件,比如关闭标签页的时候,不想让它继续占内存,或者下次打开这个标签页的时候要重新加载数据,那怎么主动销毁呢? Vue3的keep-alive组件暴露了一个cache属性和一个keys属性,但这两个属性是内部的,官方不推荐直接操作(虽然直接操作也能用,但版本更新可能会失效),官方推荐的方法是用include/exclude的响应式数组,通过修改数组来添加或删除需要缓存的组件名。 举个标签页关闭时主动销毁的例子:

<script setup>
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
// 所有打开的标签页
const tabs = ref([
  { name: 'HomeTab', title: '首页', path: '/' }
])
// 当前激活的标签页的name
const activeTabName = computed(() => route.meta.tabName || route.name)
// 需要缓存的组件名的响应式数组,从tabs里提取
const cachedNames = computed(() => tabs.value.map(tab => tab.name))
// 关闭标签页的方法
const closeTab = (tabName) => {
  // 找到要关闭的标签页的索引
  const index = tabs.value.findIndex(tab => tab.name === tabName)
  if (index === -1) return
  // 从tabs里删除
  tabs.value.splice(index, 1)
  // 如果关闭的是当前激活的标签页,跳转到前一个或后一个标签页
  if (tabName === activeTabName.value) {
    const nextTab = tabs.value[index] || tabs.value[index - 1]
    router.push(nextTab.path)
  }
}
</script>
<template>
  <div class="tab-container">
    <!-- 标签页头部 -->
    <div class="tab-header">
      <div
        v-for="tab in tabs"
        :key="tab.name"
        class="tab-item"
        :class="{ active: activeTabName === tab.name }"
        @click="router.push(tab.path)"
      >
        {{ tab.title }}
        <span v-if="tab.name !== 'HomeTab'" class="close-btn" @click.stop="closeTab(tab.name)">×</span>
      </div>
    </div>
    <!-- 标签页内容 -->
    <div class="tab-content">
      <keep-alive :include="cachedNames">
        <router-view v-slot="{ Component }">
          <component :is="Component" />
        </router-view>
      </keep-alive>
    </div>
  </div>
</template>

上面的例子里,cachedNames是从tabs响应式数组里提取的computed属性,当关闭某个标签页时,tabs数组会删除对应的元素,cachedNames也会跟着删除对应的组件名,keep-alive检测到include数组里没有这个组件名了,就会自动销毁缓存里的这个组件,下次再打开这个标签页的时候就会重新触发onMounted,完美解决了主动销毁的需求。

Vue3 keep-alive无效的完整排查清单

最后给大家整理一个快速排查的清单,以后遇到keep-alive无效的问题,照着这个清单一条一条查,99%的问题都能解决:

  1. 检查组件是不是真的被keep-alive包裹了,v-if有没有写在外面,是不是用了v-show切换;
  2. 打开Vue DevTools,看切换组件时keep-alive下面的组件列表有没有消失,状态是不是Active/Inactive切换;
  3. 检查<script setup>里的组件有没有显式声明name属性(Vue 3.3+用defineOptions,3.3以下用单独的script标签);
  4. 检查include/exclude的匹配规则,是不是完全匹配组件name,正则是不是正确绑定,exclude的优先级是不是更高;
  5. 检查max属性是不是设置得太小,导致组件被LRU策略销毁;
  6. 检查嵌套router-view的包裹层级,每个需要缓存的router-view都要单独包裹;
  7. 检查router-view是不是绑定了错误的key(route.fullPath);
  8. 检查组件内部是不是在onActivated里重置了数据,或者用了内部v-if切换没包裹keep-alive的子组件。

好啦,以上就是我整理的所有Vue3 keep-alive无效的情况和解决方法,希望能帮到大家,如果还有其他没覆盖到的坑,欢迎在评论区留言一起讨论~

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门