组件 props 和 events 设计:少传大对象,少暴露内部细节

组件要不要开一个新 prop,很多时候不是能力问题,是这个字段该不该由组件来知道的问题。能传肯定能传,props 本来就是一个开放的对象,随便加一个字段技术上毫无障碍。真正要想清楚的是:这个字段传进去之后,组件和调用方之间的耦合会不会因此变紧,以后想改会不会被这个字段拖住。

这条线把握不住的时候,组件会朝两个方向坏掉。要么 props 越加越多、事件越拆越细、插槽越开越野,组件表面上被复用了很多次,实际上只是把耦合从一个地方搬到了另一个地方;要么反过来,团队为了"通用"而把组件做得极度抽象,调用方每次用都要传一堆配置项才能拼出想要的效果,复用的门槛比自己写一遍还高。这两种坏法我在团队里都见过,判断标准其实是同一套东西:props 是不是传了组件不需要知道的信息,events 是不是暴露了组件不该暴露的内部机制,插槽是不是把临时需求当成了长期契约。

判断一:props 该传对象还是拆字段

最常见的分歧发生在传对象还是传字段上。

1<UserCard :user="user" />

这样写没有语法问题,但要看组件真正用到了 user 的哪些字段。如果 UserCard 只显示名字和头像,传整个 user 对象意味着组件隐式依赖了后端用户模型的完整结构——哪怕组件内部只读两个字段,类型层面它对整个对象都有依赖,后端改一次字段名,类型检查就可能在组件里报错,即使组件根本没用到那个字段。

拆开会更稳定:

1<UserCard :name="user.name" :avatar="user.avatarUrl" />

这样组件只声明它真正消费的东西,后端模型怎么改,只要 nameavatarUrl 还在,组件就不受影响。调用方也更容易把这个组件搬到别的页面用,不需要现凑一个 user 形状的对象出来。

但这不是"永远拆字段"的教条。如果组件本身就是业务组件,比如 UserProfileCard,它的职责就是展示一个完整的用户档案,理解并依赖 user 对象是合理的,拆成十几个字段反而是过度设计。真正要问的是:这个组件的职责范围里,"用户"是不是第一等公民。是,就大方接受这个对象;不是,只是顺路展示几个字段,就只拿字段,不背整个类型。

判断二:props 表达语义还是表达样式

第二个容易踩的坑是 props 该表达语义,还是表达具体样式值。

1<Button color="#1677ff" :radius="8" padding="0 16px" />

这种写法把组件变成了一个可以随意调参的样式容器。短期看很灵活,业务页面想要什么颜色都能传进去。但设计规范会因此失控——半年后团队里可能出现十几种蓝,谁都说不清哪个是"正确"的主按钮蓝,因为每个页面传的十六进制值都不完全一样。

更推荐限定成有限集合:

1<Button type="primary" size="medium" />

typesize 只有几个可选值,组件内部决定这几个值分别对应什么颜色、什么间距。业务页面表达的是"这是主按钮"这个语义,不是"这是某个具体的蓝色按钮"这个样式值。设计规范要改一版视觉,只需要改组件内部的映射表,不需要满仓库找传了具体颜色值的地方。

需要跳出规范的特殊场景,可以开放一个 class 作为逃生口,但这应该是极少数情况下用一次的例外,不能变成常规传参方式。一旦 class 被大量业务页面拿来覆盖组件内部样式,组件其实已经名存实亡。

判断三:events 该按 DOM 命名还是按业务动作命名

事件命名同样存在两种取向,一种是照抄内部 DOM 事件,一种是描述业务动作。

1<UserPicker @click="..." />

click 描述的是用户点了鼠标这个物理动作,调用方拿到这个事件其实是想知道"选中了谁",而不是"点没点"这件事本身。更贴切的事件是:

1<UserPicker @select="handleSelect" />

弹窗类组件同理:

1<EditDialog @submit="handleSubmit" @cancel="handleCancel" />

选这套命名的理由不只是"看起来更语义化",而是它决定了组件以后能不能安全重构内部实现。如果外部监听的是 click,组件内部哪天把触发选中的元素从按钮换成整行点击,或者加一个键盘回车确认的路径,事件名都得跟着变,调用方的监听代码也要跟着改。如果外部监听的是 select,组件内部无论用什么交互方式触发选中,只要最终 emit('select', payload) 的时机和参数不变,调用方完全无感知。events 命名的本质是在划一条契约线:线内是组件的自由,线外是对调用方的承诺。

