news 2026/10/16 3:09:31

【Web全栈进阶】Vue Router + Pinia:SPA路由与全局状态

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
【Web全栈进阶】Vue Router + Pinia:SPA路由与全局状态

手动往localStorage塞token的临时方案今天退役。登录页正式登场——Vue Router管页面跳转、Pinia管全局状态,路由守卫实现“没登录访问受保护页面自动踢回登录页”。前端基础设施,到此齐装满员。

🎯本篇产出:三页面SPA(首页 / 我的收藏 / 登录)+ 登录态全局管理 + 前端门禁。含代码约150行。阶段二收官篇。


📌 太长不看版(给想快速上手的你)

项目信息一句话说明
本篇目标给SPA加上路由与全局状态管理
代码行数~150行(含注释)
依赖vue-router+pinia
核心功能三页面路由 + 登录态持久化 + 前端门禁
跑起来的命令npm run dev→ 浏览器访问各页面
核心知识点路由表、meta.requiresAuth、Pinia store、路由守卫
做完你能得到一套完整的SPA前端基础设施

一、两个新成员:为什么SPA需要它们

🗺️ Vue Router——前端的路由表

你已经在FastAPI里写过路由:URL映射到函数。SPA只有一个HTML壳,地址栏的变化全靠JS处理——Vue Router就是前端的“URL → 组件”映射表:

后端(FastAPI): /articles → list_articles() 返回JSON 前端(Vue Router): / → HomeView组件 渲染页面 /favorites → FavoritesView组件 /login → LoginView组件

📌路由思想完全一样,只是渲染端不同——后端渲染数据给接口,前端渲染组件给用户。

📦 Pinia——全局状态仓库

你有没有发现,收藏计数、登录用户这类状态在App.vue里越传越累?props传三层就叫prop drilling(钻透),传五层就是灾难。Pinia提供一个“全局抽屉”:任何组件都能取用登录用户、令牌,不用一层层传。

不用Pinia:App → NavBar → UserMenu → 头像组件(token传了四层) 用Pinia:任何组件直接useAuthStore()取token

💡对照后端你已熟悉的模式:这就是“跨层共享的数据要集中管理”——settings单例、db session注入,同一个思想的前端版。


二、第0步:安装与项目结构改造

npminstallvue-router pinia

新建目录与文件(views/放“页面级组件”,stores/放“全局状态”):

src/ ├── main.js # 入口:挂载router与pinia ├── router/index.js # 路由表 + 守卫 ├── stores/auth.js # 登录态全局仓库 ├── views/ │ ├── HomeView.vue # 首页(文章列表——从App.vue搬过来) │ ├── LoginView.vue # 登录页(新) │ └── FavoritesView.vue # 我的收藏(新,受保护页面) └── components/ # 组件保持不变

🎯App.vue从此变成“壳”:只剩导航栏 +<router-view />(当前路由的组件在这里渲染)——页面各自成文件,这正是“组件即函数”的放大版。


三、第1步:Pinia——auth全局仓库

src/stores/auth.js:

