Vue 生态(七):第七章:应用的“交通枢纽” · Vue Router 全面指南

第七章:应用的“交通枢纽” · Vue Router 全面指南

摘要: 在本章中,我们将一气呵成地掌握 Vue Router 的全部核心知识。我们将通过构建一个包含 登录、后台布局、动态用户详情 等模块的微型应用,将路由的每一个核心 API 和设计模式,都融入到真实、连贯的开发流程中。在这个过程中,Pinia 将作为我们的“中央认证系统”,JSON Server 作为“用户数据库”,SCSS 作为“视觉设计师”,与 Vue Router 协同完成一个工业级的导航与权限控制流程。


在本章中,我们将像搭建城市交通网络一样,精确地构建应用的导航系统:

  1. 首先,我们将从 零开始,构筑一个完整的、包含所有必要工具的路由实战环境
  2. 接着,我们将学习 路由的核心组件两种导航方式:声明式与编程式。
  3. 然后,我们将深入 路由传参 的两种核心模式:动态参数和查询参数,并与 Pinia、JSON Server 深度联动。
  4. 之后,我们将探讨 嵌套路由懒加载,构建复杂的页面布局并优化性能。
  5. 最后,我们将利用 路由元信息导航守卫,结合 Pinia 打造一条坚不可摧的自动化“认证防线”。

7.1. 专业基石:从零构筑一个完整的路由实战环境

第一步:初始化 Vite 项目

我们从一个全新的、最纯净的 Vite + TypeScript 项目开始。

1
pnpm create vite vue-router-practice --template vue-ts

第二步:安装所有核心依赖

进入项目目录,然后我们将一次性安装本指南所需的全部核心依赖。

1
2
cd vue-router-practice
pnpm install
1
2
3
4
5
# 核心功能库
pnpm add vue-router@4.4.0 pinia@2.1.7

# 开发工具与辅助库
pnpm add -D sass json-server@0.17.4 @types/node
  • vue-router: 我们本章的主角。
  • pinia: 用于后续章节的认证状态管理。
  • sass: SCSS 预处理器,用于编写专业样式。
  • json-server: 用于模拟后端 API,提供真实的数据交互。
  • @types/node: 为 Node.js 内置模块(如 path)提供 TypeScript 类型定义,Vite 配置中会用到。

**第三步:配置路径别名 (@) **

为了避免在项目中出现恼人的 ../../../ 相对路径,我们必须配置 @ 路径别名,让其直接指向 src 目录。这需要同时告知 TypeScript(用于代码提示和类型检查)和 Vite(用于项目编译和运行)。

1. 配置 tsconfig.app.json (告知 TypeScript)

在 Vite 的标准项目结构中,src 目录下的应用代码配置由 tsconfig.app.json 文件控制。因此,我们必须在这里添加路径别名配置,而不是在根目录的 tsconfig.json 中。

文件路径: tsconfig.app.json (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"compilerOptions": {
// ... 其他 Vite 默认配置保持不变 ...
"target": "ES2020",
"module": "ESNext",
"strict": true,

/* ============= 新增配置 开始 ============= */
// 这是解析非相对模块名的基准目录,设置为 '.' 表示项目根目录
"baseUrl": ".",
// 路径别名,@/* 表示 src/*
"paths": {
"@/*": ["src/*"]
}
/* ============= 新增配置 结束 ============= */
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}

重要提示:请确保根目录的 tsconfig.json 文件保持其初始状态,不要在其中添加 baseUrlpaths,否则会因为配置覆盖而导致别名在 VSCode 中不生效。


2. 配置 vite.config.ts (告知 Vite)

现在,我们需要告诉 Vite 在编译和打包时如何识别这个 @ 别名。

文件路径: vite.config.ts (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 引入 Node.js 的 'url' 和 'path' 模块,用于处理文件路径
import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
// 添加 resolve 配置
resolve: {
alias: {
// 设置 '@' 别名,指向 'src' 目录
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})

提示:如果 import 'node:url' 提示找不到模块,你需要安装 Node.js 的类型定义:npm install -D @types/nodepnpm add -D @types/node


3. 重启 TypeScript 服务 (关键步骤)

修改 tsconfig 文件后,VSCode 不会立即应用新配置。你需要手动重启 TS 服务:

  1. 在 VSCode 中按下 Ctrl+Shift+P (Windows/Linux) 或 Cmd+Shift+P (Mac)。
  2. 输入并选择 TypeScript: Restart TS Server

完成后,代码中的路径错误提示(红色波浪线)就会消失,并且 @ 别名可以正常使用了。


第四步:准备 Mock 后端

我们创建 db.json 文件,并配置 package.json 脚本来启动 json-server

文件路径: db.json (新建于项目根目录)

1
2
3
4
5
6
{
"users": [
{ "id": 1, "name": "Prorise", "email": "prorise@example.com" },
{ "id": 2, "name": "VueMastery", "email": "hello@vuemastery.com" }
]
}

文件路径: server.cjs (新增)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 1. 引入所需库
const jsonServer = require("json-server");
const path = require("path");

// 2. 创建一个 Express 服务器实例
const server = jsonServer.create();

// 3. 将 db.json 作为数据源创建一个路由器
// 使用 path.join 确保路径在不同操作系统下都正确
const router = jsonServer.router(path.join(__dirname, "db.json"));

// 4. 获取 JSON Server 默认提供的一组中间件 (logger, static, cors, no-cache)
const middlewares = jsonServer.defaults();

// 5. 在服务器上应用默认中间件
server.use(middlewares);

// 6. 在服务器上应用自定义路由器
server.use(router);

// 7. 自定义响应格式
router.render = (req, res) => {
res.status(200).jsonp({
code: 200,
message: "Success",
// res.locals.data 中存放着 JSON Server 准备好的原始响应数据
data: res.locals.data,
});
};

// 8. 启动服务器,监听 3001 端口
server.listen(3001, () => {
console.log("JSON Server is running on http://localhost:3001");
});

文件路径: package.json (修改 scripts)

1
2
3
4
5
6
7
8
{
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"mock": "node server.cjs"
}
}

在后续的学习中,你需要 同时开启两个终端:一个运行 pnpm run dev 启动前端应用,另一个运行 pnpm run mock 启动后端 API 服务。

第五步:集成 Pinia 与 Router

最后,我们在 main.ts 中完成 Pinia 和 Router 的全局注册。

1. 创建路由配置文件

文件路径: src/router/index.ts (新建)

1
2
3
4
5
6
7
8
9
10
import { createRouter, createWebHistory } from 'vue-router';

const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
// 路由规则将在这里定义
],
});

export default router;

2. 完成全局注册

文件路径: src/main.ts

1
2
3
4
5
6
7
8
9
10
11
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";

const app = createApp(App);

app.use(createPinia());
app.use(router);

app.mount("#app");

7.2. 创建路由实例:createRouterhistory 模式

在上一节中,我们已经创建了 src/router/index.ts 这个文件。现在,我们来打开这个空白文件,像一位工程师一样,一步步地构建出我们应用所需的“交通枢纽”。

