Skip to content

路由视图缓存与布局编排重构

这次整理的对象主要来自下面这些本地素材:

  • 11Vue学习/program/vue-sell-app/src/App.vue
  • 11Vue学习/vue2相关文档学习笔记/API/07内置组件.md
  • 11Vue学习/vue2相关文档学习笔记/组件/5动态组件/动态组件.html
  • 11Vue学习/vue2相关文档学习笔记/vue-router/2路由注册.md

和前面的案例一样,这一轮不直接修改独立 demo,而是把其中最值得复用的“路由出口、页面缓存和布局壳层”思路整理成一篇案例文档。

这类项目的难点通常不在某个页面,而在下面这条壳层链路会不会越来越乱:

  • 根应用负责什么
  • 路由出口负责什么
  • 页面布局负责什么
  • 哪些页面应该缓存
  • 路由切换后哪些状态应该保留
  • 页面激活与失活时副作用应该怎么处理

如果这条链路没有先收口,项目后面就很容易出现几个典型问题:

  • App.vue 越写越像超级容器
  • router-view 同时承担数据透传、布局切换和缓存控制
  • 所有页面一股脑进 keep-alive
  • 页面切回来状态错乱,或者缓存太多导致行为不可预期

这类场景真正的复杂度中心

路由与布局场景里,最容易被忽略的复杂度中心,不是“路由怎么注册”,而是“页面壳层如何组织”。

vue-sell-appApp.vue 为例,虽然结构看上去很简单:

  • 顶部头部区域
  • tab 导航区域
  • router-view 页面出口

但它实际上已经隐含了几个长期会放大的问题:

  • 根组件既负责远程数据获取,又负责全局布局编排
  • 公共数据直接从根组件透传给路由页面
  • keep-alive 直接挂在路由出口上,却没有显式缓存策略

页面一旦增多,就会继续膨胀成:

  • 哪些页面该缓存没人说得清
  • 哪些布局属于全局壳层没人说得清
  • 页面切换后要不要重拉数据没人说得清
  • 路由页面和布局组件的边界越来越模糊

所以这类重构的重点,不是先加更多页面,而是先把壳层、出口和缓存边界拆清楚。

推荐的重构边界

更适合长期维护的结构,通常会把这类场景拆成下面几层:

  • 应用壳层:负责全局导航、全局头部、主内容区域结构
  • 布局层:负责不同页面布局的切换和插槽装配
  • 路由出口层:负责渲染当前页面组件
  • 缓存策略层:负责哪些页面进入 keep-alive
  • 页面层:只负责页面自己的数据与交互

如果换成更具体的职责,大概可以这样理解:

  • AppShell:负责全局框架结构
  • AppLayoutResolver:负责根据路由 meta.layout 决定布局
  • RouteCacheBoundary:负责 KeepAlive 的 include / exclude 和 key 规则
  • PageGoods / PageRatings / PageSeller:只处理页面本身

这里最重要的一条原则是:

  • 布局不是页面自己拼出来的
  • 缓存不是页面自己偷偷决定的
  • 路由出口只负责渲染,不负责兜住所有全局职责

根组件不要长期充当“超级容器”

当前 App.vue 的方向并没有错,但它已经暴露出一个很典型的信号:根组件开始同时承担数据获取、公共 props 透传、布局编排和页面出口管理。

这在早期 demo 中很常见,但在真实项目中会迅速带来几个问题:

  • 根组件生命周期变得过重
  • 新增一个页面就要改根组件透传结构
  • 公共布局和页面数据边界越来越难分清
  • 页面是否缓存只能靠根组件临时决定

更稳的做法,是让根组件只负责应用级结构,而把数据、布局和缓存拆成更清晰的层。

ts
export interface AppShellSection {
  header: boolean
  nav: boolean
  footer: boolean
}

export interface AppRouteMeta {
  layout?: 'default' | 'blank' | 'dashboard'
  keepAlive?: boolean
  cacheKey?: string
}

这一步的本质,不是先写更多组件,而是先让“根应用到底负责什么”变成显式约定。

router-view 只做页面出口,不要顺手承担布局和缓存逻辑

很多旧项目会把 router-view 当作一个“什么都能塞”的位置:

  • 在旁边临时套一层布局
  • 再塞一层 keep-alive
  • 再顺手加几层条件渲染
  • 再用一堆 key 临时修问题

短期能跑,长期就会越来越难维护。

更稳的思路,是把页面出口和外围能力拆开:

vue
<template>
  <RouterView v-slot="{ Component, route }">
    <AppLayoutResolver :layout="route.meta.layout ?? 'default'">
      <RouteCacheBoundary :route="route">
        <component :is="Component" />
      </RouteCacheBoundary>
    </AppLayoutResolver>
  </RouterView>
</template>

在这套结构里:

  • RouterView 只负责给出当前页面组件
  • AppLayoutResolver 负责布局壳层
  • RouteCacheBoundary 负责缓存策略
  • 页面组件只处理自己的页面逻辑

这样每一层职责都能稳定下来。

页面缓存要靠显式策略,不要“全包进 keep-alive”

keep-alive 是非常有用的能力,但最容易被误用。

原始素材里已经能看到它的两个关键事实:

  • 被缓存的组件不会销毁,而是进入 activated / deactivated 语义
  • 缓存适合“需要保留页面现场”的场景,不适合无差别全开

更稳的做法,是把缓存策略设计成显式协议,而不是模板里临时包一下。

ts
import { computed } from 'vue'
import type { RouteLocationNormalizedLoaded } from 'vue-router'

