少一点 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 最好”,先问两个更基础的问题:

  1. 谁拥有原始请求?
  2. 谁真正消费某段业务语义?

请求 owner 应该负责拿到 raw response、缓存或刷新它。展示 consumer 应该负责把 raw response 派生成自己需要的 UI 状态。中间如果只有改名,没有业务语义,就不要加一层。

原始接口大字段在业务 owner 处保留,展示派生靠近真实消费组件

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,能从 meConfigmeSettingwalletBalance 直连到 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 转成页面输入框文案。
  • 根据 meSettinggender 计算某个菜单入口是否显示。
  • 为了兼容早期 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 是否值得保留,通常会问:

判断 formatter 是否值得保留:先看它是不是只改名,再看业务语义是否已经被父组件消费

  1. 它有没有真正减少业务复杂度,而不是只减少 prop 数?
  2. 它的输出是不是一个稳定产品概念?
  3. 它是不是离真实消费点足够近?
  4. 它有没有把接口字段来源藏到难以追踪?
  5. 如果明天后端字段变了,我能不能一眼找到影响范围?

答案不清楚时,先别包。

很多时候,直接把 raw source 传到业务 owner,再在最近的消费处派生,反而是最容易理解、最容易测试、也最不容易被下一轮重构改歪的方案。