前言​ 前两篇文章发布后不少同学反馈了一个非常扎心的现象我按你说的把目录拆成了features/、entities/也把逻辑抽成了 Composables但一用到 Pinia代码又变回了意大利面。Store 之间相互import改一个 Store 导致半个项目报错这 Store 到底是解耦了还是耦合了这是一个非常经典的误区。很多团队把 Pinia甚至曾经的 Vuex当成了高级全局变量来用——哪里需要存数据就往里塞哪个模块需要数据就直接import对应的 Store。结果就是Store 变成了巨大的依赖黑洞。今天我们就来聊聊如何正确地使用 Pinia。这不仅关乎 API 调用更关乎架构思维。我们要做的是让 Pinia 成为应用的中枢神经而不是杂乱无章的垃圾堆放区。一、 为什么你的 Pinia 正在变成依赖黑洞让我们先看一段典型的烂大街的 Store 代码// stores/order.ts import { defineStore } from pinia import { useUserStore } from ./user // ❌ 直接引入其他 Store import { useProductStore } from ./product // ❌ 直接引入其他 Store import { useCartStore } from ./cart // ❌ 直接引入其他 Store export const useOrderStore defineStore(order, { state: () ({ list: [], currentOrder: null, }), actions: { async createOrder() { const userStore useUserStore() const productStore useProductStore() const cartStore useCartStore() // 上帝逻辑拼装各种数据 if (!userStore.isLogin) throw new Error(未登录) const params { userId: userStore.userId, items: cartStore.items.map(i ({ productId: i.id, price: productStore.getPrice(i.id), // 跨 Store 调用 getter quantity: i.count })), address: userStore.address } const res await api.createOrder(params) this.list.push(res) cartStore.clear() // 直接操作其他 Store } } })这段代码犯了三个致命错误硬编码依赖orderStore 强依赖user、product、cart。如果明天要拆分微前端或者userStore 改名了你哭都来不及。职责混乱Store 的 Action 里混杂了业务逻辑、数据转换、甚至直接操作其他 Store 的状态。难以测试想测试createOrder你得先 mock 三个 Store 的实例。结论​ 如果你在 Store A 里import Store B你就已经在制造耦合。二、 破局之道Setup Syntax 与关注点分离Vue3 时代Pinia 提供了Setup Store​ 的写法。这不仅仅是语法糖它是实现 Store 内部逻辑解耦的关键。1. 告别 Option Store拥抱 Setup StoreOption Store旧思维// 像 Vue2 的选项式 API逻辑散落在 state/getters/actions 中 export const useUserStore defineStore(user, { state: () ({ count: 0 }), getters: { double: (state) state.count * 2 }, actions: { increment() { this.count } }, })Setup Store新范式// 像 Vue3 的 setup() 函数可以完美复用 Composables import { defineStore } from pinia import { ref, computed } from vue import { useFetchUser } from features/user/composables/useFetchUser export const useUserStore defineStore(user, () { // State const user ref(null) const token ref(localStorage.getItem(token)) // Getters (computed) const isLogin computed(() !!token.value) const userName computed(() user.value?.name ?? 游客) // Actions // 核心直接复用我们上一篇写的 Composable const { fetchUser, loading, error } useFetchUser() const login async (credentials) { const res await api.login(credentials) token.value res.token localStorage.setItem(token, res.token) // 登录后获取用户信息 await fetchUser() } const logout () { token.value null user.value null localStorage.removeItem(token) } return { user, token, isLogin, userName, loading, error, login, logout, fetchUser, } }) 架构价值看到了吗Store 内部不再自己实现复杂的异步逻辑而是直接调用useFetchUser这个 Composable。逻辑复用useFetchUser可以在组件里用也可以在 Store 里用。关注点分离Store 负责状态托管和业务意图如loginComposable 负责具体实现如fetchUser。可测试性测试 Store 时你可以轻松 mockuseFetchUser。三、 Getter/Action 的职责边界什么该进 Store这是架构设计中最让人纠结的问题。我的建议是遵循以下原则1. State只存全局需要共享的真理源❌ 不该进 Store组件内部的临时状态如弹窗显隐、表单输入值——用ref或reactive。可以通过计算派生出的数据如列表过滤结果——用computed。服务端返回的、不需要全局缓存的原始数据除非需要缓存。✅ 该进 Store身份认证信息Token、User Info全应用都需要。全局 UI 状态主题、语言、布局配置。跨模块共享的实体数据当前选中的订单详情、购物车数据。2. Getter只读的计算属性Getter 应该是纯函数没有任何副作用。// ✅ 好的 Getter const totalPrice computed(() { return cartItems.value.reduce((sum, item) sum item.price * item.quantity, 0) }) // ❌ 坏的 Getter有副作用 const totalPrice computed(() { api.trackEvent(calculate_price) // 不要在这里发请求或打点 return cartItems.value.reduce((sum, item) sum item.price * item.quantity, 0) })3. Action处理复杂的异步编排Action 是唯一允许有副作用的地方。但它不应该处理具体的 HTTP 请求细节那是 Composable 的事而是负责协调。Action 的职责调用 Composables执行具体的业务逻辑单元。更新 State将 Composable 返回的结果存入 State。协调多个逻辑如果一个操作需要先后调用多个 Composable在这里编排它们。// stores/order.ts import { defineStore } from pinia import { useCreateOrder } from features/order/composables/useCreateOrder import { useCartStore } from ./cart // ⚠️ 注意这里只是引用类型不直接操作其状态 export const useOrderStore defineStore(order, () { const orderList ref([]) const { createOrder, loading } useCreateOrder() const handleCreateOrder async (params) { // Action 作为协调者 const newOrder await createOrder(params) orderList.value.push(newOrder) // 这里涉及到跨模块后面会讲如何解耦此处仅为过渡示例 // 暂时保留引出下文痛点 const cartStore useCartStore() cartStore.clearCart() } return { orderList, loading, handleCreateOrder } })四、 模块零耦合通信打破 Store 之间的直接引用回到我们开头的痛点orderStore 直接import并操作cartStore。解决方案事件机制Event Bus / Mitt 依赖注入思想核心思路Store A 不需要知道 Store B 的存在它只需要发出一个信号事件。​ 谁关心这个信号谁就来监听。Step 1: 创建一个全局事件总线// src/shared/libs/eventBus.ts import mitt from mitt // 定义事件类型增强 TypeScript 类型安全 type Events { order:created: { orderId: string; userId: string } user:login: { userId: string } cart:clear: void // 不需要参数的事件 } const emitter mittEvents() export default emitterStep 2: 改造 Order Store发送事件// stores/order.ts import { defineStore } from pinia import { useCreateOrder } from features/order/composables/useCreateOrder import emitter from shared/libs/eventBus // 引入事件总线 export const useOrderStore defineStore(order, () { const orderList ref([]) const { createOrder, loading } useCreateOrder() const handleCreateOrder async (params) { const newOrder await createOrder(params) orderList.value.push(newOrder) // ✅ 发出事件而不是直接调用 cartStore.clearCart() emitter.emit(order:created, { orderId: newOrder.id, userId: newOrder.userId }) emitter.emit(cart:clear) // 告诉世界购物车该清空了 } return { orderList, loading, handleCreateOrder } })Step 3: 改造 Cart Store监听事件// stores/cart.ts import { defineStore } from pinia import emitter from shared/libs/eventBus export const useCartStore defineStore(cart, () { const items ref([]) const clearCart () { items.value [] } // 监听事件 emitter.on(cart:clear, () { console.log(Cart store received clear event.) clearCart() }) // 甚至可以监听订单创建事件来做数据分析 emitter.on(order:created, ({ orderId }) { console.log(Order ${orderId} created, tracking...) }) return { items, clearCart } }) 架构突破现在orderStore 和cartStore彼此毫不知晓对方的存在。它们只依赖于抽象的eventBus。解耦删除cartStore不会影响orderStore 的编译和运行顶多是控制台有个警告或无响应。可维护性想找下单后清空购物车的逻辑去cartStore 里搜cart:clear即可。可扩展性新需求下单后给用户发积分只需在userStore 里监听order:created事件无需改动orderStore。注意事件机制虽好但不要滥用。如果是强关联的逻辑如父子组件Props/Emits 依然是首选。事件机制更适合跨模块、弱关联的全局通信。五、 Pinia 插件Plugin的魔法在大型项目中有一些通用的逻辑是所有 Store 都需要的。这时候Pinia Plugin​ 就派上用场了。1. 自动 Loading 管理很多同学会在每个 Action 里手动管理loading状态。太繁琐了我们可以用插件自动包裹 Actions。// src/app/store/plugins/loadingPlugin.ts import { PiniaPluginContext } from pinia // 定义一个全局的 loading store export const useGlobalLoadingStore defineStore(globalLoading, () { const pendingRequests ref(0) const isLoading computed(() pendingRequests.value 0) const addRequest () pendingRequests.value const removeRequest () pendingRequests.value-- return { isLoading, addRequest, removeRequest } }) export function loadingPlugin(context: PiniaPluginContext) { const loadingStore useGlobalLoadingStore() // 遍历所有 action Object.keys(context.store).forEach((key) { const originalAction context.store[key] // 判断是否是函数action if (typeof originalAction function) { // 用包装函数替换原 action context.store[key] async function (...args: any[]) { loadingStore.addRequest() try { return await originalAction.apply(this, args) } finally { loadingStore.removeRequest() } }.bind(context.store) } }) } // 在 main.ts 中注册 // const pinia createPinia() // pinia.use(loadingPlugin)现在任何一个 Store 的 Action 在执行时全局的isLoading都会自动变为true执行完毕后自动变为false。你可以在 App.vue 中放一个全局的 Loading 动画完美2. 状态持久化Persistence刷新页面Store 里的数据就没了我们需要持久化插件。// src/app/store/plugins/persistencePlugin.ts import { PiniaPluginContext } from pinia interface PersistenceOptions { key?: string storage?: Storage // localStorage 或 sessionStorage paths?: string[] // 指定需要持久化的字段 } export function persistencePlugin(options: PersistenceOptions {}) { const { storage localStorage, paths [] } options return (context: PiniaPluginContext) { const { store } context const key options.key ?? store.$id // 初始化时从 storage 恢复数据 const stored storage.getItem(key) if (stored) { store.$patch(JSON.parse(stored)) } // 订阅 state 变化保存到 storage store.$subscribe((mutation, state) { // 如果指定了 paths只保存指定的字段 const dataToSave paths.length 0 ? paths.reduce((obj, path) { obj[path] state[path] return obj }, {}) : state storage.setItem(key, JSON.stringify(dataToSave)) }) } } // 使用在特定的 Store 中启用 // export const useUserStore defineStore(user, () { ... }, { // plugins: [persistencePlugin({ storage: localStorage, paths: [token] })] // })3. 多 Tab 同步在后台管理系统或电商网站中经常需要在多个 Tab 之间同步登录状态。// src/app/store/plugins/syncTabsPlugin.ts import { PiniaPluginContext } from pinia export function syncTabsPlugin(context: PiniaPluginContext) { const { store } context const channel new BroadcastChannel(pinia-${store.$id}) // 接收来自其他 Tab 的消息 channel.onmessage (event) { if (event.data.type sync) { store.$patch(event.data.state) } } // 当前 Tab 状态变化时通知其他 Tab store.$subscribe((mutation, state) { channel.postMessage({ type: sync, state }) }) }六、 总结Pinia 的正确打开方式回顾一下我们今天的内容拒绝全局变量思维Pinia 是中枢不是垃圾桶。拥抱 Setup Store利用组合式 API让 Store 内部逻辑复用 Composables实现内聚。明确职责边界State存真理源。Getter纯计算。Action协调 Composables不直接处理 HTTP 细节。事件驱动解耦Store 之间不要直接import通过mitt事件总线通信打破循环依赖。善用 Plugin将 Loading 管理、持久化、多 Tab 同步等横切关注点交给插件处理保持 Store 的纯净。最终架构图┌─────────────────────────────────────────────────────────────┐ │ App.vue │ │ (Global Loading based on useGlobalLoadingStore) │ └──────────────────────────┬──────────────────────────────────┘ │ ┌──────────────────────────▼──────────────────────────────────┐ │ Pinia Root │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Plugin Layer │ │ │ │ LoadingPlugin | PersistencePlugin | SyncTabsPlugin │ │ │ └─────────────────────────────────────────────────────┘ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ User Store │ │ Order Store │ │ Cart Store │ │ │ │ (Setup API) │ │ (Setup API) │ │ (Setup API) │ │ │ │ │ │ │ │ │ │ │ │ useLogin() │ │ useCreate() │ │ useClear() │ │ │ └─────┬───────┘ └─────┬───────┘ └─────┬───────┘ │ └────────┼─────────────────┼─────────────────┼────────────────┘ │ │ │ │ ┌────────────▼────────────┐ │ │ │ Event Bus (mitt) │◄───┘ (emit/listen) │ │ order:created | cart:clear│ │ └───────────────────────────┘ │ │ ▼ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Shared Layer │ │ ┌─────────────────────┐ ┌─────────────────────────────┐ │ │ │ Composables │ │ Entities (Types/Utils) │ │ │ │ useFetchUser │ │ User, Order, Product │ │ │ │ useCreateOrder │ │ │ │ │ └─────────────────────┘ └─────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘记住一句话组件负责看Composables 负责做Store 负责管。 下期预告《依赖倒置与接口隔离——打造坚如磐石的Vue3插件与高阶组件HOC模式进阶篇》你将学到如何通过 Provide/Inject 实现依赖倒置让组件不依赖具体实现适配器模式如何封装第三方 SDK如地图、支付Renderless Components无渲染组件如何彻底分离行为与视图