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 成为首选)。它的优势:
- 更简洁的 API:没有 mutation,直接改 state(像写组件一样)。
- 完美支持 TypeScript:类型推断开箱即用。
- 体积极小:约 1KB。
- DevTools 集成:Vue DevTools 能直接看到 store 状态变化。
- 模块化天然:每个 store 是独立文件,无需注册模块。
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 };
});三要素:
- state:用
ref声明,返回出去。 - getter:用
computed声明,派生只读值。 - action:用普通函数声明,可同步可异步,可以直接改 state。
命名规范:store 文件用名词单数(user.js、cart.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对照表:
- 修改方式:Vuex 必须通过 commit mutation(同步);Pinia 直接改 state 或调 action。
- 异步操作:Vuex 必须放 action(因为 mutation 不能异步);Pinia action 可以是异步函数,直接 await。
- 模块:Vuex 用 modules + namespaced,繁琐;Pinia 每个 store 独立文件,天然模块化。
- TS 支持:Vuex 类型推断较弱,要写大量类型;Pinia 开箱即用。
- 体积:Pinia 约 1KB,Vuex 约 6KB。
结论:新项目一律用 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:登录状态、用户信息、购物车、全局主题、跨页面共享的列表数据。
- 留在组件:表单输入值、当前打开的弹窗状态、单个组件的局部 UI 状态。
- 父传子:仅父子两层共享的数据,没必要进 store。
新手常犯的错:把所有数据都塞进 store,导致代码更乱、调试更难。能用 props 解决就别用 store——store 是"全局共享"的工具,不是"全局垃圾桶"。
小结
这一章你掌握了 Vue 现代状态管理的全部:Pinia 的 Setup 写法、state/getter/action 三要素、store 互相调用、持久化、与 Vuex 的对比。学到这里,你已经具备了独立开发一个完整 Vue 应用的全部基础知识。后续就是多写项目、读优秀源码、踩坑——这些都不是教程能教的,只有实战能给你。
← 上一篇 Vue Router 路由
返回 Vue 教程目录