---
url: /courses/web-frontend-framework/router-layout-access/index.md
---
# 第4章：用路由组织页面与访问入口

第 3 章的任务板只有一个页面。现在“校园微商城管理端”要增加登录页、工作台、商品列表、商品详情和个人中心。如果继续用多个布尔变量决定显示哪一块，刷新页面后状态会丢失，浏览器返回键不好用，详情地址也无法直接复制给别人。

路由不是单纯“做菜单”。它要解决三个问题：**当前 URL 应该显示哪个页面，这个页面应该放进哪个公共布局，进入页面前要不要先检查登录状态。**

::: tip 项目目标
从单页任务板搭出管理端路由骨架：公共登录页、后台嵌套布局、商品动态详情、404 页面和登录守卫；同时知道前端守卫只能改善页面入口体验，不能代替后端授权。
:::

::: tip 学习说明
本章依次学习路由接入、动态路由、嵌套路由、导航守卫和部署边界，并通过章末任务完成综合应用。
:::

::: tip 配套资源
本章示例工程位于 `resources/ch04/classroom-demo/`，章末任务起始工程位于 `resources/ch04/after-class-starter/`。进入对应项目目录后，先执行 `npm install`，再执行 `npm run dev`。

课堂工程是“校园微商城管理端”（`/admin`），提供截至 4.5 的演示代码，包含原地址回跳示范，但不包含当堂练习答案。按 4.2—4.5 逐步实操时，用各节代码替换对应文件；4.4 起商品路径从 `/products/*` 调整为 `/admin/products/*`。新增关于页、未知商品提示、个人中心、已登录回跳与退出按钮均需自行完成。

课后起始工程和参考实现使用 4.10 要求的“校园服务后台”（`/service`），用于迁移同一套路由知识，业务名称和页面文件无需改成商品管理。
:::

::: tip 🎯 学习梯度指引（分层通关）

* **核心必学（保底通关 · 管理端路由骨架）**：掌握 4.1、4.2（路由最小接入）、4.3（动态参数与 props 解耦）、4.4（嵌套路由与公共侧栏布局及 404 兜底）、4.5（基础未登录守卫拦截）。能够独立搭建中后台管理系统的“左侧固定侧栏 + 右侧内容切换”基础路由架构，并实现基础访问拦截。
* **进阶选学（🌟 自主拓展 · 权限闭环与部署视野）**：4.5（精准原路回跳 `redirect` 与已登录防重复进登录页）、4.6（History 模式与 Hash 模式对比及生产环境 SPA 服务器回退原理）。供学有余力或有进阶架构需求的学生自主选学探索，不作为基础达标强制考核。
  :::

## 4.1 本章任务清单

本章沿着“先让 URL 能切换页面，再搭后台布局，最后加登录入口”的顺序推进。每完成一个知识点都要立即运行，避免一次改太多文件后不知道错在哪里。

| 阶段 | 本章要做什么 | 完成后应该看到 |
| --- | --- | --- |
| 路由最小接入 | 注册 router，创建首页和商品列表页 | 点击导航后地址变化，页面不整页刷新 |
| 商品动态详情 | 配置 `/products/:id`，使用 props 接收商品 id | 访问 `/products/101` 时显示商品 ID 101 |
| 后台嵌套布局 | 创建后台父路由、公共侧栏和子路由出口 | `/admin/*` 共用侧栏，只替换内容区 |
| 登录守卫 | 用 `meta` 标记后台页面，用 `beforeEach` 检查登录 | 未登录先去登录页，登录后回到原地址 |
| 404 与刷新检查 | 添加兜底路由，检查 History 模式 | 未知地址显示 404，能解释深层地址刷新条件 |

### 开工顺序

:::: steps

1. 在 `resources/ch04/classroom-demo/` 中确认项目可以正常启动。
2. 先完成首页与商品列表的最小路由，不要一开始就加入守卫。
3. 接入商品动态详情，再把商品页面放进后台公共布局。
4. 加入登录页、`meta` 和 `beforeEach`，验证登录前后的跳转。
5. 最后测试未知地址、浏览器返回键和深层地址刷新。
   ::::

::: tip 完成后应该是什么样
项目至少包含登录页、后台工作台、商品列表、商品详情、个人中心和 404 页面。地址栏能说明当前位于哪个页面；后台页面共用一套布局；未登录访问后台时先进入登录页，登录后回到原目标地址。
:::

## 4.2 知识点一：路由最小接入

**学习目标**

掌握 Vue Router 的最小接入流程，能够把 URL、路由记录、页面组件和 `RouterView` 对应起来，理解单页应用路由驱动视图的底层机制。

**语法/概念**

### 单页应用（SPA）与传统多页应用对比

在传统的多页应用（MPA）中，每次点击导航链接，浏览器都会向服务器发送全新的页面请求，导致整页闪白重新加载、脚本与样式重复解析、页面临时状态丢失。

单页应用（SPA, Single Page Application）从根本上改变了这种体验：整个应用只在初次进入时加载一次宿主 HTML 文件，后续所有的“页面切换”完全由前端路由接管——通过监听浏览器地址栏的 URL 变化，在客户端动态销毁旧页面组件并挂载新页面组件，实现零闪白、无缝顺滑的浏览体验。

### 核心流向心智模型

Vue Router 从捕获 URL 到最终呈现页面的完整链路如下：

```text
浏览器地址栏 URL 变化
       ↓
routes 路由映射表逐项查找匹配
       ↓
命中规则并获取对应 component 页面组件
       ↓
动态挂载并渲染到 <RouterView /> 页面插座中
```

### 路由系统四大核心对象职责拆解

路由系统的运转依赖四大核心对象的协同配合：

| 核心对象 | 角色定位 | 核心职责与工作机制 | 关键注意事项 |
| --- | --- | --- | --- |
| `createRouter` | 调度中枢工厂 | 接收历史模式与路由表配置，实例化全局路由管理器 `router`，负责全局导航控制与监听 | 全局唯一，通过 `app.use(router)` 挂载到应用根实例 |
| `routes` | 路由映射规则表 | 由路由记录对象组成的数组，建立 URL 路径与具体页面组件的一对一匹配规则 | 数组顺序影响匹配优先级，未声明的路径将无法被匹配 |
| `<RouterLink>` | 声明式导航链接 | 渲染为原生的 `<a>` 标签，但会自动拦截点击行为并阻止页面刷新，通过浏览器历史记录 API 改变 URL | 替代传统 `<a href>`，当前激活链接会自动添加 `.router-link-active` 类名 |
| `<RouterView>` | 动态页面插座 | 充当占位容器（Outlet），根据当前匹配的路由规则，将对应的页面组件动态渲染在此处 | 页面组件的渲染入口，若页面缺失该标签，无论 URL 如何变化均无法展示页面内容 |

