【Pinia 状态管理】+【中后台前端实战】:从状态拆分、actions 规范到持久化落地,掌握可维护状态管理写法,避开状态污染与全局状态地狱!

📑 文章目录
- [一、开篇:为什么还要学 Pinia 规范?](#一、开篇:为什么还要学 Pinia 规范?)
- [二、Pinia 基础速览(5 分钟扫盲)](#二、Pinia 基础速览(5 分钟扫盲))
- [2.1 和 Vuex 的区别(你只需要记住这些)](#2.1 和 Vuex 的区别(你只需要记住这些))
- [2.2 一个最小的 Store 长什么样?](#2.2 一个最小的 Store 长什么样?)
- [三、核心规范一:状态拆分 ------ 按领域还是按功能?](#三、核心规范一:状态拆分 —— 按领域还是按功能?)
- [3.1 问题:一个 store 塞满所有状态会怎样?](#3.1 问题:一个 store 塞满所有状态会怎样?)
- [3.2 推荐做法:按业务领域拆分](#3.2 推荐做法:按业务领域拆分)
- [3.3 什么时候需要跨 store 拿数据?](#3.3 什么时候需要跨 store 拿数据?)
- [四、核心规范二:Actions 怎么写才不乱?](#四、核心规范二:Actions 怎么写才不乱?)
- [4.1 三个原则](#4.1 三个原则)
- [4.2 标准异步 Action 写法](#4.2 标准异步 Action 写法)
- [4.3 在组件里怎么用?](#4.3 在组件里怎么用?)
- [4.4 容易踩的坑:直接解构 store](#4.4 容易踩的坑:直接解构 store)
- [五、核心规范三:持久化 ------ 刷新不丢数据](#五、核心规范三:持久化 —— 刷新不丢数据)
- [5.1 什么需要持久化?](#5.1 什么需要持久化?)
- [5.2 方案一:pinia-plugin-persistedstate(推荐)](#5.2 方案一:pinia-plugin-persistedstate(推荐))
- [5.3 方案二:自己封装(理解原理)](#5.3 方案二:自己封装(理解原理))
- [5.4 持久化需要注意的点](#5.4 持久化需要注意的点)
- [六、核心规范四:避免状态污染 ------ 深浅拷贝要分清](#六、核心规范四:避免状态污染 —— 深浅拷贝要分清)
- [6.1 什么是状态污染?](#6.1 什么是状态污染?)
- [6.2 正确写法:深拷贝、明确归属](#6.2 正确写法:深拷贝、明确归属)
- [6.3 Getter 返回引用时要注意](#6.3 Getter 返回引用时要注意)
- [七、完整示例:用户 + 应用配置 + 持久化](#七、完整示例:用户 + 应用配置 + 持久化)
- 八、小结速查表
- 九、延伸阅读建议
- [🔍 系列模块导航](#🔍 系列模块导航)
同学们好,我是 Eugene(尤金),一名多年中后台前端开发工程师。
(Eugene 发音 /juːˈdʒiːn/,大家怎么顺口怎么叫就好)
很多前端开发者都会遇到一个瓶颈:
代码能跑,但不够规范;功能能实现,但维护起来特别痛苦;一个人写没问题,一到团队协作就各种混乱、踩坑、返工。
想写出干净、优雅、可维护 的专业代码,靠的不是天赋,而是体系化的规范 + 真实实战经验。
这一系列《前端规范实战》,我会用大白话 + 真实业务场景,不讲玄学、不堆理论,只分享能直接落地的规范、标准与避坑指南。
帮你从「会写代码」真正升级为「会写优质、可维护、团队级别的代码」。
一、开篇:为什么还要学 Pinia 规范?
Vue 3 的推荐状态管理是 Pinia。如果只会写业务,但不清楚怎么拆分 store、怎么设计 actions、如何做持久化和避免污染,项目一复杂就会变成"全局状态地狱"。
这篇文章从日常开发怎么选、为什么这么选、容易踩哪些坑三个角度,帮你把 Pinia 用得更稳、更清晰。
适用读者:
- 会写 JS,但对状态管理概念还比较模糊的人
- 想从零搭建 Vue 3 项目的初学者
- 有经验但想梳理一遍习惯的开发者
[⬆ 返回目录](#⬆ 返回目录)
二、Pinia 基础速览(5 分钟扫盲)
2.1 和 Vuex 的区别(你只需要记住这些)
| 对比项 | Vuex | Pinia |
|---|---|---|
| API 风格 | mutation / action 分离 | 只有 actions |
| 模块化 | 靠 modules 注册 |
每个 store 是独立文件 |
| TypeScript | 支持一般 | 支持更好 |
| 包体积 | 较大 | 更小 |
| 组合式写法 | 需要 mapState 等 | 直接用 storeToRefs |
一句话:Pinia 更简单,功能不弱,官方推荐优先用它。
[⬆ 返回目录](#⬆ 返回目录)
2.2 一个最小的 Store 长什么样?
js
// stores/counter.js
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
}),
getters: {
doubleCount: (state) => state.count * 2,
},
actions: {
increment() {
this.count++
},
},
})
在组件里用:
html
<script setup>
import { useCounterStore } from '@/stores/counter'
const counterStore = useCounterStore()
</script>
<template>
<div>{{ counterStore.count }}</div>
<button @click="counterStore.increment">+1</button>
</template>
defineStore 第一个参数是 store 的唯一 id,第二个是配置对象(或 setup 函数)。下文会围绕这个基础,讲怎么拆、怎么改、怎么持久化。
[⬆ 返回目录](#⬆ 返回目录)
三、核心规范一:状态拆分 ------ 按领域还是按功能?
3.1 问题:一个 store 塞满所有状态会怎样?
- 文件超大,难以维护
- 修改一处,到处影响
- 团队协作容易冲突
- 难以做按需持久化
[⬆ 返回目录](#⬆ 返回目录)
3.2 推荐做法:按业务领域拆分
原则:一个 store 只管一类业务,边界清晰。
示例项目结构:
stores/
├── index.js # 统一导出
├── user.js # 用户:登录、权限、资料
├── app.js # 应用:主题、语言、布局
├── cart.js # 购物车
└── product.js # 商品列表、搜索条件
错误示范:
js
// ❌ 错误:一个 store 包揽所有
export const useMainStore = defineStore('main', {
state: () => ({
user: null,
token: '',
theme: 'light',
cartItems: [],
productList: [],
// ... 越来越多
}),
})
正确示范:
js
// ✅ stores/user.js - 只管用户相关
export const useUserStore = defineStore('user', {
state: () => ({
userInfo: null,
token: '',
permissions: [],
}),
// ...
})
// ✅ stores/app.js - 只管应用配置
export const useAppStore = defineStore('app', {
state: () => ({
theme: 'light',
language: 'zh-CN',
sidebarCollapsed: false,
}),
// ...
})
[⬆ 返回目录](#⬆ 返回目录)
3.3 什么时候需要跨 store 拿数据?
用 getter 引用其他 store:
js
// stores/cart.js
import { defineStore } from 'pinia'
import { useUserStore } from './user'
export const useCartStore = defineStore('cart', {
state: () => ({
items: [],
}),
getters: {
// 跨 store 获取用户是否登录
canCheckout: () => {
const userStore = useUserStore()
return userStore.token && userStore.userInfo
},
},
})
要点:只在 getter 里读其他 store,不要在 action 里直接改别的 store 的 state。
[⬆ 返回目录](#⬆ 返回目录)
四、核心规范二:Actions 怎么写才不乱?
4.1 三个原则
- 异步放在 actions 里:请求、副作用都集中到 actions
- 状态只通过 actions 改:组件不要直接改 state
- 一个 action 只做一件事:粒度小、好测试、易复用
[⬆ 返回目录](#⬆ 返回目录)
4.2 标准异步 Action 写法
js
// stores/user.js
import { defineStore } from 'pinia'
import { loginApi, getUserInfoApi } from '@/api/user'
export const useUserStore = defineStore('user', {
state: () => ({
userInfo: null,
token: '',
loading: false,
error: null,
}),
actions: {
async login(credentials) {
this.loading = true
this.error = null
try {
const res = await loginApi(credentials)
this.token = res.data.token
this.userInfo = res.data.user
return { success: true }
} catch (err) {
this.error = err.message || '登录失败'
return { success: false, message: this.error }
} finally {
this.loading = false
}
},
async fetchUserInfo() {
if (!this.token) return
try {
const res = await getUserInfoApi()
this.userInfo = res.data
} catch (err) {
this.error = err.message
// 可以考虑清除 token,触发重新登录
}
},
logout() {
this.token = ''
this.userInfo = null
this.error = null
},
},
})
要点:用 loading / error 描述过程,用 try/catch/finally 统一处理。
[⬆ 返回目录](#⬆ 返回目录)
4.3 在组件里怎么用?
html
<script setup>
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// 只把需要响应式的拿出来,避免整个 store 被解构
const { userInfo, loading, error } = storeToRefs(userStore)
async function handleLogin() {
const result = await userStore.login({ username, password })
if (result.success) {
// 跳转首页等
}
}
</script>
<template>
<form @submit.prevent="handleLogin">
<div v-if="error" class="error">{{ error }}</div>
<button :disabled="loading">{{ loading ? '登录中...' : '登录' }}</button>
</form>
</template>
[⬆ 返回目录](#⬆ 返回目录)
4.4 容易踩的坑:直接解构 store
js
// ❌ 错误:直接解构会丢失响应式
const { count, increment } = useCounterStore()
// count 是普通值,改了不会更新视图
// ✅ 正确:用 storeToRefs 拿 state/getter
const { count } = storeToRefs(useCounterStore())
// actions 直接从 store 上取
const { increment } = useCounterStore()
[⬆ 返回目录](#⬆ 返回目录)
五、核心规范三:持久化 ------ 刷新不丢数据
5.1 什么需要持久化?
- 需要持久化:token、主题、语言、布局偏好、购物车
- 一般不需要:临时列表、弹窗状态、加载状态
[⬆ 返回目录](#⬆ 返回目录)
5.2 方案一:pinia-plugin-persistedstate(推荐)
安装:
bash
npm install pinia-plugin-persistedstate
配置:
js
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import App from './App.vue'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
createApp(App).use(pinia).mount('#app')
在 store 里开启:
js
// stores/user.js
export const useUserStore = defineStore('user', {
state: () => ({
token: '',
userInfo: null,
}),
persist: true, // 全部持久化
})
更细粒度的配置:
js
export const useUserStore = defineStore('user', {
state: () => ({
token: '',
userInfo: null,
tempData: null, // 不想持久化
}),
persist: {
key: 'my-app-user', // 存到 localStorage 的 key
storage: localStorage, // 默认就是 localStorage
paths: ['token', 'userInfo'], // 只持久化这两个字段
},
})
[⬆ 返回目录](#⬆ 返回目录)
5.3 方案二:自己封装(理解原理)
js
// utils/persist.js
export function usePersist(store, key = store.$id) {
// 初始化时从 localStorage 恢复
const saved = localStorage.getItem(key)
if (saved) {
try {
store.$patch(JSON.parse(saved))
} catch (e) {
console.warn('恢复状态失败', e)
}
}
// 监听变化并保存
store.$subscribe((mutation, state) => {
localStorage.setItem(key, JSON.stringify(state))
})
}
在 main.js 或入口里按需调用:
js
const userStore = useUserStore()
usePersist(userStore, 'my-user')
[⬆ 返回目录](#⬆ 返回目录)
5.4 持久化需要注意的点
- 不要存敏感数据:token 可以用,但尽量用 httpOnly cookie 更安全
- 注意体积:避免把大数组、大对象全丢进 localStorage
- 版本兼容:结构变了,要做兼容或清空旧数据
[⬆ 返回目录](#⬆ 返回目录)
六、核心规范四:避免状态污染 ------ 深浅拷贝要分清
6.1 什么是状态污染?
组件或接口拿到的是 state 的引用,直接改了这个引用,就会污染 store,导致:
- 状态难追踪
- 撤销/重做不好做
- 时间旅行调试失效
典型错误:
js
// ❌ 污染:直接改传入的对象
actions: {
updateUser(updates) {
// updates 可能是外部传入的,直接合并会污染
Object.assign(this.userInfo, updates)
},
}
// 组件里
const form = { name: '张三', age: 20 }
userStore.updateUser(form) // form 可能被 store 内部修改影响
[⬆ 返回目录](#⬆ 返回目录)
6.2 正确写法:深拷贝、明确归属
js
actions: {
updateUser(updates) {
if (!this.userInfo) return
// 基于当前 state 做拷贝,再合并
this.userInfo = {
...this.userInfo,
...JSON.parse(JSON.stringify(updates)),
}
},
setCartItems(newItems) {
// 用拷贝,避免外部直接改 store
this.items = [...newItems]
},
}
复杂对象可以用 structuredClone 或 lodash.cloneDeep。
[⬆ 返回目录](#⬆ 返回目录)
6.3 Getter 返回引用时要注意
js
getters: {
// ❌ 返回内部数组引用,外部修改会污染
cartItems: (state) => state.items,
// ✅ 返回拷贝,外部改不影响 store
cartItemsSafe: (state) => [...state.items],
}
如果 getter 只是做过滤、映射,建议返回新数组/新对象。
[⬆ 返回目录](#⬆ 返回目录)
七、完整示例:用户 + 应用配置 + 持久化
下面是一个可直接拷贝、改名的示例。
js
// stores/user.js
import { defineStore } from 'pinia'
import { loginApi, getUserInfoApi } from '@/api/user'
export const useUserStore = defineStore('user', {
state: () => ({
token: '',
userInfo: null,
loading: false,
error: null,
}),
getters: {
isLoggedIn: (state) => !!state.token,
userName: (state) => state.userInfo?.name ?? '',
},
actions: {
async login(credentials) {
this.loading = true
this.error = null
try {
const res = await loginApi(credentials)
this.token = res.data.token
await this.fetchUserInfo()
return { success: true }
} catch (err) {
this.error = err.message || '登录失败'
return { success: false, message: this.error }
} finally {
this.loading = false
}
},
async fetchUserInfo() {
if (!this.token) return
try {
const res = await getUserInfoApi()
this.userInfo = { ...res.data }
} catch {
this.logout()
}
},
logout() {
this.token = ''
this.userInfo = null
this.error = null
},
},
persist: {
key: 'app-user',
paths: ['token', 'userInfo'],
},
})
js
// stores/app.js
import { defineStore } from 'pinia'
export const useAppStore = defineStore('app', {
state: () => ({
theme: 'light',
sidebarCollapsed: false,
}),
actions: {
toggleTheme() {
this.theme = this.theme === 'light' ? 'dark' : 'light'
},
toggleSidebar() {
this.sidebarCollapsed = !this.sidebarCollapsed
},
},
persist: true,
})
html
<!-- components/UserProfile.vue -->
<script setup>
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const { userInfo, loading, error } = storeToRefs(userStore)
function handleLogout() {
userStore.logout()
}
</script>
<template>
<div v-if="userInfo">
<p>欢迎,{{ userInfo.name }}</p>
<button :disabled="loading" @click="handleLogout">退出</button>
</div>
<p v-if="error" class="error">{{ error }}</p>
</template>
[⬆ 返回目录](#⬆ 返回目录)
八、小结速查表
| 场景 | 推荐做法 | 避免 |
|---|---|---|
| 拆分 | 按业务领域拆 store | 一个 store 包所有状态 |
| 异步 | 统一在 actions 里,用 loading/error | 在组件里散落请求 |
| 解构 | storeToRefs 拿 state/getter |
直接解构 store |
| 持久化 | pinia-plugin-persistedstate + paths |
全量持久化大对象 |
| 防污染 | 用拷贝更新 state,getter 返回拷贝 | 直接改引用、getter 返回内部引用 |
[⬆ 返回目录](#⬆ 返回目录)
🔍 系列模块导航
📝 状态管理与路由规范
一、《Vue3 Pinia 状态管理规范:状态拆分、Actions 写法、持久化实战,避坑状态污染|状态管理与路由规范篇》
二、《Vue3 Pinia 状态管理规范:何时用 Pinia 何时用本地状态|状态管理与路由规范篇》
三、《Vue Router 实战规范:path/name/meta 配置 + 动态 / 嵌套路由,统一团队标准|状态管理与路由规范篇》
四、《Vue3 + Vue Router + Pinia 路由守卫规范:beforeEach 应做 / 不应做,避死循环、防重复请求|状态管理与路由规范篇》
五、《Vue keep-alive 实战避坑:include/exclude + 路由 meta 标记,中后台路由缓存精准可控|状态管理与路由规范篇》
👉 跟着系列慢慢学,把技术功底扎扎实实地打牢~
📚 系列总览
「前端规范实战系列 」正在持续更新中,后续会整理一篇《前端规范实战系列全系列目录导航》,包含每篇文章简介 + 直达链接,方便大家按顺序、体系化学习。
更新中,敬请期待~
[⬆ 返回目录](#⬆ 返回目录)
技术成长,从来不是比谁写得快,而是比谁写得稳、规范、可维护。
哪怕每次只吃透一条规范,长期下来,差距会非常明显。
后续我会持续更新前端规范、工程化、可维护代码相关实战干货,帮你告别面条代码、维护噩梦,在开发与面试中更有底气。
觉得有用欢迎 点赞 + 收藏 + 关注,不错过每一篇实战内容。
我是 Eugene,与你一起写规范、写优质代码,我们下篇干货见~