判断四:v-model 用在哪类组件上合适

v-model 这一年已经不算新东西,Vuex 4、Vue Router 4 也早就是团队默认选项,但 v-model 到底该开放在什么粒度的组件上,团队内部还是有过几轮争论。

1<SearchInput v-model="keyword" />

这类"值组件"——输入框、开关、单选多选——用 v-model很自然,组件对外暴露的就是一个值,双向绑定省掉了手写 :model-value@update:model-value 的样板代码。

但复杂表单、弹窗、业务流程类组件如果暴露太多 v-model,外部会很难追踪状态变化到底从哪里发起的。团队内部有个订单编辑器最初是这样设计的:

1<OrderEditor
2  v-model:order="order"
3  v-model:step="step"
4  v-model:errors="errors"
5/>

三个 v-model 各自独立更新,调用方页面里散落着三份状态。真正出问题时是排查一个"步骤条跳到第三步但订单数据还是第一步的"的错乱——三个 v-model 分别由组件内部不同的时机触发更新,step 的更新和 order 的更新之间没有强制的先后顺序,业务页面拿到的中间态是不一致的。这类问题本质上是组件该负责什么没想清楚:OrderEditor 到底是一个纯粹的编辑器,还是顺带管理了一部分业务流程状态?如果是后者,那这些状态之间的一致性应该由组件内部保证,不应该拆成三个互相独立的双向绑定暴露给外部。

后来改成了值组件用 v-model,流程组件用明确的语义事件:

1<OrderEditor
2  :order="order"
3  @change-step="handleStepChange"
4  @submit="handleSubmit"
5/>

组件内部自己维护 step 和校验状态的一致性,只在关键节点通过事件通知外部,外部不再需要猜三个 v-model 谁先谁后。判断标准很朴素:组件对外暴露的是不是一个单一、独立、随时读写都安全的值——是,用 v-model;不是,拆成事件,把状态一致性的责任留在组件内部。

判断五:插槽开多细才算合理

插槽是三种扩展机制里最容易被滥用的一种,因为它足够灵活,灵活到可以绕开 props 和 events 的所有归属讨论,直接把内部结构掏给外部。

1<Table>
2  <template #toolbar>...</template>
3  <template #empty>...</template>
4  <template #footer>...</template>
5</Table>

这几个插槽对应的是表格这个组件里相对稳定的结构性区域,不管表格内部实现怎么改,"工具栏""空状态""底部"这几个位置大概率还会存在,把它们开放成插槽是合理的长期扩展点。

问题出在为了满足某一次具体需求,把内部实现细节直接切一刀开放出来:

1<template #cell-inner-left-before>...</template>

这种命名说明插槽已经不是在描述"表格的哪个结构区域",而是在描述"表格当前实现里某个 DOM 节点的位置"。组件内部一旦重构了单元格的渲染方式,这个插槽名字对应的挂载点可能直接消失,而这类插槽往往是某次紧急需求驱动加上去的,加的时候没人会同步考虑"这是不是该长期维护的公共契约"。

判断插槽该不该开放,可以换个问法:这个位置的名字,是不是在半年后依然能让新加入的人一眼看懂它对应页面上的哪块区域,而不需要翻组件源码去确认。如果答案是否定的,这个插槽大概率是临时需求的产物,应该收窄成更稳定的语义化插槽,或者干脆通过 props 传内容而不是通过插槽传结构。

默认值:组件替调用方做了多少决定

除了三种扩展机制该怎么开放,props 设计里还有一块常被忽略的地方:默认值。

1const props = withDefaults(defineProps<{
2  size?: 'small' | 'medium' | 'large'
3  disabled?: boolean
4}>(), {
5  size: 'medium',
6  disabled: false,
7})

withDefaults 配合 defineProps 的泛型写法,这一年在 Vue 3 项目里已经算常规操作——2 月起 Vue 3 成了官方脚手架的默认版本,<script setup> 加类型声明这套写法在新项目里基本是起手式。默认值这件事看起来只是个小细节,但它其实回答了一个更根本的问题:组件有没有替调用方想清楚"最常见的场景应该长什么样"。如果每次使用这个组件都要传一大串配置才能跑起来,说明组件没有对最高频的那个场景做出决定,把决策成本转嫁给了每一个调用方。默认值也需要写进组件文档或者类型注释里,不然调用方无法判断哪些是可以放心省略的,哪些是必须显式传的。

