首页 / 文章 / 为何前端抽象会悄然演变成技术债务

为何前端抽象会悄然演变成技术债务

了解为何过早的前端抽象会带来隐藏的复杂性,以及如何判断何时真的有必要开发共享组件、钩子或工具函数。

2389 词

几乎每个前端代码库都会在某个时刻从单纯的应用程序逐渐转变为专为支撑应用程序而设计的框架。

首先会引入组件库。

接着是设计系统。

然后会添加状态管理层。

随后会出现数据获取抽象层。

接着会有自定义钩子来封装这个数据获取抽象层。

再之后还会有人编写通用的表单组件,该组件需要一个配置对象来指定表单的行为方式。

不久之后,哪怕只是修改一个按钮这样的简单元素,也必须遍历六个文件、三层抽象结构,以及那些连创造者都无从追溯的约定。

奇怪的是,这些单独做出的选择在当时似乎都没有什么不合理之处。

这正是前端团队在处理抽象概念时存在的核心问题。

大多数抽象概念本身并非坏事,很多确实能带来帮助。问题在于,前端开发人员在尚未获得足够证据证明这些抽象概念真的有必要之前,就已经变得极为擅长构建它们了。

我们不再只是为了简化现有的复杂性而进行抽象。

我们是在消除未来可能出现复杂性的可能性本身。

这种习惯会带来一种特殊类型的技术债务。

抽象通常源于良好的初衷

想象有三个独立的组件,每个都负责获取用户数据。

第一个组件存在一些重复的加载逻辑。

第二个组件几乎采用了相同的模式。

第三个组件则又采取了略有不同的方式。

有人发现了这种重复现象,于是提出:

“我们或许应该把这部分功能整合到统一的抽象层中。”

这本身就是一个合理的观点。

于是团队开发了一个自定义钩子:

const { data, loading, error } = useUserData(userId);

结构清晰整洁。

后来另一个组件需要稍作功能调整。

团队没有直接调用API,而是选择了另一种方案:

useUserData(userId, {
  includePermissions: true,
  cache: true,
  retry: 3,
});

几个月后,这个钩子不再仅用于获取用户记录。

它现在还负责缓存处理、重试机制、权限验证、数据转换、错误标准化、乐观更新以及各种特定于功能的需求。

最初的代码重复问题已经消失。

但出现了另一个问题:**代码与其实际功能之间存在越来越大的差距。**

仅通过查看某个组件已无法判断其数据来源。

首先,你必须理解其背后的抽象概念。

这就是没人提及的权衡之处。

抽象并不能消除复杂性,它只是将复杂性转移到了其他地方。

有时这种转移确实有价值,但有时候你只是用三百行复杂的内部机制换来了原本只需五行的简单逻辑。

抽象是有代价的

开发者很早就被教导要将重复代码视为一种负担。

这确实没错,因为重复代码往往确实如此。

但重复代码远非唯一的复杂性类型。

其他形式包括:

  • 间接性
  • 配置
  • 隐含行为
  • 通用 API
  • 隐藏的依赖关系
  • 约定俗成
  • 继承机制
  • 包装组件
  • 仅因抽象而产生的调试问题
  • 认知负担

在某些情况下,直接重复代码确实比设计复杂的抽象结构更经济。

我们来比较两种方法。

一种方法是把一小段逻辑重复三次。

另一种方法则是创建一个带有十几个参数的通用工具函数,其理由仅仅是当前三个使用场景有大约60%的重叠。

第二种方法看起来更精致,更具“工程化”风格。

但它也可能变得极其难以维护。

这正是前端开发常常犯错的地方。

团队往往追求DRY原则,而非易于理解的代码

这两个目标并非可以相互替代的。

前端生态系统助长了这种倾向

前端开发与抽象概念之间存在一种特殊的关系,这主要是因为整个生态系统从设计上就是分层构建的。

一个现代应用程序可以轻松集成用于构建组件的渲染框架、某种路由库、客户端状态管理器、专门处理服务器端状态的工具,以及带有自身验证功能的表单库。除此之外还有设计系统、一组预构建的UI组件、在普通CSS之上添加的样式抽象层、负责协调所有工作的构建工具,以及监控整个架构的测试框架。

这些组件各自都能解决特定的实际问题。

一旦应用程序开始在所有这些基础上叠加自己的自定义层,情况就会变得复杂。

开发者不再直接使用框架,而是转而使用其内部的封装层。

而这个封装层又依赖于另一个封装层。

不久之后,该团队的日常编程模式就几乎与底层平台不再相似了。

这种状况在规模较大的组织中尤为常见。

