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>

关键点

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"> 就够了。

真实项目里怎么用

in4vue 目前按默认策略,pnpm build 的输出里能看到每个页面都是独立 chunk。


4. 滚动行为:切换页面时的滚动位置

场景

用户访问流程:

  1. 首页 → 滚动到第 3 屏的笔记卡片
  2. 点击进入详情页 → 详情页应该从顶部开始
  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 }
  },
})

三种情况

  1. 前进 / 后退:还原上次的滚动位置(savedPosition 自动带过来)
  2. 锚点跳转:滚到 #xxx 对应的元素(笔记里的目录跳转典型场景)
  3. 新路由:直接回顶

延迟滚动

有时候新页面数据还没加载完就触发滚动,会滚不准。用返回 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>

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:验证

  1. 首页搜索"vue"、滚到卡片中间
  2. 点进一篇笔记(应回到顶部)
  3. 浏览器后退(应恢复搜索词 + 滚动位置)

做完这一步,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 }
}

小练习

  1. 按第 7 节给 in4vue 实际加上 scrollBehavior + <KeepAlive>,手动跑一遍流程,感受体验差异
  2. 去掉 <KeepAlive>,重复步骤,对比"首页状态丢失"的糟糕感
  3. 思考:如果以后首页的笔记数据改成远程 API 实时拉,<KeepAlive> 是否还合适?(答:不一定——用户希望看最新数据时,缓存反而是坑。这时应在 onActivated 里决定要不要重拉)

延伸阅读