当前位置: 首页 > news >正文

八、【状态管理篇】:Pinia 在大型应用中的状态管理实践

【状态管理篇】:Pinia 在大型应用中的状态管理实践

    • 前言
      • 为什么选择 Pinia 而不是 Vuex?
      • 第一步:确认或安装 Pinia
      • 第二步:创建并注册 Pinia 实例
      • 第三步:定义一个 Store (以用户认证为例)
      • 第四步:在组件中使用 Store
      • 第五步:测试集成效果
    • 总结

前言

在前端应用中,“状态 (State)” 通常指的是驱动应用的数据。

  • 局部状态 (Local State): 属于单个组件自身的数据,通常通过组件的 data 选项或 Composition API 中的 ref / reactive 来管理。
  • 全局状态 / 共享状态 (Global / Shared State): 多个组件之间需要共享和响应的数据,例如:
    • 用户的登录状态和信息
    • 应用的主题设置
    • 全局的加载/错误提示状态

当应用规模较小、组件间共享状态不多时,可以通过 props 向下传递数据,通过 emits 向上发送事件来修改数据。但当应用变大,组件树变得复杂时:

  • Props drilling (Prop 逐级透传): 数据需要经过许多不直接使用该数据的中间组件传递,非常繁琐。
  • 兄弟组件通信困难: 没有直接父子关系的组件通信不便。
  • 状态来源和变更难以追踪。

状态管理库 (如 Pinia, Vuex) 提供了一个集中的存储(Store),用于存放共享状态。任何组件都可以从中读取状态,或者触发改变状态的动作 (Actions)。这样,状态的管理就变得清晰、可预测且易于维护。

为什么选择 Pinia 而不是 Vuex?

  • 更简洁的 API: Pinia 的 API 设计更接近 Vue 3 的 Composition API 风格,更直观易懂。
  • 更好的 TypeScript 支持: Pinia 从一开始就为 TypeScript 做了精心设计,类型推断非常出色。
  • 模块化和代码分割: Pinia 的 Store 是天然模块化的,每个 Store 都是一个独立的单元,更容易进行代码分割。
  • 移除了 Mutations: Vuex 中 Mutations 的概念在 Pinia 中被简化了,可以直接在 Actions 中修改 state,或通过 Store 实例直接修改 (虽然推荐通过 Actions)。
  • 更小的体积: Pinia 的体积比 Vuex 4 更小。
  • Vue Devtools 支持: Pinia 同样拥有良好的 Vue Devtools 集成,方便调试。

在【环境搭建篇】中,当我们使用 npm create vue@latest 创建项目时,如果选择了添加 Pinia,那么它应该已经被安装并配置好了。如果当时没有选择,也不用担心,我们可以手动安装。

第一步:确认或安装 Pinia

  1. 检查是否已安装:
    打开 test-platform/frontend/package.json 文件,查看 dependencies 部分是否有 pinia

    在这里插入图片描述

    如果你在创建项目时选择了 Pinia,它应该已经存在了。

  2. 如果未安装,手动安装:
    如果在 package.json 中没有找到 pinia,在你的前端项目根目录 (test-platform/frontend)下运行:

    npm install pinia --save
    

第二步:创建并注册 Pinia 实例

如果 Pinia 是在项目创建时就集成的,那么 src/main.tssrc/stores 目录可能已经包含了 Pinia 的基本设置。我们来确认并理解它。

  1. src/main.ts 中注册 Pinia:
    打开 test-platform/frontend/src/main.ts
    在这里插入图片描述

    // test-platform/frontend/src/main.ts
    import './assets/main.css'
    import { createApp } from 'vue'
    import { createPinia } from 'pinia' // 1. 导入 createPiniaimport ElementPlus from 'element-plus'
    import 'element-plus/dist/index.css'
    import zhCn from 'element-plus/dist/locale/zh-cn.mjs'import App from './App.vue'
    import router from './router'const app = createApp(App)// 2. 创建 Pinia 实例
    const pinia = createPinia()app.use(pinia) // 3. 将 Pinia 实例提供给应用
    app.use(router)
    app.use(ElementPlus, { locale: zhCn })app.mount('#app')
    

    代码解释:

    • import { createPinia } from 'pinia': 从 pinia 包中导入 createPinia 函数。
    • const pinia = createPinia(): 调用 createPinia() 来创建一个 Pinia 根存储实例。
    • app.use(pinia): 将这个 Pinia 实例注册到 Vue 应用中。这样,应用中的所有组件都可以访问到 Pinia store。

    如果你的 main.ts 之前没有这些行,请添加它们。

