组件化最容易被理解成“把页面拆小”。导航是组件,表格是组件,每一行、每个按钮也可以继续拆。文件数量增加后,页面看起来很有结构,修改一个字段却需要穿过多层属性和事件。

问题不在组件多少,而在边界为何存在。视觉上的一个方框,可能同时承担数据请求、业务规则和展示;视觉上分开的两块内容,也可能由同一状态驱动。

按变化原因拆分

我更愿意观察一段代码为什么会变化。纯展示组件因视觉规范变化,业务组件因业务规则变化,数据层因接口变化。如果不同原因混在一起,每次需求都会触碰整棵组件树。

一个好的边界会暴露较小、稳定的接口。它允许内部重写,而调用者无需了解细节。相反,如果组件需要十几个属性才能工作,往往说明它只是把耦合换了位置。

我踩过的一个坑,是把整张告警记录和一组控制开关都传给表格行:

// 看似复用,实际把页面规则泄漏给每一行
<AlarmRow
  alarm={alarm}
  permissions={permissions}
  filters={filters}
  showDevice={showDevice}
  onRefresh={reloadPage}
  onOpenDetail={openDetail}
  onAcknowledge={acknowledge}
/>

后来把业务决策留在列表边界,行组件只接收显示模型和用户意图:

type AlarmRowModel = {
  id: string;
  title: string;
  occurredAt: string;
  level: "info" | "warning" | "critical";
  deviceLabel?: string;
  canAcknowledge: boolean;
};
 
<AlarmRow value={toRowModel(alarm, context)} onAction={handleRowAction} />

行组件不再知道权限表和筛选器,测试也从构造整页上下文,变成输入一个 AlarmRowModel。这才是拆分之后认知负担真的下降。

局部清晰胜过全局通用

通用组件很有吸引力,但业务相似不等于语义相同。两个列表今天都有分页,明天可能分别需要无限滚动和批量选择。过早合并会把差异压进复杂配置。

我会先让组件在局部场景中表达清楚,等真正出现多次相同变化后再抽取。复用是理解成熟后的结果,不应成为设计的起点。

判断组件边界时,我常看四个信号:

  1. 修改一个业务规则,需要同时改多少层 props 和事件。
  2. 组件测试是否必须构造大量与当前行为无关的数据。
  3. 组件名称描述的是领域职责,还是视觉位置,例如 LeftBox
  4. 复用是否依靠不断增加布尔开关维持。

最后一种尤其危险。compacteditableshowX 单独都合理,多个开关组合后会产生没人验证的模式。与其维护一个万能组件,不如保留共享的低层原语,让两个业务组件分别表达完整语义。

数据所有权决定关系方向

组件之间最棘手的问题往往不是视觉,而是谁拥有状态。多个兄弟组件需要同一数据时,状态应提升到它们最近的共同边界;只影响内部交互的状态,则不必进入全局存储。把所有数据集中管理,会让任何变化都像系统级事件。

我会让数据向下流动、意图向上报告,避免子组件直接修改不属于自己的对象。关系越单向,组件越容易独立理解。需要跨越很远层级的依赖,则说明可能缺少更合适的业务边界或上下文入口。

组件边界最终是在分配认知负担:谁负责知道哪些事情,变化需要惊动多少代码。拆得小不代表负担更小,关系清楚才是。

页面编排、领域组件和视觉原语的变化所有权

图:组件边界的目标是隔离变化原因,而不是增加目录层级。

一个组件应该由一种变化理由拥有

后来拆组件时,我会给它写“所有者句子”:筛选器由筛选表达式变化驱动,结果表由列模型和数据变化驱动,页面编排由业务流程变化驱动。如果一个组件同时因为三种不相干原因频繁修改,它通常承担了太多职责。

反过来,只渲染一个图标、没有独立语义和测试价值的片段,不必为了目录整齐单独成组件。边界的目标是让变化局部化,不是制造更多文件。