为什么需要状态管理
在 Vue 应用中,组件之间共享状态有几种方式:
- Props drilling:通过 props 层层传递。缺点是中间组件需要透传与自身逻辑无关的 props
- Provide/Inject:解决 props drilling,但适合浅层共享,不适合复杂应用
- Event bus:通过事件总线通信。缺点是难以追踪数据流,调试困难
- 状态管理库:专门的状态管理方案,Pinia 和 Vuex 属于这一类
对于中小型应用,Provide/Inject 可能已经足够。但对于需要管理大量共享状态、复杂数据流的应用,状态管理库提供了更好的可预测性和可调试性。
一、Pinia vs Vuex:为什么 Pinia 是新的默认
1.1 Vuex 的历史和局限
Vuex 是 Vue 2 时代官方推荐的状态管理库。它引入了 单一状态树(Single Source of Truth) 的理念,以及 mutations → actions → state 的分层架构。
Vuex 的问题:
- Mutations 是同步的:所有状态修改必须通过 mutation,但异步操作必须放在 actions 中,这导致了"同步修改状态"的约束
- TypeScript 支持不完美:Vuex 是在 TypeScript 流行之前设计的,虽然后来添加了类型支持,但使用体验不如原生 TypeScript
- API 复杂:需要记住 mutations、actions、getters 的区分,模块系统也有学习成本
- HMR(热模块替换)支持有限:在某些场景下状态会丢失
1.2 Pinia 的设计选择
Pinia 在 Vue 3 发布后成为官方推荐的状态管理库,它的核心改进:
- 取消 Mutations:只有 state、getters、actions,简化了概念模型
- 原生 TypeScript 支持:完整的类型推导,不需要装饰器或额外配置
- Actions 支持同步和异步:同一个 action 可以包含同步和异步代码
- 更好的模块系统:每个 store 是独立的,不需要像 Vuex 那样处理模块的命名空间
- 支持 Composition API 风格:可以用
setup()语法定义 store
// Pinia: 简洁的 API
import { defineStore } from 'pinia';
export const useUserStore = defineStore('user', {
state: () => ({
name: 'Alice',
age: 30
}),
getters: {
isAdult: (state) => state.age >= 18
},
actions: {
async fetchUser(id: string) {
const user = await api.getUser(id);
this.name = user.name;
this.age = user.age;
}
}
});
1.3 Vuex 到 Pinia 的迁移
从 Vuex 迁移到 Pinia 很简单,因为核心概念相似:
| Vuex | Pinia |
|---|---|
state |
state |
getters |
getters |
mutations |
移除(直接修改 state) |
actions |
actions |
store.state |
store.$state |
mapState |
StoreToRefs + 解构 |
mapGetters |
StoreToRefs + 解构 |
mapActions |
直接解构 actions |
二、Pinia 核心概念
2.1 Store 的定义方式
Pinia 支持两种定义 store 的语法:选项式(Options API 风格) 和 组合式(Composition API 风格)。
选项式定义:
// stores/counter.ts
import { defineStore } from 'pinia';
export const useCounterStore = defineStore('counter', {
// 状态:返回一个函数的函数(factory function)
state: () => ({
count: 0,
history: [] as number[]
}),
// Getters:计算属性
getters: {
doubleCount: (state) => state.count * 2,
// getter 可以访问其他 store
otherStoreValue: (state) => {
const otherStore = useOtherStore();
return otherStore.value;
}
},
// Actions:修改状态的方法(支持异步)
actions: {
increment() {
this.count++;
this.history.push(this.count);
},
async fetchInitialCount() {
const count = await api.getCount();
this.count = count;
}
}
});
组合式定义(与 Composition API 一致):
// stores/counter.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useCounterStore = defineStore('counter', () => {
// 状态
const count = ref(0);
const history = ref<number[]>([]);
// Getters
const doubleCount = computed(() => count.value * 2);
// Actions
function increment() {
count.value++;
history.value.push(count.value);
}
async function fetchInitialCount() {
const countVal = await api.getCount();
count.value = countVal;
}
return { count, history, doubleCount, increment, fetchInitialCount };
});
2.2 在组件中使用 Store
<script setup>
import { useCounterStore } from '@/stores/counter';
import { storeToRefs } from 'pinia';
// 获取 store 实例
const counterStore = useCounterStore();
// 访问 state
console.log(counterStore.count); // 0
// 访问 getters
console.log(counterStore.doubleCount); // 0
// 调用 actions
counterStore.increment();
// 使用 storeToRefs 保持响应性
// 解构 store 时,直接解构会丢失响应性
import { storeToRefs } from 'pinia';
const store = useCounterStore();
// 解构后 items 仍是响应式的
const { count, doubleCount } = storeToRefs(store);
// 解构 actions 直接得到函数(本身就是函数)
const { increment } = store;
</script>
2.3 State 的修改
在 Pinia 中,state 可以直接修改(不需要 mutation):
const store = useCounterStore();
// 直接修改(选项式和组合式都支持)
store.count = 10;
// 批量修改($patch)
store.$patch({
count: 10,
history: [10]
});
// 用函数批量修改(适合需要基于当前状态计算的场景)
store.$patch((state) => {
state.count++;
state.history.push(state.count);
});
// 重置到初始状态
store.$reset();
2.4 Getters
Getters 本质上是 store 状态的计算属性:
export const useShopStore = defineStore('shop', {
state: () => ({
items: [
{ id: 1, name: 'Apple', price: 1.5, category: 'fruit' },
{ id: 2, name: 'Carrot', price: 0.8, category: 'vegetable' },
{ id: 3, name: 'Banana', price: 0.5, category: 'fruit' }
],
cart: []
}),
getters: {
// 基础 getter
totalItems: (state) => state.items.length,
// 基于其他 getter
fruitItems: (state) => state.items.filter(i => i.category === 'fruit'),
// getter 可以传参(每次调用都重新计算)
itemById: (state) => (id: number) => state.items.find(i => i.id === id),
// 使用其他 store
cartTotal: (state) => {
const cartStore = useCartStore();
return cartStore.items.reduce((sum, item) => sum + item.price, 0);
}
}
});
// 使用带参数的 getter
const shopStore = useShopStore();
const item = shopStore.itemById(1); // 每次调用都重新计算
2.5 Actions
Actions 是修改状态的方法,支持同步和异步:
export const useUserStore = defineStore('user', {
state: () => ({
profile: null as User | null,
isLoading: false,
error: null as string | null
}),
actions: {
// 同步 action
updateName(name: string) {
if (this.profile) {
this.profile.name = name;
}
},
// 异步 action
async fetchUser(id: string) {
this.isLoading = true;
this.error = null;
try {
const user = await api.getUser(id);
this.profile = user;
} catch (err) {
this.error = err instanceof Error ? err.message : 'Unknown error';
} finally {
this.isLoading = false;
}
},
// 组合多个 actions
async registerAndLogin(data: RegisterData) {
await api.register(data);
await this.fetchUser(data.id);
}
}
});
三、跨组件共享状态
3.1 组件内使用 Store
<script setup>
const storeA = useStoreA();
const storeB = useStoreB();
// 在模板中直接使用
// {{ storeA.count }} + {{ storeB.name }}
</script>
3.2 在 Store 之间共享状态
Store 之间可以相互调用,这使得跨 store 逻辑复用成为可能:
// stores/cart.ts
export const useCartStore = defineStore('cart', {
state: () => ({
items: [] as CartItem[]
}),
getters: {
total: (state) => state.items.reduce((sum, item) => sum + item.price, 0),
itemCount: (state) => state.items.length
},
actions: {
addItem(item: CartItem) {
this.items.push(item);
}
}
});
// stores/order.ts
export const useOrderStore = defineStore('order', {
actions: {
async checkout() {
const cartStore = useCartStore();
if (cartStore.items.length === 0) {
throw new Error('Cart is empty');
}
const order = await api.createOrder({
items: cartStore.items,
total: cartStore.total
});
cartStore.items = []; // 清空购物车
return order;
}
}
});
3.3 持久化 Pinia 状态
很多应用需要将部分状态持久化到 localStorage:
// stores/preferences.ts
import { defineStore } from 'pinia';
import { ref, watch } from 'vue';
export const usePreferencesStore = defineStore('preferences', () => {
const theme = ref('light');
const language = ref('en');
// 从 localStorage 恢复
const savedTheme = localStorage.getItem('theme');
if (savedTheme) theme.value = savedTheme;
// 监听变化并持久化
watch(theme, (newTheme) => {
localStorage.setItem('theme', newTheme);
});
return { theme, language };
});
四、Pinia 和 Composition API 的结合
4.1 setup 语法定义 Store
Pinia 支持完全使用 Composition API 的方式定义 store:
// stores/useUserStore.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useUserStore = defineStore('user', () => {
// State
const name = ref('');
const age = ref(0);
const friends = ref<User[]>([]);
// Getters
const isAdult = computed(() => age.value >= 18);
const friendCount = computed(() => friends.value.length);
// Actions
function setUser(user: User) {
name.value = user.name;
age.value = user.age;
friends.value = user.friends;
}
async function fetchUser(id: string) {
const user = await api.getUser(id);
setUser(user);
}
return {
name,
age,
friends,
isAdult,
friendCount,
setUser,
fetchUser
};
});
4.2 在 composables 中使用 Store
Pinia store 可以在 composables 中使用,这使得逻辑分层更清晰:
// composables/useAuth.ts
import { useUserStore } from '@/stores/user';
export function useAuth() {
const userStore = useUserStore();
const isLoggedIn = computed(() => !!userStore.profile);
async function login(credentials: Credentials) {
await userStore.fetchUser(credentials.userId);
}
function logout() {
userStore.$reset();
}
return {
isLoggedIn,
login,
logout
};
}
<script setup>
import { useAuth } from '@/composables/useAuth';
const { isLoggedIn, login, logout } = useAuth();
</script>
4.3 Store 的测试
Pinia store 可以独立于组件进行测试:
// stores/counter.test.ts
import { setActivePinia, createPinia } from 'pinia';
import { useCounterStore } from './counter';
describe('Counter Store', () => {
beforeEach(() => {
// 创建一个新的 pinia 实例
setActivePinia(createPinia());
});
test('increment', () => {
const store = useCounterStore();
expect(store.count).toBe(0);
store.increment();
expect(store.count).toBe(1);
});
test('doubleCount', () => {
const store = useCounterStore();
expect(store.doubleCount).toBe(0);
store.increment();
expect(store.doubleCount).toBe(2);
});
});
五、Pinia 的插件系统
Pinia 提供了插件系统来扩展功能,例如持久化、时间旅行调试等:
// plugins/persist.ts
export const persistPlugin = ({ store }: { store: any }) => {
// 从 localStorage 恢复
const savedState = localStorage.getItem(store.$id);
if (savedState) {
store.$patch(JSON.parse(savedState));
}
// 监听变化并保存
store.$subscribe((mutation: any, state: any) => {
localStorage.setItem(store.$id, JSON.stringify(state));
});
};
// main.ts
import { createPinia } from 'pinia';
const pinia = createPinia();
pinia.use(persistPlugin);
app.use(pinia);
六、面试高频问题
Q1: Pinia 和 Vuex 的核心区别是什么?
Pinia 取消了 mutations,只有 state、getters、actions 三个概念,简化了模型。Pinia 原生 TypeScript 支持,不需要装饰器。Pinia 的 actions 支持同步和异步,Vuex 的 mutations 必须是同步的(异步必须放 actions)。
Q2: 什么时候应该用 Pinia?
当应用有多个组件需要共享状态,或者状态逻辑复杂(涉及多个异步操作)时,应该使用 Pinia。对于简单场景,Provide/Inject 或简单的模块级状态可能更合适。
Q3: setup store 和 options store 哪个更好?
两者功能等价,选择取决于团队偏好。setup store 更接近 Composition API 的写法,适合 TypeScript-first 的团队。options store 更接近 Vue 2 的写法,对从 Vuex 迁移的团队更熟悉。
Q4: Pinia 如何处理 SSR?
在 SSR 场景下,每个请求需要独立的 Pinia 实例:
// plugins/pinia.ts
import { createPinia } from 'pinia';
export function createSSRPinia() {
const pinia = createPinia();
// SSR 需要使用 noSerialize 处理不可序列化的值
return pinia;
}
// server.ts
app.use((req, res) => {
const pinia = createSSRPinia();
// 每个请求有独立的 pinia 实例
});
延展阅读
- Pinia 官方文档 — Getting Started — 官方入门教程
- Pinia 官方文档 — Core Concepts — 核心概念文档
- Pinia 官方文档 — Plugins — 插件系统
- Vue Mastery — Pinia: The Enjoyable Vue Store — 视频课程