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

Vue3 KeepAlive怎么实现?常见问题有哪些?

terry 1小时前 阅读数 13 #Vue

你有没有遇到过这种情况?在用Vue做电商项目的时候,好不容易翻了三页女装列表,挑中一款点击详情页再回来,列表直接回到第一页,刚才选的款式也得重新找?或者做表单填写,填到第三页手滑点了底部的导航栏去首页了,再回来啥都没了,心态直接崩了?

其实只要用上Vue3内置的KeepAlive组件,这些问题就能轻松解决,KeepAlive的作用就是把切换出去的组件缓存起来,不让它销毁重建,相当于给组件开了个“临时储物间”,不用每次用都拆包再打包。

KeepAlive的基础写法是什么?和Vue2有啥不一样?

很多用过Vue2的朋友上手Vue3 KeepAlive的时候可能会懵,写法确实有变化。

先回忆下Vue2的写法,一般是用keep-alive标签包裹动态组件或者router-view,还可以加include、exclude、max这些属性对吧?但到了Vue3,如果你用的是<script setup>语法糖,直接在setup函数里写属性是不行的,得换个思路——要么是用<Transition><KeepAlive>配合动态的v-slot:default,要么是用Vue3.2+新增的<RouterView v-slot="{ Component }">语法,再配合keep-alive。

举个最常用的例子,在App.vue里给整个应用加缓存,但只缓存首页和列表页,详情页和个人中心不缓存:

<template>
  <!-- Vue3的keep-alive得和transition、v-slot配合 -->
  <router-view v-slot="{ Component }">
    <transition name="fade" mode="out-in">
      <keep-alive include="Home,ProductList" max="5">
        <!-- 注意这里Component前面要有冒号,绑定的是v-slot拿到的当前组件实例 -->
        <component :is="Component" />
      </keep-alive>
    </transition>
  </router-view>
</template>
<script setup>
// 这里不需要引入keep-alive和component,Vue3内置组件自动可用
</script>

这里有几个关键点要注意:

  1. 属性绑定方式没变,但大小写得注意Vue3的风格:include和exclude可以传字符串(逗号分隔,不能有空格哦)、数组、正则表达式,但在模板里用字符串的话,直接写就行;如果是用JS传数组或者正则,记得前面加冒号绑定动态值,而且数组里的组件名、正则匹配的组件名,必须是组件定义时的name属性或者setup里用defineOptions指定的name——这是很多新手踩的第一个坑,用<script setup>语法糖的时候,默认是没有组件名的,得手动加defineOptions。
  2. max属性的作用和Vue2一样,但缓存策略是LRU(最近最少使用):比如max设成5,当缓存的组件超过5个的时候,会自动把最久没打开过的那个组件销毁,释放内存,不用担心缓存过多导致页面卡顿。
  3. Transition可以搭配KeepAlive一起用,但顺序不能反:必须是transition包裹keep-alive,再包裹动态组件或者router-view,否则过渡动画会失效——因为如果keep-alive在外面,切换出去的组件直接被缓存,不会触发transition的enter和leave钩子,动画就出不来了。

那为什么推荐用这个写法呢?因为Vue3的Composition API让我们的代码更灵活,但内置组件的包裹方式也适配了这种灵活性,v-slot:default拿到的Component是一个响应式的组件引用,每次路由切换的时候都会更新,配合keep-alive就能精准缓存对应的组件。

用了KeepAlive后,组件的生命周期会发生什么变化?

这是第二个核心问题,也是必须搞懂的,否则你根本不知道什么时候该去更新数据。

在没用KeepAlive的时候,组件的生命周期是: 创建(beforeCreate、created)→ 挂载(beforeMount、mounted)→ 更新(beforeUpdate、updated)→ 卸载(beforeUnmount、unmounted) 每次切换到这个组件都会从创建开始走一遍,切换出去就直接卸载。

但用了KeepAlive之后,组件的生命周期会多两个钩子函数: activated(激活)deactivated(失活)

