Pinia 状态管理
大约 9 分钟约 2650 字
Pinia 状态管理
简介
Pinia 是 Vue3 的官方状态管理库,取代了 Vuex。相比 Vuex,Pinia 更简洁(去除了 mutations)、支持 TypeScript、支持 Composition API 风格。通过 Store 管理全局状态(用户信息、主题设置、缓存数据),实现组件间数据共享和持久化。
Pinia(发音为 /pi:nja/,像英语中的 "pineapple" 中的 "pi")由 Vue 核心团队成员 Eduardo San Martin Morote 创建。它的设计目标是在保持 Vuex 核心功能的同时,大幅简化 API 和改善 TypeScript 支持。
Pinia vs Vuex 对比:
- 无 mutations:直接在 actions 中修改 state,减少样板代码
- 完整的 TypeScript 支持:无需额外类型定义
- 支持 Composition API 和 Options API 两种风格
- 支持 Store 之间的互相引用
- 支持插件系统(持久化、DevTools 等)
- 无嵌套模块:每个 Store 都是独立的,扁平化管理
- 体积更小:约 1KB(gzipped)
特点
基础 Store
定义和使用
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
// State
const token = ref<string>('')
const userInfo = ref<UserInfo | null>(null)
const permissions = ref<string[]>([])
// Getters
const isLoggedIn = computed(() => !!token.value)
const username = computed(() => userInfo.value?.name ?? '未登录')
const isAdmin = computed(() => permissions.value.includes('admin'))
// Actions
async function login(username: string, password: string) {
const res = await api.login({ username, password })
token.value = res.token
userInfo.value = res.user
permissions.value = res.permissions
localStorage.setItem('token', res.token)
}
function logout() {
token.value = ''
userInfo.value = null
permissions.value = []
localStorage.removeItem('token')
}
function hasPermission(perm: string): boolean {
return permissions.value.includes(perm)
}
return {
token, userInfo, permissions,
isLoggedIn, username, isAdmin,
login, logout, hasPermission
}
})在组件中使用
<!-- 组件中使用 Store -->
<script setup lang="ts">
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
// 解构保持响应式(必须用 storeToRefs)
const { username, isLoggedIn, isAdmin } = storeToRefs(userStore)
// Actions 直接解构
const { login, logout } = userStore
const handleLogin = async () => {
await login('admin', 'password')
}
</script>
<template>
<div>
<span v-if="isLoggedIn">{{ username }}</span>
<button v-else @click="handleLogin">登录</button>
</div>
</template>storeToRefs 的重要性
// ❌ 错误:直接解构会丢失响应式
const { token, isLoggedIn } = useUserStore()
// token 和 isLoggedIn 只是普通值,不会随 Store 更新而更新
// ✅ 正确:使用 storeToRefs 保持响应式
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const { token, isLoggedIn } = storeToRefs(userStore)
// token 和 isLoggedIn 是 ref,会随 Store 更新
// Actions 不需要 storeToRefs,直接解构即可
const { login, logout, hasPermission } = userStore
// 或者始终通过 store 实例访问
const token = computed(() => userStore.token)多 Store 管理
组合多个 Store
// stores/app.ts — 应用全局状态
export const useAppStore = defineStore('app', () => {
const theme = ref<'light' | 'dark'>('light')
const sidebarCollapsed = ref(false)
const loading = ref(false)
function toggleTheme() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
document.documentElement.setAttribute('data-theme', theme.value)
}
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value
}
return { theme, sidebarCollapsed, loading, toggleTheme, toggleSidebar }
})
// stores/cart.ts — 购物车
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const totalCount = computed(() => items.value.reduce((sum, item) => sum + item.quantity, 0))
const totalPrice = computed(() => items.value.reduce((sum, item) => sum + item.price * item.quantity, 0))
function addItem(product: Product) {
const existing = items.value.find(item => item.productId === product.id)
if (existing) {
existing.quantity++
} else {
items.value.push({ productId: product.id, name: product.name, price: product.price, quantity: 1 })
}
}
function removeItem(productId: number) {
items.value = items.value.filter(item => item.productId !== productId)
}
function clearCart() {
items.value = []
}
return { items, totalCount, totalPrice, addItem, removeItem, clearCart }
})
// 在另一个 Store 中使用
export const useOrderStore = defineStore('order', () => {
const cartStore = useCartStore()
const userStore = useUserStore()
async function checkout() {
if (!userStore.isLoggedIn) throw new Error('请先登录')
const order = await api.createOrder({
items: cartStore.items,
userId: userStore.userInfo!.id
})
cartStore.clearCart()
return order
}
return { checkout }
})Store 的订阅与监听
// 监听 Store 变化
const userStore = useUserStore()
// 方式 1:$subscribe 监听整个 Store 的变化
userStore.$subscribe((mutation, state) => {
// mutation.type: 'direct' | 'patch object'
// mutation.storeId: 'user'
// mutation.payload: 修改的内容
console.log('Store 变化:', mutation.type, mutation.payload)
// 持久化到 localStorage
localStorage.setItem('user-store', JSON.stringify({
token: state.token,
userInfo: state.userInfo,
}))
})
// 方式 2:$onAction 监听 Action 调用
userStore.$onAction(({ name, after, onError }) => {
console.log(`Action "${name}" 被调用`)
after((result) => {
console.log(`Action "${name}" 完成,结果:`, result)
})
onError((error) => {
console.error(`Action "${name}" 失败:`, error)
})
})
// 方式 3:使用 watch 监听特定状态
watch(
() => userStore.isLoggedIn,
(newValue) => {
if (newValue) {
router.push('/dashboard')
} else {
router.push('/login')
}
}
)
// 方式 4:监听所有 Store 的变化
import { watch } from 'vue'
import { getActivePinia } from 'pinia'
pinia.use(({ store }) => {
store.$subscribe((mutation) => {
// 所有 Store 变化都会触发
console.log(`[${mutation.storeId}]`, mutation.type)
})
})持久化
pinia-plugin-persistedstate
// 持久化插件
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// Store 中启用持久化
export const useSettingsStore = defineStore('settings', () => {
const theme = ref('light')
const language = ref('zh-CN')
const pageSize = ref(20)
return { theme, language, pageSize }
}, {
persist: {
key: 'app-settings',
storage: localStorage,
pick: ['theme', 'language'] // 只持久化部分字段
}
})自定义持久化策略
// stores/user.ts —— 自定义持久化
export const useUserStore = defineStore('user', () => {
const token = ref('')
const userInfo = ref<UserInfo | null>(null)
function $reset() {
token.value = ''
userInfo.value = null
}
return { token, userInfo, $reset }
}, {
persist: {
key: 'user-store',
// 使用 sessionStorage(关闭浏览器标签页后清除)
storage: sessionStorage,
// 只持久化指定字段
paths: ['token'],
// 自定义序列化(处理特殊类型)
serializer: {
serialize: (state) => JSON.stringify(state),
deserialize: (value) => JSON.parse(value),
},
// 恢复前回调(可以用来验证数据)
beforeRestore: (ctx) => {
console.log('即将恢复 Store:', ctx.store.$id)
},
// 恢复后回调
afterRestore: (ctx) => {
console.log('Store 已恢复:', ctx.store.$id)
// 可以在这里根据恢复的数据执行额外操作
if (ctx.store.token) {
// 恢复用户信息
ctx.store.fetchUserInfo()
}
},
// 调试模式
debug: import.meta.env.DEV,
}
})手动实现持久化
// 不使用插件,手动实现持久化
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || '')
const userInfo = ref<UserInfo | null>(null)
// 监听变化自动保存
watch(token, (newToken) => {
if (newToken) {
localStorage.setItem('token', newToken)
} else {
localStorage.removeItem('token')
}
})
function $hydrate() {
// 从 localStorage 恢复状态
const saved = localStorage.getItem('user-store')
if (saved) {
const parsed = JSON.parse(saved)
token.value = parsed.token || ''
userInfo.value = parsed.userInfo || null
}
}
return { token, userInfo, $hydrate }
})Store 模式对比
Options vs Composition
// Options 风格(类似 Vuex)
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0, name: '计数器' }),
getters: {
doubleCount: (state) => state.count * 2,
},
actions: {
increment() { this.count++ },
async fetchCount() {
this.count = await api.getCount()
}
}
})
// Composition 风格(推荐,更灵活)
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const name = ref('计数器')
const doubleCount = computed(() => count.value * 2)
function increment() { count.value++ }
async function fetchCount() {
count.value = await api.getCount()
}
return { count, name, doubleCount, increment, fetchCount }
})Store 的 $patch 批量更新
const userStore = useUserStore()
// 方式 1:直接修改(每次修改触发一次响应)
userStore.token = 'new-token'
userStore.userInfo = { name: '张三', age: 25 }
// 方式 2:$patch 对象(一次性修改多个属性,只触发一次响应)
userStore.$patch({
token: 'new-token',
userInfo: { name: '张三', age: 25 },
})
// 方式 3:$patch 函数(适合复杂修改)
userStore.$patch((state) => {
state.token = 'new-token'
state.userInfo.name = '李四'
state.permissions.push('write')
})
// $reset 重置 Store 状态
userStore.$reset()
// $dispose 销毁 Store(移除所有监听器)
userStore.$dispose()Store 最佳实践
// 状态分层策略
// 1. 全局状态(Store):用户信息、主题、权限、全局配置
// 2. 页面状态(composable):表格筛选条件、分页、临时数据
// 3. 组件状态(ref/reactive):表单输入、弹窗开关、本地 UI 状态
// 页面级 composable 示例
// composables/useUserList.ts
export function useUserList() {
const users = ref<User[]>([])
const loading = ref(false)
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0,
})
const filters = reactive({
keyword: '',
status: '',
role: '',
})
async function fetchUsers() {
loading.value = true
try {
const res = await api.getUsers({
page: pagination.page,
pageSize: pagination.pageSize,
...filters,
})
users.value = res.data
pagination.total = res.total
} finally {
loading.value = false
}
}
function resetFilters() {
filters.keyword = ''
filters.status = ''
filters.role = ''
pagination.page = 1
fetchUsers()
}
onMounted(fetchUsers)
return { users, loading, pagination, filters, fetchUsers, resetFilters }
}
// 组件中使用
const { users, loading, pagination, filters, fetchUsers, resetFilters } = useUserList()优点
缺点
总结
Pinia 是 Vue3 状态管理的首选。推荐使用 Composition 风格定义 Store(setup 语法)。核心原则:全局共享状态放 Store,组件私有状态用 ref/reactive。组件中使用 storeToRefs 解构保持响应式。持久化用 pinia-plugin-persistedstate 插件。避免过度使用 Store,能用 props/emits 解决的不用 Store。
最佳实践清单:
- Store 只存放全局共享状态,页面级状态用 composable
- 使用 Composition 风格定义 Store,保持与 Vue3 一致
- 解构 Store 时用 storeToRefs 保持响应式,Actions 直接解构
- 使用 $patch 批量更新,减少不必要的响应触发
- 持久化只存储必要数据,避免序列化问题
- Store 之间互相引用时注意循环依赖
关键知识点
- 先判断主题更偏浏览器原理、框架机制、工程化还是性能优化。
- 前端问题很多看似是页面问题,实际源头在构建、缓存、状态流或接口协作。
- 真正成熟的前端方案一定同时考虑首屏、交互、可维护性和线上诊断。
- 前端主题最好同时看浏览器原理、框架机制和工程化约束。
项目落地视角
- 把组件边界、状态归属、网络层规范和错误处理先定下来。
- 上线前检查包体积、缓存命中、接口失败路径和关键交互降级策略。
- 如果主题和性能有关,最好用 DevTools、Lighthouse 或埋点验证。
- 对关键页面先建立状态流和数据流,再考虑组件拆分。
常见误区
- 只盯框架 API,不理解浏览器和运行时成本。
- 把状态、请求和 UI 更新混成一层,后期难维护。
- 线上问题出现时没有日志、埋点和性能基线可对照。
- 只追框架新特性,不分析实际渲染成本。
进阶路线
- 继续补齐 SSR、边缘渲染、设计系统和监控告警能力。
- 把主题和后端接口约定、CI/CD、缓存策略一起思考。
- 沉淀组件规范、页面模板和性能基线,减少团队差异。
- 继续补齐设计系统、SSR/边缘渲染、监控告警和组件库治理。
适用场景
- 当你准备把《Pinia 状态管理》真正落到项目里时,最适合先在一个独立模块或最小样例里验证关键路径。
- 适合中后台应用、门户站点、组件库和实时交互页面。
- 当需求涉及状态流、路由、网络缓存、SSR/CSR 或性能治理时,这类主题很关键。
落地建议
- 先定义组件边界和状态归属,再落地 UI 细节。
- 对核心页面做首屏、体积、缓存和错误路径检查。
- 把安全、兼容性和可访问性纳入默认交付标准。
排错清单
- 先用浏览器 DevTools 看请求、性能面板和控制台错误。
- 检查依赖版本、构建配置、环境变量和静态资源路径。
- 如果是线上问题,优先确认缓存、CDN 和构建产物是否一致。
复盘问题
- 如果把《Pinia 状态管理》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《Pinia 状态管理》最容易在什么规模、什么边界条件下暴露问题?你会用什么指标或日志去确认?
- 相比默认实现或替代方案,采用《Pinia 状态管理》最大的收益和代价分别是什么?
