别让一个 composable 承担两种身份:一次 Profile 全局状态重构
Profile 页很容易把状态写乱。
它看起来只是一个用户主页,实际同时站着两个人:当前登录账号和当前正在访问的用户。主态时这两个 uid 相同,客态时它们不同。Header、右上角账号菜单、充值入口、Profile 编辑器、备注缓存、关注按钮、打招呼扣金币,全都会在这两个身份之间来回穿。
这次重构前,项目里有一个叫 useMeProfile 的 composable。名字像“Me 页资料”,实际调用方远不止 Me 页:
- Header 用它展示当前账号头像和昵称。
- Profile 编辑器用它拿当前账号资料,保存后刷新它。
- 充值入口用它拿当前账号 uid。
- 备注缓存用它判断当前登录 uid。
- Profile 页用它判断当前路由用户是不是自己。
代码能跑,但名字和职责已经不匹配。后来只要遇到 Header 和 Profile 联动,问题就会变成一句很模糊的话:“刷新一下用户资料”。这句话没法指导代码,因为它没有说清楚刷的是当前登录账号,还是当前路由 Profile。
source owner 要先说清楚
这类状态最好先问一个问题:这份数据到底属于谁?
旧结构里的 useMeProfile 答不上来。它有时表示当前账号,有时只是为了拿当前账号 uid,有时又被 Profile 路由拿来做主客态判断。一个 composable 同时承担两个 owner,就会把刷新、缓存、错误态和测试都带偏。
这次拆成四层:
flowchart LR
Identity["当前账号身份 source\n只回答 accountUid"]
Basic["当前账号基础资料 source\nGetUserBasic + 头像昵称缓存"]
Raw["当前账号原始数据 source\n余额 / MeConfig / MeSetting"]
Route["Profile route source\n当前 /profile/:uid 用户"]
Identity --> Basic
Identity --> Raw
Identity --> Route
useCurrentAccountIdentitySource 只负责当前登录账号 uid。它不请求 GetUserBasic,也不触发余额、设置和红点。Profile 路由判断主态 / 客态时,只需要这个最小身份 source。
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 路由不再因为判断主客态而隐式触发当前账号的完整加载。
当前账号基础资料用单槽
useCurrentAccountBasicInfoSource 没有做成 Map。原因很简单:Header、右上角菜单和 Profile 编辑器只展示当前登录账号。
账号 A 切到账号 B 时,继续展示 A 的昵称和头像,比短暂空态更糟。当前账号 source 应该是一个单槽:
- 当前 uid 为空,展示空态。
- 当前 uid 是 A,只能展示 A 的资料。
- 切到 B 后,A 的资料必须马上退出展示链路。
任意用户 Profile 的缓存属于另一层,不应该混进当前账号 source。
这也影响本地缓存策略。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,不要额外假设测试环境一定有 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。
这是很小的一段逻辑,但注释必须写清楚。否则下一次重构时,很容易有人觉得“这里直接 clear 一下更简单”。
写操作后要显式刷新哪一边
拆 source 后,写操作不能再靠一个模糊的“刷新用户资料”兜底。每个写操作都要说清楚影响哪一边。
主态改头像、改昵称,影响两边:
- 当前路由 Profile 要刷新,因为页面正在展示自己。
- 当前账号基础资料也要刷新,因为 Header 和右上角菜单展示自己。
客态关注、取关,影响两边但不是同一份数据:
- 客态 Profile 的关系状态要刷新。
- 当前账号的关注数、MeConfig 或相关计数要刷新。
客态打招呼扣金币,只影响当前账号钱包:
- Profile 关系可能有业务状态变化。
- 余额一定要刷新。
Header 里做客态操作,例如拉黑、备注,也可能影响当前 Profile 页面:
- Header 发起操作。
- Profile 页面展示关系或备注。
- 成功后要显式通知 Profile route source 更新。
这类联动最好写成专门的刷新动作,而不是让某个全局 composable 暗中监听一切。全局监听看起来省事,后面会变成谁都不知道为什么刷新、为什么没刷新。
测试要跟着 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。
Profile 页状态复杂的根源是两个身份同时存在。只要这两个身份混在一个名字里,后面的所有刷新和缓存都会变得像猜谜。
把 owner 拆清楚以后,代码不一定少很多,但读代码的人能更快知道每个值从哪里来、属于谁、什么时候会刷新。这个收益比少写一个 composable wrapper 更大。