React-反模式-全-
React 反模式(全)
原文:
zh.annas-archive.org/md5/6794d9e9fd0a94e33f34d8b45365ab2b
译者:飞龙
前言
构建前端应用程序具有挑战性,尤其是在构建大型应用程序时,如果没有适当的指导,难度会不断升级。不幸的是,由于库的 UI 中心性质,许多基于 React 的应用程序都陷入了这种困境,使得开发者必须独自应对前端开发的其它复杂性。还有许多其他考虑因素,例如异步网络请求、可访问性、性能和状态管理,仅举几例。这些因素增加了前端应用程序的复杂性。随着应用程序规模的扩大,维护代码变得是一项艰巨的任务。添加新功能所需的时间比最初看起来要多得多,而识别缺陷(然后修复它们)同样具有挑战性,甚至更甚。
然而,这些挑战是可以克服的。我们可以学会识别导致问题的常见反模式,然后利用既定的模式、设计原则和实践来解决和纠正这些问题。历史告诉我们,在一个领域得出的解决方案往往对其他领域也有相关性,尤其是在像单一职责原则、依赖倒置原则和不要重复自己这样的基本设计原则方面。这些原则在 20 世纪 70 年代指导了 UNIX 系统的构建,在 20 世纪 90 年代指导了 Java Swing 应用程序的构建,并且至今仍然有效。毫无疑问,它们将继续对未来框架和库的相关性产生影响。
本书旨在深入探讨这些问题,并考察既定模式和惯例如何减轻构建大型应用程序的挑战。我们将看到设计原则和设计模式如何简化设计,使代码更容易理解、修改和长期维护。通过这次探索,读者将更深入地了解如何使用 React 导航前端开发的复杂世界,确保他们的应用程序既健壮又易于维护。
这本书面向谁
本书面向对提高代码可维护性和效率感兴趣的 React 开发者。无论你是初学者还是有经验,这里都有适合你的内容。对 React 有基本的了解是有益的,但本书旨在以直接的方式指导你理解这些概念。
重点在于识别常见反模式,并使用既定的设计原则和模式来解决它们。通过实际示例和逐步方法,你将学习如何简化代码以实现更好的理解、更轻松的修改和长期维护。
本书涵盖的内容
在第一章《介绍 React 反模式》中,你将更深入地了解构建用户界面、处理状态管理、解决“不愉快路径”以及识别 React 中常见反模式的障碍。
在第二章**,理解 React 基础知识,你将深入了解 React 的基础,包括静态组件、props、UI 分解、状态管理、渲染过程和常见的 React Hooks,为后续章节打下坚实的基础。
在第三章**,组织你的 React 应用程序,你将了解 React 中不同类型的项目结构,探讨它们的优缺点和实际应用。
在第四章**,设计你的 React 组件,你将学会识别 React 组件设计中常见的反模式,并探索包括单一职责原则和不要重复自己等基本设计原则,以改进组件结构。
在第五章**,React 中的测试,你将了解软件测试的重要性,探索各种测试类型,如单元测试、集成测试和端到端测试,并熟悉 Cypress 和 Jest 等流行测试工具,为 React 应用程序中的复杂测试场景打下坚实基础。
在第六章**,探索常见的重构技术,你将了解重构的本质,并深入研究各种重构技术,如重命名变量、提取变量和用管道替换循环,以增强代码的可维护性和可读性。
在第七章**,介绍使用 React 进行测试驱动开发,你将通过一个实际示例学习测试驱动开发的核心原则,同时在 React 应用程序中构建比萨店菜单页面的各种功能。
在第八章**,探索 React 中的数据管理,你将深入了解 React 中状态管理的常见挑战,如业务逻辑泄漏和属性钻取,并探讨包括采用反腐败层和利用 React 上下文 API 来增强代码可维护性和用户体验的解决方案。
在第九章**,在 React 中应用设计原则,你将回顾单一职责原则,接受依赖倒置原则,并理解在 React 中应用命令查询责任分离,以巩固你对关键设计原则的知识,帮助你掌握 React。
在第十章**,深入组合模式,你将通过高阶组件和自定义 Hooks 深入了解组合,并探索无头组件模式。你将欣赏到在 React 中创建可扩展、可维护和用户友好的 UI 的组合技术。
在第十一章**,介绍 React 中的分层架构中,你将探索分层架构,深入研究应用关注层,定义数据模型,并通过实际示例学习策略模式,了解它们对大型应用的重要性。
在第十二章**,实现端到端项目中,你将遍历开发天气应用程序的完整过程,从理解需求到实现城市搜索和添加到收藏夹等功能,同时确保代码可维护、可理解和可扩展。
在第十三章**,回顾反模式原则中,我们将简要回顾常见的反模式、React 设计模式和基本原理,并回顾本书中讨论的技术和实践,在你继续将这些见解应用到自己的项目中之前,提供一个简洁的复习。
要充分利用这本书
要深入阅读这本书,手头有一个文本编辑器至关重要;Visual Studio Code 或 Vim 等选择值得称赞,但任何其他你偏好的编辑器也能很好地满足需求。另一种选择是功能齐全的集成开发环境(IDE)如 WebStorm 或 IntelliJ,虽然不是强制性的,但可以显著提高你的效率。
命令行界面是另一个必需品;对于 Mac 或 Linux 用户,设置已经就绪,但 Windows 用户可能需要安装——Windows Terminal 是一个不错的选择,它提供了一个现代的终端和命令行体验。提前准备这些工具将确保你在穿越本书内容时有一个无缝的旅程。
本书涵盖的软件/硬件 | 操作系统要求 |
---|---|
React 16+ | Windows、macOS 或 Linux |
TypeScript 4.9.5 | |
Visual Studio Code 或 WebStorm | |
终端/Window Terminal(适用于 Windows 用户) |
下载示例代码文件
你可以从 GitHub(github.com/PacktPublishing/React-Anti-Patterns/
)下载本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富的书籍和视频目录的代码包,可在github.com/PacktPublishing/
找到。查看它们吧!
使用的约定
本书中使用了多种文本约定。
文本中的代码
: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“然后我们可以将所需的标题
和摘要
传递给Article
组件。”
代码块设置如下:
<Article
heading="Think in components"
summary="It's important to change your mindset when coding with React."
/>
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
<article>
<h3>Think in components</h3>
<p>It's important to change your mindset when coding with React.</p>
</article>
任何命令行输入或输出都按以下方式编写:
$ mkdir css
$ cd css
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“从管理面板中选择系统信息。”
小贴士或重要注意事项
看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请通过 customercare@packtpub.com 给我们发邮件,并在邮件主题中提及书名。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问www.packtpub.com/support/errata并填写表格。
盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过版权@packt.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com.
分享您的想法
读完《React Anti-Patterns》后,我们很乐意听听您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
您喜欢在路上阅读,但无法携带您的印刷书籍到处走?
您的电子书购买是否与您选择的设备不兼容?
不要担心,现在,您每购买一本 Packt 书籍,都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠远不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。
按照以下简单步骤获取好处:
- 扫描二维码或访问以下链接
packt.link/free-ebook/9781805123972
-
提交您的购买证明
-
就这样!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件中
第一部分:介绍基础知识
在本书的第一部分,你将通过探索其核心基础知识并了解如何高效地构建应用程序来踏入 React 的领域。本部分将有助于构建一个坚实的基础,这对于在后续部分中导航更复杂的 React 方面至关重要。
本部分包含以下章节:
-
第一章, 介绍 React 反模式
-
第二章, 理解 React 基础知识
-
第三章, 组织你的 React 应用程序
-
第四章, 设计你的 React 组件
第一章:介绍 React 反模式
本书深入探讨了 React 反模式。反模式不一定是技术错误——代码最初通常可以正常工作——但尽管它可能最初看起来是正确的,随着代码库的扩展,这些反模式可能会变得有问题。
在我们浏览本书的过程中,我们将审查可能不符合最佳实践的代码示例;一些可能难以理解,而另一些则难以修改或扩展。虽然某些代码片段可能适用于较小的任务,但当规模扩大时,它们会失败。此外,我们将探索来自广阔软件世界的经过时间考验的模式和原则,并将它们无缝地编织到我们的前端讨论中。
我的目标是实用性。代码示例要么来自过去的项目,要么来自常见的领域,如购物车和用户资料组件,以减少你需要解码领域术语的需求。为了获得全面的视角,最后一章展示了详细的全过程示例,提供了一个更有组织和沉浸式的体验。
具体来说,在本章介绍中,我们将讨论构建高级 React 应用程序的复杂性,强调状态管理和异步操作如何模糊代码的清晰度。我们将列举常见的反模式,并简要介绍书中稍后详细介绍的补救策略。
在本章中,我们将涵盖以下主题:
-
理解构建 UI 的难度
-
理解状态管理
-
探索“不愉快路径”
-
探索 React 中的常见反模式
技术要求
已创建一个 GitHub 仓库来托管本书中讨论的所有代码。对于本章,你可以在 github.com/PacktPublishing/React-Anti-Patterns/tree/main/code/src/ch1
找到代码。
理解构建 UI 的难度
除非你正在构建一个简单的、文档式的网页——例如,一个没有高级 UI 元素(如搜索框或模态框)的基本文章——否则,网络浏览器提供的内置语言通常是不够的。图 1.1 展示了一个使用 HTML(超文本标记语言)的网站示例:
图 1.1:一个简单的 HTML 文档网站
然而,如今,大多数应用程序都比这个语言最初设计时更复杂,包含的元素也更多。
网页语言与人们日常遇到的 UI 体验之间的差异是巨大的。无论是票务预订平台、项目管理工具还是图片库,现代的 Web UI 非常复杂,而原生的 Web 语言并不容易支持它们。你可以走得更远来“模拟”UI 组件,如手风琴、切换开关或交互式卡片,但本质上,你仍在处理相当于文档的东西,而不是真正的 UI 组件。
在一个理想的世界里,构建用户界面将类似于与视觉 UI 设计师合作。像 C++ Builder 或 Delphi 这样的工具,或者更现代的替代品如 Figma,允许你将组件拖放到画布上,然后在任何屏幕上无缝渲染。但在网页开发中并非如此。例如,要创建一个自定义的搜索输入框,你需要将其包裹在额外的元素中,微调颜色,调整填充和字体,可能还需要添加一个图标以供用户指导。创建一个正好位于搜索框下方、宽度与其完全匹配的自动建议列表,通常比人们最初想象的要费时得多。
如图 1.2所示,一个网页可能非常复杂,表面上看起来根本不像一份文档,尽管页面的构建块仍然是纯 HTML:
图 1.2:Jira 问题视图
这张截图显示了 Jira 的问题视图,Jira 是一款流行的基于网络的项目管理工具,用于跟踪、优先排序和协调任务和项目。问题视图包含许多细节,例如问题的标题、描述、附件、评论和链接的问题。它还包含许多用户可以与之交互的元素,例如分配给我按钮、更改问题优先级的能力、添加评论等。
对于这样的 UI,你可能会期望有一个导航组件、一个下拉列表、一个手风琴等。表面上看起来,它们确实存在,如图 1.2所示。但实际上,它们并不是组件。相反,开发者们努力使用 HTML、CSS 和 JavaScript 来模拟这些组件。
既然我们已经浏览了 Web UI 开发中语言不匹配的问题,那么深入探讨表面之下的问题可能是有帮助的——前端应用程序中我们需要管理的不同状态。这将为我们提供即将到来的挑战的预览,并阐明为什么引入模式是解决这些问题的关键步骤。
理解状态管理
在现代前端开发中管理状态是一项复杂的任务。几乎每个应用程序都必须通过网络从远程服务器检索数据——我们可以称这些数据为远程状态。远程状态源自外部来源,通常是后端服务器或 API。这与本地状态形成对比,本地状态是在前端应用程序内部生成和管理的。
远程状态有许多阴暗面,如果你不密切关注它们,将会使前端开发变得困难。在这里,我将仅列出一些明显的考虑因素:
-
异步性:从远程源获取数据通常是一个异步操作。这增加了在时间上的复杂性,尤其是在你需要同步多个远程数据时。
-
错误处理:连接到远程源可能会失败,或者服务器可能会返回错误。为了提供流畅的用户体验,正确管理这些场景可能具有挑战性。
-
加载状态:在等待从远程源到达数据时,应用程序需要有效地处理“加载”状态。这通常涉及显示加载指示器或回退 UI(当请求组件不可用时,我们暂时使用默认的一个)。
-
一致性:保持前端状态与后端同步可能很困难,尤其是在实时应用或涉及多个用户更改同一数据的应用中。
-
缓存:将一些远程状态存储在本地可以提高性能,但也会带来自己的挑战,例如失效和过时。换句话说,如果远程数据被他人更改,我们需要一种机制来接收更新或重新获取数据以更新我们的本地状态,这引入了大量的复杂性。
-
更新和乐观 UI:当用户进行更改时,你可以乐观地更新 UI,假设服务器调用将成功。但如果它失败了,你需要一种方法来回滚前端状态中的这些更改。
这些只是远程状态的一些挑战。
当数据在前端立即存储和可访问时,你基本上会以线性方式思考。这意味着你以直接顺序访问和操作数据,一个操作紧随另一个操作,导致逻辑流程清晰且直接。这种思维方式与代码的同步性质相吻合,使得开发过程直观且易于遵循。
让我们比较一下渲染静态数据和远程数据所需的代码量。考虑一个在页面上显示引用列表的著名引用应用。
要渲染传入的引用列表,你可以将数据映射到 JSX 元素中,如下所示:
function Quotes(quotes: string[]) {
return (
<ul>
{quotes.map((quote, index) => <li key={index}>{quote}</li>)}
</ul>
);
}
注意
我们在这里使用index
作为键,这对于静态引用来说是可行的。然而,通常最好避免这种做法。在实际场景中的动态列表中,使用索引可能导致渲染问题。
如果引用来自远程服务器,代码将变成如下所示:
import React, { useState, useEffect } from 'react';
function Quotes() {
const [quotes, setQuotes] = useState<string[]>([]);
useEffect(() => {
fetch('https://quote-service.com/quotes')
.then(response => response.json())
.then(data => setQuotes(data));
}, []);
return (
<ul>
{quotes.map((quote, index) => <li key={index}>{quote}</li>)}
</ul>
);
}
export default Quotes;
在这个 React 组件中,我们使用useState
创建一个引用状态变量,初始设置为空数组。useEffect
Hook 在组件挂载时从远程服务器获取引用。然后,它使用获取的数据更新引用状态。最后,组件渲染一个引用列表,遍历quotes
数组。
不要担心,现在没有必要担心细节;我们将在下一章关于 React 必备知识的章节中深入探讨。
之前的代码示例显示了理想场景,但在现实中,异步调用有其自身的挑战。我们必须考虑在数据获取时显示什么,以及如何处理各种错误场景,例如网络问题或资源不可用。这些额外的复杂性可能会使代码更长且更难以理解。
例如,在获取数据时,我们会临时过渡到加载状态,如果出现任何问题,我们会切换到错误状态:
function Quotes() {
const [quotes, setQuotes] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setIsLoading(true);
fetch('https://quote-service.com/quotes')
.then(response => {
if (!response.ok) {
throw new Error('Failed to fetch quotes');
}
return response.json();
})
.then(data => {
setQuotes(data);
})
.catch(err => {
setError(err.message);
})
.finally(() => {
setIsLoading(false);
});
}, []);
return (
<div>
{isLoading && <p>Loading...</p>}
{error && <p>Error: {error}</p>}
<ul>
{quotes.map((quote, index) => <li key={index}>{quote}</li>)}
</ul>
</div>
);
}
代码使用 useState
管理三件状态:quotes
用于存储引用,isLoading
用于跟踪加载状态,error
用于任何获取错误。
useEffect
钩子触发了获取操作。如果获取成功,则显示引用并设置 isLoading
为 false
。如果发生错误,则显示错误消息并将 isLoading
再次设置为 false
。
如你所见,组件中实际渲染的部分相当小(即 return
中的 JSX 代码)。相比之下,管理状态几乎消耗了函数体的大部分。
但这只是状态管理的一个方面。还有管理本地状态的问题,这意味着状态只需要在组件内部维护。例如,如图图 1**.3所示,手风琴组件需要跟踪它是展开还是折叠的——当你点击标题上的三角形时,它会切换列表面板:
图 1.3:可展开的部分
当你的应用达到一个使状态跟踪变得困难的高度复杂度时,使用像 Redux 或 MobX 这样的第三方状态管理库可能会有所帮助。然而,使用第三方状态管理库并非没有其缺点(学习曲线、特定库的最佳实践、迁移努力等),因此应仔细考虑。这就是为什么许多开发者倾向于使用 React 内置的 Context API 进行状态管理。
现代前端应用中另一个显著的复杂性,许多开发者往往没有注意到,但它就像一座需要更密切关注的冰山,这就是“不愉快的路径”。让我们接下来看看这些内容。
探索“不愉快的路径”
当谈到 UI 开发时,我们通常主要关注“愉快的路径”——一切按计划进行的最佳用户体验。然而,忽视“不愉快的路径”可能会使你的 UI 比最初想象的要复杂得多。以下是一些可能导致不愉快路径并进而复杂化你的 UI 开发工作的场景。
来自其他组件抛出的错误
想象一下,你正在使用第三方组件,甚至在你的应用程序中使用另一个团队的组件。如果该组件抛出错误,它可能会破坏你的 UI 或导致你必须考虑的意外行为。这可能涉及添加条件逻辑或错误边界来优雅地处理这些错误,从而使你的 UI 比最初预期的更复杂。
例如,在一个渲染项目数据的MenuItem
组件中,让我们看看当我们尝试访问传入的属性item
中不存在的东西时会发生什么(在这种情况下,我们正在寻找名为item.something.doesnt.exist
的属性):
const MenuItem = ({
item,
onItemClick,
}: {
item: MenuItemType;
onItemClick: (item: MenuItemType) => void;
}) => {
const information = item.something.doesnt.exist;
return (
<li key={item.name}>
<h3>{item.name}</h3>
<p>{item.description}</p>
<button onClick={() => onItemClick(item)}>Add to Cart</button>
</li>
);
};
MenuItem
组件接收一个item
对象和一个onItemClick
函数作为属性。它显示项目的名称和描述,以及包含一个onItemClick
函数被调用,并使用项目作为参数。
这段代码尝试访问一个不存在的属性,item.something.doesnt.exist
,这将导致运行时错误。正如图 1.4所示,在后台服务返回一些意外数据后,应用程序停止工作:
图 1.4:渲染期间组件抛出的异常
如果我们不将错误隔离到一个错误边界中,这可能会导致整个应用程序崩溃,正如我们在图 1.4中看到的那样。4* – 菜单没有显示,但类别和页面标题仍然可用;受影响的区域,我用红色虚线标出,是菜单原本应该出现的地方。React 中的错误边界是一个特性,允许你捕获子组件中发生的 JavaScript 错误,记录这些错误,并显示一个回退 UI,而不是让整个应用程序崩溃。错误边界在渲染期间、生命周期方法和它们下面的整个树的构造函数中捕获错误。
在现实世界的项目中,你的 UI 可能依赖于各种微服务或 API 来获取数据。如果这些下游系统中的任何一个出现故障,你的 UI 必须对此做出反应。你需要设计回退方案、加载指示器或友好的错误消息,以指导用户下一步该做什么。有效地处理这些场景通常需要前端和后端逻辑,从而为你的 UI 开发任务增加另一层复杂性。
学习意外的用户行为
无论你如何完美地设计你的 UI,用户总会找到使用你的系统的方式,这是你没有预料到的。无论是他们在文本字段中输入特殊字符,尝试快速提交表单,还是使用干扰你网站的浏览器扩展,你必须设计你的 UI 来处理这些边缘情况。这意味着实现额外的验证、检查和安全措施,这些可能会使你的 UI 代码库变得复杂。
让我们考察一个基本的Form
组件,以了解用户输入的考虑因素。虽然这个单字段表单可能需要在handleChange
方法中添加额外的逻辑,但重要的是要注意,大多数表单通常由多个字段组成(这意味着我们需要考虑更多的意外用户行为):
import React, { ChangeEvent, useState } from "react";
const Form = () => {
const [value, setValue] = useState<string>("");
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
const inputValue = event.target.value;
const sanitizedValue = inputValue.replace(/[^\w\s]/gi, "");
setValue(sanitizedValue);
};
return (
<div>
<form>
<label>
Input without special characters:
<input type="text" value={value} onChange={handleChange} />
</label>
</form>
</div>
);
};
export default Form;
这个Form
组件由一个单行文本输入字段组成,该字段限制输入为字母数字字符和空格。它使用一个value
状态变量来存储输入字段的值。handleChange
函数在每次输入更改时触发,在更新状态为清洗后的值之前,从用户的输入中删除任何非字母数字字符。
理解并有效地管理这些不愉快的路径对于创建一个强大、有弹性和用户友好的界面至关重要。这不仅使你的应用程序更加可靠,而且也有助于创造一个更全面和深思熟虑的用户体验。
我认为你现在应该对在 React 中构建现代前端应用程序的挑战有了更清晰的洞察。解决这些障碍并不简单,尤其是 React 没有提供明确的指南,说明应该采用哪种方法,如何构建你的代码库,管理状态,或者确保代码的可读性(以及由此带来的长期维护的便捷性),或者如何利用既定模式提供帮助,以及其他担忧。这种缺乏指导往往导致开发者创造出可能在短期内有效,但可能充满反模式的解决方案。
探索 React 中的常见反模式
在软件开发领域,我们经常遇到看似为特定问题提供有益解决方案的实践和方法。这些被标记为反模式的实践,可能提供即时的缓解或看似快速的修复,但它们通常隐藏了潜在的问题。随着时间的推移,依赖这些反模式可能导致更大的复杂性、低效,甚至可能是它们试图解决的问题。
认识和理解这些反模式对于开发者至关重要,因为它使他们能够预见潜在的陷阱,并避开那些可能长期产生反效果的解决方案。在接下来的章节中,我们将突出显示常见的反模式,并附上代码示例。我们将解决每个反模式,并概述可能的解决方案。然而,我们不会在这里深入探讨,因为整个章节都致力于详细讨论这些主题。
扩孔钻探
在复杂的 React 应用程序中,管理状态并确保每个组件都能访问它所需的数据可能变得具有挑战性。这通常以props 钻探的形式出现,其中 props 从一个父组件通过多个中间组件传递,最终到达真正需要它们的子组件。
例如,考虑一个SearchableList
、List
和ListItem
的层次结构——一个SearchableList
组件包含一个List
组件,而List
包含多个ListItem
实例:
function SearchableList({ items, onItemClick }) {
return (
<div className="searchable-list">
{/* Potentially some search functionality here */}
<List items={items} onItemClick={onItemClick} />
</div>
);
}
function List({ items, onItemClick }) {
return (
<ul className="list">
{items.map(item => (
<ListItem key={item.id} data={item} onItemClick={onItemClick}
/>
))}
</ul>
);
}
function ListItem({ data, onItemClick }) {
return (
<li className="list-item" onClick={() => onItemClick(data.id)}>
{data.name}
</li>
);
}
在这个设置中,onItemClick
属性从SearchableList
通过List
最终传递到ListItem
。尽管List
组件没有使用这个属性,但它必须将其传递给ListItem
。
这种方法可能导致复杂性增加和可维护性降低。当多个属性通过多个组件传递时,理解数据流和调试变得困难。
避免在 React 中传递属性钻取的一个潜在解决方案是利用上下文 API。它提供了一种在不需要在组件树中的每一层显式传递属性的情况下,在组件之间共享值(数据和函数)的方法。
组件内的数据转换
React 中的组件中心方法主要是将任务和关注点分解成可管理的块,从而提高可维护性。然而,一个常见的错误是开发者直接在组件内部引入复杂的数据转换逻辑。
尤其是处理外部 API 或后端时,通常会遇到数据形状或格式不适合前端的情况。而不是在更高层次或实用函数中调整这些数据,转换是在组件内部定义的。
考虑以下场景:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(response => response.json())
.then(data => {
// Transforming data right inside the component
const transformedUser = {
name: `${data.firstName} ${data.lastName}`,
age: data.age,
address: `${data.addressLine1}, ${data.city}, ${data.
country}`
};
setUser(transformedUser);
});
}, [userId]);
return (
<div>
{user && (
<>
<p>Name: {user.name}</p>
<p>Age: {user.age}</p>
<p>Address: {user.address}</p>
</>
)}
</div>
);
}
UserProfile
函数组件根据提供的userId
属性检索并显示用户的个人资料。一旦远程data
被获取,它就在组件内部进行转换,以创建一个结构化的用户资料。这个转换后的数据包括用户的全名(名和姓的组合)、年龄和格式化的地址。
通过直接嵌入转换,我们遇到了一些问题:
-
缺乏清晰度:将数据获取、转换和渲染任务结合在一个组件中,使得难以准确指出组件的确切目的
-
降低可复用性:如果另一个组件需要相同或类似的转换,我们将重复逻辑
-
测试挑战:现在测试这个组件需要考虑转换逻辑,使得测试更加复杂
为了克服这种反模式,建议将数据转换从组件中分离出来。这可以通过使用实用函数或自定义钩子来实现,从而确保更干净和模块化的设计。通过外部化这些转换,组件保持专注于渲染,而业务逻辑保持集中,从而使得代码库更加易于维护。
视图中的复杂逻辑
现代前端框架,包括 React,的美妙之处在于明确的关注点分离。按照设计,组件应该对业务逻辑的复杂性一无所知,而应专注于呈现。然而,开发者经常遇到的一个常见问题是业务逻辑在视图组件中的注入。这不仅破坏了清晰的分离,还使组件膨胀,并使它们更难测试和重用。
考虑一个简单的例子。想象一个组件,其目的是显示从 API 获取的项目列表。每个项目都有一个价格,但我们想显示高于某个阈值价格的项目:
function PriceListView({ items }) {
// Business logic within the view
const filterExpensiveItems = (items) => {
return items.filter(item => item.price > 100);
}
const expensiveItems = filterExpensiveItems(items);
return (
<div>
{expensiveItems.map(item => (
<div key={item.id}>
{item.name}: ${item.price}
</div>
))}
</div>
);
}
在这里,filterExpensiveItems
函数,一段业务逻辑,直接位于视图组件中。现在,组件不仅要呈现数据,还要处理数据。
这种方法可能会出现问题:
-
可重用性:如果另一个组件需要类似的过滤器,逻辑就需要被复制。
-
测试:随着你不仅要测试渲染,还要测试业务逻辑,单元测试会变得更加复杂。
-
维护:随着应用程序的增长和逻辑的增加,这个组件可能会变得难以控制且难以维护。
为了确保我们的组件保持可重用性和易于维护,明智的做法是接受关注点分离原则。这个原则指出,软件中的每个模块或函数都应该负责应用程序功能的一个部分。通过将业务逻辑与表示层分离,并采用分层架构,我们可以确保代码的每一部分都处理其特定的责任,从而实现更模块化和易于维护的代码库。
缺乏测试
想象一下为在线商店构建购物车组件。购物车至关重要,因为它处理项目的添加、删除和总价计算。尽管这看起来可能很简单,但它包含了各种移动部件和逻辑连接。没有测试,你为未来的问题敞开了大门,比如价格计算错误、项目添加或删除不正确,甚至安全漏洞。
考虑这个购物车的简化版本:
function ShoppingCart() {
const [items, setItems] = useState([]);
const addItem = (item) => {
setItems([...items, item]);
};
const removeItem = (itemId) => {
setItems(items.filter(item => item.id !== itemId));
};
const calculateTotal = () => {
return items.reduce((total, item) => total + item.price, 0);
};
return (
<div>
{/* Render items and controls for adding/removing */}
<p>Total: ${calculateTotal()}</p>
</div>
);
}
虽然这个购物车的逻辑看起来很简单,但潜在的问题正在潜伏。如果项目被错误地添加多次,或者价格动态变化,或者应用了折扣,会发生什么?如果没有测试,这些场景可能直到用户遇到它们才变得明显,这可能会对业务造成损害。
进入 ShoppingCart
组件,意味着要有测试来验证项目是否被正确添加或删除,总价计算是否适当调整,以及处理边缘情况,例如处理折扣等。只有在这些测试到位之后,才应该实现实际的组件逻辑。TDD(测试驱动开发)不仅仅是早期捕捉错误;它推崇结构良好、易于维护的代码。
对于 ShoppingCart
组件,采用 TDD(测试驱动开发)将需要确保项目按预期添加或删除,总计正确计算,并且边缘情况能够无缝处理。这样,随着应用程序的增长,基础 TDD 测试确保每次修改或添加都保持应用程序的完整性和正确性。
重复的代码
在许多代码库中,这是一种常见的景象——在应用程序的不同部分散布着相同或非常相似的代码块。重复的代码不仅膨胀了代码库,还引入了潜在的故障点。当发现错误或需要增强时,可能需要修改重复代码的每个实例,从而增加了引入错误的可能性。
让我们考虑两个组件,它们重复了相同的过滤逻辑:
function AdminList(props) {
const filteredUsers = props.users.filter(user => user.isAdmin);
return <List items={filteredUsers} />;
}
function ActiveList(props) {
const filteredUsers = props.users.filter(user => user.isActive);
return <List items={filteredUsers} />;
}
DRY(不要重复自己)原则在这里发挥了作用。通过将常见逻辑集中到实用函数或 HOCs(高阶组件)中,代码变得更加易于维护和阅读,并且更不容易出错。对于这个例子,我们可以抽象过滤逻辑并重用它,确保单一的真实来源和更容易的更新。
责任过重的长组件
React 鼓励创建模块化、可重用的组件。然而,随着功能的增加,一个组件可能会迅速增大其规模和责任,变成一个难以驾驭的庞然大物。一个管理各种任务的长组件变得难以维护、理解和测试。
想象一个 OrderContainer
组件,它有一个庞大的属性列表,包括许多不同方面的责任:
const OrderContainer = ({
testID,
orderData,
basketError,
addCoupon,
voucherSelected,
validationErrors,
clearErrors,
removeLine,
editLine,
hideOrderButton,
hideEditButton,
loading,
}: OrderContainerProps) => {
//..
}
这样的组件违反了 OrderContainer
组件,并将支持逻辑分离到其他更小、更专注的组件中,或者利用 Hooks 进行逻辑分离。
注意
这些列出的反模式有不同的变体,我们将在接下来的章节中相应地讨论解决方案。除此之外,书中还将讨论一些更通用的设计原则和设计模式,以及一些经过验证的工程实践,例如重构和 TDD。
揭示我们破坏反模式的方法
当涉及到解决普遍的反模式时,一系列设计模式会浮出水面。渲染属性、HOCs(高阶组件)和 Hooks 等技术对于增强组件功能而不偏离其核心角色至关重要,同时利用分层架构和关注点分离等基础模式确保代码库的流畅性,以连贯的方式界定逻辑、数据和展示。这些实践不仅提高了 React 应用的可持续性,还为开发者之间的有效团队合作奠定了基础。
同时,面向接口的编程在本质上专注于定制以软件模块之间的交互为中心的软件,主要通过接口进行。这种做法促进了敏捷性,使软件模块不仅更加连贯,而且易于修改。另一方面,无头组件范式体现了这样的组件,尽管它们没有直接的渲染职责,但负责管理状态或逻辑。这些组件将接力棒传递给它们的消费者,以进行 UI 渲染,从而倡导适应性和可重用性。
通过牢固掌握这些设计模式并明智地部署它们,我们能够避免常见的错误,从而提升我们 React 应用程序的档次。
此外,在编码生态系统中,TDD(测试驱动开发)和持续重构这两大支柱成为提升代码质量的强大工具。TDD 以其“先测试后编码”的明确号召,为潜在的不一致提供了即时反馈循环。与 TDD 携手并进,持续重构的伦理确保代码始终得到优化和精炼。这些方法不仅为代码卓越设定了基准,还培养了应对未来变化的适应性。
随着我们探索重构的领域,深入理解这些技术的本质,辨别它们的复杂性和最佳应用点至关重要。利用这些重构途径有望增强代码的清晰度、可持续性和整体效率。这正是本书将贯穿始终的内容!
摘要
在本章中,我们探讨了 UI 开发的挑战,从其复杂性到状态管理问题,我们也讨论了由于其复杂性的本质而产生的常见反模式,并简要介绍了我们的方法,该方法结合了最佳实践和有效的测试策略。这为更高效和稳健的前端开发奠定了基础。
在下一章中,我们将深入探讨 React 基础知识,为您提供掌握这个强大库所需的技术和知识。敬请期待!
第二章:理解 React 基础知识
欢迎来到我们 React 基础知识指南的基本章节!本章为您进入激动人心的 React 开发世界奠定了坚实的基础。我们将深入探讨 React 的基本概念,并为您提供启动 React 项目所需的基本知识,让您有信心地开始。
在本章中,我们将探讨如何以组件的方式思考,这是构建可重用和模块化用户界面(UIs)的关键心态。您将学习将应用程序分解成更小、自包含组件的艺术,从而能够创建可维护和可扩展的代码库。通过理解这一基本概念,您将具备构建强大和灵活的 React 应用程序的技能。
此外,我们还将向您介绍 React 中最常用的 Hooks,例如useState
、useEffect
等。这些 Hooks 是强大的工具,允许您在函数组件中管理状态、处理副作用,并访问 React 的生命周期方法。通过掌握这些 Hooks,您将能够轻松地创建动态和交互式的 UI。
到本章结束时,您将准备好探索更高级的主题,并在指南的后续章节中应对真实的 React 挑战。所以,系好安全带,准备好开始一段激动人心的 React 世界之旅。
因此,在本章中,我们将涵盖以下主题:
-
理解 React 中的静态组件
-
使用属性创建组件
-
将 UI 分解为组件
-
在 React 中管理内部状态
-
理解渲染过程
-
探索常见的 React Hooks
技术要求
已创建一个 GitHub 仓库来托管本书中讨论的所有代码。对于本章,您可以在github.com/PacktPublishing/React-Anti-Patterns/tree/main/code/src/ch2
找到它。
理解 React 中的静态组件
React 应用程序是基于组件构建的。一个组件可以从一个简单的返回 HTML 片段的函数到更复杂的组件,它可以与网络请求交互,动态生成 HTML 标签,甚至根据后端服务的变化自动刷新。
让我们从基本场景开始,定义一个静态组件。在 React 中,静态组件(也称为展示组件或哑组件)指的是没有状态且不与数据交互或处理任何事件的组件。它是一个仅根据接收到的属性渲染 UI 的组件。以下是一个示例:
const StaticArticle = () => {
return (
<article>
<h3>Think in components</h3>
<p>It's important to change your mindset when coding with
React.</p>
</article>
);
};
这个静态组件与相应的 HTML 片段非常相似,它使用<article>
标签来结构化带有标题和段落的内联内容:
<article>
<h3>Think in components</h3>
<p>It's important to change your mindset when coding with React.</p>
</article>
虽然在组件中封装静态 HTML 很有用,但可能需要组件代表不同的文章,而不仅仅是特定的某篇。就像我们传递参数给函数以使其更灵活一样,我们也可以将参数传递给组件,使其在不同的上下文中有用。这可以通过 props 来实现,我们将在下一节中讨论。
使用属性创建组件
在 React 中,组件可以通过属性接收输入,这些属性通常被称为 props。Props允许我们将数据从父组件传递到子组件。这种机制使得组件可以重用和适应,因为它们可以接收不同的 props 集合来定制其行为和外观。
Props 本质上是由键值对组成的 JavaScript 对象,其中键代表属性名,值包含相应的数据。这些属性可以包括各种类型的数据,如字符串、数字、布尔值,甚至是函数。
通过向组件传递 props,我们可以动态地控制其渲染和行为。这使我们能够创建灵活且可组合的组件,可以轻松组合在一起来构建复杂的 UI。
现在,让我们超越静态组件,看看我们如何通过使用 props 使其更具通用性。假设我们想要显示一系列博客文章,每篇都有标题和摘要。在 HTML 中,我们会手动编写 HTML 片段。然而,使用 React 组件,我们可以使用 JavaScript 动态生成这些 HTML 片段。
首先,让我们从基本结构开始:
type ArticleType = {
heading: string;
summary: string;
};
const Article = ({ heading, summary }: ArticleType) => {
return (
<article>
<h3>{heading}</h3>
<p>{summary}</p>
</article>
);
};
然后,我们可以将所需的heading
和summary
值传递给Article
组件:
<Article
heading="Think in components"
summary="It's important to change your mindset when coding with React."
/>
或者,我们可以定义另一个Article
组件:
<Article
heading="Define custom hooks"
summary="Hooks are a great way to share state logic."
/>
通过使用属性,当我们使用Article
组件时,我们可以向heading
和summary
属性传递不同的值。这使得组件更加灵活和可重用,因为它可以根据提供的属性显示具有不同标题和摘要的各种文章。
Props 是 React 中的一个基本概念,它允许我们自定义和配置组件,使它们具有动态性和适应性,以适应不同的数据或需求。
一个组件可以有任意数量的属性,尽管建议将它们保持在可管理的数量,最好不超过五到六个。这有助于保持清晰和可理解性,因为属性过多会使组件更难理解和扩展。
将 UI 分解为组件
让我们考察一个更复杂的 UI,并探讨如何将其分解为组件并单独实现。在这个例子中,我们将使用一个天气应用程序:
图 2.1:天气应用程序
整个应用程序可以定义为一个WeatherApplication
组件,它包括几个子组件:
const WeatherApplication = () => {
return (
<>
<Heading title="Weather" />
<SearchBox />
<Notification />
<WeatherList />
</>
);
};
每个子组件可以执行各种任务,例如从远程服务器获取数据、条件性地渲染下拉列表或定期自动刷新。
例如,一个SearchBox
组件可能有以下结构:
const SearchBox = () => {
return (
<div className="search-box">
<input type="text" />
<button>Search</button>
<div className="search-results" />
</div>
);
};
只有在从搜索查询获取数据时,search-results
部分才会出现。
另一方面,一个Weather
组件可能更简单,渲染传递给它的任何内容:
type WeatherType = {
cityName: string;
temperature: number;
weather: string;
};
const Weather = ({ cityName, temperature, weather }: WeatherType) => {
return (
<div>
<span>{cityName}</span>
<span>{temperature}</span>
<span>{weather}</span>
</div>
);
};
在实际场景中实现组件时,注意样式并细致地完善 HTML 结构至关重要。此外,组件应有效地管理自己的状态,确保渲染的一致性和响应性。
通过掌握组件及其在 React 中的适当结构化概念,您可以构建动态和可重用的 UI 元素,这些元素有助于您应用程序的整体功能和组织。
在您的 React 开发之旅中不断进步时,不要忘记通过样式美化视觉呈现,并考虑有效的状态管理技术来提高组件的性能和交互性。
一个完整的Weather
组件可能如下所示:
type Weather = {
main: string;
temperature: number;
}
type WeatherType = {
name: string;
weather: Weather;
}
export function WeatherCard({ name, weather }: WeatherType) {
return (
<div className={`weather-container ${weather.main}`}>
<h3>{name}</h3>
<div className="details">
<p className="temperature">{weather.temperature}</p>
<div className="weather">
<span className="weather-category">{weather.main}</span>
</div>
</div>
</div>
);
}
代码片段定义了两种类型:Weather
和WeatherType
。Weather
类型表示带有两个属性(main
(字符串)和temperature
(数字))的天气数据。WeatherType
类型表示特定位置的天气数据结构,具有一个name
属性(字符串)用于位置名称和一个weather
属性,该属性为Weather
类型。
WeatherCard
组件接收WeatherType
类型的名称和天气属性。在组件内部,它渲染一个基于weather.main
值的动态类别的div
容器。
当处理复杂的 UI 时,将它们分解成更小、更易于管理的组件至关重要。每个组件应代表一个独立的概念,并且可以使用JSX(JavaScript 扩展)将它们组合在一起,类似于编写 HTML 代码。虽然 props 对于向组件传递数据很有用,但有时我们需要在组件内部本身维护数据。这就是状态发挥作用的地方,它允许我们内部管理和更新数据。
在 React 中管理内部状态
状态在 React 中指的是组件可以持有和管理的内部数据。它允许组件存储和更新信息,从而实现动态 UI 更新、交互性和数据持久性。状态是 React 中的一个基本概念,有助于构建响应性和交互式应用程序。
应用程序具有各种状态类型,例如用于切换状态的布尔值、用于网络请求的加载状态,或用于查询的用户输入字符串。我们将探索 useState
钩子,它非常适合在组件的重新渲染之间维护本地状态。React Hooks 是在 React 16.8 中引入的一个特性,它使函数组件能够拥有状态和生命周期特性(我们将在后面的 探索常见 React Hooks 部分更详细地讨论 Hooks)。
让我们从使用一个简单的钩子来管理内部状态开始,以了解它是如何在一个组件内维护数据的。例如,以下 SearchBox
组件可以这样实现:
const SearchBox = () => {
const [query, setQuery] = useState<string>();
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value);
};
return (
<div className="search-box">
<input type="text" value={query} onChange={handleChange} />
<button>Search</button>
<div className="search-results">{query}</div>
</div>
);
};
代码片段展示了 SearchBox
包含一个输入字段、一个搜索按钮和一个用于显示搜索结果的显示区域。useState
钩子用于创建一个名为 query
的状态变量,初始化为空字符串。handleChange
函数捕获用户的输入并相应地更新查询状态。然后,component
渲染输入字段,显示 query
的当前值,一个搜索按钮,以及一个用作容器显示搜索结果的 div
标签。
注意这里,useState
钩子被用于在 SearchBox
组件中管理状态:
const [query, setQuery] = useState<string>("");
让我们更具体地解释一下这段代码:
-
useState<string>("")
:这一行声明了一个名为query
的状态变量,并用空字符串(""
)初始化它 -
const [query, setQuery]
:这个语法使用数组解构将状态变量(query
)及其相应的更新函数(setQuery
)分配给具有相同名称的变量
现在,我们已经有了状态和绑定到输入框的设置函数。当我们输入城市名称到输入框时,search-results
区域会自动更新,同时输入值也会更新。尽管在我们输入时会发生多次重新渲染,但状态值在整个过程中保持不变:
图 2.2:使用 useState 管理状态
useState
钩子非常适合管理内部状态,但在现实世界的项目中,经常需要处理各种其他状态类型。随着应用程序的扩展,管理跨多个组件共享的全局级数据,例如从父组件到子组件,变得必要。我们将在后面的章节中讨论这种状态管理的不同机制。
现在我们已经了解了如何通过 props 和状态来创建动态组件,那么探索数据变化如何影响 React 中的渲染过程就变得很重要了。通过理解这个过程,我们可以采取措施来优化我们的代码,以提高效率和性能。
理解渲染过程
当一个 React 组件依赖的数据发生变化时,无论是通过更新的 props 还是修改的状态,React 都需要更新 UI 以反映这些变化。这个过程被称为 渲染,它由以下步骤组成:
-
初始渲染:当函数组件首次渲染时,它生成组件 UI 的虚拟表示。这个虚拟表示描述了 UI 元素的结构和内容。
-
状态和属性变化:当组件的状态或属性发生变化时,React 重新评估组件的函数体。它执行 diffing 算法来比较之前和新的函数体,确定它们之间的差异。
注意
在 React 中,diffing 算法是一个内部机制,它比较组件之前和新的虚拟 文档对象模型(DOM)表示,并确定更新实际 DOM 所需的最小更改集。
-
协调:React 根据在 diffing 过程中识别出的差异确定 UI 需要更新的部分。它只更新 UI 的特定部分,保持其余部分不变。
-
重新渲染:React 通过更新 UI 的虚拟表示来重新渲染组件。它根据更新的函数体生成新的虚拟 DOM,替换之前的虚拟 DOM。
-
DOM 更新:最后,React 高效地更新实际的 DOM 以反映虚拟 DOM 的变化。它应用必要的 DOM 操作,如添加、删除或更新元素,使 UI 反映更新的状态和属性。
此过程确保 UI 与组件的状态和属性保持同步,实现响应式和动态的 UI。React 的高效渲染方法最小化了不必要的 DOM 操作,并在函数组件中提供高性能的渲染体验。
在这本书中,我们将探讨编写高性能代码至关重要的场景,确保组件仅在必要时重新渲染,同时保留未更改的部分。实现这一点需要有效地利用 Hooks 并采用各种技术来优化渲染。
在应用程序中,数据管理至关重要,但我们也会遇到副作用,如网络请求、DOM 事件以及组件间数据共享的需求。为了应对这些挑战,React 提供了一系列常用的 Hooks,它们是构建应用程序的强大工具。让我们探索这些 Hooks 并看看它们如何极大地帮助我们开发过程。
探索常见的 React Hooks
我们在 管理 React 中的内部状态 部分简要介绍了 Hooks。此外,Hooks 允许代码重用,提高可读性,并通过分离关注点使组件逻辑更模块化,从而简化测试。
在本节中,让我们讨论一些最常见的 Hooks。请注意,在本章中,我们专注于最常用的 Hooks。随着我们阅读本书的进展,我们将深入探讨这些 Hooks 的更多高级应用。关于最后一个 Hook,useContext
,我们将在本节末尾初步探讨其基本用法,以提供入门级理解。在后面的章节中,我们将更复杂地使用 useContext
,以便更深入和更实际地掌握其功能。
useState
我们在本章中已经看到了 useState
Hook 的基本用法。你可以在组件内部定义任意多的状态,这在实际项目中非常常见。例如,一个登录表单可能包括用户名、密码以及一个 记住我 标志。在用户点击提交(登录)按钮之前,所有这些状态都需要被记住:
图 2.3:登录表单
根据用户界面,我们需要为用户名、密码以及一个表示 记住我 的布尔标志准备三个不同的状态,如下所示:
const Login = () => {
const [username, setUsername] = useState<string>("");
const [password, setPassword] = useState<string>("");
const [rememberMe, setRememberMe] = useState<boolean>(false);
return (
<div className="login-form">
<div className="field">
<input
type="text"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="Username"
/>
</div>
<div className="field">
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="Password"
/>
</div>
<div className="field">
<label>
<input
type="checkbox"
checked={rememberMe}
onChange={(event) => setRememberMe(event.target.checked)}
/>
Remember Me
</label>
</div>
<div className="field">
<button>Login</button>
</div>
</div>
);
};
在代码片段中,我们必须管理三个不同的状态,因此使用 useState
Hook 来管理 username
、password
和 rememberMe
字段的状态。组件渲染用户名和密码的输入字段、一个用于 记住我 的复选框以及一个 登录 按钮。用户输入更新相应的状态变量,从而捕获表单数据。
useEffect
在 React 中,副作用指的是任何与渲染组件直接无关但影响组件范围外代码的代码。副作用通常涉及与外部资源交互,例如进行 API 调用、修改底层 DOM(不使用正常的 React 虚拟 DOM)、订阅事件监听器或管理计时器。
React 提供了一个名为 useEffect
的内置 Hook,用于在函数组件中处理副作用。useEffect
Hook 允许你在渲染后或特定依赖项更改时执行副作用。
通过使用 useEffect
Hook,你可以确保在组件的生命周期中适当的时间执行副作用。这有助于保持应用程序的一致性和完整性,同时将副作用与核心渲染逻辑分离。
让我们看看 useEffect
的一个典型用法。我们在 使用属性创建组件 部分创建了一个 Article
组件;现在,让我们制作一个文章列表。通常,文章列表可以从一些 API 调用中返回;例如,以 JSON 格式。我们可以使用 useEffect
Hook 在 API 调用返回响应后发送请求并设置状态,如下所示:
const ArticleList = () => {
const [articles, setArticles] = useState<ArticleType[]>([]);
useEffect(() => {
const fetchArticles = async () => {
fetch("/api/articles")
.then((res) => res.json())
.then((data) => setArticles(data));
};
fetchArticles();
}, []);
return (
<div>
{articles.map((article) => (
<Article heading={article.heading} summary={article.summary}
/>
))}
</div>
);
};
代码片段展示了在 React 函数组件中使用 useEffect
Hook 的用法。让我们分解一下:
-
useEffect(() => { ... }, []);
:这一行声明了useEffect
钩子,并提供了两个参数。第一个参数是一个回调函数,其中包含要执行的副作用代码。第二个参数是一个依赖项数组,它决定了何时触发副作用。一个空数组[]
表示副作用应该只在初始渲染期间运行一次。 -
const fetchArticles = async () => { ... }
:这一行声明了一个名为fetchArticles
的异步函数。在这个函数内部,对/api/articles
端点进行了 API 调用以获取数据。 -
fetch("/api/articles")...
:这一行使用fetch
函数向指定的 API 端点发出GET
请求。然后使用承诺(then
)处理响应,以提取 JSON 数据。 -
setArticles(data)
:这一行使用setArticles
函数更新组件的状态变量articles
,使用检索到的数据。这将触发组件使用更新后的数据重新渲染。 -
fetchArticles()
:这一行调用了fetchArticles
函数,触发了 API 调用并更新了文章的状态。
一旦我们有了这些文章,我们就可以使用array.map
集合 API 生成文章列表。
值得注意的是,当严格模式开启时,在开发过程中,React 会在实际设置之前额外运行一次设置和清理。实际上,你可以将整个应用程序包裹在 React 的内置组件StrictMode
中,这样你的组件将额外渲染一次以查找由不纯渲染引起的错误以及其他检查。
此外,请注意useEffect
的第二个参数是关键的。我们之前使用了一个空数组,因为我们不希望每次都触发效果,但有些情况下我们希望在依赖项之一发生变化时执行效果。
例如,假设我们有一个ArticleDetail
组件,每当文章的id
属性发生变化时,我们需要重新获取数据并重新渲染:
const ArticleDetail = ({ id }: { id: string }) => {
const [article, setArticle] = useState<ArticleType>();
useEffect(() => {
const fetchArticleDetail = async (id: string) => {
fetch(`/api/articles/${id}`)
.then((res) => res.json())
.then((data) => setArticle(data));
};
fetchArticleDetail(id);
}, [id]);
return (
<div>
{article && (
<Article heading={article.heading} summary={article.summary}
/>
)}
</div>
);
};
在useEffect
钩子内部,定义了fetchArticleDetail
函数来处理 API 调用。它根据提供的id
属性获取文章详情,将响应转换为 JSON,并使用setArticle
更新文章状态。
当id
属性发生变化时,效果被触发。在成功检索到文章数据后,Article
组件将使用文章状态中的heading
和summary
属性进行渲染。
useEffect
钩子处理副作用的一个基本特性是清理机制。当使用useEffect
时,建议返回一个清理函数,React 将在组件卸载时调用它。例如,如果你在useEffect
中设置了一个计时器,你应该提供一个函数来清除这个计时器作为返回值。这确保了适当的资源管理,并防止了应用程序中潜在的内存泄漏。
例如,假设我们有一个组件需要在初始渲染后 1 秒执行一个效果,如下所示:
const Timer = () => {
useEffect(() => {
const timerId = setTimeout(() => {
console.log("time is up")
}, 1000);
return () => {
clearTimeout(timerId)
}
}, [])
return <div>Hello timer</div>
}
因此,在Timer
组件中,useEffect
Hook 被用来处理副作用。当组件挂载时,设置一个setTimeout
函数,在延迟1000
毫秒后记录消息time is up
。然后useEffect
Hook 返回一个清理函数以防止内存泄漏。这个清理函数使用clearTimeout
在组件卸载时清除由timerId
标识的计时器。
对于ArticleDetail
示例,带有清理函数的完整版本可能如下所示:
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
const fetchArticleDetail = async (id: string) => {
fetch(`/api/articles/${id}`, { signal })
.then((res) => res.json())
.then((data) => setArticle(data));
};
fetchArticleDetail(id);
return () => {
controller.abort();
};
}, [id]);
在此代码片段中,使用useEffect
Hook 在组件内部使用AbortController
组件来管理网络请求的生命周期。当组件挂载时,useEffect
Hook 被触发,创建一个新的AbortController
实例并提取其信号。这个信号被传递给fetch
函数,将请求链接到控制器。
如果组件在请求完成之前卸载,则会调用清理函数,使用控制器的abort
方法取消正在进行的获取请求。这可以防止潜在的更新已卸载组件的状态等问题,确保更好的性能并避免内存泄漏。
现在,让我们将注意力转向另一个关键的 Hook,它通过防止在重新渲染期间创建不必要的函数来提高性能。
useCallback
React 中的useCallback
Hook 用于记忆化和优化回调函数的创建。它在将回调传递给子组件或在其他 Hooks 中将回调作为依赖项时特别有用。您可以在以下操作中看到它的作用:
const memoizedCallback = useCallback(callback, dependencies);
useCallback
Hook 接受两个参数:
-
callback
:这是您想要记忆化的函数。它可以是内联函数或函数引用。 -
dependencies
:这是一个数组,包含记忆化回调所依赖的依赖项。如果任何依赖项发生变化,回调将被重新创建。
让我们探索一个实际例子。我们需要一个编辑器组件来修改文章的摘要。每当用户输入一个字符时,摘要需要更新,触发重新渲染。然而,这种重新渲染可能会导致每次创建一个新的函数,这可能会影响性能。为了减轻这种情况,我们可以利用useCallback
Hook 来优化渲染过程并避免不必要的函数重新创建:
const ArticleEditor = ({ id }: { id: string }) => {
const submitChange = useCallback(
async (summary: string) => {
try {
await fetch(`/api/articles/${id}`, {
method: "POST",
body: JSON.stringify({ id, summary }),
headers: {
"Content-Type": "application/json",
},
});
} catch (error) {
// handling errors
}
},
[id]
);
return (
<div>
<ArticleForm onSubmit={submitChange} />
</div>
);
};
在ArticleEditor
组件中,使用useCallback
来记忆化submitChange
函数,该函数异步使用fetch
API 发送POST
请求以更新文章。这种使用useCallback
的优化确保了只有当id
属性改变时,submitChange
才会被重新创建,通过减少不必要的重新计算来提高性能。
组件随后渲染ArticleForm
,将submitChange
作为属性传递以处理表单提交:
const ArticleForm = ({ onSubmit }: { onSubmit: (summary: string) => void }) => {
const [summary, setSummary] = useState<string>("");
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
onSubmit(summary);
};
const handleSummaryChange = useCallback(
(event: ChangeEvent<HTMLTextAreaElement>) => {
setSummary(event.target.value);
},
[]
);
return (
<form onSubmit={handleSubmit}>
<h2>Edit Article</h2>
<textarea value={summary} onChange={handleSummaryChange} />
<button type="submit">Save</button>
</form>
);
};
ArticleForm
使用 useState
钩子来跟踪摘要状态。当表单提交时,handleSubmit
阻止默认表单操作,并使用当前摘要调用 onSubmit
。handleSummaryChange
函数通过 useCallback
优化,根据 textarea
输入更新摘要状态。这种使用 useCallback
的方法确保函数在每次渲染时不会不必要地重新创建,从而提高性能。
React Context API
React Context API 是一个功能,允许您直接通过组件树传递数据,而无需在每一级手动传递属性。当您的应用程序具有许多组件共享的全局数据,或者当您需要通过不需要数据的组件传递数据时,这非常有用。
例如,假设我们正在创建一个应用程序,该应用程序包括根据当前时间(例如,如果是白天,我们使用浅色模式)的深色或浅色主题。我们需要在根级别设置主题。
因此,首先,我们定义一个类型 ThemeContextType
并创建一个该类型的上下文实例 ThemeContext
:
import React from "react";
export type ThemeContextType = {
theme: "light" | "dark";
};
export const ThemeContext = React.createContext<ThemeContextType | undefined>(
undefined
);
然后,我们创建一个 ThemeProvider
组件,该组件将使用 React 状态来管理当前主题:
import React, { useState } from "react";
import { ThemeContext, ThemeContextType } from "./ThemeContext";
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState<"light" | "dark">("light");
const value: ThemeContextType = { theme };
return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
};
最后,我们可以在我们的应用程序中使用 ThemeProvider
组件:
import React from "react";
import { ThemeProvider } from "./ThemeProvider";
import App from "./App";
const Root = () => {
return (
<ThemeProvider>
<App />
</ThemeProvider>
);
};
export default Root;
在我们应用程序的任何组件中,我们现在都可以访问当前主题:
import React, { useContext } from "react";
import { ThemeContext } from "./ThemeContext";
const ThemedComponent = () => {
const context = useContext(ThemeContext);
const { theme } = context;
return <div className={theme}>Current Theme: {theme}</div>;
};
export default ThemedComponent;
在这种设置中,ThemeContext
将当前主题提供给任何对其感兴趣的应用程序树中的组件。主题存储在 ThemeProvider
组件的状态变量中,它是应用程序的根组件。
提供的代码可能不会提供很多实用性,因为主题无法修改。然而,通过利用 Context API,您可以定义一个修改器,允许子节点更改状态。这种机制对于数据共享非常有用,使其成为一个有价值的工具。让我们稍微修改一下上下文接口:
type Theme = {
theme: "light" | "dark";
toggleTheme: () => void;
};
const ThemeContext = React.createContext<Theme>({
theme: "light",
toggleTheme: () => {},
});
我们在上下文中添加了一个 toggleTheme
函数,以便组件在需要时可以修改 theme
值。
要实现提供者,我们可以使用 useState
钩子来定义内部状态。通过暴露设置函数,子组件可以利用它来更新主题的值:
const ThemeProvider = ({ children }: { children: ReactNode }) => {
// default theme is light
const [theme, setTheme] = useState<"light" | "dark">("light");
const toggleTheme = useCallback(() => {
setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
}, []);
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
然后,在调用位置,使用 useContext
钩子访问上下文中的值是直接的:
const Article = ({ heading, summary }: ArticleType) => {
const { theme, toggleTheme } = useContext(ThemeContext);
return (
<article className={theme}>
<h3>{heading}</h3>
<p>{summary}</p>
<button onClick={toggleTheme}>Toggle</button>
</article>
);
};
然后,每次我们点击 切换 按钮时,它将更改主题并触发重新渲染:
图 2.4:使用主题上下文
在 React 中,Context API 允许您创建和管理可以在整个应用程序中访问的全局状态。这种能力使您能够组合多个上下文提供者,每个提供者代表应用程序状态的不同部分或方面。
通过使用单独的上下文提供者,例如用于安全性的一个,用于日志记录的另一个,以及可能的其他提供者,您可以有效地组织和共享相关数据和功能。每个上下文提供者封装了特定的关注点,这使得管理并更新相关状态变得更加容易,而不会影响应用程序的其他部分:
import InteractionContext from "xui/interaction-context";
import SecurityContext from "xui/security-context";
import LoggingContext from "xui/logging-context";
const Application = ({ children }) => {
const context = {}; // ... define values for context
//... define securityContext
//... define loggingContext
return (
<InteractionContext.Provider value={context}>
<SecurityContext.Provider value={securityContext}>
<LoggingContext.Provider value={loggingContext}>
{children}
</LoggingContext.Provider>
</SecurityContext.Provider>
</InteractionContext.Provider>
);
};
这个例子演示了在应用程序组件中使用 React 的 Context API 创建和组合多个上下文提供者的用法。InteractionContext.Provider
、SecurityContext.Provider
和 LoggingContext.Provider
用于包装子组件并提供相应的上下文值。
React 中还有几个其他内置的 Hooks,使用频率较低。在接下来的章节中,我们将根据需要介绍这些 Hooks,重点关注与当前主题最相关的那些。
摘要
在本介绍性章节中,我们介绍了 React 的基本概念,并为您的 React 开发之旅奠定了基础。我们探讨了组件化思维的概念,强调了将应用程序拆分为可重用和模块化部分的重要性。通过采用这种思维方式,您将能够创建可维护和可扩展的代码库。
此外,我们向您介绍了 React 中最常用的 Hooks,如 useState
和 useEffect
,这些 Hooks 使您能够在函数组件中高效地管理状态和处理副作用。这些 Hooks 提供了灵活性和强大的功能,以构建动态和交互式的用户界面。
通过掌握 React 的基本原理并熟悉组件化思维的概念,您现在已为深入 React 开发世界做好了充分准备。在接下来的章节中,我们将探讨更多高级主题并解决现实世界的挑战,使您能够成为一名熟练的 React 开发者。
记住——React 是一个强大的工具,它为创建现代且健壮的 Web 应用程序打开了无限可能。在接下来的章节中,我们将深入探讨将设计拆分为更小组件的过程,并探讨组织这些组件的有效策略。我们将强调重用性和灵活性,确保我们的组件能够适应未来的变化。
第三章:组织你的 React 应用程序
欢迎来到一个专门探讨如何构建 React 项目的策略的章节。在这里,我们将超越代码,深入到应用架构的迷人世界——这是软件开发的一个基本方面,在前端领域往往没有得到应有的关注。
在本章中,你将了解不同的 React 项目结构策略——包括基于功能的结构、基于组件的结构、原子设计结构和 模型-视图-视图模型(MVVM)结构——以及每种方法带来的独特优势和潜在陷阱。你还将接触到这些结构的实际例子,了解何时使用一种而非另一种,并探索每个决策带来的权衡。
但为什么我们首先应该关心项目结构呢?一个结构良好的项目可以显著提高代码的可维护性,使新团队成员更容易理解系统,并提高可扩展性,甚至影响项目的整体成功。相反,一个低效的结构可能导致代码问题、增加复杂性,并可能成为技术债务的来源。
通过理解这些结构策略,你将更好地做出决策,这些决策可以对项目的健康和成功产生长远的影响。你将能够评估你项目的具体需求和限制,并利用这些策略作为指导,使你能够创建一个提高代码质量、促进高效开发环境,并最终导致项目成功的结构。
在本章中,我们将涵盖以下主题:
-
理解结构不清晰的项目问题
-
理解前端应用程序的复杂性
-
探索 React 应用程序中的常见结构
-
保持项目结构有序
技术要求
已创建一个 GitHub 仓库来托管本书中讨论的所有代码。对于本章,你可以在github.com/PacktPublishing/React-Anti-Patterns/tree/main/code/src/ch3
找到推荐的架构。
理解结构不清晰的项目问题
项目的快速增长可能会令人惊讶,导致事物失控的感觉。初始化前端项目通常是直接的,对于小型项目,由于要管理的文件数量很少,因此对文件结构的关注可能很小。然而,随着项目的扩展,对适当文件组织的需求变得明显:
图 3.1:一个简单的项目可能不需要结构
拥有一个结构较松散的项目的问题在于,它可能导致管理和维护代码库的有效性出现几个挑战和困难。以下是一些由于缺乏结构而产生的主要问题:
-
代码组织混乱: 没有清晰的架构,定位特定的代码文件或组件变得更加困难。这可能导致在寻找相关代码时浪费时间和精力,尤其是在项目规模扩大时。
-
代码复用性差: 没有合适的结构,识别可重用组件或函数变得具有挑战性。这可能导致代码重复和一致性不足,长期来看,使得维护和更新代码库变得更加困难。
-
协作困难: 当团队成员在一个结构较松散的项目上工作时,理解和导航彼此的代码变得更加困难。这可能导致沟通差距、开发速度减慢,以及引入错误或冲突的风险增加。
-
可扩展性问题: 随着项目的扩展和新功能的添加,缺乏结构可能会使无缝集成新组件变得具有挑战性。这可能导致难以扩展或修改的混乱代码库,从而导致生产力下降和开发时间增加。
-
维护复杂性: 没有清晰的组织,维护代码库变得更加复杂。进行更改或修复问题可能成为耗时的工作,因为代码的结构或命名可能缺乏一致性。
在提出推荐的项目结构之前,让我们先看看现代前端项目的典型组件。了解这些组件将为设计有效的项目结构提供基础。
理解前端应用的复杂性
在一个中等规模的前端项目中,你可能会对成功实施所需的众多组件感到惊讶。除了核心功能外,还有许多其他元素有助于项目的功能实现。
React 项目的文件夹结构为你在典型的 React 代码库中需要管理的各个方面提供了一个概览:
-
源代码: 这是应用的核心,包含包含应用逻辑的 JavaScript/TypeScript 文件、用于结构的 HTML 文件和用于外观的样式文件。定义应用操作和用户界面的所有内容都可以在这里找到。
-
资产: 这个类别包含了应用中使用的所有静态文件,例如图片、视频和字体。这些文件对于增强应用的视觉体验和交互至关重要,有助于提升应用的整体外观和感觉。
-
package.json
和环境特定的变量到构建项目的规则中,这些文件对于应用的运行和部署至关重要。 -
测试:这个类别致力于确保应用程序的正确性和稳定性。它包含所有单元、集成和端到端测试,这些测试模拟用户行为、验证交互并检查应用程序的功能,有助于捕捉和预防潜在的错误。
-
README
文件提供了对项目的概述,包括 API 文档和编码风格指南,这些文档有助于维护一致性、理解和易用性,对于与项目交互的任何人都有帮助。 -
构建工件:这是构建过程的输出,包括打包和优化的 JavaScript、CSS 和 HTML 文件,以及准备部署的文件,以及其他帮助调试构建问题的临时或诊断文件。它们对于将您的应用程序分发给最终用户至关重要。
-
开发工具和配置:这是强制执行代码质量、格式化和版本控制,并促进自动化测试和部署流程的工具包。它们在后台工作,确保开发过程平稳、无错误且高效。
这些多样化的组件共同构成了典型 React 代码库的基础,突显了中型前端项目中涉及的复杂性和广泛性。
探索每个功能文件夹可以是一次愉快的体验,因为它会展开各种元素:
-
常用组件,如模态对话框、导航菜单、按钮和卡片
-
专门针对特定功能的组件,例如
SpecialOffer
(仅在菜单页面上显示的特殊优惠)或PayWithApple
(使用 ApplePay 支付) -
使用 CSS-in-JS 或 SCSS/LESS 代码定义样式
-
各种类型的测试代码,包括单元测试和浏览器测试
-
封装在实用/辅助函数内的计算逻辑
-
用于可重用功能的自定义钩子
-
安全、国际化(i18n)和其他特定需求的上下文
-
如
eslint config
、jest config
、webpack settings
等更多的附加配置文件
考虑到文件众多,我们如何以方便导航和快速修改的方式组织它们?虽然没有一种适合所有情况的解决方案,但持续地组织代码库可以极大地帮助这一努力。
在命名和结构代码元素方面,一致性至关重要。无论选择哪种方法,在整个项目中保持一致性都是至关重要的。例如,如果您决定将样式文件放置在与它们相应的组件旁边,那么在代码库中的所有组件中坚持这一约定是至关重要的。
类似地,如果使用 tests
文件夹来存放测试文件,确保在整个代码库中一致地维护这一约定。例如,应避免使用 __tests__
或 specs
等其他命名模式,以防止混淆并保持一致性。
在理解了中到大型项目固有的复杂性,并认识到无序的代码库可能带来的挑战后,是时候探索一些经过验证的方法来构建我们的代码结构了。这些策略旨在简化开发过程,并为开发者提供便利。
探索 React 应用程序中的常见结构
组织大型 React 应用程序有许多不同的方式。在接下来的小节中,我们将讨论四种最常见结构:
-
基于功能的结构
-
组件化结构
-
原子化设计结构
-
MVVM 结构
每种结构都有其自身的优点和缺点,选择取决于项目的具体需求和复杂性。有时,我们可能需要以某种方式混合它们,以便它们符合我们项目的特定需求。
为了进一步探索这些不同的结构方法,我们将以在线购物应用程序为例,因为它相对复杂,并且你应该已经对该领域有所了解。该应用程序还包含 API 调用、路由和状态管理等元素。
基于功能的结构
基于功能的结构意味着应用程序是根据功能或模块组织的。每个功能都包含其自己的组件集、视图、API 调用和状态管理,从而实现功能的清晰分离和封装。
在在线购物的背景下,采用基于功能的架构,你可以按照以下方式组织你的文件和文件夹:
图 3.2:基于功能的结构
让我们更详细地看看这个结构:
-
features
目录代表应用程序的不同功能,例如Home
、Cart
、ProductDetails
、Checkout
、Profile
等 -
每个功能都有一个包含与该功能相关的
components
、containers
、pages
、services
、types
和utils
的文件夹 -
shared
目录包含可重用的components
、containers
、services
、types
和utils
,这些可以在多个功能之间共享 -
api
目录包含用于发起 API 调用的模块 -
store
目录包含状态管理模块(例如,Redux) -
router
目录包含路由配置和相关组件 -
App.tsx
文件是应用程序的入口点
此方法有以下优点:
-
关注点的清晰分离:每个功能都有一个文件夹,便于定位和修改相关代码
-
模块化:功能是自包含的,便于测试、维护和重用
-
可扩展性:添加新功能时不会直接影响现有代码
-
团队协作:开发者可以同时在不产生最小冲突的情况下工作在不同的功能上
然而,它有以下缺点:
- 潜在的重复:功能可能共享相似组件或逻辑,导致一些重复。仔细规划和重构可以帮助减轻这种情况。
基于组件的结构
基于组件的结构意味着应用是围绕可重用组件组织的。组件根据其功能进行分类,可以组合在一起构建更大的视图。
在在线购物背景下,基于组件的架构下,你可以按照以下方式组织你的文件和文件夹:
图 3.3:基于组件的结构
让我们更详细地看看这个结构:
-
components
文件夹包含与在线购物应用各种功能相关的单个组件。每个组件都组织到一个文件夹中,可能包含必要的子组件。 -
routes
文件夹处理应用的前端路由。它包括配置路由逻辑的AppRouter.tsx
主文件,以及定义单个路由及其对应组件的routes.tsx
文件。 -
api
文件夹包含针对不同 API 域或功能的单独文件。这些文件,如products.ts
、cart.ts
、auth.ts
和payment.ts
,处理与其相应域相关的 API 调用。 -
该示例还假设使用 Redux 或 React Context API 等状态管理库来管理全局应用状态。
此方法有以下优点:
-
模块化:基于功能的组件结构通过将组件组织到单独的文件和文件夹中,促进了模块化。这增强了代码的可维护性和可重用性。
-
关注点分离:每个组件专注于其特定的功能,导致代码更清晰且易于调试。关注点分离提高了代码的可读性和可维护性。
-
代码重用性:通过模块化组织组件,可以更容易地在应用或未来的项目中重用组件,从而提高开发效率。
然而,它有以下缺点:
-
项目复杂性:随着项目的增长,维护复杂的组件结构可能会变得具有挑战性。它需要仔细规划和遵循最佳实践,以避免组件蔓延并保持结构可管理。
-
学习曲线:对于新接触这些概念的开发者来说,基于组件的开发和 TypeScript 的初始学习曲线可能更陡峭。然而,在代码组织和可维护性方面获得的好处超过了初始的学习成本。
-
在
components
文件夹中,你可能找到与其它组件文件夹中相同或相似的小元素。你将这些组件分解得越细,就越有可能识别出可重用的组件。当出现这样的可重用组件时,将它们放置在“共享”文件夹中是一个好习惯,就像在基于功能的结构中展示的那样。
原子设计结构
原子设计是一种设计和组织用户界面的方法。它强调通过将用户界面分解成称为原子的小型、可重用组件来构建用户界面,这些原子组合成分子、有机体、模板和页面。
原子设计背后的关键思想是创建一种系统化的方法来构建 UI 组件,该方法鼓励可重用性、可扩展性和可维护性。它为组织和命名组件提供了一个清晰的框架,使得理解和使用 UI 代码库变得更加容易。
这是原子设计方法对 UI 组件的分类方式:
-
原子:原子是 UI 的最小构建块,代表单个元素,如按钮、输入、图标或标签。它们通常是简单且自包含的。
-
分子:分子是原子的组合,代表更复杂的 UI 组件。它们封装了一组协同工作的原子,形成一个功能单元,例如表单字段或导航栏。
-
有机体:有机体是较大的组件,它们结合分子和/或原子来创建 UI 的更重要的部分。它们代表用户界面的不同部分,如页眉、侧边栏或卡片组件。
-
模板:模板为安排有机体和/或分子提供布局结构。它们定义了页面或 UI 特定部分的总体骨架。
-
页面:页面代表由模板、有机体、分子和原子组成的完整用户界面屏幕。它们代表用户可见的最终输出。
在在线购物背景下,使用原子设计架构,你可以按照以下方式组织你的文件和文件夹:
图 3.4:原子设计结构
让我们更详细地看看这个结构:
-
atoms
、molecules
、organisms
、templates
和pages
目录代表组件组合和抽象的不同层次。 -
api
目录包含用于进行 API 调用的 API 相关文件。 -
views
目录包含渲染组件的单独视图。 -
routes
目录处理路由配置。
这种方法有以下优点:
-
可重用性:组件可以轻松地在应用程序中重用,从而提高代码效率。
-
一致性:这种结构鼓励一致的设计语言和 UI 模式。
-
可扩展性:模块化方法允许轻松扩展并添加新组件。
-
可维护性:组件按照逻辑层次结构组织,这使得它们更容易定位和更新
-
协作:原子设计结构促进了设计师和开发者之间的协作,因为它为讨论 UI 组件提供了一个共同的语言
然而,它有以下缺点:
-
学习曲线:可能需要一些初始的学习和适应,才能有效地理解和实现原子设计原则
-
复杂性:随着应用程序的增长,管理大量组件及其关系可能会变得具有挑战性
-
过度设计:在组件可重用性和过度设计之间取得平衡很重要,因为过度的抽象可能会引入不必要的复杂性
MVVM 结构
MVVM 结构 是一种主要用于构建用户界面的软件架构模式:
-
Model 代表我们实际处理的数据和/或信息。这可能是数据库、文件、Web 服务,甚至是简单的对象。
-
View 是用户看到并与之交互的内容。它是向用户展示模型的用户界面。
-
ViewModel 是在这个模式中逻辑主要存在的位置。它是视图的抽象,暴露公共属性和命令,架起了视图和模型之间的桥梁,并将模型中的数据处理成视图易于处理的形式。它可以对数据进行操作并决定如何将其呈现给视图。
在在线购物背景下,使用 MVVM 架构来结构化 React 应用程序,你可以按照以下方式组织你的文件和文件夹:
图 3.5:MVVM 结构
让我们更详细地看看这个结构:
-
components
目录包含按其相应功能组织的可重用 UI 组件 -
models
目录包含表示应用程序领域对象的数据库、文件、Web 服务或简单对象的数据模型或实体,例如CartItemModel
和ProductModel
-
viewmodels
目录包含负责管理视图状态、逻辑和交互的钩子 -
services
目录包含处理 API 调用和其他外部服务的模块 -
views
目录包括基于ViewModel
状态显示 UI 的视图组件 -
routers
目录包含路由配置和组件 -
App.tsx
文件作为应用程序的入口点
这种方法有以下优点:
-
关注点分离:ViewModel 将业务逻辑与 UI 组件分离,促进了更干净、更易于维护的代码
-
可测试性:ViewModel 可以很容易地进行单元测试,而无需实际的 UI 组件
-
可重用性:组件、模型和服务可以在不同的功能和视图中重用
-
可扩展性:可以在重用现有的 ViewModel 和服务模块的同时添加新功能和视图
然而,它有以下缺点:
-
复杂性:实现 MVVM 模式可能会给应用程序引入额外的抽象层和复杂性,尤其是在较小的项目中
-
学习曲线:开发者需要理解 MVVM 的概念和原则,以有效地构建和管理应用程序
现在我们已经探讨了这四种流行的结构,让我们深入了解我们应用程序结构的持续演变。这一持续过程确保结构在开发者方面保持有益,便于导航,无缝添加新功能,并能够在长时间内保持可扩展性。
保持您的项目结构有序
基于功能的结构始终是一个好的起点。随着项目的扩展和重复模式的出现,可以引入一个额外的层来消除冗余。
例如,让我们再次使用在线购物应用程序。它包含各种页面:
-
首页
-
登录/注册
-
存储地址搜索
-
产品列表
-
购物车
-
订单详情
-
支付
-
个人资料
-
优惠券
在初始阶段,根据功能组织页面是一种常见的方法。我们可以为每个功能创建一个文件夹,并将所有相关组件、样式和测试放入该文件夹中。
实施初始结构
在src
目录中的初始文件夹结构非常简单,遵循基于功能的方法,每个页面都有自己的文件夹:
├── Address
│ ├── AddressList
│ └── Store
├── Home
├── Login
├── Order
├── Payment
├── Product
├── Profile
│ └── Coupon
└── SignUp
然而,随着项目的演变,您可能会在不同页面之间遇到组件或功能重复的情况。为了解决这个问题,引入一个额外的抽象层变得必要。
例如,如果Login
和Order
页面都需要一个Button
组件,那么在每一页上分别实现按钮是不切实际的。相反,您可以将Button
组件提取到一个单独的层,例如组件或共享文件夹。这样,Button
就可以在多个页面之间重用,而不会出现重复。
添加额外层以去除重复项
通过添加这一额外层,您可以在代码库中提高可重用性和可维护性。这有助于消除冗余,简化开发工作,并确保应用程序的一致性。随着项目的扩展,这种模块化方法允许轻松管理并实现可扩展性,使得添加新功能或进行更改时不会影响整个代码库。
因此,您可以创建一个components
文件夹来存放所有可重用组件,以及一个pages
文件夹来存放所有功能页面,如下所示:
├── components
│ ├── Accordion
│ ├── GenericCard
│ ├── Modal
│ ├── Offer
│ │ └── SpecialOffer
│ └── StackView
└── pages
├── Address
│ ├── AddressList
│ └── Store
├── Home
├── Login
├── Order
├── Payment
├── Product
├── Profile
│ └── Coupon
└── SignUp
随着项目的扩展,有必要创建一个单独的组件文件夹来存放跨不同页面共享的可重用组件。在这种结构中,每个组件都组织在其各自的文件夹中,促进了模块化和代码重用。此外,你可以引入嵌套文件夹来表示组件层次结构,例如包含特定组件SpecialOffer
的Offer
文件夹。
除了components
文件夹外,你可能还需要为其他基本元素创建文件夹。pages
文件夹包含特定功能的页面,而hooks
文件夹则存放 React Hooks,它们提供可重用的逻辑和功能。context
文件夹用于管理全局状态,并提供可以在整个应用程序中共享的不同上下文。
重要的是要注意,并非所有组件都需要移动到components
文件夹。只有那些在不同页面中表现出重复的组件才应该提升到共享文件夹,以确保在模块化和不必要的复杂性之间保持平衡。
这种文件结构允许在项目增长时更好地组织、代码重用和可扩展性。它通过减少冗余并确保应用程序的一致性来促进可维护性。此外,为 Hooks 和上下文创建单独的文件夹有助于集中相关代码,并使管理全局状态和可重用逻辑变得更加容易。
文件命名
在单个组件中,命名文件有不同的方法,每种方法都有其优点和考虑因素。让我们探讨两种方法。
使用 index.tsx 和明确的组件名称命名文件
在这种方法中,组件文件夹内的每个文件都有一个明确的名称,与它所代表的组件相对应:
components/Button
├── Button.test.tsx
├── Button.tsx
├── index.tsx
└── style.css
index.tsx
文件作为默认导出文件,允许你直接从文件夹中导入组件。Button.tsx
是组件的 JSX,而Button.test.tsx
是对应的测试文件,style.css
定义 CSS 样式。
这种方法促进了清晰且自我描述的文件名,使得理解每个文件的目的和内容变得更容易。然而,在编辑器或文件浏览器中浏览或搜索组件时,可能会导致索引文件列表过长。
使用短横线命名法命名文件
在这种方法中,components
文件夹内的文件使用短横线命名法命名,这是一种命名约定,其中单词为小写,并由连字符分隔。如果只有一个单词,只需使用小写即可——这遵循 JavaScript 社区中使用的持续一致的约定:
components
├── button.test.tsx
├── button.tsx
├── index.tsx
└── style.css
组件的文件名明确使用短横线命名法(例如,button.tsx
)来匹配组件的名称。
这种方法与 kebab case 文件名约定保持一致,并在整个项目中促进统一的命名结构。然而,在导入组件时可能需要明确指定文件名。
这两种方法都有其优点,选择取决于个人偏好和项目需求或团队规范。在项目中建立和维护一致性对于增强团队成员之间的协作和理解至关重要。
无论哪种方式,您都可以使用 ESLint 和 FolderLint 来确保您的团队在文件和文件夹的命名标准上保持一致。例如,以下截图显示文件名应使用短横线分隔法,并建议将Button.tsx
改为button.tsx
:
图 3.6:ESLint 检查
探索更定制化的结构
随着应用程序的增长和不同类型抽象的增加,相应地组织项目结构变得必要。仅依赖于之前讨论的结构可能不适合您的特定场景。通常需要根据项目需求定制结构,以便与项目需求良好地匹配。记住,建立项目结构的主要目标是简化并优化开发过程。
从基于功能的结构开始,我们需要调整我们当前的文件夹结构到以下结构,以反映这种演变:
-
api
: 这个文件夹代表管理 API 相关代码的模块或目录,包括用于发起网络请求、处理响应以及与后端服务交互的函数。 -
components
: 这个文件夹包含可重用的 UI 组件,可以在应用程序的不同页面或功能中使用。它包括Accordion
、Button
、GenericCard
、Modal
、Offer
和StackView
等组件。这些组件可以根据其功能或目的组织到子文件夹中。 -
context
: 这个文件夹代表管理 React 上下文的模块或目录,它允许跨组件进行全局状态管理和数据共享。 -
hooks
: 这个文件夹包含封装可重用逻辑和行为的自定义 React Hooks。这些 Hooks 可以在应用程序的不同部分之间共享。 -
mocks
: 这个文件夹包含用于测试目的的模拟数据或模拟实现。它包括graphql
和rest
子文件夹,分别代表 GraphQL 和 REST API 的模拟。 -
pages
: 这个文件夹代表应用程序的不同页面或功能。每个页面或功能都有一个文件夹。包含的文件夹有Address
、Home
、Login
、Order
、Payment
、Product
、Profile
、SignUp
以及它们各自的子文件夹。子文件夹可能包含与该特定页面或功能相关的附加组件、Hooks 或上下文。
以这种方式构建项目,您可以实现模块化和组织化的代码库,这有助于代码重用、关注点分离和可扩展性。每个目录代表应用程序的特定方面,这使得定位和管理与该特定功能相关的代码变得更加容易。
你可以在下面的图中看到这一点,我们回到了我们的购物示例:
图 3.7:在线购物应用程序的混合结构
虽然这种结构提供了一个坚实的基础,但根据你项目的具体需求和规模对其进行调整是很重要的。定期审查和重构结构可以帮助保持其有效性,并适应未来的变化。
随着应用程序变大,将components
文件夹提取到一个可跨多个项目或作为内部设计系统使用的共享库可能是有益的。这种方法促进了代码重用、一致性和可维护性。共享库可以托管在内部注册表中,或发布到npmjs Registry (www.npmjs.com/
)以方便分发和消费。
此外,随着你的应用程序发展和新功能的引入,现有的结构可能不再完全满足你的需求。在这种情况下,引入如 MVVM 这样的架构模式可能是有益的,它遵循分层方法。这有助于更好地分离关注点,并更有序地管理复杂的功能和状态。我们将在第十一章中深入讨论使用分层架构。
摘要
在本章中,我们探讨了管理大型 React 应用程序时出现的挑战以及建立稳固项目结构的重要性。我们讨论了构建 React 应用程序的各种结构风格,包括基于功能、基于组件、MVVM 和原子设计。每种方法都提供了其自身的优势和考虑因素,使开发者能够为他们的特定项目需求选择最合适的结构。
此外,我们提出了一种随着项目增长而不断演进的文件夹结构塑造方法。从简单的初始结构开始,我们强调了适应和引入新层和抽象以减少重复并保持代码组织的需求。通过不断精炼结构并遵守一致的约定,开发者可以有效地导航、添加新功能并维护可伸缩性。
在本章中,我们强调了保持项目结构灵活和不断演进以满足应用程序变化需求的重要性。通过积极塑造文件夹结构,开发者可以减轻管理大型 React 应用程序的挑战,并确保长期的可维护性和可伸缩性。
在接下来的章节中,我们将深入探讨流行的组件实现设计模式和策略。这些技术将使我们能够编写易于添加功能、易于理解且维护工作量较小的代码。
第四章:设计您的 React 组件
欢迎来到这个关于掌握 React 组件设计的核心章节。在本章中,我们将开始一段丰富的旅程,以识别和消除设计 React 组件中的常见反模式,包括大型单体组件、属性钻取和其他常见的陷阱,这些陷阱经常困扰开发者,并阻碍 React 应用的可维护性和可扩展性。
首先,我们将介绍单一职责原则。在 React 的领域,这指导我们确保每个组件有一个特定的目的。遵循这个原则使组件更容易理解、测试和维护,同时使您的代码更易于阅读和管理。
接下来,我们将探索“不要重复自己”原则。这是有效编程的核心原则之一,它鼓励开发者最小化重复并促进重用。在 React 的上下文中,这个原则可能是解锁更流畅、高效和可维护的代码库的关键。
最后,我们将深入研究组件组合原则。组合允许我们通过组合更简单、可重用的组件来构建复杂的 UI。在 React 中,组合优于继承,导致更灵活且易于管理的组件。
在本章中,我们将深入探讨这些原则的每一个,提供现实世界的例子和实际应用。通过这样做,我们旨在指导您构建更高效的组件,增强您对 React 潜力的理解,并提高您在这个强大库中的问题解决技能。
因此,在本章中,我们将涵盖以下主题:
-
探索单一职责原则
-
了解“不要重复自己”原则
-
使用组合
-
结合组件设计原则
技术要求
已创建一个 GitHub 仓库来托管本书中讨论的所有代码。对于本章,您可以在github.com/PacktPublishing/React-Anti-Patterns/tree/main/code/src/ch4
找到推荐的架构。
探索单一职责原则
单一职责原则(SRP)是软件工程中的基本概念之一,它断言一个函数、类,或者在 React 的上下文中,一个组件,应该只有一个改变的理由。换句话说,每个组件理想上应该处理一个单一的任务或功能。遵循此原则可以使您的代码更易于阅读、维护,并且更容易测试和调试。
让我们用一个例子来说明这一点。假设您最初有一个 BlogPost
组件,它在一个组件中获取博客文章数据、显示文章并处理用户对文章的点赞:
import React, { useState, useEffect } from "react";
import fetchPostById from "./fetchPostById";
interface PostType {
id: string;
title: string;
summary: string;
}
const BlogPost = ({ id }: { id: string }) => {
const [post, setPost] = useState<PostType>(EmptyBlogPost);
const [isLiked, setIsLiked] = useState(false);
useEffect(() => {
fetchPostById(id).then((post) => setPost(post));
}, [id]);
const handleClick = () => {
setIsLiked(!isLiked);
};
return (
<div>
<h2>{post.title}</h2>
<p>{post.summary}</p>
<button onClick={handleClick}>
{isLiked ? "Unlike" : "Like"}
</button>
</div>
);
};
export default BlogPost;
代码定义了一个名为 BlogPost
的函数组件,它接受一个 id
属性,该属性为 string
类型。在组件内部,使用 useState
钩子定义了两个状态变量:post
和 isLiked
。post
状态表示博客文章数据,初始值为一个空的博客文章。isLiked
状态表示文章是否被喜欢,初始值为 false
。
随后,我们需要在 useEffect
钩子中管理副作用(发送网络请求)。它用于根据提供的 id
属性从服务器获取博客文章数据。每当 id
属性变化时,它都会触发 fetch
操作。一旦获取数据,就使用 setPost
函数将获取的文章更新到 post
状态。
在 useEffect
钩子调用中,对于网络请求,有一个名为 fetchPostById
的函数。该函数简单地对远程 API 端点进行 fetch
调用。我们可以假设该函数是用以下代码片段实现的:
const fetchPostById = (id: string) => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve({}), 2000);
})
};
组件从 post
状态渲染博客文章的标题和摘要。它还渲染一个按钮,当点击时切换 isLiked
状态,显示 isLiked
。
虽然这段代码可以工作,但它违反了 SRP。它做了三件不同的事情:获取数据、显示博客文章和处理喜欢功能。相反,让我们将其重构为更小、单一职责的组件:
const useFetchPost = (id: string): PostType => {
const [post, setPost] = useState<PostType>(EmptyBlogPost);
useEffect(() => {
fetchPostById(id).then((post) => setPost(post));
}, [id]);
return post;
};
const LikeButton: React.FC = () => {
const [isLiked, setIsLiked] = useState(false);
const handleClick = () => {
setIsLiked(!isLiked);
};
return <button onClick={handleClick}>
{isLiked ? "Unlike" : "Like"}
</button>;
};
const BlogPost = ({ id }: { id: string }) => {
const post = useFetchPost(id);
return (
<div>
<h2>{post.title}</h2>
<p>{post.summary}</p>
<LikeButton />
</div>
);
};
在这里,我们将 BlogPost
重构为更小、单一职责的组件:
-
useFetchPost
是一个自定义钩子,负责获取博客文章数据 -
LikeButton
是一个组件,负责处理喜欢功能 -
BlogPost
现在只负责渲染博客文章内容和LikeButton
每个部分都有一个单一的责任,可以独立进行测试和维护,从而使得代码库更易于管理。
注意
在实际应用中,点击发送到端点(例如 https://post.service/post/isLiked
状态的 fetch
请求。
因此,在第一部分,我们探讨了 SRP。这个原则鼓励每个组件负责单一的功能,使我们的代码更易于维护和理解。在这里,我们应用了这个原则,将大型、单体组件分解为更小、更易于管理的部分。
随着我们进一步深入我们的设计之旅,我们的下一节将引导我们到一个与 SRP 哲学紧密相连的原则——不要重复自己原则。
不要重复自己
不要重复自己(DRY)原则是软件开发中的一个基本概念,旨在减少代码中的重复。遵循这个原则可以带来更好的可维护性、可读性和可测试性,并可以防止由于逻辑重复而发生的错误。
假设你有一个购物网站,你想要并排显示产品列表和用户的购物车,就像这样:
图 4.1:产品列表页面
ProductList
组件将显示产品的图像、名称和价格,而Cart
组件将显示购物车中的项目列表,并带有从购物车中删除按钮。
ProductList
的一个简单实现可能看起来像这样:
type Product = {
id: string;
name: string;
image: string;
price: number;
};
const ProductList = ({
products,
addToCart,
}: {
products: Product[];
addToCart: (id: string) => void;
}) => (
<div>
<h2>Product List</h2>
{products.map((product) => (
<div key={product.id} className="product">
<img src={product.image} alt={product.name} />
<div>
<h2>{product.name}</h2>
<p>${product.price}</p>
<button onClick={() => addToCart(product.id)}>Add to Cart
</button>
</div>
</div>
))}
</div>
);
export default ProductList;
这个名为ProductList
的功能组件接受两个属性:products
(产品对象数组)和addToCart
(用于将产品添加到购物车的函数)。
每个产品对象都是Product
类型,具有id
、name
、image
和price
属性。
该组件遍历products
数组,并为每个产品渲染一个div
,包括图像、产品名称、价格,并调用带有相应产品id
属性的参数的addToCart
函数。
Cart
组件的结构与您想象中的相似;它需要一个项目列表,并渲染一个带有文本从购物车中删除的按钮和一个用户可以调用的回调函数:
const Cart = ({
cartItems,
removeFromCart,
}: {
cartItems: Product[];
removeFromCart: (id: string) => void;
}) => (
<div>
<h2>Shopping Cart</h2>
{cartItems.map((item) => (
<div key={item.id} className="product">
<img src={item.image} alt={item.name} />
<div>
<h2>{item.name}</h2>
<p>${item.price}</p>
<button onClick={() => removeFromCart(item.id)}>
Remove from Cart
</button>
</div>
</div>
))}
</div>
);
Cart
组件遍历cartItems
数组,并为每个项目渲染一个包含项目图像、名称、价格和一个removeFromCart
函数的div
,该函数以相应的项目id
属性作为参数,表示应从购物车中删除此项目。
为了减少重复并使每个组件只做一件事,我们可以提取一个LineItem
组件:
import { Product } from "./types";
const LineItem = ({
product,
performAction,
label,
}: {
product: Product;
performAction: (id: string) => void;
label: string;
}) => {
const { id, image, name, price } = product;
return (
<div key={id} className="product">
<img src={image} alt={name} />
<div>
<h2>{name}</h2>
<p>${price}</p>
<button onClick={() => performAction(id)}>{label}</button>
</div>
</div>
);
};
export default LineItem;
我们定义了一个名为LineItem
的功能组件,用于渲染产品的详细信息和一个按钮。它接受product
、performAction
和label
属性,并使用解构来提取必要的值。该组件返回 JSX 代码以显示产品信息,并在按钮被点击时触发performAction
函数。
对于ProductList
和Cart
组件,您只需将不同的属性传递给LineItem
组件,以减少我们之前存在的重复:
const ProductList = ({
products,
addToCart,
}: {
products: Product[];
addToCart: (id: string) => void;
}) => (
<div>
<h2>Product List</h2>
{products.map((product) => (
<LineItem
key={product.id}
product={product}
performAction={addToCart}
label="Add to Cart"
/>
))}
</div>
);
新的ProductList
组件接收products
和addToCart
作为属性。它渲染产品列表,每个产品都有一个添加到 购物车按钮。
类似地,对于Cart
组件,我们将有一个类似的结构,它将重用LineItem
组件来渲染产品详情(image
、name
和price
):
const Cart = ({
cartItems,
removeFromCart,
}: {
cartItems: Product[];
removeFromCart: (id: string) => void;
}) => (
<div>
<h2>Shopping Cart</h2>
{cartItems.map((item) => (
<LineItem
key={item.id}
product={item}
performAction={removeFromCart}
label="Remove from Cart"
/>
))}
</div>
);
这是一种更可维护和可重用的方法,遵循 DRY 原则。引入错误的可能性更小,因为更改只需要在一个地方进行,如果我们必须添加LineItem
的新功能,我们只需要接触一个组件。
在本节中,我们深入探讨了 DRY 原则。它指导我们消除代码中的冗余,降低不一致性和错误的可能性。通过避免代码重复,我们简化了维护工作,因为功能的变化只需要在一个地方解决。随着我们对 DRY 理解的深入,我们准备使用 React 中的一个关键概念——组合来增强我们的组件结构。
使用组合
在 React 中,div
与h2
标签无缝结合,无需引入任何新内容。
自定义组件并不比内置组件如div
更特殊;你可以像使用p
标签一样使用你的Cart
组件与div
。这种模式使得组件的重用更加直接,有助于编写更干净、更易于维护的代码。
让我们考虑一个例子。假设我们正在构建一个UserDashboard
组件,用于显示用户信息。个人资料包括头像、姓名以及用户的朋友列表和最新帖子。以下是它的可能外观:
type User = {
name: string;
avatar: string;
friends: string[];
};
type Post = {
author: string;
summary: string;
};
type UserDashboardProps = {
user: User;
posts: Post[];
};
function UserDashboard({ user, posts }: UserDashboardProps) {
return (
<div>
<h1>{user.name}</h1>
<img src={user.avatar} alt="profile" />
<h2>Friends</h2>
<ul>
{user.friends.map((friend) => (
<li key={friend}>{friend}</li>
))}
</ul>
<h2>Latest Posts</h2>
{posts.map((post) => (
<div key={post.author}>
<h3>{post.author}</h3>
<p>{post.summary}</p>
</div>
))}
</div>
);
}
export default UserDashboard;
在这个简化的例子中,UserDashboard
负责渲染用户的个人资料、朋友列表和最新帖子,这违反了单一职责原则。我们可以将其分解为更小的组件,每个组件负责一项任务。
首先,我们可以将与个人资料相关的 JSX 提取到一个UserProfile
组件中,该组件显示用户的个人资料,包括一个h1
标签(用户的姓名)和avatar
图像:
const UserProfile = ({ user }: { user: User }) => {
return (
<>
<h1>{user.name}</h1>
<img src={user.avatar} alt="profile" />
</>
);
};
接下来,我们创建一个FriendList
组件,用于显示用户的朋友列表;它包括一个h2
标签和friends
列表:
const FriendList = ({ friends }: { friends: string[] }) => {
return (
<>
<h2>Friends</h2>
<ul>
{friends.map((friend) => (
<li key={friend}>{friend}</li>
))}
</ul>
</>
);
};
最后,我们创建一个PostList
组件,用于显示帖子流,包括一个h2
标签和posts
列表:
const PostList = ({ posts }: { posts: Post[] }) => {
return (
<>
<h2>Latest Posts</h2>
{posts.map((post) => (
<div key={post.author}>
<h3>{post.author}</h3>
<p>{post.summary}</p>
</div>
))}
</>
);
};
现在,我们的UserDashboard
组件变得更加简单,并将责任委托给这些较小的组件:
function UserDashboard({ user, posts }: UserDashboardProps) {
return (
<div>
<UserProfile user={user} />
<FriendList friends={user.friends} />
<PostList posts={posts} />
</div>
);
}
重新构建的UserDashboard
组件之所以更优越,有以下几个原因:
-
通过使用
UserProfile
、FriendList
和PostList
(),你确保每个组件只负责一项任务。这提高了代码的可维护性。 -
UserDashboard
更容易阅读和理解。它立即清楚这个组件渲染了什么:用户个人资料、朋友列表和帖子列表。无需阅读关于每个部分如何渲染的细节。 -
UserProfile
、FriendList
和PostList
组件现在可以在需要时在其他应用程序的部分重用,从而促进代码重用并减少冗余。 -
可测试性:较小的、单一职责的组件更容易测试,因为它们通常具有更简单的交互和依赖关系。我们将在第五章中介绍测试和可测试性。
这是一个简单的例子,但它说明了 React 中组合的基本思想。随着你处理具有自己状态或逻辑的组件,组合可能会变得更加复杂,但核心原则保持不变:从较小的、可重用的部分构建较大的组件。
这一节让我们领略了 React 中组合的力量。通过组合,我们可以有效地构建和组合我们的组件,从更简单、单一职责的组件中创建复杂的用户界面。我们观察到组合如何使我们充分利用单一职责原则和 DRY 原则,从而创建出既复杂又易于理解、测试和维护的 UI。
组合组件设计原则
我们分别分析了单一职责原则、不要重复自己和组合原则。然而,在实际的编码场景中,事情可能会变得复杂,需要同时应用多个原则来提高代码的可读性和可维护性。
让我们考虑一个Page
组件的例子,它可能具有许多职责,例如管理标题、侧边栏和主要内容区域的状态和行为。此外,还有许多属性用于配置这些区域中的每一个。当个人简单地复制现有的代码库而没有太多考虑或批判性思维时,通常会遇到这样的代码;因此,随着新功能的添加,属性列表会增长。
这里有一个简化的例子:
import React from "react";
type PageProps = {
headerTitle: string;
headerSubtitle: string;
sidebarLinks: string[];
isLoading: boolean;
mainContent: React.ReactNode;
onHeaderClick: () => void;
onSidebarLinkClick: (link: string) => void;
};
function Page({
headerTitle,
headerSubtitle,
sidebarLinks,
mainContent,
isLoading,
onHeaderClick,
onSidebarLinkClick,
}: PageProps) {
return (
<div>
<header onClick={onHeaderClick}>
<h1>{headerTitle}</h1>
<h2>{headerSubtitle}</h2>
</header>
<aside>
<ul>
{sidebarLinks.map((link) => (
<li key={link} onClick={() => onSidebarLinkClick(link)}>
{link}
</li>
))}
</ul>
</aside>
{!isLoading && <main>{mainContent}</main>}
</div>
);
}
我们定义了一个Page
组件;该组件使用前面的属性来渲染一个带有可点击标题、包含可点击链接的侧边栏和主要内容部分的页面。该组件期望其属性为PageProps
类型的对象。
让我们更仔细地看看PageProps
内部:
-
headerTitle
:这个字符串将在页面标题中显示为主要标题 -
headerSubtitle
:这个字符串将在页面标题中显示为副标题 -
sidebarLinks
:这是一个字符串数组,其中每个字符串代表将在页面侧边栏中显示的链接 -
isLoading
:这是一个标志,用于确定主要内容是否已准备好 -
mainContent
:这可以是任何有效的 React 节点(组件、元素、null 等),它代表页面的主要内容 -
onHeaderClick
:当点击页面标题部分时,将执行此函数 -
onSidebarLinkClick
:当点击任何侧边栏链接时,将执行此函数。该函数将接收被点击的链接作为参数
Page
组件具有多个职责,并且它有一长串属性,这可能会使其难以处理。当组件有超过五个属性时,长属性列表通常表明需要组件分解。这是因为记住每个属性的目的可能具有挑战性,它还增加了传递错误属性或误排序它们的可能性。
我们可以根据它们的使用方式对属性进行分组。headerTitle
、headerSubtitle
和onHeaderClick
属性可以分成一组,而isLoading
和mainContent
属于另一组。
从大型组件中提取一小部分始终是一个好的起点。请注意,可能有许多提取的方法;如果信息看起来彼此相关,我们可以将它们分组并为此组数据创建一个新的组件。例如,我们可以首先提取一个Header
组件:
type HeaderProps = {
headerTitle: string;
headerSubtitle: string;
onHeaderClick: () => void;
};
const Header = ({
headerTitle,
headerSubtitle,
onHeaderClick,
}: HeaderProps) => {
return (
<header onClick={onHeaderClick}>
<h1>{headerTitle}</h1>
<h2>{headerSubtitle}</h2>
</header>
);
};
这个 React 中的Header
组件接受三个属性——headerTitle
、headerSubtitle
和onHeaderClick
——并渲染一个带有提供标题和副标题的标题。当点击标题时,将调用onHeaderClick
属性。
现在,因为title
、subtitle
和onClick
回调已经在Header
组件中,我们不需要在属性名称中使用header
前缀。让我们重命名这些属性:
type HeaderProps = {
title: string;
subtitle: string;
onClick: () => void;
};
const Header = ({
title,
subtitle,
onClick,
}: HeaderProps) => {
return (
<header onClick={onClick}>
<h1>{title}</h1>
<h2>{subtitle}</h2>
</header>
);
};
现在关于Header
的功能已经非常清晰——它接受title
、subtitle
和onClick
,并且不需要知道更多。这种提取也增加了Header
的可重用性,这意味着我们可能在不同的地方重用这个组件。
现在,我们可以用相同的方法提取一个Sidebar
组件:
type SidebarProps = {
links: string[];
onLinkClick: (link: string) => void;
};
const Sidebar = ({ links, onLinkClick }: SidebarProps) => {
return (
<aside>
<ul>
{links.map((link) => (
<li key={link} onClick={() => onLinkClick(link)}>
{link}
</li>
))}
</ul>
</aside>
);
};
Sidebar
组件接受一个links
数组和onLinkClick
函数作为属性,并从links
数组生成一个可点击的项目列表。当点击链接时,会触发onLinkClick
函数,并将点击的链接作为参数传递。
在我们从Page
中提取了Header
和Sidebar
之后,Page
中剩下的唯一部分就是与主要内容相关的部分。我们可以对主要内容采用相同的方法,通过简单的 JSX 片段提取一个Main
组件,如下面的代码所示:
type MainProps = {
isLoading: boolean;
content: React.ReactNode;
};
const Main = ({ isLoading, content }: MainProps) => {
return <>{!isLoading && <main>{content}</main>}</>;
};
由于我们从Page
组件中提取了大部分内容,我们现在可以使用这些简单的组件,而无需更改Page
的公共接口:
function Page({
headerTitle,
headerSubtitle,
sidebarLinks,
mainContent,
isLoading,
onHeaderClick,
onSidebarLinkClick,
}: PageProps) {
return (
<div>
<Header
title={headerTitle}
subtitle={headerSubtitle}
onClick={onHeaderClick}
/>
<Sidebar links={sidebarLinks} onLinkClick={onSidebarLinkClick}
/>
<Main isLoading={isLoading} content={mainContent} />
</div>
);
}
Page
组件安排Header
、Sidebar
和Main
组件,并接受几个属性。然后它将这些属性传递给相应的子组件——Header
获取标题、副标题和一个点击处理程序;Sidebar
接收一个链接列表和一个点击处理程序;Main
组件获取主要内容和一个加载状态。
重新设计的Page
组件看起来更美观,但还不是完美的。让我们考虑当前代码的一个常见问题。如果我们需要向Sidebar
或Main
传递新的属性会发生什么?
为了接受传递给Sidebar
或Main
的新属性,我们需要扩展属性列表,该列表已经有七个属性。对于使用Page
组件的人来说,随着属性的增加,他们需要记住更多的属性,这不会是一个好的体验(更不用说因为这些属性而增加的额外测试工作)。
而不是接受这些详细的描述来自定义Header
或Sidebar
,我们可以传递一个Header
实例,然后只需将其插入正确的插槽(以替换Header
组件):
type PageProps = {
header: React.ReactNode;
sidebarLinks: string[];
isLoading: boolean;
mainContent: React.ReactNode;
onSidebarLinkClick: (link: string) => void;
};
function Page({
header,
sidebarLinks,
mainContent,
isLoading,
onSidebarLinkClick,
}: PageProps) {
return (
<div>
{header}
<Sidebar links={sidebarLinks}
onLinkClick={onSidebarLinkClick} />
<Main isLoading={isLoading} content={mainContent} />
</div>
);
}
现在,Page
组件接受一个header
属性(以及侧边栏链接列表、加载状态、主要内容和一个链接点击处理程序作为属性),并直接渲染Header
组件。这意味着我们可以从Page
外部传递任何header
实例。
同样,我们也可以对Sidebar
和Main
做同样的事情:
type PageProps = {
header: React.ReactNode;
sidebar: React.ReactNode;
main: React.ReactNode;
};
function Page({ header, sidebar, main }: PageProps) {
return (
<div>
{header}
{sidebar}
{main}
</div>
);
}
Page
组件接受三个属性——header
、sidebar
和main
——每个属性都期望是一个Page
组件,Page
组件简单地按照提供的顺序在div
中渲染这三个属性,从而形成一个带有标题、侧边栏和主要内容页面的简单布局。
然后,你可以以最灵活的方式使用Page
组件——你可以将完全定制的Header
、Sidebar
和Main
作为参数传递给Page
组件:
const MyPage = () => {
return (
<Page
header={
<Header
title="My application"
subtitle="Product page"
onClick={() => console.log("toggle header")}
/>
}
sidebar={
<Sidebar
links={["Home", "About", "Contact"]}
onLinkClick={() => console.log("toggle sidebar")}
/>
}
main={<Main isLoading={false}
content={<div>The main</div>} />}
/>
);
};
这个MyPage
组件渲染一个Page
组件,通过 props 传入Header
、Sidebar
和Main
组件,而点击Header
和Sidebar
组件将向控制台记录某些消息。
注意,在这里你可以将任何内容传递给Page
以定义header
、sidebar
或main
。以下是一个示例:
const MySimplePage = () => {
return (
<Page
header={
<h1>A simple header</h1>
}
sidebar={
<aside>
<ul>
<li>Home</li>
<li>About</li>
</ul>
</aside>
}
main={<div>The main content</div>}
/>
);
};
MyPage
组件包裹了一个Page
组件。Page
组件接收三个 props:header
、sidebar
和main
,每个都包含 JSX 元素,指定了页面相应部分的渲染内容。header
prop 包含一个标题,sidebar
prop 包含一个列表,而main
部分包含页面的主要内容。
原始的Page
组件承担了众多责任,导致 props 列表很长。这种设计带来了prop drilling问题,其中大量数据必须通过多个组件层传递。这种设置既复杂又难以维护。
因此,让我们回顾一下我们应用不同原则的过程。重构过程始于将单体Page
组件分解成更小、更易于管理的组件——Header
、Sidebar
和Main
——使用 SRP(单一职责原则)。这些子组件被设计来处理它们各自的责任,从而简化了它们各自的 prop 需求。
一旦提取了这些组件,我们就通过组合使用修改了Page
组件以接受这些子组件(Header
、Sidebar
和Main
)作为 props。这种方法显著减少了 prop drilling 问题,因为每个子组件现在都直接在使用的点上接收 props。
重构练习简化了Page
组件,从而得到一个更干净、更易于管理的代码库。它利用了组件组合和单一职责的原则,有效地解决了 prop drilling 问题。
摘要
本章介绍了在 React 中设计和开发组件的几个关键原则:SRP(单一职责原则)、DRY(不要重复自己)以及组件组合的使用。这些原则中的每一个都提供了不同的策略,以实现干净、可维护和可扩展的代码库。
通过理解和应用这些原则,我们可以为我们的 React 应用程序打下坚实的基础。这些策略导致更组织化、可扩展和健壮的代码库,最终使我们的开发工作更加高效和愉快。
在下一章中,我们将开始探讨 React 应用程序中的一个令人兴奋的主题——测试,并看看良好的结构化测试如何帮助我们避免犯错,同时帮助我们提高代码质量。
第二部分:拥抱测试技术
在本部分,你将深入了解测试在前端开发中的重要性,探索各种测试方法和重构技术,以确保你的 React 应用程序的健壮性和可维护性。
本部分包含以下章节:
-
第五章, React 中的测试
-
第六章, 探索常见的重构技术
-
第七章, 使用 React 介绍测试驱动开发
第五章:React 中的测试
欢迎来到本章节,我们将深入了解 React 中的测试。在本章中,我们将学习软件测试的重要性,了解不同类型的测试——包括单元测试、集成测试和端到端(E2E)测试——并深入研究 Cypress、Jest 和 React Testing Library 等流行测试工具的使用。此外,我们还将揭开诸如 stubbing 和 mocking 等概念的面纱,确保你能够应对复杂的测试场景。
我们的总目标是培养对测试策略及其在 React 中实现的深入理解。我们旨在提高你编写测试的能力,使你的应用程序能够抵御 bug 和回归,并确保新功能的无缝添加。
在本章结束时,你将全面理解 React 测试,并准备好在你的项目中实施高效的测试实践。那么,让我们开始吧,进入激动人心的 React 测试世界!
在本章中,我们将涵盖以下主题:
-
理解为什么我们需要测试
-
了解不同类型的测试
-
使用 Jest 测试单个单元
-
了解集成测试
-
了解使用 Cypress 进行端到端测试
技术要求
已创建一个 GitHub 仓库来托管本书中讨论的所有代码。对于本章,你可以在github.com/PacktPublishing/React-Anti-Patterns/tree/main/code/src/ch5
找到推荐的结构。
理解为什么我们需要测试
测试不仅仅是一个可选的最佳实践;它是构建可靠和可维护软件的关键部分。没有测试,你就像在没有指南针的情况下在复杂的软件开发海洋中航行。让我们了解测试带来的多重好处:
-
确保代码正确性:测试作为验证代码按预期执行的一种保证。一个编写良好的测试可以验证你的函数对于给定的输入返回预期的输出,你的组件渲染正确,以及你的应用程序表现如预期。
-
防止回归:随着应用程序的增长和演变,新代码有时会无意中破坏现有的功能。这被称为回归。自动化测试充当一个安全网,在它们达到生产环境之前捕捉这些回归。
-
促进重构和维护:重构或更新遗留代码的过程常常伴随着恐惧。测试可以缓解这种恐惧。它们提供了一个安全区域,确保如果你在更新或重构过程中意外破坏了某些内容,你的测试将会捕捉到它。
-
提升代码质量信心:测试提高了团队的信心水平。当一系列精心编写的测试支持你的代码时,你就有了一个衡量代码质量的量化指标。这种保证在添加新功能或对系统进行更改时特别有益。
-
文档:测试也是一种文档形式。它们提供了对函数或组件预期功能的清晰理解,帮助新加入团队的开发者理解项目的功能。
在接下来的章节中,我们将深入了解你在 React 应用程序中常用的各种测试类型,并学习如何有效地使用测试工具。准备好开始一段引人入胜的软件测试之旅。
了解不同类型的测试
在软件开发领域,测试并不是一种一刀切的方法。相反,它被分为不同的类型,每种类型都服务于不同的目的,并为应用程序的功能和可靠性提供独特的见解。了解这些类别对于确保应用程序的整体健康和稳健性至关重要。通常,你将在一个代码库中拥有单元测试、集成测试和端到端(E2E)测试。
我们将在这里简要定义每种类型,并在接下来的章节中详细讨论每种类型:
-
单元测试:这些测试专注于在隔离状态下测试单个组件或函数,以确保它们按预期工作。
-
集成测试:这些测试检查不同模块或服务之间的交互,以验证它们是否协同工作。
-
端到端(E2E)测试:这些测试从开始到结束测试整个应用程序流程,模拟真实世界的用户行为,以验证系统作为一个整体是否正常工作。
在项目中如何组织测试结构也很重要。例如,你应该有很多运行速度快且能提供详细反馈的单元测试,同时只应该有少量端到端(E2E)测试来确保所有部分协同工作。这种方法与测试金字塔的原则相一致。
测试金字塔最初由 Mike Cohn 提出,建议单元测试的数量应比集成测试或端到端(E2E)测试多。理由很简单——单元测试更快、更简单,且维护成本更低:
图 5.1:传统的测试金字塔
然而,在现代前端世界中,这种模式正在演变。由于前端应用程序的复杂性和交互性的增加,越来越多的价值被放在了集成和端到端(E2E)测试上。像 Cypress 和 Puppeteer 这样的工具使得编写模拟浏览器中用户行为的端到端(E2E)测试变得容易,而像 React Testing Library 这样的库通过简化组件交互测试,鼓励更多的集成测试。
在前端应用中也引入了新的测试类型。视觉回归测试就是其中之一。视觉回归测试是一种测试方法,它捕捉并比较 Web 应用的视觉方面与之前的状态或版本。这种类型的测试在捕捉开发过程中可能引入的不期望的视觉错误和用户界面变化方面特别有用。
视觉回归测试通过在不同阶段对网页或组件进行截图(或快照),然后逐像素比较这些截图以识别任何视觉差异来工作。当检测到差异时,它会标记为待审查。然后审查可以确定变化是否是预期的(由于新功能或设计更新)或者是否是不期望的回归,需要修复。
在前端测试中,静态检查涉及在不执行代码的情况下分析代码以识别错误并确保编码标准。这包括检查语法错误、通过 linting 强制执行编码风格、通过类型检查验证正确的数据类型、分析代码复杂性、检查依赖关系以及识别安全漏洞。
你的测试金字塔的确切形状可能取决于你的应用需求,但关键是要有一个平衡的测试策略,在不同的应用层级提供快速和有用的反馈:
图 5.2:增强的测试金字塔
本章接下来的部分将为你提供编写 React 应用这类测试的实践经验,确保你能够将这些概念应用到你的项目中。让我们继续前进!
使用 Jest 测试单个单元
单元测试是测试金字塔中最小和最基础的部分,它验证代码各个单元的行为,例如函数、方法或组件。这些测试编写和执行速度快,为开发者提供即时反馈。
在本书中,我们将使用 Jest 编写单元测试和集成测试。Jest 是由 Facebook 构建的全面的 JavaScript 测试框架,它注重简单性。它功能丰富,支持异步测试、模拟和快照测试,是 React 应用的绝佳选择。
编写你的第一个测试
让我们编写一个简单的测试。假设你有一个名为 math.ts
的文件中的 add
函数:
export function add(a: number, b: number) {
return a + b;
}
要测试这个函数,你必须在同一目录下创建一个 math.test.ts
文件:
import { add } from './math';
test('add adds numbers correctly', () => {
expect(add(1, 2)).toBe(3);
});
你现在已经编写了你的第一个测试!test
函数接受两个参数:一个测试的字符串描述和一个实现测试的回调函数。expect
是一个 Jest 函数,它接受实际值,而 toBe
是一个匹配函数,它比较实际值与预期值。
另一种编写测试的方法是使用it
函数。在 Jest 中,test
和it
实际上是同一个函数,可以互换使用;名称只是来自不同的测试约定:
-
test
:这是许多测试框架和语言中测试函数的常见名称。如果你来自使用其他测试库的背景,你可能会发现test
更直观或熟悉。 -
it
:这来自像 Jasmine 或 Mocha 这样的行为驱动开发(BDD)风格的框架。
使用it
的想法是使测试读起来更像句子。例如,it("adds 1 + 2 to equal 3", () => expect(1 + 2).toBe(3))
读起来像是“它将 1 + 2 相加等于 3。”
注意
BDD 是一种软件开发方法,强调开发人员、QA 和非技术参与者在软件项目中的协作。它强调了在开发开始之前,对期望行为有明确理解的需要,从而将开发与业务需求对齐。
BDD 鼓励用所有利益相关者都能阅读和理解的简单、描述性语言表达软件行为。它利用可执行的规范,通常用 Gherkin 等语言编写,这些规范指导开发并作为验收标准。
BDD 旨在通过鼓励协作、使系统的行为对所有人明确且可理解,并确保开发的软件真正满足业务需求,来减少误解。
这取决于团队偏好以及什么最适合你团队的测试哲学——一些团队可能更喜欢它提供的句子结构,因为它通常可以使测试试图验证的内容更清晰,尤其是对非开发者来说,而另一些团队可能认为test
更直接且更简洁。我们将在这本书中编写遵循 BDD 风格的测试。
分组测试
将相关的测试分组在一个块中可以显著提高你的测试文件的可读性。通过清晰地划分不同的功能区域,一个块可以让阅读测试的人一眼就能理解测试套件的上下文。这种增强的理解对于理解正在验证的功能至关重要。在一个大型代码库中,有众多测试的情况下,这种组织可以大大减少理解应用程序不同部分如何被测试所需的认知负荷。
在 Jest 中,我们可以使用describe
函数将相关的测试分组到一个单元中。例如,考虑一个包含多个情况的函数add
:负数的加法、一个负数和一个正数的组合、小数的总和,甚至涉及虚数的计算。明智的做法是将所有这些不同的案例收集在一个describe
块下,如下所示:
import { add } from './math';
describe('math functions', () => {
it('adds positive numbers correctly', () => {
expect(add(1, 2)).toBe(3);
});
it('adds negative numbers correctly', () => {
expect(add(-1, -2)).toBe(-3);
});
// More tests...
});
describe
函数用于分组相关的测试——在这个例子中,是对一些数学函数的测试。在这个组内,有两个 it
函数,每个代表一个单独的测试。第一个测试检查 add
函数是否正确地加上了两个正数,第二个测试检查 add
函数是否正确地加上了两个负数。
使用 Jest,你可以嵌套 describe
块来更系统地组织你的测试。例如,假设我们正在扩展我们的测试套件以包括计算器的减法、乘法和除法功能。我们可以按照以下方式构建我们的测试套件:
describe('calculator', () => {
describe('addition', () => {
it('adds positive numbers correctly', () => {
expect(add(1, 2)).toBe(3);
});
it('adds negative numbers correctly', () => {
expect(add(-1, -2)).toBe(-3);
});
// More tests...
})
describe('subtraction', () => {
it('subtracts positive numbers', () => {});
})
// Other describe blocks for multiplication and division
});
在这个代码片段中,我们有一个标记为 calculator
的顶级 describe
块。在这个块内部,我们为每个数学运算嵌套了 describe
块。例如,在 addition
块中,我们有针对加法不同场景的单独 it
测试。同样,我们为 subtraction
开始一个新的 describe
块。这种嵌套结构使我们的测试套件更加有序、可读,并且更容易导航,尤其是在处理大量测试或复杂场景时。
测试 React 组件
如我们之前提到的,Jest 是一个出色的测试不同类型应用程序的工具,并且它默认支持 React 应用程序。尽管可以单独使用 Jest,但与使用如 React Testing Library 这样的专用库相比,它可能会稍微繁琐和冗长一些。
React Testing Library 是一个轻量级但功能强大的库,用于测试 React 组件。它建立在流行的 JavaScript 测试框架 Jest 之上,并为处理 React 组件添加了特定的实用工具。React Testing Library 的哲学是鼓励编写与你的软件使用方式相似的测试。它鼓励你像用户一样与你的应用程序交互,这意味着你测试的是功能而不是实现细节。这种方法导致更健壮和可维护的测试,这将让你有信心你的应用程序在生产环境中能够正常工作。
在本书提供的代码中,项目已经为你设置好了 React Testing Library。只需将 技术要求 部分中提到的代码克隆到你的本地目录中,你就可以开始了。
好吧——让我们从一个简单的 React 组件开始,看看我们如何使用 React Testing Library 来测试它。Section
组件是一个展示组件,它接受两个属性 heading
和 content
,并在 article
标签中渲染这些属性:
type SectionProps = {
heading: string;
content: string;
};
const Section = ({ heading, content }: SectionProps) => {
return (
<article>
<h1>{heading}</h1>
<p>{content}</p>
</article>
);
};
export { Section };
要测试组件,我们可以在 Section.tsx
旁边创建一个新文件,我们将称之为 Section.test.tsx
。这是我们的测试代码将存在的地方。然后,我们将使用 React Testing Library 来检查 Section
组件:
import React from "react";
import { render, screen } from "@testing-library/react";
import { Section } from "../component/Section";
describe("Section", () => {
it("renders a section with heading and content", () => {
render(<Section heading="Basic" content="Hello world" />);
expect(screen.getByText("Basic")).toBeInTheDocument();
expect(screen.getByText("Hello world")).toBeInTheDocument();
});
});
此测试代码使用了@testing-library/react
来验证Section
组件的行为是否符合预期——@testing-library/react
文本用于使用特定的属性渲染Section
组件:标题为基本,内容为Hello world。
渲染完成后,使用screen.getByText
函数查询 DOM(代表Section
组件的渲染输出)中包含特定文本的元素。
接下来,使用expect
和toBeInTheDocument
来对这些元素的状态进行断言。具体来说,测试断言Section
组件正确渲染了其标题和内容属性。
这个针对 React 组件的简单单元测试是一个有用的起点。然而,在复杂的实际项目中,我们经常遇到多个组件需要和谐交互的场景。例如,考虑一个集成了地址收集组件、支付组件和价格计算逻辑组件的结账页面。
为了自信地确保这些不同组件的无缝交互,我们必须采用更全面的测试策略:集成测试。
了解集成测试
集成测试位于金字塔的单元测试之上,验证多个代码单元之间的交互。这些可能是组件之间的交互,或者是客户端和服务器之间的交互。集成测试旨在识别当系统的不同部分组合时可能出现的潜在问题。
其中一个场景涉及测试两个独立组件之间的交互,以验证它们是否能够正确协同工作——这是在 UI 组件级别的集成测试。此外,如果您想确保前端代码和后端服务之间的协作顺畅,为此编写的测试也会被归类为集成测试,这些测试验证了应用程序的不同层是否能够正确协同工作。
让我们看看一个 React 组件的集成测试示例。在图 5.3 中,有一个条款和条件部分,其中包含关于法律信息的长文本,以及用户同意的复选框。还有一个下一步按钮,默认情况下是禁用的。然而,一旦用户选择接受条款和条件,按钮将被启用,用户可以继续操作:
图 5.3:条款和条件组件
这个集成测试可以用以下代码片段来描述——我们不是单独测试复选框和下一步按钮,而是验证它们之间的交互:
describe('Terms and Conditions', () => {
it("renders learn react link", () => {
render(<TermsAndConditions />);
const button = screen.getByText('Next');
expect(button).toBeDisabled();
const checkbox = screen.getByRole('checkbox');
act(() => {
userEvent.click(checkbox);
})
expect(button).toBeEnabled();
});
})
使用describe
函数来分组与TermsAndConditions
组件相关的所有测试,形成一个所谓的测试套件。在这个套件中,我们有一个由it
函数表示的单个测试用例。这个测试的描述是渲染学习 React 链接,考虑到这个测试中执行的操作,这似乎是一个误称。一个更合适的描述可能是在接受条款和条件后启用下一步按钮 和条件。
最初,调用render
函数来显示TermsAndConditions
组件。这个函数产生一系列输出,或渲染结果,可以通过各种方式查询以评估组件是否按预期工作。
然后,我们尝试通过文本找到按钮,使用screen.getByText
函数——它返回页面上的元素。在这个时候,我们预计这个按钮将被禁用,因此我们通过调用expect(button).toBeDisabled()
来确认这个预期。
接下来,我们使用screen.getByRole
函数查找复选框。这个函数允许我们根据其角色找到复选框,其角色是checkbox
。
使用userEvent.click
函数模拟用户点击复选框的交互,该函数被 React 的act
函数包裹。act
函数确保在继续之前处理并应用与这些操作相关的所有更新;这样,我们的断言将检查组件的更新状态。
最后,我们验证在点击复选框之后按钮是否被启用。这是通过使用expect(button).toBeEnabled()
来完成的。如果这个语句为真,我们知道我们的组件表现如预期——也就是说,在用户接受条款和条件之前禁用下一步按钮。
现在,让我们看看代码是如何编写的。正在测试的TermsAndConditions
组件由几个组件组成——heading
、LegalContent
和UserConsent
。此外,UserConsent
本身由CheckBox
和Button
组成:
const TheLegalContent = () => {
return (
<p>
{/*...*/}
</p>
);
};
type CheckBoxProps = {
label: string;
isChecked: boolean;
onCheck: (event: any) => void
}
const CheckBox = ({label, isChecked, onCheck}: CheckBoxProps) => {
return (
<label>
<input
type="checkbox"
checked={isChecked}
onChange={onCheck}
/>
{label}
</label>
)
}
type ButtonProps = {
type: 'standard' | 'primary' | 'secondary';
label: string;
disabled?: boolean;
}
const Button = ({label, disabled = true}: ButtonProps) => {
return (
<div style={{margin: '0.5rem 0'}}>
<button disabled={disabled}>{label}</button>
</div>
)
}
const UserConsent = () => {
const [isChecked, setIsChecked] = useState(false);
const handleCheckboxChange = (event: React.
ChangeEvent<HTMLInputElement>) => {
setIsChecked(event.target.checked);
};
return (
<>
<CheckBox isChecked={isChecked} onCheck={handleCheckboxChange}
label="I accept the terms and conditions" />
<Button type="primary" label="Next" disabled={!isChecked} />
</>
);
};
const TermsAndConditions = () => {
return (
<div>
<h2>Terms and Conditions</h2>
<TheLegalContent />
<UserConsent />
</div>
);
};
export { TermsAndConditions };
在这段代码中,唯一被导出的组件是TermsAndConditions
,这是我们测试策略的主要主题。在我们的测试中,我们使用userEvent.click
在jsdom
环境中引发点击事件。
实际上,我们的重点不是测试独立的 React 组件(如CheckBox
和Button
;它们应该有自己的单元测试),而是 DOM 元素及其交互。重要的是要澄清,我们在这里没有调用一个完整的浏览器,而是一个存在于内存中的无头jsdom
变体。尽管这些集成测试是在模拟环境中进行的,但它们仍然为我们提供了信心,即点击事件和按钮启用是按预期工作的。
注意
jsdom是一个基于 JavaScript 的无头浏览器,可以用来创建一个模拟浏览器环境的真实测试环境。它是在 JavaScript 中实现 HTML、DOM、CSS 等网络标准的实现。
当我们在浏览器中运行操作 DOM 的 JavaScript 时,浏览器提供 DOM。然而,当我们使用 Jest 等测试框架在 Node.js 环境中运行测试时,默认情况下并没有 DOM。这就是jsdom
发挥作用的地方。jsdom
提供了一个虚拟 DOM,从而使我们的测试能够在类似浏览器的环境中运行,即使它们在 Node.js 中运行。
我们为什么需要jsdom
?在现代前端开发中,尤其是在使用 React、Angular 和 Vue 等框架时,我们的 JavaScript 代码通常会直接与 DOM 交互。为了使我们的测试变得有用,它们需要能够模拟这种交互。jsdom
允许我们做到这一点,而无需打开浏览器窗口。
在集成测试中,我们关注的是各个模块之间的交互。然而,即使这些交互按预期工作,仍然有可能整个系统会崩溃。用户旅程通常涉及多个步骤,因此确保这些步骤无缝连接的过程对于软件持续可靠地工作至关重要。
学习使用 Cypress 进行端到端测试
端到端测试位于测试金字塔的顶部。端到端测试模拟真实用户流程和交互,测试整个系统。这些测试有助于确保应用程序的所有部分按预期协同工作,从用户界面到后端系统。
我们将在本书中使用 Cypress 作为端到端测试框架。Cypress是现代 Web 应用端到端测试的强大工具。其独特的方法使其与其他许多测试工具区别开来 – 与使用 Selenium(许多测试系统的常见引擎)不同,Cypress 直接在真实浏览器上操作,从而产生更可靠的测试和更好的调试体验。
安装 Cypress
您可以将 Cypress 安装到现有项目(就像我们在本书中所做的那样)或安装到项目以外的另一个文件夹中。Cypress 已被添加到 GitHub 代码库中的项目依赖项(在技术要求部分提供),因此您只需在项目根目录中运行npm install
(有关更多信息,请参阅官方文档:docs.cypress.io/guides/getting-started/installing-cypress
)。
安装完包后,只需运行npx cypress open
即可启动配置向导:
图 5.4:Cypress 向导 – 选择测试类型
按照向导配置端到端测试,并将Chrome作为运行所有测试的浏览器。之后,选择创建****新的 spec:
图 5.5:Cypress 向导 – 从模板创建 spec
Cypress 将为我们创建一个包含所有必要文件的文件夹:
cypress
├── downloads
├── e2e
│ └── quote-of-the-day.spec.cy.js
├── fixtures
│ └── example.json
└── support
├── commands.js
└── e2e.js
让我们分解一下它的结构:
-
在顶级目录中,我们有
cypress
目录,这是所有与 Cypress 相关的文件的根目录。 -
downloads
目录通常是 Cypress 测试期间下载的文件存储的地方;我们在这里不会使用它,因为我们在这个阶段没有要保存的内容。 -
e2e
目录是端到端测试文件所在的位置。在这个例子中,它包含quote-of-the-day.spec.cy.js
(由 Cypress 向导生成),这是一个用于测试应用程序每日引言功能的 Cypress 测试文件。 -
fixtures
目录是一个放置外部静态数据的地方,你的测试将使用这些数据。我们可以放置一些可以在测试中使用的静态文件(例如,如果我们的测试需要一些 JSON 数据来模拟网络的响应)。 -
support
目录包含 Cypress 命令和支持文件,我们也不会触摸它。
一旦我们设置了文件夹结构,我们就可以继续编写我们的第一个测试。
运行我们的第一个端到端测试
让我们修改quote-of-the-day.spec.cy.js
文件,使其访问远程网站。Cypress 将积极监视cypress/e2e/
文件夹下的文件,并且每当内容发生变化时,它将重新运行测试。
确保你在终端窗口(无论是 MacOS/Linux 的 Terminal 还是 Windows Terminal)中启动了 cypress,使用npx cypress open
:
describe('quote of the day', () => {
it('display the heading', () => {
cy.visit('https://icodeit-juntao.github.io/quote-of-the-day/');
})
});
在这个代码片段中,describe
用于声明一个测试套件——在这个例子中,是为每日引言功能。在这个套件中,定义了一个由它指定的单个测试用例,标记为显示标题
。这个测试用例的目的是访问一个网页——在这个例子中,icodeit-juntao.github.io/quote-of-the-day/
——并且每次用户刷新页面时,这个网页都会返回一个随机引言。
然而,需要注意的是,这个测试用例目前还没有执行任何实际的测试或断言。它只是导航到页面。为了使这个测试有意义,你通常会添加断言来检查页面特定元素的状态,例如标题或显示的引言:
it('display the heading', () => {
cy.visit('https://icodeit-juntao.github.io/quote-of-the-day/');
cy.contains("Quote of the day");
})
这个代码片段现在是一个有意义的测试。在访问https://icodeit-juntao.github.io/quote-of-the-day/
之后,这个测试现在包含了一个使用cy.contains()
方法的额外检查。cy.contains()
方法用于搜索和获取包含指定文本的 DOM 元素——在这个例子中,是每日引言。这个方法将获取它找到的第一个包含文本的元素,如果找不到这样的元素,它将使测试失败。
如果测试可以通过,我们就相信该 URL 对公众是可访问的,并且页面不会抛出任何异常:
图 5.6:在 Cypress 测试运行器中运行端到端测试
注意,在图 5**.6的右侧屏幕上,你可以看到真实浏览器上显示的内容,而在左侧,你可以看到测试用例和步骤。你甚至可以使用鼠标悬停在某个步骤上,以查看该点的页面快照。
此外,我们还可以添加另一个测试用例来验证页面上是否存在引用容器;这是每日引用应用最重要的部分——确保引用能够显示:
it('display a quote', () => {
cy.visit('https://icodeit-juntao.github.io/quote-of-the-day/');
cy.get('[data-testid="quote-container"]').should('have.length', 1);
})
在这个测试中,使用cy.get()
方法通过其data-testid
属性检索 DOM 元素。这个属性通常用于测试,允许你选择元素而无需担心它们的 CSS 选择器或内容,这些可能会随时间改变。
在这个测试中,被选择的元素具有data-testid
属性为quote-container
。一旦检索到元素,就会调用should()
方法来断言有关该元素状态的内容。在这种情况下,它检查元素的长度(即匹配元素的数量)为1
。
因此,在这个测试中,在导航到网页后,它会寻找具有data-testid
属性为quote-container
的元素,并检查是否存在恰好一个这样的元素。如果存在,测试将通过;如果不(无论是由于没有匹配的元素还是多于一个),测试将失败。
这很棒,但这里有一个问题:如果页面不是空的,标题渲染正确,但引用的实际内容由于某种原因没有显示出来怎么办?或者,如果我们编写测试时不确定预期的引用是什么,引用是可见的怎么办?以icodeit-juntao.github.io/quote-of-the-day/
网站为例,每次访问都会生成随机的引用。不同的用户可能在不同的时间遇到不同的引用。为了解决这些变量,我们需要一种结构化的方法来测试具有这种不可预测行为的应用程序。
截获网络请求
在某些情况下,我们不想发送实际的网络请求以使 UI 工作,而在其他情况下,直接依赖响应并不实际。我们希望通过检查内容来验证引用是否正确渲染,但由于引用内容是随机生成的,我们无法在网络请求之前预测它。这意味着我们需要一种机制来锁定响应,但我们又希望发送请求。
实现这一点的一种方法是通过截获发送到端点的网络请求并返回一些固定数据。在 Cypress 中,我们可以通过cy.intercept
API 来实现。
首先,我们可以在quote-of-the-day.spec.cy.js
文件中定义一个数据数组。它是一个普通的 JavaScript 数组,包含我们期望从服务器端返回的数据:
const quotes = [
{
content:
"Any fool can write code that a computer can understand. Good
programmers write code that humans can understand.",
author: "Martin Fowler",
},
{
content: "Truth can only be found in one place: the code.",
author: "Robert C. Martin",
},
{
content:
"Optimism is an occupational hazard of programming: feedback is
the treatment.",
author: "Kent Beck",
},
];
在测试代码中,我们的目标是捕获发送到以 https://api.quotable.io/quotes/random
开头的 URL 的任何网络请求。每当请求从 React 发送时,Cypress 将取消请求并返回 quotes
数组;这意味着测试不依赖于远程服务是否工作。这样,我们的测试就更加稳定。我们可以在以下代码中看到:
it("display the quote content", () => {
cy.intercept("GET", "https://api.quotable.io/quotes/random*", {
statusCode: 200,
body: quotes,
});
cy.visit("https://icodeit-juntao.github.io/quote-of-the-day/");
cy.contains(
"Any fool can write code that a computer can understand. Good
programmers write code that humans can understand."
);
cy.contains(
"Martin Fowler"
);
});
cy.intercept
函数正在被用来模拟对报价 API 的 HTTP GET 请求。当检测到此类请求时,而不是让请求通过到实际 API,Cypress 将以预定义的 HTTP 响应来响应。此响应的状态码为 200,表示成功,响应体被设置为我们的预定义报价数据。这种技术使我们能够控制返回的数据,使我们的测试更加确定性和独立于实际 API 的任何潜在不稳定或变化。
测试随后导航到报价网页。页面加载后,它验证页面是否包含预期的报价文本和报价作者。如果这两个检查都通过,则测试用例成功。
这里有一些有趣的事情需要我们强调。端到端测试,根据定义,测试整个软件栈,从前端到后端,包括所有中间层,如数据库和网络基础设施。端到端测试旨在模拟现实世界场景,并确认整个应用程序是否正常工作。
然而,当我们使用 cy.intercept
函数来模拟 HTTP 请求时,我们实际上正在修改这种行为。我们不再测试完整的端到端流程,因为我们正在控制和替换实际的后端响应为模拟响应。这种技术将测试从端到端测试转变为更类似于前端集成测试,因为我们正在测试前端不同组件的集成,同时模拟后端响应。
然而,这并不一定是一件坏事。在测试中,尤其是在复杂的系统中,将系统的不同部分隔离开来以获得对我们测试的更多控制,并确保我们可以更可靠和确定性地测试不同的场景,通常是很有益的。
有了这些,我们已经涵盖了 Cypress 的端到端测试基础知识,你现在可以编写健壮的端到端测试用例来测试你的 Web 应用程序。
摘要
在本章中,我们开始了对 React 应用程序测试世界的探索。我们了解到测试的必要性不仅仅是对代码正确性的验证;它铺就了可维护性的道路,提高了可读性,并推动了我们应用程序的进化,最终确保我们构建的软件始终符合预期。
测试是软件开发中的关键实践——它确保我们的应用程序不仅工作正确,而且能够适应未来的变化。React 生态系统,包括 Jest、React Testing Library 和 Cypress 等工具,为我们提供了强大的武器库,以实施全面的测试策略,从而增强我们应用程序的健壮性和可靠性。
在下一章中,我们将探讨常见的重构技术,并了解测试如何在重构过程中帮助我们。
第六章:探索常见的重构技术
欢迎来到重构的迷人世界!在本章中,我们将探讨这一基本实践的基础知识,这对于每个开发者在维护和改进代码库方面都是至关重要的。我们的目标是让你熟悉这些最常见重构技术,为你理解和运用这些宝贵工具打下坚实的基础。记住,我们的目标不是提供详尽的指南,而是让你熟悉你在编程旅程中会反复使用的要点。
重构不分语言或框架——它是一个通用的概念,适用于你编写代码的任何地方。我们将讨论的技术包括重命名变量、更改函数声明、提取函数、移动字段等。这些技术乍一看可能很简单,但它们是构建干净、易于理解、易于维护的代码的强大工具。
还要记住,重构不是一个一次性任务,而是一个持续的小型、迭代性更改的过程,这些更改逐渐提高你代码的结构和质量。正是这些频繁的增量改进使代码库保持健康、健壮,并且更容易工作。通过介绍基础知识,我们希望为你提供必要的工具和技术,这些将成为更高级重构方法的垫脚石。
虽然我们将在后面的章节中深入探讨更复杂的重构技术,但你在这里学到的实践将作为一个宝贵的起点。到本章结束时,你将拥有一个常见的重构实践工具包,以及对这些实践在提高代码质量方面重要性的新认识。最终,你在这里开始培养的重构技能将使你能够编写更干净、更高效的代码,并使你走上成为更熟练的开发者的道路。让我们开始吧!
在本章中,我们将涵盖以下主题:
-
理解重构
-
在重构前添加测试
-
使用重命名变量
-
使用提取变量
-
使用管道中的替换循环
-
使用提取函数
-
使用引入参数对象
-
使用分解条件
-
使用移动函数
技术要求
已创建一个 GitHub 仓库来托管本书中讨论的所有代码。对于本章,你可以在github.com/PacktPublishing/React-Anti-Patterns/tree/main/code/src/ch6
找到推荐的结构。
在我们深入重构之前,让我们与一些有助于我们轻松进行更改的工具保持一致。当谈到重构工具时,前端世界中有很多集成开发环境(IDEs)和源代码编辑器可用——WebStorm 和 Visual Studio Code(VS Code)是最受欢迎的,它们提供了令人印象深刻的特性,包括强大的重构功能。
WebStorm,由 JetBrains 开发,是一个专为 JavaScript 及其相关技术(如 TypeScript、HTML 和 CSS)设计的强大且功能丰富的 IDE。其最显著的特点是其高级自动重构功能,但它还提供了一系列的重构选项,如重命名、提取、内联、移动和复制,这些选项可以应用于变量、类、函数和其他元素。它还具有智能重复代码检测功能,帮助你定位和解决重复的代码块。
图 6.1:WebStorm IDE
WebStorm 的智能感知、自动完成和代码导航功能相当强大,在编写和探索代码时为你提供了很多帮助。然而,WebStorm 是一个商业产品,尽管它提供试用版,但你仍需要购买许可证才能继续使用。
Visual Studio Code(VS Code),另一方面,是由微软开发的一个免费、开源的集成开发环境(IDE)。与 WebStorm 相比,它更轻量级,以其速度和灵活性而闻名。由于拥有扩展市场,VS Code 支持广泛的编程语言,而不仅仅是 JavaScript。VS Code 的重构功能也很强大,支持常见的操作,如重命名、提取函数或变量以及更改函数签名。通过安装扩展,VS Code 的重构功能可以进一步增强,其可定制性是其关键优势之一。
图 6.2:VS Code
虽然 VS Code 可能没有 WebStorm 那么多的内置自动重构功能,但通过这些扩展,它可以定制以匹配甚至超越 WebStorm 的功能。
在两者之间进行选择通常归结为个人偏好和项目具体需求。如果你重视高度自动化的、功能丰富的环境,并且不介意为此付费,WebStorm 可能是你的最佳选择。然而,如果你更看重速度、灵活性和定制性,并且愿意通过扩展来设置你的环境,那么 VS Code 可能是更好的选择。
我更喜欢 WebStorm 作为我的工作 IDE——部分原因是我已经非常熟悉快捷键,并且我喜欢内置的自动重构功能。然而,我仍然使用 VS Code 进行休闲项目。
理解重构
重构是一种有纪律、系统的过程,旨在在不改变现有代码库外部行为的情况下改进其设计。它是日常编码的基本方面,是软件开发迭代和增量特性的一个组成部分。这个概念具有普遍适用性,不受任何特定编程语言、框架或范式的限制。无论你是在编写 JavaScript、Python 还是其他任何语言,无论你是在使用 React、Angular 还是自建框架,重构对于维护健康的代码库至关重要。
术语“重构”最初由威廉·奥普迪克和拉尔夫·约翰逊在 1990 年发表的一篇题为《重构:设计应用程序框架和演进面向对象系统的辅助工具》的论文中提出;然而,重构的概念和实践源于软件工程早期的实践。重构的艺术随着马丁·福勒 1999 年出版的书籍《重构:现有代码的设计改进》而获得了显著的关注。在这本书中,福勒将重构描述为“一种控制现有代码库设计的技巧”,强调其在减轻技术债务积累方面的作用,这使得代码更容易理解、维护和扩展。
重构不是关于对代码库进行一次重大、全面的改变以使其完美。相反,它是在一段时间内持续不断地进行小的、渐进的改进。每个单独的改变可能不会显著改变代码的质量,但集体来看,随着时间的推移,这些小的改变可以显著提高代码库的结构、可读性和可维护性。
尽管重构不添加新功能,但它直接影响了团队快速交付新功能的能力,减少错误,并更灵活地应对变化的需求。通过持续重构,我们保持代码的整洁和易于操作,并为长期、可持续的发展奠定基础。
总之,重构是开发者工具箱中的关键工具,无论技术栈或项目的规模和范围如何。它是代码库和团队的长期投资,最终,它是对交付的软件质量的投资。
重构的常见错误
人们在重构中犯的最大错误是将代码重构而不是重构代码。术语“重构”和“重构”经常被互换使用,但在软件开发中它们有截然不同的含义。
重构是一种提高现有代码库设计的纪律性技术,使其更干净、更容易理解和操作。它涉及在不修改软件外部行为的情况下改变软件的内部结构。这通常以小步骤进行,并且每个重构步骤都应保持软件的功能。它不添加新功能;相反,它使代码更易于阅读、维护,并为未来的变化做好准备。
例如,在一个 React 应用程序中,重构可能包括将一个大型组件拆分成更小、更易于管理的组件,或者用策略模式替换复杂的条件逻辑。
另一方面,重构可以被视为一个更广泛、更彻底的过程。它通常涉及大规模的变更,不仅影响软件的内部结构,还可以影响其外部行为。重构可以包括对软件架构、数据模型、接口等方面的变更。它通常是由引入软件功能或能力的主要变更或添加、提高性能或解决重大技术债务的需求所驱动的。
在 React 应用的背景下,重构可能包括更改状态管理解决方案(例如从 Redux 移动到 React Context API),更新路由机制,或者从单体架构过渡到微前端架构。
虽然重构和重构都旨在提高代码库的质量,但重构通常范围较小,不涉及功能变更,并且是常规开发过程的一部分。相比之下,重构通常范围更广,可以改变功能,并且通常是解决更重大挑战或需求变化的更大项目或倡议的一部分。
除了重构和重构之间的误解之外,人们还倾向于犯的另一个错误是他们测试的频率不够高——有时这是因为他们没有很多测试,而有时他们认为在没有测试的情况下进行这些“小”改动是安全的。让我们在下一节中看看测试。
在重构之前添加测试
由于我们不希望在重构期间造成任何可观察的行为变化,我们需要检查代码以确保我们有足够的测试来覆盖当前的行为。如果没有适当的测试,很容易出错,这不仅风险高,而且效率低,因为我们需要手动和反复地检查更改后的代码。
假设我们有一些来自在线购物应用程序的 TypeScript 代码——代码运行正常,但没有与之相关的测试。为了改进代码,使其更容易理解和扩展,我们需要对其进行重构:
interface Item {
id: string;
price: number;
quantity: number;
}
class ShoppingCart {
cartItems: Item[] = [];
addItemToCart(id: string, price: number, quantity: number) {
this.cartItems.push({ id, price, quantity });
}
calculateTotal() {
let total = 0;
for (let i = 0; i < this.cartItems.length; i++) {
let item = this.cartItems[i];
let subTotal = item.price * item.quantity;
if (item.quantity > 10) {
subTotal *= 0.9;
}
total += subTotal;
}
return total;
}
}
export { ShoppingCart };
因此,这段代码定义了一个购物车模型。首先,它定义了一个 Item
接口,它代表要添加到购物车中的项目。一个 Item
组件由一个 ID、一个价格和一个数量组成。然后,它定义了一个具有 cartItems
属性的 ShoppingCart
类,该属性是一个 Item
对象的数组,最初为空。
ShoppingCart
类有两个方法:
-
addItemToCart
方法接受一个 ID、价格和数量,然后使用这些参数创建一个项目。然后,这个项目被添加到cartItems
数组中。 -
calculateTotal
方法计算购物车中商品的总价。对于每个商品,它将商品的价格乘以其数量以得到小计。如果商品的数量超过 10 个,则对小计应用 10%的折扣。然后将每个商品的子计价相加以得到总价。该方法随后返回总价。
这里有两个重要的计算:计算总价(价格乘以数量)以及在适用的情况下应用折扣。我们通常应该更加关注这些计算的逻辑。
例如,我们需要验证if-else
语句的两边。由于我们在for
循环内部有if-else
,我们至少需要在做出更改之前添加两个测试用例。让我们添加以下 Jest 测试来描述两种计算情况——有折扣和无折扣:
import { ShoppingCart } from "../ShoppingCart";
describe("ShoppingCart", () => {
it("calculates item prices", () => {
const shoppingCart = new ShoppingCart();
shoppingCart.addItemToCart("apple", 2.0, 2);
shoppingCart.addItemToCart("orange", 3.5, 1);
const price = shoppingCart.calculateTotal();
expect(price).toEqual(7.5);
});
it('applies discount when applicable', () => {
const shoppingCart = new ShoppingCart();
shoppingCart.addItemToCart("apple", 2.0, 11);
const price = shoppingCart.calculateTotal();
expect(price).toEqual(19.8);
})
});
第一个测试“计算商品价格”,是验证在没有应用折扣的情况下calculateTotal
方法是否按预期工作。在这里,创建了一个ShoppingCart
对象,并向购物车中添加了两个商品(apple
和orange
)。这些商品的总价计算出来应该是 7.5,因为有两个苹果,每个 2 美元,还有一个橙子,3.5 美元。
第二个测试“在适用时应用折扣”,是检查当商品的数量超过 10 个时,calculateTotal
方法是否正确地应用了 10%的折扣。在这种情况下,创建了一个ShoppingCart
对象,并向购物车中添加了一种商品(apple
),数量为 11。在应用了 10%折扣后的子计价 22 美元(11 个苹果,每个 2 美元)后,该商品的总价应该是 19.8 美元。然后,计算出的总价与这个预期值进行比较。
一旦我们有测试用例来覆盖重要的逻辑,我们就可以安全地进行更改。在重构过程中,我们需要定期运行这些测试。
重构是小的步骤,可以提高代码质量。让我们看看我们非常第一个,也许是最简单的重构技术:重命名变量。
使用重命名变量
让我们从一种简单的重构技术“重命名变量”开始。重命名变量是一种非常直接且有效的方法,可以提高代码的可读性和可维护性。它涉及更改变量的名称,以更好地反映其目的和所持有的数据,或遵循某种命名约定或标准。
有时,在编码的初期阶段,开发者可能会为变量选择当时有意义的名称,但随着代码的发展,变量的目的可能会改变或变得更加清晰。然而,变量名往往保持不变。这可能会导致混淆,并使代码更难以理解和维护。将变量重命名为更准确地描述其目的可以减少未来读者(包括当前开发者的未来自我)的认知负担。
让我们回到我们的 ShoppingCart
示例。ShoppingCart
类内部的变量名 cartItems
稍显冗余;然而,我们可以将其重命名为 items
以使其更加简洁和清晰:
class ShoppingCart {
items: Item[] = [];
addItemToCart(id: string, price: number, quantity: number) {
this.items.push({ id, price, quantity });
}
calculateTotal() {
let total = 0;
for (let i = 0; i < this.items.length; i++) {
let item = this.items[i];
let subTotal = item.price * item.quantity;
if (item.quantity > 10) {
subTotal *= 0.9;
}
total += subTotal;
}
return total;
}
}
在更改后,请确保再次运行测试,以查看我们是否意外地犯了错误。
在进行一些更改后,定期运行测试并建立习惯是很重要的,并且每当测试失败时,我们需要停下来检查哪里出了问题。一旦所有测试都恢复正常通过,我们就可以继续进行。
使用提取变量
提取变量是一种常见的重构技术,用于提高代码的可读性和可维护性。这个过程涉及取一段计算值的代码,用一个新的变量替换它,并将原始表达式的结果分配给这个新变量。还有一种类似的重构称为提取常量,可以用来提取在运行时不改变的值。
这种重构技术在处理复杂表达式或重复计算时特别有用;通过将表达式的部分提取到具有有意义名称的变量中,代码变得更加易于理解和维护。
在 ShoppingCart
示例中,0.9
折扣率值得有一个自己的名字;我们可以在函数调用点提取一个变量并引用它。由于变量的值在运行时不会改变,我们可以在这种情况下将其称为提取常量:
const DISCOUNT_RATE = 0.9;
class ShoppingCart {
//...
calculateTotal() {
let total = 0;
for (let i = 0; i < this.items.length; i++) {
let item = this.items[i];
let subTotal = item.price * item.quantity;
if (item.quantity > 10) {
subTotal *= DISCOUNT_RATE;
}
total += subTotal;
}
return total;
}
//...
}
为了清晰起见,讨论中省略了与此次特定更改无关的代码部分;然而,需要注意的是,在这个例子中,我们创建了一个名为 DISCOUNT_RATE
的常量,并在代码中使用它来替代之前硬编码的 0.9
值。有时,我们可能希望给一个表达式而不是硬编码的值命名,因此我们可以创建一个变量来代表这个表达式,然后引用这个变量。
这只是一个小小的步骤,但它略微改进了代码。如果我们将来需要更改折扣率,常量名称比硬编码的 0.9
值更容易搜索和理解。
现在我们可以调查另一种重构技术,使 for
循环变得更加简单。
使用替换循环与管道
在像 JavaScript 这样的函数式编程语言中的 map
、filter
和 reduce
。
在 JavaScript 的情况下,数组原型有 map
、filter
和 reduce
等方法,可以将它们链接起来形成一个管道。这些方法中的每一个都接收一个函数作为参数,并将此函数应用于数组中的每个元素,从而以某种方式有效地转换数组。
然而,请注意,虽然用管道替换循环可以使代码更简洁、更易读,但这可能并不总是最有效的方法,尤其是在处理非常大的数据集时。因此,就像所有重构一样,在需要多次遍历大型数据集的情况下,你需要平衡可读性和可维护性与性能要求。
上一节中的for
循环可以用reduce
函数(我们不需要显式定义索引变量或保存样板代码)来替换:
class ShoppingCart {
//...
calculateTotal() {
return this.items.reduce((total, item) => {
let subTotal = item.price * item.quantity;
return total + (item.quantity > 10 ? subTotal * DISCOUNT_RATE :
subTotal);
}, 0);
}
//...
}
calculateTotal()
方法正在使用reduce()
函数来计算购物车中物品的总价。reduce()
函数是一个高阶函数,它将一个函数应用于累加器和数组中的每个元素(从左到右),以将其减少到单个输出值。
总计从 0 开始,然后对于购物车中的每一项,都会将该项目的subTotal
变量加到总计中。subTotal
变量是通过乘以每个项目的价格和数量来计算的。
接下来,我们需要重新运行所有测试,以检查一切是否顺利。由于我们的测试仍然通过,让我们看看如何通过将行提取到更小的函数中来使代码更加完善。
使用提取函数
提取函数是一种重构技术,通过将大型或复杂的函数分解成更小、更易于管理的部分,有助于提高代码的可读性和可维护性。
假设你遇到一个执行多个任务的函数。也许它正在进行一些数据验证,然后进行一些计算,最后记录结果或更新某些状态。这个函数很长且复杂,使得一眼看去很难理解它在做什么。提取函数重构就是关于识别那些不同的功能部分,将它们拉出到它们自己的独立函数中,然后从原始函数中调用这些新函数。
一个关键的好处是它使代码更具自文档性。如果你将函数的一部分提取到新的函数中并给它一个有意义的名称,它通常可以使代码更容易理解,因为函数名可以描述代码正在做什么。它还提高了代码的可重用性,因为这些较小的函数如果需要可以在其他地方重用。
计算如何计算subTotal
变量的逻辑可以从calculateTotal
中提取为一个独立的单元:
function applyDiscountIfEligible(item: Item, subTotal: number) {
return item.quantity > 10 ? subTotal * DISCOUNT_RATE : subTotal;
}
class ShoppingCart {
//...
calculateTotal() {
return this.items.reduce((total, item) => {
let subTotal = item.price * item.quantity;
return total + applyDiscountIfEligible(item, subTotal);
}, 0);
}
}
在这个代码片段中,我们看到提取函数重构的结果。如果项目数量大于 10,应用折扣的逻辑已经被提取到名为applyDiscountIfEligible
的独立函数中。
在 ShoppingCart
类中,calculateTotal
方法使用 reduce
函数计算购物车中物品的总价。对于每个物品,它计算小计作为物品价格和数量的乘积,然后将这个小计(在应用任何符合条件的折扣后)加到总金额上。
applyDiscountIfEligible
函数接受一个物品及其数量作为参数。如果物品的数量超过 10,它将对参数 subTotal
应用折扣率(表示为 DISCOUNT_RATE
);否则,它简单地返回参数 subTotal
而不改变。
通过将如何应用折扣的细节抽象到单独的、适当命名的函数中,这种重构使 calculateTotal
方法更加简洁且易于阅读。
让我们看看另一种重构方法,可以使传入的参数更容易修改。
使用引入参数对象
引入参数对象 是一种重构技术,用于函数有大量参数或多个函数共享相同参数时。在这种技术中,你将相关的参数组合成一个单一的对象,并将其传递给函数。
函数中有大量参数可能会令人困惑且难以管理。将相关的参数组合成一个对象可以增加代码的可读性,并使理解函数的功能更容易。这还使函数调用更简单、更清晰。此外,如果同一组参数在多个函数调用中使用,这种技术可以减少传递参数顺序错误的机会。
例如,考虑一个 calculateTotalPrice(quantity, price, discount)
函数。我们可以使用引入参数对象技术对其进行重构,变为 calculateTotalPrice({ quantity, price, discount })
。现在,quantity
、price
和 discount
参数被组合成一个对象(类型为 Item
),如下所示:
class ShoppingCart {
items: Item[] = [];
addItemToCart({id, price, quantity}: Item) {
this.items.push({ id, price, quantity });
}
//...
}
除了这些好处,引入参数对象重构通常可以揭示或激发之前在代码中隐藏和隐含的领域概念。参数对象可能成为一个具有自己行为和数据操作方法的类。这可能导致更面向对象和封装的代码。
接下来,让我们探索另一种重构技术,旨在简化你的 if-else
语句并提高代码可读性。
使用分解条件
将 if-else
或 switch
)提取到单独的函数中。这种技术有助于提高代码的可读性,使其更易于理解。
条件、if
子句和 else
子句(如果存在)都各自有自己的函数。然后根据它们所做的工作或它们所检查的内容来命名这些函数。这种重构的好处是,它用有良好命名的函数替换了可能需要注释来理解的代码,使代码更具自解释性。
例如,applyDiscountIfEligible
函数中的逻辑实际上可以通过这种重构来简化;我们可以提取一个名为 isDiscountEligible
的小函数来替换 item.quantity > 10
检查,如下所示:
function isDiscountEligible(item: Item) {
return item.quantity > 10;
}
function applyDiscountIfEligible(item: Item, subTotal: number) {
return isDiscountEligible(item) ? subTotal * DISCOUNT_RATE : subTotal;
}
在这个代码片段中,将逻辑提取到单独的函数中可能看起来是多余的,因为它添加了一个额外的函数调用。然而,它增强了可读性和可重用性:
function isDiscountEligible(item: Item) {
return item.quantity > 10;
}
function applyDiscountIfEligible(item: Item, subTotal: number) {
return isDiscountEligible(item) ? subTotal * DISCOUNT_RATE :
subTotal;
}
在这个代码片段中,我们将确定项目是否有资格获得折扣的逻辑分离到一个独立的 isDiscountEligible
函数中。这种提取使我们的 applyDiscountIfEligible
函数更简洁,其意图更明显。此外,它还允许在将来需要时独立更新 isDiscountEligible
逻辑,提高可维护性。
提取了这些较小的函数后,它们不必保留在当前文件中。我们可以将它们重新定位到单独的模块中,并在需要时导入它们;这不仅缩短了当前模块的长度,还提高了其可读性。让我们看看下一个例子。
使用移动函数
移动函数是一种重构方法,涉及将函数的位置更改为更合适或更适当的位置。这可能是同一类、不同类,甚至是单独的模块。这种方法的目标是通过确保函数放置在它们逻辑上最适合的位置来提高代码的可读性、可维护性和结构。
当你的类随着时间的推移而演变责任时,这种重构变得必要。你可能会发现某个函数更适合不同的类,或者也许你有一个在类中一起工作的函数组,它们更适合在自己的类或模块中。
移动函数重构可以通过将函数移动到它们的功能最相关或最需要的地方来帮助减少类的复杂性。这促进了内聚原则,即相关的代码放在一起。它还有助于通过最小化代码不同部分之间不必要的依赖来实现松耦合。
在我们的 ShoppingCart
组件中,我们可以将类型定义移动到一个名为 types.ts
的新文件中。我们还可以将 DISCOUNT_RATE
、isDiscountEligible
和 applyDiscountIfEligible
移动到一个名为 utils.ts
的单独文件中:
import { Item } from "./types";
const DISCOUNT_RATE = 0.9;
function isDiscountEligible(item: Item) {
return item.quantity > 10;
}
export function applyDiscountIfEligible(item: Item, subTotal: number) {
return isDiscountEligible(item) ? subTotal * DISCOUNT_RATE :
subTotal;
}
注意,在代码中,只有 applyDiscountIfEligible
是公共函数,可以在文件外部访问。这种重构也提高了代码的封装性。
使用了移动函数后,ShoppingCart
组件被显著简化,只包含必要的部分:
import { Item } from "./types";
import { applyDiscountIfEligible } from "./utils";
class ShoppingCart {
items: Item[] = [];
addItemToCart({ id, price, quantity }: Item) {
this.items.push({ id, price, quantity });
}
calculateTotal() {
return this.items.reduce((total, item) => {
let subTotal = item.price * item.quantity;
return total + applyDiscountIfEligible(item, subTotal);
}, 0);
}
}
export { ShoppingCart };
与所有重构一样,在移动函数时应小心,以确保系统的整体行为没有改变。应该有测试来验证重构后功能保持不变。
摘要
本章重点介绍了各种代码重构技术,这些技术对于维护和改进代码库的结构、可读性和可维护性至关重要。
介绍的重构技术包括:重命名变量,通过使用更具描述性的变量名来提高代码清晰度;提取变量,通过将复杂表达式分解成更小、更易于管理的部分来简化表达式;以及用管道替换循环,将传统的for
/while
循环转换为更简洁、声明式的更高阶函数,如map
、filter
和reduce
。
此外,提取函数通过将大函数分解成更小的函数,每个函数都有单一、明确的职责,来鼓励代码模块化和可重用性,而引入参数对象将相关参数组合成一个单一对象,从而简化函数签名。此外,分解条件将复杂的条件逻辑分解成单独的函数,提高可读性,移动函数确保函数被放置在代码库中最合理和适当的位置,促进高内聚和松耦合。
在所有这些技术中,我们强调了保持相同整体系统行为和依赖测试来确保功能在重构后保持一致性的重要性。这些方法如果正确应用,可以导致代码库更加易于理解、易于维护和更健壮。
在下一章中,我们将探讨一种提升代码质量卓越的方法——被称为测试驱动开发的方法。
第七章:介绍使用 React 的测试驱动开发
欢迎来到一个可能彻底改变你对 React 开发方法的章节——测试驱动开发(或简称TDD)。如果你一直在构建 React 应用程序,你知道它们可以多么复杂和错综复杂。要管理各种状态、处理组件和促进用户交互,确保代码库的可靠性可能具有挑战性。这就是 TDD 发挥作用的地方。
在软件开发的不断变化环境中,功能持续添加或修改,TDD(测试驱动开发)就像一座灯塔,引导你安全地穿越充满错误和回归的险恶海域。通过在编写实际代码之前编写测试,你不仅确认了你的代码做了它应该做的事情,而且还创建了一个安全网,使得未来的变更风险降低。
本章旨在加深你对 TDD 的理解,以及如何在 React 应用程序中有效地实施它。我们将介绍 TDD 的核心原则,探讨各种风格,包括单元测试驱动开发、验收测试驱动开发(ATDD)、行为驱动开发(BDD),甚至检查芝加哥和伦敦风格 TDD 的细微差别。
但我们不会止步于理论——为了使这些概念生动起来,我们将通过创建比萨店菜单页面的实际示例来引导你。从设置初始结构到管理复杂功能,我们将使用 TDD 方法指导你每一步。到本章结束时,你将牢固掌握 TDD 的能力,并准备好开始编写更可靠、更健壮的 React 应用程序。
因此,准备好进入一个测试引领方向、代码跟随的世界,创造出一个和谐平衡,从而产生更好、更可靠的软件。
本章将涵盖以下主题:
-
理解测试驱动开发(TDD)
-
介绍任务管理
-
介绍在线比萨店应用程序
-
拆分应用程序需求
-
实现应用程序标题
-
实现菜单列表
-
创建购物车
-
向购物车添加商品
-
重构购物车
技术要求
已创建一个 GitHub 仓库来托管本书中讨论的所有代码。对于本章,你可以在github.com/PacktPublishing/React-Anti-Patterns/tree/main/code/src/ch7
找到推荐的结构。
理解测试驱动开发(TDD)
TDD 并不是一个新出现的概念。它起源于极限编程(XP),这是一种鼓励在短周期内频繁发布的软件开发方法,TDD 的根源可以追溯到 20 世纪 90 年代末。是敏捷宣言的原始签署人之一,肯特·贝克,将这一实践普及为 XP 的核心部分。自那时起,这一实践已经超越了 XP 的领域,现在在包括 React 在内的各种方法和框架中普遍使用。
TDD 的核心是一个简单而效果显著的循环,被称为红-绿-重构循环:
图 7.1:红-绿-重构循环
如你所见,在实践 TDD 时,本质上有三个步骤:
-
红:在这个阶段,你编写一个定义函数或函数改进的测试。这个测试最初应该失败,因为函数尚未实现。在大多数测试框架(例如,Jest)中,将有一些红色文本来指示失败。
-
绿:在这个阶段,你编写通过测试所需的最少代码。关键在于编写尽可能少的代码来使测试通过,使文本变为绿色——不多于此。
-
重构:最后,你需要保持代码的功能性,同时对其进行清理。重构阶段是关于使代码高效、可读和易于理解,而不改变其行为。重构后编写的测试应该仍然通过。
当开发者第一次接触 TDD 时,它往往感觉不合直觉,因为编写测试在编写实际代码之前与传统的开发直觉相矛盾。然而,一旦你克服了最初的不适,TDD 的优势就很难忽视了:
-
专注的问题解决:通过首先编写一个针对特定功能的测试,你将注意力集中在一次解决一个问题,使开发过程不那么令人压倒。
-
可预测的下一步行动:当你遵循测试驱动的方法时,你总是知道下一步该做什么:使测试通过。这减少了认知负荷,使你更容易专注于手头的任务。
-
简单、可维护的设计:这个过程自然鼓励编写通过测试所需的最简单代码,从而产生尽可能最小化的设计,因此更容易理解和维护。
-
促进思维流畅性:这个循环提供了一个结构化的编码方法,有助于保持“思维流畅性”,通过减少打断高效编码会话的持续上下文切换,帮助你保持对任务的专注。
-
自动测试覆盖率:TDD 默认确保你的应用程序具有强大的测试覆盖率。你并不是在事后添加测试;它们是开发过程的一部分,确保代码库更加稳定。
TDD 是一种深深植根于敏捷和 XP 原则的实践,但它已经超越了这些方法论的相关性。凭借其结构化的红-绿-重构循环,TDD 为编写高质量代码提供了一个坚实的框架。尽管最初可能看起来有些反直觉,但采用 TDD 可以导致更专注的问题解决、可预测的开发、更简单的设计、提高的生产力和稳健的测试覆盖率。
TDD 的不同风格
TDD 的核心原则已经被适应和扩展到各种风格中,每种风格都提供了对如何最好地处理测试和开发的不同视角。让我们探索一些这些风格,了解它们如何应用于 React 开发。
TDD 的原始形式,简单地称为 TDD,主要关注单元测试。在这种风格中,你为代码的最小部分编写测试——通常是单个方法或函数。目标是确保代码库的每个部分在独立的情况下都能按预期工作。虽然这对于测试逻辑和算法来说很强大,但它可能无法完全捕捉到各个部分之间的交互,尤其是在像 React 这样的复杂 UI 框架中。
ATDD 通过在开发过程开始时使用用户验收测试来扩展 TDD。这意味着在编写任何代码之前,你需要从用户的角度定义“完成”的样子,这通常与利益相关者合作完成。然后,这些验收测试被用作开发特性的基础。ATDD 特别有助于确保你正在构建用户需要和想要的东西:
图 7.2:ATDD 循环
注意,当你编写一个验收测试时,它通常可以被分解成更小的单元测试。例如,用户登录系统可以是一个验收测试,但还需要考虑忘记密码、密码或用户名错误、记住我功能等情况,这些都需要在更底层的单元测试中涵盖。
BDD 是 TDD 和 ATDD 的进一步细化,专注于给定输入的应用程序行为。而不是编写检查特定方法是否返回预期值的测试,BDD 测试检查系统在受到某些条件影响时是否按预期行为。BDD 经常使用更描述性的语言来定义测试,这使得非技术利益相关者更容易理解正在测试的内容。
BDD 经常使用诸如Cucumber之类的工具,以人类可读的格式定义行为规范。在 Cucumber 测试中,你使用一种名为Gherkin的纯文本语言来指定行为。以下是一个使用 Cucumber 进行比萨订购功能 BDD 测试用例的简单示例(我们将在本章后面继续探讨这个比萨示例):
Feature: Pizza Ordering
Scenario: Customer orders a single pizza
Given I'm on the PizzaShop website
When I select the "Order Pizza" button
And I choose a "Margherita" pizza
And I add it to the cart
Then the cart should contain 1 "Margherita" pizza
Scenario: Customer removes a pizza from the cart
Given I'm on the PizzaShop website
And the cart contains 1 "Margherita" pizza
When I remove the "Margherita" pizza from the cart
Then the cart should be empty
此 Gherkin 文件定义了比萨订购功能的预期行为。每一行被称为一个步骤,可以解释为测试中的一个语句。场景描述了测试的行为,包括要执行的步骤和预期的结果。
Gherkin 语法不仅仅是可读的文档,它还是可执行的。例如,Cucumber 这样的工具可以解析 Gherkin 文件并根据它们执行测试——例如,cypress.visit("
pizzashop.com
")
。这确保了软件的行为与特性文件中描述的完全一致,使其成为随着应用程序发展而演变的真相来源。
BDD 特性文件(Gherkin)作为活文档的一种形式,随着应用程序的变化而更新。这使得它们对于新团队成员,甚至对于经验丰富的开发者来说,快速理解应用程序的预期行为非常有价值。
关注用户价值
无论你选择哪种风格,当与 React 一起工作时,关注用户的视角至关重要。React 组件是用户与之交互的 UI 部分,因此你的测试应该反映这种交互。用户不关心你的状态是如何管理的,或者你的生命周期方法有多高效;他们关心的是点击按钮是否会显示下拉菜单,或者表单提交是否会产生预期的结果。
React Testing Library 的创造者 Kent C. Dodds 说:“你的测试越接近你的软件的使用方式,它们就能给你带来越多的信心。”这个原则适用于所有框架或库,无论你使用什么。始终应该关注用户体验。
这种以用户为中心的方法与 BDD 和 ATDD 非常吻合,其中关注的是交互的结果,而不是实现的细节。通过遵循这些原则,你可以确保你的 React 组件不仅工作良好,而且能够提供你希望实现的用户体验。
现在我们已经了解了 TDD 是什么以及它的各种风格如何帮助向我们的客户提供价值,接下来要解决的问题是如何实施它?
介绍任务分配
任务分配是 TDD 流程中的一个重要步骤,涉及将特性或用户故事分解为小而可管理的任务,这些任务随后成为你的测试用例的基础。任务分配的目标是为你将要编写的代码、如何测试它以及你将如何按顺序进行提供一个清晰的路线图。
将大需求分解成更小的块有很多好处:
-
它明确了范围:将特性分解为任务有助于更好地理解需要做什么以及如何着手
-
它简化了问题:通过将复杂问题分解成更小的任务,你使它更容易解决
-
它优先处理工作:一旦任务被列出,它们可以被优先处理,以首先提供最大的价值,或者按逻辑顺序构建。
-
它集中精力:任务分配确保你写的每个测试都有一个明确、直接的目的,使你的 TDD 循环更加高效
-
促进协作:团队成员可以挑选个别任务,因为他们知道他们都在为整体贡献。
现在,你可能想知道,既然它如此好且有用,我们该如何进行任务分配?这并不复杂——你可能已经不知不觉中做过。你只需遵循以下步骤:
-
回顾用户故事或需求:理解你要实现的用户故事或功能。
-
识别逻辑组件:将故事分解为其逻辑组件,这些组件通常对应于领域概念、业务规则或用户工作流程中的单个步骤。
-
创建任务列表:写下任务列表。这些任务应该足够小,以至于你可以在短时间内编写一些测试用例和相应的实现代码(比如,15 到 30 分钟)。
-
安排任务顺序:确定完成这些任务的最合理顺序,通常从“正常路径”开始——即一切按预期进行,没有遇到任何错误的默认场景——然后转向边缘情况和错误处理。
-
将任务映射到测试上:对于每个任务,确定将验证该功能部分的测试。在这个阶段,你不需要编写测试;你只是在识别它们。
任务分配可能是你日常工作中的一部分,即使你没有意识到。这是一种系统化的解决问题的方法,涉及将需求分解成可管理的、顺序化的任务。这些任务理想情况下应该在几分钟到一小时内完成。
TDD 的过程类似于绘画艺术。你从一个草图或草案开始,用铅笔勾勒出基本形状和线条,就像为你的代码构建初始结构。一开始,愿景可能很模糊,只是你心中的一个想法或概念。但随着你的绘制——或编写测试和代码——图像开始成形。更多的元素被添加,细节出现,并进行调整,允许持续优化。每一层或迭代,清晰度出现,但确切的最终外观直到最后阶段仍然是个谜。就像艺术家通过逐步发展创作出杰作一样,TDD 塑造了一个强大而优雅的软件作品。
好吧,到目前为止我们已经讨论了很多理论。让我们通过一个具体的例子来深入了解如何进行任务分配,并使用任务作为应用红-绿-重构循环的指南。
介绍在线披萨店应用程序
在本节中,我们将通过一个美味实用的例子深入探讨 TDD 过程:代码烤箱,一个在线披萨店。这个名字是为了庆祝编码和烹饪艺术的融合,代码烤箱旨在满足你的食欲和智力好奇心。这个数字店面将为我们提供一个全面的沙盒,我们可以在这里应用我们讨论的所有 TDD 原则和技术。
在代码烤箱中,你可以期待看到以下内容:
-
披萨菜单:Code Oven 的核心是一个令人垂涎的八种美味披萨菜单。每个披萨都附有它的名称和价格,旨在刺激您的食欲并指导您的选择。
-
添加按钮:在每个令人垂涎的选择旁边都有一个“添加”按钮。这使用户能够通过将所选披萨添加到虚拟购物车中来开始订购过程。
-
购物车:屏幕上的一个指定区域显示了用户的当前购物车,包括每个所选披萨的名称和价格。
-
修改购物车:如果您改变主意或只是想要更多的披萨,Code Oven 允许您通过动态添加或删除项目来修改您的购物车。
-
订单总额:无需手动计算 – Code Oven 的购物车会自动计算并显示您所选商品的总价。
-
下单按钮:一个显眼的“下单”按钮是最终步骤,在现实世界的应用程序中,这将处理订单以进行配送或自取:
图 7.3:The Code Oven
随着我们构建 The Code Oven,我们将在每个阶段应用 TDD,以确保我们的虚拟比萨店不仅功能齐全,而且稳健且易于维护。准备好卷起袖子,无论是编码还是进行一些虚拟比萨制作!
分解应用程序需求。
制作应用程序的需求分解并没有一个通用的正确方法;然而,通常有两种不同的风格 – 自下而上的风格和自上而下的风格。
在 TDD 的自下而上风格中,开发者从编写测试和实现系统中最小和最基本组件的功能开始。这种方法强调构建单个单元或类,在将它们集成到更高层次的组件之前,对它们进行彻底测试。它为系统的底层部分提供了强大的验证,并有助于创建一个稳健的基础。
然而,如果未仔细考虑整体图景和单元之间的交互,这种风格可能会导致组件集成的挑战。
回到在线披萨店,我们可以将整个页面分解为以下任务:
-
实现一个带有披萨名称的单一
PizzaItem
组件。 -
向
PizzaItem
添加价格。 -
向
PizzaItem
添加一个按钮。 -
实现一个
PizzaList
组件(例如,一行显示三个项目)。 -
实现一个简单的带有按钮的
ShoppingCart
组件。 -
支持向
ShoppingCart
组件添加/删除项目的能力。 -
添加计算总披萨数量的计算。
-
使用这些独立的独立组件实现整个应用程序。
如您所见,每个任务都专注于一次一个组件。组件从简单的最小功能开始;然后,我们逐步向它们添加更多功能,包括测试用例以覆盖所需的功能,以及其他合理的边缘情况。
因此,我们从单个PizzaItem
组件(其中只包含名称)开始,然后给它一个价格,然后是一个按钮。一旦构建了单个项目,我们就开始实现PizzaList
,然后是ShoppingCart
。然后,一旦PizzaList
和ShoppingCart
完成,我们就将它们集成起来,并从用户的角度测试几个整体功能。
例如,如以下截图所示,我们可能从一个PizzaItem
组件开始,逐步实现组件,而不必担心应用程序中的其他任何事情。一旦我们有了PizzaItem
的完整实现(包括图片、名称、价格和ShoppingCart
):
图 7.4:自底向上的风格
TDD 的自顶向下风格采取相反的方法,从系统的高层架构和整体功能开始。开发者首先编写测试并为主要组件实现功能,然后逐步向下到更详细和具体的功能。
这种风格有助于确保系统的主要目标和工作流程在早期就确立,为开发过程提供清晰的路线图。它可以促进更好的集成和与整体目标的对齐,但有时可能需要使用临时的“占位符”或“模拟”来在开发之前模拟低级组件。例如,我们可以将功能分解为以下列表:
-
实现页面标题
-
实现一个包含披萨名称的菜单列表
-
实现一个只有按钮的
ShoppingCart
组件(默认禁用) -
当按钮被点击时,将项目添加到
ShoppingCart
中,之后ShoppingCart
按钮被启用 -
为
ShoppingCart
组件添加价格 -
为
ShoppingCart
添加所选项目的总数 -
从
ShoppingCart
中移除项目,总数量相应变化
对于自顶向下的方法,我们没有一个清晰的各个单元的图片,只有一个整体工作的应用程序——因此我们是从外部看到应用程序,而不了解实现细节。
例如,一开始并没有PizzaItem
组件,当我们发现组件太大时,较小的组件会逐渐从较大的组件中提取出来。这意味着我们始终会有功能性的软件在运行(即使我们一开始没有小而精良的组件),这样我们就可以在任何时候停止,而不会破坏功能。
使用自顶向下的方法可能出现的分解情况如下所示。我们从一个空列表开始,然后是一个包含披萨名称的列表,接着是一个允许用户添加项目并进入下一步的购物车:
图 7.5:自顶向下的风格
(前一个图中的文本细节已被最小化,并且与您理解它不直接相关。如果您想查看文本细节,请参阅免费可下载的电子书。)
这两种风格都为现代软件开发中的丰富方法论做出了贡献,并且它们都不是绝对“正确”或“错误”的。相反,它们提供了不同的视角和工具,开发者可以根据自己的特定需求和偏好进行选择。
在本章的后续部分,我们将使用自顶向下的风格,因为它迫使我们从用户的角度思考。我们将在介绍其他设计模式时,在下一章中更详细地探讨自底向上的方法。
实现应用程序标题
让我们从实现披萨店应用程序开始。如果您已经克隆了技术要求部分中提到的仓库,只需转到react-anti-patterns-code/src/ch7
文件夹。
由于我们正在应用 TDD,这里的第一件事是编写一个失败的测试。在上一节中,我们提到了我们想要测试的内容:实现页面标题。
那么,让我们创建一个名为App.test.tsx
的文件,并包含以下代码:
import React from 'react';
import {render, screen} from '@testing-library/react';
describe('Code Oven Application', () => {
it('renders application heading', () => {
render(<PizzaShopApp />);
const heading = screen.getByText('The Code Oven');
expect(heading).toBeInTheDocument();
});
});
我们正在为尚未创建的PizzaShopApp
React 组件编写测试。使用 React Testing Library,我们将渲染此组件并验证它是否包含一个标记为expect(heading).toBeInTheDocument();
的断言,这确认了标题已成功渲染。
现在,让我们在终端窗口中运行以下命令来运行测试:
npm run test src/ch7
终端将显示一个错误,说ReferenceError: PizzaShopApp is not defined
,如下所示:
图 7.6:失败的测试
现在,我们处于红-绿-重构循环的红阶段,因此我们需要使用尽可能简单的代码使代码通过。一个静态组件返回PizzaShopApp
,在测试文件中返回仅包含字符串的代码:
import React from 'react';
import {render, screen} from '@testing-library/react';
function PizzaShopApp() {
return <>The Code Oven</>;
}
describe("Code Oven Application", () => {
it("renders application heading", () => {
render(<PizzaShopApp />);
const heading = screen.getByText("The Code Oven");
expect(heading).toBeInTheDocument();
});
});
当我们重新运行测试时,它通过了,因为PizzaShopApp
确实做了测试所期望的事情——它显示了The Code Oven。现在,我们处于红-绿-重构循环的绿阶段。接下来,我们可以探讨改进的机会。
我们不希望在测试文件中编写所有代码——相反,我们可以将PizzaShopApp
放入一个名为App.tsx
的单独文件中。现在,实现部分位于自己的文件中,这使我们能够分别更改测试和组件:
import React from "react";
export function PizzaShopApp() {
return <>The Code Oven</>;
}
太棒了!这样一来,我们就完成了一个完整的红-绿-重构循环。现在我们可以从任务列表中移除这个任务,继续下一个任务。
注意
我们知道 TDD 是一个迭代过程,因此代码在开始时不必完美;我们总有改进代码的机会,因为我们有良好的测试来保护我们。
实现菜单列表
即使是一个只显示比萨名称的基本菜单列表,对于想要浏览和决定吃什么顾客来说也是有价值的。虽然《代码烤箱》还没有设置好在线订购,但它是一个有用的起点。
看看我们列表上的第二个任务,我们可以这样编写我们的第二个测试:
it("renders menu list", () => {
render(<PizzaShopApp />);
const menuList = screen.getByRole('list');
const menuItems = within(menuList).getAllByRole('listitem');
expect(menuItems.length).toEqual(8);
});
这个测试首先渲染组件。然后,它从渲染的组件中识别带有list
角色的 HTML 元素。使用within
函数,它将搜索范围缩小到仅限于该列表,并定位其中所有带有listitem
标签的项目。最后,它断言这些项目的数量应该等于8
(比萨店提供的项目数量)。本质上,我们希望在页面上显示八个列表项。
现在,测试失败了。为了使测试容易通过,我们可以在页面上硬编码八个空列表项:
import React from "react";
export function PizzaShopApp() {
return <>
<h1>The Code Oven</h1>
<ol>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
</ol>
</>;
}
它看起来不太好,但测试仍然通过了。这一点很重要要记住——在 TDD(测试驱动开发)过程中,我们总是希望首先让测试通过,然后再寻找改进的机会。这种思维方式的优点迫使我们考虑交付和生产准备;我们应该能够在任何时刻停止编码并将应用程序发布到生产环境——即使代码还不是完美的。
现在,重新运行测试以查看它是否通过;如果通过了,我们就可以开始重构它。为了减少长硬编码的<li>
(HTML 中的列表项标签),我们可以使用一个包含八个元素的数组,并使用map
在有序列表(<ol>
)中动态生成<li>
。
import React from "react";
export function PizzaShopApp() {
return <>
<h1>The Code Oven</h1>
<ol>
{new Array(8).fill(0).map(x => <li></li>)}
</ol>
</>;
}
在这个列表中,一个包含八个元素(所有初始化为 0)的数组被映射,生成八个空列表项(<li>
)。这符合测试标准,即有一个包含八个项目的菜单列表,并且测试在新结构下通过。
这样,我们又完成了一个红-绿-重构循环。现在,我们可以验证比萨名称是否正确显示。让我们为第二个测试案例添加几行代码:
it("renders menu list", () => {
render(<PizzaShopApp />);
const menuList = screen.getByRole('list');
const menuItems = within(menuList).getAllByRole('listitem');
expect(menuItems.length).toEqual(8);
expect(within(menuItems[0]).getByText('Margherita Pizza')).
toBeInTheDocument();
expect(within(menuItems[1]).getByText('Pepperoni Pizza')).
toBeInTheDocument();
expect(within(menuItems[2]).getByText('Veggie Supreme Pizza')).
toBeInTheDocument();
//...
});
为了使所有新添加的行通过,我们需要在PizzaShopApp
中定义一个比萨名称列表,然后使用pizzas
数组中的map
函数将这些名称映射到列表项中:
const pizzas = [
"Margherita Pizza",
"Pepperoni Pizza",
"Veggie Supreme Pizza",
"Chicken BBQ Pizza",
"Spicy Meat Feast Pizza",
"Pasta Primavera",
"Caesar Salad",
"Chocolate Lava Cake"
];
export function PizzaShopApp() {
return <>
<h1>The Code Oven</h1>
<ol>
{pizzas.map((x) => <li>{x}</li>)}
</ol>
</>;
}
测试现在成功通过了。虽然代码可能过于简化,但通过测试让我们有信心进行进一步的更改,而不用担心任何意外的功能损坏。
有一个菜单列表是很好的,但《代码烤箱》的目的在于帮助用户在线订购。所以,让我们看看我们如何创建一个购物车。
创建购物车
为了开发ShoppingCart
组件,我们将从一个简单的测试开始,该测试期望在页面上显示一个空的容器。在这个容器内部,应该有一个按钮供用户下单。
要做到这一点,我们将从一个简单的测试开始,该测试仅检查容器和按钮是否存在:
it('renders a shopping cart', () => {
render(<PizzaShopApp />);
const shoppingCartContainer = screen.getByTestId('shopping-cart');
const placeOrderButton = within(shoppingCartContainer).
getByRole('button');
expect(placeOrderButton).toBeInTheDocument();
})
Jest 测试渲染PizzaShopApp
组件,然后通过data-testid
定位购物车容器。在这个容器中,它通过其角色查找按钮元素。测试通过使用toBeInTheDocument()
匹配器来验证这个按钮是否存在于渲染输出中。
为了使这个测试通过,我们可以在一个带有data-testid
的空div
容器中添加一个空按钮:
export function PizzaShopApp() {
return <>
<h1>The Code Oven</h1>
<ol>
{pizzas.map((x) => <li>{x}</li>)}
</ol>
<div data-testid="shopping-cart">
<button></button>
</div>
</>;
}
随着测试通过,我们可以添加更多细节到测试中,检查按钮是否默认禁用。
注意
观察我们如何在测试代码和实际实现之间来回切换,尤其是在开始时。随着你对红-绿-重构循环越来越熟悉,你将能够编写越来越复杂的测试,并调整你的代码以通过它们。最初的关键目标是建立这个快速反馈循环。
我们现在应该给测试添加更多细节,以检查按钮文本和默认禁用状态——我们想要确保用户不能与按钮交互:
it('renders a shopping cart', () => {
render(<PizzaShopApp />);
const shoppingCartContainer = screen.getByTestId('shopping-cart');
const placeOrderButton = within(shoppingCartContainer).
getByRole('button');
expect(placeOrderButton).toBeInTheDocument();
expect(placeOrderButton).toHaveTextContent('Place My Order');
expect(placeOrderButton).toBeDisabled();
})
在添加了新的断言后,测试再次失败,等待我们添加更多实现细节。通过添加文本和disabled
状态,使测试通过是直接的:
export function PizzaShopApp() {
return <>
<h1>The Code Oven</h1>
<ol>
{pizzas.map((x) => <li>{x}</li>)}
</ol>
<div data-testid="shopping-cart">
<button disabled>Place My Order</button>
</div>
</>;
}
测试再次全部通过,所以这是另一个完成的任务(注意如何维护任务列表可以帮助我们集中精力,并逐步塑造我们的应用程序代码)。
接下来,我们将查看下一个任务——将菜单中的项目添加到购物车中。
添加购物车项目
一旦我们有ShoppingCart
组件的基本结构,我们需要添加更多断言来验证它是否正常工作。我们将从添加一个项目到购物车开始,可以使用以下代码完成:
it('adds menu item to shopping cart', () => {
render(<PizzaShopApp />);
const menuList = screen.getByRole('list');
const menuItems = within(menuList).getAllByRole('listitem');
const addButton = within(menuItems[0]).getByRole('button');
userEvent.click(addButton);
const shoppingCartContainer = screen.getByTestId('shopping-cart');
const placeOrderButton = within(shoppingCartContainer).
getByRole('button');
expect(within(shoppingCartContainer).getByText('Margherita Pizza')).
toBeInTheDocument();
expect(placeOrderButton).toBeEnabled();
})
这个测试渲染了PizzaShopApp
组件,获取菜单列表,并获取其中的所有列表项。然后,它模拟用户点击第一个菜单项的添加按钮。接下来,它定位购物车容器并检查两件事:
-
添加的项目,玛格丽塔披萨,出现在购物车中
-
下订单按钮已启用
让我们先给菜单项添加按钮,然后添加一个状态来管理用户选择,并根据选择启用下订单按钮:
export function PizzaShopApp() {
const [cartItems, setCartItems] = useState<string[]>([]);
const addItem = (item: string) => {
setCartItems([...cartItems, item]);
}
return <>
<h1>The Code Oven</h1>
<ol>
{pizzas.map((x) => <li>
{x}
<button onClick={() => addItem(x)}>Add</button>
</li>)}
</ol>
<div data-testid="shopping-cart">
<ol>
{cartItems.map(x => <li>{x}</li>)}
</ol>
<button disabled=>Place My Order</button>
</div>
</>;
}
PizzaShopApp
函数组件使用 React 的useState
钩子来管理cartItems
数组。它定义了一个函数addItem
,用于向购物车添加项目。组件渲染一个披萨列表,每个披萨都有一个addItem
函数,将相应的披萨添加到cartItems
数组中。
购物车以列表形式显示cartItems
中的商品。cartItems
数组。具体来说,当购物车为空时,按钮被禁用 – (cartItems.length === 0)
。
实现看起来很棒,但如果我们运行测试,会发生一些奇怪的事情。测试在终端中失败,并显示以下错误消息:TestingLibraryElementError: Found multiple elements with the role "list"
。这是因为现在屏幕上有两个列表(一个在菜单中,另一个在购物车中),React Testing Library 对此感到困惑,不知道应该寻找哪个列表的data-testid
来修改测试。
首先,让我们更改我们的PizzaShopApp
组件,并将第一个<ol>
(有序列表标签)移动到一个带有data-testid="menu-list"
属性的div
元素中:
<div data-testid="menu-list">
<ol>
{pizzas.map((x) => <li>
{x}
<button onClick={() => addItem(x)}>Add</button>
</li>)}
</ol>
</div>
然后,我们必须修改测试,使其看起来如下(注意,我们明确要求 React Testing Library 在menu-list
内部搜索所有列表项):
it('adds menu item to shopping cart', () => {
render(<PizzaShopApp />);
const menuItems = within(screen.getByTestId('menu-list')).
getAllByRole('listitem');
const addButton = within(menuItems[0]).getByRole('button');
userEvent.click(addButton);
const shoppingCartContainer = screen.getByTestId('shopping-cart');
const placeOrderButton = within(shoppingCartContainer).
getByRole('button');
expect(within(shoppingCartContainer).getByText('Margherita Pizza')).
toBeInTheDocument();
expect(placeOrderButton).toBeEnabled();
})
当我们再次运行测试时,它再次失败,并显示以下消息:TestingLibraryElementError: Unable to find an element with the text: Margherita Pizza
。这可能是因为文本被多个元素分割。在这种情况下,你可以为你的文本匹配器提供一个函数,使你的匹配器更加灵活。
预期的cartItems
状态。但是当 React 检测到状态变化并重新渲染时,测试并没有等待这一发生。换句话说,测试看到更新的cartItems
还为时过早。我们需要给 React 一点时间来消化变化并重新渲染。我们可以将测试用例标记为async
并等待userEvent.click
使状态发生变化:
it('adds menu item to shopping cart', async () => {
render(<PizzaShopApp />);
const menuItems = within(screen.getByTestId('menu-list')).getAllByRole('listitem');
const addButton = within(menuItems[0]).getByRole('button');
await userEvent.click(addButton);
const shoppingCartContainer = screen.getByTestId('shopping-cart');
const placeOrderButton = within(shoppingCartContainer)
.getByRole('button');
expect(within(shoppingCartContainer).getByText('Margherita Pizza')).
toBeInTheDocument();
expect(placeOrderButton).toBeEnabled();
})
在这个代码片段中,async
和await
的使用确保了异步操作在测试的下一步之前完成。测试函数本身被标记为async
,使其返回一个 Promise,Jest 将在测试完成之前等待这个 Promise。
这里的await userEvent.click(addButton);
行尤其重要。userEvent.click
模拟了真实用户点击按钮,可能会触发 React 组件中的状态更新或效果。使用await
确保在继续测试的后续行之前,所有相关的更新和效果都已完全完成。
通过确保userEvent.click
已被完全处理,测试将安全地继续查询并断言更新后的 DOM 或状态。这对于防止假阴性至关重要,在这种情况下,测试可能失败并不是因为代码错误,而是因为测试在所有更新发生之前检查了 DOM。
由于所有测试都通过了,现在是时候考虑其他改进的机会了。
代码重构
我们现在的代码并不难理解,但还有一些改进的空间。让我们快速看一下我们已经取得的成果:
export function PizzaShopApp() {
const [cartItems, setCartItems] = useState<string[]>([]);
const addItem = (item: string) => {
setCartItems([...cartItems, item]);
}
return <>
<h1>The Code Oven</h1>
<div data-testid="menu-list">
<ol>
{pizzas.map((x) => <li>
{x}
<button onClick={() => addItem(x)}>Add</button>
</li>)}
</ol>
</div>
<div data-testid="shopping-cart">
<ol>
{cartItems.map(x => <li>{x}</li>)}
</ol>
<button disabled={cartItems.length === 0}>Place My Order
</button>
</div>
</>;
}
现在,让我们做一些更改。首先,我们可以将 x
改为 item
,使其更具意义。此外,现在终端中有一个警告,说 Warning: Each child in a list should have a unique "key" prop
—— 因为 React 期望为它渲染的每个项目都有一个唯一的键,所以我们需要为每个 <li>
元素提供一个键。目前,我们可以使用项目(披萨名称)作为键来解决这个问题:
export function PizzaShopApp() {
const [cartItems, setCartItems] = useState<string[]>([]);
const addItem = (item: string) => {
setCartItems([...cartItems, item]);
}
return <>
<h1>The Code Oven</h1>
<div data-testid="menu-list">
<ol>
{pizzas.map((item) => <li key={item}>
{item}
<button onClick={() => addItem(item)}>Add</button>
</li>)}
</ol>
</div>
<div data-testid="shopping-cart">
<ol>
{cartItems.map(item => <li key={item}>{item}</li>)}
</ol>
<button disabled={cartItems.length === 0}>Place My Order
</button>
</div>
</>;
}
作为另一个更改,菜单项列表看起来相当独立,不依赖于它所在上下文之外的内容,因此我们可以在这里提取一个新的组件来封装这个逻辑:
const MenuList = ({
onAddMenuItem,
}: {
onAddMenuItem: (item: string) => void;
}) => {
return (
<div data-testid="menu-list">
<ol>
{pizzas.map((item) => (
<li key={item}>
{item}
<button onClick={() => onAddMenuItem(item)}>Add</button>
</li>
))}
</ol>
</div>
);
};
MenuList
组件接受一个名为 onAddMenuItem
的单一属性,这是一个接受代表菜单项的字符串参数的函数。该组件渲染一个披萨列表,这大概是一个字符串数组。对于每一款披萨,它创建一个列表项,并使用相应的披萨名称作为参数调用 onAddMenuItem
函数。该组件使用 data-testid="menu-list"
属性,以便在测试期间更容易查询此部分。总的来说,这是一个用于显示披萨列表并通过提供的回调处理菜单项添加的展示性组件。
同样,我们可以提取一个新的组件用于购物车,如下所示:
const ShoppingCart = ({ cartItems }: { cartItems: string[] }) => {
return (
<div data-testid="shopping-cart">
<ol>
{cartItems.map((item) => (
<li key={item}>{item}</li>
))}
</ol>
<button disabled={cartItems.length === 0}>Place My Order
</button>
</div>
);
};
ShoppingCart
组件接受一个名为 cartItems
的数组属性。这个数组包含已添加到购物车的项目名称。该组件渲染一个有序列表 (<ol>
),其中每个列表项 (<li>
) 对应购物车中的一个项目。它使用 data-testid="shopping-cart"
属性,以便在测试中更容易识别此组件。此外,一个空的 cartItems
数组意味着购物车中没有项目。总的来说,这个组件旨在显示购物车中的项目,并提供一个下单选项。
在这些提取之后,我们可以在主组件 PizzaShopApp
中使用这些组件:
export function PizzaShopApp() {
const [cartItems, setCartItems] = useState<string[]>([]);
const addItem = (item: string) => {
setCartItems([...cartItems, item]);
};
return (
<>
<h1>The Code Oven</h1>
<MenuList onAddMenuItem={addItem} />
<ShoppingCart cartItems={cartItems} />
</>
);
}
在这些相对较大的变更期间,我们的测试始终保持在绿色状态——这意味着没有函数被破坏。我们还没有实施我们分解的所有任务,但我相信你已经理解了红-绿-重构循环。你可以使用剩余的任务作为练习,确保你总是先编写测试,只编写最少的代码,并在测试通过后寻找改进。
你可能一开始会觉得这种编码方法具有挑战性,因为它要求你抵制立即深入实现的诱惑。相反,采取逐步的方法,享受这个过程。很快你就会发现,逐步前进的力量,以及保持稳定节奏如何提高你的专注力和生产力。
摘要
在本章关于 TDD 的讨论中,我们探讨了 TDD 的各种形式,强调了通过任务分解将复杂问题分解为可管理任务的重要性。我们深入探讨了两种关键方法——自顶向下和自底向上,每种方法都有其独特的优点和应用场景。为了说明这些概念,我们使用了构建比萨店应用程序的实际示例。
这个动手实践示例帮助我们巩固了所讨论的理论和方法,提供了对如何在不同场景下有效应用测试驱动开发(TDD)的全面理解。
在接下来的章节中,我们将深入探讨 React 应用程序中数据管理的复杂世界。具体来说,我们将探索各种常见的设计模式,这些模式被广泛采用以实现高效的数据访问和处理。
第三部分:揭示业务逻辑和设计模式
本部分探讨业务逻辑和设计模式,这在应对状态管理中的常见挑战和遵循原则(如单一职责原则)以保持代码库的整洁和高效方面至关重要。
本部分包含以下章节:
-
第八章,探索 React 中的数据管理
-
第九章,在 React 中应用设计原则
-
第十章,深入探索组合模式
第八章:探索 React 中的数据管理
在现代前端开发中,我们处理状态和数据访问的方式可能会使应用程序成功或失败。无论你是独立开发者还是大型团队的一员,理解最佳实践和常见陷阱至关重要。本章旨在提高你在 React 应用程序中状态管理和数据处理方面的熟练度,重点关注可扩展和可维护的方法。
状态管理具有挑战性,尤其是在 React 中,开发者日常工作中会遇到许多问题。其中一个挑战是你在代码库中可以定位业务逻辑的位置。当业务逻辑渗透到 UI 组件中时,它会损害它们的可重用性。许多领域对象以及计算逻辑要么故意要么无意地散布在 UI 组件中,这可能导致难以追踪、调试和测试的复杂逻辑。它也可能导致性能问题,从而对用户体验产生负面影响。
另一个问题就是属性钻取,将属性从父组件传递到深层嵌套的子组件变得既繁琐又容易出错。这通常会导致代码重复,因为相同的代码片段会出现在多个文件中,使得任何未来的更新都变得复杂。
最后,在 React 应用程序中共享状态也带来了一系列挑战。存在各种机制来实现这一点,但选择最有效的方式在组件之间共享状态逻辑可能会相当令人困惑。我们将深入探讨共享状态的问题,并看看 React 的 Context API 如何帮助解决这个问题。
本章将涵盖以下主题:
-
理解业务逻辑泄露
-
介绍反腐败层
-
探索属性钻取问题
-
使用 Context API 解决属性钻取问题
技术要求
已创建一个 GitHub 仓库来托管本书中讨论的所有代码。对于本章,你可以在github.com/PacktPublishing/React-Anti-Patterns/tree/main/code/src/ch8
找到推荐的架构。
理解业务逻辑泄露
业务逻辑指的是对业务应用程序操作至关重要的规则、计算和流程。当这种业务逻辑“泄露”到不属于它的组件或应用程序区域时,这被称为业务****逻辑泄露。
这个问题在各个项目中经常出现,部分原因是因为在 React 中处理业务逻辑没有广泛达成共识的方法。框架的灵活性允许你直接在组件、Hooks 或辅助函数中实现这种逻辑;因此,开发者往往会将逻辑直接嵌入到需要的组件中——这就是泄露的原因。
这种泄漏可能导致许多问题。业务逻辑泄漏可能导致紧密耦合的组件变得难以测试、维护或重用。当业务逻辑散布在应用程序的不同部分时,会导致代码重复和不一致,使应用程序更容易出错且更难调试。此外,这种分散使得对业务规则的任何未来修改都变得复杂,因为可能需要在多个地方进行更改,从而增加了引入新问题的风险。
有多种指标表明业务逻辑正在渗入你的代码,但最普遍的迹象是在视图中或 UI 组件中直接包含数据转换。在本节中,我们将深入探讨这个问题;在下一节中,我们将探讨缓解这个问题的解决方案。
UserProfile
函数:
function UserProfile({ id }: { id: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
async function fetchUser() {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
setUser({
id: data.user_identification,
name: data.user_full_name,
isPremium: data.is_premium_user,
subscription: data.subscription_details.level,
expire: data.subscription_details.expiry,
});
}
fetchUser();
}, [id]);
if (!user) {
return <div>Loading...</div>;
}
return (
<div data-testid="user-profile">
<h1>{user.name}</h1>
</div>
);
}
UserProfile
函数组件从后端 API 获取用户数据。useEffect
钩子确保在 ID 发生变化时进行数据获取。获取的数据随后存储在本地状态变量 user
中,组件在数据可用时显示用户姓名。如果数据仍在加载中,则显示 加载中… 消息。
在 useEffect
块中,存储在 user
状态变量中的获取数据转换。具体来说,从获取的 JSON 响应中映射的键被映射到更适合前端应用程序的新名称——例如,data.user_identification
变为 id
,data.user_full_name
变为 name
。这种转换使得在 React 组件中更容易处理和阅读。
如你所想,这种类型的转换可以在应用程序的许多地方发生,并不仅限于 React 组件内部;有时,这种转换可以存在于 Hooks 和其他地方,如下面的图所示:
图 8.1:在视图中使用数据转换
后端服务可以以不同技术支持的各种格式提供数据——一些使用 RESTful API,而另一些则使用 GraphQL。从前端代码的角度来看,这些格式的具体细节被抽象化。例如,一个组件可能负责将 XML 数据(用橙色菱形表示)转换为内部类型 X(表示为蓝色方块)。同时,另一个组件可能与 GraphQL 端点交互,将接收到的数据(用红色圆圈表示)处理成相同的内部类型 X。
这种差异需要在代码的多个区域进行数据转换。当这种转换被重复时,更容易忽略更改,尤其是如果后端更改了其数据结构。
所有这些不同的转换都可以集中到一个单一的位置,在那里进行数据重塑,包括空字段检查、字段重命名和消除不必要的字段。这很自然地过渡到我们下一个主题:反腐败 层(ACL)。
引入 ACL
在软件开发中,ACL(访问控制列表)充当不同子系统之间的翻译者或调解者,这些子系统可能说着不同的“语言”。想象一下,你有两个系统,每个系统都有自己的规则、结构和复杂性。如果这些系统直接交互,它们可能会以未预料到的方式相互影响,导致领域逻辑中的所谓腐败。
在前端开发的背景下,尤其是在复杂的应用程序中,ACL 对于管理前端与各种后端或 API 之间的交互至关重要。前端开发者经常必须处理可能具有不一致或复杂的数据格式的多个服务。在前端实现 ACL 允许你创建一个统一的接口来与这些服务交互。
例如,如果你的前端应用程序需要与多个 RESTful API、GraphQL 服务和甚至 WebSocket 服务器通信,每个都可能有一套自己的规则、数据结构和复杂性。前端 ACL 将承担将这些不同的数据形式转换为前端应用程序理解格式的角色。这意味着你的 UI 组件不必担心每个服务的详细数据格式,这使得组件更容易开发、测试和维护。
ACL 还可以成为处理缓存、错误转换和其他横切关注点的战略位置。通过集中这些功能,你避免了在前端代码库中分散类似的逻辑,从而遵循不要重复自己(DRY)原则:
图 8.2:引入了用于数据转换的 ACL
如*图 8**.2 所示,所有数据转换现在都集中化,消除了在视图中进行此类操作的需求。你可能想知道如何在代码中实现这一点。
引入典型用法
要开始,让我们基于上一节中的示例,引入一个基本函数作为我们 ACL 的起点。我们应该确定外部数据格式以及我们消费的内容,然后定义函数作为转换器,然后再将它们放在一个公共位置。
首先,让我们定义从远程服务器接收的用户数据格式的类型。通过使用 TypeScript,我们获得了编译时检查的优势,从而确保在应用程序运行之前,任何数据格式的不一致性都会被标记出来:
type RemoteUser = {
user_identification: string;
user_full_name: string;
is_premium_user: boolean;
subscription_details: {
level: string;
expiry: string;
}
}
我们还将定义本地的User
类型——它包括我们在UserProfile
组件中使用的所有字段(id
、name
等):
type UserSubscription = "Basic" | "Standard" | "Premium" | "Enterprise";
type User = {
id: string;
name: string;
isPremium: boolean;
subscription: UserSubscription;
expire: string;
};
type
定义指定了将在 React 组件中使用的用户对象的结构。请注意,subscription
属性使用自定义类型 UserSubscription
,它可以取四个字符串值之一:expire
是一个字符串,表示用户的订阅何时将过期。
使用这种设置,我们可以在名为 transformer.ts
的文件中定义一个新的函数,称为 transformUser
。该文件简单地返回我们刚刚定义的 User
类型的映射对象:
import {RemoteUser, User, UserSubscription} from "./types";
export const transformUser = (remoteUser: RemoteUser): User => {
return {
id: remoteUser.user_identification,
name: remoteUser.user_full_name,
isPremium: remoteUser.is_premium_user,
subscription: remoteUser.subscription_details.level as
UserSubscription,
expire: remoteUser.subscription_details.expiry,
};
};
使用在 transformer.ts
中提取的新函数,我们的组件可以简化如下:
async function fetchUserData<T>(id: string) {
const response = await fetch(`/api/users/${id}`);
const rawData = await response.json();
return transformUser(rawData) as T;
}
function UserProfile({ id }: { id: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
async function fetchUser() {
const response = await fetchUserData<User>(id);
setUser(response);
}
fetchUser();
}, [id]);
if (!user) {
return <div>Loading…</div>;
}
return (
<div data-tested="user-profile">
<h1>{user.name}</h1>
</div>
);
}
UserProfile
组件依赖于 fetchUserData
辅助函数从 API 获取和处理用户数据。这种设计使 UserProfile
与对远程数据结构的任何了解隔离开。如果将来对 RemoteUser
类型进行了更改,UserProfile
仍然不受影响,因为所有调整都将局限于 transformer.ts
。
拥有一个专门用于管理远程数据并将其塑造成适合高级视图要求的函数是有利的。然而,当后端未能提供必要的数据时,会出现复杂性。在这种情况下,需要在这个层中添加额外的逻辑来建立回退或默认值。
使用回退或默认值
与数据转换相关的一个常见问题是 React 视图中过度使用防御性编程。虽然防御性编程通常是良好的实践,并在各种环境中很有用,但过度加载 React 组件过多的空值检查和回退可能会使代码杂乱无章,难以理解。
注意
防御性编程 是一种编写代码的方式,它预测可能出现的错误、故障或异常,并以优雅的方式处理它们。目标是使你的应用程序更具弹性和可维护性,通过最小化意外情况的影响。
例如,在 UserProfile
示例中,你可能会遇到远程服务中某些值为空的情况。而不是向最终用户显示 null
或 undefined
,你需要实现回退值。
让我们回顾一下我们之前提取的 transformUser
函数:
export const transformUser = (remoteUser: RemoteUser): User => {
return {
id: remoteUser.user_identification,
name: remoteUser.user_full_name,
isPremium: remoteUser.is_premium_user,
subscription: remoteUser.subscription_details.level as
UserSubscription,
expire: remoteUser.subscription_details.expiry,
};
};
如果 subscription_details
不存在,或者当 expiry
不是后端有效的日期格式时会发生什么?这些不匹配可能导致运行时异常,因此当远程数据格式不正确时,我们应该回退到一些默认值。
我们可以将回退逻辑放在组件中,在我们渲染它们之前。如果没有 ACL,我们可能会在 UserProfile
中遇到一些逻辑,如下所示:
function UserProfile({ user }: { user: User }) {
const fullName = user && user.name ? user.name : "Loading"…";
const subscriptionLevel =
user && user.subscription ? user.subscription": "Basic";
const subscriptionExpiry = user && user.expire ? user.expire":
"Never";
return (
<div>
<h1>{fullName}</h1>
<p>Subscription Level: {subscriptionLevel}</p>
<p>Subscription Expiry: {subscriptionExpiry}</p>
</div>
);
}
UserProfile
函数组件接受一个用户对象作为属性。它显示用户的全名、订阅级别和订阅过期日期。如果这些值中的任何一个缺失或为假,它将提供默认回退文本,例如 加载中…、基础 或 从未。
因此,逻辑开始渗透到UserProfile
组件中,增加了组件的长度和复杂性。
这种逻辑可以通过将其移动到transformUser
等函数中更好地管理,这样就可以对其进行彻底的测试:
export const transformUser = (remoteUser: RemoteUser): User => {
return {
id: remoteUser.user_identification ?? 'N/A',
name: remoteUser.user_full_name ?? 'Unknown User',
isPremium: remoteUser.is_premium_user ?? false,
subscription: (remoteUser.subscription_details?.level ?? 'Basic')
as UserSubscription,
expire: remoteUser.subscription_details?.expiry ?? 'Never',
};
};
transformUser
函数将远程用户数据结构中的字段映射到应用程序期望的user
数据结构,为每个字段提供默认值,以防它们缺失或为 null。例如,如果remoteUser.user_identification
为null
,则将使用N/A作为默认 ID。
注意,在这里,我们使用了?
,这允许您在不检查每一层嵌套的情况下访问深层嵌套的属性。如果subscription_details
或level
为 null 或 undefined,则结果subscription
也将为 undefined,并且不会抛出错误。我们还使用了??
进行回退——如果其左侧的值不是 null 或 undefined,则它将采用左侧的值;否则,它将采用右侧的值。
所有这些转换和回退逻辑现在都被封装在了一个共同的地方——ACL。任何对远程或本地数据形状的进一步更改都可以轻松地发生在这个层,我们不需要在整个代码库中查找不同的用法。
优秀——ACL 模式有效地将业务逻辑与视图隔离开来。然而,在 React 应用程序中管理数据时还有额外的挑战,例如在组件之间共享数据和避免属性钻取。在下一节中,我们将探讨如何使用 Context API 来解决这些问题。
探索钻进问题
属性钻取是在您必须通过不需要数据的多个组件层级传递数据,以便它能够到达需要它的更深层次的组件时出现的问题。这通常会使代码更难跟踪、理解和维护。
考虑一个标准的 React 场景,其中我们有一个通用的可搜索列表组件。该组件接受一个列表并显示每个项目,无论是书籍列表、菜单、票务还是您能想到的任何其他内容。除了显示列表外,该组件还包括一个搜索框,允许用户轻松过滤长列表:
图 8.3:可搜索列表组件
初看之下,实现似乎很简单,并不过于复杂:
import React, { ChangeEvent, useState } from "react";
export type Item = {
id: string;
name: string;
description: string;
};
const SearchableList = ({ items }: { items: Item[] }) => {
const [filteredItems, setFilteredItems] = useState<Item[]>(items);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setFilteredItems(
items.filter((item) => item.name.includes(e.target.value))
);
};
return (
<div>
<input type="text" onChange={handleChange} />
<ul>
{filteredItems.map((item, index) => (
<li key={index}>{item.name}</li>
))}
</ul>
</div>
);
};
export default SearchableList;
代码在 React 中定义了一个SearchableList
组件,该组件根据用户输入过滤并显示项目列表。它从一个完整的项目列表开始,并在输入框中的文本更改时更新过滤后的列表。
随着组件的演变和在更多场景中的应用,代码库变得越来越复杂,导致额外的布局更改和更多的代码行。如图 8.4*所示,在右侧,我们可以将可搜索列表输入分解为三个子组件,称为SearchInput
、List
和ListItem
:
图 8.4:将可搜索列表分解成更小的组件
在右侧,SearchInput
接受用户的输入(在顶部红色矩形中)。它是 List
组件的兄弟组件(在绿色矩形中),其中包含多个 ListItem
(在紫色矩形中)——每个 ListItem
代表一个项目(可能包含标题、描述或按钮)。
让我们详细看看这些提取的组件:
const ListItem = ({ item }: { item: Item }) => {
return (
<li>
<h2>{item.name}</h2>
<p>{item.description}</p>
</li>
);
};
ListItem
显示项目的名称和描述。对于任何需要的进一步细节,我们只需直接修改此文件即可。
然后,每个 ListItem
都被包裹在一个容器组件 List
中,如下所示:
const List = ({ items }: { items: Item[] }) => {
return (
<section data-testid="searchable-list">
<ul>
{items.map((item) => (
<ListItem item={item} />
))}
</ul>
<footer>Total items: {items.length}</footer>
</section>
);
};
这个 List
作为所有项目的容器。它还包括一个页脚来显示一些项目的摘要信息。
最后,我们有 SearchInput
组件,它负责收集用户输入并在用户输入时触发搜索:
const SearchInput = ({ onSearch }: { onSearch: (keyword: string) => void }) => {
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
onSearch(e.target.value);
};
return <input type="text" onChange={handleChange} />;
};
const SearchableList = ({ items }: { items: Item[] }) => {
const [filteredItems, setFilteredItems] = useState<Item[]>(items);
const onSearch = (keyword: string) => {
setFilteredItems(items.filter((item) => item.name.
includes(keyword)));
};
return (
<div>
<SearchInput onSearch={onSearch} />
<List items={filteredItems} />
</div>
);
};
注意,这个代码分解仅是一个内部结构变化——我们只做了这个变化,以便使每个部分易于阅读和理解。使用 SearchableList
的人根本不知道这样的变化——到目前为止,SearchableList
的属性还没有改变。
然而,消费者请求了一个新功能:他们希望跟踪用户交互以进行数据分析,特别是为了衡量项目的受欢迎程度和搜索功能的利用率。为了满足这一要求,引入了两个新的属性:一个用于捕获项目点击时的 onItemClicked
回调,以及一个用于监控搜索执行时的 onSearch
回调。
因此,我们需要修改我们的代码以满足这些要求,从 SearchableList
中使用的新类型开始:
type SearchableListProps = {
items: Item[];
onSearch: (keyword: string) => void;
onItemClicked: (item: Item) => void;
};
const SearchableList = ({
items,
onSearch,
onItemClicked,
}: SearchableListProps) => {
//...
}
要传递 onSearch
和 onItemClicked
,我们将对 ListItem
进行一些修改。我们需要更改属性列表(添加 onItemClicked
),然后在列表被点击时调用该函数:
const ListItem = ({
item,
onItemClicked,
}: {
item: Item;
onItemClicked: (item: Item) => void;
}) => {
return (
<li onClick={() => onItemClicked(item)}>
<h2>{item.name}</h2>
<p>{item.description}</p>
</li>
);
};
然而,由于 ListItem
没有直接暴露给外部世界,属性是从其父组件 List
传递过来的。因此,我们还需要更新 List
组件的属性列表,添加 onItemClicked
:
const List = ({
items,
onItemClicked,
}: {
items: Item[];
onItemClicked: (item: Item) => void;
}) => {
return (
<section data-testid="searchable-list">
<ul>
{items.map((item) => (
<ListItem item={item} onItemClicked={onItemClicked} />
))}
</ul>
<footer>Total items: {items.length}</footer>
</section>
);
};
List
组件必须接受 onItemClicked
作为属性,然后将 onItemClicked
属性传递给 ListItem
而不对其进行任何操作。这是一个代码异味,表明组件正在处理与其功能不直接相关的事情。
然后,onItemClick
属性从 List
组件的父组件 SearchableList
组件传递过来,如下所示:
const SearchableList = ({
items,
onSearch,
onItemClicked,
}: SearchableListProps) => {
const [filteredItems, setFilteredItems] = useState<Item[]>(items);
const handleSearch = (keyword: string) => {
setFilteredItems(items.filter((item) => item.name.
includes(keyword)));
};
return (
<div>
<SearchInput onSearch={handleSearch} />
<List items={filteredItems} onItemClicked={onItemClicked} />
</div>
);
};
观察我们如何将 onItemClicked
属性传递给 ListItem
组件。首先,它通过 List
组件传递,该组件直接将其传递给 ListItem
而不用于任何操作。这是一个经典的属性钻探示例。同样的事情也可能发生在 SearchInput
上。随着我们继续添加越来越多的属性并从外部向下钻探组件树,整个结构很快就会变得难以管理。
幸运的是,Context API 为 prop drilling 问题提供了一个优雅的解决方案,我们将在下一节中对其进行回顾(我们已经在第二章中介绍了使用 Context API 的基础知识;如果您想复习,可以回顾一下那一章)。
使用 Context API 解决 prop drilling
使用 Context API 来处理 prop drilling 背后的概念是为一个共同的父组件下的所有子组件创建一个共享容器。这消除了从父组件显式传递 props 到子组件的需求。子组件可以在必要时直接访问共享上下文。使用 Context API 的另一个优点是,当上下文中的数据发生变化时,它会触发组件的自动重新渲染。
回到我们的可搜索列表示例,它已经存在 prop drilling 问题,List
组件中的onItemClicked
是不必要的,因为List
没有使用这个 prop。这种场景只涉及一层和一个 prop。现在,设想一个我们需要在List
和ListItem
组件之间插入额外元素的情况;我们必须将onItemClicked
prop 传递到它被使用的地方。如果有多个 props 需要传递,复杂性会进一步增加。
第一步是定义一个具有适当类型的上下文:
import { createContext } from "react";
import { Item } from "./types";
type SearchableListContextType = {
onSearch: (keyword: string) => void;
onItemClicked: (item: Item) => void;
};
const noop = () => {};
const SearchableListContext = createContext<SearchableListContextType>({
onSearch: noop,
onItemClicked: noop,
});
export { SearchableListContext };
这段代码定义了一个名为SearchableListContext
的 React 上下文。它指定了上下文将持有的onSearch
和onItemClicked
函数的类型。它还默认使用无操作(noop
)函数初始化这些函数。
现在,我们可以将上下文用作可搜索列表的包装器,为所有子组件提供上下文:
const SearchableList = ({
items,
onSearch,
onItemClicked,
}: SearchableListProps) => {
const [filteredItems, setFilteredItems] = useState<Item[]>(items);
const handleSearch = (keyword: string) => {
setFilteredItems(items.filter((item) => item.name.
includes(keyword)));
};
return (
<SearchableListContext.Provider value={{ onSearch, onItemClicked }}>
<SearchInput onSearch={handleSearch} />
<List items={filteredItems} />
</SearchableListContext.Provider>
);
};
SearchableList
组件将其子组件SearchInput
和List
包裹在SearchableListContext.Provider
内部。这允许这些子组件无需显式作为 props 传递,就可以从上下文中访问onSearch
和onItemClicked
函数。handleSearch
函数根据搜索关键字过滤项目。
这意味着我们的List
组件可以恢复到我们在引入onItemClicked
之前的简单版本:
const List = ({ items }: { items: Item[] }) => {
return (
<section data-testid="searchable-list">
<ul>
{items.map((item) => (
<ListItem item={item} />
))}
</ul>
<footer>Total items: {items.length}</footer>
</section>
);
};
对于ListItem
,我们可以直接从上下文中访问onItemClicked
:
const ListItem = ({ item }: { item: Item }) => {
const { onItemClicked } = useContext(SearchableListContext);
return (
<li onClick={() => onItemClicked(item)}>
<h2>{item.name}</h2>
<p>{item.description}</p>
</li>
);
};
ListItem
组件现在使用useContext
从SearchableListContext
访问onItemClicked
函数。当点击列表项时,onItemClicked
会以被点击的项作为参数被调用。
同样,对于SearchInput
组件,没有必要从SearchableList
传递额外的 props。相反,我们可以直接从上下文中获取我们需要的:
const SearchInput = ({ onSearch }: { onSearch: (keyword: string) =>
void }) => {
const { onSearch: providedOnSearch } =
useContext(SearchableListContext);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
onSearch(e.target.value);
providedOnSearch(e.target.value);
};
return <input type="text" onChange={handleChange} />;
};
SearchInput
组件使用useContext
Hook 从SearchableListContext
访问onSearch
函数。当输入改变时,它调用本地的onSearch
函数和上下文中的函数,有效地合并了外部和内部行为。
如所示,Context API 导致结构更加清晰。它允许你在不担心不断向下传递 props 的情况下,对子组件进行额外的结构调整。这简化了组件接口,使其更容易阅读和理解。
摘要
在本章中,我们深入探讨了在 React 开发中经常遇到的一些最紧迫的挑战,例如业务逻辑的泄漏、与 prop 钻取相关的复杂性以及管理共享状态困难。为了应对这些问题,我们引入了如 ACL 和 Context API 等强大的解决方案。这些策略旨在简化你的代码,使其在长期项目中更具可维护性和有效性。
接下来,我们将深入探讨你可用于进一步磨练编码技能的常见 React 设计模式。敬请期待。
第九章:在 React 中应用设计原则
设计原则就像指导软件开发的基本规则,确保代码在长时间内保持可维护性、可扩展性和可读性。在技术不断变化的领域中,坚持这些原则可能是项目长期成功与陷入“代码地狱”之间的区别,在“代码地狱”中,更改变得越来越困难,错误也变得频繁。
对于 React 应用程序,由于库的声明性特性和基于组件的架构,设计原则的重要性日益凸显。React 使开发者能够从称为组件的小型、独立的代码片段构建复杂的 UI。虽然这种模块化方法是 React 最强大的功能之一,但如果忽视设计原则,也可能导致代码库变得混乱且难以管理。
在一个典型的 React 项目中,组件通常共享状态和行为,相互嵌套,并在应用程序的不同部分中重用。如果不遵循设计原则,你可能会发现自己陷入依赖关系的网中,这使得更改或甚至理解代码变得困难。例如,忽视单一职责原则可能导致难以测试和重构的组件,而忽视接口隔离原则可能导致你的组件更难以重用,并且与特定用例耦合得更紧密。
此外,随着 React 的不断发展,新特性如 Hooks 和并发模式,以设计原则为中心的方法确保你可以适应这些变化,而无需进行重大的重写。这让你可以专注于构建功能、修复错误和交付价值,而不是与技术债务作斗争。
在 React 开发中坚持设计原则不仅是一种最佳实践,也是一种必要性。它作为一种主动措施来对抗复杂性,使你的 React 代码更容易阅读、测试和维护。
在本章中,我们首先回顾了 SRP(单一职责原则),这是一个经常作为干净、可维护代码基石的核心概念。从简单的字符串转换函数的朴素起点,我们将探讨这一原则如何扩展到渲染属性等复杂性的复杂性,从而丰富你的 React 组件的结构和可读性。
从这里过渡,我们介绍了依赖倒置原则(DIP),这是一种改变组件设计的革命性方法。本节强调,关注接口——而不是实现的细节——是构建可重用且易于理解的组件的途径。
本章的结尾,我们深入探讨了命令查询责任分离(CQRS),这是一个随着你的 React 应用程序规模和复杂性的增长而变得重要的模式。通过讨论 CQRS,你将发现分离应用程序的命令和查询责任的战略,从而使应用程序更易于管理和扩展。
本章旨在帮助你全面理解关键设计原则,这将为你掌握 React 的其余旅程打下坚实的基础。
在本章中,我们将涵盖以下主题:
-
重温单一职责原则
-
接受依赖倒置原则
-
理解 React 中的命令和查询职责分离
技术要求
已经创建了一个 GitHub 仓库来托管本书中讨论的所有代码。对于本章,你可以在github.com/PacktPublishing/React-Anti-Patterns/tree/main/code/src/ch9
找到推荐的架构。
重温单一职责原则
在第四章中,我们探讨了在设计和 React 组件的背景下 SRP(单一职责原则)。然而,这个原则更为普遍,是各种其他编程原则的基础。为了使这个想法变得生动,让我们通过一些实际案例来操作。
确定组件的核心职责对于遵循 SRP 至关重要。一旦你确定了组件的基本功能,就更容易重构和抽象出辅助功能。
SRP 作为一个高级指导原则,在直接应用于代码层面时具有优势。有无数种方法可以实现这个原则,但认识到何时应用它至关重要,尤其是在复杂性增加时。
我们将使用最常见的技术是渲染属性和组合。渲染属性指的是 React 中的一种技术,通过一个值为函数的 prop 在组件之间共享代码。具有渲染属性的组件接收一个返回 React 元素的函数,并调用它而不是实现自己的渲染逻辑。另一方面,React 中的组合是一种开发模式,其中你将组件构建成小的、可重用的部分,然后将它们组合在一起以创建更复杂的 UI。
在下一节中,我们将探讨两个具体的例子,展示我们如何分别使用渲染属性和组合来在实际中遵循这个原则。
探索渲染属性模式
让我们从简单的函数组件Title
开始:
const Title = () => <div>Title │ This is a title</div>
就目前而言,这个组件仅仅输出一个静态字符串。为了使其能够渲染不同的标题,我们引入了一个title
prop:
const Title = ({ title }: { title: string }) => <div>Title │ {title}</div>;
通过这个变化,组件变得更加灵活,将一个固定的前缀Title |
附加到我们传递的任何标题上。但如果我们想进一步操作标题,比如将其转换为大写字母怎么办?
通过使用高阶函数——以下代码片段中的transformer
参数——我们可以按如下方式修改我们的Title
组件:
const Title = ({
title,
transformer,
}: {
title: string;
transformer: (s: string) => string;
}) => <div>Title │ {transformer(title)}</div>;
注意
在许多编程语言(包括 JavaScript)中,数组的map
、filter
和reduce
、函数组合、柯里化和事件处理。高阶函数简化了代码结构,提高了可维护性,并允许使用更高级的编程技术。
太好了——我们的标题现在可以完全自定义。但让我们更进一步。如果我们想将标题放在h3
标签内而不是简单的div
标签中怎么办?React 已经为我们解决了这个问题——我们可以传递一个返回 JSX 元素的函数:
const Title = ({
title,
render,
}: {
title: string;
render: (s: string) => React.ReactNode;
}) => <div>{render(title)}</div>;
注意渲染属性的使用——我们将其作为一个函数调用,并传递title
。
要使用渲染属性,我们可以将一个匿名函数(在大括号内)传递给它,如下面的代码所示:
<Title
title="This is a title"
render={(s: string) => {
const formatted = s.toUpperCase();
return <h3>{formatted}</h3>;
}}
/>
在 React 中,这个高阶函数不一定必须命名为render
。我们同样可以使用children
属性进行更直观的设计,如下所示:
const Title = ({
title,
children,
}: {
title: string;
children: (s: string) => React.ReactNode;
}) => <div>{children(title)}</div>;
这允许我们像调用常规函数一样调用children
:
<Title title="This is a title">
{(s: string) => {
const formatted = s.toUpperCase();
return <h3>{formatted}</h3>;
}}
</Title>
Title
组件接收一个title
属性和一个子函数(后者被称为s
,将其转换为大写,并在h3
标签内渲染。Title
组件使用提供的标题属性调用这个子函数以进行自定义渲染。
在 React 中,渲染属性模式涉及将一个函数作为属性传递给组件。这个函数返回组件将作为其输出的一部分渲染的 JSX。这种模式通过让父组件控制子组件渲染逻辑的一部分,使得组件更加灵活和可重用。这对于在多个组件之间共享行为特别有用。
注意这里起主导作用的模式:抽象。最初,我们可能会将h2
或h3
视为标题的具体实例。然而,当我们稍微放大视角时,我们开始理解它们是更广泛抽象的一部分:一个 React 组件,或者更技术性地,ReactNode
。
这一认识使我们能够看到使用渲染属性或子组件作为高阶函数的实用性。它们不仅仅是特性;它们代表了我们所达到的抽象层次。现在,我们不再局限于特定的 HTML 标签,如h3
,我们可以传递任何 JSX 元素作为参数,从标题到完全样式化的组件。
通过我们新创建的通用组件,它使用渲染属性,我们实际上创建了一个可重用的框架。其美妙之处在于我们只需要编写一次这种通用代码。
渲染属性和组合是处理此类问题的优秀技术。它们允许你在不改变组件核心逻辑的情况下扩展或自定义组件的行为。这使你的组件保持整洁、模块化,并且易于测试,因为每个组件只做一件事,并且做得很好。我们已经看到了在Title
组件演变过程中渲染属性是如何工作的,现在让我们看看组合。
使用组合来应用 SRP(单一责任原则)
组合是我们全书多次使用的一个术语,其核心是 SRP。如果系统的每个部分都能很好地完成其工作,那么就可以将它们组合在一起。让我们检查一个具体的例子。
假设我们有一个包含便捷功能的Avatar
组件的设计系统:如果用户将name
属性传递给组件,那么当鼠标悬停在头像上时,一个包含名字内容的提示框会出现在头像底部:
图 9.1:带有 Tooltip 的 Avatar 组件
在内部,Avatar
组件利用另一个组件Tooltip
来实现这一功能:
import Tooltip from "@xui/tooltip";
type AvatarProps = {
name?: string;
url: string;
};
const Avatar = ({ name, url }: AvatarProps) => {
if (name) {
return (
<Tooltip content={name}>
<div className="rounded">
<img src={url} alt={name} />
</div>
</Tooltip>
);
}
return (
<div className="rounded">
<img src={url} alt="" />
</div>
);
};
Avatar
组件接受两个可选属性name
和url
,并使用提供的 URL 显示图像。如果提供了name
属性,它将图像包裹在一个Tooltip
组件中,当鼠标悬停时显示名字。div
标签使用了rounded
类,这将使头像以圆形呈现。
Avatar
组件的原始代码将其紧密耦合到Tooltip
功能上。随着用户对 tooltip 的定制选项需求增加,保持这种耦合变得具有挑战性。添加更多属性来处理 tooltip 定制可能会使Avatar
组件膨胀,并产生连锁反应:任何对Tooltip
的改变都可能需要修改Avatar
,这使得管理变得困难。
我们不是将Tooltip
强加到Avatar
中,而是可以将Avatar
简化,仅关注其主要功能——显示图像。这个简化版的Avatar
排除了 tooltip,减少了其包的大小,并使其更易于维护。以下是简化后的Avatar
组件的示例:
const Avatar = ({ name = "", url }: AvatarProps) => (
<div className="rounded">
<img src={url} alt={name} title={name} />
</div>
);
通过这样做,我们使得Avatar
和Tooltip
组件可以组合使用,这意味着它们可以独立工作。消费者可以选择将Tooltip
包裹在Avatar
中,如下面的代码片段所示:
import Avatar from "@xui/avatar";
import Tooltip from "@xui/tooltip";
const MyAvatar = (props) => (
<Tooltip
content="Juntao Qiu"
>
<Avatar
name="Juntao Qiu"
url="https://avatars.githubusercontent.com/u/122324"
/>
</Tooltip>
);
代码从"@xui"
库中导入Avatar
和Tooltip
组件。然后定义了一个MyAvatar
组件,用于显示"Juntao Qiu"
的头像(如果这里不需要名字,我们则不使用Tooltip
组件)。当你鼠标悬停在头像上时,一个包含Juntao Qiu名字的提示框会出现在顶部,背景为蓝色,字体颜色为白色。
这种方法的优点是双重的:
-
Avatar
组件保持精简,减少了其包的大小。 -
消费者有自由定制
Tooltip
或使用不同的 tooltip 库,而不会影响Avatar
。
简而言之,这种分离使得代码更加模块化,用户只需为他们实际使用的功能“付费”,即支付代码和复杂度。
在渲染属性和组合的示例中,我们强调了现代 Web 开发中 SRP(单一职责原则)的本质。SRP 主张构建只做一件事并且做得好的组件,使它们更易于维护、重用和灵活。
接下来,让我们转向讨论 DIP,这是补充这些设计原则的另一个关键视角。
接受依赖倒置原则
DIP 是构成 SOLID 的五个原则之一,SOLID 是一套旨在帮助开发者创建更易于维护、灵活和可扩展的软件的指南。具体来说,DIP 鼓励开发者依赖于抽象,而不是具体实现。
注意
SOLID 的五个原则是单一职责原则、开闭原则、里氏替换原则、接口隔离原则和依赖倒置原则。
DIP 解决了开发者在构建和维护大型系统时面临的几个挑战。其中一个问题是紧密耦合模块带来的刚性。当高级模块依赖于低级模块时,即使是低级代码的微小更改也可能产生广泛的影响,需要整个系统进行更改。
理解 DIP 的工作原理
在高级模块和低级模块的术语中,让我们考虑一个系统中的通知功能。在这里,我们希望以用户偏好的形式发送通知,无论是电子邮件、短信还是两者兼而有之:
class EmailNotification {
send(message: string, type: string) {
console.log(`Sending email with message: ${message}, type: ${type}`);
}
}
class Application {
private emailNotification: EmailNotification;
constructor(emailNotification: EmailNotification) {
this.emailNotification = emailNotification;
}
process() {
// perform some actions to response user interaction
this.emailNotification.send("Some events happened", "info");
}
}
const app = new Application(new EmailNotification());
app.process();
在代码中,EmailNotification
类有一个名为 send
的方法,该方法接受 message
和 type
作为参数。然后它打印一条日志,表明正在发送一个具有此 message
和 type
的电子邮件。另一方面,Application
类有一个 process
方法,该方法模拟某种用户交互。在这个方法内部,Application
使用 EmailNotification
的一个实例在调用 process
时发送电子邮件。
在这里需要注意的一个重要事项是,Application
与 EmailNotification
是紧密耦合的。这意味着,如果你想要改变通知的发送方式,比如使用短信而不是电子邮件,你将不得不直接修改 Application
类,从而违反了单一职责原则(SRP),使系统变得不那么灵活。
因此,为了解决这个问题,我们可以引入一个 Notification
接口,并让 EmailNotification
实现该接口。这意味着我们可以有多个接口的实现。此外,Application
不再依赖于 EmailNotification
类,而是依赖于 Notification
接口。因为我们依赖于接口,从 Application
的角度来看,传入的具体实现是哪一个并不重要,只要它实现了 Notification
接口——这意味着我们可以轻松地将其更改为 SMSNotification
类。以下是所有这些代码的示例:
interface Notification {
send(message: string, type: string): void;
}
class EmailNotification implements Notification {
send(message: string, type: string) {
console.log(`Sending email with message: ${message}, type:
${type}`);
}
}
class Application {
private notifier: Notification;
constructor(notifier: Notification) {
this.notifier = notifier;
}
process() {
// perform some actions to response user interaction
this.notifier.send("Some event happened", "info");
}
}
代码定义了一个具有 send
方法的 Notification
接口,然后由 EmailNotification
类实现。现在,Application
类使用任何遵循 Notification
接口的对象进行构造。在其 process
方法中,Application
使用此对象发送通知。这种设置将 Application
类与特定的通知机制解耦,使其更加灵活且易于更改或扩展。
例如,如果我们决定用 SMSNotification
替换 EmailNotification
,则 Application
类不需要任何修改;我们只需提供一个实现 Notification
接口的不同实例:
const app = new Application(new EmailNotification());
app.process();
// or
const app = new Application(new SMSNotification());
app.process();
好的,这就是 DIP(依赖倒置原则)的简要介绍。让我们看看另一个例子,以了解如何在 React 应用程序内部应用相同的原理。
在分析按钮中应用 DIP
现在,假设你有一个通用的按钮组件,它在应用程序的各个部分中都被使用。你希望在按钮被点击时发送分析事件,但具体如何发送这些事件应该从按钮组件本身抽象出来。
问题在于,通用按钮已经在许多产品中被广泛使用,并不是所有这些都需要分析功能。所以,如果你简单地更改共享的 Button
组件中的 onClick
处理程序,将会让许多无辜的用户感到烦恼。
让我们看看当前的 Button
实现如下:
const Button = ({ onClick: provided, name, ...rest }: ButtonProps) => {
const onClick = (e) => {
// emit an event to the analytic server
return provided(e);
};
return <button onClick={onClick} {...rest} />;
};
相反,我们可以定义一个新的组件,它将原始按钮包裹起来,并劫持点击处理程序以进行分析:
import { Button } from "@xui/button";
const FancyButton = ({
onClick: originalOnClick,
...rest
}: FancyButtonProps) => {
const onClick = (e) => {
//emit an event to the analytic server
console.log('sending analytics event to a remote server');
return originalOnClick(e);
};
return <Button onClick={onClick} {...rest} />;
};
新代码定义了一个 FancyButton
组件,它围绕一个基本的 Button
组件。当点击时,FancyButton
首先向远程服务器发送一个分析事件,然后继续执行传递给它的原始 onClick
函数。所有其他属性都直接传递到底层的 Button
组件。
这里的问题是,许多使用 Button
组件的实例可能包含类似的分析代码,导致代码库中存在重复的逻辑。这种冗余是不希望的,因为任何对分析逻辑的更改都需要在多个位置进行更新,增加了出错的风险。
因此,让我们考虑 DIP。我们将在原始的 Button
组件中做一些更改,但不是直接发送分析事件,而是首先提取一个接口,并让按钮依赖于这个接口(记住,这个接口可能有多个实现)。
就像之前的 Notification
示例一样,EmailNotification
是发送电子邮件的通知渠道之一。在这个按钮示例中,一种实现方式是发送一个事件,而对于完全不使用分析的产品,它们只是传递一个空的实现。
为了进行这个更改,我们需要定义一个新的接口类型,并且需要一个上下文来存放接口的实现:
import { createContext } from "react";
export interface InteractionMeasurement {
measure(name: string | undefined, timestamp?: number): void;
}
export default createContext<InteractionMeasurement | null>(null);
此代码创建了一个名为InteractionMeasurement
的 React 上下文,其中包含一个指定measure
方法的接口。此方法接受一个名称(可以是字符串或undefined
)和一个可选的时间戳,上下文初始化为null
。
在Button
组件内部,我们可以使用useContext
来访问我们定义的上下文:
import InteractionContext, {
InteractionMeasurement
} from "./InteractionContext";
const Button = ({ name, onClick: providedOnClick, children }: ButtonType) => {
const interactionContext = useContext<InteractionMeasurement |
null>(
InteractionContext
);
const handleClick = useCallback(
(e) => {
interactionContext &&
interactionContext.measure(name, e.timeStamp);
providedOnClick(e);
},
[providedOnClick, interactionContext, name]
);
return <button onClick={handleClick}>{children}</button>;
};
代码定义了一个使用InteractionContext
来跟踪点击的Button
组件。当按钮被点击时,它会从上下文中调用measure
方法,传入按钮的名称和点击事件的戳记。然后,它继续执行提供的任何额外的onClick
逻辑。这样,点击跟踪就被抽象到上下文中,使Button
组件更具可重用性和可维护性。
如果interactionContext
为null
,则不会调用measure
函数,组件将仅执行作为属性传入的providedOnClick
函数。这允许基于InteractionContext
的可用性进行可选的分析跟踪。
这将完美解决我们遇到的问题——如果一个产品想要启用分析功能,他们可以在包含InteractionMeasurement
实现的上下文中使用Button
。
话虽如此,假设我们有一个使用Button
组件的FormApp
应用程序,该组件位于InteractionContext
实例内部:
import InteractionContext from "./InteractionContext";
import { Button } from "@xui/button";
const FormApp = () => {
const context = {
measure: (e, t) => {
//send event and timestamp to remote
console.log(`sending to remote server ${e}: ${t}`);
},
};
const onClick = () => {
console.log("submit");
};
return (
<InteractionContext.Provider value={context}>
<form>
<Button name="submit-button" onClick={onClick}>
Submit
</Button>
</form>
</InteractionContext.Provider>
);
};
FormApp
组件在其上下文对象内部的measure
函数中定义了自己的分析逻辑。然后,它通过InteractionContext.Provider
将此函数传递给子组件。当表单内的按钮被点击时,不仅会执行按钮的特定onClick
逻辑,measure
函数还会将事件和时间戳数据发送到远程服务器进行分析。这种设置允许基于上下文进行基于上下文的分析,而无需将Button
组件绑定到特定的实现。
对于不想使用分析功能的用户,他们可以像平常一样使用Button
组件:
import { Button } from "@xui/button";
const App = () => {
const onClick = () => {
console.log("checkout");
};
return (
<Button name="checkout-button" onClick={onClick}>
Checkout
</Button>
);
};
这种方法提供了卓越的灵活性和动态性,对于设计通用组件来说非常宝贵。它增强了代码的可重用性和系统的可维护性,同时减少了整体包的大小。
请注意,在这种情况下添加额外的context
对象可能最初看起来有些过度。然而,在大型代码库中,当不同的团队在独立的部分上工作时,这种方法变得更加相关。例如,一个专注于分析的团队可能与其他目标不同的设计系统团队相比,其目标是开发通用和原子的组件。设计系统团队可能不关心分析方面。因此,在这种情况下直接修改Button
组件可能是不切实际或具有挑战性的。
话虽如此,我想介绍另一个我在代码中不断使用的原则;你可以将其视为 SRP(单一职责原则)的一种特殊形式。这个原则是 CQRS。
理解 React 中的命令和查询责任分离
命令和查询责任分离(CQRS)原则(也称为 命令和查询分离原则)是一种软件设计原则,它建议方法或函数应该是修改系统状态的命令或返回系统状态信息的查询,但不能两者兼具。
命令(或 修饰符)是执行操作或改变对象状态但不返回值的方法。另一方面,查询是读取对象状态而不进行任何更改的方法。将命令和查询分开可以帮助减少组件之间的耦合,使测试、维护和修改代码变得更加容易。它还使推理代码的行为变得更加容易,并可以改善系统的整体设计。
尽管这种模式在大型项目中广泛使用,例如在设计系统架构时,它同样在代码层面上也表现良好。我将在 ShoppingCart
组件中演示这一点:
type Item = {
id: string;
name: string;
price: number;
}
const ShoppingApplication = () => {
const [cart, setCart] = useState<Item[]>([]);
const addItemToCart = (item: Item) => {
setCart([...cart, item]);
};
const removeItemFromCart = (id: string) => {
setCart(cart.filter((item) => item.id !== id));
};
const totalPrice = cart.reduce((total, item) => total + item.price,
0);
return (
<div>
<ProductList addToCart={addItemToCart} />
<h2>Shopping Cart</h2>
<ul>
{cart.map((item) => (
<li key={item.id}>
{item.name} - {item.price}
<button onClick={() => removeItemFromCart(item.
id)}>Remove</button>
</li>
))}
</ul>
<p>Total Price: {totalPrice}</p>
</div>
);
};
ShoppingApplication
组件使用 useState
Hook 和一个类型为 Item
的项目数组来维护购物车。addItemToCart
函数向购物车添加新项目,而 removeItemFromCart
函数则根据其 id
值移除项目。totalPrice
是购物车中所有项目价格的累加。
组件渲染购物车中的项目列表及其总价。每个项目都有一个在点击时移除项目的 removeItemFromCart
。同时渲染一个 ProductList
组件,它接收 addItemToCart
作为属性以添加产品到购物车。
初看之前的代码似乎没问题,但它包含一些微妙的问题。一个问题是在购物车中添加多个相同的产品时,键值会重叠,从而触发 React 关于唯一键的警告。此外,如果你在这种情况下点击 移除 按钮,它将删除购物车中该产品的所有实例,这远远不是理想的,并且会导致糟糕的用户体验。
为了修复这些问题,我们需要向 Item
类型引入一个新的 uniqKey
字段。我们还需要在将项目插入 cart
数组之前生成一个唯一的键。有了这个唯一的 ID,我们最终能够通过 uniqKey
而不是 id
来移除项目。代码将如下所示:
const addItemToCart = (item: Item) => {
setCart([...cart, { ...item, uniqKey: `${item.id}-${Date.now()}` }]);
};
const removeItemFromCart = (key: string) => {
setCart(cart.filter((item) => item.uniqKey !== key));
};
我们还需要更新在 JSX 中渲染购物车的方式:
<h2>Shopping Cart</h2>
<ul>
{cart.map((item) => (
<li key={item.uniqKey}>
{item.name} - {item.price}
<button onClick={() => removeItemFromCart(item.uniqKey)}>
Remove
</button>
</li>
))}
</ul>
虽然代码在技术上听起来合理,并且对于当前的范围来说足够直接,但随着我们向 ShoppingApplication
组件添加更多状态和计算,应用 CQRS 原则可以提供一种结构化的方式来保持一切井然有序。
我们将使用 React Context API 和 useReducer
Hook 来为 ShoppingApplication
组件实现 CQRS。现在让我们来看看它们。
引入 useReducer
React 中的useReducer
钩子用于在函数组件中进行状态管理;它在下一个状态依赖于前一个状态或当你有复杂的状态逻辑时特别有用。useReducer
钩子接受两个参数:一个 reducer 函数和一个初始状态,它返回当前状态和一个dispatch
方法来触发更新。
对于第一个参数,reducer 函数接收当前状态和一个action
对象,它包含有关如何更新状态的信息。该函数应根据操作类型和负载返回新状态。第二个参数是要传递的初始状态,它将在调用时用作默认值。
让我们为我们的ShoppingApplication
组件定义一个 reducer 函数:
type ShoppingCartState = {
items: Item[];
totalPrice: number;
};
type ActionType = {
type: string;
payload: Item;
};
const shoppingCartReducer = (
state: ShoppingCartState = initState,
action: ActionType
) => {
switch (action.type) {
case "ADD_ITEM": {
const item = {
...action.payload,
uniqKey: `${action.payload.id}-${Date.now()}`,
};
return { ...state, items: [...state.items, item] };
}
case "REMOVE_ITEM":
const newItems = state.items.filter(
(item) => item.uniqKey !== action.payload.uniqKey
);
return { ...state, items: newItems };
default:
return state;
}
};
因此,shoppingCartReducer
是一个函数,它接受两个参数——当前状态和一个操作:
-
状态的类型是
ShoppingCartState
,它包括一个项目数组和totalPrice
-
操作的类型是
ActionType
,它包括一个用于标识操作的string
类型和一个包含Item
对象的负载
在 reducer 函数内部,使用switch
语句来确定正在派发的哪个操作。"ADD_ITEM"
情况将一个新项目添加到状态中的items
数组。这个项目被赋予一个唯一的键uniqKey
,以区分相同的项目。"REMOVE_ITEM"
情况根据这个唯一键从items
数组中删除一个项目。
通过使用这种结构,reducer 函数提供了一种可预测的方式来管理购物车状态,以响应不同的操作。注意,在这个 reducer 函数中没有什么特别的地方;它只是一个普通的 JavaScript 函数。为了了解它是如何工作的,我们可以用以下代码测试 reducer 函数:
const item = {
id: "p1",
name: "iPad",
price: 666,
};
let x = shoppingCartReducer(initState, {
type: "ADD_ITEM",
payload: item,
});
console.log(x);
我们会得到类似这样的结果(显然,你的uniqKey
值会与我的不同,因为它是当项目被添加时生成的):
{
"items": [
{
"id": "p1",
"name": "iPad",
"price": 666,
"uniqKey": "p1-1696059737801"
}
],
"totalPrice": 0
}
好吧——这应该能让你对 reducer 函数是什么以及它是如何与任何给定输入一起工作的有一个大致的了解。现在,让我们看看我们如何将其与我们的应用程序连接起来。
在上下文中使用 reducer 函数
让我们看看我们如何使用 reducer 函数来实现 CQRS 来简化我们的购物车示例。首先,我们需要一个上下文来管理购物车状态,并公开查询函数供组件使用:
import React, { createContext, useContext, useReducer } from "react";
import { Item } from "./type";
type ShoppingCartContextType = {
items: Item[];
addItem: (item: Item) => void;
removeItem: (item: Item) => void;
};
const ShoppingCartContext = createContext<ShoppingCartContextType | null>(null);
export const ShoppingCartProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const [state, dispatch] = useReducer(shoppingCartReducer, {
items: [],
totalPrice: 0,
});
const addItem = (item: Item) => {
dispatch({type: ADD_ITEM, payload: item});
};
const removeItem = (item: Item) => {
dispatch({type: REMOVE_ITEM, payload: item});
};
return (
<ShoppingCartContext.Provider value={{items: state.items, addItem,
removeItem}}>
{children}
</ShoppingCartContext.Provider>
);
};
这段代码创建了一个用于管理购物车的 React 上下文。在ShoppingCartProvider
内部,它使用useReducer
钩子来处理购物车操作。两个函数addItem
和removeItem
派发操作来修改购物车。Provider
组件通过ShoppingCartContext
将购物车状态和这些函数提供给其子组件。这允许任何嵌套组件与购物车进行交互。
注意,addItem
和removeItem
是 CQRS 原则中的两个命令函数,它们只改变状态而不返回任何数据。如果我们想获取数据,我们可以定义一个查询函数,如下所示:
export const useTotalPrice = () => {
const context = useContext<ShoppingCartContextType>(
ShoppingCartContext
);
const {items} = context;
return items.reduce((acc, item) => acc + item.price, 0);
};
在这里,我们定义了一个名为 useTotalPrice
的自定义 Hook,用于计算购物车中商品的总价。它使用 React 的 useContext
Hook 从 ShoppingCartContext
访问购物车数据。然后使用 reduce
方法将购物车中所有商品的价格相加,初始值为 0
。
对于 ShoppingApplication
组件,我们可以在刚刚创建的 ShoppingCartContext
实例中简单地包裹 ProductList
和 ShoppingCart
:
const ShoppingApplication = () => {
const context = useContext(ShoppingCartContext);
const { items, addItem, removeItem } = context;
const totalPrice = useTotalPrice();
return (
<div>
<ProductList addToCart={addItem} />
<h2>Shopping Cart</h2>
<ul>
{items.map((item) => (
<li key={item.uniqKey}>
{item.name} - {item.price}
<button onClick={() => removeItem(item)}>Remove</button>
</li>
))}
</ul>
<p>Total Price: {totalPrice}</p>
</div>
);
};
ShoppingApplication
组件作为购物应用程序的主要界面。它使用 React 的 useContext
Hook 来访问购物车上下文,该上下文提供购物车中的商品列表(items
)、添加商品的函数(addItem
)和删除商品的函数(removeItem
)。该组件还使用 useTotalPrice
自定义 Hook 来计算购物车中商品的总价。
在最外层的 App
组件中,我们可以封装 ShoppingApplication
组件:
<ShoppingCartProvider>
<ShoppingApplication />
</ShoppingCartProvider>
因此,CQRS 是一种设计模式,它将系统的修改和查询方面分离,以增强可伸缩性、可维护性和简单性。我们通过实现购物车功能来展示这一原则——修改购物车状态的命令(如添加或删除商品)与查询(包括获取商品列表和计算总价)被分离;这种分离通过使用 React 的 Context API 和自定义 Hook 来实现,这些 Hook 有效地隔离了每个责任。这不仅提高了代码的可读性,还使得在未来管理和扩展应用程序变得更加容易。
摘要
在本章中,我们探讨了三个关键的设计原则:SRP(单一职责原则)用于创建专注且易于理解的组件,DIP(依赖倒置原则)用于创建模块化和可测试的代码,以及 CQRS(命令查询责任分离)用于命令和查询之间的明确分离,增强了可维护性。这些原则为构建可伸缩且高质量的软件提供了坚实的基础。
在下一章中,我们将更深入地探讨组合原则,以进一步细化我们对 React 应用程序设计的处理方法。
第十章:深入探索组合模式
构建可扩展和可维护的用户界面的旅程充满了挑战。开发者面临的一个主要挑战是确保组件在代码库增长时保持模块化、可重用和易于理解。我们的组件变得越来越交织和紧密耦合,维护、测试或甚至让新团队成员加入就变得更加困难。
组合已成为解决这一挑战的有力技术,使开发者能够构建更组织化、可扩展和更干净的代码库。我们不是创建执行众多任务的大型、单体组件,而是将它们分解成更小、更易于管理的部分,这些部分可以以多种方式组合。这为我们提供了一条清晰的路径,以简化逻辑、增强可重用性,并保持关注点的清晰分离。
本章致力于理解和掌握 React 中的组合。在过渡到高阶组件和 Hooks 之前,我们将深入研究基础技术,如高阶函数。你将学习这些工具如何无缝地与组合原则相匹配,使你能够使用 React 构建更健壮的应用程序。我们的旅程将以深入研究无头组件为高潮,这种范式封装了逻辑,而不规定 UI,提供了无与伦比的灵活性。
到本章结束时,你将欣赏到采用组合技术的益处。你将准备好创建不仅可扩展和可维护,而且令人愉悦的 UI。让我们开始这次关于 React 中组合的启发式探索之旅。
在本章中,我们将涵盖以下主题:
-
通过高阶组件理解组合
-
深入探索自定义 Hooks
-
开发下拉列表组件
-
探索无头组件模式
技术要求
已创建一个 GitHub 仓库来托管本书中讨论的所有代码。对于本章,你可以在github.com/PacktPublishing/React-Anti-Patterns/tree/main/code/src/ch1
找到推荐的架构。
通过高阶组件理解组合
组合可能是软件设计中最重要的技术之一,就像许多其他基本设计原则一样,它适用于许多不同的层面。在本节中,我们将回顾如何在 React 世界中使用高阶函数及其变体——高阶组件——来实现组合。
复习高阶函数
我们在第九章讨论了一些高阶函数的例子,但这是一个如此重要的概念,我想在这里再详细回顾一下。高阶函数(HOF)是一个函数,它要么接受另一个函数作为其参数,要么返回一个函数,或者两者都是。接受函数作为参数的能力有很多优点,尤其是在组合方面。
考虑以下示例:
const report = (content: string) => {
const header = "=== Header ===";
const footer = "=== Footer ===";
return [header, content, footer].join("\n");
};
在这里,report
函数生成一个包含标题、提供的内容和页脚的格式化报告。例如,给定输入hello world
,输出将如下所示:
=== Header ===
hello world
=== Footer ===
现在,想象一个场景,其中一些用户希望将内容打印为大写。虽然我们可以通过content.toUpperCase()
来实现这一点,但其他用户可能更喜欢内容保持原样。在报告函数中引入条件是取悦这两组用户的一种方法。从我们之前关于第九章标题示例的讨论中汲取灵感,我们可以允许传递一个transformer
函数。
这使得客户可以按照自己的意愿格式化字符串,如下所示:
const report = (content: string, transformer: (s: string) => string) => {
const header = "=== Header ===";
const footer = "=== Footer ===";
return [header, transformer(content), footer].join("\n");
};
为了灵活性,我们可以提供一个默认的转换器,确保那些不想自定义格式的人可以使用该函数而不做任何更改:
const report = (
content: string,
transformer: (s: string) => string = (s) => s
) => {
const header = "=== Header ===";
const footer = "=== Footer ===";
return [header, transformer(content), footer].join("\n");
};
报告函数生成一个包含定义的标题和页脚以及中间主要内容的字符串。它接受一个内容字符串和一个可选的转换函数。如果提供了转换函数,它将修改内容;否则,内容保持不变。结果是带有修改后或原始内容放置在标题和页脚之间的格式化报告。这就是 HOFs 如此强大的本质,帮助我们编写更可组合的代码。
反思这一点,一个有趣的想法浮现出来——我们能否将这种可组合和功能性的方法融入到我们的 React 应用程序中?确实,我们可以。增强组件的能力并不仅限于标准函数。在 React 中,我们有高阶组件(HOCs)。
介绍 HOCs
HOC 本质上是一个接受组件并返回其新、增强版本的函数。HOC 背后的原理很简单——它们允许你向现有组件注入额外的功能。这种模式特别有益于当你想在多个组件之间重用某些行为时。
让我们深入一个例子:
const checkAuthorization = () => {
// Perform authorization check, e.g., check local storage or send
a request to a remote server
}
const withAuthorization = (Component: React.FC): React.FC => {
return (props: any) => {
const isAuthorized = checkAuthorization();
return isAuthorized ? <Component {...props} /> : <Login />;
};
};
在这个片段中,我们定义了一个函数checkAuthorization
来处理授权检查。然后,我们创建了一个 HOC,withAuthorization
。这个 HOC 接受一个组件(Component
)作为其参数,并返回一个新的函数。这个返回的函数在渲染时,将根据用户是否授权来渲染原始的Component
或Login
组件。
现在,假设我们有一个想要保护的ProfileComponent
。我们可以使用withAuthorization
来创建一个新的、受保护的ProfileComponent
版本:
const Profile = withAuthorization(ProfileComponent);
这意味着每当Profile
被渲染时,它首先会检查用户是否有权限。如果有,它将渲染ProfileComponent
;否则,它将用户重定向到Login
组件。
现在我们已经看到了如何使用withAuthorization
来控制访问权限,让我们将注意力转向增强用户交互。我们将深入研究ExpandablePanel
组件,展示 HOC 如何管理交互式 UI 元素和状态转换。
实现ExpandablePanel
组件
让我们从基本的ExpandablePanel
组件开始。正如其名所示,这个组件由一个标题和一个内容区域组成。最初,内容区域是折叠的,但点击标题可以将其展开以显示内容。
图 10.1:可展开的面板
这样一个组件的代码很简单:
export type PanelProps = {
heading: string;
content: ReactNode;
};
const ExpandablePanel = ({ heading, content }: PanelProps) => {
const [isOpen, setIsOpen] = useState<boolean>(false);
return (
<article>
<header onClick={() => setIsOpen((isOpen) =>
!isOpen)}>{heading}</header>
{isOpen && <section>{content}</section>}
</article>
);
};
现在,假设我们想要让它更加生动,使得面板在渲染时自动展开,然后几秒钟后折叠。以下是调整代码以实现这一目标的方法:
const AutoCloseExpandablePanel = ({ heading, content }: PanelProps) => {
const [isOpen, setIsOpen] = useState<boolean>(true);
useEffect(() => {
const id = setTimeout(() => {
setIsOpen(false);
}, 3000);
return () => {
clearTimeout(id);
};
}, []);
return (
<article>
<header onClick={() => setIsOpen((isOpen) =>
!isOpen)}>{heading}</header>
{isOpen && <section>{content}</section>}
</article>
);
};
在这个修订版本中,我们将isOpen
初始化为true
,以便面板以展开状态开始。然后,我们使用useEffect
设置一个计时器,在 3,000 毫秒(3 秒)后折叠面板。
这种自动折叠组件的模式在 UI 开发中相当常见——想想通知、警报或提示,它们在一段时间后会消失。为了提高代码的可重用性,让我们将这个自动折叠逻辑提取到一个 HOC 中:
interface Toggleable {
isOpen: boolean;
toggle: () => void;
}
const withAutoClose = <T extends Partial<Toggleable>>(
Component: React.FC<T>,
duration: number = 2000
) => (props: T) => {
const [show, setShow] = useState<boolean>(true);
useEffect(() => {
if (show) {
const timerId = setTimeout(() => setShow(false), duration);
return () => clearTimeout(timerId);
}
}, [show]);
return (
<Component
{…props}
isOpen={show}
toggle={() => setShow((show) => !show)}
/>
);
};
在withAutoClose
中,我们定义了一个通用的 HOC,它为任何组件添加自动关闭功能。这个 HOC 接受一个持续时间参数来定制自动关闭延迟,默认为 2,000 毫秒(2 秒)。
为了确保顺利集成,我们还可以扩展PanelProps
以包括可选的Toggleable
属性:
type PanelProps = {
heading: string;
content: ReactNode;
} & Partial<Toggleable>;
现在,我们可以重构ExpandablePanel
以接受isOpen
和从withAutoClose
来的切换属性:
const ExpandablePanel = ({
isOpen,
toggle,
heading,
content,
}: PanelProps) => {
return (
<article>
<header onClick={toggle}>{heading}</header>
{isOpen && <section>{content}</section>}
</article>
);
};
使用这种设置,创建一个自动关闭版本的ExpandablePanel
变得轻而易举:
export default withAutoClose(ExpandablePanel, 3000);
而且你知道吗?我们封装在withAutoClose
中的自动关闭逻辑可以在各种组件之间重用:
const AutoDismissToast = withAutoClose(Toast, 3000);
const TimedTooltip = withAutoClose(Tooltip, 3000);
HOC 的通用性在组合方面表现得尤为出色——将一个 HOC 应用于另一个 HOC 的结果的能力。这种能力与函数式编程中的函数组合原则相吻合。
让我们考虑另一个 HOC,withKeyboardToggle
,它增强面板的行为以响应键盘输入来切换面板的展开/折叠状态。以下是withKeyboardToggle
的代码:
const noop = () => {};
const withKeyboardToggle =
<T extends Partial<Toggleable>>(Component: React.FC<T>) =>
(props: T) => {
const divRef = useRef<HTMLDivElement>(null);
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
(props.toggle ?? noop)();
}
if (event.key === "Escape" && divRef.current) {
divRef.current.blur();
}
};
return (
<div onKeyDown={handleKeyDown} tabIndex={0} ref={divRef}>
<Component {...props} />
</div>
);
};
export default withKeyboardToggle;
在 withKeyboardToggle
高阶组件(HOC)中,创建了一个引用(divRef
)用于包装 div
以启用键盘交互。handleKeyDown
函数定义了 Enter、Space 和 Escape 键的行为——Enter 或 Space 键切换面板的状态,而 Escape 键则从面板移除焦点。这些键盘事件处理器允许包装组件响应键盘导航。
现在,让我们将 withKeyboardToggle
和 withAutoClose
组合起来创建一个新的组件,AccessibleAutoClosePanel
:
const AccessibleAutoClosePanel = withAutoClose(withKeyboardToggle(ExpandablePanel), 2000);
在 withAutoClose(withKeyboardToggle(ExpandablePanel), 2000);
表达式中,withKeyboardToggle
首先应用于 ExpandablePanel
,增强了其键盘切换功能。然后,这个结果被输入到 withAutoClose
中,进一步增强了组件,使其在 2,000 毫秒后自动关闭。这种 HOCs 的链式调用产生了一个新的组件,AccessibleAutoClosePanel
,它继承了键盘切换和自动关闭的行为。
这是一个生动的例子,说明了如何将 HOCs 嵌套和组合起来,从更简单、单一职责的组件构建更复杂的行为,这在 图 10.2 中进一步说明。2*:
图 10.2:高阶组件
如果你有一些面向对象编程的背景,这个概念可能对你有共鸣,因为它与 装饰器 设计模式相吻合。如果你不熟悉,它通过将对象包装在额外的对象中来动态地为对象添加行为,而不是改变其结构。这比继承提供了更大的灵活性,因为它在不修改原始对象的情况下扩展了功能。
现在,尽管 HOCs 在各种场景下对类组件和函数组件都有益,但 React Hooks 提供了一种更轻量级的方法来实现组合。让我们接下来看看 Hooks。
探索 React Hooks
Hooks 提供了一种从组件中提取有状态逻辑的方法,使其能够独立测试和重用。它们为在不改变组件层次结构的情况下重用有状态逻辑铺平了道路。本质上,Hooks 允许你从函数组件中“钩入”React 状态和其他生命周期特性。
接着从 ExpandablePanel
组件的例子来看,让我们看看这段代码:
const useAutoClose = (duration: number) => {
const [isOpen, setIsOpen] = useState<boolean>(true);
useEffect(() => {
if (isOpen) {
const timerId = setTimeout(() => setIsOpen(false), duration);
return () => clearTimeout(timerId);
}
}, [duration, isOpen]);
const toggle = () => setIsOpen((show) => !show);
return { isOpen, toggle };
};
export default useAutoClose;
在这个 useAutoClose
Hooks 中,我们创建了一个 isOpen
状态和一个切换状态的函数。useEffect
函数设置一个计时器,在指定的时间后将 isOpen
改为 false
,但仅当 isOpen
为 true
时。它还清理计时器以防止内存泄漏。
现在,为了将这个 Hook 集成到我们的 ExpandablePanel
中,需要做的最小修改:
const ExpandablePanel = ({ heading, content }: PanelProps) => {
const { isOpen, toggle } = useAutoClose(2000);
return (
<article>
<header onClick={toggle}>{heading}</header>
{isOpen && <section>{content}</section>}
</article>
);
};
在这里,我们删除了传入的 isOpen
和 toggle
属性,并利用了 useAutoClose
Hooks 的返回值,无缝地结合了自动关闭功能。
接下来,为了实现键盘导航,我们定义了另一个 Hook,useKeyboard
,它捕获按键事件以切换面板:
const useKeyboard = (toggle: () => void) => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toggle();
}
};
return { handleKeyDown };
};
然后,在 ExpandablePanel
中嵌入 useKeyboard
是直接的:
const ExpandablePanel = ({ heading, content }: PanelProps) => {
const { isOpen, toggle } = useAutoClose(2000);
const { handleKeyDown } = useKeyboard(toggle);
return (
<article onKeyDown={handleKeyDown} tabIndex={0}>
<header onClick={toggle}>{heading}</header>
{isOpen && <section>{content}</section>}
</article>
);
};
这里,useKeyboard
的 handleKeyDown
被用来检测按键,增强了我们的组件的键盘交互性。
在 图 10.3 中,你可以观察到钩子如何与底层的 ExpandablePanel
相关联,与组件被包装的 HOC 场景形成对比:
图 10.3:使用替代钩子
钩子体现了一组整洁的可重用逻辑,与组件隔离但易于集成。与 HOCs 的包装方法不同,Hooks 提供了一种插件机制,使它们轻量级且由 React 管理良好。Hooks 的这一特性不仅促进了代码模块化,还提供了一种更干净、更直观的方式来丰富我们的组件,增加额外的功能。
然而,请注意,Hooks 的多功能性比最初看起来更广。它们不仅用于管理与 UI 相关的状态,而且对于处理 UI 副作用也非常有效,例如数据获取和全局事件处理(如页面级别的键盘快捷键)。我们已经看到了如何使用它们来处理键盘事件处理器,现在,让我们探索 Hooks 如何简化网络请求。
揭示远程数据获取
在前面的章节中,我们利用 useEffect
进行数据获取,这是一种常见的做法。当从远程服务器获取数据时,通常需要引入三种不同的状态 – loading
、error
和 data
。
这里是一个实现这些状态的方法:
//...
const [loading, setLoading] = useState<boolean>(false);
const [data, setData] = useState<Item[] | null>(null);
const [error, setError] = useState<Error | undefined>(undefined);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const response = await fetch("/api/users");
if (!response.ok) {
const error = await response.json();
throw new Error(`Error: ${error.error || response.status}`);
}
const data = await response.json();
setData(data);
} catch (e) {
setError(e as Error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
//...
在前面的代码中,我们使用 React 钩子来管理异步数据获取,初始化 loading
、data
和 error
的状态。在 useEffect
中,fetchData
函数尝试从 "/api/users"
端点获取用户数据。如果成功,数据将被存储;如果不成功,将记录错误。无论结果如何,都会更新加载状态以反映完成情况。useEffect
只运行一次,类似于组件的初始挂载阶段。
优化重构以实现优雅和可重用性
将获取逻辑直接嵌入我们的组件中是可以工作的,但这不是最优雅或最可重用的方法。让我们通过将获取逻辑提取到单独的函数中来重构它:
const fetchUsers = async () => {
const response = await fetch("/api/users");
if (!response.ok) {
const error = await response.json();
throw new Error('Something went wrong');
}
return await response.json();
};
在 fetchUsers
函数就位后,我们可以通过将获取逻辑抽象成一个通用的钩子来更进一步。这个钩子将接受一个获取函数并管理相关的 loading
、error
和 data
状态:
const useService = <T>(fetch: () => Promise<T>) => {
const [loading, setLoading] = useState<boolean>(false);
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | undefined>(undefined);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const data = await fetch();
setData(data);
} catch(e) {
setError(e as Error);
} finally {
setLoading(false);
}
};
fetchData();
}, [fetch]);
return {
loading,
error,
data,
};
}
现在,useService
钩子作为跨应用获取数据的可重用解决方案出现。它是一个整洁的抽象,我们可以用它来获取各种类型的数据,如下所示:
const { loading, error, data } = useService(fetchProducts);
//or
const { loading, error, data } = useService(fetchTickets);
通过这次重构,我们不仅简化了数据获取逻辑,还使其可以在应用的不同场景中重用。这为我们继续增强下拉组件并深入研究更高级的功能和优化奠定了坚实的基础。
随着我们探索了钩子和它们在管理状态和逻辑方面的能力,让我们将这些知识应用到从头构建一个更复杂的 UI 组件——下拉列表。这个练习不仅将加强我们对钩子的理解,还将展示它们在创建交互式 UI 元素中的实际应用。
我们将从下拉列表的基本版本开始,然后逐步引入更多功能,使其功能齐全且用户友好。这个过程也将为后续关于无头组件的讨论奠定基础,展示一个进一步抽象和管理 UI 组件状态和逻辑的设计模式。
开发下拉列表组件
下拉列表是一个在许多地方都常用的组件。尽管有原生选择组件用于基本用例,但一个更高级的版本,提供对每个选项的更多控制,可以提供更好的用户体验。
图 10.4:下拉列表组件
当从头开始创建时,完整的实现所需的努力比最初看起来要多。必须考虑键盘导航、可访问性(例如,屏幕阅读器兼容性)以及移动设备上的可用性等问题。
我们将从简单的桌面版本开始,仅支持鼠标点击,逐步添加更多功能以使其更真实。请注意,这里的目的是揭示一些软件设计模式,而不是教你如何构建用于生产环境的下拉列表(实际上,我不建议从头开始构建,而是建议使用更成熟的库)。
基本上,我们需要一个用户可以点击的元素(让我们称它为触发器),以及一个状态来控制列表面板的显示和隐藏操作。最初,我们隐藏面板,当触发器被点击时,我们显示列表面板。以下是代码:
import { useState } from "react";
interface Item {
icon: string;
text: string;
id: string;
description: string;
}
type DropdownProps = {
items: Item[];
};
const Dropdown = ({ items }: DropdownProps) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState<Item | null>(null);
return (
<div className="dropdown">
<div className="trigger" tabIndex={0} onClick={() =>
setIsOpen(!isOpen)}>
<span className="selection">
{selectedItem ? selectedItem.text : "Select an item..."}
</span>
</div>
{isOpen && (
<div className="dropdown-menu">
{items.map((item) => (
<div
key={item.id}
onClick={() => setSelectedItem(item)}
className="item-container"
>
<img src={item.icon} alt={item.text} />
<div className="details">
<div>{item.text}</div>
<small>{item.description}</small>
</div>
</div>
))}
</div>
)}
</div>
);
};
在代码中,我们已经为我们的下拉组件设置了基本结构。使用useState
钩子,我们管理isOpen
和selectedItem
状态来控制下拉的行为。简单的点击触发器可以切换下拉菜单,而选择一个项目会更新selectedItem
状态。
让我们将组件分解成更小、更易于管理的部分,以便更清晰地看到它。我们将首先提取一个Trigger
组件来处理用户点击:
const Trigger = ({
label,
onClick,
}: {
label: string;
onClick: () => void;
}) => {
return (
<div className="trigger" tabIndex={0} onClick={onClick}>
<span className="selection">{label}</span>
</div>
);
};
同样,我们将提取一个DropdownMenu
组件来渲染项目列表:
const DropdownMenu = ({
items,
onItemClick,
}: {
items: Item[];
onItemClick: (item: Item) => void;
}) => {
return (
<div className="dropdown-menu">
{items.map((item) => (
<div
key={item.id}
onClick={() => onItemClick(item)}
className="item-container"
>
<img src={item.icon} alt={item.text} />
<div className="details">
<div>{item.text}</div>
<small>{item.description}</small>
</div>
</div>
))}
</div>
);
};
现在,在Dropdown
组件中,我们只需简单地使用这两个组件,传入相应的状态,将它们转换为纯受控组件(无状态组件):
const Dropdown = ({ items }: DropdownProps) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState<Item | null>(null);
return (
<div className="dropdown">
<Trigger
label={selectedItem ? selectedItem.text : "Select an item..."}
onClick={() => setIsOpen(!isOpen)}
/>
{isOpen && <DropdownMenu items={items}
onItemClick={setSelectedItem} />}
</div>
);
};
在这个更新的代码结构中,我们通过为下拉的不同部分创建专门的组件来分离关注点,使代码更加有序且易于管理。我们在这里可以看到结果:
图 10.5:原生实现列表
如您在图 10**.5中看到的,基本的下拉列表出现了,但这只是整个下拉列表功能的一小部分。例如,键盘导航是一个可访问组件的必要功能,我们将在下一部分实现它。
实现键盘导航
在我们的下拉列表中集成键盘导航,通过提供鼠标交互的替代方案来增强用户体验。这对于可访问性尤为重要,并在网页上提供无缝的导航体验。让我们探讨如何使用onKeyDown
事件处理器来实现这一点。
初始时,我们将handleKeyDown
函数附加到Dropdown
组件中的onKeyDown
事件。在这里,我们使用switch
语句来确定按下的特定键并执行相应的操作。例如,当按下Enter
或Space
键时,下拉菜单会切换。同样,ArrowDown
和ArrowUp
键允许在列表项之间导航,当需要时循环回到列表的开始或结束:
const Dropdown = ({ items }: DropdownProps) => {
// ... previous state variables ...
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
// ... case blocks ...
}
};
return (
<div className="dropdown" onKeyDown={handleKeyDown}>
{/* ... rest of the JSX ... */}
</div>
);
};
此外,我们已更新我们的DropdownMenu
组件以接受selectedIndex
属性。此属性用于应用高亮样式并将aria-selected
属性设置为当前选定的项,增强视觉反馈和可访问性:
const DropdownMenu = ({
items,
selectedIndex,
onItemClick,
}: {
items: Item[];
selectedIndex: number;
onItemClick: (item: Item) => void;
}) => {
return (
<div className="dropdown-menu" role="listbox">
{/* ... rest of the JSX ... */}
</div>
);
};
在接下来的工作中,我们可以将状态和键盘事件处理逻辑封装在一个名为useDropdown
的自定义钩子中。此钩子返回一个包含必要状态和函数的对象,可以在Dropdown
组件中使用解构,保持其整洁和可维护性:
const useDropdown = (items: Item[]) => {
// ... state variables ...
const handleKeyDown = (e: React.KeyboardEvent) => {
// ... switch statement ...
};
const toggleDropdown = () => setIsOpen((isOpen) => !isOpen);
return {
isOpen,
toggleDropdown,
handleKeyDown,
selectedItem,
setSelectedItem,
selectedIndex,
};
};
现在,我们的Dropdow
n 组件已经简化并更具可读性;它利用useDropdown
钩子来管理其状态和处理键盘交互,展示了关注点的清晰分离,使得代码更容易理解和维护:
const Dropdown = ({ items }: DropdownProps) => {
const {
isOpen,
selectedItem,
selectedIndex,
toggleDropdown,
handleKeyDown,
setSelectedItem,
} = useDropdown(items);
return (
<div className="dropdown" onKeyDown={handleKeyDown}>
<Trigger
onClick={toggleDropdown}
label={selectedItem ? selectedItem.text : "Select an item..."}
/>
{isOpen && (
<DropdownMenu
items={items}
onItemClick={setSelectedItem}
selectedIndex={selectedIndex}
/>
)}
</div>
);
};
通过这些修改,我们成功地在下拉列表中实现了键盘导航,使其更具可访问性和用户友好性。此示例还说明了如何利用钩子以结构化和模块化的方式管理复杂的状态和逻辑,为我们的 UI 组件的进一步增强和功能添加铺平道路。
我们可以使用 React DevTools 更好地可视化代码。请注意,在钩子部分,所有状态都被列出:
图 10.6:使用 Chrome DevTools 检查钩子部分的内容
当我们需要实现不同的 UI 同时保持相同的基本功能时,将我们的逻辑提取到钩子中时,其威力就完全显现出来。通过这样做,我们将状态管理和交互逻辑从 UI 渲染中分离出来,使得在不接触逻辑的情况下更改 UI 变得轻而易举。
我们已经探讨了如何利用带有自定义钩子的小组件来增强我们的代码结构。然而,当我们面临管理更复杂的状态时会发生什么?考虑一个下拉数据来自服务 API 的场景,需要我们处理异步服务调用以及额外的状态管理。在这种情况下,这种结构是否仍然有效?
从远程源获取数据的场景需要管理更多的状态——具体来说,我们需要处理加载、错误和数据状态。如图 图 10.7 所示,除了显示常规列表外,我们还旨在管理数据不可立即访问的情况——要么是它仍在从远程 API 加载,要么是不可用。
图 10.7:不同的状态
这样的状态,虽然很常见,但对于用户体验至关重要。以一个包含国家名称的下拉列表为例。这是一个常见的功能,但当我们打开列表时,名称可能仍在加载,此时会显示一个加载指示器。此外,在下游服务不可用或其他错误发生的情况下,会显示错误消息。
当扩展我们现有的代码时,深思熟虑将要引入的额外状态至关重要。让我们探讨在集成新功能时保持简洁的策略。
在下拉组件中保持简洁性
由于 useService
和 useDropdown
钩子中的抽象逻辑,引入远程数据获取并没有使我们的 Dropdown
组件变得复杂。我们的组件代码保持最简形式,有效地管理获取状态并根据接收到的数据渲染内容:
const Dropdown = () => {
const { data, loading, error } = useService(fetchUsers);
const {
toggleDropdown,
dropdownRef,
isOpen,
selectedItem,
selectedIndex,
updateSelectedItem,
getAriaAttributes,
} = useDropdown<Item>(data || []);
const renderContent = useCallback(() => {
if (loading) return <Loading />;
if (error) return <Error />;
if (data) {
return (
<DropdownMenu
items={data}
updateSelectedItem={updateSelectedItem}
selectedIndex={selectedIndex}
/>
);
}
return null;
}, [loading, error, data, updateSelectedItem, selectedIndex]);
return (
<div
className="dropdown"
ref={dropdownRef as RefObject<HTMLDivElement>}
{...getAriaAttributes()}
>
<Trigger
onClick={toggleDropdown}
text={selectedItem ? selectedItem.text : "Select an item..."}
/>
{isOpen && renderContent()}
</div>
);
};
在这个更新的 Dropdown
组件中,我们使用 useService
钩子来管理数据获取状态,使用 useDropdown
钩子来管理下拉特定的状态和交互。renderContent
函数优雅地处理基于获取状态的渲染逻辑,确保正确的内容被显示,无论是加载、错误还是数据。
通过关注点的分离和钩子的使用,我们的 Dropdown
组件保持简洁直观,展示了 React 中可组合逻辑的力量。现在,这种模式在构建 UI 时实际上有一个特定的名称——无头组件模式。让我们更详细地看看它。
引入无头组件模式
无头组件模式揭示了一条强大的途径,可以干净地分离我们的 JSX 代码和底层逻辑。虽然使用 JSX 构建声明性 UI 来说很自然,但真正的挑战在于管理状态。这就是无头组件发挥作用的地方,它承担了所有状态管理的复杂性,并推动我们迈向抽象的新境界。
在本质上,无头组件是一个封装逻辑但不自身渲染任何内容的函数或对象。它将渲染部分留给消费者,从而在 UI 的渲染方式上提供了高度的灵活性。当我们有复杂的逻辑想要在不同视觉表示中重用时,这种模式可以非常有用。
如以下代码所示,useDropdownLogic
Hook 拥有所有逻辑但没有 UI 元素,而MyDropdown
使用无头组件,并且只需处理渲染逻辑:
function useDropdownLogic() {
// ... all the dropdown logic
return {
// ... exposed logic
};
}
function MyDropdown() {
const dropdownLogic = useDropdownLogic();
return (
// ... render the UI using the logic from dropdownLogic
);
}
在视觉表示中,无头组件充当一个薄接口层。在一侧,它与 JSX 视图交互,在另一侧,它与底层数据模型通信。我们在第八章中提到了数据建模,我们将在第十一章中重新讨论它。这种模式对那些只寻求 UI 的行为或状态管理方面的人来说特别有益,因为它方便地将它从视觉表示中分离出来。
让我们在图 10.8中看看一个视觉说明。你可以把你的代码看作有几个层次——JSX 在顶部,负责应用的外观和感觉部分,无头组件(在这种情况下是 Hooks)管理所有有状态的逻辑,在其下方是领域层,它具有处理数据映射和转换的逻辑(我们将在第十一章和第十二章中详细介绍这一点)。
图 10.8:无头组件模式
总结无头组件模式时,值得提到的是,尽管它可以通过 HOCs 或 render props 实现,但作为 React Hook 的实现更为普遍。在无头组件模式中,所有可共享的逻辑都被封装起来,允许无缝过渡到其他 UI,而无需对状态逻辑进行任何修改。
无头组件模式的优势和缺点
无头组件模式的优势包括以下内容:
-
可重用性:封装在无头组件模式中的逻辑可以在多个组件之间重用。这促进了代码库中的不要重复自己(DRY)原则。
-
关注点分离:通过将逻辑与渲染解耦,无头组件促进了关注点的清晰分离,这是可维护代码的基石。
-
灵活性:它们允许在共享相同核心逻辑的同时,实现不同的 UI 实现,这使得适应不同的设计要求或框架变得更加容易。
无头组件模式的缺点包括以下内容:
-
学习曲线:这种模式可能会给不熟悉它的开发者带来学习曲线,这可能会在最初阶段减缓开发速度。
-
过度抽象:如果不加以妥善管理,无头组件创建的抽象可能会导致代码难以跟踪的间接层次。
图书馆和进一步的学习
Headless Component 模式已被各种库所采用,以促进可访问、可定制和可重用组件的创建。以下是一些知名库及其简要描述:
-
React Aria:Adobe 提供的库,提供可访问性原语和 Hooks,以构建包容性的 React 应用程序。它提供了一系列 Hooks,用于管理键盘交互、焦点管理和 Aria 注释,使创建可访问的 UI 组件变得更加容易。
-
Headless UI:一个完全无样式的、完全可访问的 UI 组件库,旨在与 Tailwind CSS 美妙集成。它提供了构建自定义样式组件的行为和可访问性基础。
-
React Table:一个为 React 构建快速和可扩展表格和数据网格的无头实用工具。它提供了一个灵活的 Hook,允许你轻松创建复杂的表格,并将 UI 表示留给你自己。
-
Downshift:一个帮助您创建可访问和可定制下拉列表、组合框等的简约库。它处理所有逻辑,同时让您定义渲染方面。
这些库通过封装复杂的逻辑和行为,体现了 Headless Component 模式的精髓,使得创建高度交互和可访问的 UI 组件变得简单。虽然提供的示例可以作为学习的垫脚石,但在实际场景中构建强大、可访问和可定制的组件时,利用这些生产级库是明智的。
这种模式不仅教会我们如何管理复杂的逻辑和状态,还鼓励我们探索经过磨炼的 Headless Component 方法,为实际应用提供强大、可访问和可定制的组件的生产级库。
摘要
在本章中,我们深入探讨了 React 中的高阶组件(HOCs)和钩子(Hooks)的世界,探讨了它们在增强组件逻辑的同时保持干净、可读的代码库中的效用。通过创建可展开的面板和下拉列表的视角,我们展示了 HOCs 的可组合性和 Hooks 提供的状态逻辑封装。过渡到更复杂的下拉列表,我们介绍了异步数据获取,展示了 Hooks 如何简化数据加载场景中的状态管理。
然后,我们过渡到了无头组件的领域,这是一种强大的模式,它将逻辑与 JSX 代码分离,提供了一个强大的框架来管理状态,同时将 UI 表示留给开发者。通过示例,我们展示了这种分离如何促进可重用、可访问和可定制的组件的创建。讨论内容得到了对一些知名库的回顾,如 React Table、Downshift、React Aria 和 Headless UI,这些库体现了无头组件模式,提供了构建交互式和可访问 UI 组件的现成解决方案。
在下一章中,我们将实现我们讨论过的模式,并深入研究增强模块化的架构策略。我们还将解决大型应用程序带来的挑战。
第四部分:参与实际实施
在本书的最后一部分,您将通过在 React 中使用分层架构并经历端到端项目实施的过程,以实际操作的方式应用您积累的知识。本部分旨在总结书中讨论的所有原则、模式和最佳实践。
本部分包含以下章节:
-
第十一章, 在 React 中引入分层架构
-
第十二章, 实施端到端项目
-
第十三章, 回顾反模式原则
第十一章:在 React 中引入分层架构
随着 React 应用程序的大小和复杂性的增长,有效地管理代码成为一个挑战。功能的线性增长可能导致复杂性的指数级增加,使得代码库难以理解、测试和维护。进入分层架构,这是一种不仅限于后端系统,而且对客户端应用程序同样有益的设计方法。
以分层方式构建你的 React 应用程序可以解决几个关键问题:
-
关注点分离:不同的层处理不同的责任,使得代码库更容易导航和理解
-
可重用性:业务逻辑和数据模型可以轻松地在应用程序的不同部分之间重用
-
可测试性:分层架构使得编写单元和集成测试更加简单,从而使得应用程序更加健壮
-
可维护性:随着应用程序的扩展,遵循分层结构进行更改或添加功能变得显著更容易
在本章中,我们将探讨在 React 应用程序的背景下分层架构的概念,深入探讨提取应用关注层、定义精确的数据模型以及展示策略模式的使用。通过逐步示例,我们将看到如何实际实现这些概念,以及为什么它们对于大规模应用程序是不可或缺的。
在本章中,我们将涵盖以下主题:
-
理解 React 应用程序的演变
-
提升 Code Oven 应用程序
-
实现 ShoppingCart 组件
-
深入研究分层架构
技术要求
已创建一个 GitHub 仓库来托管本书中讨论的所有代码。对于本章,你可以在github.com/PacktPublishing/React-Anti-Patterns/tree/main/code/src/ch11
找到推荐的结构。
理解 React 应用程序的演变
不同规模的应用程序需要不同的策略。对于小型或一次性项目,你可能会发现所有逻辑都只是写在 React 组件内部。你可能只会看到一到几个组件。代码看起来几乎像是 HTML,只使用了一些变量或状态来使页面“动态”,但总体来说,代码易于理解和修改。
随着应用程序的增长,越来越多的代码被添加到代码库中,如果没有适当的方式来组织它们,代码库很快就会陷入不可维护的状态。这意味着即使添加小的功能也会变得耗时,因为开发者需要更多的时间来阅读代码。
在本节中,我将列出几种不同的方法,我们可以用这些方法来构建我们的 React 应用,以确保我们的代码始终保持健康状态,使得添加新功能变得轻而易举,并且易于扩展或修复现有缺陷。我们将从一个简单的结构开始,并逐步演进以处理规模问题。让我们快速回顾一下构建可扩展前端应用的步骤。
单组件应用
首先,让我们谈谈编写 React 应用的最简单方法——单组件应用。
图 11.1:单组件应用
单组件承担了各种任务,从从远程服务器获取数据、管理其内部状态,到处理领域逻辑,再到渲染。这种方法可能适用于只有单个表单的小型应用,或者那些希望了解将应用从另一个框架迁移到 React 的过程。
然而,你很快就会意识到将所有内容都整合到一个组件中会使代码难以理解和管理。所有内容都放在一个组件中会很快变得令人不知所措,尤其是在处理诸如遍历项目列表以创建单个组件的逻辑时。这种复杂性突显了将单组件分解为更小、职责明确的组件的需求。
多组件应用
决定将组件拆分为几个组件,这些结构反映了最终 HTML 上的情况,这是一个好主意,并且有助于你一次专注于一个组件。
实际上,你将从一个单体组件过渡到多个组件,每个组件都有特定的目的。例如,一个组件可能专门用于渲染列表,另一个用于渲染列表项,还有一个仅用于获取数据并将其传递给子组件。
有明确的职责会更好。然而,随着你的应用扩展,职责不仅限于视图层,还包括发送网络请求、为视图重塑数据以供消费、收集数据以发送回服务器等任务。此外,一旦数据被获取,可能还需要对数据进行转换的逻辑。将这种计算逻辑放在视图中似乎并不合适,因为它与用户界面没有直接关系。此外,一些组件可能会因为过多的内部状态而变得杂乱无章。
使用 Hooks 进行状态管理
将这种逻辑拆分到不同的地方会更好。幸运的是,在 React 中,你可以定义自己的 Hooks。这是一种在状态变化时共享状态和逻辑的绝佳方式。
图 11.3:使用 Hooks 进行状态管理
现在你已经从组件中提取了一堆元素。你有一些纯展示组件,一些可复用的钩子,它们使其他组件具有状态,还有一些容器组件(例如用于数据获取)。
在这个阶段,你可能会发现计算被分散在视图、钩子或各种实用函数中。缺乏结构可能会使进一步的修改变得非常具有挑战性,并且容易出错。例如,如果你已经获取了一些用于渲染的数据,但视图中的数据模式不同,你需要转换数据。然而,放置这种转换逻辑的位置可能并不明确。
提取商业模式
因此,你已经开始意识到将这种逻辑提取到另一个地方可以带来许多好处。例如,通过这种分割,逻辑可以更加一致且独立于任何视图。然后,你提取了一些领域对象。
这些简单的对象可以处理数据映射(从一个格式到另一个格式)、检查空值,并在需要时使用回退值。随着这些领域对象的增加,你会发现你需要一些继承或多态来使事情更加清晰。因此,你将应用从其他地方找到的许多有用的设计模式到前端应用中:
图 11.4:提取商业模式
现在,你的代码库已经通过更多元素扩展,每个元素都有关于其职责的明确边界。钩子用于状态管理,而领域对象代表领域概念,例如包含头像的用户对象,或代表支付方式详细信息的PaymentMethod
对象。
随着我们从视图中分离出不同的元素,代码库相应地扩展。最终,我们会达到一个需要更高效地应对变化的点,这时我们需要对应用进行结构化。
分层前端应用
随着应用的持续发展,某些模式开始显现。你会注意到一些不属于任何用户界面的对象集合,它们对底层数据是否来自远程服务、本地存储或缓存保持中立。因此,你可能会希望将它们分离到不同的层。我们需要为应用的不同部分引入更好的方法。
图 11.5:分层前端应用
如图 11.5所示,我们可以将不同的部分分配到不同的文件夹中,每个文件夹都与其他文件夹明显且物理上隔离。这样,如果需要修改模型,你就不需要导航到视图文件夹,反之亦然。
那只是一个关于演变过程的高级概述,您应该对如何结构化您的代码或至少方向应该是什么有所了解。在更大规模的应用程序中,您可能会遇到各种模块和函数,每个都针对应用程序的不同方面进行了定制。这可能包括处理网络请求的请求模块,或者设计用于与各种数据供应商接口的适配器,例如谷歌的登录 API 或支付网关客户端。
然而,会有许多细节,例如如何定义一个模型,如何从视图或钩子中访问模型,等等。在将理论应用于您的应用程序之前,您需要考虑这些因素。
阅读更多
您可以在martinfowler.com/bliki/PresentationDomainDataLayering.html
找到关于表示域数据分层的高级概述。
在以下章节中,我将引导您扩展我们在第七章中介绍的 Code Oven 应用程序,以展示大型前端应用程序的基本模式和设计原则。
增强 Code Oven 应用程序
回想一下,到第七章结束时,我们开发了一个名为 Code Oven 的披萨店应用程序的基本结构,利用测试驱动开发为应用程序建立坚实的基础。
图 11.6:Code Oven 应用程序
注意
记住,我们使用设计草图作为指导,而不是详尽无遗地实现所有细节。主要目标仍然是说明如何在保持可维护性的同时重构代码。
虽然在第七章中我们没有深入探讨功能实现,但在本章中,我们将进一步扩展我们的设置。我们将探讨不同架构类型如何帮助我们管理复杂性。
作为复习,到第七章结束时,我们的结构看起来是这样的:
export function PizzaShopApp() {
const [cartItems, setCartItems] = useState<string[]>([]);
const addItem = (item: string) => {
setCartItems([...cartItems, item]);
};
return (
<>
<h1>The Code Oven</h1>
<MenuList onAddMenuItem={addItem} />
<ShoppingCart cartItems={cartItems} />
</>
);
}
我们假设数据是这样的形状:
const pizzas = [
"Margherita Pizza",
"Pepperoni Pizza",
"Veggie Supreme Pizza"
];
虽然这种设置允许消费者浏览餐厅提供的菜品,但如果我们启用在线订购,这将更有用。然而,一个直接的问题是披萨缺少价格和描述,这对于支持在线订购至关重要。描述也很重要,因为它们列出了配料,告知消费者包含的内容。
话虽如此,在 JavaScript 代码中定义菜单数据实际上并不实用。通常,我们会有一个服务来托管此类数据,提供更详细的信息。
为了展示这一点,假设我们有一个托管在api.code-oven.com/menus
远程服务上的数据,定义如下:
[
{
"id": "p1",
"name": "Margherita Pizza",
"price": 10.99,
"description": "Classic pizza with tomato sauce and mozzarella",
"ingredients": ["Tomato Sauce", "Mozzarella Cheese", "Basil",
"Olive Oil"],
"allergyTags": ["Dairy"],
"calories": 250,
"category": "Pizza"
},
//...
]
为了弥合我们的应用程序和这些数据之间的差距,我们需要为远程数据定义一个类型,如下所示:
type RemoteMenuItem = {
id: string;
name: string;
price: number;
description: string;
ingredients: string[];
allergyTags: string[];
category: string;
calories: number
}
现在,为了集成这个远程菜单数据,我们将使用useEffect
来获取数据,并在获取后显示项目。我们将在MenuList
组件内进行这些更改:
const MenuList = ({
onAddMenuItem,
}: {
onAddMenuItem: (item: string) => void;
}) => {
const [menuItems, setMenuItems] = useState<string[]>([]);
useEffect(() => {
const fetchMenuItems = async () => {
const result = await fetch('https://api.code-oven.com/menus');
const menuItems = await result.json();
setMenuItems(menuItems.map((item: RemoteMenuItem) => item.
name));
}
fetchMenuItems();
}, [])
return (
<div data-testid="menu-list">
<ol>
{menuItems.map((item) => (
<li key={item}>
{item}
<button onClick={() => onAddMenuItem(item)}>Add</button>
</li>
))}
</ol>
</div>
);
};
在这里,MenuList
组件在初始渲染时从外部 API 获取菜单项列表并显示此列表。每个项目都附带一个onAddMenuItem
函数,作为属性传递给MenuList
,其参数为项目名称。
通过在获取数据后将RemoteMenuItem
映射到字符串,我们确保我们的测试继续通过。
现在,我们的目标是揭示价格并将数据中的成分显示到 UI 组件中。然而,鉴于成分列表可能很长,我们只显示前三个以避免占用过多的屏幕空间。此外,我们希望使用小写的category
并将其重命名为type
。
初始时,我们定义一个新的类型以更好地结构化我们的数据:
type MenuItem = {
id: string;
name: string;
price: number;
ingredients: string[];
type: string;
}
在这里,MenuItem
类型包括项目的id
、name
、price
、ingredients
和type
属性。
现在,是时候更新我们的MenuList
组件以使用这种新类型:
const MenuList = ({
onAddMenuItem,
}: {
onAddMenuItem: (item: string) => void;
}) => {
const [menuItems, setMenuItems] = useState<MenuItem[]>([]);
useEffect(() => {
const fetchMenuItems = async () => {
const result = await fetch("http://api.code-oven.com/menus");
const menuItems = await result.json();
setMenuItems(
menuItems.map((item: RemoteMenuItem) => {
return {
id: item.id,
name: item.name,
price: item.price,
type: item.category.toUpperCase(),
ingredients: item.ingredients.slice(0, 3),
};
})
);
};
fetchMenuItems();
}, []);
return (
<div data-testid="menu-list">
<ol>
{menuItems.map((item) => (
<li key={item.id}>
<h3>{item.name}</h3>
<span>${item.price}</span>
<div>
{item.ingredients.map((ingredient) => (
<span>{ingredient}</span>
))}
</div>
<button onClick={() => onAddMenuItem(item.name)}>Add
</button>
</li>
))}
</ol>
</div>
);
};
在MenuList
组件中,我们现在已经使用了MenuItem
类型在我们的useState
钩子中。在useEffect
中触发的fetchMenuItems
函数调用 API,获取菜单项,并将它们映射到以转换数据到所需的MenuItem
格式。这种转换包括为每个项目保留ingredients
数组中的前三个项目。
每个MenuItem
组件随后在该组件内部渲染为一个列表项。我们显示项目的名称、价格,并遍历ingredients
数组以渲染每个成分。
虽然代码是功能性的,但存在一个担忧:我们在单个组件中交织了网络请求、数据映射和渲染逻辑。将视图相关的代码与非视图代码分离是一种良好的实践,可以确保代码更干净、更易于维护。
通过自定义钩子重构 MenuList
我们不陌生于使用自定义钩子进行数据获取——这是一种增强可读性和整洁逻辑的实践。在我们的场景中,将menuItems
状态和获取逻辑提取到单独的钩子中,将使MenuList
组件变得简洁。
那么,让我们创建一个名为useMenuItems
的钩子:
const useMenuItems = () => {
const [menuItems, setMenuItems] = useState<MenuItem[]>([]);
useEffect(() => {
const fetchMenuItems = async () => {
const result = await fetch(
"https://api.code-oven.com/menus"
);
const menuItems = await result.json();
setMenuItems(
menuItems.map((item: RemoteMenuItem) => {
// ... transform RemoteMenuItem to MenuItem
})
);
};
fetchMenuItems();
}, []);
return { menuItems };
};
在useMenuItems
钩子内部,我们使用空数组初始化menuItems
状态。当钩子挂载时,它触发fetchMenuItems
函数,从指定的 URL 获取数据。在获取之后,执行映射操作将每个RemoteMenuItem
对象转换为MenuItem
对象。转换的细节在此省略,但这是我们适应获取数据到所需格式的位置。随后,转换后的菜单项被设置为menuItems
状态。
现在,在我们的MenuList
组件中,我们可以简单地调用useMenuItems
来获取menuItems
数组:
const MenuList = ({
onAddMenuItem,
}: {
onAddMenuItem: (item: string) => void;
}) => {
const { menuItems } = useMenuItems();
//...
}
这种重构非常有益,将MenuList
重新定向到一个简化的状态,并恢复其单一职责。然而,当我们把注意力转向useMenuItems
钩子,特别是数据映射部分时,发生了一些操作。它从远程数据中获取数据,并删除了一些未使用的字段,如description
和calories
。它还封装了仅保留前三个配料的逻辑。理想情况下,我们希望将这种转换逻辑集中到一个公共位置,确保代码整洁且易于管理。
过渡到基于类模型
如在第第八章中所述,将MenuItem
类型定义应用于类中,从而将所有映射逻辑集中在这个类中。这种设置将作为一个专门的中心,用于处理任何未来的数据形状变更和相关逻辑。
将MenuItem
从类型转换为类是直接的。我们需要一个构造函数来接受RemoteMenuItem
和一些获取函数来访问数据:
export class MenuItem {
private readonly _id: string;
private readonly _name: string;
private readonly _type: string;
private readonly _price: number;
private readonly _ingredients: string[];
constructor(item: RemoteMenuItem) {
this._id = item.id;
this._name = item.name;
this._price = item.price;
this._type = item.category;
this._ingredients = item.ingredients;
}
// ... getter functions for id, name, price just returns the private
fields
get type() {
return this._type.toLowerCase();
}
get ingredients() {
return this._ingredients.slice(0, 3);
}
}
在MenuItem
类中,我们为id
、name
、type
、price
和ingredients
定义了私有的readonly
属性。构造函数使用传递给它的RemoteMenuItem
对象中的值来初始化这些属性。然后我们有每个属性的获取方法,以提供对其值的只读访问。特别是,ingredients
获取方法只返回ingredients
数组中的前三个项目。
虽然乍一看,这种设置似乎比简单类型定义的代码更多,但它有效地封装了数据并以受控的方式暴露它。这与不可变性和封装的原则相一致。类结构的美丽之处在于它能够容纳行为——在我们的案例中,配料切片逻辑被整洁地封装在类中。
在这个新类到位后,我们的useMenuItems
钩子变得更加简洁:
export const useMenuItems = () => {
//...
useEffect(() => {
const fetchMenuItems = async () => {
//...
setMenuItems(
menuItems.map((item: RemoteMenuItem) => {
return new MenuItem(item);
})
);
};
fetchMenuItems();
}, []);
return { menuItems };
};
现在,useMenuItems
钩子仅仅映射到获取的菜单项,为每个创建一个新的MenuItem
实例,这显著清理了之前在钩子中存放的转换逻辑。
基于类模型的益处
从简单类型过渡到基于类模型带来了一系列优势,这些优势可以从长远来看为我们的应用程序提供良好的服务:
-
封装:类将相关的属性和方法集中在一起,从而促进清晰的架构和组织。它还限制了直接的数据访问,促进了更好的控制和数据完整性。
-
方法行为:对于与菜单项相关联的复杂行为或操作,类提供了一个结构化的平台来定义这些方法,无论它们是关于数据处理还是其他业务逻辑。
-
继承和多态:在菜单项之间存在层次结构或多态行为的情况下,类结构是必不可少的。它允许不同的菜单项类型从公共基类继承,根据需要覆盖或扩展行为。
-
一致的界面:类确保了对数据的统一接口,这在多个应用程序部分与菜单项交互时非常有价值。
-
只读属性:类允许定义只读属性,从而控制数据突变。这是维护数据完整性和使用不可变数据结构的一个关键方面。
现在,随着我们过渡到通过购物车扩展应用程序的功能,以从我们的数据建模练习中吸取的教训来处理这个新的部分至关重要。这将确保结构化和有效的实现,为用户友好的在线订购体验铺平道路。
实现购物车组件
在我们实施ShoppingCart
组件的过程中,我们的目标是提供一个无缝的界面,让用户在结账前查看他们所选的商品。除了显示商品外,我们还打算通过一些吸引人的折扣政策奖励我们的客户。
在第七章中,我们定义了一个基本的ShoppingCart
组件,如下所示:
export const ShoppingCart = ({ cartItems }: { cartItems: string[] }) => {
return (
<div data-testid="shopping-cart">
<ol>
{cartItems.map((item) => (
<li key={item}>{item}</li>
))}
</ol>
<button disabled={cartItems.length === 0}>Place My Order
</button>
</div>
);
};
ShoppingCart
组件接受一个cartItems
属性,它是一个字符串数组。它返回一个包含有序列表(<ol>
)的div
标签,其中cartItems
数组中的每个项目都被渲染为一个列表项(<li>
)。在列表下方,cartItems
数组为空。
然而,为了增强用户体验,显示每个商品的价格和总价在项目列表下方、提交订单按钮上方至关重要。以下是我们可以如何增强我们的组件以满足这些要求:
export const ShoppingCart = ({ cartItems }: { cartItems: MenuItem[] }) => {
const totalPrice = cartItems.reduce((acc, item) => (acc += item.price), 0);
return (
<div data-testid="shopping-cart" className="shopping-cart">
<ol>
{cartItems.map((item) => (
<li key={item.id}>
<h3>{item.name}</h3>
<span>${item.price}</span>
</li>
))}
</ol>
<div>Total: ${totalPrice}</div>
<button disabled={cartItems.length === 0}>Place My Order
</button>
</div>
);
};
ShoppingCart
组件现在可以接受一个cartItems
属性,它包含一个MenuItem
对象数组(而不是简单的字符串)。为了计算购物车中商品的总价,我们使用reduce
方法。此方法遍历每个项目,累计其价格以显示总价。然后组件返回一个 JSX 标记,渲染购物车项目的列表,每个项目都显示其名称和价格。
这个改进的ShoppingCart
组件不仅增强了用户对订单的清晰度,还为引入折扣政策奠定了基础,我们可以在我们继续完善应用程序的过程中探索这些政策。
应用折扣到商品
假设我们对不同类型的菜单项有不同的折扣政策。例如,含有三个以上配料的披萨享有 10%的折扣,而大型意面菜肴享有 15%的折扣。
为了实现这一点,我们最初尝试通过一个名为calculateDiscount
的新字段扩展MenuItem
类:
export class MenuItem {
//... the private fields
constructor(item: RemoteMenuItem) {
//... assignment
}
get calculateDiscount() {
return this.type === 'pizza' && this.toppings >= 3 ? this.price *
0.1 : 0;
}
}
然而,我们遇到了一个问题——由于意面菜品没有配料,这导致了一个类型错误。
为了解决这个问题,我们首先提取了一个名为 IMenuItem
的接口,然后让 PizzaMenuItem
和 PastaMenuItem
类实现此接口:
export interface IMenuItem {
id: string;
name: string;
type: string;
price: number;
ingredients: string[];
calculateDiscount(): number;
}
接下来,我们定义一个抽象类来实现接口,允许 PizzaMenuItem
和 PastaMenuItem
分别扩展这个抽象类:
export abstract class AbstractMenuItem implements IMenuItem {
private readonly _id: string;
private readonly _name: string;
private readonly _price: number;
private readonly _ingredients: string[];
protected constructor(item: RemoteMenuItem) {
this._id = item.id;
this._name = item.name;
this._price = item.price;
this._ingredients = item.ingredients;
}
static from(item: IMenuItem): RemoteMenuItem {
return {
id: item.id,
name: item.name,
price: item.price,
category: item.type,
ingredients: item.ingredients,
};
}
//... the getter functions
abstract calculateDiscount(): number;
}
在 AbstractMenuItem
类中,我们引入了一个静态的 from
方法。该方法接受一个 IMenuItem
实例,并将其转换为 RemoteMenuItem
实例,保留了我们应用程序所需的所有字段。
calculateDiscount
方法被声明为一个抽象方法,要求其子类实现实际的折扣计算。
注意
一个 抽象类 作为其他类的基类,不能单独实例化。它是一种定义一组派生类公共接口和/或实现的方式。抽象类通常包含抽象方法,这些方法声明时不包含实现,留由派生类提供具体的实现。通过这种方式,抽象类确保了公共结构,同时确保某些方法在派生类中得到实现,从而在所有派生类型之间促进了一致的行为。它们是面向对象编程中的关键特性,支持多态和封装。
我们需要在子类中重写并放置实际的 calculateDiscount
逻辑。对于 PizzaMenuItem
,它简单地扩展了 AbstractMenuItem
并实现了 calculateDiscount
:
export class PizzaMenuItem extends AbstractMenuItem {
private readonly toppings: number;
constructor(item: RemoteMenuItem, toppings: number) {
super(item);
this.toppings = toppings;
}
calculateDiscount(): number {
return this.toppings >= 3 ? this.price * 0.1 : 0;
}
}
PizzaMenuItem
类继承自 AbstractMenuItem
,继承了其属性和方法。它定义了一个私有的 readonly
属性 toppings
,用于存储配料数量。在构造函数中,它接受两个参数:RemoteMenuItem
和 toppings
(表示配料数量)。它使用 super(item)
调用 AbstractMenuItem
的构造函数,并用传入的 toppings
参数初始化 this.toppings
。
calculateDiscount
方法被实现为,如果配料数量为 3 个或更多,则返回 10%的折扣。此方法覆盖了来自 AbstractMenuItem
的抽象 calculateDiscount
方法。
同样,我们可以创建一个 PastaMenuItem
类,如下所示:
export class PastaItem extends AbstractMenuItem {
private readonly servingSize: string;
constructor(item: RemoteMenuItem, servingSize: string) {
super(item);
this.servingSize = servingSize;
}
calculateDiscount(): number {
return this.servingSize === "large" ? this.price * 0.15 : 0;
}
}
这些类之间的关系可以如图 11.7 所示:
图 11.7:模型类
AbstractMenuItem
抽象类实现了 IMenuItem
接口并使用 RemoteMenuItem
。PizzaItem
和 PastaItem
都扩展了 AbstractMenuItem
并有自己的折扣计算逻辑。
接下来,在 MenuList
组件中,当向购物车添加项目时,我们根据项目类型创建正确的类实例:
export const MenuList = ({}) => {
//...
const [toppings, setToppings] = useState([]);
const [size, setSize] = useState<string>("small");
const handleAddMenuItem = (item: IMenuItem) => {
const remoteItem = AbstractMenuItem.from(item);
if (item.type === "pizza") {
onAddMenuItem(new PizzaMenuItem(remoteItem, toppings.length));
} else if (item.type === "pasta") {
onAddMenuItem(new PastaItem(remoteItem, size));
} else {
onAddMenuItem(item);
}
};
return (
//...
);
};
handleAddMenuItem
函数使用 AbstractMenuItem.from(item)
方法将 IMenuItem
对象 item
转换为 RemoteMenuItem
对象。随后,它检查 item
的类型属性以确定它是否是披萨还是意面。如果是披萨,则使用 remoteItem
和选定的配料数量创建一个新的 PizzaMenuItem
实例,并通过 onAddMenuItem
函数将这个新项目添加到购物车中。如果项目既不是披萨也不是意面,则直接通过 onAddMenuItem
函数将原始项目添加到购物车中。
最后,在 ShoppingCart
组件中,我们像计算总价一样计算总折扣值,并用于渲染:
export const ShoppingCart = ({ cartItems }: { cartItems: IMenuItem[] }) => {
const totalPrice = cartItems.reduce((acc, item) => (acc += item.price), 0);
const totalDiscount = cartItems.reduce(
(acc, item) => (acc += item.calculateDiscount()),
0
);
return (
<div data-testid="shopping-cart">
{/* rendering the list */}
<div>Total Discount: ${totalDiscount}</div>
<div>Total: ${totalPrice - totalDiscount}</div>
<button disabled={cartItems.length === 0}>Place My Order
</button>
</div>
);
};
ShoppingCart
组件通过遍历 cartItems
数组并累加每个项目的价格来计算 totalPrice
。同样,它通过调用每个项目的 calculateDiscount()
方法来计算 totalDiscount
,即累加每个项目的折扣。在返回的 JSX 中,它渲染一个列表,并显示 totalDiscount
和最终的总价(即 totalPrice
减去 totalDiscount
)。
在这个阶段,函数运行得非常有效。然而,还有几个因素需要考虑——折扣目前是针对每个产品指定的:例如,披萨有自己的折扣规则,而意面有自己的。如果我们需要实现全店折扣,比如公共假期的折扣,我们的方法会是什么?
探索策略模式
假设是繁忙的周五晚上,我们希望对所有披萨和饮料提供特别折扣。然而,我们不想对已经打折的项目应用额外的折扣——例如,四种配料的披萨只能获得这个特定的特别折扣。
处理这样的任意折扣可能很复杂,需要将计算逻辑从项目类型中解耦。此外,我们希望有灵活性在周五之后或一定时期后移除这些折扣。
我们可以使用名为 策略模式 的设计模式来实现这里的灵活性。策略模式是一种行为设计模式,它允许在运行时选择算法的实现。它封装了一组算法,并使它们可互换,允许客户端选择最合适的一个,而无需修改代码。
我们将提取逻辑到一个单独的实体中,定义一个策略接口如下:
export interface IDiscountStrategy {
calculate(price: number): number;
}
此接口为不同的折扣策略提供了一个蓝图。例如,我们可以有一个没有折扣的策略:
class NoDiscountStrategy implements IDiscountStrategy {
calculate(price: number): number {
return 0;
}
}
NoDiscountStrategy
类实现了 IDiscountStrategy
接口,并带有 calculate
方法,该方法接受一个价格作为输入并返回零,这意味着没有应用折扣。
对于 SpecialDiscountStrategy
组件,将应用一个提供 15% 折扣的特殊折扣策略:
class SpecialDiscountStrategy implements IDiscountStrategy {
calculate(price: number): number {
return price * 0.15;
}
}
要利用这些策略,我们需要稍微修改一下 IMenuItem
接口:
export interface IMenuItem {
// ... other fields
discountStrategy: IDiscountStrategy;
}
我们在 IMenuItem
接口中添加了 discountStrategy
类型为 IDiscountStrategy
。由于我们将计算折扣的逻辑移动到了策略中,我们不再需要在 AbstractMenuItem
中使用 calculateDiscount
抽象方法,因此该类将不再保持抽象状态,所以我们将其重命名为 BaseMenuItem
。相反,它将包含一个用于折扣策略的设置器并实现折扣计算:
export class BaseMenuItem implements IMenuItem {
// ... other fields
private _discountStrategy: IDiscountStrategy;
constructor(item: RemoteMenuItem) {
// ... other fields
this._discountStrategy = new NoDiscountStrategy();
}
// ... other getters
set discountStrategy(strategy: IDiscountStrategy) {
this._discountStrategy = strategy;
}
calculateDiscount() {
return this._discountStrategy.calculate(this.price);
}
}
BaseMenuItem
类现在实现了 IMenuItem
接口,并封装了一个折扣策略,最初设置为 NoDiscountStrategy
。它定义了一个设置器来更新折扣策略,以及一个 calculateDiscount
方法,该方法将折扣计算委托给封装的折扣策略的 calculate
方法,并将商品的价格作为参数传递。
图 11.8 现在应该能让你更清楚地了解关系:
图 11.8:所有类的类图
如观察所示,BaseMenuItem
实现了 IMenuItem
接口并使用 IDiscountStrategy
。存在多个 IDiscountStrategy
接口的实现,用于特定的折扣算法,并且有多个类扩展了 BaseMenuItem
类。
注意,RemoteMenuItem
类型被所有实现 IMenuItem
接口的类使用。
现在,当我们需要应用特定的策略时,可以轻松完成,如下所示:
export const MenuList = ({
onAddMenuItem,
}: {
onAddMenuItem: (item: IMenuItem) => void;
}) => {
// ...
const handleAddMenuItem = (item: IMenuItem) => {
if (isTodayFriday()) {
item.discountStrategy = new SpecialDiscountStrategy();
}
onAddMenuItem(item);
};
在 MenuList
组件中,handleAddMenuItem
函数使用 isTodayFriday
函数检查今天是否是星期五。如果是,它在将项目传递给接收作为属性的 onAddMenuItem
函数之前,将项目的 discountStrategy
设置为 SpecialDiscountStrategy
的新实例。这样,在星期五对菜单项应用特殊折扣。
这种设置为我们提供了所需的灵活性。例如,在 handleAddMenuItem
函数中,根据是否是星期五或项目是披萨,我们可以轻松切换折扣策略:
const handleAddMenuItem = (item: IMenuItem) => {
if (isTodayFriday()) {
item.discountStrategy = new SpecialDiscountStrategy();
}
if(item.type === 'pizza') {
item.discountStrategy = new PizzaDiscountStrategy();
}
onAddMenuItem(item);
};
在这个 handleAddMenuItem
函数中,根据某些条件,在将项目传递给 onAddMenuItem
函数之前,对项目应用不同的折扣策略。最初,它使用 isTodayFriday()
检查今天是否是星期五,如果是,则将 SpecialDiscountStrategy
的新实例分配给 item.discountStrategy
。然而,如果项目是 pizza
类型,无论哪一天,它都会用 PizzaDiscountStrategy
的新实例覆盖 item.discountStrategy
。
这种方法使我们的折扣逻辑模块化且易于调整,通过最小化代码修改来适应不同的场景。随着我们从应用程序代码中提取新的逻辑组件——钩子、数据模型、领域逻辑(折扣策略)和视图,它正在演变成一个分层的前端应用程序。
深入分层架构
我们的应用已经完美过渡到一个更健壮的状态,具有清晰、易懂且可修改的逻辑,现在也更加便于测试。
我设想的一个进一步改进是将ShoppingCart
中存在的逻辑移至自定义 Hook。我们可以这样做:
export const useShoppingCart = (items: IMenuItem[]) => {
const totalPrice = useMemo(
() => items.reduce((acc, item) => (acc += item.price), 0),
[items]
);
const totalDiscount = useMemo(
() => items.reduce((acc, item) => (acc += item.
calculateDiscount()), 0),
[items]
);
return {
totalPrice,
totalDiscount,
};
};
useShoppingCart
Hook 接受一个IMenuItem
对象数组,并计算两个值——totalPrice
和totalDiscount
:
-
totalPrice
是通过减少项目数量,对它们的price
属性进行求和来计算的 -
totalDiscount
是通过减少项目数量,对每个项目通过调用item.calculateDiscount()
获得的折扣进行求和来计算的
这两个计算都被useMemo
包装,以确保只有在项目数组发生变化时才会重新计算。
通过这次修改,ShoppingCart
变得简洁优雅,可以轻松利用这些值:
export const ShoppingCart = ({ cartItems }: { cartItems: IMenuItem[] }) => {
const { totalPrice, totalDiscount } = useShoppingCart(cartItems);
return (
{/* JSX for the rendering logic */}
);
};
另一种方法可能是使用 context 和useReducer
Hook 来管理上下文中和 Hooks 中的所有逻辑,然而,由于我们在第八章中已经探讨了这一点,我将进一步的探索留给你们(你们可以使用第八章中提供的代码示例以及本章,并尝试使用context
和useReducer
来简化ShoppingCart
)。
应用程序的分层结构
我们已经深入探讨了将组件和模型组织到单独的文件中;继续改进我们的项目结构同样至关重要。具有不同职责的函数应该位于不同的文件夹中,这样可以简化应用程序的导航并节省时间。我们的应用程序现在展现出了新的结构解剖学:
src
├── App.tsx
├── hooks
│ ├── useMenuItems.ts
│ └── useShoppingCart.ts
├── models
│ ├── BaseMenuItem.ts
│ ├── IMenuItem.ts
│ ├── PastaItem.ts
│ ├── PizzaMenuItem.ts
│ ├── RemoteMenuItem.ts
│ └── strategy
│ ├── IDiscountStrategy.ts
│ ├── NoDiscountStrategy.ts
│ ├── SpecialDiscountStrategy.ts
│ └── TenPercentageDiscountStrategy.ts
└── views
├── MenuList.tsx
└── ShoppingCart.tsx
正是这样形成了层。在视图层中,我们主要使用纯 TSX 渲染直接标签。这些视图利用 Hooks 进行状态和副作用管理。同时,在模型层中,模型对象包含业务逻辑、在不同折扣策略之间切换的算法和数据形状转换等功能。这种结构促进了关注点的分离,使得代码更加有序、可重用且易于维护。
需要注意的是这里的一个单向链接;上层访问下层,但反之则不然。TSX 使用 Hooks 进行状态管理,Hooks 使用模型进行计算。然而,我们无法在模型层中使用 JSX 或 Hooks。这种分层技术使得在不影响上层的情况下,可以方便地更改或替换底层,促进了干净且易于维护的结构。
在我们的 Code Oven 应用程序中,如图图 11.9所示,布局包括左侧的菜单项列表和右侧的购物车。在购物车中,每个项目在页面上显示详细的折扣和价格信息。
图 11.9:应用程序的最终外观和感觉
分层架构的优势
分层架构带来了许多好处:
-
增强可维护性:将组件划分为不同的部分,便于更容易地识别和纠正特定代码部分的缺陷,从而最小化花费的时间和减少在修改过程中产生新错误的可能性。
-
增加模块化:这种架构本质上是更模块化的,促进了代码重用,简化了新功能的添加。即使在每个层,如视图层,代码也往往更易于组合。
-
增强可读性:代码中的逻辑变得更加易于理解和导航,这不仅对原始开发者有益,也对可能与之交互的其他人有益。这种清晰度对于在代码中实施变更至关重要。
-
提高可扩展性:每个模块内的复杂性降低,使得应用程序更易于扩展,更容易引入新功能或变更,而不会影响整个系统——这对于预计会随时间演变的庞大、复杂的应用程序来说是一个关键优势。
-
技术栈迁移:尽管在大多数项目中不太可能,但如果需要,可以通过封装在纯 JavaScript(或 TypeScript)代码中的领域逻辑(对视图的存在无感知),在不改变底层模型和逻辑的情况下替换视图层。
摘要
在本章中,我们在应用程序中实现了分层架构,增强了其可维护性、模块化、可读性、可扩展性和技术栈迁移的潜力。通过分离逻辑,通过自定义钩子精炼ShoppingCart
组件,并将应用程序组织成不同的层,我们显著增强了代码的结构和管理便捷性。这种架构方法不仅简化了当前的代码库,还为未来的扩展和改进奠定了坚实的基础。
在下一章中,我们将探讨从头开始实现应用程序的端到端旅程,使用用户验收测试驱动的开发方法,在过程中进行重构、清理,并始终努力保持我们的代码尽可能干净。
第十二章:实现端到端项目
在前面的章节中,我们深入探讨了各种主题,包括测试、测试驱动开发(TDD)、设计模式和设计原则。这些概念非常有价值,因为它们为构建更健壮和可维护的代码库铺平了道路。现在,我想开始一段从零开始构建应用程序的旅程,将我们所学知识应用于解决端到端场景。
目标是展示我们如何将需求分解为可执行的任务,然后进行测试和实现。我们还将探讨如何模拟网络请求,从而在开发过程中消除对远程系统的依赖,以及如何在没有担心破坏现有功能的情况下自信地重构代码。
我们将从头开始构建一个功能性的天气应用程序,与真实的天气 API 服务器接口以获取和显示天气数据列表。在这个过程中,我们将实现诸如键盘交互等无障碍功能,回顾反腐败层(ACL)和单一职责原则,以及更多内容。
总体目标是展示构建一个功能软件解决方案的端到端过程,同时保持代码的可维护性、可理解性和可扩展性。
将涵盖以下主题:
-
审查天气应用程序的要求
-
编写我们的初始验收测试
-
实现城市搜索功能
-
实现 ACL
-
实现添加到收藏夹功能
-
当应用程序重新启动时获取以前的天气数据
技术要求
已创建一个 GitHub 仓库来托管本书中将要讨论的所有代码。对于本章,您可以在github.com/PacktPublishing/React-Anti-Patterns/tree/main/code/src/ch12
找到推荐的架构。
在我们继续之前,我们需要完成几个步骤。请遵循下一节以设置必要的 API 密钥。
获取 OpenWeatherMap API 密钥
要使用 OpenWeatherMap,您需要在openweathermap.org/
创建一个账户。尽管根据使用情况有多种计划可供选择,但免费计划足以满足我们的需求。注册后,导航到我的 API 密钥以找到您的 API 密钥,如图图 12.1所示:
图 12.1:OpenWeatherMap API 密钥
请将此密钥随身携带,因为我们将会用它来调用天气 API 以获取数据。
准备项目的代码库
如果您想跟我一起做,在我们开始之前,您需要安装几个包。然而,如果您想看到最终结果,它们已经在前面提到的仓库中。我建议您跟随操作,看看我们如何将应用程序逐步发展到最终状态。
为了开始,我们将使用以下命令创建一个新的 React 应用程序:
npx create-react-app weather-app --template typescript
cd weather-app
yarn add cypress jest-fetch-mock -D
yarn install
这些命令用于设置一个新的 React 项目,使用 TypeScript 和 Cypress:
-
npx create-react-app weather-app --template typescript
:此命令使用npx
运行create-react-app
实用程序,在名为weather-app
的目录中构建一个新的 React 应用程序。--template typescript
选项指定该项目应配置为使用 TypeScript。 -
yarn add cypress jest-fetch-mock -D
:此命令将 Cypress(一个测试框架)作为开发依赖项安装到项目中,并将jest-fetch-mock
用于在 jest 测试中模拟fetch
函数。-D
标志表示这是一个开发依赖项,这意味着它不是应用程序生产版本所必需的。 -
yarn install
:此命令安装项目package.json
文件中列出的所有依赖项,确保所有必要的库和工具都可用。
最后,我们可以通过运行以下命令启动模板应用程序:
yarn start
这将在端口 3000 上启动应用程序。您可以将应用程序在端口 3000 上运行并保持打开状态,然后在另一个终端窗口中运行测试。
审查天气应用程序的要求
我们构想的天气应用程序旨在成为一个功能齐全的平台,具有以下功能:
-
使用户能够搜索感兴趣的城市,无论是他们的家乡、当前居住地还是未来的旅行目的地
-
允许用户将城市添加到收藏夹中,选择将持久化本地,以便在未来的访问中轻松访问
-
支持向用户的列表中添加多个城市
-
确保网站可以通过键盘完全导航,从而方便所有用户访问
结果将类似于图 12**.2中所示:
图 12.2:天气应用程序
虽然这不是一个过于复杂的应用程序,但它包含了几个有趣元素。例如,我们将克服在 UI 应用程序中应用 TDD 的障碍,测试 Hooks,并在何时使用用户验收测试与较低级别的测试之间做出明智的决定。
我们将开始一个初始验收测试,以确保应用程序端到端运行,尽管它只是验证单个文本元素的外观。
构建我们的初始验收测试
第七章 使我们熟悉了从验收测试开始的概念——这种测试是从最终用户的角度出发的,而不是从开发者的角度。本质上,我们旨在让我们的测试验证用户在网页上会感知或与之交互的方面,而不是像函数调用或类初始化这样的技术细节。
在您在技术要求部分创建的文件夹中(即weather-app
),在cypress/e2e/weather.spec.cy.ts
中创建一个 Cypress 测试:
describe('weather application', () => {
it('displays the application title', () => {
cy.visit('http://localhost:3000/');
cy.contains('Weather Application');
});
});
在此代码片段中,我们定义了一个名为weather application
的测试套件。它使用 Cypress 测试框架中的 describe 函数。此测试用例包括两个主要操作:使用cy.visit
导航到本地开发服务器 http://localhost:3000/,然后使用cy.contains
检查页面以确保它包含Weather Application
文本。如果找到Weather Application
,则测试将通过;如果没有找到,则测试将失败。
使用npx cypress run
执行测试时,正如预期的那样,由于我们的应用程序尚未修改,控制台将显示错误:
1) weather application
displays the application title:
AssertionError: Timed out retrying after 4000ms: Expected to find
content: 'Weather Application' but never did.
at Context.eval (webpack://tdd-weather/./cypress/e2e/weather.
spec.cy.ts:4:7)
此错误表明它预期会找到Weather Application
文本,但未在 Cypress 指定的默认 4 秒超时时间内找到它。为了纠正这一点,我们需要调整App.tsx
,使其包含此文本。
在清除App.tsx
中的当前内容(由create-react-app
生成)后,我们将插入一个简单的h1
标签以显示文本:
import React from 'react';
function App() {
return (
<div className="App">
<h1>Weather Application</h1>
</div>
);
}
此代码在 React 中定义了一个名为App
的功能组件,它渲染一个包含Weather Application
文本的div
元素。有了这个标题的定义,我们的 Cypress 测试将通过。
现在,让我们继续到第一个有意义的特性——允许用户按城市名称搜索。
实现城市搜索功能
让我们开始开发我们的第一个特性——城市搜索。用户将能够将城市名称输入到搜索框中,这将触发对远程服务器的请求。在接收到数据后,我们将将其渲染成列表供用户选择。在整个章节中,我们将使用 OpenWeatherMap API 进行城市搜索以及获取天气信息。
介绍 OpenWeatherMap API
OpenWeatherMap 是一个通过 API 提供全球天气数据的服务,允许用户访问任何地点的当前、预测和历史天气数据。它是开发人员在应用程序和网站上嵌入实时天气更新的流行选择。
在我们的天气应用程序中,我们将使用两个 API:一个用于按名称搜索城市,另一个用于获取实际实时天气。要使用 API,您需要按照技术 要求部分中的说明获得的 API 密钥。
您可以尝试使用浏览器或命令行工具(如curl
或 http httpie.io/
)向 OpenWeatherMap 发送请求:
http https://api.openweathermap.org/geo/1.0/direct?q="Melbourne"&limit=5&appid=<your-app-key>
这行代码使用http
命令向 OpenWeatherMap API 发送 HTTP 请求,特别是其地理编码端点(geo/1.0/direct
),查找名为Melbourne
的城市,结果限制为 5 个。appid
(如前述 URL 中指定)参数是您需要插入您的 OpenWeatherMap API 密钥以验证请求的地方。
因此,该命令检索名为墨尔本的城市的基本地理编码信息,这些信息可以稍后用于获取这些位置的天气数据。您将得到如下所示的 JSON 格式的结果:
[
{
"country": "AU",
"lat": -37.8142176,
"local_names": {},
"lon": 144.9631608,
"name": "Melbourne",
"state": "Victoria"
},
{
"country": "US",
"lat": 28.106471,
"local_names": {
},
"lon": -80.6371513,
"name": "Melbourne",
"state": "Florida"
}
]
请注意,OpenWeatherMap 免费计划附带一个速率限制,限制我们每分钟最多 60 个请求和每月最多 1,000,000 个请求。虽然这些限制看起来很高,但在开发和测试调试过程中,请求的数量可以迅速累积。为了节省我们的请求配额,我们将避免向真实服务器发送请求,而是模拟这些请求,返回预定义的值。有关模拟的复习,请参阅第五章。
让我们将结果数据保存到名为search-results.json
的文本文件中,该文件位于cypress/fixtures/search-result.json
下。
模拟搜索结果
文件就绪后,我们可以为fixtures/search-result.json
编写一个测试:
import searchResults from '../fixtures/search-result.json';
describe('weather application', () => {
//...
it('searches for a city', () => {
cy.intercept("GET", "https://api.openweathermap.org/geo/1.0/
direct?q=*", {
statusCode: 200,
body: searchResults,
});
cy.visit('http://localhost:3000/');
cy.get('[data-testid="search-input"]').type('Melbourne');
cy.get('[data-testid="search-input"]').type('{enter}');
cy.get('[data-testid="search-results"] .search-result')
.should('have.length', 5);
});
});
在这里,我们创建了一个名为'searches for a city'
的测试用例。该测试用例执行以下操作:
-
首先,它设置了一个拦截针对 OpenWeatherMap API 城市搜索的
GET
请求。每当有符合标准的请求时,它以 200 状态码和来自预定义的searchResults
文件的正文内容作为响应,实际上模拟了 API 响应。 -
然后,它导航到运行在
http://localhost:3000/
的应用程序。 -
接下来,它模拟用户在一个具有
data-testid
值为search-input
的输入字段中输入Melbourne
,并按下Enter键。 -
最后,它检查一个具有
data-testid
值为"search-results"
的容器是否包含恰好五个具有search-result
类的元素,这些元素是从模拟的 API 请求返回的搜索结果。这验证了应用程序正确显示了搜索结果。
我们现在处于 TDD 的红色步骤(第一个步骤,表示测试失败),所以让我们进入我们的应用程序代码App.tsx
来修复测试:
function App() {
const [query, setQuery] = useState<string>("");
const [searchResults, setSearchResults] = useState<any[]>([]);
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
fetchCities();
}
};
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
};
const fetchCities = () => {
fetch(
`https://api.openweathermap.org/geo/1.0/direct?q=${query}&limit=
5&appid=<app-key>`
)
.then((r) => r.json())
.then((cities) => {
setSearchResults(
cities.map((city: any) => ({
name: city.name,
}))
);
});
};
return (
<div className="app">
<h1>Weather Application</h1>
<div className="search-bar">
<input
type="text"
data-testid="search-input"
onKeyDown={handleKeyDown}
onChange={handleChange}
placeholder="Enter city name (e.g. Melbourne, New York)"
/>
</div>
<div className="search-results-popup">
{searchResults.length > 0 && (
<ul data-testid="search-results">
{searchResults.map((city, index) => (
<li key={index} className="search-result">
{city.name}
</li>
))}
</ul>
)}
</div>
</div>
);
}
代码中的App
函数使用 React 设置了一个简单的天气应用程序。它初始化query
和searchResults
状态变量来处理用户输入和显示搜索结果。handleKeyDown
和handleChange
事件处理器被设置来更新搜索查询并在用户按下Enter时触发城市搜索。fetchCities
函数向 OpenWeatherMap API 发送请求,处理响应以提取城市名称,并更新searchResults
。
在 TSX 部分,提供了一个输入字段供用户输入城市名称,并且每当有可用的搜索结果时,都会显示一个列表。
这些更改后,测试现在通过,我们可以启动浏览器来访问应用程序。如图*图 12**.3 所示,我们的实现现在在执行搜索后显示城市下拉列表:
图 12.3:搜索结果下拉菜单
注意
我已经加入了一些 CSS 来增强视觉效果。然而,为了保持对核心内容的关注,CSS 在此处并未包含。为了全面理解,您可以参考技术要求部分中提到的仓库,以查看完整的实现。
增强搜索结果列表
由于我们使用城市名称进行搜索查询,在搜索结果中遇到多个匹配项是很常见的。为了细化这一点,我们可以为每个项目添加额外的详细信息,例如州名、国家名,甚至坐标,以使结果更加独特。
按照 TDD(测试驱动开发)方法,我们将从一个测试开始。虽然可以构建一个 Cypress 测试,但详细说明这些方面更适合于单元测试等低级测试。Cypress 测试是端到端的,包括所有部分——页面、网络(甚至带有拦截器)——并且从其角度来看,它感知不到组件,只有 HTML、CSS 和 JavaScript。这使得它们运行成本更高,与低级测试相比,低级测试通常在内存浏览器中运行,并关注隔离的区域。
为了这次增强,我们将使用 Jest 测试,它们更轻量级、更快,并且在测试用例编写中提供特异性。
在下面的代码片段中,我们旨在测试一个项目是否显示城市名称:
it("shows a city name", () => {
render(<SearchResultItem item={{ city: "Melbourne" }} />);
expect(screen.getByText("Melbourne")).toBeInTheDocument();
});
在这里,我们调用 React Testing Library 中的render
方法,对一个带有city
字段的SearchResultItem
组件进行传递属性。然后,我们断言文档中存在Melbourne
文本。
到目前为止,我们还没有准备好用于测试的SearchResultItem
组件。然而,一点重构可以帮助我们提取一个。让我们创建一个SearchResultItem.tsx
文件,并按如下定义组件:
export const SearchResultItem = ({ item }: { item: { city: string } }) => {
return <li className="search-result">{item.city}</li>;
};
现在,将组件集成到App.tsx
中:
function App() {
//...
<div className="search-results-popup">
{searchResults.length > 0 && (
<ul data-testid="search-results" className="search-results">
{searchResults.map((city, index) => (
<SearchResultItem key={index} item={{ city }} />
))}
</ul>
)}
</div>
//...
}
在App.tsx
的这一部分,我们遍历searchResults
,为每个城市渲染SearchResultItem
,并将城市数据作为属性传递。
现在,让我们扩展我们的测试,以检查城市名称、州和国家:
it("shows a city name, the state, and the country", () => {
render(
<SearchResultItem
item={{ city: "Melbourne", state: "Victoria", country:
"Australia" }}
/>
);
expect(screen.getByText("Melbourne")).toBeInTheDocument();
expect(screen.getByText("Victoria")).toBeInTheDocument();
expect(screen.getByText("Australia")).toBeInTheDocument();
});
接下来,为了适应这些新字段,我们将调整SearchResultItem
的类型定义,并渲染传递的属性:
type SearchResultItemProps = {
city: string;
state: string;
country: string;
};
export const SearchResultItem = ({ item }: { item: SearchResultItemProps }) => {
return (
<li className="search-result">
<span>{item.city}</span>
<span>{item.state}</span>
<span>{item.country}</span>
</li>
);
};
在这里,我们定义一个SearchResultItemProps
类型,以指定项目属性的结构,确保它包含city
、state
和country
字段。然后,我们的SearchResultItem
组件将这些字段在列表项中渲染,每个字段都在一个单独的span
元素中。
如您所见,现在,列表项提供了更多细节,以帮助用户区分结果:
图 12.4:增强的城市下拉列表
在我们继续到下一个主要功能之前,让我们先处理一些日常维护任务。虽然我们一直专注于交付功能,但到目前为止,我们并没有太多关注代码质量,所以让我们来做这件事。
实现访问控制列表(ACL)
在我们的应用程序中,SearchResultItem
组件很好地完成了其任务。然而,挑战来自于我们所需要的数据形状与我们从远程服务器接收到的数据形状之间的差异。
考虑服务器的响应:
[
{
"country": "US",
"lat": 28.106471,
"local_names": {
"en": "Melbourne",
"ja": "メルボーン",
"ru": "Мельбурн",
"uk": "Мелборн"
},
"lon": -80.6371513,
"name": "Melbourne",
"state": "Florida"
}
]
服务器响应中包含许多我们不需要的元素。此外,我们希望保护我们的 SearchResultItem
组件免受服务器数据形状未来更改的影响。
正如我们在第八章中讨论的那样,我们可以使用访问控制列表(ACL)来解决这个问题。使用它,我们旨在直接映射城市名称和州,但对于国家,我们希望显示其全名,以避免在用户界面中产生任何歧义。
要做到这一点,首先,我们必须定义一个 RemoteSearchResultItem
类型来表示远程数据形状:
interface RemoteSearchResultItem {
city: string;
state: string;
country: string;
lon: number;
lat: number;
local_names: {
[key: string]: string
}
}
接下来,我们必须将 SearchResultItemProps
类型更改为类,使其可以在 TypeScript 代码中初始化:
const countryMap = {
"AU": "Australia",
"US": "United States",
"GB": "United Kingdom"
//...
}
class SearchResultItemType {
private readonly _city: string;
private readonly _state: string;
private readonly _country: string;
constructor(item: RemoteSearchResultItem) {
this._city = item.city;
this._state = item.state;
this._country = item.country
}
get city() {
return this._city
}
get state() {
return this._state
}
get country() {
return countryMap[this._country] || this._country;
}
}
这段代码定义了一个类,SearchResultItemType
,它在 constructor
中接受一个 RemoteSearchResultItem
对象,并相应地初始化其属性。它还提供了获取器方法来访问这些属性,对于国家属性,有一个特殊处理程序将国家代码映射到其全名。
现在,我们的 SearchResultItem
组件可以利用这个新定义的类:
import React from "react";
import { SearchResultItemType } from "./models/SearchResultItemType";
export const SearchResultItem = ({ item }: { item: SearchResultItemType }) => {
return (
<li className="search-result">
<span>{item.city}</span>
<span>{item.state}</span>
<span>{item.country}</span>
</li>
);
};
注意我们如何使用 item.city
和 item.state
获取器函数,就像一个常规的 JavaScript 对象一样。
然后,在我们的 Jest 测试中,我们可以直接验证转换逻辑,如下所示:
it("converts the remote type to local", () => {
const remote = {
country: "US",
lat: 28.106471,
local_names: {
en: "Melbourne",
ja: "メルボーン",
ru: "Мельбурн",
uk: "Мелборн",
},
lon: -80.6371513,
name: "Melbourne",
state: "Florida",
};
const model = new SearchResultItemType(remote);
expect(model.city).toEqual('Melbourne');
expect(model.state).toEqual('Florida');
expect(model.country).toEqual('United States');
});
在这个测试中,我们使用模拟的 RemoteSearchResultItem
对象创建一个 SearchResultItemType
实例,并验证转换逻辑是否按预期工作——字段被正确映射,并且国家也有其全名。
一旦测试确认了预期的行为,我们就可以在我们的应用程序代码中应用这个新类,如下所示:
const fetchCities = () => {
fetch(
`https://api.openweathermap.org/geo/1.0/direct?q=${query}&limit=5&
appid=<api-key>`
)
.then((r) => r.json())
.then((cities) => {
setSearchResults(
cities.map(
(item: RemoteSearchResultItem) => new
SearchResultItemType(item)
)
);
});
};
这个函数从远程服务器获取城市数据,将接收到的数据转换为 SearchResultItemType
实例,然后更新 searchResults
状态。
在下拉菜单中添加丰富详情后,用户可以识别他们想要的城镇。在实现这一点后,我们可以继续允许用户将城市添加到他们的收藏列表中,为显示这些选定城市的天气信息铺平道路。
我们的 Cypress 功能测试提供了一个保障,以防止意外破坏功能。此外,随着新引入的单元测试,远程和本地数据形状之间的任何差异都将自动检测。我们现在已经准备好开始开发下一个功能。
实现添加到收藏功能
让我们调查实现下一个功能:'添加城市到收藏列表'
。因为这个功能在天气应用中至关重要,我们想要确保用户可以看到添加的城市,并且下拉菜单已经关闭。
首先,我们将开始另一个 Cypress 测试:
it('adds city to favorite list', () => {
cy.intercept("GET", "https://api.openweathermap.org/geo/1.0/direct?q=*", {
statusCode: 200,
body: searchResults,
});
cy.visit('http://localhost:3000/');
cy.get('[data-testid="search-input"]').type('Melbourne');
cy.get('[data-testid="search-input"]').type('{enter}');
cy.get('[data-testid="search-results"] .search-result')
.first()
.click();
cy.get('[data-testid="favorite-cities"] .city')
.should('have.length', 1);
cy.get('[data-testid="favorite-cities"]
.city:contains("Melbourne")').should('exist');
cy.get('[data-testid="favorite-cities"] .city:contains("20°C")').
should('exist');
})
在测试中,我们设置了一个拦截来模拟对 OpenWeatherMap API 的 GET
请求,然后访问运行在本地的应用程序。从这里,它模拟在搜索输入中键入 Melbourne
并按 Enter。之后,它点击第一个搜索结果并检查收藏城市列表是否现在包含一个城市。最后,它验证收藏城市列表是否包含一个具有 Melbourne
和 20°C
的城市元素。
请注意,在最后两行中,有一些事情需要更多的解释:
-
cy.get(selector)
: 这是一个 Cypress 命令,用于查询页面上的 DOM 元素。它与document.querySelector
类似。在这里,它被用来选择 DOM 特定部分内具有特定文本内容的元素。Cypress 不仅支持基本的 CSS 选择器,如类和 ID 选择器,还支持高级选择器,如.city:contains("Melbourne")
,因此我们可以使用更具体的选择器。 -
.city:contains(text)
: 这是一个 Cypress 支持的 jQuery 风格的选择器。它允许您选择包含特定文本的元素。在这种情况下,它被用来查找[data-testid="favorite-cities"]
中具有city
类并包含Melbourne
或20°C
的元素。 -
.should('exist')
: 这是一个 Cypress 命令,用于断言选定的元素应该存在于 DOM 中。如果元素不存在,测试将失败。
现在,为了获取城市的天气,我们需要另一个 API 端点:
http https://api.openweathermap.org/data/2.5/weather?lat=-37.8142176&lon=144.9631608&appid=<api-key>&units=metric
API 需要两个参数:纬度和经度。
然后,它以这种格式返回当前的天气:
{
//...
"main": {
"feels_like": 20.75,
"humidity": 56,
"pressure": 1009,
"temp": 20.00,
"temp_max": 23.46,
"temp_min": 18.71
},
"name": "Melbourne",
"timezone": 39600,
"visibility": 10000,
"weather": [
{
"description": "clear sky",
"icon": "01d",
"id": 800,
"main": "Clear"
}
],
//...
}
响应中有许多字段,但我们目前只需要其中的一些。我们可以拦截请求并在 Cypress 测试中提供响应,就像我们对城市搜索 API 所做的那样:
cy.intercept('GET', 'https://api.openweathermap.org/data/2.5/weather*', {
fixture: 'melbourne.json'
}).as('getWeather')
转到实现方面,我们将在 SearchResultItem
中编织一个 onClick
事件处理程序;在点击项目时,将触发 API 调用,然后向用于渲染的列表中添加一个城市。
export const SearchResultItem = ({
item,
onItemClick,
}: {
item: SearchResultItemType;
onItemClick: (item: SearchResultItemType) => void;
}) => {
return (
<li className="search-result" onClick={() => onItemClick(item)}>
{ /* JSX for rendering the item details */ }
</li>
);
};
现在,让我们深入应用程序代码,将数据获取逻辑交织在一起:
const onItemClick = (item: SearchResultItemType) => {
fetch(
`http https://api.openweathermap.org/data/2.5/weather?lat=${item.latitude}&lon=${item.longitude}&appid=<api-key>&units=metric`
)
.then((r) => r.json())
.then((cityWeather) => {
setCity({
name: cityWeather.name,
degree: cityWeather.main.temp,
});
});
};
onItemClick
函数在点击城市项目时触发。它使用项目的纬度和经度向 OpenWeatherMap API 发起网络请求,获取所选城市的当前天气数据。然后,它将响应解析为 JSON,从解析的数据中提取城市名称和温度,并使用 setCity
函数更新城市状态,这将导致组件重新渲染并显示所选城市名称和当前温度。
注意前面片段中的 SearchResultItemType
参数。我们需要扩展此类型,使其包含纬度和经度。我们可以通过重新访问 SearchResultItemType
类中的 ACL 层来实现这一点:
class SearchResultItemType {
//... the city, state, country as before
private readonly _lat: number;
private readonly _long: number;
constructor(item: RemoteSearchResultItem) {
//... the city, state, country as before
this._lat = item.lat;
this._long = item.lon;
}
get latitude() {
return this._lat;
}
get longitude() {
return this._long;
}
}
有了这个,我们已经将 SearchResultItemType
扩展为两个新字段,latitude
和 longitude
,这些字段将在 API 查询中使用。
最后,在成功检索到城市数据后,是时候进行渲染了:
function App() {
const [city, setCity] = useState(undefined);
const onItemClick = (item: SearchResultItemType) => {
//...
}
return(
<div className="search-results-popup">
{searchResults.length > 0 && (
<ul data-testid="search-results">
{searchResults.map((item, index) => (
<SearchResultItem
key={index}
item={item}
onItemClick={onItemClick}
/>
))}
</ul>
)}
</div>
<div data-testid="favorite-cities">
{city && (
<div className="city">
<span>{city.name}</span>
<span>{city.degree}°C</span>
</div>
)}
</div>
);
}
在这段代码块中,将onItemClick
函数分配为每个SearchResultItem
的onClick
事件处理器。当点击城市时,会调用onItemClick
函数,触发一个获取选定城市天气数据的 fetch 请求。一旦获得数据,setCity
函数更新城市状态,然后触发重新渲染,显示在收藏****城市部分选定的城市。
现在所有测试都通过了,这意味着我们的实现与到目前为止的预期相符。然而,在我们进行下一个增强之前,进行一些重构以确保我们的代码库保持健壮和易于维护是非常重要的。
模拟天气
正如我们模拟了城市搜索结果一样,出于类似的原因——为了使我们的实现与远程数据形状隔离,以及集中数据形状转换、回退逻辑等。
几个区域需要改进。我们必须做以下几件事:
-
确保所有相关数据都已类型化
-
创建一个天气数据模型以集中所有格式化逻辑
-
在数据模型中使用回退值,当某些数据不可用时。
让我们从远程数据类型RemoteCityWeather
开始:
interface RemoteCityWeather {
name: string;
main: {
temp: number;
humidity: number;
};
weather: [{
main: string;
description: string;
}];
wind: {
deg: number;
speed: number;
};
}
export type { RemoteCityWeather };
在这里,我们定义了一个名为RemoteCityWeather
的类型来反映远程数据形状(并且也已经过滤掉了一些我们不使用的字段)。
然后,我们必须定义一个新的类型CityWeather
供我们的 UI 使用 CityWeather:
import { RemoteCityWeather } from "./RemoteCityWeather";
export class CityWeather {
private readonly _name: string;
private readonly _main: string;
private readonly _temp: number;
constructor(weather: RemoteCityWeather) {
this._name = weather.name;
this._temp = weather.main.temp;
this._main = weather.weather[0].main;
}
get name() {
return this._name;
}
get degree() {
return Math.ceil(this._temp);
}
get temperature() {
if (this._temp == null) {
return "-/-";
}
return `${Math.ceil(this._temp)}°C`;
}
get main() {
return this._main.toLowerCase();
}
}
这段代码定义了一个CityWeather
类来模拟城市天气数据。它接受一个RemoteCityWeather
对象作为构造函数参数,并从它初始化私有字段——即_name
、_temp
和_main
。该类提供了获取城市名称、四舍五入的温度(以摄氏度为单位)、格式化的温度字符串以及小写形式的天气描述的 getter 方法。
对于温度的getter
方法,如果_temp
为 null 或 undefined,它返回一个字符串-/-
。否则,它将使用Math.ceil(this._temp)
计算_temp
的向上取整(四舍五入到最接近的整数),并在其后面附加一个度符号,然后返回这个格式化的字符串。这样,当_temp
未设置时,该方法提供回退值-/-
,而当_temp
设置时,格式化温度值。
现在,在App
中,我们可以使用计算出的逻辑:
const onItemClick = (item: SearchResultItemType) => {
fetch(
`https://api.openweathermap.org/data/2.5/weather?lat=${item.latitude}&lon=${item.longitude}&appid=<api-key>&units=metric`
)
.then((r) => r.json())
.then((cityWeather: RemoteCityWeather) => {
setCity(new CityWeather(cityWeather));
setDropdownOpen(false);
});
};
当点击城市项目时,会触发onItemClick
函数。它使用点击的城市项目的经纬度向 OpenWeatherMap API 发起一个fetch
请求。在收到响应后,它将响应转换为 JSON 格式,然后使用接收到的数据创建一个新的CityWeather
实例,并使用setCity
更新城市状态。
此外,它通过将setDropdownOpen
状态设置为false
来关闭下拉菜单。如果我们不关闭它,Cypress 测试将无法“看到”底层的天气信息,这将导致测试失败,如下面的截图所示:
图 12.5:Cypress 测试失败,因为天气被遮挡
然后,我们必须相应地渲染所选城市的详细信息:
<div data-testid="favorite-cities">
{city && (
<div className="city">
<span>{city.name}</span>
<span>{city.temperature}</span>
</div>
)}
</div>
对于渲染城市部分,如果城市状态已定义(即已选择城市),它将显示一个具有city
类名的div
元素。在这个div
元素内部,我们可以使用city.name
和city.temperature
属性分别看到城市的名称和温度。
现在,通过一些额外的样式,我们的应用程序看起来是这样的:
图 12.6:将城市添加到收藏夹
我们在数据建模方面做得很好,并建立了一个坚实的访问控制列表(ACL)来增强我们 UI 的健壮性和易于维护性。然而,在检查根App
组件时,我们无疑会发现需要改进的地方。
重构当前实现
我们当前的App
组件已经变得过长,难以阅读和添加功能,这表明需要进行一些重构以整理代码。它并没有很好地遵循单一职责原则,因为它承担了多个职责:处理城市搜索和天气查询的网络请求,管理下拉列表的打开和关闭状态,以及多个事件处理器。
一种与更好的设计原则重新对齐的方法是将 UI 分解成更小的组件。另一种方法是利用自定义钩子进行状态管理。鉴于这里的大部分逻辑都围绕着管理城市搜索下拉列表的状态,因此开始时隔离这部分是合理的。
让我们先提取所有与城市搜索相关的逻辑到一个自定义钩子中:
const useSearchCity = () => {
const [query, setQuery] = useState<string>("");
const [searchResults, setSearchResults] =
useState<SearchResultItemType[]>(
[]
);
const [isDropdownOpen, setDropdownOpen] = useState<boolean>(false);
const fetchCities = () => {
fetch(
`https://api.openweathermap.org/geo/1.0/direct?q=${query}&limit=
5&appid=<api-key>`
)
.then((r) => r.json())
.then((cities) => {
setSearchResults(
cities.map(
(item: RemoteSearchResultItem) => new
SearchResultItemType(item)
)
);
openDropdownList();
});
};
const openDropdownList = () => setDropdownOpen(true);
const closeDropdownList = () => setDropdownOpen(false);
return {
fetchCities,
setQuery,
searchResults,
isDropdownOpen,
openDropdownList,
closeDropdownList,
};
};
export { useSearchCity };
useSearchCity
钩子管理城市搜索功能。它使用useState
初始化查询、搜索结果和下拉列表打开状态。fetchCities
函数触发网络请求以根据查询获取城市,处理响应以创建SearchResultItemType
实例,更新搜索结果状态,并打开下拉列表。定义了两个函数,openDropdownList
和closeDropdownList
,用于切换下拉列表的打开状态。钩子返回一个包含这些功能的对象,这些功能可以被导入和调用useSearchCity
的组件使用。
接下来,我们提取一个组件,SearchCityInput
,来处理所有与搜索输入相关的工作:处理Enter键以执行搜索,打开下拉列表,以及处理用户点击每个项:
export const SearchCityInput = ({
onItemClick,
}: {
onItemClick: (item: SearchResultItemType) => void;
}) => {
const {
fetchCities,
setQuery,
isDropdownOpen,
closeDropdownList,
searchResults,
} = useSearchCity();
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
fetchCities();
}
};
const handleChange = (e: ChangeEvent<HTMLInputElement>) =>
setQuery(e.target.value);
const handleItemClick = (item: SearchResultItemType) => {
onItemClick(item);
closeDropdownList();
};
return (
<>
<div className="search-bar">
<input
type="text"
data-testid="search-input"
onKeyDown={handleKeyDown}
onChange={handleChange}
placeholder="Enter city name (e.g. Melbourne, New York)"
/>
</div>
{isDropdownOpen && (
//... render the dropdown
)}
</>
);
};
SearchCityInput
组件负责渲染和管理用户用于搜索城市的输入,利用 useSearchCity
钩子访问搜索相关功能。
定义了 handleKeyDown
和 handleChange
函数来处理用户交互,在按下 Enter 键时触发搜索,并在输入更改时更新查询。然后,定义了 handleItemClick
函数来处理搜索结果项被点击时的动作,这会触发 onItemClick
属性函数并关闭下拉列表。
在 render
方法中,提供了一个输入字段供用户输入搜索查询,并根据 isDropdownOpen
状态条件性地渲染下拉列表。如果下拉列表打开且有搜索结果,则渲染一个 SearchResultItem
组件列表,每个组件都传递了当前项目数据和 handleItemClick
函数。
对于一个城市的所有天气逻辑,我们可以提取另一个钩子 useCityWeather
:
const useFetchCityWeather = () => {
const [cityWeather, setCityWeather] = useState<CityWeather |
undefined>(undefined);
const fetchCityWeather = (item: SearchResultItemType) => {
fetch(
`https://api.openweathermap.org/data/2.5/weather?lat=${item.latitude}&lon=${item.longitude}&appid=<api-key>&units=metric`
)
.then((r) => r.json())
.then((cityWeather: RemoteCityWeather) => {
setCityWeather(new CityWeather(cityWeather));
});
};
return {
cityWeather,
fetchCityWeather,
};
};
useFetchCityWeather
自定义钩子旨在管理为指定城市获取和存储天气数据。它维护一个状态 cityWeather
来保存天气数据。钩子提供了一个函数 fetchCityWeather
,它接受一个 SearchResultItemType
对象作为参数,以获取 API 调用的 latitude
和 longitude
值。
在收到响应后,它处理 JSON 数据,从 RemoteCityWeather
数据创建一个新的 CityWeather
对象,并使用它更新 cityWeather
状态。钩子返回 cityWeather
状态和 fetchCityWeather
函数,以便在其他组件(例如 Weather
)中使用。
我们可以将 Weather
组件提取出来,接受 cityWeather
并进行渲染:
const Weather = ({ cityWeather }: { cityWeather: CityWeather | undefined }) => {
if (cityWeather) {
return (
<div className="city">
<span>{cityWeather.name}</span>
<span>{cityWeather.degree}°C</span>
</div>
);
}
return null;
};
Weather
组件接受一个属性 cityWeather
。如果 cityWeather
被定义,组件将渲染一个带有 city
类名的 div
元素,显示城市的名称和摄氏度温度。如果 cityWeather
未定义,它将返回 null
。
通过提取钩子和组件,我们的 App.tsx
被简化成如下形式:
function App() {
const { cityWeather, fetchCityWeather } = useFetchCityWeather();
const onItemClick = (item: SearchResultItemType) =>
fetchCityWeather(item);
return (
<div className="app">
<h1>Weather Application</h1>
<SearchCityInput onItemClick={onItemClick} />
<div data-testid="favorite-cities">
<Weather cityWeather={cityWeather} />
</div>
</div>
);
}
在 App
函数中,我们使用 useFetchCityWeather
自定义钩子来获取 cityWeather
和 fetchCityWeather
值。定义了 onItemClick
函数,用于调用 fetchCityWeather
并传递 SearchResultItemType
类型的项目。在渲染部分,我们现在可以简单地使用我们提取的组件和函数。
如果我们打开项目文件夹来检查当前的文件夹结构,我们会看到我们在不同的模块中定义了不同的元素:
src
├── App.tsx
├── index.tsx
├── models
│ ├── CityWeather.ts
│ ├── RemoteCityWeather.ts
│ ├── RemoteSearchResultItem.ts
│ ├── SearchResultItemType.test.ts
│ └── SearchResultItemType.ts
├── search
│ ├── SearchCityInput.tsx
│ ├── SearchResultItem.test.tsx
│ ├── SearchResultItem.tsx
│ └── useSearchCity.ts
└── weather
├── Weather.tsx
├── useFetchCityWeather.test.ts
├── useFetchCityWeather.ts
└── weather.css
现在,经过所有这些工作,每个模块都拥有更清晰的边界和明确的职责。如果你想要深入了解搜索功能,SearchCityInput
就是你的起点。对于实际搜索执行的了解,你应该查看 useSearchCity
钩子。每个层级都保持自己的抽象和独特的职责,这显著简化了代码的理解和维护。
由于我们的代码状态良好,并且已经准备好添加更多功能,我们可以考虑增强当前的功能。
启用收藏夹列表中的多个城市
现在,让我们通过一个具体的例子来展示我们如何轻松地通过简单的功能升级来扩展现有的代码。用户可能有几个他们感兴趣的城市,但到目前为止,我们只能显示一个城市。
要允许在收藏夹列表中包含多个城市,我们应该修改哪个组件来实现这一变化?正确——useFetchCityWeather
钩子。为了使其能够管理城市列表,我们需要在App
中显示这个列表。没有必要深入研究与城市搜索相关的文件,这表明这种结构将我们筛选文件所需的时间减半。
由于我们正在进行 TDD,让我们首先为钩子编写一个测试:
const weatherAPIResponse = JSON.stringify({
main: {
temp: 20.0,
},
name: "Melbourne",
weather: [
{
description: "clear sky",
main: "Clear",
},
],
});
const searchResultItem = new SearchResultItemType({
country: "AU",
lat: -37.8141705,
lon: 144.9655616,
name: "Melbourne",
state: "Victoria",
});
首先,让我们定义一些我们想要测试的数据。我们可以使用weatherAPIResponse
初始化数据,它包含一个来自天气 API 的模拟响应的 JSON 字符串格式,以及searchResultItem
,它包含一个SearchResultItemType
实例,其中包含澳大利亚墨尔本的位置详情。
对于实际的测试用例,我们需要使用来自jest-fetch-mock
的fetchMock
;我们在技术要求部分安装了它:
describe("fetchCityWeather function", () => {
beforeEach(() => {
fetchMock.resetMocks();
});
it("returns a list of cities", async () => {
fetchMock.mockResponseOnce(weatherAPIResponse);
const { result } = renderHook(() => useFetchCityWeather());
await act(async () => {
await result.current.fetchCityWeather(searchResultItem);
});
await waitFor(() => {
expect(result.current.cities.length).toEqual(1);
expect(result.current.cities[0].name).toEqual("Melbourne");
});
});
});
之前的代码为fetchCityWeather
函数设置了一个测试套件。在每次测试之前,它会重置任何模拟的获取调用。测试用例旨在验证该函数返回一个城市列表。它使用fetchMock.mockResponseOnce
模拟 API 响应,然后调用useFetchCityWeather
自定义钩子。fetchCityWeather
函数在act
块内被调用以处理状态更新。最后,测试断言返回的城市列表中包含一个城市,墨尔本。
这个设置有助于在隔离状态下测试fetchCityWeather
函数,确保它在提供特定输入并接收到特定 API 响应时表现如预期。
相应地,我们需要更新useFetchCityWeather
钩子以启用多个项目:
const useFetchCityWeather = () => {
const [cities, setCities] = useState<CityWeather[]>([]);
const fetchCityWeather = (item: SearchResultItemType) => {
//... fetch
.then((cityWeather: RemoteCityWeather) => {
setCities([new CityWeather(cityWeather), ...cities]);
});
};
return {
cities,
fetchCityWeather,
};
};
useFetchCityWeather
钩子现在维护一个名为cities
的CityWeather
对象数组的状态。我们仍然向OpenWeatherMap
API 发送请求,并确保在获取新项目时将其插入列表的开头。它返回一个包含cities
数组的对象,并将其返回到调用位置。
最后,在App
中,我们可以遍历cities
来为每个城市生成Weather
组件:
function App() {
//...
const { cities, fetchCityWeather } = useFetchCityWeather();
return (
<div className="app">
{/* other jsx */}
<div data-testid="favorite-cities">
{cities.map((city) => (
<Weather key={city.name} cityWeather={city} />
))}
</div>
</div>
);
}
cities
数组被映射,对于数组中的每个CityWeather
对象,都会渲染一个Weather
组件。每个Weather
组件的关键属性被设置为城市的名称,cityWeather
属性被设置为CityWeather
对象本身,它将显示列表中每个城市的天气信息。
现在,我们将在 UI 中看到类似以下内容:
图 12.7:显示收藏列表中的多个城市
在深入探讨我们下一个也是最后一个特性之前,对现有代码进行一次更直接的改进至关重要——确保遵循单一职责原则。
重构天气列表
功能已正常运行,所有测试都已通过;接下来的重点是提升代码质量。请记住 TDD 方法:一次处理一个任务,逐步改进。然后,我们可以提取一个 WeatherList
组件来渲染整个城市列表:
const WeatherList = ({ cities }: { cities: CityWeather[] }) => {
return (
<div data-testid="favorite-cities" className="favorite-cities">
{cities.map((city) => (
<Weather key={city.name} cityWeather={city} />
))}
</div>
);
};
WeatherList
组件接收一个 cities
属性,它是一个 CityWeather
对象的数组。它使用 map
方法遍历这个数组,为每个城市渲染一个 Weather
组件。
在新的 WeatherList
组件到位后,App.tsx
将简化为如下所示:
function App() {
const { cities, fetchCityWeather } = useFetchCityWeather();
const onItemClick = (item: SearchResultItemType) =>
fetchCityWeather(item);
return (
<div className="app">
<h1>Weather Application</h1>
<SearchCityInput onItemClick={onItemClick} />
<WeatherList cities={cities} />
</div>
);
}
太棒了!现在我们的应用程序结构已经整洁,每个组件都拥有单一职责,这正是我们深入实现一个新特性(也是最后一个)以进一步增强我们的天气应用程序的好时机。
应用程序重新启动时获取以前的天气数据
在我们的天气应用程序的最后一个特性中,我们旨在保留用户的选取,以便在他们下次访问应用程序时,而不是遇到一个空列表,他们能看到之前选择的那些城市。这个特性可能会被高度使用——用户最初只需要添加几个城市,之后他们只需打开应用程序,他们的城市天气就会自动加载。
因此,让我们使用 Cypress 开始这个特性的用户验收测试:
const items = [
{
name: "Melbourne",
lat: -37.8142,
lon: 144.9632,
},
];
it("fetches data when initializing when possible", () => {
cy.window().then((window: any) => {
window.localStorage.setItem(
"favoriteItems",
JSON.stringify(items, null, 2)
);
});
cy.intercept("GET", "https://api.openweathermap.org/data/2.5/
weather*", {
fixture: "melbourne.json",
}).as("getWeather");
cy.visit("http://localhost:3000/");
cy.get('[data-testid="favorite-cities"] .city').should("have.
length", 1);
cy.get(
'[data-testid="favorite-cities"] .city:contains("Melbourne")'
).should("exist");
cy.get('[data-testid="favorite-cities"] .city:contains("20°C")').
should(
"exist"
);
});
cy.window()
命令访问全局窗口对象,并在 localStorage
中设置一个 favoriteItems
项,该项包含项目数组。随后,cy.intercept()
模拟对 OpenWeatherMap API 的网络请求,使用名为 melbourne.json
的固定文件作为模拟响应。cy.visit()
命令导航到 http://localhost:3000/
上的应用程序。一旦进入页面,测试将检查收藏城市列表中的一个城市项,验证 Melbourne
城市项的存在,并确认它显示的温度为 20°C
。
换句话说,我们在 localStorage
中设置了一个项目,以便在页面加载时,它可以读取 localStorage
并向远程服务器发送请求,就像我们在 onItemClick
中做的那样。
接下来,我们需要在 useFetchCityWeather
中提取一个数据获取函数。目前,fetchCityWeather
函数正在处理两个任务——获取数据和更新城市状态。为了遵循单一职责原则,我们应该创建一个新的仅用于获取数据的函数,让 fetchCityWeather
处理更新状态:
export const fetchCityWeatherData = async (item: SearchResultItemType) => {
const response = await fetch(
`https://api.openweathermap.org/data/2.5/weather?lat=${item.latitude}&lon=${item.longitude}&appid=<api-key>&units=metric`
);
const json = await response.json();
return new CityWeather(json);
};
fetchCityWeatherData
函数接受一个 SearchResultItemType
对象作为参数,使用该对象的纬度和经度构建一个 URL,并向 OpenWeatherMap API 发送一个 fetch
请求。在收到响应后,它将响应转换为 JSON 格式,使用 JSON 数据创建一个新的 CityWeather
对象,并将其返回。
现在,fetchCityWeather
可以更新如下:
const useFetchCityWeather = () => {
//...
const fetchCityWeather = (item: SearchResultItemType) => {
return fetchCityWeatherData(item).then((cityWeather) => {
setCities([cityWeather, ...cities]);
});
};
//...
}
useFetchCityWeather
钩子现在包含一个 fetchCityWeather
函数,该函数使用给定的 SearchResultItemType
项目调用 fetchCityWeatherData
。当承诺解决时,它接收一个 CityWeather
对象,然后通过在现有城市数组开头添加新的 CityWeather
对象来更新状态中的城市。
接下来,在 App
组件中,我们可以使用 useEffect
来填充 localStorage
数据并发送实际天气数据的请求:
useEffect(() => {
const hydrate = async () => {
const items = JSON.parse(localStorage.getItem("favoriteItems") ||
"[]");
const promises = items.map((item: any) => {
const searchResultItem = new SearchResultItemType(item);
return fetchCityWeatherData(searchResultItem);
});
const cities = await Promise.all(promises);
setCities(cities);
};
hydrate();
}, []);
在此代码片段中,一个 useEffect
钩子通过空依赖数组 []
触发名为 hydrate
的函数,当组件挂载时。
在 hydrate
内部,首先,它从 localStorage
中的 favoriteItems
键检索一个字符串化的数组,将其解析回 JavaScript 数组,如果该键不存在,则默认为空数组。然后,它遍历这个项目数组,为每个项目创建一个新的 SearchResultItemType
实例,并将其传递给 fetchCityWeatherData
函数。此函数返回一个承诺,该承诺被收集到一个承诺数组中。
使用 Promise.all
,它在更新状态之前等待所有这些承诺解决,使用 setCities
填充获取的城市天气数据。最后,在组件挂载时,在 useEffect
中调用 hydrate
来执行此逻辑。
最后,为了在用户点击项目时在 localStorage
中保存项目,我们需要更多的代码:
const onItemClick = (item: SearchResultItemType) => {
setTimeout(() => {
const items = JSON.parse(localStorage.getItem("favoriteItems") || "[]");
const newItem = {
name: item.city,
lon: item.longitude,
lat: item.latitude,
};
localStorage.setItem(
"favoriteItems",
JSON.stringify([newItem, ...items], null, 2)
);
}, 0);
return fetchCityWeather(item);
};
在这里,onItemClick
函数接受一个 SearchResultItemType
类型的参数。在函数内部,使用 setTimeout
并设置延迟为 0
毫秒,实际上是将其内容的执行推迟到当前调用栈清除之后——因此,UI 不会被阻塞。
在此延迟块中,它从 localStorage
中的 favoriteItems
键检索一个字符串化的数组,将其解析回 JavaScript 数组,如果该键不存在,则默认为空数组。然后,它从参数项中提取并重命名一些属性,创建一个新的对象 newItem
。
随后,它使用包含 newItem
在开头,后跟先前存储的项目字符串化的数组更新 localStorage
中的 favoriteItems
键。
在 setTimeout
之外,它使用项目参数调用 fetchCityWeather
,获取点击城市的天气数据,并从 onItemClick
返回此调用的结果。
现在,当我们检查浏览器中的 localStorage
时,我们将看到对象以 JSON 格式列出,并且数据将一直持续到用户明确清理它:
图 12.8:使用本地存储的数据
干得好!现在一切都在良好运行,代码处于一个易于构建的健壮状态。此外,项目结构直观,便于我们在需要实施更改时轻松导航和定位文件。
本章内容相当丰富,充满了有见地的信息,是对你迄今为止所学知识的良好总结。虽然继续添加更多功能会很有趣,但我相信现在是时候让你深入应用从本书中学到的概念和技术了。我将增强任务委托给你,相信你会在引入更多功能时做出值得称赞的调整。
摘要
在本章中,我们从零开始创建了一个天气应用程序,遵循 TDD 方法。我们使用了 Cypress 进行用户验收测试和 Jest 进行单元测试,逐步构建应用程序的功能。在重构过程中,我们还探讨了诸如建模领域对象、模拟网络请求以及应用单一职责原则等关键实践。
虽然本章并未涵盖前几章中所有技术,但它强调了在开发阶段保持纪律性步伐的重要性。它突出了能够识别代码“异味”并有效解决它们的价值,同时确保有坚实的测试覆盖率,以促进构建一个健壮且易于维护的代码库。本章作为一个实用的综合,敦促你将所学知识和技能应用于进一步增强应用程序。
在即将到来的最后一章中,我们将回顾我们探索的反模式,重新审视我们考察的设计原则和实践,并提供额外的资源以供进一步学习。
第十三章:回顾反模式原则
在这个简短的最后一章中,我们将简要回顾本书的关键见解,并为您提供更多资源,以便您更深入地探索 React 和软件设计领域。
本书的主要目标是挖掘在 React 代码库中经常遇到的常见反模式,尤其是在大型 React 应用程序中。我们探讨了纠正这些问题的潜在补救措施和技术。叙述中的例子要么来自我的先前项目,要么与开发者可能熟悉的领域相关——例如购物车、用户资料和网络请求,仅举几例。
我倡导一种逐步和渐进的交付方法,引导您从一个最初不太理想的实现逐步过渡到一个精炼的版本,每次只进行一点小的改进。我们开始组织一个典型的 React 应用程序,通过测试驱动开发(TDD)进入前端测试领域,并从常见的重构技术开始我们的旅程。此后,我们 navigated the challenging waters of data/state management in React,阐明了常见的设计原则,并探讨了组合策略。一系列章节都是从头开始构建完整示例,包括下拉列表、购物车和天气应用程序。
在这次探险中,我们发现了许多实用的技巧,例如如何在 Cypress 和 Jest 中模拟网络请求,将策略设计模式应用于 JavaScript 模型,以及在现实世界的代码场景中采用反腐败层(ACLs)。
本书讨论的技术可能不是突破性的或新颖的;事实上,许多都是已经确立的。然而,它们在 React 生态系统中的应用尚未得到充分探索。我真诚地希望这本书已经成功地填补了这一差距,重新引入这些宝贵的原则和模式到 React 社区中,从而在长期内为开发者提供更流畅的编码体验。
在本章中,我们将回顾以下主题:
-
回顾常见的反模式
-
概览设计模式
-
回顾基础设计原则
-
回顾技术和实践
回顾常见的反模式
在前几章中,我们探讨了众多反模式。识别反模式是纠正它的第一步。让我们简要回顾一下到目前为止我们所学的知识。
Props 钻取
当一个 prop 穿越多个组件层级,最终在更深层次的组件中使用,导致中间组件不必要地了解这个 prop 时,就会产生 Props 钻取。这种做法可能导致代码复杂且难以维护。
解决方案:使用 Context API 创建一个中心存储和访问此存储的函数,允许组件树在需要时访问 props,而无需 prop-drilling。
长 props 列表/大组件
一个接受大量属性或包含大量逻辑的组件可能成为一个庞然大物,难以理解、重用或维护。这种反模式违反了单一职责原则(SRP),该原则主张组件或模块只应有一个改变的理由。
解决方案:将组件拆分成更小、更易于消化的组件,并分离关注点可以改善这个问题。每个组件应体现一个清晰、单一的责任。自定义钩子也是简化组件内代码和减少其大小的有效手段。
业务泄露
当业务逻辑被植入应保持纯展示性的组件中时,就会发生业务泄露,这可能会复杂化应用管理并降低组件的可重用性。
解决方案:使用自定义钩子或将其业务逻辑移至单独的模块或层,可以解决此问题。采用访问控制列表(ACL)可以是一种有效的技术来纠正这个问题。
视图中的复杂逻辑
在视图组件中嵌入复杂的逻辑会使代码变得混乱,难以阅读、理解和维护。视图应尽可能保持简洁,仅负责渲染数据。
解决方案:将复杂逻辑移至自定义钩子、实用函数或单独的业务逻辑层,可以帮助保持视图组件的整洁和可管理性。最初,将组件分解成更小的部分,然后逐步将逻辑分离到适当的位置可能是有益的。
缺乏测试(在每个级别)
缺乏足够的单元测试、集成测试或端到端测试来验证应用功能,正如预期的那样,可能会导致错误、回归以及难以重构或扩展的代码。
解决方案:采用包括单元测试、集成测试和端到端测试在内的强大测试策略,结合 TDD 等实践,可以确保代码的正确性和易于维护。
代码重复
在多个组件或应用的多个部分中重复类似的代码会复杂化代码库的维护,并增加出现错误的可能性。
解决方案:遵循不要重复自己(DRY)原则,将常见功能抽象为共享的实用函数、组件或钩子,可以帮助减少代码重复并提高代码的可维护性。
在分析了常见的反模式之后,现在迫切需要深入研究作为这些普遍问题解毒剂的设计原则。这些原则不仅提供了解决方案,还指导你编写更干净、更高效的代码。
浏览设计模式
有有效的模式可以对抗 React 中的反模式,有趣的是,其中一些模式超出了 React 的上下文,在更广泛的场景中也有用。让我们迅速回顾这些模式。
高阶组件
高阶组件(HOCs)是 React 中用于重用组件逻辑的有效模式。HOCs 是接受一个组件并返回一个新组件的函数,该新组件具有额外的属性或行为。通过利用 HOCs,您可以在组件之间提取和共享常见的行为,有助于减轻属性钻取和代码重复等问题。
渲染属性
渲染属性模式包括一种在 React 组件之间通过一个值是函数的 prop 共享代码的技术。这是一种将函数作为 prop 传递给组件的方法,该函数返回一个 React 元素。这种模式可以通过促进重用和组合来缓解诸如长属性列表和大型组件等问题。
无头组件
无头组件是指那些管理行为和逻辑但不渲染 UI 的组件,赋予消费者对渲染的控制权。它们将行为逻辑与展示逻辑分离,这可以成为解决业务泄漏和视图中的复杂逻辑的有效方案,使组件更加灵活和易于维护。
数据建模
数据建模涉及组织和定义您的数据,这有助于理解和管理您应用程序中的数据,从而简化组件内的逻辑。这个原则可以用来解决视图中的复杂逻辑和业务逻辑泄漏问题。
分层架构
分层架构涉及将关注点分离和组织代码,以便每一层都有特定的责任。这种分离可以导致更组织化和可管理的代码库,解决诸如业务泄漏和视图中的复杂逻辑等问题。
作为提醒,图 13**.1展示了这种分层架构。在分层架构中,每一层包含多个模块,每个模块都致力于整体应用中的特定任务。这包括用于数据检索的模块(图 13**.1中显示的Fetcher模块),以及与外部服务(如社交媒体登录和支付网关)接口的适配器(图 13**.1中显示的Adaptor网关),以及用于分析和安全相关功能的组件:
图 13.1:React 应用程序中的分层架构
我们在第十一章中对这个主题进行了全面的案例研究,深入探讨了系统的演变,并确定了应用这种架构风格的最佳时机。
上下文作为接口
利用上下文作为接口,允许组件在不需要向下传递多个层级属性的情况下与数据交互。这种策略可以减轻属性钻取和长属性列表的问题,使组件树更易于阅读和维护。
在牢固掌握基础和设计原则的基础上,是时候探索那些能让你在日常编码实践中运用这些原则的实用技术了。
回顾基础设计原则
除了 React 特定的模式外,我们在各个章节中讨论了几个高级设计原则。这些原则作为指导方针,适用于你工作的各个方面,无论是 React、数据建模、事件测试,还是促进集成的脚本。它们并不局限于特定情境,采纳它们可以显著提升你在不同领域的编码方法。
单一职责原则
单一职责原则(SRP)主张一个类或组件应该只有一个改变的理由。遵循 SRP 可以导致更可维护和易于理解的代码,减轻如大型组件和复杂的视图逻辑等问题。
我们已经从多个层面探讨了这一原则,从从较大的组件中分离出较小的组件,到创建新的 Hook,再到重大的重构,如将 ACL 集成到天气应用中。值得注意的是,无论何时你发现自己陷入大型组件的困境,单一职责原则(SRP)始终是你最可靠的盟友。
依赖倒置原则
依赖倒置原则(DIP)强调依赖于抽象,而不是具体实现,这导致高级和低级结构的解耦。这一原则可以用来管理业务逻辑泄漏,并促进清晰的关注点分离(SoC)。
不要重复自己
DRY 原则是关于最小化代码中的重复。通过遵循 DRY 原则,你可以最小化代码重复,使代码库更容易维护和扩展。
防腐层
ACL 充当应用程序不同部分或层之间的屏障,创建一个稳定的接口。实现 ACL 可以是一种强大的策略来管理业务泄漏并确保清晰的 SoC。
当你的代码需要以任何方式与其他系统交互时,访问控制列表(ACL)特别有益,这种情况在与其他团队协作时经常出现——这在许多设置中是常见的。通过 ACL 建立清晰的系统边界,我们可以减轻其他系统变化对我们自身的影响,从而更好地控制我们的应用程序,缓解潜在的集成挑战。图 13.2展示了如何在 React 中应用 ACL:
图 13.2:在 React 中应用 ACL
使用组合
组合是 React 的核心原则之一,它赋予开发者从其他组件构建组件的能力,从而促进重用和简化。采用组合可以缓解各种问题,包括长的属性列表、大型组件和代码重复,从而使得代码库更加可维护和组织。
理解这些反模式、设计模式和原则对于管理前端代码库的复杂性至关重要。然而,技术和实践同样重要,因为它们代表了开发者每天实际参与的工作。
回顾技术和实践
我们强调了测试的重要性和逐步改进。这种方法不仅保持了高代码质量,而且培养了一位全面的开发者——磨练批判性思维技能和一次专注于解决一个问题的能力。
编写用户验收测试
用户验收测试(UAT)是开发过程中的一个关键部分,确保你的应用程序符合其规范并按预期工作。在开发早期实施 UAT 可以帮助及早发现问题,确保你的应用程序处于正确的轨道上。
如 图 13.3 所示,我们强调测试应该从最终用户的角度编写,关注交付客户价值而不是实现细节。这在你在更高层次开始实现一个功能时尤其相关:
图 13.3:用户验收测试
测试驱动开发
TDD 是一种软件工程技术,其中测试是在需要测试的代码之前编写的。这个过程主要分为以下迭代开发周期:编写测试,使测试通过,然后重构。TDD 可以显著帮助确保你的代码库是功能性的且无错误的,解决每个级别的测试不足问题。
重构和常见代码异味
重构涉及在不改变其外部行为的情况下改进现有代码的设计。意识到常见的代码异味并持续重构你的代码可以导致更健康、更易于维护的代码库。这项技术对于解决代码重复、视图中的复杂逻辑和业务泄露等问题非常有用。
现在我们已经了解了常见的反模式,阐明了设计原则,并探讨了技术,是时候超越这本书的内容了。以下部分提供了一系列推荐阅读,这些阅读将进一步深化你对 React、TypeScript 和软件设计原则领域的理解,并磨练你的技能。
其他资源
当我们结束这本书的内容时,通往掌握 React 和避免常见陷阱的旅程还远未结束。尤其是与 React 这样的框架一起,Web 开发的领域始终在不断发展。持续学习和适应是保持相关性和熟练的关键。
以下部分旨在为您提供额外的学习和探索途径。这些书籍经过精心挑选,旨在扩展您的理解,并介绍软件开发中更广泛或补充的概念。每一本书都开启了一个新的知识维度,确保您的成长轨迹保持陡峭且富有成效。因此,当您从这里迈步向前时,让这些资源成为您在持续学习与掌握网络开发艺术之旅中的伴侣。
我想推荐几本开创性的书籍,这些书籍可以进一步深化您对网络应用领域良好设计、架构和开发实践的理解和欣赏,特别是关注 React 和 TypeScript:
-
《重构:改善既有代码的设计》 by 马丁·福勒
马丁·福勒关于重构的基石之作,是关于如何增强代码结构同时保持其功能性和无错误性的知识库。对于任何希望磨练重构技能的人来说,这是一本必读之作。
-
《代码整洁之道》 by 罗伯特·马丁
罗伯特·马丁的《代码整洁之道》是软件开发世界的一个里程碑。它深入探讨了编写整洁、可维护代码的各种实践和原则,这对于复杂项目的长期成功至关重要。
-
《企业应用架构模式》 by 马丁·福勒
通过这本书,您可以扩展您的架构视野,它剖析了设计稳健和可扩展企业应用的关键模式。这是一本重要的读物,可以帮助您把握应用架构的更大图景,超越了前端领域。
-
《使用 React 和 TypeScript 进行测试驱动开发》 by 丘俊涛
沉浸在 TDD 的世界中,专注于 React 和 TypeScript。这本书将引导您了解 TDD 的原则,以及它如何显著提高代码的质量、可维护性和稳健性。
摘要
我想对您对磨练技艺和追求技术卓越的奉献和热情表示衷心的感谢。正是像您这样渴望知识和进步的人,推动了我们的行业向前发展。当您翻过最后一页时,请记住,旅程并没有结束;事实上,它为您在现实世界项目中的应用和探索开启了新的篇章。
成长的精髓在于应用,以及将所学知识不断应用于挑战规范和追求更好解决方案的持续努力。本书旨在为您提供坚实的基础,但真正的魔法发生在您将这些概念应用于实践、实验并将它们融入日常工作之中时。
我衷心感谢您花时间浏览这些页面并与材料互动。我真诚地希望您能继续保持这种势头,深入研究,并继续通过您的贡献丰富 React 社区。随着您踏上旅程的下一阶段,我祝愿您好运。愿您的代码整洁,您的解决方案创新,您的旅程充满回报。
感谢您,祝您在持续学习和改进的旅程中一切顺利!