Vue Router 与 Pinia 状态管理深度指南

2024-09-08 前端

前言

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' } })

<router-link> 是声明式导航的基础组件,提供了丰富的配置选项。

在 Vue Router 4 中,<router-link> 默认渲染为 <a> 元素,但通过 custom 属性和 v-slot 可以自定义渲染任意元素。


二、路由进阶

2.1 导航守卫

导航守卫是 Vue Router 最强大的特性之一,用于在路由跳转过程中进行拦截和控制。共有三类守卫。

全局守卫

路由独享守卫

直接在路由配置中定义,只对该路由生效。

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 字段是路由配置中存储自定义信息的标准方式,常用于权限控制、页面标题、布局选择等。

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 中获取)

先进入页面,显示加载状态,然后获取数据。适合:需要即时交互或数据请求较轻的场景。

2.4 路由过渡动画

利用 Vue 的 <Transition> 组件,可以为路由切换添加流畅的动画效果。

在路由配置中指定动画类型:

{
  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() 允许在运行期间动态注册新路由,是实现权限控制的核心机制。


三、Pinia 状态管理

3.1 创建 Store:defineStore

Pinia 使用 defineStore 定义 store,支持两种写法:选项式(Options API)和组合式(Composition API)。

选项式 Store

组合式 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 操作非常灵活直观。

注意:$patch 以对象形式调用时,对于数组操作(如 push、splice)需要通过索引赋值或使用函数形式。

3.3 Getters:定义、访问其他 Getter、传参

Getters 是 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 中定义业务逻辑的地方,支持同步和异步操作。

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 等函数,适合复杂的业务逻辑。

4.2 Store 间互相调用

在实际应用中,store 之间经常需要互相通信。

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 逻辑。


五、综合实战

5.1 权限系统:路由守卫 + 状态管理联合

一个完整的前端权限系统通常由 Vue Router 的动态路由和 Pinia 的状态管理协同实现。

5.2 用户登录流

一个完整的登录流程设计,从登录 → 存储 token → 动态路由 → 跳转。

5.3 页面加载状态与错误处理

一个健壮的 SPA 应用需要完善的加载状态和错误处理。

在具体页面组件中使用:

<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 中,性能优化是必须考虑的问题。

路由层面优化

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 错误边界与全局异常处理

5.7 完整代码示例:一个权限管理页面

结合上述所有知识点,实现一个完整的权限管理页面。

六、常见问题与排查技巧

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()
  // ...
})

总结

  1. 单一职责:每个 store 只负责一个领域的状态
  2. 按功能模块分离:使用 business/ 子目录分类
  3. 避免循环引用:store 间可以互相调用,但要避免 A → B → A 的死循环
  4. 合理使用组合式 Store:复杂业务逻辑用组合式,简单 CRUD 用选项式
  5. 通过 $reset 支持状态重置:确保登出/切换账户时能干净清理
  6. 使用 TypeScript:充分利用 Pinia 的类型推断优势
  7. 不要在 Store 中保存路由或组件引用:保持 store 可序列化

总结

本文全面介绍了 Vue Router 4 和 Pinia 的核心概念与进阶用法:

Vue Router 4 方面,我们涵盖了路由模式的选择、基础配置、动态路由匹配、嵌套路由、编程式导航,以及高级特性如导航守卫的完整解析流程、路由元信息、数据获取策略、过渡动画、滚动行为、懒加载和动态路由权限控制。

Pinia 方面,我们从创建 Store 的基础开始,深入讲解了 State 的多种操作方式、Getter 的定义与传参、Action 的同步异步处理与跨 Store 调用。进阶部分涉及组合式 Store、订阅机制、SSR 注意事项和单元测试,最后通过完整的实战案例展示了路由守卫与状态管理的联合应用。

通过 Vue Router 4 和 Pinia 的协同,我们可以构建结构清晰、类型安全、可维护性高的 Vue 3 应用。这套组合已成为现代 Vue 开发的事实标准,掌握它们将极大提升你的前端开发效率。