Skip to content
第 127 / 250 章前端⏱ 10 分钟阅读

第 127 章:Pinia 状态管理

学习目标

  • 掌握 Pinia 的设计与基本用法
  • 学会定义 store(state/getters/actions)
  • 理解模块化、持久化、DevTools 集成
  • 在 Vue 3 + TypeScript 项目中使用 Pinia

一、为什么需要 Pinia

Vuex 写起来繁琐,Pinia 是 Vue 官方推出的下一代状态管理,API 简洁、类型友好、支持 Composition API。

二、安装与配置

2.1 安装

bash
pnpm add pinia

2.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-persistedstate
typescript
// 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

维度PiniaVuex 4
API 风格CompositionOptions
TypeScript优秀一般
模板代码
模块化自动modules 配置
DevTools支持支持
体积~1KB~10KB
状态变更直接修改mutations

十五、本章小结

概念关键
Setup StoredefineStore + ref/computed/function
storeToRefs解构保持响应式
$patch批量修改 state
$subscribe订阅 state 变化
$onAction监听 action
持久化插件或手动
TypeScript类型自动推断

动手练习

  1. 用户 store:实现登录、获取用户信息、登出
  2. 购物车 store:实现加减商品、计算总价、清空
  3. 持久化:把用户登录状态保存到 localStorage
  4. 跨 store:购物车结算时调用用户地址 store
  5. 测试:为已有 store 编写单元测试

推荐阅读


下一章第 128 章:TypeScript 深度集成

本站基于 VitePress 构建 · 由 Codebook 团队维护