第 127 章:Pinia 状态管理
学习目标
- 掌握 Pinia 的设计与基本用法
- 学会定义 store(state/getters/actions)
- 理解模块化、持久化、DevTools 集成
- 在 Vue 3 + TypeScript 项目中使用 Pinia
一、为什么需要 Pinia
Vuex 写起来繁琐,Pinia 是 Vue 官方推出的下一代状态管理,API 简洁、类型友好、支持 Composition API。
二、安装与配置
2.1 安装
bash
pnpm add pinia2.2 注入
typescript
// main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
const app = createApp(App);
app.use(createPinia());
app.mount('#app');三、定义 Store
3.1 Options Store(类 Vuex)
typescript
// stores/user.ts
import { defineStore } from 'pinia';
export const useUserStore = defineStore('user', {
state: () => ({
name: 'Tom',
age: 18,
token: ''
}),
getters: {
isAdult: (state) => state.age >= 18,
upperName: (state) => state.name.toUpperCase()
},
actions: {
login(credentials: LoginDTO) {
// this 指向 store
this.token = credentials.token;
},
async fetchUser() {
const res = await api.getUser();
this.name = res.data.name;
this.age = res.data.age;
}
}
});3.2 Setup Store(推荐,类 composable)
typescript
// stores/user.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useUserStore = defineStore('user', () => {
// state
const name = ref('Tom');
const age = ref(18);
const token = ref('');
// getters
const isAdult = computed(() => age.value >= 18);
const upperName = computed(() => name.value.toUpperCase());
// actions
function login(credentials: LoginDTO) {
token.value = credentials.token;
}
async function fetchUser() {
const res = await api.getUser();
name.value = res.data.name;
age.value = res.data.age;
}
return { name, age, token, isAdult, upperName, login, fetchUser };
});四、在组件中使用
4.1 直接访问
vue
<script setup lang="ts">
import { useUserStore } from '@/stores/user';
const userStore = useUserStore();
// 读取 state
console.log(userStore.name);
// 读取 getter
console.log(userStore.isAdult);
// 调用 action
userStore.login({ token: 'xxx' });
</script>
<template>
<p>{{ userStore.name }}</p>
<button @click="userStore.fetchUser">刷新</button>
</template>4.2 解构 store
typescript
import { storeToRefs } from 'pinia';
const userStore = useUserStore();
// ✅ 用 storeToRefs 保持响应式
const { name, isAdult } = storeToRefs(userStore);
// ✅ actions 可以直接解构
const { login, fetchUser } = userStore;vue
<template>
<p>{{ name }}</p>
<button @click="login">登录</button>
</template>五、State 操作
5.1 修改
typescript
const userStore = useUserStore();
// 直接赋值
userStore.name = 'Jerry';
// 批量修改
userStore.$patch({ name: 'Alice', age: 20 });
// 函数式 patch
userStore.$patch((state) => {
state.name = 'Bob';
state.age = 25;
});
// 重置
userStore.$reset();5.2 订阅
typescript
const userStore = useUserStore();
// 订阅 state 变化
userStore.$subscribe((mutation, state) => {
console.log(mutation.type); // 'direct' / 'patch object' / 'patch function'
console.log(state);
// 持久化
localStorage.setItem('user', JSON.stringify(state));
});
// 取消订阅
const unsubscribe = userStore.$subscribe(...);5.3 监听 actions
typescript
userStore.$onAction(({
name, // action 名
args, // 参数
result, // 返回值
after, // 完成后回调
onError, // 错误回调
trigger // 触发源
}) => {
console.log(`action ${name} called`);
after(() => console.log('done'));
onError((err) => console.error(err));
});六、Getters
6.1 基础
typescript
getters: {
doubleCount: (state) => state.count * 2
}6.2 互相访问
typescript
getters: {
doubleCount: (state) => state.count * 2,
doubleCountPlusOne(): number {
// this 访问其他 getter
return this.doubleCount + 1;
}
}6.3 传参
typescript
getters: {
getUserById: (state) => (id: number) => {
return state.users.find((u) => u.id === id);
}
}typescript
const userStore = useUserStore();
userStore.getUserById(123); // 返回 User | undefined七、Actions
7.1 异步
typescript
actions: {
async fetchUsers() {
const res = await api.getUsers();
this.users = res.data;
}
}7.2 调用其他 action
typescript
actions: {
async login(credentials: LoginDTO) {
const res = await api.login(credentials);
this.token = res.data.token;
// 调用其他 action
await this.fetchUser();
}
}7.3 跨 store 调用
typescript
import { useCartStore } from './cart';
actions: {
async checkout() {
const cart = useCartStore();
await cart.clear();
await api.submitOrder();
}
}八、模块化
8.1 多 store
typescript
// stores/cart.ts
import { defineStore } from 'pinia';
export const useCartStore = defineStore('cart', () => {
const items = ref<Item[]>([]);
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price, 0)
);
function addItem(item: Item) {
items.value.push(item);
}
return { items, total, addItem };
});typescript
// stores/product.ts
export const useProductStore = defineStore('product', () => {
const list = ref<Product[]>([]);
// ...
return { list };
});8.2 嵌套结构
typescript
// stores/index.ts 统一导出
export { useUserStore } from './user';
export { useCartStore } from './cart';
export { useProductStore } from './product';九、持久化
9.1 手动实现
typescript
export const useUserStore = defineStore('user', () => {
const name = ref(localStorage.getItem('user-name') || 'Tom');
watch(name, (val) => {
localStorage.setItem('user-name', val);
});
return { name };
});9.2 插件方式(pinia-plugin-persistedstate)
bash
pnpm add pinia-plugin-persistedstatetypescript
// main.ts
import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
app.use(pinia);typescript
export const useUserStore = defineStore('user', () => {
// ...
return { name, age };
}, {
persist: {
key: 'user-store',
storage: localStorage,
paths: ['name'] // 只持久化 name
}
});十、TypeScript 集成
10.1 状态类型
typescript
interface UserState {
name: string;
age: number;
token: string;
}
export const useUserStore = defineStore('user', {
state: (): UserState => ({
name: 'Tom',
age: 18,
token: ''
}),
// ...
});10.2 Setup Store 自动推断
typescript
export const useUserStore = defineStore('user', () => {
const name = ref('Tom'); // Ref<string>
const age = ref(18); // Ref<number>
const login = (c: LoginDTO) => {}; // (c: LoginDTO) => void
return { name, age, login };
});
const s = useUserStore();
s.name; // ✅ string
s.age; // ✅ number
s.login; // ✅ (c: LoginDTO) => void十一、DevTools 集成
Pinia 自动集成 Vue DevTools:
- 查看 store 列表
- 时间旅行调试
- state diff
- action 调用追踪
typescript
// 开启/关闭
const pinia = createPinia();
// 默认开启十二、测试
12.1 单元测试
typescript
import { setActivePinia, createPinia } from 'pinia';
import { useUserStore } from '@/stores/user';
describe('user store', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it('login', () => {
const store = useUserStore();
store.login({ token: 'xxx' });
expect(store.token).toBe('xxx');
});
it('isAdult', () => {
const store = useUserStore();
store.age = 20;
expect(store.isAdult).toBe(true);
});
});12.2 Mock
typescript
import { vi } from 'vitest';
import * as api from '@/api/user';
vi.spyOn(api, 'getUser').mockResolvedValue({ data: { name: 'Mock', age: 30 } });
const store = useUserStore();
await store.fetchUser();
expect(store.name).toBe('Mock');十三、常见模式
13.1 重置
typescript
// 单个 store
userStore.$reset();
// 全部 store
import { getActivePinia } from 'pinia';
const pinia = getActivePinia();
pinia._s.forEach((store) => store.$reset());13.2 异步初始化
typescript
export const useUserStore = defineStore('user', () => {
const name = ref('');
const loading = ref(false);
async function init() {
if (name.value) return;
loading.value = true;
const res = await api.getUser();
name.value = res.data.name;
loading.value = false;
}
return { name, loading, init };
});
// 在 App.vue 中
userStore.init();13.3 错误处理
typescript
actions: {
async fetchData() {
try {
this.loading = true;
const res = await api.getData();
this.data = res.data;
} catch (e) {
this.error = (e as Error).message;
} finally {
this.loading = false;
}
}
}十四、Pinia vs Vuex
| 维度 | Pinia | Vuex 4 |
|---|---|---|
| API 风格 | Composition | Options |
| TypeScript | 优秀 | 一般 |
| 模板代码 | 少 | 多 |
| 模块化 | 自动 | modules 配置 |
| DevTools | 支持 | 支持 |
| 体积 | ~1KB | ~10KB |
| 状态变更 | 直接修改 | mutations |
十五、本章小结
| 概念 | 关键 |
|---|---|
| Setup Store | defineStore + ref/computed/function |
| storeToRefs | 解构保持响应式 |
| $patch | 批量修改 state |
| $subscribe | 订阅 state 变化 |
| $onAction | 监听 action |
| 持久化 | 插件或手动 |
| TypeScript | 类型自动推断 |
动手练习
- 用户 store:实现登录、获取用户信息、登出
- 购物车 store:实现加减商品、计算总价、清空
- 持久化:把用户登录状态保存到 localStorage
- 跨 store:购物车结算时调用用户地址 store
- 测试:为已有 store 编写单元测试
推荐阅读
- 📖 Pinia 官方文档 — 完整指南
- 📖 Pinia 与 Vuex 对比 — 迁移指南
- 🌐 Pinia 最佳实践 — 范例
- 📖 pinia-plugin-persistedstate — 持久化插件
下一章:第 128 章:TypeScript 深度集成 →