首页 / 文章

复制与耦合:决定何时需要存在共享代码

了解为何合并相似的 React 组件或 NestJS 服务可能比直接复制成本更高,以及如何通过三个问题来判断何时该使用抽象层。

发布者:REACTAPP.TOP阅读约 12 分钟2806 词

来源: I Don’t Want My Code to Be Reusable

大多数开发者都被训练成将重复代码视为缺陷:发现两个相似的组件后,提取出公共部分并继续处理。这种反应通常是正确的,但它掩盖了一个成本问题,直到数月后,当这个公共部分需要服务于需求已发生变化的用户时,这个问题才会显现。如果将这一决策视为在重复代码和强耦合这两种不同成本之间的权衡,那么在 React、React Native 以及后端代码中选择承担哪种成本时,就可以通过几道简单的问题来做出决定。

核心观点很简单,但也容易被误解:重复本身并非优点,但在某些情况下重复代码的成本低于强耦合。下文将提供识别这类情况的指南。

整洁的共享组件如何变成配置语言

先从两个用于显示人物图片的组件开始。其中一个是为普通用户设计的:

<UserAvatar user={user} />

另一个则是为教练设计的:

<TrainerAvatar trainer={trainer} />

在第一天,它们几乎无法区分。每种都会渲染:

  • 个人资料图片
  • 图片缺失时的备用内容
  • 完全相同的尺寸
  • 加载状态

显而易见的结论是应该用一个组件来完成这两项功能,于是便出现了通用的头像:

<Avatar
  imageUrl={...}
  fallback={...}
  size="medium"
/>

这看起来显然是个优势:代码更少,错误也只需在一处修复。但随着产品的发展,用户需要符合隐私设置的占位符,教练则需要验证徽章,用户则可以选用首字母作为替代,而教练则需要显示自己当前是否可用。每项请求都会以另一个属性的形式传递给这个共享组件:

<Avatar
  imageUrl={...}
  fallback={...}
  size="medium"
  showInitials={...}
  showVerification={...}
  showAvailability={...}
  privacyMode={...}
/>

越来越多的需求不断出现,每项需求都变成一个需要处理的标志。最终,那个“通用”的头像组件不仅要处理用户信息、教练相关内容、隐私规则、身份验证、可用性等问题,还要应对各种与绘制带图片的圆形毫无关系的业务逻辑。虽然从技术上讲它仍然可以复用,但已不再简单。复杂性并未被消除,只是被集中到了同一个文件中,现在所有使用该组件的地方都必须依赖这个文件中的全部内容。如果这种属性爆炸的模式看起来很熟悉,那么文章《当可复用的React组件适得其反:属性爆炸问题及解决方案》会更深入地探讨如何重构这类组件。

共享代码意味着共享未来

很少有人讨论的一点是,提取共享代码不仅仅是一个实现层面的决策,它还将各个使用方的未来紧密相连。当组件A和组件B都依赖同一个抽象层时,对A所做的任何改动都可能破坏或改变B的功能。代码复用会形成某种关联,而这种关联其实就是耦合。

这也就改变了我们应该提出的问题。对于“这两个组件能否共享代码?”这个问题,几乎总能回答“可以”。更好的问题是:这两个组件的未来是否应该相互关联?

如今,两段代码在文本上可能完全相同,但仍属于领域中互不相关的部分。它们的实现方式可能一致,但需要修改的原因却未必相同。这种差异比单纯统计重复行数更能准确预测维护成本。它与单一职责原则密切相关,该原则通常表述为“一个模块应只有一个修改原因”,其中“修改原因”指的是推动其变更的一组人员。

当重复标记带来独立性时

以用户卡片为例:

<UserCard />

再比如支付卡片:

<PaymentCard />

目前两者呈现的结构完全相同:

<div className="card">
  <h3>{title}</h3>
  <p>{description}</p>
</div>

接下来自然就是创建共享卡片了:

<Card />