### 核心配置代码骨架与属性剖析

```js
// 导入创建路由实例与 Web History 模式的核心函数
import { createRouter, createWebHistory } from 'vue-router'
// 导入实际承担页面展示职责的 Vue 单文件组件
import HomeView from './views/HomeView.vue'

// 1. 定义路由映射规则表
const routes = [
  {
    path: '/',               // 浏览器访问的 URL 路径
    name: 'home',            // 路由专属唯一名称（命名路由，解耦路径维护）
    component: HomeView,     // URL 命中时负责渲染挂载的页面组件
  },
]

// 2. 创建并导出路由器调度实例
const router = createRouter({
  history: createWebHistory(), // 使用 HTML5 Web History 模式（自然路径，无 # 号）
  routes,                      // 注册上方声明的路由映射表
})

export default router
```

### 插件注册顺序与执行时机

在应用入口 `main.js` 中，路由插件必须在应用挂载前完成注册：

```js
createApp(App)
  .use(router)        // 必须先注册路由插件，注入全局组件与监听
  .mount('#app')      // 再将 Vue 根组件挂载到 DOM 容器中
```

* **注册机制**：`app.use(router)` 会向 Vue 应用根实例中注入全局属性（如 `$router`、`$route`）、注册全局可用组件（`<RouterLink>`、`<RouterView>`）并启动全局 URL 监听。
* **颠倒后果**：若在 `.mount('#app')` 之后才调用 `.use(router)`，Vue 在首次解析 `App.vue` 模板时将无法识别 `<RouterView />`，导致首屏直接白屏。

播放下面的流程，观察 URL、路由记录、页面组件和出口的先后关系。

**课堂演示**

1. **步骤 1：启动课堂演示项目。**

   在终端执行：

   ```bash
   cd docs/courses/web-frontend-framework/resources/ch04/classroom-demo
   npm install
   npm run dev
   ```

   运行后，终端应显示本地访问地址。浏览器能打开页面，就说明项目环境正常。

2. **步骤 2：创建最小路由所需文件。**

   保留 `src/main.js` 和 `src/App.vue`，创建 `src/router.js`、`src/views/HomeView.vue`、`src/views/ProductListView.vue`。文件创建后，编辑器中应看到两个页面组件和一个路由文件。

3. **步骤 3：注册 router 并写出两个页面。**

   Vue 应用注册插件使用的完整方法是 **app.use**。先观察 `.use(router)` 与 `.mount('#app')` 的顺序，再解锁代码。

   保存后，点击“首页”和“商品管理”，地址栏应在 `/` 与 `/products` 之间变化，内容区也应切换，但浏览器不应整页闪白刷新。

4. **步骤 4：用地址栏直接验证。**

   分别把 `/` 和 `/products` 粘贴到地址栏访问。两个地址都应显示对应页面；浏览器后退按钮也应能返回上一个页面。

::: warning 常见错误

* 页面完全空白：先检查 `main.js` 是否在 `.mount()` 前调用 `.use(router)`。
* 地址变化但内容不变：检查 `App.vue` 是否有 `<RouterView />`。
* 导航整页刷新：检查是否误用了普通 `<a href>`，站内导航应使用 `RouterLink`。
  :::

**当堂练习（分层双轨制）**

* **【必做任务（基础通关）】**：新增 `src/views/AboutView.vue`，页面显示“关于管理端”；在 `src/router.js` 中增加 `/about` 路由并在 `App.vue` 导航栏中添加“关于”链接。保存后点击链接应进入 `/about`，浏览器后退能回到原页面。只使用本节学过的路由记录、`RouterLink` 和 `RouterView`。
* **【选做挑战（🌟 自主拓展）】**：在 `App.vue` 的样式中利用 `.router-link-active` 类名定制高亮样式（例如添加下划线或加粗变色反馈），使当前所在页面的导航链接呈现醒目的激活状态。

## 4.3 知识点二：动态路由

**学习目标**

掌握动态参数、命名路由和 `props: true`，能够让商品详情页从 URL 接收商品 id，理解路由参数与组件解耦的设计思想。

**语法/概念**

### 动态路由解决的业务痛点

在真实的电商系统中，商品数量成千上万。如果使用静态路由，就必须在路由表中手动罗列 `/products/101`、`/products/102`、`/products/103`……这会导致路由配置文件急剧膨胀，且无法应对动态上架的新商品。

**动态路由（Dynamic Route Matching）**通过在路径中声明**参数占位符**，使得一条通用规则能够匹配无限量的同构业务资源。

### 核心配置代码骨架与四项核心属性深度拆解

动态路由的核心配置对象结构如下：

```js
{
  path: '/products/:id',         // 1. 动态路径：冒号后为参数占位符
  name: 'product-detail',        // 2. 命名路由：全局唯一标识，解耦路径与跳转
  component: ProductDetailView,  // 3. 页面组件：命中规则时挂载渲染的目标组件
  props: true,                   // 4. 参数解耦：自动将路由参数注入为组件 props
}
```

这四个配置属性各自承担着关键的架构职责：

| 配置属性 | 属性类型 | 核心作用与工作机制 | 教学要点与避坑说明 |
| --- | --- | --- | --- |
| `path` | `string` | **动态路径规则声明**。使用冒号（`:`）定义动态参数占位符。当用户访问 `/products/101` 时，URL 中对应位置的 `101` 会被自动捕获并存入路由对象的 `params.id` 中 | 冒号是必不可少的占位标识；提取出来的参数值始终为 `string` 字符串类型 |
| `name` | `string` | **命名路由标识**。为该路由赋予一个唯一的语义化代号。后续在做页面跳转时，直接通过 `name` 指向该页面，而无需在多处手写硬编码 URL 路径 | 当后期将路径改为 `/goods/:id` 时，只需修改配置中的 `path` 一处，全局所有使用 `name` 的跳转代码无需修改 |
| `component` | `Component` | **目标展示组件**。当地址栏 URL 与 `path` 规则匹配成功时，将该单文件组件实例化并动态渲染到外层的 `<RouterView />` 出口中 | 必须确保对应组件已正确导入（import） |
| `props` | `boolean` | **路由参数解耦核心开关**。设置为 `true` 时，Vue Router 会将捕获到的 `route.params` 键值对自动作为组件的 `props` 传入 | 极力推荐的工业级实践，避免组件内部直接依赖 `useRoute()`，大幅提升组件的复用性与独立可测性 |

