组件粒度别按行数拆:一个下拉开出四个组件,小红点却留在 Badge 里
同一个组件库里,两件看起来矛盾的事:
- Ant Design 的 Select 在 headless 层被拆成 Root、Trigger、Value、Content、Item 五个部分,开发者可以分别替换。
- Badge 右上角那个小红点(antd 里叫 ScrollNumber)却不对外暴露,一直是 Badge 的内部实现。
如果判据是“单一职责”,这两件事说不通:小红点只干一件事,为什么不能是个组件?如果判据是“行数超过 300 就拆”,那 Select 更说不通——Trigger 本身没几行。
真正被使用的判据是另一套:这个子结构有没有以自己的身份和外界建立关系。下面把它拆开讲,来源是 Proto UI 白皮书的《组件的边界》一章。
判据:五条通路,四条算数
把一个待拆的子结构拿出来,问它启用了哪些“信息通路”:
| 子结构启用的通路 | 判断 |
|---|---|
| Event、Props、Expose、Context 任一条独立成立 | 必须拆成独立组件 |
| 只有 Feedback(视觉/听觉呈现) | 可拆可不拆,默认不拆 |
| 一条都没有 | 不应该拆成组件 |
四条通路的具体含义:
- Event:用户的操作以它为语义目标吗?点击、键盘、focus 落在它身上吗?
- Props:使用方能不能把它当作独立对象配置、替换、观察?
- Expose:它是否向外暴露自己的信号、状态、常量或方法?
- Context:它是否和其他组件交换信息?分发或订阅。
Feedback 指的是它能独立向用户呈现效果。这一条单独被判为“可拆可不拆”,因为它是最容易滥用的判据:只要一个节点有背景色、字号、flex 布局,它就有 Feedback。按这个拆,实现一个 Switch 能拆出十几个只承担样式的部件,定义文件反而更难读。
“独立”这两个字是全部难点
最常见的误判是:父组件接了一个 color 参数,然后把内部某个节点画成这个颜色,于是认为这个节点拥有了自己的 Props。
不是。使用方配置的仍然是父组件,内部节点只是父组件完成 Feedback 的方式。只有使用方能把这个子结构本身作为对象去配置、替换或观察时,它和外部的关系才真正独立出来。
用 Vue 写出来是这样的差别:
{{ text }}
defineProps()
这里 badge__dot 只有 Feedback。使用方没法单独替换这个 dot,也没法观察它的内部状态,它就是一个内部节点,留在 Badge 里是对的。
import { inject } from 'vue'
const ctx = inject('select-ctx')!
const emit = defineEmits()
function onClick() {
if (ctx.disabled) return
emit('toggle')
}
Trigger 同时有 Event(点击目标是它)和 Context(订阅 open、disabled),两条独立成立,它必须是独立组件。
还有一个容易被忽略的中间态:一个节点视觉上长在 Trigger 里面,模板里直接读 ctx.selectedLabel。它看起来像独立部件,但只要没有自己的 placeholder 配置、没有对外 display state,它就还在 Trigger 内部。一旦使用方需要单独配 placeholder,或者需要在外部读到它当前的显示值,这个节点就越过了边界——Proto UI 的 Select 正是因为这个把 Value 拆了出去。
拿 Switch 走一遍
Root 和 Thumb 是常见拆法:
User --Event-----------------------------> Switch Root
App Maker --Props-----------------------------> Switch Root
Switch Root --Expose----------------------------> App Maker
Switch Root --Context { checked, disabled }-----> Switch Thumb
Root/Thumb --Feedback--------------------------> User
Thumb 主要以 Feedback 为工作——它就是那个会滑动的圆块。但它订阅了 Root 提供的 Context 来决定自己的状态,于是不再是 feedback-only,必须拥有独立边界。
拆出 Thumb 不代表 Switch 里有两个开关。Root 仍然是唯一的语义主体和值主体,Thumb 只是依赖 Root 的指示器。两个组件共同组成一个 Switch,责任不同。
反过来也成立:如果 Thumb 不订阅 Context,由 Root 直接改内部布局(比如用 padding 把它“挤”过去),那 Switch 就只有一个独立组件。这种极简设计不违规,前提是暂时没有单独定制 Thumb 的需求。
代价是可扩展性:以后 Thumb 的定制需求多了,参数就要一个个经 Root 转发。这时候应该重新判断,把 Thumb 提升为可直接配置的 part。
Caret 是最干净的例子
Trigger 里的下拉箭头,两种实现都合理:
- 固定图形,只提示“这里可以展开”:只有 Feedback,跟着 Trigger 待着。
- 使用方可以替换成自己的图标,或者它要根据
open状态转 180 度:有了 Props 或 Context,就得是独立组件(Select Arrow)。
区别不在这个箭头有多少行代码,而在有没有人需要拿它当对象。
为什么“行数/嵌套层数”这类阈值不靠谱
工程规范里常见的说法是“文件超过 300 行就抽子组件”“嵌套不要超过三层”。这些是提醒,不是判据。
行数阈值描述的是阅读负担,不是责任边界。一个 400 行的表单组件,如果所有字段共享同一份提交状态、没有哪个区块需要被单独配置,硬拆成四个子组件只会让 props 穿过三层往下传,边界反而更模糊。反过来,一个 30 行的 Trigger 只要有独立的 Event 和 Context,就必须拆。
真正能验证的判据是:拆完之后,这个组件的 API 里有没有一堆只是往下转发的 props?如果 SelectArrow 的 props 全是 open、disabled、size 这类由父组件透传的东西,且使用方从不需要单独配它,那大概率拆早了。
落地时的四个自检问题
- 这个子结构有没有自己的事件语义目标?用户点击后,事件应该从谁身上冒出来?
- 使用方能不能把它当独立对象配置或替换?如果不能,父组件给它开一个
xxxColor参数不算。 - 它会不会向外部暴露状态或方法(
defineExpose、ref调用)?会,就必须拆。 - 它和其他组件有没有 Context 交换?有,就必须拆。
四条全否,就留在内部。如果只有 Feedback 单独成立(它有独立的呈现效果)、另外三条全否,也别急着拆——这是最容易做出碎片化组件库的地方。
还没解决的问题
这套模型是 Proto UI 白皮书里的“当前工作模型”,作者自己标注为仍待实践检验。实际用的时候有两个灰色地带:
- Vue 的
defineExpose很容易被滥用成“我拆了所以我得暴露点东西”,这时候 Expose 通路是被造出来的,不是需求驱动的。判据本身回答不了这个。 - Web Components / Shadow DOM 下“使用方能否配置”的边界比 Vue/React 更模糊,
part和 CSS 自定义属性算不算 Props 通路,白皮书没给结论。
先把四条通路当作排除法用,比按行数拆要稳。
参考: