首页 / 文章 / 可复用React组件反噬:属性爆炸问题及解决方案

可复用React组件反噬:属性爆炸问题及解决方案

看看过早的重用是如何将一个简单的 React 组件变成一个充满冗余属性的负担,以及如何通过复制、复合组件和“三原则”来避免这种情况。

2023 词

共享组件本应节省时间,但许多前端代码库中都有一个没人敢修改的组件:那种带有数十个属性的<Modal /><Card />,修改其中一个界面的错误往往会影响到其他三个界面。这种情况很少是疏忽造成的,而是过早重复使用UI的必然结果。本文将探讨组件为何会变成这样的负担,解释为何复制UI往往更经济,同时介绍两种避免这种情况的实用方法:复合组件与“三原则”。

一个看似无害的快捷方式如何变成拥有32个属性的组件

这个设计通常始于一个合理的请求。设计师提交了一个结算页面,其对话框与设置页上的确认对话框几乎一致。差异很小:操作按钮位于左侧,标题旁有一个橙色徽章,而且按钮与内容之间用一条细分隔线隔开。

在评审过程中,有人指出已经存在一个<Modal />组件,建议直接复用它。几小时后便有一份拉取请求提交,其中添加了四个属性:hasOrangeBadgealignActionsLeftshowDividerLinebadgeText

一年之内要重复做上十几次。同一个模态组件现在需要32个属性,包含15个嵌套的三元运算符以及12个布尔标志,同时还依赖3个useEffect钩子来确保内部动画能与不同的属性组合保持同步。后来有人为了修复账单计算错误而调整了一行内边距,结果无意间破坏了四个无关页面上的模态组件。本想让代码更简洁、更具复用性,最终却得到了没人愿意维护的组件。

为何DRY原则在UI领域适用方式不同

“不要重复自己”之所以是明智的建议,是有原因的。当诸如支付计算或权限检查之类的业务逻辑被重复编写时,对其中一个副本进行的修改会导致其他副本出错,而且这些副本还会逐渐出现差异。

UI组件的变化源于多种原因。当业务领域发生变化时,业务规则也会随之改变。界面则会随着用户使用流程、响应式断点、无障碍访问要求、产品决策以及设计实验而变化,这些因素会独立地影响每个屏幕。

外观相似的两个元素并不一定代表相同的概念。在两种UI模式的设计尚未确定之前就将它们合并为一个共享组件,会在原本不存在的各个功能之间产生耦合关系。此后,即便只是对用户引导界面进行微调,也可能需要重新测试设置、计费功能以及分析模块,仅仅因为它们恰好使用了同一个组件。到那时,重复利用反而会带来负面影响。

prop爆炸现象的成因分析

最好一次只关注一个迭代中的变化。起点是一张包含简洁明确契约的卡片:标题、描述以及可选的点击处理函数。

interface CardProps {
  title: string;
  description: string;
  onClick?: () => void;
}

实现代码同样简洁且易于理解:

export function Card({ title, description, onClick }: CardProps) {
  return (
    <div className="card" onClick={onClick}>
      <h3>{title}</h3>
      <p>{description}</p>
    </div>
  );
}

这个组件本身没有问题。到了第4个迭代,市场部希望博客卡片能在顶部显示图片,于是又增加了两个可选的图片属性:

interface CardProps {
  // ...
  imageUrl?: string;
  imageAlt?: string;
}

在第7个迭代时,仪表板团队要求为处于活动状态的项目添加仅在该状态下可见的角标按钮,这需要一个标志、一个图标以及一个处理函数:

interface CardProps {
  // ...
  hasTopRightAction?: boolean;
  topRightActionIcon?: React.ReactNode;
  onTopRightAction?: () => void;
}

到了第12个迭代,分析团队希望在标题下方添加第二行内容、三种颜色的状态徽章以及可展开的页脚,这又增加了六个属性:

interface CardProps {
  // ...
  subtitle?: string;
  badgeText?: string;
  badgeVariant?: "success" | "warning" | "danger";
  isExpandable?: boolean;
  expandedContent?: React.ReactNode;
  defaultExpanded?: boolean;
}