要创建一个路由器实例,vue-router 库为我们提供了核心的工厂函数 createRouter。同时,我们需要决定应用的 URL 风格。现代单页应用为了追求更美观、更有利于 SEO 的 URL(例如 /about),通常会选择基于浏览器原生 History API 的模式,为此,我们还需要 createWebHistory 这个工具。

文件路径: src/router/index.ts

1
2
3
4
5
6
7
8
9
10
11
12
import { createRouter, createWebHistory } from 'vue-router';

const router = createRouter({
// history 选项用于配置路由模式
history: createWebHistory(import.meta.env.BASE_URL),
// routes 选项用于定义路由规则
routes: [
// 我们将在这里添加路由规则
],
});

export default router;

createRouter 函数接收一个配置对象,其中 history 属性用于指定路由模式。除了我们首选的 createWebHistory,Vue Router 还提供了其他模式以应对不同场景。
HTML5 模式
这是现代单页应用的 最佳实践。它利用了浏览器原生的 history.pushState API,URL 看起来就像传统的网站一样,非常美观。
Hash 模式
这种模式会在 URL 中使用一个 # 符号(例如 https://example.com/#/about)。它的最大优点是 兼容性极好,无需任何服务器端配置即可运行。
内存模式
这种模式不与浏览器地址栏交互,主要用于非浏览器环境,例如 服务端渲染 (SSR)


本章核心知识点:
在创建好路由实例后,必须通过 app.use(router) 将其 全局注册 到 Vue 应用中,使其生效。注册后,Vue Router 提供了两个 核心全局组件

  1. <router-view>: 路由内容的 渲染出口。它是一个占位符,用于显示当前 URL 匹配到的组件。
  2. <router-link>: 声明式导航 的实现方式。它被渲染为 <a> 标签,用于创建导航链接,实现无页面刷新的路由跳转。

创建好的路由实例需要被 Vue 应用所知晓,才能真正地工作起来。我们在 main.ts 中通过 app.use() 来完成全局注册。

文件路径: src/main.ts

1
2
3
4
5
6
7
8
9
10
11
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from '@/router'; // 1. 导入我们创建的路由实例

const app = createApp(App);

app.use(createPinia());
app.use(router); // 2. 全局注册路由,将其挂载到应用实例上

app.mount('#app');

注册完成后,我们就可以在应用的任何组件中使用路由功能了。现在,我们需要认识两个由 Vue Router 提供的核心全局组件,它们是实现页面导航和内容展示的基石。

为此,我们先创建两个简单的“页面”组件,并为它们定义路由规则。

文件路径: src/views/HomePage.vue (新建)

1
2
3
4
5
<template>
<div class="page">
<h1>欢迎来到主页</h1>
</div>
</template>

文件路径: src/views/AboutPage.vue (新建)

1
2
3
4
5
<template>
<div class="page">
<h1>关于我们</h1>
</div>
</template>

文件路径: src/router/index.ts (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';

// routes 数组就是我们的“交通地图”
const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'Home',
// ✅ 最佳实践:使用懒加载,优化首屏性能
component: () => import('@/views/HomePage.vue'),
},
{
path: '/about',
name: 'About',
component: () => import('@/views/AboutPage.vue'),
},
];

const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});

export default router;

我们在这里直接使用了 路由懒加载 (() => import(...)) 的写法。这是一个至关重要的 最佳实践,它能将不同页面的代码分割成独立的 JS 文件,只有在访问该页面时才会被下载,从而极大地优化应用的首屏加载速度。

现在,我们修改 App.vue 来使用 <router-link><router-view>

文件路径: src/App.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
<template>
<header class="main-header">
<nav>
<router-link to="/">主页</router-link>
<router-link to="/about">关于</router-link>
</nav>
</header>

<main class="content-body">
<router-view />
</main>
</template>

<style lang="scss">
/* 全局基础样式 */
body {
font-family: sans-serif;
margin: 0;
background-color: #f0f2f5;
}
.page {
background: #fff;
padding: 1rem 2rem;
border-radius: 8px;
}
</style>

<style lang="scss" scoped>
.main-header {
background-color: #fff;
box-shadow: 0 2px 8px #f0f1f2;
nav {
display: flex;
gap: 1.5rem;
padding: 1rem 2rem;
a {
text-decoration: none;
color: #333;
font-weight: 500;
transition: color 0.3s;

&:hover {
color: #42b883;
}

// Vue Router 会为当前激活的链接自动添加这个 class
&.router-link-exact-active {
color: #42b883;
border-bottom: 2px solid #42b883;
}
}
}
}
.content-body {
padding: 2rem;
}
</style>

现在,运行项目,你将看到一个带有导航栏的页面。点击链接,下方的正文内容会在两个组件之间瞬时切换,而整个浏览器页面 并未刷新

  • <router-view>: 它是 路由内容的“渲染出口”。这是一个占位符,Vue Router 会将当前 URL 匹配到的组件渲染在这个位置。
  • <router-link>: 它是 声明式导航的最佳实践。它会被渲染成一个 <a> 标签,但它会拦截浏览器的默认点击事件,通过 history.pushState API 来改变 URL 并更新视图,从而避免了代价高昂的整页刷新。

7.4. 编程式导航:useRouter

本章核心知识点:
除了使用 <router-link> 进行模板内的 声明式导航 外,我们经常需要在 <script> 逻辑中手动触发页面跳转(例如登录成功后)。这种方式称为 编程式导航

  • useRouter(): 这是 vue-router 提供的一个 Composition API 钩子 (Hook),用于在组件的 setup 函数中获取全局的 router 实例
  • router.push() / router.replace(): 获取到的 router 实例提供了多种导航方法。push 会在历史记录中添加新条目,而 replace 会替换当前条目(常用于登录等场景)。

我们在上一节学习的 <router-link> 是一种 声明式 导航,它非常适合用于那些用户直接点击的、静态的导航菜单。但在真实的应用中,有大量的场景需要在执行完一段业务逻辑后,再由代码来主动控制页面的跳转。

最经典的场景莫过于 用户登录:我们不能简单地用 <router-link> 包裹“登录”按钮,因为程序需要先进行表单验证、调用 API、等待服务器返回成功响应,然后 才能将用户导航到主页。这种由逻辑驱动的、手动的跳转,就叫做 编程式导航

要实现编程式导航,我们首先需要获取到路由器的实例。在 <script setup> 环境中,vue-router 提供了一个名为 useRouter 的钩子 (Hook),它可以让我们轻松地获取到在 main.ts 中创建的那个全局 router 实例,这个实例就是我们进行编程式导航的“遥控器”。

第一步:创建登录页与路由

我们先创建一个 LoginPage.vue 组件,并为其配置路由。

