Pinia 状态管理深度详解

中等 🟡Vue 生态
7 个标签
预计阅读时间:106 分钟
Pinia状态管理Vue3StoreTypeScript持久化插件

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. 安装与配置

安装:

bashCode
npm install 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');

在 Nuxt 3 中使用:

bashCode
npm install @pinia/nuxt
javascriptCode
// 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 定义:

javascriptCode
// 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

vueCode
<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:直接访问

javascriptCode
const store = useCounterStore();
console.log(store.count);

方式 2:使用 storeToRefs(保持响应式)

javascriptCode
import { storeToRefs } from 'pinia';

const store = useCounterStore();
// storeToRefs 会把 state 和 getters 转成 ref,解构后仍保持响应式
const { count, name } = storeToRefs(store);

console.log(count.value); // 在 <script> 中用 .value,模板中直接用 count

方式 3:直接解构(❌ 失去响应式)

javascriptCode
const store = useCounterStore();
const { count } = store;
// count 只是一次性拷贝的普通值,store.count 之后变化,这里的 count 不会更新

为什么直接解构会失去响应式? 因为 store 是一个 reactive 对象,解构相当于把响应式对象的某个属性值取出来赋给普通变量,切断了与响应式系统的连接。storeToRefs 的作用就是给每个属性包一层 ref,保住这个连接。

2. 修改 State

方式 1:直接修改

javascriptCode
store.count = 10;

方式 2:使用 $patch(批量修改,性能更优)

javascriptCode
// 修改多个字段:Pinia 会合并成一次更新,只触发一次订阅
store.$patch({
  count: 10,
  name: 'New Name'
});

// 函数形式:适合数组操作或依赖旧值的复杂修改
store.$patch((state) => {
  state.count++;
  state.items.push({ id: 1 });
});

方式 3:使用 actions(推荐,逻辑集中)

javascriptCode
store.increment();

$patch 与多次直接赋值的区别:连续多次直接赋值会触发多次 $subscribe 回调;而 $patch 合并成一次,在做持久化、日志埋点时能显著减少开销。

3. 重置 State

javascriptCode
// Options Store 自带 $reset,恢复到 state() 定义的初始值
store.$reset();

// 订阅 state 变化
const unsubscribe = store.$subscribe((mutation, state) => {
  console.log('变更类型:', mutation.type);
  console.log('最新状态:', state);
});
unsubscribe(); // 组件卸载时可手动取消(默认会随组件自动清理)

四、Getters 计算属性

1. 基本 Getters

javascriptCode
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 查找"这种需要外部参数的场景。

javascriptCode
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

javascriptCode
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。

javascriptCode
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

javascriptCode
actions: {
  async loginAndFetchProfile(credentials) {
    // 调用当前 store 的其他 action
    await this.login(credentials);

    // 调用其他 store 的 action
    const profileStore = useProfileStore();
    await profileStore.fetchProfile();
  }
}

3. Action 错误处理

javascriptCode
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 调用,非常适合做日志、埋点、耗时统计、错误上报。

javascriptCode
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

javascriptCode
// 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

vueCode
<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. 使用插件持久化

安装插件:

bashCode
npm install pinia-plugin-persistedstate

配置插件:

javascriptCode
// 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 中使用:

javascriptCode
export const useUserStore = defineStore('user', {
  state: () => ({ user: null, token: '' }),
  persist: {
    key: 'user-store',   // localStorage 键名
    storage: localStorage, // 也可用 sessionStorage
    paths: ['token']     // 只持久化 token,避免存整个 user 对象
  }
});

2. 手动持久化(理解原理)

javascriptCode
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 注入属性、订阅变化等。

javascriptCode
// 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. 类型定义

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 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(需自行实现)。

typescriptCode
// 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,包含登录、自动续期、持久化、错误处理。

typescriptCode
// 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 只存必要字段 |

十二、最佳实践