第三步:定义一个 Store (以用户认证为例)

在 Pinia 中,Store 是通过 defineStore 函数定义的。每个 Store 都需要一个唯一的 ID。

我们将创建一个 userStore 来管理用户的认证信息,如 Token 和用户名。

  1. 创建 Store 文件:
    通常,我们会把所有的 Store 文件放在 src/stores 目录下。
    如果 src/stores 目录不存在,请创建它。
    如果项目初始化时已包含 Pinia,已经存在 counter.ts ,可以删除它。
    在这里插入图片描述

  2. 编写 user.ts Store:
    在这里插入图片描述

    // test-platform/frontend/src/stores/user.ts
    import { defineStore } from 'pinia'
    import { ElMessage } from 'element-plus' // 用于消息提示
    import router from '@/router' // 引入 router 实例用于跳转// 定义用户信息的接口 (可选,但推荐用于类型安全)
    interface UserState {token: string | null;username: string | null;// 可以添加更多用户信息,如 roles, avatar 等
    }// 使用 defineStore 创建一个 store
    // 第一个参数是 store 的唯一 ID,Pinia 用它来连接 devtools
    // 第二个参数是一个对象或一个 Setup 函数
    export const useUserStore = defineStore('user', {// 1. State: 定义状态的地方 (类似组件的 data)state: (): UserState => ({token: localStorage.getItem('user-token') || null, // 尝试从 localStorage 初始化 tokenusername: localStorage.getItem('username') || null, // 尝试从 localStorage 初始化 username}),// 2. Getters: 计算属性 (类似组件的 computed)// Getters 是可选的getters: {isAuthenticated: (state) => !!state.token,// 你可以定义更多 getters,例如:// welcomeMessage: (state) => `欢迎回来, ${state.username || '游客'}!`},// 3. Actions: 方法 (类似组件的 methods)// Actions 可以是异步的,你可以在其中执行 API 调用等操作actions: {// 模拟登录操作async login(payload: { username: string; token: string }) {// 实际项目中,这里可能是调用登录 API 成功后的回调this.token = payload.tokenthis.username = payload.username// 将 token 和 username 持久化到 localStoragelocalStorage.setItem('user-token', payload.token)localStorage.setItem('username', payload.username)ElMessage.success('登录成功!')// 登录成功后跳转const redirectPath = router.currentRoute.value.query.redirect as string || '/'router.push(redirectPath)},// 退出登录操作logout() {this.token = nullthis.username = nulllocalStorage.removeItem('user-token')localStorage.removeItem('username')ElMessage.success('已退出登录')router.push('/login') // 跳转到登录页},// 可选:尝试从 localStorage 加载用户信息 (如果应用启动时需要)// 通常在 state 初始化时已经做了,但也可以作为一个 actionloadUserFromLocalStorage() {const token = localStorage.getItem('user-token')const username = localStorage.getItem('username')if (token && username) {this.token = tokenthis.username = usernameconsole.log('User info loaded from localStorage into Pinia store.')}}},
    })
    

    代码解释:

    • defineStore('user', { ... }):
      • 'user' 是这个 Store 的唯一 ID。
      • 第二个参数是一个对象,包含了 state, getters, 和 actions
    • state: (): UserState => ({ ... }):
      • state 必须是一个返回初始状态对象的函数 (为了 SSR 和更好的 TypeScript 支持)。
      • 我们定义了 tokenusername 两个状态属性,并尝试从 localStorage 中读取它们的初始值。这使得用户在刷新页面后仍能保持登录状态(只要 localStorage 中的 token 未过期)。
    • getters: { ... }:
      • isAuthenticated: (state) => !!state.token: 定义了一个名为 isAuthenticated 的 getter。它会根据 state.token 是否存在来返回一个布尔值,表示用户是否已认证。Getters 会被缓存,只有当依赖的状态改变时才会重新计算。
    • actions: { ... }:
      • login(payload): 模拟登录操作。它接收包含 usernametokenpayload,更新 state 中的 tokenusername,并将它们存储到 localStorage。然后显示成功消息并进行路由跳转。
      • logout(): 清除 statelocalStorage 中的用户信息,显示退出消息并跳转到登录页。
      • loadUserFromLocalStorage(): 一个可选的 action,用于显式从 localStorage 加载数据到 store(虽然我们的 state 初始化已经做了类似的事情)。
    • 注意: 在 Store 的 Actions 中,this 指向 Store 实例本身,所以你可以通过 this.token = ... 来直接修改 state。这是 Pinia 与 Vuex (需要通过 mutations) 的一个主要区别,使得代码更简洁。