到第20个迭代时,渲染函数已变成一系列条件判断。根元素根据一个标志来决定其类名,而图像只有在存在URL时才会被渲染:

export function Card(props: CardProps) {
  return (
    <div
      className={`card ${
        props.isExpandable ? "card-expandable" : ""
      }`}
    >
      {props.imageUrl && (
        <img src={props.imageUrl} alt={props.imageAlt} />
      )}

标题容器以标题开始:

      <div className="card-header">
        <div>
          <h3>{props.title}</h3>

其后是仅在提供时才会显示的副标题:

          {props.subtitle && <h4>{props.subtitle}</h4>}
        </div>

右上角的操作取决于一个布尔标志,而非是否存在处理函数,因此有可能设置了标志却忘了添加图标,或者提供了图标却忘了设置标志:

        {props.hasTopRightAction && (
          <button onClick={props.onTopRightAction}>
            {props.topRightActionIcon}
          </button>
        )}
      </div>

徽章会根据其变体生成类名,若无法生成则默认使用该类型中甚至未列出的default变体:

      {props.badgeText && (
        <span
          className={`badge badge-${
            props.badgeVariant || "default"
          }`}
        >
          {props.badgeText}
        </span>
      )}

描述是原始设计中仅存的部分,位于中间位置:

      <p>{props.description}</p>

可扩展的页脚用于关闭该组件。注意,isExpandable同时控制着根类和页脚,而接口中的defaultExpanded在标记中并未被使用:

      {props.isExpandable && (
        <div className="card-footer">
          {props.expandedContent}
        </div>
      )}
    </div>
  );
}

这类小矛盾很常见。一旦一个组件拥有如此多的标志位,就没人能同时看清所有组合情况,于是无效或未完全实现的状态就会出现。<Card />的每个使用者都必须学习越来越多的选项,并自行判断哪些组合是被支持的。这样一来,这种抽象结构反而比它原本要替代的简单标记更难理解。

重复实现往往比错误的抽象更经济

Sandi Metz 提出过一句在软件设计领域广为流传的至理名言:“重复代码的成本远低于错误的抽象设计。” 前端开发正是最能体现这一建议价值的方向。

当两个组件仅外观相似时,将它们分开通常才是更安全的选择。假设 BillingModalOnboardingModal 是两个独立的组件:

  • BillingModal 的修改不会影响 OnboardingModal
  • 删除入门引导功能时,其对应的模态框以及所有与该功能相关的逻辑也会一同被移除。
  • 每个模态框都可以根据自身的需求独立发展。
  • 细微的视觉改动无需去理解属于其他功能的数十个属性。

重复的JSX只会多出几行代码,但错误的抽象方式会在调试、回归测试以及后续维护上耗费更多时间。并非所有重复的标记片段都适合被封装为共享组件。

复合组件:组合优于配置

真正的重用依然有其价值,尤其是在设计系统中。关键在于共享组件如何体现灵活性——与其使用由越来越多布尔值控制的单一组件,不如提供一组小型且相互关联的部件,让使用者自行组合。

以下就是用这种方式构建的账单对话框。负责管理的组件会将打开状态本地保存:

export function BillingSettings() {
  const [isOpen, setIsOpen] = useState(false);

根级的<Modal>组件仅接收它真正需要的内容,即打开状态和变化回调函数,而覆盖层则是独立的组件:

  return (
    <Modal open={isOpen} onOpenChange={setIsOpen}>
      <Modal.Overlay />

内容区域包含一个标题栏,而标题栏中则有标题:

      <Modal.Content>
        <Modal.Header>
          <Modal.Title>Update Billing Plan</Modal.Title>

徽章仅仅是标题栏的另一个子元素,其不同样式通过徽章自身的属性来体现:

          <Modal.Badge variant="warning">
            Action Required
          </Modal.Badge>
        </Modal.Header>

主体部分可容纳任意内容,通常以一条消息开始:

        <Modal.Body>
          <p>
            Please update your payment method to avoid account suspension.
          </p>

接着是该功能特有的表单,而模态框本身对此并不知情:

          <CreditCardForm />
        </Modal.Body>

页脚可自行控制对齐方式,其中包含普通按钮,第一个按钮用于关闭对话框:

        <Modal.Footer align="right">
          <Button
            variant="ghost"
            onClick={() => setIsOpen(false)}
          >
            Cancel
          </Button>

主要操作会完成页脚及整个结构:

          <Button variant="primary">
            Save Changes
          </Button>
        </Modal.Footer>
      </Modal.Content>
    </Modal>
  );
}

