在学习了前端工程化基础和 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:
- 组件化:我们可以把每一个页面当作一个庞大的“页面级组件”。
- 响应式:当路由状态(当前 URL)发生变化时,Vue 可以迅速响应并渲染对应的组件。
- 前端接管路由:不再依赖后端服务器来决定返回哪个页面,前端自己就能根据 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 提供了两个全局组件:
<router-view>:占位符。当 URL 匹配到某个路由时,对应的组件就会渲染在这个位置。<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 向父组件发送事件。在简单的层级关系中,这套机制非常清晰好用。
但是,当应用变得庞大时,会遇到两个噩梦:
- 多层级嵌套(Props Drilling):假设组件层级是 A -> B -> C -> D。A 组件获取了用户信息,D 组件需要显示用户名。你不得不把数据从 A 传给 B,再传给 C,最后传给 D。中间的 B 和 C 根本不需要这个数据,却成了无情的“传参机器”。
- 兄弟组件/跨页面通信:比如我们在
Header组件中需要显示“购物车数量”,而“添加到购物车”的按钮在ProductList页面组件中。它们之间没有直接的父子关系,怎么通知对方数据变了?
为了解决这个问题,我们需要引入状态管理的概念。
简单来说,就是把那些“很多组件都要用到的公共数据(状态)”从组件中剥离出来,放到一个全局的公共仓库中。任何组件都可以直接去这个仓库里读取数据,或者修改数据。一旦仓库里的数据变了,所有使用了这个数据的组件都会自动更新。
5. 现代 Vue 的状态管理方案:Pinia
过去,Vue 生态中最著名的状态管理库是 Vuex。但随着 Vue 3 的发布,官方推荐使用新一代的状态管理库 —— Pinia。
为什么选择 Pinia?
- 极简的 API:抛弃了 Vuex 中繁琐的
mutations,代码写起来就像写一个普通的 Vue 组合式函数。 - 完美的 TypeScript 支持:无需复杂的类型声明,天生具备类型推导。
- 更轻量:体积小巧,并且支持代码分割。
5.1 Pinia 的三大核心概念
Pinia 中的一个仓库(Store)就像是一个拥有自己状态和业务逻辑的实体。它包含三个核心部分:
- State:存放数据的地方。可以理解为组件中的
ref或reactive变量。 - Getters:从 State 计算派生出的数据。相当于组件中的
computed计算属性。 - 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、购物车数据),常见的解决方案有两种:
- 手动结合
localStorage存取。 - 引入持久化插件,如
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 组件库接入到我们的项目中,让你的应用瞬间具备“专业感”。