Pinia 状态管理:Vue 生态的官方推荐

深入解析 Pinia 的核心概念和使用场景:为什么 Pinia 取代 Vuex 成为 Vue 3 的默认状态管理方案,Store 的定义方式,State/Getters/Actions 的机制,以及 Pinia 与 Composition API 的结合。

为什么需要状态管理

在 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 实例
});

延展阅读