Pinia 状态管理
1. Pinia 简介
1.1 为什么用 Pinia
Pinia vs Vuex:
├── API 更简单 (没有 mutations)
├── TypeScript 支持更好
├── 体积更小 (~1KB)
├── 模块化更灵活 (不需要嵌套 modules)
├── 异步 actions 写法简单
└── 组合式风格原生支持1.2 安装配置
bash
npm install piniajavascript
// main.js
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. Store 定义
2.1 选项式 Store
javascript
// stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed, actions } from 'pinia'
export const useCounterStore = defineStore('counter', {
// 状态
state: () => ({
count: 0,
history: []
}),
// 计算属性
getters: {
doubleCount: (state) => state.count * 2,
// 访问其他 store
otherStoreCount: (state) => {
const otherStore = useOtherStore()
return otherStore.count
}
},
// 方法 (同步/异步)
actions: {
increment() {
this.count++
this.history.push(this.count)
},
async fetchData() {
const res = await fetch('/api/data')
this.data = await res.json()
}
}
})2.2 组合式 Store (推荐)
javascript
// stores/user.js
import { defineStore } from 'pinia'
import { ref, computed, markRaw } from 'vue'
import { usePermissionStore } from './permission'
export const useUserStore = defineStore('user', () => {
// 状态
const profile = ref(null)
const token = ref(localStorage.getItem('token') || '')
const isLoading = ref(false)
// 计算属性
const isLoggedIn = computed(() => !!token.value)
const userName = computed(() => profile.value?.name || 'Guest')
const permissions = computed(() => profile.value?.permissions || [])
// 方法
async function login(credentials) {
isLoading.value = true
try {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(credentials)
})
const data = await res.json()
token.value = data.token
profile.value = data.user
localStorage.setItem('token', data.token)
return true
} catch (error) {
console.error('登录失败:', error)
return false
} finally {
isLoading.value = false
}
}
function logout() {
token.value = ''
profile.value = null
localStorage.removeItem('token')
}
// 检查权限
function hasPermission(permission) {
return permissions.value.includes(permission)
}
// 返回
return {
profile,
token,
isLoading,
isLoggedIn,
userName,
permissions,
login,
logout,
hasPermission
}
})3. Store 使用
3.1 组件中使用
vue
<script setup>
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
import { useCounterStore } from '@/stores/counter'
// 获取 store
const userStore = useUserStore()
const counterStore = useCounterStore()
// 解构状态 (需要用 storeToRefs 保持响应式)
const { profile, isLoggedIn } = storeToRefs(userStore)
// 解构 actions (不需要 storeToRefs,直接保持响应式)
const { login, logout } = userStore
// 直接访问
console.log(userStore.profile)
console.log(userStore.token)
// 调用 actions
async function handleLogin() {
await userStore.login({ username: 'admin', password: '123' })
}
</script>
<template>
<div>
<p v-if="isLoggedIn">欢迎, {{ userStore.userName }}</p>
<button @click="handleLogin">登录</button>
</div>
</template>3.2 storeToRefs 使用
javascript
import { storeToRefs } from 'pinia'
const store = useStore()
// 错误: 解构丢失响应式
const { name, age } = store // name, age 失去响应式
// 正确: storeToRefs
const { name, age } = storeToRefs(store) // 保持响应式
name.value = '新名字'
// 对于 actions,不需要 storeToRefs
const { updateName } = store // actions 保持响应式4. Getters
4.1 基础 getters
javascript
// options style
export const useStore = defineStore('shop', {
state: () => ({
products: [
{ id: 1, name: '苹果', price: 5, category: '水果' },
{ id: 2, name: '白菜', price: 3, category: '蔬菜' },
{ id: 3, name: '香蕉', price: 6, category: '水果' }
],
cart: []
}),
getters: {
// 基本 getter
fruitProducts: (state) => {
return state.products.filter(p => p.category === '水果')
},
// 依赖其他 getter
totalPrice: (state) => {
return state.cart.reduce((sum, item) => {
const product = state.products.find(p => p.id === item.id)
return sum + product.price * item.quantity
}, 0)
},
// 使用 this
expensiveItems: (state) => {
return state.products.filter(p => p.price > 5)
},
// 传参的 getter
getProductById: (state) => (id) => {
return state.products.find(p => p.id === id)
}
}
})4.2 组合式 getters
javascript
export const useStore = defineStore('shop', () => {
const products = ref([
{ id: 1, name: '苹果', price: 5 },
{ id: 2, name: '白菜', price: 3 }
])
// getter
const expensiveProducts = computed(() => {
return products.value.filter(p => p.price > 4)
})
// 带参数的 getter
const getProductById = computed(() => (id) => {
return products.value.find(p => p.id === id)
})
return { products, expensiveProducts, getProductById }
})5. Actions
5.1 同步/异步 Actions
javascript
export const useUserStore = defineStore('user', () => {
const users = ref([])
const isLoading = ref(false)
const error = ref(null)
// 同步 action
function setUser(user) {
this.currentUser = user
}
// 异步 action
async function fetchUsers() {
isLoading.value = true
error.value = null
try {
const res = await fetch('/api/users')
if (!res.ok) throw new Error('获取用户失败')
users.value = await res.json()
} catch (e) {
error.value = e.message
} finally {
isLoading.value = false
}
}
// 多个异步操作
async function initialize() {
isLoading.value = true
await Promise.all([
fetchUsers(),
fetchPermissions(),
fetchSettings()
])
isLoading.value = false
}
// 错误处理
async function riskyAction() {
try {
await doSomethingRisky()
} catch (error) {
// 处理错误,可能回滚状态
console.error(error)
}
}
return { users, isLoading, error, fetchUsers, initialize }
})5.2 访问其他 Store
javascript
// stores/cart.js
export const useCartStore = defineStore('cart', () => {
const items = ref([])
// 访问其他 store
const userStore = useUserStore()
const total = computed(() => {
return items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
})
async function checkout() {
// 检查用户状态
if (!userStore.isLoggedIn) {
throw new Error('请先登录')
}
// ...
}
return { items, total, checkout }
})5.3 Action 返回 Promise
javascript
export const useApiStore = defineStore('api', () => {
async function fetchData() {
const response = await fetch('/api/data')
if (!response.ok) {
throw new Error('请求失败')
}
return await response.json()
}
return { fetchData }
})
// 组件中使用
const api = useApiStore()
// 方式1: await
const data = await api.fetchData()
// 方式2: Promise
api.fetchData()
.then(data => console.log(data))
.catch(err => console.error(err))6. 持久化
6.1 手动持久化
javascript
// stores/user.js
export const useUserStore = defineStore('user', {
state: () => ({
token: localStorage.getItem('token') || '',
refreshToken: localStorage.getItem('refreshToken') || '',
profile: null
}),
actions: {
setToken(token, refreshToken) {
this.token = token
this.refreshToken = refreshToken
localStorage.setItem('token', token)
localStorage.setItem('refreshToken', refreshToken)
},
clearToken() {
this.token = ''
this.refreshToken = ''
localStorage.removeItem('token')
localStorage.removeItem('refreshToken')
}
}
})6.2 插件方式
javascript
// plugins/pinia-plugin-persist.js
export function piniaPluginPersist({ store }) {
// 初始化时恢复状态
const savedState = localStorage.getItem(`pinia-${store.$id}`)
if (savedState) {
store.$patch(JSON.parse(savedState))
}
// 订阅变化,自动保存
store.$subscribe((mutation, state) => {
localStorage.setItem(`pinia-${store.$id}`, JSON.stringify(state))
})
}
// main.js
import { createPinia } from 'pinia'
import piniaPluginPersist from '@/plugins/pinia-plugin-persist'
const pinia = createPinia()
pinia.use(piniaPluginPersist)7. Store 进阶
7.1 订阅 store 变化
javascript
const store = useStore()
// 订阅 mutation
store.$subscribe((mutation, state) => {
console.log('store 变化:', mutation)
console.log('新状态:', state)
}, { detached: true }) // 组件卸载后继续监听
// 订阅 action
store.$onAction((action) => {
console.log('调用 action:', action.name)
action.after((result) => {
console.log('action 完成,结果:', result)
})
action.onError((error) => {
console.error('action 失败:', error)
})
})7.2 重置 store
javascript
const store = useStore()
// 重置到初始状态
store.$reset()
// 手动重置
function resetStore() {
store.$state = {
count: 0,
name: '',
items: []
}
}7.3 替换 store 状态
javascript
const store = useStore()
// 完全替换
store.$state = {
count: 100,
name: '新名字'
}
// 部分替换
store.$patch({
count: 50,
name: '部分更新'
})8. Pinia 实战
8.1 用户认证模块
javascript
// stores/auth.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useAuthStore = defineStore('auth', () => {
const user = ref(null)
const token = ref(localStorage.getItem('token'))
const permissions = ref([])
const isAuthenticated = computed(() => !!token.value)
const isAdmin = computed(() => permissions.value.includes('admin'))
async function login(credentials) {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
})
if (!res.ok) {
const error = await res.json()
throw new Error(error.message || '登录失败')
}
const data = await res.json()
token.value = data.token
user.value = data.user
permissions.value = data.permissions || []
localStorage.setItem('token', data.token)
return data
}
async function logout() {
try {
await fetch('/api/auth/logout', { method: 'POST' })
} finally {
token.value = null
user.value = null
permissions.value = []
localStorage.removeItem('token')
}
}
async function refreshToken() {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token.value}`
}
})
if (!res.ok) {
logout()
throw new Error('Token 刷新失败')
}
const data = await res.json()
token.value = data.token
localStorage.setItem('token', data.token)
}
function hasPermission(permission) {
return permissions.value.includes(permission)
}
return {
user,
token,
permissions,
isAuthenticated,
isAdmin,
login,
logout,
refreshToken,
hasPermission
}
})8.2 全局请求拦截
javascript
// stores/request.js
import { defineStore } from 'pinia'
import { useAuthStore } from './auth'
export const useRequestStore = defineStore('request', () => {
const auth = useAuthStore()
async function request(url, options = {}) {
const headers = {
'Content-Type': 'application/json',
...options.headers
}
if (auth.token) {
headers['Authorization'] = `Bearer ${auth.token}`
}
try {
const res = await fetch(url, {
...options,
headers
})
// 处理 401
if (res.status === 401) {
// 尝试刷新 token
try {
await auth.refreshToken()
// 重试请求
headers['Authorization'] = `Bearer ${auth.token}`
return fetch(url, { ...options, headers })
} catch {
await auth.logout()
window.location.href = '/login'
throw new Error('登录已过期')
}
}
if (!res.ok) {
const error = await res.json().catch(() => ({}))
throw new Error(error.message || '请求失败')
}
return await res.json()
} catch (error) {
throw error
}
}
function get(url, options = {}) {
return request(url, { ...options, method: 'GET' })
}
function post(url, data, options = {}) {
return request(url, {
...options,
method: 'POST',
body: JSON.stringify(data)
})
}
return { request, get, post }
})9. 面试要点
9.1 Pinia vs Vuex
Pinia 优势:
- API 更简洁 (去掉了 mutations)
- TypeScript 支持更好
- actions 可以是异步,不用 commit
- 模块化更灵活
- 体积更小
- DevTools 支持好
Vuex 4 特点:
- 兼容 Vue 3
- 保留了 mutations (同步)
- 需要 commit 调用
- 相对更规范但更繁琐9.2 setup store vs options store
setup store (组合式):
- 更接近普通 composition API 代码
- 灵活性高
- TypeScript 支持好
- 可以直接测试
- 推荐使用
options store (选项式):
- 风格统一 (state/getters/actions)
- 对 Vue 2 用户友好
- 但 getters 访问其他 store 不直观9.3 storeToRefs 原理
javascript
// 原理
function storeToRefs(store) {
// 遍历 state 和 getters,转为 ref
const refs = {}
for (const key in store.$state) {
refs[key] = toRef(store.$state, key)
}
// actions 不需要,保持原样
return refs
}
// 注意: actions 解构后仍有响应式,因为 actions 是函数
const { increment } = store // 没问题
const { name } = storeToRefs(store) // name 是 ref,需要 .value9.4 持久化方案
1. 手动持久化
- 在 action 中手动读/写 localStorage
- 控制粒度细
2. 插件方式
- 封装成 pinia 插件
- 自动持久化
- 可配置白名单/黑名单
3. 注意
- 不要持久化敏感信息
- 考虑加密
- 大数据量要优化