阅读视图

发现新文章,点击刷新页面。

Vue3 嵌套路由 KeepAlive:动态缓存与反向配置方案

在电商系统中,用户的操作路径往往是这样的:

进入商品列表页 → 进行筛选、排序、分页 → 点击进入商品详情页 → 查看后返回列表页。

这时,用户通常有一个非常明确的预期:

  • 筛选条件还在
  • 分页状态保持
  • 滚动位置不丢失

但同时,我们也会遇到另一种场景:

  • 从首页、搜索页或其他模块进入商品列表页时
  • 希望列表页是“全新状态”
  • 需要重新请求数据

也就是说:

同一个页面,在不同来源路径下,对“是否缓存”的期望是不同的。

在 Vue 项目开发中,KeepAlive 是提升用户体验的重要工具。但在复杂的嵌套路由场景(例如:Layout → SubLayout → Page)下,我们常常会遇到两个典型痛点:

1️⃣ 缓存失效

明明在路由里配置了 keepAlive: true
但页面返回时依然重新挂载,onMounted 再次触发,状态丢失。

2️⃣ 控制逻辑越来越混乱

当我们尝试实现:

  • 从 A 页面跳到 B 页面时缓存
  • 从 C 页面跳到 B 页面时不缓存

路由守卫里开始出现大量 if-else 判断,
逻辑逐渐变得难以维护,甚至演变成“屎山”。

如何实现:

  • 精准控制缓存来源
  • 支持嵌套路由结构
  • 避免父级 Layout 被误销毁
  • 同时保持代码清晰、可维护

本文将分享一种“反向配置”的缓存设计方案,并深入解析嵌套路由场景下的一个核心原则:

父随子存。

通过这套设计,我们可以在复杂电商场景中,实现精细化、可控且可扩展的页面缓存策略。

一、核心痛点:为什么嵌套路由下的缓存容易失效?

在 Vue 中,KeepAlive 的本质其实是“链路存活”。

换句话说,只要组件所在的这条渲染链路还存在,它的缓存才能继续保留。一旦链路中某一层被销毁,缓存就会随之消失。

假设我们的路由结构是这样的:

  • 一级容器:WebsiteLayout(顶层布局)
  • 二级容器:UserLayout(用户中心布局)
  • 三级页面:UserProfile、UserFavorites 等子页面
WebsiteLayout
  └── UserLayout
        └── UserProfile / UserFavorites

这里有一个很多人忽略的“真相”:

如果父容器(例如 UserLayout)被销毁,那么它内部缓存的所有子页面,也会被瞬间“物理清空”。

即使你在 UserProfile 上配置了keepAlive: true, 只要 UserLayout 这一层被重新挂载, 内部所有缓存都会失效,onMounted 会再次触发。

这就是嵌套路由场景下缓存“看起来配置了却没生效”的根本原因。

很多开发者只给子页面设置缓存,却忽略了一个关键原则:

子页面想存活,父容器必须先存活。

这也是后文要讲的核心设计理念——“父随子存”原则。

二、 解决方案:反向配置 + 递归缓存

1. 路由配置:由“去向页”决定“来源页”

我们不再在每个页面写复杂的判断,而是在详情页声明:“从我这里回退时,请保持谁的缓存”

// router/index.ts
export const routes = [
  {
    path: '/user',
    name: 'User',
    component: () => import('@/layouts/UserLayout.vue'),
    meta: { keepAlive: true }, // 父容器必须支持缓存
    children: [
      {
        path: 'favorites',
        name: 'UserFavorites',
        component: () => import('@/views/user/Favorites.vue'),
        meta: { keepAlive: true }
      }
    ]
  },
  {
    path: '/product/:id',
    name: 'Product',
    component: () => import('@/views/product/index.vue'),
    meta: { 
      // 反向配置:从这里回退时,保护以下页面的缓存
      keepAliveSources: ['UserFavorites', 'Home'] 
    }
  }
];

2. 全局守卫:实现“父随子存”

这是整套方案的灵魂。我们需要在路由守卫中做两件事:

  1. 补全链路:进入子页面时,强制将其所有父 Layout 加入缓存名单。
  2. 精准清理:非合法来源进入时,即时销毁旧缓存。
export const cacheStack = ref<string[]>([]);

router.beforeEach(async (to, from, next) => {
    const fromName = from.name as string;
    const keepAliveSources = to.meta.keepAliveSources as string[] || [];

    // 1️⃣ 核心:补全父路由缓存名单
    // 遍历 to.matched,确保当前路由的所有父 Layout 都在缓存数组中
    to.matched.forEach((record) => {
      if (record.meta.keepAlive && record.name) {
        const name = record.name as string;
        if (!cacheStack.value.includes(name)) {
          cacheStack.value.push(name);
        }
      }
    });

    // 2️⃣ 处理来源页逻辑
    if (from.meta.keepAlive && fromName) {
      // 如果来源页本身声明了需要缓存,保留它
      if (!cacheStack.value.includes(fromName)) cacheStack.value.push(fromName);
    } else if (keepAliveSources.includes(fromName)) {
      // 如果去向页声明了它是合法的回退来源,保留它
      if (!cacheStack.value.includes(fromName)) cacheStack.value.push(fromName);
    } else if (fromName) {
      // 3️⃣ 清理:如果不是合法来源,从名单中移除,触发组件销毁
      const index = cacheStack.value.indexOf(fromName);
      if (index > -1) cacheStack.value.splice(index, 1);
    }

    next();
});

