Vue3 KeepAlive怎么实现?常见问题有哪些?
你有没有遇到过这种情况?在用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>
这里有几个关键点要注意:
- 属性绑定方式没变,但大小写得注意Vue3的风格:include和exclude可以传字符串(逗号分隔,不能有空格哦)、数组、正则表达式,但在模板里用字符串的话,直接写就行;如果是用JS传数组或者正则,记得前面加冒号绑定动态值,而且数组里的组件名、正则匹配的组件名,必须是组件定义时的name属性或者setup里用defineOptions指定的name——这是很多新手踩的第一个坑,用
<script setup>语法糖的时候,默认是没有组件名的,得手动加defineOptions。 - max属性的作用和Vue2一样,但缓存策略是LRU(最近最少使用):比如max设成5,当缓存的组件超过5个的时候,会自动把最久没打开过的那个组件销毁,释放内存,不用担心缓存过多导致页面卡顿。
- 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滚动。
具体做法是:
- 在失活(deactivated)的时候,获取这个容器的scrollTop,保存到一个全局的变量或者localStorage、sessionStorage里;
- 在激活(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呢?
可以这样写:
- 在Pinia或者Vuex的store里,用路由的path作为key,保存对应的savedPosition;
- 在scrollBehavior钩子的最后,如果savedPosition存在,就返回savedPosition;如果不存在,就从store里获取对应的位置;如果store里也没有,就返回顶部;
- 在路由配置里,给需要缓存滚动位置的页面加一个meta.keepScroll属性;
- 在离开路由的时候(可以用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的最佳实践,方便大家快速上手:
- 正确设置组件名:用
<script setup>语法糖的时候,必须用defineOptions指定组件名,否则include和exclude会失效; - 合理设置max属性:根据业务需求设置max属性,避免缓存过多组件导致内存泄漏;
- 灵活控制缓存:如果需要静态缓存,用路由的meta属性和v-if/v-else;如果需要动态缓存,用响应式的数组或者Pinia/Vuex来控制include;
- 正确处理生命周期:第一次加载和每次重新加载组件的时候,在activated里更新数据(如果需要的话);在deactivated里保存组件状态和滚动位置;
- 防止内存泄漏:在deactivated里清除定时器、事件监听器,取消订阅;在unmounted里也加一遍清除的代码;
- 正确恢复滚动位置:如果是特定容器滚动,在组件内部手动保存和恢复;如果是整个页面滚动,利用Vue Router的scrollBehavior钩子;
- 配合Transition使用:注意Transition包裹KeepAlive的顺序,不要反了,否则过渡动画会失效。
KeepAlive是Vue3里非常实用的一个内置组件,用好了可以大大提升用户体验,但如果用不好,也会带来很多问题,比如内存泄漏、滚动位置丢失、数据不更新等等,希望这篇文章能帮到大家,让大家在项目里正确使用KeepAlive。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


