路由与布局骨架篇:多标签页(Tab)与缓存 | keep-alive、includeexclude、路由 meta

11 阅读8分钟

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

一、先搞懂:多标签页为什么需要缓存?

1.1 典型场景

中后台系统常见结构:

┌─────────────────────────────────────────────────────────────┐
│  首页 | 用户管理 | 订单列表 | 商品详情 | 数据报表  ← 多个 Tab
├─────────────────────────────────────────────────────────────┤
│                                                             │
│              【当前激活的页面内容区域】                    
│                                                             │
└─────────────────────────────────────────────────────────────┘

无缓存时:

  • 从「用户管理」切到「订单列表」,再切回「用户管理」
  • 组件会被销毁再重建,之前的滚动位置、表单、筛选条件全部丢失

有缓存时:

  • 切到其他页再回来,之前的滚动、表单、筛选等都保留
  • 用户体验明显更好

所以多标签页场景下,通常要用 keep-alive 对路由组件做缓存。

二、keep-alive 到底是个啥?

2.1 一句话理解

<keep-alive> 是 Vue 内置组件,用来在切换时保留被包裹组件的状态(例如 data、滚动位置等),而不是销毁重建。

2.2 基本用法

<!-- App.vue 或 主布局组件 -->
<template>
  <div id="app">
    <router-view v-slot="{ Component }">
      <keep-alive>
        <component :is="Component" />
      </keep-alive>
    </router-view>
  </div>
</template>

这里:

  • router-view 渲染当前路由对应的组件
  • keep-alive 包裹 component,让所有路由组件都被缓存

2.3 这样写会有什么问题?

如果 所有路由 都被缓存:

  • 登录页、404 等不该缓存的页面也会被缓存
  • 页面增多后,内存占用会变大

所以实际开发中,通常会用 include / exclude路由 meta 来控制「哪些要缓存」。

三、include 和 exclude:精确控制缓存范围

3.1 基本概念

  • include:只有名字匹配的组件会被缓存
  • exclude:名字匹配的组件不会被缓存

这里的「名字」指的是 组件的 name 选项,不是路由的 pathname

3.2 静态写法示例

<template>
  <router-view v-slot="{ Component }">
    <!-- 只缓存 UserList 和 OrderList -->
    <keep-alive include="UserList,OrderList">
      <component :is="Component" />
    </keep-alive>
  </router-view>
</template>

被缓存组件需要显式定义 name

<!-- UserList.vue -->
<script>
export default {
  name: 'UserList',  // 必须和 include 里的名字一致
  // ...
}
</script>

3.3 动态 include(配合路由 meta)

实际项目里,往往是「根据路由配置决定是否缓存」,而不是在 keep-alive 里写死组件名,所以会用到 路由 meta

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

<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()

// 假设 cachedViews 来自 pinia/vuex,存的是需要缓存的组件名
const cachedViews = computed(() => store?.cachedViews ?? [])
</script>

路由配置中标记「可缓存」并保证组件 name 正确:

// router/index.js
{
  path: '/user/list',
  name: 'UserList',
  meta: { keepAlive: true },  // 表示该页面需要缓存
  component: () => import('@/views/user/UserList.vue')
}

配合 Tab 逻辑:打开 Tab 时,把对应组件的 name 加入 cachedViews,关闭 Tab 时移除,这样 keep-alive 就能按需缓存。

3.4 include 的三种写法

<!-- 1. 字符串:多个用逗号分隔 -->
<keep-alive include="UserList,OrderList">
  <component :is="Component" />
</keep-alive>

<!-- 2. 正则 -->
<keep-alive :include="/UserList|OrderList/">
  <component :is="Component" />
</keep-alive>

<!-- 3. 数组 -->
<keep-alive :include="['UserList', 'OrderList']">
  <component :is="Component" />
</keep-alive>

四、路由 meta 和 keep-alive 的配合

4.1 meta 的作用

路由 meta 用来放和业务相关的配置,比如:

  • keepAlive:是否缓存
  • title:Tab 标题
  • icon:图标
  • affix:是否固定在 Tab 栏(如首页)

4.2 在路由里标记缓存

