如何设计一个优秀的组件

在开发新功能时,是什么决定现有组件是否能够正常工作?当一个组件不起作用时,这究竟意味着什么?

是组件在功能上没有达到预期的效果,比如一个标签系统不能切换到正确的面板吗?还是它过于死板,无法支持设计的内容,比如一个按钮上的图标放在内容之后而不是之前?或者它可能过于预定义和结构化,无法支持轻微变体,比如一个模态框总是有一个标题部分,现在需要一个没有标题的变体?

这就是一个组件的生活。它们往往是为了较小的目标而构建,然后匆忙地一次又一次地扩展,直到它不再起作用。在那一刻,就会创建一个新的组件,技术债务增加,入门学习曲线变得更陡峭,代码库的可维护性变得更具挑战性。

这是否只是一个组件不可避免的生命周期?还是可以避免这种情况?更重要的是,如果可以避免,该如何做?

自私。或者说,自利。更确切地说,也许两者都有一点。

太多时候,组件考虑得太多。太过于彼此关心,尤其是太过于关心它们自己的内容。为了创建能够与产品一起扩展的组件,关键在于自利,几乎可以说是自私——冷酷、自恋、世界围绕我转的自私。

这篇文章不会解决关于自利和自私之间界限的长达几个世纪的辩论。坦率地说,我没有资格参与任何哲学辩论。然而,这篇文章将演示构建自私组件对每个其他组件、设计师、开发人员和消费你内容的人都是最有利的。事实上,自私的组件能够创造出周围的这么多好处,以至于你几乎可以说它们是无私的。

我不知道 🤷♀️ 让我们看一些组件,自己决定吧。

注意:本文中的所有代码示例和演示将基于ReactTypeScript。然而,这些概念和模式是独立于框架的。

功能迭代

或许,展示一个体贴组件的最佳方式是通过走过其生命周期。我们能够看到它们如何从小而功能强大的开始,但一旦设计发展,就变得难以驾驭。每次迭代都将组件推向一个角落,直到产品的设计和需求超出了组件本身的能力。

让我们考虑一下初级的按钮组件。它看似复杂,但经常陷入复杂场景,因此是一个很好的工作例子。

第一次迭代

虽然这些示例设计相当简陋,没有展示各种·:hover·、·:focus·和禁用状态,但它们展示了一个带有两种颜色主题的简单按钮。

乍一看,生成的按钮组件可能简陋。

1
2
3
4
5
// First, extend native HTML button attributes like onClick and disabled from React.
type ButtonProps = React.ComponentPropsWithoutRef<"button"> & {
text: string;
theme: 'primary' | 'secondary';
}
1
2
3
4
5
<Button
onClick={someFunction}
text="Add to cart"
theme="primary"
/>

