首页 / 文章 / 以责任而非复用来设计前端组件

以责任而非复用来设计前端组件

了解为何按照明确的所有权和责任来组织前端组件——而非追求最大程度的代码复用——能更便于隔离和维护功能变更。

3171 词

当组件边界是根据明确的职责来划分,而非基于理论上可以共享的代码量时,前端就更容易维护。

我们前端的问题从来不是单个组件变得过于庞大,实际上大多数组件的规模都很小。

我们拥有一个完善的库,其中包含可重用的按钮、卡片、模态框、表格元素、表单控件、钩子函数、API辅助工具、共享的架构规范,以及合理的文件夹结构。查看代码仓库时,一切看起来都井然有序:重用现象普遍,明显的重复代码很少,而较高的抽象层次也让整个代码库显得十分成熟。

真正的问题总是在需要修改某个功能时才会出现。

哪怕是一个小小的需求,都可能让我们同时要在多个毫无关联的层面中寻找解决方案:屏幕级组件、通用表单、用钩子封装的共享逻辑、网络代码、适用于多种场景的对话框,以及用于验证输入的架构。原本因为两个屏幕外观相似而设计的共享组件,会逐渐积累各种标志、回调属性、条件验证规则、不同布局,以及它本不该处理的特殊工作流程。

这些代码虽然可以重复使用,但职责却分散在整个系统中。

最终让前端变得更简单的,并非新的文件夹命名规则、不同的状态管理库,或是每个组件的严格行数限制。而是我们对于组件边界应代表的含义有了新的认知。

不再只是问:

这个组件能否在其他地方重复使用?

我们开始思考:

这个组件应该承担什么职责?

很快另一个问题也变得同样重要:

当这些职责需要变更时,会有多少无关代码随之被牵连?

这个问题促使我们将架构调整为简单的层次结构,中间层承担最核心的功能。

可重复使用的基础组件仅负责界面展示,功能组件则承载业务流程的逻辑,而页面及其他组合边界则决定所有部分如何协同工作。

前端开发变得更容易了,不是因为去除了所有重复代码,而是因为功能层面的改动更加局限,代码库中需要相互理解的模块也减少了。

1. 重用并不等同于简化

“避免重复组件”这一建议听起来通常很合理。

实际上也常常如此。

当两个界面使用相同的按钮、输入框或模态框结构时,统一实现方式可以减少不一致性及后续维护工作。

问题在于将视觉上的相似性误认为是功能上的共享责任。

想象有两个独立的功能都需要确认对话框。

第一种实现方式可能如下所示:

<ConfirmationDialog
  title="Delete project?"
  onConfirm={deleteProject}
/>

而后另一种工作流程也需要同样的对话框,只不过没有取消按钮而已。

第三种情况需要自定义警告信息。

还有另一种情况则要求在用户确认之前先进行异步检查。

不久之后,该组件就发展成了类似这样的形态:

<ConfirmationDialog
  title="Delete project?"
  variant="danger"
  showCancel
  disableConfirm={isDeleting}
  customWarning={warning}
  onBeforeConfirm={validateDeletion}
  onConfirm={deleteProject}
  onSpecialAction={archiveInstead}
  useLegacyLayout={false}
/>

从技术层面来看,该组件仍在被重复使用。

但从架构角度而言,它已逐渐演变成一种配置语言。

每有一种新的工作流程出现,都需要让这个共享组件支持另一种变体。虽然它的灵活性提高了,但这种灵活性是以在不断增加的属性背后隐藏更多行为为代价的。

这正是抽象的成本与复制成本产生差异的地方。

复制的问题会立刻显现出来——仓库中会出现两个几乎完全相同的组件,任何阅读代码的人都能一眼看出来。

选择不当的抽象层乍看之下显得很廉价。其真正的代价要等到它所服务的功能开始朝不同方向发展时才会显现。

代码重复会让你立即看到损失;而选择错误的抽象层则会在那些“类似”的功能不再同步变化时,你才会察觉到损失。

这一认识改变了我们评估代码复用的方式。

重复出现的 JSX 再也不会引发自动的怀疑。更重要的是判断这两段代码是否真的承担了相同的职责,还是仅仅在某一时刻看起来相似而已。

2. 我们开始围绕职责来设计组件

最重大的架构变革在于让组件树反映实际的职责分配,而非追求代码复用的可能性。

以设置工作流为例。

该工作流中的每一层都有其独特的存在理由。

UserSettingsPage负责将该功能整合到应用程序的其他部分中。它可能了解路由信息、页面布局,以及当前正在编辑的是哪个账户。