### 关键机制深度解析：为什么极力推荐 `props: true` 参数解耦？

#### 模式对比：强耦合模式 vs 属性解耦模式

* **未开启 `props: true`（强耦合模式）**：
  组件内部必须深度依赖路由上下文：
  ```vue
  <script setup>
  import { useRoute } from 'vue-router'
  const route = useRoute()
  const productId = route.params.id // 强依赖全局 route 实例
  </script>
  ```
  **弊端**：组件与 Vue Router 形成强绑定。若日后该详情组件需要作为抽屉弹窗在其他页面直接使用（通过 `<ProductDetailView :id="selectedId" />`），或者编写脱离路由环境的单元测试，组件将因缺失路由上下文而报错崩溃。

* **开启 `props: true`（解耦模式 · 推荐实践）**：
  开启后，路由参数直接转化为标准 Vue Props 输入：
  ```vue
  <script setup>
  // 组件只关心外界传入的属性，完全不感知路由的存在
  defineProps({
    id: {
      type: String,
      required: true,
    },
  })
  </script>
  ```
  **优势**：组件退化为纯粹的“数据驱动型展示组件”，既可通过路由访问，也可作为常规子组件灵活复用，职责边界清晰。

### 命名路由跳转的最佳实践

从列表进入详情页时，推荐使用“命名路由 + params 对象”：

```vue
<!-- 推荐：结构自解释、参数类型清晰、路径重构零成本 -->
<RouterLink
  :to="{
    name: 'product-detail',
    params: { id: product.id },
  }"
>
  查看详情
</RouterLink>
```

* **相比手写模板字符串的优势**：若写成 `:to="'/products/' + product.id"`，一旦路径变动（如改为 `/goods/:id`），所有分散在各处的拼接代码都必须全局查找并替换，且极易因斜杠漏写而引发 404；使用对象语法由 Vue Router 底层自动拼接，安全健壮。

**课堂演示**

1. **步骤 1：创建商品详情组件。**

   在课堂演示项目中新建 `src/views/ProductDetailView.vue`。创建后浏览器暂时不会显示详情页，因为还没有动态路由记录。

2. **步骤 2：配置动态路由并从列表传入 id。**

   把相关文件改成下面这样：

   ::: code-tree title="商品动态详情" entry="src/router.js" height="620px"

   ```js title="src/router.js" :active
   import { createRouter, createWebHistory } from 'vue-router'
   import HomeView from './views/HomeView.vue'
   import ProductDetailView from './views/ProductDetailView.vue'
   import ProductListView from './views/ProductListView.vue'

   export default createRouter({
     history: createWebHistory(),
     routes: [
       {
         path: '/',
         name: 'home',
         component: HomeView,
       },
       {
         path: '/products',
         name: 'product-list',
         component: ProductListView,
       },
       {
         // :id 为动态占位符，匹配所有 /products/xxx 格式的地址
         path: '/products/:id',
         name: 'product-detail',
         component: ProductDetailView,
         // 开启解耦模式：将 route.params.id 作为 prop 自动传递给 ProductDetailView
         props: true,
       },
     ],
   })
   ```

   ```vue title="src/views/ProductListView.vue"
   <script setup>
   // 模拟商品数据列表
   const products = [
     { id: 101, name: '机械键盘' },
     { id: 102, name: '无线鼠标' },
   ]
   </script>

   <template>
     <section>
       <h1>商品列表</h1>
       <ul>
         <li v-for="product in products" :key="product.id">
           <span>{{ product.name }}</span>
           <!-- 声明式命名路由跳转：将商品唯一 id 作为动态参数 params 传入 -->
           <RouterLink
             :to="{
               name: 'product-detail',
               params: { id: product.id },
             }"
           >
             查看详情
           </RouterLink>
         </li>
       </ul>
     </section>
   </template>
   ```

   ```vue title="src/views/ProductDetailView.vue"
   <script setup>
   // 得益于路由配置中的 props: true，组件无需引入 useRoute，直接通过 defineProps 声明接收
   defineProps({
     id: {
       type: String, // 动态路由参数在 URL 中默认为字符串类型
       required: true,
     },
   })
   </script>

   <template>
     <section>
       <h1>商品详情</h1>
       <!-- 模板直接渲染从路由参数解耦传入的 id -->
       <p>当前商品 ID：{{ id }}</p>
       <!-- 返回列表页时同样使用命名路由，避免路径硬编码 -->
       <RouterLink :to="{ name: 'product-list' }">
         返回商品列表
       </RouterLink>
     </section>
   </template>
   ```

   :::

   保存后，商品列表应显示两个“查看详情”链接。点击“机械键盘”，地址应变成 `/products/101`，详情页应显示“当前商品 ID：101”。

3. **步骤 3：直接输入详情地址。**

   在地址栏输入 `/products/102` 并刷新。页面仍应显示商品 ID 102，说明商品身份保存在 URL 中，不依赖刚才从哪个页面跳过来。

4. **步骤 4：检查参数名。**

   临时把组件 prop 的 `id` 改成 `productId`，页面会得不到原来的值，因为路由占位符仍叫 `id`。恢复名称后，详情应重新显示。

::: warning 动态参数只表示页面身份
本章不发送接口请求。详情页只显示 URL 中的 id，不能因为页面出现了 id 就认为商品数据已经加载成功。
:::

**当堂练习（分层双轨制）**

* **【必做任务（基础通关）】**：课堂案例只有商品 `101` 和 `102`。修改 `ProductDetailView.vue`：访问这两个 id 时正常显示详情；访问 `/products/999` 等未知 id 时显示“商品不存在”，不能出现空白页。只判断当前 `id` 是否在已知 id 列表中，不发接口请求。
* **【选做挑战（🌟 自主拓展）】**：在商品不存在时，额外提供一个“返回商品列表”的命名路由跳转链接（使用 `<RouterLink :to="{ name: 'product-list' }">`），确保用户在误入未知商品时有明确的返回途径。

