跳到主内容

前端状态管理选型决策:组合式函数、Pinia 与信号机制一文讲透 ​

「状态放哪」是每个 Vue3 团队都要回答的问题,而且往往是吵出来的:有人说全上 Pinia,有人说组合式函数就够,有人搬来 React 生态的信号(Signals)概念说细粒度更新才是未来。这场争论的根源是三者解决的问题有重叠但不相同——用同一个购物车需求分别实现一遍,差异立刻显形。

本文先拆三种方案的本质,再用同一个购物车做三份实现,最后给出可执行的选型决策树。所有代码基于 Vue3.4+ 的稳定 API。

三种方案到底在解决什么问题 ​

先把概念对齐,很多争论源于双方说的不是一件事:

  • 组合式函数:用 ref/computed 加普通函数封装状态逻辑。当 ref 声明在模块顶层(函数外)时,它天然是单例,所有调用方共享同一份状态——这是一种「零依赖的全局状态方案」。
  • Pinia:官方状态库,核心增值在于 store 的组织(命名、模块化拆分)、DevTools 集成(时间旅行、状态快照)、插件生态(持久化、同步 tab 等),以及 SSR 场景下每请求独立实例的安全模型。
  • 信号机制:一种比组件级重渲染更细的响应式策略——依赖被精确收集到「读取响应式值的那行代码」,更新时只重执行真正依赖它的计算。Vue 的 ref/computed 本身就是这个思想的实现;Solid、Qwik、Angular 的 signals 把它推向了编译期或框架级优化。

一句话概括:Vue 里你写的每个 ref 就是一个信号。所以「要不要信号」在 Vue 语境下不是换库问题,而是「是否充分用好了 ref/computed 的细粒度特性」。

实测:同一个购物车的三种实现 ​

方案一:模块级单例的组合式函数,适合轻量共享:

ts
// composables/useCart.ts:模块级单例,零依赖全局共享
import { ref, computed } from "vue";

// 注意:ref 必须声明在模块作用域(函数外),调用方才共享同一份数据
const items = ref<CartItem[]>([]);

export function useCart() {
  const total = computed(() => items.value.reduce((s, i) => s + i.price * i.count, 0));
  function add(item: CartItem) {
    const exist = items.value.find((i) => i.id === item.id);
    exist ? exist.count++ : items.value.push({ ...item, count: 1 });
  }
  function clear() { items.value = []; }
  return { items, total, add, clear };
}

方案二:Pinia store,适合正式的业务模块:

ts
// stores/cart.ts:defineStore 组合式写法,DevTools 与插件全量可用
import { defineStore } from "pinia";

export const useCartStore = defineStore("cart", () => {
  const items = ref<CartItem[]>([]);
  const total = computed(() => items.value.reduce((s, i) => s + i.price * i.count, 0));
  // 复杂异步、跨 store 协作、服务端同步这类「业务重量级」逻辑放这里
  async function syncFromServer() {
    const remote = await fetchCart();
    items.value = remote;
  }
  return { items, total, syncFromServer };
}, {
  // persist 插件:页面刷新购物车不丢
  persist: true
});

方案三:细粒度更新的运用。信号思想在 Vue 里体现为「让读取路径尽可能窄」。实测过一个百行表格场景:把「选中行 id」做成独立的小 ref,而不是塞进大对象里整体替换,更新选中态时只有引用它的那几行重渲染,整表不抖。反过来,把所有状态揉进一个巨型 reactive 对象,任何字段变更都可能触发大范围重算——这时抱怨「状态管理卡」其实是在为粗粒度的组织方式买单。

三份实现跑下来,功能完全等价。差异在工程属性上:组合式函数最轻但调试全靠自觉;Pinia 多一层样板但换来组织与调试基建;细粒度更新不是独立方案而是贯穿两者的使用技巧。

选型决策树:按规模与场景回答三个问题 ​

第一问:**状态跨几个组件?**组件树内两三层可传递,props/provide 足够,别为局部状态建全局 store。第二问:**要不要 SSR?**要 SSR,全局共享的单例 ref 是事故源(详见避坑第一条),Pinia 的每请求实例模型是唯一安全选择;纯 SPA,组合式单例完全可用。第三问:**模块复杂度如何?**有跨模块异步协作、需要持久化、需要时间旅行调试,选 Pinia;独立的小块共享状态(主题、当前用户信息),组合式函数更轻。

实测建议的落点:中大型项目「Pinia 为主干、组合式函数做局部轻共享」的组合最稳;纯组合式函数方案适合原型与小型工具站;「信号」不必引入额外依赖,用好 Vue 原生的细粒度响应式,收益已经拿到大半。

避坑总结 ​

  1. SSR 下模块级单例会跨请求泄漏。服务端只有一个 Node 进程,模块顶层的 ref 在请求 A 和请求 B 之间共享,用户 A 能看到用户 B 的购物车——真实发生过的生产事故;SSR 项目要么全量 Pinia(每请求新实例),要么用工厂函数让每次调用创建独立状态。
  2. ref 写进函数体里就失去了共享性。useCart() 里 const items = ref([]) 每次调用都新建一份,两个组件拿到的购物车永远不同步;共享状态必须提升到模块作用域,这是组合式方案最常见的翻车点。
  3. 别把组件局部状态塞进全局 store。「这个弹窗开没开」进 Pinia 的结果是 store 膨胀到没人敢动;判断标准很简单——刷新页面后这个状态需要恢复吗?不需要就不进全局。
  4. 持久化必须带版本号。结构一变,旧数据反序列化进新结构轻则字段丢失重则运行时报错;持久化载荷里加 version 字段,读取时按版本迁移,迁移逻辑与字段定义放同一个文件里。
  5. computed 里禁止副作用。在计算属性里写 localStorage、发请求,会让「缓存重算时机」变得不可预测(computed 是惰性的、可能不执行);副作用一律放 watch 或事件回调,显式声明依赖与触发时机。

写在最后 ​

状态管理选型的分歧,多数时候不是技术分歧而是语境错位:拿小工具站的经验指导多模块中后台,或者拿 SSR 大项目的规范约束两页面活动页。三种方案本质是一条连续光谱上的不同位置——共享范围从组件树到全局,工程基建从零到全套。按「跨组件范围、SSR 与否、模块复杂度」三问定位自己的位置,选型自然收敛,不必执着于某个标准答案。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。