别让一个 composable 承担两种身份:一次 Profile 全局状态重构
Profile 页同时涉及两个身份:当前登录账号和当前正在访问的用户。主态时这两个 uid 相同,客态时它们不同。Header、右上角账号菜单、充值入口、Profile 编辑器、备注缓存、关注按钮和打招呼扣金币,都需要分清数据属于哪个身份。
这次重构前,项目里有一个叫 useMeProfile 的 composable。名字像「Me 页资料」,实际调用方远不止 Me 页:
- Header 用它展示当前账号头像和昵称。
- Profile 编辑器用它拿当前账号资料,保存后刷新它。
- 充值入口用它拿当前账号 uid。
- 备注缓存用它判断当前登录 uid。
- Profile 页用它判断当前路由用户是不是自己。
代码能跑,但名字和职责已经不匹配。后来只要遇到 Header 和 Profile 联动,问题就会变成一句很模糊的话:「刷新一下用户资料」。这句话没法指导代码,因为它没有说清楚刷的是当前登录账号,还是当前路由 Profile。
这次重构先确定每份状态的 owner,再按归属调整刷新、缓存、慢响应保护和测试。
当前账号和路由 Profile 的 source
旧结构里的 useMeProfile 没有明确区分这些职责。它有时表示当前账号,有时只是为了拿当前账号 uid,有时又被 Profile 路由拿来做主客态判断。一个 composable 同时承担两个 owner,问题不会只停在命名上;刷新会不知道刷哪边,缓存会不知道缓存谁,错误态和测试也会跟着模糊。
这次拆成四层:
useCurrentAccountIdentitySource 只负责当前登录账号 uid。它不请求 GetUserBasic,也不触发余额、设置和红点。Profile 路由判断主态 / 客态时,只需要这个最小身份 source,避免为一个 boolean 判断额外加载完整账号资料。
useCurrentAccountBasicInfoSource 负责当前登录账号的基础资料:GetUserBasic、头像昵称本地缓存、Pinia 里的当前账号快照。Header、账号弹窗和 Profile 编辑器都读它。
useCurrentAccountSources 是当前账号聚合入口。它组合基础资料、余额、MeConfig、MeSetting、备注和红点,但它仍然只服务当前登录账号。
useProfileState 继续服务当前路由用户。它管 /profile/:uid 的 Profile 数据、关系状态和预加载,不把当前账号完整 source 拉进来。
拆完以后,调用关系变得直接:
const {
accountUid,
basicInfo,
walletBalance,
meConfig,
refreshBasicInfo,
refreshCurrentAccountSources,
} = useCurrentAccountSources()
只需要判断「我是谁」的地方,使用最小入口即可:
const { accountUid } = useCurrentAccountIdentitySource()
Profile 路由判断主客态时不再隐式触发当前账号的完整加载。调用方只读取需要的 source,也就避免了无关刷新。
当前账号基础资料用单槽
Header、右上角菜单和 Profile 编辑器只展示当前登录账号,因此 useCurrentAccountBasicInfoSource 使用单槽,没有做成 Map。
账号 A 切到账号 B 时,继续展示 A 的昵称和头像,比短暂空态更糟。这个判断决定了单槽的行为:
- 当前 uid 为空,展示空态。
- 当前 uid 是 A,只能展示 A 的资料。
- 切到 B 后,A 的资料必须马上退出展示链路。
任意用户 Profile 的缓存属于另一层。与当前账号 source 共用 Map,会让当前账号首帧与任意用户缓存互相污染。
这也影响本地缓存策略。localStorage 里的头像昵称可以作为当前账号首帧兜底,但它只能在客户端使用。SSR 阶段读不到本地缓存,所以服务端输出应该保持稳定空值,Header 再通过 hydrated 守卫避免首帧水合错位。
代码里判断环境时也踩了一个小坑。第一次写成:
if (!state || !import.meta.client) return
在 Vitest 环境里,import.meta.server 是 false,但 import.meta.client 不一定被注入成 true。结果单测里请求被跳过。这里的真实语义只是「不要在 SSR 执行」,所以更稳的是:
if (!state || import.meta.server) return
这里依赖 Nuxt 构建注入的环境标记,测试也要明确配置这些标记。只阻止 SSR 时可以直接判断 import.meta.server;但普通 Node 环境里两个字段都未定义,并不能据此认定正在浏览器中执行。需要访问 DOM 或浏览器存储的代码,仍要检查对应运行条件。
慢响应不能覆盖新账号
账号 source 还有一个典型风险:账号切换时,旧请求可能比新请求更晚回来。
A 登录后触发 GetUserBasic(A),随后切到 B,A 的请求却可能最后才返回。此时它已经不属于当前账号,不能再写入 Pinia 或当前账号缓存。
实现上用请求序号和 uid 记录本轮请求:
let requestSerial = 0
let pendingUid = ''
每次刷新递增 requestSerial,同时记录本次 owner uid。响应回来后只有满足两个条件才允许提交:
if (requestSerial !== currentSerial || pendingUid !== ownerUid) return
if (accountUid.value !== ownerUid) return
业务请求可能无法取消,也可能已经进入响应处理阶段,因此提交前还要检查 owner,在写入状态的最后一刻确认「这份响应还属于当前账号」。
账号切换时清理也要小心。第一次实现里粗暴 clearBasicInfo(false),结果可能把登录流程刚写入的 B 账号资料也清掉。后来改成只丢弃上一个 uid 的内存快照:
- 登出时清空当前账号资料和本地缓存。
- A 切 B 时只清 A。
- 如果 Pinia 里正好还是 A,才清 Pinia。
- 如果登录流程已经写入 B,不要碰 B。
注释需要说明只清旧 uid 的原因,避免后续简化为全量 clear,把登录流程刚写入的新账号资料一起清掉。
写操作后要显式刷新哪一边
拆 source 后,写操作不能再靠一个模糊的「刷新用户资料」兜底。每个写操作都要说清楚影响哪一边。
刷新范围由写操作实际影响的数据决定:
- 主态改头像、昵称。当前路由 Profile 和当前账号基础资料都要刷新,因为 Profile、Header 和右上角菜单都在展示自己。
- 客态关注、取关。刷新客态 Profile 的关系状态,以及当前账号的关注数、MeConfig 或相关计数,两边更新的并不是同一份数据。
- 客态打招呼扣金币。当前账号钱包余额必须刷新;Profile 关系是否更新,取决于业务状态是否变化。
- Header 中的客态操作。拉黑、备注从 Header 发起,关系或备注却可能展示在当前 Profile 页,成功后要显式通知 Profile route source 更新。
这些联动适合写成专门的刷新动作。全局 composable 监听所有变化,会让刷新原因难以追踪;显式刷新虽然多写几行,review 时却能直接看到影响面。
测试从当前账号 identity 与 basic info 的真实 owner 驱动,再用项目文档说明各 source 的读取与刷新责任。完整内容见状态归属的测试与项目约定弹窗。
总结
useMeProfile 同时承载当前账号和路由用户,导致「刷新用户资料」缺少明确对象。这次把当前账号的 identity、基础资料、聚合入口与路由 Profile source 分开,只判断主客态的调用方不再加载完整账号资料,写操作也按实际影响显式刷新对应 source。
归属还决定数据怎样进出状态:当前账号基础资料使用单槽,SSR 保持稳定空值,客户端缓存通过水合守卫参与展示。切换账号时只清旧 uid,避免误清登录流程刚写入的 B 资料;响应提交前再核对请求序号和 owner,防止 A 的慢响应覆盖 B。测试随真实认证态和 source 组合调整,README 则集中说明入口与刷新约定。
状态归属的测试与项目约定
测试要跟着 source owner 改
删掉 useMeProfile 后,很多测试不能再 mock 它。这个改动反而逼测试更接近真实结构。
当前账号相关测试改成通过 useAuth() 和 useAppStore() 驱动 uid。这样测到的是当前账号 identity source 和 basic info source 的组合结果,不是一个过度简化的 mock。
新增测试覆盖了三个风险:
- 当前账号 uid 来自认证态,而不是从随手可见的昵称字段猜。
session.name不直接 seed 业务资料,头像昵称必须来自业务 source。- A 的慢响应不会覆盖 B。
这里还暴露了另一个工程细节:纯 TS composable 里最好显式 import Vue API。
组件 <script setup> 里依赖 Nuxt 自动导入通常没问题,但单测直接 import 一个 TS composable 时,测试运行环境不一定替它注入 ref、computed。如果这个文件本身就是可直接 import 的 TypeScript 模块,写清楚:
import { computed, ref } from 'vue'
这条显式 import 负责给纯 TS 模块补上独立运行边界,组件里的 Nuxt 自动导入约定仍然可以保留。
README 比注释更适合写全局约定
代码注释适合解释反常逻辑,例如:
- 为什么当前账号 source 是单槽。
- 为什么 A 切 B 时不能全量 clear。
- 为什么失败时保留旧头像昵称。
- 为什么只用
import.meta.server挡 SSR。
composable 文件夹里的 README.md 更适合写宏观约定:
- 推荐入口是什么。
- 哪些 source 属于当前账号。
- 哪些 source 属于路由 Profile。
- 写操作后应该刷新哪一边。
- 不要重新新增什么名字含糊的 wrapper。
README 的价值是给后来的开发者和 Agent 一个入口。看到 useCurrentAccountSources 时,先读 README,就能知道这里只管理当前登录账号;任意用户资料缓存和 Profile 页路由状态有各自的 source。