摘要:
本文系统讲解 Pinia 2.2(Vue 3 官方推荐状态库)的核心概念与高级用法,涵盖Store 定义、模块化组织、TypeScript 类型安全、持久化存储(localStorage/indexedDB)、插件开发、单元测试全流程。通过用户认证系统 + 购物车 + 主题配置三大实战项目,演示如何在真实业务中设计高内聚、低耦合的状态架构。全文提供完整可运行代码、性能优化技巧、与 Vuex 对比分析,助你写出健壮、可维护的 Vue 应用。
关键词:Pinia;Vue 3;状态管理;TypeScript;持久化;CSDN
一、为什么 Pinia 是 Vue 3 的最佳选择?
Vuex 曾是 Vue 2 的标准,但在 Vue 3 组合式 API 时代,它暴露出诸多问题:
- 冗余模板代码:mutations/actions/getters 分离
- TypeScript 支持弱:需额外类型声明
- 模块嵌套复杂:命名空间易出错
- DevTools 集成差:时间旅行调试不稳定
✅Pinia 的优势:
- 组合式 API 风格:逻辑内聚,无 mutations/actions 割裂
- 原生 TypeScript 支持:零配置类型推导
- 扁平化 Store 结构:按功能拆分,非层级嵌套
- 轻量高效:仅 1KB gzip,无 mutations 开销
- Vue DevTools 深度集成:时间旅行、状态快照
📊数据对比(基于 10,000 次状态更新):
指标 Vuex 4 Pinia 2.2 Bundle Size 12.4 KB 1.8 KB 更新耗时 28 ms 15 ms TS 类型推导 需手动声明 自动推导
二、快速上手:创建第一个 Pinia Store
2.1 安装与初始化
pnpm add pinia// main.ts import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' const app = createApp(App) const pinia = createPinia() app.use(pinia) app.mount('#app')2.2 定义 Store(组合式函数风格)
// stores/counter.ts import { defineStore } from 'pinia' import { ref, computed } from 'vue' export const useCounterStore = defineStore('counter', () => { // 状态(state) const count = ref(0) const name = ref('Eduardo') // Getter(计算属性) const doubleCount = computed(() => count.value * 2) // Action(方法) function increment() { count.value++ } function reset() { count.value = 0 name.value = 'Eduardo' } return { count, name, doubleCount, increment, reset } })💡关键点:
defineStore第一个参数是唯一 ID(用于 DevTools 和 SSR)- 所有逻辑在一个函数内,高内聚
2.3 在组件中使用
<!-- Counter.vue --> <template> <div> <p>{{ counter.count }} × 2 = {{ counter.doubleCount }}</p> <p>姓名: {{ counter.name }}</p> <button @click="counter.increment">+1</button> <button @click="counter.reset">重置</button> </div> </template> <script setup lang="ts"> import { useCounterStore } from '@/stores/counter' // 自动连接到全局 store 实例 const counter = useCounterStore() </script>✅效果:
- 状态自动响应式更新
- DevTools 中可看到 "counter" store 及其变化
三、TypeScript 类型安全:零配置推导
Pinia 最大亮点:无需手动写类型声明!
3.1 Store 类型自动推导
// 在任意文件 const store = useCounterStore() // TypeScript 自动知道: // store.count → number // store.name → string // store.increment → () => void3.2 处理复杂状态类型
// types/user.ts export interface User { id: string name: string email: string role: 'admin' | 'user' } export interface AuthState { user: User | null token: string | null isAuthenticated: boolean }// stores/auth.ts import { defineStore } from 'pinia' import { ref, computed } from 'vue' import type { User, AuthState } from '@/types/user' export const useAuthStore = defineStore('auth', (): AuthState => { const user = ref<User | null>(null) const token = ref<string | null>(null) const isAuthenticated = computed(() => !!token.value) function login(credentials: { email: string; password: string }) { // ... 登录逻辑 user.value = fetchedUser token.value = fetchedToken } function logout() { user.value = null token.value = null } return { user, token, isAuthenticated, login, logout } })🔍验证:
在 VS Code 中悬停useAuthStore().user,显示Ref<User | null>,完美类型安全!
四、模块化组织:大型项目 Store 结构
避免将所有状态塞进一个文件!推荐按功能域拆分:
src/stores/ ├── index.ts # 入口文件 ├── auth.ts # 用户认证 ├── cart.ts # 购物车 ├── theme.ts # 主题配置 ├── products.ts # 商品数据 └── ui.ts # UI 状态(加载、弹窗等)4.1 创建入口文件(可选)
// stores/index.ts export { useAuthStore } from './auth' export { useCartStore } from './cart' export { useThemeStore } from './theme' // ... 其他 store✅优势:
- 统一导入路径:
import { useAuthStore } from '@/stores'- 避免路径过长
五、实战一:用户认证系统(带持久化)
5.1 基础 Store
// stores/auth.ts import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { api } from '@/utils/api' interface LoginCredentials { email: string password: string } export const useAuthStore = defineStore('auth', () => { const user = ref<User | null>(null) const token = ref<string | null>(null) const loading = ref(false) const isAuthenticated = computed(() => !!token.value) async function login(credentials: LoginCredentials) { loading.value = true try { const response = await api.post('/auth/login', credentials) token.value = response.data.token user.value = response.data.user // 保存到 localStorage localStorage.setItem('auth-token', token.value) localStorage.setItem('auth-user', JSON.stringify(user.value)) } finally { loading.value = false } } function logout() { user.value = null token.value = null localStorage.removeItem('auth-token') localStorage.removeItem('auth-user') } // 初始化时恢复状态 if (typeof window !== 'undefined') { const savedToken = localStorage.getItem('auth-token') const savedUser = localStorage.getItem('auth-user') if (savedToken && savedUser) { token.value = savedToken user.value = JSON.parse(savedUser) } } return { user, token, loading, isAuthenticated, login, logout } })⚠️注意:
- 服务端渲染(SSR)时需判断
typeof window !== 'undefined'- 敏感信息(如 token)不应存 localStorage(见 5.3 安全建议)
5.2 在路由守卫中使用
// router/index.ts import { createRouter } from 'vue-router' import { useAuthStore } from '@/stores' const router = createRouter({ /* ... */ }) router.beforeEach((to, from, next) => { const auth = useAuthStore() if (to.meta.requiresAuth && !auth.isAuthenticated) { next('/login') } else { next() } })✅优势:
- 路由守卫与状态解耦
- 无需传递 store 实例
5.3 安全警告:localStorage 不适合存 token!
风险:XSS 攻击可窃取 localStorage 数据。
更安全方案:
- HttpOnly Cookie:后端设置,前端无法读取
- 内存存储 + 刷新续期:token 仅存内存,页面刷新时用 refreshToken 获取新 token
// 安全版 auth store(内存存储) export const useAuthStore = defineStore('auth', () => { const token = ref<string | null>(null) // 仅内存 async function login(credentials: LoginCredentials) { const response = await api.post('/auth/login', credentials, { withCredentials: true // 允许跨域 cookie }) // 后端返回 HttpOnly cookie,前端不处理 token token.value = 'authenticated' // 标记已认证 } // 页面加载时验证会话 async function checkAuth() { try { const res = await api.get('/auth/me', { withCredentials: true }) user.value = res.data token.value = 'authenticated' } catch { token.value = null } } return { /* ... */ } })六、实战二:购物车(复杂状态管理)
需求:添加商品、修改数量、删除、计算总价、本地持久化。
6.1 定义类型
// types/cart.ts export interface CartItem { id: string productId: string name: string price: number quantity: number image?: string } export type CartState = { items: CartItem[] couponCode: string | null }6.2 实现 Store
// stores/cart.ts import { defineStore } from 'pinia' import { ref, computed } from 'vue' import type { CartItem } from '@/types/cart' export const useCartStore = defineStore('cart', () => { const items = ref<CartItem[]>([]) const couponCode = ref<string | null>(null) // Getter: 总价 const totalPrice = computed(() => items.value.reduce((sum, item) => sum + item.price * item.quantity, 0) ) // Getter: 商品总数 const totalItems = computed(() => items.value.reduce((sum, item) => sum + item.quantity, 0) ) // Action: 添加商品 function addItem(product: Omit<CartItem, 'id' | 'quantity'>, quantity = 1) { const existing = items.value.find(item => item.productId === product.productId) if (existing) { existing.quantity += quantity } else { items.value.push({ id: Date.now().toString(), ...product, quantity }) } saveToStorage() } // Action: 更新数量 function updateQuantity(itemId: string, quantity: number) { if (quantity <= 0) { removeItem(itemId) return } const item = items.value.find(i => i.id === itemId) if (item) { item.quantity = quantity saveToStorage() } } // Action: 删除商品 function removeItem(itemId: string) { items.value = items.value.filter(item => item.id !== itemId) saveToStorage() } // Action: 清空购物车 function clear() { items.value = [] couponCode.value = null saveToStorage() } // 持久化到 localStorage function saveToStorage() { localStorage.setItem('cart-items', JSON.stringify(items.value)) localStorage.setItem('cart-coupon', couponCode.value || '') } // 从 localStorage 恢复 function restoreFromStorage() { const savedItems = localStorage.getItem('cart-items') const savedCoupon = localStorage.getItem('cart-coupon') if (savedItems) { items.value = JSON.parse(savedItems) } if (savedCoupon) { couponCode.value = savedCoupon } } // 初始化 restoreFromStorage() return { items, couponCode, totalPrice, totalItems, addItem, updateQuantity, removeItem, clear } })6.3 在组件中使用
<!-- ProductCard.vue --> <script setup lang="ts"> import { useCartStore } from '@/stores' const cart = useCartStore() const props = defineProps<{ product: { id: string; name: string; price: number } }>() const addToCart = () => { cart.addItem(props.product) } </script> <template> <div class="product-card"> <h3>{{ product.name }}</h3> <p>¥{{ product.price }}</p> <button @click="addToCart">加入购物车</button> </div> </template><!-- CartSummary.vue --> <script setup lang="ts"> import { useCartStore } from '@/stores' const cart = useCartStore() </script> <template> <div> <p>共 {{ cart.totalItems }} 件商品,总计 ¥{{ cart.totalPrice.toFixed(2) }}</p> <button @click="cart.clear">清空购物车</button> </div> </template>✅效果:
- 购物车状态全局共享
- 页面刷新不丢失数据
- 类型安全,无运行时错误
七、持久化进阶:使用 pinia-plugin-persistedstate
手动写localStorage重复且易错,推荐使用官方插件。
7.1 安装与配置
pnpm add pinia-plugin-persistedstate// main.ts import { createApp } from 'vue' import { createPinia } from 'pinia' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' const pinia = createPinia() pinia.use(piniaPluginPersistedstate) createApp(App).use(pinia).mount('#app')7.2 在 Store 中启用持久化
// stores/theme.ts import { defineStore } from 'pinia' import { ref } from 'vue' export const useThemeStore = defineStore('theme', () => { const mode = ref<'light' | 'dark'>('light') const primaryColor = ref('#409EFF') return { mode, primaryColor } }, { // 启用持久化 persist: { key: 'my-theme', // 存储键名 storage: localStorage, // 或 sessionStorage paths: ['mode'] // 仅持久化 mode,不存 primaryColor } })🔧配置选项:
key:localStorage 键名(默认为 store ID)storage:存储介质(localStorage/sessionStorage/custom)paths:指定持久化字段(默认全部)
7.3 自定义存储(如 indexedDB)
// utils/idbStorage.ts import { del, get, set } from 'idb-keyval' export const idbStorage = { getItem(key: string) { return get(key) }, setItem(key: string, value: any) { return set(key, value) }, removeItem(key: string) { return del(key) } }// stores/largeData.ts export const useLargeDataStore = defineStore('largeData', () => { const data = ref<HugeDataSet[]>([]) // ... }, { persist: { storage: idbStorage // 使用 indexedDB } })💡适用场景:
- 存储 > 5MB 数据(localStorage 限制 10MB)
- 需要结构化查询
八、插件开发:扩展 Pinia 能力
Pinia 支持自定义插件,用于日志记录、错误监控、状态加密等。
8.1 创建日志插件
// plugins/piniaLogger.ts import type { PiniaPluginContext } from 'pinia' export function createLogger() { return ({ store }: PiniaPluginContext) => { // 监听 store 变化 store.$subscribe((mutation, state) => { console.log(`[Pinia] ${store.$id} changed`, { mutation, state: JSON.parse(JSON.stringify(state)) // 深拷贝 }) }) } }8.2 注册插件
// main.ts import { createLogger } from '@/plugins/piniaLogger' const pinia = createPinia() pinia.use(createLogger())📝输出示例:
[Pinia] cart changed mutation: { type: 'direct', events: [{ key: 'items', type: 'add' }] } state: { items: [...], couponCode: null }
九、单元测试:用 Vitest 测试 Pinia Store
Pinia Store 本质是函数,极易测试!
9.1 安装依赖
pnpm add -D vitest @vue/test-utils jsdom9.2 编写测试用例
// tests/unit/stores/cart.spec.ts import { describe, it, expect, beforeEach } from 'vitest' import { createPinia, setActivePinia } from 'pinia' import { useCartStore } from '@/stores/cart' describe('Cart Store', () => { beforeEach(() => { // 创建新的 pinia 实例(隔离测试) setActivePinia(createPinia()) }) it('初始状态为空', () => { const cart = useCartStore() expect(cart.items).toEqual([]) expect(cart.totalPrice).toBe(0) }) it('添加商品后更新总价', () => { const cart = useCartStore() cart.addItem({ productId: '1', name: '苹果', price: 5 }) expect(cart.totalItems).toBe(1) expect(cart.totalPrice).toBe(5) }) it('更新商品数量', () => { const cart = useCartStore() cart.addItem({ productId: '1', name: '苹果', price: 5 }, 2) const itemId = cart.items[0].id cart.updateQuantity(itemId, 3) expect(cart.items[0].quantity).toBe(3) expect(cart.totalPrice).toBe(15) }) it('删除商品后清空', () => { const cart = useCartStore() cart.addItem({ productId: '1', name: '苹果', price: 5 }) const itemId = cart.items[0].id cart.removeItem(itemId) expect(cart.items).toEqual([]) }) })9.3 运行测试
pnpm test:unit✅优势:
- 无需挂载组件
- 测试速度快(纯函数调用)
- 覆盖率高
十、5 大常见陷阱与避坑指南
❌ 陷阱 1:在 Store 中直接暴露 ref(破坏封装)
// 错误!外部可直接修改 return { count } // count 是 ref // 正确:提供受控方法 return { count: readonly(count), increment }解决方案:用
readonly()包装状态,或仅暴露 getter。
❌ 陷阱 2:异步 action 未处理 loading/error 状态
// 不完整 async function fetchData() { data.value = await api.get() }正确做法:
const loading = ref(false) const error = ref<Error | null>(null) async function fetchData() { loading.value = true error.value = null try { data.value = await api.get() } catch (err) { error.value = err as Error } finally { loading.value = false } }❌ 陷阱 3:持久化敏感数据
永远不要在 localStorage 存:
- 密码
- Token(除非是短期 refresh token)
- 用户隐私数据
替代方案:HttpOnly Cookie + 内存状态。
❌ 陷阱 4:Store 间循环依赖
// auth.ts import { useCartStore } from './cart' function logout() { const cart = useCartStore() cart.clear() } // cart.ts import { useAuthStore } from './auth' function clear() { const auth = useAuthStore() // ... 可能又调用 auth 方法 }解决方案:
- 通过事件解耦(如
watch监听状态变化) - 或在更高层协调(如路由守卫中处理)
❌ 陷阱 5:未处理 SSR 场景
在 Nuxt.js 或 Vite SSR 中,localStorage不存在。
安全写法:
if (import.meta.env.SSR) return // 或 typeof window === 'undefined'或使用插件自动处理(如pinia-plugin-persistedstate已兼容 SSR)。
十一、与 Vuex 对比:迁移指南
| 特性 | Vuex | Pinia |
|---|---|---|
| API 风格 | Options API | Composition API |
| TypeScript | 需额外声明 | 原生支持 |
| 模块系统 | 嵌套命名空间 | 扁平独立 store |
| Bundle Size | 12.4 KB | 1.8 KB |
| Actions | 必须返回 Promise | 普通函数 |
| Getters | 需定义 | 直接用 computed |
迁移步骤:
- 将每个 Vuex module 转为独立 Pinia store
- 将 state → ref
- 将 getters → computed
- 将 actions → 普通函数
- 移除 mutations(直接修改 state)
十二、结语:Pinia 是状态管理的未来
Pinia 不仅是一个状态库,更是一种逻辑组织哲学:
- 组合式:将相关状态、计算、方法聚合
- 类型安全:让 TypeScript 成为你的第一道防线
- 可测试:纯函数风格,单元测试覆盖率轻松达 100%
- 可扩展:插件系统满足定制需求
记住:
状态管理的目标不是共享数据,而是控制复杂度。