第七章:应用的“交通枢纽” · Vue Router 全面指南 摘要 : 在本章中,我们将一气呵成地掌握 Vue Router 的全部核心知识。我们将通过构建一个包含 登录、后台布局、动态用户详情 等模块的微型应用,将路由的每一个核心 API 和设计模式,都融入到真实、连贯的开发流程中。在这个过程中,Pinia
将作为我们的“中央认证系统”,JSON Server
作为“用户数据库”,SCSS
作为“视觉设计师”,与 Vue Router
协同完成一个工业级的导航与权限控制流程。
在本章中,我们将像搭建城市交通网络一样,精确地构建应用的导航系统:
首先,我们将从 零开始,构筑一个完整的、包含所有必要工具的路由实战环境 。 接着,我们将学习 路由的核心组件 和 两种导航方式 :声明式与编程式。 然后,我们将深入 路由传参 的两种核心模式:动态参数和查询参数,并与 Pinia、JSON Server 深度联动。 之后,我们将探讨 嵌套路由 和 懒加载 ,构建复杂的页面布局并优化性能。 最后,我们将利用 路由元信息 和 导航守卫 ,结合 Pinia 打造一条坚不可摧的自动化“认证防线”。 7.1. 专业基石:从零构筑一个完整的路由实战环境 第一步:初始化 Vite 项目 我们从一个全新的、最纯净的 Vite + TypeScript
项目开始。
1 pnpm create vite vue-router-practice --template vue-ts
第二步:安装所有核心依赖 进入项目目录,然后我们将一次性安装本指南所需的全部核心依赖。
1 2 cd vue-router-practicepnpm 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" : { "target" : "ES2020" , "module" : "ESNext" , "strict" : true , "baseUrl" : "." , "paths" : { "@/*" : [ "src/*" ] } } , "include" : [ "src/**/*.ts" , "src/**/*.d.ts" , "src/**/*.tsx" , "src/**/*.vue" ] }
重要提示 :请确保根目录的 tsconfig.json
文件保持其初始状态,不要在其中添加 baseUrl
或 paths
,否则会因为配置覆盖而导致别名在 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 import { fileURLToPath, URL } from 'node:url' import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig ({ plugins : [vue ()], resolve : { alias : { '@' : fileURLToPath (new URL ('./src' , import .meta .url )) } } })
提示 :如果 import 'node:url'
提示找不到模块,你需要安装 Node.js 的类型定义:npm install -D @types/node
或 pnpm add -D @types/node
。
3. 重启 TypeScript 服务 (关键步骤)
修改 tsconfig
文件后,VSCode 不会立即应用新配置。你需要手动重启 TS 服务:
在 VSCode 中按下 Ctrl+Shift+P
(Windows/Linux) 或 Cmd+Shift+P
(Mac)。 输入并选择 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 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); server.use (router); router.render = (req, res ) => { res.status (200 ).jsonp ({ code : 200 , message : "Success" , data : res.locals .data , }); }; 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. 创建路由实例:createRouter
与 history
模式 在上一节中,我们已经创建了 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 : createWebHistory (import .meta .env .BASE_URL ), routes : [ ], }); export default router;
createRouter
函数接收一个配置对象,其中 history
属性用于指定路由模式。除了我们首选的 createWebHistory
,Vue Router 还提供了其他模式以应对不同场景。HTML5 模式 这是现代单页应用的 最佳实践 。它利用了浏览器原生的 history.pushState
API,URL 看起来就像传统的网站一样,非常美观。Hash 模式 这种模式会在 URL 中使用一个 #
符号(例如 https://example.com/#/about
)。它的最大优点是 兼容性极好 ,无需任何服务器端配置即可运行。内存模式 这种模式不与浏览器地址栏交互,主要用于非浏览器环境,例如 服务端渲染 (SSR) 。
7.3. 全局注册与核心组件:<router-link>
和 <router-view>
本章核心知识点 : 在创建好路由实例后,必须通过 app.use(router)
将其 全局注册 到 Vue 应用中,使其生效。注册后,Vue Router 提供了两个 核心全局组件 :
<router-view>
: 路由内容的 渲染出口 。它是一个占位符,用于显示当前 URL 匹配到的组件。<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' ; const app = createApp (App );app.use (createPinia ()); app.use (router); 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' ;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' ;const router = useRouter ();function handleLogin ( ) { console .log ('正在执行登录逻辑...' ); setTimeout (() => { console .log ('登录成功!' ); 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 > = [ { 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 ('登录成功!' ); 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
。
接着,我们创建 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" ;const apiClient = axios.create ({ baseURL : "http://localhost:3001" , timeout : 10000 , }); apiClient.interceptors .request .use ( (config ) => { return config; }, (error ) => { return Promise .reject (error); } ); apiClient.interceptors .response .use ( (response ) => { 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" , () => { 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 { const response = await getUserById (userId); user.value = response.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 > = [ { path : '/users/: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 ( () => route.params .id , (newId ) => { if (newId && typeof newId === 'string' ) { userStore.fetchUser (Number (newId)); } }, { 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,通过我们封装的 apiClient
向 json-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 ; }
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) || "" );function onSearch ( ) { router.push ({ name : "Search" , query : { name_like : keyword.value } }); } watch ( () => route.query .name_like , (newKeyword ) => { keyword.value = newKeyword as string; productStore.searchProducts ({ name_like : newKeyword as string }); }, { 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 > <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 /> </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 : [ { path : "" , name : "DashboardOverview" , component : () => import ("@/views/DashboardOverview.vue" ), }, { path : "users/:id" , name : "UserProfile" , component : () => import ("@/views/UserProfilePage.vue" ), }, ], },
请注意,我们将之前的 /users/:id
路由 移动 到了 /dashboard
路由的 children
数组中,并修改了其 path
为相对路径 users/:id
。UserProfile
现在成为了 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 和逻辑封装在父路由组件中,我们极大地减少了代码冗余,并使得项目的结构层次更加清晰、更符合直觉。
本节核心知识点 :
路由元信息 (meta
) : 学习如何使用 meta
字段为路由“贴标签”,标记出哪些页面需要权限。全局前置守卫 (beforeEach
) : 掌握 router.beforeEach
这个最重要、最常用的导航守卫,理解其作为应用“总保安”的角色。认证流程 Demo : 见证 Vue Router
与 Pinia
的首次协同,共同实现一个简易但完整的路由拦截流程。我们已经构建了包含多个页面的应用,甚至还有一个仪表盘布局。但目前,这个仪表盘是“不设防”的,任何人都可以通过直接在地址栏输入 /dashboard
来访问。这在真实世界中是绝不可接受的。
现在,我们需要为应用建立一道“安全防线”。在本节中,我们将构建一个简易的入门 Demo,它的唯一目标,是在 完全没有 API 和 Token 复杂度的干扰下 ,让你纯粹地、清晰地理解路由保护的 核心工作机制 。
我们需要一种方法来告诉路由器,哪些页面是“公共区域”,哪些是“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 : { requiresAuth : true , }, children : [ ], }, ];
现在,/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' , () => { const isAuthenticated = ref (false ); const isLoggedIn = computed (() => isAuthenticated.value ); 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' ; const router = createRouter ({ }); router.beforeEach ((to, from ) => { const authStore = useAuthStore (); if (to.meta .requiresAuth && !authStore.isLoggedIn ) { return { name : 'Login' , query : { redirect : to.fullPath }, }; } }); 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 > .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 >
现在,刷新你的应用:
在 未登录 状态下,尝试点击“仪表盘”,你会发现页面被立刻重定向到了登录页。 点击“模拟登录”按钮,状态变为“已登录”。 再次点击“仪表盘”,你将能成功进入。 我们已经成功地构建了一个功能完备的、简易的认证防线。最重要的是,我们已经清晰地掌握了路由保护的 核心工作机制 :通过 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
需要进化,它不仅要能提供数据,还要能扮演“认证中心”的角色。
期望功能 :
提供一个 POST /login
接口,接收用户名密码,成功后返回用户信息和 Token。 为其他需要保护的接口(如 /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); server.use (jsonServer.bodyParser ); 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 (), token : 'my-secret-token-prorise-is-awesome' , }); } else { res.status (400 ).jsonp ({ error : 'Invalid username or password' }); } }); server.use ((req, res, next ) => { const protectedRoutes = /^\/users\/.*/ ; if (req.method === 'GET' && protectedRoutes.test (req.path )) { 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" ;const apiClient = axios.create ({ baseURL : "http://localhost:3001" , timeout : 10000 , }); apiClient.interceptors .request .use ( (config ) => { const authStore = useAuthStore (); if (authStore.token ) { config.headers .Authorization = `Bearer ${authStore.token} ` ; } return config; }, (error ) => { return Promise .reject (error); } ); apiClient.interceptors .response .use ( (response ) => { return response.data ; }, (error ) => { console .error ("API Error:" , error); return Promise .reject (error); } ); export default apiClient;
第三步:升级 Pinia (authStore.ts
) 我们的 authStore
将不再管理一个简单的布尔值,而是要负责管理真实的 user
和 token
,并处理真正的登录 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' , () => { const user = ref<User | null >(null ); const token = ref<string | null >(null ); const isLoggedIn = computed (() => !!token.value && !!user.value ); async function login (credentials : {username: string , password: string } ) { 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 }; }, { persist : { storage : localStorage , paths : ['token' , 'user' ], } } );
我们在这里巧妙地结合了上一章学习的 pinia-plugin-persistedstate
插件,将 token
和 user
持久化到 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
函数,也 不能 改变导航本身。它是一个纯粹的“收尾”钩子。核心场景 :动态更新页面标题 : 这是最常见的用法。根据目标路由 to.meta.title
来设置 document.title
。发送页面分析 (Analytics) : 当需要统计页面浏览量 (PV) 时,可以在这里向分析服务器发送数据。关闭全局加载指示器 : 如果你在 beforeEach
中开启了一个全局 Loading 动画,afterEach
是关闭它的最佳位置。语法示例 :1 2 3 4 5 6 7 8 9 router.afterEach ((to, from ) => { 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 ) => { if (to.params .id !== from .params .id ) { userStore.fetchUser (Number (to.params .id )); } }); </script >
我们已经快速概览了 Vue Router 提供的导航守卫“全家桶”。现在,你只需要记住:beforeEach
负责全局准入控制 ,afterEach
负责全局收尾工作 ,而 onBeforeRouteLeave
负责保护组件内的数据 。掌握这三者,你就已经能应对 99% 的业务场景了。