组件内部的错误不该被吞掉

有一类业务组件内部会自己发请求,比如带远程搜索的用户选择器。这类组件的错误处理该怎么收也值得单独拎出来讲,因为它不属于 props/events/slots 这三类接口设计,但同样决定了组件能不能被放心复用。

1try {
2  options.value = await searchUsers(keyword.value)
3} catch (error) {
4  errorMessage.value = error.message || '用户搜索失败'
5}

错误不应该只落一个 console.error 就结束。组件至少要在界面上呈现失败状态,或者把错误通过事件抛给外部,让调用方决定要不要弹全局提示:

1emit('error', error)

这里隐含的判断标准和前面几条是一致的:组件内部的实现细节(用什么接口、怎么发请求)不该泄漏给外部,但组件内部发生的失败状态必须有出口,不能悄悄吞掉。通用组件对这类内部请求要保持谨慎,内置的请求越多,组件就越偏业务化,复用范围也越窄。

判断六:什么时候该换成 provide/inject

props 和 events 是父子之间一对一的通道,但组件树深了之后,经常会遇到中间层组件只是把 props 原样往下传的情况。

1<!-- Layout.vue -->
2<Sidebar :theme="theme" :collapsed="collapsed" />
3
4<!-- Sidebar.vue -->
5<MenuList :theme="theme" :collapsed="collapsed" />
6
7<!-- MenuList.vue -->
8<MenuItem :theme="theme" :collapsed="collapsed" />

themecollapsed 这两个字段,SidebarMenuList 自己根本不用,只是负责往下传一层。这种"透传 props"多了以后,中间组件的 props 声明列表会越堆越长,而且大部分字段跟组件自身职责毫无关系,纯粹是为了给孙子组件搭桥。

这种场景更适合用 provide/inject:

1// Layout.vue
2provide('theme', theme)
3provide('collapsed', collapsed)
4
5// MenuItem.vue,中间隔了两层也能直接拿到
6const theme = inject('theme')
7const collapsed = inject('collapsed')

SidebarMenuList 的 props 声明里不再需要出现这两个跟自己无关的字段,组件树里间的层级也不需要为了传递而增加 props。

provide/inject 不能滥用成"全局变量"的替代品。它解决的是"跨越多层组件树、且这条链路上的中间组件确实不关心这个数据"的场景,典型的是主题、语言、布局密度这类偏"环境配置"性质的信息。如果是两个平级组件之间要通信,或者父子只隔一层,老老实实用 props/events 更直接——inject 的数据来源不写在组件自身的 props 声明里,阅读组件时不看祖先节点很难判断这个值从哪来,调试起来也不如 props 直观(浏览器 Vue Devtools 里 props 会显示在组件面板,inject 的值不会直接列出来,需要点开 provide 链路去找)。团队内部的经验是:能用 props 说清楚的关系,不要因为图省事就上 provide/inject;但中间层纯粹为了透传而膨胀 props 列表时,该换就换,不要硬撑着用 props 逐层传递。

判断七:什么时候组件该交给状态管理而不是 props/events

provide/inject 解决的是"跨层级、单方向"的问题,但如果数据需要被多个不相关的组件树分支共享、且任意一处都能修改,props/events 和 provide/inject 都会力不从心——这时候该考虑 Vuex 或者 Pinia 这类集中状态管理了。

判断标准可以拆成三个问题:

  • 这份数据是不是只在一棵组件树内部流动?如果是,props/events 或 provide/inject 足够。
  • 是不是有多个彼此没有父子关系的组件都需要读写同一份数据?比如购物车数量要同时体现在顶部导航和商品列表页,这两处大概率不在同一棵组件树里。
  • 这份数据的变化是不是需要被记录、追溯、或者跨路由跳转后依然存在?本地组件状态在组件卸载后就没了,全局 store 不会。

三条里满足任意一条,尤其是后两条,就该考虑挪进 Vuex 或者 Pinia,而不是硬用一层层 provide 往下传,或者用事件总线这种 Vue 3 已经不推荐的模式凑合。今年官方文档把 Pinia 列成了推荐的状态管理方案,对比 Vuex 少了 mutations 这一层概念,写法也更贴近组合式 API 的风格,团队里新起的模块基本都直接用 Pinia,旧的 Vuex 模块暂时没有强制迁移的计划。