那整个流程就变成了: 第一次打开组件: 创建 → 挂载 → activated 切换出去到另一个组件: deactivated → (不卸载!组件被存在临时储物间里了) 再切换回来: activated → (不需要重新创建和挂载!直接用缓存的组件) 只有当组件被销毁的时候(比如max超过限制,或者路由配置里加了meta.noCache,exclude里加了这个组件名),才会走beforeUnmount和unmounted。

这里举个实际的例子,比如刚才说的女装列表页,第一次打开的时候,我们在mounted或者activated里都可以请求列表数据,但第二次打开的时候,mounted不会再触发了,只有activated会触发,那这时候我们是直接用缓存的列表和滚动位置,还是更新列表数据呢?

这得看你的业务需求:

  • 如果是做那种新闻资讯类的应用,用户可能想每次回来都看到最新的内容,那就在activated里重新请求数据,并且重置滚动位置;
  • 如果是做电商列表页,用户回来肯定是想继续刚才的浏览,那这时候就不能重新请求数据,也不能重置滚动位置——但这时候又有一个问题,就是KeepAlive默认不会缓存滚动位置,这怎么办?

KeepAlive不会缓存滚动位置?那该怎么解决?

对,这是很多朋友踩的第三个坑,KeepAlive只是缓存了组件的状态(比如表单里填的内容、组件里data或者ref里的变量值),但不会缓存页面或者容器的滚动位置——因为滚动位置是DOM的属性,不是组件内部的状态,组件被缓存的时候,DOM节点还在页面上吗?不,组件被失活的时候,DOM节点其实是被从页面上移除了,只是被Vue存起来了,下次激活的时候再插回去,但插回去之后滚动位置就变成0了。

那怎么解决呢?有两种常用的方法:

在组件内部手动保存和恢复滚动位置

这种方法适合只有某个特定容器滚动的情况,比如女装列表页,不是整个页面滚动,而是一个.list-container的div滚动。

具体做法是:

  1. 在失活(deactivated)的时候,获取这个容器的scrollTop,保存到一个全局的变量或者localStorage、sessionStorage里;
  2. 在激活(activated)的时候,再把这个scrollTop赋值给这个容器。

比如用全局变量的话,可以用Pinia或者Vuex(虽然Vue3推荐Pinia,但有些老项目还在用Vuex)来存,这样更规范,不会污染全局命名空间:

先在Pinia的store里建一个scrollPosition模块:

// stores/scrollPosition.js
import { defineStore } from 'pinia'
export const useScrollPositionStore = defineStore('scrollPosition', {
  state: () => ({
    // 用路由的path作为key,保存对应的滚动位置
    positions: {}
  }),
  actions: {
    setPosition(path, top) {
      this.positions[path] = top
    },
    getPosition(path) {
      return this.positions[path] || 0
    }
  }
})

然后在女装列表页里写:

<template>
  <div class="list-container" ref="listContainerRef">
    <!-- 列表内容 -->
    <div v-for="item in productList" :key="item.id" class="product-item">
      <!-- 商品信息 -->
    </div>
  </div>
</template>
<script setup>
import { ref, onActivated, onDeactivated, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useScrollPositionStore } from '@/stores/scrollPosition'
const route = useRoute()
const scrollStore = useScrollPositionStore()
const listContainerRef = ref(null)
const productList = ref([])
// 第一次加载的时候请求数据,并且保存初始的滚动位置(其实是0,但可以和后面保持一致)
onMounted(async () => {
  await getProductList()
  // 注意,这里要等数据加载完,DOM渲染好之后再恢复滚动位置,否则DOM高度不够,scrollTop设了也没用
  // 可以用nextTick,或者用setTimeout稍微延迟一点
  await nextTick()
  const savedTop = scrollStore.getPosition(route.path)
  if (listContainerRef.value) {
    listContainerRef.value.scrollTop = savedTop
  }
})
// 每次激活的时候也恢复滚动位置
onActivated(async () => {
  await nextTick()
  const savedTop = scrollStore.getPosition(route.path)
  if (listContainerRef.value) {
    listContainerRef.value.scrollTop = savedTop
  }
})
// 失活的时候保存滚动位置
onDeactivated(() => {
  if (listContainerRef.value) {
    scrollStore.setPosition(route.path, listContainerRef.value.scrollTop)
  }
})
// 获取商品列表的函数
const getProductList = async () => {
  // 模拟请求
  await new Promise(resolve => setTimeout(resolve, 500))
  productList.value = Array.from({ length: 50 }, (_, i) => ({ id: i + 1, name: `女装${i + 1}` }))
}
</script>

