跳到主内容

从重复请求到秒回:Vue3 KeepAlive 缓存电商列表页实战 ​

问题背景:电商列表页为什么需要缓存 ​

在典型的电商浏览链路中,用户从「商品列表页」下滑浏览几十款商品,点击进入「商品详情页」,再点击返回。如果列表页没有缓存,Vue Router 会重新创建组件实例:商品数据重新请求、分页状态丢失、滚动位置回到顶部,用户不得不重新下滑寻找刚才那款商品。这种体验在弱网环境下尤其糟糕,重复接口还会给后端带来无谓压力。

KeepAlive 是 Vue3 内置的缓存组件,被它包裹的动态组件在切换时不会被卸载,而是进入「停用(deactivated)」状态,DOM 与组件状态(包括滚动容器的 scrollTop)都得以保留。但直接无脑缓存所有页面会引发新问题:内存持续增长、用户从不同入口进入看到的是旧数据、搜索关键词残留。因此生产环境通常采用 include 白名单 + 滚动还原 的精细化方案。

方案对比:三种缓存策略的取舍 ​

方案实现成本内存占用数据新鲜度适用场景
不缓存(默认)低低每次最新详情页、表单页
KeepAlive 全量缓存低高,持续增长容易过期页面极少的后台系统
include 白名单缓存中可控可按入口刷新电商列表、资讯流

白名单方案的核心思路是:只缓存明确需要保留状态的列表页,详情页、购物车页等照常销毁;同时通过路由 meta 动态控制白名单,在「首页 → 列表」时缓存,在「订单页 → 列表」这类需要全新数据的入口下不缓存。

KeepAlive 缓存链路与滚动还原流程 ​

列表页浏览记录 scrollTop进入详情页列表触发 deactivated实例进入缓存池include 白名单匹配返回列表页触发 activated恢复滚动位置nextTick 后赋值

实操步骤一:路由 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,防止上个账号的列表数据残留造成信息泄露。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。