## 4.4 知识点三：嵌套路由与公共布局

**学习目标**

掌握父子路由、嵌套 `RouterView`、子路由相对路径规则与 404 兜底路由，能够让后台页面共用侧栏骨架并在局部内容区顺畅切换。

**语法/概念**

### 后台管理系统布局痛点与嵌套路由设计思想

在典型的中后台管理系统中，页面的外部骨架（左侧导航菜单、顶部面包屑与用户信息）在绝大多数页面中是固定常驻的，只有右侧主体内容区域需要随业务模块切换。

如果采用扁平的普通路由配置，每个页面组件都必须在模板里手动编写一份侧栏代码。这不仅会导致海量模板代码冗余，而且每次路由切换时都会触发整套侧栏的重新挂载与销毁，导致侧栏滚动位置、折叠状态丢失，界面出现明显抖动。

\*\*嵌套路由（Nested Routes）\*\*通过“父路由承载公共布局、子路由挂载局部插座”的树状分层设计，完美实现外层布局骨架持久常驻、内层业务模块无感切换。

### 核心配置代码骨架与关键属性深度拆解

```js
{
  path: '/admin',                    // 1. 父级路径：管理端模块统一前缀
  component: AdminLayout,            // 2. 布局组件：包含公共侧栏与子路由插座
  redirect: { name: 'dashboard' },   // 3. 访问重定向：直接访问 /admin 时自动跳转到默认子页面
  children: [                        // 4. 嵌套子路由表：声明挂载在该布局内的所有子页面
    {
      path: 'dashboard',             // 核心规则：相对路径，绝无前导斜杠 /
      name: 'dashboard',             // 子路由唯一标识
      component: DashboardView,      // 挂载到 AdminLayout 内部插座的页面组件
    },
    {
      path: 'products',              // 浏览器完整访问路径拼接为：/admin/products
      name: 'product-list',
      component: ProductListView,
    },
    {
      path: 'products/:id',          // 嵌套子级同样支持动态参数，完整路径为：/admin/products/:id
      name: 'product-detail',
      component: ProductDetailView,
      props: true,                   // 动态参数依然支持解耦传入
    },
  ],
}
```

配置属性深度拆解：

| 配置属性 | 属性类型 | 核心作用与工作机制 | 教学要点与避坑说明 |
| --- | --- | --- | --- |
| `path: '/admin'` | `string` | **父级模块根路径**。作为所有子路由的统一命名空间与前缀 | 顶级路由必须以斜杠 `/` 开头，声明基础访问入口 |
| `component: AdminLayout` | `Component` | **公共布局外壳组件**。渲染固定常驻的侧栏、顶栏以及供子页面渲染的局部 `<RouterView />` | 必须在模板中留有子路由插座，否则子路由将无处展示 |
| `redirect` | `RouteLocation` | **默认路由重定向**。当用户直接访问 `/admin` 时，由路由器自动重写 URL 并引导至工作台 | 避免用户访问父路径时看到空荡荡的未激活工作区 |
| `children` | `RouteRecord[]` | **嵌套子路由数组**。子路由规则由父级统一纳管，路径自动完成层级拼接 | 子路由的 `path` 必须严格遵循相对路径规范 |

### 核心机制深度解析：双层 `<RouterView />` 插座层级模型

嵌套路由的本质是“插座的多级套嵌”，整个系统包含两个层级的出口：

```text
访问 URL: /admin/dashboard
   │
   ├─ 第一级（顶层出口 · 位于 App.vue）:
   │  匹配到 /admin 根规则 → 渲染 AdminLayout.vue 布局组件
   │
   └─ 第二级（局部出口 · 位于 AdminLayout.vue 的 <main> 中）:
      匹配到 children 中的 dashboard → 渲染 DashboardView.vue 到布局插座内部
```

* **顶层插座（`App.vue`）**：负责决定整页的宏观版式。例如访问 `/login` 时渲染全屏独立的登录页面，访问 `/admin/*` 时渲染带有完整后台骨架的 `AdminLayout`。
* **内部子插座（`AdminLayout.vue`）**：负责管理端内部各模块的局部切换。切换菜单时，外部的 `AdminLayout`（以及内部的侧栏组件）完全不受影响，不发生销毁重建。

### 关键避坑指南：前导斜杠 `/` 的致命陷阱

这是初学者配置嵌套路由时最高频的语法错误：

* **正确写法（相对路径）**：`path: 'dashboard'` 或 `path: 'products'`。Vue Router 会自动将其与父路径拼接为 `/admin/dashboard` 与 `/admin/products`。
* **错误写法（误加前导斜杠）**：`path: '/dashboard'`。
  * **严重后果**：在 Vue Router 的底层解析规范中，**任何以 `/` 开头的路径都会被强制视为根绝对路径**。一旦写成 `path: '/dashboard'`，它将不再挂载在 `/admin` 之后，导致路由匹配规则严重混乱，无法正常呈现后台布局。

### 404 兜底路由与正则通配机制

在单页应用中，用户随时可能手动输入错误的 URL 地址。必须在路由表的最末端提供一条通配兜底路由：

```js
{
  // 使用自定义正则表达式通配符匹配所有未命中的任意路径
  path: '/:pathMatch(.*)*',
  name: 'not-found',
  component: NotFoundView,
}
```

* **通配机制剖析**：Vue Router 4 废弃了旧版本的简单星号 `*`，采用标准正则语法。`/:pathMatch(.*)*` 中，`:pathMatch` 是捕获参数的名称，`(.*)` 表示匹配任意数量的任意字符，末尾的 `*` 表示支持重复匹配多级路径（如 `/foo/bar/baz`）。
* **必须放置在路由表最底部**：Vue Router 的匹配策略是**自上而下、先声明先命中**。如果把通配路由写在数组最前方，后续所有正常业务路由都会被它提前拦截；只有把通配路由放在数组最后一位，它才能在所有业务路由均未命中时发挥兜底保护作用。

本节最终页面关系如下：

::: file-tree title="管理端路由结构" icon="colored"