一个团队最终可能会形成类似如下的嵌套结构:

<AppPage>
  <DataBoundary>
    <PermissionGate>
      <FormContainer>
        <EntityEditor />
      </FormContainer>
    </PermissionGate>
  </DataBoundary>
</AppPage>

每个部分都有明确的功能定位。

每一层都有记录在案的存在理由。

但一旦出现问题,开发人员甚至在接触实际功能代码之前,就必须在脑海中重新构建整个技术栈。

这种重构无论在认知成本还是时间投入上都是不小的负担。

通用组件往往是问题最严重的根源

导致前端复杂度上升的最简单途径之一,就是设计出试图满足所有未来需求的组件。

它起初看起来并无不妥:

<Button />

随后逐渐变得复杂一些:

<Button variant="primary" />

接着不断扩展下去:

<Button
  variant="primary"
  size="large"
  loading
  icon={...}
  permission="admin"
  analyticsEvent="save"
  confirm
/>

最终得到的东西在技术上已经不再属于按钮了。

它其实是一个用于渲染任意操作的微型框架。

由于现在可以通过配置来创建新按钮,而无需重新实现,团队认为效率提升了。

但无论看起来是否像代码,配置本质上仍是代码。

在某些方面情况更糟,因为配置会掩盖真正的控制流程。

阅读二十行简单的代码,就能清楚地了解发生了什么。

阅读二十行配置文件,可能需要深入研究组件的实现方式,追踪配置解析器的处理过程,弄清楚默认值,还要分析哪些选项会在背后相互影响。

明确的代码实际上已被一种“词汇表”所取代。

这样的词汇表确实能发挥巨大作用。

但它也很容易变成没人愿意维护的方言形式。

“防未来风险”的陷阱

通常,人们为引入某种抽象概念而提出的最有力的理由就是“防未来风险”。

“以后我们可能会用到它。”

“未来可能会出现更多变体。”

“它可以在应用程序的其他地方被重复使用。”

“那就从一开始就设计成通用的吧。”

偶尔这种直觉是正确的,但更多时候,你只是还缺乏足够的信息来判断。

问题在于,任何抽象都会隐含对问题的某些假设。过早进行抽象意味着在真正理解要构建的内容之前就固定了架构选择。而一旦代码库的其他部分开始依赖这种抽象,想要改变方向就会迅速变得成本高昂。

这正是过早抽象比看上去更危险的原因。重复的代码通常在准备好的时候很容易进行重构。而有缺陷的抽象则往往会向外扩散影响。

想象三个外观相似的组件。你可以暂时不去处理重复问题。如果随着时间推移出现了真正的共享模式,再将其提取出来即可。但如果你直接采用通用的抽象方式,那么未来的每一个用例都得被迫适应你最初所做的那些假设。

到了那个阶段,抽象不再是一种便利,而变成了一种约束。本意是为了消除重复的抽象,最终却让修改变得比直接复制更加困难。

优秀的抽象通常源于痛苦

最强大的抽象往往并非事先规划好的,而是通过实践发现的。

一个团队会多次实现相同的功能。最终他们会发现哪些部分是完全一致的,哪些只是表面相似。他们逐渐明白真正存在差异的地方。只有到那时,他们才会提取出稳定且可共享的核心部分。

这样就能建立起更为坚实的基础。可以用这样的顺序来描述:

复制导致重复,重复带来理解,而理解则催生抽象。

不过,前端团队往往遵循不同的路径:

可能性会直接导致抽象化,进而产生配置问题,最终引发混乱。

第一种方法前期需要更多时间。但从整个项目周期来看,通常效率更高,因为由此产生的抽象化反映了团队通过实践真正积累的知识。

并非所有重复内容都应删除

这对开发者来说是个令人不适的事实,因为重复通常被视为错误。但有时重复反而是正确的选择。

假设两个组件拥有几乎相同的验证逻辑,而该逻辑仅五行代码,且每个组件适用的业务规则不同,那么保留代码重复可能才是更明智的做法。

为什么?因为将它们分开可以保持本地实现的独立性。日后负责某个组件的开发人员可以在调整其行为时不必担心会意外破坏另一个组件。

重复的代码隐含的意思是:目前这两者只是偶然相似而已。

而抽象则表达了更强的含义:这两者本应完全相同,且今后也应一同变化。

这是一个更为强烈的主张。只有当你真正相信这一主张成立时,才应该使用抽象。

抽象应具备简洁的 API

一个实用的检验标准是:要正确使用这个抽象,实际上需要学习多少内容?