假设面向用户的卡片需要频繁重新设计,而支付卡片则受完全不同的产品与合规要求约束。两者之间的标记结构只是巧合,并不存在业务层面的关联。将二者分开只需保留少量重复的JSX代码行即可。作为交换,每个概念都能在无需协调的情况下独立发展。这种重复实际上带来了实际好处:可以单独修改其中一个,而无需与其他部分的负责人沟通。从这个角度看,两个独立的组件并非草率的架构设计,反而可能是一种有意的划分方式。

对于 React 来说有一个重要的细微差别。纯粹的视觉外壳,即没有业务含义、仅用于样式设计的容器,往往可以安全地共享,因为其变更原因在于设计系统而非产品功能。问题出现在当这些被共享的组件开始吸收本应属于某个使用方的业务决策时。

先问“为什么两者相同”,而非“如何合并它们”

遇到重复内容时,人们的第一个问题往往是“该如何抽象它?”。但实际上更有效的思考顺序是:

  • 为什么现在这两者是相同的?
  • 它们会因为同样的原因一直保持相同吗?

第二个问题更难回答,因为它要求你停止比较语法结构,转而思考背后的意图。

完全相同的实现方式可以表达不同的概念

使用两个格式化辅助函数,一个用于用户:

formatUserName(user)

另一个用于培训师:

formatTrainerName(trainer)

假设这两个函数目前包含相同的内容:

return `${firstName} ${lastName}`;

那么它们就会被合并为一个辅助函数:

formatFullName(person)

在规则不一致之前这似乎并无问题。用户名称可能需要考虑:

  • 首选名称
  • 隐私设置
  • 本地化规则

培训师名称可能需要:

  • 职业头衔
  • 认证信息
  • 特定的显示名称规则

原本的两个函数本可以沿着各自独立的路径发展而不会产生冲突。但通用辅助函数却将用户和培训师视为同一种“人”来处理命名问题,而实际上已不再如此。这就是抽象带来的不太明显的风险:

抽象不仅仅用于共享代码,它还体现了对领域结构的一种描述。

当这种描述有误时,就会误导后续的开发者,因为后者会合理地认为任何名为 formatFullName 的功能都适用于系统中的所有人。

过早使用抽象的代价会在日后显现

过早采用抽象很有吸引力,因为在引入它的当天,所有可见的指标都会有所改善。文件大小会从类似以下的情况:

100 lines

变为:

60 lines

重复代码消失了,拉取请求看起来更整洁,抽象结构也显得更为优雅。但代价被推迟了。一年后,当有人需要修改某个使用该组件的功能时,却发现这个共享组件还被其他七个地方使用。为了避免破坏这些功能,他们选择创建分支:

if (variant === "A") ...
else if (variant === "B") ...
else if (variant === "C") ...

接着又出现了另一个属性、另一个标志,以及为旧屏幕设计的兼容路径。抽象概念依然存在,但其概念上的简洁性却不复存在了。正因如此,代码库中才会出现界面如下的组件:

<UniversalThing
  mode="..."
  variant="..."
  type="..."
  compact
  showHeader
  showFooter
  enableSomething
  disableSomethingElse
/>

此时,该组件已不再是一种抽象概念,而变成了用于多种互不相关用例的小型配置语言。这些属性的每一种组合都代表着需要人们去理解的状态,而且大多数组合从未经过测试。

重用反而可能让修改变得更困难

具有讽刺意味的是,共享代码本是为了降低修改成本而创建的,但过度共享往往会让成本上升。其原因在于影响范围:即单个修改可能波及的所有内容。

在抽象概念出现之前,每个功能都有属于自己的组件:

Feature A → Component A
Feature B → Component B

之后,每个功能都会通过一个共享组件来处理:

Feature A ─┐
Feature B ─┼→ Shared Component
Feature C ─┘

第二个图表中的代码重复较少,但依赖关系更为复杂。因此有意义的比较并非“哪个版本的重复代码更少?”,而是“哪个版本能让未来修改的成本更具可预测性?”当需要对功能B进行修改时,还必须针对功能A和C进行回归测试,这种情况下即使共享组件让代码更短,也会降低修改的可预测性。

为何React容易引发过早共享

