Pinia 状态管理

中等 🟡Vue 生态
6 个标签
预计阅读时间:25 分钟
VuePinia状态管理StoreVuexTypeScript

Pinia 状态管理

Pinia 是 Vue 3 官方推荐的状态管理库,替代了 Vuex,提供了更简洁的 API 和更好的 TypeScript 支持。它的作者 Eduardo San Martin Morote 同时也是 Vue Router 的核心维护者,因此 Pinia 从设计之初就与 Vue 生态深度契合。

一、什么是状态管理

1. 为什么需要状态管理

在讲 Pinia 之前,先要理解"状态管理"到底解决什么问题。可以把组件树想象成一家公司:每个组件是一个员工,props 是"上级给下级下发文件",emit 是"下级向上级汇报"。当公司只有三五个人时,靠打招呼就能传递信息;一旦公司有几百人,跨部门的信息传递就必须有一个"中央档案室"——这就是状态管理库的角色。

没有状态管理时,跨层级组件共享数据会遇到两个典型问题:

Props 逐层透传(Prop Drilling):数据要从顶层组件一层层传到最深层的子组件,中间的组件明明用不到,却被迫当"二传手"。
兄弟组件通信困难:两个没有父子关系的组件想共享数据,只能通过共同的祖先中转,代码非常绕。

状态管理库把这些共享状态抽离到一个独立的、全局可访问的仓库(Store)中,任何组件都能直接读写,从而彻底解耦。

2. Pinia 与 Vuex 的区别

Vuex 是 Vue 2 时代的官方方案,但它的 API 相对繁琐(尤其是 mutations 的存在),对 TypeScript 的支持也不够友好。Pinia 正是为了解决这些痛点而生。

| 对比维度 | Vuex 4 | Pinia |

|----------|--------|-------|

| 包体积 | 约 9.6 KB | 约 1.5 KB(gzip 后约 1KB) |

| mutations | 必须通过 mutations 改状态 | 已移除,直接改或用 actions |

| 修改方式 | commit/dispatch 字符串 | 直接调用方法,有类型提示 |

| 模块化 | 嵌套 modules,命名空间复杂 | 扁平化多 store,天然隔离 |

| TypeScript | 需要大量手写类型 | 类型自动推导,几乎零配置 |

| 组合式 API | 支持有限 | 原生支持 Setup Store |

| Devtools | 支持 | 支持更好,含时间旅行 |

| 服务端渲染 | 支持 | 支持,且更简洁 |

一句话总结:Pinia = Vuex 5 的设计理念提前落地。事实上 Vuex 官方文档已明确建议新项目直接使用 Pinia。

二、安装与配置

安装:

bashCode
# 使用 npm
npm install pinia

# 使用 pnpm
pnpm add pinia

# 使用 yarn
yarn add pinia

Vue 3 中注册:

javascriptCode
// 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');

Vue 2 中使用(需要 @vue/composition-api):

Vue 2 项目要使用 Pinia,必须先安装 @vue/composition-api 插件来提供组合式 API 支持。@vue/composition-api 是 Vue 官方为 Vue 2 打造的适配器,让老项目也能享受组合式 API 的代码组织优势。

javascriptCode
// main.js(Vue 2)
import Vue from 'vue';
import { createPinia, PiniaVuePlugin } from 'pinia';

Vue.use(PiniaVuePlugin);
const pinia = createPinia();

new Vue({
  el: '#app',
  pinia
});

Nuxt 3 中使用:

bashCode
npm install @pinia/nuxt
javascriptCode
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@pinia/nuxt']
});

三、核心概念

Pinia 的一个 Store 由三部分组成,可以类比 Vue 组件:

| Pinia 概念 | 对应组件概念 | 作用 |

|------------|--------------|------|

| state | data | 存放响应式状态数据 |

| getters | computed | 基于 state 派生出的计算值,带缓存 |

| actions | methods | 修改状态、处理同步与异步逻辑 |

1. 定义 Store(Options 风格)

javascriptCode
// stores/counter.js
import { defineStore } from 'pinia';

export const useCounterStore = defineStore('counter', {
  // state:初始状态,必须是返回对象的函数
  state: () => ({
    count: 0,
    name: 'Pinia'
  }),
  // getters:派生状态,自动缓存
  getters: {
    doubleCount: (state) => state.count * 2,
    // 通过 this 访问其他 getter
    doubleCountPlusOne() {
      return this.doubleCount + 1;
    }
  },
  // actions:同步/异步方法
  actions: {
    increment() {
      this.count++;
    },
    async incrementAsync() {
      await new Promise((resolve) => setTimeout(resolve, 1000));
      this.count++;
    }
  }
});

其中 defineStore 的第一个参数 'counter' 是这个 store 的唯一 ID,Pinia 用它来连接 Devtools 并做数据隔离,整个应用中不能重复。

2. 定义 Store(Setup 风格,推荐)

Setup 风格用组合式 API 的写法定义 Store,灵活性更高,也更容易复用组合函数:

