Vue Router 进阶:嵌套路由、懒加载、滚动行为
上一篇讲的是"能跑"的路由。这篇讲的是"像成品"的路由——多级布局、按需加载、切换页面时滚动位置不乱。
1. 嵌套路由:多级布局的秘密
问题场景
一个后台应用常见的结构:
┌──────────────────────────────┐
│ 顶栏(所有页面共用) │
├────────┬─────────────────────┤
│ 侧边栏 │ │
│(所有) │ 内容区(随路由变化) │
│ │ │
└────────┴─────────────────────┘
访问 /admin/users 和 /admin/orders,顶栏 + 侧边栏完全一样,只有中间内容不同。难道要在每个页面组件里都写一遍顶栏?
嵌套路由的解法
路由表也可以嵌套:
const routes = [
{
path: '/admin',
component: AdminLayout, // 外层:顶栏 + 侧边栏
children: [
{ path: 'users', component: UserList }, // 匹配 /admin/users
{ path: 'orders', component: OrderList }, // 匹配 /admin/orders
{ path: '', component: Dashboard }, // 匹配 /admin(默认子路由)
],
},
]
AdminLayout.vue 里留一个嵌套的 <RouterView />:
<!-- AdminLayout.vue -->
<template>
<div class="admin">
<TopBar />
<div class="body">
<SideBar />
<main>
<RouterView /> <!-- 子路由的内容渲染到这里 -->
</main>
</div>
</div>
</template>
关键点:
- 顶层
<RouterView />渲染AdminLayout AdminLayout内部的<RouterView />渲染子路由(UserList/OrderList)- 顶栏、侧边栏只挂载一次,切换子路由时不重建
Java 对照
Thymeleaf / JSP 的模板继承 + <fragment> 插槽:
<!-- base.html -->
<header>顶栏</header>
<aside>侧边栏</aside>
<main th:replace="${template}"></main>
每个页面只定义 <main> 的内容,外壳复用 base.html。嵌套路由就是这套机制的 SPA 版本。
children 的 path 规则
{
path: '/admin',
children: [
{ path: 'users' }, // 最终路径:/admin/users(不以 / 开头 → 相对父路径)
{ path: '/users' }, // ❌ 最终路径:/users(以 / 开头 → 从根开始,违反直觉)
{ path: '' }, // 最终路径:/admin(空字符串 → 默认子路由)
],
}
规则:子路由的 path 不要加前导 /,让它自动拼接到父路径后面。
in4vue 目前的层级
当前 App.vue + DefaultLayout.vue 已经是"布局 + 内容"的结构,但没用嵌套路由——DefaultLayout 是直接写死在 App.vue 里的。这种方式两个路由都共用一个布局时够用;如果以后需要"管理后台"或"无布局的登录页"不共享布局,就改成嵌套路由会更合理。
2. 命名视图:同一级多个 <RouterView>
偶尔遇到这种布局:一个页面有两个独立的内容区(比如"主内容 + 右侧栏"都根据路由变化)。
<template>
<RouterView /> <!-- 默认视图 -->
<RouterView name="sidebar" /> <!-- 命名视图 -->
</template>
路由配置用 components(复数):
{
path: '/',
components: {
default: HomePage,
sidebar: HomeSidebar,
},
}
使用频率低,一般见于复杂后台。普通业务用单一 <RouterView /> 就够。
3. 路由懒加载的几种写法
已经在第二阶段《异步组件与 Suspense》里讲过原理。这里只补几个进阶技巧。
写法 1:基础懒加载(默认)
{ path: '/admin', component: () => import('@/pages/Admin.vue') }
Vite 自动按组件切 chunk。每个路由一个独立 chunk。
写法 2:webpackChunkName / Vite magic comment 分组
{
path: '/admin/users',
component: () => import(
/* webpackChunkName: "admin" */
'@/pages/admin/Users.vue'
),
},
{
path: '/admin/orders',
component: () => import(
/* webpackChunkName: "admin" */
'@/pages/admin/Orders.vue'
),
},
两个路由会打到同一个 chunk(名为 admin)。适合"访问 A 页面的用户大概率也会访问 B"的场景(提前加载好)。
Vite 的 magic comment 叫 /* vite-chunk: */(新版支持),效果相同。
写法 3:预加载(访问前就加载)
// 鼠标 hover 到链接时就开始下载
<RouterLink to="/heavy" @mouseenter="() => import('@/pages/Heavy.vue')">
重的页面
</RouterLink>
或者用 Vue Router 的 prefetch 插件。不过这个场景前端路由直接用 <link rel="prefetch"> 就够了。
真实项目里怎么用
- 默认策略:每个路由单独 chunk(就是 Vite 默认行为)
- 除非几个小页面合起来也 <10KB,才考虑合并
- 或者紧耦合的页面组(比如 AI 聊天页内部有多个 tab,但都是 AI 相关),可以同一 chunk
in4vue 目前按默认策略,pnpm build 的输出里能看到每个页面都是独立 chunk。
4. 滚动行为:切换页面时的滚动位置
场景
用户访问流程:
- 首页 → 滚动到第 3 屏的笔记卡片
- 点击进入详情页 → 详情页应该从顶部开始
- 点击浏览器"后退" → 首页应该回到第 3 屏
浏览器原生:多页应用(MPA)自动支持——每个页面的滚动位置浏览器会记住。
SPA:不会。router.push() 只是换组件,滚动条留在原位置。所以默认体验很糟:从长页面跳到新页面,发现已经在底部。
解法:scrollBehavior
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
// savedPosition:浏览器"后退 / 前进"时带的上次位置
if (savedPosition) {
return savedPosition
}
// 有 hash(比如 /notes/xxx#section2):滚到锚点
if (to.hash) {
return { el: to.hash, behavior: 'smooth' }
}
// 默认:回到顶部
return { top: 0 }
},
})
三种情况:
- 前进 / 后退:还原上次的滚动位置(
savedPosition自动带过来) - 锚点跳转:滚到
#xxx对应的元素(笔记里的目录跳转典型场景) - 新路由:直接回顶
延迟滚动
有时候新页面数据还没加载完就触发滚动,会滚不准。用返回 Promise 延迟:
scrollBehavior(to, from, savedPosition) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(savedPosition ?? { top: 0 })
}, 300) // 等 300ms 让页面数据加载
})
}
更健壮的方案:在页面组件里用 nextTick 后手动滚动,或监听"数据就绪事件"后再滚。
in4vue 的现状
src/router/index.ts 里还没配 scrollBehavior。跑一下就能验证:"从首页底部点进笔记 → 详情页也停在底部"这个坑。下一步就是把它加上:
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
scrollBehavior(_to, _from, savedPosition) {
return savedPosition ?? { top: 0 }
},
})
这一行代码就能让体验从"能用"升级到"舒服"。
5. 路由过渡动画
切换路由时淡入淡出:
<!-- App.vue -->
<template>
<RouterView v-slot="{ Component }">
<Transition name="fade" mode="out-in">
<component :is="Component" />
</Transition>
</RouterView>
</template>
<style>
.fade-enter-active, .fade-leave-active { transition: opacity .2s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
<Transition> 是 Vue 内置组件,后面《CSS 过渡与动画》专门讲。这里只先知道"可以这么做"。
6. <KeepAlive>:缓存页面组件
在 Vue 里切换路由默认会销毁旧组件、创建新组件。有些页面希望"切走时保留状态":
<RouterView v-slot="{ Component }">
<KeepAlive>
<component :is="Component" />
</KeepAlive>
</RouterView>
效果:从首页切到详情页再切回来,首页的滚动位置、搜索框内容、筛选选项都还在——因为组件实例没被销毁。
配合生命周期:
<script setup>
import { onActivated, onDeactivated } from 'vue'
onActivated(() => {
// 被缓存的组件"激活"(从后台回到前台)时触发
console.log('回到首页了')
})
onDeactivated(() => {
// 被缓存的组件"停用"(切走)时触发
})
</script>
坑:
- 所有缓存的组件一直占内存,不适合"有几十个页面"的后台应用
- 用
:include/:exclude属性限定哪些组件缓存:<KeepAlive :include="['HomePage']">
in4vue 的场景:首页有搜索和筛选状态,用户从详情页返回时希望保留 → 值得加 <KeepAlive>。详情页切走可以不缓存。
7. 实战:给 in4vue 加"两件套"
目标:加上 scrollBehavior + <KeepAlive>(只缓存首页),体验变化。
步骤 1:router 配 scrollBehavior
// src/router/index.ts
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
scrollBehavior(to, _from, savedPosition) {
if (savedPosition) return savedPosition
if (to.hash) return { el: to.hash, behavior: 'smooth' }
return { top: 0 }
},
})
步骤 2:App.vue 里包 KeepAlive
<template>
<DefaultLayout>
<RouterView v-slot="{ Component }">
<KeepAlive :include="['Home']">
<component :is="Component" />
</KeepAlive>
</RouterView>
</DefaultLayout>
</template>
注意::include 里写的是组件的 name,不是路由的 name。所以 HomePage.vue 里要加:
<script lang="ts">
// 选项式声明组件名(<script setup> 本身没法声明)
export default { name: 'Home' }
</script>
<script setup lang="ts">
// 正常的 setup 代码
</script>
步骤 3:验证
- 首页搜索"vue"、滚到卡片中间
- 点进一篇笔记(应回到顶部)
- 浏览器后退(应恢复搜索词 + 滚动位置)
做完这一步,in4vue 的路由体验就从"能跑"升级到"成品级"。
8. 速查表
// 嵌套路由
{
path: '/admin',
component: AdminLayout,
children: [
{ path: '', component: Dashboard }, // 默认
{ path: 'users', component: UserList }, // /admin/users
],
}
// 路由懒加载
{ component: () => import('./X.vue') }
// 滚动行为
createRouter({
scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition
if (to.hash) return { el: to.hash }
return { top: 0 }
},
})
// 缓存页面组件
<RouterView v-slot="{ Component }">
<KeepAlive :include="['Home']">
<component :is="Component" />
</KeepAlive>
</RouterView>
9. 新手常见误区
误区 1:子路由 path 前加斜杠
// ❌
children: [{ path: '/users', component: UserList }] // 结果变成 /users,不是 /admin/users
// ✅
children: [{ path: 'users', component: UserList }]
误区 2:KeepAlive 缓存了错误的组件
:include 按组件 name匹配,不是路由 name。<script setup> 的组件默认没有 name,需要手写 <script> 块导出。或者用 unplugin-vue-router 自动生成。
误区 3:忘了 savedPosition
// ❌ 永远回顶,浏览器后退不还原位置
scrollBehavior() {
return { top: 0 }
}
// ✅
scrollBehavior(_to, _from, savedPosition) {
return savedPosition ?? { top: 0 }
}
小练习
- 按第 7 节给 in4vue 实际加上
scrollBehavior+<KeepAlive>,手动跑一遍流程,感受体验差异 - 去掉
<KeepAlive>,重复步骤,对比"首页状态丢失"的糟糕感 - 思考:如果以后首页的笔记数据改成远程 API 实时拉,
<KeepAlive>是否还合适?(答:不一定——用户希望看最新数据时,缓存反而是坑。这时应在onActivated里决定要不要重拉)