Pinia 状态管理深入
Vue 官方推荐的状态管理库,轻量、类型安全、模块化
什么是 Pinia?
定义:Vue 的轻量级状态管理库,是 Vuex 的继任者。
核心特性:
- 轻量:约 1KB(gzip)
- 类型安全:完整的 TypeScript 支持
- 模块化:天然支持,无需嵌套
- DevTools:时间旅行、热更新
- 简洁:无 mutations,直接修改 state
一、基础使用
1.1 安装和配置
bash
npm install piniatypescript
// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
app.mount('#app')1.2 定义 Store
Setup 语法(推荐):
typescript
// 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('Counter')
// getters
const doubleCount = computed(() => count.value * 2)
// actions
function increment() {
count.value++
}
async function incrementAsync() {
await new Promise(resolve => setTimeout(resolve, 1000))
count.value++
}
return { count, name, doubleCount, increment, incrementAsync }
})Options 语法:
typescript
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
name: 'Counter'
}),
getters: {
doubleCount: (state) => state.count * 2
},
actions: {
increment() {
this.count++
},
async incrementAsync() {
await new Promise(resolve => setTimeout(resolve, 1000))
this.count++
}
}
})1.3 使用 Store
vue
<script setup>
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'
const store = useCounterStore()
// ❌ 直接解构会失去响应式
const { count, doubleCount } = store
// ✅ 使用 storeToRefs 保持响应式
const { count, doubleCount } = storeToRefs(store)
// ✅ actions 可以直接解构
const { increment } = store
</script>
<template>
<div>
<p>Count: {{ count }}</p>
<p>Double: {{ doubleCount }}</p>
<button @click="increment">Increment</button>
</div>
</template>二、核心概念
2.1 State
定义 state:
typescript
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
const token = ref('')
const permissions = ref<string[]>([])
return { user, token, permissions }
})访问 state:
typescript
const store = useUserStore()
// 读取
console.log(store.user)
// 修改(直接修改)
store.user = { id: 1, name: 'Alice' }
// 批量修改
store.$patch({
user: { id: 1, name: 'Alice' },
token: 'abc123'
})
// 函数式修改
store.$patch((state) => {
state.user = { id: 1, name: 'Alice' }
state.permissions.push('admin')
})
// 重置
store.$reset()2.2 Getters
定义 getters:
typescript
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
// 简单 getter
const isLoggedIn = computed(() => !!user.value)
// 带参数的 getter
const hasPermission = computed(() => {
return (permission: string) => {
return user.value?.permissions.includes(permission) ?? false
}
})
// 访问其他 store 的 getter
const cartTotal = computed(() => {
const cartStore = useCartStore()
return cartStore.total
})
return { user, isLoggedIn, hasPermission, cartTotal }
})使用 getters:
typescript
const store = useUserStore()
console.log(store.isLoggedIn) // 直接访问
console.log(store.hasPermission('admin')) // 带参数2.3 Actions
定义 actions:
typescript
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
// 同步 action
function setUser(newUser: User) {
user.value = newUser
}
// 异步 action
async function login(credentials: Credentials) {
loading.value = true
error.value = null
try {
const response = await api.login(credentials)
user.value = response.user
return response
} catch (e) {
error.value = e as Error
throw e
} finally {
loading.value = false
}
}
// 调用其他 store 的 action
async function logout() {
const cartStore = useCartStore()
await cartStore.clear()
user.value = null
}
return { user, loading, error, setUser, login, logout }
})三、Store 组合
3.1 Store 之间的引用
typescript
// stores/user.ts
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
const isLoggedIn = computed(() => !!user.value)
return { user, isLoggedIn }
})
// stores/cart.ts
export const useCartStore = defineStore('cart', () => {
const userStore = useUserStore() // 引用其他 store
const items = ref<CartItem[]>([])
const total = computed(() => {
// 只有登录用户才计算总价
if (!userStore.isLoggedIn) return 0
return items.value.reduce((sum, item) => sum + item.price, 0)
})
function addItem(item: CartItem) {
if (!userStore.isLoggedIn) {
throw new Error('Please login first')
}
items.value.push(item)
}
return { items, total, addItem }
})3.2 组合式函数复用逻辑
typescript
// composables/useAsync.ts
export function useAsync<T>(asyncFn: () => Promise<T>) {
const data = ref<T | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
async function execute() {
loading.value = true
error.value = null
try {
data.value = await asyncFn()
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
return { data, loading, error, execute }
}
// stores/product.ts
export const useProductStore = defineStore('product', () => {
const products = ref<Product[]>([])
// 复用异步逻辑
const { loading, error, execute: fetchProducts } = useAsync(async () => {
const response = await api.getProducts()
products.value = response.data
return response.data
})
return { products, loading, error, fetchProducts }
})四、插件系统
4.1 持久化插件
typescript
// plugins/persist.ts
import { PiniaPluginContext } from 'pinia'
export function createPersistedState() {
return ({ store }: PiniaPluginContext) => {
const key = `pinia-${store.$id}`
// 从 localStorage 恢复
const saved = localStorage.getItem(key)
if (saved) {
store.$patch(JSON.parse(saved))
}
// 监听变化,保存到 localStorage
store.$subscribe((mutation, state) => {
localStorage.setItem(key, JSON.stringify(state))
})
}
}
// main.ts
const pinia = createPinia()
pinia.use(createPersistedState())使用第三方插件:
bash
npm install pinia-plugin-persistedstatetypescript
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// stores/user.ts
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
return { user }
}, {
persist: true // 启用持久化
})4.2 日志插件
typescript
export function createLogger() {
return ({ store }: PiniaPluginContext) => {
store.$subscribe((mutation, state) => {
console.group(`[Pinia] ${mutation.storeId}`)
console.log('Mutation:', mutation.type)
console.log('Payload:', mutation.payload)
console.log('State:', state)
console.groupEnd()
})
store.$onAction(({ name, args, after, onError }) => {
const startTime = Date.now()
console.log(`[Action] ${store.$id}.${name}`, args)
after((result) => {
console.log(
`[Action] ${store.$id}.${name} finished in ${Date.now() - startTime}ms`,
result
)
})
onError((error) => {
console.error(`[Action] ${store.$id}.${name} failed`, error)
})
})
}
}五、高级用法
5.1 $subscribe 监听 state 变化
typescript
const store = useUserStore()
// 监听所有 state 变化
store.$subscribe((mutation, state) => {
console.log(mutation.type) // 'direct' | 'patch object' | 'patch function'
console.log(mutation.storeId) // 'user'
console.log(mutation.payload) // 传递给 $patch 的对象
console.log(state) // 当前 state
})
// 组件卸载后仍然监听
store.$subscribe(callback, { detached: true })5.2 $onAction 监听 actions
typescript
const unsubscribe = store.$onAction(({
name, // action 名称
store, // store 实例
args, // action 参数
after, // action 成功后的钩子
onError // action 失败后的钩子
}) => {
const startTime = Date.now()
console.log(`Start "${name}" with params [${args.join(', ')}]`)
after((result) => {
console.log(
`Finished "${name}" after ${Date.now() - startTime}ms with result:`,
result
)
})
onError((error) => {
console.error(`Failed "${name}" after ${Date.now() - startTime}ms`, error)
})
})
// 取消监听
unsubscribe()5.3 动态 Store
typescript
// 动态创建 store
function createDynamicStore(id: string) {
return defineStore(id, () => {
const data = ref({})
return { data }
})
}
// 使用
const store1 = createDynamicStore('dynamic-1')()
const store2 = createDynamicStore('dynamic-2')()六、TypeScript 支持
6.1 完整类型定义
typescript
interface User {
id: number
name: string
email: string
permissions: string[]
}
interface UserState {
user: User | null
token: string
loading: boolean
error: Error | null
}
export const useUserStore = defineStore('user', () => {
// State
const user = ref<User | null>(null)
const token = ref<string>('')
const loading = ref<boolean>(false)
const error = ref<Error | null>(null)
// Getters
const isLoggedIn = computed<boolean>(() => !!user.value)
const userName = computed<string>(() => user.value?.name ?? 'Guest')
// Actions
async function login(email: string, password: string): Promise<User> {
loading.value = true
error.value = null
try {
const response = await api.login({ email, password })
user.value = response.user
token.value = response.token
return response.user
} catch (e) {
error.value = e as Error
throw e
} finally {
loading.value = false
}
}
function logout(): void {
user.value = null
token.value = ''
}
return {
user,
token,
loading,
error,
isLoggedIn,
userName,
login,
logout
}
})
// 类型推导
type UserStore = ReturnType<typeof useUserStore>6.2 扩展 Store 类型
typescript
declare module 'pinia' {
export interface PiniaCustomProperties {
// 添加自定义属性
$router: Router
$api: Api
}
export interface PiniaCustomStateProperties<S> {
// 添加自定义 state 属性
$createdAt: Date
}
}
// 在插件中使用
pinia.use(({ store }) => {
store.$router = router
store.$api = api
store.$createdAt = new Date()
})七、Pinia vs Vuex
7.1 核心区别
| 特性 | Pinia | Vuex |
|---|---|---|
| 大小 | ~1KB | ~2KB |
| TypeScript | ✅ 完整支持 | ⚠️ 支持较弱 |
| Mutations | ❌ 无需 | ✅ 必需 |
| 模块化 | ✅ 天然支持 | ⚠️ 需要嵌套 |
| DevTools | ✅ 支持 | ✅ 支持 |
| 热更新 | ✅ 支持 | ✅ 支持 |
| 插件系统 | ✅ 简洁 | ✅ 复杂 |
7.2 代码对比
Pinia:
typescript
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
function increment() {
count.value++ // 直接修改
}
return { count, increment }
})
// 使用
const store = useCounterStore()
store.count++ // 直接修改
store.increment() // 调用 actionVuex:
typescript
export default {
state: () => ({
count: 0
}),
mutations: {
INCREMENT(state) {
state.count++
}
},
actions: {
increment({ commit }) {
commit('INCREMENT')
}
}
}
// 使用
store.commit('INCREMENT') // 必须通过 mutation
store.dispatch('increment') // 或通过 action7.3 迁移指南
从 Vuex 迁移到 Pinia:
typescript
// Vuex
const store = new Vuex.Store({
state: { count: 0 },
getters: {
double: state => state.count * 2
},
mutations: {
increment(state) {
state.count++
}
},
actions: {
incrementAsync({ commit }) {
setTimeout(() => commit('increment'), 1000)
}
}
})
// Pinia
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const double = computed(() => count.value * 2)
function increment() {
count.value++
}
async function incrementAsync() {
await new Promise(resolve => setTimeout(resolve, 1000))
increment()
}
return { count, double, increment, incrementAsync }
})八、最佳实践
8.1 Store 组织
按功能模块拆分:
stores/
├── user.ts # 用户相关
├── cart.ts # 购物车
├── product.ts # 产品
├── order.ts # 订单
└── index.ts # 导出所有 storesindex.ts:
typescript
export { useUserStore } from './user'
export { useCartStore } from './cart'
export { useProductStore } from './product'
export { useOrderStore } from './order'8.2 命名规范
typescript
// ✅ 推荐
export const useUserStore = defineStore('user', () => {})
export const useCartStore = defineStore('cart', () => {})
// ❌ 不推荐
export const userStore = defineStore('user', () => {})
export const UserStore = defineStore('user', () => {})8.3 避免直接导出 store 实例
typescript
// ❌ 不推荐
export const userStore = useUserStore()
// ✅ 推荐
export const useUserStore = defineStore('user', () => {})
// 在组件中使用
const userStore = useUserStore()8.4 使用 storeToRefs
typescript
// ❌ 失去响应式
const { count, doubleCount } = store
// ✅ 保持响应式
const { count, doubleCount } = storeToRefs(store)
// ✅ actions 可以直接解构
const { increment } = store8.5 错误处理
typescript
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
const error = ref<Error | null>(null)
async function login(credentials: Credentials) {
error.value = null
try {
const response = await api.login(credentials)
user.value = response.user
return response
} catch (e) {
error.value = e as Error
// 可以在这里统一处理错误
console.error('Login failed:', e)
throw e // 重新抛出,让调用者处理
}
}
return { user, error, login }
})面试高频题
1: Pinia 和 Vuex 有什么区别?
答案:
Pinia 优势:
- 更轻量:约 1KB vs 2KB
- 无 mutations:直接修改 state,代码更简洁
- TypeScript 支持更好:完整的类型推导
- 模块化更简单:天然支持,无需嵌套
- Vue 3 原生支持:使用 Composition API
Vuex 劣势:
- mutations 样板代码多
- 模块嵌套复杂
- TypeScript 支持弱
2: Pinia 的 Store 何时被创建?
答案:
Store 是懒创建的,只有在第一次调用 useXxxStore() 时才会创建实例。
typescript
// 定义 store(不会创建实例)
export const useUserStore = defineStore('user', () => {})
// 第一次调用时创建实例
const store = useUserStore() // 创建
// 后续调用返回同一个实例
const store2 = useUserStore() // 复用
console.log(store === store2) // true3: 如何在 Pinia 中实现持久化?
答案:
方法 1:使用插件:
bash
npm install pinia-plugin-persistedstatetypescript
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
export const useUserStore = defineStore('user', () => {
const user = ref(null)
return { user }
}, {
persist: true
})方法 2:手动实现:
typescript
export const useUserStore = defineStore('user', () => {
const user = ref(null)
// 从 localStorage 恢复
const saved = localStorage.getItem('user')
if (saved) {
user.value = JSON.parse(saved)
}
// 监听变化
watch(user, (newUser) => {
localStorage.setItem('user', JSON.stringify(newUser))
}, { deep: true })
return { user }
})4: Pinia 如何实现时间旅行?
答案:
通过 $subscribe 记录每次 state 变化:
typescript
const history: any[] = []
let currentIndex = -1
store.$subscribe((mutation, state) => {
// 记录当前状态
history.push(JSON.parse(JSON.stringify(state)))
currentIndex = history.length - 1
})
function undo() {
if (currentIndex > 0) {
currentIndex--
store.$patch(history[currentIndex])
}
}
function redo() {
if (currentIndex < history.length - 1) {
currentIndex++
store.$patch(history[currentIndex])
}
}5: 如何在 setup 外使用 Pinia Store?
答案:
typescript
// ❌ 错误 - 在模块顶层调用
import { useUserStore } from '@/stores/user'
const store = useUserStore() // pinia 还未安装
// ✅ 正确 - 在函数中调用
import { useUserStore } from '@/stores/user'
export function someFunction() {
const store = useUserStore() // pinia 已安装
return store.user
}
// ✅ 正确 - 传入 pinia 实例
import { useUserStore } from '@/stores/user'
import { pinia } from '@/main'
const store = useUserStore(pinia)6: Pinia 的 Setup Store 和 Options Store 有什么区别?
答案:
Setup Store(推荐):
- 使用 Composition API
- 更灵活
- 更好的 TypeScript 支持
- 可以使用组合式函数
Options Store:
- 类似 Vuex
- 更熟悉的语法
- 适合从 Vuex 迁移
typescript
// Setup Store
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const double = computed(() => count.value * 2)
function increment() { count.value++ }
return { count, double, increment }
})
// Options Store
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
getters: { double: (state) => state.count * 2 },
actions: { increment() { this.count++ } }
})7: 如何在 Pinia 中处理异步操作?
答案:
直接在 actions 中使用 async/await:
typescript
export const useUserStore = defineStore('user', () => {
const user = ref(null)
const loading = ref(false)
const error = ref(null)
async function fetchUser(id: number) {
loading.value = true
error.value = null
try {
const response = await api.getUser(id)
user.value = response.data
return response.data
} catch (e) {
error.value = e
throw e
} finally {
loading.value = false
}
}
return { user, loading, error, fetchUser }
})8: Pinia Store 之间如何通信?
答案:
直接在一个 Store 中引用另一个 Store:
typescript
export const useCartStore = defineStore('cart', () => {
const userStore = useUserStore() // 引用其他 store
const items = ref([])
function addItem(item) {
if (!userStore.isLoggedIn) {
throw new Error('Please login first')
}
items.value.push(item)
}
return { items, addItem }
})9: 如何测试 Pinia Store?
答案:
typescript
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from './counter'
describe('Counter Store', () => {
beforeEach(() => {
// 创建新的 pinia 实例
setActivePinia(createPinia())
})
it('increments', () => {
const store = useCounterStore()
expect(store.count).toBe(0)
store.increment()
expect(store.count).toBe(1)
})
it('doubles count', () => {
const store = useCounterStore()
store.count = 5
expect(store.doubleCount).toBe(10)
})
})10: Pinia 的插件系统如何工作?
答案:
插件是一个函数,接收 PiniaPluginContext 参数:
typescript
export function myPlugin({ store, app, pinia, options }: PiniaPluginContext) {
// 为所有 store 添加属性
store.$customProperty = 'custom'
// 监听 state 变化
store.$subscribe((mutation, state) => {
console.log('State changed')
})
// 监听 action
store.$onAction(({ name, args }) => {
console.log(`Action ${name} called with`, args)
})
// 返回对象会合并到 store
return {
$customMethod() {
console.log('Custom method')
}
}
}
// 使用
const pinia = createPinia()
pinia.use(myPlugin)