构建规模化、高性能、易于变更的前端架构
在大规模项目中,构建高性能且易于更改的前端架构并不容易。
在本指南中,我们将探讨复杂性如何在由多个开发人员和团队共同完成的前端项目中迅速而悄然地累积。
我们会研究有效的方式来避免被复杂性所淹没,不论是在问题出现之前,还是在复杂性已经导致我们自问“事情怎么变得这么复杂了”之后。
前端架构是一个广泛的话题,包含多个方面。本指南将特别聚焦于组件代码结构,以帮助创建具有韧性、能够轻松适应变更的前端。
本指南中的示例基于 React,但所述的底层原则适用于任何基于组件的框架。
我们将从最开始讲起,探索在任何代码编写之前,代码结构如何受到影响。
常见的思维模型的影响
我们所拥有的思维模型,或我们如何看待事物,很大程度上会影响我们的决策。
在大型代码库中,不同人不断做出许多决策的总和最终决定了代码的整体结构。
团队合作开发时,明确我们持有的思维模型并期望他人也拥有相同的模型非常重要,因为每个人通常都有自己隐含的模型。
这就是为什么团队通常需要共享样式指南和工具(如 Prettier),以确保大家对如何保持一致、事物是什么以及代码应放置在哪里有共同的认识。
这样做可以极大地简化开发过程,避免代码库随着时间的推移因各自为政而变得难以维护。
如果你曾参与一个由许多急于交付的开发人员进行的快速开发项目,你可能会发现,在没有适当的准则下,事情会很快失控。随着代码的增加,前端的运行速度逐渐减慢,性能逐步恶化。
接下来的几个部分中,我们将回答以下问题:
- 在使用类似 React 的组件模型框架开发前端应用时,最常见的思维模型是什么?
- 这些模型如何影响我们组织组件的方式?
- 这些模型中隐含的权衡是什么?如何将它们显性化,从而理解复杂性快速增加的原因?
- 如何在不破坏现有行为的情况下,保持前端项目的可维护性和可变更性?
组件化思维
React 是目前最受欢迎的基于组件的前端框架。“用 React 的方式思考”通常是初学者接触的第一篇文章,它阐述了构建前端应用时的关键思维模型。这篇文章之所以好,是因为其中的建议适用于任何基于组件的框架。
其主要原则让我们在构建组件时能够提出以下问题:
这个组件的唯一职责是什么? 良好的组件 API 设计遵循单一职责原则,这是组件组合模式的关键。简单和容易是两个不同的概念,随着需求不断变化和增加,保持简单往往是相当困难的,我们将在本指南的后面详细讨论这一点。
其状态的绝对最小但完整的表示是什么? 原则上,从最小但完整的状态源出发,再从此基础上派生其他状态会更灵活且简单,可以避免常见的数据同步问题,例如更新了一个状态却忘记更新另一个状态。
状态应该存在哪里? 状态管理是一个超出本指南范围的广泛话题。但一般而言,如果一个状态可以局部化到组件内部,那么就应该这样做。组件对全局状态的依赖越多,其可复用性就越低。提出这个问题有助于我们确定哪些组件应依赖哪些状态。
此外,文章中还提供了更多的智慧:
“一个组件理想情况下应只做一件事。如果它变得过于复杂,就应该将其分解为更小的子组件。”
这些简单、经过实战检验的原则可以有效控制复杂性,形成了创建组件时最常见的思维模型。
然而,简单并不代表容易。尤其在大型项目和多团队的协作环境中,实践中常常不容易做到。
成功的项目往往是通过长期坚持基础原则、避免过多代价高昂的错误而实现的。
这引出了两个问题,我们将在后面探讨:
- 什么情况会阻碍这些简单原则的应用?
- 我们如何尽量减轻这些情况的影响?
在实践中,保持简单并不总是那么直接,我们将在下文中探讨原因。
自上而下与自下而上
在现代框架(如 React)中,组件是核心抽象单元。构建组件主要有两种方式。“用 React 的方式思考”中提到:
“你可以自上而下,也可以自下而上构建。也就是说,你可以从层次结构较高的组件开始构建。在简单示例中,自上而下更容易,而在大型项目中,自下而上更容易,并可以在构建过程中编写测试。”
这条建议很实用,看似简单,比如我们容易认同“单一职责原则很好”。但在实际操作中,自上而下和自下而上的思维模式差异很大。当一个团队广泛分享其中一种构建方式作为默认的思维模型时,最终的代码结构会大不相同。
构建自上而下的结构
前文的引用隐含了在简单的示例中通过自上而下的方式来快速推进进度的权衡,相较之下,更适合大项目的自下而上的方式虽然进度较慢,但更具可扩展性。
通常,自上而下的方法更直观和直接。在我看来,开发人员在进行功能开发时常采用的组件结构思维模式正是自上而下的方式。
那么自上而下的方法是什么样的?常见的建议是,当拿到设计图时,先在 UI 上划分区域框,这些区域框最终将成为组件。
这为我们创建顶层组件提供了基础。通过这种方法,我们往往会从一个粗粒度的组件开始,确定看似合适的边界,作为起点。
假设我们获得了一个新的管理员仪表盘设计。我们查看设计图,判断需要构建哪些组件。
在设计中有一个新的侧边导航栏。我们在侧边栏上划了一个框,并创建了一条任务说明,告诉开发人员要创建一个 <SideNavigation />
组件。
按照自上而下的方法,我们可能会考虑这个组件需要接收什么样的 props,以及它的渲染方式。假设我们从后端 API 获取导航项列表,然后传递给侧边栏组件。以下是可能的伪代码:
// 从 API 调用获取列表
// 并将其转换成传递给导航组件的列表
const navItems = [
{ label: 'Home', to: '/home' },
{ label: 'Dashboards', to: '/dashboards' },
{ label: 'Settings', to: '/settings' },
]
...
<SideNavigation items={navItems} />
到目前为止,这种自上而下的方法看起来相当直接。我们的意图是让组件易于重用,使用方只需传递所需的导航项,SideNavigation
就会处理剩下的部分。
自上而下方法的常见特点:
- 我们从最初在设计中标记的顶层边界开始构建组件。
- 它是一个单一的抽象,处理所有与侧边导航栏相关的内容。
- 其 API 通常是“自上而下”的,即由上层传入数据,组件在内部处理。
- 通常情况下,我们的组件直接渲染从后端数据源获取的数据,因此与这种“自上而下”的数据传递模式非常契合。
对于小型项目,这种方法没有什么问题。然而,在许多开发人员希望快速交付的大型代码库中,自上而下的思维模式在规模上会迅速变得棘手。
自上而下方法的问题
在实际操作中,自上而下的思维模式倾向于一开始就专注于某个抽象来解决眼前的问题。这种方式直观而简洁,也通常会产生优化了最初使用便捷性的 API。
常见的情境如下:项目正在快速开发中,你划定了组件的范围并合并了新组件。此时,出现了一个新需求,要求更新侧边导航组件。
这时,事情可能会迅速变得复杂。常见的情况会导致大而臃肿的组件的产生。
此时开发人员有两个选择:
A. 考虑当前的抽象是否合理。如果不合理,先进行拆分,再进行新功能开发。
B. 添加一个新的 prop,通过简单的条件判断来实现新功能,写几个测试覆盖新 prop 的情况。实现功能并完成测试,而且速度快。
正如 Sandy Mets 所说:
“现有代码具有强大的影响力。它的存在表明它是正确且必要的。代码代表了我们投入的精力,我们非常希望保留这些努力的价值。不幸的是,代码越复杂难懂,我们越觉得有压力去保留它(沉没成本谬误)。”
沉没成本谬误存在,是因为我们天生更倾向于避免损失。如果在有时间压力的情况下,选项 B 的可能性更大。
在规模化的项目中,这些小决策的快速积累会迅速增加组件的复杂性。
自上而下方法的典型问题示例
以下是一个简单的导航侧边栏示例。
设计变更出现,我们需要为导航项添加图标、不同字体大小,以及某些链接是导航页面跳转而非 SPA 内部跳转。
由于我们将导航项列表作为数组传递给侧边栏组件,每个新需求都需在对象中增加额外的属性,以区分不同的导航项类型及其状态。
此时,navItems
对象的结构可能如下:{ id, to, label, icon, size, type, separator, isSelected }
,其中 type
表示是链接还是普通导航项。
在 <SideNavigation />
组件内,我们将根据 type
渲染不同的导航项。简单的变更就已显得有些臃肿。
问题
使用自上而下的方法,在需求变化时往往会增加 API,并根据传入的数据进行内部逻辑分支。
“小问题可以变大问题”
几周后,又来了一个新功能需求:用户点击导航项时,可以切换到其下的子导航,还需要返回按钮返回主导航列表。管理员还需通过拖拽来重新排序导航项。
此时,我们需在列表中引入嵌套导航的概念,并关联父子导航项,有些项可拖拽,有些则不行。
很快,组件从一个简单的组件演变成了臃肿的代码,要求复杂的配置,同时运行速度缓慢且易出错。
臃肿组件的增长
“一切都应自上而下构建,除第一次外。” —— Alan Perlis
正如我们所见,臃肿的组件往往承担了过多的职责。它们接收过多的数据或配置选项,通过 props 传递,管理过多的状态,渲染过多的 UI。
这些组件通常始于简单的组件,随着需求的自然增加,逐渐演变成臃肿的组件。
以下是臃肿组件导致前端隐性崩溃的其他原因:
过早抽象。开发人员倾向于避免重复,因此容易快速抽象出一个组件,却忽视了它可能会过度承担职责。
限制跨团队代码重用。在快速开发的环境中,团队往往会重复实现相似但略有差异的组件。
增加打包大小。在大型应用中,优化加载和解析的优先级至关重要。臃肿的组件限制了这种优化的实现,导致加载和渲染效率降低。
导致运行时性能下降。在 React 等框架中,组件的状态变化会引起虚拟 DOM 的更新。臃肿组件让识别变化和最小化重渲染的难度加大,从而影响性能。
构建自底向上
相比自上而下的方法,自底向上往往不那么直观,开始时可能会更慢。它的结果是生成多个较小、API可复用的组件,而不是大而全的组件。
在追求快速发布时,这种方法显得不合常理,因为实际上并不是每个组件都需要具备可复用性。然而,构建API即使不需要复用的组件,通常会带来更可读、可测试、易变更、易删除的组件结构。
没有一个“正确”的分解方式,关键是以单一职责原则为大致指导来管理。
自底向上的思维模型与自上而下有何不同?
回到我们的例子。采用自底向上的方法,我们仍然可能会创建一个顶级的<SideNavigation />
组件,但区别在于我们从不同的角度开始工作。
我们首先识别出顶级的<SideNavigation />
组件,但工作的起点并不在这里,而是先梳理组成<SideNavigation />
整体功能的底层元素,构建这些较小的部分,并将它们组合在一起。起初,这种方法可能略显复杂。
这样做的好处在于,总体复杂度分散在多个具备单一职责的小组件中,而非集中于一个单体组件。
自底向上的方法示例
回到侧边导航的例子。以下是一个简单的实现:
<SideNavigation>
<NavItem to="/home">Home</NavItem>
<NavItem to="/settings">Settings</NavItem>
</SideNavigation>
在简单场景中,这看似普通。若需要支持嵌套组的API会是什么样子呢?
<SideNavigation>
<Section>
<NavItem to="/home">Home</NavItem>
<NavItem to="/projects">Projects</NavItem>
<Separator />
<NavItem to="/settings">Settings</NavItem>
<LinkItem to="/foo">Foo</NavItem>
</Section>
<NestedGroup>
<NestedSection title="My projects">
<NavItem to="/project-1">Project 1</NavItem>
<NavItem to="/project-2">Project 2</NavItem>
<NavItem to="/project-3">Project 3</NavItem>
<LinkItem to="/foo.com">See documentation</LinkItem>
</NestedSection>
</NestedGroup>
</SideNavigation>
自底向上方法的最终结果是直观的。它需要更多前期工作,因为我们将简单API的复杂性封装在各个组件后面,这正是其成为一个长期可用和可适应方法的原因。
与自上而下方法相比,底层构建的好处如下:
- 使用组件的不同团队只需导入和使用所需的组件。
- 代码拆分和异步加载更简单,未优先显示给用户的元素可延后加载。
- 渲染性能更好且易于管理,因为仅有发生更新的子树需要重新渲染。
- 每个组件可在其导航中承担特定职责,并可独立优化,从代码结构角度来说更具扩展性。
- 从一开始便从消费者视角出发,构建理想API,逐步向这一目标靠拢。
那么自底向上方法的缺点是什么?
自底向上初期较慢,但从长远看更快,因为其更具适应性,易于避免过早抽象,并随着时间推移,在合适时刻自然形成抽象。这是防止单体组件蔓延的最佳方法。
对于整个代码库中使用的共享组件(如侧边导航),自底向上可能在消费端需要更多的工作来组装各个部分。但如我们所见,对于具有许多共享组件的大型项目,这是一个值得做的权衡。
自底向上的强大之处在于,我们的模型以“我可以组合哪些简单的原语来实现目标”为前提,而不是一开始就带着特定抽象思维出发。
“敏捷软件开发最重要的经验之一就是迭代的价值;这在软件开发的各个层面,包括架构设计中都适用。”
自底向上的方法从长远来看更适合迭代。
接下来我们总结一些有助于构建这种方式的有效原则:
避免单体组件的策略
平衡单一职责与DRY(不要重复自己)
自底向上通常意味着采用组合模式,这意味着在消费点可能会出现一些重复。
DRY是开发者早期学习到的原则,减少重复的代码让人有成就感。但在一切变得重复时再将其DRY化往往更好。
这种方法让你“随复杂性波动而动”,随着项目发展和需求变化而逐渐抽象,使得在适当时机为更便于消费进行抽象成为可能。
控制反转
理解该原则的简单例子是回调与Promise的区别。
回调中你未必知道函数会传到哪里、被调用多少次或用何种方式。
Promise将控制权反转给消费者,让你可以组合逻辑,仿佛值已存在。
// 不知道 onLoaded 会如何处理传入的回调
onLoaded((stuff) => {
doSomething(stuff)
})
// 控制权归我们,可以开始组合逻辑,就像值已存在一样
onLoaded.then((stuff) => {
doSomething(stuff)
})
在React中,我们可以通过组件API设计来实现这一点。
我们可以通过children或渲染样式的props暴露“插槽”,在消费者一侧实现控制反转。
有时在这种情况下可能会抗拒控制反转,认为这样消费者会有更多工作量。但这既是放弃对未来的预测,也是选择赋予消费者灵活性。
// “自上而下”方式的简单按钮API
<Button isLoading={loading} />
// 控制反转方式
// 提供一个插槽,让消费者灵活利用
<Button before={loading ? <LoadingSpinner /> : null} />
第二个例子既更灵活以应对需求变更,也更具性能优势,因为<LoadingSpinner />
无需成为Button包的依赖。
在这里可以看到自上而下与自底向上的细微差异。第一个例子中我们传入数据让组件处理,第二个例子中我们多做一些工作,但最终是一个更灵活、更高效的方式。
值得注意的是<Button />
本身可以在底层通过较小的原语组件来组合完成。例如我们可以将其进一步拆分为Pressable
,它适用于按钮和Link
组件,这些可结合起来创建类似LinkButton
的元素。这种细粒度分解通常是设计系统库的领域,但对于面向产品的工程师来说值得记住。
开放以扩展
即使在使用组合模式自底向上构建时,您仍然希望导出具有可消费API的专用组件,但这些组件是由更小的原语构建而成。为了灵活性,您还可以从您的包中暴露组成该专用组件的小构建块。
理想情况下,您的组件应执行一项任务。因此,对于预制抽象,消费者可以获取所需的单一功能并将其封装,以便扩展自己的功能。或者,他们可以直接使用构成现有抽象的一些原语,根据需要构建所需的内容。
利用 Storybook 驱动的开发
我们的组件中通常会管理大量离散的状态。状态机库正因多种原因而变得越来越受欢迎。
我们可以在与 Storybook 一起构建独立 UI 组件时采用这些模型,确保为组件可能处于的每种状态编写故事。
这种前期的工作可以避免您在生产中意识到忘记实现良好的错误状态。
这也有助于识别构建所需的所有子组件,确保组件能够顺利搭建。
在独立构建 UI 组件时可以自问的一些问题,以便构建出更具韧性的组件:
以下是一些常见情况,需要避免以防止构建出不够韧性的组件:
基于实际功能命名组件。 这与单一职责原则相关。如果名称能够表达清晰的含义,长名称并不可怕。
-
避免使用过于通用的名称。 有时候,组件的名称可能比实际功能稍微宽泛。当某些事物的名称过于通用时,这可能会向其他开发者暗示这是一个处理与X相关的所有内容的抽象。
因此,当新的需求出现时,常常会自然显现出变更的明显位置,即使这样做可能并不合适。
-
避免包含实现细节的属性名称。 尤其对于 UI 样式的“叶子”组件。尽可能避免添加像
isSomething
这样的属性,其中something
与内部状态或特定领域的内容相关,且当该属性传入时,组件会执行不同的操作。如果必须这样做,属性名称最好反映出在消费该组件的上下文中它的实际功能。
例如,如果
isSomething
属性最终控制诸如填充(padding)之类的内容,那么该属性名称应反映出这一点,而不是让组件意识到与其似乎无关的事情。 -
谨慎通过属性配置。 这与控制反转有关。
像
<SideNavigation navItems={items} />
这样的组件在您确定只会有一种子类型(并且您绝对知道这不会改变!)时,可以正常工作,因为它们也可以安全地进行类型定义。但如我们所见,这一模式很难在不同团队和开发者中扩展,尤其是在快速发布的情况下。实践中,它们往往对变更不够韧性,并且快速增加复杂性。
您通常会希望扩展组件以拥有不同或额外类型的子组件,这意味着您需要将更多内容添加到这些配置选项或属性中,并增加分支逻辑。
与其让消费者安排和传递对象,更灵活的方法是同时导出内部子组件,让消费者组合和传递组件。
-
避免在渲染方法中定义组件。 有时在一个组件中包含“辅助”组件是常见的做法。这些组件会在每次渲染时被重新挂载,可能会导致一些奇怪的错误。
此外,具有多个内部
renderX
、renderY
方法往往是个信号,表明组件正变得单体化,这是一个适合拆解的好候选者。
拆分单体组件
如果可能,尽早并频繁地进行重构。识别出可能变更的组件并积极拆解它们,是在估算中纳入的良好策略。
当您发现前端变得过于复杂时,您通常有两个选择:
- 重写代码并逐步迁移到新组件。
- 逐步拆解现有代码。
深入探讨组件重构策略超出了本指南的范围,但您可以利用现有的一系列经过验证的重构模式。
在像 React 这样的框架中,“组件”实际上只是伪装的函数。因此,您可以将“函数”一词替换为组件,应用于所有现有的可靠重构技术。
以下是一些相关的示例:
- 移除标志参数
- 用多态替代条件
- 提升字段
- 重命名变量
- 内联函数
结语
我们在这里讨论了很多内容。让我们总结一下本指南的主要要点。
我们所拥有的模型影响了我们在设计和构建前端组件时所做的许多微观决策。使这些决策显而易见非常有用,因为它们会迅速积累。这些决策的积累最终决定了可以实现的功能——无论是增加还是减少添加新功能或采用新架构的摩擦,从而使我们能够进一步扩展。
在构建组件时,自上而下与自底向上的方法可能会导致在规模上截然不同的结果。自上而下的思维模型通常是构建组件时最直观的。常见的UI拆解模型是围绕功能区域绘制框架,这些框架随后成为您的组件。这种功能拆解过程是自上而下的,通常直接导致创建带有特定抽象的专用组件。需求会变化。在经过几轮迭代后,这些组件很容易迅速变成单体组件。
自上而下的设计和构建可能导致单体组件。一个充满单体组件的代码库会导致最终的前端架构变得缓慢且不够韧性。单体组件的缺点包括:
- 变更和维护成本高。
- 变更风险大。
- 难以在团队间利用现有工作。
- 性能较差。
- 增加采用未来技术和架构的摩擦,这些技术和架构对于持续扩展前端至关重要,例如有效的代码拆分、团队间的代码重用、加载阶段、渲染性能等。
通过理解通常导致过早抽象或持续扩展的基本模型和环境,我们可以避免创建单体组件。
在设计组件时,React 更有效地支持自底向上的模型。这有效地避免了过早的抽象,使我们能够“随复杂性波动而动”,在合适的时机进行抽象。这种构建方式为实现组件组合模式提供了更多可能性。意识到单体组件的真正成本,我们可以将标准重构实践应用于日常产品开发中,定期进行拆解。
React 的组件模型是其核心。如今,几乎所有前端框架都采用了这一模型,成为了现代前端应用的结构化标准。
声明式组件模型的影响已经扩展到了原生移动开发,比如 iOS 的 Swift UI 和 Android 的 Jetpack Compose。正如所有事后看起来显而易见的东西一样,组件组合是一种构建前端的绝佳方式。
独立组件的组合是应对项目扩展时复杂性快速增长的主要手段。它帮助我们将内容拆解为易于理解的部分。
本文是《构建面向未来的前端》一文的后续篇章。在上文中,我们探讨了导致组件不可组合的原因。
也就是说,出现了单体组件。这些组件不易组合,随着时间推移会变得缓慢且难以更改。通常会被重复使用并在需求更改时稍加修改。
本文将深入探讨用于拆解组件和设计可组合 API 的主要原则。阅读完本文后,我们将能够在构建可重用的组件时有效运用这种强大的组合模型。
掌握这些原则后,我们将尝试设计和实现任何共享组件库中的经典组件——一个标签页组件。理解核心问题及我们在此过程中需做出的权衡。
什么是基于组合的 API?
让我们先看看 HTML,这是最早的“声明式 UI”技术之一。一个常见的示例是原生的 <select>
元素:
<select id="cars" name="cars">
<option value="audi">Audi</option>
<option value="mercedes">Mercedes</option>
</select>
在 React 元素中应用这种组合风格,称为“复合组件”模式。其核心思想是让多个组件协作,实现单一实体的功能。
在讨论 API 时,我们可以将 props
视为组件的公共 API,而组件本身则是一个包的 API。
好的 API 设计通常会随着时间的推移在反馈中不断迭代。这一挑战的部分原因在于 API 会有不同类型的消费者。一部分人只需简单用例,另一部分人则需要一定的灵活性。还有少数人可能会需要深入的自定义来满足难以预料的需求。
对于许多常用的前端组件,基于组合的 API 是应对这些不可预见用例和不断变化需求的良好防御手段。
设计可组合的组件
关键问题在于,我们如何将组件分解到合适的层级?
纯粹的自底向上方法可能会创建太多小组件,而这些小组件难以使用。自顶向下的方法(更为常见)则不足以分解组件,导致大而单体的组件接受过多 props
,并试图完成过多任务,难以管理。
当我们遇到模棱两可的问题时,不妨从最终用户出发,思考我们要解决的问题。
在组件 API 设计中,一个有用的原则是稳定依赖原则。这里有两个主要思想:
- 作为组件或包的消费者,我们希望依赖那些有很大概率保持稳定的事物,以便顺利完成工作。
- 作为组件或包的开发者,我们希望封装那些可能会发生变化的事物,以保护消费者免受不稳定因素的影响。
我们可以将这个原则应用到 Tabs 组件上。只要 Tabs 的概念不发生根本性变化,就可以相对安全地围绕其主要元素来设计组件。
这种设计相较于更抽象的实体,通常能带来更好的体验。在视觉上,我们可以想象一个 Tab 列表(点击以切换内容)和基于当前选中标签显示内容的区域。
Tabs 组件的设计
基于上述结构,我们的 Tabs API 可能如下所示(与 Reakit 等开源组件库中的 API 类似):
import { Tabs, TabsList, Tab, TabPanel } from '@mycoolpackage/tabs'
<Tabs>
<TabsList>
<Tab>first</Tab>
<Tab>second</Tab>
</TabsList>
<TabPanel>hey there</TabPanel>
<TabPanel>friend</TabPanel>
</Tabs>
它看起来很像 HTML 的 <select>
元素。各组件协作完成功能,分发状态以确保组件间的协同工作。
需要解决的底层问题
组件间的内部协调
当我们将事物拆解为独立组件时,第一个问题是如何使它们在保持解耦的同时协作。为了让组件既可以作为独立子组件重用,又能为共同目标而协同工作,这些组件需要在后台协调。
在 Tabs 组件中,TabPanels 的顺序嵌入在顶级 Tabs 渲染的元素顺序中。
渲染任意子组件
另一个问题是如何处理包裹组件的任意组件。例如:
<Tabs>
<TabsList>
<CustomTabComponent />
<ToolTip message="cool">
<Tab>Second</Tab>
</ToolTip>
</TabsList>
</Tabs>
因为 Tabs 和内容是根据子树中的顺序关联的,我们需要跟踪各个索引以处理下一个和上一个项目的选择。同时,还需管理焦点和键盘导航,这在用户可在组件间随意渲染标记时具有挑战性。
React context 的使用
通过共享上下文,子组件可以读取状态信息。这种方法使得管理更灵活,同时也避免了繁琐的克隆方法。
实现 Tabs 组件
在实际实现中,我们可以将每个组件的状态分离到不同的上下文中:
const TabContext = createContext(null)
const TabListContext = createContext(null)
const TabPanelContext = createContext(null)
这些上下文提供数据和辅助属性,使组件之间互相协调以构建完整的 Tabs 体验。
测试我们的组件
对于这种独立组件协作的情况,通常采用黑盒测试。创建测试用例,将各组件组合起来,测试主要用例以及消费者自定义组件的特殊情况。
可扩展性
跨团队共享代码:通过良好的组合 API,使组件在面对不同需求时更加灵活。通过控制反转,组件更容易被扩展和复用,避免了大量重复代码的出现。
性能:拆分后的独立组件更易于代码分割,按需加载,且 React 可以更精确地处理重渲染,从而提高运行时性能。
设计可组合的 API 需要权衡,并且往往需要付出更多努力来构建真正可重用、可访问的组件。这也是 Web 组件理念的潜力所在,将常见组件标准化,避免重复实现。
一路组合构建
让我们回顾一下迄今为止采取的步骤。我们从一个顶级组件开始,从底层向上构建。构建之前,我们需要先确定一个目标 API,以清晰定义我们要实现的理想效果。
我们的 Tabs 组件是由更小、更灵活的组件组合而成的。这样的组合模式可以应用到应用程序的根层级。
各功能由不同组件之间的组合关系构成。应用程序则由不同功能之间的关系构成。
这就是“一路组合构建”。
尽管这种说法可能有些哲学意味,我们还是回到现实,看看它如何关联到经典的软件工程分层原则。
我们可以通过分层视角来理解 React 应用中的高级组合:
基础层:由设计令牌、常量和变量组成的通用集合,供共享组件库使用。
原始组件:组件库中使用基础层构建的原始组件和工具,帮助构建库中的组件。例如,供按钮和链接内部使用的可按压组件。
共享组件库:组合共享工具和原始组件,提供常用的 UI 元素,如按钮、选项卡等。这些组件成为上层的“原始元素”。
产品特定的通用组件改编:例如,产品中常用的“有机体”组件,它们可能会将多个组件库组件包装在一起,在组织内多个功能中共享。
产品特定的专用组件:例如,在我们的产品中,Tabs 组件可能需要调用 API 来决定渲染哪些选项卡和内容。组件的好处在于我们可以将其封装成一个 <ProductTabs />
,该组件在内部使用我们的 Tab 组件。
总结
我们在本指南中涵盖了很多内容。最后,让我们回顾一下分解组件和设计基于组合的 API 的指导原则:
- 稳定依赖原则:创建 API 和组件时始终考虑最终用户。依赖于不太可能改变的内容,同时隐藏复杂的部分。
- 单一职责原则:每个组件封装一个单一关注点。这样更易于测试、维护,更重要的是便于组合。
- 控制反转:不要试图预见所有未来的用例,而是赋予使用者自主整合的能力。
如往常一样,没有银弹,灵活性总伴随权衡。
关键在于理解你优化的目标和原因,以便减轻相应权衡,或者在资源和时间有限的情况下,将其作为最优选择接受。
在过少和过多灵活性之间保持平衡。本指南中,我们优化的是一个能够在团队和功能之间灵活复用的组件。
这种组件的主要权衡是消费者需要进行的外部协调,以便按照预期方式使用组件。
在这种情况下,清晰的指导方针、详细的文档和可复制的示例代码能够帮助减轻这种权衡,使开发者的工作更轻松。