这里有个小细节要注意:恢复滚动位置的时候,必须等DOM渲染完成之后再设,比如用nextTick,或者用setTimeout延迟100-200ms,否则如果列表数据是异步加载的,第一次激活或者重新激活的时候,DOM还没渲染好,容器的高度不够,scrollTop设了也会被重置为0。

利用Vue Router的scrollBehavior钩子

这种方法适合整个页面滚动的情况,而且不需要在每个组件里手动写代码,更方便。

Vue Router的scrollBehavior钩子的作用是:在路由切换的时候,控制页面滚动到哪里,比如默认返回顶部,或者返回上次滚动的位置。

Vue3的Vue Router4里,scrollBehavior钩子的参数有三个:to(要去的路由)、from(离开的路由)、savedPosition(上次离开这个路由时保存的滚动位置)——注意哦,这个savedPosition只有当路由是通过浏览器的前进/后退按钮触发的时候才会有,如果是通过编程式导航(比如router.push、router.replace)或者点击router-link触发的,savedPosition是null。

那如果我们想不管用什么方式切换路由,都能保存和恢复页面的滚动位置,该怎么结合KeepAlive呢?

可以这样写:

  1. 在Pinia或者Vuex的store里,用路由的path作为key,保存对应的savedPosition;
  2. 在scrollBehavior钩子的最后,如果savedPosition存在,就返回savedPosition;如果不存在,就从store里获取对应的位置;如果store里也没有,就返回顶部;
  3. 在路由配置里,给需要缓存滚动位置的页面加一个meta.keepScroll属性;
  4. 在离开路由的时候(可以用router的beforeEach或者afterEach钩子),如果要离开的路由有meta.keepScroll属性,就把当前页面的滚动位置存到store里。

具体的代码我就不贴太多了,和刚才的手动保存类似,只是把滚动容器换成了window,并且利用了Vue Router的钩子。

include和exclude怎么用?动态缓存怎么实现?

刚才的基础写法里,include是用字符串传的静态值,Home,ProductList",但有时候我们需要动态控制哪些组件被缓存,比如电商项目里,只有用户从商品详情页返回列表页的时候,才缓存列表页;如果用户从首页进入列表页,就不缓存,重新请求数据。

那这时候动态缓存怎么实现呢?有几种常用的方法:

用响应式的数组或者变量来控制include

比如在App.vue里,用一个ref的数组来存需要缓存的组件名,然后把这个数组绑定到include属性上:

<template>
  <router-view v-slot="{ Component }">
    <transition name="fade" mode="out-in">
      <keep-alive :include="cachedComponents" max="5">
        <component :is="Component" />
      </keep-alive>
    </transition>
  </router-view>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useCachedComponentsStore } from '@/stores/cachedComponents' // 也可以用store存,方便跨组件修改
const route = useRoute()
// 用store存的话更方便,比如在商品详情页里可以修改这个数组
const cachedStore = useCachedComponentsStore()
const cachedComponents = ref(cachedStore.cachedList)
// 监听store的变化,更新当前的cachedComponents
watch(() => cachedStore.cachedList, (newVal) => {
  cachedComponents.value = newVal
}, { deep: true })
</script>

