单页应用进阶:Vue Router 与 Pinia 状态管理

在学习了前端工程化基础和 Vue 3 的入门概念后,接下来最迫切需要的,是学习如何在 Vue 中处理路由(页面跳转)以及状态管理。现代前端应用几乎都是单页应用(SPA),只有学会了这两者,才能真正把一个个零散的组件串联成一个完整的 Web 项目。

1. 什么是单页应用(SPA)?

1.1 传统多页应用 vs 现代单页应用

在传统的 Web 开发中,网站通常是“多页应用(MPA - Multi-Page Application)”。

  • 多页应用:用户每次点击链接跳转到新页面时,浏览器都会向服务器发送请求,服务器返回一个全新的 HTML 页面,浏览器重新渲染整个页面。在这个过程中,屏幕通常会短暂“闪烁”或白屏。

随着前端技术的发展,我们希望在浏览器中获得像原生 App 一样流畅的体验,于是出现了“单页应用(SPA - Single-Page Application)”。

  • 单页应用:整个网站只有一个 HTML 页面(通常是 index.html)。当用户点击链接时,页面不会刷新。前端代码(JavaScript)会拦截这个跳转动作,只向服务器请求必要的数据,然后在本地动态替换掉需要改变的 DOM 结构(比如把“首页组件”卸载,挂载“关于我们组件”)。

1.2 为什么 Vue 适合做 SPA?

Vue 作为一个以数据驱动和组件化为核心的框架,天生就适合构建 SPA:

  1. 组件化:我们可以把每一个页面当作一个庞大的“页面级组件”。
  2. 响应式:当路由状态(当前 URL)发生变化时,Vue 可以迅速响应并渲染对应的组件。
  3. 前端接管路由:不再依赖后端服务器来决定返回哪个页面,前端自己就能根据 URL 决定展示什么内容,体验更加丝滑。

然而,Vue 核心库本身只负责视图层的渲染。要实现“根据不同的 URL 显示不同的组件”,我们就需要引入官方的路由管理器 —— Vue Router

2. 页面导航工程师:Vue Router 基础

2.1 什么是前端路由?

后端的路由是:“当用户请求 /api/users 时,执行查询数据库的代码”。 前端的路由是:“当浏览器 URL 变成 /home 时,在页面上渲染 <Home /> 组件”。

这就是 Vue Router 的核心工作:监听 URL 变化,并渲染相应的组件

2.2 安装与基础配置

如果你使用 Vite 创建项目,可以在创建时选择包含 Vue Router。如果是现有项目,可以通过 npm 安装:

npm install vue-router@4

然后,我们需要在项目中配置路由。通常我们会在 src 目录下新建一个 router/index.js 文件:

// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import AboutView from '../views/AboutView.vue'

// 1. 定义路由表:URL 路径和组件的映射关系
const routes = [
  { path: '/', component: HomeView },
  { path: '/about', component: AboutView }
]

// 2. 创建路由实例
const router = createRouter({
  // 使用 HTML5 模式的历史记录
  history: createWebHistory(),
  routes,
})

export default router

关于 history: createWebHistory()

