组织多个 store
Pinia 状态管理 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
应用变大后,一个 store 会膨胀成「什么都管」的上帝对象。这一节讲多 store 的拆分原则、store 之间的依赖方式、以及避免循环依赖的实践。
问题场景
项目里有课程、用户、购物车、通知四块状态,起初都塞进一个 useAppStore。半年后:改通知逻辑要读懂 800 行的 store;两个功能互相依赖,import 绕成环;测试一个 action 要 mock 整个 store。拆分不是洁癖,是可维护性的必要条件。
核心解释
按「领域」拆,不按「页面」拆。每个 store 对应一个业务领域,有自己的生命周期:
// stores/user.js
export const useUserStore = defineStore("user", () => {
const profile = ref(null);
const isLoggedIn = computed(() => !!profile.value);
async function login(name) { /* ... */ }
function logout() { /* ... */ }
return { profile, isLoggedIn, login, logout };
});
store 之间可以互相调用:在 action 或 getter 里 useUserStore() 是允许的,例如购物车 store 里用 computed(() => useUserStore().profile?.name ?? "游客") 派生「归属人」,Pinia 保证拿到同一实例。
store 之间可以互相调用:在 action 或 getter 里 useUserStore() 是允许的,Pinia 保证拿到同一实例。但要遵守两条规则:
- 只在函数内部调用,不要在 store 模块顶层调用(此时 Pinia 未初始化)。
- 避免循环依赖:A 调 B、B 又调 A 会让初始化顺序变得脆弱。判断方法:如果两个 store 互相依赖,通常说明它们应该合并,或者依赖应该提升到一个「协调层」。
跨 store 的协调放 action,不放 getter:getter 里调用另一个 store 的 action 会产生副作用,破坏 computed 的纯函数语义。协调逻辑(比如「下单成功后清空购物车并弹通知」)放在调用方的 action 里:
// stores/checkout.js
export const useCheckoutStore = defineStore("checkout", () => {
const loading = ref(false);
async function placeOrder() {
const cart = useCartStore();
const user = useUserStore();
const notify = useNotifyStore();
if (!user.isLoggedIn) return { ok: false, reason: "未登录" };
loading.value = true;
try {
await api.createOrder({ items: cart.items, user: user.profile });
cart.clear();
notify.push("下单成功");
return { ok: true };
} finally {
loading.value = false;
}
}
return { loading, placeOrder };
});
命名与文件结构:stores/<领域>.js,store id 与文件名一致(defineStore("cart", ...)),Devtools 里一眼能对上。不要把 store 写成 useDataStore 这种含义模糊的名字。
什么时候不需要 store:只在单个页面内使用的状态(筛选条件、表单草稿)留在组件里;能用 URL 表达的状态(分页、搜索词)放 query(V10);纯展示派生用 computed。store 只装「跨组件、跨页面、需要集中管理」的状态。
示例拆解
一个「通知」store,演示单例队列与自动清理:
// stores/notify.js
import { ref } from "vue";
import { defineStore } from "pinia";
let seq = 0;
export const useNotifyStore = defineStore("notify", () => {
const items = ref([]); // { id, text, type }
function push(text, type = "info") {
const id = ++seq;
items.value.push({ id, text, type });
setTimeout(() => dismiss(id), 3000);
}
function dismiss(id) {
items.value = items.value.filter((n) => n.id !== id);
}
return { items, push, dismiss };
});
<!-- components/NotifyHost.vue -->
<script setup>
import { useNotifyStore } from "../stores/notify";
const notify = useNotifyStore();
</script>
<template>
<TransitionGroup name="toast" tag="div" class="toast-host">
<p v-for="n in notify.items" :key="n.id" :class="n.type">
{{ n.text }}
<button @click="notify.dismiss(n.id)">×</button>
</p>
</TransitionGroup>
</template>
注意 store 里的 setTimeout 属于「应用级」资源,不需要组件卸载时清理(store 生命周期跟随应用)。如果这段逻辑写在组件里,就必须在 onUnmounted 清理——这是「状态放在哪一层」的直接后果。
动手任务
- 把购物车、用户、通知拆成三个 store,用 checkout action 串起「下单 → 清空 → 通知」。
- 故意制造循环依赖(user 里调 cart、cart 里调 user),观察行为并记录为什么危险;然后重构消除它。
- 给每个 store 加一个
reset()action(reset 到初始状态),用于登出时统一清理。
常见错误
- store 顶层调用另一个 store:
const user = useUserStore()写在 store 函数体外,Pinia 未初始化时报错。 - getter 里做副作用:调 action、发请求、写 localStorage,导致依赖追踪混乱。getter 保持纯。
- 一个 store 管所有事:文件膨胀、测试困难、循环依赖。按领域拆。
- 把页面级状态塞进 store:表单草稿、临时筛选留在组件或 URL 里,减少全局状态面积。
检查与复盘
- 能按领域拆分 store 并说清每个 store 的职责边界
- 能指出循环依赖的两个 store 并给出重构方向
- 能判断一个状态该放组件、URL 还是 store
参考资料
- Pinia 官方文档「组合式 store」:store 之间互相使用
- Vue 官方文档「状态管理」:状态层级的选择
读完这一课了?
标记完成后,工作台会安排接下来的学习。