前言
Vue 3 生态中,Vue Router 4 和 Pinia 是构建中大型单页应用(SPA)的两大核心支柱。Vue Router 负责页面导航与路由管理,Pinia 则取代 Vuex 成为官方推荐的状态管理方案。本文将从基础概念讲起,逐步深入到高级用法和实战场景,帮助你全面掌握这两大工具。
一、Vue Router 4 基础
1.1 路由模式:三种 History 模式
Vue Router 4 支持三种路由模式,分别适用于不同的部署场景。
| 模式 | 创建方式 | 原理 | 适用场景 |
|---|---|---|---|
| HTML5 History | createWebHistory() |
利用 History API,URL 中无 # |
有后端配合的正式部署 |
| Hash History | createWebHashHistory() |
利用 URL hash(# 后内容不发送到服务端) |
静态部署、无需后端配置 |
| Memory History | createMemoryHistory() |
基于内存,不操作浏览器 URL | 服务端渲染、测试 |
import { createRouter, createWebHistory, createWebHashHistory, createMemoryHistory } from 'vue-router'
// HTML5 History 模式(推荐,需要后端支持)
const router1 = createRouter({
history: createWebHistory('/app/'),
routes: []
})
// Hash 模式(兼容性好,无需后端配置)
const router2 = createRouter({
history: createWebHashHistory(),
routes: []
})
// Memory 模式(SSR 或测试环境)
const router3 = createRouter({
history: createMemoryHistory(),
routes: []
})选择建议:
- 开发阶段或静态托管(GitHub Pages、Netlify)→ 使用
createWebHashHistory - 正式生产且后端正确配置了回退 → 使用
createWebHistory(URL 更美观) - SSR(Nuxt 等框架内层)→ 使用
createMemoryHistory
1.2 路由配置:routes 数组与 component 映射
路由配置的核心是将 URL 路径映射到对应的组件。
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'
import User from '@/views/User.vue'
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/about',
name: 'About',
component: About
},
{
path: '/user/:id',
name: 'User',
component: User
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router在根组件中通过 <router-view> 渲染匹配的组件:
<template>
<div id="app">
<header>
<nav>
<router-link to="/">首页</router-link>
<router-link to="/about">关于</router-link>
</nav>
</header>
<main>
<!-- 匹配的视图组件在此渲染 -->
<router-view />
</main>
</div>
</template>1.3 命名路由与命名视图
命名路由:为路由分配名称,在导航时通过名称引用,避免硬编码 URL。
const routes = [
{
path: '/user/:id',
name: 'UserProfile', // 命名路由
component: UserProfile
}
]
// 编程式导航使用名称
router.push({ name: 'UserProfile', params: { id: '42' } })命名视图:同一页面内渲染多个视图组件,每个视图有独立名称。
const routes = [
{
path: '/dashboard',
name: 'Dashboard',
components: {
default: DashboardMain,
sidebar: DashboardSidebar,
toolbar: DashboardToolbar
}
}
]模板中使用:
<template>
<div class="dashboard-layout">
<router-view name="toolbar" />
<aside>
<router-view name="sidebar" />
</aside>
<main>
<router-view /> <!-- 默认视图 (default) -->
</main>
</div>
</template>1.4 路由匹配:动态路由、通配符与匹配顺序
动态路由参数:使用 :paramName 定义 URL 中的变量部分。
const routes = [
// 动态片段
{ path: '/users/:id', component: UserDetail },
// 多个动态参数
{ path: '/posts/:year/:month/:slug', component: PostDetail },
// 可选参数(加 ?)
{ path: '/products/:category?', component: ProductList }
]在组件中访问参数:
<script setup>
import { useRoute, useRouter } from 'vue-router'
import { watch } from 'vue'
const route = useRoute()
const router = useRouter()
// 访问路由参数
console.log(route.params.id) // 从 :id 获取
console.log(route.params.year) // 从 :year 获取
console.log(route.query.page) // 从 ?page= 获取
// 监听参数变化(同一组件内切换参数时更新数据)
watch(() => route.params.id, (newId) => {
fetchUserData(newId)
})
</script>通配符与 404 处理:使用 /:pathMatch(.*) 捕获所有未匹配路径。
const routes = [
// ... 其他正常路由
{
// 捕获所有未匹配路径,展示 404 页面
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: NotFoundPage
}
]匹配顺序:Vue Router 按照 routes 数组定义的顺序 进行匹配,先匹配到的优先。
const routes = [
// 1. 静态路由优先
{ path: '/users/new', component: CreateUser },
// 2. 动态路由在后
{ path: '/users/:id', component: UserDetail },
// 3. 通配符最后
{ path: '/:pathMatch(.*)*', component: NotFound }
]1.5 嵌套路由
嵌套路由用于构建带有子页面的布局结构。
const routes = [
{
path: '/dashboard',
component: DashboardLayout, // 父布局组件
children: [
{
// 匹配 /dashboard
path: '',
name: 'DashboardHome',
component: DashboardHome
},
{
// 匹配 /dashboard/settings
path: 'settings',
name: 'DashboardSettings',
component: DashboardSettings
},
{
// 匹配 /dashboard/users/:id
path: 'users/:id',
name: 'DashboardUserDetail',
component: DashboardUserDetail
}
]
}
]父组件 DashboardLayout.vue 中必须包含 <router-view> 来渲染子路由:
<template>
<div class="dashboard-layout">
<aside>
<nav>
<router-link to="/dashboard">概览</router-link>
<router-link to="/dashboard/settings">设置</router-link>
</nav>
</aside>
<section class="content">
<!-- 子路由在此渲染 -->
<router-view />
</section>
</div>
</template>1.6 编程式导航
除了 <router-link> 组件,Vue Router 还提供了编程式导航 API。
import { useRouter } from 'vue-router'
const router = useRouter()
// 字符串路径
router.push('/users/123')
// 对象路径
router.push({ path: '/users/123' })
// 命名路由 + 参数
router.push({ name: 'UserDetail', params: { id: '123' } })
// 带查询参数
router.push({ path: '/users', query: { page: 1, sort: 'name' } })
// 带 hash
router.push({ path: '/settings', hash: '#profile' })
// 替换当前历史记录(不会产生新记录)
router.replace({ name: 'Home' })
// 前进 / 后退
router.go(1) // 前进 1 步
router.go(-1) // 后退 1 步
router.go(3) // 前进 3 步
// 简写
router.back() // 后退 1 步
router.forward() // 前进 1 步重要提示:当使用 params 时,必须配合 name 使用,不能与 path 混用:
// ✅ 正确
router.push({ name: 'UserDetail', params: { id: '123' } })
// ❌ 错误:params 在 path 模式下会被忽略
router.push({ path: '/users/:id', params: { id: '123' } })1.7 router-link 组件
<router-link> 是声明式导航的基础组件,提供了丰富的配置选项。
<template>
<div class="nav-bar">
<!-- 基础用法 -->
<router-link to="/">首页</router-link>
<router-link :to="{ name: 'About' }">关于</router-link>
<!-- 激活样式类名 -->
<router-link
to="/dashboard"
active-class="active-link"
exact-active-class="exact-active-link"
>
控制面板
</router-link>
<!-- 自定义 tag 渲染 -->
<router-link to="/profile" custom v-slot="{ navigate, isActive }">
<button
@click="navigate"
:class="{ 'is-active': isActive }"
>
个人中心
</button>
</router-link>
</div>
</template>
<style scoped>
.active-link {
color: #42b983;
font-weight: bold;
}
.exact-active-link {
border-bottom: 2px solid #42b983;
}
.is-active {
background-color: #e0f7fa;
}
</style>在 Vue Router 4 中,<router-link> 默认渲染为 <a> 元素,但通过 custom 属性和 v-slot 可以自定义渲染任意元素。
二、路由进阶
2.1 导航守卫
导航守卫是 Vue Router 最强大的特性之一,用于在路由跳转过程中进行拦截和控制。共有三类守卫。
全局守卫
// 全局前置守卫 —— 最常用的守卫
router.beforeEach(async (to, from) => {
// to:目标路由对象
// from:来源路由对象
// 返回值:false 取消导航,路由对象重定向,undefined 放行
// 检查用户是否已登录
const isAuthenticated = localStorage.getItem('token')
// 如果访问需要登录的页面但未登录,重定向到登录页
if (to.meta.requiresAuth && !isAuthenticated) {
return { name: 'Login', query: { redirect: to.fullPath } }
}
// 如果已登录但访问登录页,重定向到首页
if (to.name === 'Login' && isAuthenticated) {
return { name: 'Home' }
}
})
// 全局解析守卫 —— 在导航确认前、所有组件内守卫执行后调用
router.beforeResolve(async (to) => {
// 可用于预加载数据
if (to.meta.requiresLoading) {
await prefetchData(to)
}
})
// 全局后置钩子 —— 导航完成后调用,不能改变导航
router.afterEach((to, from, failure) => {
// 页面标题更新
document.title = to.meta.title || '默认标题'
// 分析页面访问日志
analytics.trackPageView(to.fullPath)
})路由独享守卫
直接在路由配置中定义,只对该路由生效。
const routes = [
{
path: '/admin/settings',
component: AdminSettings,
beforeEnter: (to, from) => {
// 只对 /admin/settings 生效
const role = localStorage.getItem('role')
if (role !== 'admin') {
return { name: 'Forbidden' }
}
}
},
{
path: '/articles/:id/edit',
component: ArticleEditor,
beforeEnter: [
// 可以传入数组,依次执行
checkAuthentication,
checkArticlePermission
]
}
]
async function checkArticlePermission(to) {
const articleId = to.params.id
const canEdit = await api.canEditArticle(articleId)
if (!canEdit) {
return { name: 'NotFound' }
}
}组件内守卫
在组件内部通过组合式 API 使用。
<script setup>
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import { ref } from 'vue'
const hasUnsavedChanges = ref(false)
// 离开当前路由时触发
onBeforeRouteLeave((to, from) => {
if (hasUnsavedChanges.value) {
// 返回 false 阻止离开
// 或者返回字符串显示确认对话框
const answer = window.confirm('有未保存的更改,确定离开吗?')
if (!answer) {
return false
}
}
})
// 在同一路由内参数变化时触发
onBeforeRouteUpdate(async (to, from) => {
// 路由参数改变时重新获取数据
if (to.params.id !== from.params.id) {
await fetchArticle(to.params.id)
}
})
</script>完整的导航解析流程
理解导航守卫的执行顺序对于调试和开发复杂路由逻辑至关重要:
1. 导航被触发
2. 在失活的组件里调用 onBeforeRouteLeave 守卫
3. 调用全局的 beforeEach 守卫
4. 在重用的组件里调用 onBeforeRouteUpdate 守卫
5. 调用路由配置里的 beforeEnter 守卫
6. 解析异步路由组件(懒加载)
7. 在被激活的组件里调用 onBeforeRouteEnter
8. 调用全局的 beforeResolve 守卫
9. 导航被确认
10. 调用全局的 afterEach 钩子
11. 触发 DOM 更新
12. 调用 onBeforeRouteEnter 传给 next 的回调函数(已废弃 —— 组合式 API 中不存在此问题)2.2 路由元信息(meta)
meta 字段是路由配置中存储自定义信息的标准方式,常用于权限控制、页面标题、布局选择等。
const routes = [
{
path: '/admin',
component: AdminLayout,
meta: {
requiresAuth: true, // 需要登录
role: 'admin', // 需要的角色
title: '管理后台', // 页面标题
layout: 'sidebar', // 布局类型
transition: 'slide-left', // 过渡动画名
keepAlive: true // 是否缓存组件
},
children: [
{
path: 'dashboard',
component: AdminDashboard,
meta: {
title: '控制台'
// 继承父路由的 meta,也可以覆盖
}
},
{
path: 'users',
component: AdminUsers,
meta: {
title: '用户管理',
role: 'super-admin' // 比父路由更严格
}
}
]
}
]
// 在导航守卫中读取 meta
router.beforeEach((to, from) => {
// 设置页面标题 —— 深度合并路由树的 meta
const nearestWithTitle = to.matched
.slice()
.reverse()
.find(r => r.meta.title)
if (nearestWithTitle) {
document.title = nearestWithTitle.meta.title
}
// 检查权限
if (to.matched.some(record => record.meta.requiresAuth)) {
// 需要登录...
}
})
// 在组件中访问 meta
import { useRoute } from 'vue-router'
const route = useRoute()
console.log(route.meta.requiresAuth) // true
console.log(route.meta.title) // '控制台'2.3 数据获取策略
数据获取有两种主流方式:导航前获取 和 导航后获取。
导航前获取(在守卫中获取)
进入路由前先获取数据,用户看到新页面时数据已就绪。适合:数据对页面渲染至关重要的场景。
// 路由配置
const routes = [
{
path: '/articles/:id',
component: ArticleDetail,
beforeEnter: async (to) => {
// 导航前预加载数据
try {
const article = await api.fetchArticle(to.params.id)
// 将数据存储到全局状态或通过 provide/inject 传递
store.articleData = article
} catch (error) {
// 加载失败,重定向到错误页
return { name: 'Error', params: { message: '文章加载失败' } }
}
}
}
]导航后获取(在组件 mounted 中获取)
先进入页面,显示加载状态,然后获取数据。适合:需要即时交互或数据请求较轻的场景。
<script setup>
import { ref, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import api from '@/api'
const route = useRoute()
const article = ref(null)
const loading = ref(false)
const error = ref(null)
async function fetchArticle(id) {
loading.value = true
error.value = null
try {
article.value = await api.fetchArticle(id)
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
onMounted(() => {
fetchArticle(route.params.id)
})
// 参数变化时重新获取
watch(() => route.params.id, (newId) => {
fetchArticle(newId)
})
</script>
<template>
<div class="article-container">
<div v-if="loading" class="loading">
<span class="spinner" /> 加载中...
</div>
<div v-else-if="error" class="error">
⚠️ {{ error }}
<button @click="fetchArticle(route.params.id)">重试</button>
</div>
<article v-else>
<h1>{{ article?.title }}</h1>
<div v-html="article?.content" />
</article>
</div>
</template>2.4 路由过渡动画
利用 Vue 的 <Transition> 组件,可以为路由切换添加流畅的动画效果。
<template>
<router-view v-slot="{ Component, route }">
<transition
:name="route.meta.transition || 'fade'"
mode="out-in"
>
<component :is="Component" :key="route.path" />
</transition>
</router-view>
</template>
<style scoped>
/* 淡入淡出动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 滑动动画 */
.slide-left-enter-active,
.slide-left-leave-active,
.slide-right-enter-active,
.slide-right-leave-active {
transition: transform 0.35s ease, opacity 0.35s ease;
}
.slide-left-enter-from {
transform: translateX(30px);
opacity: 0;
}
.slide-left-leave-to {
transform: translateX(-30px);
opacity: 0;
}
.slide-right-enter-from {
transform: translateX(-30px);
opacity: 0;
}
.slide-right-leave-to {
transform: translateX(30px);
opacity: 0;
}
/* 缩放动画 */
.scale-enter-active,
.scale-leave-active {
transition: transform 0.25s ease, opacity 0.25s ease;
}
.scale-enter-from {
transform: scale(0.8);
opacity: 0;
}
.scale-leave-to {
transform: scale(1.1);
opacity: 0;
}
</style>在路由配置中指定动画类型:
{
path: '/home',
component: Home,
meta: { transition: 'slide-right' }
},
{
path: '/about',
component: About,
meta: { transition: 'slide-left' }
}2.5 滚动行为控制
当切换路由时,可以使用 scrollBehavior 控制页面滚动位置。
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
// savedPosition:当使用浏览器前进/后退时,保存的滚动位置
if (savedPosition) {
// 浏览器前进/后退时恢复到上次位置
return savedPosition
}
if (to.hash) {
// 跳转到指定锚点
return {
el: to.hash,
behavior: 'smooth' // 平滑滚动
}
}
// 默认回到顶部
return { top: 0, behavior: 'smooth' }
// 也可以返回 Promise 实现延迟滚动
// return new Promise((resolve) => {
// setTimeout(() => resolve({ top: 0 }), 300)
// })
}
})2.6 路由懒加载
当应用较大时,路由懒加载可以显著减少首屏加载时间。
const routes = [
// 方法一:动态 import(推荐)
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue')
},
// 方法二:使用 webpack 注释指定 chunk 名称
{
path: '/reports',
component: () => import(/* webpackChunkName: "reports" */ '@/views/Reports.vue')
},
// 方法三:嵌套路由 + 懒加载
{
path: '/admin',
component: () => import('@/views/admin/AdminLayout.vue'),
children: [
{
path: 'users',
component: () => import('@/views/admin/AdminUsers.vue')
},
{
path: 'settings',
component: () => import('@/views/admin/AdminSettings.vue')
}
]
}
]配合加载状态提示:
<template>
<router-view v-slot="{ Component }">
<Suspense>
<component :is="Component" />
<template #fallback>
<div class="loading-skeleton">
<div class="skeleton-header" />
<div class="skeleton-body" />
</div>
</template>
</Suspense>
</router-view>
</template>2.7 动态路由添加与权限控制
router.addRoute() 允许在运行期间动态注册新路由,是实现权限控制的核心机制。
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue')
},
{
path: '/',
component: () => import('@/views/Layout.vue'),
children: [
{
path: '',
name: 'Home',
component: () => import('@/views/Home.vue')
}
]
}
]
})
/**
* 根据用户角色动态添加路由
* @param {string[]} roles - 用户拥有的角色列表
*/
export function setupDynamicRoutes(roles) {
// 1. 获取该角色可访问的路由配置
const asyncRoutes = getRoutesByRoles(roles)
// 2. 逐个添加为子路由
asyncRoutes.forEach(route => {
router.addRoute('Home', route)
})
// 3. 如果找不到通配符路由,添加 404
if (!router.hasRoute('NotFound')) {
router.addRoute({
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFound.vue')
})
}
}
/**
* 清除动态路由
*/
export function resetDynamicRoutes() {
// 获取所有动态路由的名称
const dynamicRouteNames = [
'Dashboard', 'UserManagement',
'ArticleManagement', 'Settings'
]
dynamicRouteNames.forEach(name => {
if (router.hasRoute(name)) {
router.removeRoute(name)
}
})
}三、Pinia 状态管理
3.1 创建 Store:defineStore
Pinia 使用 defineStore 定义 store,支持两种写法:选项式(Options API)和组合式(Composition API)。
选项式 Store
// stores/counter.ts
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
// state:数据源
state: () => ({
count: 0,
name: '计数器'
}),
// getters:计算属性
getters: {
doubleCount: (state) => state.count * 2,
// 访问其他 getter
doubleCountPlusOne(): number {
return this.doubleCount + 1
}
},
// actions:修改状态的方法
actions: {
increment() {
this.count++
},
// 异步 action
async fetchAndIncrement() {
const result = await api.fetchCount()
this.count += result
}
}
})组合式 Store
// stores/counter-composable.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCounterStore = defineStore('counter-composable', () => {
// state —— 使用 ref
const count = ref(0)
const name = ref('组合式计数器')
// getters —— 使用 computed
const doubleCount = computed(() => count.value * 2)
const doubleCountPlusOne = computed(() => doubleCount.value + 1)
// actions —— 普通函数
function increment() {
count.value++
}
async function fetchAndIncrement() {
const result = await api.fetchCount()
count.value += result
}
// 返回所有暴露的属性和方法
return {
count, name, doubleCount, doubleCountPlusOne,
increment, fetchAndIncrement
}
})在组件中使用
<script setup>
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'
const counterStore = useCounterStore()
// ❌ 直接解构会失去响应性
// const { count, doubleCount } = counterStore
// ✅ 使用 storeToRefs 保持响应性
const { count, doubleCount, name } = storeToRefs(counterStore)
function handleIncrement() {
// Action 可以直接解构
counterStore.increment()
}
// 也可以直接修改 state
counterStore.count = 10
</script>
<template>
<div class="counter">
<h2>{{ name }}</h2>
<p>计数: {{ count }}</p>
<p>两倍: {{ doubleCount }}</p>
<button @click="handleIncrement">+1</button>
</div>
</template>3.2 State:访问、重置与批量更新
Pinia 的 state 操作非常灵活直观。
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// 1. 直接访问
console.log(userStore.name)
console.log(userStore.roles)
// 2. 直接修改
userStore.name = '张三'
userStore.age = 28
// 3. 批量更新 —— $patch(对象形式)
userStore.$patch({
name: '李四',
age: 30,
email: 'lisi@example.com'
})
// 4. 批量更新 —— $patch(函数形式,推荐用于复杂逻辑)
userStore.$patch((state) => {
state.name = '王五'
state.age = state.age + 1
if (state.roles.includes('admin')) {
state.permissions.push('delete')
}
})
// 5. 替换整个 state
userStore.$state = {
id: 1,
name: '赵六',
email: 'zhaoliu@example.com',
age: 25,
roles: ['user'],
permissions: ['read']
}
// 6. 重置到初始状态
userStore.$reset()注意:$patch 以对象形式调用时,对于数组操作(如 push、splice)需要通过索引赋值或使用函数形式。
3.3 Getters:定义、访问其他 Getter、传参
Getters 是 Store 的计算属性,支持缓存和依赖追踪。
import { defineStore } from 'pinia'
export const useProductStore = defineStore('products', {
state: () => ({
products: [
{ id: 1, name: '笔记本', price: 5999, category: '电子' },
{ id: 2, name: '马克杯', price: 29, category: '家居' },
{ id: 3, name: '键盘', price: 399, category: '电子' }
],
discountRate: 0.8
}),
getters: {
// 1. 基础 getter(使用箭头函数,接收 state)
saleProducts: (state) => {
return state.products.map(p => ({
...p,
salePrice: p.price * state.discountRate
}))
},
// 2. 使用 this 访问其他 getter
saleProductsSummary(): string {
const count = this.saleProducts.length
const total = this.saleProducts.reduce((sum, p) => sum + p.salePrice, 0)
return `共 ${count} 件商品,总价 ¥${total.toFixed(2)}`
},
// 3. 返回一个函数 —— 实现传参效果
// 注意:这种方式不缓存结果
productsByCategory: (state) => {
return (category: string) => {
return state.products.filter(p => p.category === category)
}
},
// 4. 带参数的缓存 getter(使用 computed 在组合式 Store 中实现)
}
})组件中使用传参 getter:
<script setup>
import { useProductStore } from '@/stores/products'
const productStore = useProductStore()
// 调用返回的函数
const electronics = productStore.productsByCategory('电子')
</script>
<template>
<div>
<p>{{ productStore.saleProductsSummary }}</p>
<ul>
<li v-for="product in electronics" :key="product.id">
{{ product.name }} - ¥{{ product.salePrice }}
</li>
</ul>
</div>
</template>3.4 Actions:同步、异步、访问其他 Store
Actions 是 Store 中定义业务逻辑的地方,支持同步和异步操作。
// stores/auth.ts
import { defineStore } from 'pinia'
import { useUserStore } from './user'
import { useCartStore } from './cart'
import api from '@/api'
export const useAuthStore = defineStore('auth', {
state: () => ({
token: null as string | null,
isAuthenticated: false,
loginLoading: false,
loginError: null as string | null
}),
actions: {
// 同步 action
logout() {
this.token = null
this.isAuthenticated = false
localStorage.removeItem('token')
// 重置其他 store
const userStore = useUserStore()
userStore.$reset()
const cartStore = useCartStore()
cartStore.$reset()
},
// 异步 action —— 登录
async login(credentials: { username: string; password: string }) {
this.loginLoading = true
this.loginError = null
try {
const response = await api.login(credentials)
this.token = response.token
this.isAuthenticated = true
// 持久化 token
localStorage.setItem('token', response.token)
// 获取用户信息并存到 user store
const userStore = useUserStore()
await userStore.fetchUserProfile()
return true
} catch (error: any) {
this.loginError = error.message || '登录失败'
return false
} finally {
this.loginLoading = false
}
},
// 组合多个 action
async initializeApp() {
const token = localStorage.getItem('token')
if (!token) return
this.token = token
this.isAuthenticated = true
const userStore = useUserStore()
const cartStore = useCartStore()
// 并发获取数据
await Promise.all([
userStore.fetchUserProfile(),
cartStore.fetchCartItems()
])
}
}
})3.5 插件:持久化(pinia-plugin-persistedstate)
Pinia 的插件系统可以轻松实现 state 持久化。
安装与配置
npm install pinia-plugin-persistedstate// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const app = createApp(App)
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
app.mount('#app')在 Store 中启用持久化
// stores/user.ts
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
token: null as string | null,
profile: {
name: '',
avatar: '',
email: ''
},
preferences: {
theme: 'light',
language: 'zh-CN'
}
}),
// 启用持久化
persist: {
key: 'user-store', // localStorage 的 key
storage: localStorage, // 存储引擎:localStorage / sessionStorage
paths: ['token', 'preferences'], // 只持久化指定字段
// serializer: { serialize, deserialize } // 自定义序列化
}
})也可以在应用级别统一配置:
// main.ts
pinia.use(piniaPluginPersistedstate)
// 所有 store 默认持久化
// 或每个 store 单独配置3.6 Pinia vs Vuex:核心差异
| 对比维度 | Vuex 4 | Pinia |
|---|---|---|
| Mutations | 必须定义 mutations 修改 state | 无需 mutations,直接修改 state |
| TypeScript | 支持但不友好,需要大量类型声明 | 一等 TypeScript 支持,类型推断自动 |
| 代码量 | 模板代码多(state/getters/mutations/actions) | 更简洁,选项式或组合式 |
| Store 结构 | 单例模式,一个 store 包含多个 module | 多点模式,每个 defineStore 独立 |
| DevTools | 支持 | 更友好,支持时间旅行、编辑 state |
| 模块 | 使用 namespaced: true 区分 |
无需命名空间,每个 store 独立 |
| HMR | 不支持 | 热更新支持 |
| Composition API | 需要额外辅助函数 | 原生集成 |
结论:对于新项目,Pinia 是明确的选择。Vuex 已被 Vue 团队标记为维护模式。
四、Pinia 进阶
4.1 组合式 Store 深入
组合式 Store 使用 Vue 组合式 API 的 ref、computed、watch 等函数,适合复杂的业务逻辑。
// stores/todo.ts
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
export interface Todo {
id: number
title: string
completed: boolean
createdAt: Date
}
export const useTodoStore = defineStore('todo', () => {
// --- State ---
const todos = ref<Todo[]>([])
const filter = ref<'all' | 'active' | 'completed'>('all')
const loading = ref(false)
// --- Getters ---
const filteredTodos = computed(() => {
switch (filter.value) {
case 'active':
return todos.value.filter(t => !t.completed)
case 'completed':
return todos.value.filter(t => t.completed)
default:
return todos.value
}
})
const activeCount = computed(() =>
todos.value.filter(t => !t.completed).length
)
const completedCount = computed(() =>
todos.value.filter(t => t.completed).length
)
// --- Actions ---
function addTodo(title: string) {
const newTodo: Todo = {
id: Date.now(),
title,
completed: false,
createdAt: new Date()
}
todos.value.push(newTodo)
}
function toggleTodo(id: number) {
const todo = todos.value.find(t => t.id === id)
if (todo) {
todo.completed = !todo.completed
}
}
function removeTodo(id: number) {
todos.value = todos.value.filter(t => t.id !== id)
}
async function fetchTodos() {
loading.value = true
try {
const response = await fetch('/api/todos')
todos.value = await response.json()
} finally {
loading.value = false
}
}
// --- 使用 watch 实现副作用 ---
watch(todos, (newTodos) => {
localStorage.setItem('todos', JSON.stringify(newTodos))
}, { deep: true })
// 从 localStorage 恢复数据
const savedTodos = localStorage.getItem('todos')
if (savedTodos) {
try {
todos.value = JSON.parse(savedTodos)
} catch { /* ignore */ }
}
return {
// state
todos, filter, loading,
// getters
filteredTodos, activeCount, completedCount,
// actions
addTodo, toggleTodo, removeTodo, fetchTodos
}
})4.2 Store 间互相调用
在实际应用中,store 之间经常需要互相通信。
// stores/order.ts
import { defineStore } from 'pinia'
import { useAuthStore } from './auth'
import { useCartStore } from './cart'
import { useNotificationStore } from './notification'
export const useOrderStore = defineStore('order', {
state: () => ({
orders: [] as Order[],
currentOrder: null as Order | null,
submitting: false
}),
actions: {
async submitOrder() {
const authStore = useAuthStore()
const cartStore = useCartStore()
const notificationStore = useNotificationStore()
// 检查登录状态
if (!authStore.isAuthenticated) {
notificationStore.show({
type: 'warning',
message: '请先登录'
})
return false
}
// 检查购物车
if (cartStore.items.length === 0) {
notificationStore.show({
type: 'error',
message: '购物车为空'
})
return false
}
this.submitting = true
try {
const order = await api.createOrder({
userId: authStore.user.id,
items: cartStore.items,
total: cartStore.totalPrice
})
this.orders.unshift(order)
cartStore.clearCart()
notificationStore.show({
type: 'success',
message: `订单 #${order.id} 提交成功`
})
return true
} catch (error) {
notificationStore.show({
type: 'error',
message: '提交订单失败,请重试'
})
return false
} finally {
this.submitting = false
}
}
}
})4.3 订阅机制
Pinia 提供了强大的订阅机制,用于监听 state 和 action 的变化。
$subscribe —— 监听 State 变化
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// 订阅 state 变化
const unsubscribe = userStore.$subscribe((mutation, state) => {
// mutation.type: 'direct' | 'patch object' | 'patch function'
// mutation.storeId: store 的 id
// mutation.events: 具体变更信息(调试用)
console.log(`State changed via ${mutation.type}`)
console.log('New state:', state)
// 自动持久化到 localStorage
localStorage.setItem('user-store', JSON.stringify(state))
}, {
detached: false, // 设为 true 时,组件卸载后停止订阅
deep: true // 深度监听(默认浅层)
})
// 取消订阅
unsubscribe()$onAction —— 监听 Action 调用
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const unsubscribe = authStore.$onAction(({
name, // action 名称
store, // store 实例
args, // 调用参数数组
after, // action 完成后回调
onError // action 出错回调
}) => {
console.log(`Action "${name}" called with:`, args)
// 记录开始时间
const startTime = Date.now()
after((result) => {
// action 成功返回后执行
console.log(`Action "${name}" succeeded in ${Date.now() - startTime}ms`, result)
})
onError((error) => {
// action 抛出异常时执行
console.error(`Action "${name}" failed:`, error)
})
})
// 取消订阅
unsubscribe()4.4 服务端渲染注意事项
在 SSR 场景中使用 Pinia 需要注意以下几点:
// stores/ssr-safe.ts
import { defineStore } from 'pinia'
export const useSsrSafeStore = defineStore('ssr-safe', {
state: () => ({
// 使用工厂函数,每次调用都创建新实例
items: [] as Item[]
}),
actions: {
// 不要在 action 中访问浏览器特有 API
async fetchItems() {
// ✅ 使用完全限定 URL
const response = await fetch('https://api.example.com/items')
// ❌ 不要使用相对路径(在 SSR 中无意义)
// const response = await fetch('/api/items')
}
}
})// 在 SSR 入口文件中使用
// entry-server.ts
import { createPinia } from 'pinia'
export function createApp() {
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
// 每个请求创建独立的 pinia 实例
return { app, pinia }
}4.5 测试 Store
Pinia 对测试非常友好,可以直接测试 store 逻辑。
// stores/__tests__/counter.spec.ts
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from '@/stores/counter'
describe('Counter Store', () => {
beforeEach(() => {
// 每个测试前创建新的 pinia 实例
setActivePinia(createPinia())
})
it('初始 state 正确', () => {
const store = useCounterStore()
expect(store.count).toBe(0)
expect(store.name).toBe('计数器')
})
it('increment 正确增加计数', () => {
const store = useCounterStore()
store.increment()
expect(store.count).toBe(1)
store.increment()
expect(store.count).toBe(2)
})
it('doubleCount getter 正确', () => {
const store = useCounterStore()
store.count = 5
expect(store.doubleCount).toBe(10)
expect(store.doubleCountPlusOne).toBe(11)
})
it('$patch 批量更新正确', () => {
const store = useCounterStore()
store.$patch({ count: 10, name: '新计数器' })
expect(store.count).toBe(10)
expect(store.name).toBe('新计数器')
})
it('$reset 恢复到初始状态', () => {
const store = useCounterStore()
store.count = 100
store.name = '修改后'
store.$reset()
expect(store.count).toBe(0)
expect(store.name).toBe('计数器')
})
it('异步 action 正确执行', async () => {
const store = useCounterStore()
// mock 全局 api
global.api = { fetchCount: vi.fn().mockResolvedValue(5) }
await store.fetchAndIncrement()
expect(store.count).toBe(5)
})
})五、综合实战
5.1 权限系统:路由守卫 + 状态管理联合
一个完整的前端权限系统通常由 Vue Router 的动态路由和 Pinia 的状态管理协同实现。
// stores/permission.ts
import { defineStore } from 'pinia'
import router from '@/router'
import { useUserStore } from './user'
import { asyncRoutes, constantRoutes } from '@/router/routes'
export const usePermissionStore = defineStore('permission', {
state: () => ({
// 当前用户可访问的路由
accessibleRoutes: [] as RouteRecordRaw[],
// 是否已加载动态路由
isRoutesLoaded: false
}),
actions: {
/**
* 根据用户角色生成可访问路由
*/
generateRoutes(roles: string[]) {
// 递归过滤路由
const accessedRoutes = filterAsyncRoutes(asyncRoutes, roles)
this.accessibleRoutes = accessedRoutes
},
/**
* 动态添加路由到路由器
*/
addDynamicRoutes() {
// 已经添加过则跳过
if (this.isRoutesLoaded) return
// 将过滤后的路由添加到 Home 路由下
this.accessibleRoutes.forEach(route => {
router.addRoute('Home', route)
})
// 添加 404 通配符路由
router.addRoute({
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/error/NotFound.vue'),
meta: { hidden: true }
})
this.isRoutesLoaded = true
},
/**
* 重置权限状态
*/
resetPermission() {
this.accessibleRoutes = []
this.isRoutesLoaded = false
// 移除动态路由
const removeRouteNames = ['Dashboard', 'UserManagement', 'ArticleManagement', 'Settings']
removeRouteNames.forEach(name => {
if (router.hasRoute(name)) {
router.removeRoute(name)
}
})
}
}
})
/**
* 递归过滤路由
*/
function filterAsyncRoutes(
routes: RouteRecordRaw[],
roles: string[]
): RouteRecordRaw[] {
const res: RouteRecordRaw[] = []
routes.forEach(route => {
const temp = { ...route }
// 检查角色权限:如果路由没有 meta.roles 字段则默认可见
if (hasPermission(roles, temp)) {
if (temp.children) {
temp.children = filterAsyncRoutes(temp.children, roles)
}
res.push(temp)
}
})
return res
}
/**
* 检查角色是否有路由的访问权限
*/
function hasPermission(roles: string[], route: RouteRecordRaw): boolean {
if (route.meta?.roles) {
return roles.some(role => route.meta.roles.includes(role))
}
// 没有设置 meta.roles 则默认开放
return true
}// router/index.ts —— 完整的路由配置与守卫
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
// 常量路由 —— 所有用户都可以访问
export const constantRoutes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/Login.vue'),
meta: { hidden: true }
},
{
path: '/',
component: () => import('@/views/layout/Layout.vue'),
name: 'Home',
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/Index.vue'),
meta: { title: '控制台', icon: 'dashboard', affix: true }
}
]
},
{
path: '/forbidden',
name: 'Forbidden',
component: () => import('@/views/error/Forbidden.vue'),
meta: { hidden: true }
}
]
// 异步路由 —— 根据角色动态加载
export const asyncRoutes: RouteRecordRaw[] = [
{
path: 'users',
name: 'UserManagement',
component: () => import('@/views/system/UserManagement.vue'),
meta: {
title: '用户管理',
icon: 'user',
roles: ['admin', 'super-admin']
}
},
{
path: 'articles',
name: 'ArticleManagement',
component: () => import('@/views/content/ArticleManagement.vue'),
meta: {
title: '文章管理',
icon: 'document',
roles: ['admin', 'editor', 'super-admin']
},
children: [
{
path: 'create',
name: 'CreateArticle',
component: () => import('@/views/content/CreateArticle.vue'),
meta: { title: '写文章', roles: ['editor', 'super-admin'] }
},
{
path: ':id/edit',
name: 'EditArticle',
component: () => import('@/views/content/EditArticle.vue'),
meta: { title: '编辑文章', roles: ['editor', 'super-admin'], hidden: true }
}
]
},
{
path: 'settings',
name: 'Settings',
component: () => import('@/views/system/Settings.vue'),
meta: {
title: '系统设置',
icon: 'setting',
roles: ['super-admin']
}
}
]
const router = createRouter({
history: createWebHistory(),
routes: constantRoutes
})
// 全局路由守卫 —— 完整的权限控制逻辑
router.beforeEach(async (to, from) => {
// 设置页面标题
const title = to.meta.title as string
if (title) {
document.title = `${title} - 管理系统`
}
// 获取用户 store
const userStore = useUserStore()
const permissionStore = usePermissionStore()
// 白名单路由(无需登录)
const whiteList = ['Login', 'Forbidden']
if (whiteList.includes(to.name as string)) {
return
}
// 检查 token
const token = userStore.token
if (!token) {
return {
name: 'Login',
query: { redirect: to.fullPath }
}
}
// 已登录但还没获取用户信息
if (!userStore.profile) {
try {
await userStore.fetchUserProfile()
// 根据角色生成动态路由
permissionStore.generateRoutes(userStore.roles)
permissionStore.addDynamicRoutes()
// 重定向到目标页面
return { ...to, replace: true }
} catch (error) {
// token 失效,清除登录状态
userStore.resetToken()
permissionStore.resetPermission()
return {
name: 'Login',
query: { redirect: to.fullPath }
}
}
}
// 检查具体页面的角色权限
if (to.meta.roles) {
const hasRole = userStore.roles.some(role =>
(to.meta.roles as string[]).includes(role)
)
if (!hasRole) {
return { name: 'Forbidden' }
}
}
})
export default router5.2 用户登录流
一个完整的登录流程设计,从登录 → 存储 token → 动态路由 → 跳转。
<!-- views/login/Login.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
import { usePermissionStore } from '@/stores/permission'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const userStore = useUserStore()
const permissionStore = usePermissionStore()
const form = ref({
username: '',
password: '',
remember: false
})
const loading = ref(false)
const errorMessage = ref('')
async function handleLogin() {
loading.value = true
errorMessage.value = ''
try {
// 1. 调用登录 API
const response = await authStore.login({
username: form.value.username,
password: form.value.password
})
// 2. 存储 token(authStore 中已处理)
// 3. 获取用户信息
await userStore.fetchUserProfile()
// 4. 根据角色动态生成路由
permissionStore.generateRoutes(userStore.roles)
permissionStore.addDynamicRoutes()
// 5. 跳转到之前试图访问的页面,或默认首页
const redirect = (route.query.redirect as string) || '/'
router.push(redirect)
} catch (error: any) {
errorMessage.value = error.message || '登录失败,请重试'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-container">
<div class="login-card">
<h1>管理系统</h1>
<form @submit.prevent="handleLogin">
<div class="form-group">
<label>用户名</label>
<input
v-model="form.username"
type="text"
placeholder="请输入用户名"
required
autocomplete="username"
/>
</div>
<div class="form-group">
<label>密码</label>
<input
v-model="form.password"
type="password"
placeholder="请输入密码"
required
autocomplete="current-password"
/>
</div>
<div class="form-group remember">
<label>
<input v-model="form.remember" type="checkbox" />
记住我
</label>
</div>
<div v-if="errorMessage" class="error-message">
{{ errorMessage }}
</div>
<button type="submit" :disabled="loading" class="login-btn">
<span v-if="loading" class="spinner" />
{{ loading ? '登录中...' : '登 录' }}
</button>
</form>
</div>
</div>
</template>
<style scoped>
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
background: white;
padding: 40px;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: 100%;
max-width: 400px;
}
/* ... 更多样式 */
.login-btn {
width: 100%;
padding: 12px;
background: #667eea;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
}
.login-btn:hover { background: #5a67d8; }
.login-btn:disabled { background: #a0aec0; cursor: not-allowed; }
.error-message {
color: #e53e3e;
background: #fff5f5;
padding: 10px;
border-radius: 6px;
margin-bottom: 16px;
font-size: 14px;
}
</style>5.3 页面加载状态与错误处理
一个健壮的 SPA 应用需要完善的加载状态和错误处理。
<!-- components/AppRouterView.vue —— 全局路由视图封装 -->
<script setup lang="ts">
import { ref, provide } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const pageLoading = ref(false)
const pageError = ref<string | null>(null)
// 通过 provide/inject 透传全局加载和错误处理
provide('pageLoading', pageLoading)
provide('pageError', pageError)
function setLoading(value: boolean) {
pageLoading.value = value
}
function setError(message: string | null) {
pageError.value = message
}
provide('setLoading', setLoading)
provide('setError', setError)
</script>
<template>
<div class="router-view-container">
<!-- 全局加载遮罩 -->
<transition name="fade">
<div v-if="pageLoading" class="global-loading">
<div class="loading-content">
<div class="loader" />
<p>加载中...</p>
</div>
</div>
</transition>
<!-- 全局错误显示 -->
<transition name="fade">
<div v-if="pageError" class="global-error">
<div class="error-content">
<span class="error-icon">⚠️</span>
<h3>出错了</h3>
<p>{{ pageError }}</p>
<button @click="setError(null); $router.go(0)" class="retry-btn">
重新加载
</button>
</div>
</div>
</transition>
<!-- 路由视图 -->
<router-view v-slot="{ Component }">
<transition name="page-fade" mode="out-in">
<keep-alive>
<component
:is="Component"
:key="route.fullPath"
/>
</keep-alive>
</transition>
</router-view>
</div>
</template>
<style scoped>
/* 加载动画 */
.global-loading {
position: fixed;
inset: 0;
background: rgba(255, 255, 255, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.loader {
width: 40px;
height: 40px;
border: 4px solid #e2e8f0;
border-top-color: #667eea;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 12px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 错误提示 */
.global-error {
position: fixed;
inset: 0;
background: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.error-content {
text-align: center;
padding: 32px;
}
.error-icon {
font-size: 48px;
}
.retry-btn {
margin-top: 16px;
padding: 10px 24px;
background: #667eea;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
}
.fade-enter-active, .fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
</style>在具体页面组件中使用:
<script setup lang="ts">
import { inject, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import api from '@/api'
const route = useRoute()
const setLoading = inject('setLoading') as (v: boolean) => void
const setError = inject('setError') as (v: string | null) => void
const article = ref(null)
onMounted(async () => {
setLoading(true)
setError(null)
try {
article.value = await api.fetchArticle(route.params.id)
} catch (e: any) {
setError(e.message || '加载文章失败')
} finally {
setLoading(false)
}
})
</script>5.4 Store 组织最佳实践
随着项目规模增长,良好的 store 组织方式至关重要。
目录结构
src/
├── stores/
│ ├── index.ts # Pinia 实例创建与插件配置
│ ├── auth.ts # 认证相关(登录、登出、token)
│ ├── user.ts # 用户信息
│ ├── permission.ts # 权限与路由
│ ├── app.ts # 全局应用状态(侧边栏、主题等)
│ ├── settings.ts # 用户偏好设置
│ ├── notification.ts # 全局通知
│ ├── tags-view.ts # 多标签页
│ │
│ ├── business/ # 按业务模块组织
│ │ ├── article.ts # 文章管理
│ │ ├── category.ts # 分类管理
│ │ └── comment.ts # 评论管理
│ │
│ └── __tests__/ # 测试文件
│ ├── auth.spec.ts
│ └── user.spec.ts
│
└── composables/ # 组合式函数(非状态管理的逻辑)
├── usePagination.ts
└── useFormValidation.ts各 Store 职责划分原则
// stores/app.ts —— 全局 UI 状态
export const useAppStore = defineStore('app', () => {
const sidebarOpened = ref(true)
const device = ref<'desktop' | 'mobile'>('desktop')
const theme = ref<'light' | 'dark'>('light')
function toggleSidebar() {
sidebarOpened.value = !sidebarOpened.value
}
return { sidebarOpened, device, theme, toggleSidebar }
})// stores/notification.ts —— 全局通知(解耦 UI 与 store)
export const useNotificationStore = defineStore('notification', () => {
const messages = ref<Notification[]>([])
let nextId = 0
function show(notification: Omit<Notification, 'id'>) {
const id = nextId++
messages.value.push({ ...notification, id })
// 自动移除(3 秒后)
setTimeout(() => {
remove(id)
}, 3000)
}
function remove(id: number) {
messages.value = messages.value.filter(m => m.id !== id)
}
return { messages, show, remove }
})5.5 性能优化:路由懒加载与 Store 按需加载
在大型 SPA 中,性能优化是必须考虑的问题。
路由层面优化
// router/index.ts
const routes = [
{
path: '/admin',
component: () => import(/* webpackChunkName: "admin" */ '@/views/admin/AdminLayout.vue'),
children: [
{
path: 'dashboard',
component: () => import(/* webpackChunkName: "admin-dashboard" */ '@/views/admin/Dashboard.vue')
},
{
path: 'users',
component: () => import(/* webpackChunkName: "admin-users" */ '@/views/admin/Users.vue')
}
]
}
]
// 预加载策略(用户 hover 时预加载)
const prefetchRoutes = ['Dashboard', 'UserManagement']
router.beforeEach((to) => {
if (prefetchRoutes.includes(to.name as string)) {
// 用户即将访问的页面,可以提前加载
}
})
// 使用 IntersectionObserver 预加载可见链接对应的组件
function setupPrefetch() {
const links = document.querySelectorAll('[data-prefetch]')
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const routeName = (entry.target as HTMLElement).dataset.prefetch
// 触发对应 chunk 的预加载
observer.unobserve(entry.target)
}
})
})
links.forEach(link => observer.observe(link))
}Store 按需加载
不要在应用启动时一次性注册所有 store,而是按需调用:
// ❌ 不推荐:在组件 setup 外部调用 store
// const userStore = useUserStore()
// const permissionStore = usePermissionStore()
export default {
setup() {
// ✅ 推荐:只在需要时调用
const userStore = useUserStore()
const permissionStore = usePermissionStore()
// ...
}
}
// 对于只在特定页面使用的 store,建议懒加载
// stores/business/heavy-report.ts
export const useHeavyReportStore = defineStore('heavy-report', () => {
// 只有在用户访问报表页面时才初始化
const data = ref([])
// ... 大量数据处理逻辑
return { data }
})5.6 错误边界与全局异常处理
// utils/error-handler.ts
import type { Router } from 'vue-router'
import { useNotificationStore } from '@/stores/notification'
import { useAuthStore } from '@/stores/auth'
/**
* 全局 API 错误处理
*/
export function setupErrorHandler(router: Router) {
// Axios 响应拦截器
api.interceptors.response.use(
response => response,
async (error) => {
const notificationStore = useNotificationStore()
if (!error.response) {
// 网络错误
notificationStore.show({
type: 'error',
message: '网络连接异常,请检查网络设置'
})
return Promise.reject(error)
}
const { status, data } = error.response
switch (status) {
case 401:
// Token 过期,清除登录状态并跳转到登录页
const authStore = useAuthStore()
await authStore.logout()
router.push({ name: 'Login', query: { redirect: router.currentRoute.value.fullPath } })
notificationStore.show({
type: 'warning',
message: '登录已过期,请重新登录'
})
break
case 403:
notificationStore.show({
type: 'error',
message: '没有权限执行此操作'
})
break
case 404:
notificationStore.show({
type: 'warning',
message: '请求的资源不存在'
})
break
case 422:
// 表单验证错误
notificationStore.show({
type: 'error',
message: data.message || '输入数据校验失败'
})
break
case 429:
notificationStore.show({
type: 'error',
message: '请求过于频繁,请稍后再试'
})
break
case 500:
case 502:
case 503:
notificationStore.show({
type: 'error',
message: '服务端异常,请稍后再试'
})
break
default:
notificationStore.show({
type: 'error',
message: data?.message || '未知错误'
})
}
return Promise.reject(error)
}
)
}
/**
* Vue 应用级别的错误处理
*/
export function setupVueErrorHandler(app: App, router: Router) {
app.config.errorHandler = (err, instance, info) => {
console.error('Vue Error:', err)
console.error('Error Info:', info)
// 生产环境上报错误
if (import.meta.env.PROD) {
reportError(err, instance, info)
}
}
}5.7 完整代码示例:一个权限管理页面
结合上述所有知识点,实现一个完整的权限管理页面。
<!-- views/permission/RoleManagement.vue -->
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useUserStore } from '@/stores/user'
import { usePermissionStore } from '@/stores/permission'
import { useNotificationStore } from '@/stores/notification'
import api from '@/api'
const userStore = useUserStore()
const permissionStore = usePermissionStore()
const notificationStore = useNotificationStore()
const { t } = useI18n() // 假设使用国际化
// State
const roles = ref<Role[]>([])
const loading = ref(false)
const searchQuery = ref('')
const selectedRole = ref<Role | null>(null)
const showDialog = ref(false)
// Getter
const filteredRoles = computed(() => {
if (!searchQuery.value) return roles.value
const q = searchQuery.value.toLowerCase()
return roles.value.filter(r =>
r.name.toLowerCase().includes(q) ||
r.description?.toLowerCase().includes(q)
)
})
// 权限树配置
const permissionTree = computed(() => [
{
key: 'dashboard',
label: '控制台',
children: [
{ key: 'dashboard:view', label: '查看' },
{ key: 'dashboard:export', label: '导出' }
]
},
{
key: 'user',
label: '用户管理',
children: [
{ key: 'user:view', label: '查看列表' },
{ key: 'user:create', label: '创建用户' },
{ key: 'user:edit', label: '编辑用户' },
{ key: 'user:delete', label: '删除用户' }
]
},
{
key: 'article',
label: '文章管理',
children: [
{ key: 'article:view', label: '查看文章' },
{ key: 'article:create', label: '写文章' },
{ key: 'article:publish', label: '发布文章' },
{ key: 'article:delete', label: '删除文章' }
]
}
])
// Actions
async function fetchRoles() {
loading.value = true
try {
const data = await api.getRoles()
roles.value = data
} catch (error) {
notificationStore.show({
type: 'error',
message: '获取角色列表失败'
})
} finally {
loading.value = false
}
}
async function handleSaveRole(roleData: Partial<Role>) {
try {
if (selectedRole.value?.id) {
await api.updateRole(selectedRole.value.id, roleData)
notificationStore.show({
type: 'success',
message: '角色更新成功'
})
} else {
await api.createRole(roleData)
notificationStore.show({
type: 'success',
message: '角色创建成功'
})
}
showDialog.value = false
await fetchRoles()
} catch (error) {
notificationStore.show({
type: 'error',
message: '保存角色失败'
})
}
}
function openEditDialog(role: Role) {
selectedRole.value = role
showDialog.value = true
}
function openCreateDialog() {
selectedRole.value = null
showDialog.value = true
}
onMounted(fetchRoles)
</script>
<template>
<div class="role-management">
<!-- 页面标题与操作栏 -->
<div class="page-header">
<h2>{{ t('permission.roleManagement') }}</h2>
<div class="header-actions">
<input
v-model="searchQuery"
type="text"
:placeholder="t('common.search')"
class="search-input"
/>
<button
v-permission="'role:create'"
class="btn-primary"
@click="openCreateDialog"
>
+ {{ t('permission.createRole') }}
</button>
</div>
</div>
<!-- 角色列表 -->
<div class="role-grid">
<div
v-for="role in filteredRoles"
:key="role.id"
class="role-card"
>
<div class="role-info">
<h3>{{ role.name }}</h3>
<p class="role-desc">{{ role.description }}</p>
<div class="role-meta">
<span class="badge">{{ role.usersCount }} 位用户</span>
<span class="badge">{{ role.permissions.length }} 项权限</span>
</div>
</div>
<div class="role-actions">
<button
v-permission="'role:edit'"
@click="openEditDialog(role)"
>
编辑
</button>
</div>
</div>
<div v-if="!loading && filteredRoles.length === 0" class="empty-state">
<p>暂无角色数据</p>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-overlay">
<div class="loader" />
<p>{{ t('common.loading') }}</p>
</div>
<!-- 角色编辑弹窗 -->
<teleport to="body">
<transition name="fade">
<div v-if="showDialog" class="dialog-overlay" @click.self="showDialog = false">
<div class="dialog-card">
<div class="dialog-header">
<h3>{{ selectedRole ? '编辑角色' : '创建角色' }}</h3>
<button class="close-btn" @click="showDialog = false">×</button>
</div>
<RoleForm
:role="selectedRole"
:permission-tree="permissionTree"
@submit="handleSaveRole"
@cancel="showDialog = false"
/>
</div>
</div>
</transition>
</teleport>
</div>
</template>
<style scoped>
/* 完整样式 */
.role-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 20px; }
.role-card { background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 20px; transition: box-shadow 0.2s; }
.role-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.role-info h3 { margin: 0 0 8px; font-size: 18px; color: #2d3748; }
.role-desc { color: #718096; font-size: 14px; margin-bottom: 12px; }
.role-meta { display: flex; gap: 8px; }
.badge { background: #edf2f7; padding: 4px 10px; border-radius: 12px; font-size: 12px; color: #4a5568; }
.role-actions { margin-top: 16px; padding-top: 12px; border-top: 1px solid #e2e8f0; }
.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.dialog-card { background: white; border-radius: 12px; padding: 24px; width: 90%; max-width: 640px; max-height: 80vh; overflow-y: auto; }
.dialog-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.close-btn { background: none; border: none; font-size: 24px; cursor: pointer; color: #718096; }
</style>六、常见问题与排查技巧
6.1 Vue Router 常见问题
路由参数变化但页面不刷新
这是 Vue Router 最常见的坑之一 —— 当在同一路由内切换不同参数(如 /users/1 → /users/2)时,组件会被 复用 而非重新创建,因此 onMounted 不会再次触发。
解决方案:
<script setup>
import { watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
// 方案一:watch route.params 变化
watch(() => route.params.id, async (newId, oldId) => {
if (newId !== oldId) {
await fetchData(newId)
}
}, { immediate: true })
// 方案二:watch 整个 route 对象(深度监听)
watch(
() => route.fullPath,
async () => {
await fetchData(route.params.id)
}
)
// 方案三:使用 onBeforeRouteUpdate 守卫
import { onBeforeRouteUpdate } from 'vue-router'
onBeforeRouteUpdate(async (to, from) => {
if (to.params.id !== from.params.id) {
await fetchData(to.params.id)
}
})
</script>重复导航错误
当试图导航到当前所在页面时,Vue Router 4 会抛出一个 NavigationDuplicated 错误。
// 解决方案一:全局捕获
router.beforeEach((to) => {
if (to.path === router.currentRoute.value.path) {
return false // 阻止重复导航
}
})
// 解决方案二:使用 router.push 的错误处理
router.push('/dashboard').catch(err => {
if (err.name !== 'NavigationDuplicated') {
throw err // 非重复导航错误才抛出
}
})
// 解决方案三:重写 push 和 replace(推荐)
const originalPush = Router.prototype.push
Router.prototype.push = function push(location) {
return originalPush.call(this, location).catch(err => err)
}路由守卫中异步操作未 await
// ❌ 错误:async 但未 await
router.beforeEach(async (to) => {
if (to.meta.requiresAuth) {
checkAuth() // 返回了 Promise 但未 await
}
})
// ✅ 正确
router.beforeEach(async (to) => {
if (to.meta.requiresAuth) {
await checkAuth()
}
})6.2 Pinia 常见问题
解构丢失响应性
<script setup>
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
// ❌ 错误:直接解构导致 count 失去响应性
const { count, name } = store
// ✅ 正确:使用 storeToRefs
import { storeToRefs } from 'pinia'
const { count, name } = storeToRefs(store)
// ✅ Action 可以直接解构,因为它们就是普通函数
const { increment } = store
</script>$reset 在组合式 Store 中不可用
// 选项式 Store:$reset 可用
const optionStore = useOptionStore()
optionStore.$reset() // ✅
// 组合式 Store:$reset 不可用
const composableStore = useComposableStore()
composableStore.$reset() // ❌ 会报错
// 解决方案:手动实现 reset 函数
export const useComposableStore = defineStore('composable', () => {
const initialState = () => ({
count: 0,
name: '计数器'
})
const count = ref(initialState().count)
const name = ref(initialState().name)
function $reset() {
count.value = initialState().count
name.value = initialState().name
}
return { count, name, $reset }
})在组件外使用 Store(如工具函数、路由守卫)
// 确保 Pinia 已被激活
import { createPinia } from 'pinia'
const pinia = createPinia()
// 在路由守卫中(已通过 app.use(pinia) 注册后)
// router/index.ts
import { useUserStore } from '@/stores/user'
router.beforeEach((to) => {
// ✅ 此时 Pinia 实例已全局可用
const userStore = useUserStore()
// ...
})总结
- 单一职责:每个 store 只负责一个领域的状态
- 按功能模块分离:使用
business/子目录分类 - 避免循环引用:store 间可以互相调用,但要避免 A → B → A 的死循环
- 合理使用组合式 Store:复杂业务逻辑用组合式,简单 CRUD 用选项式
- 通过
$reset支持状态重置:确保登出/切换账户时能干净清理 - 使用 TypeScript:充分利用 Pinia 的类型推断优势
- 不要在 Store 中保存路由或组件引用:保持 store 可序列化
总结
本文全面介绍了 Vue Router 4 和 Pinia 的核心概念与进阶用法:
Vue Router 4 方面,我们涵盖了路由模式的选择、基础配置、动态路由匹配、嵌套路由、编程式导航,以及高级特性如导航守卫的完整解析流程、路由元信息、数据获取策略、过渡动画、滚动行为、懒加载和动态路由权限控制。
Pinia 方面,我们从创建 Store 的基础开始,深入讲解了 State 的多种操作方式、Getter 的定义与传参、Action 的同步异步处理与跨 Store 调用。进阶部分涉及组合式 Store、订阅机制、SSR 注意事项和单元测试,最后通过完整的实战案例展示了路由守卫与状态管理的联合应用。
通过 Vue Router 4 和 Pinia 的协同,我们可以构建结构清晰、类型安全、可维护性高的 Vue 3 应用。这套组合已成为现代 Vue 开发的事实标准,掌握它们将极大提升你的前端开发效率。