外观
从重复请求到秒回:Vue3 KeepAlive 缓存电商列表页实战
问题背景:电商列表页为什么需要缓存
在典型的电商浏览链路中,用户从「商品列表页」下滑浏览几十款商品,点击进入「商品详情页」,再点击返回。如果列表页没有缓存,Vue Router 会重新创建组件实例:商品数据重新请求、分页状态丢失、滚动位置回到顶部,用户不得不重新下滑寻找刚才那款商品。这种体验在弱网环境下尤其糟糕,重复接口还会给后端带来无谓压力。
KeepAlive 是 Vue3 内置的缓存组件,被它包裹的动态组件在切换时不会被卸载,而是进入「停用(deactivated)」状态,DOM 与组件状态(包括滚动容器的 scrollTop)都得以保留。但直接无脑缓存所有页面会引发新问题:内存持续增长、用户从不同入口进入看到的是旧数据、搜索关键词残留。因此生产环境通常采用 include 白名单 + 滚动还原 的精细化方案。
方案对比:三种缓存策略的取舍
| 方案 | 实现成本 | 内存占用 | 数据新鲜度 | 适用场景 |
|---|---|---|---|---|
| 不缓存(默认) | 低 | 低 | 每次最新 | 详情页、表单页 |
| KeepAlive 全量缓存 | 低 | 高,持续增长 | 容易过期 | 页面极少的后台系统 |
| include 白名单缓存 | 中 | 可控 | 可按入口刷新 | 电商列表、资讯流 |
白名单方案的核心思路是:只缓存明确需要保留状态的列表页,详情页、购物车页等照常销毁;同时通过路由 meta 动态控制白名单,在「首页 → 列表」时缓存,在「订单页 → 列表」这类需要全新数据的入口下不缓存。
KeepAlive 缓存链路与滚动还原流程
实操步骤一:路由 meta 与 include 白名单
首先在路由配置中给需要缓存的列表页打上标记。注意 KeepAlive 的 include 匹配的是组件的 name 选项,而不是路由 name 或路径,这是最常见的踩坑点。
typescript
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/products',
name: 'ProductList',
// 组件必须显式声明 name,且与白名单一致
component: () => import('@/views/ProductList.vue'),
meta: { keepAlive: true }
},
{
path: '/product/:id',
name: 'ProductDetail',
component: () => import('@/views/ProductDetail.vue')
}
]
})
export default router在 Vue3 的 <script setup> 中组件没有天然的 name 属性,需要用一个独立的 <script> 块声明,或借助 unplugin-vue-define-options:
vue
<!-- src/views/ProductList.vue -->
<script lang="ts">
// 该 name 必须与 include 白名单中的字符串完全一致
export default { name: 'ProductList' }
</script>
<script setup lang="ts">
import { ref } from 'vue'
const goods = ref<Array<{ id: number; title: string }>>([])
const loading = ref(false)
async function loadGoods() {
loading.value = true
const res = await fetch('/api/products').then(r => r.json())
goods.value = res.list
loading.value = false
}
loadGoods()
</script>实操步骤二:根组件动态维护白名单
在布局根组件中用响应式数组承接 include,并监听路由变化。这样做的额外收益是:可以在离开列表页去往「不需要保留状态」的页面时,主动把组件名踢出白名单,下次进入即重新挂载、拉取最新数据。
vue
<!-- src/layouts/AppLayout.vue -->
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
// 白名单:只缓存这里列出的组件 name
const cachedViews = ref<string[]>(['ProductList'])
watch(
() => route.meta.keepAlive,
(keep) => {
const name = route.name as string
if (keep && !cachedViews.value.includes(name)) {
cachedViews.value.push(name)
}
},
{ immediate: true }
)
// 业务中可调用此方法强制让列表页下次重新加载
function removeCache(name: string) {
const idx = cachedViews.value.indexOf(name)
if (idx > -1) cachedViews.value.splice(idx, 1)
}
defineExpose({ removeCache })
</script>
<template>
<router-view v-slot="{ Component }">
<!-- include 支持字符串、正则或数组;max 限制缓存实例数量防内存膨胀 -->
<KeepAlive :include="cachedViews" :max="10">
<component :is="Component" />
</KeepAlive>
</router-view>
</template>实操步骤三:列表页滚动位置还原
KeepAlive 能保留普通窗口滚动,但电商列表的滚动往往发生在内部容器(如 .list-scroll)上,且返回时图片等资源可能重新布局,直接依赖浏览器记忆并不可靠。稳妥做法是在 onActivated 中手动恢复。
vue
<script setup lang="ts">
import { ref, onActivated, onDeactivated, nextTick } from 'vue'
const scrollEl = ref<HTMLElement | null>(null)
// 用模块级变量或 Pinia 存储位置,避免组件复用时被重置
let savedTop = 0
onDeactivated(() => {
// 离开列表进入详情的瞬间保存位置
if (scrollEl.value) savedTop = scrollEl.value.scrollTop
})
onActivated(async () => {
// 等待列表 DOM 与图片占位渲染完成,再恢复滚动
await nextTick()
// 二次等待:图片有懒加载高度时,可结合数据是否已缓存判断
if (scrollEl.value) {
scrollEl.value.scrollTop = savedTop
}
})
// 按需刷新:从特定入口(如搜索)进入时重置
function resetScrollAndReload() {
savedTop = 0
loadGoods()
}
</script>
<template>
<div ref="scrollEl" class="list-scroll">
<div v-for="item in goods" :key="item.id" class="goods-card">
{{ item.title }}
</div>
<p v-if="loading">加载中...</p>
</div>
</template>
<style scoped>
.list-scroll {
height: calc(100vh - 120px);
overflow-y: auto; /* 滚动发生在内部容器,必须显式声明 */
}
</style>如果希望全局复用,可把存取逻辑抽成 composable:
typescript
// src/composables/useKeepScroll.ts
import { onActivated, onDeactivated, nextTick, type Ref } from 'vue'
// 按 key 分别记录多个列表页的位置
const store = new Map<string, number>()
export function useKeepScroll(key: string, el: Ref<HTMLElement | null>) {
onDeactivated(() => {
if (el.value) store.set(key, el.value.scrollTop)
})
onActivated(async () => {
await nextTick()
const top = store.get(key) ?? 0
// 部分浏览器需要双 requestAnimationFrame 才能稳定生效
requestAnimationFrame(() => {
el.value?.scrollTo({ top })
})
})
}避坑总结
坑一:include 配了路由 name 却不生效,页面每次都重新请求。 这是因为 include 匹配的是组件的 name 选项,与路由配置无关。<script setup> 单文件组件默认 name 取自文件名但不可靠,务必显式添加普通 <script> 块导出 name,并保证与白名单字符串逐字一致,排查时可在 Vue Devtools 中核对组件实际名称。
坑二:返回列表后滚动位置先恢复又被弹回顶部。 典型原因是恢复时机早于图片/骨架屏渲染,DOM 高度变化导致浏览器重置滚动。修正方法是在 onActivated 中先 await nextTick(),必要时再套一层 requestAnimationFrame 或等待图片 onLoad;同时给商品图设置固定宽高比容器(aspect-ratio),从源头避免布局抖动。
坑三:缓存了列表页,导致换了搜索词或分类入口仍显示旧数据。 KeepAlive 缓存的是整个实例,setup 只执行一次,路由参数变化不会重新请求。应在组件内 watch(() => route.query) 触发重新加载并重置 savedTop,或在跳转前调用 removeCache('ProductList') 将其踢出白名单,使下一次进入走全新挂载。
坑四:长时间使用后页面卡顿、内存上涨。 白名单不断累加且从不清理会让缓存实例无限增长。务必设置 :max 上限(Vue 会按 LRU 策略淘汰最久未访问的实例),并在用户退出登录时清空 cachedViews,防止上个账号的列表数据残留造成信息泄露。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。