然后在Pinia的cachedComponents store里:

// stores/cachedComponents.js
import { defineStore } from 'pinia'
export const useCachedComponentsStore = defineStore('cachedComponents', {
  state: () => ({
    cachedList: ['Home'] // 默认只缓存首页
  }),
  actions: {
    addComponent(name) {
      if (!this.cachedList.includes(name)) {
        this.cachedList.push(name)
      }
    },
    removeComponent(name) {
      const index = this.cachedList.indexOf(name)
      if (index > -1) {
        this.cachedList.splice(index, 1)
      }
    }
  }
})

接下来在女装列表页的setup里:

// 列表页的name是ProductList,在defineOptions里指定
defineOptions({
  name: 'ProductList'
})
// 监听路由变化,如果是从首页进入的,就从缓存列表里移除自己,这样下次进来就会重新创建
const from = useRoute().matched[0].instances.default?.$route?.path || ''
if (from === '/') {
  cachedStore.removeComponent('ProductList')
} else {
  // 如果不是从首页进入的,就把自己加到缓存列表里
  cachedStore.addComponent('ProductList')
}

然后在商品详情页里:

// 详情页的setup里,点击返回按钮的时候,可以先判断一下,但其实不需要,因为列表页已经处理了
// 或者如果用户在详情页点击了“去首页”,就可以把列表页从缓存里移除
const goHome = () => {
  cachedStore.removeComponent('ProductList')
  router.push('/')
}

利用路由的meta属性和v-if/v-else

这种方法更简单,不需要用store,直接在路由配置里给需要缓存的页面加一个meta.keepAlive属性,然后在App.vue里用v-if/v-else来判断是否用keep-alive包裹:

<template>
  <router-view v-slot="{ Component, route }">
    <transition name="fade" mode="out-in">
      <!-- 这里利用了Vue的特性:相同的组件如果用不同的key包裹,会被视为不同的组件,会重新创建 -->
      <!-- 但我们这里不用key,而是用v-if/v-else -->
      <keep-alive include="Home,ProductList" max="5">
        <component :is="Component" v-if="route.meta.keepAlive" :key="route.path" />
      </keep-alive>
      <component :is="Component" v-else :key="route.path" />
    </transition>
  </router-view>
</template>

然后在路由配置里:

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
  {
    path: '/',
    name: 'Home',
    component: () => import('@/views/Home.vue'),
    meta: { keepAlive: true }
  },
  {
    path: '/product-list',
    name: 'ProductList',
    component: () => import('@/views/ProductList.vue'),
    meta: { keepAlive: true }
  },
  {
    path: '/product-detail/:id',
    name: 'ProductDetail',
    component: () => import('@/views/ProductDetail.vue'),
    meta: { keepAlive: false }
  },
  {
    path: '/user-center',
    name: 'UserCenter',
    component: () => import('@/views/UserCenter.vue'),
    meta: { keepAlive: false }
  }
]
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes
})
export default router

这种方法虽然简单,但不够灵活,比如不能动态控制某个页面是否缓存,只能在路由配置里写死,如果需要动态控制,还是推荐用第一种方法,用响应式的数组或者store来控制include。

用了KeepAlive之后,组件里的定时器、事件监听器怎么办?

这是第四个核心问题,也是很容易被忽略的,如果处理不好,会导致内存泄漏,页面越来越卡。

为什么会导致内存泄漏呢?因为用了KeepAlive之后,组件被失活的时候,不会走beforeUnmount和unmounted钩子,如果你在created、mounted或者setup里定义了定时器、事件监听器,或者订阅了Pinia/Vuex的状态,这些东西不会被自动清除,会一直留在内存里,下次激活组件的时候又会重新创建一个,久而久之,内存就会越来越多,导致页面卡顿甚至崩溃。