javascriptCode
// stores/counter.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useCounterStore = defineStore('counter', () => {
  // ref/reactive → state
  const count = ref(0);
  const name = ref('Pinia');

  // computed → getters
  const doubleCount = computed(() => count.value * 2);

  // function → actions
  function increment() {
    count.value++;
  }

  async function incrementAsync() {
    await new Promise((resolve) => setTimeout(resolve, 1000));
    count.value++;
  }

  // 必须 return 需要暴露的内容
  return { count, name, doubleCount, increment, incrementAsync };
});

3. 在组件中使用

vueCode
<script setup>
import { storeToRefs } from 'pinia';
import { useCounterStore } from '@/stores/counter';

const store = useCounterStore();

// 直接解构会丢失响应式,必须用 storeToRefs 包裹 state/getters
const { count, doubleCount } = storeToRefs(store);

// actions 是方法,可以直接解构
const { increment } = store;
</script>

<template>
  <div>
    <p>Count: {{ count }}</p>
    <p>Double: {{ doubleCount }}</p>
    <button @click="increment">+1</button>
    <button @click="store.incrementAsync">异步 +1</button>
  </div>
</template>

四、State 的读取与修改

读取: 直接 store.count 或用 storeToRefs 保持响应式。

修改状态的三种方式:

javascriptCode
const store = useCounterStore();

// 方式 1:直接赋值(最简单)
store.count = 10;

// 方式 2:$patch 批量修改(一次性变更多个字段,性能更好)
store.$patch({
  count: store.count + 1,
  name: 'Updated'
});

// 方式 3:$patch 传函数(适合数组、复杂逻辑)
store.$patch((state) => {
  state.count++;
  state.name = state.name.toUpperCase();
});

// 方式 4:通过 action(推荐,业务逻辑集中)
store.increment();

重置状态:

javascriptCode
// Options 风格自带 $reset
store.$reset();

// 注意:Setup 风格不支持 $reset,需要自己实现
// 在 Setup Store 里手写一个 reset 方法
function reset() {
  count.value = 0;
  name.value = 'Pinia';
}

五、Getters 详解

Getters 类似组件的计算属性,会根据依赖自动缓存,只有依赖变化时才重新计算。

javascriptCode
export const useProductStore = defineStore('product', {
  state: () => ({
    products: [
      { id: 1, name: '键盘', price: 200, stock: 5 },
      { id: 2, name: '鼠标', price: 100, stock: 0 }
    ]
  }),
  getters: {
    // 普通 getter
    inStockProducts: (state) => state.products.filter((p) => p.stock > 0),

    // 带参数的 getter:返回一个函数(这种写法不会被缓存)
    getProductById: (state) => {
      return (id) => state.products.find((p) => p.id === id);
    }
  }
});

// 使用
const store = useProductStore();
console.log(store.inStockProducts);      // [{ id: 1, ... }]
console.log(store.getProductById(1));    // { id: 1, name: '键盘', ... }

六、Actions 详解

Actions 用于封装业务逻辑,可以是同步的,也可以是异步的,并且支持完善的错误处理。

javascriptCode
export const useUserStore = defineStore('user', {
  state: () => ({
    user: null,
    token: '',
    loading: false
  }),
  actions: {
    async login(credentials) {
      this.loading = true;
      try {
        const res = await fetch('/api/login', {
          method: 'POST',
          body: JSON.stringify(credentials)
        });
        if (!res.ok) throw new Error('登录失败');
        const data = await res.json();
        this.user = data.user;
        this.token = data.token;
        localStorage.setItem('token', data.token);
      } catch (error) {
        console.error('登录出错:', error);
        throw error; // 抛出给组件层处理
      } finally {
        this.loading = false;
      }
    },
    logout() {
      this.user = null;
      this.token = '';
      localStorage.removeItem('token');
    }
  }
});

七、真实案例:电商购物车

下面用一个跨 store 协作的购物车场景,演示 Pinia 在真实项目中的用法。购物车 store 需要读取用户 store(判断是否 VIP 打折)和商品 store(查价格)。

javascriptCode
// stores/cart.js
import { defineStore } from 'pinia';
import { useUserStore } from './user';
import { useProductStore } from './product';

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [] // [{ productId, quantity }]
  }),
  getters: {
    // 商品总数
    totalCount: (state) =>
      state.items.reduce((sum, i) => sum + i.quantity, 0),

    // 总价(跨 store 协作 + VIP 折扣)
    totalPrice() {
      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;
    }
  },
  actions: {
    addItem(productId, quantity = 1) {
      const existing = this.items.find((i) => i.productId === productId);
      if (existing) {
        existing.quantity += quantity;
      } else {
        this.items.push({ productId, quantity });
      }
    },
    removeItem(productId) {
      this.items = this.items.filter((i) => i.productId !== productId);
    },
    clear() {
      this.items = [];
    }
  }
});

在组件中组合使用:

vueCode
<script setup>
import { storeToRefs } from 'pinia';
import { useCartStore } from '@/stores/cart';

const cart = useCartStore();
const { totalCount, totalPrice } = storeToRefs(cart);
</script>