这行代码用于配置路由的历史记录模式。 在 Vue 3 中,官方改用了工厂函数来配置模式。createWebHistory() 代表使用 HTML5 History 模式。 在这种模式下,浏览器的 URL 看起来就像普通的网页路径一样(例如:https://example.com/user/123),不会带有 # 号,更加美观。 补充说明:如果这里使用的是 createWebHashHistory(),那么 URL 就会带有 # 号(例如:https://example.com/#/user/123)。

接着,在 main.js 中将路由实例注册到 Vue 应用中:

// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router' // 引入路由配置

const app = createApp(App)
app.use(router) // 注册路由
// 把应用挂载到页面入口节点
app.mount('#app')

2.3 核心组件

配置好后,我们需要告诉 Vue 在哪里显示这些页面组件,以及如何进行跳转。Vue Router 提供了两个全局组件:

  1. <router-view>占位符。当 URL 匹配到某个路由时,对应的组件就会渲染在这个位置。
  2. <router-link>导航链接。它相当于 Vue 中的 <a> 标签,但点击它不会触发页面刷新,只会改变 URL 并让 <router-view> 更新内容。

在你的根组件 App.vue 中,通常会这样写:

<template>
  <div>
    <h1>我的 Vue 应用</h1>
    <nav>
      <!-- 使用 router-link 替代 a 标签 -->
      <router-link to="/">首页</router-link> | 
      <router-link to="/about">关于我们</router-link>
    </nav>

    <!-- 路由匹配到的组件将渲染在这里 -->
    <main>
      <router-view></router-view>
    </main>
  </div>
</template>

3. Vue Router 进阶应用

除了基础的页面跳转,实际开发中我们经常会遇到以下三种常见需求。

3.1 动态路由匹配

有些时候,我们需要将匹配某种模式的 URL 映射到同一个组件。例如,我们有一个 User 组件,它应该对所有用户进行渲染,但 URL 可能是 /user/1/user/2

在路由配置中,我们可以使用动态路径参数(以冒号 : 开头):

const routes = [
  // 动态字段以冒号开始
  { path: '/user/:id', component: UserView },
]

当路由跳转到 /user/123 时,在 UserView.vue 组件内部,我们可以通过 $route.params 或 Composition API 的 useRoute() 来获取这个参数:

import { useRoute } from 'vue-router'
import { onMounted } from 'vue'

const route = useRoute()

onMounted(() => {
  // 通过动态路由参数读取当前用户 ID
  console.log('当前用户的 ID 是:', route.params.id) // 输出 123
})

3.2 编程式导航

除了在模板中使用 <router-link> 声明式地导航,很多时候我们需要在 JavaScript 逻辑中触发跳转。例如:“点击登录按钮,验证成功后跳转到首页”。

这时可以使用 useRouter() 返回的路由实例的 push 方法:

import { useRouter } from 'vue-router'

const router = useRouter()

const handleLogin = async () => {
  await api.login() // 假设这是一个登录请求
  // 登录成功后,跳转到首页
  router.push('/')
  
  // 或者带参数跳转
  // router.push({ path: '/user/123' })
}

3.3 嵌套路由

许多应用的 UI 由多层嵌套的组件组成。比如一个后台管理系统,通常有一个固定不变的侧边栏(Sidebar)和顶部导航(Header),只有中间的内容区域会根据路由变化。

这种结构可以通过“嵌套路由”来实现。我们在定义路由时使用 children 属性:

const routes = [
  {
    path: '/admin',
    component: AdminLayout, // 这是带有侧边栏的父组件
    children: [
      {
        path: 'dashboard', // 注意:子路由不要加开头的 '/'
        component: DashboardView // 渲染在 AdminLayout 的 <router-view> 中
      },
      {
        path: 'settings',
        component: SettingsView
      }
    ]
  }
]

在父组件 AdminLayout.vue 中,我们需要放置一个子级的 <router-view>

<template>
  <div class="admin-layout">
    <Sidebar />
    <div class="main-content">
      <!-- Dashboard 或 Settings 会渲染在这里 -->
      <router-view></router-view> 
    </div>
  </div>
</template>

3.4 路由守卫:权限拦截

真实的商业项目中,我们几乎都需要做“登录验证”:如果用户没登录,尝试访问 /admin 时应该被强制踢回 /login。这就需要用到全局前置守卫 router.beforeEach

router/index.js 中添加:

router.beforeEach((to, from, next) => {
  const isAuthenticated = localStorage.getItem('token');

  // 如果要去的是 admin 开头的页面,且没登录
  if (to.path.startsWith('/admin') && !isAuthenticated) {
    next('/login'); // 踢回登录页
  } else {
    next(); // 放行
  }
});

4. 为什么需要状态管理?

在学习 Vue 组件时,我们知道父组件可以通过 props 向子组件传值,子组件可以通过 emits 向父组件发送事件。在简单的层级关系中,这套机制非常清晰好用。

但是,当应用变得庞大时,会遇到两个噩梦:

  1. 多层级嵌套(Props Drilling):假设组件层级是 A -> B -> C -> D。A 组件获取了用户信息,D 组件需要显示用户名。你不得不把数据从 A 传给 B,再传给 C,最后传给 D。中间的 B 和 C 根本不需要这个数据,却成了无情的“传参机器”。
  2. 兄弟组件/跨页面通信:比如我们在 Header 组件中需要显示“购物车数量”,而“添加到购物车”的按钮在 ProductList 页面组件中。它们之间没有直接的父子关系,怎么通知对方数据变了?

为了解决这个问题,我们需要引入状态管理的概念。

简单来说,就是把那些“很多组件都要用到的公共数据(状态)”从组件中剥离出来,放到一个全局的公共仓库中。任何组件都可以直接去这个仓库里读取数据,或者修改数据。一旦仓库里的数据变了,所有使用了这个数据的组件都会自动更新。

5. 现代 Vue 的状态管理方案:Pinia

过去,Vue 生态中最著名的状态管理库是 Vuex。但随着 Vue 3 的发布,官方推荐使用新一代的状态管理库 —— Pinia

为什么选择 Pinia?

  • 极简的 API:抛弃了 Vuex 中繁琐的 mutations,代码写起来就像写一个普通的 Vue 组合式函数。
  • 完美的 TypeScript 支持:无需复杂的类型声明,天生具备类型推导。
  • 更轻量:体积小巧,并且支持代码分割。

5.1 Pinia 的三大核心概念

Pinia 中的一个仓库(Store)就像是一个拥有自己状态和业务逻辑的实体。它包含三个核心部分:

  1. State:存放数据的地方。可以理解为组件中的 refreactive 变量。
  2. Getters:从 State 计算派生出的数据。相当于组件中的 computed 计算属性。
  3. Actions:修改 State 或执行异步逻辑的方法。相当于组件中的 function 普通函数。

5.2 安装与基础使用

首先安装 Pinia:

npm install pinia

main.js 中注册:

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
app.use(createPinia()) // 注册 Pinia
app.mount('#app')

接下来,我们定义一个简单的计数器 Store(通常放在 src/stores 目录下):

// src/stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// defineStore 的第一个参数是这个 store 的唯一 id
export const useCounterStore = defineStore('counter', () => {
  // 1. State: 定义响应式数据
  const count = ref(0)

  // 2. Getters: 定义计算属性
  const doubleCount = computed(() => count.value * 2)

  // 3. Actions: 定义修改数据的方法
  function increment() {
    // 统一通过 action 修改状态
    count.value++
  }

  // 最后必须将这些暴露出去
  return { count, doubleCount, increment }
})

注意:这里使用的是 Pinia 的 Setup Store 写法,它与 Vue 3 的 Composition API 完全一致,非常直观。

然后在任何组件中,我们都可以直接使用这个 Store:

<template>
  <div>
    <p>当前计数: {{ counterStore.count }}</p>
    <p>双倍计数: {{ counterStore.doubleCount }}</p>
    <button @click="counterStore.increment()">点击增加</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'

// 实例化 store
const counterStore = useCounterStore()
</script>

5.3 避坑:响应式解构陷阱 (storeToRefs)

在组件中使用 Store 时,很多新手觉得每次写 counterStore.count 太长,想要解构它:

// ❌ 错误做法:直接解构会破坏响应式!数据变了页面也不会更新!
const { count, doubleCount } = useCounterStore()

为了保持响应式,Pinia 提供了 storeToRefs 方法:

import { storeToRefs } from 'pinia'

// ✅ 正确做法:使用 storeToRefs 包裹后再解构
const { count, doubleCount } = storeToRefs(useCounterStore())

注意:Actions 方法(如 increment)可以直接解构,不需要 storeToRefs

5.4 状态持久化提示

初学者常常会遇到一个问题:刷新页面后,Pinia 里的数据全部重置为空了! 这是因为 Pinia 的数据是保存在内存里的,页面刷新就会释放。

如果需要刷新不丢失(例如用户的登录 Token、购物车数据),常见的解决方案有两种:

  1. 手动结合 localStorage 存取。
  2. 引入持久化插件,如 pinia-plugin-persistedstate,只需配置一行代码,就能自动把数据无缝同步到本地缓存中。

6. 实战串联:一个简单的购物车逻辑

让我们把 Vue Router 和 Pinia 结合起来,想象一个最常见的电商场景:

  • 我们有两个页面:ProductList.vue(商品列表页)和 Cart.vue(购物车页)。
  • 顶部有一个贯穿始终的 Header.vue(导航栏),里面显示购物车里有几件商品。

第一步:定义购物车的 Store

// src/stores/cart.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCartStore = defineStore('cart', () => {
  const items = ref([]) // 购物车列表

  // Getter: 计算商品总数
  const totalItems = computed(() => {
    return items.value.reduce((total, item) => total + item.quantity, 0)
  })

  // Action: 添加到购物车
  function addToCart(product) {
    // 先查找购物车里是否已有同一个商品
    const existingItem = items.value.find(item => item.id === product.id)
    if (existingItem) {
      existingItem.quantity++
    } else {
      // 没有则追加一条新商品记录
      items.value.push({ ...product, quantity: 1 })
    }
  }

  return { items, totalItems, addToCart }
})