UserSettingsForm则承载着工作流的相关知识。它知道用户设置中应包含哪些数据、各字段之间的关联方式、提交操作的含义,以及如何向用户显示错误信息。

ButtonInputCheckbox根本不知道用户设置的存在。它们之所以能够重复使用,正是因为它们的功能确实具有通用性。

这类中间功能层往往是团队最先忽略的部分。

在过度追求重用的前端架构中,开发者常常直接从页面跳转到通用组件,绕过了任何针对特定功能的层。

这样一来,业务逻辑就找不到明确的归属位置。

最终这些逻辑会渗入配置中。

通用表单开始知道某个工作流需要用户名字段,而另一个则不需要;通用表格也开始了解针对特定类型域对象哪些操作是有效的。共享模态框会因为某个工作流恰好在其内部渲染内容而具备特殊处理能力。

本不应承担这些信息的重用层逐渐积攒了与业务相关的知识。

以职责为导向的设计可以扭转这种状况。

针对特定功能的组件被允许理解其所服务的功能。

可重用的基础组件被刻意设计为不对任何特定功能有所了解。

这种分离使得整个架构更易于理解,因为每一层只需处理较少的上下文信息。

3. 专用组件有其存在的理由

最难改掉的习惯之一,就是认为只在一个地方被使用的组件意味着设计缺陷。

举个例子:

<OrderCancellationDialog />

仅从名称就能看出这个组件是为单一工作流程而设计的。

再对比一下这样的例子:

<ConfirmationDialog />

第二个名称给人的感觉是更具可重用性。

然而,取消订单最终可能需要远不止简单的确认操作。

用户可能需要选择取消原因。界面上可能需要显示退款详情。某些订单可能不符合取消条件。权限检查方式也可能有所不同。根据订单处理状态,警告信息也会随之变化。该操作本身可能还包含独立的异步错误处理机制。

将所有这些功能整合到一个通用的ConfirmationDialog中,这个共享组件就能逐渐成为处理订单取消流程的专家。

更好的结构通常如下所示:

OrderCancellationDialog负责整个工作流程。

它能够掌握权限信息、取消原因、异步处理状态、退款提示以及错误状态,因为这些内容本就属于同一范畴。

与此同时,较低层级的组件由于完全不涉及订单相关知识,因此仍可重复使用。

那次拆分改变了组件决策的制定方式。

不再需要通过统计有多少地方引用了某个组件来为其存在找借口。

并非每个组件都必须具备可重用性。有些组件的存在仅仅是为了为某段业务逻辑提供一个合适的载体。

即使一个组件的使用场景仅有一次,只要它能让工作流更易于定位、理解以及后续修改,它依然能发挥重要作用。

这种局部的清晰性往往比仅仅因为表面 API 看起来相似就强行将另一个无关的工作流纳入共享抽象层所带来的成本更为重要。

4. 业务逻辑应远离显示组件

在数据处理和基础设施相关问题上,同样的职责划分问题再次出现。

一个最初仅具有视觉功能的组件,可以逐渐承担起获取数据、读取URL参数、执行变更操作、触发通知、处理导航、管理缓存以及跟踪工作流状态等职责。

想象一下,一个ProjectTable最终要处理所有这些功能:

ProjectTable
 ├── fetch projects
 ├── read query parameters
 ├── filter projects
 ├── manage loading state
 ├── render rows
 ├── delete projects
 ├── show notifications
 └── navigate after actions

其名称仍然让人联想到“表格”。

但实际上该组件已经掌握了大部分相关功能。

这就使得它难以在其他地方重复使用,因为重新使用该表格也会带来关于数据获取、变更操作、导航、缓存以及副作用等方面的固有假设。

以职责为导向的结构可能看起来会更像这样:

功能级代码决定了“加载”时的表现形式、错误处理方式、删除操作的具体效果、适用于该特定工作流的筛选条件,以及之后是否需要将用户重定向。

ProjectTable本身仅负责显示项目数据并记录用户操作。

这并不意味着展示组件就必须完全去除所有逻辑。

将此视为硬性规则只会以另一种形式重现同样的问题。表格完全可以保存本地交互状态,比如哪些行已展开、哪些列可见,因为这些状态确实属于表格本身。

更好的指导原则是:

不要让基础设施层面的知识在组件树中传播得超出功能实际所需的范围。

关键不在于完全去除组件中的逻辑。

而是要将逻辑置于赋予其意义的职责附近。