React几乎让代码提取变得毫无障碍。一个按钮出现了:

<Button />

它便可以被重复使用了。接着是一个卡片:

<Card />

然后是一个模态框:

Modal />

接着是一个表单字段:

FormField />

最后是一个自定义钩子:

useSomething()

不久之后,整个应用就会使用一个内部组件库。其中很多内容确实具有很高的价值。有些元素显然应该被共享:

  • 能够始终如一地体现应用设计风格的按钮
  • 低级别的无障碍功能,比如焦点管理或可访问的标签
  • 真正稳定的领域概念

重复使用本身并非问题。仅仅因为外观相似就重复使用才是问题所在。只要不涉及业务规则,共享的UI组件就能很好地发挥作用,这一点在围绕职责而非重复使用来设计组件一文中也有探讨。

React Native会导致状态数量激增

移动应用带来了新的挑战。两个屏幕看似相似,但其生命周期需求却可能大相径庭。在某个屏幕上表现良好的组件日后可能需要应对以下情况:

  • 应用进入后台
  • 键盘操作问题
  • 网络连接中断
  • iOS与Android之间的平台差异
  • 权限问题
  • 深度链接
  • 不同尺寸的设备
  • 导航状态
  • 离线模式

如果一开始就设计成通用型,那么该共享组件最终就需要了解其可能运行的所有环境。虽然这样能提升“灵活性”,但灵活性是有代价的:每增加一个选项,可能的状态数量就会呈倍数增长。所有这些状态最终都需要有人去理解、测试和维护。两个选项会产生四种组合;五个选项则会产生三十二种。

后端服务也会陷入同样的陷阱

这并非仅限于前端的问题。想象有两个NestJS服务:

UserService
TrainerService

最初它们可能会提供相同的操作:

create()
findById()
update()
delete()

一个通用的基类看起来很吸引人:

BaseService<T>

有时这样做确实能节省代码量。但一旦用户和培训师的业务规则出现差异,基类就会开始积累异常。首先是类型检查:

if (entityType === "user") ...

接着是在更新之前的可重写钩子:

protected beforeUpdate(...)

然后是创建之后的另一个钩子:

protected afterCreate(...)

渐渐地,会出现一整套扩展点,其唯一目的就是让同一个通用服务在不同领域中表现出不同的行为。这种做法用条件复杂性来替代重复代码,而后者通常更难以理解——因为要了解某个实体的行为,现在就必须同时阅读其基类、所有钩子函数以及所有的重写代码。如果这听起来很熟悉,那么利用DDD规则构建NestJS领域结构的相关讨论可以提供关于边界应如何设定的补充视角。

这并非支持复制粘贴的理由

认为重复代码是好事同样过于简单化。重复代码确实会带来诸多代价:

  • 如果有十个地方独立实现相同的业务规则,那么一个漏洞修复可能需要十次修改,而遗漏一次就会导致行为不一致。
  • 如果有二十个组件各自实现相同的无障碍功能,要保持它们的一致性将非常困难。
  • 如果有几个应用程序依赖相同的稳定接口,共享该接口将具有巨大价值。

实际要点更为具体:重复与耦合是两种不同的成本。优秀的工程实践在于根据问题选择合适的成本控制方式,而非一味地最小化其中某一种。

提取抽象概念前的三个问题

当两段代码看起来相似时,在合并之前先停下来思考以下问题。

它们是因为相同的原因而发生变化的吗?

这是最重要的一点。如果产品需求倾向于同时改变A和B,那么共享代码可能是合理的做法。但如果A因某个业务因素而变化,B则因另一个因素变化,那么目前完全相同的实现并不能作为它们属于同一组的充分证据。

它们的含义相同吗?

代码可能完全一致,但其语义却可能不同,而正是语义会不断演变。价格和账户余额虽然都可能是普通数字,但这并不意味着就可以在所有地方将它们合并为同一个别名:

type Amount = number;

表现形式可能相同,但含义并不如此。价格可能会涉及货币和税收规则,而余额则可能有透支限额。即便目前两者都是数字,仍将它们视为不同的概念,也能轻松应对未来的差异。

