别让一个 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,问题不会只停在命名上;刷新会不知道刷哪边,缓存会不知道缓存谁,错误态和测试也会跟着模糊。

这次拆成四层:

Profile source 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

语义是阻止 SSR 时,就按 SSR 写 guard,不要额外假设测试环境一定有 import.meta.client === true。

慢响应不能覆盖新账号

账号 source 还有一个典型风险:账号切换时,旧请求可能比新请求更晚回来。

如果 A 登录后触发了 GetUserBasic(A),随后切到 B,GetUserBasic(A) 最后才返回。这个响应不能再写入 Pinia,也不能写入当前账号缓存。

实现上用了两个条件:

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 要刷新,因为页面正在展示自己。
  • 当前账号基础资料也要刷新,因为 Header 和右上角菜单展示自己。

客态关注、取关,影响两边但不是同一份数据:

  • 客态 Profile 的关系状态要刷新。
  • 当前账号的关注数、MeConfig 或相关计数要刷新。

客态打招呼扣金币,只影响当前账号钱包:

  • Profile 关系可能有业务状态变化。
  • 余额一定要刷新。

Header 里做客态操作,例如拉黑、备注,也可能影响当前 Profile 页面:

  • Header 发起操作。
  • Profile 页面展示关系或备注。
  • 成功后要显式通知 Profile route source 更新。

这些联动适合写成专门的刷新动作。全局 composable 监听所有变化,会让刷新原因难以追踪;显式刷新虽然多写几行,review 时却能直接看到影响面。

测试要跟着 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。

按状态归属约束后续改动

这次重构得到的规则可以沉淀进项目 Skill 和前端 Skill:

  • 一个 composable 名字必须回答 source owner。回答不了,就不要急着复用。
  • 只需要 uid 时,提供最小 identity source,不要为了一个 uid 触发完整资料加载。
  • 当前账号 source 和当前路由 source 不要互相包含。
  • 写操作成功后,按影响面显式刷新 source。
  • 客户端单槽 source 要处理 stale response,不能让旧 uid 的慢响应覆盖新 uid。
  • SSR guard 按真实语义写。只挡服务端时,优先判断 import.meta.server。
  • 纯 TS composable 如果会被单测直接 import,Vue 运行时 API 要显式 import。

两个身份混在同一个名字里,刷新和缓存就难以判断归属。拆开 owner 后,代码未必减少很多,但每个值的来源、作用范围和刷新时机都更明确。我更看重这个收益,也更愿意直接用入口表达身份,避免事后靠注释解释它实际属于哪个用户。