<template>
  <div class="cart">
    <p>共 {{ totalCount }} 件商品</p>
    <p>合计:¥{{ totalPrice }}</p>
    <button @click="cart.addItem(1)">加入键盘</button>
    <button @click="cart.clear">清空</button>
  </div>
</template>

八、持久化存储

刷新页面后,Pinia 中的状态会丢失(因为它保存在内存里)。要让登录 token、购物车等数据在刷新后依然存在,需要做持久化。

方式 1:使用官方推荐插件

bashCode
npm install pinia-plugin-persistedstate
javascriptCode
// main.js
import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';

const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
javascriptCode
// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({ user: null, token: '' }),
  persist: {
    key: 'user-store',       // localStorage 键名
    storage: localStorage,   // 也可用 sessionStorage
    paths: ['token']         // 只持久化 token,不存整个 user
  }
});

方式 2:自定义插件(了解原理)

javascriptCode
// plugins/persist.js
export function myPersistPlugin({ store }) {
  // 恢复
  const saved = localStorage.getItem(store.$id);
  if (saved) store.$patch(JSON.parse(saved));

  // 订阅变化并保存
  store.$subscribe((mutation, state) => {
    localStorage.setItem(store.$id, JSON.stringify(state));
  });
}

九、订阅与插件系统

Pinia 提供了强大的订阅能力和插件机制,可用于日志、埋点、持久化等横切关注点。

javascriptCode
const store = useCounterStore();

// $subscribe:监听 state 变化
store.$subscribe((mutation, state) => {
  console.log('类型:', mutation.type);  // 'direct' | 'patch object' | 'patch function'
  console.log('storeId:', mutation.storeId);
  console.log('新状态:', state);
});

// $onAction:监听 action 调用(可做统一日志/耗时统计)
store.$onAction(({ name, args, after, onError }) => {
  const start = Date.now();
  console.log(`Action ${name} 开始,参数:`, args);
  after((result) => {
    console.log(`Action ${name} 完成,耗时 ${Date.now() - start}ms`);
  });
  onError((error) => {
    console.error(`Action ${name} 出错:`, error);
  });
});

十、TypeScript 支持

Pinia 对 TypeScript 的支持是它最大的卖点之一。Options 风格几乎零配置就能获得完整类型推导:

typescriptCode
// 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 res = await fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify(credentials)
      });
      const data: { user: User; token: string } = await res.json();
      this.user = data.user;
      this.token = data.token;
    }
  }
});

十一、调试

Vue DevTools 是 Vue 官方浏览器扩展,对 Pinia 有一等支持。它提供组件树检查、状态查看、时间旅行调试等功能,可以直观查看每个 store 的 state/getters、追踪每次 action 调用、回退到任意历史状态。Chrome 和 Firefox 商店均可安装,Vue 2/3 都支持。

Pinia 在 DevTools 中的三大调试能力:

状态实时查看:面板中直接看到每个 store 的完整状态树。
时间旅行(Time Travel):像录像一样回退/前进到任意一次状态变更。
状态快照:导出/导入当前状态,方便复现 bug。

十二、常见坑与最佳实践

常见坑:

| 坑 | 现象 | 解决 |

|----|------|------|

| 直接解构 store | 数据不再响应式 | 用 storeToRefs 包裹 state/getters |

| 在 setup 外调用 useStore | 报错找不到 pinia 实例 | 确保在组件 setup 或已注册 pinia 后调用 |

| Setup Store 用 $reset | 报错不存在该方法 | 自己写 reset 方法 |

| 持久化了敏感的整个 user | 泄露风险 | 用 paths 精确控制持久化字段 |

最佳实践:

按功能划分 store,一个业务模块一个文件。
优先使用 TypeScript 获得类型安全。
解构必用 storeToRefs,actions 可直接解构。
异步逻辑统一放 actions,组件只负责调用与 UI。
持久化只存必要字段,避免存储敏感或冗余数据。

推荐的目录结构:

codeCode
stores/
├── user.ts          # 用户相关状态
├── cart.ts          # 购物车相关状态
├── product.ts       # 产品相关状态
├── settings.ts      # 设置相关状态
└── index.ts         # 统一导出

十三、总结

| 要点 | 说明 |

|------|------|

| 定位 | Vue 3 官方状态管理库,取代 Vuex |

| 核心 | state / getters / actions 三件套 |

| 两种写法 | Options 风格(直观)、Setup 风格(灵活,推荐) |

| 响应式解构 | 必须用 storeToRefs |

| 修改状态 | 直接改 / $patch / action |

| 持久化 | pinia-plugin-persistedstate 插件 |

| 扩展 | $subscribe / $onAction / 自定义插件 |

| 优势 | 体积小、类型好、API 简洁、Devtools 强 |

Pinia 用极简的 API 覆盖了从简单计数器到复杂电商系统的全部状态管理需求。掌握 state/getters/actions 三件套,配合 storeToRefs 和持久化插件,就能应对绝大多数实际项目场景。