少一点 formatter:前端展示层和后端原始字段的边界
有段时间我特别想把组件 prop 变少。
一个账号面板要展示头像、昵称、金币、钻石、访客、红点、关系数、等级入口、钱包入口。父组件传一长串 prop,看起来很吵。于是很自然会想:包一个 accountOverviewAccount,里面把所有展示字段都整理好,子组件只接收一个对象,多清爽。
后来发现,这种“清爽”很容易变成另一个问题:接口字段的来源被藏起来了。
你看到 accountOverviewAccount.walletCoins,不知道它来自 walletBalance,还是 meConfig,还是某个 Header 已经算过一遍的值;看到 following,不知道它是不是接口里的 fav;看到 visitorPreview,不知道它是后端直接返回,还是前端用头像数组和数量拼出来的。等业务逻辑出问题,排查路径会变成“先找 formatter,再找 formatter 调了谁,再找 raw source 在哪”。
这篇文章想讲的不是“prop 越少越好”,也不是“永远传原始接口字段”。更准确的原则是:
原始数据源停在拥有它的业务 owner;展示派生值放在最近的真实消费处。不要为了减少 prop 数量,提前制造只有改名意义的 view-model。
问题不在 prop 多,而在边界不清
假设后端给了几份当前账号相关数据:
type AccountSources = {
basicInfo: UserBasicInfo | null
meConfig: MeConfigV2Response | null
meSetting: MeSettingV2Response | null
walletBalance: CurrencyUserBalanceResponse | null
}页面 Header 需要头像、金币、钻石和账号入口;账号 Overview 弹窗需要头像、钱包、访客、关系数、红点、入口显隐;我的页也会复用其中一部分。
如果每个调用点都自己请求接口,就会重复请求。如果父组件把所有字段拆成几十个展示 prop,调用点会很长。如果包一个大 view-model,又容易把来源藏掉。
所以先别问“传几个 prop 最好”,先问两个更基础的问题:
- 谁拥有原始请求?
- 谁真正消费某段业务语义?
请求 owner 应该负责拿到 raw response、缓存或刷新它。展示 consumer 应该负责把 raw response 派生成自己需要的 UI 状态。中间如果只有改名,没有业务语义,就不要加一层。
rename-only view-model 最危险
最危险的是这种对象:
const accountOverviewAccount = computed(() => ({
avatarName: basicInfo.value?.name,
avatarSrc: basicInfo.value?.avatar,
following: meConfig.value?.relationsUserCount?.fav,
followers: meConfig.value?.relationsUserCount?.fan,
friends: meConfig.value?.relationsUserCount?.friend,
coins: walletBalance.value?.coin,
diamonds: walletBalance.value?.diamond,
}))它看起来像抽象,其实大多只是改名。
改名本身不是罪。问题是这些新名字会逐层传播,最后组件里没人知道 following 对应接口字段 fav。一旦 Android 或后端文档里继续用 fav / fan / friend,Web 代码却变成 following / followers / friends,排查时脑子里要反复做映射。更糟的是,如果某个字段被误解了,view-model 会把误解包装成“前端自己的字段”,后续更难改。
这种对象还会制造一种错觉:accountOverviewAccount 像是一个新的业务模型。但它并不是后端契约,也不是产品概念,只是当前页面为了方便传参拼出来的中间态。名字越像领域模型,越容易让后续代码依赖它。
更稳的做法:raw source 入场,派生靠近消费
对于账号 Overview 这种明确的业务面板,可以让它直接接收 raw source:
<CommonAccountOverview
:basic-info="basicInfo"
:me-config="meConfig"
:me-setting="meSetting"
:wallet-balance="walletBalance"
/>组件内部再用一个小 composable 派生展示状态:
const { menuState, walletState, visitorState } = useAccountOverviewDerivedState({
basicInfo,
meConfig,
meSetting,
walletBalance,
})这样有几个好处:
- 调用点一眼能看出传的是后端 / 业务原始大字段。
- Overview 内部拥有自己的展示规则,不需要 Header 或 Me 页面替它算一堆细节。
- 派生逻辑集中在一个和 Overview 同域的地方,测试可以直接覆盖。
- 如果某个展示字段有 bug,能从
meConfig、meSetting、walletBalance直连到 UI,不必穿过一个重命名层。
这不是说永远不要派生。派生当然要有,只是它要靠近真实消费处,而且名字要说明它是展示派生,不要伪装成新的接口模型。
什么时候可以传局部 view
还有一种情况刚好相反:父组件已经消费过接口字段了,这时再让叶子组件重复 formatter 也不好。
比如父组件拿到一个关系列表,它已经在 v-for 里判断每一项是 CP、挚友、邀请位,已经决定卡片主题、背景、标题、头像和天数。这个时候如果继续把原始 item 传给 CpCard,让 CpCard 再读 level / day / relationName / bgUrl,同一套业务语义就被父子各消费一次。
这时父组件可以产出局部展示对象:
const cards = computed(() =>
relationshipItems.value.map((item) => ({
id: item.id,
title: resolveRelationshipTitle(item),
daysLabel: resolveDaysLabel(item),
avatarSrc: resolveAvatar(item),
themeTier: resolveThemeTier(item),
backgroundSrc: resolveBackground(item),
})),
)然后叶子组件只负责画卡片:
<RelationshipCard
v-for="card in cards"
:key="card.id"
:title="card.title"
:days-label="card.daysLabel"
:avatar-src="card.avatarSrc"
:theme-tier="card.themeTier"
:background-src="card.backgroundSrc"
/>这不是和前面矛盾,而是同一条原则的另一面:
如果父组件已经真实消费了这段业务语义,就在父组件产出局部展示对象;不要让叶子组件重复消费同一份 raw item。
如果父组件只是为了少传 prop 而改名包装,就不要包。
判断关键不是 raw vs view 的教条,而是谁拥有这段语义。
API module 不做 UI formatter
还有一个边界要守住:API module 只负责打到正确 endpoint,并透传接口 JSON。
它不应该做这些事:
- 把
fav / fan / friend改成following / followers / friends。 - 把接口的
birthday转成页面输入框文案。 - 根据
meSetting和gender计算某个菜单入口是否显示。 - 为了兼容早期 mock,同时支持三套旧字段。
- 把后端错误转成某个 UI 组件的状态。
这些逻辑都属于业务消费层。API module 如果开始做 UI formatter,会让所有调用方都被迫接受同一套展示判断,也会让真实接口契约变得不透明。
一个更好的边界是:
- API module:endpoint、method、参数、raw response。
- domain composable / helper:字段语义、权限态、空态、按钮态、展示派生。
- 组件:布局、交互、slot、emit。
如果旧字段兼容只是为了早期 fixture 或 mock,优先改 fixture,不要把兼容逻辑长期塞进 API module。否则每个新维护者都会以为这些字段都可能真实存在。
加载和错误也属于边界
数据边界不只影响字段,也影响 loading 和 error。
如果某个组件展示前一定需要三份 raw source,就不要让它内部偷偷再请求一次,也不要让外层和内层各请求一次。更好的方式是把请求 owner 定清楚:
- Header 外层需要金币 / 钻石,就由 Header 当前账号数据源统一请求。
- Overview 打开时只消费这份数据,不重复请求。
- Me 页如果没有 Header,页面自己就近请求,然后传给 Overview。
错误态也要分清:
- 渲染时异常,比如组件真的 throw,交给 ErrorBoundary。
- 请求过程失败,比如保存失败、上传失败、接口 504,通常用 Toast 或局部错误态,不要随手把整个页面切成错误页面。
- 加载中需要遮住局部内容,就用局部 loading,不要让子组件因为数据暂时为空而渲染一堆默认假值。
这些边界说清楚之后,代码会少很多“兜底”。兜底本身不是坏事,但如果它掩盖了 owner 不清、请求重复或字段误解,后面一定会变成排查成本。
测试怎么跟上
这类重构如果只靠 E2E,很难守住边界。E2E 能证明用户路径跑通,但不能证明代码没有重新加一层旧 view-model。
我更倾向于分三层测:
1. API contract test
守 endpoint、method、参数和 raw 字段语义:
it('getMeConfigV2 保持返回 meConfig 原始字段,不做 UI view-model 转换', () => {
// 断言 API module 调用正确 endpoint,返回 response body。
})这类测试的重点不是 mock 后端业务,而是防止调用链回退到旧 transport,或在 API module 偷偷做 UI formatter。
2. derived helper unit test
守展示派生规则:
it('从 relationsUserCount 派生关注、粉丝和好友展示数', () => {
const state = deriveAccountOverviewState({
meConfig: {
relationsUserCount: {
fav: 12,
fan: 34,
friend: 5,
},
},
})
expect(state.relations).toEqual({
fav: 12,
fan: 34,
friend: 5,
})
})这里可以把 Android、后端字段和 Web UI 的语义写清楚。比如 fav 就是关注数,不要在中间改成另一个名字再传十层。
3. architecture / source test
有些边界更适合 source-level test:
- 某个组件不再 import 旧 host。
- API module 不出现
/api/grpc/...fallback。 - Overview 调用点不再传
:wallet-coins、:red-dots这种扁平展示 prop。 - i18n key 必须紧跟
t(),不能藏在常量里。
这类测试不是为了替代用户路径,而是为了防止架构边界被无意改回去。它适合那些“运行时能跑,但维护性会变差”的规则。
最后:别把 formatter 当垃圾桶
formatter 很有用,但它不应该成为垃圾桶。
好的 formatter 有明确输入、明确输出、明确业务语义,比如“把生日时间戳转成日期输入框值”“把接口关系字段派生成关系卡片展示状态”。坏的 formatter 只是把所有字段重新命名一遍,然后让调用方以为自己拿到了一个更高级的业务模型。
我现在判断一个 formatter 是否值得保留,通常会问:
- 它有没有真正减少业务复杂度,而不是只减少 prop 数?
- 它的输出是不是一个稳定产品概念?
- 它是不是离真实消费点足够近?
- 它有没有把接口字段来源藏到难以追踪?
- 如果明天后端字段变了,我能不能一眼找到影响范围?
答案不清楚时,先别包。
很多时候,直接把 raw source 传到业务 owner,再在最近的消费处派生,反而是最容易理解、最容易测试、也最不容易被下一轮重构改歪的方案。