第二步:在商品列表页(页面A)调用 Action

<!-- src/views/ProductList.vue -->
<template>
  <div>
    <h2>商品列表</h2>
    <div v-for="product in products" :key="product.id">
      <span>{{ product.name }} - ¥{{ product.price }}</span>
      <!-- 点击触发 Store 中的 addToCart 方法 -->
      <button @click="cartStore.addToCart(product)">加入购物车</button>
    </div>
  </div>
</template>

<script setup>
import { useCartStore } from '@/stores/cart'

const cartStore = useCartStore()
const products = [
  { id: 1, name: 'Vue 3 高级编程', price: 99 },
  { id: 2, name: '机械键盘', price: 499 }
]
</script>

第三步:在导航栏读取 Getter 并跳转(全局组件)

<!-- src/components/Header.vue -->
<template>
  <header>
    <router-link to="/">首页</router-link>
    <!-- 读取 Store 中的 totalItems,数据变化这里会自动更新 -->
    <router-link to="/cart">购物车 ({{ cartStore.totalItems }})</router-link>
  </header>
</template>

<script setup>
import { useCartStore } from '@/stores/cart'
const cartStore = useCartStore()
</script>

在这个例子中,商品列表页、购物车页、导航栏,这三个毫不相干的组件,通过 Pinia 的 Store 完美地共享了数据,并通过 Vue Router 实现了无缝的页面切换。这就是现代前端开发的魅力。

7. 总结与下一步

到这里,你已经掌握了构建现代 Web 应用的“四大金刚”:

  • Vue 3:负责构建组件和声明式渲染。
  • Vite:负责极速的本地开发和项目打包。
  • Vue Router:负责管理多个页面的无刷新跳转。
  • Pinia:负责管理那些在多个组件间共享的全局数据。

拥有了这套技术栈,你已经完全具备了独立开发一个复杂单页应用(SPA)的能力。

下一步去哪? 虽然我们学会了如何组织代码和逻辑,但手写的按钮、表单和弹窗可能还不够美观。在实际的商业项目中,我们通常会引入现成的前端 UI 组件库(比如 Element Plus、Ant Design Vue 等)。 下一篇文章,我们将探讨如何将这些精美的 UI 组件库接入到我们的项目中,让你的应用瞬间具备“专业感”。