Vue3 Pinia 状态管理规范:状态拆分、Actions 写法、持久化实战,避坑状态污染|状态管理与路由规范篇

【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 三个原则

  1. 异步放在 actions 里:请求、副作用都集中到 actions
  2. 状态只通过 actions 改:组件不要直接改 state
  3. 一个 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 持久化需要注意的点

  1. 不要存敏感数据:token 可以用,但尽量用 httpOnly cookie 更安全
  2. 注意体积:避免把大数组、大对象全丢进 localStorage
  3. 版本兼容:结构变了,要做兼容或清空旧数据

[⬆ 返回目录](#⬆ 返回目录)


六、核心规范四:避免状态污染 ------ 深浅拷贝要分清

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]
  },
}

复杂对象可以用 structuredClonelodash.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,与你一起写规范、写优质代码,我们下篇干货见~

相关推荐
black方块cxy1 小时前
实现一个输入框多个ip以逗号分隔最多20组,且ip不能重复
java·服务器·前端
@PHARAOH2 小时前
WHAT - AI 时代下的候选人
大数据·前端·人工智能
竹林8182 小时前
从零到一:我在Solana NFT铸造前端中搞定@solana/web3.js连接与交易
前端·javascript
猪八宅百炼成仙2 小时前
不用点击也能预览图片:Element UI ImageViewer 命令式调用方案
前端
尘世中一位迷途小书童2 小时前
前端工程化基石:package.json 40+ 字段逐一拆解
前端·javascript·架构
OpenTiny社区3 小时前
WebMCP + WebSkills:企业级智能化页面操控方案,兼顾隐私安全与高效落地!
前端·ai编程·mcp
酉鬼女又兒3 小时前
零基础快速入门前端JavaScript四大核心内置对象:Math、Date、String、Array全解析(可用于备赛蓝桥杯Web应用开发)
前端·javascript·css·蓝桥杯·前端框架·js
__sgf__3 小时前
ES11(ES2020)新特性
前端·javascript
__sgf__3 小时前
ES8(ES2017)新特性
前端·javascript