// router/index.js
export default [
  {
    path: '/home',
    name: 'Home',
    meta: {
      title: '首页',
      keepAlive: true,
      affix: true   // 首页固定,不能关闭
    },
    component: () => import('@/views/Home.vue')
  },
  {
    path: '/user/list',
    name: 'UserList',
    meta: {
      title: '用户管理',
      keepAlive: true
    },
    component: () => import('@/views/user/UserList.vue')
  },
  {
    path: '/login',
    name: 'Login',
    meta: {
      title: '登录',
      keepAlive: false   // 登录页不缓存
    },
    component: () => import('@/views/Login.vue')
  }
]

4.3 要点:组件 name 必须和路由 name 一致(或单独维护映射)

keep-aliveinclude 认的是 组件的 name,不是路由的 name。常见两种做法:

  • 约定:路由 name 和组件 name 一致
  • 或:在 store 里维护「路由 name → 组件 name」的映射

否则即使 meta.keepAlive: true,组件也不会被缓存。

五、一个完整的多标签页 + 缓存示例

5.1 项目结构(Vue 3 + Vue Router 4)

src/
├── views/
│   ├── Home.vue
│   ├── UserList.vue
│   └── OrderList.vue
├── layout/
│   ├── Index.vue          # 主布局(含 Tab 栏 + 内容区)
│   └── TabBar.vue         # Tab 标签栏
├── store/
│   └── tabs.js            # Tab 状态(Vuex / Pinia)
└── router/
    └── index.js

5.2 路由配置

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/',
    component: () => import('@/layout/Index.vue'),
    redirect: '/home',
    children: [
      {
        path: 'home',
        name: 'Home',
        meta: { title: '首页', keepAlive: true, affix: true },
        component: () => import('@/views/Home.vue')
      },
      {
        path: 'user/list',
        name: 'UserList',
        meta: { title: '用户管理', keepAlive: true },
        component: () => import('@/views/user/UserList.vue')
      },
      {
        path: 'order/list',
        name: 'OrderList',
        meta: { title: '订单列表', keepAlive: true },
        component: () => import('@/views/order/OrderList.vue')
      }
    ]
  }
]

export default createRouter({
  history: createWebHistory(),
  routes
})

5.3 Tab 状态管理(Pinia 示例)

// store/tabs.js
import { defineStore } from 'pinia'

export const useTabsStore = defineStore('tabs', {
  state: () => ({
    visitedViews: [],   // 已访问的 Tab 列表
    cachedViews: []     // 需要缓存的组件 name 列表(给 keep-alive 用)
  }),

  actions: {
    addView(view) {
      // 避免重复
      if (this.visitedViews.some(v => v.path === view.path)) return

      this.visitedViews.push({
        name: view.name,
        path: view.path,
        title: view.meta?.title || '未命名'
      })

      // 需要缓存的加入 cachedViews
      if (view.meta?.keepAlive && !this.cachedViews.includes(view.name)) {
        this.cachedViews.push(view.name)
      }
    },

    removeView(view) {
      const index = this.visitedViews.findIndex(v => v.path === view.path)
      if (index > -1) {
        this.visitedViews.splice(index, 1)
        const cacheIndex = this.cachedViews.indexOf(view.name)
        if (cacheIndex > -1) {
          this.cachedViews.splice(cacheIndex, 1)
        }
      }
    }
  }
})

5.4 主布局(含 keep-alive)

<!-- layout/Index.vue -->
<template>
  <div class="layout">
    <TabBar :tabs="tabsStore.visitedViews" @close="handleCloseTab" />
    <main class="main-content">
      <router-view v-slot="{ Component, route }">
        <keep-alive :include="tabsStore.cachedViews">
          <component
            :is="Component"
            :key="route.fullPath"
          />
        </keep-alive>
      </router-view>
    </main>
  </div>
</template>