export function useRouteCache(route: RouteLocationNormalizedLoaded) {
  const shouldCache = computed(() => Boolean(route.meta.keepAlive))
  const cacheKey = computed(() => {
    return (route.meta.cacheKey as string | undefined) ?? String(route.name ?? route.path)
  })

  return {
    shouldCache,
    cacheKey
  }
}

有了这层之后,缓存边界就可以围绕页面语义来讨论:

  • 列表页切回来是否要保留滚动位置
  • 详情页切回来是否要保留局部展开状态
  • 表单页切回来是否要保留未提交输入
  • 仪表盘页是否值得缓存,还是更应该每次重新拉数据

这比把所有页面一起缓存要稳得多。

activated / deactivated 不是 mounted / unmounted 的替代品

页面缓存一旦引入,最容易出问题的地方就是生命周期误判。

很多页面最开始是这样写的:

  • mounted 里请求数据
  • beforeUnmount 里清理副作用

但一旦页面进入 keep-alive,页面切走时不一定会销毁,切回来时也不一定会重新挂载。这时如果还按“挂载 / 卸载”思维写代码,就会出现:

  • 切回来数据不刷新
  • 事件监听没有按预期恢复
  • 页面副作用重复注册
  • 滚动位置和订阅状态异常

更稳的方式,是明确区分三类逻辑:

  • 首次进入页面需要初始化的逻辑
  • 页面再次激活时需要恢复的逻辑
  • 页面失活时需要暂停的逻辑
ts
import { onActivated, onDeactivated, onMounted } from 'vue'

export function usePageActivation(options: {
  onFirstEnter?: () => void
  onActivated?: () => void
  onDeactivated?: () => void
}) {
  onMounted(() => options.onFirstEnter?.())
  onActivated(() => options.onActivated?.())
  onDeactivated(() => options.onDeactivated?.())
}

这样页面缓存就会从“偶然有效”变成“可设计的运行时行为”。

布局切换要走路由协议,不要让页面自己包外壳

随着页面增多,一个很典型的问题是:

  • 登录页想隐藏导航
  • 工作台页想要侧边栏
  • 内容页想复用通栏头部
  • 某些弹窗页甚至希望脱离默认布局

如果这些布局变化都靠页面内部自己包壳层,最后一定会出现:

  • 页面模板里充满重复布局结构
  • 同一种布局在不同页面各写一套
  • 全局头部和页面级头部职责混在一起

更稳的做法,是把布局类型定义进路由 meta

ts
const routes = [
  {
    path: '/login',
    component: () => import('./pages/LoginPage.vue'),
    meta: { layout: 'blank' }
  },
  {
    path: '/goods',
    component: () => import('./pages/GoodsPage.vue'),
    meta: { layout: 'default', keepAlive: true }
  },
  {
    path: '/dashboard',
    component: () => import('./pages/DashboardPage.vue'),
    meta: { layout: 'dashboard' }
  }
]

这样以后布局就是显式协议:

  • 页面只声明自己需要哪种布局
  • 布局层统一解释协议
  • 根壳层不需要知道每个页面的内部结构

公共数据不要长期靠根组件透传给所有页面

vue-sell-appseller 数据从根组件拿到再透传给路由页面,这在 demo 阶段很常见,但如果项目继续扩大,根组件透传很容易演变成新的复杂度中心。

更稳的方向通常有两种:

  • 应用级共享数据进入专门的 composable 或 store
  • 页面级数据在页面内部声明式获取

判断规则可以很简单:

  • 所有页面都需要、生命周期跟整个应用一致的数据,适合放应用级状态层
  • 只属于某几个页面的数据,适合让页面自己声明依赖

一旦这层边界清晰,根组件就不再需要充当“全站 props 中转站”。

更适合现代 Vue 3 / Nuxt 的组织方式

如果把这类旧式路由壳层结构迁到现代项目,更推荐的目录边界通常是:

  • components/layout/AppShell.vue
  • components/layout/AppLayoutResolver.vue
  • components/router/RouteCacheBoundary.vue
  • composables/router/useRouteCache.ts
  • composables/router/usePageActivation.ts
  • pages/**/*.vue

在这套结构里:

  • 应用壳层只做应用级框架
  • 布局组件只做结构与插槽装配
  • 缓存边界只做缓存控制
  • 页面只做自己的数据与交互

这和前面案例里的统一方法完全一致:

  • 先找到复杂度中心
  • 先稳定协议和边界
  • 再拆组件与页面
  • 最后才优化模板细节

这类项目最值得先检查的 6 个问题

以后再遇到类似“顶部壳层 + tab 导航 + 路由页面 + keep-alive”的项目,可以先检查这几个问题:

  1. 根组件是否已经承担过多页面级职责
  2. 路由出口是否同时承担布局和缓存逻辑
  3. 页面缓存是否依赖显式 meta 或统一策略
  4. 页面是否正确区分 mountedactivated 语义
  5. 布局是否通过统一协议切换,而不是页面自己包壳
  6. 公共数据是否已经从根组件透传,走向明确共享边界

如果这 6 个问题答不清楚,就说明路由与布局层还没有真正收口。

这篇案例最后沉淀出的核心方法

这轮最重要的不是多包一层 keep-alive,而是沉淀出一条可复用的路由壳层重构思路:

  • 先把应用壳层和页面层分开
  • 再把布局切换和路由出口分开
  • 再把缓存策略从模板技巧升级成显式协议
  • 最后让页面只处理页面自己的状态与交互

这样以后面对后台工作台、内容平台、多 tab 系统、移动端多页壳层这些场景时,路由出口、页面缓存和布局编排都能落在同一套稳定框架里,而不会每个项目重新发散一遍。

共 20 个模块,1301 篇 Markdown 文档。