* src/
  * **App.vue** # 最外层路由出口
  * router.js # 父子路由与 404
  * layouts/
    * **AdminLayout.vue** # 后台侧栏与子路由出口
  * views/
    * HomeView.vue
    * DashboardView.vue
    * ProductListView.vue
    * ProductDetailView.vue
    * ++ ProfileView.vue # 当堂练习新增
    * NotFoundView.vue
      :::

**课堂演示**

1. **步骤 1：创建后台布局和必要页面。**

   新建 `src/layouts/AdminLayout.vue`、`src/views/DashboardView.vue`、`src/views/NotFoundView.vue`。商品列表和商品详情继续使用上一节已经创建的组件。

2. **步骤 2：配置父子路由和 404。**

   子页面最终要进入父布局中的 **RouterView**。先指出布局里的出口，再解锁后台骨架。

   保存后，访问 `/admin` 应自动进入 `/admin/dashboard`，并显示侧栏和工作台内容。

3. **步骤 3：按顺序验收嵌套路由。**

   * 访问 `/admin/products`：侧栏保持不动，内容区显示商品列表；
   * 访问 `/admin/products/101`：内容区显示商品 ID 101；
   * 使用浏览器返回键：只切换路由页面；
   * 访问 `/this-page-does-not-exist`：显示 404 页面。

   每个地址都要直接粘贴到地址栏访问一次。保存和运行后，不能只靠点击侧栏完成验收。

::: warning 两个最高频踩坑警示

* **前导斜杠陷阱（/）**：子路由 `path` 必须写相对路径（如 `path: 'products'`），如果随手多写了开头斜杠（写成 `path: '/products'`），Vue Router 会把它当作根绝对路径处理，导致无法按预期挂载在 `/admin` 下。
* **缺失子插座出口**：后台地址在地址栏匹配成功但右侧内容区一片空白，优先检查 `AdminLayout.vue` 中是否放置了子路由出口 `<RouterView />`。
  :::

**当堂练习（分层双轨制）**

* **【必做任务（基础通关）】**：新增 `src/views/ProfileView.vue`，显示“个人中心”；把它配置为 `/admin/profile` 子路由（注意子路由 `path` 保持相对路径 `profile`，不要加前导斜杠 `/`），并在 `AdminLayout.vue` 侧栏增加“个人中心”导航链接。保存后切换“工作台、商品管理、个人中心”时，侧栏必须保持不动，只替换内容区。
* **【选做挑战（🌟 自主拓展）】**：在 `AdminLayout.vue` 的样式中利用 `.router-link-active` 为“个人中心”及其他菜单项定制选中高亮底色与文字颜色，让用户在侧栏中能清晰辨识当前处于哪个管理模块。

## 4.5 知识点四：导航守卫

**学习目标**

掌握路由 `meta` 元信息标记、全局前置守卫 `beforeEach` 的运行机制与返回值控制，能够实现未登录拦截、登录后原路精准回跳，并规避守卫死循环陷阱。

**语法/概念**

### 中后台访问控制与门禁安检心智模型

在管理端系统中，绝大部分页面涉及业务数据与管理权限，不能允许访客在未授权的情况下随意通过修改地址栏直接进入。

全局前置导航守卫（`router.beforeEach`）就如同“机场登机口的安检通道”：用户在前端发起的**每一次页面跳转**，在目标组件实际被挂载渲染之前，都必须先在此接受资格核验。安检合格方可放行进入；核验不合格则会被引导至服务台（登录页）办理入场凭据。

### 核心守卫代码骨架与关键属性深度拆解

全局前置守卫的标准实现骨架如下：

```js
router.beforeEach((to, from) => {
  // 1. 获取客户端会话中的登录凭证状态
  const signedIn = Boolean(sessionStorage.getItem('demo-session'))

  // 2. 核心鉴权条件：目标页面要求登录 且 当前处于未登录状态
  if (to.meta.requiresAuth && !signedIn) {
    // 3. 拦截跳转：重定向至登录页，并暂存原目标地址以便登录后原路返回
    return {
      name: 'login',
      query: { redirect: to.fullPath },
    }
  }

  // 4. 凭证有效或访问公开页面，放行本次导航
  return true
})
```

核心概念与关键机制深度拆解：

| 核心要素 | 机制类型 | 核心作用与底层逻辑 | 关键注意事项 |
| --- | --- | --- | --- |
| `to` | `RouteLocation` | **即将进入的目标路由对象**。包含解析后的完整信息（如 `to.path`、`to.name`、`to.meta`、`to.fullPath`、`to.params`） | 守卫通过检查 `to` 的属性来决定放行还是拦截 |
| `from` | `RouteLocation` | **当前正要离开的路由对象**。记录跳转前的来源页面上下文 | 可用于判断导航来源路径或记录回退历史 |
| `meta` | `RouteMeta` | **路由元信息打标**。为路由记录附加自定义业务元数据。父路由标记 `requiresAuth: true`，其所有子路由均处于保护伞下 | 解耦鉴权逻辑与具体页面组件，配置清晰可维护 |
| `return` 返回值 | 控制信号 | **决定导航最终走向**：`return true` 放行；`return { name: 'login' }` 重定向；`return false` 取消导航 | Vue Router 4 极力推崇 `return` 控制，避免旧版 `next()` 的多次调用漏洞 |

### 核心机制深度解析：登录后精准原路回跳（`redirect`）闭环

在实际业务中，未登录用户直接点击特定分享链接（如 `/admin/products/101`）被拦截是常见场景：

* **痛点**：若用户登录成功后一律死板跳转到工作台首页，用户不得不重新在一层层菜单中寻找刚才想要查看的商品，交互流程割裂。
* **闭环方案**：
  1. **拦截时暂存**：守卫拦截未登录访问时，将目标完整地址 `to.fullPath`（包含路径与查询参数）保存至 URL 的 query 参数中：`query: { redirect: to.fullPath }`。此时登录页 URL 变为 `/login?redirect=%2Fadmin%2Fproducts%2F101`。
  2. **登录后读取并送达**：登录成功后，读取 `route.query.redirect`，调用 `router.replace(target)` 直接精准跳转回目标商品详情页，若无该参数则兜底进入默认工作台，达成无缝流畅的业务闭环。

### 关键避坑指南：守卫死循环陷阱（Infinite Redirect Loop）

这是初学者在编写守卫时极容易导致的灾难性错误：