可能我们都见过类似的按钮组件。也许我们甚至自己制作过类似的组件。一些命名可能不同,但按钮的·propsAPI`大致相同。

为了满足设计的要求,按钮定义了用于主题和文本的props。这第一次迭代起作用,并满足了设计和产品当前的需求。

然而,设计和产品当前的需求很少是最终的需求。当创建下一个设计迭代时,”加入购物车”按钮现在需要一个图标。

第二次迭代

在确认产品的UI后,决定给“加入购物车”按钮添加一个图标会很有益。然而,设计说明表明,并非每个按钮都包含图标。

回到我们的按钮组件,它的props可以通过一个可选的图标prop进行扩展,该prop映射到一个图标的名称,以有条件地进行渲染。

1
2
3
4
5
type ButtonProps = {
theme: 'primary' | 'secondary';
text: string;
icon?: 'cart' | '...所有其他可能的图标名称';
}
1
2
3
4
5
6
<Button
theme="primary"
onClick={someFunction}
text="Add to cart"
icon="cart"
/>

有了新的图标prop,按钮现在可以支持带有或不带有图标的变体。当然,这假设图标将始终显示在文本的末尾,这并不是下一次迭代设计时的情况。

第三次迭代

先前的按钮组件实现在文本末尾包含了图标,但新的设计要求图标可选地放在文本的开头。单一的图标prop将不再满足最新设计需求。
有几种不同的方法可以满足这个新的产品需求。也许可以向按钮添加一个iconPosition prop。但如果需要在两侧都有图标呢?也许我们的按钮组件可以提前考虑到这个假定的需求,并对props进行一些更改。

单一的图标prop将不再满足产品的需求,因此被移除。取而代之的是引入了两个新的props,iconAtStart和iconAtEnd。

1
2
3
4
5
6
type ButtonProps = {
theme: 'primary' | 'secondary' | 'tertiary';
text: string;
iconAtStart?: 'cart' | '...所有其他可能的图标名称';
iconAtEnd?: 'cart' | '...所有其他可能的图标名称';
}

在对代码库中现有的Button使用进行重构以使用新的props之后,又一场危机被解除。现在,按钮有了一些灵活性。这一切都是在组件本身中硬编码并包裹在条件语句中的,但毫无疑问,UI不知道的事情不会伤害它。

直到这一点,按钮图标始终与文本颜色相同。这似乎是合理的,并且是一个可靠的默认设置,但让我们通过为按钮图标定义一个具有对比色的变体,向这个运转良好的组件增加个变量。

第四次迭代

为了提供一种反馈感,设计了一个确认UI阶段,当成功将商品添加到购物车时,该阶段将被暂时显示。

也许这是开发团队选择反对产品需求的时候。但尽管有抵制,决定还是决定提供颜色灵活性给按钮图标。

再次,可以采用多种方法来解决这个问题。也许可以传递一个iconClassName prop给按钮,以更好地控制图标的外观。但还有其他产品开发的优先事项,因此选择了一个快速的解决方案。

因此,向按钮添加了一个iconColor prop。

1
2
3
4
5
6
7
type ButtonProps = {
theme: 'primary' | 'secondary' | 'tertiary';
text: string;
iconAtStart?: 'cart' | '...所有其他可能的图标名称';
iconAtEnd?: 'cart' | '...所有其他可能的图标名称';
iconColor?: 'green' | '...其他主题颜色名称';
}

有了这个快速修复,按钮图标现在可以与文本不同样式。UI可以提供设计的确认,产品可以再次前进。

当然,随着产品需求的不断增长和扩展,设计也在不断发展。

第五次迭代

根据最新的设计,按钮现在必须仅使用图标。这可以通过几种不同的方法来实现,但所有这些方法都需要进行一定程度的重构。

也许创建一个新的IconButton组件,将所有其他按钮逻辑和样式复制到两个地方。或者,该逻辑和样式被集中在两个组件之间共享。然而,在这个例子中,开发团队决定将所有变体保留在同一个Button组件中。

相反,text prop被标记为可选。这可能只是在props中将其标记为可选,但如果有任何逻辑期望文本存在,可能需要额外的重构。

但接着就出现了一个问题,如果按钮只有图标,应该使用哪个图标prop呢?iconAtStart和iconAtEnd都不适当地描述了按钮。最终决定将原始的icon prop重新引入,并将其用于仅图标的变体。

1
2
3
4
5
6
7
8
type ButtonProps = {
theme: 'primary' | 'secondary' | 'tertiary';
iconAtStart?: 'cart' | '...所有其他可能的图标名称';
iconAtEnd?: 'cart' | '...所有其他可能的图标名称';
iconColor?: 'green' | '...其他主题颜色名称';
icon?: 'cart' | '...所有其他可能的图标名称';
text?: string;
}

现在,Button的API变得令人困惑。可能在组件中留下一些注释,以解释何时使用特定的props,何时不使用,但学习曲线正在变得越来越陡峭,错误的可能性也在增加。

例如,在不向ButtonProps类型添加过多复杂性的情况下,无法阻止某人同时使用icon和text props。这可能会破坏UI,或者需要在Button组件本身内部增加更大的条件复杂性来解决。此外,icon prop可以与iconAtStart和IconAtEnd props中的任一个或两者一起使用。同样,这可能会破坏UI,或者需要在组件内部添加更多层次的条件语句来解决。

我们心爱的按钮在这一点上变得相当难以管理。希望产品已经达到了一个稳定的状态,不会再有新的变化或需求发生。永远都不会。

第六次迭代

这下就算是告别不再有任何变化的时代了。 🤦♂️

这个按钮的下一个也是最终的迭代可以说是压垮骆驼的最后一根稻草。在“添加到购物车”按钮中,如果当前商品已经在购物车中,我们希望在按钮上显示其数量。表面上,这只是一个动态构建text prop字符串的简单更改。但由于当前商品数量需要不同的字体粗细和下划线,组件崩溃了。由于按钮只接受纯文本字符串而不接受其他子元素,组件不再起作用。

如果这是第二次迭代,这种设计是否会破坏按钮?也许不会。那时组件和代码库都比较年轻。但到了这一点,代码库已经变得非常庞大,为了满足这个需求进行重构成了一座难以攀登的山。

这时可能会发生以下之一:

  • 进行更大规模的重构,将按钮从text prop转移到接受子元素或接受组件或标记作为文本值。
  • 将按钮拆分为单独的AddToCart按钮,具有更为严格的API,专用于这个用例。这也可能导致将任何按钮逻辑和样式复制到多个位置,或将它们提取到一个集中的文件中以在所有地方共享。
  • 淘汰按钮,创建一个ButtonNew组件,拆分代码库,引入技术债务,并增加入职学习曲线。

这两种结果都不理想。

那么,Button组件出了什么问题呢?

共享导致障碍

HTML按钮元素的责任是什么?缩小这个答案范围将为前面Button组件面临的问题带来启示。

原生HTML按钮元素的责任不会超出以下范围:

  • 显示,无需意见,传递给它的任何内容。
  • 处理基本功能和属性,如onClick和disabled。
    是的,每个浏览器都有其自己的按钮元素可能的外观和内容显示方式,但通常使用CSS重置来消除这些差异意见。因此,按钮元素实际上只不过是一个用于触发事件的功能性容器。

格式化按钮内部任何内容的责任不是按钮的责任,而是内容本身的责任。按钮不应该关心。按钮不应该与其内容分享责任。

考虑到组件设计的核心问题在于组件props定义了内容而不是组件本身。在先前的Button组件中,第一个主要限制是text prop。从第一次迭代开始,Button的内容受到了限制。虽然text prop在那个阶段符合设计,但它立即偏离了原生HTML按钮的两个核心责任。它立即强迫按钮意识到并对其内容负责。

在接下来的迭代中,引入了icon。虽然在按钮中嵌入一个有条件的图标似乎是合理的,但这样做也偏离了按钮的核心责任。这样做限制了组件的用例。在后续迭代中,需要在不同位置使用图标,并且按钮props被迫扩展以样式化图标。

当组件负责显示其内容时,它需要一个能够适应所有内容变化的API。最终,该API将崩溃,因为内容将永远且总是发生变化。

引入“团队中的我”

在所有团队体育中都有一句格言:“团队中没有‘我’。”尽管这种思维方式是崇高的,但一些最伟大的个人运动员体现了其他思想。

迈克尔·乔丹以自己的观点做出了著名回应:“获胜中有‘我’。”已故的科比·布莱恩特也有类似的想法:“[团队]中有‘M-E’。”

我们最初的Button组件是一个团队合作者。它分担了其内容的责任,直到达到淘汰点。按钮如何通过体现“团队中的我”态度避免这些限制?

Me, Myself, And UI

当组件对其显示的内容负责时,它会崩溃,因为内容将永远不断地变化。
采用自私的组件设计方法会如何改变我们原始的Button组件呢?

在考虑HTML按钮元素的两个核心责任的同时,我们的Button组件的结构将立即有所不同。

1
2
3
4
// 首先,从React中扩展原生HTML按钮属性,如onClick和disabled。
type ButtonProps = React.ComponentPropsWithoutRef<"button"> & {
theme: 'primary' | 'secondary' | 'tertiary';
}
1
2
3
4
5
6
<Button
onClick={someFunction}
theme="primary"
>
<span>添加到购物车</span>
</Button>

通过删除原始的text prop,改用无限制的子元素,Button能够与其核心责任保持一致。现在,Button几乎只是用于触发事件的容器。

通过将Button移回原生支持子内容的方式,不再需要各种与图标相关的props。现在可以在Button内的任何位置呈现图标,而无论其大小和颜色如何。也许各种与图标相关的props可以提取到它们自己的自私的Icon组件中。

1
2
3
4
5
6
7
<Button
onClick={someFunction}
theme="primary"
>
<Icon name="cart" />
<span>添加到购物车</span>
</Button>

通过从Button中删除特定于内容的props,它现在可以做到所有自私的角色最擅长的事情,思考自己。

1
2
3
4
5
6
// 首先,从React中扩展原生HTML按钮属性,如onClick和disabled。
type ButtonProps = React.ComponentPropsWithoutRef<"button"> & {
size: 'sm' | 'md' | 'lg';
theme: 'primary' | 'secondary' | 'tertiary';
variant: 'ghost' | 'solid' | 'outline' | 'link'
}

通过具有特定于自身且独立于内容的API,Button现在是一个可维护的组件。自我利益的props保持了学习曲线的最小和直观,同时保留了对各种Button用例的极大灵活性。

Button图标现在可以放置在内容的任一端。

1
2
3
4
5
6
7
8
9
10
11
<Button
onClick={someFunction}
size="md"
theme="primary"
variant="solid"
>
<Box display="flex" gap="2" alignItems="center">
<span>添加到购物车</span>
<Icon name="cart" />
</Box>
</Button>

或者,Button可以只有一个图标。

1
2
3
4
5
6
7
8
<Button
onClick={someFunction}
size="sm"
theme="secondary"
variant="solid"
>
<Icon name="cart" />
</Button>

然而,产品可能随着时间的推移而发展,自定义组件设计提高了与之一起演进的能力。让我们超越Button,深入自定义组件设计的基石。

自定义组件的关键

就像创造一个虚构角色时,最好通过展示而非告知读者他们是自定义的。通过阅读角色的思想和行为,可以理解他们的个性和特质。组件设计可以采取相同的方法。

但是,我们如何确切地在组件的设计和使用中展示它是自定义的呢?

HTML主导组件设计

很多时候,组件被构建为本机HTML元素的直接抽象,比如按钮或图像。当这种情况发生时,让本机HTML元素驱动组件的设计。

具体来说,如果本机HTML元素接受子元素,抽象组件也应该接受子元素。组件的每个方面,如果偏离其本机元素,都必须重新学习。

当我们的原始Button组件偏离了按钮元素的本机行为,不支持子内容时,它不仅变得僵化,而且需要进行心智模型转变才能使用该组件。

HTML元素的结构和定义已经经过了很多时间和思考。不需要在每次都重新发明轮子。

子元素自己照顾自己

如果你读过《蝇王》,你就知道当一群孩子被迫自己照顾时,情况有多危险。然而,在自定义组件设计的情况下,我们将做到这一点。

正如我们原始的Button组件所示,它试图样式化其内容,变得越来越僵化和复杂。当我们去除这一责任时,组件能够做得更多,但使用更少。

许多元素只不过是语义容器。我们不经常希望section元素样式化其内容。按钮元素只是一种非常具体的语义容器。将其抽象为组件时,同样的方法可以应用。

组件专注于单一目标

将自定义组件设计看作是安排一堆糟糕的第一次约会。组件的props就像完全专注于它们和它们的即时责任的对话:

  • 我看起来怎么样?
    Props需要满足组件的自尊心。在我们重构的Button示例中,我们使用了像size、theme和variant这样的props来实现这一点。
  • 我在做什么?
    组件应该只对它自己正在做的事情感兴趣。同样,在我们重构的Button组件中,我们使用了onClick prop来实现这一点。就Button而言,如果在其内容中的其他地方有另一个点击事件,那是内容的问题。Button不关心。
  • 我什么时候和我下一步要去哪里?
    任何飞行的旅行者都会迅速谈论他们的下一个目的地。对于模态、抽屉和工具提示等组件,它们的何时以及在哪里变得同样重要。这些组件并不总是在DOM中呈现。这意味着除了知道它们的外观和功能之外,它们还需要知道何时以及在哪里。换句话说,这可以用类似于isShown和position的props来描述。
    组合至上
    一些组件,如模态和抽屉,通常可能包含不同的布局变体。例如,一些模态将显示标题栏,而其他一些则不会。一些抽屉可能带有带有行动号召的页脚,而其他一些可能根本没有页脚。

与其在单个Modal或Drawer组件中定义每个布局,带有条件props,如hasHeader或showFooter,不如将单个组件拆分为多个可组合的子组件。

1
2
3
4
5
<Modal>
<Modal.CloseButton />
<Modal.Header> ... </Modal.Header>
<Modal.Main> ... <Modal.Main>
</Modal>
1
2
3
4
<Drawer>
<Drawer.Main> ... </Drawer.Main>
<Drawer.Footer> ... </Drawer.Footer>
</Drawer>

通过使用组件组合,每个单独的组件都可以像它想要的那样自私,并仅在需要的地方使用。这使得根组件的API保持清晰,并且可以将许多props移动到特定的子组件。

让我们更深入地探讨自定义组件设计的这些关键点。


在这个例子中,我们在三种不同的模态布局中具有预知变化的优势。这将有助于在应用自定义设计的每个关键点的同时引导我们的模态的方向。

首先,让我们审视一下我们的模型并分解每个设计的布局。

在编辑个人资料模态中,有定义好的标题、主要和页脚部分。还有一个关闭按钮。在上传成功的模态中,有一个修改过的标题,没有关闭按钮,还有一个类似英雄的图像。页脚中的按钮也被拉伸了。最后,在朋友模态中,关闭按钮返回,但现在内容区域是可滚动的,并且没有页脚。

那么,我们学到了什么?

我们了解到标题、主要和页脚部分是可互换的。它们可能在任何给定的视图中存在或不存在。我们还了解到关闭按钮独立运作,不与任何特定的布局或部分相关联。

由于我们的模态可以由可互换的布局和排列组成,这是采取可组合的子组件方法的标志。这将允许我们根据需要将各个组件插入模块。

这种方法允许我们非常狭义地定义根模态组件的职责。

有条件地渲染任何组合的内容布局。

仅此而已。只要我们的模态只是一个有条件渲染的容器,它就永远不需要关心或负责其内容。

随着我们的模态的核心职责和可组合的子组件方法被确定,让我们分解每个可组合部分及其角色。

组件 角色
Modal 整个模态组件的入口点。此容器负责何时何地渲染,模态的外观以及它的功能,如处理无障碍考虑。
DynamicContent 可互换的模态子组件,只有在需要时才包含。此组件将类似于我们重构的Button组件。它负责外观、显示位置以及功能。
Header 标题部分将是本机HTML标题元素的抽象。它只不过是一个语义容器,用于显示标题或图像等任何内容。
Main 主要部分将是本机HTML主要元素的抽象。它只不过是一个语义容器,用于任何内容。
Footer 页脚部分将是本机HTML页脚元素的抽象。它只不过是一个语义容器,用于任何内容。
每个组件及其角色已经定义,我们可以开始创建支持这些角色和职责的props。

在先前的定义中,我们了解了Modal的基本责任,即在何时进行有条件渲染。这可以通过使用像 isShown 这样的 prop 来实现。因此,我们可以使用这些 props,每当它为 true 时,Modal及其内容将渲染。

1
2
3
type ModalProps = {
isShown: boolean;
}
1
2
3
<Modal isShown={showModal}>
...
</Modal>

任何样式和定位都可以在 Modal 组件中直接使用 CSS 来完成。此时不需要创建特定的 props。

鉴于我们之前重构的按钮组件,我们知道 CloseButton 应该如何工作。事实上,我们甚至可以使用我们的按钮来构建 CloseButton 组件。

1
2
3
4
5
6
7
import { Button, ButtonProps } from 'components/Button';

export function CloseButton({ onClick, ...props }: ButtonProps) {
return (
<Button {...props} onClick={onClick} variant="ghost" theme="primary" />
)
}
1
2
3
<Modal>
<Modal.CloseButton onClick={closeModal} />
</Modal>

每个独立的布局部分,Modal.Header、Modal.Main 和 Modal.Footer,可以从它们的 HTML 中获取指导,即 header、main 和 footer。这些元素支持任何子内容的变体,因此我们的组件也将如此。

这里不需要特殊的 props。它们只充当语义容器。

1
2
3
4
5
6
<Modal>
<Modal.CloseButton onClick={closeModal} />
<Modal.Header> ... </Modal.Header>
<Modal.Main> ... </Modal.Main>
<Modal.Footer> ... </Modal.Footer>
</Modal>

有了我们的 Modal 组件和其子组件的定义,让我们看看它们如何可以互换使用来创建这三种设计。

注意:为了不从核心要点中脱离,没有显示完整的标记和样式。

编辑个人资料Modal框

在编辑个人资料模态框中,我们使用了每个 Modal 组件。然而,每个都仅用作样式和定位自身的容器。这就是为什么我们没有为它们包含 className prop 的原因。任何内容的样式都应由内容本身处理,而不是我们的容器组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<Modal>
<Modal.CloseButton onClick={closeModal} />

<Modal.Header>
<h1>Edit Profile</h1>
</Modal.Header>

<Modal.Main>
<div className="modal-avatar-selection-wrapper"> ... </div>
<form className="modal-profile-form"> ... </form>
</Modal.Main>

<Modal.Footer>
<div className="modal-button-wrapper">
<Button onClick={closeModal} theme="tertiary">Cancel</Button>
<Button onClick={saveProfile} theme="secondary">Save</Button>
</div>
</Modal.Footer>
</Modal>
上传成功Modal框

与先前的例子一样,上传成功的Modal框使用其组件作为无意见的容器。内容的样式由内容本身处理。也许这意味着按钮可以通过 modal-button-wrapper 类来拉伸,或者我们可以为 Button 组件添加一个 “how do I look?” 的 prop,比如 isFullWidth,用于设置更宽或全宽尺寸。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<Modal>
<Modal.Header>
<img src="..." alt="..." />
<h1>Upload Successful</h1>
</Modal.Header>

<Modal.Main>
<p> ... </p>
<div className="modal-copy-upload-link-wrapper"> ... </div>
</Modal.Main>

<Modal.Footer>
<div className="modal-button-wrapper">
<Button onClick={closeModal} theme="tertiary">Skip</Button>
<Button onClick={saveProfile} theme="secondary">Save</Button>
</div>
</Modal.Footer>
</Modal>
朋友Modal框

最后,我们的朋友Modal框摒弃了 Modal.Footer 部分。在这里,可能会引起在 Modal.Main 上定义溢出样式,但这是将容器的责任扩展到其内容的领域。相反,处理这些样式更适合在 modal-friends-wrapper 类中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<Modal>
<Modal.CloseButton onClick={closeModal} />

<Modal.Header>
<h1>AngusMcSix's Friends</h1>
</Modal.Header>

<Modal.Main>
<div className="modal-friends-wrapper">
<div className="modal-friends-friend-wrapper"> ... </div>
<div className="modal-friends-friend-wrapper"> ... </div>
<div className="modal-friends-friend-wrapper"> ... </div>
</div>
</Modal.Main>
</Modal>

通过自定义设计的Modal框组件,我们可以使用灵活且范围明确的组件来适应不断变化的设计。

下一个Modal框的发展

考虑到我们所涵盖的所有内容,让我们对我们的Modal框进行一些假设,看看它可能如何发展。你会如何处理这些设计变化?

  • 设计需要一个全屏Modal框。你会如何调整Modal框以适应全屏变体?
  • 另一种设计是一个两步注册过程。Modal框如何适应这种设计和功能?
    image.png
    image.png

总结

组件是现代Web开发的中流砥柱。越来越多的重要性被赋予组件库,无论是独立存在还是作为设计系统的一部分。随着Web的快速发展,拥有可访问、稳定和弹性的组件变得至关重要。

不幸的是,组件经常被构建得过于复杂。它们被构建为继承其内容和周围环境的责任和关注点。许多应用了这种考虑水平的模式在每次迭代中进一步分解,直到组件不再起作用。在这一点上,代码库分裂,引入更多技术债务,并且不一致性渗入用户界面。

如果我们将组件分解为其核心职责,并构建一个仅定义这些职责的props API,而不考虑组件内部或周围的内容,我们就能构建出对变化具有弹性的组件。这种对组件设计的自私态度确保组件仅对自身负责,而不对其内容负责。将组件视为不过是语义容器意味着内容可以自由变化,甚至可以在容器之间移动而不产生影响。组件对其内容和周围环境考虑得越少,对所有人越有利——对于将永远变化的内容更有利,对于设计和用户界面的一致性更有利,进而对于消费这些变化内容的人更有利,最后对于使用这些组件的开发人员也更有利。

良好组件设计的关键在于责任。成为一个体贴的团队合作者是开发人员的责任