讲解

组件内状态用 ref,子树共享用 provide/inject,那整个应用共享的状态——登录用户、购物车、全局通知——放哪?答案是 Pinia,Vue 官方的状态管理库。一个 store 就是一个「可全局访问的组合式函数」:defineStore 定义,任何组件 useXxxStore() 取用,拿到的是同一个响应式实例。

store 的三件套和组件一一对应:state 是数据(相当于 ref),getters 是推导(相当于 computed),actions 是方法(相当于普通函数,可异步)。推荐 setup 写法,和 <script setup> 风格完全一致:

什么时候需要 Pinia?一条判断线:状态被「不相邻的多个组件」使用,或需要在路由切换后保持(购物车跳页面不能丢),或需要 devtools 时间旅行调试。两个组件共享用 props/emits 或 provide 就够,别急着上 store。

三个高频细节。其一,解构 store 会丢响应式,const { count } = store 是快照不是引用,要么直接用 store.count,要么用 storeToRefs。其二,异步逻辑放 actions:async function fetchUser() { user.value = await api.getUser() },别在组件里散落请求。其三,多个 store 可以互相引用(cart store 里用 user store 的 id),但注意避免循环依赖。

示例

手写一个迷你 Pinia:模块级单例 + state/getters/actions 三件套(在本教程构建时被真实执行):

import assert from 'node:assert/strict';

// 迷你 store 工厂:单例缓存 + computed 风格的 getter
const registry = new Map();

function defineStore(id, setup) {
  return function useStore() {
    if (!registry.has(id)) {
      registry.set(id, setup()); // 第一次调用才创建 → 全局单例
    }
    return registry.get(id);
  };
}

const useCartStore = defineStore('cart', () => {
  const items = [];
  return {
    items,
    get totalPrice() {
      // getter 等价于 computed
      return items.reduce((sum, i) => sum + i.price * i.qty, 0);
    },
    add(product, qty = 1) {
      // action:修改 state 的唯一入口
      const found = items.find((i) => i.id === product.id);
      if (found) found.qty += qty;
      else items.push({ ...product, qty });
    },
    async checkout() {
      // 异步 action
      const total = this.totalPrice;
      items.length = 0;
      return total;
    },
  };
});

const cart1 = useCartStore();
const cart2 = useCartStore(); // 另一个「组件」拿到的同一个实例
assert.strictEqual(cart1, cart2);

cart1.add({ id: 1, name: '键盘', price: 199 });
cart2.add({ id: 1, name: '键盘', price: 199 }); // 同款合并数量
cart1.add({ id: 2, name: '鼠标', price: 99 }, 2);

assert.strictEqual(cart1.items.length, 2);
assert.strictEqual(cart1.items[0].qty, 2);
assert.strictEqual(cart2.totalPrice, 199 * 2 + 99 * 2);

cart1.checkout().then((paid) => {
  assert.strictEqual(paid, 596);
  assert.strictEqual(cart1.items.length, 0);
  console.log('结算', paid, '元,购物车已清空');
});

console.log('购物车总价:', cart1.totalPrice, '元(两个组件共享同一份状态)');

真实的 Pinia store(setup 写法):

// stores/cart.js —— 仅示意:真实项目代码(依赖 pinia 包),构建时不执行
import { ref, computed } from 'vue';
import { defineStore } from 'pinia';

export const useCartStore = defineStore('cart', () => {
  const items = ref([]);
  const totalPrice = computed(() =>
    items.value.reduce((sum, i) => sum + i.price * i.qty, 0),
  );

  function add(product, qty = 1) {
    const found = items.value.find((i) => i.id === product.id);
    if (found) found.qty += qty;
    else items.value.push({ ...product, qty });
  }

  async function checkout() {
    const total = totalPrice.value;
    await fetch('/api/orders', { method: 'POST', body: JSON.stringify(items.value) });
    items.value = [];
    return total;
  }

  return { items, totalPrice, add, checkout };
});
<script setup>
import { storeToRefs } from 'pinia';
import { useCartStore } from './stores/cart';

const cart = useCartStore();
const { items, totalPrice } = storeToRefs(cart); // 解构保持响应式
</script>

<template>
  <p>{{ items.length }} 种商品,共 {{ totalPrice }} 元</p>
  <button @click="cart.checkout()">结算</button>
</template>

常见坑

  • 直接解构 store 丢响应式const { items } = cart 是静态快照,要么用 cart.items,要么 storeToRefs。
  • 在组件里直接改 store 数据:能跑但绕过了 actions,状态变化无迹可查;复杂项目约定「修改只走 actions」。
  • store 定义放在组件里:defineStore 要在模块顶层(或按需模块),放组件里会随组件重建丢失状态。
  • 滥用 store:两个组件共享的小状态上 Pinia 是杀鸡用牛刀,先用 props/provide。
  • 忘记持久化:刷新页面 store 归零,购物车这类状态要配合 localStorage 插件或手动序列化。

小结

Pinia = 全局单例的组合式函数;state/getters/actions 对应 ref/computed/方法;解构用 storeToRefs。下一章给界面加动感:过渡与动画。