* **错误示范**：
  ```js
  // 错误！未做排除判断，必然引发死循环！
  router.beforeEach(() => {
    if (!signedIn) {
      return { name: 'login' }
    }
    return true
  })
  ```
* **死循环原理**：
  1. 用户未登录访问 `/admin` → 命中 `!signedIn` → 重定向至 `/login`；
  2. 进入 `/login` 再次触发 `beforeEach` 守卫；
  3. 守卫再次执行，检测到依然 `!signedIn`，**再次重定向至 `/login`**；
  4. 如此无限递归触发，最终导致浏览器页面卡死崩溃，控制台抛出 `RangeError: Maximum call stack size exceeded` 错误。
* **正规防御手段**：
  必须为跳转施加边界保护，例如**只对带有 `to.meta.requiresAuth` 的受保护页面进行拦截**，或者显式判断排除登录页自身（`to.name !== 'login'`）。

### 演示登录标记与生产安全边界

本章演示统一使用浏览器会话存储：

```js
// 写入演示会话标记
sessionStorage.setItem('demo-session', 'yes')

// 退出登录时清除标记并回到登录页
function logout() {
  sessionStorage.removeItem('demo-session')
  router.push({ name: 'login' })
}
```

::: warning 安全认知边界
前端路由守卫与 `sessionStorage` 仅用于提升客户端单页应用的用户体验与页面流转控制（防君子不防小人）。客户端的数据和代码完全可以被用户在开发者工具中篡改。涉及真实业务数据的访问，必须由后端接口验证 JWT Token 或 Session 会话，前端守卫绝不能替代后端权限校验。
:::

播放下面的流程，观察“准备进入后台—检查标记—进入登录页—完成登录—回到原地址”的顺序。

**课堂演示**

1. **步骤 1：创建公共登录页。**

   新建 `src/views/LoginView.vue`。登录页放在后台父路由外面，因此它不会显示后台侧栏。

2. **步骤 2：给后台路由加 meta，并注册 beforeEach。**

   路由记录中保存访问策略的字段是 **meta**。解锁后，把路由文件和登录页改成下面这样。示例只展示演示主线；若已完成 4.2、4.4 的关于页或个人中心练习，请保留自己新增的导入、路由和导航，不要整文件覆盖后丢掉练习成果。

   保存后，后台父路由和它的子路由都会带有 `requiresAuth` 访问标记。

3. **步骤 3：验证未登录拦截和登录回跳。**

   打开浏览器 Console，先执行：

   ```js
   sessionStorage.removeItem('demo-session')
   ```

   然后直接访问 `/admin/products/101`。页面应先进入：

   ```text
   /login?redirect=/admin/products/101
   ```

   点击“模拟登录”后，应回到 `/admin/products/101`，并显示商品 ID 101。

4. **步骤 4：验证 404 不被后台守卫误拦截。**

   访问 `/unknown-page`，应直接显示 404，而不是进入登录页。说明只有带 `requiresAuth` 的路由才进行登录检查。

::: danger 避坑警示：守卫死循环陷阱（Infinite Redirect Loop）
在 `beforeEach` 中进行条件跳转时，务必确保目标路由不会再次触发相同的重定向规则！例如：若直接写 `if (!signedIn) return { name: 'login' }`，却没有判断目标页面本身是不是登录页（`to.name === 'login'`）或是否需要鉴权（`to.meta.requiresAuth`），守卫就会在 `/login` 上不断重定向到自己，引发浏览器卡死或报 `RangeError: Maximum call stack size exceeded` 错误。
:::

::: warning 边界提醒
前端守卫只负责页面入口体验。用户可以修改浏览器里的 JavaScript 和存储内容，所以敏感接口仍必须由服务端检查身份和权限。
:::

**当堂练习（分层双轨制）**

* **【必做任务（基础通关）】**：清除模拟登录标记，直接访问 `/admin/products/101`，确认被拦截到登录页；点击模拟登录后应能进入后台。再清除标记，确认下一次访问后台会重新被拦截，而 `/login` 和未知地址的 404 页面仍可公开访问。
* **【选做挑战（🌟 自主拓展）】**：补上“已经登录的人访问 `/login` 时自动回到工作台”的防重复进入分支。完成后先模拟登录，再手动在地址栏访问 `/login`，验证地址会自动变回 `/admin/dashboard` 且不会出现死循环。课堂工程有意不提供这个分支。
* **【选做挑战（🌟 自主拓展）】**：在 `AdminLayout.vue` 侧栏或顶栏增加“退出登录”按钮，绑定点击事件执行 `sessionStorage.removeItem('demo-session')` 并跳转回登录页，验证退出后再尝试进入后台能够被守卫精准重新拦截。

## 4.6 知识点五：History 部署边界

::: tip 🌟 进阶选学：路由模式对比与生产部署边界
在平时本地开发（`npm run dev`）时，Vite 开发服务器已经内置了单页应用路由回退，无论使用 History 模式还是 Hash 模式，页面切换和直接刷新都能正常工作。本节内容主要帮助学生建立起“前端单页路由与 Web 生产服务器（如 Nginx/Apache）协同工作”的架构视野，理解 URL 中的 `#` 到底起什么作用，供学有余力或准备上线的同学自主探索，不作为基础实训卡点考核。
:::

**学习目标**

掌握 History 模式与 Hash 模式的底层网络请求差异，理解单页应用在生产服务器（如 Nginx）上的 404 刷新边界与 SPA 回退配置原理。

**语法/概念**

### 本地开发与生产部署的认知割裂

在前端工程化开发中，很多初学者会遇到这样的困惑：

* 在本地执行 `npm run dev` 时，不论如何切换菜单、直接刷新深层详情地址（如 `/admin/products/101`），页面均能正常渲染；
* 但一旦执行 `npm run build` 将打包产物放到生产服务器（如 Nginx、Apache、云存储静态托管）后，只要在深层地址按 F5 刷新，浏览器立即弹出服务器底层的 **404 Not Found** 错误。

这种现象的根源在于**浏览器网络请求机制**与**服务器静态文件查找规则**。

### 底层机制探秘：为什么 `#` 号有魔力？（两种模式对比）

Vue Router 提供了两种核心的历史记录管理模式：