3. 布局组件:视图层的配合

在所有包含 router-viewLayout 组件中,必须使用 include 绑定这个全局名单。

  1. WebsiteLayout.vue
// WebsiteLayout.vue
<template>
    <router-view v-slot="{ Component, route }">
      <keep-alive :include="cacheStackList">
        <component 
          :is="Component" 
          :key="route.fullPath" 
        />
      </keep-alive>
    </router-view>
</template>

<script lang="ts" setup name="WebsiteLayout">
  import { defineComponent, computed } from 'vue';
  import { cacheStack } from '/@/router/guard/index';

  const cacheStackList = computed(() => {
    return cacheStack.value
  })
</script>
  1. UserLayout.vue
// UserLayout.vue
<template>
  <div class="flex flex-col">
    <div class="flex gap-6 py-4">
      <!-- 左侧菜单 -->
      <aside class="w-96 shrink-0">
        <UserMenu />
      </aside>

      <!-- 右侧内容 -->
      <main class="flex-1">
        <router-view v-slot="{ Component, route }">
          <keep-alive :include="cacheStackList">
            <component 
              :is="Component" 
              :key="route.fullPath" 
            />
          </keep-alive>
        </router-view>
      </main>
    </div>
  </div>
</template>
<script setup lang="ts" name="User">
import UserMenu from './userMenu/index.vue'
import { cacheStack } from '/@/router/guard/index';
import { computed } from 'vue';
const cacheStackList = computed(() => {
  return cacheStack.value
})
</script>

三、 方案优势

  1. 物理隔离确保生效:通过 to.matched 递归确保父容器存活,彻底解决了嵌套路由“名单对了但不缓存”的问题。

  2. 配置解耦:新加一个详情页时,只需在详情页 meta 里增加一行回退目标,无需改动任何业务组件。

  3. 内存友好:不满足回退条件时,缓存会被立即 splice 清理,避免内存堆积。

Vite 性能瓶颈排查标准流程

第一步:诊断(必须做)

按照分析工具

pnpm add -D rollup-plugin-visualizer
or
yarn add -D  rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'

plugins: [
  visualizer({
    open: true,
    gzipSize: true,
    brotliSize: true
  })
]

运行

pnpm build
or
yarn build

重点观察

在分析页里重点看

关注点 看什么
那个chunk最大 首屏是否>500kb
Inported by 谁引用了它
重依赖 是否多个chunk重复打包
大JSON图/图标 是否被主入口引用

常见“元凶”

  • 第三方UI库全量引入(如 ant-design-vue/element plus)
  • lodash 全量
  • moment
  • echarts
  • icon大数据文件
  • 地图geojson
  • 误import整个utils目录

第二步:合理分包(manualChunks)

这是最核心的优化点

build: {
  rollupOptions: {
    output: {
      manualChunks(id) {

        // 1️⃣ 第三方库分离
        if (id.includes('node_modules')) {

          if (id.includes('ant-design-vue'))
            return 'antd-vendor'

          if (id.includes('vue'))
            return 'vue-core'

          return 'vendor'
        }

        // 2️⃣ 业务页面自动分包
        if (id.includes('src/views/')) {
          const parts = id.split('/')
          const index = parts.indexOf('views')
          const folderName = parts[index + 1]
          return `view-${folderName}`
        }

      }
    }
  }
}

优化目标

包类型 目标
主入口index <200kb
vendor 可缓存
每个view 按需加载

第三步:巨型静态数据处理

错误示范

// main.ts
import icons from './all-icons.js'  // ❌ 500kB 直接进主包

正确做法(动态加载)

const loadIcons = () => import('./all-icons.js')

规则

文件大小 处理方式
>100kB 必须动态import
>300kb 拆分 or cdn
>1MB 禁止进主包

第四步:资源CDN化

1. vite base 指向 CDN

export default defineConfig({
  base: process.env.NODE_ENV === 'production'
    ? 'https://cdn.xxx.com/'
    : '/'
})

2. 图片/字体策略

  • .woff2>100kB 上传CDN
  • 大背景图禁止进JS
  • 不要base64大图

3. 巨型JS文件CDN化 例如:

  • icon库
  • geojson
  • 富文本编辑器扩展 直接
<script src="https://cdn.xxx.com/icons.js"></script>

第五步:PWA检查

如果使用

vite-plugin-pwa

必须检查:

workbox: {
  globPatterns: ['**/*.{js,css,html}']
}

风险点

  • index.html被precache
  • index.js 过大
  • 每次发布强制用户下载大包

建议

  • 只precache必要资源
  • 避免缓存大业务chunk
  • 首屏尽量轻量

进阶补充

1. 开启esbuild压缩

build: {
  minify: 'esbuild'
}

2. 开启tree shaking 优化

optimizeDeps: {
  include: []
}

不要乱include

3. 路由懒加载必须写对

{
  path: '/home',
  component: () => import('@/views/home/index.vue')
}

禁止

import Home from '@/views/home/index.vue'
❌