Appearance
Vue Router 和 Pinia 是 Vue.js 生态系统的核心组件,也是构建大型应用的必备技术栈。在面试中,对它们的理解深度直接反映了候选人的项目架构和状态管理设计能力
Vue Router 核心概念与应用
Vue Router 是 Vue.js 的官方路由管理器。它能帮助我们构建单页面应用(SPA),将组件映射到不同的 URL 路径。
首先,我们定义一个路由表 routes,它是一个由多个路由配置对象组成的数组,每个对象描述一个页面路径与对应组件的映射关系。
javascript
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'
// 1. 定义路由表
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/about',
name: 'About',
component: About
}
]
// 2. 创建路由实例
const router = createRouter({
// 3. 配置路由模式
history: createWebHistory(), // 使用 HTML5 History 模式
routes // 等价于 routes: routes
})
export default router这里有几个关键点值得注意:
- history: createWebHistory() 表示使用 HTML5 的 History 模式,URL 是普通路径形式,例如(https://example.com/about), 没有 # 这种模式的好处是更美观、更贴近真实网站结构,但需要服务器做一些额外配置——无论访问哪个路径,都应该返回 index.html,否则刷新页面会 404。
- 另一种方式:createWebHashHistory() 如果不想配置服务器,可以使用 hash 模式,路径中会带 #,例如:https://example.com/#/about。这种方式兼容性更好,但 URL 略显"丑陋"。
在 Vue 组件中使用
使用 <router-link> 生成导航链接,使用 <router-view> 渲染当前路由匹配的组件。
vue
<!-- App.vue -->
<template>
<header>
<nav>
<router-link to="/">首页</router-link> |
<router-link to="/about">关于</router-link>
</nav>
</header>
<!-- 路由出口:当前路由匹配的组件将在这里渲染 -->
<main>
<router-view />
</main>
</template>2. 高级路由特性
动态路由匹配
当需要将具有给定模式的路由映射到同一个组件时,可以使用动态路由。例如,一个 User 组件需要根据不同的用户 ID 显示不同内容。
javascript
// router/index.js
import User from '@/views/User.vue'
const routes = [
// `:id` 是一个动态段,可以匹配任意字符串
{
path: '/user/:id',
name: 'User',
component: User,
props: true // 将路由参数(如 id)作为 props 传递给组件
}
]在 User.vue 组件中,可以通过 props 接收 id:
vue
<script setup>
// 在 <script setup> 中,通过 defineProps 接收
const props = defineProps(['id'])
console.log('当前用户ID:', props.id)
</script>嵌套路由
对于复杂的布局,可以使用嵌套路由。父路由拥有自己的 <router-view> 来渲染子路由组件。
javascript
// router/index.js
// ... 假设已导入 User, UserProfile, UserSettings 组件
{
path: '/user/:id',
name: 'User',
component: User, // User.vue 是父路由组件
props: true,
children: [ // 子路由
{
// 当 URL 是 /user/:id 时,UserProfile 会在 User 的 <router-view> 中渲染
path: '',
component: UserProfile
},
{
// 当 URL 是 /user/:id/settings 时,UserSettings 会在 User 的 <router-view> 中渲染
path: 'settings',
component: UserSettings
}
]
}User.vue 组件需要包含 <router-view> 来显示子组件:
vue
<!-- User.vue -->
<template>
<div>
<h1>用户 {{ id }} 的主页</h1>
<!-- 子路由组件的渲染出口 -->
<router-view />
</div>
</template>路由懒加载
当应用规模变大时,可以将不同路由的组件分割成不同的代码块(chunk),然后在访问路由时才加载它们。这可以显著提升首屏加载速度。
javascript
// router/index.js
const routes = [
{
path: '/dashboard',
name: 'Dashboard',
// 只有在访问 /dashboard 时,才会下载并执行 Dashboard.vue 的代码
component: () => import('@/views/Dashboard.vue')
}
]重定向与别名
- 重定向: 当用户访问 /home 时,URL 会被替换成 /,然后匹配 / 的路由。
- 别名: 访问 /people 的效果与访问 /users 完全一样,但 URL 保持为 /people。
javascript
// ... 假设已导入 UserList 组件
const routes = [
// 重定向
{ path: '/home', redirect: '/' },
// 别名
{ path: '/users', component: UserList, alias: '/people' }
]3. 编程式导航
除了使用 <router-link>,我们还可以在 JavaScript 代码中控制路由跳转。
javascript
// 在组件的 <script setup> 中
import { useRouter } from 'vue-router'
const router = useRouter()
// 跳转到指定路径
const goToDashboard = () => {
router.push('/dashboard')
}
// 带参数跳转
const goToUser = userId => {
router.push({ name: 'User', params: { id: userId } })
}
// 替换当前历史记录,用户无法通过后退按钮返回
const replaceToLogin = () => {
router.replace('/login')
}
// 前进或后退
const goBack = () => {
router.go(-1) // 或 router.back()
}4. 路由守卫
路由守卫(Navigation Guards)提供了在路由跳转过程中执行逻辑的机会,常用于权限验证、数据预取等场景。
全局前置守卫 (beforeEach)
beforeEach 是最常用的守卫,它在任何路由跳转发生之前被调用。
javascript
// router/index.js
// import { useUserStore } from '@/stores/user' // 示例:从 Pinia store 获取用户状态
router.beforeEach((to, from, next) => {
const isAuthenticated = false // 示例:实际应从 store 或 cookie 获取
// 检查路由是否需要认证
if (to.meta.requiresAuth && !isAuthenticated) {
// 用户未登录,重定向到登录页
next({ name: 'Login', query: { redirect: to.fullPath } })
} else {
// 允许导航
next()
}
})- to: 即将进入的目标路由对象。
- from: 当前导航正要离开的路由对象。
- next: 必须调用的函数,以解析这个钩子。
next(): 继续导航。next(false): 中断当前导航。next('/path')或next({ name: '...' }): 重定向到新的地址。
Pinia 状态管理深入
Pinia 是 Vue 官方推荐的状态管理库。它以更简洁的 API、出色的 TypeScript 支持和对 Composition API 的友好性,成为了 Vuex 的现代替代品。
1. Store 定义与使用
一个 Store(仓库)是使用 defineStore 定义的,它包含三部分核心内容:state、getters 和 actions。
- State: 响应式的数据源,类似于组件的 data。
- Getters: 计算属性,类似于组件的 computed,用于派生 state。
- Actions: 方法,类似于组件的 methods,用于修改 state,可以包含异步操作。
基础 Store 示例 (stores/user.js)
javascript
// stores/user.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
// --- State ---
const user = ref(null)
const token = ref('')
// --- Getters ---
const isAuthenticated = computed(() => !!user.value && !!token.value)
const userName = computed(() => user.value?.name || '游客')
// --- Actions ---
async function login(credentials) {
// 假设 authAPI 是一个处理网络请求的模块
// const response = await authAPI.login(credentials);
// user.value = response.user;
// token.value = response.token;
// 模拟登录成功
user.value = { name: '张三', email: 'zhangsan@example.com' }
token.value = 'fake-jwt-token-string'
}
function logout() {
user.value = null
token.value = ''
}
return {
// State
user,
token,
// Getters
isAuthenticated,
userName,
// Actions
login,
logout
}
})注意:在 Composition API 风格的 store 中,ref() 定义 state,computed() 定义 getters,普通 function 定义 actions。
在组件中使用 Store
vue
<script setup>
import { useUserStore } from '@/stores/user'
// 获取 store 实例
const userStore = useUserStore()
// 访问 state 和 getters (具有响应性)
console.log(userStore.userName)
// 调用 actions
const handleLogin = async () => {
try {
await userStore.login({ username: 'test', password: '123' })
console.log('登录成功!')
} catch (error) {
console.error('登录失败:', error)
}
}
</script>
<template>
<div>
<p>用户: {{ userStore.userName }}</p>
<p v-if="userStore.isAuthenticated">状态: 已登录</p>
<button v-if="!userStore.isAuthenticated" @click="handleLogin">登录</button>
<button v-else @click="userStore.logout()">登出</button>
</div>
</template>2. Store 组合
Pinia 的一大优势是其模块化的设计,Store 之间可以轻松地相互调用。
javascript
// stores/cart.js
import { defineStore } from 'pinia'
import { useUserStore } from './user' // 导入用户 store
export const useCartStore = defineStore('cart', () => {
const userStore = useUserStore() // 在另一个 store 中获取实例
async function checkout() {
if (userStore.isAuthenticated) {
console.log(`用户 ${userStore.userName} 正在结算...`)
// 执行结算逻辑...
} else {
console.log('请先登录再结算。')
}
}
return { checkout }
})📋 面试常见追问及应对
"Pinia 相比 Vuex 有什么优势?"
答题思路:从设计理念、API 简洁性和开发体验三个方面进行对比,并用表格清晰展示。
| 特性 | Pinia | Vuex (4.x) |
|---|---|---|
| 核心概念 | State, Getters, Actions | State, Getters, Mutations, Actions |
| State 修改 | 在 Actions 中直接修改 | 必须通过 Mutations,Actions commit Mutation |
| API 风格 | 更贴近 Vue 3 Composition API,直观 | 概念较多,有一定模板代码 |
| TypeScript | 完美的类型推断,无需额外类型定义 | 需要复杂的类型体操来获得良好支持 |
| 模块化 | 天然的模块化,每个 store 都是一个独立的模块 | 通过 modules 配置,有命名空间概念 |
| 代码体积 | 非常轻量,仅约 1KB | 体积相对较大 |
总结:Pinia 的主要优势在于其简洁直观的 API、移除了 Mutations的心智负担、出色的 TypeScript 支持以及更自然的模块化方式。它让状态管理代码更易于编写和维护。
"如何设计一个大型应用的路由结构?"
答题思路:从模块化、权限控制、性能和可维护性四个角度阐述。
一个健壮的路由结构应该具备以下特点:
1. 模块化
按业务功能或页面区域(如 后台管理、用户中心)将路由配置拆分到不同的文件中,再由主路由文件统一导入整合。这样可以避免单个路由文件过于庞大。
2. 权限控制
- 在路由的 meta 字段中定义权限信息,如
meta: { roles: ['admin'] }。 - 使用全局路由守卫
router.beforeEach来检查用户角色和 meta 字段,实现页面访问控制。 - 对于需要根据用户权限动态生成的菜单,可以后端返回路由数据,前端进行动态添加 (
router.addRoute())。
3. 性能优化
- 全量使用懒加载:对所有页面级组件使用
() => import(...)进行懒加载,这是最关键的性能优化手段。 - 预加载(Prefetching):对于用户很可能访问的下一个页面,可以考虑使用 webpack 的魔法注释
/* webpackPrefetch: true */进行资源预加载。
4. 可维护性
- 统一命名:为路由 name 属性制定清晰、唯一的命名规范,方便编程式导航和缓存控制(
<keep-alive>)。 - 目录结构清晰:将路由配置文件、视图组件、路由相关的工具函数等分门别类存放。
"如何处理 Pinia 中的异步操作和错误?"
答题思路:展示一个包含 loading 和 error 状态管理的标准异步 action 模式。
在 Pinia 中,异步操作通常在 actions 中使用 async/await 完成。一个健壮的实践是同时管理加载状态(loading)和错误状态(error)。
javascript
// stores/data.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useDataStore = defineStore('data', () => {
const data = ref(null)
const loading = ref(false)
const error = ref(null)
async function fetchData() {
loading.value = true
error.value = null // 重置之前的错误
try {
// 假设 myApi.get() 是一个返回 Promise 的 API 请求函数
const response = await myApi.get('/some-data')
data.value = response.data
} catch (e) {
// 捕获错误并存储
error.value = e
// 可以选择将错误再次抛出,让调用方处理 UI 反馈,如弹窗提示
throw e
} finally {
// 确保 loading 状态总是被重置
loading.value = false
}
}
return { data, loading, error, fetchData }
})最佳实践
- 分离状态:用 loading 和 error 两个独立的 state 来追踪异步流程。
- UI 绑定:在组件中,可以直接使用
v-if="store.loading"显示加载指示器,或v-if="store.error"显示错误信息。 - 错误抛出:在 catch 块中再次 throw 错误,可以让组件层捕获到具体的失败,从而执行如"消息提示"、"跳转页面"等交互。
- finally 清理:使用 finally 确保无论成功还是失败,loading 状态都会被正确地设置为 false。