前言
在 Vue3 生态中,Pinia 已经成为官方推荐的状态管理库。它以其极简的 API、完美的 TypeScript 支持和与 Composition API 的无缝集成,彻底改变了我们管理全局状态的方式。然而,再好的工具如果使用不当,也会带来性能问题和维护噩梦。
本文将深入探讨 Pinia 的核心设计哲学,从基础的类型安全定义到高级性能优化,从常见陷阱到测试策略,帮助你在实际项目中真正驾驭这个强大的工具。
为什么我们需要Pinia?
从一个真实场景开始
想象我们正在开发一个电商网站,有这样一个需求:
html
<!-- 头部组件:显示用户名和购物车数量 -->
<template>
<header>
<div>欢迎您,{{ username }}</div>
<div>购物车({{ cartCount }})</div>
</header>
</template>
<!-- 商品列表组件:用户点击加入购物车 -->
<template>
<div v-for="product in products">
<h3>{{ product.name }}</h3>
<button @click="addToCart(product)">加入购物车</button>
</div>
</template>
<!-- 购物车组件:显示已选商品 -->
<template>
<div v-for="item in cartItems">
{{ item.name }} x {{ item.quantity }}
</div>
</template>
这时候问题来了:当用户在商品列表页点击"加入购物车"时:
- 头部组件需要更新购物车数量
- 购物车组件需要显示新加的商品
- 用户信息可能在多个地方使用
如果没有状态管理,我们可能会使用 事件总线 或 props 层层传递,这样组件之间通信会变得极其复杂。
Pinia是什么?
简单来说,Pinia就是一个 中央数据仓库:
text
┌─────────────────┐
│ Pinia Store │
│ (数据仓库) │
├─────────────────┤
│ 用户信息 │
│ 购物车数据 │
│ 主题设置 │
└─────────────────┘
▲ ▲ ▲
│ │ │
┌─────┴────┴────┴─────┐
│ 所有组件直接访问 │
└─────────────────────┘
Pinia vs Vuex:为什么选Pinia?
在 Vue2 中,类似的功能我们通常使用 Vuex4 进行管理,为什么不继续使用 Vuex4 ,而要改用 Pinia 呢?让我们做个简单对比:
Vuex4 写法 - 繁琐的模板代码
typescript
const store = createStore({
state: { count: 0 },
mutations: { // 为什么要多一层?
increment(state) {
state.count++
}
},
actions: { // 又要一层?
increment({ commit }) {
commit('increment')
}
}
})
Pinia 写法 - 简单直观
typescript
const useStore = defineStore('main', {
state: () => ({ count: 0 }),
actions: {
increment() {
this.count++ // 直接修改state,不需要mutations
}
}
})
Pinia的核心优势:
- 更少的代码:比
Vuex4少了 30% - 40% 的模板代码 - 更好的 TypeScrip t支持:不用额外写类型定义
- 更简单的API:只有
state、getters、actions - 模块化:每个
store都是独立的,不需要额外的module
快速上手 - 第一个Pinia Store
安装和配置
首先,我们需要在 Vue3 项目中安装 Pinia :
bash
npm install pinia
# 或者
yarn add pinia
然后在 main.js 中注册:
typescript
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia() // 创建Pinia实例
app.use(pinia) // 使用Pinia
app.mount('#app')
创建第一个 Store
在 src/stores 目录下创建一个 counter.js 文件:
typescript
// stores/counter.js
import { defineStore } from 'pinia'
// 定义并使用一个store
export const useCounterStore = defineStore('counter', {
// state:存储数据的地方
state: () => ({
count: 0,
name: '计数器'
}),
// getters:计算属性,相当于computed
getters: {
// 自动推导返回类型
doubleCount: (state) => state.count * 2,
// 带参数的getter(返回一个函数)
multiply: (state) => (times) => state.count * times
},
// actions:修改state的方法
actions: {
// 普通修改
increment() {
this.count++
},
// 带参数修改
add(amount) {
this.count += amount
},
// 异步操作
async fetchAndSet() {
// 模拟API调用
const res = await fetch('/api/count')
const data = await res.json()
this.count = data.count
}
}
})
在组件中使用Store
现在,在任何组件中都可以使用这个计数器了:
html
<!-- Counter.vue -->
<template>
<div class="counter">
<h2>{{ store.name }}</h2>
<p>当前值: {{ store.count }}</p>
<p>双倍值: {{ store.doubleCount }}</p>
<p>乘以3: {{ store.multiply(3) }}</p>
<button @click="store.increment()">+1</button>
<button @click="store.add(5)">+5</button>
<button @click="handleAsync">异步获取</button>
</div>
</template>
<script setup>
import { useCounterStore } from '@/stores/counter'
// 获取store实例
const store = useCounterStore()
// 异步操作
async function handleAsync() {
await store.fetchAndSet()
}
</script>
深入理解 - Store的三个核心部分
State:数据存储
创建 state
State 就是存储数据的地方,类似于组件的 data 选项:
typescript
// stores/user.js
export const useUserStore = defineStore('user', {
state: () => ({
// 基础信息
id: null,
name: '',
email: '',
// 复杂数据
preferences: {
theme: 'light',
language: 'zh-CN',
notifications: true
},
// 集合类型
permissions: [],
// 状态标志
isLoading: false,
lastLogin: null
})
})
访问和修改 state
typescript
// 获取store
const userStore = useUserStore()
// ✅ 读取state
console.log(userStore.name)
console.log(userStore.preferences.theme)
// ✅ 直接修改state(最简单的方式)
userStore.name = '张三'
userStore.preferences.theme = 'dark'
// ✅ 批量修改(推荐,只触发一次更新)
userStore.$patch({
name: '李四',
email: 'lisi@example.com'
})
// ✅ 更灵活的批量修改
userStore.$patch((state) => {
state.name = '王五'
state.preferences.theme = 'dark'
state.permissions.push('admin')
})
// ✅ 重置state到初始值
userStore.$reset()
Getter:计算属性
创建 Getter
Getter 类似于组件的 computed 属性,用于派生出新的数据:
typescript
// stores/user.js
export const useUserStore = defineStore('user', {
state: () => ({
firstName: '张',
lastName: '三',
todos: [
{ text: '学习Pinia', done: true },
{ text: '写代码', done: false }
]
}),
getters: {
// 基础getter
fullName: (state) => `${state.firstName}${state.lastName}`,
// 使用其他getter
introduction: (state) => {
return `我是${state.firstName}${state.lastName}`
},
// 带参数的getter(返回函数)
getTodoByStatus: (state) => (done) => {
return state.todos.filter(todo => todo.done === done)
},
// 统计完成数量
completedCount: (state) => {
return state.todos.filter(todo => todo.done).length
},
// 进度百分比
progress: (state) => {
const completed = state.todos.filter(todo => todo.done).length
const total = state.todos.length
return total === 0 ? 0 : Math.round((completed / total) * 100)
}
}
})
在组件中使用 getters
html
<template>
<div>
<h3>{{ userStore.fullName }}</h3>
<p>进度: {{ userStore.progress }}%</p>
<!-- 使用带参数的getter -->
<div v-for="todo in userStore.getTodoByStatus(false)">
{{ todo.text }} (未完成)
</div>
</div>
</template>
Action:业务逻辑
创建 action
Action 是修改 state 的地方,可以包含异步操作:
typescript
// stores/user.js
export const useUserStore = defineStore('user', {
state: () => ({
user: null,
loading: false,
error: null
}),
actions: {
// 同步action
setUser(user) {
this.user = user
},
// 带参数的同步action
updateUserInfo({ name, email }) {
if (this.user) {
this.user.name = name
this.user.email = email
}
},
// 异步action
async login(credentials) {
this.loading = true
this.error = null
try {
// 调用登录API
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(credentials)
})
if (!response.ok) {
throw new Error('登录失败')
}
const data = await response.json()
this.user = data.user
// 可以返回数据给组件
return data.user
} catch (error) {
this.error = error.message
throw error // 抛出错误,让组件处理
} finally {
this.loading = false
}
},
// 组合多个action
async logout() {
try {
await fetch('/api/logout')
} finally {
// 重置所有状态
this.$reset()
}
}
}
})
在组件中使用 action
typescript
import { useUserStore } from '@/stores/user'
import { ref } from 'vue'
const userStore = useUserStore()
const email = ref('')
const password = ref('')
const errorMsg = ref('')
async function handleLogin() {
try {
await userStore.login({
email: email.value,
password: password.value
})
// 登录成功,跳转到首页
router.push('/dashboard')
} catch (error) {
errorMsg.value = error.message
}
}
组合式风格 - 更现代的写法
从 Vue3 开始,组合式 API 成为主流。Pinia 也支持用组合式风格定义 store。
基础组合式 Store
typescript
// stores/user.js (组合式风格)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
// ========== State:用ref定义 ==========
const user = ref(null)
const token = ref(localStorage.getItem('token'))
const loading = ref(false)
const error = ref(null)
// ========== Getters:用computed定义 ==========
const isLoggedIn = computed(() => !!token.value && !!user.value)
const fullName = computed(() => {
if (!user.value) return ''
return `${user.value.lastName}${user.value.firstName}`
})
const isAdmin = computed(() => user.value?.role === 'admin')
// 返回函数的getter
const hasPermission = (permission) => {
return computed(() => user.value?.permissions?.includes(permission))
}
// ========== Actions:普通函数 ==========
function setUser(userData) {
user.value = userData
}
async function login(credentials) {
loading.value = true
error.value = null
try {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(credentials)
})
const data = await response.json()
user.value = data.user
token.value = data.token
// 保存到localStorage
localStorage.setItem('token', data.token)
return data.user
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
function logout() {
user.value = null
token.value = null
localStorage.removeItem('token')
}
// 返回所有内容
return {
// state
user,
token,
loading,
error,
// getters
isLoggedIn,
fullName,
isAdmin,
hasPermission,
// actions
setUser,
login,
logout
}
})
为什么推荐组合式风格?
选项式风格:数据和逻辑分离
typescript
defineStore('counter', {
state: () => ({ count: 0 }),
getters: { double: (state) => state.count * 2 },
actions: { increment() { this.count++ } }
})
组合式风格:相关代码在一起,更易维护
typescript
defineStore('counter', () => {
// 所有的相关代码都在这里
const count = ref(0)
const double = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, double, increment }
})
组合式风格的优势:
- 更好的代码组织:相关的逻辑放在一起
- 更容易复用:可以提取公共逻辑到组合式函数
- 更灵活的TypeScript支持
实用技巧 - 解决常见问题
解构陷阱:为什么不能用解构?
这是新手很容易犯的错误:
typescript
import { useUserStore } from '@/stores/user'
// ❌ 错误:解构会失去响应式
const { name, email } = useUserStore()
// 当store中的name变化时,这里的name不会更新!
原理示意图
text
Store (响应式对象)
├── name (响应式属性)
├── email (响应式属性)
└── login (普通函数)
直接解构:
const { name } = store
name --> 变成了普通变量,失去响应式
正确解构:storeToRefs
typescript
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
// ✅ 正确:使用storeToRefs保持响应式
const { name, email, isAdmin } = storeToRefs(userStore)
// ✅ actions可以直接解构(它们不是响应式的)
const { login, logout } = userStore
// 现在name是ref,修改会自动更新
console.log(name.value) // 注意要加.value
storeToRefs 做了什么
typescript
// 简单理解它的原理
function storeToRefs(store) {
const refs = {}
for (const key in store) {
const value = store[key]
// 如果是响应式数据,转换为ref
if (isRef(value) || isReactive(value)) {
refs[key] = toRef(store, key)
}
// actions被忽略,保持原样
}
return refs
}
批量更新:避免多次渲染
typescript
// ❌ 错误:多次修改导致多次渲染
function addItems(items) {
for (const item of items) {
this.items.push(item) // 触发一次渲染
this.total += item.price // 又触发一次
this.count++ // 又一次触发
}
}
// ✅ 正确:使用$patch批量更新
function addItems(items) {
this.$patch((state) => {
// 在$patch内部的所有修改只触发一次更新
for (const item of items) {
state.items.push(item)
state.total += item.price
state.count++
}
})
}
// ✅ 或者:先计算再赋值
function addItems(items) {
const newItems = [...this.items, ...items]
const total = newItems.reduce((sum, i) => sum + i.price, 0)
// 一次性更新
this.items = newItems
this.total = total
this.count = newItems.length
}
大型数据性能优化
当需要存储大量数据时:
typescript
// stores/data.js
import { defineStore } from 'pinia'
import { shallowRef } from 'vue'
export const useDataStore = defineStore('data', () => {
// ❌ 如果数据很大,ref会让所有属性都变成响应式
const bigData = ref(fetchHugeDataset())
// ✅ 使用shallowRef,只跟踪引用变化,内部属性不跟踪
const bigDataOptimized = shallowRef(fetchHugeDataset())
// 更新时整体替换
function updateData(newData) {
bigDataOptimized.value = newData // 触发更新
// 修改内部属性不会触发更新
// bigDataOptimized.value[0].name = 'test' ❌ 不会触发渲染
}
return { bigDataOptimized, updateData }
})
避免在循环中使用store
html
<!-- ❌ 错误:每次循环都创建一个store实例 -->
<template>
<div v-for="user in users" :key="user.id">
<UserCard :store="useUserStore(user.id)" />
</div>
</template>
解决方案:使用store工厂或传递ID
typescript
// stores/user.js
export const useUserStore = defineStore('user', () => {
const users = ref(new Map()) // 用Map存储多个用户
function getUser(id) {
if (!users.value.has(id)) {
users.value.set(id, null)
}
return computed({
get: () => users.value.get(id),
set: (value) => users.value.set(id, value)
})
}
async function fetchUser(id) {
const user = await api.getUser(id)
users.value.set(id, user)
}
return { getUser, fetchUser }
})
// 在组件中使用
const userStore = useUserStore()
const user = userStore.getUser(props.userId)
watchEffect(() => {
if (!user.value) {
userStore.fetchUser(props.userId)
}
})
循环依赖
typescript
// ❌ 错误:两个store相互引用
// storeA.js
export const useAStore = defineStore('a', () => {
const bStore = useBStore() // 依赖B
const data = ref(bStore.someData)
return { data }
})
// storeB.js
export const useBStore = defineStore('b', () => {
const aStore = useAStore() // 依赖A
const data = ref(aStore.someData)
return { data }
})
解决方案:提取共享逻辑
typescript
// 创建共享store:storeShared.js
export const useSharedStore = defineStore('shared', () => {
const sharedData = ref({})
return { sharedData }
})
// storeA.js
export const useAStore = defineStore('a', () => {
const shared = useSharedStore()
const data = computed(() => shared.sharedData.a)
return { data }
})
// storeB.js
export const useBStore = defineStore('b', () => {
const shared = useSharedStore()
const data = computed(() => shared.sharedData.b)
return { data }
})
Store 组合:1+1 > 2
一个 Store 中使用另一个 Store
typescript
// stores/cart.js
import { defineStore } from 'pinia'
import { useUserStore } from './user'
import { useProductStore } from './product'
export const useCartStore = defineStore('cart', () => {
// 引入其他store
const userStore = useUserStore()
const productStore = useProductStore()
// state
const items = ref([])
const coupon = ref(null)
// getters - 组合多个store的数据
const cartItems = computed(() => {
return items.value.map(item => {
// 从商品store获取详细信息
const product = productStore.getProductById(item.productId)
return {
...item,
product,
subtotal: product.price * item.quantity
}
})
})
const total = computed(() => {
return cartItems.value.reduce((sum, item) => sum + item.subtotal, 0)
})
const canCheckout = computed(() => {
// 同时依赖多个store
return userStore.isLoggedIn && items.value.length > 0
})
// actions
function addItem(productId, quantity = 1) {
const existing = items.value.find(i => i.productId === productId)
if (existing) {
existing.quantity += quantity
} else {
items.value.push({ productId, quantity })
}
// 调用其他store的action
productStore.reduceStock(productId, quantity)
}
async function checkout() {
if (!canCheckout.value) {
throw new Error('不能结算')
}
// 使用用户信息和购物车数据创建订单
const order = {
userId: userStore.user.id,
items: items.value,
total: total.value,
coupon: coupon.value
}
// 调用订单API
const result = await api.createOrder(order)
// 清空购物车
items.value = []
return result
}
return {
items,
coupon,
cartItems,
total,
canCheckout,
addItem,
checkout
}
})
共享逻辑复用:工厂模式
typescript
// stores/factories/createListStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
/**
* 创建一个通用的列表管理store
* @param {string} id store的唯一标识
* @param {Object} options 配置选项
*/
export function createListStore(id, options) {
return defineStore(id, () => {
// state
const items = ref([])
const loading = ref(false)
const error = ref(null)
const filters = ref({})
// getters
const total = computed(() => items.value.length)
const filteredItems = computed(() => {
let result = items.value
// 应用自定义过滤逻辑
if (options.filter) {
result = result.filter(item => options.filter(item, filters.value))
}
return result
})
// actions
async function fetchItems(params) {
loading.value = true
error.value = null
filters.value = params || {}
try {
const data = await options.fetch(params)
items.value = data
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
async function addItem(data) {
if (!options.create) {
throw new Error('create method not implemented')
}
const newItem = await options.create(data)
items.value.push(newItem)
return newItem
}
async function updateItem(id, data) {
if (!options.update) {
throw new Error('update method not implemented')
}
const updated = await options.update(id, data)
const index = items.value.findIndex(i => i.id === id)
if (index !== -1) {
items.value[index] = updated
}
return updated
}
async function deleteItem(id) {
if (!options.delete) {
throw new Error('delete method not implemented')
}
await options.delete(id)
items.value = items.value.filter(i => i.id !== id)
}
return {
items,
loading,
error,
filters,
total,
filteredItems,
fetchItems,
addItem,
updateItem,
deleteItem
}
})
}
// 使用工厂创建具体的store
// stores/users.js
import { createListStore } from './factories/createListStore'
import { userApi } from '@/api/user'
export const useUserStore = createListStore('users', {
fetch: userApi.getUsers,
create: userApi.createUser,
update: userApi.updateUser,
delete: userApi.deleteUser,
filter: (user, filters) => {
if (filters.keyword && !user.name.includes(filters.keyword)) {
return false
}
if (filters.role && user.role !== filters.role) {
return false
}
return true
}
})
// 在组件中使用
const userStore = useUserStore()
await userStore.fetchItems({ keyword: '张' })
黄金法则与最佳实践
Store设计原则
| 原则 | 说明 | 示例 |
|---|---|---|
| 按业务划分 | 每个store管理一个业务领域 | user、product、cart |
| 扁平化 | 避免嵌套,保持简单 | 不要用modules |
| 单一职责 | 一个store只做一件事 | 购物车不处理订单 |
| 可组合 | store之间可以互相使用 | 购物车使用商品和用户 |
性能优化原则
| 原则 | 说明 | 示例 |
|---|---|---|
| 使用 storeToRefs | 只解构需要的响应式数据 | const { name } = storeToRefs(store) |
| actions 直接解构 | actions 不是响应式的 | const { login } = store |
| 批量更新 | 用 $patch 批量更新,减少触发更新次数 |
store.$patch({ ... }) |
| 大型数据用 shallowRef | 避免深度响应式 | const data = shallowRef([]) |
| 避免循环依赖 | store 之间不要相互引用 | 使用共享 store 解耦 |
| 按需加载 | 路由级别拆分 store |
只在需要时 import |
代码组织原则
推荐的 store 文件结构
text
stores/
├── index.js # 统一导出
├── user.js # 用户相关
├── product.js # 商品相关
├── cart.js # 购物车相关
└── factories/ # 工厂函数
└── createListStore.js
推荐的 store 内部结构
typescript
export const useStore = defineStore('id', () => {
// 1. state (ref)
const data = ref(null)
// 2. getters (computed)
const computedData = computed(() => data.value)
// 3. actions (functions)
function action() {}
// 4. return
return { data, computedData, action }
})
常见错误检查清单
- 是不是直接解构了
store? - 是不是忘了用
storeToRefs? - 是不是在循环中创建
store实例? - 是不是有循环依赖?
- 是不是用了太多响应式?
- 是不是在
getter中做了异步操作?
最终建议
Pinia 的成功在于它的简单 和类型安全。但简单不等于随意,类型安全不等于复杂。在实际项目中:
- 从简单的 store 开始,不要一开始就追求完美设计
- 遵循组合式风格,它更适合 Vue 3 的生态
- 注意性能陷阱 ,特别是
storeToRefs的使用 - 充分利用 TypeScript,让类型系统帮你发现错误
- 测试核心逻辑 ,特别是涉及异步操作的
actions
结语
Pinia 只是工具,不是目标,不要为了用而用,而是要在真正需要共享状态的地方使用它。好的状态管理应该让业务代码更清晰,而不是增加复杂度。
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!