<script setup>
import { watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import TabBar from './TabBar.vue'
import { useTabsStore } from '@/store/tabs'

const route = useRoute()
const router = useRouter()
const tabsStore = useTabsStore()

// 路由变化时,往 Tab 里加一条
watch(
  () => route,
  (newRoute) => {
    tabsStore.addView(newRoute)
  },
  { immediate: true }
)

function handleCloseTab(tab) {
  tabsStore.removeView(tab)
  // 如果关闭的是当前页,跳转到最后一个 Tab
  if (route.path === tab.path) {
    const last = tabsStore.visitedViews[tabsStore.visitedViews.length - 1]
    router.push(last?.path || '/home')
  }
}
</script>

5.5 被缓存组件的 name 定义

<!-- views/UserList.vue -->
<template>
  <div class="user-list">
    <!-- 表格、搜索、分页等 -->
  </div>
</template>

<script>
export default {
  name: 'UserList',  // 必须和路由 name 一致,否则 include 不生效
  // ...
}
</script>

Home.vueOrderList.vue 同理,name 要和对应路由的 name 保持一致。

六、常见坑点

6.1 坑一:组件没定义 name,或 name 和 include 不一致

  • 现象:写了 keepAlive: true,但切换回来还是重新加载
  • 原因:include 匹配的是组件的 name,未定义或名字对不上,就不会被缓存
  • 解决:为需要缓存的组件写 name,并和路由 name(或你维护的 cachedViews 列表)一致

6.2 坑二:动态组件没有 key 或 key 不合适

  • 现象:同路由不同参数(如 /user/1/user/2)共用一个缓存
  • 原因:component 没有合适的 key,Vue 会复用同一个实例
  • 解决:用 route.fullPath 作为 key,让「路径+参数」不同时使用不同缓存
<component :is="Component" :key="route.fullPath" />

6.3 坑三:关闭 Tab 后缓存未清除

  • 现象:Tab 关了,但组件仍被 keep-alive 缓存,内存不释放
  • 原因:cachedViews 没有在关 Tab 时移除对应组件名
  • 解决:在 removeView 中同时从 cachedViews 移除该组件的 name

6.4 坑四:使用了 Vue Router 的 <router-view> 嵌套

  • 现象:子路由页面不缓存
  • 原因:嵌套路由下,缓存的是父级组件,子路由组件在父级内部
  • 解决:把 keep-alive 包在真正渲染子路由的 router-view 外层,并确保子组件也有正确的 name

七、生命周期:activated 和 deactivated

使用 keep-alive 后,组件不会被销毁,所以 mounted 只会执行一次。需要「每次显示时」做刷新时,用 activated;需要在「每次离开时」做清理时,用 deactivated

<script>
export default {
  name: 'UserList',
  activated() {
    // 每次从其他 Tab 切回来时触发
    this.fetchData()
  },
  deactivated() {
    // 每次切换到其他 Tab 时触发
    // 可做清理,如取消请求、清除定时器等
  }
}
</script>

八、小结

概念作用
keep-alive缓存组件实例,避免重复创建销毁
include指定要缓存的组件名(字符串/正则/数组)
exclude指定不缓存的组件名
meta.keepAlive在路由中标记该页面是否参与缓存
组件 namekeep-alive 通过它匹配 include/exclude,必须正确设置

多标签页场景的典型流程:

  1. 在路由 meta 里标记 keepAlive: true
  2. 用 Tab 逻辑维护 cachedViews(组件名列表)
  3. keep-alive :include="cachedViews" 控制缓存
  4. 给需要缓存的组件设置正确的 name
  5. 需要「每次进入时」刷新时,在 activated 里写逻辑

按这个思路实现,就能得到一个可用的多标签页 + 缓存方案,并避免大部分常见问题。如果你希望我帮你再细化某一块(比如 TabBar 关闭逻辑、权限和 affix 的交互),可以说一下具体场景,我可以再补一版更贴近你项目的示例。

🔍 本系列专栏导航

一、《路由与布局骨架篇:Vue Router 实战 | 动态路由、嵌套路由与多级菜单》

二、《路由与布局骨架篇:登录态与路由守卫 | token 校验、白名单、重定向》

三、《路由与布局骨架篇:多标签页(Tab)与缓存 | keep-alive、includeexclude、路由 meta》

四、《路由与布局骨架篇:布局系统 | 头部、侧边栏、内容区、面包屑的拆分与复用》

👉 跟着系列慢慢学,把技术功底扎扎实实地打牢~


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~