5. 拼装组件而非切换选项

最显著的转变之一在于我们不再用新的属性来应对每一个新需求。

通用组件往往会通过配置不断膨胀。

一个表格可能最初很简单:

<DataTable rows={projects} columns={columns} />

随后需求越来越多:

<DataTable
  rows={projects}
  columns={columns}
  selectable
  sortable
  paginated
  editableRows
  showBulkActions
  enableExport
  customToolbar={toolbar}
  rowActions={rowActions}
  emptyState={emptyState}
  onSelectionChange={handleSelection}
/>

这些属性本身并无不合理之处。

问题出现在组件必须追踪哪些属性组合才是有效的时候。

一行数据能否同时具备可编辑性和可选择性?

如果关闭了导出功能,是否仍要显示批量操作选项?

自定义工具栏是替代默认工具栏,还是与它并存?

分页是在客户端处理还是由服务器处理?

每新增一个布尔值或回调函数,通用组件就需要考虑的状态数量就会增加。

组合模式将部分责任重新交还给调用方:

<Table>
  <TableToolbar>
    <ProjectFilters />
    <ExportButton />
  </TableToolbar>
<ProjectRows projects={projects} />
  <Pagination />
</Table>

现在由父组件决定特定工作流实际上需要哪些部分。

核心表格元素无需预先内置应用程序未来可能出现的所有产品特定变体。

配置要求组件能够预判所有可能的组合情况,而组合模式则让调用方仅构建其实际需要的组合。

组合模式并不能自动阻止人们创建不合理的内容,调用方仍然可能生成有缺陷的组合。

它所改变的是不同的方面:共享组件不再需要自行编码每一种特定于业务的变体。

仍有一些配置保持不变也是完全可以的。例如,一个可重用的Button应该支持大小、禁用状态或视觉强调等一致的选项。

真正需要思考的问题是,某个属性究竟代表了同一核心职责的另一种变体,还是它在暗中让一个组件同时处理多项无关的任务。

6. 明确的责任归属让状态决策更简单

大多数前端状态问题表面上看起来都是工具相关的问题。

讨论通常会变成关于实现机制的争论:

这些数据应该存储在组件状态、Context、Zustand、Redux、URL中,还是服务器缓存里?

但实际上,一旦明确了谁才是该行为的真正负责人,许多问题就会自行解决。

每当所有权不明确时,状态往往会趋向于混乱:

模态框最初的状态都存在于其内部。

后来有其他组件也需要触发它,于是状态就被提升到了更高层级。

再之后,树结构中较远的工具栏也需要访问该状态,于是它也被放入了上下文之中。

不久之后,一个全局状态存储就包含了模态框的显示状态、未完成的表单草稿、表格筛选设置、缓存的API响应、当前激活的标签页选择,以及各种彼此无关的功能细节。

到了这时,人们通常会将这种混乱状况归咎于状态管理库。

但真正的问题往往不在于工具本身,而在于所有权的问题。

一旦组件的边界能够准确反映其实际职责,状态自然就有合适的位置存放。

用户当前在某个字段中输入的内容应保留在该字段内。

多步骤的取消流程应当放在取消功能内部处理。

从服务器获取的任何数据都应存储在应用处理服务器状态的相关部分。

需要在页面切换间保持或可通过链接共享的过滤器,最好放在URL中。

那些真正需要在应用多个无关部分之间共享的状态,或许需要更明确、更集中的管理责任。

这些原则并不能让状态管理的决策自动消失,但能为做出这些决策提供框架。

一旦组件边界能体现真正的责任归属,确定状态应存放的位置就会变得简单得多。

那种思维模式为团队带来的益处,远超过选择某个“正确”的状态库所能实现的。

7. 有时复制反而是更好的选择

接受这种方法时最难的一点在于:一定程度的复制不仅是可行的,甚至是有益的。

想象有两个外观几乎相同的表单,它们的标记和结构有大约80%是相似的。

人们的本能反应会是立即将它们合并为一个共享组件。

但剩下的20%可能蕴含着完全不同的业务逻辑。

也许其中一个表单的验证规则不同。

提交其中一个表单可能会触发与另一个不同的后续操作序列。

其中一个表单可能只是保存草稿,

而另一个则可能会触发不可逆的操作。

随着时间推移,其中一个表单可能会变得越来越复杂,而另一个则应保持简洁。

试图将两者强行整合到同一个可复用组件中,往往会导致该组件充斥着各种条件判断和特殊处理逻辑,看上去就像一座迷宫。

