【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仓库地址】

相关推荐
陈随易18 小时前
Postman 被曝自动往 Claude Code 和 Codex 装插件
前端·后端·程序员
Dream-Y.ocean20 小时前
鸿蒙PC平台宅匠 3D 室内设计适配实战:基于 Electron 壳方案的 Three.js 双渲染引擎与参数化户型编辑器开发
javascript·3d·harmonyos
Csvn21 小时前
性能监控
前端
Highcharts.js21 小时前
Highcharts Gantt使用符号的自定义数据标签开发甘特图示例
javascript·甘特图·highcharts·图表开发·实战代码·响应式图表
Csvn21 小时前
构建工具
前端
FRing031 天前
vue2干货总结3(computed计算属性、watch监听器)
开发语言·前端·vue.js·vue2
Java小卷1 天前
JuanForm vs FormCreate!Vue3 表单引擎联动配置 PK!
vue.js·低代码
kyriewen1 天前
Skills 榜前 10 我全装了:只有 3 个能直接用
前端·javascript·程序员
IT_陈寒1 天前
当Java的hashCode遇上Lombok,我的缓存被全清了
前端·人工智能·后端
工控丫丫1 天前
帆软看板中经常使用的公式函数及经常搭的组合
前端·javascript·数据库