| 对比维度 | HTML5 History 模式 (`createWebHistory`) | Hash 模式 (`createWebHashHistory`) |
| --- | --- | --- |
| **URL 外观** | 自然美观，如 `/admin/products/101` | 包含井号，如 `/#/admin/products/101` |
| **`#` 的网络语义** | 无井号，路径完全遵循传统 URL 规范 | 井号后为**URL 锚点（Hash Fragment）**，HTTP 协议规定**绝不向服务器发送** |
| **刷新时的请求** | 浏览器向服务器发起 `GET /admin/products/101` 真实文件请求 | 浏览器向服务器仅发起 `GET /` 根路径请求，获取 `index.html` |
| **生产服务器依赖** | **必须配置服务器 SPA 回退（Fallback）**，否则深层刷新报 404 | 静态文件服务器开箱即用，无需任何额外反向代理或回退配置 |
| **架构与选型场景** | 现代商业项目、B端管理端与 C端产品的首选 | 内部临时演示系统、无服务器配置权限的纯静态托管空间 |

### 核心代码骨架与模式声明

History 模式配置（工业界主流实践）：

```js
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  // 采用 HTML5 History API（pushState / replaceState / popstate）
  // URL 呈现为真实的自然路径，需要服务端配套配置支持
  history: createWebHistory(),
  routes,
})
```

Hash 模式配置（免服务端配置实践）：

```js
import { createRouter, createWebHashHistory } from 'vue-router'

const router = createRouter({
  // 采用 location.hash 与 hashchange 事件监听
  // 所有路由信息均保存在 # 之后，无需服务端做任何重写配置
  history: createWebHashHistory(),
  routes,
})
```

### 生产环境 Nginx 单页回退配置原理

在 History 模式下，当用户在浏览器中刷新 `/admin/products/101` 时，Nginx 会在本地磁盘寻找是否有对应文件。因为单页应用只有一个唯一的宿主入口文件 `dist/index.html`，服务器找不到对应文件就会抛出 404。

解决方案是为 Web 服务器配置 **SPA 单页应用回退规则**。以工业界最常用的 Nginx 为例：

```nginx
server {
  listen       80;
  server_name  mall.campus.edu;

  location / {
    # 前端打包产物 dist 目录所在的物理磁盘路径
    root   /usr/share/nginx/html;
    index  index.html;

    # 核心回退规则：按顺序探测物理文件、物理目录；若均未找到，兜底重写至 index.html
    try_files $uri $uri/ /index.html;
  }
}
```

* **`$uri`**：检查当前请求是否存在具体的物理静态资源（如 `main.js`、`style.css`、`logo.png`）。如果存在，直接作为静态文件返回（200）。
* **`$uri/`**：检查当前请求是否对应物理子目录。
* **`/index.html`**：若前两者均不命中（说明当前请求是一个前端自定义的路由路径），Nginx 将请求内部转发回唯一的单页入口 `index.html`。
* **闭环生效**：浏览器接收到 `index.html` 后执行其中的 JavaScript 脚本，Vue Router 启动并检测当前地址栏的 `/admin/products/101`，精准渲染对应的商品详情组件，成功解决生产环境 404 痛点。

**课堂演示**

1. **步骤 1：确认当前使用 History 模式。**

   先完成模拟登录，确认 `sessionStorage` 中已经有 `demo-session`。再检查 `src/router.js` 中是否导入并调用 `createWebHistory()`。执行 `npm run dev` 后访问 `/admin/products/101`，地址栏不应出现 `#`。

2. **步骤 2：直接刷新深层地址。**

   在 `/admin/products/101` 页面按刷新。Vite 开发服务器下应仍能返回 Vue 应用，再由路由显示商品详情。

3. **步骤 3：对照现象表判断问题属于谁。**

   | 现象 | 先判断什么 |
   | --- | --- |
   | 点击菜单正常，刷新深层地址出现服务器 404 | 静态服务器是否配置 SPA 回退 |
   | 所有页面都打不开 | Vue 应用或部署目录本身是否正确 |
   | 页面能开，图片资源 404 | `BASE_URL` 与部署子路径是否一致 |

   观察后应能说明：点击菜单正常，不代表生产环境刷新深层 URL 一定正常。

4. **步骤 4：保留最终课程写法。**

   本章最终代码继续使用 `createWebHistory()`。保存后重新运行，地址栏应保持自然路径，不出现 `#`。

**当堂练习（分层双轨制）**

* **【必做任务（基础通关）】**：确认当前项目使用 `createWebHistory()`，访问商品详情 `/admin/products/101`，记录此时的自然 URL 样式；在浏览器中直接按 F5 刷新，验证本地 Vite 开发服务器能够正常响应并渲染详情页。
* **【选做挑战（🌟 自主拓展）】**：把 `createWebHistory()` 临时换成 `createWebHashHistory()`，保存后访问商品详情，观察地址栏中 `#` 的位置变化；尝试在新标签页直接粘贴并刷新带有 `#` 的地址，对比两者在表现形式和服务器请求上的本质差异，验证完毕后切回 `createWebHistory()`。

## 4.7 排错矩阵

| 现象 | 证据 | 常见原因 |
| --- | --- | --- |
| 页面完全空白 | Console 报 router 或组件错误 | `main.js` 没有 `.use(router)`，或导入路径写错 |
| 地址变化但页面不变 | 页面中找不到对应出口 | 缺少 `<RouterView />` |
| 点击导航整页刷新 | Network 中重新请求整个 HTML | 站内导航误用了普通 `<a href>` |
| 商品 id 是 `undefined` | 路由参数名与组件 prop 不一致 | `:id`、`props: true`、组件 `id` 没有对齐 |
| 子路由匹配不到 | `/admin/products` 没进入后台布局 | 子路由 path 错写成不需要的绝对路径 |
| 后台布局出现但内容区空白 | 父路由已匹配，子页面没显示 | `AdminLayout` 缺少嵌套 `RouterView` |
| 未登录也能进入后台 | `to.meta.requiresAuth` 是空值 | 受保护父路由没有配置 `meta` |
| 登录后只回工作台 | 登录前 URL 有 redirect，登录后没读取 | `LoginView` 丢失了 `route.query.redirect` |
| 守卫反复跳转 | 地址在登录页和后台之间来回变化 | 登录页也被错误拦截，或分支没有结束 |
| 未知地址显示空白 | 路由表没有匹配结果 | 缺少最后的 404 兜底路由 |
| 开发环境正常，部署刷新 404 | 服务器响应的不是 `index.html` | History 模式缺少 SPA 回退 |