1.按功能划分 Store:每个业务模块一个 store 文件(user、cart、product)。
2.使用 TypeScript:获得完整的类型推导与提示。
3.解构必用 storeToRefs:保持响应式,actions 可直接解构。
4.合理使用持久化:只存必要数据,敏感信息谨慎处理。
5.错误处理集中在 actions:组件只负责调用与 UI 反馈。
6.命名规范:统一使用 useXxxStore 格式。
7.善用 $onAction / 插件:把日志、埋点等横切逻辑抽离。

推荐目录结构:

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

十三、Pinia 响应式原理深挖

理解 Pinia 的底层实现,能帮你写出更符合其设计的代码,也能在遇到「数据不更新」「解构失去响应式」等问题时快速定位根因。

1. defineStore 到底返回了什么

`defineStore` 并不直接创建 store,而是返回一个「useStore 函数」。只有在组件里调用 `useStore()` 时才真正初始化(懒加载)。这就是为什么必须在 setup 或 pinia 注册之后才能调用。

javascriptCode
// 简化版 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 被销毁时可一次性清理,避免内存泄漏。

javascriptCode
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 对象会得到「值的快照」,切断与源对象的响应式连接。

javascriptCode
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)
javascriptCode
// 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. 两种写法的完整对照

javascriptCode
// 写法一: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;
    }
  }
});
javascriptCode
// 写法二: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 的独有能力:私有状态

javascriptCode
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`,可以自己封装。

javascriptCode
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 记录。

javascriptCode
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 变化,常用于持久化、日志、数据同步。

javascriptCode
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 执行前后插入逻辑,实现日志、错误上报、性能统计等横切关注点。

javascriptCode
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

javascriptCode
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. 迁移前后代码对比

javascriptCode
// 迁移前: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')
javascriptCode
// 迁移后: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 的字段,需要显式标注。

typescriptCode
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 的类型定义

typescriptCode
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` 提取类型。

typescriptCode
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 识别。

typescriptCode
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 必须是函数

javascriptCode
// 错误:state 是对象,所有请求共享同一份,多用户数据串号
// state: { count: 0 }

// 正确:state 是函数,每次调用返回全新对象,请求间隔离
export const useStore = defineStore('main', {
  state: () => ({ count: 0 }) // 每个请求独立
});

2. Nuxt 3 中使用 Pinia

bashCode
# 安装 Nuxt 模块
npm install @pinia/nuxt pinia
javascriptCode
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@pinia/nuxt'],
  pinia: {
    // 自动导入 storeToRefs 等
    storesDirs: ['./stores/**']
  }
});
vueCode
<!-- Nuxt 页面中使用(无需手动 import useStore,自动导入) -->
<script setup>
const store = useCounterStore();

// useAsyncData 在服务端预取数据,状态自动脱水注水到客户端
await useAsyncData('user', async () => {
  await store.fetchUserInfo();
  return store.userInfo;
});
</script>

3. 状态脱水与注水原理

javascriptCode
// 服务端:渲染完成后把 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. 测试基础配置

javascriptCode
// 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

javascriptCode
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。

javascriptCode
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 注入通用属性

javascriptCode
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. 自定义持久化插件(理解原理)

javascriptCode
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. 全局重置插件

javascriptCode
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 调 $reset

4. action 防抖插件

结合前面扩展的 `debounce` option 类型,实现 action 自动防抖。

javascriptCode
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

javascriptCode
// 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(核心)

javascriptCode
// 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

javascriptCode
// 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 协作下单)

javascriptCode
// 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. 组件中使用

vueCode
<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. 避免不必要的响应式开销

javascriptCode
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 不变,多次访问不会重复计算。

javascriptCode
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. 大型应用推荐目录结构

codeCode
src/
├── stores/
│   ├── modules/
│   │   ├── user.ts          # 用户认证
│   │   ├── permission.ts    # 权限路由
│   │   ├── cart.ts          # 购物车
│   │   ├── product.ts       # 商品
│   │   └── settings.ts      # 应用设置
│   ├── plugins/
│   │   ├── persist.ts       # 持久化插件
│   │   └── logger.ts        # 日志插件
│   └── index.ts             # 创建 pinia、注册插件、统一导出
├── api/                     # 接口层
└── types/                   # 类型定义
typescriptCode
// 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 的模块嵌套直观得多。

javascriptCode
// 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 无需刷新整个页面,状态还能保留。

javascriptCode
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 添加调试信息

javascriptCode
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. 自定义日志插件辅助调试

javascriptCode
// 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 自动刷新)

javascriptCode
// 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. 应用启动时恢复登录态

javascriptCode
// 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. 结合路由守卫做权限控制

javascriptCode
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、测试与性能优化,就能从容应对从小型项目到大型企业级应用的全部状态管理挑战。

推荐学习路径:

1.入门:先掌握 defineStore 的两种写法、state/getters/actions 三要素,能实现一个计数器和 Todo 列表。
2.进阶:吃透 storeToRefs 响应式解构、$patch 批量更新、跨 store 协作,能实现登录认证模块。
3.实战:加上持久化、路由守卫权限、自定义插件,完成一套后台管理或电商系统。
4.精通:理解 effectScope 底层原理、SSR 脱水注水、编写单元测试、做性能优化,胜任大型企业级项目架构。

按这个路径循序渐进,配合本文的可运行代码逐个实践,就能真正把 Pinia 从「会用」提升到「用好」。

各阶段能力自检清单:

| 阶段 | 能独立完成 | 关键指标 |

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

| 入门 | 定义 store 并在组件读写状态 | 理解单例、会用 storeToRefs |

| 进阶 | 封装认证/购物车模块 | 跨 store 协作、异步 action 错误处理 |

| 实战 | 搭建完整业务系统 | 持久化、权限、插件、目录规范 |

| 精通 | 主导大型项目状态架构 | SSR、测试覆盖、性能调优、原理讲解 |

最后的建议: 状态管理的本质不是「把所有数据都放进 store」,而是「让共享状态可预测、可追踪、可测试」。始终遵循单一职责拆分 store、用 action 收敛状态变更逻辑、组件只负责触发和展示,就能写出经得起长期迭代的 Pinia 代码。

常用代码片段速记:

定义:`const useX = defineStore('x', () => { ... return {...}; })`
使用:`const store = useX()`
解构:`const { a, b } = storeToRefs(store)`(state/getters)
方法:`const { doSomething } = store`(actions 可直接解构)
批量改:`store.$patch({ a: 1, b: 2 })`
订阅:`store.$subscribe((mutation, state) => {...})`
拦截:`store.$onAction(({ name, after, onError }) => {...})`
重置:`store.$reset()`(Options Store 内置)
持久化:store 第三参数 `{ persist: { paths: ['token'] } }`
整体读写:`store.$state` 读取,`store.$state = {...}` 整体替换
销毁:`store.$dispose()` 停止响应式作用域
测试初始化:`setActivePinia(createPinia())`
组件测试 mock:`createTestingPinia({ createSpy: vi.fn })`
HMR:`import.meta.hot.accept(acceptHMRUpdate(useX, import.meta.hot))`
插件注册:`pinia.use(myPlugin)`
注入 router:插件里 `store.$router = markRaw(router)`
私有状态:Setup Store 里不 return 的变量即私有
手动 reset:Setup Store 自实现 `$reset` 恢复初始值
大数据优化:`shallowRef` 存表格、`markRaw` 存图表实例
提取类型:`type Store = ReturnType`
全局访问:`getActivePinia()` 获取当前 pinia 实例
SSR 注水:`pinia.state.value = window.__pinia`
防抖 action:结合插件对指定 action 包一层 debounce

把这些片段刻进肌肉记忆,日常开发就能行云流水。至此,Pinia 从原理到实战的完整知识体系已经建立,剩下的就是在真实项目中反复打磨、形成自己的最佳实践。