结构上的差异很重要。当模态框需要徽章时,无需添加showBadge属性,只需渲染<Modal.Badge />即可。而当页面需要自定义内容时,只需将其放置到合适的位置,无需再创建新的标志属性。其优势显而易见:

  • 无属性冗余。不需要徽章或页脚的模态框根本不会渲染<Modal.Badge /><Modal.Footer />
  • 更高灵活性。标题旁边的图标可直接放在<Modal.Header>内部,无需新增属性。
  • 独立样式控制。修改<Modal.Badge />时无需改动核心容器。
  • 结构清晰易读。JSX能够直接展示布局,无需让读者去查看TypeScript接口并推断哪种属性组合会生成何种布局。
  • 在底层实现中,复合组件通常通过React上下文共享诸如open这样的状态,这样<Modal.Footer>或关闭按钮就能无需层层传递属性即可访问到该状态。组合式设计将控制权交予使用方,同时不会把组件变成配置对象。不过这种做法并非没有代价:设计系统必须明确规定哪些部分可以嵌套在何处,而且每次使用时使用者都需要编写稍多的标记代码。关于这种重构方式的另一种视角,请参阅我们关于利用组合式设计与插槽解决React属性过载问题的指南。

    提取共享组件的“三则规则”

    有一个简单的启发式方法可以帮助判断何时应该进行抽象:等待第三次实际出现。

    第一次出现:直接在原处编写

    将标记直接放在需要它的视图内部。不要急于抽象,应将样式和结构保留在相关功能旁边。

    第二次出现:复制并调整

    当另一个界面需要类似的内容时,创建全局组件会显得很有吸引力。相反,应复制标记并根据新环境进行调整。这样你就有了两个具体的示例,随着时间推移可以观察它们真正存在差异的地方,而无需猜测未来的需求。

    第三次出现:有依据地提取

    当第三个独立的界面也需要相同的视觉和行为模式时,你就有了足够的依据来判断哪些部分是真正共享的,哪些存在差异。经过这样的等待后,就能发现那些真正的不变要素——即每次都保持不变的部分——以及那些必须保持灵活性的真实变化。这类变化更适合作为组合槽的候选,而非布尔属性。

    目标并非避免使用可复用组件,而是避免基于假设来构建抽象结构。

    关键要点

    • 注意属性泛滥问题。当某个组件不断为不相关的使用场景增加配置属性时,在添加新属性之前应先审视该抽象设计。
    • 优先选择组合方式而非配置方式。子组件、槽以及复合组件能够在设计变更时提供灵活性,而无需每次都新增布尔属性。
  • 接受一定程度的重复。一对具有部分相同标记的小型独立组件,通常比试图涵盖所有变体的单个组件更易于维护。
  • 遵循三则原则。让共享组件源自实际使用场景,而非臆测。
  • 优秀的前端架构并非以最少的代码行数来衡量,而是看代码在变更时能否不会对整个应用产生连锁反应。有时最好的组件并非那些被到处重复使用的,而是那些未被改动过的。

    相关阅读

  • 在可复用UI架构中框架边界应位于何处 — 了解状态机、Web组件以及基于属性的布局与动画机制如何让UI行为独立于框架存在,以及何时这种可移植性并不值得追求。
  • 重复代码与耦合关系:判断何时需要共享代码 — 了解为何合并相似的React组件或NestJS服务可能带来的代价高于直接创建重复代码,同时通过三个问题来判断某个抽象层是否真的有必要存在。
  • React的特性切片设计:层级结构、导入规则及何时停止使用 — 了解特性切片设计的层级结构、单向导入规则、精简的公共API以及@x跨模块导入功能如何帮助理清React代码库,以及何时只需采用其中的部分功能。