这样一来,你用重复的认知负担取代了重复的JSX代码。现在任何修改这些工作流的人都必须先逆向分析该共享组件是如何保护另一部分的,才能安全地进行更改。

在类似情况下,保持两个独立的、有明确名称的组件往往更为合理:

FeatureAForm
FeatureBForm

即便部分底层代码存在重复。

这并不是说重复本身就是一种优点。

一旦真正的共享责任变得明确且稳定,就可以将其提取出来。这两种形式最终可能会使用相同的输入元素、验证工具、布局容器或其他与具体领域无关的组件。

真正的区别在于时机,而非原则。

与其在两样东西看起来相似时就试图进行抽象化,不如等到它们之间确实存在共同的边界并且这一边界经过时间检验得到证实。

由此产生了一条经验法则:

与其使用基于仍在变化的假设而构建的逻辑,不如直接复制简单的代码。

单纯的复制代码一目了然,且仅存在于一个地方。

相反,过早创建的抽象概念可能会将多个针对特定功能的假设以一个看似整洁的名称汇总在一起,从而掩盖了你原本试图消除的复杂性。

8. 这背后的真正意义:将变更限制在局部

最终人们意识到,这种方法的真正目的从来都不是关乎组件的规模或可重用性。

重点在于如何实现局部化修改

每次都值得思考的问题是:

当我们修改某个功能时,需要先理解多少无关代码?

采用更多共享的、通用化组件的设计,从技术上讲可能比那些由大量功能专用代码构成的设计拥有更少的重复行数。

但即便如此,开发者仍可能需要记住整个应用程序中更大比例的代码,才能安全地进行一次修改。

这类成本很少会体现在任何重用指标中。

即便某个组件具有出色的可重用性,它仍可能为修改工作带来极大的障碍。

相反,即使某个组件仅为实现某一功能而设计且仅在某一个地方使用,它依然能够提升代码库的可维护性,因为它能让该特定的工作流程保持独立且易于理解。

这最终成了对整个理念最精炼的表述:

设计组件边界时应以局部逻辑为依据,而非追求最大程度的复用。

这一原则将所有其他内容串联在了一起。

可复用的基础组件之所以存在,是因为代码库中确实会反复出现相同的呈现模式。

针对特定功能的组件之所以保持专用性,是因为业务工作流程的变化往往彼此没有关联。

组合模式使得共享组件无需掌握所有可能的业务变体。

状态会始终与实际控制它的行为保持一致。

只有当共享代码会使得那些本已逐渐偏离的假设进一步绑定在一起时,重复代码才会被接受。

前端变得更简单,并非因为每个组件都变得更小或更易复用,而是因为每个组件对阅读它的人的要求更低了。

恰当的组件边界是那种在功能发生变化时能够解释清楚的边界

人们很容易通过表面上的指标来评判前端代码库:高度的组件复用、极少的重复代码、井然有序的文件夹结构、较小的文件大小。这些指标并非毫无意义,但它们对于需求发生变化时实际会发生什么却几乎无法说明问题。

事实证明,这才是更有用的测试标准。

这段功能实际上应该属于哪里?

哪个组件负责理解这条业务规则?

这个特定的状态数据应该存储在哪里?

如果需求发生变化,哪些文件理应被归到一起?

UI中的哪些部分真正可以复用,哪些又必须仅适用于某个特定功能?

最终简化这个前端架构的并非什么巧妙的新抽象技术。

关键在于让代码库的每一层都有更少需要管理的内容。

可复用的基础组件负责界面展示。

功能组件负责处理工作流程。

组合边界则决定了各功能之间的关联方式。

而那些与业务相关的组件则被允许保持其特定性。

如此构建的代码库并不一定意味着组件数量更少,或重复代码达到理论上的最小值。

它真正实现的是这样一个代码库:开发者可以专注于某个功能,而无需先记住整个前端的架构结构。

事实证明,这种对简洁性的定义比单纯计算组件数量或重复代码行数要实用得多。

以您的经验来看,更多可复用的组件或更清晰的现有组件边界是否更有助于提升前端的可维护性?

相关阅读

  • React文件夹结构模式的实用对比 — 阐述了基于功能、基于层级和基于领域的React项目结构,并为应用发展过程中选择合适的结构提供指导。
  • 为浏览器后退/前进缓存设计:资格判定、状态恢复与处理 — 了解浏览器如何通过后退/前进缓存恢复整个页面,哪些因素会隐秘地阻碍这一过程,以及如何通过pageshow和pagehide事件处理恢复后的页面状态。