先记住这个答案
ref 在组合式函数内部创建时,每次调用通常获得独立状态;放在模块顶层时,同一模块实例的调用者会拿到同一个引用。前者适合局部编辑、分页等互不干扰的功能,后者适合经过明确设计的共享状态。但模块范围不一定等于单个用户或单个应用,尤其 SSR 中模块可能被多个请求复用,用户数据不能随意放入共享单例。需要应用或请求级状态时,应在相应工厂中创建并通过依赖注入或参数传递。
- 函数内创建通常按调用隔离,模块顶层通常按模块实例共享
- 共享数据与共享副作用的生命周期都要设计
- SSR 应明确请求级隔离,不能依赖模块变量区分用户
用引用身份理解两种写法
两个列表各有自己的当前页,把 ref 放在函数内可以避免翻动一个列表影响另一个。若把它移到模块外,两次调用返回的可能正是同一页码引用,组件数量增加后才出现串联更新。重构时看起来只是移动一行,实际已经改变状态所有权。
下面把两种写法并列。共享函数返回同一个模块变量,本地函数每次调用重新创建 ref。比较两次调用的 count 身份,再只修改其中一次返回值,可以直接看到共享与隔离,而不必依赖组件渲染来猜测。
import { ref } from 'vue';
const sharedCount = ref(0);
function useSharedCounter() { return { count: sharedCount }; }
function useLocalCounter() {
const count = ref(0);
return { count };
}两次 useSharedCounter 的 count 相同;两次 useLocalCounter 的 count 不同。该示例只演示状态范围,没有引入共享监听器或请求缓存。
共享状态还需要清楚的释放责任
共享一个连接状态时,多个组件可能都需要读取,但不能每个组件卸载时都无条件关闭同一连接。应由应用所有者、引用计数或其他明确策略管理资源寿命。共享 ref 本身很简单,真正复杂的是它背后的订阅、缓存与异步任务何时创建和停止。
本地状态也不保证完全独立。如果函数内创建的容器仍引用同一个外部对象,嵌套数据可能继续共享。判断范围要追踪所有可变来源,而不是只看最外层 ref 的声明位置;测试也应覆盖多个调用并存和其中一个销毁后的行为。
SSR 为什么要特别注意模块单例
服务端运行中,同一模块可能处理多个用户请求。把当前用户资料、权限相关输入或临时页面状态放在模块级 ref,可能让后来的请求读取前一个请求的数据。即使客户端页面之间共享符合预期,服务端的复用边界也不同。
常见做法是在应用或请求工厂中创建状态,再通过 provide/inject、显式参数或框架支持的状态管理交给需要者。测试应从干净状态开始,并模拟两个独立上下文验证隔离。简单清空全局变量难以处理并发请求,不应把它当作请求隔离的通用方案。
容易答错的地方
- 每个组件调用 use 函数就一定独立
- 函数可能返回模块级引用,是否隔离取决于实际创建与引用关系。排查“Vue 组合式函数模块状态与实例状态共享范围”时还要核对输入、版本和执行顺序,并用反例确认修复后的边界。
- SSR 里每个请求都会重新加载模块
- 运行环境可能复用模块及其变量,用户状态需要显式请求级设计。排查“Vue 组合式函数模块状态与实例状态共享范围”时还要核对输入、版本和执行顺序,并用反例确认修复后的边界。
面试官还会怎么问?
客户端全站共享状态可以放模块外吗?
简单场景可以,但要明确多应用、热更新、测试和资源清理的行为;复杂共享需求通常需要更清楚的所有者。
把模块 ref 每次初始化清零能避免请求污染吗?
并发请求可能互相覆盖,清零不是隔离。应为每个请求创建独立状态容器。针对“Vue 组合式函数模块状态与实例状态共享范围”,还应保留最小复现、预期结果和失败路径,避免只凭一次现象下结论。
如何测试一个 composable 是共享还是独立?
同时调用两次,比较引用并更新一侧,再检查另一侧变化;若有副作用,还要测试单侧释放不会破坏另一侧。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。