Pinia 状态管理深度详解
Pinia 状态管理深度详解
Pinia 是 Vue.js 的官方状态管理库,专为 Vue 3 设计。相比 Vuex,Pinia 更轻量、TypeScript 支持更好、API 更简洁。本文详细讲解 Pinia 的核心概念、高级用法和最佳实践。
零、为什么需要状态管理
在深入 Pinia 之前,先要理解它到底解决什么问题。Vue 组件之间的通信天然有三种方式:父传子用 props、子传父用 emit、跨层级用 provide/inject。但当应用变复杂后,这些方式会遇到瓶颈。
想象一个电商应用:顶部导航栏要显示购物车数量,商品列表页要往购物车加商品,购物车页要读取和修改购物车,结算页又要读取购物车总价。这四个组件分散在组件树的不同分支上,如果用 props/emit 传递购物车数据,就要层层透传,代码会变成一团乱麻。这种现象叫 Prop Drilling(属性逐层钻透)。
状态管理库的思路是:把这种"多个组件都要访问的共享状态"抽离到一个独立的中央仓库(Store)里,任何组件都能直接读写,无需层层传递。这就像公司把公共文件放进"中央档案室",而不是让文件在员工之间手手相传。
什么时候该用 Pinia,什么时候不用?
| 场景 | 建议 |
|------|------|
| 多个不相关组件共享状态(用户信息、购物车、主题) | 用 Pinia |
| 需要跨路由持久保存的状态 | 用 Pinia |
| 仅父子组件通信 | 用 props/emit 即可 |
| 仅局部组件树共享(如表单向导) | provide/inject 即可 |
一、Pinia 简介
1. Pinia vs Vuex
Vuex 是 Vue 2 时代的官方状态管理方案,但它引入了 mutations 这一层"仪式感"很强的概念——修改状态必须先 commit 一个 mutation,异步操作还要经过 action 再 commit,链路较长。Pinia 移除了 mutations,让 API 大幅简化。
| 特性 | Vuex 4 | Pinia |
|------|--------|-------|
| 体积 | 较大(约 9.6KB) | 更小(约 1.5KB,gzip 约 1KB) |
| mutations | 必需 | 已移除 |
| actions | 支持 | 支持(同步/异步都可) |
| getters | 支持 | 支持 |
| modules | 嵌套模块 + 命名空间 | 扁平化多 store,天然隔离 |
| TypeScript | 支持一般,需手写类型 | 支持优秀,自动推导 |
| 组合式 API | 支持有限 | 原生支持(Setup Store) |
| Devtools | 支持 | 支持更好,时间旅行 |
| 热更新 | 支持 | 支持 |
Vuex 官方已停止新特性开发,并推荐新项目直接使用 Pinia。可以认为 Pinia 就是"Vuex 5"的正式形态。
2. 安装与配置
安装:
npm install pinia在 Vue 3 中注册:
// 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');在 Nuxt 3 中使用:
npm install @pinia/nuxt// nuxt.config.js
export default defineNuxtConfig({
modules: ['@pinia/nuxt']
});二、Pinia 核心概念
Pinia 的一个 Store 对标 Vue 组件的三大要素,理解这个映射关系就掌握了 Pinia 的核心:
| Pinia | Vue 组件 | 说明 |
|-------|----------|------|
| state | data | 响应式状态数据 |
| getters | computed | 派生状态,带缓存 |
| actions | methods | 修改状态、业务逻辑(可异步) |
1. 定义 Store
使用 defineStore 定义:
// stores/counter.js
import { defineStore } from 'pinia';
// 第一个参数 'counter' 是全局唯一 ID,用于 Devtools 与数据隔离
export const useCounterStore = defineStore('counter', {
// state:状态(必须是返回对象的箭头函数,保证每个实例独立)
state: () => ({
count: 0,
name: 'Pinia'
}),
// getters:计算属性
getters: {
doubleCount: (state) => state.count * 2,
upperName: (state) => state.name.toUpperCase()
},
// actions:方法
actions: {
increment() {
this.count++;
},
incrementBy(amount) {
this.count += amount;
}
}
});为什么 state 必须是函数? 因为 SSR(服务端渲染)场景下,如果 state 是一个共享对象,多个用户的请求会共用同一份状态,造成数据串号。写成函数后,每次调用都返回一份全新的状态,互不干扰。
2. 在组件中使用 Store
<script setup>
import { useCounterStore } from '@/stores/counter';
const counterStore = useCounterStore();
// 访问 state
console.log(counterStore.count);
// 访问 getters
console.log(counterStore.doubleCount);
// 调用 actions
counterStore.increment();
counterStore.incrementBy(5);
</script>
<template>
<div>
<p>Count: {{ counterStore.count }}</p>
<p>Double: {{ counterStore.doubleCount }}</p>
<button @click="counterStore.increment">+1</button>
<button @click="counterStore.incrementBy(5)">+5</button>
</div>
</template>三、State 状态管理
1. 访问 State
方式 1:直接访问
const store = useCounterStore();
console.log(store.count);方式 2:使用 storeToRefs(保持响应式)
import { storeToRefs } from 'pinia';
const store = useCounterStore();
// storeToRefs 会把 state 和 getters 转成 ref,解构后仍保持响应式
const { count, name } = storeToRefs(store);
console.log(count.value); // 在 <script> 中用 .value,模板中直接用 count方式 3:直接解构(❌ 失去响应式)
const store = useCounterStore();
const { count } = store;
// count 只是一次性拷贝的普通值,store.count 之后变化,这里的 count 不会更新为什么直接解构会失去响应式? 因为 store 是一个 reactive 对象,解构相当于把响应式对象的某个属性值取出来赋给普通变量,切断了与响应式系统的连接。storeToRefs 的作用就是给每个属性包一层 ref,保住这个连接。
2. 修改 State
方式 1:直接修改
store.count = 10;方式 2:使用 $patch(批量修改,性能更优)
// 修改多个字段:Pinia 会合并成一次更新,只触发一次订阅
store.$patch({
count: 10,
name: 'New Name'
});
// 函数形式:适合数组操作或依赖旧值的复杂修改
store.$patch((state) => {
state.count++;
state.items.push({ id: 1 });
});方式 3:使用 actions(推荐,逻辑集中)
store.increment();$patch 与多次直接赋值的区别:连续多次直接赋值会触发多次 $subscribe 回调;而 $patch 合并成一次,在做持久化、日志埋点时能显著减少开销。
3. 重置 State
// Options Store 自带 $reset,恢复到 state() 定义的初始值
store.$reset();
// 订阅 state 变化
const unsubscribe = store.$subscribe((mutation, state) => {
console.log('变更类型:', mutation.type);
console.log('最新状态:', state);
});
unsubscribe(); // 组件卸载时可手动取消(默认会随组件自动清理)四、Getters 计算属性
1. 基本 Getters
export const useUserStore = defineStore('user', {
state: () => ({
firstName: 'John',
lastName: 'Doe',
age: 25
}),
getters: {
// 通过 state 参数访问状态
fullName: (state) => state.firstName + ' ' + state.lastName,
// 通过 this 访问其他 getter(注意:此时不能用箭头函数)
fullInfo() {
return this.fullName + ', ' + this.age + ' years old';
}
}
});2. 带参数的 Getters
Getter 本身带缓存,但当它返回一个函数时,这个函数每次调用都会执行(相当于放弃缓存),适合"按 id 查找"这种需要外部参数的场景。
export const useProductStore = defineStore('product', {
state: () => ({
products: [
{ id: 1, name: 'Product A', price: 100 },
{ id: 2, name: 'Product B', price: 200 }
]
}),
getters: {
// 返回函数的 getter(无缓存)
getProductById: (state) => (id) => {
return state.products.find((p) => p.id === id);
},
getProductsByPrice: (state) => (maxPrice) => {
return state.products.filter((p) => p.price <= maxPrice);
}
}
});
const productStore = useProductStore();
const product = productStore.getProductById(1);
const cheapProducts = productStore.getProductsByPrice(150);3. 访问其他 Store 的 Getters
export const useCartStore = defineStore('cart', {
state: () => ({ items: [] }),
getters: {
total: (state) => {
// 在 getter 内部可以直接调用其他 store
const userStore = useUserStore();
const discount = userStore.isVip ? 0.9 : 1;
return state.items.reduce((sum, item) => sum + item.price, 0) * discount;
}
}
});五、Actions 动作
1. 基本 Actions
Actions 是修改状态和处理业务逻辑的地方,既能同步也能异步。相比 Vuex,Pinia 不再区分 mutation 和 action,一律用 action。
export const useUserStore = defineStore('user', {
state: () => ({
user: null,
token: '',
loading: false
}),
actions: {
// 同步 action
setUser(user) {
this.user = user;
},
// 异步 action
async login(credentials) {
this.loading = true;
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(credentials)
});
const data = await response.json();
this.user = data.user;
this.token = data.token;
localStorage.setItem('token', data.token);
this.loading = false;
},
logout() {
this.user = null;
this.token = '';
localStorage.removeItem('token');
}
}
});2. 组合调用 Actions
actions: {
async loginAndFetchProfile(credentials) {
// 调用当前 store 的其他 action
await this.login(credentials);
// 调用其他 store 的 action
const profileStore = useProfileStore();
await profileStore.fetchProfile();
}
}3. Action 错误处理
actions: {
async login(credentials) {
try {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(credentials)
});
if (!response.ok) throw new Error('Login failed');
const data = await response.json();
this.user = data.user;
this.token = data.token;
} catch (error) {
console.error('Login error:', error);
throw error; // 继续抛出,让组件层决定如何提示用户
}
}
}4. 监听 Action($onAction)
$onAction 可以统一拦截所有 action 调用,非常适合做日志、埋点、耗时统计、错误上报。
const store = useUserStore();
store.$onAction(({ name, args, after, onError }) => {
const start = Date.now();
after((result) => {
console.log(`[action] ${name} 成功,耗时 ${Date.now() - start}ms`);
});
onError((error) => {
// 统一错误上报
reportError({ action: name, args, error });
});
});六、Store 组合
1. 在 Store 中使用其他 Store
// stores/cart.js
import { defineStore } from 'pinia';
import { useUserStore } from './user';
import { useProductStore } from './product';
export const useCartStore = defineStore('cart', {
state: () => ({ items: [] }),
getters: {
total() {
const userStore = useUserStore();
const productStore = useProductStore();
const subtotal = this.items.reduce((sum, item) => {
const product = productStore.getProductById(item.productId);
return sum + (product?.price || 0) * item.quantity;
}, 0);
return userStore.isVip ? subtotal * 0.9 : subtotal;
}
}
});2. 在组件中组合多个 Store
<script setup>
import { useUserStore } from '@/stores/user';
import { useCartStore } from '@/stores/cart';
import { useProductStore } from '@/stores/product';
const userStore = useUserStore();
const cartStore = useCartStore();
const productStore = useProductStore();
// 真实场景:未登录先登录,再加购
const buyProduct = async (productId) => {
if (!userStore.user) {
await userStore.login({ username: 'guest', password: 'guest' });
}
const product = productStore.getProductById(productId);
cartStore.addItem({ productId, quantity: 1 });
};
</script>七、持久化存储
Pinia 的状态默认存在内存中,刷新页面就会丢失。登录 token、用户偏好、购物车等需要跨刷新保留的数据必须做持久化。
1. 使用插件持久化
安装插件:
npm install pinia-plugin-persistedstate配置插件:
// main.js
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
const app = createApp(App);
app.use(pinia);在 Store 中使用:
export const useUserStore = defineStore('user', {
state: () => ({ user: null, token: '' }),
persist: {
key: 'user-store', // localStorage 键名
storage: localStorage, // 也可用 sessionStorage
paths: ['token'] // 只持久化 token,避免存整个 user 对象
}
});2. 手动持久化(理解原理)
export const useUserStore = defineStore('user', {
state: () => ({ user: null, token: '' }),
actions: {
login(credentials) {
this.token = 'new-token';
localStorage.setItem('token', this.token); // 写入
},
init() {
const token = localStorage.getItem('token'); // 恢复
if (token) this.token = token;
}
}
});持久化安全提示:不要把密码、完整用户信息等敏感数据无脑存进 localStorage(易被 XSS 读取)。建议只存必要的 token,并考虑用 sessionStorage 或加密。
八、编写自定义插件
Pinia 的插件机制非常强大,一个插件就是一个接收 context 的函数,可以给所有 store 注入属性、订阅变化等。
// plugins/logger.js
export function loggerPlugin({ store, options }) {
// 给每个 store 注入一个公共属性
store.createdAt = new Date();
// 订阅每个 store 的状态变化
store.$subscribe((mutation, state) => {
console.log(`[${store.$id}] 状态变化:`, mutation.type);
});
// 也可以返回对象来添加属性(会被 Devtools 追踪)
return { secret: 'shared-value' };
}
// main.js
pinia.use(loggerPlugin);九、TypeScript 支持
1. 类型定义
// stores/user.ts
import { defineStore } from 'pinia';
interface User {
id: number;
name: string;
email: string;
}
interface UserState {
user: User | null;
token: string;
}
export const useUserStore = defineStore('user', {
state: (): UserState => ({
user: null,
token: ''
}),
getters: {
isLoggedIn: (state): boolean => !!state.token,
userName: (state): string => state.user?.name ?? 'Guest'
},
actions: {
async login(credentials: { username: string; password: string }) {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(credentials)
});
const data: { user: User; token: string } = await response.json();
this.user = data.user;
this.token = data.token;
}
}
});2. Setup Store 语法
Setup Store 用组合式 API 的写法定义 store,写法更自由,也更容易复用其他组合函数。缺点是不支持 $reset(需自行实现)。
// stores/counter.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useCounterStore = defineStore('counter', () => {
// ref → state
const count = ref(0);
const name = ref('Pinia');
// computed → getters
const doubleCount = computed(() => count.value * 2);
const upperName = computed(() => name.value.toUpperCase());
// function → actions
function increment() {
count.value++;
}
function incrementBy(amount: number) {
count.value += amount;
}
function reset() {
count.value = 0; // Setup Store 需手动实现 reset
}
// 必须 return 需要暴露的内容,否则外部访问不到
return { count, name, doubleCount, upperName, increment, incrementBy, reset };
});Options Store 与 Setup Store 如何选?
| 维度 | Options Store | Setup Store |
|------|---------------|-------------|
| 写法 | 对象配置,结构清晰 | 函数,自由灵活 |
| $reset | 自带支持 | 需手动实现 |
| 复用组合函数 | 不便 | 方便(可直接调 useXxx) |
| 上手难度 | 低 | 中(需熟悉组合式 API) |
| 推荐场景 | 简单业务、团队新手多 | 复杂逻辑、需复用 composable |
十、真实案例:完整的用户认证模块
下面综合演示一个贴近生产的用户认证 store,包含登录、自动续期、持久化、错误处理。
// stores/auth.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
interface User {
id: number;
name: string;
roles: string[];
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null);
const token = ref('');
const loading = ref(false);
const isLoggedIn = computed(() => !!token.value);
const isAdmin = computed(() => user.value?.roles.includes('admin') ?? false);
async function login(username: string, password: string) {
loading.value = true;
try {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ username, password })
});
if (!res.ok) throw new Error('用户名或密码错误');
const data = await res.json();
user.value = data.user;
token.value = data.token;
localStorage.setItem('token', data.token);
} finally {
loading.value = false;
}
}
function logout() {
user.value = null;
token.value = '';
localStorage.removeItem('token');
}
// 应用启动时恢复登录态
async function restore() {
const saved = localStorage.getItem('token');
if (!saved) return;
token.value = saved;
const res = await fetch('/api/me', {
headers: { Authorization: `Bearer ${saved}` }
});
if (res.ok) {
user.value = await res.json();
} else {
logout(); // token 失效则清理
}
}
return { user, token, loading, isLoggedIn, isAdmin, login, logout, restore };
});十一、常见坑
| 坑 | 现象 | 解决 |
|----|------|------|
| 直接解构 store | 数据不再更新 | 用 storeToRefs 包裹 state/getters |
| state 写成对象而非函数 | SSR 下多用户数据串号 | state 必须是返回对象的函数 |
| getter 用箭头函数还想用 this | this 指向错误 | 需访问 this 时用普通函数 |
| Setup Store 调 $reset | 报错方法不存在 | 自行实现 reset |
| 在 setup 外过早调用 useStore | 找不到 pinia 实例 | 确保 pinia 已注册后再调用 |
| 持久化整个敏感 user | XSS 泄露风险 | 用 paths 只存必要字段 |
十二、最佳实践
推荐目录结构:
stores/
├── user.ts # 用户相关状态
├── cart.ts # 购物车相关状态
├── product.ts # 产品相关状态
├── settings.ts # 设置相关状态
└── index.ts # 统一导出十三、Pinia 响应式原理深挖
理解 Pinia 的底层实现,能帮你写出更符合其设计的代码,也能在遇到「数据不更新」「解构失去响应式」等问题时快速定位根因。
1. defineStore 到底返回了什么
`defineStore` 并不直接创建 store,而是返回一个「useStore 函数」。只有在组件里调用 `useStore()` 时才真正初始化(懒加载)。这就是为什么必须在 setup 或 pinia 注册之后才能调用。
// 简化版 defineStore 原理示意
function defineStore(id, setup) {
function useStore() {
// 从当前 pinia 实例的缓存里取,取不到才创建
const pinia = getActivePinia();
if (!pinia._stores.has(id)) {
createSetupStore(id, setup, pinia); // 首次调用才创建
}
return pinia._stores.get(id);
}
useStore.$id = id;
return useStore;
}关键结论: 同一个 store 在整个应用内是单例的。不同组件调用 `useUserStore()` 拿到的是同一个实例,这正是跨组件共享状态的基础。
2. effectScope:Pinia 管理响应式的核心
Pinia 内部用 Vue 3 的 `effectScope` API 把每个 store 的所有响应式副作用(computed、watch)收集在一个作用域里,store 被销毁时可一次性清理,避免内存泄漏。
import { effectScope, ref, computed } from 'vue';
// effectScope 的作用演示
const scope = effectScope();
scope.run(() => {
const count = ref(0);
// 这个 computed 归属于 scope
const double = computed(() => count.value * 2);
// watch 也归属于 scope
watch(count, () => console.log('changed'));
});
// 一行清理作用域内所有副作用
scope.stop();这解释了为什么 Setup Store 里可以直接写 `computed`、`watch` 而无需手动清理——Pinia 帮你管理了它们的生命周期。
3. 为什么解构会失去响应式
Pinia 的 state 本质是一个 `reactive` 对象。解构 reactive 对象会得到「值的快照」,切断与源对象的响应式连接。
import { reactive, toRefs } from 'vue';
const state = reactive({ count: 0, name: '张三' });
// 错误:解构后 count 只是数字 0,不再响应式
const { count } = state;
// count 永远是 0,state.count 变了它也不变
// 正确:toRefs 把每个属性转成 ref,保持响应式连接
const { count: countRef } = toRefs(state);
// countRef.value 会随 state.count 变化
// storeToRefs 就是 Pinia 版的 toRefs,且会跳过 actions(方法不需要转 ref)// storeToRefs 简化原理
function storeToRefs(store) {
const refs = {};
for (const key in store) {
const value = store[key];
// 只对 state 和 getters 转 ref,跳过函数(actions)
if (isRef(value) || isReactive(value)) {
refs[key] = toRef(store, key);
}
}
return refs;
}十四、Options Store 与 Setup Store 深度对比
Pinia 提供两种定义 store 的写法,理解它们的差异和转换关系,能让你在不同场景下做出最优选择。
1. 两种写法的完整对照
// 写法一:Options Store(类似 Vuex/Options API,直观)
import { defineStore } from 'pinia';
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
history: []
}),
getters: {
double: (state) => state.count * 2,
// 访问其他 getter 用 this
doublePlusOne() {
return this.double + 1;
}
},
actions: {
increment() {
this.count++;
this.history.push(this.count);
},
async fetchAndAdd() {
const res = await fetch('/api/num').then((r) => r.json());
this.count += res.value;
}
}
});// 写法二:Setup Store(类似 Composition API,灵活)
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useCounterStore = defineStore('counter', () => {
// ref/reactive → state
const count = ref(0);
const history = ref([]);
// computed → getters
const double = computed(() => count.value * 2);
const doublePlusOne = computed(() => double.value + 1);
// function → actions
function increment() {
count.value++;
history.value.push(count.value);
}
async function fetchAndAdd() {
const res = await fetch('/api/num').then((r) => r.json());
count.value += res.value;
}
// 必须显式返回所有要暴露的内容
return { count, history, double, doublePlusOne, increment, fetchAndAdd };
});2. 两种写法能力对比
| 维度 | Options Store | Setup Store |
|------|---------------|-------------|
| 心智模型 | 贴近 Vuex,迁移友好 | 贴近 Composition API |
| state 定义 | state 函数返回对象 | ref/reactive |
| getters | getters 对象 | computed |
| actions | actions 对象 | 普通函数 |
| this 用法 | 依赖 this 访问 | 无 this,直接访问变量 |
| 使用 Composable | 不方便 | 可直接调用 useXxx |
| 私有状态 | 不支持 | 支持(不 return 即私有) |
| watch/生命周期 | 不便 | 可直接用 watch |
| 灵活性 | 中 | 高 |
| $reset | 内置支持 | 需手动实现 |
3. Setup Store 的独有能力:私有状态
export const useSearchStore = defineStore('search', () => {
const results = ref([]);
// 私有状态:不 return,外部无法访问,仅内部逻辑使用
const _cache = new Map();
let _requestId = 0;
async function search(keyword) {
if (_cache.has(keyword)) {
results.value = _cache.get(keyword);
return;
}
const currentId = ++_requestId;
const res = await fetch(`/api/search?q=${keyword}`).then((r) => r.json());
// 防止竞态:只接受最新请求的结果
if (currentId === _requestId) {
results.value = res;
_cache.set(keyword, res);
}
}
// 只暴露 results 和 search,_cache 和 _requestId 对外隐藏
return { results, search };
});4. 为 Setup Store 手动实现 $reset
Setup Store 默认没有 `$reset`,可以自己封装。
export const useFormStore = defineStore('form', () => {
const getInitialState = () => ({ name: '', email: '', age: 0 });
const form = ref(getInitialState());
function $reset() {
form.value = getInitialState();
}
return { form, $reset };
});十五、Store 实例 API 全解
每个 store 实例都挂载了一批以 `$` 开头的方法和属性,掌握它们能实现批量更新、状态订阅、动作拦截等高级能力。
1. $patch:批量更新状态
多个字段同时改时,用 `$patch` 比逐个赋值更高效——它只触发一次响应式更新和一次 Devtools 记录。
const store = useUserStore();
// 方式一:传对象(适合简单字段更新)
store.$patch({
name: '李四',
age: 30,
isVip: true
});
// 方式二:传函数(适合数组操作、依赖旧值的复杂更新)
store.$patch((state) => {
state.items.push({ id: 1, name: '商品' });
state.total = state.items.reduce((sum, i) => sum + i.price, 0);
state.count++;
});性能对比: 连续 5 次单独赋值会触发 5 次更新通知,而一次 `$patch` 只触发 1 次,在高频更新场景(如批量导入)性能差异明显。
2. $subscribe:订阅状态变化
监听整个 store 的 state 变化,常用于持久化、日志、数据同步。
const cartStore = useCartStore();
// 订阅 state 变化
const unsubscribe = cartStore.$subscribe((mutation, state) => {
// mutation.type: 'direct'(直接改) | 'patch object' | 'patch function'
// mutation.storeId: store 的 id
console.log('变更类型:', mutation.type);
console.log('store:', mutation.storeId);
// 典型用途:自动持久化到 localStorage
localStorage.setItem('cart', JSON.stringify(state.items));
}, {
detached: true // 组件卸载后仍保持订阅(默认组件卸载会自动取消)
});
// 手动取消订阅
// unsubscribe();3. $onAction:拦截 action 调用
在 action 执行前后插入逻辑,实现日志、错误上报、性能统计等横切关注点。
const store = useUserStore();
store.$onAction(({
name, // action 名称
store, // store 实例
args, // 传给 action 的参数
after, // 钩子:action 成功返回后(Promise 也会 resolve 后)
onError // 钩子:action 抛错时
}) => {
const startTime = Date.now();
console.log(`开始执行 action: ${name},参数:`, args);
after((result) => {
console.log(`${name} 执行成功,耗时 ${Date.now() - startTime}ms,返回:`, result);
});
onError((error) => {
console.error(`${name} 执行失败:`, error);
// 可在此统一上报错误
});
});4. $reset 与 $dispose
const store = useCounterStore();
// $reset:把 state 重置为初始值(仅 Options Store 内置)
store.$reset();
// $state:直接读取或整体替换整个 state
console.log(store.$state);
store.$state = { count: 100, name: '重置' }; // 整体替换
// $dispose:停止 store 的响应式作用域并从 pinia 中移除
store.$dispose();5. Store 实例 API 速查
| API | 作用 | 常见场景 |
|-----|------|----------|
| $patch | 批量更新 state | 多字段同时改、数组操作 |
| $subscribe | 订阅 state 变化 | 持久化、日志、同步 |
| $onAction | 拦截 action | 埋点、错误上报、性能统计 |
| $reset | 重置 state | 表单清空、退出登录 |
| $state | 读取/替换整个 state | 快照、整体重置 |
| $dispose | 销毁 store | 动态 store 清理 |
十六、从 Vuex 迁移到 Pinia 实战
许多老项目仍在使用 Vuex。Pinia 作为官方推荐的继任者,迁移过程有清晰的对应关系。
1. 核心概念映射
| Vuex 概念 | Pinia 对应 | 说明 |
|-----------|-----------|------|
| state | state | 基本一致 |
| getters | getters | 基本一致 |
| mutations | 删除 | Pinia 无 mutation,直接改或用 action |
| actions | actions | 可同步可异步,不再区分 |
| modules | 多个独立 store | 扁平化,无嵌套命名空间 |
| namespaced | 不需要 | 每个 store 天然独立 |
2. 迁移前后代码对比
// 迁移前:Vuex
const store = {
namespaced: true,
state: () => ({ count: 0 }),
getters: {
double: (state) => state.count * 2
},
mutations: {
// Vuex 必须通过 mutation 改 state
INCREMENT(state, payload) {
state.count += payload;
}
},
actions: {
async fetchAndAdd({ commit }) {
const res = await api.getNum();
commit('INCREMENT', res.value); // 提交 mutation
}
}
};
// 组件里:this.$store.commit('module/INCREMENT', 1)
// this.$store.dispatch('module/fetchAndAdd')// 迁移后:Pinia(省去了 mutation 这层样板代码)
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
getters: {
double: (state) => state.count * 2
},
actions: {
increment(payload) {
this.count += payload; // 直接改,无需 mutation
},
async fetchAndAdd() {
const res = await api.getNum();
this.increment(res.value); // 直接调用其他 action
}
}
});
// 组件里:const store = useCounterStore();
// store.increment(1); store.fetchAndAdd();3. 迁移收益
| 维度 | Vuex | Pinia |
|------|------|-------|
| 样板代码 | 多(mutation 层) | 少(直接改 state) |
| TypeScript 支持 | 弱,需大量手写类型 | 强,自动推导 |
| 包体积 | 约 10 KB | 约 1.3 KB |
| 模块化 | 嵌套 + namespaced | 扁平独立 store |
| Devtools | 支持 | 支持且更强 |
| 代码提示 | 字符串 type,无提示 | 方法调用,全提示 |
十七、Pinia 与 TypeScript 高级技巧
Pinia 是为 TypeScript 而生的,类型推导几乎零配置。但在大型项目中,掌握一些高级类型技巧能进一步提升开发体验。
1. Options Store 的类型定义
Options Store 的类型基本自动推导,只需在 state 里给初始值即可。对于初始为 null 的字段,需要显式标注。
import { defineStore } from 'pinia';
interface UserInfo {
id: number;
name: string;
roles: string[];
}
export const useUserStore = defineStore('user', {
state: () => ({
// 初始为 null 的字段必须断言类型,否则被推断为 null
userInfo: null as UserInfo | null,
token: '',
// 空数组要标注元素类型
permissions: [] as string[]
}),
getters: {
// 返回值类型自动推导为 boolean
isLoggedIn: (state) => !!state.token,
// 复杂返回值可显式标注
userName(state): string {
return state.userInfo?.name ?? '游客';
}
},
actions: {
setUser(info: UserInfo) {
this.userInfo = info;
this.permissions = info.roles;
}
}
});2. Setup Store 的类型定义
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { Ref } from 'vue';
interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
}
export const useCartStore = defineStore('cart', () => {
// 显式标注 ref 泛型
const items: Ref<CartItem[]> = ref([]);
const totalPrice = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
const totalCount = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
);
function addItem(item: Omit<CartItem, 'quantity'>) {
const existing = items.value.find((i) => i.id === item.id);
if (existing) {
existing.quantity++;
} else {
items.value.push({ ...item, quantity: 1 });
}
}
function removeItem(id: number) {
items.value = items.value.filter((i) => i.id !== id);
}
return { items, totalPrice, totalCount, addItem, removeItem };
});3. 提取 Store 类型用于函数参数
有时需要把 store 作为参数传给工具函数,可用 `ReturnType` 提取类型。
import type { useCartStore } from '@/stores/cart';
// 提取 store 实例的类型
type CartStore = ReturnType<typeof useCartStore>;
// 工具函数接收 store 实例
function logCart(store: CartStore) {
console.log(`购物车共 ${store.totalCount} 件,总价 ${store.totalPrice} 元`);
}4. 给插件扩展的属性添加类型
自定义插件给 store 注入属性时,需要通过声明合并让 TypeScript 识别。
import 'pinia';
declare module 'pinia' {
// 扩展 store 实例属性
export interface PiniaCustomProperties {
$now: number; // 插件注入的属性
$router: Router;
}
// 扩展 store 的 options(用于自定义 option)
export interface DefineStoreOptionsBase<S, Store> {
debounce?: Partial<Record<keyof Store, number>>;
}
}十八、SSR 与 Nuxt 中的 Pinia
服务端渲染(SSR)场景下,状态管理有特殊考量:每个请求必须有独立的 store 实例,避免多用户数据串号;服务端的状态需要「脱水/注水」到客户端。
1. 为什么 SSR 下 state 必须是函数
// 错误:state 是对象,所有请求共享同一份,多用户数据串号
// state: { count: 0 }
// 正确:state 是函数,每次调用返回全新对象,请求间隔离
export const useStore = defineStore('main', {
state: () => ({ count: 0 }) // 每个请求独立
});2. Nuxt 3 中使用 Pinia
# 安装 Nuxt 模块
npm install @pinia/nuxt pinia// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@pinia/nuxt'],
pinia: {
// 自动导入 storeToRefs 等
storesDirs: ['./stores/**']
}
});<!-- Nuxt 页面中使用(无需手动 import useStore,自动导入) -->
<script setup>
const store = useCounterStore();
// useAsyncData 在服务端预取数据,状态自动脱水注水到客户端
await useAsyncData('user', async () => {
await store.fetchUserInfo();
return store.userInfo;
});
</script>3. 状态脱水与注水原理
// 服务端:渲染完成后把 pinia 状态序列化注入 HTML
// window.__pinia = pinia.state.value
// 客户端:初始化时用服务端状态填充,避免二次请求和闪烁
const pinia = createPinia();
if (window.__pinia) {
pinia.state.value = window.__pinia; // 注水
}4. SSR 注意事项
| 事项 | 说明 |
|------|------|
| state 必须是函数 | 保证请求间状态隔离 |
| 避免在模块顶层调 useStore | 拿不到当前请求的 pinia 实例 |
| 慎用 localStorage | 服务端没有 window/localStorage |
| 持久化插件需适配 | 服务端跳过、客户端恢复 |
十九、测试 Pinia Store
Store 承载核心业务逻辑,必须有测试覆盖。Pinia 官方提供了对测试友好的支持。
1. 测试基础配置
// counter.spec.js
import { describe, it, expect, beforeEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { useCounterStore } from '@/stores/counter';
describe('Counter Store', () => {
beforeEach(() => {
// 每个测试前创建全新 pinia,保证测试隔离
setActivePinia(createPinia());
});
it('初始 count 为 0', () => {
const store = useCounterStore();
expect(store.count).toBe(0);
});
it('increment 使 count 加一', () => {
const store = useCounterStore();
store.increment();
expect(store.count).toBe(1);
});
it('double getter 返回两倍', () => {
const store = useCounterStore();
store.count = 5;
expect(store.double).toBe(10);
});
});2. 测试异步 action
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { useUserStore } from '@/stores/user';
import * as authApi from '@/api/auth';
describe('User Store 登录', () => {
beforeEach(() => setActivePinia(createPinia()));
it('login 成功后保存 token 和用户信息', async () => {
// Mock API
vi.spyOn(authApi, 'login').mockResolvedValue({ token: 'abc123' });
vi.spyOn(authApi, 'getUserInfo').mockResolvedValue({
id: 1, name: '张三', roles: ['admin']
});
const store = useUserStore();
await store.login({ username: 'admin', password: '123456' });
expect(store.token).toBe('abc123');
expect(store.userInfo.name).toBe('张三');
expect(store.isLoggedIn).toBe(true);
});
});3. 在组件测试中 Mock Store
用 `@pinia/testing` 提供的 `createTestingPinia`,可以在组件测试里自动 mock 所有 action。
import { mount } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import { vi } from 'vitest';
import UserProfile from '@/components/UserProfile.vue';
import { useUserStore } from '@/stores/user';
it('渲染用户名并能触发登出', async () => {
const wrapper = mount(UserProfile, {
global: {
plugins: [createTestingPinia({
createSpy: vi.fn, // action 自动被 spy
initialState: {
user: { userInfo: { name: '李四' } } // 预设初始状态
}
})]
}
});
expect(wrapper.text()).toContain('李四');
const store = useUserStore();
await wrapper.find('button.logout').trigger('click');
// 断言 action 被调用(默认不会真正执行)
expect(store.logout).toHaveBeenCalledTimes(1);
});二十、Pinia 插件进阶
除了持久化,插件机制还能实现很多强大功能。一个插件就是接收 `{ store, app, pinia, options }` 的函数。
1. 给所有 store 注入通用属性
import { markRaw } from 'vue';
import router from '@/router';
// 让所有 store 都能访问 router
export function routerPlugin({ store }) {
// markRaw 避免 router 被响应式化(提升性能)
store.$router = markRaw(router);
}
// main.js: pinia.use(routerPlugin);
// 之后任意 store 内可用 this.$router.push(...)2. 自定义持久化插件(理解原理)
export function persistPlugin({ store, options }) {
// 读取 store 定义时传入的 persist 配置
const persist = options.persist;
if (!persist) return;
const key = persist.key || store.$id;
// 初始化:从 localStorage 恢复
const saved = localStorage.getItem(key);
if (saved) {
store.$patch(JSON.parse(saved));
}
// 订阅变化:自动写入
store.$subscribe((mutation, state) => {
// 支持 paths 精确控制持久化字段
let toSave = state;
if (persist.paths) {
toSave = persist.paths.reduce((obj, path) => {
obj[path] = state[path];
return obj;
}, {});
}
localStorage.setItem(key, JSON.stringify(toSave));
});
}3. 全局重置插件
import { cloneDeep } from 'lodash-es';
// 记录每个 store 的初始状态,提供全局 $resetAll
export function resetPlugin({ store }) {
// 缓存初始 state
const initialState = cloneDeep(store.$state);
store.$reset = () => {
store.$patch(cloneDeep(initialState));
};
}
// 用途:用户退出登录时,一次性重置所有 store
// 遍历所有 store 调 $reset4. action 防抖插件
结合前面扩展的 `debounce` option 类型,实现 action 自动防抖。
function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
export function debouncePlugin({ options, store }) {
if (options.debounce) {
// 用防抖版本覆盖指定的 action
return Object.keys(options.debounce).reduce((debounced, action) => {
debounced[action] = debounce(store[action], options.debounce[action]);
return debounced;
}, {});
}
}
// store 定义里:
// defineStore('search', { actions: {...}, debounce: { search: 300 } })二十一、真实案例:完整电商购物车系统
下面用 Pinia 实现一个功能完整的电商购物车,涵盖商品管理、购物车、优惠券、结算等模块,展示多 store 协作的真实架构。
1. 商品 Store
// stores/product.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { getProducts, getProductDetail } from '@/api/product';
export const useProductStore = defineStore('product', () => {
const list = ref([]);
const detail = ref(null);
const loading = ref(false);
const categoryFilter = ref('all');
// 按分类过滤的商品列表
const filteredList = computed(() => {
if (categoryFilter.value === 'all') return list.value;
return list.value.filter((p) => p.category === categoryFilter.value);
});
// 有货商品
const inStockList = computed(() =>
filteredList.value.filter((p) => p.stock > 0)
);
async function fetchList() {
loading.value = true;
try {
list.value = await getProducts();
} finally {
loading.value = false;
}
}
async function fetchDetail(id) {
detail.value = await getProductDetail(id);
}
function setCategory(category) {
categoryFilter.value = category;
}
return {
list, detail, loading, categoryFilter,
filteredList, inStockList,
fetchList, fetchDetail, setCategory
};
});2. 购物车 Store(核心)
// stores/cart.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { useCouponStore } from './coupon';
export const useCartStore = defineStore('cart', () => {
const items = ref([]);
// 商品总数
const totalCount = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
);
// 商品原价小计
const subtotal = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
// 跨 store 协作:结合优惠券计算最终价格
const finalTotal = computed(() => {
const couponStore = useCouponStore();
const discount = couponStore.calculateDiscount(subtotal.value);
return Math.max(0, subtotal.value - discount);
});
// 已选中的商品(用于结算)
const checkedItems = computed(() =>
items.value.filter((item) => item.checked)
);
const isAllChecked = computed(() =>
items.value.length > 0 && items.value.every((item) => item.checked)
);
// 添加商品(已存在则数量+1)
function addToCart(product) {
const existing = items.value.find((item) => item.id === product.id);
if (existing) {
if (existing.quantity < product.stock) {
existing.quantity++;
} else {
throw new Error('库存不足');
}
} else {
items.value.push({
id: product.id,
name: product.name,
price: product.price,
image: product.image,
stock: product.stock,
quantity: 1,
checked: true
});
}
}
// 更新数量
function updateQuantity(id, quantity) {
const item = items.value.find((i) => i.id === id);
if (!item) return;
if (quantity <= 0) {
removeFromCart(id);
} else if (quantity <= item.stock) {
item.quantity = quantity;
}
}
function removeFromCart(id) {
items.value = items.value.filter((item) => item.id !== id);
}
function toggleCheck(id) {
const item = items.value.find((i) => i.id === id);
if (item) item.checked = !item.checked;
}
function toggleCheckAll() {
const target = !isAllChecked.value;
items.value.forEach((item) => (item.checked = target));
}
function clearCart() {
items.value = [];
}
return {
items, totalCount, subtotal, finalTotal, checkedItems, isAllChecked,
addToCart, updateQuantity, removeFromCart,
toggleCheck, toggleCheckAll, clearCart
};
}, {
// 持久化购物车,刷新不丢
persist: { key: 'shop-cart', paths: ['items'] }
});3. 优惠券 Store
// stores/coupon.js
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useCouponStore = defineStore('coupon', () => {
const available = ref([]); // 可用优惠券
const selected = ref(null); // 当前选中的优惠券
// 根据金额计算优惠额(供 cart store 调用)
function calculateDiscount(amount) {
if (!selected.value) return 0;
const { type, value, threshold } = selected.value;
// 未达到使用门槛
if (amount < threshold) return 0;
if (type === 'fixed') return value; // 满减
if (type === 'percent') return amount * value; // 折扣
return 0;
}
function selectCoupon(coupon) {
selected.value = coupon;
}
return { available, selected, calculateDiscount, selectCoupon };
});4. 订单 Store(跨多个 store 协作下单)
// stores/order.js
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { createOrder } from '@/api/order';
import { useCartStore } from './cart';
import { useCouponStore } from './coupon';
import { useUserStore } from './user';
export const useOrderStore = defineStore('order', () => {
const submitting = ref(false);
const orderList = ref([]);
async function submitOrder(address) {
const cartStore = useCartStore();
const couponStore = useCouponStore();
const userStore = useUserStore();
if (!userStore.isLoggedIn) {
throw new Error('请先登录');
}
if (cartStore.checkedItems.length === 0) {
throw new Error('请选择要购买的商品');
}
submitting.value = true;
try {
const order = await createOrder({
items: cartStore.checkedItems.map((i) => ({
productId: i.id,
quantity: i.quantity
})),
couponId: couponStore.selected?.id ?? null,
totalAmount: cartStore.finalTotal,
address
});
// 下单成功后移除已购买商品
cartStore.checkedItems.forEach((item) => {
cartStore.removeFromCart(item.id);
});
couponStore.selectCoupon(null);
orderList.value.unshift(order);
return order;
} finally {
submitting.value = false;
}
}
return { submitting, orderList, submitOrder };
});5. 组件中使用
<script setup>
import { storeToRefs } from 'pinia';
import { useCartStore } from '@/stores/cart';
import { useOrderStore } from '@/stores/order';
const cartStore = useCartStore();
const orderStore = useOrderStore();
// 解构响应式数据用 storeToRefs,方法直接从 store 取
const { items, subtotal, finalTotal, isAllChecked, totalCount } = storeToRefs(cartStore);
const { submitting } = storeToRefs(orderStore);
async function handleCheckout() {
try {
const order = await orderStore.submitOrder({ city: '北京', detail: '...' });
alert(`下单成功,订单号:${order.id}`);
} catch (e) {
alert(e.message);
}
}
</script>
<template>
<div class="cart">
<div class="cart-header">
<input type="checkbox" :checked="isAllChecked" @change="cartStore.toggleCheckAll" />
全选(共 {{ totalCount }} 件)
</div>
<div v-for="item in items" :key="item.id" class="cart-item">
<input type="checkbox" :checked="item.checked" @change="cartStore.toggleCheck(item.id)" />
<img :src="item.image" :alt="item.name" />
<span>{{ item.name }}</span>
<span>¥{{ item.price }}</span>
<div class="quantity">
<button @click="cartStore.updateQuantity(item.id, item.quantity - 1)">-</button>
<span>{{ item.quantity }}</span>
<button @click="cartStore.updateQuantity(item.id, item.quantity + 1)">+</button>
</div>
<button @click="cartStore.removeFromCart(item.id)">删除</button>
</div>
<div class="cart-footer">
<span>小计:¥{{ subtotal }}</span>
<span>应付:¥{{ finalTotal }}</span>
<button :disabled="submitting" @click="handleCheckout">
{{ submitting ? '提交中...' : '去结算' }}
</button>
</div>
</div>
</template>这个案例展示了 Pinia 的核心优势:多个独立 store 各司其职(product/cart/coupon/order/user),通过在 getter/action 内互相调用实现协作,代码清晰、职责单一、易于测试和维护。
二十二、性能优化与大型应用架构
随着应用规模增长,store 的组织和性能会成为关注点。以下是大型项目的架构经验。
1. Store 拆分粒度
| 策略 | 说明 | 适用规模 |
|------|------|----------|
| 按业务模块拆 | user/cart/order 各一个 store | 中大型项目推荐 |
| 按页面拆 | 每个复杂页面一个 store | 页面逻辑重时 |
| 全局单 store | 所有状态放一起 | 仅小型 demo |
原则:一个 store 的 state 字段控制在 10~15 个以内,超过就考虑拆分。store 之间通过组合协作,而非塞进一个巨型 store。
2. 避免不必要的响应式开销
import { defineStore } from 'pinia';
import { ref, shallowRef, markRaw } from 'vue';
export const useDataStore = defineStore('data', () => {
// 大型只读数据用 shallowRef,避免深层响应式转换的性能开销
const bigTableData = shallowRef([]);
// 第三方实例(图表、地图)用 markRaw,跳过响应式代理
let chartInstance = null;
function setChart(instance) {
chartInstance = markRaw(instance);
}
// 更新 shallowRef 需整体替换才触发更新
function updateTable(newData) {
bigTableData.value = newData; // 替换而非 push
}
return { bigTableData, updateTable, setChart };
});性能数据: 对一个包含 1 万行的表格数据,用普通 `ref` 会递归代理所有对象,初始化耗时约 80ms;改用 `shallowRef` 只代理顶层,耗时降到约 3ms,提升超过 20 倍。
3. Getter 缓存特性
Getter 基于 computed,具有缓存能力:只要依赖的 state 不变,多次访问不会重复计算。
export const useStatsStore = defineStore('stats', () => {
const records = ref([]);
// 昂贵的计算,getter 会缓存,依赖不变时不重复算
const expensiveStats = computed(() => {
console.log('重新计算统计...'); // 只在 records 变化时打印
return records.value.reduce((acc, r) => {
acc.total += r.value;
acc.max = Math.max(acc.max, r.value);
return acc;
}, { total: 0, max: -Infinity });
});
return { records, expensiveStats };
});
// 组件多次读 store.expensiveStats 只计算一次4. 大型应用推荐目录结构
src/
├── stores/
│ ├── modules/
│ │ ├── user.ts # 用户认证
│ │ ├── permission.ts # 权限路由
│ │ ├── cart.ts # 购物车
│ │ ├── product.ts # 商品
│ │ └── settings.ts # 应用设置
│ ├── plugins/
│ │ ├── persist.ts # 持久化插件
│ │ └── logger.ts # 日志插件
│ └── index.ts # 创建 pinia、注册插件、统一导出
├── api/ # 接口层
└── types/ # 类型定义// stores/index.ts
import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
import { loggerPlugin } from './plugins/logger';
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
pinia.use(loggerPlugin);
export default pinia;
// 统一导出所有 store,便于按需引入
export * from './modules/user';
export * from './modules/cart';
export * from './modules/product';二十三、常见问题 FAQ
| 问题 | 原因 | 解决 |
|------|------|------|
| 解构后数据不更新 | 解构 reactive 断开连接 | 用 storeToRefs 包裹 state/getters |
| 页面刷新状态丢失 | 状态存内存 | 用持久化插件 |
| SSR 多用户数据串号 | state 写成对象 | state 必须是返回对象的函数 |
| getter 里 this 报错 | 用了箭头函数 | 需要 this 时用普通函数 |
| Setup Store 调 $reset 报错 | 未内置该方法 | 手动实现或用重置插件 |
| 模块顶层调 useStore 报错 | pinia 尚未初始化 | 在 setup 内或确保 app.use(pinia) 后调用 |
| action 里改 state 无效 | 误用了解构的变量 | 直接改 this.xxx 或 ref.value |
| Devtools 看不到 store | 未在开发环境或未注册 | 确认 pinia 已注册、开发模式运行 |
二十四、与其他状态管理方案对比
| 方案 | 包体积 | TS 支持 | 学习成本 | 心智模型 | 推荐度 |
|------|--------|---------|----------|----------|--------|
| Pinia | 约 1.3 KB | 优秀 | 低 | 三要素,直观 | Vue 3 首选 |
| Vuex 4 | 约 10 KB | 一般 | 中 | 五概念,繁琐 | 老项目维护 |
| 全局 reactive | 0(原生) | 好 | 极低 | 简单共享对象 | 极简场景 |
| 全局 Composable | 0(原生) | 好 | 低 | 函数返回状态 | 中小项目 |
| Vue I18n 等专用库 | 视库而定 | 视库 | 中 | 领域专用 | 特定需求 |
选型建议: Vue 3 新项目直接上 Pinia;需求极简(几个共享变量)可用全局 reactive 或 Composable;老项目 Vuex 可平滑迁移到 Pinia。Pinia 在体积、类型、API 简洁度、Devtools 支持上全面胜出,是绝大多数 Vue 3 项目的最优解。
二十五、Store 组合与热更新
1. 在 Store 内组合其他 Store
Setup Store 里可以直接调用其他 store,形成清晰的依赖关系。这比 Vuex 的模块嵌套直观得多。
// stores/notification.js
import { defineStore } from 'pinia';
import { ref, watch } from 'vue';
import { useUserStore } from './user';
import { useCartStore } from './cart';
export const useNotificationStore = defineStore('notification', () => {
const messages = ref([]);
const userStore = useUserStore();
const cartStore = useCartStore();
// 监听购物车变化,自动提示
watch(
() => cartStore.totalCount,
(newCount, oldCount) => {
if (newCount > oldCount) {
addMessage(`已加入购物车,当前 ${newCount} 件`);
}
}
);
// 监听登录状态
watch(
() => userStore.isLoggedIn,
(loggedIn) => {
addMessage(loggedIn ? '登录成功' : '已退出登录');
}
);
function addMessage(text) {
const id = Date.now();
messages.value.push({ id, text });
// 3 秒后自动移除
setTimeout(() => {
messages.value = messages.value.filter((m) => m.id !== id);
}, 3000);
}
return { messages, addMessage };
});2. Vite 热模块替换(HMR)
给 store 加上 HMR 支持,开发时修改 store 无需刷新整个页面,状态还能保留。
import { defineStore, acceptHMRUpdate } from 'pinia';
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
actions: {
increment() {
this.count++;
}
}
});
// 开启 HMR:修改 store 时热更新,保留当前状态
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useCounterStore, import.meta.hot));
}二十六、Devtools 调试与时间旅行
Pinia 与 Vue Devtools 深度集成,提供强大的调试能力,这是纯 Composable 方案无法比拟的优势。
1. Devtools 能做什么
| 功能 | 说明 |
|------|------|
| 状态检查 | 实时查看所有 store 的 state 和 getters |
| 状态编辑 | 直接在面板里改 state,页面即时反映 |
| 时间旅行 | 回放 state 变化历史,定位问题 |
| action 追踪 | 查看每个 action 的调用、参数、耗时 |
| 事件时间线 | 在 timeline 里看 state 变更事件 |
2. 给 action 添加调试信息
export const useUserStore = defineStore('user', () => {
const userInfo = ref(null);
async function login(credentials) {
// Devtools 会自动记录这个 action 的调用
const res = await loginApi(credentials);
userInfo.value = res.user;
// 状态变更会出现在 timeline,可回放
}
return { userInfo, login };
});3. 自定义日志插件辅助调试
// stores/plugins/logger.js
export function loggerPlugin({ store }) {
// 只在开发环境启用
if (!import.meta.env.DEV) return;
store.$subscribe((mutation, state) => {
console.group(`[${store.$id}] state 变更`);
console.log('变更类型:', mutation.type);
console.log('最新 state:', { ...state });
console.groupEnd();
});
store.$onAction(({ name, args, after, onError }) => {
console.log(`[${store.$id}] 调用 action: ${name}`, args);
after((result) => console.log(`[${store.$id}] ${name} 完成`, result));
onError((err) => console.error(`[${store.$id}] ${name} 出错`, err));
});
}调试建议: 开发阶段配合 Devtools 的时间旅行功能,遇到「状态莫名其妙变了」的 bug,可以逐步回放 state 历史,快速定位是哪个 action 或哪次变更导致的,效率远高于打印日志逐行排查。
二十七、进阶实战:认证与权限完整方案
结合前面所有知识点,实现一套生产级的认证 + 权限方案,串联 token 管理、自动刷新、权限路由、登出清理。
1. 认证 Store(含 token 自动刷新)
// stores/auth.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { login as loginApi, refreshToken as refreshApi, getUserInfo } from '@/api/auth';
export const useAuthStore = defineStore('auth', () => {
const accessToken = ref('');
const refreshTokenValue = ref('');
const userInfo = ref(null);
const roles = ref([]);
const permissions = ref([]);
let refreshTimer = null;
const isLoggedIn = computed(() => !!accessToken.value);
const isAdmin = computed(() => roles.value.includes('admin'));
async function login(credentials) {
const res = await loginApi(credentials);
accessToken.value = res.accessToken;
refreshTokenValue.value = res.refreshToken;
// 登录后拉取用户信息并启动刷新定时器
await loadUserInfo();
scheduleRefresh(res.expiresIn);
}
async function loadUserInfo() {
const info = await getUserInfo();
userInfo.value = info;
roles.value = info.roles;
permissions.value = info.permissions;
}
// 在 token 过期前自动刷新
function scheduleRefresh(expiresIn) {
clearTimeout(refreshTimer);
// 提前 1 分钟刷新
const delay = (expiresIn - 60) * 1000;
refreshTimer = setTimeout(async () => {
await doRefresh();
}, delay);
}
async function doRefresh() {
try {
const res = await refreshApi(refreshTokenValue.value);
accessToken.value = res.accessToken;
scheduleRefresh(res.expiresIn);
} catch {
logout(); // 刷新失败则登出
}
}
function hasPermission(perm) {
return permissions.value.includes(perm);
}
function logout() {
clearTimeout(refreshTimer);
accessToken.value = '';
refreshTokenValue.value = '';
userInfo.value = null;
roles.value = [];
permissions.value = [];
}
return {
accessToken, refreshTokenValue, userInfo, roles, permissions,
isLoggedIn, isAdmin,
login, loadUserInfo, doRefresh, hasPermission, logout
};
}, {
// 只持久化 token,用户信息每次重新拉取,保证最新
persist: { key: 'auth', paths: ['accessToken', 'refreshTokenValue'] }
});2. 应用启动时恢复登录态
// main.js 或 App.vue setup
import { useAuthStore } from '@/stores/auth';
async function bootstrap() {
const authStore = useAuthStore();
// 持久化恢复了 token,但用户信息需要重新拉取
if (authStore.accessToken) {
try {
await authStore.loadUserInfo();
} catch {
authStore.logout(); // token 已失效
}
}
}3. 结合路由守卫做权限控制
router.beforeEach((to) => {
const authStore = useAuthStore();
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
return { path: '/login', query: { redirect: to.fullPath } };
}
// 校验页面所需权限
if (to.meta.permission && !authStore.hasPermission(to.meta.permission)) {
return { path: '/403' };
}
return true;
});4. 方案要点小结
| 环节 | 做法 | 收益 |
|------|------|------|
| token 存储 | 持久化 access/refresh token | 刷新页面不掉登录 |
| 自动刷新 | 过期前定时刷新 | 用户无感知续期 |
| 用户信息 | 每次启动重新拉取 | 保证权限最新 |
| 权限校验 | store + 路由守卫双层 | 页面级 + 按钮级控制 |
| 登出清理 | 清 token + 清定时器 + 清状态 | 防内存泄漏和越权 |
这套方案把 Pinia 的持久化、跨 store 协作、computed 派生、action 封装、副作用管理全部用上,是 Pinia 在真实项目中最典型的综合应用。
二十八、总结
| 主题 | 关键点 |
|------|--------|
| 定位 | Vue 官方状态管理库,Vuex 的继任者 |
| 三要素 | state(data)、getters(computed)、actions(methods) |
| 两种写法 | Options Store(直观)、Setup Store(灵活) |
| 底层原理 | 单例 useStore、effectScope 管理副作用、reactive state |
| 响应式解构 | 必用 storeToRefs,直接解构会断开响应式 |
| 修改状态 | 直接赋值 / $patch 批量 / action 封装 |
| 实例 API | $patch、$subscribe、$onAction、$reset、$dispose |
| 跨 store | 在 getter/action 内直接调用其他 useXxxStore |
| 持久化 | pinia-plugin-persistedstate,用 paths 精控 |
| 扩展能力 | $subscribe、$onAction、自定义插件 |
| TypeScript | 自动推导、InjectionKey、声明合并扩展 |
| SSR | state 必须是函数、脱水/注水、Nuxt 模块 |
| 测试 | setActivePinia + createTestingPinia |
| 性能 | shallowRef、markRaw、getter 缓存、store 拆分 |
| 调试 | Devtools 时间旅行、日志插件 |
| 优势 | 体积小(约 1.3KB)、类型好、API 简洁、Devtools 强大 |
Pinia 以极简的心智模型覆盖了从简单计数器到复杂认证/电商系统的全部状态管理需求。掌握三要素、storeToRefs、持久化与插件机制,理解 effectScope 与响应式底层原理,配合 TypeScript、SSR、测试与性能优化,就能从容应对从小型项目到大型企业级应用的全部状态管理挑战。
推荐学习路径:
按这个路径循序渐进,配合本文的可运行代码逐个实践,就能真正把 Pinia 从「会用」提升到「用好」。
各阶段能力自检清单:
| 阶段 | 能独立完成 | 关键指标 |
|------|-----------|----------|
| 入门 | 定义 store 并在组件读写状态 | 理解单例、会用 storeToRefs |
| 进阶 | 封装认证/购物车模块 | 跨 store 协作、异步 action 错误处理 |
| 实战 | 搭建完整业务系统 | 持久化、权限、插件、目录规范 |
| 精通 | 主导大型项目状态架构 | SSR、测试覆盖、性能调优、原理讲解 |
最后的建议: 状态管理的本质不是「把所有数据都放进 store」,而是「让共享状态可预测、可追踪、可测试」。始终遵循单一职责拆分 store、用 action 收敛状态变更逻辑、组件只负责触发和展示,就能写出经得起长期迭代的 Pinia 代码。
常用代码片段速记:
把这些片段刻进肌肉记忆,日常开发就能行云流水。至此,Pinia 从原理到实战的完整知识体系已经建立,剩下的就是在真实项目中反复打磨、形成自己的最佳实践。