但集中状态管理不该变成偷懒的万能钥匙——组件内部一个纯粹局部的 UI 状态(比如某个下拉菜单是否展开),没必要为了"图方便"塞进全局 store,那样反而让 store 变成一个谁都能往里塞东西的垃圾场,状态之间的依赖关系会越来越难追踪。

defineExpose:父组件到底能不能直接调子组件的方法

<script setup> 语法糖默认把组件内部的变量和方法都封闭起来,父组件通过模板 ref 拿到的组件实例访问不到内部任何东西,这和 Vue 2 里 this.$refs.child.someMethod() 随便调的习惯不太一样,是这一年切 Vue 3 时团队里问得最多的问题之一。

1<!-- Child.vue -->
2<script setup>
3function focus() {
4  inputRef.value?.focus()
5}
6function reset() {
7  inputRef.value.value = ''
8}
9
10defineExpose({ focus })
11</script>

defineExpose 需要显式声明父组件能拿到哪些方法,没写进去的,父组件即使拿到了 ref 也调不到。这个设计本身就是一次该暴露多少的判断:子组件把内部方法一股脑暴露出去看似方便,父组件却可能因此绕开 props/events 直接操纵子组件的内部状态,把两者的耦合焊死。上面例子里只暴露了 focus,没暴露 reset,就是刻意限制父组件只能让输入框获得焦点,不能绕过组件自身的状态管理逻辑直接清空值——清空应该走 v-model 或者一个明确的 clear 事件,而不是父组件直接改子组件内部的 DOM。

判断要不要用 defineExpose 暴露方法,可以先问:这个能力是不是没法通过 props/events 表达的命令式操作。像"聚焦输入框""滚动到某一行""触发一次校验"这类动作,天然是命令式的,用事件或者 props 都别扭,defineExpose 是合理选择;但如果本质是一份数据或者一次状态变化,还是应该走 props/events,不要图省事直接把内部状态整个 defineExpose 出去。

一个和状态管理迁移相关的插曲

前阵子团队在评估要不要把一部分 Vuex 模块迁到 Pinia——今年 Pinia 被官方文档正式收进了推荐方案,团队里已经有人在新模块里试点。迁移的过程意外暴露了一批组件的耦合问题:好几个组件当初为了图方便,直接在内部 import store 并调用 store.commit,而不是通过 props 接收数据、通过 events 通知变化。这类组件在 Vuex 语境下能跑,但换成 Pinia 之后,内部直接依赖的 store 结构变了,组件跟着报错。

这件事换个角度看也是一次 props/events 该负责什么的反面教材:组件直接耦合全局状态管理工具,相当于把"数据从哪来"这个问题也算进了组件内部实现,而不是通过接口暴露出去。理想情况下,即使外部状态管理方案从 Vuex 换成 Pinia,组件本身应该是无感的——它只关心 props 传进来的数据和 events 抛出去的动作,至于这些数据背后连着 Vuex 还是 Pinia,是调用方(通常是页面级组件)该操心的事。这也是为什么"组件不要在内部直接引用全局 store"值得写进团队的组件规范里,不只是为了解耦,也是为了在这次这类工具链迁移时少改代码。

写进 code review 里的这几条

这几条判断标准后来陆续写进了团队 code review 的常规检查项,但它们不是凭空定的规则,每一条都能对上前面某个真实场景踩过的坑:传对象还是拆字段对应 UserCard,语义还是样式值对应 Button 那批莫名其妙冒出来的十几种蓝,v-model 还是拆事件对应 OrderEditor 那次步骤条和订单数据对不上的错乱,插槽粒度对应 #cell-inner-left-before 这种一看就是临时开的洞,组件不该直接耦合 store 则是这次 Vuex 迁 Pinia 暴露出来的教训。

组件 API 一旦被多个页面引用,想改动就要付出成倍的成本——每加一个调用方,回归测试的范围就多一块。这次 Pinia 迁移顺手把几个耦合过深的组件理清楚之后,团队里倒是达成了一个共识:组件要不要多开一个 prop 或者一个事件,花两分钟想清楚"调用方以后会不会被这个字段拖住",比事后花两天排查一个牵连了好几个页面的改动划算得多。