React 反模式(三)
原文:
zh.annas-archive.org/md5/6794d9e9fd0a94e33f34d8b45365ab2b译者:飞龙
第十章:深入探索组合模式
构建可扩展和可维护的用户界面的旅程充满了挑战。开发者面临的一个主要挑战是确保组件在代码库增长时保持模块化、可重用和易于理解。我们的组件变得越来越交织和紧密耦合,维护、测试或甚至让新团队成员加入就变得更加困难。
组合已成为解决这一挑战的有力技术,使开发者能够构建更组织化、可扩展和更干净的代码库。我们不是创建执行众多任务的大型、单体组件,而是将它们分解成更小、更易于管理的部分,这些部分可以以多种方式组合。这为我们提供了一条清晰的路径,以简化逻辑、增强可重用性,并保持关注点的清晰分离。
本章致力于理解和掌握 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,
};
};
现在,我们的Dropdown 组件已经简化并更具可读性;它利用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 进行单元测试,逐步构建应用程序的功能。在重构过程中,我们还探讨了诸如建模领域对象、模拟网络请求以及应用单一职责原则等关键实践。
虽然本章并未涵盖前几章中所有技术,但它强调了在开发阶段保持纪律性步伐的重要性。它突出了能够识别代码“异味”并有效解决它们的价值,同时确保有坚实的测试覆盖率,以促进构建一个健壮且易于维护的代码库。本章作为一个实用的综合,敦促你将所学知识和技能应用于进一步增强应用程序。
在即将到来的最后一章中,我们将回顾我们探索的反模式,重新审视我们考察的设计原则和实践,并提供额外的资源以供进一步学习。