那怎么解决呢?很简单,在失活(deactivated)的时候清除定时器、事件监听器,取消订阅;在激活(activated)的时候重新创建定时器、事件监听器,重新订阅——如果是第一次创建组件,在mounted里创建也可以,但为了统一,建议在activated里创建,在deactivated里清除。

举个例子,比如一个倒计时组件,放在女装列表页的顶部,显示“限时优惠还有XX小时XX分XX秒”:

<template>
  <div class="countdown">
    限时优惠还有:{{ hours }}小时{{ minutes }}分{{ seconds }}秒
  </div>
</template>
<script setup>
import { ref, onActivated, onDeactivated, onMounted, onUnmounted } from 'vue'
const hours = ref(0)
const minutes = ref(0)
const seconds = ref(0)
let timer = null
// 计算剩余时间的函数
const calculateRemainingTime = () => {
  // 假设限时优惠到2025年12月31日23点59分59秒
  const endTime = new Date('2025-12-31T23:59:59').getTime()
  const now = new Date().getTime()
  const remaining = endTime - now
  if (remaining <= 0) {
    hours.value = 0
    minutes.value = 0
    seconds.value = 0
    clearInterval(timer)
    return
  }
  hours.value = Math.floor(remaining / (1000 * 60 * 60))
  minutes.value = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60))
  seconds.value = Math.floor((remaining % (1000 * 60)) / 1000)
}
// 第一次激活和每次重新激活的时候都启动定时器
onActivated(() => {
  calculateRemainingTime()
  timer = setInterval(calculateRemainingTime, 1000)
})
// 失活的时候清除定时器
onDeactivated(() => {
  if (timer) {
    clearInterval(timer)
    timer = null
  }
})
// 最后还要在unmounted里清除定时器,防止组件被销毁的时候定时器还在
onUnmounted(() => {
  if (timer) {
    clearInterval(timer)
    timer = null
  }
})
</script>

这里有个小细节要注意:除了在deactivated里清除,还要在unmounted里清除——因为如果组件被max超过限制或者exclude里加了组件名而销毁的时候,只会走unmounted,不会走deactivated,所以必须在unmounted里也加一遍清除的代码,防止内存泄漏。

事件监听器也是一样的,比如在created或者mounted里给window加了一个scroll事件监听器,就要在deactivated里移除,在activated里重新添加,最后在unmounted里也移除。

订阅Pinia/Vuex的状态也是一样的,比如用store.$subscribe或者watchEffect订阅了某个状态,就要在deactivated里取消订阅,在activated里重新订阅,最后在unmounted里也取消订阅。

总结一下KeepAlive的最佳实践

说了这么多,最后总结一下KeepAlive的最佳实践,方便大家快速上手:

  1. 正确设置组件名:用<script setup>语法糖的时候,必须用defineOptions指定组件名,否则include和exclude会失效;
  2. 合理设置max属性:根据业务需求设置max属性,避免缓存过多组件导致内存泄漏;
  3. 灵活控制缓存:如果需要静态缓存,用路由的meta属性和v-if/v-else;如果需要动态缓存,用响应式的数组或者Pinia/Vuex来控制include;
  4. 正确处理生命周期:第一次加载和每次重新加载组件的时候,在activated里更新数据(如果需要的话);在deactivated里保存组件状态和滚动位置;
  5. 防止内存泄漏:在deactivated里清除定时器、事件监听器,取消订阅;在unmounted里也加一遍清除的代码;
  6. 正确恢复滚动位置:如果是特定容器滚动,在组件内部手动保存和恢复;如果是整个页面滚动,利用Vue Router的scrollBehavior钩子;
  7. 配合Transition使用:注意Transition包裹KeepAlive的顺序,不要反了,否则过渡动画会失效。

KeepAlive是Vue3里非常实用的一个内置组件,用好了可以大大提升用户体验,但如果用不好,也会带来很多问题,比如内存泄漏、滚动位置丢失、数据不更新等等,希望这篇文章能帮到大家,让大家在项目里正确使用KeepAlive。

版权声明

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

热门