第四步:在组件中使用 Store

现在我们定义好了 userStore,可以在 Vue 组件中使用它了。

  1. 修改登录组件 (LoginView.vue) 以使用 userStore
    我们将之前的模拟登录逻辑移到 userStorelogin action 中,并在 LoginView.vue 中调用它。
    在这里插入图片描述

    <!-- test-platform/frontend/src/views/LoginView.vue -->
    <template><div class="login-container"><h1>用户登录</h1><el-form ref="loginFormRef" :model="loginForm" label-width="80px" class="login-form"><el-form-item label="用户名" prop="username"><el-input v-model="loginForm.username" placeholder="输入 admin"></el-input></el-form-item><el-form-item label="密码" prop="password"><el-input v-model="loginForm.password" type="password" placeholder="任意密码"></el-input></el-form-item><el-form-item><el-button type="primary" @click="handleLogin" :loading="loading">登录</el-button></el-form-item></el-form></div>
    </template><script setup lang="ts">
    import { reactive, ref } from 'vue'
    // import { useRouter, useRoute } from 'vue-router' // useRouter 和 useRoute 在 store 中处理跳转了
    import type { FormInstance } from 'element-plus'
    import { ElMessage } from 'element-plus' // <--- 在这里显式导入 ElMessage
    import { useUserStore } from '@/stores/user' // 1. 导入 user store// const router = useRouter() // 不再需要,跳转由 store action 处理
    // const route = useRoute()   // 不再需要,跳转由 store action 处理
    const userStore = useUserStore() // 2. 获取 user store 实例const loginFormRef = ref<FormInstance>()
    const loginForm = reactive({username: 'admin',password: 'password' // 保持和 store 中一致的模拟
    })
    const loading = ref(false)const handleLogin = async () => {if (!loginForm.username || !loginForm.password) {ElMessage.error('请输入用户名和密码')return}loading.value = truetry {// 3. 调用 store action 进行登录// 模拟 API 调用延迟await new Promise(resolve => setTimeout(resolve, 500)); // 假设登录成功,后端返回了 token 和用户信息await userStore.login({ username: loginForm.username, token: `fake-token-for-${loginForm.username}` // 生成一个简单的假 token})// 登录成功后的跳转和消息提示已在 store action 中处理} catch (error) {console.error('登录失败:', error)ElMessage.error('登录失败,请稍后再试')} finally {loading.value = false}
    }
    </script><style scoped>
    /* 样式保持不变 */
    .login-container {display: flex;flex-direction: column;align-items: center;justify-content: center;height: 100vh;background-color: #f0f2f5;
    }
    .login-form {width: 350px;padding: 30px;background-color: #fff;border-radius: 6px;box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
    }
    h1 {margin-bottom: 20px;
    }
    </style>
    

    代码解释:

    • import { useUserStore } from '@/stores/user': 导入我们创建的 userStore
    • const userStore = useUserStore(): 在组件的 setup 函数中调用 useUserStore() 来获取 Store 实例。
    • await userStore.login({ ... }): 在 handleLogin 方法中,我们调用了 userStore.login action,并传递了用户名和模拟的 token。登录成功后的消息提示和页面跳转逻辑现在都封装在 Store 的 action 里面了。
  2. 修改布局组件 (Layout.vue) 以显示用户名和处理退出:
    在这里插入图片描述
    在这里插入图片描述

    <!-- test-platform/frontend/src/layout/index.vue -->
    <template><el-container class="app-layout"><el-header class="app-header"><div class="logo-title"><img src="@/assets/logo.svg" alt="Logo" class="logo" /><span class="title">测试平台</span></div><div class="user-info" v-if="userStore.isAuthenticated"> <!-- 已修改为从 store 判断 --><!-- 用户信息和退出登录等 --><el-dropdown @command="handleCommand"><span class="el-dropdown-link">欢迎, {{ userStore.username || 'Admin' }} <!-- 已修改为从 store 获取用户名 --><el-icon class="el-icon--right"><arrow-down /></el-icon></span><template #dropdown><el-dropdown-menu><el-dropdown-item>个人中心</el-dropdown-item><el-dropdown-item>修改密码</el-dropdown-item><el-dropdown-item command="logout" divided>退出登录</el-dropdown-item> <!-- 重点:添加 command="logout" --></el-dropdown-menu></template></el-dropdown></div><div class="user-info" v-else> <!-- 添加 v-else 用于未登录状态 --><el-button type="primary" @click="goToLogin">登录</el-button></div></el-header><el-container class="app-body"><el-aside width="200px" class="app-aside"><el-menudefault-active="1"class="el-menu-vertical-demo"router><el-menu-item index="/"><el-icon><HomeFilled /></el-icon><span>首页</span></el-menu-item><el-sub-menu index="/project"><template #title><el-icon><Folder /></el-icon><span>项目管理</span></template><el-menu-item index="/project/list">项目列表</el-menu-item><el-menu-item index="/project/create">新建项目</el-menu-item></el-sub-menu><el-menu-item index="/testcases"><el-icon><List /></el-icon><span>用例管理</span></el-menu-item><el-menu-item index="/reports"><el-icon><DataAnalysis /></el-icon><span>测试报告</span></el-menu-item></el-menu></el-aside><el-main class="app-main"><RouterView /> <!-- 子路由的出口 --></el-main></el-container></el-container>
    </template><script setup lang="ts">
    import { RouterView } from 'vue-router'
    // 导入需要的 Element Plus 图标
    import { ArrowDown, HomeFilled, Folder, List, DataAnalysis } from '@element-plus/icons-vue'
    import { ElMessage, ElMessageBox } from 'element-plus' // 导入 ElMessage
    import { useRouter } from 'vue-router' // 导入 useRouter
    import { useUserStore } from '@/stores/user' // 1. 导入 user storeconst userStore = useUserStore() // 2. 获取 user store 实例
    const router = useRouter() // 获取路由实例const goToLogin = () => {router.push('/login')
    }const handleCommand = (command: string | number | object) => {if (command === 'logout') {ElMessageBox.confirm('确定要退出登录吗?', '提示', {confirmButtonText: '确定',cancelButtonText: '取消',type: 'warning',}).then(() => {userStore.logout() // 3. 调用 store action 退出登录// 退出成功后的跳转和消息提示已在 store action 中处理}).catch(() => {// 用户取消操作})} else if (command === 'profile') {// router.push('/profile') // 跳转到个人中心} else if (command === 'changePassword') {// router.push('/change-password') // 跳转到修改密码}
    }
    </script><style scoped lang="scss">
    /* 样式保持不变 */
    .app-header {/* ... */.user-info {.el-dropdown-link {cursor: pointer;display: flex;align-items: center;}}
    }
    /* ... */
    </style>
    

    代码解释:

    • import { useUserStore } from '@/stores/user': 导入 Store。
    • const userStore = useUserStore(): 获取 Store 实例。
    • v-if="userStore.isAuthenticated": 使用 Store 的 getter isAuthenticated 来判断是否显示用户信息下拉框或登录按钮。
    • {{ userStore.username || '用户' }}: 直接从 Store 中读取 username 状态来显示。
    • userStore.logout(): 在 handleCommand 中,当命令为 logout 时,调用 Store 的 logout action。
  3. 修改路由守卫 (router/index.ts) 以使用 userStore
    之前我们直接在路由守卫中读取 localStorage,现在我们可以通过 userStore 来获取认证状态,这样逻辑更集中。
    在这里插入图片描述
    在这里插入图片描述

    // test-platform/frontend/src/router/index.ts
    import { createRouter, createWebHistory } from 'vue-router'
    // ... (其他 imports)
    import { useUserStore } from '@/stores/user' // 导入 user storeconst router = createRouter({ /* ... routes ... */ })router.beforeEach(async (to, from, next) => {const userStore = useUserStore(); // 如果是首次加载应用,userStore.token 可能是 null (如果 localStorage 没有)// 我们可以在这里尝试从 localStorage 加载一次,或者依赖 store state 初始化时的逻辑if (!userStore.token && localStorage.getItem('user-token')) {userStore.loadUserFromLocalStorage(); // 确保 store 与 localStorage 同步}const isAuthenticated = userStore.isAuthenticated // 使用 getterconst pageTitle = to.meta.title ? `${to.meta.title} - 测试平台` : '测试平台'document.title = pageTitle;if (to.meta.requiresAuth && !isAuthenticated) {console.log('路由需要认证,但用户未登录 (Pinia),跳转到登录页')next({ name: 'login', query: { redirect: to.fullPath } })} else if (to.name === 'login' && isAuthenticated) {console.log('用户已登录 (Pinia),访问登录页,跳转到首页')next({ name: 'dashboard' })}else {next()}
    })export default router
    

    关于在导航守卫中使用 Pinia Store 的说明:

    • useUserStore() 可以在导航守卫中直接调用,前提是 Pinia 实例已经在 main.ts 中通过 app.use(pinia) 安装到了 Vue 应用中。Vite/Vue CLI 项目的构建和执行流程通常能保证这一点。
    • 确保 Store 状态的及时性: 当应用首次加载时,路由守卫会先执行。如果你的 userStorestate 初始化依赖于 localStorage (如我们代码所示),那么 userStore.isAuthenticated 在首次检查时应该是准确的。
    • 我们添加了一行 if (!userStore.token && localStorage.getItem('user-token')) { userStore.loadUserFromLocalStorage(); } 来再次确保如果 store 因为某些原因(例如热重载后 store 状态丢失但 localStorage 还在)没有同步,可以尝试加载一次。更稳妥的做法是在应用初始化时(例如 App.vueonMountedmain.ts 中 Pinia 注册后)就调用 userStore.loadUserFromLocalStorage()
  4. App.vue 中初始化 Store 状态:
    为了确保 Pinia store 在应用启动时能正确地从 localStorage 加载持久化的状态,可以在根组件 App.vuesetup (或 onMounted) 中调用一次加载 action。
    在这里插入图片描述

    <!-- test-platform/frontend/src/App.vue -->
    <script setup lang="ts">
    import { RouterView } from 'vue-router'
    import { useUserStore } from '@/stores/user'
    import { onMounted } from 'vue';const userStore = useUserStore()onMounted(() => {// 尝试从 localStorage 加载用户信息到 Pinia store// 这确保了即使用户刷新页面,只要 localStorage 有 token,store 状态也能恢复userStore.loadUserFromLocalStorage() console.log('App.vue onMounted: User store initialized from localStorage if available.');
    })
    </script><template><RouterView />
    </template><style scoped>
    /* 样式可以为空 */
    </style>
    

    这样,在任何路由守卫执行之前,userStore 的状态就已经尽可能地与 localStorage 同步了。

第五步:测试集成效果

  1. 清除 localStorage 打开浏览器开发者工具,清除 user-tokenusername
    在这里插入图片描述

  2. 刷新页面或访问受保护路由 (如 /dashboard):

    • 你应该被重定向到 /login
      在这里插入图片描述
  3. 在登录页登录:

    • 输入用户名 (例如 “admin”) 和密码,点击登录。
    • userStore.login action 被调用,statelocalStorage 被更新。
    • 页面跳转到 /dashboard (或你之前想访问的 redirect 路径)。
    • Layout.vue 顶部现在应该显示 “欢迎, admin”。
      在这里插入图片描述
  4. 刷新页面:

    • 由于 App.vue 中的 onMounted (或 userStore state 初始化) 会从 localStorage 加载数据,你的登录状态应该被保持。
    • 顶部依然显示 “欢迎, admin”。
  5. 点击退出登录:

    • userStore.logout action 被调用,statelocalStorage 被清除。
    • 页面跳转到 /login
    • 顶部变回“登录”按钮。
      在这里插入图片描述

如果以上所有步骤都按预期工作,那么恭喜你,你已经成功地在你的 Vue3 项目中集成了 Pinia,并用它来管理了用户认证状态!

总结

在这篇文章中,我们学习了如何在 Vue3 项目中使用 Pinia进行状态管理:

  • 理解了为什么需要状态管理以及 Pinia 相对于 Vuex 的优势。
  • 安装(或确认安装)了 Pinia 并在 main.ts 中创建和注册了 Pinia 实例。
  • 定义了一个 userStore,包含了 state (用户信息)、getters (如 isAuthenticated) 和 actions (如 login, logout, loadUserFromLocalStorage)。
  • 学习了如何在 Store 的 state 初始化时从 localStorage 读取数据,以及如何在 actions 中将数据持久化到 localStorage
  • 在登录组件 (LoginView.vue)、布局组件 (Layout.vue) 和路由守卫 (router/index.ts) 中使用了 userStore 来读取状态和调用 actions。
  • 推荐了在应用根组件 (App.vue) 初始化时从 localStorage 同步状态到 Pinia Store 的做法。
  • 通过一系列测试验证了 Pinia 集成的效果和用户认证流程。

Pinia 的引入使得我们应用的全局状态管理更加清晰和模块化。现在,当我们需要在不同组件间共享和操作用户登录信息时,只需要和 userStore 打交道即可。

在下一篇文章中,我们将开始进行前后端联调。我们会使用 Axios 这个流行的 HTTP客户端库,让我们的 Vue3 前端能够真正地调用之前用 Django REST Framework 构建的后端 API,获取真实数据并提交数据。

相关文章:

  • mediapipe标注视频姿态关键点(基础版加进阶版)
  • SE91 找到报错的程序
  • MySQL的参数 innodb_force_recovery 详解
  • 研发中的隐形瓶颈:知识为何越来越难被留下?
  • 清理skywalking历史索引
  • C++:设计模式--工厂模式
  • 【MySQL】第11节|MySQL 8.0 主从复制原理分析与实战
  • 看fp脚本学习的知识1
  • vmvare 虚拟机内存不足
  • atomic.Value与sync.map有什么区?
  • Navicat 17 SQL 预览时表名异常右键表名,点击设计表->SQL预览->另存为的SQL预览时,表名都是 Untitled。
  • 02.【Qt开发】Qt Creator介绍及新建项目流程
  • 跳表(Skip List)查找算法详解
  • 豆包AI一键生成短视频脚本,内容创作更高效
  • 【git】 pull + rebase 或 pull + merge什么区别?
  • 没有经验能考OCP认证吗?
  • SOC-ESP32S3部分:16-I2C
  • Java基础 Day22
  • MySql(四)
  • 【React】jsx 从声明式语法变成命令式语法
  • 站酷网怎么接单赚钱/成都网站制作费用
  • icp备案的网站名称/怎样做品牌推广
  • 学网站开发好找工作吗/免费模式营销案例
  • 怎么建设网站百度搜索的到/网店运营培训
  • 柳州正规网站制作公司/seo外链怎么做
  • 公司做网站推广有没有用/网络营销平台有哪些?