Vue Composables 的共享状态陷阱:为什么两个计数器会同步
译注:本文编译自 dev.to 作者 Parsa Jiravand 的技术文章《Vue Composables: The Shared State Trap (+ Cheat Sheet)》,原文发布于 2026 年 9 月 22 日,针对 Vue 3.5.42 稳定版撰写。为便于中文开发者阅读,文中代码与术语保持原貌,部分表述做了本地化处理。原文链接见文末。
导语
你写了一个 useCounter() composable,在页面上放了两个 <Counter />,点击第一个按钮——两个计数器同时加一。这不是复制粘贴出的 bug,也不是 Vue 的问题,而是你对「composable 到底是什么」的心智模型存在缺口。而这个缺口在服务端渲染场景下会变得极其危险:那里的「另一个计数器」可能是另一个用户的浏览器标签页。
核心心智模型:composable 只是一个普通函数
原文给出的关键结论是:composable 与「组件状态」之间没有任何特殊关系,它就是一个普通的 JavaScript 函数,一切由 JavaScript 自身的作用域规则决定。
- 声明在函数体内部的状态,每次函数执行时都会重新创建。由于
<Counter />每个组件实例都会调用一次useCounter(),每个实例就拿到自己的ref——私有状态。 - 声明在函数外部(模块作用域)的状态,只在模块首次被导入时创建一次。此后每次调用
useCounter()都返回同一个ref的引用。这不是 composable 模式的缺陷,而是 ES 模块一贯的行为。
Vue 的 Composition API 规则(ref、reactive、onMounted、provide/inject 等)从未规定你的 const 该放在哪里。useXxx 的命名约定只说明它做什么,不说明它存在几份——后者的答案只需读一件事:这个 ref() 调用是在导出函数内部还是外部。
四个阶段
阶段一:状态放在函数内部。 把 count 移入 useCounter,每次调用都成为各自 ref 上的闭包。导出形式没变,只是 count 的作用域从模块级移到了函数级。这是绝大多数情况下你想要的默认做法。
阶段二:带副作用的 composable 需要清理。 以 useMousePosition 为例,若省略 onUnmounted 中的 removeEventListener,每个用过该 composable 的组件都会在 window 上永久留下一个 mousemove 监听器。这不是崩溃,而是缓慢、静默的泄漏,表现为「页面切换一阵子后越来越卡」。composable 的生命周期钩子会把副作用绑定到调用它的组件上——你启动了它,就要负责在对应钩子里停掉它。
阶段三:composable 调用其他 composable。 useEventListener 在 <script setup> 中调用没问题,因为 onMounted/onUnmounted 会注册到当前正在运行的组件实例上。但若从 Pinia store 的 setup 或普通测试文件中调用,就没有「当前实例」可挂载,Vue 会打印开发警告,回调永远不会触发。通用解法是 onScopeDispose,它绑定到 Vue 的 effect scope 而非特定组件实例——组件自身的 setup 函数本身就是一个 effect scope。
阶段四:当你确实需要共享状态。 登录用户、主题偏好、WebSocket 连接这类全局单例,模块级状态才是正确设计。此时应「有意地」把状态放在函数外,并用 readonly() 封住写路径,让所有写入都经过 login()/logout()。原文强调:让这段代码成为单例的写法,与开头的 bug 在形态上完全一致;区别只在于意图和对写路径的保护,因此一句解释「为什么这里是模块级」的注释并非装饰,而是读者无法从代码形态中获取的关键信息。
边界情况与注意事项
- SSR 会让这个 bug 更严重。 在浏览器中,模块级
ref只在单个用户的页面内共享,虽烦但可控。而在 Vue SSR 服务器(Nuxt 或自建方案)上,一个 Node 进程通常用同一份已加载模块渲染多个不同用户的请求。模块级ref在服务器启动时创建一次,而非每请求一次,于是useAuthUser的状态可能从第一个请求的用户泄漏到下一个用户的响应中。这是「状态在函数内还是函数外」必须刻意搞对、而非凭习惯决定的最重要原因。 - 在组件外测试 composable 会抛错或警告。 无活动组件实例时调用
onMounted/onUnmounted会触发开发警告且不生效。单元测试时应用effectScope()包裹调用,让onScopeDispose之类的钩子有处可挂:
import { effectScope } from 'vue'
const scope = effectScope()
scope.run(() => {
// 在这里调用 composable
})
scope.stop() // 触发 onScopeDispose 清理小结
原文在结尾提供了一份速查表(Cheat Sheet),供开发者在编写下一个 composable 时对照使用。核心判断标准始终只有一条:这个 ref() 是在导出函数内部还是外部创建的——它决定了状态是每个调用者私有,还是全应用共享。
原文链接
https://dev.to/parsajiravand/vue-composables-the-shared-state-trap-cheat-sheet-37ia