{{reflection-checkpoint:vue-router-layout-access-checkpoint}}

## 4.8 本章小测

{{assessment:vue-router-layout-access-check}}

## 4.9 本章知识自检

| 能力 | 达标标准 |
| --- | --- |
| 最小接入 | 能说明 `app.use`、`RouterLink` 和 `RouterView` 分别做什么 |
| 路由映射 | 能从 URL 找到对应路由记录与页面组件 |
| 动态参数 | 商品详情通过 props 接收 id，直接刷新地址仍能恢复 |
| 嵌套路由 | 后台公共布局稳定，子页面进入正确的 `RouterView` |
| 404 | 未知地址显示明确页面和回退入口 |
| 登录守卫 | 未登录拦截、登录后回跳、已登录不再停留登录页 |
| 登录标记 | 全章统一使用 `sessionStorage` 的 `demo-session` |
| 安全边界 | 能解释前端守卫不能代替后端授权 |
| 部署边界 | 能说明 History 刷新为什么依赖服务器回退 |

## 4.10 课后任务：校园服务后台导航

### 任务目标

使用 `resources/ch04/after-class-starter/` 起始项目，为校园服务后台完成登录页、工作台、场地列表、场地动态详情、公共后台布局、登录守卫和 404 页面。未登录直接访问后台时先进入登录页，基础任务登录后进入工作台；回到原本想访问的地址属于进阶拓展。

起始工程已提供公共布局、工作台、场地列表、模拟登录及基础未登录拦截。按 `TODO（基础）` 补上动态详情路由、404 兜底和退出登录；按 `TODO（提高）` 选做原地址回跳与防重复登录。

### 基础通关要求（全员必做）

1. 建立 `/login`、`/service/dashboard`、`/service/rooms`、`/service/rooms/:id` 和 404 兜底路由。
2. `/service` 下的工作台、场地列表和场地详情共用同一套侧栏布局与内容出口 `<RouterView />`。
3. 场地详情通过 `props: true` 接收动态参数 `id`，页面显示当前场地 id，不发送接口请求。
4. 使用路由 `meta: { requiresAuth: true }` 标记受保护页面；未登录时由 `beforeEach` 守卫拦截跳转至登录页。
5. 登录标记统一使用 `sessionStorage` 的 `demo-session`；登录成功后能进入工作台，退出登录后再次访问后台必须被重新拦截。
6. 未知地址显示 404 页面，并提供回到校园服务工作台的链接。
7. 项目使用 `createWebHistory()`，并能在开发环境直接打开场地详情地址。

### 进阶拓展任务（自主选做 · 🌟 自主拓展）

1. **精准原路回跳**：在守卫拦截未登录访问时，将目标路径保存至 `query.redirect`；登录成功后读取并优先精准回跳至原目标地址（如 `/service/rooms/201`）。
2. **防重复登录**：在 `beforeEach` 中判断若已登录用户手动访问 `/login`，自动重定向回工作台。
3. **部署认知探索**：临时把 `createWebHistory()` 换成 `createWebHashHistory()`，观察地址栏出现 `#` 后再恢复；直接刷新一个 History 深层地址，思考并记录生产服务器为什么需要单页应用（SPA）回退到 `index.html`。

### 必须使用的知识

* **基础核心**：嵌套路由与父布局中的 `RouterView`、动态参数与 `props: true`、命名路由、路由 `meta` 与基础 `beforeEach` 守卫拦截、404 兜底路由、`sessionStorage` 状态检查。
* **进阶拓展（🌟 自主拓展）**：`redirect` 原地址保存与登录后精准回跳、防重复登录拦截、Hash 模式与 History 模式对比及生产环境 SPA 回退机制。

### 完成效果

* **基础效果**：
  * 未登录直接访问后台时被守卫拦截到登录页。
  * 点击模拟登录后可进入后台，工作台、场地列表和场地详情共用同一侧栏，切换子页面时只替换内容区。
  * 场地详情页能正常接收并显示“当前场地 ID”。
  * 清除登录标记后再次访问后台，会重新被拦截至登录页。
  * 访问未知地址时显示 404 和“回到工作台”链接，不出现空白页。
* **拓展效果（🌟 自主拓展）**：
  * 访问 `/service/rooms/201` 被拦截后，登录成功直接返回该详情页；
  * 已登录访问 `/login` 时自动回到工作台；
  * 理解 Hash 地址与 History 地址的差异及生产服务器回退原因。

### 验收步骤

1. 清除 `demo-session`，直接访问 `/service/dashboard`，确认被拦截进入登录页。
2. 点击模拟登录，确认能顺利进入工作台。
3. 依次切换工作台、场地列表和场地详情，确认公共侧栏保持不动，内容区正常切换。
4. 清除登录标记后再次访问 `/service/dashboard`，确认重新进入登录页。
5. 访问 `/unknown-page`，确认显示 404 页面和可点击的回退入口。
6. 执行 `npm run build`，确认构建成功且 Console 没有路由错误。
7. （🌟 自主拓展验收）：验证带 `redirect` 的精准回跳、已登录访问 `/login` 的重定向，以及 Hash/History 切换观察。

### 提交内容

* 完整项目源码，不包含 `node_modules/` 和 `dist/`；
* 动态详情与 404 两张运行效果截图；
* 一张 `npm run build` 成功终端截图；
* （🌟 自主拓展选交）：登录回跳截图、Hash 地址截图或不超过 150 字的 SPA 回退理解说明。

::: warning 边界提醒
前端守卫只能控制页面入口，不能代替后端权限校验。真实项目仍必须让服务端验证身份、登录状态和操作权限。
:::

## 本章小结

* 路由把 URL 映射到页面组件，`RouterView` 是匹配页面的显示出口。
* 动态参数把商品身份放进 URL，命名路由和 `params` 减少手动拼接路径。
* 嵌套路由让多个后台页面复用同一布局，404 路由负责接住未知地址。
* `meta` 与 `beforeEach` 可以组织登录入口，`redirect` 让登录后回到原目标页。
* 全章演示登录标记统一使用 `sessionStorage` 的 `demo-session`，但它不能代替后端授权。
* History 模式地址自然，生产服务器需要为深层 URL 配置单页应用回退。
* 下一章会使用 Element Plus 完成商品列表、表单、弹窗和操作反馈。
