编程 组件粒度别按行数拆:一个下拉开出四个组件,小红点却留在 Badge 里

2026-11-03 00:04:12

组件粒度别按行数拆:一个下拉开出四个组件,小红点却留在 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 这类由父组件透传的东西,且使用方从不需要单独配它,那大概率拆早了。

落地时的四个自检问题

  1. 这个子结构有没有自己的事件语义目标?用户点击后,事件应该从谁身上冒出来?
  2. 使用方能不能把它当独立对象配置或替换?如果不能,父组件给它开一个 xxxColor 参数不算。
  3. 它会不会向外部暴露状态或方法(defineExpose、ref 调用)?会,就必须拆。
  4. 它和其他组件有没有 Context 交换?有,就必须拆。

四条全否,就留在内部。如果只有 Feedback 单独成立(它有独立的呈现效果)、另外三条全否,也别急着拆——这是最容易做出碎片化组件库的地方。

还没解决的问题

这套模型是 Proto UI 白皮书里的“当前工作模型”,作者自己标注为仍待实践检验。实际用的时候有两个灰色地带:

  • Vue 的 defineExpose 很容易被滥用成“我拆了所以我得暴露点东西”,这时候 Expose 通路是被造出来的,不是需求驱动的。判据本身回答不了这个。
  • Web Components / Shadow DOM 下“使用方能否配置”的边界比 Vue/React 更模糊,part 和 CSS 自定义属性算不算 Props 通路,白皮书没给结论。

先把四条通路当作排除法用,比按行数拆要稳。

参考:

推荐文章

程序员茄子在线接单