Vue 状态管理 Pinia 与 Vuex

组件通信只解决了"父子"问题。当应用变大,你会遇到"跨多个层级的组件共享数据"的场景:登录状态、购物车、用户信息、主题偏好——这些数据不属于任何单个组件,而是全局的。状态管理库就是为此而生。Pinia 是 Vue 官方推荐的新一代状态管理库,取代了老牌的 Vuex。本章讲清它。

1. 为什么需要状态管理?

设想一个电商页面:购物车图标在顶部导航栏,加入购物车按钮在商品列表里,购物车数量徽章在底部 tab 栏。这三处分散在不同层级的组件里,都要读同一个"购物车数据"。

如果用 props/emit 传递,就要层层转发(App → Header → CartIcon;App → ProductList → ProductItem → AddButton),中间层组件根本不关心购物车,被迫做"快递员"。这就是经典的 prop drilling(属性钻取)问题。

<!-- ❌ 没有状态管理的"prop drilling"惨剧 -->
<!-- 顶层 App.vue -->
<template>
  <Grandpa :user="user" />   <!-- 只是为了传给孙子 -->
</template>

<!-- Grandpa.vue -->
<template>
  <Parent :user="user" />    <!-- 只是为了传给儿子 -->
</template>

<!-- Parent.vue -->
<template>
  <Child :user="user" />     <!-- 真正用 user 的地方 -->
</template>

<!-- 中间层组件根本不关心 user,被迫层层转发。
     一旦链条变长(5+ 层),代码极难维护。
     Pinia 把数据放到"全局",任何组件直接拿,不再层层传递。 -->

状态管理库的思路:把"全局共享数据"放到一个独立于组件树之外的地方(叫 store),任何组件都能直接读写,不需要层层传递。

2. Pinia 简介

Pinia 是 Vue 团队官方推荐的状态管理库(2022 年起取代 Vuex 成为首选)。它的优势:

3. 安装

# 用 create-vue 创建项目时勾选 Pinia
npm create vue@latest

# 或手动安装
npm install pinia

记得在 main.js 里安装:

import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";

const app = createApp(App);
app.use(createPinia());
app.mount("#app");

4. 定义 Store:Setup 写法(推荐)

Pinia 有两种写法,推荐Setup Store——和 <script setup> 完全一致的语法,学习曲线最平缓。

// src/stores/user.js
import { defineStore } from "pinia";
import { ref, computed } from "vue";

// 推荐:Setup Store 写法(和 <script setup> 一致)
export const useUserStore = defineStore("user", () => {
  // state:用 ref 声明
  const name = ref("");
  const token = ref(localStorage.getItem("token") || "");
  const cart = ref([]);

  // getter:用 computed 声明(只读派生值)
  const isLogin = computed(() => !!token.value);
  const cartCount = computed(() => cart.value.length);

  // action:用普通函数声明(可以修改 state)
  async function login(username, password) {
    const res = await fetch("/api/login", {
      method: "POST",
      body: JSON.stringify({ username, password }),
    });
    const data = await res.json();
    token.value = data.token;
    name.value = data.name;
    localStorage.setItem("token", data.token);
  }

  function logout() {
    token.value = "";
    name.value = "";
    cart.value = [];
    localStorage.removeItem("token");
  }

  // 把需要暴露的内容 return 出去
  return { name, token, cart, isLogin, cartCount, login, logout };
});

三要素:

命名规范:store 文件用名词单数(user.jscart.js),函数名以 use 开头(useUserStore)。

5. 在组件里用 Store

<template>
  <div>
    <!-- 模板里直接用 -->
    <p v-if="userStore.isLogin">你好,{{ userStore.name }}</p>
    <button v-else @click="goLogin">请登录</button>
    <p>购物车:{{ userStore.cartCount }} 件</p>

    <!-- 解构出来也行(用 storeToRefs 保持响应式) -->
    <p>{{ name }} - {{ cartCount }}</p>
  </div>
</template>

<script setup>
import { storeToRefs } from "pinia";
import { useUserStore } from "../stores/user";

const userStore = useUserStore();

// ⚠️ state/getter 解构时要包 storeToRefs,否则丢响应式
const { name, cartCount } = storeToRefs(userStore);

// action 可以直接解构(函数不存在响应式问题)
const { login, logout } = userStore;

function goLogin() {
  login("test", "123456");
}
</script>

重要陷阱:解构 store 时,state 和 getter 要包一层 storeToRefs!直接解构会丢响应式(原理和 reactive 一样)。action 是函数,不存在响应式问题,直接解构。

6. Options Store 写法

另一种写法是Options Store,类似 Vue 2 的 Options API。如果你看过老教程,可能见到这种:

// src/stores/counter.js
import { defineStore } from "pinia";

// Options Store 写法(类似 Vue 2 Options API)
export const useCounterStore = defineStore("counter", {
  state: () => ({
    count: 0,
    history: [],
  }),

  getters: {
    double: (state) => state.count * 2,
    // 访问别的 getter
    quadruple() {
      return this.double * 2;
    },
  },

  actions: {
    increment() {
      this.history.push(this.count);
      this.count++;
    },
    async fetchInitial() {
      const res = await fetch("/api/count");
      this.count = await res.json();
    },
  },
});