如果所需学习的知识清单不断增长,那很可能意味着这个抽象正在演变成一个框架。

良好的抽象能够隐藏复杂性,而糟糕的抽象则会掩盖决策过程。两者听起来相似,但实际上完全不同。

设计良好的 API 可能会显得如此简单:

const user = useUser(id);

设计欠佳的 API 则会不断增加各种标志和选项,最终变成类似这样的结构:

const user = useUser(id, {
  cache: true,
  normalize: true,
  permissions: true,
  optimistic: false,
  retry: 3,
  suspense: false,
  transform: customTransform,
  mode: "editor",
});

一旦发展到某个阶段,这种抽象就不再具有简化问题的作用了。它只不过是将原始问题转移到配置对象中而已。这种变化应当被视为一个警示信号。

前端团队需要抽象预算

目前团队们已经经常讨论性能预算、包大小预算、错误预算以及基础设施预算。在这些预算之外,再增加一个抽象预算也是必要的。

其目标并非绝对减少抽象的数量,而是将抽象的数量控制在团队能够轻松理解的范围内。

在新增一个抽象概念之前,先问几个问题会有所帮助。

这种完全相同的模式已经出现过多少次?如果只出现过一次,还不要将其抽象化。出现两次的话,应视为需要怀疑的迹象而非采取行动的理由。出现五次则开始具备成为实际证据的潜力。

接下来要判断:这些事物是否确实因相同的原因而发生变化?仅仅外观相似并不足以作为依据。两个组件可能因为不同的业务原因而沿着完全不同的路径发展,却看起来几乎一模一样——在这种情况下将它们合并为一个抽象概念可能是错误的。

最后要确认:这个抽象概念是否真的能让日常遇到的情况变得更简单?不是指某种假设的未来情况,而是人们会不断面对的实际情况。如果使用这个抽象概念意味着每次都得重新阅读相关文档,那很可能只是用一个问题换来了另一个更严重的问题。

最优秀的前端代码往往很枯燥

软件开发中存在着一种奇特的层级观念:巧妙的抽象设计比三个简单的组件更令人印象深刻,通用的系统比单一的函数更具架构深度,可复用的框架也比重复的代码显得更专业。

但实际生产环境并不看重代码的复杂程度,而是重视那些开发者能够安全修改的代码。

最有价值的前端代码往往是那种枯燥的类型——打开一个组件就能立刻知道其数据来源,清楚了解用户点击某元素时会发生什么,可以直接编辑标记代码,追踪状态的变化流程,还能轻松定位 API 调用位置。

你不应为了修复一个漏洞而不得不去理解某个团队的内部设计理念。这并非工程水平低下的表现,反而是优秀工程的体现。

抽象应简化思考,而非增加负担

抽象的存在并非为了让代码看起来可复用,其目的是让系统更易于理解。这正是前端团队对每一种抽象应设定的标准。

如果某种抽象能让十个组件共享复杂行为,而无需让每位开发者都了解该行为的实现方式,那它就发挥了应有的作用。反之,如果每位开发者都必须学习复杂的API才能调整一个简单的组件,那么这种抽象很可能正在起反作用。

这种区分很重要,因为前端工作本身就已经十分复杂。浏览器很复杂,用户界面也很复杂,保持状态同步很复杂,无障碍性设计很复杂,性能优化同样很复杂。没必要在这一切之上再增添额外的复杂性,只为了让架构在纸面上看起来更高级。

前端开发的下一阶段或许不会来自又一层抽象的发现,而会来自于更善于判断何时不需要构建这样的抽象层。

那些出类拔萃的工程师未必是那些能够设计出最复杂且可复用性最高的组件系统的人,而是那些面对问题时能准确判断是否真的需要一个系统的人。

有时候,合适的抽象形式就是一个函数;有时候是一个组件;有时候只是一个命名恰当的模块。而有时则是五行重复的代码,团队中的任何成员都能立刻读懂并理解。

区分这些情况才是真正值得培养的技能。

相关阅读

  • React设计模式:从经典面向对象到现代Hooks — 阐述了Singleton、Factory、Observer等经典软件模式在React中的应用,以及HOCs、Hooks、复合组件等React特有的设计模式。
  • 通过实际代码示例理解SOLID原则 — 该指南通过具体的代码示例详细讲解了SOLID五大原则,展示了它们在真实项目及React应用中的运用方式。
  • 六种将类型转化为真正错误预防机制的 TypeScript 技术 — 了解如何通过 satisfies、带标签的联合类型、never checks、unknown、派生类型以及品牌化 ID,让 TypeScript 在编译时而非生产环境中捕获真正的错误。