路由视图缓存与布局编排重构
这次整理的对象主要来自下面这些本地素材:
11Vue学习/program/vue-sell-app/src/App.vue11Vue学习/vue2相关文档学习笔记/API/07内置组件.md11Vue学习/vue2相关文档学习笔记/组件/5动态组件/动态组件.html11Vue学习/vue2相关文档学习笔记/vue-router/2路由注册.md
和前面的案例一样,这一轮不直接修改独立 demo,而是把其中最值得复用的“路由出口、页面缓存和布局壳层”思路整理成一篇案例文档。
这类项目的难点通常不在某个页面,而在下面这条壳层链路会不会越来越乱:
- 根应用负责什么
- 路由出口负责什么
- 页面布局负责什么
- 哪些页面应该缓存
- 路由切换后哪些状态应该保留
- 页面激活与失活时副作用应该怎么处理
如果这条链路没有先收口,项目后面就很容易出现几个典型问题:
- 根
App.vue越写越像超级容器 router-view同时承担数据透传、布局切换和缓存控制- 所有页面一股脑进
keep-alive - 页面切回来状态错乱,或者缓存太多导致行为不可预期
这类场景真正的复杂度中心
路由与布局场景里,最容易被忽略的复杂度中心,不是“路由怎么注册”,而是“页面壳层如何组织”。
以 vue-sell-app 的 App.vue 为例,虽然结构看上去很简单:
- 顶部头部区域
- tab 导航区域
router-view页面出口
但它实际上已经隐含了几个长期会放大的问题:
- 根组件既负责远程数据获取,又负责全局布局编排
- 公共数据直接从根组件透传给路由页面
keep-alive直接挂在路由出口上,却没有显式缓存策略
页面一旦增多,就会继续膨胀成:
- 哪些页面该缓存没人说得清
- 哪些布局属于全局壳层没人说得清
- 页面切换后要不要重拉数据没人说得清
- 路由页面和布局组件的边界越来越模糊
所以这类重构的重点,不是先加更多页面,而是先把壳层、出口和缓存边界拆清楚。
推荐的重构边界
更适合长期维护的结构,通常会把这类场景拆成下面几层:
- 应用壳层:负责全局导航、全局头部、主内容区域结构
- 布局层:负责不同页面布局的切换和插槽装配
- 路由出口层:负责渲染当前页面组件
- 缓存策略层:负责哪些页面进入
keep-alive - 页面层:只负责页面自己的数据与交互
如果换成更具体的职责,大概可以这样理解:
AppShell:负责全局框架结构AppLayoutResolver:负责根据路由meta.layout决定布局RouteCacheBoundary:负责KeepAlive的 include / exclude 和 key 规则PageGoods / PageRatings / PageSeller:只处理页面本身
这里最重要的一条原则是:
- 布局不是页面自己拼出来的
- 缓存不是页面自己偷偷决定的
- 路由出口只负责渲染,不负责兜住所有全局职责
根组件不要长期充当“超级容器”
当前 App.vue 的方向并没有错,但它已经暴露出一个很典型的信号:根组件开始同时承担数据获取、公共 props 透传、布局编排和页面出口管理。
这在早期 demo 中很常见,但在真实项目中会迅速带来几个问题:
- 根组件生命周期变得过重
- 新增一个页面就要改根组件透传结构
- 公共布局和页面数据边界越来越难分清
- 页面是否缓存只能靠根组件临时决定
更稳的做法,是让根组件只负责应用级结构,而把数据、布局和缓存拆成更清晰的层。
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 临时修问题
短期能跑,长期就会越来越难维护。
更稳的思路,是把页面出口和外围能力拆开:
<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语义 - 缓存适合“需要保留页面现场”的场景,不适合无差别全开
更稳的做法,是把缓存策略设计成显式协议,而不是模板里临时包一下。
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,页面切走时不一定会销毁,切回来时也不一定会重新挂载。这时如果还按“挂载 / 卸载”思维写代码,就会出现:
- 切回来数据不刷新
- 事件监听没有按预期恢复
- 页面副作用重复注册
- 滚动位置和订阅状态异常
更稳的方式,是明确区分三类逻辑:
- 首次进入页面需要初始化的逻辑
- 页面再次激活时需要恢复的逻辑
- 页面失活时需要暂停的逻辑
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:
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-app 的 seller 数据从根组件拿到再透传给路由页面,这在 demo 阶段很常见,但如果项目继续扩大,根组件透传很容易演变成新的复杂度中心。
更稳的方向通常有两种:
- 应用级共享数据进入专门的 composable 或 store
- 页面级数据在页面内部声明式获取
判断规则可以很简单:
- 所有页面都需要、生命周期跟整个应用一致的数据,适合放应用级状态层
- 只属于某几个页面的数据,适合让页面自己声明依赖
一旦这层边界清晰,根组件就不再需要充当“全站 props 中转站”。
更适合现代 Vue 3 / Nuxt 的组织方式
如果把这类旧式路由壳层结构迁到现代项目,更推荐的目录边界通常是:
components/layout/AppShell.vuecomponents/layout/AppLayoutResolver.vuecomponents/router/RouteCacheBoundary.vuecomposables/router/useRouteCache.tscomposables/router/usePageActivation.tspages/**/*.vue
在这套结构里:
- 应用壳层只做应用级框架
- 布局组件只做结构与插槽装配
- 缓存边界只做缓存控制
- 页面只做自己的数据与交互
这和前面案例里的统一方法完全一致:
- 先找到复杂度中心
- 先稳定协议和边界
- 再拆组件与页面
- 最后才优化模板细节
这类项目最值得先检查的 6 个问题
以后再遇到类似“顶部壳层 + tab 导航 + 路由页面 + keep-alive”的项目,可以先检查这几个问题:
- 根组件是否已经承担过多页面级职责
- 路由出口是否同时承担布局和缓存逻辑
- 页面缓存是否依赖显式
meta或统一策略 - 页面是否正确区分
mounted与activated语义 - 布局是否通过统一协议切换,而不是页面自己包壳
- 公共数据是否已经从根组件透传,走向明确共享边界
如果这 6 个问题答不清楚,就说明路由与布局层还没有真正收口。
这篇案例最后沉淀出的核心方法
这轮最重要的不是多包一层 keep-alive,而是沉淀出一条可复用的路由壳层重构思路:
- 先把应用壳层和页面层分开
- 再把布局切换和路由出口分开
- 再把缓存策略从模板技巧升级成显式协议
- 最后让页面只处理页面自己的状态与交互
这样以后面对后台工作台、内容平台、多 tab 系统、移动端多页壳层这些场景时,路由出口、页面缓存和布局编排都能落在同一套稳定框架里,而不会每个项目重新发散一遍。