两种写法功能等价,选哪种看个人喜好。新项目推荐 Setup 写法,因为它和组件代码风格一致,且对 TS 更友好。

7. Store 互相调用

Pinia 的 store 可以互相调用——比如 cart store 里需要检查登录状态:

// src/stores/cart.js
import { defineStore } from "pinia";
import { useUserStore } from "./user";

export const useCartStore = defineStore("cart", () => {
  const userStore = useUserStore();   // 在 store 里用别的 store

  const items = ref([]);

  function checkout() {
    if (!userStore.isLogin) {         // 直接调 user store 的 getter
      throw new Error("请先登录");
    }
    return fetch("/api/checkout", {
      body: JSON.stringify({
        items: items.value,
        user: userStore.name,
      }),
    });
  }

  return { items, checkout };
});

这在 Vuex 里要做模块导入、命名空间隔离,复杂得多。Pinia 直接 useXxxStore() 就行。

8. 状态持久化

默认 store 数据存在内存里,刷新页面就丢失。需要"刷新后还在"的场景(主题、登录状态),要持久化到 localStorage:

// 方式一:手动同步 localStorage
import { defineStore } from "pinia";
import { ref, watch } from "vue";

export const useSettingsStore = defineStore("settings", () => {
  const theme = ref(localStorage.getItem("theme") || "light");

  // 监听变化,自动同步到 localStorage
  watch(theme, (val) => localStorage.setItem("theme", val));

  return { theme };
});

// 方式二:用 pinia-plugin-persistedstate 插件(自动)
// npm i pinia-plugin-persistedstate
//
// 在 store 里加 persist: true 选项
// defineStore("user", { ...options, persist: true });

方式一(手动)灵活但繁琐;方式二(插件)开箱即用,适合大多数场景。注意敏感数据(密码、token)不要持久化,或加过期时间。

9. Pinia vs Vuex:核心区别

老项目可能还在用 Vuex。如果你看过 Vuex 教程,会注意到一个核心差异:Vuex 有 mutations,Pinia 没有

// Vuex 4 写法(老项目可能见到)
import { createStore } from "vuex";

export default createStore({
  state: () => ({ count: 0 }),
  mutations: {
    // 同步修改:只能通过 commit mutation
    INCREMENT(state) { state.count++; },
  },
  actions: {
    // 异步操作:action 提交 mutation,不能直接改 state
    increment({ commit }) {
      commit("INCREMENT");
    },
  },
  getters: {
    double: (state) => state.count * 2,
  },
});

// 组件里:
this.$store.commit("INCREMENT");        // 触发 mutation
this.$store.dispatch("increment");      // 触发 action
this.$store.getters.double;             // 读 getter

对照表:

结论:新项目一律用 Pinia,Vuex 仅在维护老项目时遇到。Vue 官方也不再为 Vuex 推荐新功能。

10. 实战:购物车示例

把上面的知识组合起来,实现一个最小可用购物车:

// stores/cart.js
import { defineStore } from "pinia";
import { ref, computed } from "vue";

export const useCartStore = defineStore("cart", () => {
  const items = ref([]);

  const totalCount = computed(() =>
    items.value.reduce((sum, item) => sum + item.qty, 0)
  );
  const totalPrice = computed(() =>
    items.value.reduce((sum, item) => sum + item.price * item.qty, 0)
  );

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

  function removeFromCart(id) {
    items.value = items.value.filter((i) => i.id !== id);
  }

  return { items, totalCount, totalPrice, addToCart, removeFromCart };
});
<!-- 任何组件里都能用 -->
<template>
  <button @click="cart.addToCart(product)">加入购物车</button>
  <p>购物车:{{ cart.totalCount }} 件,共 ¥{{ cart.totalPrice }}</p>
</template>

<script setup>
import { useCartStore } from "../stores/cart";
const cart = useCartStore();
const product = { id: 1, name: "苹果", price: 5 };
</script>

不管这个按钮和这个购物车数量显示在哪个层级的组件里,都能直接读写同一个 cart store。这就是状态管理的威力。

11. 何时不该用状态管理?

Pinia 虽好,但不是所有数据都需要进 store。判断原则:

新手常犯的错:把所有数据都塞进 store,导致代码更乱、调试更难。能用 props 解决就别用 store——store 是"全局共享"的工具,不是"全局垃圾桶"。

小结

这一章你掌握了 Vue 现代状态管理的全部:Pinia 的 Setup 写法、state/getter/action 三要素、store 互相调用、持久化、与 Vuex 的对比。学到这里,你已经具备了独立开发一个完整 Vue 应用的全部基础知识。后续就是多写项目、读优秀源码、踩坑——这些都不是教程能教的,只有实战能给你。

← 上一篇 Vue Router 路由

返回 Vue 教程目录

✈️💬