【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 → 组件"映射表:

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

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

📦 Pinia------全局状态仓库

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

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

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


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

bash 复制代码
npm install vue-router pinia

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

text 复制代码
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:

javascript 复制代码
import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
import api from '../lib/api'

export const useAuthStore = defineStore('auth', () => {
  // 初始化从localStorage读------刷新页面登录态不丢
  const token = ref(localStorage.getItem('token') || '')
  const username = ref(localStorage.getItem('username') || '')

  const isLoggedIn = computed(() => Boolean(token.value))

  // token一变就写回localStorage(持久化)
  watch(token, (t) => {
    if (t) {
      localStorage.setItem('token', t)
    } else {
      localStorage.removeItem('token')
    }
  })

  async function login(name, password) {
    // ⚠️ 登录接口用OAuth2PasswordRequestForm,接收的是表单编码
    const form = new URLSearchParams({ username: name, password })
    const { data } = await api.post('/auth/login', form)
    token.value = data.access_token
    username.value = name
  }

  function logout() {
    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:

javascript 复制代码
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth'

const routes = [
  { 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: '/' },
]

const router = createRouter({ history: createWebHistory(), routes })

// 路由守卫:前端版门禁(login_required的镜像)
router.beforeEach((to) => {
  const auth = useAuthStore()
  if (to.meta.requiresAuth && !auth.isLoggedIn) {
    return { path: '/login', query: { redirect: to.fullPath } }
  }
})

export default router

📌 三个关键点

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

🚨 重要认知

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

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


五、第3步:三页面落地

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

javascript 复制代码
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'

createApp(App)
  .use(createPinia())     // 全局状态
  .use(router)            // 路由
  .mount('#app')

🔑 LoginView.vue(登录页核心)

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)

vue 复制代码
<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返回当前用户收藏的文章。后端补上这个接口:

python 复制代码
# app/routers/favorites.py 追加
from sqlalchemy import select

@router.get("")
def list_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())
    )
    return session.scalars(stmt).all()

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

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

vue 复制代码
<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>

六、验收清单

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

✅ 出门条

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

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


九、课后练习

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

📦 配套代码

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

相关推荐
ManageEngineITSM1 小时前
工单优先级怎么定?影响×紧急度矩阵的设计与落地方法
java·大数据·运维·前端·数据库·变更管理
智商网输送线配件1 小时前
电子制造零散耗材补货实战:小批量混批采购平台怎么选才不停机?
大数据·服务器·前端·数据库·自动化
梦帮科技1 小时前
Web 漏洞攻防深度:WAF 规则逃逸技术(畸形分块传输、双重编码)与对抗加固
linux·前端·网络安全·ssl·web
兰亭妙微UI设计公司1 小时前
兰亭妙微软件界面设计公司的解锁按钮设计10大密码
开发语言·前端·javascript
迪幻2 小时前
封装自己的 npm 依赖并存储到个人 GitHub
前端·npm·github
u0111026752 小时前
WebP 转成 PNG 后为什么更大?图片格式转换不等于压缩
前端·javascript·vue.js
万少2 小时前
Qoder sonus模型 20分钟,复刻 马斯克 飞天汽车
前端·后端·算法
seconp3 小时前
基于Spring Boot和Vue 3的智慧旅游网站:住宿预订、行程规划与千问接入
vue.js·spring boot·毕业设计·软件工程·课程设计·旅游·毕设
java1234_小锋3 小时前
【技术专题】HTML5 网页技术 - HTML5 列表标签
前端·html·html5