这是在消除重复代码还是仅仅删除行数?

这些都是不同的结果。良好的抽象会消除重复的概念,而糟糕的抽象仅仅只是缩短了文件大小。将两个组件中的30行代码提取到一个包含8个属性、共40行的辅助函数中,并不一定能带来任何改进;复杂性只是发生了转移,同时还多了一个需要维护的接口。

刻意重复是一种合理的选择

当以下情况成立时,将两段代码分开是合理的:

  • 规模较小
  • 结构简单
  • 很可能独立发展
  • 不负责维护关键不变量
  • 不属于同一领域概念的一部分

选择不干预的原因并非缺乏处理抽象概念的能力,而是明白进行抽象会带来何种代价。能够看到重复现象并决定“暂不处理”,这是成熟的体现,而非懒惰。并非所有的重复都属于技术债务,有时它仅仅就是重复而已。

何时重复会成为警示信号

另一方面的情况同样重要。某些重复现象显然是需要整合的信号:

  • 相同的复杂业务规则被复制到多个地方
  • 三个应用程序各自实现相同的认证流程
  • 多个团队必须遵守同一份API协议
  • 规则变更后需要记住十个不同的位置

在那种情况下,正确的问题是:哪些知识被重复了?这比重复的行数重要得多,因为需要避免重复的是知识而非文本。

DRY原则始终关乎知识

“不要重复自己”通常被理解为“绝不要两次编写相同的代码”。而《实用程序员》一书中最初的表述则是围绕知识展开的:系统中的每项知识都应只有一份权威的表示形式。这是两套不同的规则。

两个组件可以包含相似的JSX代码,而无需重复任何业务逻辑。同时,两个外观完全不同的函数也可以各自实现相同的业务规则,例如在结账组件和后端验证器中都硬编码相同的折扣阈值。第二种情况较为危险,因为它会导致问题在不知不觉中逐渐恶化。因此,不要纠结于某样东西是否可以复用,而应思考这些业务逻辑应该存储在何处。

让抽象自行找到定位

一旦出现重复代码,并不需要立即设计抽象层。先让这些重复部分存在一段时间,观察它们的变化趋势,这是一种有效的策略。如果第二种和第三种使用场景持续朝着相同方向发展,合适的抽象形式就会自然显现。这样一来,共享的业务逻辑是被发现的,而非人为设计的。

这种差异非常重要。从三个真正相似的使用场景中抽象出的模型,通常会比仅基于一个使用场景而为两个假设的未来场景设计的模型更为稳固。这正是人们熟知的“三则法则”这一启发式原则的依据。换种说法就是:

应基于你对代码当前实际状态的理解来抽象,而非基于你对其未来可能形态的想象。

目标是可变化的设计,而非可重用的代码

仅从重用性这一角度来衡量架构质量并不可靠。更好的指标包括:

  • 代码的可理解程度
  • 典型变更的隔离性如何
  • 变更带来的影响范围是否可预测
  • 业务概念是否保持清晰分离
  • 边界是否位于合理位置
  • 系统是否能够毫无顾虑地发展。
  • 有时这些标准会带来一种简洁的共享抽象;有时则会导致两个几乎完全相同的组件并存,而后者反可能是更好的设计,因为它们从一开始就不是同一回事,只是目前看起来相似而已。

    关键要点

    • 代码共享会在使用者之间产生依赖关系;应将每一次代码提取视为一种确认其未来彼此关联的决策。
    • 评估抽象方案的优劣应依据其变更原因和实际意义,而非文本相似度。
    • Prop标志、变体分支以及可覆盖的钩子都是表明某个共享代码正在为不同领域服务的迹象。
    • 小型、简单且能独立发展的代码可自由复制;而诸如业务规则、契约和安全流程之类的重复性知识则应予以整合。
    • 优先选择从多个实际用例中总结出的抽象概念,而非为虚构场景设计的抽象。
    • 在合并两个结构相似的代码片段之前,先思考自己是在实现代码复用还是建立某种关联,以及这种关联是否应比所保存的代码本身更长久存在。