文件路径: src/views/LoginPage.vue (新建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<script setup lang="ts">
import { useRouter } from 'vue-router';

// 1. 调用 useRouter() 钩子,获取 router 实例
const router = useRouter();

function handleLogin() {
console.log('正在执行登录逻辑...');

// 2. 模拟一个异步的登录请求
setTimeout(() => {
console.log('登录成功!');

// 3. 登录成功后,使用 router.push() 进行跳转
router.push({ name: 'Home' });
}, 1000);
}
</script>

<template>
<div class="page">
<h1>登录</h1>
<button @click="handleLogin">点击模拟登录</button>
</div>
</template>

文件路径: src/router/index.ts (修改)

1
2
3
4
5
6
7
8
9
10
// ...
const routes: Array<RouteRecordRaw> = [
// ... Home, About 路由
{
path: '/login',
name: 'Login',
component: () => import('@/views/LoginPage.vue'),
},
];
// ...

为了方便访问,我们在 App.vue 中也增加一个登录页的链接。
文件路径: src/App.vue (修改)

1
2
3
4
5
6
7
8
9
<template>
<header class="main-header">
<nav>
<router-link to="/">主页</router-link>
<router-link to="/about">关于</router-link>
<router-link to="/login">登录</router-link>
</nav>
</header>
</template>

现在,你可以点击“登录”链接进入登录页,然后点击“点击模拟登录”按钮,会发现在 1 秒后,页面自动跳转回了主页。

push vs replace:历史记录的艺术

我们刚刚使用的 router.push() 方法,会在浏览器的历史记录栈中 新增 一条记录。这意味着在登录跳转后,用户可以点击浏览器的“后退”按钮,再次回到登录页面。这在登录场景下显然是不合理的。

我们需要的是一种不会留下历史记录的跳转方式。

  • router.push(): 推入 新纪录到历史栈。A -> B,历史栈为 [A, B]
  • router.replace(): 替换 当前记录。A -> B,历史栈为 [B]

让我们用 replace 来修正登录逻辑。

文件路径: src/views/LoginPage.vue (修改 handleLogin 函数)

1
2
3
4
5
6
7
8
9
function handleLogin() {
console.log('正在执行登录逻辑...');

setTimeout(() => {
console.log('登录成功!');
// ✅ 使用 replace 跳转,不会留下历史记录
router.replace({ name: 'Home' });
}, 1000);
}

最佳实践: 对于登录、注册等一次性操作,或者任何不希望用户能够“后退”回来的页面跳转,都应该优先使用 router.replace()

此外,router 实例还提供了 router.go(n) 方法,允许你在历史记录中前进或后退 n 步,例如 router.go(-1) 就等同于浏览器的后退按钮。


7.5. 路由传参(一):动态路由参数与生态协同

本章核心知识点:
为了用一条路由规则匹配多种相似的 URL(如 /users/1, /users/2),我们使用 动态路由参数,在 path 中以冒号 : 开头定义,例如 path: '/users/:id'

  • useRoute(): vue-router 提供的另一个核心钩子,用于获取 当前激活的路由对象。这是一个只读对象,包含了当前 URL 的所有信息。
  • route.params: 通过 useRoute() 获取的 route 对象上的 params 属性,可以访问到动态路由参数的值(如 route.params.id)。

核心概念区分:

  • useRouter(): 是“遥控器”,负责 执行动作,比如 push, replace
  • useRoute(): 是“GPS 定位信息”,负责 提供只读信息,比如当前路径和 URL 参数。

我们现在已经掌握了如何通过代码控制页面跳转,但这些页面都是静态的。一个真实的应用,其核心价值在于展示 动态 的数据——例如,查看用户 A 的个人资料,或是商品 B 的详情页。我们不可能为系统中的每一个用户都手动创建一条路由规则。

这就引出了路由系统中一个极其强大的概念——动态路由参数。它允许我们用 一条 规则来匹配所有同类型的路径,例如用 /users/:id 来匹配所有用户详情页。

要实现这个功能,我们需要一个新的钩子 useRoute读取 当前 URL 上的信息,并与我们的 API 层和状态管理层进行深度协同。

第一步:构建专业的 API 请求层

在真实的企业级项目中,我们不会在业务逻辑中直接使用 fetch。而是会使用 axios 并将其封装到一个独立的、可复用的模块中,以集中管理配置和拦截器。

首先,安装 axios

1
pnpm add axios@1.7.2

接着,我们创建 apiClient,这是我们项目中所有 API 请求的唯一出口。

文件路径: src/types/user.ts (新建)

1
2
3
4
5
export interface User {
id: number;
name: string;
email: string;
}

文件路径: src/api/client.ts (新建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import axios from "axios";

// 1. 创建 axios 实例
const apiClient = axios.create({
// 从 Vite 环境变量中读取 API 基地址
baseURL: "http://localhost:3001",
// 设置请求超时时间
timeout: 10000, // 10 秒
});

// 2. 添加请求拦截器 (为未来的 token 认证等功能预留位置)
apiClient.interceptors.request.use(
(config) => {
// 在发送请求之前做些什么,例如统一添加 token
// const token = localStorage.getItem('token');
// if (token) {
// config.headers.Authorization = `Bearer ${token}`;
// }
return config;
},
(error) => {
return Promise.reject(error);
}
);

// 3. 添加响应拦截器 (为未来的统一错误处理等功能预留位置)
apiClient.interceptors.response.use(
(response) => {
// axios 会将后端返回的数据包裹在 data 属性中
return response.data;
},
(error) => {
// 在这里可以进行全局的错误处理
console.error("API Error:", error);
return Promise.reject(error);
}
);

export default apiClient;

文件路径: src/services/userService.ts (新建)

1
2
3
4
5
6
7
import apiClient from "../client";
import type { AxiosResponse } from "axios";
import type { User } from "@/types/user";

export const getUserById = async (id: number): Promise<AxiosResponse<User>> => {
return apiClient.get(`/users/${id}`);
};

第二步:创建数据驱动的 Pinia Store

现在,我们创建一个 userStore,它将使用我们刚刚构建的 apiClient 来与后端通信。

文件路径: src/stores/user.ts (新建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { ref, computed } from "vue";
import { defineStore } from "pinia";
import type { User } from "@/types/user";
import { getUserById } from "@/api/services/userService";

export const useUserStore = defineStore("user", () => {
// 准备我们的 Pinia Store 四件套,刻在脑子里!
const user = ref<User | null>(null);
const isLoading = ref(false);
const error = ref<Error | null>(null);
const isLoggedIn = computed(() => !!user.value);

async function fetchUser(userId: number) {
isLoading.value = true;
error.value = null;
try {
// getUserById 很可能返回的是 AxiosResponse <User>
const response = await getUserById(userId);
user.value = response.data; // 只取 data 字段
} catch (err) {
error.value = err as Error;
} finally {
isLoading.value = false;
}
}
return {
user,
isLoading,
error,
isLoggedIn,
fetchUser,
};
});

第三步:定义动态路由并实现组件

确保你的 json-server 正在运行。现在,我们在 router/index.ts 中添加动态路由规则。注意 path 中的 :id 部分,这个冒号前缀告诉 Vue Router, 这是一个 动态段

文件路径: src/router/index.ts (修改)

1
2
3
4
5
6
7
8
9
10
// ...
const routes: Array<RouteRecordRaw> = [
// ... Home, About, Login 路由
{
path: '/users/:id', // : id 就是动态参数
name: 'UserProfile',
component: () => import('@/views/UserProfilePage.vue'),
},
];
// ...

最后,我们创建详情页组件,将所有技术栈在此交汇。

文件路径: src/views/UserProfilePage.vue (新建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
<script setup lang="ts">
import { useRoute } from 'vue-router';
import { useUserStore } from '@/stores/user';
import { storeToRefs } from 'pinia';
import { watch } from 'vue';

const route = useRoute();
const userStore = useUserStore();
const { user, isLoading, error } = storeToRefs(userStore);

// watch 侦听器确保在路由参数变化时(例如从 /users/1 跳转到 /users/2),
// 组件能够重新获取数据。
watch(
() => route.params.id,
(newId) => {
// 确保 newId 存在且是字符串(params 的值可能是字符串数组)
if (newId && typeof newId === 'string') {
userStore.fetchUser(Number(newId));
}
},
{
// immediate: true 确保组件首次加载时也会执行
immediate: true,
}
);
</script>

<template>
<div class="user-profile-card">
<h2>用户资料 (ID: {{ route.params.id }})</h2>
<div v-if="isLoading" class="loading">正在加载中...</div>
<div v-else-if="error" class="error">加载失败: {{ error.message }}</div>
<div v-else-if="user" class="user-info">
<p><strong>姓名:</strong> {{ user.name }}</p>
<p><strong>邮箱:</strong> {{ user.email }}</p>
</div>
</div>
</template>

<style lang="scss" scoped>
.user-profile-card {
border: 1px solid #ccc;
padding: 1.5rem;
border-radius: 8px;
width: 300px;
background: #fff;

h2 {
margin-top: 0;
border-bottom: 1px solid #eee;
padding-bottom: 0.5rem;
color: #333;
}

.loading, .error {
color: #888;
padding: 1rem 0;
}

.error {
color: #d9534f;
}

.user-info p {
margin: 0.5rem 0;
strong {
margin-right: 0.5em;
}
}
}
</style>

第四步:添加入口链接

为了能方便地测试,在 App.vue 中添加几个指向不同用户详情的链接。

文件路径: src/App.vue (修改)

1
2
3
4
<nav>
<router-link :to="{ name: 'UserProfile', params: { id: 1 } }">用户 1</router-link>
<router-link :to="{ name: 'UserProfile', params: { id: 2 } }">用户 2</router-link>
</nav>

现在,刷新你的应用。点击“用户 1”和“用户 2”,你会看到组件成功地从 URL 中获取 ID,调用 Pinia Action,通过我们封装的 apiClientjson-server 请求数据,并最终将不同用户的信息优雅地渲染出来。

这个实践完美地演示了一个 数据驱动的动态视图 的完整生命周期。我们通过 Vue Router (useRoute) 捕获用户意图(ID),通过一个专业的 API Client (axios) 执行数据请求,通过 Pinia (Action) 管理业务逻辑与状态,最终将结果响应式地呈现在 UI 上。这就是现代前端框架生态协同工作的强大之处。


7.6. 路由传参(二):查询参数 (Query)

本章核心知识点:
当需要对资源列表进行 筛选、排序或分页 时,我们使用 查询参数 (Query)。它是 URL 中 ? 之后的部分,以 key=value 形式存在,例如 /search?q=vue&sort=price

  • route.query: 与 route.params 类似,通过 useRoute() 获取的 route 对象上的 query 属性,可以访问到 URL 中的所有查询参数。
  • URL 驱动开发: 一种最佳实践,即组件的输入(如搜索框)不直接触发数据请求,而是先 更新 URL 的查询参数;然后通过 watch 侦听 URL 的变化,再根据新的查询参数去请求数据。这使得 URL 成为“唯一信源”,利于分享和收藏。

核心定位区分:

  • Params (/users/:id): 用于 定位 一个唯一的资源,是路径的一部分。
  • Query (/search?q=vue): 用于 筛选 一个资源集合,是附加的查询条件。

在上一节中,我们完美地掌握了如何通过 动态路由参数 (/users/:id) 来获取并展示一个 特定的资源。现在,我们将面临一个更常见的场景:如何展示一个 资源集合,并对其进行 筛选、排序或分页

痛点背景: 假设我们需要一个商品搜索页面。用户输入的搜索关键词、选择的排序方式、以及当前页码,这些信息应该如何传递?如果尝试用动态参数,路径会变得非常笨拙和僵化,例如 /products/keyword/price/asc/page/1。这种结构难以扩展和维护。

解决方案: 查询参数 (Query)。它是 URL 路径 ? 之后的部分(例如 /search?q=vue&sort=price),专门用于传递非定位性的、描述性的筛选条件。

第一步:准备 Mock 后端

我们将在 db.json 中新增一份 products 列表数据。json-server 的强大之处在于,我们无需修改 server.cjs,它天生就支持通过 Query String 对资源进行过滤。例如,向 /products?name_like=Pro 发起请求,它会自动返回 name 字段包含 “Pro” 的所有商品。

文件路径: db.json (添加 products 数组)

1
2
3
4
5
6
7
8
9
10
11
12
{
"users": [
{ "id": 1, "name": "Prorise", "email": "prorise@example.com" },
{ "id": 2, "name": "VueMastery", "email": "hello@vuemastery.com" }
],
"products": [
{ "id": 101, "name": "Vue.js Pro Course", "price": 99 },
{ "id": 102, "name": "React Pro Course", "price": 99 },
{ "id": 103, "name": "Advanced Vue Patterns", "price": 129 },
{ "id": 104, "name": "Pinia Deep Dive", "price": 79 }
]
}

第二步:构建 API 与状态管理层

我们将严格遵循之前建立的最佳实践,一步步地构建起完整的垂直分层。

1. 定义类型
文件路径: src/types/product.ts (新建)

1
2
3
4
5
6
7
8
9
10
11
export interface Product {
id: number;
name: string;
price: number;
}

// 定义搜索参数的类型
export interface ProductSearchParams {
name_like?: string; // json-server 支持 `_like` 后缀进行模糊搜索
// 未来可以扩展 sort, order, page 等
}

2. 创建 API 服务
文件路径: src/api/services/productService.ts (新建)

1
2
3
4
5
6
7
8
9
import apiClient from "@/api/client";
import type { Product, ProductSearchParams } from "@/types/product";
import type { AxiosResponse } from "axios";

export const searchProducts = async (
params: ProductSearchParams
): Promise<AxiosResponse<Product[]>> => {
return apiClient.get("/products", { params });
};

3. 创建 Pinia Store
文件路径: src/stores/productStore.ts (新建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { ref } from "vue";
import { defineStore } from "pinia";
import type { Product, ProductSearchParams } from "@/types/product";
import { searchProducts as searchProductsApi } from "@/api/services/productService";

export const useProductStore = defineStore("product", () => {
const products = ref<Product[]>([]);
const isLoading = ref(false);
const error = ref<Error | null>(null);

async function searchProducts(params: ProductSearchParams) {
isLoading.value = true;
error.value = null;
try {
const response = await searchProductsApi(params);
products.value = response.data;
} catch (e) {
error.value = e as Error;
} finally {
isLoading.value = false;
}
}
return {
products,
isLoading,
error,
searchProducts,
};
});

第三步:构建“URL 驱动”的搜索页面

这是本节的核心实践。我们将构建一个搜索页面,其中 URL 是驱动数据的“唯一信源”

文件路径: src/views/SearchPage.vue (新建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
<script lang="ts" setup>
import { ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useProductStore } from "@/stores/productStore";
import { storeToRefs } from "pinia";

const route = useRoute();
const router = useRouter();
const productStore = useProductStore();

const { products, isLoading, error } = storeToRefs(productStore);
const keyword = ref((route.query.name_like as string) || "");

// 1. 搜索函数只负责更新 URL
function onSearch() {
router.push({ name: "Search", query: { name_like: keyword.value } });
}

// 2. watch 侦听 URL query 的变化,然后才触发 Action
watch(
() => route.query.name_like,
(newKeyword) => {
keyword.value = newKeyword as string;
productStore.searchProducts({ name_like: newKeyword as string });
},
// 确保页面首次加载时,如果URL带有参数,也会触发搜索
{ immediate: true }
);
</script>

<template>
<div class="search-page">
<div class="search-bar">
<input
v-model="keyword"
@keyup.enter="onSearch"
placeholder="搜索商品..."
/>
<button @click="onSearch">搜索</button>
</div>

<div v-if="isLoading">正在搜索...</div>
<div v-else-if="error">搜索失败: {{ error.message }}</div>
<ul v-else-if="products.length" class="results-list">
<li v-for="product in products" :key="product.id">
<span>{{ product.name }}</span>
<span class="price">${{ product.price }}</span>
</li>
</ul>
</div>
</template>

<style lang="scss" scoped>
.search-page {
.search-bar {
display: flex;
gap: 1rem;

input {
flex-grow: 1;
padding: 0.5rem;
font-size: 1rem;
}

button {
padding: 0.5rem 1rem;
font-size: 1rem;
}
}

.results-list {
list-style: none;
li {
display: flex;
justify-content: space-between;
padding: 0.75rem;
background: #fff;
border-radius: 4px;
margin-bottom: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);

.price {
font-weight: bold;
color: #fb005c;
}
}
}
}
</style>

第四步:配置路由并添加入口

文件路径: src/router/index.ts (修改)

1
2
3
4
5
6
7
8
9
10
// ...
const routes: Array<RouteRecordRaw> = [
// ... 其他路由
{
path: '/search',
name: 'Search',
component: () => import('@/views/SearchPage.vue'),
},
];
// ...

文件路径: src/App.vue (修改)

1
2
3
<nav>
<router-link to="/search">商品搜索</router-link>
</nav>

这个“URL 驱动”的模式是构建健壮 Web 应用的核心原则。它的最大好处是,用户可以 收藏、分享这个带查询参数的 URL (.../search?name_like=Vue),当他们或其他用户再次访问时,watch 侦听器会自动触发搜索,确保看到完全相同的页面状态。这极大地提升了应用的可预测性和用户体验。


7.7. 进阶架构:嵌套路由

本章核心知识点:
当应用需要一个持久化的布局(例如,一个带有固定侧边栏和顶栏的后台界面),并且只在布局内部切换部分内容时,我们使用 嵌套路由

  • 父路由: 对应一个“布局”组件。这个布局组件内部必须包含一个自己的 <router-view>,这个 <router-view> 就是其子路由组件的渲染出口。
  • children 属性: 在路由配置中,我们在父路由的路由记录对象上,使用 children 数组来定义其嵌套的子路由。
  • 相对路径: children 数组中的子路由,其 path相对于父路由 的,它会被自动拼接在父路径之后
  • (例如,父 /dashboard + 子 profile = /dashboard/profile)。

到目前为止,我们创建的所有页面都是 顶级路由,它们会完全替换掉 App.vue 中唯一的那个 <router-view>。但在真实的应用,尤其是中后台管理系统中,我们经常会遇到一种更复杂的布局需求:一个包含侧边栏、顶部导航的持久化布局,只在布局内部的特定区域切换内容。

痛点背景: 如果我们尝试在每个页面组件(如 UserProfilePage.vue, SettingsPage.vue)内部都复制一份侧边栏和顶部导航的 HTML 和 CSS,代码会变得极度冗余且难以维护。一旦导航菜单需要修改,我们就必须去修改每一个相关的页面文件。

解决方案: 嵌套路由 (Nested Routes)。它允许一个父路由组件拥有自己的 <router-view>,作为其子路由组件的渲染出口,从而轻松实现复杂的多层级页面布局。

第一步:创建布局(父路由)组件

我们首先创建一个专门用于后台布局的组件。按照约定,这类“布局”性质的组件,我们通常放在一个新的 src/layouts 文件夹中。

文件路径: src/views/layouts/DashboardLayout.vue (新建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
<script setup lang="ts">
// 在这里可以添加布局相关的逻辑,例如获取用户信息、处理退出登录等
</script>

<template>
<div class="dashboard-layout">
<aside class="sidebar">
<nav>
<!-- 注意这里的 to 绑定,使用了命名路由,更利于维护 -->
<router-link :to="{ name: 'DashboardOverview' }">概览</router-link>
<router-link :to="{ name: 'UserProfile', params: { id: 1 } }">个人资料</router-link>
</nav>
</aside>
<main class="main-content">
<!-- 这个 router-view 就是子路由的渲染出口 -->
<router-view />
</main>
</div>
</template>

<style lang="scss" scoped>
.dashboard-layout {
display: flex;
height: 100vh;
}

.sidebar {
width: 200px;
background-color: #2c3e50;
color: #fff;
padding: 1.5rem;

nav {
display: flex;
flex-direction: column;
gap: 1rem;

a {
color: #bdc3c7;
text-decoration: none;
padding: 0.5rem;
border-radius: 4px;
transition: background-color 0.3s, color 0.3s;

&:hover {
background-color: #34495e;
}

&.router-link-exact-active {
background-color: #42b883;
color: #fff;
font-weight: bold;
}
}
}
}

.main-content {
flex-grow: 1;
padding: 2rem;
background-color: #f0f2f5;
overflow-y: auto;
}
</style>

为了让布局有内容可以展示,我们再创建一个简单的“仪表盘概览”页面。

文件路径: src/views/DashboardOverview.vue (新建)

1
2
3
4
5
6
<template>
<div class="page">
<h1>仪表盘概览</h1>
<p>这里是仪表盘的主内容区域。</p>
</div>
</template>

第二步:重构路由配置

现在,我们来进行最关键的一步:在 router/index.ts 中使用 children 属性来定义嵌套关系。

文件路径: src/router/index.ts (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ...前面的路由保持不变
{
path: "/dashboard",
name: "Dashboard",
component: () => import("@/views/layouts/DashboardLayout.vue"),
// (核心) 使用 children 数组定义嵌套路由
children: [
{
// 当访问 /dashboard 时,默认渲染此组件
path: "",
name: "DashboardOverview",
component: () => import("@/views/DashboardOverview.vue"),
},
{
// 注意:子路由的 path 不以 '/' 开头
// 它会自动拼接在父路由的 path 之后,最终路径为 /dashboard/users/: id
path: "users/:id",
name: "UserProfile", // name 保持不变,确保之前的链接依然可用
component: () => import("@/views/UserProfilePage.vue"),
},
],
},

请注意,我们将之前的 /users/:id 路由 移动 到了 /dashboard 路由的 children 数组中,并修改了其 path 为相对路径 users/:idUserProfile 现在成为了 Dashboard 布局的一部分。

第三步:更新入口

最后,我们更新 App.vue 中的主导航,并修改登录页的跳转逻辑,让用户可以进入我们全新的仪表盘布局。

文件路径: src/App.vue (修改)

1
2
3
4
5
6
7
<nav>
<router-link to="/">主页</router-link>
<router-link to="/about">关于</router-link>
<router-link to="/search">商品搜索</router-link>
<router-link to="/dashboard">仪表盘</router-link>
<router-link to="/login">登录</router-link>
</nav>

文件路径: src/views/LoginPage.vue (修改 handleLogin 函数)

1
2
3
4
5
6
7
// ...
function handleLogin() {
console.log('登录成功!');
// 登录后跳转到仪表盘主页
router.replace({ name: 'DashboardOverview' });
}
// ...

现在,刷新应用并点击“仪表盘”链接。你会看到 DashboardLayout 的整体布局被渲染出来,并且默认显示“仪表盘概览”的内容。接着,点击侧边栏的“个人资料”,你会发现,只有右侧的主内容区发生了变化,而侧边栏和整体布局保持不变。

嵌套路由是构建可维护、可扩展的复杂应用布局的基石。通过将共享的 UI 和逻辑封装在父路由组件中,我们极大地减少了代码冗余,并使得项目的结构层次更加清晰、更符合直觉。


7.8. 应用的安全防线:metabeforeEach 的入门 Demo

本节核心知识点:

  • 路由元信息 (meta): 学习如何使用 meta 字段为路由“贴标签”,标记出哪些页面需要权限。
  • 全局前置守卫 (beforeEach): 掌握 router.beforeEach 这个最重要、最常用的导航守卫,理解其作为应用“总保安”的角色。
  • 认证流程 Demo: 见证 Vue RouterPinia 的首次协同,共同实现一个简易但完整的路由拦截流程。

我们已经构建了包含多个页面的应用,甚至还有一个仪表盘布局。但目前,这个仪表盘是“不设防”的,任何人都可以通过直接在地址栏输入 /dashboard 来访问。这在真实世界中是绝不可接受的。

现在,我们需要为应用建立一道“安全防线”。在本节中,我们将构建一个简易的入门 Demo,它的唯一目标,是在 完全没有 API 和 Token 复杂度的干扰下,让你纯粹地、清晰地理解路由保护的 核心工作机制

第一步:“贴标签” - 为路由添加元信息 meta

我们需要一种方法来告诉路由器,哪些页面是“公共区域”,哪些是“VIP 室”。Vue Router 为此提供了 `meta` 字段。它允许我们为路由规则附加任意的自定义数据。

我们回到 router/index.ts,为 /dashboard 这条父级路由规则,添加一个 meta 对象,并在其中自定义一个 requiresAuth 属性。

文件路径: src/router/index.ts (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ...
const routes: Array<RouteRecordRaw> = [
// ... 其他路由
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/layouts/DashboardLayout.vue'),
// (核心) 添加 meta 字段
meta: {
requiresAuth: true, // 自定义一个“需要认证”的标记
},
// (重要) meta 字段会被父路由传递给所有子路由
children: [
// ... dashboard 的子路由
],
},
];
// ...

现在,/dashboard 路由及其所有子路由(如 UserProfile),都带上了一个 requiresAuth: true 的“身份证”,等待着我们的哨兵前来检查。

第二步:创建简易的 Pinia 认证 Store

为了配合这个入门 Demo,我们创建一个极简的 authStore,它只负责管理一个简单的登录状态。

文件路径: src/stores/authStore.ts (新建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { ref, computed } from 'vue';
import { defineStore } from 'pinia';

export const useAuthStore = defineStore('auth', () => {
// State: 一个简单的布尔值来表示认证状态
const isAuthenticated = ref(false);

// Getter
const isLoggedIn = computed(() => isAuthenticated.value);

// Actions
function login() {
isAuthenticated.value = true;
}

function logout() {
isAuthenticated.value = false;
}

return { isAuthenticated, isLoggedIn, login, logout };
});

第三步:“设哨兵” - 实现 beforeEach 全局前置守卫

router.beforeEach 是 Vue Router 提供的一个 全局前置守卫。它就像一个设置在所有道路入口的总哨兵,每一次 导航发生之前,都会先经过它的检查。

我们在 router/index.ts 文件的末尾,路由器实例导出之前,来注册这个守卫。

文件路径: src/router/index.ts (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
import { useAuthStore } from '@/stores/authStore'; // 导入我们的认证 Store

// ... routes 数组定义 ...

const router = createRouter({
// ... history 和 routes 配置 ...
});

// (核心) 注册全局前置守卫
router.beforeEach((to, from) => {
// 在守卫函数外部无法直接使用 useAuthStore(),
// 因为此时 Pinia 实例可能还未挂载。
// 但在守卫的回调函数内部,应用已初始化,可以安全调用。
const authStore = useAuthStore();

// 检查目标路由是否需要认证,以及用户当前是否已认证
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
// 如果是,则中断当前导航,并重定向到登录页
// return false; // 也可以直接取消导航
return {
name: 'Login',
// (可选) 将用户原本想访问的路径作为 query 参数传递,
// 登录成功后可以再跳回该页面
query: { redirect: to.fullPath },
};
}

// 如果不需要认证,或者用户已认证,则直接放行
// return true; // 或者不返回任何值
});

export default router;

第四步:提供交互 Demo

最后,我们在 App.vue 中添加两个临时按钮来模拟登录和登出,以便直观地测试我们的守卫效果。

文件路径: src/App.vue (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<script setup lang="ts">
import { useAuthStore } from '@/stores/authStore';
import { storeToRefs } from 'pinia';

const authStore = useAuthStore();
const { isLoggedIn } = storeToRefs(authStore);
</script>

<template>
<header class="main-header">
<nav>
<router-link to="/dashboard">仪表盘</router-link>
</nav>
<div class="auth-controls">
<span>当前状态: {{ isLoggedIn ? '已登录' : '未登录' }}</span>
<button v-if="!isLoggedIn" @click="authStore.login()">模拟登录</button>
<button v-else @click="authStore.logout()">模拟登出</button>
</div>
</header>

<main class="content-body">
<router-view />
</main>
</template>

<style lang="scss" scoped>
/* ... nav 样式 ... */
.main-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.auth-controls {
display: flex;
align-items: center;
gap: 1rem;
padding-right: 2rem;
button {
padding: 0.25rem 0.5rem;
}
}
</style>

现在,刷新你的应用:

  1. 未登录 状态下,尝试点击“仪表盘”,你会发现页面被立刻重定向到了登录页。
  2. 点击“模拟登录”按钮,状态变为“已登录”。
  3. 再次点击“仪表盘”,你将能成功进入。

我们已经成功地构建了一个功能完备的、简易的认证防线。最重要的是,我们已经清晰地掌握了路由保护的 核心工作机制:通过 meta 字段为路由打标,再通过 beforeEach 守卫检查这个标记和 Pinia 中的状态,最终决定导航的走向。这个基础模型,将是我们下一节构建工业级 Token 认证流程的坚实地基。


7.9. 项目实战:构建完整的 Token 认证工作流

本节核心知识点:

  • 后端 Token 认证: 使用 json-server 中间件,模拟一个真实的、颁发并校验 Token 的登录接口。
  • axios 拦截器: 实现请求拦截器,为所有需要认证的 API 请求自动附加 Authorization 头。
  • Pinia 状态联动: 将 Token 和用户信息作为核心状态存入 Pinia,并使其成为“唯一信源”。
  • 完整认证闭环: 走完从 登录 -> 获取 Token -> 存储 Token -> 请求自动携带 Token -> 后端校验 Token -> 路由守卫放行 的工业级标准流程。

我们在上一节用一个简单的布尔值开关,成功地演示了路由守卫的核心机制。但这并非真实世界的运作方式。

现在,让我们丢掉玩具枪,拿起真枪实弹,构筑一条由 `Token``API``状态管理` 共同驱动的、工业级的自动化认证防线。

第一步:升级 Mock 后端 (server.cjs)

我们的 json-server 需要进化,它不仅要能提供数据,还要能扮演“认证中心”的角色。

期望功能:

  1. 提供一个 POST /login 接口,接收用户名密码,成功后返回用户信息和 Token。
  2. 为其他需要保护的接口(如 /users/:id)添加一个校验中间件,检查请求头中是否包含合法的 Token。

文件路径: server.cjs (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
const jsonServer = require("json-server");
const path = require("path");

const server = jsonServer.create();
const router = jsonServer.router(path.join(__dirname, "db.json"));
const middlewares = jsonServer.defaults();

server.use(middlewares);

// 1. (核心) 添加一个 body-parser 中间件,才能解析 POST 请求的 body
server.use(jsonServer.bodyParser);

// 2. (新增) /login 登录接口
server.post("/login", (req, res) => {
const { username, password } = req.body;
// (简化版校验) 在真实项目中,这里应该是数据库查询和密码比对
if (username === 'admin' && password === '123456') {
res.status(200).jsonp({
user: router.db.get('users').find({ id: 1 }).value(), // 返回 db.json 中的第一个用户
token: 'my-secret-token-prorise-is-awesome', // 颁发一个 Token
});
} else {
res.status(400).jsonp({ error: 'Invalid username or password' });
}
});

// 3. (新增) 权限校验中间件
server.use((req, res, next) => {
// 定义需要保护的路由,例如所有 /users 开头的 GET 请求
const protectedRoutes = /^\/users\/.*/;
if (req.method === 'GET' && protectedRoutes.test(req.path)) {
// 检查 Authorization 请求头
if (req.headers.authorization === 'Bearer my-secret-token-prorise-is-awesome') {
next(); // 凭证正确,放行
} else {
res.status(401).jsonp({ error: 'Unauthorized: Access token is missing or invalid.' });
}
} else {
next(); // 对于其他所有请求,直接放行
}
});

server.use(router);

server.listen(3001, () => {
console.log("JSON Server with Auth is running on http://localhost:3001");
});

第二步:升级 API 请求层 (api/client.ts)

我们的 apiClient 需要变得更“智能”,它必须能在每次发送请求前,自动地从 Pinia Store 中读取 Token,并将其附加到请求头中。这正是 axios 请求拦截器 的完美应用场景。

文件路径: src/api/client.ts (修改请求拦截器)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import axios from "axios";
import { useAuthStore } from "@/stores/authStore";
// 1. 创建 axios 实例
const apiClient = axios.create({
// 从 Vite 环境变量中读取 API 基地址
baseURL: "http://localhost:3001",
// 设置请求超时时间
timeout: 10000, // 10 秒
});

// (核心) 实现请求拦截器
apiClient.interceptors.request.use(
(config) => {
// 在 Pinia 初始化之后,我们才能在拦截器中安全地使用 store
const authStore = useAuthStore();
if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);

// 3. 添加响应拦截器 (为未来的统一错误处理等功能预留位置)
apiClient.interceptors.response.use(
(response) => {
// axios 会将后端返回的数据包裹在 data 属性中
return response.data;
},
(error) => {
// 在这里可以进行全局的错误处理
console.error("API Error:", error);
return Promise.reject(error);
}
);

export default apiClient;

第三步:升级 Pinia (authStore.ts)

我们的 authStore 将不再管理一个简单的布尔值,而是要负责管理真实的 usertoken,并处理真正的登录 API 调用。

文件路径: src/stores/authStore.ts (重构)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { ref, computed } from 'vue';
import { defineStore } from 'pinia';
import type { User } from '@/types/user';
import apiClient from '@/api/client';

export const useAuthStore = defineStore('auth', () => {
// State: 管理 user 和 token
const user = ref<User | null>(null);
const token = ref<string | null>(null);

// Getter
const isLoggedIn = computed(() => !!token.value && !!user.value);

// Actions
async function login(credentials: {username: string, password: string}) {
// (为简化,这里省略了 isLoading 和 error 状态管理)
const response = await apiClient.post('/login', credentials);
user.value = response.user;
token.value = response.token;
}

function logout() {
user.value = null;
token.value = null;
}

return { user, token, isLoggedIn, login, logout };
},
{
// 开启持久化,并只持久化 token
persist: {
storage: localStorage,
paths: ['token', 'user'], // 同时持久化 user 和 token
}
}
);

我们在这里巧妙地结合了上一章学习的 pinia-plugin-persistedstate 插件,将 tokenuser 持久化到 localStorage。这样,即使用户刷新页面,登录状态也能被保留。

第四步:升级登录页 (LoginPage.vue)

登录页现在需要一个真实的表单,来调用我们全新的 login Action。

文件路径: src/views/LoginPage.vue (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/authStore';

const router = useRouter();
const authStore = useAuthStore();

const username = ref('admin');
const password = ref('123456');

async function handleLogin() {
try {
await authStore.login({
username: username.value,
password: password.value,
});
router.replace({ name: 'Dashboard' });
} catch (error) {
alert('登录失败: ' + (error as Error).message);
}
}
</script>

<template>
<div class="page">
<h1>登录</h1>
<form @submit.prevent="handleLogin" class="login-form">
<input v-model="username" type="text" placeholder="用户名 (admin)" />
<input v-model="password" type="password" placeholder="密码 (123456)" />
<button type="submit">登录</button>
</form>
</div>
</template>

<style lang="scss" scoped>
.login-form {
display: flex;
flex-direction: column;
gap: 1rem;
width: 300px;
}
</style>

第五步:升级导航守卫 (router/index.ts)

最后一步,我们的导航守卫逻辑几乎不需要改变,因为它依赖的 authStore.isLoggedIn Getter 现在是由真实的 token 驱动的,变得更加可靠和有意义。

文件路径: src/router/index.ts (确认守卫逻辑)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ...
import { useAuthStore } from '@/stores/authStore';

// ...
router.beforeEach((to, from) => {
const authStore = useAuthStore();

if (to.meta.requiresAuth && !authStore.isLoggedIn) {
return {
name: 'Login',
query: { redirect: to.fullPath },
};
}
});
// ...

第六步:更新应用主布局 (App.vue)

现在,我们的 App.vue 将扮演最终的角色:根据 authStore 的真实登录状态,动态地展示不同的导航和操作项。

  • 如果用户未登录: 在导航栏显示“登录”链接。
  • 如果用户已登录: 显示用户的名称,并提供一个“登出”按钮。

文件路径: src/App.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
<script setup lang="ts">
import { useAuthStore } from '@/stores/authStore';
import { storeToRefs } from 'pinia';
import { useRouter } from 'vue-router';

const authStore = useAuthStore();
const router = useRouter();

// 解构出需要响应式使用的数据
const { isLoggedIn, user } = storeToRefs(authStore);

// 登出逻辑
async function handleLogout() {
await authStore.logout();
// 登出后,重定向到登录页
router.push({ name: 'Login' });
}
</script>

<template>
<header class="main-header">
<nav>
<router-link to="/">主页</router-link>
<router-link to="/about">关于</router-link>
<router-link to="/search">商品搜索</router-link>
<router-link v-if="isLoggedIn" to="/dashboard">仪表盘</router-link>
</nav>

<div class="auth-controls">
<div v-if="isLoggedIn && user" class="user-info">
<span>欢迎, {{ user.name }}</span>
<button @click="handleLogout">登出</button>
</div>
<router-link v-else :to="{ name: 'Login' }">
<button>登录</button>
</router-link>
</div>
</header>

<main class="content-body">
<router-view />
</main>
</template>

<style lang="scss" scoped>
.main-header {
display: flex;
justify-content: space-between;
align-items: center;
background-color: #fff;
box-shadow: 0 2px 8px #f0f1f2;
}

nav {
display: flex;
gap: 1.5rem;
padding: 1rem 2rem;
a {
text-decoration: none;
color: #333;
font-weight: 500;
transition: color 0.3s;
&:hover {
color: #42b883;
}
&.router-link-exact-active {
color: #42b883;
border-bottom: 2px solid #42b883;
}
}
}

.auth-controls {
padding-right: 2rem;
button {
padding: 0.25rem 0.75rem;
cursor: pointer;
}
.user-info {
display: flex;
align-items: center;
gap: 1rem;
}
}
</style>

7.10. 导航守卫全景:生命周期钩子概览

本节核心知识点:

  • 导航生命周期: 建立一个关于“导航”从开始到结束的完整心智模型。
  • 钩子概览: 快速了解除 beforeEach 之外的其他全局守卫和组件内守卫。
  • 核心场景: 掌握每个守卫最典型的应用场景,以便在未来遇到问题时能迅速找到正确的解决方案。

我们已经深入实践了 beforeEach 这个最重要的全局前置守卫。但实际上,一次完整的导航就像一段拥有多个关键时间点的“生命周期”,Vue Router 在这些时间点上都为我们预留了“钩子”,让我们有机会介入并执行相应的逻辑。

本节,我们将像查阅清单一样,快速概览这些守卫的用法和核心场景。我们暂时不进行复杂的编码实践,而是先将这些工具收入我们的“知识库”,在后续的实战章节中遇到合适的场景时,我们再来逐步展开应用。

全局后置钩子:afterEach

  • 时机: 在导航 已经成功确认,页面内容也已渲染更新 之后 被调用。
  • 特点: 它 不会 接收 next 函数,也 不能 改变导航本身。它是一个纯粹的“收尾”钩子。
  • 核心场景:
    1. 动态更新页面标题: 这是最常见的用法。根据目标路由 to.meta.title 来设置 document.title
    2. 发送页面分析 (Analytics): 当需要统计页面浏览量 (PV) 时,可以在这里向分析服务器发送数据。
    3. 关闭全局加载指示器: 如果你在 beforeEach 中开启了一个全局 Loading 动画,afterEach 是关闭它的最佳位置。
  • 语法示例:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 文件路径: src/router/index.ts
    router.afterEach((to, from) => {
    // 假设我们在 meta 中定义了 title
    if (to.meta.title) {
    document.title = `Prorise - ${to.meta.title}`;
    } else {
    document.title = 'Prorise';
    }
    });

全局解析守卫:beforeResolve

  • 时机: 在导航被确认之前,同时在所有组件内守卫和异步路由组件被解析之后 被调用。可以理解为 beforeEach 和组件内守卫都执行完毕,马上就要真正跳转前的最后一个“确认”关卡。
  • 核心场景: 这是一个相对高级的钩子,通常用于确保在展示页面前,所有与该路由相关的数据或权限都已准备就绪。例如,在进入某个页面前,需要先异步请求一份所有子组件都依赖的通用数据。

组件内守卫

除了全局守卫,我们还可以在组件内部直接定义只对当前组件生效的守卫。

  • onBeforeRouteLeave

    • 时机: 当导航正要 离开 当前组件渲染的路由时被调用。
    • 核心场景: 防止用户在未保存更改的情况下意外离开。这是它最经典、最重要的应用场景。
    • 语法示例:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      <script setup lang="ts">
      import { onBeforeRouteLeave } from 'vue-router';
      import { ref } from 'vue';

      const isFormDirty = ref(false); // 假设这个状态会根据用户输入而改变

      onBeforeRouteLeave((to, from) => {
      if (isFormDirty.value) {
      const answer = window.confirm('你有未保存的更改,确定要离开吗?');
      if (!answer) {
      return false; // 如果用户点击“取消”,则中断导航
      }
      }
      });
      </script>
  • onBeforeRouteUpdate

    • 时机: 当 当前路由改变,但该组件被复用 时调用。
    • 核心场景: 最典型的例子就是我们在 UserProfilePage.vue 中遇到的情况:从 /users/1 导航到 /users/2。组件实例被复用,但路由参数 id 发生了变化。我们之前使用 watch 监听 route.params.id 来解决,而 onBeforeRouteUpdate 提供了另一种专门处理这种情况的方式。
    • 语法示例:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      <script setup lang="ts">
      import { onBeforeRouteUpdate } from 'vue-router';
      import { useUserStore } from '@/stores/user';

      const userStore = useUserStore();

      onBeforeRouteUpdate((to, from) => {
      // 仅当 id 变化时,才重新获取数据
      if (to.params.id !== from.params.id) {
      userStore.fetchUser(Number(to.params.id));
      }
      });
      </script>

我们已经快速概览了 Vue Router 提供的导航守卫“全家桶”。现在,你只需要记住:beforeEach 负责全局准入控制afterEach 负责全局收尾工作,而 onBeforeRouteLeave 负责保护组件内的数据。掌握这三者,你就已经能应对 99% 的业务场景了。