import{computed,ref,watch}from'vue'import{defineStore}from'pinia'importapifrom'../lib/api'exportconstuseAuthStore=defineStore('auth',()=>{// 初始化从localStorage读——刷新页面登录态不丢consttoken=ref(localStorage.getItem('token')||'')constusername=ref(localStorage.getItem('username')||'')constisLoggedIn=computed(()=>Boolean(token.value))// token一变就写回localStorage(持久化)watch(token,(t)=>{if(t){localStorage.setItem('token',t)}else{localStorage.removeItem('token')}})asyncfunctionlogin(name,password){// ⚠️ 登录接口用OAuth2PasswordRequestForm,接收的是表单编码constform=newURLSearchParams({username:name,password})const{data}=awaitapi.post('/auth/login',form)token.value=data.access_token username.value=name}functionlogout(){token.value=''username.value=''localStorage.removeItem('username')}return{token,username,isLoggedIn,login,logout}})

📌 三段式设计

#结构作用
①ref存状态token、username
②computed派生isLoggedIn
③action改状态login、logout

💡watch(token, ...)做持久化——令牌一变自动写localStorage,手动setItem从此消失。

🚨 登录请求的编码坑

📌登录接口用的是OAuth2PasswordRequestForm,接收表单编码(application/x-www-form-urlencoded),不是JSON。前端必须用URLSearchParams构造请求体,否则后端会报422。


四、第2步:Router——路由表 + 守卫

src/router/index.js:

import{createRouter,createWebHistory}from'vue-router'import{useAuthStore}from'../stores/auth'constroutes=[{path:'/',component:()=>import('../views/HomeView.vue')},// lazy import:路由级代码分割——访问到才加载,首屏更快{path:'/favorites',component:()=>import('../views/FavoritesView.vue'),meta:{requiresAuth:true},// 标记:这条路由需要登录},{path:'/login',component:()=>import('../views/LoginView.vue')},// 兜底:没匹配到的路径一律回首页{path:'/:pathMatch(.*)*',redirect:'/'},]constrouter=createRouter({history:createWebHistory(),routes})// 路由守卫:前端版门禁(login_required的镜像)router.beforeEach((to)=>{constauth=useAuthStore()if(to.meta.requiresAuth&&!auth.isLoggedIn){return{path:'/login',query:{redirect:to.fullPath}}}})exportdefaultrouter

📌 三个关键点

#关键点说明
①meta.requiresAuth路由的“元信息”标记——声明这条路由需要登录,守卫读它做判断
②守卫每次跳转前执行——没登录访问/favorites→ 踢到/login并带上?redirect=/favorites(登录后原路返回)
③() => import(...)懒加载路由级代码分割——访问到哪个页面才下载哪个页面的代码,首屏不用一次拉全站

🚨 重要认知

📌前端守卫是“体验”不是“安全边界”——绕过它直接调API照样能拿到数据(如果后端没锁)。真正的安全永远在后端(get_current_user)。

前端守卫管“让用户看到什么”,后端门禁管“让请求拿到什么”。


五、第3步:三页面落地

🚀 src/main.js(挂载插件)

import{createApp}from'vue'import{createPinia}from'pinia'importrouterfrom'./router'importAppfrom'./App.vue'createApp(App).use(createPinia())// 全局状态.use(router)// 路由.mount('#app')

🔑 LoginView.vue(登录页核心)

<script setup> import { ref } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useAuthStore } from '../stores/auth' const username = ref('') const password = ref('') const errorMsg = ref('') const auth = useAuthStore() const router = useRouter() const route = useRoute() async function submit() { errorMsg.value = '' try { await auth.login(username.value, password.value) // 登录成功:跳回被拦截时记下的页面,没有就回首页 router.push(route.query.redirect || '/') } catch (e) { errorMsg.value = '登录失败:' + (e.response?.data?.detail || '请重试') } } </script> <template> <form class="login" @submit.prevent="submit"> <h1>登录早报站</h1> <input v-model="username" placeholder="用户名" /> <input v-model="password" type="password" placeholder="密码" /> <p v-if="errorMsg" class="error">{{ errorMsg }}</p> <button type="submit">登 录</button> </form> </template>

🐚 App.vue变壳(补齐script setup + template)

<script setup> import { RouterLink, RouterView } from 'vue-router' import { useAuthStore } from './stores/auth' const auth = useAuthStore() </script> <template> <nav> <RouterLink to="/">首页</RouterLink> <RouterLink to="/favorites">我的收藏</RouterLink> <template v-if="auth.isLoggedIn"> <span>👤 {{ auth.username }}</span> <a href="#" @click.prevent="auth.logout()">退出</a> </template> <RouterLink v-else to="/login">登录</RouterLink> </nav> <main> <RouterView /> <!-- 当前路由的组件渲染在这里 --> </main> </template>

⚠️注意:RouterLink和RouterView在<script setup>里显式import了,才能被模板识别(Vue Router 4的约定,不再自动全局注册)。

🆕 FavoritesView.vue(受保护页面)

需要后端配合——GET /favorites返回当前用户收藏的文章。后端补上这个接口:

# app/routers/favorites.py 追加fromsqlalchemyimportselect@router.get("")deflist_favorites(session:Session=Depends(get_session),user:User=Depends(get_current_user),):"""返回当前用户收藏的文章列表"""stmt=(select(Article).join(favorites,favorites.c.article_id==Article.id).where(favorites.c.user_id==user.id).order_by(Article.id.desc()))returnsession.scalars(stmt).all()

💡favorites.c.article_id的.c是访问Table对象列的语法——关联表在这里正式被查询用到。

前端照三段式模板拉数据:

<script setup> import { onMounted, ref } from 'vue' import api from '../lib/api' import ArticleCard from '../components/ArticleCard.vue' const articles = ref([]) const loading = ref(true) const error = ref('') onMounted(async () => { try { const { data } = await api.get('/favorites') articles.value = data } catch (e) { error.value = '加载失败:' + (e.response?.data?.detail || e.message) } finally { loading.value = false } }) </script> <template> <h1>我的收藏</h1> <p v-if="loading">加载中…</p> <p v-else-if="error" class="error">{{ error }}</p> <p v-else-if="articles.length === 0">还没有收藏任何文章。</p> <ArticleCard v-for="a in articles" :key="a.id" v-bind="a" /> </template>

六、验收清单

1. 未登录点“我的收藏” → 自动踢到/login?redirect=/favorites2. 登录成功 → 自动跳回/favorites(redirect兑现)3. 刷新页面 → 登录态还在(Pinia + localStorage持久化)4. 点“退出” → 导航栏变回“登录”,访问/favorites再次被踢5. 收藏页列出收藏过的文章(真接口数据)6. 随便敲一个不存在的路径 → 兜底回首页7. 全程无报错后提交Git
gitadd.gitcommit-m"Router + Pinia:SPA路由、全局登录态、前端门禁"

七、常见报错:这6个,Router/Pinia的标配(重点!)

①[Vue Router warn]: No match found for location with path "/xxx"

🔍 原因:路由表里没有这个路径。

✅ 解法:加catch-all兜底(第4节的/:pathMatch(.*)*);或检查路径拼写。

📌地址栏是用户唯一能直接输入的界面——永远给它一个兜底。


②Failed to resolve async component

🔍 原因:懒加载的import路径写错,或views/文件名与路径不一致(大小写敏感)。

✅ 解法:核对文件路径。

⚠️ lazy import的字符串是运行时才解析——写错不会在启动时报,只在访问时炸。


③ 模板里用auth报Property "auth" is not defined

🔍 原因:Pinia store不会自动注入模板——要像其他变量一样在script setup里声明。

✅ 解法:const auth = useAuthStore()后再用。

💡 想解构取值必须storeToRefs(auth)——直接解构会丢响应性(Pinia版的.value之痛)。


④ 刷新后登录态丢了

🔍 原因:store初始化时没从localStorage读token(或watch持久化没写)。

✅ 解法:对照第3节初始化两行 + watch写法。

📌持久化三件套:初始化读、变更时写、清除时删。


⑤ 路由守卫无限循环 / 一直跳登录页

🔍 原因:守卫把登录页也拦了(login路由带了requiresAuth),或重定向目标仍是受保护页。

✅ 解法:

  • login路由不带meta.requiresAuth;
  • 守卫里跳转前确认目标不是自己。

⑥ 登录成功却停在登录页

🔍 原因:route.query.redirect读错拼写,或登录后跳转被守卫再次拦截。

✅ 解法:

  • router.push(route.query.redirect || '/');
  • F12 → Network看登录请求是否真的200。

八、阶段 · 收官自查 ✅

从零到能用的现代前端:

主题装进工具箱的能力
前端世界观声明式渲染 + Node/Vite翻译表
Vue3起步SFC、ref响应式、props、v-for
组件通信emit、具名插槽、v-model原理、单向数据流
前后端分离axios、CORS、拦截器、防抖请求
Router + PiniaSPA路由、全局登录态、前端门禁

✅ 出门条

  • 你的早报站前端能登录
  • 能收藏
  • 能搜索
  • 有路由有守卫
  • 与后端(阶段一)合在一起,一个“前后端分离的真产品”呼之欲出

🎯后续,把这两个半场正式合成一个完整产品。


九、课后练习

#练习难度提示
1404页面:把catch-all从redirect换成专属NotFoundView组件(带“回首页”链接)⭐⭐更友好的用户体验
2登录页完善:提交按钮加loading态、错误信息标红、密码框回车可提交⭐⭐表单体验
3角色守卫:路由meta加role(admin专属页),守卫里校验auth.role⭐⭐⭐权限分级
4(选做)退出确认:退出前弹确认⭐⭐组件内dialog或window.confirm——思考哪个体验好

📦 配套代码

完整前端与后端GET /favorites已上传GitHub(python_daily/):【gitee仓库地址】

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 3:08:40

青岛网站建设公司推荐:从技术视角看企业建站选型

从技术层面看&#xff0c;企业建站远不止“做个页面”这么简单&#xff0c;涉及前端框架、后端架构、数据库、服务器环境、多语言适配、安全防护等一系列工程问题。青岛润商网络科技有限公司&#xff0c;以下简称“润商科技”&#xff0c;成立于2010年&#xff0c;专注外贸网站…

作者头像 李华
网站建设 2026/10/16 3:08:22

【STM32C5教程】(7)TIM1 互补 PWM 输出实验

欢迎关注 youcansxidian【嵌入式软件AI编程】专栏 【STM32C5教程】&#xff08;2&#xff09;STM32CubeMX2 安装与STM32C5闪灯 【STM32C5教程】&#xff08;4&#xff09;GPIO 外部中断实验 【STM32C5教程】&#xff08;5&#xff09;定时器周期中断实验 【STM32C5教程】&#…

作者头像 李华
网站建设 2026/10/16 3:08:06

C++互斥锁学习

使用目的&#xff1a;保护共享资源&#xff0c;防止多线程并发读写造成数据错乱基础概念&#xff1a;临界区&#xff1a;多线程会访问共享变量的那段代码竞态&#xff1a;多个线程并发访问共享数据&#xff0c;至少有一个线程是写操作&#xff1b;最终结果取决于线程调度的先后…

作者头像 李华
网站建设 2026/10/16 3:08:04

第 6 篇 IOMMU 的基本职能:地址翻译与设备隔离

本文属于第二册“IOMMU&#xff1a;地址翻译与隔离的硬件机制及内核管理”&#xff0c;回答第 0 篇提出的问题 6&#xff1a;IOMMU 如何同时解决物理连续性、寻址范围与设备隔离三个问题&#xff1f;其内部的地址翻译过程是怎样的&#xff1f; 第 5 篇第 6 节指出&#xff0c;…

作者头像 李华
网站建设 2026/10/16 3:07:23

Halcon 数组操作(Tuple)详解与算子说明

Halcon 数组操作(Tuple)详解与算子说明 适用版本:Halcon 17.12(算子名、签名、默认值、取值范围均严格对照本地官方文档 D:/Soft/WorkSoft/Halcon17/doc/html/reference/operators/) 本文档覆盖 Halcon 17.12 Tuple / 数组操作全部 121 个官方算子,共 13 个官方分类。 一…

作者头像 李华