React-和-ReactNative-第五版-全-

React 和 ReactNative 第五版(全)

原文:zh.annas-archive.org/md5/47e218557a614bce0d999181bbb2b76b

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

几年来,ReactReact Native在 JavaScript 开发者中已被证明是全面且实用的 React 生态系统指南的热门选择。第五版包含了 React 的最新功能、增强和修复,同时与 React Native 兼容。它包括新的章节,涵盖了使用 React 和TypeScript进行现代跨平台应用开发的关键特性和概念。

从 React 的基础知识到流行的特性,如Hooks、服务器端渲染和单元测试,这本权威指南将按步骤帮助你成为一名专业的 React 开发者。

你将从学习 React 组件的基本构建块开始。接下来,你将学习如何使用 TypeScript 提高组件的稳定性。随着你通过章节的深入,你将在应用开发中使用更高级的功能,然后通过为 Web 和原生平台开发用户界面组件来应用你的知识。

在本书结束时,你将能够自信地构建 Web 上的 React 应用和多个平台上的 React Native 应用:Web、移动和桌面。

这本书面向谁

这本书适合任何想要开始学习如何使用 React 和 React Native 进行移动和 Web 应用开发的 JavaScript 开发者。不需要具备 React 的先验知识;然而,为了能够跟上本书涵盖的内容,需要具备 JavaScript、HTML 和 CSS 的实际操作能力。

这本书涵盖的内容

第一章为什么选择 React?,描述了 React 是什么以及为什么你想用它来构建你的应用。

第二章使用 JSX 进行渲染,介绍了 React 组件使用的标记语言 JSX 的基础知识。

第三章理解 React 组件和 Hooks,介绍了 React 应用中组件和 Hooks 的核心机制。

第四章React 方式的事件处理,概述了 React 组件如何处理事件。

第五章构建可重用组件,通过示例指导你进行组件重构的过程。

第六章使用 TypeScript 进行类型检查和验证,描述了 React 组件经历的各个阶段以及为什么这对 React 开发者来说很重要。

第七章使用 Routes 处理导航,提供了大量如何为你的 React Web 应用设置路由的示例。

第八章使用懒加载组件和 Suspense 进行代码拆分,介绍了导致性能更优、更高效的应用的代码拆分技术。

第九章用户界面框架组件,概述了如何开始使用 MUI,这是一个用于构建 UI 的 React 组件库。

第十章高性能状态更新,深入探讨了 React 中允许进行高效状态更新和高性能应用的新特性。

第十一章,从服务器获取数据,讨论了我们可以使用各种方式从服务器检索数据。

第十二章,React 中的状态管理,涵盖了使用 Redux 和 Mobx 等流行解决方案在应用中管理状态。

第十三章,服务器端渲染,教你如何使用 Next.js 构建在服务器和客户端上渲染内容的庞大 React 应用程序。

第十四章,React 中的单元测试,概述了使用 Vitest 进行单元测试的软件测试。

第十五章,为什么选择 React Native?,描述了 React Native 库是什么以及与原生移动开发的区别。

第十六章,React Native 内部机制,概述了 React Native 的架构。

第十七章,启动 React Native 项目,教你如何开始一个新的 React Native 项目。

第十八章,使用 Flexbox 构建响应式布局,描述了如何创建布局并添加样式。

第十九章,屏幕间导航,展示了在应用中切换屏幕的方法。

第二十章,实现数据列表,描述了如何在应用中实现数据列表。

第二十一章,地理位置和地图,解释了如何在应用中跟踪地理位置并添加地图。

第二十二章,收集用户输入,教你如何创建表单。

第二十三章,响应用户手势,提供了处理用户手势的示例。

第二十四章,显示进度,展示了如何处理进程指示器和进度条。

第二十五章,显示模态屏幕,教你如何创建对话框模态。

第二十六章,使用动画,描述了如何在应用中实现动画。

第二十七章,控制图像显示,概述了如何在 React Native 应用中渲染图像。

第二十八章,离线使用,展示了当手机没有互联网连接时如何处理应用。

为了充分利用这本书

本书假设你已对 JavaScript 编程语言有基本了解。它还假设你会跟随示例进行操作,这需要命令行终端、代码编辑器和网络浏览器。你将在第一章,为什么选择 React?中学习如何设置 React 项目。

学习 React Native 的要求与 React 开发相同,但要在真实设备上运行应用,你需要一部 Android 或 iOS 智能手机。为了在模拟器中运行 iOS 应用,你需要一台 Mac 电脑。要与 Android 模拟器一起工作,你可以使用任何类型的 PC。

下载示例代码文件

该书的代码包托管在 GitHub 上,网址为github.com/PacktPublishing/React-and-React-Native-5E。我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/找到。去看看吧!

下载彩色图片

我们还提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/gbp/9781805127307

使用的约定

本书中使用了多种文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。例如:“您实际声明的路由作为<Route>元素。”

代码块设置如下:

export default function First() {
  return <p>Feature 1, page 1</p>;
} 

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

export default function List({ data, fetchItems, refreshItems,
isRefreshing }) {
  return (
    <FlatList
data={data}
**renderItem****=****{({****item** **}) =>****<****Text****style****=****{styles.**
**item****}>****{item.value}****</****Text****>****}**
onEndReached={fetchItems} onRefresh={refreshItems} refreshing={isRefreshing}
/> );
} 

任何命令行输入或输出都按以下方式编写:

npm install @react-navigation/bottom-tabs @react-navigation/
drawer 

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。例如:“从管理面板中选择系统信息。”

警告或重要提示看起来是这样的。

小贴士和技巧看起来是这样的。

联系我们

我们始终欢迎读者的反馈。

一般反馈:请通过feedback@packtpub.com发送电子邮件,并在邮件主题中提及书籍的标题。如果您对本书的任何方面有疑问,请通过questions@packtpub.com发送电子邮件给我们。

勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/submit-errata,点击提交勘误表,并填写表格。

盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

分享您的想法

一旦您阅读了React 和 React Native,第五版,我们很乐意听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区都至关重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢您购买此书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走?

您的电子书购买是否与您选择的设备不兼容?

别担心,现在,随着每本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠远不止于此,您将获得独家折扣、新闻通讯以及每天收件箱中的优质免费内容。

按照以下简单步骤获取福利:

  1. 扫描下面的二维码或访问以下链接:

packt.link/free-ebook/9781805127307

  1. 提交您的购买证明。

  2. 就这样!我们将直接将您的免费 PDF 和其他福利发送到您的电子邮件。

第一部分

React

在本部分,我们将介绍 React 工具和概念的基础,并将它们应用于构建高性能的 Web 应用。

在本部分,我们将涵盖以下章节:

  • 第一章为什么选择 React?

  • 第二章使用 JSX 进行渲染

  • 第三章理解 React 组件和 Hooks

  • 第四章以 React 的方式处理事件

  • 第五章构建可重用组件

  • 第六章使用 TypeScript 进行类型检查和验证

  • 第七章使用路由处理导航

  • 第八章使用懒加载组件和 Suspense 进行代码拆分

  • 第九章用户界面框架组件

  • 第十章高性能状态更新

  • 第十一章从服务器获取数据

  • 第十二章React 中的状态管理

  • 第十三章服务器端渲染

  • 第十四章React 中的单元测试

第一章:为什么选择 React?

如果你正在阅读这本书,你可能已经熟悉React了。但如果你不熟悉,不要担心。我会尽力将哲学定义保持到最小。然而,这是一本内容丰富的长书,所以我感觉设定基调是一个合适的第一步。我们的目标是学习 React 和React Native,但也是构建一个可扩展和适应性强的架构,可以处理我们今天和未来想要用 React 构建的一切。换句话说,我们想要围绕 React 建立一个基础,并配备一套额外的工具和方法,这些工具和方法能够经受时间的考验。本书将指导你使用诸如路由、TypeScript 类型、测试等工具的过程。

本章首先简要解释了 React 存在的理由。然后,我们将思考 React 的简洁性以及它如何能够处理许多网络开发者面临的典型性能问题。接下来,我们将探讨 React 的声明式哲学以及 React 程序员可以期望与之一起工作的抽象级别。然后,我们将简要介绍 React 的一些主要功能。最后,我们将探讨如何设置一个项目以开始使用 React。

一旦你对 React 及其如何解决 UI 开发问题有了概念性的理解,你将更有能力应对本书的其余部分。本章将涵盖以下主题:

  • 什么是 React?

  • React 的新功能有哪些?

  • 设置新的 React 项目

什么是 React?

我认为 React 在其主页上的一行描述(react.dev/)简洁且准确:

“一个用于构建用户界面的 JavaScript 库。”

这非常完美,因为事实证明,这是我们大多数时候想要的全部。我认为这个描述最好的部分就是它省略了所有内容。它不是一个大型框架。它不是一个全栈解决方案,将处理从数据库到通过WebSocket连接的实时更新的所有事情。我们可能实际上并不需要大多数这些预包装的解决方案。如果 React 不是一个框架,那么它究竟是什么呢?

React 只是视图层

通常认为 React 是应用程序中的视图层。应用程序通常分为不同的层,如视图层、逻辑层和数据层。在这个背景下,React 主要处理视图层,这涉及到根据数据和应用状态的变化来渲染和更新 UI。React 组件改变用户看到的内容。以下图表说明了 React 在我们前端代码中的位置:

图 1.1:React 应用程序的层级

这就是 React 的全部内容——核心概念。当然,随着我们阅读本书,这个主题可能会有细微的变化,但流程大致相同:

  1. 应用逻辑:从一些生成数据的应用逻辑开始

  2. 将数据渲染到 UI 上:下一步是将这些数据渲染到 UI 上。

  3. React 组件:为了实现这一点,您将数据传递给 React 组件。

  4. 组件的角色:React 组件承担将 HTML 放入页面的责任。

你可能会想这有什么大不了的;React 看起来只是另一种渲染技术。在章节的剩余部分,我们将讨论 React 在哪些关键领域可以简化应用程序开发。

简单是好的

React 没有太多需要学习和理解的部分。虽然 React 吹嘘有一个相对简单的 API,但重要的是要注意,在表面之下,React 以一定程度的复杂性运行。在这本书的整个过程中,我们将深入研究这些内部工作原理,探索 React 架构和机制的各个方面,以提供给您一个全面的理解。使用小型 API 的优势在于,您可以花更多的时间熟悉它,实验它,等等。相反,大型框架则不同,您所有的时间都用于弄清楚一切是如何工作的。以下图表为您提供了一个大致的概念,当使用 React 编程时,我们需要考虑哪些 API:

图 1.2:React API 的简单性

React 分为两个主要 API:

  • React 组件 API:这些是页面由React DOM渲染的部分。

  • React DOM:这是用于在网页上执行渲染的 API。

在 React 组件中,我们有以下区域需要考虑:

  • 数据:这是来自某处(组件并不关心它来自哪里)的数据,并由组件渲染。

  • 生命周期:例如,生命周期的一个阶段是组件即将被渲染的时候。在 React 组件中,方法或钩子会响应组件在 React 渲染过程中的进入和退出阶段,这些阶段随着时间的推移而发生。

  • 事件:这是我们编写的响应用户交互的代码。

  • JSX:这是在 React 组件中描述 UI 结构的常用语法。尽管 JSX 与 React 紧密相关,但它也可以与其他JavaScript框架和库一起使用。

不要一开始就专注于这些不同的 React API 区域代表什么。这里的要点是,React 本质上很简单。只需看看有多少东西需要弄清楚!这意味着我们不必花很多时间在这里详细研究 API。相反,一旦掌握了基础知识,我们就可以花更多的时间在符合声明式 UI 结构的细微 React 使用模式上。

声明式 UI 结构

React 新手往往难以理解组件如何将标记与 JavaScript 混合以声明 UI 结构。如果你看过 React 示例并产生了相同的负面反应,请不要担心。起初,我们可能会对这个方法持怀疑态度,我认为原因是我们几十年来一直被“关注点分离”原则所影响。这个原则指出,不同的关注点,如逻辑和展示,应该彼此分离。现在,每当我们看到事物结合在一起时,我们就会自动假设这是不好的,不应该发生。

React 组件使用的语法称为 JSX(即 JavaScript XML,也称为 JavaScript 语法扩展)。组件通过返回一些 JSX 来渲染内容。JSX 本身通常是 HTML 标记,混合了 React 组件的自定义标签。具体细节在此处并不重要;我们将在接下来的章节中详细介绍。

声明式 JSX 方法令人耳目一新的地方在于,我们不必手动执行复杂的操作来更改组件的内容。相反,我们描述 UI 在不同状态下的外观,React 高效地更新实际的 DOM 以匹配。因此,React UI 变得更容易和更高效地处理,从而带来更好的性能。

例如,想想使用 jQuery 来构建你的应用程序。你有一个页面,上面有一些内容,你想要在按钮点击时给一个段落添加一个类:

$(document).ready(function() {
  $('#my-button').click(function() {
    $('#my-paragraph').addClass('highlight');
  });
}); 

执行这些步骤足够简单。这被称为命令式编程,对于 UI 开发来说是有问题的。命令式编程在 UI 开发中的问题在于,它可能导致难以维护和修改的代码。这是因为命令式代码通常紧密耦合,意味着对代码某一部分的更改可能会在别处产生意外的后果。此外,命令式代码可能难以推理,因为它可能很难理解控制流的流动和应用程序在任何给定时间的状态。虽然这个更改元素类的例子很简单,但真实的应用程序往往需要超过三四个步骤才能实现某个功能。

React 组件不需要你以命令式的方式执行步骤。这就是 JSX 成为 React 组件核心的原因。XML 风格的语法使得描述 UI 应该是什么样子变得容易——也就是说,组件将要渲染哪些 HTML 元素?

export const App = () => {
  const [isHighlighted, setIsHighlighted] = useState(false);
  return (
    <div>
      <button onClick={() => setIsHighlighted(true)}>Add Class</button>
      <p className={isHighlighted && "highlight"}>This is paragraph</p>
    </div>
  );
}; 

在这个例子中,我们不仅仅是编写浏览器应该执行的命令式程序。这更像是一条指令,我们说明 UI 应该是什么样子,以及用户应该如何与之交互。这被称为声明式编程,非常适合 UI 开发。一旦你声明了你的 UI 结构,你需要指定它如何随时间变化。

数据会随时间变化

另一个对 React 新手来说难以掌握的领域是 JSX 就像一个静态字符串,代表了一块渲染输出的片段。这就是数据和时间的流逝发挥作用的地方。React 组件依赖于传入它们的数据。这些数据代表了 UI 的动态部分——例如,基于布尔值的渲染 UI 元素可能会在组件下一次渲染时发生变化。以下是一个说明这个概念的图示:

图 1.3:React 组件随时间变化

每次 React 组件渲染时,就像在那个确切时刻对 JSX 进行快照。随着你的应用程序随时间推移,你将有一个有序的渲染 UI 组件集合。除了声明性地描述 UI 应该是什么样子之外,重新渲染相同的 JSX 内容使开发者的工作变得更加容易。挑战在于确保 React 可以处理这种方法的性能需求。

性能很重要

使用 React 构建 UI 意味着我们可以用 JSX 声明 UI 的结构。这比逐个组装 UI 的命令式方法更不容易出错。然而,声明式方法在性能上确实提出了挑战。

例如,对于初始渲染来说,具有声明性 UI 结构是可行的,因为页面上还没有任何内容。所以 React 渲染器可以查看 JSX 中声明的结构,并在 DOM 浏览器中渲染它。

文档对象模型DOM)代表浏览器中渲染后的 HTML。DOM API 是 JavaScript 如何更改页面内容的方式。

以下图示说明了这个概念:

图 1.4:JSX 语法在浏览器 DOM 中如何转换为 HTML

在初始渲染时,React 组件及其 JSX 与其他模板库没有区别。例如,有一个名为Handlebars的模板库用于服务器端渲染,它将模板渲染为 HTML 标记字符串,然后将其插入到浏览器 DOM 中。React 与 Handlebars 等库的不同之处在于,React 可以在数据发生变化并且我们需要重新渲染组件时进行适应,而 Handlebars 将仅仅重新构建整个 HTML 字符串,就像它在初始渲染时做的那样。由于这对性能来说是个问题,我们通常最终会实现命令式的工作区,手动更新 DOM 的一小部分。我们最终会得到一个声明性模板和命令式代码的混乱组合,用于处理 UI 的动态方面。

我们在 React 中不这样做。这就是 React 与其他视图库不同的地方。组件在初始渲染时是声明性的,即使在它们重新渲染时也是如此。这就是 React 在底层如何使声明性 UI 结构的重新渲染成为可能。

然而,在 React 中,当我们创建一个组件时,我们清楚地描述了它应该看起来是什么样子。即使我们更新我们的组件,React 也会在幕后平滑地处理这些变化。换句话说,组件在初始渲染时是声明式的,即使在重新渲染时也是如此。这是可能的,因为 React 使用虚拟 DOM,它用于在内存中保持真实 DOM 元素的表示。它这样做是为了每次我们重新渲染一个组件时,它都可以将新内容与页面上已显示的内容进行比较。根据差异,虚拟 DOM 可以执行必要的命令步骤来做出更改。因此,当我们需要更新 UI 时,我们不仅保留了我们的声明式代码,React 还会确保它是高效完成的。以下是这个过程的样子:

图 1.5:React 将 JSX 语法转换为命令式 DOM API 调用

当您阅读有关 React 的内容时,您经常会看到诸如 diffingpatching 这样的词汇。Diffing 意味着比较 旧内容(UI 的先前状态)与 新内容(更新的状态)以识别差异,就像比较文档的两个版本以查看发生了什么变化一样。Patching 意味着执行必要的 DOM 操作以渲染新内容,确保只做出特定的更改,这对于性能至关重要。

与任何其他 JavaScript 库一样,React 受限于主线程的 运行至完成 特性。例如,如果 React 虚拟 DOM 逻辑正忙于比较内容并修补真实 DOM,浏览器就无法响应用户输入,如点击或交互。

在本章的下一节中,您将看到对 React 内部渲染算法进行了修改,以减轻这些性能陷阱。在解决了性能问题后,我们需要确保 React 足够灵活,能够适应我们未来可能希望部署应用程序的不同平台。

正确的抽象层次

在我们深入研究 React 代码之前,我想在较高层次上讨论另一个主题,那就是 抽象

在上一节中,您看到了 JSX 语法如何转换为低级操作来更新我们的 UI。React 将我们的声明式 UI 组件转换为更好的方式是,我们并不一定关心渲染目标是什么。在 React 中,渲染目标恰好是浏览器 DOM,但正如我们将要看到的,它并不局限于浏览器 DOM。

React 有潜力用于我们想要创建的任何 UI,在任何可想象设备上。我们只是刚开始看到 React Native,但可能性是无限的。我不会对React Toast(这根本不是一回事)突然变得相关而感到惊讶,其中 React 针对的是可以将 JSX 渲染输出烧焦到面包上的烤面包机。React 的抽象级别在多功能性和适应性之间取得了平衡,同时保持了实际和高效的用户界面开发方法。

以下图表展示了 React 如何针对不仅仅是浏览器:

图片

图 1.6:React 将目标渲染环境从我们实现的组件中抽象出来

从左到右,我们有React DOMReact NativeReact PDFReact Unity。所有这些 React 渲染器库都接受 React 组件并返回特定平台的输出。正如你所见,要针对新事物,相同的模式适用:

  • 实现特定于目标组件。

  • 实现一个 React 渲染器,使其能够在底层执行特定平台的操作。

这显然是对任何给定 React 环境中实际实现内容的过度简化。但对于我们来说,细节并不那么重要。重要的是,我们可以利用我们的 React 知识来专注于描述我们任何平台上的 UI 结构。

现在你已经了解了 React 中抽象的作用,让我们看看 React 的新特性。

React 的新特性是什么?

React 是一个在不断变化的 Web 开发领域中持续演进的库。当你开始学习并掌握 React 的旅程时,了解库的演变及其随时间的变化是很重要的。

React 的一个优点是,其核心 API 在近年来相对稳定。这提供了一种连续性,并允许开发者利用他们从先前版本中获得的知识。React 的概念基础保持完整,这意味着三五年前获得的技能今天仍然适用。让我们回顾一下,从 React 的早期版本到最近的版本,React 的历史。从React 0.xReact 18,已经进行了许多关键的变化和增强,如下所示:

  • React 0.14:在这个版本中,函数组件的引入允许开发者将函数用作组件,简化了基本 UI 元素的创建。当时,没有人知道现在我们只会编写函数组件,几乎完全放弃基于类的组件。

  • React 15:采用新的版本方案,React 15 的下一个更新对内部架构进行了全面的重构,从而提高了性能和稳定性。

  • React 16:然而,这个版本是 React 历史上最引人注目的发布之一。它引入了 hooks,这是一个革命性的概念,使开发者能够在不使用类组件的情况下使用状态和其他 React 功能。Hooks 使代码更简单、更易读,改变了开发者编写组件的方式。本书将探讨许多 hooks。此外,React 16 还引入了 Fiber,这是一种新的协调机制,显著提高了性能,尤其是在处理动画和复杂 UI 结构时。

  • React 17:这个版本专注于更新和维护与先前版本的兼容性。它引入了一个新的 JSX 转换系统。

  • React 18:这次发布继续了改进的轨迹,强调了性能提升和新增功能,例如渲染的自动批处理、状态转换、服务器组件和流式服务器端渲染。与性能相关的大部分重要更新将在第十二章高性能状态更新中进行探讨。关于服务器端渲染的更多细节将在第十四章使用 React 框架进行服务器端渲染和静态站点生成中介绍。

  • React 19:引入了几个主要功能和改进。React 编译器是一个新的编译器,它能够实现自动记忆化并优化重新渲染,消除了手动 useMemouseCallback 和记忆优化。增强的 Hooks,如用于数据获取的 use(promise)、用于表单处理的 useFormStatus()useFormState()、以及用于乐观 UI 的 useOptimistic() 简化了常见任务。React 19 还带来了简化的 API,例如 ref 成为常规属性、React.lazy 被取代,以及 Context.Provider 变为 Context。异步渲染允许在渲染过程中异步获取数据,而不会阻塞 UI,而错误处理改进提供了更好的机制来诊断和修复应用程序中的问题。

React 的稳定性和兼容性使其成为长期使用的可靠库,而持续的更新确保它始终处于网络和移动开发的前沿。在这本书中,所有示例都将使用最新的 React API,确保它们在未来版本中仍然功能性和相关性。

现在我们已经探讨了 React 的演变和更新,我们可以更深入地了解 React,并检查如何设置新的 React 项目。

设置新的 React 项目

在开始创建 React 项目时,有几种方法可以创建 React 项目。在本节中,我们将探讨三种常见的方法:

  • 使用网络打包器

  • 使用框架

  • 使用在线代码编辑器

    要开始开发和预览您的 React 应用程序,您首先需要在您的计算机上安装 Node.js。Node.js 是执行 JavaScript 代码的运行环境。

让我们接下来在以下子节中深入了解每种方法。

使用网络打包器

使用网络打包器是创建 React 项目的有效方法,尤其是如果你正在构建一个单页应用程序SPA)。在这本书的所有示例中,我们将使用Vite作为我们的网络打包器。Vite 以其卓越的速度和易于设置及使用而闻名。

要使用 Vite 设置你的项目,你需要采取以下步骤:

  1. 请确保你的计算机上已安装 Node.js,你可以通过访问官方 Node.js 网站 (nodejs.org/) 并下载适合你操作系统的相应版本来做到这一点。

  2. 打开你的终端或命令提示符,导航到你想要创建项目的目录:

    mkdir react-projects
    cd react-projects 
    
  3. 运行以下命令以使用 Vite 创建一个新的 React 项目:

    npm create vite@latest my-react-app -- --template react 
    

    此命令创建一个名为 my-react-app 的新目录,并使用 Vite 模板设置一个 React 项目。

  4. 一旦项目创建完成,你的终端应该如下所示:

    Scaffolding project in react-projects/my-react-app...
    Done. Now run:
      cd my-react-app
      npm install
         npm run dev 
    
  5. 导航到项目目录并安装依赖项。终端的结果应该如下所示:

    added 279 packages, and audited 280 packages in 21s
    103 packages are looking for funding
      run 'npm fund' for details
    found 0 vulnerabilities 
    

最后,通过运行以下命令启动开发服务器:npm run dev

此命令启动开发服务器,你可以通过打开浏览器并访问 http://localhost:3000 来查看你的 React 应用程序。

到目前为止,你已经成功使用 Vite 作为网络打包器设置了你的 React 项目。有关 Vite 及其可能的配置的更多信息,请访问官方网站 vitejs.dev/

使用框架

对于现实世界和商业项目,建议使用构建在 React 之上的框架。这些框架提供了额外的功能,例如路由和资产管理(图像、SVG 文件、字体等)。它们还指导你有效地组织项目结构,因为框架通常强制执行特定的文件组织规则。一些流行的 React 框架包括Next.jsGatsbyRemix

第十三章服务器端渲染中,我们将探讨设置 Next.js 以及它与使用普通网络打包器之间的差异。

在线代码编辑器

在线代码编辑器结合了网络打包器和框架的优点,但允许你在云端或直接在浏览器中设置你的 React 开发环境。这消除了在你的机器上安装任何东西的需要,并让你可以直接在浏览器中编写和探索 React 代码。

虽然有各种在线代码编辑器可供选择,其中一些最受欢迎的选项包括CodeSandboxStackBlitzReplit。这些平台提供了一个用户友好的界面,并允许你创建、分享和协作 React 项目,而无需任何本地设置。

要开始使用在线代码编辑器,您甚至不需要账户。只需在浏览器上遵循此链接:react.new。几秒钟后,您将看到 CodeSandbox 已经准备好使用模板项目进行工作,并且编辑器的实时预览可以直接在浏览器标签中查看。如果您想保存更改,则需要创建账户。

使用在线代码编辑器是学习和实验 React 的便捷方式,尤其是如果您更喜欢基于浏览器的开发环境。

在本节中,我们探讨了不同的方法来设置您的 React 项目。无论您选择 Web 打包器、框架还是在线代码编辑器,每种方法都提供了其独特的优势。选择您喜欢且适合您项目需求的方法。现在,我们准备好进入 React 开发的领域了!

摘要

在本章中,我们全面介绍了 React,以便您对其有一个大致的了解以及其必要的方面,为本书的其余部分定下基调。React 是一个具有小型 API 的库,用于构建 UI。然后,我们向您介绍了 React 的一些关键概念。我们讨论了 React 之所以简单,是因为它没有很多可移动部件。

之后,我们探讨了 React 组件和 JSX 的声明性本质。在此之后,您了解到 React 通过编写可重复渲染的声明性代码来实现有效的性能。

您还深入了解了渲染目标的概念以及 React 如何轻松成为各种平台的首选 UI 工具。然后,我们为您提供了 React 历史的简要概述并介绍了最新进展。最后,我们深入探讨了如何设置新的 React 项目并启动学习过程。

目前,这些就足够作为入门和概念性内容了。随着我们继续阅读本书,我们将重新审视这些概念。接下来,让我们退后一步,牢固掌握基础知识,从下一章使用 JSX 进行渲染开始。

加入我们的 Discord 吧!

与其他用户和作者一起阅读本书。提出问题,为其他读者提供解决方案,与作者聊天等等。扫描二维码或访问链接加入社区。

packt.link/ReactAndReactNative5e

第二章:使用 JSX 渲染

本章将向您介绍 JSX,这是一种嵌入在您的 JavaScript 代码中的 XML/HTML 标记语法,用于声明您的 React 组件。在最基本的层面上,您将使用 HTML 标记来描述您的 UI 的各个部分。构建 React 应用程序涉及将这些 HTML 标记片段组织成组件。在 React 中,创建一个组件允许您定义超出基本 HTML 标记的自定义元素。这些自定义元素或组件使用 JSX 定义,然后将其转换为浏览器可以识别的标准 HTML 元素。能够创建和重用自定义组件是 React 的核心特性,它使得更动态和复杂的 UI 成为可能。这正是 React 引人入胜的地方——拥有自己的 JSX 标签,可以使用 JavaScript 表达式使您的组件生动起来。JSX 是用于描述使用 React 构建的 UI 的语言。

在本章中,我们将涵盖以下内容:

  • 您的第一个 JSX 内容

  • 渲染 HTML

  • 创建您自己的 JSX 元素

  • 使用 JavaScript 表达式

  • 构建 JSX 片段

技术要求

本章的代码可以在配套 GitHub 仓库的以下目录中找到:github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter02

您的第一个 JSX 内容

在本节中,我们将实现必做的 Hello World JSX 应用程序。这个初步的探索只是开始——这是一个简单而有效的方法来熟悉语法及其功能。随着我们的进展,我们将深入研究更复杂和细微的示例,展示 JSX 在构建 React 应用程序中的强大和灵活性。我们还将讨论是什么使得这种语法适用于声明式 UI 结构。

Hello JSX

不再拖延,这是您的第一个 JSX 应用程序:

import * as ReactDOM from "react-dom";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <p>
    Hello, <strong>JSX</strong>
  </p>
); 

让我们来看看这里发生了什么。

render() 函数接受 JSX 作为参数,并将其渲染到传递给 ReactDOM.createRoot() 的 DOM 节点。

在此示例中,实际的 JSX 内容渲染了一个包含一些粗体文本的段落。这里没有发生什么特别的事情,所以我们完全可以直接将这个标记作为纯字符串插入到 DOM 中。然而,此示例的目的是展示将 JSX 渲染到页面上的基本步骤。

在底层,JSX 并不是由网络浏览器直接理解的,需要转换成浏览器可以执行的标准的 JavaScript 代码。这种转换通常使用像 ViteBabel 这样的工具来完成。当 Vite 处理 JSX 代码时,它会将 JSX 编译成 React.createElement() 调用。这些调用创建代表虚拟 DOM 元素的 JavaScript 对象。例如,上面示例中的 JSX 表达式被编译成这样:

import * as ReactDOM from "react-dom";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  React.createElement(
    "p",
    null,
    "Hello, ",
    React.createElement("strong", null, "JSX")
  )
); 

React.createElement 的第一个参数是元素的类型(例如,用于 DOM 元素的字符串 divp,或用于复合组件的 React 组件)。第二个参数是一个包含此元素属性的对象,任何后续参数都是此元素的子元素。这种转换由 Vite 在幕后完成,你永远不会编写这样的代码。

React.createElement() 创建的这些对象,称为 React 元素,以对象格式描述了 UI 组件的结构和属性,React 可以处理这些对象。然后,React 使用这些对象构建实际的 DOM 并保持其更新。这个过程涉及一个协调算法,它以高效的方式更新 DOM 以匹配 React 元素。当组件的状态发生变化时,React 计算出更新 DOM 所需的最小更改集,而不是重新渲染整个组件。这使得更新更加高效,也是使用 React 的关键优势之一。

在我们继续更深入的代码示例之前,让我们花一点时间回顾一下我们的 Hello World 示例。JSX 内容简短且简单。它也是声明性的,因为它描述了要渲染的内容,而不是如何渲染。具体来说,通过查看 JSX,你可以看到这个组件将渲染一个段落和一些粗体文本。如果以命令式方式完成,可能需要更多步骤,并且它们可能需要按特定顺序执行。

我们刚刚实现的示例应该让你对声明性 React 有一定的感觉。随着我们在本章以及整本书的进展,JSX 标记将变得更加复杂。然而,它始终会描述 UI 中的内容。

render() 函数告诉 React 以最有效的方式将你的 JSX 标记更新到 UI 上。这就是 React 如何让你能够声明 UI 的结构,而无需考虑执行更新屏幕上元素的有序步骤,这种方法往往会导致错误。React 默认支持任何 HTML 页面上都能找到的标准 HTML 标签,例如 divph1ulli 等。

现在我们已经发现了 JSX 是什么,它是如何工作的,以及它遵循的声明性理念,让我们探索如何渲染纯 HTML 标记,以及我们应该遵循哪些约定。

渲染 HTML

最终,React 组件的职责是在 DOM 浏览器中渲染 HTML。这就是为什么 JSX 默认支持 HTML 标签。在本节中,我们将查看一些渲染可用 HTML 标签的代码。然后,我们将介绍在 React 项目中使用 HTML 标签时通常遵循的一些约定。

内置 HTML 标签

当我们渲染 JSX 时,元素标签引用 React 组件。由于为 HTML 元素创建组件会很麻烦,React 提供了 HTML 组件。我们可以在 JSX 中渲染任何 HTML 标签,输出将正如我们所期望的那样。

现在,让我们尝试渲染一些这些标签:

import * as ReactDOM from "react-dom";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <div>
    <button />
    <code />
    <input />
    <label />
    <p />
    <pre />
    <select />
    <table />
    <ul />	
  </div>
); 

对于这个示例,不必担心渲染输出的格式。我们确保可以渲染任意 HTML 标签,并且它们会按照预期渲染,无需任何特殊的定义和导入。

你可能已经注意到了周围的 <div> 标签,它将所有其他标签作为其子元素分组。这是因为 React 需要一个根元素来渲染。在章节的后面,你将学习如何在不将相邻元素包裹在父元素中的情况下渲染它们。

使用 JSX 渲染的 HTML 元素紧密遵循常规 HTML 元素语法,但在大小写敏感性和属性方面有一些细微的差异。

HTML 标签约定

当你在 JSX 标记中渲染 HTML 标签时,预期你会使用小写字母来表示标签名。实际上,将 HTML 标签名称大写将会失败。标签名是区分大小写的,并且非 HTML 元素名称是大写的。这样,就可以轻松地扫描标记并区分内置的 HTML 元素和其他所有元素。

你还可以传递 HTML 元素任何它们的标准属性。当你传递它们意料之外的东西时,会记录一条关于未知属性的警告。以下是一个说明这些概念的示例:

import * as ReactDOM from "react-dom";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <button title="My Button" foo="bar">
    My Button
  </button>
);
root.render(<Button />); 

当你运行这个示例时,它将无法编译,因为 React 不了解 <Button> 元素;它只知道 <button>

你可以使用任何有效的 HTML 标签作为 JSX 标签,只要你记住它们是区分大小写的,并且需要传递正确的属性名称。除了只有属性值的简单 HTML 标签外,你还可以使用更语义化的 HTML 标签来描述页面内容的结构。

描述 UI 结构

JSX 能够以将它们组合在一起形成完整 UI 结构的方式描述屏幕元素。让我们看看一些声明比单个段落更复杂结构的 JSX 标记:

import * as ReactDOM from "react-dom";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <section>
    <header>
      <h1>A Header</h1>
    </header>
    <nav>
      <a href="item">Nav Item</a>
    </nav>
    <main>
      <p>The main content...</p>
    </main>
    <footer>
      <small>&copy; 2024</small>
    </footer>
  </section>
); 

这个 JSX 标记描述了一个相当复杂的 UI 结构。然而,由于它是 HTML,HTML 适合简洁地表达层次结构,因此它比命令式代码更容易阅读。这就是我们希望在 UI 需要改变时如何思考——不是作为一个单独的元素或属性,而是作为一个整体 UI。

下面是渲染后的内容看起来像什么:

图片

图 2.1:使用 JSX 语法描述 HTML 标签结构

在这个标记中有许多描述 UI 结构的语义元素。例如,<header> 元素描述了标题所在的页面顶部部分,而 <main> 元素描述了主要页面内容所在的位置。这种复杂结构使开发者更容易推理。但在我们开始实现动态 JSX 标记之前,让我们创建一些自己的 JSX 组件。

创建自己的 JSX 元素

组件是 React 的基本构建块。实际上,它们可以被看作是 JSX 标记的词汇表,允许你通过可重用、封装的元素创建复杂界面。在本节中,我们将深入了解如何创建自己的组件并在其中封装 HTML 标记。

封装 HTML

我们创建新的 JSX 元素,以便我们可以封装更大的结构。这意味着我们不需要输入复杂的标记,可以使用自定义标签。React 组件返回用于标签位置的 JSX。让我们看看以下示例:

import * as ReactDOM from "react-dom";
function MyComponent() {
  return (
    <section>
      <h1>My Component</h1>
      <p>Content in my component...</p>
    </section>
  );
}
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<MyComponent />); 

这就是渲染后的输出效果:

图片

图 2.2:一个渲染封装 HTML 标记的组件

这是我们在 React 中实现的第一个组件,所以让我们花点时间分析一下这里发生了什么。我们创建了一个名为 MyComponent 的函数,在它的返回语句中我们放置了我们的 HTML 标签。这就是我们创建一个用作新 JSX 元素的 React 组件的方法。正如你在对 render() 的调用中看到的那样,你正在渲染一个 <MyComponent> 元素。

这个组件封装的 HTML 是从我们创建的函数中返回的。在这种情况下,当 JSX 由 react-dom 渲染时,它被一个 <section> 元素和它内部的所有内容所替换。

当 React 渲染 JSX 时,你使用的任何自定义元素都必须在相同的作用域内有相应的 React 组件。在前面的示例中,MyComponent 函数是在与 render() 调用相同的作用域中声明的,所以一切按预期工作。通常,你会导入组件,将它们添加到适当的作用域中。随着你通过本书的进展,你会看到更多关于这一点的内容。

HTML 元素如 <div> 常常包含嵌套子元素。让我们看看我们是否可以用我们创建的 JSX 元素做到同样的事情,这些元素是通过实现组件来创建的。

嵌套元素

使用 JSX 标记描述具有父子关系的 UI 结构是有用的。子元素是通过在另一个组件(父组件)内部嵌套它们来创建的。

例如,一个 <li> 标签仅当它是 <ul> 标签或 <ol> 标签的子元素时才是有效的 – 你可能也会用你自己的 React 组件创建类似的嵌套结构。为此,你需要使用 children 属性。让我们看看这是如何工作的。以下是 JSX 标记:

import * as ReactDOM from "react-dom";
import MySection from "./MySection";
import MyButton from "./MyButton";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <MySection>
    <MyButton>My Button Text</MyButton>
  </MySection>
); 

你正在导入你自己的两个 React 组件:MySectionMyButton

现在,如果你查看 JSX 标记,你会注意到<MyButton><MySection>的子元素。你也会注意到MyButton组件接受文本作为其子元素,而不是更多的 JSX 元素。

让我们看看这些组件是如何工作的,从MySection开始:

export default function MySection(props) {
  return (
    <section>
      <h2>My Section</h2>
      {props.children}
    </section>
  );
} 

此组件渲染一个标准的<section>HTML 元素,一个标题,然后是{props.children}。正是这个最后的部分允许组件访问嵌套元素或文本并将它们渲染出来。

在前面的例子中使用的两个大括号用于 JavaScript 表达式。我将在下一节中详细介绍 JSX 标记中找到的 JavaScript 表达式语法的更多细节。

现在,让我们看看MyButton组件:

export default function MyButton(props) {
  return <button>{props.children}</button>;
} 

此组件使用与MySection完全相同的模式;它获取{props.children}的值并将其包裹在标记中。React 会为你处理细节。在这个例子中,按钮文本是MyButton的子元素,而MyButton又是MySection的子元素。然而,按钮文本通过MySection透明地传递。换句话说,我们不需要在MySection中编写任何代码来确保MyButton获取其文本。真是太酷了,对吧?下面是渲染输出的样子:

图 2.3:使用子 JSX 值渲染的按钮元素

现在,你已经知道了如何构建自己的 React 组件,在标记中引入新的 JSX 标签。我们在本章中查看的组件到目前为止都是静态的。也就是说,一旦我们渲染了它们,它们就再也没有更新过。JavaScript 表达式是 JSX 的动态部分,根据条件给出不同的输出。

使用 JavaScript 表达式

正如你在前面的章节中看到的,JSX 有一个特殊的语法,允许你嵌入 JavaScript 表达式。每当 React 渲染 JSX 内容时,标记中的表达式都会被评估。这个特性是 JSX 动态性的核心;它使得组件的内容和属性能够根据不同的数据或状态条件进行变化。每次 React 渲染或重新渲染 JSX 内容时,这些嵌入的表达式都会被评估,使得显示的 UI 能够反映当前的数据和状态。你还将学习如何将数据集合映射到 JSX 元素。

动态属性值和文本

一些 HTML 属性或文本值是静态的,这意味着当 JSX 标记重新渲染时,它们不会改变。其他值,即属性或文本的值,基于在应用程序其他地方找到的数据。记住,React 只是视图层。让我们看一个例子,这样你就可以感受到 JSX 标记中 JavaScript 表达式语法的样子:

import * as ReactDOM from "react-dom";
const enabled = false;
const text = "A Button";
const placeholder = "input value...";
const size = 50;
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <section>
    <button disabled={!enabled}>{text}</button>
    <input placeholder={placeholder} size={size} />
  </section>
); 

任何有效的 JavaScript 表达式,包括嵌套的 JSX,都可以放在大括号{}之间。对于属性和文本,这通常是变量名或对象属性。注意,在这个例子中,!enabled表达式计算出一个布尔值。下面是渲染输出的样子:

图 2.4:动态更改按钮的属性值

如果你正在跟随可下载的配套代码,我强烈推荐这样做,请尝试调整这些值,看看渲染的 HTML 如何变化:github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter02

原始的 JavaScript 值在 JSX 语法中使用起来非常直接。显然,我们可以在 JSX 中使用更复杂的数据类型,例如对象和数组,以及函数来处理事件。让我们来探讨一下。

事件处理

在 React 中,你可以轻松地将函数传递给组件的属性,以处理用户交互,如按钮点击、表单提交和鼠标移动。这允许你创建交互式和响应式的用户界面。React 提供了一种方便的方法,可以直接使用类似 addEventListenerremoveEventListener 方法在传统 JavaScript 中使用的语法将事件处理器附加到组件上。

为了说明这一点,让我们考虑一个例子,其中我们想在 React 组件中处理一个按钮点击事件:

import * as ReactDOM from "react-dom";
const handleClick = () => {
  console.log("Button clicked!");
};
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <section>
    <button onClick={handleClick}>Click me</button>
  </section>
); 

在这个例子中,我们定义了一个名为 handleClick 的函数,当按钮被点击时会调用该函数。然后我们将这个函数作为事件处理器附加到 <button> 组件的 onClick 属性上。每当按钮被点击时,React 将调用 handleClick 函数。

与在传统 JavaScript 中使用 addEventListenerremoveEventListener 相比,React 抽象掉了一些复杂性。使用 React 的事件处理,你不必担心手动将事件监听器附加到或从 DOM 元素中移除。React 管理着事件委托,并为组件内部的事件处理提供了一种更声明式的方法。

React 默认实现事件委托以优化性能。它不是将事件处理器附加到每个单独的元素上,而是将单个事件处理器附加到应用程序的根(或父组件)上。当一个事件在子元素上触发时,它会在组件树中向上冒泡,直到达到具有事件处理器的父组件。然后 React 的合成事件系统根据事件对象的 target 属性确定哪个组件应该处理该事件。这允许 React 高效地管理事件,而无需将处理器附加到每个元素上。

通过使用这种方法,你可以轻松地将事件传递给子组件,在父组件中处理它们,甚至通过多层嵌套组件传播事件。这有助于构建模块化和可重用的组件架构。我们将在下一章中看到这一点。

除了onClick事件外,React 还支持许多其他事件,如onChangeonSubmitonMouseOver以及所有标准事件。你可以将事件处理器附加到各种元素上,如按钮、输入字段、复选框等。

注意,React 提倡单向数据流,这意味着数据从父组件流向子组件。要从子组件向父组件传递数据或信息,你可以定义回调作为 props,并用必要的数据调用它们。在本书的后续章节中,我们将更深入地探讨 React 中的事件处理以及如何创建自定义回调。

将集合映射到元素

有时候,你需要编写 JavaScript 表达式来改变你的标记结构。在前面的章节中,你学习了如何使用 JavaScript 表达式语法动态更改 JSX 元素的属性值。那么,当你需要根据 JavaScript 集合添加或删除元素时怎么办呢?

在整本书中,当我提到 JavaScript 集合时,我指的是普通对象和数组,或者更普遍地说,任何可迭代的东西。

动态控制 JSX 元素的最佳方式是将它们从集合中映射出来。让我们看看如何做到这一点的一个例子:

import * as ReactDOM from "react-dom";
const array = ["First", "Second", "Third"];
const object = {
  first: 1,
  second: 2,
  third: 3,
};
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <section>
    <h1>Array</h1>
    <ul>
      {array.map((i) => (
        <li key={i}>{i}</li>
      ))}
    </ul>
    <h1>Object</h1>
    <ul>
      {Object.keys(object).map((i) => (
        <li key={i}>
          <strong>{i}: </strong>
          {object[i]}
        </li>
      ))}
    </ul>
  </section>
); 

第一个集合是一个名为array的数组,其中包含字符串值。向下到 JSX 标记,你可以看到对array.map()的调用,它返回一个新数组。映射函数实际上返回一个 JSX 元素(<li>),这意味着数组中的每个项目现在都在标记中表示。

评估这个表达式的结果是数组。不用担心——JSX 知道如何渲染元素数组。为了提高性能,给数组中的每个组件分配一个唯一的key prop 至关重要,这样 React 就可以在后续的重新渲染中高效地管理更新。

对象集合使用相同的技巧,但你必须调用Object.keys()然后映射这个数组。将集合映射到页面上的 JSX 元素的好处是,你可以根据收集到的数据来控制 React 组件的结构。

这意味着你不必依赖于命令式逻辑来控制 UI。

下面是渲染输出的样子:

图片

图 2.5:将 JavaScript 集合映射到 HTML 元素的结果

JavaScript 表达式让 JSX 内容生动起来。React 评估表达式,并根据已经渲染的内容和变化更新 HTML 内容。理解如何利用这些表达式非常重要,因为这是任何 React 开发者日常活动中最常见的事情之一。

现在,是时候学习如何在不依赖 HTML 标签的情况下将 JSX 标记分组在一起了。

构建 JSX 片段

片段是一种将标记块组合在一起的方法,而无需向你的页面添加不必要的结构。例如,一个常见的做法是让 React 组件返回被 <div> 元素包裹的内容。这个元素没有任何实际用途,只会给 DOM 增加杂乱。

让我们看看一个例子。这里有组件的两个版本。一个使用包装元素,另一个使用新的片段功能:

import * as ReactDOM from "react-dom";
import WithoutFragments from "./WithoutFragments";
import WithFragments from "./WithFragments";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <div>
    <WithoutFragments />
    <WithFragments />
  </div>
); 

渲染的两个元素是 <WithoutFragments><WithFragments>。以下是它们渲染后的样子:

图 2.6:片段有助于渲染更少的 HTML 标签,而没有任何视觉差异

现在让我们比较这两种方法。

使用包装元素

第一种方法是将兄弟元素包裹在 <div> 中。以下是源代码的样子:

export default function WithoutFragments() {
  return (
    <div>
      <h1>Without Fragments</h1>
      <p>
        Adds an extra <code>div</code> element.
      </p>
    </div>
  );
} 

这个组件的本质是 <h1><p> 标签。然而,为了从 render() 方法中返回它们,你必须用 <div> 标签将它们包裹起来。实际上,使用浏览器开发者工具检查 DOM 会发现 <div> 标签除了添加另一个结构层级外,并没有做任何事情:

图 2.7:DOM 中的另一个结构层级

现在,想象一个包含大量这些组件的应用——那将有很多无用的元素!让我们看看如何使用片段来避免不必要的标签。

使用片段

让我们看看 WithFragments 组件,其中我们避免了使用不必要的标签:

export default function WithFragments() {
  return (
    <>
      <h1>With Fragments</h1>
      <p>Doesn't have any unused DOM elements.</p>
    </>
  );
} 

与将组件内容包裹在 <div> 中的做法不同,这里使用的是 <> 元素。这是一个特殊的元素类型,表示只需要渲染其子元素。<>React.Fragment 组件的简写。如果你需要向片段传递一个键属性,就不能使用 <> 语法。

如果你检查 DOM,可以看到与 WithoutFragments 组件相比的差异。

图 2.8:片段中的更少 HTML

随着在 JSX 标记中使用片段的出现,页面上渲染的 HTML 更少了,因为我们不需要使用 <div> 等标签来仅仅是为了将元素组合在一起。相反,当组件渲染片段时,React 会知道在组件被使用的地方渲染片段的子元素。

因此,片段使 React 组件能够仅渲染必要的元素;不再会有无用的元素出现在渲染的页面上。

概述

在本章中,你学习了 JSX 的基础知识,包括其声明性结构,这有助于编写更易于维护的代码。然后,你编写了一些代码来渲染基本的 HTML,并学习了如何使用 JSX 描述复杂结构;每个 React 应用至少包含一些结构。

然后,你花了一些时间学习如何通过实现自己的 React 组件来扩展 JSX 标记的词汇量,这就是你如何将 UI 设计为一系列较小的部分并将它们粘合在一起形成一个整体的方式。接着,你学习了如何将动态内容引入 JSX 元素属性,以及如何将 JavaScript 集合映射到 JSX 元素,从而消除了控制 UI 显示的命令式逻辑的需求。最后,你学习了如何渲染 JSX 内容的片段,这可以防止使用不必要的 HTML 元素。

现在你已经对通过在 JavaScript 模块中嵌入声明性 XML 来渲染 UI 的感觉有了了解,是时候进入下一章了,我们将更深入地探讨组件、属性和状态。

第三章:理解 React 组件和 Hooks

在本章中,我们将深入探讨 React 组件及其基本方面,并介绍Hooks的强大功能。

我们将探讨组件数据的基本概念以及它是如何塑造你的 React 应用结构的。我们将讨论两种主要的组件数据类型:属性状态。属性允许我们向组件传递数据,而状态使组件能够动态地管理和更新其内部数据。我们将探讨这些概念如何应用于函数组件,并说明设置组件状态和传递属性的工作原理。

在本章中,我们将涵盖以下主题:

  • React 组件简介

  • 组件属性是什么?

  • 组件状态是什么?

  • React Hooks

  • 使用 Hooks 维护状态

  • 执行初始化和清理操作

  • 使用 context Hooks 共享数据

  • 使用 Hooks 进行记忆化

技术要求

本章的代码可以在以下链接找到:github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter03

React 组件简介

React 组件是现代 Web 和移动应用的基础构建块。它们封装了可重用的代码部分,用于定义用户界面不同部分的架构、行为和外观。通过将 UI 分解成更小、更独立的组件,React 使开发者能够创建可扩展、可维护和交互式的应用。

在本质上,React 组件是一个返回类似 HTML 标记的 JSX 语法的 JavaScript 函数或类。在本书中,我们将主要关注函数组件,因为它们在近年来已成为构建组件的首选方法。与类组件相比,函数组件更简单、更简洁、更容易理解。它们利用 JavaScript 函数的力量,并利用 React Hooks 来管理状态和执行副作用。

在 React 中使用组件的主要优势之一是它们的可重用性。组件可以在应用的多个部分中重用,减少代码重复并提高开发效率。此外,组件促进了模块化开发方法,使开发者能够将复杂的 UI 分解成更小、更易于管理的部分。

组件属性是什么?

在 React 中,组件属性,通常称为props,允许我们将数据从父组件传递到其子组件。Props 提供了一种定制和配置组件的方式,使它们灵活且可重用。Props 是只读的,这意味着子组件不应直接修改它们。相反,父组件可以更新 props 值并触发子组件的重新渲染,以使用更新的数据。

当定义一个函数组件时,你可以将其作为参数访问传递给它的 props:

const MyComponent = (props) => {
  return (
    <div>
      <h1>{props.title}</h1>
      <p>{props.description}</p>
    </div>
  );
}; 

在上面的例子中,MyComponent函数组件将 props 对象作为参数接收。我们可以通过点符号访问单个属性,例如props.titleprops.description,以在组件的 JSX 标记中渲染数据。也可以通过解构来访问属性:

const MyComponent = ({ title, description }) => {
  return (
    <div>
      <h1>{title}</h1>
      <p>{description}</p>
    </div>
  );
}; 

如你所见,这种方法甚至更简洁,还允许我们使用另一个解构特性,默认值,我们将在本章中讨论。

传递属性值

React 组件属性是通过在渲染组件时传递 JSX 属性来设置的。在第七章Type Checking and Validation with TypeScript中,我将更详细地介绍如何验证传递给组件的属性值。现在,让我们创建一些额外的组件,除了MyComponent之外,它们期望不同类型的属性值:

const MyButton = ({ disabled, text }) => {
  return <button disabled={disabled}>{text}</button>;
}; 

这个简单的按钮组件期望一个布尔值禁用属性和一个字符串文本属性。当我们创建组件来展示如何传递以下属性时,你会注意到我们已经在按钮 HTML 元素中传递了这些属性:

  • 禁用属性:我们将其放入按钮属性中,属性名为disabled

  • 文本属性:我们将其作为子属性传递给按钮

还重要的是要知道,你想要传递给组件的任何 JavaScript 表达式都应该用大括号括起来。

让我们再创建一个期望数组属性值的组件:

const MyList = ({ items }) => (
  <ul>
    {items.map((i) => (
      <li key={i}>{i}</li>
    ))}
  </ul>
); 

你可以通过 JSX 传递几乎任何你想要的属性值,只要它是一个有效的 JavaScript 表达式。MyList组件接受一个 items 属性,一个映射到<li>元素的数组。

现在,让我们编写一些代码来设置这些属性值:

import * as ReactDOM from "react-dom";
import MyButton from "./MyButton";
import MyList from "./MyList";
import MyComponent from "./MyComponent";
const root = ReactDOM.createRoot(document.getElementById("root"));
const appState = {
  text: "My Button",
  disabled: true,
  items: ["First", "Second", "Third"],
};
function render(props) {
  root.render(
    <main>
      <MyComponent
        title="Welcome to My App"
        description="This is a sample component."
      />
      <MyButton text={props.text} disabled={props.disabled} />
      <MyButton text="Another Button" disabled />
      <MyList items={props.items} />
    </main>
  );
}
render(appState);
setTimeout(() => {
  appState.disabled = false;
  appState.items.push("Fourth");
  render(appState);
}, 1000); 

render函数看起来每次被调用时都会创建新的 React 组件实例。React 足够智能,能够判断这些组件已经存在,并且它只需要确定新属性值将导致输出差异。在这个例子中,setTimeout的调用导致 1 秒的延迟。然后,appState.disabled的值被更改为 false,appState.items数组末尾添加了一个新值。对render的调用将使用新的属性值重新渲染组件。

从这个例子中,我们还可以得到一个启示,即你有一个appState对象,它保留着应用程序的状态。当组件渲染时,这个状态的一部分会被作为属性传递给组件。状态必须存在于某个地方,在这个例子中,它位于组件之外。我们将在第十二章React 中的状态管理中深入探讨这种方法,以及为什么它很重要。

希望你已经注意到我们渲染了另一个按钮,我们以不同的方式传递了 props:

<MyButton text="Another Button" disabled /> 

这是一个有效的 JSX 表达式,如果我们想向组件传递常量值,我们可以传递不带花括号的字符串,并传递布尔值 true,只需在组件中留下属性名即可。

默认属性值

除了传递数据之外,我们还可以使用 defaultProps 属性指定属性的默认值。当属性未提供时,这很有用,确保组件仍然表现正确:

const MyButton = ({ disabled, text }) => (
  <button disabled={disabled}>{text}</button>
);
MyButton.defaultProps = {
  disabled: false,
  text: "My Button",
}; 

在此情况下,如果父组件没有提供 textdisabled 属性,组件将回退到在 defaultProps 中指定的默认值。

如我之前所述,使用解构,我们有更方便的方式来设置默认属性。

让我们看看 MyButton 组件的更新示例:

const MyButton = ({ disabled = false, text = "My Button" }) => (
  <button disabled={disabled}>{text}</button>
); 

使用解构,我们可以在函数内部定义属性并设置默认值。当组件有很多属性时,这更简洁且易于查看。

在接下来的章节中,我们将进一步探讨使用 Hooks 和其他关键概念的组件状态。

什么是组件状态?

在 React 中,组件状态指的是组件持有的内部数据。它代表可以在组件内部使用并可随时间更新的可变值。状态允许组件跟踪可能发生变化的信息,例如用户输入、API 响应或任何需要动态和响应式的其他数据。

状态是 React 提供的一个特性,它使组件能够管理和更新它们自己的数据。它允许组件在状态变化时重新渲染,确保用户界面反映了最新的数据。

要在 React 组件中定义状态,你应该在组件内部使用 useState hook。然后你可以在组件的方法或 JSX 代码中访问和修改状态。当状态更新时,React 会自动重新渲染组件及其子组件以反映这些更改。

在跳转到组件中使用状态的示例之前,让我们简要地探索一下 React hook 是什么。

React Hooks

React Hooks 是在 React 16.8 中引入的一个特性,它允许你在函数组件中使用状态和其他 React 特性。在 Hooks 之前,状态管理和生命周期方法主要在类组件中使用。Hooks 提供了一种在函数组件中实现类似功能的方法,使它们更强大、更容易编写和理解。

Hooks 是一些函数,它们使你能够“挂钩”到 React 的内部特性,例如状态管理、上下文、效果等。它们以 use 关键字为前缀(例如 useStateuseEffectuseContext 等)。React 提供了几个内置的 Hooks,你也可以创建自定义 Hooks 来封装可重用的状态逻辑。

最常用的内置 Hooks 包括:

  • useState:这个钩子允许你在函数式组件中添加状态。它返回一个包含两个元素的数组:当前状态值和一个用于更新状态的函数。

  • useEffect:这个钩子允许你在组件中执行副作用,例如获取数据、订阅事件或手动操作 DOM。它默认在每次渲染后运行,可以用来处理组件的生命周期事件,比如组件挂载、更新或卸载。

  • useContext:这个钩子允许你从 React 上下文中消费值。它提供了一种方法来访问上下文值,而无需嵌套多个组件。

  • useCallbackuseMemo:这些钩子用于性能优化。useCallback 缓存了一个函数,防止它在每次渲染时被重新创建,而 useMemo 缓存了一个值,只有当它的依赖项改变时才会重新计算。

我们将在本章中检查所有这些钩子,并在整本书中使用它们。让我们继续讨论状态,并探索如何使用 useState 钩子来管理它。

使用 Hooks 维护状态

我们将要查看的第一个 React 钩子 API 被称为 useState,它使你的函数式 React 组件能够拥有状态。在本节中,你将学习如何使用 Hooks 初始化状态值并改变组件的状态。

初始状态值

当我们的组件首次渲染时,它们可能期望设置一些状态值。这被称为组件的初始状态,我们可以使用 useState 钩子来设置初始状态。

让我们来看一个例子:

export default function App() {
  const [name] = React.useState("Mike");
  const [age] = React.useState(32);
  return (
    <>
      <p>My name is {name}</p>
      <p>My age is {age}</p>
    </>
  );
} 

App 组件是一个功能性的 React 组件,它返回 JSX 标记。但现在它也是一个有状态的组件,多亏了 useState 钩子。这个例子初始化了两个状态值,nameage。这就是为什么有两个 useState 调用,每个状态值一个。

你可以在组件中拥有你需要的任意数量的状态。最佳实践是每个状态值使用一个 useState 调用。你当然可以使用一个 useState 调用来定义一个对象作为组件的状态,但这会使事情变得复杂,因为你必须通过对象来访问状态值,而不是直接访问。使用这种方法更新状态值也会更复杂。如果有疑问,请为每个状态值使用一个 useState 钩子。

当我们调用 useState 时,我们得到一个返回给我们的数组。这个数组的第一个值是状态值本身。由于我们在这里使用了数组解构语法,我们可以将值命名为我们想要的任何名称;在这种情况下,它是 nameage。这两个常量在组件首次渲染时都有值,因为我们已经将每个的初始状态值传递给了 useState。以下是渲染后的页面外观:

图 3.1:使用状态钩子值渲染的输出

既然你已经看到了如何设置组件的初始状态值,让我们来了解一下如何更新这些值。

更新状态值

React 组件使用状态来表示随时间变化的价值。组件使用的状态值最初处于一种状态,就像我们在上一节中看到的那样,然后响应某些事件而改变:例如,服务器响应 API 请求并返回新数据,或者用户点击了按钮或更改了表单字段。

要更新状态,useState 钩子为每一块状态提供了一个单独的函数,我们可以从 useState 钩子返回的数组中访问它。第一个元素是状态值,第二个是用于更新值的函数。让我们看看一个例子:

function App() {
  const [name, setName] = React.useState("Mike");
  const [age, setAge] = React.useState(32);
  return (
    <>
      <section>
        <input value={name} onChange={(e) => setName(e.target.value)} />
        <p>My name is {name}</p>
      </section>
      <section>
        <input
          type="number"
          value={age}
          onChange={(e) => setAge(e.target.value)}
        />
        <p>My age is {age}</p>
      </section>
    </>
  );
} 

就像初始状态值部分中的示例一样,本例中的 App 组件有两个状态:nameage。与前面的示例不同,此组件使用两个函数来更新每一块状态。这些函数是从 useState 调用中返回的。让我们更仔细地看看:

const [name, setName] = React.useState("Mike");
const [age, setAge] = React.useState(32); 

现在,我们有两个函数:setNamesetAge,它们可以用来更新我们组件的状态。让我们看看更新 name 状态的文本输入字段:

<section>
  <input value={name} onChange={(e) => setName(e.target.value)} />
  <p>My name is {name}</p>
</section> 

当用户更改 <input> 字段中的文本时,会触发 onChange 事件。此事件的处理器调用 setName,并将 e.target.value 作为参数传递给它。传递给 setName 的参数是名称的新状态值。接下来的段落显示了每次用户更改文本输入时,文本输入也会更新名称的新值。

接下来,让我们看看 age 数字输入字段以及这个值是如何传递给 setAge 的:

<section>
  <input
    type="number"
    value={age}
    onChange={(e) => setAge(e.target.value)}
  />
  <p>My age is {age}</p>
</section> 

age 字段遵循与 name 字段完全相同的模式。唯一的区别是我们将输入类型设置为数字。每当数字发生变化时,setAge 就会被调用,并带有 onChange 事件响应的更新值。接下来的段落显示了随着 age 状态的每次更改,数字输入也会更新。

当这两个输入及其对应的段落在屏幕上渲染时,它们看起来是这样的:

图 3.2:使用 Hooks 更改状态值

在本节中,你学习了 useState 钩子,它用于向功能 React 组件添加状态。每一块状态都使用它自己的钩子,并有自己的值变量和自己的设置函数。这大大简化了在组件中访问和更新状态。任何给定的状态值都应该有一个初始值,这样组件才能在第一次渲染时正确显示。要重新渲染使用状态钩子的功能组件,你可以使用 useState 返回的设置函数来按需更新你的状态值。

你接下来要学习的下一个钩子用于执行初始化和清理操作。

执行初始化和清理操作

通常,我们的 React 组件需要在组件创建时执行某些操作。例如,一个常见的初始化操作是获取组件需要的 API 数据。另一个常见操作是在组件移除时确保任何挂起的 API 请求被取消。在本节中,你将了解 useEffect 钩子以及它如何帮助你在这些两种场景下。你还将了解如何确保初始化代码不会运行得太频繁。

获取组件数据

useEffect 钩子用于在组件中运行“副作用”。另一种思考副作用代码的方式是,函数组件只有一个任务:返回用于渲染的 JSX 内容。如果组件需要做其他事情,例如获取 API 数据,这应该在 useEffect 钩子中完成。例如,如果你只是将 API 调用作为组件函数的一部分,你可能会引入竞态条件和其他难以修复的故障行为。

让我们看看一个使用 Hooks 获取 API 数据的示例:

function App() {
  const [id, setId] = React.useState("loading...");
  const [name, setName] = React.useState("loading...");
  const fetchUser = React.useCallback(() => {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve({ id: 1, name: "Mike" });
      }, 1000);
    });
  }, []);
  React.useEffect(() => {
    fetchUser().then((user) => {
      setId(user.id);
      setName(user.name);
    });
  });
  return (
    <>
      <p>ID: {id}</p>
      <p>Name: {name}</p>
    </>
  );
} 

useEffect 钩子期望一个函数作为参数。这个函数在组件完成渲染后以安全的方式被调用,不会干扰 React 在组件背后进行的任何其他操作。让我们更仔细地看看这个示例的各个部分,从模拟 API 函数开始:

const fetchUser = React.useCallback(() => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ id: 1, name: "Mike" });
    }, 1000);
  });
}, []); 

fetchUser 函数使用 useCallback 钩子定义。这个钩子用于记忆函数,意味着它只会创建一次,并且除非依赖项发生变化,否则不会在后续渲染中重新创建。useCallback 接受两个参数:第一个是我们想要记忆的函数,第二个是用于识别 React 应该重新创建此函数而不是使用记忆版本的依赖项列表。fetchUser 函数将空数组 ([]) 作为依赖项列表传递。这意味着函数在初始渲染期间只会创建一次,并且在后续渲染中不会重新创建。

fetchUser 函数返回一个承诺。承诺解析一个包含两个属性 idname 的简单对象。setTimeout 函数将承诺解决延迟 1 秒,因此这个函数是异步的,就像正常的 fetch 调用一样。

接下来,让我们看看 App 组件使用的 Hooks:

const [id, setId] = React.useState("loading...");
const [name, setName] = React.useState("loading...");
React.useEffect(() => {
  fetchUser().then((user) => {
    setId(user.id);
    setName(user.name);
  });
}); 

如您所见,除了 useCallback,我们还在这个组件中使用了两个 Hooks:useStateuseEffect。以这种方式组合 hook 功能非常强大且被鼓励。首先,我们设置组件的 idname 状态。然后,使用 useEffect 设置一个函数,当承诺解决时调用 fetchUser 并设置组件的状态。

这是 App 组件首次渲染时的样子,使用初始状态 idname

图 3.3:显示加载文本,直到数据到达

图 3.3:在数据到达之前显示加载文本

1 秒后,fetchUser 返回的 promise 使用 API 数据解析,然后用于更新 ID 和名称状态。这导致 App 重新渲染:

图片

图 3.4:状态变化,移除加载文本并显示返回值

有很大可能性,你的用户会在 API 请求挂起时在应用中导航。useEffect 钩子可以用来处理取消这些请求。

取消操作和重置状态

有很大可能性,在某个时刻,你的用户会在 API 请求的响应到达之前导航你的应用,导致组件卸载。有时你的组件可以监听某些事件,你应该在卸载组件之前删除所有监听器以避免内存泄漏。一般来说,当相关组件从屏幕中删除时,停止执行任何后台操作是很重要的。

幸运的是,useEffect 钩子有一个机制来清理组件移除时挂起的 setInterval 等效果。让我们看看一个实际应用的例子:

import * as React from "react";
function Timer() {
  const [timer, setTimer] = React.useState(100);
  React.useEffect(() => {
    const interval = setInterval(() => {
      setTimer((prevTimer) => (prevTimer === 0 ? 0 : prevTimer - 1));
    }, 1000);
    return () => {
      clearInterval(interval);
    };
  }, []);
  return <p>Timer: {timer}</p>;
}
export default Timer; 

这是一个简单的 Timer 组件。它具有 timer 状态,它在 useEffect() 中设置间隔回调以更新 timer,并使用当前 timer 值渲染输出。让我们更仔细地看看 useEffect() 钩子:

React.useEffect(() => {
  const interval = setInterval(() => {
    setTimer((prevTimer) => (prevTimer === 0 ? 0 : prevTimer - 1));
  }, 1000);
  return () => {
    clearInterval(interval);
  };
}, []); 

此效果通过调用带有回调的 setInterval 函数创建一个间隔计时器,该回调更新我们的 timer 状态。你在这里会发现有趣的是,对于 setTimer 函数,我们传递的是一个回调而不是一个数字。这是一个有效的 React API:当我们需要使用前一个状态值来计算新值时,我们可以传递一个回调,其中第一个参数是当前或“前一个”状态值,我们应该从这个回调中返回新状态值以更新我们的状态。

useEffect 中,我们还在返回一个函数,React 在组件移除时运行此函数。在这个例子中,通过调用 setInterval 创建的间隔被调用 useEffect 中返回的函数的 clearInterval 清除。从 useEffect 返回的函数将在组件将要卸载时触发。

现在,让我们看看渲染和移除 Timer 组件的 App 组件:

const ShowHideTimer = ({ show }) => (show ? <Timer /> : null);
function App() {
  const [show, setShow] = React.useState(false);
  return (
    <>
      <button onClick={() => setShow(!show)}>
        {show ? "Hide Timer" : "Show Timer"}
      </button>
      <ShowHideTimer show={show} />
    </>
  );
} 

App 组件渲染一个按钮,用于切换 show 状态。此状态值决定是否渲染 Timer 组件,但通过使用 ShowHideTimer 便利组件。如果 show 为真,则渲染 <Timer />;否则,移除 Timer,触发我们的 useEffect 清理行为。

这是屏幕首次加载时的样子:

图片

图 3.5:用于启动状态变化的按钮

由于App组件的show状态为falseTimer组件没有被渲染。尝试点击显示计时器按钮。这将改变show状态并渲染Timer组件:

图片

图 3.6:显示计时器

你可以再次点击隐藏计时器按钮来移除Timer组件。如果没有添加到useEffect中的清理间隔,这将每次计时器渲染时创建新的监听器,这将影响内存泄漏。

React 允许我们控制何时运行我们的效果。例如,当我们想在第一次渲染后进行所有 API 请求,或者当特定状态改变时执行效果。我们将看看如何做到这一点。

优化副作用操作

默认情况下,React 假设每个运行的效果都需要清理,并且应该在每次渲染时运行。这通常不是情况。例如,你可能有一些特定的属性或状态值需要清理,并在它们改变时再运行一次。你可以将一个要观察的值的数组作为useEffect的第二个参数传递:例如,如果你有一个在改变时需要清理的解析状态,你会这样编写你的效果代码:

const [resolved, setResolved] = useState(false);
useEffect(() => {
  // ...the effect code...
  return () => {
    // ...the cleanup code
  };
}, [resolved]); 

在此代码中,效果将在状态值解析改变时触发,并且只有在解析状态值改变时才会运行。如果效果运行但解析状态没有改变,则清理代码将不会运行,原始效果代码也不会再次运行。另一个常见的情况是,除了组件被移除时,从不运行清理代码。实际上,这正是我们在“获取用户数据”部分示例中想要发生的。目前,效果在每次渲染后都会运行。这意味着我们反复获取用户 API 数据,而我们真正想要的只是当组件首次挂载时获取一次。

让我们对从获取组件数据请求示例中的App组件进行一些修改:

React.useEffect(() => {
  fetchUser().then((user) => {
    setId(user.id);
    setName(user.name);
  });
}, []); 

我们向useEffect添加了一个第二个参数,一个空数组。这告诉 React 没有要观察的值,并且我们只想在渲染后运行效果,并在组件移除时运行清理代码。我们还向fetchUser函数中添加了console.count('fetching user')。这使得查看浏览器开发者工具控制台并确保我们的组件数据只获取一次变得更容易。如果你从传递给useEffect[]参数中移除,你会注意到fetchUser被多次调用。

在本节中,你了解了 React 组件中的副作用。效果是一个重要的概念,因为它们是 React 组件和外部世界之间的桥梁。效果最常见的使用场景之一是在组件首次创建时获取组件所需的数据,然后在组件移除后进行清理。

现在,我们将探讨另一种与 React 组件共享数据的方法:上下文。

使用上下文 Hooks 共享数据

React 应用程序通常有一些全局性质的数据。这意味着几个组件,可能是一个应用程序中的所有组件,共享这些数据:例如,当前登录用户的信息可能在多个地方使用。这就是Context API派上用场的地方。Context API 提供了一种创建共享数据存储的方法,任何树中的组件都可以访问,无论其深度如何。

要利用 Context API,我们需要使用React库中的createContext函数创建一个上下文:

import { createContext } from 'react';
const MyContext = createContext(); 

在上面的例子中,我们使用createContext创建了一个名为MyContext的上下文。这创建了一个包含ProviderConsumer的上下文对象。

Provider组件负责将共享数据提供给其子组件。我们用Provider包装组件树的相关部分,并通过value属性传递数据:

<MyContext.Provider value={/* shared data */}>
  {/* Child components */}
</MyContext.Provider> 

MyContext.Provider内的任何组件都可以使用Consumer组件或useContext钩子访问共享数据。让我们看看如何使用钩子读取上下文:

import React, { useContext } from 'react';
const MyComponent = () => {
  const value = useContext(MyContext);
  // Render using the shared data
}; 

通过利用 Context API,我们可以避免需要通过多个组件层级传递数据的 prop-drilling 问题。它简化了数据共享的过程,并允许组件直接访问共享数据,使代码更易于阅读和维护。

值得注意的是,Context API 并不适用于所有场景,应谨慎使用。它对于共享真正全局或与组件树大部分相关联的数据最有用。对于较小规模的数据共享,属性仍然是推荐的方法。

使用 Hooks 进行缓存

在 React 中,函数组件在每次渲染时都会被调用,这意味着昂贵的计算和函数创建可能会对性能产生负面影响。为了优化性能并防止不必要的重新计算,React 提供了三个 Hooks:useMemouseCallbackuseRef。这些 Hooks 允许我们分别缓存值、函数和引用。

useMemo 钩子

useMemo钩子用于缓存计算结果,确保只有当依赖项发生变化时才重新计算。它接受一个函数和一个依赖项数组,并返回缓存的值。

下面是使用useMemo钩子的一个例子:

import { useMemo } from 'react';
const Component = () => {
  const expensiveResult = useMemo(() => {
    // Expensive computation
    return computeExpensiveValue(dependency);
  }, [dependency]);
  return <div>{expensiveResult}</div>;
}; 

在这个例子中,expensiveResult值使用useMemo进行了缓存。函数内的计算只有在dependency值改变时才会执行。如果dependency保持不变,则返回之前缓存的值,而不是重新计算结果。

useCallback 钩子

我们已经在本章中探讨了 useCallback 钩子,但我想要强调一个重要的用例。当一个函数组件渲染时,它所有的函数都会被重新创建,包括在组件内部定义的任何内联回调。这可能导致接收这些回调作为属性的孩子组件的不必要重新渲染,因为这些组件将回调视为新的引用并触发重新渲染。让我们看看下面的例子:

const MyComponent = () => {
  return <MyButton onClick={() => console.log("click")} />;
}; 

在这个例子中,我们提供给 onClick 属性的匿名函数将在 MyComponent 每次渲染时创建。这意味着 MyButton 组件每次都会接收到一个新的函数引用,正如我们已知的,这将导致 MyButton 组件的新渲染。

下面是一个演示 useCallback 钩子使用的例子:

const MyComponent = () => {
  const clickHandler = React.useCallback(() => {
    console.log("click");
  }, []);
  return <MyButton onClick={clickHandler} />;
}; 

在这个例子中,clickHandler 函数是通过 useCallback 进行缓存的。空依赖数组 [] 表示该函数没有依赖项,并且应该在组件的生命周期内保持不变。

因此,在 MyComponent 的每次渲染中,都会向 MyButton 提供相同的函数实例,从而防止孩子组件的不必要重新渲染。

useRef 钩子

useRef 钩子允许我们创建一个在组件渲染之间持续存在的可变引用。它通常用于存储需要在渲染之间保留的值或引用,而不会触发重新渲染。此外,useRef 可以用来访问 DOM 节点或 React 组件实例:

const Component = () => {
  const inputRef = useRef();
  const handleButtonClick = () => {
    inputRef.current.focus();
  };
  return (
    <div>
      <input type="text" ref={inputRef} />
      <button onClick={handleButtonClick}>Focus Input</button>
    </div>
  );
}; 

在这个例子中,inputRef 是通过 useRef 创建的,并且被分配给了 input 元素的 ref 属性。这使我们能够通过 inputRef.current 属性访问 DOM 节点。在 handleButtonClick 函数中,我们调用 inputRef.current 上的 focus 方法,以便在按钮被点击时聚焦输入元素。

通过使用 useRef 访问 DOM 节点,我们可以直接与底层的 DOM 元素交互,而不会触发组件的重新渲染。

通过利用 useMemouseCallbackuseRef 钩子的缓存功能,我们可以通过避免不必要的计算、防止不必要的重新渲染以及跨渲染保留值和引用来优化我们的 React 应用程序的性能。这导致更平滑的用户体验和更高效地使用资源。

摘要

本章向您介绍了 React 组件和 React Hooks。您通过实现将属性值从 JSX 传递到组件的代码来学习了组件属性或 props。接下来,您了解了状态是什么以及如何使用 useState 钩子来操作它。然后,您学习了 useEffect,它使功能 React 组件能够进行生命周期管理,例如在组件挂载时获取 API 数据,以及在组件移除时清理任何挂起的异步操作。然后,您学习了如何使用 useContext() 钩子来访问全局应用程序数据。最后,您学习了使用 useMemouseCallbackuseMemo 钩子进行记忆化。

在下一章中,您将学习如何使用 React 组件处理事件。

第四章:React 方式的事件处理

本章的重点是高阶事件处理器函数。

之后,你将学习 React 如何在底层将事件处理器映射到 DOM 元素。最后,你将了解 React 传递给事件处理器函数的合成事件以及它们如何为了性能目的而被池化。一旦你完成这一章,你将能够轻松地在你的 React 组件中实现事件处理器。到那时,你的应用程序将因为用户能够与之交互而变得生动起来。

本章涵盖了以下主题:

  • 声明事件处理器

  • 声明内联事件处理器

  • 将处理器绑定到元素上

  • 使用合成事件对象

  • 理解事件池化

技术要求

本章中展示的代码可以在以下链接找到:github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter04

声明事件处理器

与 React 组件中的事件处理相比,其区分因素在于它是声明式的。将此与例如jQuery进行比较,在 jQuery 中,你必须编写命令式代码来选择相关的 DOM 元素并将事件处理器函数附加到它们上。

JSX 标记中声明式事件处理器方法的优点是它们是 UI 结构的一部分。不必追踪分配事件处理器的代码,这在心理上是一种解放。

在本节中,你将编写一个基本的事件处理器,以便你可以感受到在 React 应用程序中找到的声明式事件处理语法。然后,你将学习如何使用通用事件处理器函数。

声明处理器函数

让我们看看一个基本的组件,它声明了一个元素的点击事件处理器:

function MyButton(props) {
  const clickHandler = () => {
    console.log("clicked");
  };
  return <button onClick={clickHandler}>{props.children}</button>;
} 

clickHandler函数被传递到<button>元素的onClick属性。通过查看这个标记,你可以确切地看到当按钮被点击时将运行哪些代码。

react.dev/reference/react-dom/components/common查看官方 React 文档,以获取支持的完整事件属性名称列表。

接下来,让我们看看如何使用同一元素的不同事件处理器来响应多种类型的事件。

多个事件处理器

我非常喜欢声明式事件处理器语法的是,当分配给一个元素多个处理器时,它很容易阅读。有时,例如,一个元素有两个或三个处理器。对于单个事件处理器,命令式代码就很难处理,更不用说多个处理器了。当一个元素需要更多处理器时,它只是另一个 JSX 属性。从代码可维护性的角度来看,这一点从以下示例中可以看出:

function MyInput() {
  const onChange = () => {
    console.log("changed");
  };
  const onBlur = () => {
    console.log("blured");
  };
  return <input onChange={onChange} onBlur={onBlur} />;
} 

这个<input>元素可以有更多的事件处理器,代码的可读性仍然很好。

当你继续向你的组件添加更多的事件处理器时,你会注意到其中很多都在做同样的事情。接下来,你将学习关于内联事件处理器函数的内容。

声明内联事件处理器

将处理函数分配给 JSX 属性的传统方法是使用命名函数。然而,有时你可能想使用内联函数,其中函数作为标记的一部分被定义。这是通过将箭头函数直接分配给 JSX 标记中的事件属性来完成的:

function MyButton(props) {
  return (
    <button onClick={(e) => console.log("clicked", e)}>
      {props.children}
    </button>
  );
} 

使用这种内联事件处理器的最主要用途是当你有一个想要传递给另一个函数的静态参数值时。在这个例子中,你正在调用console.log并传入点击的字符串。你可以通过创建一个新的函数或使用高阶函数,在 JSX 标记之外设置一个特殊函数来达到这个目的。但那样你就需要为另一个函数想一个新的名字。有时候内联处理器的使用会更简单一些。

接下来,你将学习 React 是如何将处理函数绑定到浏览器中底层的 DOM 元素上的。

将处理器绑定到元素上

当你在 JSX 中将事件处理器函数分配给一个元素时,React 实际上并没有将事件监听器附加到底层的 DOM 元素上。相反,它将函数添加到内部函数映射中。页面上有一个文档级别的单一事件监听器。当事件通过 DOM 树向上冒泡到文档时,React 处理器会检查是否有任何组件具有匹配的处理器。这个过程在这里被展示出来:

图 4.1:事件处理器周期

你可能会问,为什么 React 要费这么大的劲?这与我在前几章中一直在讲述的相同原则:尽可能地将声明性 UI 结构从 DOM 中分离出来。DOM 仅仅是一个渲染目标;React 的架构允许它对最终的渲染目的地和事件系统保持中立。

例如,当一个新组件被渲染时,它的处理函数被简单地添加到 React 维护的内部映射中。当一个事件被触发并击中文档对象时,React 将事件映射到处理器。如果找到匹配项,它将调用处理器。最后,当React 组件被移除时,处理器将从处理器列表中简单地移除。

这些 DOM 操作实际上并没有触及 DOM。这一切都被一个单独的事件监听器抽象了。这对性能和整体架构(换句话说,保持渲染目标与应用代码的分离)是有好处的。

在接下来的部分,你将学习 React 如何使用合成事件实现来确保良好的性能和安全的异步行为。

使用合成事件对象

当您使用原生的 addEventListener 函数将事件处理函数附加到 DOM 元素时,回调将获得一个事件参数传递给它。React 中的事件处理函数也传递一个事件参数,但它不是标准的事件实例。它被称为 SyntheticEvent,它是原生事件实例的简单包装。

合成事件在 React 中具有两个目的:

  • 它们提供了一个一致的事件接口,标准化了浏览器的不一致性。

  • 它们包含传播所需的信息。

这里是一个React 组件上下文中合成事件的图示:

图 4.2:合成事件是如何创建和处理的

当一个 DOM 元素作为React 组件的一部分派发事件时,React 将处理该事件,因为它为它们设置了自有的监听器。然后,根据可用性,它将创建一个新的合成事件或从池中重用其中一个。如果为该组件声明了任何与派发的 DOM 事件匹配的事件处理程序,它们将使用传递给它们的合成事件运行。

React 中的事件对象具有与原生 JavaScript 事件类似的属性和方法。您可以通过 event.target 访问属性来检索触发事件的 DOM 元素,或通过 event.currentTarget 来引用事件处理程序附加到的元素。

此外,事件对象提供了如 event.preventDefault() 这样的方法来阻止与事件关联的默认行为,例如表单提交或链接点击。您还可以使用 event.stopPropagation() 来阻止事件进一步向上传播到组件树,防止事件冒泡。

事件传播在 React 中与传统 JavaScript 事件处理不同。在传统方法中,事件通常通过 DOM 树向上冒泡,触发祖先元素上的处理程序。

在 React 中,事件传播基于组件层次结构而不是 DOM 层次结构。当一个事件在子组件中发生时,React 会在组件树的根处捕获该事件,然后向下遍历到触发事件的特定组件。这种方法称为事件委托,通过在组件树的根处集中事件逻辑来简化事件处理。

React 的事件委托提供了几个好处。首先,它减少了附加到单个 DOM 元素上的事件监听器的数量,从而提高了性能。其次,它允许您处理动态创建或删除的元素的事件,而无需担心手动附加或移除事件监听器。

在下一节中,您将看到这些合成事件如何为了性能原因而被池化,以及这对异步代码的影响。

理解事件池化

将原生事件实例包装起来的一大挑战是它可能会引起性能问题。每个创建的合成事件包装器最终都需要进行垃圾收集,这在 CPU 时间上可能代价高昂。

当垃圾收集器运行时,你的任何 JavaScript 代码都无法运行。这就是为什么内存效率很重要;频繁的垃圾收集意味着响应用户交互的代码有更少的 CPU 时间。

例如,如果你的应用程序只处理少量事件,这不会有多大影响。但即使按照适度标准,应用程序也会响应许多事件,即使处理程序实际上并没有对它们做任何事情。如果 React 不断需要分配新的合成事件实例,这就会成为问题。

React 通过分配一个合成实例池来处理这个问题。每当一个事件被触发时,它会从池中取出一个实例并填充其属性。当事件处理程序运行完成后,合成事件实例被释放回池中,如下所示:

图 4.3:合成事件被重复使用以节省内存资源

这防止了在触发大量事件时垃圾收集器频繁运行。池保留了合成事件实例的引用,因此它们永远不会符合垃圾收集的条件。React 也永远不会需要分配新实例。

然而,有一个需要注意的陷阱。它涉及到从事件处理程序中的异步代码访问合成事件实例。这是一个问题,因为一旦处理程序运行完成,实例就会回到池中。当它回到池中时,所有属性都会被清除。

下面是一个示例,说明这可能会出错:

function fetchData() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, 1000);
  });
}
function MyButton(props) {
  function onClick(e) {
    console.log("clicked", e.currentTarget.style);
    fetchData().then(() => {
      console.log("callback", e.currentTarget.style);
    });
  }
  return <button onClick={onClick}>{props.children}</button>;
} 

第二次调用 console.log 尝试从一个异步回调中访问合成事件属性,该回调直到事件处理程序完成才运行,这导致事件清空了其属性。这会产生一个警告和一个未定义的值。

本例的目的是说明当你编写与事件交互的异步代码时,事情可能会出错。只是不要这样做!

在本节中,你了解到事件被池化是为了性能原因,这意味着你永远不应该以异步方式访问事件对象。

摘要

本章介绍了 React 中的事件处理。React 与其他事件处理方法的关键区别在于处理程序是在 JSX 标记中声明的。这使得追踪哪些元素处理哪些事件变得简单得多。

你了解到在单个元素上添加多个事件处理程序只是添加新的 JSX 属性的问题。然后,你学习了内联事件处理函数及其潜在用途,以及 React 实际上如何将单个 DOM 事件处理程序绑定到文档对象。

合成事件是封装原生事件的抽象;你已经了解到它们为何是必要的,以及它们是如何为了高效内存消耗而被池化的。

在下一章中,你将学习如何创建适用于多种目的的可重用组件。而不是为每个遇到的使用案例编写新的组件,你将学会必要的技能来重构现有组件,以便它们可以在多个上下文中使用。

第五章:构建可复用组件

本章的目标是向您展示如何实现具有多个目的的 React 组件。阅读本章后,您将对自己的应用功能组合充满信心。

本章首先简要介绍了 HTML 元素及其在帮助实现功能与具有高度实用性方面的作用。然后,您将看到单一组件的实现,并发现它将带来的问题。下一节将致力于以这种方式重新实现单一组件,即功能由更小的组件组成。

最后,本章以对 React 组件渲染树的讨论结束,并为您提供一些关于如何避免在分解组件时引入过多复杂性的建议。我将在最后一节重申高级功能组件与实用组件的概念。

本章将涵盖以下主题:

  • 可复用 HTML 元素

  • 单一组件的困难之处

  • 重构组件结构

  • 渲染属性

  • 渲染组件树

技术要求

您可以在 GitHub 上找到本章的代码文件,链接为github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter05

可复用 HTML 元素

让我们暂时思考一下 HTML 元素。根据 HTML 元素的类型,它要么是功能中心的,要么是实用中心的。实用中心的 HTML 元素比功能中心的 HTML 元素更易于复用。例如,考虑<section>元素。这是一个通用元素,几乎可以用于任何地方,但其主要目的是组成功能的结构方面:功能的壳体和功能的内部部分。这就是<section>元素最有用的地方。

在另一边,您有像<p><span><button>这样的元素。这些元素因为设计上的通用性而提供了高级的实用性。当用户可以点击并执行操作时,您应该使用<button>元素,这比功能的概念低一个层次。

虽然谈论具有高度实用性的 HTML 元素与针对特定功能设计的元素很容易,但当涉及数据时,讨论会更加详细。HTML 是静态标记;React 组件将静态标记与数据相结合。问题是,您如何确保您正在创建正确的以功能为中心和以实用为中心的组件?

本章的目标是找出如何将定义功能的单一 React 组件转变为更小的以功能为中心的组件,并结合实用组件。

单一组件的困难之处

如果你只为任何给定的功能实现一个组件,这将简化你的工作。至少,维护的组件不会很多,数据的流动路径也不会很多,因为所有内容都在组件内部。

然而,这个想法由于多种原因而不可行。拥有单体功能组件使得协调任何类型的团队开发工作变得困难,例如版本控制合并冲突并行开发。单体组件越大,将来重构为更好的组件就越困难。

还有一个功能重叠和功能通信的问题。重叠是由于功能之间的相似性造成的;一个应用程序不太可能有一组完全独特的功能。这将使应用程序非常难以学习和使用。组件通信基本上意味着一个功能中某个东西的状态将影响另一个功能中某个东西的状态。状态很难处理,当有很多状态被封装在单体组件中时,情况更是如此。

学习如何避免单体组件的最佳方式是亲身体验。你将在本节的剩余部分实现一个单体组件。在下一节中,你将看到这个组件是如何被重构为更可持续的样式的。

JSX 标记

我们将要实现的单体组件是一个列出文章的功能。这只是为了说明目的,所以我们不想让组件的大小过大。它将简单而单体。用户可以向列表中添加新项目,切换列表中项目的摘要,并从列表中删除项目。

这是组件的 JSX 标记:

<section>
      <header>
        <h1>Articles</h1>
        <input placeholder="Title" value={title} onChange={onChangeTitle} />
        <input
          placeholder="Summary"
          value={summary}
          onChange={onChangeSummary}
        />
        <button onClick={onClickAdd}>Add</button>
      </header>
      <article>
        <ul>
          {articles.map((i) => (
            <li key={i.id}>
              <a
                href={'#${i.id}'}
                title="Toggle Summary"
                onClick={() => onClickToggle(i.id)}
              >
                {i.title}
              </a>
              &nbsp;
              <button
                href={'#${i.id}'}
                title="Remove"
                onClick={() => onClickRemove(i.id)}
              >
                &#10007;
              </button>
              <p style={{ display: i.display }}>{i.summary}</p>
            </li>
          ))}
        </ul>
      </article>
    </section> 

这里的 JSX 明显比必要的多。我们将在下一节中改进这一点,但现在,让我们为这个组件实现初始状态。

初始状态

现在,让我们看看这个组件的初始状态:

 const [articles, setArticles] = React.useState([
    {
      id: id.next(),
      title: "Article 1",
      summary: "Article 1 Summary",
      display: "none",
    },
    {
      id: id.next(),
      title: "Article 2",
      summary: "Article 2 Summary",
      display: "none",
    },
  ]);
  const [title, setTitle] = React.useState("");
  const [summary, setSummary] = React.useState(""); 

状态由一个articles数组、一个title字符串和一个summary字符串组成。articles数组中的每个文章对象都有几个字符串字段来帮助渲染文章,以及一个id字段,这是一个数字。这个数字是由id.next()生成的。

让我们看看这是如何工作的:

const id = (function* () {
  let i = 1;
  while (true) {
    yield i;
    i += 1;
  }
})(); 

id 常量是一个生成器。它通过定义一个内联生成器函数并立即调用它来创建。这个生成器将无限期地产生数字。所以,第一次调用id.next()返回1,下一次是2,依此类推。这个简单的实用工具在需要添加新文章并需要一个新唯一 ID 时会很有用。

事件处理器实现

到目前为止,你已经有了组件的初始状态和 JSX 代码。现在,是时候实现事件处理器了:

 const onChangeTitle = useCallback((e) => {
    setTitle(e.target.value);
  }, []);
  const onChangeSummary = useCallback((e) => {
    setSummary(e.target.value);
  }, []); 

onChangeTitle()onChangeSummary()方法分别使用钩子的setState()来更新titlesummary状态值。新值来自event参数的target.value属性,这是用户输入到文本输入中的值:

 const onClickAdd = useCallback(() => {
    setArticles((state) => [
      ...state,
      {
        id: id.next(),
        title: title,
        summary: summary,
        display: "none",
      },
    ]);
    setTitle("");
    setSummary("");
  }, [summary, title]); 

onClickAdd()方法向articles状态添加一篇新文章。这个状态值是一个数组。我们使用扩展运算符从现有数组([...state])构建一个新数组,并将新对象添加到新数组的末尾。我们构建新数组并将其传递给setArticles()的原因是为了避免意外。换句话说,我们将状态值视为不可变,以便更新相同状态的其它代码不会意外地造成问题。接下来,我们将使用一个处理程序来删除文章:

 const onClickRemove = useCallback((id) => {
    setArticles((state) => 
      state.filter((article) => article.id !== id)
    );
  }, []); 

onClickRemove()方法从articles状态中删除具有给定 ID 的文章。它是通过在数组上调用filter()来实现的,这返回一个新数组,因此操作是不可变的。过滤器删除具有给定 ID 的对象:

 const onClickToggle = useCallback((id) => {
    setArticles((state) => {
      const articles = [...state];
      const index = articles.findIndex((article) => article.id === id);
      articles[index] = {
        ...articles[index],
        display: articles[index].display ? "" : "none",
      };
      return articles;
    });
  }, []); 

onClickToggle()方法切换具有给定 ID 的文章的可见性。在这个方法中,我们执行了两个不可变操作。首先,我们构建一个新的articles数组。然后,根据给定 ID 的索引,我们用新对象替换索引处的文章对象。我们使用对象扩展运算符来填充属性({...articles[index]}),然后根据现有的显示值切换显示属性值。

这里是输出渲染的截图:

图 5.1:渲染的文章

到目前为止,我们有一个组件,它完成了我们需要的所有功能。然而,它是单体化的,难以维护。想象一下,如果我们应用中的其他地方也使用了相同的MyFeature片段。他们必须重新发明它们,因为他们无法共享。在下一节中,我们将努力将MyFeature分解为更小的可重用组件。

重构组件结构

你有一个单体功能组件:接下来怎么办?让我们让它变得更好。

在本节中,你将学习如何将上一节中实现的功能组件拆分为更易于维护的组件。你将从JSX开始,因为这可能是最佳的重构起点。然后,你将为功能实现新的组件。

接下来,你将使这些新组件具有功能性,而不是基于类的。最后,你将学习如何使用渲染属性来减少应用程序中直接组件依赖项的数量,以及如何通过在功能组件中使用钩子来管理状态来完全删除类。

从 JSX 开始

任何单体组件JSX是将其重构为更小组件的最佳起点。让我们可视化我们目前正在重构的组件结构:

图 5.2:构成 React 组件的 JSX 可视化

JSX 的上半部分是表单控件,因此这可以很容易地成为一个自己的组件:

<header>
  <h1>Articles</h1>
  <input 
    placeholder="Title" 
    value={title} 
    onChange={onChangeTitle} />
  <input 
    placeholder="Summary" 
    value={summary} 
    onChange={onChangeSummary} />
  <button onClick={onClickAdd}>Add</button>
</header>; 

接下来,你有文章列表:

<ul>
  {articles.map((i) => (
    <li key={i.id}>
      <a
        href={`#${i.id}`}
        title="Toggle Summary"
        onClick={() => onClickToggle(i.id)}
      >
        {i.title}
      </a>
      &nbsp;
      <button
        href={'#${i.id}'}
        title="Remove"
        onClick={() => onClickRemove(i.id)}
      >
        &#10007;
      </button>
      <p style={{ display: i.display }}>{i.summary}</p>
    </li>
  ))}
</ul> 

在这个列表中,有一个文章组件的潜力,它包括了 <li> 标签中的所有内容。让我们尝试构建这个组件。

实现文章列表组件

这是 ArticleList 组件的实现看起来像:

function ArticleList({ articles, onClickToggle, onClickRemove }) {
  return (
    <ul>
      {articles.map((i) => (
        <li key={i.id}>
          <a
            href={'#${i.id}'}
            title="Toggle Summary"
            onClick={() => onClickToggle(i.id)}
          >
            {i.title}
          </a>
          &nbsp;
          <button
            href={'#${i.id}'}
            title="Remove"
            onClick={() => onClickRemove(i.id)}
          >
            &#10007;
          </button>
          <p style={{ display: i.display }}>{i.summary}</p>
        </li>
      ))}
    </ul>
  );
} 

我们将相关的 JSX 从单体组件中提取出来,并将其放在这里。现在,让我们看看功能组件的 JSX 看起来像:

 <section>
      <header>
        <h1>Articles</h1>
        <input placeholder="Title" value={title} onChange={onChangeTitle} />
        <input
          placeholder="Summary"
          value={summary}
          onChange={onChangeSummary}
        />
        <button onClick={onClickAdd}>Add</button>
      </header>
      <ArticleList
        articles={articles}
        onClickRemove={onClickRemove}
        onClickToggle={onClickToggle}
      />
    </section> 

文章列表现在由 ArticleList 组件渲染。要渲染的文章列表作为属性传递给此组件,同时还有两个事件处理程序。

为什么我们要将事件处理程序传递给子组件?原因是为了让 ArticleList 组件不必担心状态或状态如何变化。它只关心渲染内容和确保适当的事件回调被连接到适当的 DOM 元素。这是一个容器组件的概念,我将在本章后面进一步阐述。

现在我们有了 ArticleList 组件,让我们看看我们是否可以进一步将其分解成更小的可重用组件。

实现文章项目组件

在实现了文章列表组件之后,你可能会决定将其分解成更小的组件。

另一种看待它的方法是:如果最终我们发现实际上不需要将项目作为自己的组件,这个新组件不会引入太多的间接或复杂性。无需多言,以下是文章项目组件:

function ArticleItem({ article, onClickRemove }) {
  const [isOpened, setIsOpened] = React.useState(article.display !== "none");
  const onClickToggle = React.useCallback(() => {
    setIsOpened((state) => !state);
  }, []);
  return (
    <li>
      <a href={'#${article.id}'} title="Toggle Summary" onClick={onClickToggle}>
        {article.title}
      </a>
      &nbsp;
      <button
        href={'#${article.id}'}
        title="Remove"
        onClick={() => onClickRemove(article.id)}
      >
        &#10007;
      </button>
      <p style={{ display: isOpened ? "block" : "none" }}>{article.summary}</p>
    </li>
  );
} 

实际上,组件除了一个增强之外没有变化:我们将展开和折叠文章的逻辑重新定位到了 ArticleItem 组件,这提供了几个优点。首先,我们减少了原始的 MyFeature 组件,因为它根本不需要知道何时隐藏或展开文章。其次,由于在展开文章时,我们不再使用展开操作符重新创建文章数组,而是仅更改本地状态,因此我们提高了应用程序的性能。结果,在展开文章时,文章列表保持不变,React 不会重新渲染页面,但只有一个组件被重新渲染。

这是 ArticleList 组件正在渲染的新 ArticleItem 组件:

function ArticleList({ articles, onClickRemove }) {
  return (
    <ul>
      {articles.map((article) => (
        <ArticleItem
          key={article.id.value}
          article={article}
          onClickRemove={onClickRemove}
        />
      ))}
    </ul>
  );
} 

你看到这个列表是如何映射文章列表的吗?如果你想要实现另一个具有过滤功能的文章列表,那么拥有一个可重用的 ArticleItem 组件是有益的。接下来,我们将添加文章的标记移动到自己的组件中。

实现添加文章组件

现在我们完成了文章列表,是时候考虑用于添加新文章的表单控件了。让我们为这个功能方面实现一个组件:

function AddArticle({
  name,
  title,
  summary,
  onChangeTitle,
  onChangeSummary,
  onClickAdd,
}) {
  return (
    <section>
      <h1>{name}</h1>
      <input placeholder="Title" value={title} onChange={onChangeTitle} />
      <input placeholder="Summary" value={summary} onChange={onChangeSummary} />
      <button onClick={onClickAdd}>Add</button>
    </section>
  );
} 

现在,我们的功能组件只需要渲染 <AddArticle><ArticleList> 组件:

<section>
  <AddArticle
    name="Articles"
    title={title}
    summary={summary}
    onChangeTitle={onChangeTitle}
    onChangeSummary={onChangeSummary}
    onClickAdd={onClickAdd}
  />
  <ArticleList articles={articles} onClickRemove={onClickRemove} />
</section> 

这个组件的焦点在于功能数据,而它将渲染 UI 元素的任务委托给其他组件。在下一节中,我们将探讨渲染属性如何使得将组件作为属性传递而不是直接作为依赖项导入成为可能。

渲染属性

想象一下实现一个由几个较小的组件组成的功能,就像你在本章中一直在做的那样。MyFeature 组件依赖于 ArticleListAddArticle。现在,想象一下在不同的应用部分使用 MyFeature,在这些部分使用不同的 ArticleListAddArticle 实现是有意义的。基本挑战是替换一个组件为另一个组件。

渲染属性 是解决这个挑战的好方法。其思路是,你向你的组件传递一个属性,其值是一个返回要渲染的组件的函数。这样,你就可以配置它们,而不是让功能组件直接依赖于其子组件;它们将它们作为渲染属性值传递。让我们看看一个例子。与其让 MyFeature 直接依赖于 AddArticleArticleList,不如将它们作为渲染属性传递。以下是 MyFeature 使用渲染属性填充 add 之前所在空缺处的样子:

 <section>
      {addArticle({
        title,
        summary,
        onChangeTitle,
        onChangeSummary,
        onClickAdd,
      })}
      {articleList({ articles, onClickRemove })}
    </section> 

addArticle()articleList() 函数使用与 <AddArticle><ArticleList> 分别传递的相同属性值被调用。现在的不同之处在于,这个模块不再将 AddArticleArticleList 作为依赖项导入。

现在,让我们看看 <MyFeature> 被渲染的 main.js 文件:

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <MyFeature
    addArticle={({
      title,
      summary,
      onChangeTitle,
      onChangeSummary,
      onClickAdd,
    }) => (
      <AddArticle
        name="Articles"
        title={title}
        summary={summary}
        onChangeTitle={onChangeTitle}
        onChangeSummary={onChangeSummary}
        onClickAdd={onClickAdd}
      />
    )}
    articleList={({ articles, onClickRemove }) => (
      <ArticleList articles={articles} onClickRemove={onClickRemove} />
    )}
  />
); 

与仅仅渲染 <MyFeature> 时相比,这里现在发生的事情要多得多。让我们分析一下为什么会这样。这是你传递 addArticlearticleList 渲染属性的地方。这些属性值是接受来自 MyComponent 的参数值的函数。例如,onClickRemove() 函数来自 MyFeature,并用于改变该组件的状态。你可以使用 渲染属性 函数将此传递给将要渲染的组件,以及任何其他值。这些函数的返回值是最终要渲染的内容。

在本节中,你了解到通过传递渲染属性值:渲染 JSX 标记的函数,你可以避免在可能想要共享功能的地方硬编码依赖项。向组件传递不同的属性值通常比更改给定模块使用的依赖项更容易。

渲染组件树

让我们花点时间回顾一下本章到目前为止所取得的成果。曾经是单体的功能组件最终几乎完全专注于状态数据。它处理初始状态,处理转换状态,并且如果有的话,它会处理获取状态的网络请求。这是一个典型的 React 应用程序中的容器组件,也是数据的起点。

你为了更好地组合功能而实现的新的组件是这些数据的接收者。这些组件与它们的容器之间的区别在于,它们只关心在渲染时传入它们的属性。换句话说,它们只关心特定时间点的数据快照。从这里,这些组件可能会将属性数据作为属性传递给它们自己的子组件。组合 React 组件的通用模式如下:

图片

图 5.3:从较小组件组合较大 React 组件的模式

容器组件通常包含一个直接子组件。在这个图中,你可以看到容器包含一个项目详情组件或一个列表组件。当然,这两个类别中会有所变化,因为每个应用程序都是不同的。这种通用模式有三个组件组合级别。数据从容器单向流向底层的实用组件

一旦添加超过三层,应用程序架构就难以理解。可能会有需要添加四层 React 组件的异常情况,但作为一个经验法则,你应该避免这样做。

功能组件和实用组件

在本章中,我们讨论的单体组件示例中,你从一个完全专注于一个功能的单一组件开始。这意味着该组件在应用程序的其他地方几乎没有实用性。

原因在于顶层组件处理应用程序状态。有状态组件在其它任何上下文中都难以使用。随着你对单体功能组件进行重构,你创建了新的组件,这些组件进一步远离了数据。一般规则是,你的组件离有状态数据越远,它们的实用性就越大,因为它们的属性值可以从应用程序的任何地方传入。

概述

本章是关于避免单体组件设计。然而,单体通常是在任何 React 组件设计中必要的起点。

你首先学习了不同 HTML 元素具有不同程度的实用性。接下来,你了解了单体 React 组件的问题,并了解了单体组件的实现。

然后,你花费了几个章节学习如何将单体组件重构为更可持续的设计。从这个练习中,你了解到容器组件只需考虑处理状态,而较小的组件因为它们的属性值可以从任何地方传递,所以具有更多的实用性。你还了解到,你可以使用渲染属性来更好地控制组件依赖和替换。

在下一章中,你将学习关于组件属性验证和类型检查的内容。

第六章:使用 TypeScript 进行类型检查和验证

在本章中,我们将探讨在 React 组件中属性验证的重要性,以创建健壮、无错误的程序。我们将介绍 TypeScript,这是一种在 JavaScript 中进行静态类型检查的强大工具。

我们将指导您在项目中设置 TypeScript 并介绍其基本和高级概念。我们还将提供如何使用 TypeScript 在 React 组件中进行类型检查的示例。

到本章结束时,您将在属性验证和类型检查方面打下坚实的基础,并准备好使用 TypeScript 创建更可预测、更可靠的组件。

本章将涵盖以下主题:

  • 了解预期内容

  • TypeScript 简介

  • 在 React 中使用 TypeScript

技术要求

本章的代码文件可以在 GitHub 上找到:github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter05

了解预期内容

在任何应用程序中,可预测性都是关键。一个可预测的应用程序会以预期的行为运行,减少错误,提高用户体验,并简化维护。当我们谈论 React 中的可预测性时,我们通常指的是组件根据其接收的属性如何行为。Props 是属性的简称,作为 React 组件的输入,决定了其行为和渲染。这就是 属性验证 概念发挥作用的地方。

属性验证的重要性

属性验证是一种确保组件接收正确类型数据的方法。它就像组件之间的合同。当组件指定它期望接收的属性类型时,它承诺如果接收了这些类型的属性,它将以某种方式行为。

属性验证对于以下几个原因至关重要:

  • 它有助于在开发过程中早期捕获错误:如果组件接收到了一个意外的属性类型,它可能不会按预期行为,导致难以追踪的错误。通过验证属性,我们可以在它们造成问题之前捕获这些错误。

  • 属性验证提高了代码可读性:通过查看组件的属性类型,您可以快速了解组件期望接收哪些数据。这使得在整个应用程序中使用和重用组件变得更加容易。

  • 属性验证使组件更具可预测性:当组件明确指定它期望接收的属性类型时,更容易理解组件将如何根据其属性行为。

没有属性验证的潜在问题

如果没有足够的属性验证,组件可能会变得不可预测,并容易产生错误。让我们看看一个组件:

const MyList = ({ list }) => (
  <ul>
    {list.map((user) => (
      <li key={user.name}>
        {user.name} ({user.email})
      </li>
    ))}
  </ul>
); 

在这个例子中,一个组件期望接收一个list prop,它应该是一个包含名称和电子邮件属性的数组对象。如果这个组件接收到的 list prop 是一个字符串、一个数字,甚至是一个数组,但没有对象,它可能会尝试访问user.nameuser.email,这会导致错误。

这种类型的错误可能很难调试,尤其是在具有许多组件的大型应用程序中。也可能很难理解我们应该向组件提供什么,而不必阅读这个组件的每一行代码。错误也可能导致应用程序崩溃或出现意外的行为。但如果我们能向我们的组件添加 props 验证,这可以帮助我们提前捕获这些错误并确保组件按预期行为?让我们来探索一下。

props 验证的选项

你可以使用几种工具在 React 和 React Native 中进行 props 验证。其中之一是PropTypes,这是一个库,允许你指定组件应接收的属性类型。另一个选项是 TypeScript,它是 JavaScript 的一个静态类型超集,提供了强大的类型检查工具。

现在,我想向您展示带有PropTypesMyList组件的示例。请看这个组件:

import PropTypes from 'prop-types';
const MyList = ({ list }) => (
  <ul>
    {list.map((user) => (
      <li key={user.name}>
        {user.name} ({user.email})
      </li>
    ))}
  </ul>
);
MyList.propTypes = {
  list: PropTypes.arrayOf(
    PropTypes.shape({
      name: PropTypes.string.isRequired,
      email: PropTypes.string.isRequired,
    })
  ).isRequired,
}; 

在这个例子中,我们使用PropTypes来指定list prop 应该是一个对象数组,并且每个对象都应该有一个nameemail属性,这两个属性都应该是字符串。

接下来,让我们看看 TypeScript 的示例:

type User = {
  name: string;
  email: string;
};
type MyListProps = {
  list: User[];
};
const MyList = ({ list }: MyListProps) => (
  <ul>
    {list.map((user) => (
      <li key={user.name}>
        {user.name} ({user.email})
      </li>
    ))}
  </ul>
); 

在这个 TypeScript 示例中,我们定义了一个User类型和一个MyListProps类型。User类型是一个具有nameemail属性的对象,这两个属性都是字符串。MyListProps类型是一个具有list属性的对象,该属性是一个User对象的数组。

虽然 PropTypes 和 TypeScript 都提供了用于 props 验证的有价值工具,但我们将在这本书的剩余部分专注于 TypeScript。TypeScript 提供了一种更全面、更强大的类型检查方法,并且在 React 和 React Native 社区中越来越受欢迎。

在接下来的章节中,所有示例都将使用 TypeScript。到这本书结束时,你将有一个扎实的 TypeScript 理解,并知道如何在你的 React 和 React Native 项目中使用它。所以,让我们深入探索 TypeScript 的世界吧!

TypeScript 简介

在我们开始学习类型检查和验证的旅程时,让我们暂时从 React 和 React Native 中抽身,将注意力转向 TypeScript。你可能想知道,“TypeScript 究竟是什么?”

TypeScript 是由微软开发和维护的 JavaScript 的静态类型超集。这意味着它为 JavaScript 添加了额外的功能,其中最显著的是静态类型。虽然 JavaScript 是动态类型的,但 TypeScript 引入了一个类型系统,允许你明确地定义变量、函数参数和函数返回值可以具有的数据类型。

但不用担心,TypeScript 与 JavaScript 完全兼容。事实上,任何有效的 JavaScript 代码也是有效的 TypeScript 代码。TypeScript 使用一个转译器(一种编译器类型)将 TypeScript 代码转换为浏览器无法直接理解的 JavaScript 代码,这样就可以在任何 JavaScript 可以运行的环境中运行。

考虑以下 JavaScript 函数:

function greet(name) {
  return "Hello, " + name;
}
console.log(greet("Mike")); // "Hello, Mike"
console.log(greet(32)); // "Hello, 32" 

当你传递一个字符串作为参数时,这个函数按预期工作。但是,如果你传递一个数字,它不会抛出错误,尽管问候一个数字在逻辑上并不合理。

现在,让我们看看我们如何用 TypeScript 编写这个函数:

function greet(name: string) {
  return "Hello, " + name;
}
console.log(greet("Mike")); // "Hello, Mike"
console.log(greet(32)); // Error: Argument of type 'number' is not assignable to parameter of type 'string'. 

在 TypeScript 版本中,我们为name参数添加了类型注解。这告诉 TypeScriptname始终应该是字符串类型。如果我们尝试用数字调用greet,TypeScript 会给出错误。这有助于我们在运行代码之前就捕获到错误。

这是一个简单的例子,但它说明了 TypeScript 的一个关键好处:它可以帮助我们在错误导致代码中的 bug 之前尽早捕获错误。这就像有一个有用的副驾驶,在问题变得严重之前指出潜在的问题。

为什么使用 TypeScript?

现在我们已经介绍了 TypeScript 是什么,让我们深入探讨为什么你可能想在项目中学习和使用它:

  • 尽早捕获错误:我们之前已经讨论过这一点,但将其放在列表的第一位仍然很有价值。TypeScript 最大的优点之一就是能够在编译时捕获错误,甚至在代码运行之前。这有助于防止许多在常规 JavaScript 中可能直到运行时才被捕获的常见错误。

  • 提高代码可读性:TypeScript 的类型注解使得函数期望的参数类型或函数返回的类型值一目了然。这使得代码更容易阅读和理解,尤其是对于可能正在同一代码库上工作的其他开发者来说。

  • 更容易重构:TypeScript 的静态类型还使得代码重构变得更加容易。如果你更改变量的类型或函数的签名,TypeScript 可以帮助你找到代码中需要做出相应更改的所有位置。

  • 社区和工具支持:TypeScript 在 JavaScript 社区中获得了显著的流行度,并被微软、谷歌和 Airbnb 等许多大型公司使用。这意味着有一个庞大的开发者社区可以提供支持,并提供丰富的学习 TypeScript 的资源。此外,许多代码编辑器对 TypeScript 提供了出色的支持,提供如 自动完成类型推断错误突出显示等功能。

  • 与现代框架和库的集成:TypeScript 与现代 JavaScript 框架如 React 和 React Native 集成良好,这些框架内置了 TypeScript 定义,使得构建强类型应用变得更加容易。此外,大多数流行的 JavaScript 库都提供了 TypeScript 定义。这些定义通常由社区贡献,提供了关于库函数和对象类型信息,使得在 TypeScript 项目中使用这些库更加容易和安全。

    这种在 JavaScript 生态系统中对 TypeScript 的广泛应用确保了你可以几乎在代码库的任何地方利用 TypeScript 的益处。

  • 增加就业市场需求:TypeScript 的流行不仅限于开发实践:它在就业市场上也越来越受欢迎。许多公司,从小型初创公司到大型企业,都在其项目中采用 TypeScript,因此对熟练掌握 TypeScript 的开发者的需求不断增长。这对于涉及 React 和 React Native 的职位尤其如此,在这些职位中,TypeScript 通常用于其在大规模代码库扩展和维护方面的优势。通过学习 TypeScript,你不仅为你的项目获得了一项宝贵的技能,而且作为开发者,也使自己在市场上更具竞争力。

总结来说,TypeScript 提供了一系列可以帮助你编写更健壮、可维护代码的益处。它是任何 JavaScript 开发者工具箱中的宝贵工具,其在就业市场上的日益流行使其成为你职业发展的值得投资。

但理解 TypeScript 的好处只是第一步。要真正发挥其威力,你需要知道如何在项目中使用它。在下一节中,我们将指导你通过在 React 项目中设置 TypeScript 的过程。我们将从安装 TypeScript 到配置项目使用 TypeScript 的所有内容进行讲解。那么,让我们深入探索 TypeScript 的实际应用吧!

在项目中设置 TypeScript

在第一章中,我们介绍了使用 Vite 创建新 React 项目的流程。现在,让我们看看如何创建一个 TypeScript 项目。

Vite 为创建新的 React 和 TypeScript 项目提供了一个模板。你可以使用以下命令创建一个新项目:

npm create vite@latest my-react-app -- --template react-ts 

此命令使用 react-ts 模板创建一个新的 Vite 项目,该模板包含 TypeScript。基于此模板的项目将在您的项目根目录中包含 tsconfig.json 文件。此文件用于为您的项目配置 TypeScript。

下面是 tsconfig.json 文件可能的样子:

{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "jsx": "react-jsx",
    "strict": true,
    "moduleResolution": "node",
    "esModuleInterop": true
  }
} 

这些设置告诉 TypeScript 将您的代码编译为最新版本的 JavaScript ("target": "esnext"), 使用最新的模块系统 ("module": "esnext"), 并使用 React 17 中引入的新 JSX 转换 ("jsx": "react-jsx"). "strict": true 选项启用了一组广泛的类型检查行为,以捕获更多问题。

在设置好 TypeScript 之后,让我们编写一些代码。然而,TypeScript 使用与 JavaScript 不同的文件扩展名:没有 JSX 的文件使用 *.ts 扩展名,而带有 JSX 的文件使用 *.tsx 扩展名。因此,让我们使用 TypeScript 创建我们的第一个 React 组件:

type AppProps = {
  message: string;
};
function App({ message }: AppProps) {
  return <div>{message}</div>;
} 

在这个例子中,我们正在为 App 组件的 props 定义一个 AppProps 类型。这告诉 TypeScript,message prop 应该是一个字符串。

现在,让我们看看 main.tsx 现在的样子:

图片 2

图 6.1:main.tsx 文件中的 App 组件及其 TypeScript 错误

这就是 TypeScript 如何检查和验证组件中 props 的使用。在这里,我们应该传递 message prop:

图片 3

图 6.2:main.tsx 文件中没有错误的 App 组件

最后,您可以使用以下命令运行您的项目:

npm run dev 

此命令启动 Vite 开发服务器。如果您的代码中存在任何 类型错误,TypeScript 也会在控制台中显示它们。

TypeScript 中的基本类型

TypeScript 的一个关键特性是其丰富的类型系统。TypeScript 引入了几种基本类型,您可以使用它们来描述您数据的结构。要指定变量的类型,您在变量名后使用冒号,然后跟类型。

让我们探索这些基本类型:

  • 布尔值: 最基本的类型是简单的 true/false 值,JavaScript 和 TypeScript 称其为 Boolean:

    let isDone: boolean = false; 
    
  • 数字: 与 JavaScript 一样,TypeScript 中的所有数字都是浮点值。这些浮点数获得 number 类型:

    let age: number = 32; 
    
  • 字符串: 在 JavaScript 中创建用于网页和服务器程序的基本部分之一是处理 文本数据。与其他语言一样,我们使用 string 类型来引用这些文本数据类型:

    let color: string = "blue"; 
    
  • 数组: TypeScript,就像 JavaScript 一样,允许您使用值数组。数组类型可以用两种方式之一来编写。在第一种方式中,您使用元素的类型,然后跟上一个 [] 来表示该元素类型的数组:

    let list: number[] = [1, 2, 3]; 
    

    第二种方式使用泛型数组类型,Array<elemType>

    let list: Array<number> = [1, 2, 3]; 
    
  • 元组: 元组类型允许您表达一个数组,其中已知固定数量元素的类型,但不需要它们相同。例如,您可能希望将一个值表示为一个 string 和一个 number 的对:

    let x: [string, number];
    x = ["hello", 10]; // OK 
    
  • 枚举:JavaScript 标准数据类型集的一个有用补充是 enum。类似于 C# 等语言,enum 是为数值集合提供更友好名称的一种方式:

    enum Color {
      Red,
      Green,
      Blue,
    }
    let c: Color = Color.Green; 
    
  • Any:当我们编写应用程序时,我们可能需要描述我们不知道的变量的类型。这些值可能来自动态内容,例如用户或第三方库。在这些情况下,我们希望退出类型检查并让值通过编译时检查。为此,我们用 any 类型标记这些值:

    let notSure: any = 4;
    notSure = "maybe a string instead";
    notSure = false; // okay, definitely a Boolean 
    
  • Unknownunknown 类型是 any 的类型安全对应物。任何东西都可以赋值给 unknown,但 unknown 只能赋值给自己和 any(在没有类型断言或基于控制流的缩窄的情况下)。同样,在没有首先断言或缩窄到更具体的类型之前,不允许对 unknown 执行任何操作:

    let notSure: unknown = 4;
    notSure = "maybe a string instead";
    // OK, because of structural typing
    notSure = false; 
    let surelyNotAString: string = notSure; // Error, 'unknown' is not assignable to 'string' 
    

    在这个例子中,如果没有类型检查,我们不能将 notSure 赋值给 surelyNotAString,因为 notSureunknown 类型。这有助于防止错误,因为我们不能在不首先检查其类型的情况下意外地对 unknown 类型的变量执行操作。

    unknown 的一个常见用例是在 catch 子句中,其中 error 对象的类型是未知的:

    try {
      // some operation that might throw
    } catch (error: unknown) {
      if (error instanceof Error) {
        console.log(error.message);
      }
    } 
    

    在这个例子中,我们不知道 error 类型可能是什么,所以我们给它赋予 unknown 类型。这迫使我们在与它交互之前检查其类型。

  • Voidvoid 类似于 any 的对立面:完全没有类型。你可能会常见到它是没有返回值的函数的返回类型:

    function warnUser(): void {
      console.log("This is my warning message");
    } 
    
  • Null 和 undefined:在 TypeScript 中,undefinednull 实际上分别有自己的类型,分别命名为 undefinednull。与 void 类似,它们本身并不非常有用:

    let u: undefined = undefined;
    let n: null = null; 
    

    然而,undefined 在可选类型中起着至关重要的作用。在 TypeScript 中,你可以在类型名称后添加 ? 来使一个类型可选。这意味着值可以是指定的类型或 undefined。例如:

    function greet(name?: string) {
      return 'Hello ${name}';
    }
    greet("Mike");
    greet(undefined); // OK
    greet(); // Also OK 
    
  • 永不:在 TypeScript 中,never 类型代表一种永远不会发生值的类型。它在函数永远不会返回值或达到其执行路径的末尾时使用。例如,抛出错误的函数或具有无限循环的函数可以用 never 类型进行注解:

    function throwError(errorMsg: string): never {
        throw new Error(errorMsg);
    }
    function infiniteLoop(): never {
        while (true) {
        }
    } 
    

理解这些基本类型是在 TypeScript 中工作的关键第一步。当你开始在项目中使用 TypeScript 时,你会发现这些类型是编写健壮、可维护代码的强大工具。

在下一节中,我们将更深入地探讨 TypeScript 的类型系统,并探索接口和类型别名,它们提供了一种定义复杂类型的方法。

接口和类型别名

虽然基本类型对于简单数据类型很有用,但在处理更复杂的数据结构时,我们需要更强大的工具。这就是接口和类型别名发挥作用的地方。它们允许我们定义复杂类型并给它们命名。

接口

在 TypeScript 中,接口是一种定义复杂类型合同的方式。它描述了一个对象应该具有的形状。以下是一个示例:

interface User {
  name: string;
  email: string;
} 

在这个示例中,我们定义了一个具有两个属性nameemailUser接口,这两个属性都是字符串类型。我们可以使用这个接口来进行对象类型检查:

const user: User = {
  name: "Alice",
  email: "alice@example.com",
}; 

如果我们尝试将一个不符合User接口的对象赋值给用户变量,TypeScript 将会给我们一个错误。

类型别名

类型别名与接口非常相似,但也可以用于其他类型,而不仅仅是对象。以下是一个type别名的示例:

type Point = {
  x: number;
  y: number;
};
type ID = number | string; 

在这个示例中,我们定义了一个Point类型,它代表二维空间中的一个点,以及一个可以是字符串或数字的ID。我们可以像使用接口一样使用这些type别名:

const point: Point = {
  x: 10,
  y: 20,
};
const id: ID = 100; 

接口与类型别名

那么,何时应该使用接口,何时应该使用类型别名?在许多情况下,两者可以互换,这主要是个人的偏好问题。

然而,也有一些不同之处。接口更具有可扩展性,因为它们可以被多次声明,并且会合并在一起。类型别名不能重新打开以添加新属性。另一方面,类型别名可以表示其他类型,如联合类型、交叉类型、元组以及其他在接口中目前不可用的类型。

通常,如果你正在定义对象的形状,无论是接口还是类型别名都可以。如果你正在定义一个可能不是对象的类型,你需要使用类型别名。

在本节中,我们迈出了 TypeScript 世界的第一步。我们学习了在Vite项目中设置 TypeScript、其基本类型以及如何使用接口和类型别名定义复杂类型。

现在,让我们探索如何将 TypeScript 与 React 组件、状态、事件处理程序一起使用。

在 React 中使用 TypeScript

好的,我们已经走到这一步了!我们已经学习了 TypeScript 的基础知识,并讨论了它的好处。现在,是时候卷起袖子,用一些实际的 TypeScript 在 React 中动手实践了。

在本节中,我们将探讨如何使用 TypeScript 来检查 React 应用程序的所有不同部分。我们将查看组件、props、状态、事件处理程序、上下文,甚至是 refs。不用担心:我会通过大量的示例来帮助你理解这些概念。

在 React 组件中检查 props

在一个 React 应用程序中,我们可以利用 TypeScript 的主要领域之一是在我们的组件中,特别是与 props 相关。让我们看看示例:

type GreetingProps = {
  name: string;
};
const Greeting = ({ name }: GreetingProps) => {
  return <h1>Hello, {name}!</h1>;
}; 

在这个例子中,我们定义了一个GreetingProps类型,它指定了Greeting应该接收的 props 的形状。然后我们使用这个类型来检查Greeting组件中的name prop。

这是一个只有一个 props 的简单例子,但同样的方法也可以用于具有更复杂 props 的组件。例如,如果一个组件接收一个对象或数组作为 props,我们可以定义一个类型来描述该对象或数组的形状。以下是一个例子:

type UserProps = {
  user: {
    name: string;
    email: string;
  };
};
const UserCard = ({ user }: UserProps) => {
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}; 

在这个例子中,UserCard组件接收一个包含nameemail属性的对象类型的user属性。我们定义一个UserProps类型来描述这个对象的形状,并使用它来检查user属性的类型。

让我们考虑 React 中另一个常见的场景:可选 props。有时,一个组件的 props 不是总是必需的。在这些情况下,我们可以为 prop 提供一个默认值,并在我们的类型定义中将其标记为可选。以下是一个例子:

type ButtonProps = {
  children: React.ReactNode;
  disabled?: boolean;
};
const Button = ({ children, disabled = false }: ButtonProps) => {
  return <button disabled={disabled}>{children}</button>;
}; 

ButtonProps类型中,我们使用React.ReactNode作为children prop 的类型。这是 React 提供的一个特殊类型,可以接受任何可渲染的内容。这包括字符串、数字、JSX 元素、这些类型的数组,甚至是返回这些类型的函数。通过使用React.ReactNode,我们表示children prop 可以是 React 可以渲染的任何类型的内容。此外,我们还使用了可选的disabled prop。我们通过在ButtonProps类型中将disabled prop 名称后面添加一个?来表示disabled是可选的。我们还在组件函数参数中为disabled提供了默认值 false。

这样,我们可以在有或没有disabled prop 的情况下使用Button组件,TypeScript 仍然会正确地进行类型检查:

<Button>Click me!</Button> // OK
<Button disabled>Don't click me!</Button> // OK 

类型化状态

正如我们对 props 进行了类型检查一样,我们也可以使用 TypeScript 来检查组件中的状态。这确保了我们始终使用正确的状态值类型,为我们的代码提供了另一层安全保障。

让我们看看如何将 TypeScript 应用于函数组件中的状态的例子:

const Counter = () => {
  const [count, setCount] = React.useState<number>(0);
  return (
    <div>
      <p>Count: {count}</p>
      <button
        onClick={() => {
          setCount(count + 1);
        }}
      >
        Increment
      </button>
    </div>
  );
}; 

在这个Counter组件中,我们使用React.useState<number>(0)来声明一个初始值为0的状态变量count。通过将<number>作为useState的类型参数,我们告诉 TypeScript count始终应该是数字类型。顺便说一句:我们可以省略传递<number>,因为 TypeScript 足够智能,可以根据初始值的类型推断出count应该是数字类型。

这也意味着setCount函数只会接受数字。如果我们尝试用非数字参数调用setCount,TypeScript 会给我们一个错误。

类型化事件处理器

另一个 TypeScript 在 React 应用程序中非常有用的领域是在事件处理器。通过类型检查我们的事件处理器,我们可以确保我们使用正确的事件类型,并访问事件对象上的正确属性。

让我们看看一个具有输入字段和类型化事件处理器的函数组件的例子:

const InputField = () => {
  const [value, setValue] = React.useState("");
  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setValue(event.target.value);
  };
  return <input value={value} onChange={handleChange} />;
}; 

在这个InputField组件中,我们定义了一个handleChange函数,该函数将在输入字段值变化时被调用。我们使用React.ChangeEvent<HTMLInputElement>类型作为事件参数,以指定这个函数应该接收来自输入字段的更改事件。

这个类型包括我们从输入字段更改事件中期望的所有属性,例如event.target.value。如果我们尝试访问这个类型上不存在的属性,TypeScript 会给我们一个错误。

类型化上下文

当使用 TypeScript 与 React 时,我们还可以对上下文进行类型检查,以确保我们始终使用正确的值类型。让我们看看一个例子:

type ThemeContextType = {
  theme: string;
  setTheme: (theme: string) => void;
};
const ThemeContext = React.createContext<ThemeContextType | null>(null);
const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
  const [theme, setTheme] = React.useState('light');
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};
const useTheme = () => {
  const context = React.useContext(ThemeContext);
  if (context === null) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
}; 

在这个例子中,我们使用React.createContext创建了一个ThemeContext。我们向createContext提供一个ThemeContextType作为类型参数,以指定上下文值的形状。这个类型包括一个主题字符串和一个setTheme函数。

然后,我们创建一个ThemeProvider组件,它将主题和setTheme值提供给上下文。在useTheme钩子内部,我们使用React.useContext来消费ThemeContext。如果上下文是null,我们抛出一个错误。

这是一个常见的模式,以确保在提供者内部使用上下文。

通过这个例子,我想强调 TypeScript 的一个重要特性。在useTheme钩子中,我们不需要指定类型。它返回上下文值,TypeScript 知道它是ThemeContextType类型而不是null,这要归功于错误检查。这意味着当我们使用useTheme时,TypeScript 会自动提供正确的非空上下文类型。

输入引用

现在,让我们将注意力转向 React 中的另一个强大特性:refs。正如你从第三章理解 React 组件和 Hooks中已经知道的,refs 给我们提供了一个在组件内部直接访问DOM 节点React 元素的方法。但我们是怎样确保正确使用 refs 的呢?TypeScript 来帮忙。

考虑这个例子,我们将 TypeScript 应用到 refs 上:

const InputWithRef = () => {
  const inputRef = React.useRef<HTMLInputElement>(null);
  const focusInput = () => {
    if (inputRef.current) {
      inputRef.current.focus();
    }
  };
  return (
    <div>
      <input ref={inputRef} type="text" />
      <button onClick={focusInput}>Focus the input</button>
    </div>
  );
}; 

在这个InputField组件中,我们使用React.useRef创建了一个引用。我们向useRef提供一个HTMLInputElement作为类型参数,以指定引用的类型。HTMLInputElement是 TypeScript 内置 DOM 类型提供的一个类型,它代表 DOM 中的一个输入元素。这个类型对应于引用附加到的 DOM 元素的类型。

这意味着inputRef.current将是HTMLInputElement | null类型,TypeScript 会知道它有一个focus方法。

摘要

在这一章中,我们深入探讨了 React 中的类型检查和验证的世界。我们从属性验证的重要性开始,然后介绍了 TypeScript 及其在健壮类型检查方面的好处。

我们随后将 TypeScript 应用于 React,展示了它在检查 React 组件各个方面(从 props 和 state 到事件处理器、上下文和 refs)中的使用。所有这些功能都允许你创建不仅更可靠而且更容易维护的应用程序,能够早期发现错误,显著提高你的代码质量和作为开发者的效率。

随着我们进入下一章,使用路由处理导航,我们将把我们的重点转向 React 应用程序中的导航。我们将学习如何设置和使用路由在应用程序的不同部分之间进行导航。

第七章:使用路由处理导航

几乎每个 Web 应用程序都需要路由,这是根据一组路由处理声明对 URL 进行响应的过程。换句话说,这是 URL 到渲染内容的映射。然而,这项任务比最初看起来要复杂得多,因为管理不同的 URL 模式并将它们映射到适当的内容渲染涉及到许多复杂性。这包括处理嵌套路由、动态参数以及确保正确的导航流程。这些任务的复杂性是为什么在本章中,你将利用 react-router 包,这是 React 的既定路由工具。

首先,你将学习使用 JSX 语法声明路由的基础知识。然后,你将了解路由的动态方面,例如动态路径段和查询参数。接下来,你将使用 react-router 的组件实现链接。

本章我们将涵盖以下高级主题:

  • 声明路由

  • 处理路由参数

  • 使用链接组件

技术要求

你可以在 GitHub 上找到本章的代码文件,地址为 github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter07

声明路由

使用 react-router,你可以将路由与它们渲染的内容进行组合。通过使用 JSX 语法定义与组件关联的路由,react-router 使开发者能够为他们的 React 应用程序创建一个清晰且逻辑的结构。这种组合使得理解应用程序的不同部分是如何连接和导航的变得更加容易,从而提高了代码库的可读性和可维护性。

在本章中,我们将使用 react-router 探索 React 应用程序中路由的基础知识。我们将从创建一个基本的示例路由开始,以便熟悉路由声明的语法和结构。然后,我们将更深入地研究按功能组织路由,而不是依赖于一个单一的路由模块。最后,我们将实现一个常见的父子路由模式,以展示如何处理更复杂的路由场景。

嗨,路由

在我们开始编写代码之前,让我们设置 react-router 项目。运行以下命令以将 react-router-dom 添加到依赖项中:

npm install react-router-dom 

让我们创建一个简单的路由,它渲染一个简单的组件:

  1. 首先,我们有一个小的 React 组件,当路由被激活时我们想要渲染它:

    function MyComponent() {
      return <p>Hello Route!</p>;
    } 
    
  2. 接下来,让我们看看路由定义:

    import React from "react";
    import ReactDOM from "react-dom/client";
    import { createBrowserRouter, RouterProvider } from "react-router-dom";
    import MyComponent from "./MyComponent";
    const router = createBrowserRouter([
      {
        path: "/",
        element: <MyComponent />,
      },
    ]);
    ReactDOM.createRoot(document.getElementById("root")!).render(
      <React.StrictMode>
        <RouterProvider router={router} />
      </React.StrictMode>
    ); 
    

RouterProvider 组件是应用程序的最高级组件。让我们分解它,以了解路由器内部发生了什么。

你在createBrowserRouter函数中声明了实际的路线。任何路由都有两个关键属性:pathelement。当path属性与活动 URL 匹配时,组件将被渲染。但它在哪里渲染呢?实际上,路由器并不渲染任何内容;它负责根据当前 URL 管理其他组件的连接。换句话说,路由器检查当前 URL,并从createBrowserRouter声明中返回相应的组件。确实,当你在一个浏览器中查看这个例子时,<MyComponent>如预期那样被渲染:

图 7.1:我们组件的渲染输出

path属性与当前 URL 匹配时,路由组件会被element属性值替换。在这个例子中,路由返回<MyComponent>。如果给定的路由不匹配,则不会渲染任何内容。

这个例子展示了 React 中路由的基础。声明路由非常简单直观。为了进一步巩固你对react-router的理解,我鼓励你尝试实验我们覆盖的概念。尝试自己创建更多路由,并观察它们如何影响你应用程序的行为。之后,你可以尝试更高级的技术,比如使用 React.lazy 和 Suspense 来懒加载组件(你将在下一章中了解更多关于这些的内容),并实现基于路由的代码拆分以优化你应用程序的性能。通过深入研究这些主题并将它们应用到自己的项目中,你将更加欣赏react-router的能力及其在现代、高效和用户友好的 React 应用程序构建中的作用。

解耦路由声明

路由的困难在于当你的应用程序在单个模块中声明了数十个路由时,因为将路由映射到功能上在心理上更困难。

为了帮助解决这个问题,应用程序的每个顶级功能都可以定义自己的路由。这样,就可以清楚地知道哪些路由属于哪个功能。所以,让我们从App组件开始:

const router = createBrowserRouter([
  {
    path: "/",
    element: <Layout />,
    children: [
      {
        index: true,
        element: <h1>Nesting Routes</h1>,
      },
      routeOne,
      routeTwo,
    ],
  },
]);
export const App = () => <RouterProvider router={router} />; 

在这个例子中,应用程序有两个路由:onetwo。这些作为路由对象导入,并放置在createBrowserRouter内部。这个路由器中的第一个element<Layout />组件,它渲染一个带有永不改变的数据的页面模板,并作为我们路由数据的位置。让我们看看<Layout />组件:

function Layout() {
  return (
    <main>
      <nav>
        <Link to="/">Main</Link>
        <span> | </span>
        <Link to="/one">One</Link>
        <span> | </span>
        <Link to="/two">Two</Link>
      </nav>
      <Outlet />
    </main>
  );
} 

这个组件包含一个带有链接和<Outlet />组件的小型导航工具栏。它是一个内置的react-router组件,它将被匹配的路由元素替换。

路由的大小仅与应用程序的功能数量相关,而不是路由的数量,这可能会大得多。让我们看看其中一个功能路由:

 const routes: RouteObject = {
  path: "/one",
  element: <Outlet />,
  children: [
    {
      index: true,
      element: <Redirect path="/one/1" />,
    },
    {
      path: "1",
      element: <First />,
    },
    {
      path: "2",
      element: <Second />,
    },
  ],
}; 

这个模块,one/index.js,导出了一个包含三个路由的配置对象:

  • /one路径匹配时,重定向到/one/1

  • 当匹配到/one/1路径时,渲染First组件。

  • 当匹配到/one/2路径时,渲染Second组件。

这意味着当应用程序加载 URL/one时,<Redirect>组件将用户发送到/one/1。与RouterProvider一样,Redirect组件内部没有 UI 元素;它仅管理逻辑。

这与 React 将组件嵌入布局以处理特定功能的做法相一致。这种方法允许实现关注点的清晰分离,组件专注于渲染 UI 元素,而其他如Redirect等组件则专注于处理路由逻辑。react-router中的Redirect组件负责将用户程序性地导航到不同的路由。它通常用于根据某些条件(如身份验证状态或路由参数)将用户从一个 URL 重定向到另一个 URL。通过将导航逻辑抽象到单独的组件中,它促进了应用程序中的代码重用和可维护性。

您在这里使用Redirect是因为我们在根路由上没有内容。通常,您的应用程序实际上在功能的根或应用程序本身的根处没有要渲染的内容。这种模式允许您将用户发送到适当的路由和内容。以下是您打开应用程序并点击One链接时将看到的内容:

图 7.2:第 1 页的内容

第二个功能遵循与第一个完全相同的模式。以下是First组件的示例:

export default function First() {
  return <p>Feature 1, page 1</p>;
} 

在这个例子中,每个功能都使用相同的最小渲染内容。这些组件是用户在导航到特定路由时最终需要看到的内容。通过这种方式组织路由,您已经使功能在路由方面具有自包含性。

在下一节中,您将学习如何进一步将路由组织成父子关系。

处理路由参数

本章中您所看到的 URL 都是静态的。大多数应用程序将同时使用静态动态路由。在本节中,您将学习如何将动态 URL 段传递给组件,如何使这些段可选,以及如何获取查询字符串参数。

路由中的资源 ID

一个常见的用例是将资源的 ID 作为 URL 的一部分。这使得您的代码能够获取 ID,然后执行一个API调用以获取相关资源数据。让我们实现一个渲染用户详情页的路由。这需要一个包含用户 ID 的路由,然后需要以某种方式将用户 ID 传递给组件,以便它可以获取用户信息。

让我们从声明路由的App组件开始:

const router = createBrowserRouter([
  {
    path: "/",
    element: <UsersContainer />,
    errorElement: <p>Route not found</p>,
  },
  {
    path: "/users/:id",
    element: <UserContainer />,
    errorElement: <p>User not found</p>,
    loader: async ({ params }) => {
      const user = await fetchUser(Number(params.id));
      return { user };
    },
  },
]);
function App() {
  return <RouterProvider router={router} />;
} 

: 语法标记了 URL 变量的开始。id 变量将被传递给 UserContainer 组件。在显示组件之前,loader 函数被触发,异步获取指定用户 ID 的数据。在数据加载错误的情况下,errorElement 属性提供了一个回退来有效地处理这种情况。以下是 UserContainer 的实现方式:

function UserContainer() {
  const params = useParams();
  const { user } = useLoaderData() as { user: User };
  return (
    <div>
      User ID: {params.id}
      <UserData user={user} />
    </div>
  );
} 

useParams() 钩子用于获取 URL 的任何动态部分。在这种情况下,您对 id 参数感兴趣。然后,我们使用 useLoaderData 钩子从 loader 函数中获取 user。如果 URL 完全缺少该部分,则此代码根本不会运行;路由器将使我们回退到 errorElement 组件。

现在,让我们看看在这个示例中使用的 API 函数:

export type User = {
  first: string;
  last: string;
  age: number;
};
const users: User[] = [
  { first: "John", last: "Snow", age: 40 },
  { first: "Peter", last: "Parker", age: 30 },
];
export function fetchUsers(): Promise<User[]> {
  return new Promise((resolve) => {
    resolve(users);
  });
}
export function fetchUser(id: number): Promise<User> {
  return new Promise((resolve, reject) => {
    const user = users[id];
    if (user === undefined) {
      reject('User ${id} not found');
    } else {
      resolve(user);
    }
  });
} 

fetchUsers() 函数由 UsersContainer 组件用于填充用户链接列表。fetchUser() 函数将从模拟数据的 users 数组中查找并解析一个值。

这是 User 组件,它负责渲染用户详细信息:

type UserDataProps = {
  user: User;
};
function UserData({ user }: UserDataProps) {
  return (
    <section>
      <p>{user.first}</p>
      <p>{user.last}</p>
      <p>{user.age}</p>
    </section>
  );
} 

当您运行此应用程序并导航到 / 时,您应该会看到一个用户列表,看起来像这样:

图 7.3:应用程序主页的内容

点击第一个链接应将您带到 /users/0,看起来像这样:

图 7.4:用户页面的内容

如果您导航到一个不存在的用户,例如 /users/2,您将看到以下内容:

图 7.5:当找不到用户时

您得到此错误消息而不是 500 错误的原因是 API 端点知道如何处理缺失的资源:

if (user === undefined) {
  reject('User ${id} not found');
} 

此拒绝将由 react-router 使用提供的 errorElement 组件来处理。

在下一节中,我们将探讨定义可选路由参数。

查询参数

有时,我们需要可选的 URL 路径值或查询参数。对于简单的选项,URL 效果最好;如果组件可以使用许多值,则查询参数效果最好。

让我们实现一个用户列表组件,用于渲染用户列表。可选地,您希望能够按降序排序列表。让我们使用可以接受查询字符串的路由来实现这一点:

const router = createBrowserRouter([
  {
    path: "/",
    element: <UsersContainer />,
  },
]);
ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
); 

路由器中没有特殊设置用于处理查询参数。处理任何提供的查询字符串的责任在于组件。因此,虽然路由声明没有提供定义接受查询字符串的机制,但路由器仍然会将查询参数传递给组件。让我们看看用户列表容器组件:

export type SortOrder = "asc" | "desc";
function UsersContainer() {
  const [users, setUsers] = useState<string[]>([]);
  const [search] = useSearchParams();
  useEffect(() => {
    const order = search.get("order") as SortOrder;
    fetchUsers(order).then((users) => {
      setUsers(users);
    });
  }, [search]);
  return <Users users={users} />;
} 

此组件查找任一 order 查询字符串。它使用此作为 fetchUsers() API 的参数来确定排序顺序。

这是 Users 组件的外观:

type UsersProps = {
  users: string[];
};
function Users({ users }: UsersProps) {
  return (
    <ul>
      {users.map((user) => (
        <li key={user}>{user}</li>
      ))}
    </ul>
  );
} 

当您导航到 / 时,以下是渲染的内容:

图 7.6:按默认顺序渲染用户列表

如果你通过导航到/?order=desc包含order查询参数,你将得到以下内容:

图片

图 7.7:按降序渲染用户列表

在本节中,你学习了关于路由中的参数。可能最常见的一种模式是将应用中资源的 ID 作为 URL 的一部分,这意味着组件需要能够解析出这些信息以便与 API 交互。你还学习了关于路由中的查询参数,这对于动态内容、过滤或组件之间传递临时数据非常有用。接下来,你将学习关于链接组件的内容。

使用链接组件

在本节中,你将学习如何创建链接。你可能倾向于使用标准的<a>元素来链接到由react-router控制的页面。这种方法的缺点是,从简单来说,这些链接将尝试通过发送GET请求在后台定位页面。这不是你想要的,因为路由配置已经在应用中,并且我们可以本地处理路由。

首先,你将看到一个示例,说明<Link>组件的行为与<a>元素类似,但它们是本地工作的。然后,你将学习如何构建使用 URL 参数和查询参数的链接。

基本链接

React 应用中链接的概念是它们指向指向组件的路由,这些组件渲染新的内容。Link组件还负责浏览器历史 API 并查找路由-组件映射。以下是一个渲染两个链接的应用组件:

function Layout() {
  return (
    <>
      <nav>
        <p>
          <Link to="first">First</Link>
        </p>
        <p>
          <Link to="second">Second</Link>
        </p>
      </nav>
      <main>
        <Outlet />
      </main>
    </>
  );
}
const router = createBrowserRouter([
  {
    path: "/",
    element: <Layout />,
    children: [
      {
        path: "/first",
        element: <First />,
      },
      {
        path: "/second",
        element: <Second />,
      },
    ],
  },
]);
function App() {
  return <RouterProvider router={router} />;
} 

to属性指定了点击时激活的路由。在这种情况下,应用程序有两个路由:/first/second。以下是渲染的链接的外观:

图片

图 7.8:应用的第一页和第二页的链接

当你点击第一个链接时,页面内容将改变,看起来像这样:

图片

图 7.9:应用渲染时的第一页

现在你已经可以使用Link组件渲染到基本路径的链接,是时候学习如何使用参数构建动态链接了。

URL 和查询参数

构建传递给<Link>的路径的动态段涉及字符串操作。路径的任何部分都发送到to属性。这意味着你需要编写更多的代码来构建字符串,但也意味着在路由器中幕后发生的魔法更少。

让我们创建一个简单的组件,它将回显传递给 echo URL 段或 echo 查询参数的内容:

function Echo() {
  const params = useParams();
  const [searchParams] = useSearchParams();
  return <h1>{params.msg || searchParams.get("msg")}</h1>;
} 

为了获取传递给路由的搜索参数,你可以使用useSearchParams()钩子,它给你一个URLSearchParams对象。在这种情况下,我们可以调用searchParams.get("msg")来获取所需的参数。

现在,让我们看看渲染两个链接的App组件。第一个将构建一个使用动态值作为 URL 参数的字符串。第二个将使用URLSearchParams构建 URL 的查询字符串部分:

const param = "From Param";
const query = new URLSearchParams({ msg: "From Query" });
export default function App() {
  return (
    <section>
      <p>
        <Link to={'echo/${param}'}>Echo param</Link>
      </p>
      <p>
        <Link to={'echo?${query.toString()}'}>Echo query</Link>
      </p>
    </section>
  );
} 

下面是两个链接渲染后的样子:

图片

图 7.10:不同类型的链接参数

参数链接将你带到/echo/From%20Param,看起来像这样:

图片

图 7.11:页面的参数版本

查询链接将你带到/echo?msg=From+Query,看起来像这样:

图片

图 7.12:页面的查询版本

在了解Link组件和动态链接构建的过程中,你解锁了更互动和可导航的网页体验,使用户能够通过包含丰富旅程的 URL 和查询参数在应用程序中移动。

摘要

在本章中,你学习了 React 应用程序中的路由。路由器的工作是渲染与 URL 相对应的内容。react-router包是完成这项工作的标准工具。你学习了路由是如何像它们渲染的组件一样是 JSX 元素。有时,你需要将路由拆分成基于功能的模块。结构页面内容的一个常见模式是有一个父组件,它根据 URL 的变化渲染动态部分。然后,你学习了如何处理 URL 段和查询字符串的动态部分。你还学习了如何使用<Link>元素在你的应用程序中构建链接。

理解 React 应用程序中的路由为构建具有高效导航的复杂应用程序奠定了基础,为后续章节深入性能优化、状态管理和集成外部 API 做好了准备,确保了无缝的用户体验。

在下一章中,你将学习如何使用懒组件将你的代码拆分成更小的块。

第八章:使用懒组件和 Suspense 进行代码拆分

代码拆分在 React 应用程序中已经是一个重要的部分,甚至在官方支持被包含在React API中之前就已经存在。React 的演变带来了专门设计用于帮助代码拆分场景的 API。当处理包含大量需要发送到浏览器的 JavaScript 代码的大型应用程序时,代码拆分变得至关重要。

在过去,包含整个应用的单体 JavaScript 包可能会因为页面加载时间过长而导致可用性问题。多亏了代码拆分,我们现在可以更细致地控制代码从服务器传输到浏览器的方式。这为我们提供了大量优化加载时间用户体验UX)的机会。

在本章中,我们将回顾如何在 React 应用程序中使用lazy() API 和Suspense组件来实现这一点。这些功能是 React 工具箱中非常强大的工具。通过深入了解这些组件的工作原理,你将完全准备好无缝地将代码拆分集成到你的应用程序中。

本章将涵盖以下主题:

  • 使用lazy() API

  • 使用Suspense组件

  • 避免使用懒组件

  • 探索懒加载页面路由

技术要求

你可以在 GitHub 上找到本章的代码文件,链接为github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter08

使用懒 API

在 React 中使用lazy() API 涉及两个部分。首先,将组件打包到它们自己的单独文件中,以便浏览器可以单独从应用程序的其他部分下载它们。其次,一旦创建了这些包,你就可以构建懒加载的 React 组件:它们在需要之前不会下载任何内容。让我们看看这两个方面。

动态导入和包

本书中的代码示例使用Vite工具创建包。这种方法的优点是,你不需要维护任何包配置。相反,根据你如何导入模块,包会自动为你创建。如果你在所有地方都使用普通的import语句(不要与import方法混淆),你的应用程序将一次性下载在一个包中。当你的应用程序变大时,可能会出现一些用户可能永远不会使用或不如其他用户频繁使用的功能。你可以使用import()函数按需导入模块。通过使用此函数,你是在告诉 Vite 为动态导入的代码创建一个单独的包。

让我们看看一个我们可能希望与应用程序其他部分分开打包的简单组件:

export default function MyComponent() {
  return <p>My Component</p>;
} 

现在,让我们看看如何使用import()函数动态导入这个模块,从而创建一个单独的包:

function App() {
  const [MyComponent, setMyComponent] = React.useState<() => React.ReactNode>(
    () => () => null
  );
  React.useEffect(() => {
    import("./MyComponent").then((module) => {
      setMyComponent(() => module.default);
    });
  }, []);
  return <MyComponent />;
} 

当你运行这个示例时,你会立即看到<p>文本被渲染。如果你打开浏览器开发者工具并查看网络请求,你会注意到有一个单独的调用去获取包含MyComponent代码的包。这是因为对import("./MyComponent")的调用。import()函数返回一个解析为模块对象的 promise。由于我们需要默认导出以访问MyComponent,我们在调用setMyComponent()时引用module.default

我们将组件设置为MyComponent状态的原因是,当App组件首次渲染时,我们还没有加载MyComponent的代码。一旦加载,MyComponent将引用正确的值,从而渲染出正确的文本。

现在你已经了解了包是如何被创建并由应用获取的,是时候看看lazy() API 如何极大地简化了这个过程。

使组件懒加载

你不需要手动处理import()返回的 promise,通过返回默认导出和设置状态,而是可以依赖lazy() API。这个函数接收一个返回import() promise 的函数。返回值是一个懒加载组件,你可以直接渲染。让我们修改App组件以使用这个 API:

import * as React from "react";
const MyComponent = React.lazy(() => import("./MyComponent"));
function App() {
  return <MyComponent />;
} 

MyComponent的值是通过调用lazy()创建的,将动态模块导入作为参数传递。现在,你有一个为你的组件创建的单独包和一个在首次渲染时加载这个包的懒加载组件。

在本节中,你学习了代码拆分的工作原理。你了解到import()函数为你处理了包的创建。你还了解到lazy() API 使你的组件变得懒加载,并为你处理了导入组件的所有繁琐工作。但我们需要最后一件事,即Suspense组件,以帮助在组件加载时显示占位符。

使用Suspense组件

在本节中,我们将探讨Suspense组件的一些更常见的使用场景。我们将查看在组件树中放置Suspense组件的位置,如何在获取包时模拟延迟,以及我们可以用作回退内容的选项。

最高层级的Suspense组件

懒加载组件需要被渲染在Suspense组件内部。然而,它们不必是Suspense的直接子组件,这很重要,因为这意味着你可以有一个Suspense组件来处理你应用中的所有懒加载组件。让我们用一个例子来说明这个概念。这是一个我们希望单独打包并懒加载的组件:

export default function MyFeature() {
  return <p>My Feature</p>;
} 

接下来,让我们将MyFeature组件懒加载,并在MyPage组件中渲染它:

const MyFeature = React.lazy(() => import("./MyFeature"));
function MyPage() {
  return (
    <>
      <h1>My Page</h1>
      <MyFeature />
    </>
  );
} 

在这里,我们使用lazy() API 使MyFeature组件变为懒加载。这意味着当MyPage组件被渲染时,包含MyFeature的代码包将会被下载,因为MyFeature也被渲染了。需要注意的是,对于MyPage组件来说,它正在渲染一个懒加载组件(MyFeature),但它没有渲染一个Suspense组件。这是因为我们的假设应用有许多页面组件,每个页面都有自己的懒加载组件。让每个组件都渲染自己的Suspense组件将是多余的。相反,我们可以在App组件内部渲染一个Suspense组件,如下所示:

function App() {
  return (
    <React.Suspense fallback={"loading..."}>
      <MyPage />
    </React.Suspense>
  );
} 

MyFeature代码包正在下载时,<MyPage>会被替换为传递给Suspense的回退文本。所以,即使MyPage本身不是懒加载的,它也会渲染一个Suspense所知的懒加载组件,并在这一过程中用回退内容替换其子组件。

到目前为止,我们还没有真正看到在懒加载组件加载代码包时显示的回退内容。这是因为当本地开发时,这些包几乎会立即加载。为了能够看到回退组件和加载过程,你可以在开发者工具的网络选项卡中启用限制:

图 8.1:在浏览器中启用限制

这个设置模拟了慢速的互联网连接。页面不会立即加载,而是会渲染几秒钟,你将看到一个加载中…的回退。

在下一节中,我们将探讨使用加载spinner作为回退组件的方法。

使用spinner回退

你可以使用Suspense组件的最简单的回退是一些指示用户正在发生什么的文本。回退属性可以是任何有效的 React 元素,这意味着我们可以增强回退,使其更具视觉吸引力。例如,react-spinners包提供了一系列spinner组件,所有这些都可以作为Suspense的回退使用。

让我们将上一节中的App组件修改一下,以包含来自react-spinners包的spinner作为Suspense的回退:

import * as React from "react";
import { FadeLoader } from "react-spinners";
import MyPage from "./MyPage";
function App() {
  return (
    <React.Suspense fallback={<FadeLoader color="lightblue" />}>
      <MyPage />
    </React.Suspense>
  );
} 

FadeLoader组件将渲染一个我们配置了lightblue颜色的spinnerFadeLoader组件的渲染元素被传递到fallback属性。使用慢速 3G 限制,你应该能在首次加载应用时看到spinner

图 8.2:加载组件渲染的图像

现在,我们不再显示文本,而是显示一个动画spinner。这很可能会提供一个用户更习惯的用户体验。react-spinners包提供了几个spinner供你选择,每个spinner都有几个配置选项。你也可以使用其他spinner库,或者自己实现。

在本节中,你学习了如何使用单个 Suspense 组件来显示其回退内容,这对于树中任何较低级别的懒加载组件都是有效的。你学习了如何在本地开发期间模拟延迟,以便你可以体验你的用户将如何体验你的 Suspense 回退内容。最后,你学习了如何使用来自其他库的组件作为回退内容,以提供比纯文本看起来更好的东西。

在下一节中,你将了解到为什么将应用中的每个组件都做成懒加载组件是没有意义的。

避免使用懒加载组件

可能会很有诱惑力将大多数 React 组件做成懒加载组件,这些组件各自存在于自己的包中。毕竟,设置单独的包和创建懒加载组件并不需要做太多额外的工作。然而,这样做也有一些缺点。如果你有太多的懒加载组件,你的应用最终会同时发起多个 HTTP 请求来获取它们:这并没有为在应用同一部分使用的组件使用单独的包带来任何好处。你最好尝试以某种方式将组件打包在一起,使得只需一个 HTTP 请求就能加载当前页面上所需的内容。

有助于思考的一种方式是将 页面 相关联。如果你有懒加载的页面组件,该页面上的一切也将是懒加载的,并且与其他页面上的组件打包在一起。让我们构建一个示例,演示如何组织我们的懒加载组件。假设你的应用有几个页面,每个页面上都有一些功能。如果当页面加载时这些功能都需要,我们就不一定想使这些功能成为懒加载的。以下是显示用户选择要加载哪个页面的 App 组件:

const First = React.lazy(() => import("./First"));
const Second = React.lazy(() => import("./Second"));
function ShowComponent({ name }: { name: string }) {
  switch (name) {
    case "first":
      return <First />;
    case "second":
      return <Second />;
    default:
      return null;
  }
} 

FirstSecond 组件是我们应用中的页面,因此我们希望它们成为按需加载其包的懒加载组件。当用户更改选择器时,ShowComponent 组件会渲染适当的页面:

function App() {
  const [component, setComponent] = React.useState("");
  return (
    <>
      <label>
        Load Component:{" "}
        <select
          value={component}
          onChange={(e) => setComponent(e.target.value)}
        >
          <option value="">None</option>
          <option value="first">First</option>
          <option value="second">Second</option>
        </select>
      </label>
      <React.Suspense fallback={<p>loading...</p>}>
        <ShowComponent name={component} />
      </React.Suspense>
    </>
  );
} 

接下来,让我们看看第一页,看看它是如何组成的,从 First 组件开始:

import One from "./One";
import Two from "./Two";
import Three from "./Three";
export default function First() {
  return (
    <>
      <One />
      <Two />
      <Three />
    </>
  );
} 

First 组件会引入三个组件并将它们渲染出来:OneTwoThree。这三个组件将构成同一个包。虽然我们可以使它们成为懒加载的,但这并没有什么意义,因为我们所做的只是同时发起三个 HTTP 请求来获取包,而不是一个。

现在你已经更好地理解了如何将应用页面的结构映射到包上,让我们看看另一个用例,其中我们使用路由组件在应用中导航。

探索懒加载页面和路由

Avoiding lazy components(避免使用懒加载组件)部分,你看到了在没有好处的情况下应避免使组件成为懒加载的地方。当你在使用 react-router 作为在应用中导航的机制时,可以应用相同的模式。让我们看看一个例子。以下是我们需要导入的内容:

const First = React.lazy(() => import("./First"));
const Second = React.lazy(() => import("./Second"));
function Layout() {
  return (
    <section>
      <nav>
        <span>
          <Link to="first">First</Link>
        </span>
        <span> | </span>
        <span>
          <Link to="second">Second</Link>
        </span>
      </nav>
      <section>
        <React.Suspense fallback={<FadeLoader color="lightblue" />}>
          <Outlet />
        </React.Suspense>
      </section>
    </section>
  );
}
export default function App() {
  return (
    <Router>
      <Routes>
        <Route path="/" element={<Layout />}>
          <Route path="/first" element={<First />} />
          <Route path="/second" element={<Second />} />
        </Route>
      </Routes>
    </Router>
  );
} 

在前面的代码中,我们有两个将单独打包的懒加载页面组件。在这个例子中,回退内容使用了在使用旋转器回退部分中引入的相同的FadeLoader spinner组件。

注意,Suspense组件放置在导航链接之下。这意味着当内容加载时,将在这个位置渲染出最终显示的页面内容。Suspense组件的子元素是Route组件,它们将渲染我们的懒加载页面组件:例如,当/first路由被激活时,First组件将首次渲染,触发包下载。

这就带我们结束了这一章。

摘要

本章全部关于代码拆分和打包,这些是大型 React 应用程序中的重要概念。我们首先通过使用import()函数查看如何在你的 React 应用程序中将代码拆分成包。然后,我们探讨了lazy() React API 以及它是如何帮助简化首次渲染组件时的包加载。接下来,我们更深入地研究了Suspense组件,该组件用于在组件包被检索时管理内容。fallback属性是我们指定在加载包时显示的内容的方式。只要你的应用程序遵循一致的打包模式,通常你不需要在应用程序中使用超过一个Suspense组件。

在下一章中,你将学习如何使用Next.js框架来处理在服务器上渲染 React 组件。Next.js 框架允许你创建作为 React 组件的页面,这些页面可以在服务器和浏览器上渲染。这对于需要良好的初始页面加载性能的应用程序来说是一个重要的功能:也就是说,所有应用程序。

第九章:用户界面框架组件

当你开发 React 应用程序时,通常依赖于现有的UI 库而不是从头开始构建。有许多 React UI 组件库可供选择,只要组件使你的生活变得更简单,就没有错误的选择。

在本章中,我们将深入研究 Material UI React 库,这是 React 开发的流行选择。Material UI 因其全面的定制组件套件、遵循 Google 的 Material Design 原则以及广泛的文档而脱颖而出,使其成为寻求 UI 设计效率和美学一致性的开发者的最佳选择。以下是我们将涵盖的具体主题:

  • 布局和 UI 组织

  • 使用导航组件

  • 收集用户输入

  • 样式主题一起工作

技术要求

你可以在 GitHub 上找到本章中存在的代码文件,地址为github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter09

你也可以在mui.com/material-ui/找到更多关于 Material UI 组件及其 API 的信息。

布局和组织

Material UI在简化应用程序布局的复杂过程中表现出色。通过提供强大的组件集,特别是容器网格,它使开发者能够高效地构建和组织 UI 元素。容器作为基础,提供了一种灵活的方式来封装和定位整体布局中的内容。另一方面,网格允许更精细的控制,能够精确地放置和对齐不同屏幕尺寸下的组件,确保响应性和一致性。

本节旨在解开 Material UI 中容器和网格的功能。我们将探讨如何利用这些工具创建直观且美观的布局,这对于提升用户体验至关重要。

使用容器

在页面上水平对齐组件通常是一个重大挑战,因为这需要在间距、对齐和响应性之间保持复杂的平衡。这种复杂性源于需要在各种屏幕尺寸上保持视觉吸引力和功能性布局的需求,确保元素均匀分布,并保持其预期的外观,避免意外的重叠或间隙。Material UI 的Container组件是一个简单但功能强大的布局工具。它控制其子元素的横向宽度。让我们看看一个例子,看看可能实现什么:

import Typography from "@mui/material/Typography";
import Container from "@mui/material/Container";
export default function MyApp() {
  const textStyle = {
    backgroundColor: "#cfe8fc",
    margin: 1,
    textAlign: "center",
  };
  return (
    <>
      <Container maxWidth="sm">
        <Typography sx={textStyle}>sm</Typography>
      </Container>
      <Container maxWidth="md">
        <Typography sx={textStyle}>md</Typography>
      </Container>
      <Container maxWidth="lg">
        <Typography sx={textStyle}>lg</Typography>
      </Container>
    </>
  );
} 

这个例子有三个Container组件,每个组件都包裹一个Typography组件。Typography组件用于在 Material UI 应用程序中渲染文本。在这个例子中使用的每个Container组件都包含一个maxWidth属性。它接受一个断点字符串值。这些断点代表常见的屏幕尺寸。这个例子使用了小(sm)、中(md)和大型(lg)。当屏幕达到这些断点大小时,容器宽度将停止增长。以下是当宽度小于sm断点时页面看起来像什么:

图片

图 9.1:sm 断点

现在,如果我们调整屏幕大小,使其大于md断点,但小于lg断点,它看起来会是这样:

图片

图 9.2:lg 断点

注意,现在我们已经超过了其maxWidth断点,第一个容器保持固定宽度。mdlg容器将继续随着屏幕增长,直到它们的断点被超过。

让我们看看当屏幕宽度超过所有断点时这些Container组件看起来像什么:

图片

图 9.3:所有断点

Container组件让您控制页面元素如何水平增长。它们也是响应式的,因此当屏幕尺寸变化时,您的布局将得到更新。

在下一节中,我们将探讨使用 Material UI 组件构建更复杂和响应式布局。

构建响应式网格布局

Material UI 有一个Grid组件,我们可以用它来组合响应式复杂布局。从高层次来看,一个Grid组件可以是容器或容器内的一个项目。通过结合这两个角色,我们可以为我们的应用程序实现任何类型的布局。为了熟悉 Material UI 网格布局,让我们放在一起一个例子,它使用我们在许多 Web 应用程序中常见的常见布局模式。这是结果看起来像什么:

图片

图 9.4:一个示例响应式网格布局

如您所见,这个布局有在许多 Web 应用程序中常见的熟悉部分。这只是一个示例布局;您可以使用Grid组件构建任何您能想象到的布局。让我们看看创建这个布局的代码:

const headerFooterStyle = {
  textAlign: "center",
  height: 50,
};
const mainStyle = {
  textAlign: "center",
  padding: "8px 16px",
};
const Item = styled(Paper)(() => ({
  height: "100%",
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
}));
export default function App() {
  return (
    <Grid container spacing={2} sx={{ backgroundColor: "#F3F6F9" }}>
      <Grid xs={12}>
        <Item sx={headerFooterStyle}>
          <Typography sx={mainStyle}>Header</Typography>
        </Item>
      </Grid>
      <Grid xs="auto">
        <Item>
          <Stack spacing={1}>
            <Typography sx={mainStyle}>Nav Item 1</Typography>
            <Typography sx={mainStyle}>Nav Item 2</Typography>
            <Typography sx={mainStyle}>Nav Item 3</Typography>
            <Typography sx={mainStyle}>Nav Item 4</Typography>
          </Stack>
        </Item>
      </Grid>
      <Grid xs>
        <Item>
          <Typography sx={mainStyle}>Main content</Typography>
        </Item>
      </Grid>
      <Grid xs={12}>
        <Item sx={headerFooterStyle}>
          <Typography sx={mainStyle}>Footer</Typography>
        </Item>
      </Grid>
    </Grid>
  );
} 

让我们分析这个布局中的部分是如何创建的。我们将从页眉部分开始:

<Grid xs={12}>
  <Item sx={headerFooterStyle}>
    <Typography sx={mainStyle}>Header</Typography>
  </Item>
</Grid> 

xs断点属性值12意味着页眉将始终占据整个屏幕宽度,因为12是这里可以使用的最高值。接下来,让我们看看导航项:

<Grid xs="auto">
  <Item>
    <Stack spacing={1}>
      <Typography sx={mainStyle}>Nav Item 1</Typography>
      <Typography sx={mainStyle}>Nav Item 2</Typography>
      <Typography sx={mainStyle}>Nav Item 3</Typography>
      <Typography sx={mainStyle}>Nav Item 4</Typography>
    </Stack>
  </Item>
</Grid> 

在导航部分,我们有一个带有xs="auto"属性的网格。它使列的大小与其内容的宽度相匹配。此外,您还可以看到我们使用Stack组件以垂直方向和间距放置组件。

接下来,我们将查看主要内容部分:

<Grid xs>
  <Item>
    <Typography sx={mainStyle}>Main content</Typography>
  </Item>
</Grid> 

xs断点是一个用于在网格中填充导航部分之后所有空闲空间的真值。

在本节中,你了解了 Material UI 在布局方面能提供什么。你可以使用Container组件来控制节宽以及它们如何响应屏幕尺寸变化。然后你了解到Grid组件用于组合更复杂的网格布局。

在下一节中,我们将查看 Material UI 中的一些导航组件。

使用导航组件

一旦我们有了我们应用程序布局的外观和工作方式的初步想法,我们就可以开始考虑导航了。这是我们的 UI 的一个重要部分,因为它是用户在应用程序中导航的方式,并且会被频繁使用。在本节中,我们将了解 Material UI 提供的两个导航组件。

使用抽屉导航

Drawer组件就像一个物理抽屉一样,滑动打开以显示易于访问的内容。当我们完成时,抽屉再次关闭。这对于导航来说效果很好,因为它不会妨碍,允许屏幕上有更多空间用于用户正在参与的活动。让我们看一个例子,从App组件开始:

<BrowserRouter>
  <Button onClick={toggleDrawer}>Open Nav</Button>
  <section>
    <Routes>
      <Route path="/first" element={<First />} />
      <Route path="/second" element={<Second />} />
      <Route path="/third" element={<Third />} />
    </Routes>
  </section>
  <Drawer open={open} onClose={toggleDrawer}>
    <div
      style={{ width: 250 }}
      role="presentation"
      onClick={toggleDrawer}
      onKeyDown={toggleDrawer}
    >
      <List component="nav">
        {links.map((link) => (
          <NavLink
            key={link.url}
            to={link.url}
            style={{ color: "black", textDecoration: "none" }}
          >
            {({ isActive }) => (
              <ListItemButton selected={isActive}>
                <ListItemText primary={link.name} />
              </ListItemButton>
            )}
          </NavLink>
        ))}
      </List>
    </div>
  </Drawer>
</BrowserRouter> 

让我们看看这里发生了什么。这个组件渲染的任何内容都在BrowserRouter组件内部,因为抽屉中的项目是路由的链接:

<Button onClick={toggleDrawer}>Open Nav</Button>
<section>
  <Routes>
    <Route path="/first" element={<First />} />
    <Route path="/second" element={<Second />} />
    <Route path="/third" element={<Third />} />
  </Routes>
</section> 

FirstSecondThird组件用于在用户点击抽屉中的链接时渲染主要应用程序内容。当点击打开导航按钮时,抽屉本身会打开。让我们更仔细地看看用于控制此状态的变量:

const [open, setOpen] = useState(false);
const toggleDrawer = ({ type, key }: { type?: string; key?: string }) => {
  if (type === "keydown" && (key === "Tab" || key === "Shift")) {
    return;
  }
  setOpen(!open);
}; 

open状态控制抽屉的可见性。Drawer组件的onClose属性也会调用此函数,这意味着当抽屉内的任何链接被激活时,抽屉会关闭。接下来,让我们看看抽屉内的链接是如何生成的:

<List component="nav">
  {links.map((link) => (
    <NavLink
      key={link.url}
      to={link.url}
      style={{ color: "black", textDecoration: "none" }}
    >
      {({ isActive }) => (
        <ListItemButton selected={isActive}>
          <ListItemText primary={link.name} />
        </ListItemButton>
      )}
    </NavLink>
  ))}
</List> 

Drawer组件中显示的项目实际上是列表项,正如你在这里可以看到的。links属性包含所有具有urlname属性的链接对象。items 数组中的每个项目都映射到NavLink,用于处理导航并突出显示活动路由。在NavLink内部,我们有ListItemButton组件,它通过渲染ListItemText组件来生成带有文本的列表项。

最后,让我们看看links属性的默认值:

const links = [
  { url: "/first", name: "First Page" },
  { url: "/second", name: "Second Page" },
  { url: "/third", name: "Third Page" },
]; 

这是屏幕首次加载后打开抽屉的样子:

图片

图 9.5:显示到我们页面链接的抽屉

尝试点击第一页链接。抽屉关闭并渲染/first路由的内容。然后,当你再次打开抽屉时,你会注意到第一页链接被渲染为活动链接:

图片

图 9.6:在抽屉中,第一页链接被样式化为活动链接

在本节中,你学习了如何使用Drawer组件作为应用程序的主要导航。在下一节中,我们将探讨Tabs组件。

使用标签导航

标签是现代网络应用中另一种常见的导航模式。Material UI 的Tabs组件允许我们使用标签作为链接并将它们连接到路由器。让我们看看如何做到这一点的示例。以下是App组件:

export default function App() {
  return <RouterProvider router={router} />;
}
const router = createBrowserRouter([
  {
    path: "/",
    element: <RouteLayout />,
    children: [
      {
        path: "/page1",
        element: <Typography>Item One</Typography>,
      }, // same routes for /page2 and /page3
    ],
  },
]);
function RouteLayout() {
  const routeMatch = useRouteMatch(["/", "/page1", "/page2", "/page3"]);
  const currentTab = routeMatch?.pattern?.path;
  return (
    <Box>
      <Tabs value={currentTab}>
        <Tab label="Item One" component={Link} to="/page1" value="/page1" />
        <Tab label="Item Two" component={Link} to="/page2" value="/page2" />
        <Tab label="Item Three" component={Link} to="/page3" value="/page3" />
      </Tabs>
      <Outlet />
    </Box>
  );
} 

为了节省空间,我省略了/page2/page3的路由配置;它们的模式与/page1相同。Material UI 中的TabsTab组件实际上不会在选中的标签下渲染任何内容。这取决于我们提供内容,因为Tabs组件只负责显示标签并标记其中一个为选中状态。本例旨在让Tab组件使用Link组件,这些组件链接到由路由渲染的内容。

现在我们来仔细看看RouteLayout组件。每个Tab组件都使用Link组件,这样当它被点击时,路由器就会激活to属性中指定的路由。然后使用Outlet组件作为路由内容的子组件。为了匹配激活的标签,我们使用useRouteMatch来处理当前路由的简单方法:

function useRouteMatch(patterns: readonly string[]) {
  const { pathname } = useLocation();
  for (let i = 0; i < patterns.length; i += 1) {
    const pattern = patterns[i];
    const possibleMatch = matchPath(pattern, pathname);
    if (possibleMatch !== null) {
      return possibleMatch;
    }
  }
  return null;
} 

useRouteMatch钩子使用useLocation获取当前的pathname,然后检查它是否与我们的模式匹配。

这是页面首次加载时的样子:

图 9.7:第一个选项处于激活状态

如果你点击项目二标签,URL 将更新,激活的标签将改变,标签下面的页面内容也会改变:

图 9.8:第二个选项处于激活状态

到目前为止,你已经了解了在 Material UI 应用程序中可以使用的两种导航方法。第一种是使用仅在用户需要访问导航链接时显示的Drawer。第二种是使用始终可见的Tabs。在下一节中,你将学习如何收集用户输入。

收集用户输入

从用户那里收集输入可能很困难。如果我们想要提供良好的用户体验,我们需要考虑每个字段许多细微之处。幸运的是,Material UI 中可用的Form组件为我们处理了许多可用性问题。在本节中,你将简要了解你可以使用的输入控件。

复选框和单选按钮

复选框用于从用户那里收集true/false答案,而单选按钮用于让用户从少量选项中选择一个。让我们看看 Material UI 中这些组件的示例:

export default function Checkboxes() {
  const [checkbox, setCheckbox] = React.useState(false);
  const [radio, setRadio] = React.useState("First");
  return (
    <div>
      <FormControlLabel
        label={'Checkbox ${checkbox ? "(checked)" : ""}'}
        control={
          <Checkbox
            checked={checkbox}
            onChange={() => setCheckbox(!checkbox)}
          />
        }
      />
      <FormControl component="fieldset">
        <FormLabel component="legend">{radio}</FormLabel>
        <RadioGroup value={radio} onChange={(e) => setRadio(e.target.value)}>
          <FormControlLabel value="First" label="First" control={<Radio />} />
          <FormControlLabel value="Second" label="Second" control={<Radio />} />
          <FormControlLabel value="Third" label="Third" control={<Radio />} />
        </RadioGroup>
      </FormControl>
    </div>
  );
} 

此示例包含两件状态信息。checkbox状态控制Checkbox组件的值,而radio值控制RadioGroup组件的状态。checkbox状态传递给Checkbox组件的checked属性,而radio状态传递给RadioGroup组件的value属性。这两个组件都有onChange处理程序,它们调用它们各自的状态设置函数:setCheckbox()setRadio()。你会注意到许多其他 Material UI 组件都参与了这些控件显示。例如,checkbox的标签使用FormControlLabel组件显示,而单选控件使用FormControl组件和FormLabel组件。

下面是这两个输入控件的外观:

图片

图 9.9:复选框和单选组

这两个控件标签都更新以反映组件的状态变化。复选框标签显示复选框是否被选中,而单选按钮标签显示当前选定的值。在下一节中,我们将查看文本输入和选择组件。

文本输入和选择输入

文本字段允许我们的用户输入文本,而选择允许他们从几个选项中进行选择。选择和单选按钮之间的区别在于,由于选项仅在用户打开选项菜单时才显示,因此选择在屏幕上占用的空间更少。

现在让我们看看Select组件:

import { useState } from "react";
import InputLabel from "@mui/material/InputLabel";
import MenuItem from "@mui/material/MenuItem";
import FormControl from "@mui/material/FormControl";
import Select from "@mui/material/Select";
export default function MySelect() {
  const [value, setValue] = useState<string | undefined>();
  return (
    <FormControl>
      <InputLabel id="select-label">My Select</InputLabel>
      <Select
        labelId="select-label"
        id="select"
        label="My Select"
        value={value}
        onChange={(e) => setValue(e.target.value)}
        inputProps={{ id: "my-select" }}
      >
        <MenuItem value="first">First</MenuItem>
        <MenuItem value="second">Second</MenuItem>
        <MenuItem value="third">Third</MenuItem>
      </Select>
    </FormControl>
  );
} 

在此示例中使用的值状态控制Select组件中的选定值。当用户更改他们的选择时,setValue()函数会更改值。

MenuItem组件用于指定select字段中可用的选项;当选择给定项时,value属性设置为值状态。以下是菜单显示时的select字段外观:

图片

图 9.10:第一个项目处于活动状态的菜单

接下来,让我们看看一个TextField组件的示例:

export default function MyTextInput() {
  const [value, setValue] = useState("");
  return (
    <TextField
      label="Name"
      value={value}
      onChange={(e) => setValue(e.target.value)}
      margin="normal"
    />
  );
} 

值状态控制文本输入的值,并随着用户的输入而改变。下面是text字段的外观:

图片

图 9.11:带有用户提供的文本的文本字段

与其他FormControl组件不同,TextField组件不需要其他几个支持组件。我们所需的一切都可以通过属性来指定。在下一节中,我们将查看Button组件。

与按钮一起工作

Material UI 按钮与 HTML 按钮元素非常相似。区别在于它们是 React 组件,与 Material UI 的其他方面(如主题和布局)配合得很好。让我们看看一个渲染不同样式按钮的示例:

type ButtonColor = "primary" | "secondary";
export default function App() {
  const [color, setColor] = useState<ButtonColor>("secondary");
  const updateColor = () => {
    setColor(color === "secondary" ? "primary" : "secondary");
  };
  return (
    <Stack direction="row" spacing={2}>
      <Button variant="contained" color={color} onClick={updateColor}>
        Contained
      </Button>
      <Button color={color} onClick={updateColor}>
        Text
      </Button>
      <Button variant="outlined" color={color} onClick={updateColor}>
        Outlined
      </Button>
      <IconButton color={color} onClick={updateColor}>
        <AndroidIcon />
      </IconButton>
    </Stack>
  );
} 

此示例渲染了四种不同的按钮样式。我们使用Stack组件来渲染按钮行。当按钮被点击时,状态会在主要和次要之间切换。

这是按钮首次渲染时的样子:

图 9.12:四种 Material UI 按钮样式

这是每个按钮被点击后的样子:

图 9.13:按钮被点击后的样子

在本节中,你了解了 Material UI 中一些可用的用户输入控件。复选框单选按钮在用户需要开启或关闭某个功能或选择一个选项时非常有用。当用户需要输入一些文本时,文本输入是必要的,而选择框在您有一系列选项可供选择但显示空间有限时非常有用。最后,你了解到 Material UI 有几种按钮样式,当用户需要启动一个动作时可以使用。在下一节中,我们将探讨在 Material UI 中样式和主题是如何工作的。

与样式和主题一起工作

Material UI 包含用于扩展 UI 组件样式和扩展应用于所有组件的主题样式的系统。在本节中,你将了解如何使用这两个系统。

制作样式

Material UI 自带一个styled()函数,可以用来基于 JavaScript 对象创建样式化组件。这个函数的返回值是一个应用了新样式的新的组件。

让我们更详细地看看这种方法:

const StyledButton = styled(Button)(({ theme }) => ({
  "&.MuiButton-root": { margin: theme.spacing(1) },
  "&.MuiButton-contained": { borderRadius: 50 },
  "&.MuiButton-sizeSmall": { fontWeight: theme.typography.fontWeightLight },
}));
export default function App() {
  return (
    <>
      <StyledButton>First</StyledButton>
      <StyledButton variant="contained">Second</StyledButton>
      <StyledButton size="small" variant="outlined">
        Third
      </StyledButton>
    </>
  );
} 

在这个样式中使用的名字(MuiButton-rootMuiButton-containedMuiButton-sizeSmall)并不是我们想出来的。这些都是按钮 CSS API的一部分。根样式应用于所有按钮,因此在这个例子中,所有三个按钮都将具有我们在这里应用的边距值。contained样式应用于使用包含变体的按钮。sizeSmall样式应用于具有小尺寸属性值的按钮。

这是自定义按钮样式的外观:

图 9.14:使用自定义样式的按钮

现在你已经知道了如何更改单个组件的外观和感觉,是时候考虑如何自定义整个应用程序的外观和感觉了。

自定义主题

Material UI 自带默认主题。我们可以以此为基础创建自己的主题。在 Material UI 中创建新主题主要有两个步骤:

  1. 使用createTheme()函数来自定义默认主题设置,并返回一个新的主题对象。

  2. 使用ThemeProvider组件包裹我们的应用程序,以便应用适当的主题。

让我们看看这个流程在实际中是如何工作的:

import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import { ThemeProvider, createTheme } from "@mui/material/styles";
const theme = createTheme({
  typography: {
    fontSize: 11,
  },
  components: {
    MuiMenuItem: {
      styleOverrides: {
        root: {
          marginLeft: 15,
          marginRight: 15,
        },
      },
    },
  },
});
export default function App() {
  return (
    <ThemeProvider theme={theme}>
      <Menu anchorEl={document.body} open={true}>
        <MenuItem>First Item</MenuItem>
        <MenuItem>Second Item</MenuItem>
        <MenuItem>Third Item</MenuItem>
      </Menu>
    </ThemeProvider>
  );
} 

我们在这里创建的自定义主题做了两件事:

  • 它将所有组件的默认字体大小更改为11

  • 它更新了MenuItem组件的左右边距值。

在 Material UI 主题中可以设置许多值;更多自定义信息请参考自定义文档。components 部分用于组件特定的自定义。当你需要为应用中每个组件实例设置样式时,这非常有用。

摘要

本章是对 Material UI 的非常简要介绍,它是最受欢迎的 React UI 框架。我们首先查看用于帮助布局我们页面的组件。然后我们查看可以帮助用户在应用中导航的组件。接下来,你学习了如何使用 Material UI 表单组件收集用户输入。最后,你学习了如何通过样式和修改主题来设置你的 Material UI。

从本章中获得的认识使你能够在不从头开发 UI 组件的情况下构建复杂界面,从而加速你的开发过程。此外,React 应用开发本质上依赖于各种辅助库的协同使用。对 React 生态系统及其关键库的深入了解使开发者能够快速原型设计和迭代他们的应用,使开发更有效。

在下一章中,我们将探讨使用 React 最新版本中提供的最新功能来提高组件状态更新效率的方法。

第十章:高性能状态更新

状态代表了你的 React 应用程序的动态方面。当状态发生变化时,你的组件会对这些变化做出反应。没有状态,你将只有一些花哨的 HTML 模板语言。通常,执行状态更新并在屏幕上渲染更改所需的时间几乎不明显,如果有的话。然而,有时复杂的状态变化可能导致用户注意到明显的延迟。本章的目标是解决这些问题,并找出我们如何避免这些延迟。

在本章中,你将学习以下内容:

  • 将你的状态更改批量处理在一起以实现最小化重新渲染

  • 优先更新状态以渲染对用户体验至关重要的内容

  • 批量处理优先处理状态更新时开发执行异步操作的战略

技术要求

对于本章,你需要你的代码编辑器(Visual Studio Code)。我们将遵循的代码可以在以下位置找到:github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter10

你可以在 Visual Studio Code 中的终端运行npm install,以确保你能够随着阅读本章的例子而跟进。

批量处理状态更新

在本节中,你将了解 React 如何将状态更新批量处理在一起,以防止在多个状态变化同时发生时进行不必要的渲染。特别是,我们将探讨在React 18中引入的更改,这些更改使得状态更新的自动批量处理变得普遍。

当你的 React 组件发出状态变化时,这会导致 React 内部重新渲染由于这种状态更新而视觉上发生变化的部分。例如,想象你有一个包含名称状态的组件,该状态渲染在<span>元素内部,并将名称状态从Adam更改为Ashley。这是一个简单的更改,导致重新渲染得太快,以至于用户甚至注意不到。不幸的是,Web 应用程序中的状态更新很少如此简单。相反,可能会有数十个状态变化在 10 毫秒内发生。例如,名称状态可能会跟随以下变化:

  1. Adam

  2. Ashley

  3. Andrew

  4. Ashley

  5. Aaron

  6. Adam

在这里,我们短时间内发生了六个名为 state 的变化。这意味着 React 会重新渲染DOM六次,每次设置一个值作为 name state。关于这个场景有趣的是最终的状态更新:我们回到了起点,Adam。这意味着我们无端地重新渲染了 DOM 五次。现在,想象一下在 Web 应用规模上的这些浪费的重新渲染,以及这些类型的状态更新可能会对性能造成的问题。例如,当应用使用复杂的动画、用户交互如拖放、超时和间隔时,都可能导致不必要的重新渲染,从而对性能产生负面影响。

解决这个问题的答案是批处理。这就是 React 如何将我们在组件代码中做出的几个状态更新视为一个单一的状态更新。而不是逐个处理每个状态更新,在每次更新之间重新渲染 DOM,状态更改都被合并,从而只导致一次 DOM 重新渲染。总的来说,这大大减少了我们的 Web 应用需要执行的工作量。

React 17中,状态更新的自动批处理仅在事件处理函数内部发生。例如,假设你有一个带有onClick()处理器的按钮,该处理器执行五个状态更新。React 会将所有这些状态更新一起批处理,从而只需要一次重新渲染。问题出现在你的事件处理器进行异步调用,通常是为了获取一些数据,然后在异步调用完成后进行状态更新。这些状态更改不再自动批处理,因为它们不是直接在事件处理器函数中运行的。相反,它们运行在异步操作的回调代码中,React 17 不会批处理这些更新。这是一个挑战,因为我们的 React 组件异步获取数据并在事件响应中执行状态更新是很常见的!

现在我们知道了如何处理最常见的不必要重新渲染问题,即短时间内对状态进行多次更改。现在,让我们通过例子来理解它。

React 18 批处理

现在,让我们将注意力转向一些代码,看看React 18是如何解决我们刚刚概述的批处理问题的。在这个例子中,我们将渲染一个按钮,当点击时,将执行 100 次状态更新。我们将使用setTimeout()来确保更新是异步执行的,在事件处理函数之外。目的是展示两种不同的 React 版本处理此代码的方式之间的差异。为此,我们可以在浏览器开发者工具中打开React 分析器,在按下按钮执行我们的状态更改之前点击记录。下面是代码的样子:

import * as React from "react";
export default function BatchingUpdates() {
  let [value, setValue] = React.useState("loading...");
  function onStart() {
    setTimeout(() => {
      for (let i = 0; i < 100; i++) {
        setValue('value ${i + 1}');
      }
    }, 1);
  }
  return (
    <div>
      <p>
        Value: <em>{value}</em>
      </p>
      <button onClick={onStart}>Start</button>
    </div>
  );
} 

通过点击该组件渲染的按钮,我们调用由我们的组件定义的 onStart() 事件处理器函数。然后,我们的处理器在循环中调用 setValue() 100 次。理想情况下,我们不想进行 100 次重新渲染,因为这会损害我们应用程序的性能,而且也不需要这样做。这里只关心 setValue() 的最终调用。

首先,让我们看看使用 React 17 捕获的该组件的配置文件:

图 13.1 – 使用 React 开发工具查看每次状态更新时进行的重新渲染

图 10.1:使用 React 开发工具查看每次状态更新时进行的重新渲染

通过按下与我们事件处理器相关联的按钮,我们进行了 100 次状态更新调用。由于这是在 setTimeout() 函数外部完成的,所以不会发生自动批处理。我们可以在 BactchingUpdates 组件的配置文件输出中看到这一点,其中有一长串的渲染。其中大部分是不必要的,并增加了 React 需要执行以响应用户交互的工作量,从而损害了我们应用程序的整体性能。

让我们捕获使用 React 18 渲染的相同组件的配置文件:

图 13.2 – React 开发工具显示启用自动批处理时仅有一个渲染

图 10.2:React 开发工具显示启用自动批处理时仅有一个渲染

自动批处理应用于所有进行状态更新的地方,甚至在像这种情况这样的常见异步场景中也是如此。正如配置文件所示,当我们点击按钮时,只有一个重新渲染,而不是 100 个。我们也不必对我们的组件代码进行任何调整来实现这一点。然而,为了使状态更新自动批处理,我们需要进行一个更改。假设你使用了 ReactDOM.render() 来渲染你的根组件,如下所示:

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById("root")
); 

相反,你可以使用 ReactDOM.createRoot() 并渲染它:

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
); 

通过这种方式创建和渲染你的根节点,你可以确保在 React 18 中,你将获得整个应用程序的批处理状态更新。你不再需要担心手动优化状态更新以确保它们立即发生:React 现在为你这样做。然而,有时你会有比其他状态更新优先级更高的状态更新。在这种情况下,我们需要一种方法来告诉 React 优先处理某些状态更新,而不是将所有内容一起批处理。

优先处理状态更新

当我们的 React 应用程序发生某些事件时,我们通常会进行多次状态更新,以便 UI 可以反映这些变化。通常,你可以做出这些状态变化而不必过多考虑渲染性能的影响。例如,假设你有一个需要渲染的长列表项目。这可能会对 UI 产生一些影响:当列表正在渲染时,用户可能无法与某些页面元素交互,因为 JavaScript 引擎在短时间内 100%被占用。

然而,当昂贵的渲染干扰了用户期望的正常浏览器行为时,这可能会成为一个问题。例如,如果用户在文本框中输入文本,他们期望刚刚输入的字符立即显示出来。但如果你组件正忙于渲染一个大型项目列表,文本框的状态无法立即更新。这就是新的 React 状态更新优先级 API 派上用场的地方。

startTransition() API 用于标记某些状态更新为过渡性,这意味着更新被视为低优先级。如果你考虑一个项目列表要么是首次渲染,要么是改变为另一个项目列表,这种转换不需要立即进行。另一方面,如更改文本框中的值这样的状态更新应该尽可能接近立即。通过使用startTransition(),你告诉 React,如果存在更重要的更新,任何状态更新都可以等待。

对于startTransition()的一个好的经验法则是用于以下情况:

  • 任何可能执行大量渲染工作的内容

  • 任何不需要用户对其交互立即反馈的内容

让我们通过一个例子来了解,当用户在文本框中输入以过滤列表时,如何渲染大量项目列表。

这个组件将渲染一个用户可以输入以过滤 25000 个项目列表的文本框。我选择这个数字是基于我编写此代码时所使用的笔记本电脑的性能:如果你没有延迟,你可能需要调整它,如果渲染任何东西都花费太长时间,你可能需要将其降低。当页面首次加载时,你应该看到一个看起来像这样的过滤器文本框:

图片

图 10.3:用户输入任何内容之前的过滤器框

当你开始在过滤器文本框中输入时,过滤后的项目将显示在其下方。由于需要渲染的项目很多,可能需要一秒钟或两秒钟:

图片

图 10.4:当用户开始输入时,过滤器输入下方的过滤项目

现在,让我们从一组大量项目开始,逐步分析代码:

let unfilteredItems = new Array(25000)
  .fill(null)
  .map((_, i) => ({ id: i, name: 'Item ${i}' })); 

数组的尺寸是在数组构造函数中指定的,然后它被填充了我们可以通过其进行过滤的编号字符串值。

接下来,让我们看看这个组件使用的状态:

let [filter, setFilter] = React.useState("");
let [items, setItems] = React.useState([]); 

filter状态表示过滤器文本框的值,默认为空字符串。items状态表示来自我们的unfilteredItems数组的过滤项。当用户在过滤器文本框中输入时,此数组被填充。

接下来,让我们看看这个组件渲染的标记:

<div>
  <div>
    <input
      type="text"
      placeholder="Filter"
      value={filter}
      onChange={onChange}
    />
  </div>
  <div>
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  </div>
</div> 

过滤器文本框由一个<input>元素渲染,而过滤结果通过遍历items数组以列表形式渲染。

最后,让我们看看当用户在过滤器文本框中输入时触发的事件处理函数:

const onChange = (e) => {
  setFilter(e.target.value);
  setItems(
    e.target.value === ""
      ? []
      : unfilteredItems.filter((item) => item.name.includes(e.target.value))
  );
}; 

当用户在过滤器文本框中输入时,会调用onChange()函数,并设置两个状态值。首先,它使用setFilter()来设置过滤器文本框的值。然后,它调用setItems()来设置要渲染的过滤项,除非过滤器文本为空,在这种情况下,我们不渲染任何内容。

当与这个示例交互时,你可能会注意到在输入时文本框的响应性问题。这是因为在这个函数中,我们不仅设置了文本框的值,还设置了过滤项。这意味着在文本值可以渲染之前,我们必须等待数千个项被渲染。

尽管这些是两个独立的状态更新(setFilter()setItems()),但它们被批处理并被视为单一状态更新。同样,当渲染开始时,React 会一次性执行所有更改,这意味着 CPU 不会让用户与文本框交互,因为它完全被利用,渲染出长长的过滤结果列表。理想情况下,我们希望优先处理文本框的状态更新,同时允许项在之后渲染。换句话说,我们希望降低项渲染的优先级,因为它成本高昂,并且用户不会直接与之交互。

这就是startTransition() API 发挥作用的地方。传递给startTransition()函数内部发生的任何状态更新都将被赋予比其外部发生的任何状态更新更低的优先级。在我们的过滤示例中,我们可以通过将setItems()状态更改移动到startTransition()内部来修复文本框的响应性问题。

这是我们的新onChange()事件处理器的样子:

const onChange = (e) => {
  setFilter(e.target.value);
  React.startTransition(() => {
    setItems(
      e.target.value === ""
        ? []
        : unfilteredItems.filter((item) => item.name.includes(e.target.value))
    );
  });
}; 

注意,我们不需要对项的状态更新方式做出任何更改:相同的代码被移动到一个传递给startTransition()的函数中。这告诉 React 仅在所有其他状态更改完成后执行此状态更改。在我们的情况下,这允许文本框在setItems()状态更改运行之前更新和渲染。如果你现在运行示例,你会看到文本框的响应性不再受渲染长列表所需时间的影响。

在这个新 API 介绍之前,你可以通过使用 setTimeout() 的变通方法来实现状态更新优先级。这种方法的主要缺点是,React 内部调度器对您的状态更新及其优先级一无所知。例如,通过使用 startTransitiion(),React 可以在状态更改再次发生之前或组件卸载时取消整个更新。

在实际应用中,这不仅仅是一个优先考虑哪个状态更新应该首先运行的问题。相反,它是在确保优先级得到考虑的同时异步获取数据。在本章的最后部分,我们将把这些内容串联起来。

处理异步状态更新

在本章的最后部分,我们将探讨异步获取数据和设置渲染优先级的常见场景。我们想要解决的关键场景是确保用户在输入或进行任何需要即时反馈的交互时不会被中断。这需要适当的优先级处理和从服务器处理异步响应。让我们首先看看可以帮助这个场景的 React API。

startTransition() API 可以用作 钩子。当我们这样做时,我们也会得到一个布尔值,我们可以检查它以确定转换是否仍在挂起。这有助于向用户显示正在加载。让我们修改上一节中的示例,使用异步数据获取函数来获取我们的项目。我们还将使用 useTransition() 钩子,并给组件的输出添加加载行为:

let unfilteredItems = new Array(25000)
  .fill(null)
  .map((_, i) => ({ id: i, name: 'Item ${i}' }));
function filterItems(filter: string) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(unfilteredItems.filter((item) => item.name.includes(filter)));
    }, 1000);
  });
}
export default function AsyncUpdates() {
  const [isPending, startTransition] = React.useTransition();
  const [isLoading, setIsLoading] = React.useState(false);
  const [filter, setFilter] = React.useState("");
  const [items, setItems] = React.useState<{ id: number; name: string }[]>([]);
  const onChange: React.ChangeEventHandler<HTMLInputElement> = async (e) => {
    setFilter(e.target.value);
    startTransition(() => {
      if (e.target.value === "") {
        setItems([]);
      } else {
        filterItems(e.target.value).then((result) => {
          setItems(result);
        });
      }
    });
  };
  return (...);
} 

这个例子表明,一旦你在过滤文本框中开始输入,它将触发 onChange() 处理程序,这将调用 filterItems() 函数。我们还有一个 isLoading 值,我们可以用它来向用户显示后台正在发生某些事情:

<div>
  <div>
    <input
      type="text"
      placeholder="Filter"
      value={filter}
      onChange={onChange}
    />
  </div>
  <div>
    {isPending && <em>loading...</em>}
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  </div>
</div> 

isLoadingtrue 时,用户将看到以下内容:

图片

图 10.5:状态转换挂起时的加载指示器

然而,我们的方法存在一个小问题。你可能已经注意到,在文本框中输入时,加载信息会短暂闪烁。但随后,你可能有一个更长的时间段,项目仍然不可见,加载信息消失了。这里发生了什么?嗯,来自 useTransition() 钩子的 isPending 值可能会误导。我们设计组件的方式是,以下情况下 isPending 将为 true

  • 如果 filterItems() 函数仍在获取我们的数据

  • 如果 setItems() 状态更新仍在执行一个昂贵的渲染,并且有很多项目

很不幸,isPending 并不是这样工作的。这个值只有在我们将函数传递给 startTransition() 之前是 true 的。这就是为什么你会在数据获取操作和渲染操作期间看到加载指示器短暂闪烁而不是一直显示的原因。记住,React 在内部调度状态更新,通过使用 startTransition(),我们已经调度了 setItems() 在其他状态更新之后运行。

另一种思考 isPending 的方式是,它在高优先级更新仍在运行时是 true 的。我们可以称之为 highPriorityUpdatesPending 以避免混淆。尽管如此,这个值的用途很窄,但它们确实偶尔会发生。对于我们的更常见情况,即获取数据和执行昂贵的渲染,我们需要考虑另一种解决方案。让我们审查我们的代码,并以一种方式重构它,使得在获取和更高优先级的更新发生时显示加载指示器。首先,让我们引入一个新的 isLoading 状态,默认为 false

const [isLoading, setIsLoading] = React.useState(false);
const [filter, setFilter] = React.useState("");
const [items, setItems] = React.useState([]); 

现在,在我们的 onChange() 处理程序内部,我们可以将状态设置为 true。在数据获取完成后运行的转换中,我们将其设置回 false

const onChange: React.ChangeEventHandler<HTMLInputElement> = async (e) => {
  setFilter(e.target.value);
  setIsLoading(true);
  React.startTransition(() => {
    if (e.target.value === "") {
      setItems([]);
      setIsLoading(false);
    } else {
      filterItems(e.target.value).then((result) => {
        setItems(result);
        setIsLoading(false);
      });
    }
  });
}; 

现在我们正在跟踪 isLoading 状态,我们知道所有重负载何时完成,并且可以隐藏加载指示器。最后的更改是将指示器的显示基于 isLoading 而不是 isPending

<div>
  {isLoading && <em>loading...</em>}
  <ul>
    {items.map((item) => (
      <li key={item.id}>{item.name}</li>
    ))}
  </ul>
</div> 

当你运行这些更改的示例时,结果应该会更加可预测。setLoading()setFilter() 状态更新是高优先级的,并且会立即执行。使用 filterItems() 获取数据的调用直到高优先级状态更新完成后才会进行。

只有在我们获取到数据后,我们才会隐藏加载指示器。

摘要

本章向您介绍了 React 18 中可用的新 API,这些 API 有助于您实现高性能状态更新。我们从 React 18 中自动状态更新批处理的变化开始,并探讨了如何最好地利用它们。然后我们探讨了新的 startTransition() API 以及如何将其用于标记某些状态更新为比那些需要即时用户交互反馈的状态更新具有更低优先级。最后,我们探讨了如何将状态更新优先级与异步数据获取相结合。

在下一章中,我们将介绍从服务器获取数据。

第十一章:从服务器获取数据

网络技术的发展使得浏览器与服务器之间的交互以及服务器数据的处理成为网络开发的一个核心部分。如今,很难在传统网页和完整的网络应用之间划清界限。这一变革的核心是浏览器中 JavaScript 的能力,它能够向服务器发起请求,高效地处理接收到的数据,并在页面上动态显示。这个过程已成为创建我们今天所看到的交互式和响应式网络应用的基础。在本章中,我们将探讨从服务器获取数据的各种方法和途径,讨论它们对网络应用架构的影响,并熟悉这一领域的现代实践。

因此,在本章中,我们将涵盖以下主题:

  • 处理远程数据

  • 使用 Fetch API

  • 使用 Axios

  • 使用 TanStack Query

  • 使用 GraphQL

技术要求

您可以在 GitHub 上找到本章的代码文件,地址为github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter11

处理远程数据

在网络开发领域,从服务器获取数据的过程经历了显著的变革。在 20 世纪 90 年代初,随着HTTP 1.0的出现,标志着服务器通信的开始。网页是静态的,HTTP 请求也很基础,仅用于获取整个页面或静态资源。每次请求都需要建立新的连接,交互性非常有限,主要限于 HTML 表单。安全性也很基础,反映了网络的初级阶段。

千禧年的转折点见证了异步 JavaScript 和 XMLAJAX)的兴起,这带来了增强的交互性,允许网络应用在后台与服务器通信,而无需重新加载整个页面。它由XMLHttpRequest对象驱动。以下是一个使用XMLHttpRequest获取数据的简单示例:

var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
  if (xhr.readyState == XMLHttpRequest.DONE) {
    if (xhr.status === 200) {
      console.log(xhr.responseText);
    } else {
      console.error('Error fetching data');
    }
  }
};
xhr.open('GET', 'http://example.com', true);
xhr.send(); 

这个例子说明了典型的XHR 请求。成功和错误响应通过回调函数管理。这反映了异步代码严重依赖回调的时代。

随着我们的进步,HTTP 演变为1.1版本,通过持久连接增强了效率,并标准化了RESTful API。这些 API 使用标准的 HTTP 方法,并围绕可识别的资源设计,大大提高了可扩展性和开发者的生产力。

Fetch API的出现提供了一种现代的、基于 Promise 的机制来发起网络请求。Fetch 比XMLHttpRequest更强大、更灵活。以下是一个使用 Fetch 的示例:

fetch('http://example.com/data')
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error('Error:', error)); 

此外,还有许多基于 Fetch API 和 XHR 开发的社区工具。例如,Axios、GraphQL 和 React Query 进一步简化了服务器通信和数据获取,增强了开发者的体验。

Axios是一个现代的 HTTP 客户端库,通过基于 Promise 的 API 和一系列有用的功能(如拦截请求和响应)进一步简化了数据获取。以下是如何使用 Axios 进行GET请求的示例:

axios.get('http://example.com/data')
  .then(response => console.log(response.data))
  .catch(error => console.error('Error:', error)); 

这个例子可能看起来与Fetch API 相同,但在实际项目中,当你设置了拦截器时,它就变成了一个节省大量时间的游戏改变者。拦截器允许你在请求发送之前拦截并修改请求,在响应处理之前拦截并修改响应。一个常见的用例是在访问令牌过期时刷新访问令牌。拦截器可以将新令牌添加到所有后续请求中。通过使用像 Axios 这样的库,许多低级网络代码被抽象化,让你可以专注于发送请求和处理响应。拦截器、错误处理和其他功能以可重用的方式解决跨切面问题,从而产生更干净的代码。

接下来是GraphQL,它通过允许客户端请求他们确切需要的数据,从而彻底改变了数据获取方式,消除了过度获取和不足获取的问题。它提供了一种灵活且高效的方式从服务器检索数据。而不是预定义的端点,客户端指定他们的数据需求,服务器则返回精确请求的数据。这减少了网络负载并提高了应用程序的性能。

import { GraphQLClient, gql } from 'graphql-request';
const endpoint = 'http://example.com/graphql';
const client = new GraphQLClient(endpoint);
const query = gql'
  query {
    user(id: 123) {
      name
      email
    }
  }
';
client.request(query)
  .then(data => console.log(data))
  .catch(error => console.error('Error:', error)); 

在这里,我们通过ID请求用户,只指定了两个字段:nameemail。无论用户对象的大小如何,GraphQL 服务器都能高效地处理它,只向客户端发送请求的数据。

我还想探讨另一个工具React Query。这个库旨在简化 React 应用程序中的数据获取和状态管理。它抽象掉了获取和缓存数据的复杂性,处理后台更新,并提供 Hooks 以方便与组件集成。React Query 通过使以高效和可维护的方式与服务器数据一起工作变得简单,从而提高了开发过程。

import { useQuery } from 'react-query';
function UserProfile({ userId }) {
  const { data, error, isLoading } = useQuery(userId, fetchUser);
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  return (
    <div>
      <h1>{data.name}</h1>
      <p>Email: {data.email}</p>
    </div>
  );
} 

如您所见,我们甚至不需要处理错误或手动设置和更新加载状态。所有这些都是由一个 Hook 提供的。

服务器通信的另一个显著发展是WebSockets,它实现了实时双向通信。这对于需要实时数据更新的应用程序,如聊天应用或交易平台来说是一个游戏改变者。以下是一个使用 WebSockets 的基本示例:

const socket = new WebSocket('ws://example.com');
socket.onopen = function(event) {
  console.log('Connection established');
};
socket.onmessage = function(event) {
  console.log('Message from server ', event.data);
};
socket.onerror = function(error) {
  console.error('WebSocket Error ', error);
}; 

在这里,我们仍然使用回调方法,因为双向通信的心理模型。

总之,服务器通信在 Web 开发中的演变对于提升用户体验和开发者生产力至关重要。从HTTP 1.0的初级阶段到今天的复杂工具,我们见证了巨大的转变。Ajax、Fetch API、Axios、GraphQL 和 React Query 等技术的引入不仅简化了服务器交互,还标准化了应用程序中的异步行为。这些进步对于高效管理加载、错误和离线场景等状态至关重要。这些工具在现代 Web 应用程序中的集成标志着在构建更响应、更健壮和用户友好的界面方面迈出了重要一步。这是对技术不断演变及其对网络内容创建和消费深远影响的证明。

在下一节中,我们将探讨如何使用 Fetch API 从服务器获取数据的真实示例。

使用 Fetch API

让我们探索如何在实践中从服务器检索数据。我们将从最常见和基础的Fetch API开始。

在我们开始之前,让我们创建一个小型应用程序,该应用程序从 GitHub 获取用户数据并在屏幕上显示他们的头像和基本信息。为此,我们需要一个空的Vite项目,并使用 React。您可以使用以下命令创建它:

npm create vite@latest 

由于我们在示例中使用TypeScript,让我们首先定义GitHubUser接口和所有必要的参数。

为了找出服务器返回的数据,我们通常需要参考文档,通常由后端开发者提供。在我们的案例中,由于我们使用 GitHub REST API,我们可以在官方 GitHub 文档中找到用户信息,链接如下:docs.github.com/en/rest/users/users?apiVersion=2022-11-28

让我们按照以下方式创建GitHubUser接口:

export interface GitHubUser {
  login: string;
  id: number;
  avatar_url: string;
  html_url: string;
  gists_url: string;
  repos_url: string;
  name: string;
  company: string | null;
  location: string | null;
  bio: string | null;
  public_repos: number;
  public_gists: number;
  followers: number;
  following: number;
} 

这些是我们将在应用程序中使用的必要字段。实际上,user对象中还有更多字段,但我只包括了我们将要使用的那些。

现在我们知道了用户将拥有的字段,让我们创建一个组件,该组件将在屏幕上显示用户数据:

const UserInfo = ({ user }: GitHubUserProps) => {
  return (
    <div>
      <img src={user.avatar_url} alt={user.login} width="100" height="100" />
      <h2>{user.name || user.login}</h2>
      <p>{user.bio}</p>
      <p>Location: {user.location || "Not specified"}</p>
      <p>Company: {user.company || "Not specified"}</p>
      <p>Followers: {user.followers}</p>
      <p>Following: {user.following}</p>
      <p>Public Repos: {user.public_repos}</p>
      <p>Public Gists: {user.public_gists}</p>
      <p>
        GitHub Profile:{" "}
        <a href={user.html_url} target="_blank" rel="noopener noreferrer">
          {user.login}
        </a>
      </p>
    </div>
  );
}; 

在这里,我们将用户的头像和一些有用的信息以及一个打开他们 GitHub 个人资料页面的链接一起展示。

现在让我们看看App组件,在那里我们处理服务器数据检索逻辑:

function App() {
  const [user, setUser] = useState<GitHubUser>();
  const [loading, setLoading] = useState(true);
  useEffect(() => {
    setLoading(true);
    fetch("https://api.github.com/users/sakhnyuk")
      .then((response) => response.json())
      .then((data) => setUser(data))
      .catch((error) => console.log(error))
      .finally(() => setLoading(false));
  }, []); 

我们使用useState钩子来存储user数据和加载状态。在useEffect中,我们通过 Fetch API 请求从 GitHub API 获取数据。如您所见,fetch函数接受一个 URL 作为参数。我们处理响应,将其保存到状态中,使用catch块处理错误,并最终使用finally块关闭加载过程。

为了完成应用程序,我们展示检索到的用户数据:

 return (
    <div>
      {loading && <p>Loading...</p>}
      {!loading && !user && <p>No user found.</p>}
      {user && <UserInfo user={user} />}
    </div>
  );
} 

您可以使用以下命令运行您的应用程序:

npm run dev 

打开终端中出现的链接,你会看到:

图 11.1:由 Fetch API 请求的 GitHub 用户

现在你已经知道了如何使用 Fetch API 获取数据。让我们探索一个类似的应用程序的实施,其中我们使用其他工具请求数据。

使用 Axios

在本节中,我们将探索一个用于与服务器交互的最受欢迎的库,称为 Axios。这个库类似于 Fetch API,但也提供了额外的功能,使其成为处理请求的强大工具。

让我们把我们的前一个项目拿来做一些修改。首先,让我们将 Axios 作为依赖项安装:

npm install axios 

Axios 的一个特性是能够创建具有特定配置的实例,例如头部信息、基本 URL、拦截器等。这使得我们可以拥有一个预先配置的实例,以满足我们的需求,减少代码重复,并使其更具可扩展性。

让我们创建一个 API 类,它封装了与服务器交互所需的所有必要逻辑:

class API {
  private apiInstance: AxiosInstance;
  constructor() {
    this.apiInstance = axios.create({
      baseURL: "https://api.github.com",
    });
    this.apiInstance.interceptors.request.use((config) => {
      console.log("Request:", '${config.method?.toUpperCase()} ${config.url}');
      return config;
    });
    this.apiInstance.interceptors.response.use(
      (response) => {
        console.log("Response:", response.data);
        return response;
      },
      (error) => {
        console.log("Error:", error);
        return Promise.reject(error);
      }
    );
  }
  getProfile(username: string) {
    return this.apiInstance.get<GitHubUser>('/users/${username}');
  }
}
export default new API(); 

在这个类的构造函数中,我们创建并存储一个 Axios 实例,并设置基本 URL,从而消除在未来的请求中重复此域的需要。接下来,我们为每个请求和响应配置拦截器。这是为了演示目的,所以当我们运行应用程序时,我们可以在控制台日志中看到所有的请求和响应:

图 11.2:Axios 拦截器日志

现在,让我们看看使用我们新的 API 类的 App 组件将是什么样子:

function App() {
  const [user, setUser] = useState<GitHubUser>();
  const [loading, setLoading] = useState(true);
  useEffect(() => {
    setLoading(true);
    api
      .getProfile("sakhnyuk")
      .then((res) => setUser(res.data))
      .finally(() => setLoading(false));
  }, []);
  return (
    <div>
      {loading && <p>Loading...</p>}
      {!loading && !user && <p>No user found.</p>}
      {user && <UserInfo user={user} />}
    </div>
  );
} 

如前所述,Axios 与 Fetch API 并无显著差异,但它提供了更强大的功能,使得创建更复杂的用于处理服务器数据的解决方案变得容易。

在下一节中,我们将探索使用 TanStack Query 实现的相同应用程序。

使用 TanStack Query

TanStack Query,更常被称为 React Query,是一个将服务器交互提升到新高度的库。这个库允许我们请求数据并将其缓存。因此,我们可以在一次渲染期间多次调用相同的 useQuery 钩子,但只需向服务器发送一个请求。该库还包括内置的加载和错误状态,简化了请求状态的处理。

要开始,让我们将库作为我们项目的依赖项安装:

npm install @tanstack/react-query 

接下来,我们需要通过添加 QueryClientProvider 来配置库:

const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById("root")!).render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>
); 

在此设置之后,我们可以开始工作在应用程序上。这个库的一个独特特性是它对用于数据获取的工具是中立的。你只需要提供一个返回数据的承诺函数。让我们使用 Fetch API 创建这样一个函数:

const userFetcher = (username: string) =>
  fetch("https://api.github.com/users/sakhnyuk")
  .then((response) => response.json()); 

现在,让我们看看我们的 App 组件变得多么简单:

function App() {
  const {
    data: user,
    isPending,
    isError,
  } = useQuery({
    queryKey: ["githubUser"],
    queryFn: () => userFetcher("sakhnyuk"),
  });
  return (
    <div>
      {isPending && <p>Loading...</p>}
      {isError && <p>Error fetching data</p>}
      {user && <UserInfo user={user} />}
    </div>
  );
} 

现在,所有制作请求和处理加载和错误状态的逻辑都包含在一个单一的 useQuery 钩子中。

在下一节中,我们将探索一个更强大的用于数据获取的工具,即 GraphQL。

使用 GraphQL

在本章早期,我们讨论了 GraphQL 是什么以及它如何允许我们指定从服务器获取的确切数据,从而减少传输的数据量并加快数据获取速度。

在这个例子中,我们将探索与 @apollo/client 库结合使用的 GraphQL,该库提供了与 React Query 类似的功能,但与 GraphQL 查询一起工作。

首先,让我们使用以下命令安装必要的依赖项:

npm install @apollo/client graphql 

接下来,我们需要在我们的应用程序中添加一个提供者:

const client = new ApolloClient({
  uri: "https://api.github.com/graphql",
  cache: new InMemoryCache(),
  headers: {
    Authorization: 'Bearer YOUR_PAT', // Put your GitHub personal access token here
  },
});
ReactDOM.createRoot(document.getElementById("root")!).render(
  <ApolloProvider client={client}>
    <App />
  </ApolloProvider>
); 

在这个阶段,在客户端设置过程中,我们指定我们想要与之工作的服务器 URL、缓存设置和身份验证。在早期示例中,我们使用了公共 GitHub API,但 GitHub 也支持 GraphQL。为此,我们需要提供一个 GitHub 个人访问令牌,您可以在您的 GitHub 个人资料设置中获取。

对于我们的示例,为了演示我们如何仅选择所需的字段,让我们缩减用户数据。组件中的 GraphQL 查询将如下所示:

const GET_GITHUB_USER = gql'
  query GetGithubUser($username: String!) {
    user(login: $username) {
      login
      id
      avatarUrl
      bio
      name
      company
      location
    }
  }
'; 

现在一切准备就绪,让我们看看 App 组件将是什么样子:

function App() {
  const { data, loading, error } = useQuery(GET_GITHUB_USER, {
    variables: { username: "sakhnyuk" },
  });  
  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error fetching data</p>;
  const user = data.user;
  return (
    <div>
      <UserInfo user={user} />
    </div>
  );
} 

与 React Query 类似,我们有访问加载状态、错误和实际数据的能力。当我们打开应用程序时,我们将看到结果:

图片 B19636_11_03

图 11.3:通过 GraphQL 请求的 GitHub 用户

为了确保服务器返回我们请求的确切数据,我们可以打开 Chrome 开发者工具,转到 网络 选项卡,并检查我们的请求:

图片 B19636_11_04

图 11.4:GraphQL 请求

图 11.4 所示,服务器发送给我们我们在查询中指定的精确数据。你可以通过实验查询参数来查看差异。

摘要

在本章中,我们探讨了如何从服务器获取数据。我们首先简要回顾了客户端-服务器通信的历史,并强调了与服务器交互的主要方法。接下来,我们构建了一个应用程序,使用 Fetch API、Axios、TanStack Query 和 Apollo GraphQL 来检索 GitHub 用户数据。

本章中你学到的技术将使你能够显著扩展你自己的 Web 应用程序的功能。通过从服务器高效地获取数据,你可以为用户创建动态、数据驱动的体验。无论你是构建一个显示实时流的社交媒体应用程序,一个提供最新产品信息的电子商务网站,还是一个可视化实时数据的仪表板,你获得的技术将证明是无价的。

在下一章中,我们将深入探讨使用状态管理库来管理应用程序状态。

第十二章:React 中的状态管理

在前面的章节中,我们探讨了 React 中的状态概念,并掌握了使用useState钩子与之交互的基础。现在,我们需要深入探讨应用的全局状态管理。在本章中,我们将关注全局状态:我们将定义它是什么,它的关键优势以及有效管理的策略。

本章将涵盖以下主题:

  • 什么是全局状态?

  • React 上下文 API 和 useReducer

  • Redux

  • Mobx

什么是全局状态?

在开发 React 应用程序时,需要特别注意的一个关键方面是状态管理。我们已经熟悉了useState钩子,它允许我们在组件内部创建和管理状态。这种类型的状态通常被称为局部,它在一个组件内部非常有效,简单且易于使用。

为了更清晰地说明,考虑一个具有小型表单组件的例子,其中我们有两个输入元素,并为每个输入创建了两个状态:

图 12.1:带有局部状态的表单组件

在这个例子中,一切都很简单:用户在input中输入一些内容,这会触发一个onChange事件,我们通常在这里改变我们的state,导致表单的全局重新渲染,然后我们在屏幕上看到输入的结果。

然而,随着应用程序的复杂性和规模的增加,不可避免地需要一种更可扩展和灵活的状态管理方法。让我们进一步考虑我们的例子,并想象在填写表单信息后,我们需要向服务器发送用户授权请求并获取会话键。然后,使用这个键,我们需要请求用户数据:姓名、姓氏和头像。

在这里,我们立即遇到了困难:会话键和用户数据应该存储在哪里?也许我们可以在表单内部直接检索数据,然后将其传递给父组件,因为它是更全局的并且负责。好吧,让我们来展示这一点并看看:

图 12.2:带有表单组件的登录页面

因此,现在我们有一个登录页面,其中我们为会话用户对象创建了局部状态。使用 props,我们可以将像onSessionChangeonUserChange这样的函数传递给表单组件,这最终使我们能够将数据从表单传输到登录页面。此外,在表单中,我们现在有getSessionKeygetUser这样的函数。这些方法与服务器交互,在成功响应后,它们不会在本地存储数据,而是调用上述的onSessionChangeonUserChange

有些人可能会认为数据存储问题已经解决,但很可能在用户授权并获得他们的数据后,我们需要将用户重定向到我们应用程序的某个主页。我们可能再次重复将数据提升到更高层次的小把戏,但在这样做之前,让我们提前思考并想象获取用户数据可能不仅仅是授权表单的工作,这种功能可能在其他页面上也需要。

最终,我们理解到,除了数据本身之外,我们还需要将处理数据的逻辑保持在组件树的上层:

图片

图 12.3:应用程序根组件

这张图片清楚地展示了当我们需要将所有必要的数据和方法从应用程序的最顶层组件传递到所有页面和组件时,应用程序如何变得更加复杂。

除了实现和维护这种组织应用程序状态的方法的复杂性之外,还存在一个重大的性能问题。例如,在根组件中通过useState创建的状态,每次我们更新它时,整个应用程序都会重新渲染,因为应用程序的根组件将被重新绘制。

因此,我们已经识别出在大型应用程序的组件中组织本地状态的主要问题:

  • 组件树过于复杂,所有重要数据都必须通过 props 从上到下传递。这紧密耦合了组件,使代码及其维护变得复杂。

  • 性能问题,当应用程序不需要时,可能会不必要地重新渲染。

看到最后一张图,人们可能会想是否可以切断我们组件的连接,并将所有数据和逻辑提取到组件之外。这就是全局状态概念发挥作用的地方。

全局状态是一种数据管理方法,它允许状态在应用程序的不同层次和组件之间可访问和可修改。这种解决方案克服了本地状态的局限性,促进了组件之间的数据交换,并提高了大规模项目中的状态可管理性。

为了清楚地了解全局状态在我们例子中的样子,请看下面的图片:

图片

图 12.4:应用程序根组件和全局状态

在这个例子中,我们有一个位于组件和整个树之外的全局状态。只有那些实际上需要从状态中获取数据的组件可以直接访问它并订阅其变化。

通过实现全局状态,我们可以一次解决两个问题:

  • 简化了组件树和依赖关系,从而扩展并支持了应用程序。

  • 提高了应用程序的性能,因为现在,只有订阅了全局状态数据的组件在状态变化时才会重新渲染。

然而,重要的是要理解,局部状态仍然是一个非常强大的工具,不应该为了全局状态而放弃。我们只有在状态需要在应用组件的不同层级之间使用时才能获得优势。否则,如果我们开始将所有变量和状态转移到全局状态,我们只会使应用复杂化而不会获得任何好处。

既然我们知道全局状态仅仅是组织数据的一种方式,我们该如何管理全局状态呢?状态管理器 是一个帮助组织和管理应用状态的工具,尤其是在处理复杂交互和大量数据时。它为应用的所有状态提供了一个集中式存储库,并以有序和可预测的方式管理其更新。在实践中,状态管理器通常以 npm 包的形式表示,作为项目依赖项安装。然而,也可以使用 React 的 API 独立管理全局状态,而不使用任何库。我们将在稍后探讨这种方法。

React Context API 和 useReducer

要自己组织全局状态,你可以使用 React 生态系统中的现有工具,即 Context APIuseReducer。它们是一对强大的状态管理工具,尤其是在使用第三方状态管理器显得过多的情况下。这些工具非常适合在更紧凑的应用中创建和管理全局状态。

React Context API 的设计是为了在组件树中传递数据,而不需要在每个层级传递 props。这简化了深层嵌套组件中数据的访问,并减少了 prop 传递(通过多个层级传递 props),如图 12.4 所示。React Context API 对于像主题设置、语言偏好或用户信息这样的数据尤其有用。

这里是一个如何使用上下文来存储主题设置的示例:

const ThemeContext = createContext();
const ThemeProvider = ({ children }) => {
  const theme = 'dark';
  return (
    <ThemeContext.Provider value={theme}>
      {children}
    </ThemeContext.Provider>
  );
};
const useTheme = () => useContext(ThemeContext);
export { ThemeProvider, useTheme }; 

在这个例子中,我们使用 createContext 函数创建了 ThemeContext。然后,我们创建了一个 ThemeProvider 组件,它应该包裹应用的根组件。这将允许使用 useTheme 钩子在任何嵌套组件的层级上访问,该钩子是用 useContext 钩子创建的:

const MyComponent = () => {
  const theme = useTheme();
  return (
    <div>
      <p>Current theme: {theme}</p>
    </div>
  );
}; 

在组件树的任何层级上,我们都可以使用 useTheme 钩子访问当前的主题。

接下来,让我们看看这对中的一员,即那个将帮助我们构建全局状态的特殊钩子。useReducer 是一个钩子,允许你使用还原器(reducer)来管理复杂的状态:还原器是接受当前状态和动作,然后返回新状态的函数。useReducer 对于需要复杂逻辑或多个子状态的状态管理来说非常理想。让我们考虑一个使用 useReducer 的简单计数器示例:

import React, { useReducer } from 'react';
const initialState = { count: 0 };
function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    default:
      throw new Error();
  }
}
function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
    </>
  );
} 

在这个例子中,实现了一个具有两个动作的还原器:增加减少 计数器。

Context API 和useReducer的组合为创建和管理应用程序的全局状态提供了一个强大的机制。这种方法对于小型应用程序来说很方便,因为现成的和更大的状态管理解决方案可能显得多余。然而,也值得注意,这种解决方案并没有完全解决性能问题,因为在useTheme示例中的主题更改或计数器示例中的计数器更改都会导致提供者,进而导致整个组件树重新渲染。这可以通过额外的逻辑和编码来避免。

因此,更复杂的应用程序需要更强大的工具。为此,有几个现成的和流行的解决方案用于处理状态,每个解决方案都有其独特的功能和适用于不同的用例。

Redux

这些工具中的第一个当然是Redux。它是管理复杂 JavaScript 应用程序状态最受欢迎的工具之一,尤其是在与 React 一起使用时。Redux 通过维护单个全局对象中的应用程序状态来提供可预测的状态管理,简化了更改跟踪和数据管理。

Redux 基于三个核心原则:单一事实来源(一个全局状态)、状态是只读的(不可变)和更改是通过纯函数(还原器)进行的。这些原则确保了有序和受控的数据流。

function counterReducer(state = { count: 0 }, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 };
    case 'DECREMENT':
      return { count: state.count - 1 };
    default:
      return state;
  }
}
const store = createStore(counterReducer);
store.subscribe(() => console.log(store.getState()));
store.dispatch({ type: 'INCREMENT' });
store.dispatch({ type: 'DECREMENT' }); 

在这个例子中,应用程序的状态是从计数器示例实现的。我们有一个counterReducer,它是一个常规函数,它接受当前状态和要对其执行的操作。还原器始终返回一个新的状态。

在 Redux 世界中实现异步操作是一个复杂的问题,因为默认情况下它只提供了中间件,这是第三方解决方案使用的。其中一个解决方案是redux-thunk

redux-thunk是一个中间件,允许你调用返回函数而不是动作对象的动作创建函数。这提供了通过异步请求延迟动作分发或分发多个动作的能力。

function fetchUserData() {
  return (dispatch) => {
    dispatch({ type: 'LOADING_USER_DATA' });
    fetch('/api/user')
      .then((response) => response.json())
      .then((data) => dispatch({ type: 'FETCH_USER_DATA_SUCCESS', payload: data }))
      .catch((error) => dispatch({ type: 'FETCH_USER_DATA_ERROR', error }));
  };
}
const store = createStore(reducer, applyMiddleware(thunk));
store.dispatch(fetchUserData()); 

正如你在示例中所见,我们创建了一个函数fetchUserData,它不会立即改变状态。相反,它返回另一个带有dispatch参数的函数。这个dispatch可以根据需要多次使用来改变状态。

还有其他更强大但更复杂的异步操作解决方案。我们在这里不会讨论这些。

Redux 非常适合在应用程序中管理复杂的全局状态。它提供了强大的调试工具,例如时间旅行。由于数据与其处理之间的清晰分离,Redux 还简化了状态和逻辑的测试。

要将 Redux 与 React 集成,使用React-Redux库。它提供了Provider组件,以及useSelectoruseDispatch钩子,这些钩子允许轻松地将 Redux 存储连接到你的 React 应用程序。

function Counter() {
  const count = useSelector((state) => state.count);
  const dispatch = useDispatch();
  return (
    <div>
      <div>Count: {count}</div>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
    </div>
  );
} 

在上面的示例中,Counter组件通过useSelector订阅变化来与 Redux 状态交互。这种订阅更为细致,改变计数器不会导致整个应用的重新渲染,而只会导致调用此钩子的特定组件重新渲染。

然而,需要注意的是 Redux 的缺点。尽管它是最受欢迎的解决方案,但它存在一些重大问题,这些问题影响了我个人对这个解决方案的选择:

  • Redux 的语法较为繁琐。实现一个大型全局状态需要编写大量的样板代码,例如 reducers、actions、selectors 等。

  • 随着项目的增长,维护和扩展 Redux 状态复杂性的增加是不成比例的。

随着项目和全局状态的增长,应用性能显著下降。这发生是因为需要大量计算,即使只是将一个值的状态从false改为true

Redux 不支持开箱即用的异步操作,需要额外的解决方案,这进一步增加了项目理解和维护的复杂性。

将状态和业务逻辑分割成块以实现懒加载需要大量的工作。因此,应用的大小及其初始加载速度受到影响。

尽管存在这些缺点,许多公司和开发者仍然使用这个解决方案,因为它适合大多数业务任务,因此我认为了解这个工具并能够使用它非常重要。

MobX

管理全局状态的下一个流行解决方案是MobX库。这个库与 Redux 有显著的不同,在某些方面甚至可以说是相反的。

MobX 是一个状态管理库,它提供了对数据的反应性和灵活的交互。其主要思想是将应用状态尽可能简化并透明化,通过创建尽可能多次的小对象和类来实现,这些对象和类可以嵌套在一起。

技术上,这个库允许创建不仅是一个全局状态,还可以直接与某些应用功能相关联的多个小对象,这在处理大型应用时提供了显著的优势。要了解一个全局状态和 MobX 状态之间的区别,可以查看以下图表:

图片

图 12.5:MobX 状态

在 MobX 中,应用状态是通过observable方法管理的,该方法自动跟踪变化并通知相关的计算值和反应。这使得应用能够自动根据状态变化更新,简化数据流并增加灵活性。

class Store {
  @observable accessor count = 0;
  @computed get doubleCount() {
    return this.count * 2;
  }
  @action increment() {
    this.count += 1;
  }
  @action decrement() {
    this.count -= 1;
  }
}
const myStore = new Store(); 

在示例中,相同的计数器使用 MobX 实现。在一个类中,既有实际数据,也有计算数据,以及用于改变状态的操作。

谈及异步操作,MobX 在这方面没有任何问题,因为你可以在一个常规类中工作,并添加一个返回 Promise 的新方法。

class Store {
  @observable count = 0;
  @computed get doubleCount() {
    return this.count * 2;
  }
  @action increment() {
    this.count += 1;
  }
  @action decrement() {
    this.count -= 1;
  }
  @action async fetchCountFromServer() {
    const response = await fetch('/count');
    const data = await response.json();
    this.count = data.count;
  }
}
const myStore = new Store(); 

MobX 非常适合需要高性能和简单管理复杂数据依赖的应用程序。它提供了一种优雅直观的方式来处理复杂的状态,使开发者能够专注于业务逻辑而不是状态管理。

这个库的一个缺点是它在组织状态方面提供了相当大的自由度,这可能导致在不熟练的手中遇到困难和可扩展性问题。例如,MobX 允许直接操作对象数据,这可以触发组件更新,但这也可能导致大型项目中的意外状态变化和调试挑战。同样,这种自由度往往导致小的、干净的 MobX 类变得紧密耦合,使得测试和项目开发更具挑战性。

要将 MobX 与 React 集成,使用mobx-react库,它提供了observer函数。这允许 React 组件自动响应观察数据的变化。

import React from 'react';
import { observer } from 'mobx-react';
import myStore from './myStore';
const Counter = observer(() => {
  return (
    <div>
      <div>Count: {myStore.count}</div>
      <div>Double: {myStore.doubleCount}</div>
      <button onClick={() => myStore.increment()}>-</button>
      <button onClick={() => myStore.decrement()}>+</button>
    </div>
  );
}); 

在示例中,使用 MobX 实现了相同的计数器。正如你所见,我们不需要使用 hooks 来访问状态或使用 providers 将其存储在应用程序上下文中。我们只需从文件中导入变量并使用它。从Store类创建的myStore本身就是状态。在组件中使用对象的观察值非常简单,因为组件会立即订阅该值的所有变化,并且每次它变化时都会重新渲染。

只从示例中,你就可以看到 MobX 在管理状态方面的简单和方便。由于它只是一个对象,当需要时可以轻松地懒加载它,当数据不再需要时,可以清除应用程序的缓存和内存。我认为它是状态管理的一个强大工具,并强烈推荐在实际项目中尝试使用它。

摘要

在本章中,我们学习了全局状态及其管理方法。以有限局部状态为例,我们讨论了为什么在应用程序的不同层级需要共享数据时,拥有全局状态很重要。

我们通过使用 React Context API 的示例,确定了何时使用它以及何时更倾向于更强大的状态管理解决方案。接下来,我们探讨了两种这样的解决方案,即 Redux 和 MobX。

在下一章中,我们将讨论服务器端渲染及其对我们应用程序可能带来的好处。

第十三章:服务器端渲染

正如我们在第一章为什么选择 React中讨论的那样,React 库在将我们的组件转换为各种目标格式方面非常灵活。你可能已经猜到了,其中一个目标格式是标准的 HTML 标记,以字符串形式呈现并在服务器上生成。在本章中,我们将深入探讨 React 中服务器端渲染SSR)的工作原理以及它为用户和开发者提供的优势。你将了解为什么这种方法对你的应用程序来说很有价值,以及它是如何增强整体用户体验和性能的。

本章涵盖了以下主题:

  • 在服务器上工作

  • 使用 Next.js

  • React 服务器组件

技术要求

你可以在 GitHub 上找到本章的代码文件,链接为github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter13

在服务器上工作

网络技术已经走了很长的路,或者更准确地说,已经回到了起点。一切始于由服务器准备的静态网页。服务器是所有网站和应用逻辑的基础,因为它们完全负责其功能。然后,我们试图摆脱SSR,转向在浏览器中渲染页面,这导致了网页作为完整应用程序的开发取得了重大飞跃,现在可以与桌面应用程序相媲美。因此,浏览器成为了应用逻辑的核心,而服务器只是为应用程序提供数据。

目前,开发周期已经让我们回到了 SSR 和服务器组件,但现在我们有了服务器和客户端的统一逻辑和代码。为什么会出现这种情况,以及我们在技术演变过程中获得了哪些结论和经验,我们将在本节中尝试理解,同时我们还将了解我们的应用程序在服务器上执行的工作类型。

服务器端渲染

在传统的单页应用程序SPA)方法中,我们完全依赖本地浏览器渲染。我们编写所有代码、样式和标记,专门针对浏览器,在应用程序构建过程中,我们得到静态的 HTML、CSS 和 JavaScript 文件,然后这些文件被加载到浏览器中。

在大多数情况下,初始的 HTML 文件是空的,没有任何内容。在这个文件中唯一重要的事情是连接的 JavaScript 文件,它将渲染我们所需的一切。

下面是一个示意图,说明了单页应用程序(SPA)应用是如何加载和渲染的:

图 13.1:单页应用程序(SPA)应用

这种方法引入了交互性,使得应用程序感觉和功能就像真正的桌面应用程序一样。不再需要每次更新内容、接收通知、新电子邮件或消息时都重新加载页面,因为整个应用程序逻辑直接在浏览器中。随着时间的推移,浏览器应用程序几乎完全取代了桌面应用程序。现在,我们可以在单个浏览器中写电子邮件、处理文档、观看电影以及做更多的事情。许多公司,而不是开发桌面应用程序,开始将他们的项目作为 Web 应用程序来创建。浏览器能够在任何架构和操作系统上运行的能力显著降低了开发成本。

同时,服务器也经历了变化,远离了页面模板、缓存等。后端开发者不再需要关注页面布局,可以更多地投入到更复杂的逻辑和架构中。

然而,单页应用程序(SPA)确实存在一些缺点,包括由于需要下载和处理脚本而导致的长时间初始加载时间。在这个过程中,用户会看到一个空白屏幕或加载指示器。此外,空白的初始 HTML 文件不适合搜索引擎优化,因为搜索引擎将其视为一个空白页面。

在创建在线商店等场景的背景下,普通的 React SPA 可能不适合,因为用户和搜索引擎需要立即看到页面内容。在 SPAs 出现之前,这类任务是由仅在服务器端工作的工具解决的,这些工具始终准备内容。在 React 中,解决这个问题更复杂,因为我们知道 React 在浏览器端工作。

解决方案的第一步显然是使用 React 在服务器上渲染页面内容。这不会是问题。自从其发布以来,React 就提供了用于此目的的renderToString函数,该函数可以在Node.js 服务器环境中调用。此函数返回一个 HTML 字符串,当发送到浏览器时,允许内容在用户的屏幕上渲染。

让我们看看使用renderToString函数的 SSR 会如何工作:

图片

图 13.2:使用 renderToString 进行服务器渲染

在这个例子中,当在浏览器中请求页面时,服务器通过调用renderToString函数并将它传递给 React 组件树,输出 HTML。通过将这个 HTML 字符串作为对浏览器请求的响应发送,浏览器渲染结果。

然而,在这样的例子中,服务器上生成的并在浏览器中渲染的 HTML 缺乏交互性和客户端应用程序的功能。对于像按钮、导航以及我们在单页应用程序(SPAs)中习惯的所有功能,都需要 JavaScript。因此,在实现服务器渲染的交互式网站或应用程序的下一步中,不仅要传输 HTML,还要传输 JavaScript,这将提供我们需要的所有交互性。

为了解决这个问题,引入了同构 JavaScript的方法。以这种风格编写的代码可以先在服务器上执行,然后再在客户端执行。这允许你在服务器上准备初始渲染,并将准备好的 HTML 以及 JavaScript 包发送到客户端,然后允许浏览器提供交互性。这种方法加快了应用的初始加载速度,同时保持其功能,并允许搜索引擎在搜索结果中索引页面。

当用户打开一个页面时,他们立即看到服务器上执行的渲染结果,甚至在 JavaScript 加载之前。这种快速的初始响应显著提高了用户体验。页面和 JS 包加载后,浏览器对页面进行激活至关重要,正如我们从renderToString示例中所知,我们所有的元素都缺乏交互性。为此,脚本需要将所有必要的事件监听器附加到元素上。这个过程被称为激活,与从头开始的全页渲染相比,这是一个更轻更快的过程。

交互性的另一个重要特性是能够瞬间或平滑地导航到应用中的下一个页面,而无需重新加载浏览器页面。通过同构 JavaScript,这成为了可能,因为只需要加载下一个页面的 JavaScript 代码,然后应用就可以在本地渲染下一个页面。

图 13.3:SSR

上图以示意图的形式展示了 SSR 方法,其中应用是完全交互式的。最初,当请求一个页面时,服务器渲染内容并返回带有附加 JavaScript 包的 HTML。然后,浏览器加载 JS 文件并使页面上先前显示的所有内容生效。这种方法就是现在所知的 SSR。它已经在 React 开发者中得到了广泛应用,并在现代网络技术中找到了其位置。SSR 结合了页面内容的快速加载和服务器渲染的高性能,以及客户端应用的灵活性和交互性。

静态站点和增量静态生成

虽然 SSR 代表了一个重大的改进,但它并不是万能的解决方案,有其缺点,包括需要为每个请求从头生成一个页面。例如,没有动态内容的页面每次都必须在服务器上生成,这可能会使用户的显示延迟。此外,即使是简单的应用或网站,SSR 也需要一个 Node.js 服务器进行渲染,与 SPAs 不同,在 SPAs 中,只需要使用内容分发网络CDN)将应用文件放置得更靠近用户,从而加快加载速度。

解决这些问题的方案在于静态站点生成SSG)方法。SSG 的逻辑是在项目构建过程中在服务器上渲染所有静态页面。因此,我们得到许多准备就绪的 HTML 页面,可以在请求时立即交付。与 SSR 类似,在 SSG 中,JavaScript 包在页面加载后进行激活,使其具有交互性。最终,我们获得与单页应用(SPAs)相同但不是空 HTML 文件的经验:而是充满内容以便快速渲染。SSG 项目可以托管在快速 Web 服务器或 CDNs 上,这也允许进行额外的缓存,并加快此类应用程序的加载时间。

SSG 成为网站、博客和简单在线商店的理想解决方案,确保快速页面加载时间,不阻塞请求,支持 SEO,并且与 SPAs 具有相同的交互性。此外,现在可以将 SSR 用于动态数据和 SSG 用于静态页面结合起来。这种混合方法为实施更复杂的项目开辟了新的可能性,结合了两种方法的优势。它允许开发者通过选择最佳的渲染方法来优化性能和用户体验,具体取决于网站或应用的每一页的具体要求。

开发者和公司面临的一个问题是更新静态生成的页面。例如,传统上,添加新的博客文章或更新在线商店的库存需要完全重建项目,这可能既耗时又麻烦,尤其是在大型项目中。想象一下,一个有 1,000 篇文章的博客因为添加了一篇新文章而不得不完全重建和重新渲染。

这个问题通过一种称为增量静态生成ISR)的方法得到解决。ISR 结合了 SSG 和 SSR 的原则以及缓存功能。为了理解这种方法,想象我们在构建阶段生成的所有 HTML 和 JS 文件只是一个缓存,代表项目构建的当前结果。与任何缓存一样,我们现在需要引入其重新验证的逻辑。只要我们的缓存有效,所有页面请求都像以前一样使用 SSG 方法工作。但是,当重新验证时间到期时,下一个页面请求将启动在 SSR 模式下在服务器上的重新渲染。生成的输出被发送到客户端,并同时用新的 HTML 文件替换旧的 HTML 文件,即更新缓存。然后应用程序继续在 SSG 模式下运行。

多亏了增量静态生成ISR),现在可以实施包含数百万页面的大规模项目,这些页面不需要为小更新而不断重建。还可能完全跳过构建阶段的页面生成,因为所需的页面将在请求时进行渲染和保存。对于大型项目,这提供了项目构建速度的显著提升。

目前,结合 ISR 的 SSG 和传统的 SSR 是实施简单网站、博客以及复杂应用程序中最受欢迎的方法之一。然而,传统的 SPA 仍然是一个非常受欢迎的解决方案。但如果我们知道如何创建和组装 SPA,那么我们刚才讨论的所有其他内容又该如何呢?针对这个问题,重要的是要注意,你不需要手动开发所有这些方法。有几个基于 React 的框架提供了上述所有功能:

  • Next.js:这个框架以其灵活性和强大的功能而闻名。Next.js 最初是 SSR,但现在支持 SSR 和 SSG,包括 ISR 支持。最近,Next.js 一直在深入研究一个新的概念,即使用服务器组件实现应用程序,我们将在本章末尾讨论这一点。

  • Gatsby:Gatsby 的主要区别在于它对使用来自各种来源(如CMSMarkdown)的数据生成静态站点的强烈关注。尽管与 Next.js 的差异没有以前那么大,但它仍然是一个相当受欢迎的解决方案。

  • Remix:这是一个相对较新的框架,专注于与 Web 标准的更紧密集成和提升用户体验。Remix 提供了独特的数据处理和路由方法,我们可以按页面部分而不是按页面工作,通过仅更改和缓存需要动态内容的页面部分来实现嵌套导航。

所有这些框架共同提供了我们讨论过的方法的相似体验和实现。接下来,我们将探讨如何使用 Next.js 实现 SSR 和静态生成。

使用 Next.js

在熟悉了 SSR 的理论之后,让我们看看如何使用Next.js框架在实践上实现所有这些。

Next.js 是一个流行的基于 React 的框架,专门设计用来简化 SSR 和静态站点生成的过程。它提供了创建高性能 Web 应用程序的强大和灵活的功能。

Next.js 的功能:

  • 一个易于使用的 API,自动实现 SSR 和静态生成:你只需要使用提供的方法和函数编写代码,框架将自动确定哪些页面应该在服务器端渲染,哪些可以在项目构建过程中渲染。

  • 基于文件的路由:Next.js 使用基于项目文件夹和文件结构的简单直观的路由系统。这大大简化了应用程序中路由的创建和管理。

  • 通过 API 路由实现创建全面的全栈应用程序的能力,这些 API 路由允许你实现服务器端 REST API 端点。

  • 图像、字体和脚本的优化,提高项目的性能。

框架的另一个重要特性是与 React Core 团队紧密合作以实现新的 React 特性。因此,Next.js 目前支持两种应用程序实现类型,称为Pages RouterApp Router。前者实现了我们之前讨论的主要功能,而后者是一种为与 React Server Components 一起工作而设计的新方法。我们将在本章的后面部分检查这种方法,但现在,让我们从 Pages Router 开始。

要开始使用 Next.js,你只需要执行一个命令,这个命令会为你设置一切:

npx create-next-app@latest 

这个 CLI 命令会问你几个问题:

✔ What is your project named? … using-nextjs
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes
✔ What import alias would you like configured? … @/* No / Yes 

对于我们当前的示例,你应该对所有问题回答“是”,除了关于使用 App Router 的问题。此外,你可以访问提供的链接中我们将进一步讨论的现成示例:github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter13/using-nextjs

在示例中,我们将创建一个包含多个页面的小型网站,每个页面使用不同的服务器渲染方法。在 Next.js 中,网站的每一页都应该放置在具有与 URL 路径对应的名称的单独文件中。在我们的项目示例中:

  • 网站的主页,可通过根路径domain.com/访问,将位于pages文件夹中的index.tsx文件。为了理解以下示例,主页面文件的路径将是pages/index.tsx

  • /about页面将位于pages/about.tsx文件中。

  • 接下来,我们将在路径pages/posts/index.tsx创建一个/posts页面。

  • 每个单独的帖子页面将位于一个使用路径pages/posts/[post].tsx的文件中。带有方括号名称的文件指示 Next.js 这将是一个动态页面,其中帖子变量作为参数。这意味着像/posts/1/posts/2这样的页面将使用此文件作为页面组件。

  • 这就是文件路由的工作方式。项目的主要目录是pages文件夹,我们可以嵌套文件,这些文件将用于根据文件和文件夹的结构和名称生成网站页面。

pages文件夹中,还有两个服务文件,它们不是实际的页面,但被框架用于准备页面:

  • _document.tsx文件对于准备 HTML 标记是必要的。在这个文件中,我们可以访问<html><body>标签。这个文件始终在服务器上渲染。

  • _app.tsx文件用于初始化页面。你可以使用这个组件来连接脚本或用于在路由之间重复使用的页面的根布局。

让我们在App组件中给我们的网站添加一个标题。下面是_app.tsx文件的样子:

const inter = Inter({ subsets: ["latin"] });
export default function App({ Component, pageProps }: AppProps) {
  return (
    <div className={inter.className}>
      <header className="p-4 flex items-center gap-4">
        <Link href="/">Home</Link>
        <Link href="/posts">Posts</Link>
        <Link href="/about">About</Link>
      </header>
      <div className="p-4">
        <Component {...pageProps} />
      </div>
    </div>
  );
} 

App组件返回的标记将被用于我们项目的每个页面,这意味着我们将在任何页面上看到这个标题。此外,我们还可以使用组件控制,其中将放置项目的其余动态部分。

现在,让我们看看我们项目的首页将是什么样子:

图 13.4:主页

图 13.4:主页

在这个页面上,我们可以看到带有链接和标题的网站标题,这些内容是从pages/index.tsx文件中取出的:

export default function Home() {
  return (
    <main>
      <h1>Home Page</h1>
    </main>
  );
} 

pages/index.tsx文件只导出一个包含标题的组件。重要的是要注意,这个页面没有其他函数或参数,将在项目构建过程中自动渲染。这意味着当我们访问这个页面时,我们会得到浏览器可以立即渲染的预制的 HTML。

通过访问localhost:3000/,我们可以确认我们收到了准备好的标记。为此,我们只需要打开浏览器开发者工具,检查这个请求返回的内容。

图 13.5:About 组件

图 13.5:Chrome DevTools 中的主页响应

我们可以看到 Next.js 如何从AppHome组件中提取内容,并从它组装 HTML。所有这些都是在服务器端完成的,而不是在浏览器中。

接下来,让我们看看/about页面。在这个页面上,我们将实现 SSR,这意味着页面不是在构建过程中生成 HTML,而是在每次请求时渲染。为此,Next.js 提供了getServerSideProps函数,它在页面请求时运行,并返回组件用于渲染的 props。

对于我们的示例,我从第十一章从服务器获取数据中取了一些逻辑,其中我们从 GitHub 获取了用户数据。让我们看看about.tsx文件将是什么样子:

export const getServerSideProps = (async () => {
  const res = await fetch("https://api.github.com/users/sakhnyuk");
  const user: GitHubUser = await res.json();
  return { props: { user } };
}) satisfies GetServerSideProps<{ user: GitHubUser }>; 

getServerSideProps函数中,我们使用Fetch API请求用户数据。我们接收到的数据存储在user变量中,然后作为props对象返回。

重要的是要理解这个函数是 Node.js 环境的一部分,在那里我们可以使用服务器端 API。这意味着我们可以读取文件、访问数据库等。这为实施复杂的全栈项目提供了显著的能力。

接下来,在同一个about.tsx文件中,我们有About组件:

export default function About({
  user,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
  return (
    <main>
      <Image src={user.avatar_url} alt={user.login} width="100" height="100" />
      <h2>{user.name || user.login}</h2>
      <p>{user.bio}</p>
      <p>Location: {user.location || "Not specified"}</p>
      <p>Company: {user.company || "Not specified"}</p>
      <p>Followers: {user.followers}</p>
      <p>Following: {user.following}</p>
      <p>Public Repos: {user.public_repos}</p>
    </main>
  );
} 

About组件中,我们使用从getServerSideProps函数返回的user变量来创建页面的标记。仅通过这个一个函数,我们就实现了 SSR(服务器端渲染)。

接下来,让我们创建/posts/posts/[post]页面,在这些页面中我们将实现 SSG(静态生成)和 ISR(增量静态化)。为此,Next.js 提供了两个函数:getStaticPropsgetStaticPaths

  • getStaticProps:这个函数与getServerSideProps具有类似的作用,但在项目构建过程中被调用。

  • getStaticPaths:这个函数用于动态页面,其中路径包含参数(如[post].tsx)。这个函数确定在构建过程中应该预生成哪些路径。

让我们看看 Posts 页面组件是如何实现的:

export async function getStaticProps() {
  const posts = ["1", "2", "3"];
  return {
    props: {
      posts,
    },
  };
}
export default function Posts({ posts }: { posts: string[] }) {
  return (
    <main>
      <h1>Posts</h1>
      <ul>
        {posts.map((post) => (
          <li key={post}>
            <Link href={`/posts/${post}`}>Post {post}</Link>
          </li>
        ))}
      </ul>
    </main>
  );
} 

在这个例子中,getStaticProps 函数没有请求任何数据,只是简单地返回三个页面。然而,就像在 getServerSideProps 中一样,您可以使用 getStaticProps 来获取数据或与文件系统交互。然后,Posts 组件接收帖子作为 props 并使用它们来显示帖子链接列表。

下面是 Posts 页面的外观:

图 13.6:帖子页面

当打开任何帖子时,来自 [post].tsx 文件的组件将被加载。以下是它的外观:

export const getStaticPaths = (async () => {
  return {
    paths: [
      {
        params: {
          post: "1",
        },
      },
      {
        params: {
          post: "2",
        },
      },
      {
        params: {
          post: "3",
        },
      },
    ],
    fallback: true,
  };
}) satisfies GetStaticPaths; 

此函数通知构建器在构建过程中只需要渲染三个页面。在此函数中,我们还可以进行网络请求。我们返回的 "fallback" 参数表明,理论上可能存在比我们返回的更多帖子页面。例如,如果我们访问 /posts/4 页面,它将以 SSR 模式渲染并保存为构建结果:

Export const getStaticProps = (async (context) => {
  const content = `This is a dynamic route example. The value of the post parameter is ${context.params?.post}.`;
  return { props: { content }, revalidate: 3600 };
}) satisfies GetStaticProps<{
  content: string;
}>; 

getStaticProps 函数中,我们现在可以从 context 参数中读取页面参数。我们从函数中返回的 revalidate 值启用了 ISR,并告诉服务器在从上次构建后的 3600 秒后,在下一个请求中重建此页面。以下是 Post 页面的外观:

export default function Post({
  content,
}: InferGetStaticPropsType<typeof getStaticProps>) {
  const router = useRouter();
  return (
    <main>
      <h1>Post – {router.query.post}</h1>
      <p>{content}</p>
    </main>
  );
} 

当我们通过链接打开任何帖子时,我们将看到以下内容:

图 13.7:帖子页面

在这个例子中,我们创建了一个网站,其中页面使用不同的服务器端渲染方法,这对于构建大型和复杂的项目非常有用且方便。然而,Next.js 的功能远不止于此。接下来,我们将探讨使用 App Router 构建网站的新方法。

React 服务器组件

React 服务器组件代表了在 Next.js 中处理组件的新范式,它消除了同构 JavaScript。此类组件的代码仅在服务器上运行,并且可以作为结果进行缓存。在这个概念中,您可以直接从组件中读取服务器的文件系统或访问数据库。

在 Next.js 中,React 服务器组件允许您将组件分为两种类型:服务器端客户端。服务器端组件在服务器上处理,并以静态 HTML 的形式发送到客户端,从而减少浏览器的负载。客户端组件仍然具有浏览器 JavaScript 的所有功能,但有一个要求:您需要在文件开头使用 use client 指令。

要在 Next.js 中使用服务器端组件,您需要创建一个新的项目。对于路由,您仍然使用文件,但现在,项目的主要文件夹是 app 文件夹,并且路由名称仅基于文件夹名称。在每一个路由(文件夹)内部,应该有框架指定的文件。以下是一些关键文件:

  • page.tsx: 此文件及其组件将用于显示页面。

  • loading.tsx:这个文件的组件将在page.tsx文件中的组件执行和加载时作为加载状态发送到客户端。

  • layout.tsx:这相当于_app.tsx文件,但在这个情况下,我们可以有多个布局,它们可以在嵌套路由中相互嵌套。

  • route.tsx:这个文件用于实现 API 端点。

现在,让我们使用基于App Router的新架构重构我们的带有帖子的网站。让我们从主页开始。由于我们的网站没有交互元素,我建议添加一个。让我们创建一个最简单的带有计数器的按钮并将其放置在主页上。下面是这个按钮的代码:

"use client";
import React from "react";
export const Counter = () => {
  const [count, setCount] = React.useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}; 

这个组件渲染了一个带有计数器的按钮。通过点击按钮,我们更新计数器。为了让这个组件与 App Router 一起工作,我们需要添加“use client"指令,这告诉 Next.js 在请求时将这个组件的代码包含在包中并发送到浏览器。

现在,让我们把这个按钮添加到主页上,下面是这个按钮的代码示例:

export default function Home() {
  return (
    <main>
      <h1>Home Page</h1>
      <Counter />
    </main>
  );
} 

由于页面很简单,它与我们之前在 Pages Router 中看到的不同之处仅在于新按钮。尽管如此,默认情况下,App Router 将所有组件视为服务器端组件,在这种情况下,页面将在构建过程中渲染并保存为静态页面。

现在,让我们继续到“关于”页面。为了创建这个页面,我们需要创建一个名为about的文件夹,并在其中创建一个名为page.tsx的文件,我们将在这里放置组件。下面是这个文件的代码:

export const dynamic = "force-dynamic";
export default async function About() {
  const res = await fetch("https://api.github.com/users/sakhnyuk");
  const user: GitHubUser = await res.json();
  return (
    <main>
      <Image src={user.avatar_url} alt={user.login} width="100" height="100" />
      <h2>{user.name || user.login}</h2>
      <p>{user.bio}</p>
      <p>Location: {user.location || "Not specified"}</p>
      <p>Company: {user.company || "Not specified"}</p>
      <p>Followers: {user.followers}</p>
      <p>Following: {user.following}</p>
      <p>Public Repos: {user.public_repos}</p>
    </main>
  );
} 

如您所见,与使用 Pages Router 相比,这个页面的代码变得更加简单。About组件已经变为异步的,这允许我们进行网络请求并等待结果。由于在我们的例子中,我们想要使用 SSR 并在每个请求时在服务器上渲染页面,我们需要从文件中导出带有force-dynamic值的“dynamic”变量。这个参数明确告诉 Next.js 我们希望为每个请求生成一个新的页面。否则,Next.js 会在项目构建期间生成页面并将结果保存为静态页面(通过使用 SSG)。

然而,如果 App Router 只是重复之前的功能而不提供任何新功能,那就很奇怪了。如果我们创建一个位于about文件夹中的loading.tsx文件,当打开“关于”页面时,它将立即使用来自loading文件的内容作为后备来提供页面,而不是等待服务器从 GitHub 请求信息并准备页面。一旦page.tsx文件中的组件准备好,服务器就会将其发送到客户端以替换loading组件。这提供了显著的性能优势并改善了用户体验。

现在,让我们继续到“帖子”页面。在它里面创建一个posts文件夹和一个page.tsx文件。下面是更新后的/posts页面的代码示例:

export default async function Posts() {
  const posts = ["1", "2", "3"];
  return (
    <main>
      <h1>Posts</h1>
      <ul>
        {posts.map((post) => (
          <li key={post}>
            <Link href={`/posts/${post}`}>Post {post}</Link>
          </li>
        ))}
      </ul>
    </main>
  );
} 

再次强调,代码已经变得非常简洁。在渲染页面之前,我们需要获取的所有内容都可以直接在组件内部获取和创建。在我们的例子中,我们硬编码了三个将被渲染为链接的页面。

要实现一个“帖子”页面,在posts文件夹内,你需要创建一个名为 [post] 的文件夹,并在其中创建 page.tsx 文件。以下是代码,现在它更加简洁和易读:

export async function generateStaticParams() {
  return [{ post: "1" }, { post: "2" }, { post: "3" }];
} 

我们不是使用 getStaticPaths,而是通过 generateStaticParams 函数向 Next.js 提供有关在项目构建期间生成静态页面的列表信息。然后,我们使用组件内部的 props 来显示页面内容:

export const revalidate = 3600
export default async function Post({ params }: { params: { post: string } }) {
  return (
    <main>
      <h1>Post - {params.post}</h1>
      <p>
        This is a dynamic route example. The value of the post parameter is
        {params.post}.
      </p>
    </main>
  );
} 

内容基本保持不变。要激活 ISR,我们只需要从包含重新验证值的文件中导出 revalidate 变量。

在这个例子中,我们介绍了使用 React Server Components 和 Next.js 的 App Router 构建应用程序的基本方法。本章提供的 Page Router 和 App Router 示例并没有涵盖 Next.js 的所有可能性。为了更深入地了解这个框架,我建议查看其网站上的优秀文档:nextjs.org/docs

摘要

在本章中,我们探讨了在 React 应用程序上下文中使用 SSR。我们讨论了如 SSR、SSG 和 ISR 等方法,学习了每种方法的优缺点。

然后,我们学习了如何在 Next.js 和 Pages Router 的应用中应用这些方法。最后,我们介绍了一种名为 React Server Components 的新技术,以及 Next.js 的更新版架构,称为 App Router。

在下一章中,我们将学习如何测试我们的组件和应用。

第十四章:React 的单元测试

尽管测试是软件开发过程的一个组成部分,但在现实中,开发人员和公司往往对它投入的关注出奇地少,尤其是对自动化测试。在本章中,我们将试图了解为什么关注测试很重要以及它带来的优势。我们还将探讨 ReactJS 中单元测试的基础,包括一般测试理论、工具和方法,以及测试 ReactJS 组件的特定方面。

在本章中,我们将涵盖以下主题:

  • 一般测试

  • 单元测试

  • 测试 ReactJS

技术要求

您可以在 GitHub 上找到本章的代码文件,地址为 github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter14

一般测试

软件测试是一个旨在识别错误和验证产品功能的过程,以确保其质量。测试还允许开发人员和测试人员评估系统在各种条件下的行为,并确保新的更改没有导致回归,即它们没有破坏现有的功能。

测试过程包括一系列执行的动作,旨在检测和识别任何不符合要求或预期的方面。这样的动作的一个例子可能是手动测试,其中开发人员或测试人员手动检查应用。然而,这种方法耗时且几乎不能保证应用在操作上安全且没有关键错误。

为了确保在测试上节省时间的同时提高应用的可靠性,存在自动化测试。它们允许在无需人工干预的情况下验证应用的功能。

自动化测试通常由一系列预定义的测试和一个软件产品组成,通常被称为运行器,它启动这些测试并分析结果以确定每个测试的成功或失败。除此之外,自动化测试还可以用于检查性能、稳定性、安全性、可用性和兼容性,使您能够编写真正稳定、大型和成功的项目。这就是为什么避免测试从来都不是一个好主意;相反,了解它们并尝试在所有可能的项目中使用它们是值得的。

作为开发者,我们显然对自动化测试比对手动测试更感兴趣,因此本章将专注于这一点。但在那之前,让我们简要地看看测试的方法和存在的测试类型。

测试类型和方法

软件测试可以根据各种标准进行分类,包括测试的级别和它追求的目标。

通常,以下类型的测试被区分出来:

  • 单元测试:对程序的单个模块或组件进行正确操作的测试。单元测试通常由开发者编写和执行,以检查特定的函数或方法。这类测试通常编写快速,执行也快,但它们并不测试最终应用程序的临界错误,因为被测试和稳定的组件在相互交互时可能存在问题。一个单元测试的例子是检查单个函数、React 组件或 Hook 的功能。

  • 集成测试:这种测试是在各种模块或系统组件之间检查交互的测试。目标是检测集成组件之间的接口和交互中的缺陷。这类测试通常在服务器端进行,以确保所有系统协同工作,并且业务逻辑符合指定的要求。

    例如,一个集成测试可能是一个检查用户注册是否正常工作的测试,通过向 REST API 端点发出真实调用并检查返回的数据。这种测试对应用程序的实现和代码的依赖性较小,更多地是检查行为和业务逻辑。

  • 端到端(E2E)测试:测试一个完整且集成的软件系统,以确保它符合指定的要求。端到端测试评估整个程序。这种测试是最可靠的,因为它完全抽象了应用程序的实现,并通过直接与应用程序交互来检查最终行为。在测试过程中,例如,在一个网络应用程序中,在一个特殊环境中启动一个真实浏览器,其中脚本执行与应用程序的真实操作,如点击按钮、填写表单和浏览页面。

尽管集成和端到端测试等测试类型在验证应用程序质量方面提供了更大的信心,但它们也伴随着复杂性和测试开发速度、执行速度等缺点,从而增加了成本。因此,被认为是一种良好的实践,在保持平衡的同时,优先考虑单元测试,因为它们更容易维护且运行速度更快。然后,所有主要业务流程和逻辑都通过集成测试进行验证,而端到端测试仅覆盖最关键的业务案例。这种方法可以用金字塔的形式表示:

图片 1

图 14.1:测试金字塔

金字塔完美地描述了我们上面讨论的方法。其底部是单元测试,应该尽可能全面地覆盖应用程序的源代码。它具有最低的开发和维护成本,以及最高的测试执行性能。中间是集成测试,执行速度快,但开发成本较高。在最顶部,我们有端到端测试,执行时间最长,开发成本最高,但它们提供了对正在测试的产品质量的最高信心。

由于集成测试和端到端测试抽象了实现,以及应用程序中使用的编程语言或库,我们不会涉及这些类型的测试。因此,让我们更详细地关注单元测试。

单元测试

我们已经知道,单元测试是验证代码单个“单元”正确性的过程:即,函数方法。单元测试的目标是确保每个单独的单元能够正确执行其任务,这反过来又增加了对整个应用程序可靠性的信心。

export function sum(a: number, b: number): number {
  return a + b;
}
test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3);
}); 

上面的例子代表了添加两个值的最基本和最简单的函数测试。测试代码本身是一个函数,它调用一个特殊的方法,expect,该方法接受一个值,然后有一系列方法允许检查和比较结果。

看到这段代码,可能会产生的第一个问题是,真的有必要为这么简单的三行函数再写三条测试代码吗?为什么要测试这样的函数呢?我会明确地回答:是的。经常会出现这样的情况,一个函数可以被一个比它本身更大的测试覆盖,而这并没有什么问题。让我们来理解一下原因。

单元测试在测试纯函数时最有用和有效,这些函数没有副作用且不依赖于外部状态。相反,当被测试的函数由于外部因素或仅仅是因为函数的设计方式而改变其行为时,单元测试就毫无用处。例如,从服务器请求数据、从localStorage获取数据或依赖于全局变量的函数可能会对相同的输入返回不同的结果。由此我们可以得出结论,在需要通过测试实现代码覆盖率的应用程序开发方法中,你将自动努力编写可测试的代码,这意味着更加模块化、独立、清洁和可扩展的代码。这在大型项目中尤其明显。如果从一开始就编写了测试,这样的项目可以继续增长而无需进行大规模的重构或从头开始重写功能。此外,在带有测试的项目中,新来者更容易理解,因为测试可以作为模块的额外文档,通过阅读测试可以了解模块负责的内容以及它具有的行为。

对于编写单元测试来说,存在一系列的概念和方法。其中最主要和最受欢迎的是在代码开发之后的传统测试覆盖率。这种方法的优点是主要功能开发的速度快,因为测试通常是在之后处理的。因此,这种方法的问题在于延迟测试,这可能导致积累未经过测试的代码。后来,在编写测试时,通常需要修正主要代码,使其更加模块化和清洁,这需要额外的时间。

还有一种直接针对编写测试的方法,称为测试驱动开发TDD)。这是一种软件开发方法,其中测试是在代码本身之前编写的。这种方法的优点是代码将立即被测试覆盖,这意味着代码将更加清洁和可靠。然而,这种方法可能不适合原型设计或需求经常变化的项目。

在 TDD(测试驱动开发)和开发后测试之间的选择取决于许多因素,包括团队文化、项目需求和开发者的偏好。重要的是要理解,这两种方法都不是万能的解决方案,不同的情境下可能会有不同的合理选择。最重要的是,要理解测试的重要性,并且应该避免那种完全不编写测试的工作方法,因为在大多数情况下,这样的代码注定要完全重写。

现在我们已经了解了单元测试及其重要性,让我们更深入地了解一下。在编写测试之前,我们应该设置我们将要运行测试的环境。

设置测试环境

编写和运行单元测试最流行的框架是 Jest。然而,我们将探讨其性能更优的替代方案,它与 Vite 完全兼容,被称为 Vitest。要在你的项目中安装 Vitest,你需要执行以下命令:

npm install -D vitest 

对于基本操作,Vitest 不需要任何配置,因为它与 Vite 配置文件完全兼容。

接下来,为了开始,我们需要创建一个扩展名为 *.test.ts 的文件。文件的位置不是关键;最重要的是文件要在你的项目内部。通常,测试文件与被测试的函数文件相关联,并放置在同一目录下;例如,对于位于 sum.ts 文件中的 sum 函数,会创建一个名为 sum.test.ts 的测试文件,并将其放置在同一文件夹中。

要运行测试,我们需要在 package.json 文件中添加一个启动脚本:

{
  "scripts": {
    "test": "vitest"
  }
} 

然后,要调用它,只需在终端中执行命令:

npm run test 

这个命令将启动 Vitest 进程,它会扫描项目中的 .test 扩展名的文件,然后执行每个这样的文件中的所有测试。一旦所有测试完成,你将在终端窗口中看到结果,然后进程将等待测试文件的变化以重新运行它们。这特别设计为开发测试的模式,其中你不需要不断运行测试命令。对于一次性测试运行,你可以添加另一个命令,在测试完成后关闭进程:

"test:run": "vitest run" 

run 参数正是用来告诉 Vitest 你只想运行一次测试。

Vitest 特性

现在,让我们看看 Vitest 的主要特性和我们可以编写的测试类型。让我们从一个简单的函数 squared 开始:

export const squared = (n: number) => n * n 

这个函数返回一个数字的平方。以下是这个函数的测试示例:

import { expect, test } from 'vitest'
test('Squared', () => {
  expect(squared(2)).toBe(4)
  expect(squared(4)).toBe(16)
  expect(squared(25)).toBe(625)
}) 

testexpect 函数是 Vitest 包的一部分。test 函数将其名称作为第一个参数,将测试函数本身作为第二个参数。expect 方法作为检查被测试函数期望结果的基础。调用 expect 方法会创建一个包含大量方法的对象,允许以不同的方式检查执行结果。在我们的例子中,我们明确比较了执行 squared 函数的结果与期望值。

运行这个测试后,在终端窗口中,我们会看到以下信息:

✓ test/basic.test.ts (1)
   ✓ Squared
 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  17:39:33
   Duration  1.14s 

为了检查测试是否正确工作,让我们将期望值从 4 改变,看看我们会得到什么结果:

FAIL  test/basic.test.ts > Squared
AssertionError: expected 4 to be 5 // Object.is equality
- Expected
+ Received
 ❯ eval test/basic.test.ts:13:22
     11| 
     12| test('Squared', () => {
     13|   expect(squared(2)).toBe(5);
       |                      ^
     14|   expect(squared(4)).toBe(16);
     15|   expect(squared(25)).toBe(625);
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
 Test Files  1 failed (1)
      Tests  1 failed (1)
   Start at  17:41:45
   Duration  1.15s 

当测试失败时,我们可以在结果中直接看到错误发生的位置,我们得到了什么结果,以及我们期望的是什么。

toBe 方法对于直接比较结果非常有用,但对于对象和数组呢?让我们考虑这个测试示例:

test('objects', () => {
  const obj1 = { a: 1 };
  const obj2 = { a: 1 };
  expect(obj1).not.toBe(obj2);
  expect(obj1).toEqual(obj2);
}); 

在这个测试中,我们创建了两个相同的对象,但作为变量它们不会相等。为了期望相反的断言,我们使用额外的.not.键,这最终给出了两个变量不相等的陈述。如果我们仍然想检查对象具有相同的结构,有一个名为toEqual的方法,它可以递归地比较对象。这个方法也与数组类似工作。

对于数组,也有一些额外的方法可以用来检查元素是否存在,这通常非常有用:

test('Array', () => {
  expect(['1', '2', '3']).toContain('3');
}); 

toContain方法也可以与字符串和 DOM 元素一起工作,检查classList中是否存在类。

单元测试的下一个重要部分是处理函数。Vitest 允许你创建可模拟的假函数,这让你可以检查这个函数是如何以及使用什么参数被调用的。让我们看看一个示例函数:

const selector = (onSelect: (value: string) => void) => {
  onSelect('1');
  onSelect('2');
  onSelect('3');
}; 

这个函数只是为了演示,但我们很容易想象一些模块或选择器组件,它接受onSelect回调函数,该函数将在某些条件下被调用:在我们的例子中,连续调用三次。现在让我们看看我们如何使用可观察的函数进行测试:

test('selector', () => {
  const onSelect = vi.fn();
  selector(onSelect);
  expect(onSelect).toBeCalledTimes(3);
  expect(onSelect).toHaveBeenLastCalledWith('3');
}); 

在测试中,我们使用Vitest包中的vi模块创建了onSelect函数。现在这个函数允许我们检查它被调用了多少次以及使用了什么参数。为此,我们使用了toBeCalledTimestoHaveBeenLastCalledWith方法。还有一个名为toHaveBeenCalledWith的方法,它可以逐步检查在观察函数的每次调用中使用了哪些参数。在我们的例子中,有效的检查会是这三行:

 expect(onSelect).toHaveBeenCalledWith('1');
  expect(onSelect).toHaveBeenCalledWith('2');
  expect(onSelect).toHaveBeenCalledWith('3'); 

Vitest 还允许你模拟一个真实函数,你需要使用vi.spyOn方法。然而,为了做到这一点,函数必须可以从一个对象中访问。让我们看看模拟一个真实函数的示例:

test('spyOn', () => {
  const cart = {
    getProducts: () => 10,
  };
  const spy = vi.spyOn(cart, 'getProducts');
  expect(cart.getProducts()).toBe(10);
  expect(spy).toHaveBeenCalled();
  expect(spy).toHaveReturnedWith(10);
}); 

要为函数创建一个观察,我们调用vi.spyOn并传递对象作为第一个参数以及方法的名称作为第二个参数。然后,我们可以处理原始函数,稍后通过使用spy变量进行必要的检查。在上面的例子中,你还可以注意到新的方法toHaveReturnedWith,它允许你检查观察到的函数返回了什么。

模拟

接下来,我想提到单元测试中最具挑战性的部分之一:即处理具有副作用或依赖于外部数据或库的函数。之前,我提到在具有副作用的函数中进行测试是无用的,比如在底层调用某些东西。实际上,这并不完全正确。在某些情况下,编写一个纯函数可能是不可能的,但这并不意味着它不能被测试。为了测试这样的函数,我们可以使用模拟:即模拟外部行为或简单地替换某些模块或库的实现。

一个例子可能是一个依赖于计算机系统时间的函数,或者一个从服务器返回数据的函数。在这种情况下,我们可以应用一个特定的模拟指令来更改计算机的当前日期,以便为这个测试创建一个干净的结果,这样更容易进行测试。同样,也可以创建一个网络请求的模拟实现,它最终将在本地执行并返回预定的值。让我们在本节中讨论一些这些场景。

考虑到测试和使用计时器的例子。在测试环境中,我们可以避免等待计时器,并手动控制它们,以便更彻底地测试函数的行为。让我们看看一个例子:

function executeInMinute(func: () => void) {
  setTimeout(func, 1000 * 60)
}
function executeEveryMinute(func: () => void) {
  setInterval(func, 1000 * 60)
}
const mock = vi.fn(() => console.log('done')) 

我们创建了executeInMinuteexecuteEveryMinute函数,分别用于延迟函数调用一分钟和每分钟循环执行。我们还创建了一个模拟函数,我们将随后对其进行监视。以下是测试将呈现的样子:

describe('delayed execution', () => {
  beforeEach(() => {
    vi.useFakeTimers()
  })
  afterEach(() => {
    vi.restoreAllMocks()
  })
  it('should execute the function', () => {
    executeInMinute(mock)
    vi.runAllTimers()
    expect(mock).toHaveBeenCalledTimes(1)
  })
  it('should not execute the function', () => {
    executeInMinute(mock)
    vi.advanceTimersByTime(2)
    expect(mock).not.toHaveBeenCalled()
  })

  it('should execute every minute', () => {
    executeEveryMinute(mock)
    vi.advanceTimersToNextTimer()
    expect(mock).toHaveBeenCalledTimes(1)
    vi.advanceTimersToNextTimer()
    expect(mock).toHaveBeenCalledTimes(2)
  })
}) 

在这个例子中,有很多东西可以讨论,但让我们从我们没有使用test函数这个事实开始;相反,我们使用了describeitdescribe函数允许我们创建一个可以有自己的上下文和生命周期的测试套件。在测试套件中,我们可以设置初始参数或模拟某些行为,以便我们的测试用例可以在以后重用这个上下文和这些参数。在我们的例子中,我们使用了beforeEachafterEach方法,这些方法在每个测试之前设置模拟计时器,然后在每个测试之后将一切恢复到原始状态。

it方法是对test方法的别名,在功能上与它没有区别。它只是为了让测试用例在结果中更易于阅读。例如,在结果中使用describedelayed executionitshould execute the function将看起来像这样:

delayed execution > should execute the function 

然而,使用test,我们会看到的结果是:

delayed execution > if should execute the function 

现在,让我们看看测试本身。第一个测试使用executeInMinute函数,实际上,它将在一分钟后才调用我们观察的方法,但在测试中,我们可以控制时间。通过使用vi.runAllTimers(),我们强制环境启动并跳过所有计时器,并立即检查结果。在下一个测试中,我们使用vi.advanceTimersByTime(2)将时间向前推进 2 毫秒,这已经允许我们确保原始函数不会被调用。

接下来,让我们讨论executeEveryMinute方法,它应该每分钟通过调用一个参数来启动一个计时器。在这种情况下,我们可以通过使用advanceTimersToNextTimer逐步遍历这个计时器的每个迭代,这样我们就可以在不等待真实时间的情况下精确控制时间。

在编写单元测试时,我们经常会遇到被测试的函数依赖于某些库甚至是一个包。

通常情况下,你会在 React Native 中遇到这种情况,如果某个库或某些方法使用了设备的原生功能。在这种情况下,为了编写测试,我们需要创建一个模拟版本的逻辑,该逻辑将在测试期间被调用。

让我们考虑一个简单的例子,我们假设我们有一个可以与设备交互并获取当前步数的包。为了获取步数,我们将使用getSteps函数:

export function getSteps() {
  // SOME NATIVE LOGIC
  return 100;
} 

作为例子,这个函数本身将非常简单,它只会返回100的值。然而,在现实中,这样的函数将与智能手机 API 交互,这在测试范围内是无法调用的。接下来,让我们看看在编写测试时我们可以做什么:

import { beforeAll, describe, expect, it, vi } from 'vitest';
import { getSteps } from './ios-health-kit';
describe('IOS Health Kit', () => {
  beforeAll(() => {
    vi.mock('./ios-health-kit', () => ({
      getSteps: vi.fn().mockImplementation(() => 2000),
    }));
  });
  it('should return steps', () => {
    expect(getSteps()).toBe(2000);
    expect(getSteps).toHaveBeenCalled();
  });
}); 

测试和整个例子相当简单,但它们将帮助你理解模拟是如何工作的。在文件的开头,我们导入我们的原始包ios-health-kit,然后使用beforeAll方法调用vi.mock,将包的路径作为第一个参数传递,并传递一个函数,该函数将返回原始文件的实现:即创建一个具有getSteps方法作为假函数的对象,其实施将返回2000的值。然后,在测试中,我们检查它确实返回了这个值。

在这个测试中,vi.mock函数创建了一个导入包的模拟,并用它替换了原始导入,这使得我们能够成功测试这个功能。

实际上,这个例子本质上并没有测试任何东西,只是展示了模拟的可能性。在实际项目中,你可能会需要测试一些函数,这些函数内部可能使用了需要模拟的重要库。为此,在真正的测试之前不断手动编写模拟可能不太方便;为了解决这个问题,你可以在全局级别模拟库和 API。为此,你需要创建一个配置文件或使用vi.stubGlobal。我不建议在没有理解和学习基础知识的情况下立即深入下去,所以让我们继续。

更多关于通过配置进行依赖项模拟的信息可以在vitest.dev/guide/mocking找到。

最后但同样重要的是,我想讨论的例子是模拟网络请求。你将要开发的任何应用程序几乎都会与需要从服务器获取的数据交互。对于单元测试来说,这可能是一个问题,因为测试单元时,重要的是要测试与外部环境抽象的单元。因此,在单元测试中,你应该始终模拟服务器请求并提供当前测试用例所需的数据。有一个名为Mock Service Worker的库用于模拟服务器请求。它允许你非常灵活地模拟 REST 和 GraphQL 请求。让我们看一个例子:

import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
const server = setupServer(
  http.get('https://api.github.com/users', () => {
    return HttpResponse.json({
      firstName: 'Mikhail',
      lastName: 'Sakhniuk',
    });
  })
);
describe('Mocked fetch', () => {
  beforeAll(() => server.listen());
  afterEach(() => server.resetHandlers());
  afterAll(() => server.close());
  it('should returns test data', async () => {
    const response = await fetch('https://api.github.com/users');
    expect(response.status).toBe(200);
    expect(response.statusText).toBe('OK');
    expect(await response.json()).toEqual({
      firstName: 'Mikhail',
      lastName: 'Sakhniuk',
    });
  });
}); 

在这个测试中,我们为路径 https://api.github.com/users 创建了一个模拟网络请求,它返回我们需要的数据。为此,我们使用了来自 Mock Service Worker 包的 setupServer 函数。接下来,在生命周期方法中,我们设置了模拟服务器以监听服务器请求,然后实现了一个标准测试,其中使用常规 Fetch API 请求数据。正如您在结果中可以看到的,我们可以检查状态码和返回的数据。

使用这种模拟方法,我们确实有广泛的测试不同逻辑的可能性,这取决于从服务器返回的数据、状态码、错误等。

在本节中,我们介绍了单元测试的基础:即它们是什么以及为什么我们需要编写它们。我们学习了如何设置测试环境并为我们的未来项目编写基本测试。接下来,让我们继续本章的主要主题,即测试 ReactJS 组件。

测试 ReactJS

我们已经知道单元测试涉及检查小的单元,通常是函数,这些函数执行一些逻辑并返回一个结果。为了理解 ReactJS 中的测试是如何工作的,概念和想法是相同的。我们知道在核心上,React 组件实际上是返回节点的 createElement 函数,这些节点作为 render 函数的结果,在浏览器屏幕上以 HTML 元素的形式显示。在单元测试中,我们没有浏览器,但这对我们来说不是问题,因为我们知道 React 的渲染目标几乎可以是任何东西。正如您可能已经猜到的,在 ReactJS 组件的单元测试中,我们将渲染组件到专门创建的 JSDOM 格式,它与 DOM 完全相同,React Testing Library 将帮助我们完成这项工作。

这个库包含一套工具,允许渲染组件、模拟事件,并以各种方式检查结果。

在我们开始之前,让我们设置测试 React 组件的环境。为此,在一个新的 Vite 项目中,执行以下命令:

npm install --save-dev \
  @testing-library/react \
  @testing-library/jest-dom \
  vitest \
  jsdom 

此命令将安装我们需要的所有依赖项。接下来,我们需要创建一个 tests/setup.ts 文件,以集成 Vitest 和 React Testing Library:

import { expect, afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import * as matchers from "@testing-library/jest-dom/matchers";
expect.extend(matchers);
afterEach(() => {
  cleanup();
}); 

接下来,我们需要更新 vite.config.ts 配置文件,并在其中添加以下代码:

 test: {
    globals: true,
    environment: "jsdom",
    setupFiles: "./tests/setup.ts",
  }, 

这些参数告诉 Vitest 在开始测试之前使用一个额外的环境和执行我们的设置脚本。

最后一步是配置 TypeScript 类型,我们将指定 expect 函数现在将具有与 React 组件一起工作的额外方法。为此,我们需要将以下代码添加到 src/vite-env.d.ts 文件中:

import type { TestingLibraryMatchers } from "@testing-library/jest-dom/matchers";
declare global {
  namespace jest {
    interface Matchers<R = void>
      extends TestingLibraryMatchers<typeof expect.stringContaining, R> {}
  }
} 

这种结构为 React Testing Library 提供的所有新方法添加了类型。有了这个,环境设置就完成了,我们可以继续编写测试。

首先,让我们考虑一个最基础的检查,即组件是否已成功渲染并存在于文档中。为此,我们将创建一个返回带有Hello world文本的标题的App组件:

export function App() {
  return <h1>Hello world</h1>;
} 

这样一个组件的测试看起来会是这样:

import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { App } from "./App";
describe("App", () => {
  it("should be in document ", () => {
    render(<App />);
    expect(screen.getByText("Hello world")).toBeInTheDocument();
  });
}); 

测试本身的架构与之前相同,并且我们已经非常熟悉。需要注意的是,在测试开始时,我们使用来自testing-libraryrender函数来渲染组件,之后我们就可以执行检查。为了处理渲染结果,我们使用screen模块。它允许我们以各种方式与我们的虚拟 DOM 树进行交互,并搜索必要的元素。

我们将在稍后介绍主要的方法,但在本例中,我们使用了getByText方法,它查询包含文本“Hello World”的元素。为了检查该元素是否存在于文档中,我们使用toBeInTheDocument方法。这是运行测试时的输出:

✓ src/App.test.tsx (1)
   ✓ App (1)
     ✓ should be in document
 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  14:19:01
   Duration  198ms 

现在让我们考虑一个更复杂的例子,其中我们需要检查点击按钮会给组件添加一个新的className属性:

export function ClassCheck() {
  const [clicked, setClicked] = useState(false);
  return (
    <button
      className={clicked ? "active" : ""}
      onClick={() => setClicked(true)}
    >
      Click me
    </button>
  );
} 

通过点击按钮,我们更新了状态,这更新了组件并给它添加了一个active类。现在,让我们为这个组件编写一个测试:

describe("ClassCheck", () => {
  it("should have class active when button was clicked", () => {
    render(<ClassCheck />);
    const button = screen.getByRole("button");
    expect(button).not.toHaveClass("active");
    fireEvent.click(button);
    expect(button).toHaveClass("active");
  });
}); 

在这个测试中,你首先渲染ClassCheck组件,然后我们需要找到按钮元素,为此,我们使用带有getByRole方法的screen模块。这是下一个允许在文档中查询元素的方法,但重要的是要理解,如果文档中存在多个button元素,这个测试将产生错误。因此,在不同情况下应用合适的查询方法是必要的。现在按钮是可访问的,我们首先使用带有not前缀的toHaveClass方法确保组件不包含active类。

要点击这个按钮,React Testing Library 提供了fireEvent模块,它允许生成点击事件。点击按钮后,我们检查元素中是否存在所需的类。

使用fireEvent,可以生成所有可能的事件,如点击、拖动、播放、聚焦、失焦等。一个非常重要且需要测试的常见事件是输入元素中的change事件。让我们以Input组件为例来讨论这个问题:

export function Input() {
  return <input type="text" data-testid="userName" />;
} 

这个组件简单地返回一个input元素,但在这个例子中,我还添加了一个特殊的属性,data-testid。这个属性用于在文档中更方便地搜索元素,因为它抽象了你对组件内容或元素角色的操作。在项目开发过程中,你经常会更新你的组件,而data-testid属性将帮助你更频繁地修复由于内容更新或更改(例如从h1h2div到更语义化的元素)而导致的损坏的测试。

现在让我们为这个组件编写一个测试:

describe("Input", () => {
  it("should handle change event", () => {
    render(<Input />);
    const input = screen.getByTestId<HTMLInputElement>("userName");
    fireEvent.change(input, { target: { value: "Mikhail" } });
    expect(input.value).toBe("Mikhail");
  });
}); 

在这个测试中,像往常一样,我们渲染组件,然后使用更方便的方法 getByTestId 找到我们的元素。接下来,我们使用 fireEvent.change 方法在 input 上模拟 change 事件,该方法接受事件对象,并在测试结束时断言输入的值与预期的值相符。这样,我们现在可以测试具有各种逻辑的大型表单,例如格式化、验证等。

就像测试组件一样,React 测试库也可以测试 Hooks。这允许我们只测试自定义逻辑,并从组件中抽象出来。让我们编写一个小的 useCounter Hook,它将返回当前的 counter 值和 incrementdecrement 函数:

export function useCounter(initialValue: number = 0) {
  const [count, setCount] = useState(initialValue);
  const increment = () => setCount((c) => c + 1);
  const decrement = () => setCount((c) => c - 1);
  return { count, increment, decrement };
} 

为了测试这个 Hook,而不是使用 render 函数,React 测试库有一个 renderHook 方法。这就是这个 Hook 的测试看起来像:

test("useCounter", () => {
  const { result } = renderHook(() => useCounter());
  expect(result.current.count).toBe(0);
  act(() => {
    result.current.increment();
  });
  expect(result.current.count).toBe(1);
  act(() => {
    result.current.decrement();
  });
  expect(result.current.count).toBe(0);
}); 

首先,我们渲染 Hook 本身并检查初始值是否为零。renderHook 方法返回 result 对象,通过它我们可以读取 Hook 返回的数据。接下来,我们需要测试 incrementdecrement 方法。为此,仅仅调用它们是不够的,因为 Hooks 本质上不是纯函数,并且在其内部包含大量逻辑。因此,我们需要将这些方法包裹在 act 方法中调用,这将同步等待方法执行和 Hook 重新渲染。之后,我们可以以通常的方式断言期望。输出将看起来与我们在上一个示例中看到的一样,但现在让我们尝试更新测试以使结果失败。将第一个断言从 .toBe(0) 更新到 .toBe(10) 将看起来像:

AssertionError: expected +0 to be 10 // Object.is equality
- Expected
+ Received
- 10
+ 0
 ❯ src/useCounter.test.ts:8:32
      6|   const { result } = renderHook(() => useCounter());
      7| 
      8|   expect(result.current.count).toBe(10);
       |                                ^
      9| 
     10|   act(() => {
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
 Test Files  1 failed (1)
      Tests  1 failed (1)
   Start at  14:24:06
   Duration  200ms 

你将注意到 Vitest 如何突出显示我们得到失败断言的代码部分。

在本节中,我们学习了如何使用 React 测试库测试组件和 Hooks。

摘要

在本章中,我们探讨了广泛的测试主题。我们熟悉了测试的概念、测试类型和不同的方法。然后,我们深入研究了单元测试,学习了它是什么,以及这种测试类型提供了哪些可能性。之后,我们学习了如何设置环境并为常规函数和逻辑编写测试。在本章结束时,我们检查了测试 React 组件和 Hooks 的基本功能。

通过本章,我们结束了与惊人的 ReactJS 库的相识,并将深入探索 React 生态系统,利用创建基于 React Native 的移动应用程序的惊人机会。

加入我们的 Discord 社群!

与其他用户和作者一起阅读这本书。提出问题,为其他读者提供解决方案,与作者聊天,等等。扫描二维码或访问链接加入社区。

packt.link/ReactAndReactNative5e

二维码

第二部分

React Native

在本部分,我们将探讨使用React Native库构建移动应用。我们将探索基本 API 和一些常见方法,以帮助您开发稳定且性能良好的应用程序。

本部分包含以下章节:

  • 第十五章为什么选择 React Native?

  • 第十六章React Native 内部机制

  • 第十七章启动 React Native 项目

  • 第十八章使用 Flexbox 构建响应式布局

  • 第十九章屏幕间导航

  • 第二十章渲染项目列表

  • 第二十一章地理位置和地图

  • 第二十二章收集用户输入

  • 第二十三章响应用户手势

  • 第二十四章显示进度

  • 第二十五章显示模态屏幕

  • 第二十六章使用动画

  • 第二十七章控制图像显示

  • 第二十八章离线使用

第十五章:为什么选择 React Native?

Meta(原名 Facebook)创建了 React Native 来构建其移动应用程序。它始于 2013 年夏天的 Facebook 内部黑客马拉松项目,并于 2015 年开源。这样做的原因源于 React 在 Web 上的成功。因此,如果 React 是 UI 开发的优秀工具,而你需要一个原生应用程序,那么为什么要与之抗争呢?只需让 React 与原生移动操作系统 UI 元素协同工作!因此,同年,Facebook 将 React 分为两个独立的库,ReactReactDOM,从那时起,React 必须只与接口工作,而不关心这些元素将在哪里渲染。

在本章中,你将了解使用 React Native 构建原生移动 Web 应用程序的动机。以下是本章我们将涵盖的主题:

  • 什么是 React Native?

  • React 和 JSX 熟悉

  • 移动浏览器体验

  • Android 和 iOS:不同却相同

  • 移动 Web 应用程序的案例

技术要求

本章没有技术要求,因为它是对 React Native 的简要概念介绍。

什么是 React Native?

在本书的早期部分,我介绍了渲染目标的概念,即 React 组件渲染到的对象。对于 React 程序员来说,渲染目标是抽象的。例如,在 React 中,渲染目标可以是字符串,也可以是文档对象模型DOM)。因此,你的组件永远不会直接与渲染目标接口,因为你永远无法确定渲染发生在哪里。

移动平台有UI 小部件库,开发者可以利用这些库为该平台构建应用程序。在 Android 上,开发者使用JavaKotlin实现应用程序,而在 iOS 上,开发者实现Objective-CSwift应用程序。如果你想有一个功能性的移动应用程序,你必须选择一个。然而,你需要学习这两种语言,因为只支持两个主要平台中的一个对于成功来说并不现实。

对于 React 开发者来说,这不是问题。你构建的相同 React 组件可以在任何地方工作,甚至在移动浏览器上!需要学习两种额外的编程语言来构建和发布移动应用程序既费时又费力。解决方案是引入一个新的 React 平台,该平台支持新的渲染目标:原生移动 UI 小部件。

React Native 使用一种技术,对底层移动操作系统进行异步调用,该系统调用原生小部件 API。有一个 JavaScript 引擎,React API 与 Web 上的 React 大致相同。区别在于目标;不是 DOM,而是异步 API 调用。这个概念在这里得到了可视化:

图 15.1:React Native 工作流程

这过于简化了底层发生的一切,但基本思想如下:

  • 在 Web 上使用的相同 React 库也被React Native使用,并在JavaScriptCore上运行。

  • 发送到原生平台 API 的消息是异步的,并且为了性能目的而批量处理。

  • React Native 附带适用于移动平台的组件,而不是 HTML 元素。

  • React Native 仅仅是通过 iOS 和 Android API 渲染组件的一种方式。它可以使用相同的概念替换为 tvOS、Android TV、Windows、macOS,甚至再次用于 Web。这可以通过 React Native 的分支和附加组件来实现。在本书的这一部分,我们将学习如何为 iOS 和 Android 编写移动应用。有关其他可能平台的更多信息,请在此处查看:reactnative.dev/docs/out-of-tree-platforms

关于 React Native 的历史和机制,更多信息可以在engineering.fb.com/2015/03/26/android/react-native-bringing-modern-web-techniques-to-mobile/找到。

React 和 JSX 都很熟悉

为 React 实现一个新的渲染目标并不简单。这本质上与在 iOS 和 Android 上运行的新 DOM 的发明是一样的。那么,为什么要费这么大的劲呢?

首先,对移动应用的需求量很大。原因是移动 Web 浏览器的用户体验不如原生应用。其次,JSX 是构建 UI 的绝佳工具。你不必学习新技术,使用你已知的工具就足够了。

最后一点对你来说最为相关。如果你正在阅读这本书,你很可能对使用 React 来开发 Web 应用和原生移动应用感兴趣。我无法用言语表达 React 在开发资源方面的价值。你不需要一个专门负责 Web UI 的团队、一个专门负责 iOS 的团队、一个专门负责 Android 的团队等等,只需要一个理解 React 的 UI 团队。

在接下来的部分,你将了解在移动 Web 浏览器上提供良好用户体验的挑战。

移动浏览器的体验

移动浏览器缺乏许多移动应用的功能。这是因为浏览器无法像 HTML 元素那样复制相同的原生平台小部件。你可以尝试这样做,但通常最好是直接使用原生小部件而不是尝试复制它。这部分的理由是这需要你更少的维护工作,部分是因为使用平台原生的小部件意味着它们与平台的其他部分保持一致。例如,如果你的应用程序中的日期选择器与用户在手机上交互的所有日期选择器都不同,这并不是一个好现象。熟悉度是关键,使用原生平台小部件使得熟悉度成为可能。

移动设备上的用户交互与你在 Web 上通常设计的交互在本质上是有区别的。例如,Web 应用假设存在鼠标,并且按钮上的点击事件只是一个阶段。然而,当用户用手指与屏幕交互时,事情变得更加复杂。移动平台有一个所谓的 手势系统 来处理这种情况。React Native 在处理这些类型的事情上比 Web 上的 React 更合适,因为这些事情在 Web 应用中你不必过多考虑。

随着移动平台的更新,你希望你的应用组件也保持更新。在 React Native 中这不是问题,因为应用使用的是平台实际组件。再次强调,一致性和熟悉性对于良好的用户体验至关重要。因此,当你的应用中的按钮看起来和表现方式与设备上其他应用中的按钮相同,你的应用就会感觉像是设备的一部分。

现在你已经了解了为什么为移动浏览器开发 UI 比较困难,是时候看看 React Native 如何弥合不同原生平台之间的差距了。

Android 和 iOS:不同却相同

当我第一次听说 React Native 时,我自然而然地认为它将是一种跨平台解决方案,让你能够编写一个可以在任何设备上本地运行的单一 React 应用。然而,现实更加复杂。虽然 React Native 允许在平台之间共享大量代码,但重要的是要理解 iOS 和 Android 在许多基本层面上是不同的,它们的用户体验哲学也不同。

React Native 的目标是“一次学习,到处编写”而不是“一次编写,到处运行”。这意味着在某些情况下,你可能希望你的应用利用平台特定的控件来提供更好的用户体验。

话虽如此,React Native 生态系统已经取得了进步,使得跨平台开发更加无缝。

例如,Expo 现在支持 Web 开发,允许你使用 React Native for Web 在 Web 上运行你的应用。这意味着你可以使用单一代码库开发在 Android、iOS 和 Web 上运行的应用。此外,Tamagui UI 套件 对 Web 和移动平台都提供 100%的支持,这使得创建在多个平台上运行且不牺牲用户体验的应用变得更加容易。

鉴于这些发展,重要的是要认识到,虽然 React Native 可能不会提供一个完美的“一次编写,到处运行”的解决方案,但它已经在实现更高效的跨平台开发方面取得了长足的进步。有了像 Expo 和 Tamagui 这样的工具,开发者可以创建在不同平台上运行的应用,同时在必要时利用平台特定的功能。

在下一节中,我们将探讨移动 Web 应用在浏览器中运行可能更适合你的用户的情况。

移动 Web 应用的优势

并非你的每一位用户都愿意安装应用,尤其是如果你还没有高下载量和评分。Web 应用进入门槛要低得多:用户只需要一个浏览器。

尽管无法复制原生平台 UI 所能提供的一切,你仍然可以在移动 Web UI 中实现很棒的事情。也许拥有一个好的 Web UI 是提高你的移动应用下载量和评分的第一步。

理想情况下,你应该追求以下目标:

  • 标准 Web(笔记本电脑/台式机浏览器)

  • 移动 Web(手机/平板浏览器)

  • 移动应用(手机/平板原生平台)

在这三个空间中投入相同数量的努力可能没有太多意义,因为你的用户可能更倾向于其中一个领域而不是另一个。例如,一旦你知道你的移动应用的需求比网络版本高,那么你就可以在那里分配更多的努力。

摘要

在本章中,你了解到 React Native 是 Facebook 为了重用 React 来创建原生移动应用的努力。React 和 JSX 擅长声明 UI 组件,鉴于现在对移动应用的需求巨大,使用你所知道的 Web 知识是有意义的。

对于移动应用的需求超过移动浏览器的原因是它们感觉更好。Web 应用缺乏像应用那样处理移动手势的能力,并且从外观和感觉的角度来看,它们通常不像移动体验的一部分。

React Native 在过去的几年里发展迅速,使开发者能够创建更高效的跨平台应用。虽然 iOS 和 Android 确实存在根本性的差异,但 React Native 在提供更无缝的开发体验方面取得了进展。然而,重要的是要记住,React Native 的目标是“一次学习,到处编写”而不是“一次编写,到处运行”。这意味着开发者仍然可以利用平台特定的功能来提供更好的用户体验。

现在你已经了解了 React Native 是什么以及它的优势,你将在下一章学习如何开始新的 React Native 项目。

第十六章:React Native 内部机制

上一章简要介绍了 React Native 是什么以及用户在 React Native UI 和移动浏览器之间体验到的差异。

在本章中,我们将深入探讨 React Native,深入了解它在移动设备上的表现以及我们在开始使用此框架之前应该达到的目标。我们还将探讨我们可以执行哪些原生功能选项以及我们将面临哪些限制。

我们将涵盖以下主题:

  • 探索 React Native 架构

  • 解释 JavaScript 和本地模块

  • 探索 React Native 组件和 API

探索 React Native 架构

在理解 React Native 的工作原理之前,让我们回顾一下关于 React 架构和网页与原生移动应用之间差异的历史观点。

过去网页和移动应用的状态

Meta 在 2013 年发布了 React,这是一个用于创建应用的单一工具,采用组件方法和 虚拟 DOM。它为我们提供了开发无需考虑浏览器进程(如解析 JS 代码、创建 DOM、处理层和渲染)的网页应用的机会。我们只需使用状态和属性创建界面,用于数据和 CSS 用于样式,从后端获取数据,保存在本地存储中等。

React 与浏览器一起,使我们能够在更短的时间内创建性能应用。当时,React 的架构看起来是这样的:

图片

图 16.1:2013 年的 React 架构

由于快速开发和低门槛,新的声明式接口开发方法变得更加受欢迎。此外,如果你的后端是用 Node.js 构建的,你可以通过仅使用一种编程语言来享受整个项目的支持和开发的便利。

同时,移动应用需要更复杂的技术来创建应用。对于 Android 和 iOS 应用,公司应该管理三个不同团队,这些团队具有无与伦比的经验,以支持三个主要生态系统:

  • 网页开发者应该了解 HTML、CSS、JS 和 React。

  • JavaKotlin SDK 经验对于 Android 开发者来说是必需的。

  • iOS 开发者应该熟悉 Objective-CSwiftCocoaPods

开发应用的每一步,从原型设计到发布,都需要独特的技能。在跨平台解决方案出现之前,网页和移动应用开发看起来是这样的:

图片

图 16.2:网页和移动应用的状态

即使是一家公司执行一个基本应用,也可能面临一些重大问题:

  • 这些团队中的每一个都实现了相同的企业逻辑。

  • 在团队之间共享代码没有替代方案。

  • 在团队之间共享资源是不可能的(Android 开发者无法为 iOS 应用编写代码,反之亦然)。

由于这些重大问题,我们在测试资源方面也遇到了复杂性,因为存在更多可能产生错误的地方。开发速度也各不相同,因为移动应用程序需要更多时间来实现相同的功能。所有这些都累积成了对公司来说成本高昂的大问题。其中许多公司提出了如何编写单一代码库或重用现有代码库的想法,这些代码库可以在多个生态系统中使用。最简单的方法是使用浏览器将 Web 应用程序包装成移动应用,但正如我们在 第十五章 中探讨的,“为什么选择 React Native?” 这在处理触摸和手势方面存在局限性。

针对这些问题的回应,Meta 开始投资资源开发跨平台框架,并在 2015 年发布了 React Native 库。它还将 React 分为两个独立的库。现在,为了在浏览器中渲染我们的应用程序,我们应该使用 ReactDOM 库。

图 16.3 中,我们可以看到 React 如何与 ReactDOMReact Native 协同工作以渲染我们的应用程序:

图 16.3:ReactDOM 和 React Native 流程

现在,React 只负责管理组件树。这种方法封装了任何渲染 API,并隐藏了许多平台特定的方法。我们可以专注于开发界面,而无需猜测它们将如何被渲染。

正因如此,React 常常被宣称为一个渲染无关的库。此外,对于 Web 应用程序,我们使用 ReactDOM,它形成元素并将它们直接应用到浏览器 DOM 上。对于移动应用程序,React Native 直接在移动屏幕上渲染我们的界面。

但是,React Native 是如何替换整个浏览器 API,并允许我们编写熟悉的代码并在移动设备上运行的呢?

React Native 当前架构

React Native 库允许您通过利用原生构建块使用 React 和 JS 创建原生应用程序。例如,<Image/> 组件代表了两个其他原生组件,Android 上的 ImageView 和 iOS 上的 UIImageView。这是可行的,因为 React Native 的架构包括两个专门的层,分别由 JSNative 线程表示:

图 16.4:React Native 线程

在接下来的章节中,我们将探索每个线程,并了解它们如何进行通信,确保 JS 能够集成到原生代码中。

JS 作为 React Native 的一部分

由于浏览器通过 JS 引擎(如 V8SpiderMonkey 等)执行 JS,React Native 也包含一个 JS 虚拟机。在那里,我们的 JS 代码被执行,API 调用被处理,触摸事件被处理,以及许多其他过程发生。

最初,React Native 只支持苹果的 JavaScriptCore 虚拟机。在 iOS 设备上,这个虚拟机是内置的,并且可以直接使用。在 Android 设备的情况下,JavaScriptCore 是与 React Native 一起打包的。这增加了应用程序的大小。

因此,React Native 的Hello World应用程序在 Android 上大约消耗 3 到 4 MB。从 0.60 版本开始,React Native 开始使用新的Hermes 虚拟机,从 0.64 版本开始,也提供了对 iOS 的支持。

Hermes 虚拟机为两个平台带来了许多改进:

  • 提高了应用的启动时间

  • 减少了下载的应用大小

  • 减少了内存使用

  • 内置代理支持,使react-native-firebasemobx可用

在面试中,了解新旧架构之间的比较优势是一个相对常见的话题。有关 Hermes 的更多信息,请参阅reactnative.dev/docs/hermes

在 React Native 中,与浏览器一样,JS 是在单个线程中实现的。这个线程负责执行 JS。我们编写的业务逻辑在这个线程上执行。这意味着我们所有的常见代码,如组件、状态、Hooks 和 REST API 调用,都将由应用中的 JS 部分处理。

我们整个应用程序结构都使用Metro打包器打包成一个文件。它还负责将 JSX 代码转换为 JS。如果我们想使用 TypeScript,Babel可以支持它。它直接可用,因此无需进行任何配置。在未来的章节中,我们将学习如何启动一个现成的项目。

“原生”部分

这里是执行原生代码的地方。React Native 为每个平台实现了这部分的原生代码:Android 使用 Java,iOS 使用 Objective-C。原生层主要由与 Android 或 iOS SDK 通信的 Native 模块组成,旨在为我们提供使用统一 API 的原生功能。例如,如果我们想显示一个警告对话框,原生层为两个平台提供了一个统一的 API,我们可以通过 JS 线程使用单个 API 来调用它。

当你需要更新界面或调用原生函数时,这个线程会与 JS 线程交互。这部分有两个部分:

  • 第一个是React Native UI,负责使用原生界面塑造工具。

  • 第二个是原生模块,允许应用程序访问它们运行的平台上的特定功能。

线程间的通信

如前所述,每个 React Native 层为应用中的每个原生和 UI 功能实现了一个独特的 API。层与层之间的通信是通过桥接完成的。该模块是用 C++编写的,基于异步队列。当桥接从一方接收数据时,它会将其序列化,将其转换为JSON字符串,并通过队列传递。到达目的地后,数据会被反序列化。

如警报示例所示,本地部分接受来自 JS 的调用并显示对话框。实际上,当 JS 方法被调用时,它会向 发送消息,并在接收到这条消息后,本地部分执行指令。本地消息也可以转发到 JS 层。例如,在点击按钮时,Native 层会向 JS 层发送一个带有 onClick 事件的 Native 消息。可以想象如下:

图 16.5:桥梁

JS 和该架构的本地部分,连同桥一起,类似于网络应用的客户端和服务器端,它们通过 REST APIs 进行通信。对我们来说,本地部分是用哪种语言或如何实现的不重要,因为 JS 中的代码是隔离的。我们只需通过桥发送消息并接收响应。这既是显著的优势,也是巨大的劣势:首先,它允许我们用一个代码库实现跨平台应用,但当我们应用中有大量业务逻辑时,它可能成为瓶颈。应用中的所有事件和动作都依赖于异步的 JSON-bridged 消息。每一方发送这些消息,期望在未来某个时刻收到这些消息的响应(这并不保证)。在这种数据交换方案中,存在过载通信通道的风险。

这里有一个常用的例子,用来说明这种通信方案如何导致应用出现性能问题。假设一个应用的用户在滚动一个巨大的列表。当在本地环境中发生 onScroll 事件时,信息会异步传递到 JS 环境中。但是本地机制不会等待 JS 应用部分完成工作并向其报告。因此,在显示内容之前,列表中空白的区域会出现延迟。我们可以通过使用分页的 FlatList 等特殊方法来避免许多常见问题。我们将在未来的章节中探讨主要技巧,但记住当前架构的限制是很重要的。

设计

我们已经理解了跨平台的概念,因此可以假设每个平台都有自己的技术来创建和设计界面。为了统一这些技术,React Native 使用 CSS-in-JS 语法来设计应用的外观。使用 Flexbox,组件能够指定其子组件的布局。这确保了在不同屏幕尺寸上保持一致的布局。这通常与网页上 CSS 的工作方式相似,只是名称采用驼峰式,例如 backgroundColor 而不是 background-color

在 JS 中,它是一个具有样式属性的普通对象,在原生代码中,它是一个名为Shadow的独立线程。它使用 Meta 开发的Yoga引擎重新计算应用程序的布局,在这个线程中执行与形成应用程序界面相关的计算。这些计算的结果被发送到负责显示界面的原生 UI 线程。

当所有部分组合在一起时,React Native 的最终架构如图所示:

图片

图 16.6:当前 React Native 架构

当前 React Native 的架构解决了主要的商业问题:可以在同一个团队内开发 Web 和移动应用程序,可以重用大量的业务逻辑代码,甚至没有移动开发经验的开发者也能轻松使用 React Native。

然而,当前的架构并不理想。在过去的几年里,React Native 团队一直在努力解决桥接瓶颈问题。新的架构旨在解决这个问题。

React Native 的未来架构

React Native 引入了一系列重大改进,这将简化开发过程,使每个人都更加方便。

React Native 的重构将逐步弃用桥接,并用一个新的组件JS 接口JSI)来替代它。此外,这个元素将启用新的Fabric组件和TurboModules

使用 JSI 为改进打开了众多可能性。在图 16.7中,你可以看到 React Native 架构的主要更新:

图片

图 16.7:新的 React Native 架构

第一个变化是 JS 包不再依赖于JavaScriptCore虚拟机。实际上,它现在是当前架构的一部分,因为现在我们可以在两个平台上启用新的Hermes JS 引擎。换句话说,JavaScriptCore 引擎现在可以轻松地被其他东西取代,很可能是性能更好的东西。

第二个改进是新的 React Native 架构的核心所在。JSI 允许 JS 直接调用原生方法和函数。这是通过HostObject C++对象实现的,它存储了对原生方法和属性的引用。在 JS 中,HostObject将原生方法和属性绑定到一个全局对象上,因此直接调用 JS 函数将调用 Java 或 Objective-C API。

新的 React Native 的另一个好处是能够完全控制名为TurboModules的原生模块。而不是一次性启动它们,应用程序将只在需要时使用它们。

Fabric 是新的 UI 管理器,在 图 16.7 中被称为 Renderer,它预计将通过消除对桥接器的需求来改变渲染层。现在可以直接在 C++ 中创建 Shadow Tree,这提高了速度并减少了渲染特定元素所需的步骤数量。

为了确保 React Native 和本地部分之间的通信顺畅,Meta 目前正在开发一个名为 CodeGen 的工具。它预计将自动化强类型本地代码和动态类型 JS 的兼容性,使它们同步。通过这次升级,将不再需要为两个线程重复代码,从而实现平滑的同步。

新的架构可能为开发能够实现旧 React Native 应用程序中不可用的新设计开辟了道路。事实上,我们现在可以利用 C++ 的力量。这意味着,使用 React Native,现在将能够创建比以前更多的应用程序种类。

在这里,我们讨论了解释 React Native 如何工作的基本原理。了解我们使用的工具的架构非常重要。拥有这些知识可以让你在规划和原型设计时避免错误,并最大限度地发挥未来应用程序的潜力。在下一节中,我们将简要探讨如何通过模块扩展 React Native。

解释 JS 和本地模块

React Native 并没有提供所有内置的本地功能。它只提供了在基本应用程序中需要的最常见功能。此外,Meta 团队最近将一些功能移动到其自己的模块中,以减少整体应用程序的大小。例如,用于在设备上存储数据的 AsyncStorage 被移动到单独的包中,如果你打算使用它,就必须安装。

然而,React Native 是一个可扩展的框架。我们可以添加自己的本地模块,并使用相同的桥接器或 JSI 暴露 JS API。在这本书中,我们的重点不会放在开发本地模块上,因为我们需要先有 Objective-C 或 Java 的经验。此外,这也不是必要的,因为 React 社区已经为所有情况创建了大量现成的模块。我们将在后续章节中学习如何安装本地包。

以下是一些最受欢迎的本地模块,没有它们,大多数项目都无法繁荣发展。

React Navigation

React Navigation 是创建应用导航菜单和屏幕的最佳 React Native 导航库之一。它对于初学者来说是个好工具,因为它稳定、快速且错误较少。文档非常好,并为所有用例提供了示例。

我们将在第十九章“屏幕间导航”中了解更多关于 React Navigation 的内容。

UI 组件库

UI 组件库使您能够快速组装应用布局,而无需花费时间设计和编码原子元素。此外,此类库通常更稳定、更一致,这导致 UI 和 UX 方面都取得更好的结果。

这些是一些最受欢迎的库(我们将在未来的章节中更详细地探讨其中的一些):

  • NativeBase:这是一个组件库,使开发者能够构建通用设计系统。它建立在 React Native 之上,允许您为 Android、iOS 和网页开发应用。

  • React Native Element:这提供了一个用于在 React Native 中创建应用的综合性 UI 工具包。

  • UI Kitten:这是Eva 设计系统的 React Native 实现。该框架包含一组以类似方式设计的通用 UI 组件。

  • React-native-paper:这是一个为 React Native 提供的可定制和现成组件集合,遵循谷歌的 Material Design 指南。

  • Tamagui:这个 UI 工具包提供可以在移动设备和网页上运行的组件。

启动画面

将启动画面添加到您的移动应用中可能是一项繁琐的任务,因为这个屏幕应该在 JS 线程开始之前出现。react-native-bootsplash包允许您从命令行创建一个花哨的启动画面。如果您提供图像和背景颜色,该包将为您完成所有工作。

图标

图标是界面可视化的一个重要部分。每个平台都使用不同的方法来显示图标和其他矢量图形。React Native 为我们统一了这一点,但仅限于使用如react-native-vector-icons等额外库。使用react-native-svg,你还可以在 React Native 应用中渲染可缩放矢量图形SVG)。

处理错误

通常,当我们开发 Web 应用时,我们能够轻松处理错误,因为它们不会超出 JS 的作用域。因此,在出现关键错误的情况下,我们拥有更多的控制和稳定性,因为如果应用根本无法启动,我们可以轻松地看到原因并在DevTools中打开日志。

由于 React Native 应用中除了环境的 JS 之外还有一个本地组件,这可能会在应用执行过程中引起错误,因此存在更多复杂性。因此,当发生错误时,我们的应用将立即关闭。这将使我们很难找出原因。

react-native-exception-handler提供了一种处理本地和 JS 错误并提供反馈的简单技术。要使其工作,您需要安装并链接该模块。然后,注册您的全局处理器以处理 JS 和本地异常,如下所示:

import { setJSExceptionHandler, setNativeExceptionHandler }
  from "react-native-exception-handler";
setJSExceptionHandler((error, isFatal) => {
  // …
});
const exceptionhandler = (exceptionString) => {
  // your exception handler code here
};
setNativeExceptionHandler(
  exceptionhandler,
  forceAppQuit,
  executeDefaultHandler
); 

setJSExceptionHandlersetNativeExceptionHandler方法是一些自定义的全局错误处理器。如果发生崩溃,您可以显示错误消息,使用 Google Analytics 进行跟踪,或者使用自定义 API 通知开发团队。

推送通知

我们生活在一个通知至关重要的世界。我们每天打开数十个应用,仅仅是因为我们收到了它们的推送通知。

推送通知通常与一个网关提供商相关联,该提供商向用户的设备发送消息。以下库可以用于向你的应用添加推送通知:

  • react-native-onesignal:用于推送通知、电子邮件和短信的 OneSignal 提供商

  • react-native-firebase:Google Firebase

  • @aws-amplify/pushnotification:AWS Amplify

空中传输更新

作为正常应用更新的一个部分,当你构建新版本并将其上传到应用商店时,你可以通过空中传输(OTA)来替换 JS 包。由于包中只包含一个文件,更新它并不复杂。你可以随时更新你的应用,无需等待苹果或谷歌验证你的应用。这就是 React Native 的真正力量。

我们可以使用它是因为微软提供的 CodePush 服务。你可以在以下链接中找到更多关于 CodePush 的信息:docs.microsoft.com/en-gb/appcenter/distribution/codepush/

Expo 也支持使用 expo-updates 包进行空中传输更新。

JS 库

对于 JS(非原生)模块,我们几乎没有限制,除了使用不受支持的 API 的库,如 DOM 和 Node.js。我们可以使用任何用 JS 编写的包:MomentLodashAxiosReduxMobX 和成千上万个其他包。

在本节中,我们仅仅触及了使用各种模块扩展应用的潜力。因为 React Native 有数千个库,逐一浏览它们几乎毫无意义。为了找到所需的包,有一个名为 React Native 目录 的项目收集并评估了大量包。该项目可以在以下地址找到:reactnative.directory/

我们现在知道了 React Native 的内部组织结构以及如何扩展其功能。我们的下一步是检查这个框架提供的 API 和组件。

探索 React Native 组件和 API

每个新章节将详细讨论主要模块和组件,但就目前而言,让我们先熟悉它们。React Native 框架中提供了一些核心组件,可以在应用中使用。

几乎所有应用都至少使用这些组件中的一个。这些是 React Native 应用的基本构建块:

  • View:任何应用的基石。这相当于 <div>,在移动设备上表示为 UIViewandroid.view。任何 <View/> 组件都可以嵌套在另一个 <View/> 组件内部,并且可以有零个或多个任何类型的子组件。

  • Text:这是一个用于显示文本的 React 组件。与 View 一样,<Text/> 支持嵌套、样式化和触摸处理。

  • Image:这显示来自各种来源的图像,如网络图像、静态资源、临时本地图像和相册中的图像。

  • TextInput:这允许用户使用键盘输入文本。属性可以配置各种功能,包括自动更正、自动大写、占位文本以及不同的键盘类型,如数字键盘。

  • ScrollView:这个组件是用于滚动多个视图和组件的通用容器。对于可滚动项,可以有垂直和水平滚动(通过调整水平属性)。如果你需要渲染大量或无限列表项,你应该使用FlatList。它支持一系列特殊属性,如下拉刷新滚动加载(懒加载)。如果你的列表需要分成几个部分,那么也有专门用于此的特殊组件:SectionList

  • Button:React Native 有高级组件可以用来创建自定义按钮和其他触摸组件,例如TouchableHighlightTouchableOpacityTouchableWithoutFeedback

  • Pressable:这为 React Native 0.63 版本提供了更精确的触摸控制。基本上,它是一个用于检测触摸的包装器。它是一个定义良好的组件,可以用作TouchableOpacityButton等触摸组件的替代品。

  • Switch:这个组件类似于复选框;然而,它以我们在移动设备上熟悉的开关形式呈现。

在接下来的章节中,我们将更深入地探讨常见组件及其属性,以及探索很少使用的组件。我们还将查看代码示例,展示如何组合组件以创建应用程序界面。

所有可用组件的详细信息可以在reactnative.dev/docs/components-and-apis找到。

摘要

在本章中,我们探讨了跨平台框架 React Native 的历史以及它为公司解决了哪些问题。有了它,公司可以使用单一的全能开发团队构建一个业务逻辑,并将其同时应用于所有平台,从而节省大量时间和金钱。详细考虑 React Native 在底层的工作原理使我们能够在规划阶段识别潜在问题并解决它们。

此外,我们开始检查 React Native 的基本组件,并且随着每一章的新内容,我们将更多地了解它们。

在下一章中,你将学习如何开始新的 React Native 项目。

第十七章:快速启动 React Native 项目

在本章中,你将开始使用 React Native。幸运的是,创建新项目时涉及的大量样板代码由命令行工具为你处理。我们将探讨 React Native 应用程序的不同 CLI 工具,并创建我们的第一个简单应用程序,你将能够直接在你的设备上上传并启动。

在本章中,我们将涵盖以下主题:

  • 探索 React Native CLI 工具

  • 安装和使用 Expo 命令行工具

  • 在你的手机上查看你的应用程序

  • 在 Expo Snack 上查看你的应用程序

技术要求

你可以在 GitHub 上找到本章的代码文件,地址为github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter17

探索 React Native CLI 工具

为了简化并加快开发过程,我们使用特殊的命令行工具,这些工具安装带有应用程序模板、依赖项和其他工具的空白项目,以启动、构建和测试。我们可以应用两种主要的 CLI 方法:

  • React Native CLI

  • Expo CLI

React Native CLI是由 Meta 创建的一个工具。该项目基于原始 CLI 工具,包括三个部分:原生 iOS 和 Android 项目以及一个 React Native JavaScript 应用程序。要开始,你需要XcodeAndroid Studio。React Native CLI 的主要优势之一是其灵活性。你可以将任何具有原生模块的库连接起来,或者直接编写代码到原生部分。然而,所有这些都需要至少对移动开发有基本的了解。

Expo CLI是开发 React Native 应用程序的大生态系统的一部分。Expo是一个用于通用 React 应用程序的框架和平台。围绕 React Native 和原生平台构建,它允许你从单个 JavaScript/TypeScript 代码库中构建、部署、测试,并快速迭代 iOS、Android 和 Web 应用程序。

Expo 框架提供以下功能:

  • Expo CLI:一个命令行工具,可以创建空白项目,然后运行、构建和更新它们。

  • Expo Go:一个 Android 和 iOS 应用程序,可以直接在你的设备上运行你的项目(无需编译和签名原生应用程序)并与你的整个团队共享。

  • Expo Snack:一个在线游乐场,允许你在浏览器中开发 React Native 应用程序。

  • Expo 应用程序服务EAS):一套深度集成的云服务,用于 Expo 和 React Native 应用程序。应用程序可以使用 EAS 在云中编译、签名并上传到商店。

Expo 附带大量可用的功能。以前,它对项目施加了限制,因为它不支持自定义原生模块。然而,这种限制不再存在。现在,Expo 支持通过 Expo 开发构建添加自定义原生代码和自定义原生代码(Android/Xcode 项目)。要使用任何自定义原生代码,您可以创建开发构建和配置插件。

由于 Expo 对没有移动开发技能的新开发者很有用,我们将用它来设置我们的第一个 React Native 项目。

安装和使用 Expo 命令行工具

Expo 命令行工具负责创建项目所需的所有脚手架,以便运行基本的 React Native 应用程序。此外,Expo 还有一些其他工具,使在开发期间运行我们的应用程序变得简单直接。但首先,我们需要设置环境和项目:

  1. 在我们能够使用 Expo 之前,我们需要安装Node.jsGitWatchman。Watchman 是一个用于监视项目中文件的工具,当文件发生变化时,它可以触发诸如重建等操作。所有必需的工具和详细信息都可以在这里找到:docs.expo.dev/get-started/installation/#requirements

  2. 一旦安装完成,我们可以通过运行以下命令来启动新项目:

    npx create-expo-app --template 
    
  3. 接下来,CLI 将询问您关于您未来项目的问题。您应该在终端看到类似以下内容:

    ? Choose a template: ' - Use arrow-keys. Return to submit.
        Blank
    ❯   Blank (TypeScript) - blank app with TypeScript enabled
        Navigation (TypeScript)
        Blank (Bare) 
    

    我们将选择Blank (TypeScript)选项。

  4. 接下来,进程将询问您项目名称:

    ? What is your app named? ' my-project 
    

    让我们称它为my-project

  5. 安装所有依赖项后,Expo 将为您完成项目的创建:

    ✅ Your project is ready! 
    

现在我们已经创建了一个空白 React Native 项目,您将学习如何在您的计算机上启动 Expo 开发服务器并在您的设备之一上查看应用程序。

在手机上查看您的应用

为了在开发期间在您的设备上查看 React Native 项目,我们需要启动 Expo 开发服务器:

  1. 在命令行终端中,请确保您位于项目目录中:

    cd path/to/my-project 
    
  2. 一旦您进入my-project目录,您可以通过运行以下命令来启动开发服务器:

    npm start 
    
  3. 这将在终端显示有关开发服务器的一些信息:

    ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
    █ ▄▄▄▄▄ █▄▀▀▄▄▀▀█ █ ▄▄▄▄▄ █
    █ █   █ ███▄█  ▀▄▄█ █   █ █
    █ █▄▄▄█  █▄▀▄▀ ██▀█ █▄▄▄█ █
    █▄▄▄▄▄▄▄█ █ ▀▄▀ ▀ █▄▄▄▄▄▄▄█
    █ ▄▀▄▄▀▄▀█ ▄▄▀▀█▀ █▄█▀█▀▀▄█
    █ █▄█▀▀▄▀▄▀  ▀█▄▄ ▀███▄▀▀ █
    █ █▄ ▀█▄▄▀▄█▄▄▀▄ █ ▄▀▀█▀ ██
    █ ▄ ▀▄▀▄▄ █▄ ▄▄▀ ▄  ██▄▀  █
    █▄██▄▄█▄▄ █ ▀▀  █ ▄▄▄  ▄▀▄█
    █ ▄▄▄▄▄ ██ █▄▀  █ █▄█ ██▀▄█
    █ █   █ █ ███▄██▄ ▄  ▄ █  █
    █ █▄▄▄█ █▀█▄█▄█  ▄█▀▀▄█   █
    █▄▄▄▄▄▄▄█▄▄██▄▄▄▄▄▄█▄▄███▄█
    ' Metro waiting on exp://192.168.1.15:8081
    ' Scan the QR code above with Expo Go (Android) or the Camera app (iOS)
    ' Using Expo Go
    ' Press s │ switch to development build
    ' Press a │ open Android
    ' Press i │ open iOS simulator
    ' Press w │ open web
    	' Press j │ open debugger
    ' Press r │ reload app
    ' Press m │ toggle menu
    ' Press o │ open project code in your editor
    ' Press ? │ show all commands 
    
  4. 为了在我们的设备上查看应用程序,我们需要安装Expo Go应用程序。您可以在 Android 设备的 Play Store 或 iOS 设备的 App Store 中找到它。一旦您安装了 Expo,您可以使用设备上的原生相机扫描二维码:图 16.2 – Expo Go 应用

    图 17.1:Expo Go 应用

    如果您登录到 Expo Go 和 Expo CLI,您将能够运行应用程序而无需二维码。在图 17.1中,您可以查看为my-project打开的开发会话;如果您点击它,应用程序将运行。

  5. 一旦扫描二维码或您在 Expo Go 上打开的会话被点击,您将在终端中注意到新的日志和新的连接设备:

    iOS Bundling complete 205ms 
    
  6. 现在您应该能看到应用程序正在运行:

图 17.2:在 Expo Go 中打开的应用程序

到目前为止,您已经准备好开始开发您的应用程序了。实际上,如果您同时想要使用多个物理设备,您可以重复此过程。这个 Expo 设置的最好部分是我们可以在计算机上对代码进行更新时,在物理设备上免费获得实时重新加载。现在让我们尝试一下,以确保一切按预期工作:

  1. 让我们打开 my-project 文件夹内的 App.ts 文件。在那里,您将看到 App 组件:

    export default function App() {
      return (
        <View style={styles.container}>
          <Text>Open up App.tsx to start working on your app!</Text>
          <StatusBar style="auto" />
        </View>
      );
    } 
    
  2. 现在让我们进行一个小小的样式更改,使字体加粗:

    export default function App() {
      return (
        <View style={styles.container}>
          <Text style={styles.text}>
            Open up App.tsx to start working on your app!
          </Text>
          <StatusBar style="auto" />
        </View>
      );
    }
    const styles = StyleSheet.create({
      container: {
        flex: 1,
        backgroundColor: "#fff",
        alignItems: "center",
        justifyContent: "center",
      },
    **text****: {** **fontWeight****:** **"bold"** **},**
    }); 
    
  3. 我们添加了一个名为 text 的新样式,并将其应用于 Text 组件。如果您保存文件并返回到您的设备,您将立即看到更改被应用:

图 17.3:更新了文本样式的应用程序

现在您能够在物理设备上本地运行您的应用程序了,是时候看看如何使用 Expo Snack 服务在多种虚拟设备模拟器上运行您的 React Native 应用程序了。

在 Expo Snack 中查看您的应用程序

Expo 提供的 Snack 服务是您 React Native 代码的游乐场。它允许您像在本地计算机上一样组织您的 React Native 项目文件。如果您最终组合出值得构建的东西,您可以导出您的 Snack。您还可以创建一个 Expo 账户并保存您的 Snacks 以继续工作或与他人分享。您可以通过此链接找到 Expo Snack:snack.expo.dev/.

我们可以在 Expo Snack 中从头开始创建一个 React Native 应用程序,它将被存储在 Expo 账户中,或者我们可以从 Git 仓库导入现有项目。导入仓库的好处是,当您向 Git 推送更改时,您的 Snack 也会更新。本章中我们工作的示例的 Git URL 看起来是这样的:github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter17/my-project.

我们可以在 Snack 项目菜单中点击 导入 Git 仓库 按钮,并粘贴此 URL:

图 17.4:将 Git 仓库导入到 Expo Snack

一旦导入仓库并保存 Snack,您将获得一个更新的 Snack URL,该 URL 反映了 Git 仓库的位置。例如,本章中的 Snack URL 看起来是这样的:https://snack.expo.dev/@sakhnyuk/2a2429.

如果您打开此 URL,Snack 界面将加载,您可以在运行之前对代码进行更改以进行测试。Snack 的主要优势是能够在虚拟化设备上轻松运行。运行应用程序在虚拟设备上的控件可以在 UI 的右侧找到,看起来像这样:

图 17.5:Expo Snack 模拟器

在手机图片上方的顶部控制栏用于选择要模拟的设备类型:AndroidiOSWeb点击播放按钮将启动选定的虚拟设备。在您的设备上运行按钮允许您使用二维码方法在 Expo Go 中运行应用。

这是我们的应用在虚拟 iOS 设备上的样子:

图片

图 17.6:Expo Snack iOS 模拟器

然后这是我们的应用在虚拟安卓设备上的样子:

图片

图 17.7:Expo Snack 安卓模拟器

此应用仅显示文本并对它应用一些样式,因此在不同的平台上看起来几乎相同。随着我们在这本书的 React Native 章节中继续前进,你会看到 Snack 这样的工具在比较两个平台以及理解它们之间的差异方面是多么有用。

摘要

在本章中,你学习了如何使用 Expo 命令行工具启动 React Native 项目。首先,你学习了如何安装 Expo 工具。然后,你学习了如何初始化一个新的 React Native 项目。接下来,你启动了 Expo 开发服务器,并了解了开发服务器 UI 的各个部分。

特别是,你学习了如何将开发服务器与任何你想要测试应用的设备上的 Expo 应用连接起来。Expo 还提供了 Snack 服务,它允许我们实验代码片段或整个 Git 仓库。你学习了如何导入仓库并在虚拟 iOS 和 Android 设备上运行它。

在下一章中,我们将探讨如何在我们的 React Native 应用中构建响应式布局。

第十八章:使用 Flexbox 构建响应式布局

在本章中,你将感受到在移动设备屏幕上布局组件的感觉。幸运的是,React Native 填充了许多你过去可能用于在 Web 应用程序中实现页面布局的 CSS 属性。

在你深入实现布局之前,你将简要了解 Flexbox 以及如何在 React Native 应用程序中使用 CSS 样式属性:它并不完全像你习惯的常规 CSS 样式表那样。然后,你将使用 Flexbox 实现几个 React Native 布局。

在本章中,我们将涵盖以下主题:

  • 介绍 Flexbox

  • 介绍 React Native 样式

  • 使用 Styled Components 库

  • 构建 Flexbox 布局

技术要求

你可以在 GitHub 上找到本章中存在的代码文件,链接为github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter18

介绍 Flexbox

在引入灵活的盒模型布局模型到 CSS 之前,用于构建布局的各种方法都是复杂的,并且容易出错。例如,我们使用了浮动,它最初是为了让文本围绕图像而设计的,用于表格布局。Flexbox通过抽象出许多你通常需要提供的属性来解决这一问题,以便使布局工作。

从本质上讲,Flexbox 模型可能对你来说听起来就是这样:一个灵活的盒模型。这就是 Flexbox 的美丽之处:它的简单性。你有一个充当容器的盒子,你在这个盒子内有子元素。容器和子元素在屏幕上的渲染方式都是灵活的,如下所示:

图片 1

图 18.1:Flexbox 元素

Flexbox 容器有一个方向,要么是列(上/下),要么是行(左/右)。这实际上在我最初学习 Flexbox 时让我感到困惑;我的大脑拒绝相信行是从左到右并排组织的。行是堆叠在一起的!要记住的关键点是,它是盒子伸缩的方向,而不是盒子在屏幕上放置的方向。

对于 Flexbox 概念的更深入探讨,请参阅css-tricks.com/snippets/css/a-guide-to-Flexbox

现在我们已经从高层次上了解了 Flexbox 布局的基础知识,是时候学习 React Native 应用程序中的样式是如何工作的了。

介绍 React Native 样式

是时候实现你的第一个 React Native 应用程序了,超越由Expo生成的样板代码。我想确保你在开始下一节实现 Flexbox 布局之前,对使用 React Native 样式表感到舒适。

这就是 React Native 样式表的样子:

import { Platform, StyleSheet, StatusBar } from "react-native";
export default StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: "center",
    alignItems: "center",
    backgroundColor: "ghostwhite",
    ...Platform.select({
      ios: { paddingTop: 20 },
      android: { paddingTop: StatusBar.currentHeight },
    }),
  },
  box: {
    width: 100,
    height: 100,
    justifyContent: "center",
    alignItems: "center",
    backgroundColor: "lightgray",
  },
  boxText: {
    color: "darkslategray",
    fontWeight: "bold",
  },
}); 

这是一个 JavaScript 模块,而不是 CSS 模块。如果你想声明 React Native 样式,你需要使用纯对象。然后,你调用 StyleSheet.create() 并从样式模块导出它。请注意,样式名称与 web CSS 非常相似,只是它们是驼峰式命名的;例如,justifyContent 而不是 justify-content

如你所见,这个样式表有三个样式:containerboxboxText。在 container 样式中,有一个对 Platform.select() 的调用:

...Platform.select({
ios: { paddingTop: 20 },
android: { paddingTop: StatusBar.currentHeight }
}) 

这个函数将根据移动设备的平台返回不同的样式。在这里,你正在处理顶级 container 视图的顶部填充。你可能会在大多数应用中使用这段代码来确保你的 React 组件不会渲染在设备的状态栏下方。根据平台的不同,填充需要不同的值。如果是 iOS,paddingTop20。如果是 Android,paddingTop 将是 StatusBar.currentHeight 的值。

之前的 Platform.select() 代码是一个需要实现平台差异解决方案的例子。例如,如果 StatusBar.currentHeight 在 iOS 和 Android 上都可用,你就不需要调用 Platform.select()

让我们看看这些样式是如何导入并应用到 React Native 组件中的:

import React from "react";
import { Text, View } from "react-native";
import styles from "./styles";
export default function App() {
  return (
    <View style={styles.container}>
      <View style={styles.box}>
        <Text style={styles.boxText}>I'm in a box</Text>
      </View>
    </View>
  );
} 

样式是通过 style 属性分配给每个组件的。你正在尝试渲染一个在屏幕中间带有文本的盒子。让我们确保它看起来像我们预期的那样。

图片 2

图 18.2:屏幕中间的盒子

我们已经找到了如何使用内置模块应用样式的办法,但定义样式的方法不止一种。我们还有在 React Native 中编写 CSS 的选项。让我们快速了解一下。

使用 Styled Components 库

Styled Components 是一个 CSS-in-JS 库,它使用纯 CSS 来设置 React Native 组件的样式。使用这种方法,你不需要通过对象定义样式类并提供样式属性。CSS 本身是通过 styled-components 提供的标签模板字面量来确定的。

要安装 styled-components,在你的项目中运行以下命令:

npm install --save styled-components 

让我们尝试重写 介绍 React Native 样式 部分的组件。这是我们的 Box 组件的样子:

import styled from "styled-components/native";
const Box = styled.View'
  width: 100px;
  height: 100px;
  justify-content: center;
  align-items: center;
  background-color: lightgray;
';
const BoxText = styled.Text'
  color: darkslategray;
  font-weight: bold;
'; 

在这个例子中,我们有两个组件,BoxBoxText。现在我们可以像平常一样使用它们,但不需要任何其他额外的样式属性:

const App = () => {
  return (
    <Box>
      <BoxText>I'm in a box</BoxText>
    </Box>
  );
}; 

在接下来的章节中,我将使用 StyleSheet 对象,但为了避免性能问题,我会避免使用 styled-components。如果你想了解更多关于 styled-components 的信息,你可以在这里阅读更多:styled-components.com/

完美!现在你已经了解了如何在 React Native 元素上设置样式,让我们使用 Flexbox 开始创建一些屏幕布局。

构建 Flexbox 布局

在本节中,你将了解你可以在你的 React Native 应用程序中使用的一些潜在布局。我不想强调一个布局比另一个布局更好。相反,我会向你展示 Flexbox 布局模型在移动屏幕上的强大之处,这样你就可以设计最适合你应用程序的布局。

简单的三列布局

首先,让我们实现一个简单的布局,包含三个在列方向(从上到下)上伸缩的部分。我们首先看看我们想要达到的结果。

图片 3

图 18.3:简单三列布局

在这个例子中,想法是这样的:你给三个屏幕部分添加样式和标签,使它们突出。换句话说,在真实的应用程序中,这些组件可能不会有任何样式,因为它们是用来在屏幕上排列其他组件的。

现在,让我们看看创建这个屏幕布局所使用的组件:

import React from "react";
import { Text, View } from "react-native";
import styles from "./styles";
export default function App() {
  return (
    <View style={styles.container}>
      <View style={styles.box}>
        <Text style={styles.boxText}>#1</Text>
      </View>
      <View style={styles.box}>
        <Text style={styles.boxText}>#2</Text>
      </View>
      <View style={styles.box}>
        <Text style={styles.boxText}>#3</Text>
      </View>
    </View>
  );
} 

容器视图(最外层的<View>组件)是列,子视图是行。<Text>组件用于标记每一行。从 HTML 元素的角度来看,<View>类似于<div>元素,而<Text>类似于<p>元素。

也许这个例子可以被称为三行布局,因为它有三行。但与此同时,这三个布局部分正在它们所在的列方向上伸缩。使用对你最有概念意义的命名约定。

现在,让我们看看创建这个布局所使用的样式:

import { Platform, StyleSheet, StatusBar } from "react-native";
export default StyleSheet.create({
  container: {
    flex: 1,
    flexDirection: "column",
    alignItems: "center",
    justifyContent: "space-around",
    backgroundColor: "ghostwhite",
    ...Platform.select({
      ios: { paddingTop: 20 },
      android: { paddingTop: StatusBar.currentHeight }
    })
  },
  box: {
    width: 300,
    height: 100,
    justifyContent: "center",
    alignItems: "center",
    backgroundColor: "lightgray",
    borderWidth: 1,
    borderStyle: "dashed",
    borderColor: "darkslategray"
  },
  boxText: {
    color: "darkslategray",
    fontWeight: "bold"
  }
}); 

containerflexflexDirection属性使得行的布局可以从上到下流动。alignItemsjustifyContent属性分别将子元素对齐到容器的中心,并在它们周围添加空间。

让我们看看当您将设备从纵向旋转到横向时,这个布局看起来如何:

图片 4

图 18.4:横幅方向

Flexbox 自动为你解决了布局问题。然而,你可以稍微改进一下。例如,横幅方向现在左右两侧有很多浪费的空间。你可以为你要渲染的盒子创建自己的抽象。在下一节中,我们将改进这个布局。

改进的三列布局

有几件事情我认为你可以从上一个例子中改进。让我们调整样式,使得 Flexbox 的子元素可以拉伸以利用可用空间。你还记得在最后一个例子中,当您将设备从纵向旋转到横向时吗?有很多浪费的空间。如果组件能自动调整自己会很好。下面是新样式模块的样子:

import { Platform, StyleSheet, StatusBar } from "react-native";
export default StyleSheet.create({
  container: {
    flex: 1,
    flexDirection: "column",
    backgroundColor: "ghostwhite",
    justifyContent: "space-around",
    ...Platform.select({
      ios: { paddingTop: 20 },
      android: { paddingTop: StatusBar.currentHeight },
    }),
  },
  box: {
    height: 100,
    justifyContent: "center",
    alignSelf: "stretch",
    alignItems: "center",
    backgroundColor: "lightgray",
    borderWidth: 1,
    borderStyle: "dashed",
    borderColor: "darkslategray",
  },
  boxText: {
    color: "darkslategray",
    fontWeight: "bold",
  },
}); 

关键的改变在于 alignSelf 属性。这告诉具有 box 样式的元素根据其容器的 flexDirection 改变其 widthheight(取决于容器的 flexDirection),以填充空间。此外,box 样式不再定义 width 属性,因为这将现在实时计算。

下面是在纵向模式下的部分外观:

图片 5

图 18.5:纵向布局中的改进三列布局

现在,每个部分都占据了屏幕的全宽,这正是你想要的。实际上,浪费空间的问题在横向布局中更为普遍,所以让我们旋转设备,看看这些部分现在会发生什么。

图片 6

图 18.6:横向布局中的改进三列布局

现在布局正在利用屏幕的整个宽度,无论方向如何。最后,让我们实现一个可以由 App.js 使用的正确 Box 组件,而不是在布局中放置重复的样式属性。以下是 Box 组件的外观:

import React from "react";
import { PropTypes } from "prop-types";
import { View, Text } from "react-native";
import styles from "./styles";
export default function Box({ children }) {
  return (
    <View style={styles.box}>
      <Text style={styles.boxText}>{children}</Text>
    </View>
  );
}
Box.propTypes = {
  children: PropTypes.node.isRequired,
}; 

现在,你已经有了良好的布局基础。接下来,你将学习关于其他方向的弹性:从左到右。

灵活的行

在本节中,你将学习如何使屏幕布局部分从顶部延伸到底部。要做到这一点,你需要一个 灵活的行。以下是该屏幕的样式:

import { Platform, StyleSheet, StatusBar } from "react-native";
export default StyleSheet.create({
  container: {
    flex: 1,
    flexDirection: "row",
    backgroundColor: "ghostwhite",
    alignItems: "center",
    justifyContent: "space-around",
    ...Platform.select({
      ios: { paddingTop: 20 },
      android: { paddingTop: StatusBar.currentHeight },
    }),
  },
  box: {
    width: 100,
    justifyContent: "center",
    alignSelf: "stretch",
    alignItems: "center",
    backgroundColor: "lightgray",
    borderWidth: 1,
    borderStyle: "dashed",
    borderColor: "darkslategray",
  },
  boxText: {
    color: "darkslategray",
    fontWeight: "bold",
  },
}); 

下面是 App 组件,使用你在上一节中实现的相同的 Box 组件:

import React from "react";
import { Text, View, StatusBar } from "react-native";
import styles from "./styles";
import Box from "./Box";
export default function App() {
  return (
    <View style={styles.container}>
      <Box>#1</Box>
      <Box>#2</Box>
    </View>
  );
} 

下面是在纵向模式下的屏幕结果:

图片 7

图 18.7:纵向布局中的灵活行

由于 alignSelf 属性,两列从屏幕顶部延伸到底部,实际上并没有指定拉伸的方向。两个 Box 组件从顶部到底部拉伸,因为它们以 flex 行 的形式显示。注意这两个部分之间的间距是如何从左到右变化的?这是因为容器具有 flexDirection 属性,其值为 row

现在,让我们看看这个弹性方向如何影响屏幕旋转到横向布局时的布局。

图片 8

图 18.8:横向布局中的灵活行

由于 FlexboxjustifyContent 样式属性值为 space-around,空间会按比例添加到左侧、右侧以及部分之间。在下一节中,你将了解灵活的网格。

灵活的网格

有时,你需要一个像网格一样流动的屏幕布局。例如,如果你有多个宽度相同、高度相同的部分,但你不确定这些部分将渲染多少个?Flexbox 使得构建从左到右流动直到屏幕末尾的行变得容易。然后,它将自动在下一行从左到右渲染元素。

下面是一个纵向模式下的布局示例:

图片 9

图 18.9:纵向布局的灵活网格

这种方法的优点是您不需要事先知道给定行中有多少列。每个子组件的尺寸决定了什么可以放入给定行中。

要查看创建此布局所使用的样式,您可以点击此链接:github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter18/flexible-grids/styles.ts.

这是渲染每个部分的App组件:

import React from "react";
import { View, StatusBar } from "react-native";
import styles from "./styles";
import Box from "./Box";
const boxes = new Array(10).fill(null).map((v, i) => i + 1);
export default function App() {
  return (
    <View style={styles.container}>
      <StatusBar hidden={false} />
      {boxes.map((i) => (
        <Box key={i}>#{i}</Box>
      ))}
    </View>
  );
} 

最后,让我们确保横向布局与这个布局兼容:

图片 10

图 17.10:横向布局的灵活网格

您可能已经注意到右侧有一些多余的空间。记住,这些部分之所以在这个书中可见,是因为我们希望它们可见。在实际应用中,它们只是对其他 React Native 组件进行分组。然而,如果屏幕右侧的空间成为问题,您可以尝试调整子组件的边距和宽度。

现在您已经了解了灵活网格的工作原理,我们将接下来查看灵活的行和列。

灵活的行和列

让我们学习如何组合行和列来为您的应用创建一个复杂的布局。例如,有时您需要将列嵌套在行内或行嵌套在列内的能力。要查看嵌套列在行内的应用的App组件,您可以点击此链接:github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter18/flexible-rows-and-columns/App.tsx.

您已经为布局组件(<Row><Column>)和内容组件(<Box>)创建了抽象。让我们看看这个屏幕看起来像什么:

图片 11

图 18.11:灵活的行和列

这个布局可能看起来很熟悉,因为您在灵活网格部分已经做过。与图 18.9相比,关键的区别在于这些内容部分的顺序。

例如,#2不会放在#1的右边,它会放在下面。这是因为我们将#1#2放在了<Column>中。#3#4也是同样的情况。这两个列被放置在同一行中。然后,下一行开始,以此类推。

这只是通过嵌套行 Flexbox 和列 Flexbox 所能实现的可能布局之一。现在让我们看看Row组件:

import React from "react";
import PropTypes from "prop-types";
import { View, Text } from "react-native";
import styles from "./styles";
export default function Box({ children }) {
  return (
    <View style={styles.box}>
      <Text style={styles.boxText}>{children}</Text>
    </View>
  );
}
Box.propTypes = {
  children: PropTypes.node.isRequired,
}; 

此组件将行样式应用于<View>组件。当创建复杂布局时,App组件中的最终结果是更干净的 JSX 标记。最后,让我们看看Column组件:

import React from "react";
import PropTypes from "prop-types";
import { View } from "react-native";
import styles from "./styles";
export default function Column({ children }) {
  return <View style={styles.column}>{children}</View>;
}
Column.propTypes = {
  children: PropTypes.node.isRequired,
}; 

这看起来就像Row组件,只是应用了不同的样式。它也服务于与Row相同的目的:为其他组件中的布局提供更简单的 JSX 标记。

摘要

本章向你介绍了 React Native 中的样式。虽然你可以使用许多你熟悉的相同 CSS 样式属性,但用于 Web 应用的 CSS 样式表看起来非常不同。具体来说,它们由纯 JavaScript 对象组成。

然后,你学习了如何使用 React Native 的主要布局机制:Flexbox。这是目前大多数 Web 应用布局的首选方式,因此能够在原生应用中重用这种方法是有意义的。你创建了几个不同的布局,并看到了它们在纵向和横向方向上的外观。

在下一章中,你将开始为你的应用实现导航功能。

第十九章:屏幕间导航

本章的重点是导航 React Native 应用程序中构成屏幕之间的导航。在原生应用中的导航与在网页应用中的导航略有不同:主要是因为用户没有意识到任何 URL 的概念。在 React Native 的早期版本中,有一些原始的导航组件,你可以使用它们来控制屏幕间的导航。这些组件存在一些挑战,导致完成基本导航任务需要更多的代码。例如,初始导航组件,如 NavigatorNavigatorIOS,实现起来复杂且功能不足,导致性能问题和跨平台的不一致性。

更新版本的 React Native 鼓励你使用 react-navigation 包,这将是本章的重点,尽管还有其他几个选项。你将学习导航基础知识、向屏幕传递参数、更改标题内容、使用标签和抽屉导航以及使用导航处理状态。我们还将探讨一种现代导航方法,称为基于文件的导航。

本章我们将涵盖以下主题:

  • 导航的基本知识

  • 路由参数

  • 导航标题

  • 标签和抽屉导航

  • 基于文件的导航

技术要求

你可以在 GitHub 上找到本章的代码文件,链接为 github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter19

导航的基本知识

在 React Native 中,导航至关重要,因为它管理着应用中不同屏幕之间的过渡。它通过逻辑组织应用流程来提高用户体验,使用户能够直观地了解如何访问功能和信息。有效的导航使应用感觉快速且响应灵敏,减少用户挫败感并提高用户参与度。它还支持应用架构,通过明确定义组件之间的链接和交互,使应用更容易扩展和维护。没有适当的导航,应用可能会变得令人困惑且难以使用,这会严重影响其成功和用户留存。本节将通过创建一个小型应用来引导你设置应用中的导航,在这个小应用中你可以导航到不同的屏幕。

让我们从使用 react-navigation 包从一个页面跳转到另一个页面的基本操作开始。

在开始之前,你应该将 react-navigation 包安装到一个新项目中,以及一些与示例相关的附加依赖:

npm install @react-navigation/native 

然后,使用 expo 安装本地依赖:

npx expo install react-native-screens react-native-safe-area-context 

上一节的安装步骤将适用于本章的每个示例,但我们还需要添加一个与堆栈导航相关的包:

npm install @react-navigation/native-stack 

现在,我们已经准备好开发导航。下面是 App 组件的样貌:

import Home from "./Home";
import Settings from "./Settings";
const Stack = createNativeStackNavigator<RootStackParamList>();
export default function App() {
  return (
    <NavigationContainer>
      <Stack.Navigator>
        <Stack.Screen name="Home" component={Home} />
        <Stack.Screen name="Settings" component={Settings} />
      </Stack.Navigator>
    </NavigationContainer>
  );
} 

createNativeStackNavigator()是一个设置导航的函数。它返回一个对象,包含两个属性,即ScreenNavigator组件,它们用于配置堆栈导航器。

此函数的第一个参数映射到可以导航的屏幕组件。第二个参数用于更通用的导航选项:在这种情况下,你正在告诉导航器主页应该是默认的屏幕组件。<NavigationContainer>组件是必要的,这样屏幕组件就能获得它们需要的所有导航属性。

这是Home组件的外观:

type Props = NativeStackScreenProps<RootStackParamList>;
export default function Home({ navigation }: Props) {
  return (
    <View style={styles.container}>
      <StatusBar barStyle="dark-content" />
      <Text>Home Screen</Text>
      <Button
        title="Settings"
        onPress={() => navigation.navigate("Settings")}
      />
    </View>
  );
} 

这是一个典型的功能 React 组件。你在这里可以使用基于类的组件,但不需要,因为没有状态或生命周期方法。它渲染一个应用了容器样式的View组件。

这后面跟着一个Text组件,用于标记screen,然后是一个Button组件。screen可以是任何你想要的东西:它只是一个普通的 React Native 组件。导航组件为你处理路由和屏幕之间的转换。

此按钮的onPress处理程序在点击时导航到设置屏幕。这是通过调用navigation.navigate('Settings')完成的。导航属性通过react-navigation传递给你的screen组件,并包含你需要的所有路由功能。与在 React web 应用中处理 URL 相比,这里你调用导航器 API 函数,并传递屏幕名称。

要在导航中获得类型安全的环境,我们需要定义一个名为RootStackParamList的类型,它包含有关我们路由的所有信息。我们使用它和NativeStackScreenProps一起定义路由Props。这是RootStackParamList的外观:

export type RootStackParamList = {
  Home: undefined;
  Settings: undefined;
}; 

我们为每个路由传递 undefined,因为我们没有在路由上设置任何参数。因此,我们只能用SettingsHome调用navigation.navigate()

让我们看看Settings组件:

type Props = NativeStackScreenProps<RootStackParamList>;
export default function Settings({ navigation }: Props) {
  return (
    <View style={styles.container}>
      <StatusBar barStyle="dark-content" />
      <Text>Settings Screen</Text>
      <Button title="Home" onPress={() => navigation.navigate("Home")} />
    </View>
  );
} 

此组件与Home组件类似,只是文本不同,当按钮被点击时,你将被带回到主页

这就是主页的外观:

图片 1

图 19.1:主页

如果你点击设置按钮,你将被带到设置屏幕,其外观如下:

图片 2

图 19.2:设置屏幕

这个屏幕看起来几乎与Home 屏幕完全相同。它有不同的文本和不同的按钮,点击该按钮将带你返回到Home 屏幕。然而,还有另一种返回Home 屏幕的方法。看看屏幕顶部,你会注意到一个白色的导航栏。在导航栏的左侧,有一个返回箭头。这就像网页浏览器中的返回按钮一样,会带你回到上一个屏幕。react-navigation 的好处是它会为你渲染这个导航栏。

在这个导航栏设置好之后,你不必担心你的布局样式如何影响状态栏。你只需要担心你每个屏幕的布局。

如果你在这个 Android 应用上运行,你会在导航栏中看到相同的返回按钮。但你也可以使用大多数 Android 设备上应用外部的标准返回按钮。

在下一节中,你将学习如何向你的路由传递参数。

路由参数

当你开发 React Web 应用时,一些路由中包含动态数据。例如,你可以链接到一个详情页面,在该 URL 中,你将有一个某种标识符。组件将拥有渲染特定详细信息所需的内容。在react-navigation中,也存在同样的概念。你不仅可以指定你想要导航到的屏幕名称,还可以传递额外的数据。

让我们看看路由参数的实际应用。

我们将从App组件开始:

const Stack = createNativeStackNavigator<RootStackParamList>();
export default function App() {
  return (
    <NavigationContainer>
      <Stack.Navigator>
        <Stack.Screen name="Home" component={Home} />
        <Stack.Screen name="Details" component={Details} />
      </Stack.Navigator>
    </NavigationContainer>
  );
} 

这看起来就像导航基础部分中的例子,只不过没有Settings页面,而是有一个Details页面。这是你想要动态传递数据以渲染适当信息的页面。

要为我们的路由启用 TypeScript,需要定义RootStackParamList

export type RootStackParamList = {
  Home: undefined;
  Details: { title: string };
}; 

接下来,让我们看看Home屏幕组件:

type Props = NativeStackScreenProps<RootStackParamList, "Home">;
export default function Home({ navigation }: Props) {
  return (
    <View style={styles.container}>
      <StatusBar barStyle="dark-content" />
      <Text>Home Screen</Text>
      <Button
        title="First Item"
        onPress={() => navigation.navigate("Details", { title: "First Item" })}
      />
      <Button
        title="Second Item"
        onPress={() => navigation.navigate("Details", { title: "Second Item" })}
      />
      <Button
        title="Third Item"
        onPress={() => navigation.navigate("Details", { title: "Third Item" })}
      />
    </View>
  );
} 

Home屏幕有三个Button组件,每个都导航到Details屏幕。注意,在navigation.navigate()调用中,除了屏幕名称外,每个都有一个第二个参数。这些参数是包含特定数据的对象,这些数据被传递到Details屏幕。

接下来,让我们看看Details屏幕,看看它是如何消费这些路由参数的:

type Props = NativeStackScreenProps<RootStackParamList, "Details">;
export default function ({ route }: Props) {
  const { title } = route.params;
  return (
    <View style={styles.container}>
      <StatusBar barStyle="dark-content" />
      <Text>{title}</Text>
    </View>
  );
} 

尽管这个例子只传递了一个title参数,但你可以向屏幕传递你需要的任意多个参数。你可以使用路由属性paramsvalue来访问这些参数。

这是渲染后的Home 屏幕的样子:

图片 3

图 19.3:Home 屏幕

如果你点击第一个项目按钮,你将被带到使用路由参数数据渲染的Details屏幕:

图片 4

图 19.4:Details 屏幕

你可以点击导航栏中的返回按钮回到主屏幕。如果你点击主屏幕上的其他任何按钮,你将返回到带有更新数据的详情屏幕。路由参数是必要的,以避免不得不编写重复的组件。你可以将向navigator.navigate()传递参数视为向 React 组件传递 props。

在下一节中,你将学习如何用内容填充导航部分标题。

导航标题

本章中你创建的导航栏到目前为止相当简单。这是因为你没有配置它们执行任何操作,所以react-navigation只会渲染一个带有返回按钮的普通栏。你创建的每个屏幕组件都可以配置特定的导航标题内容。

让我们基于在Route参数部分讨论的例子进行扩展,该例子使用了按钮来导航到详情页面。

App组件有重大更新,让我们看看它:

const Stack = createNativeStackNavigator<RoutesParams>();
export default function App() {
  return (
    <NavigationContainer>
      <Stack.Navigator>
        <Stack.Screen name="Home" component={Home} />
        <Stack.Screen
          name="Details"
          component={Details}
          options={({ route }) => ({
            headerRight: () => {
              return (
                <Button
                  title="Buy"
                  onPress={() => {}}
                  disabled={route.params.stock === 0}
                />
              );
            },
          })}
        />
      </Stack.Navigator>
    </NavigationContainer>
  );
} 

Screen组件接受options属性作为对象或函数,以提供额外的屏幕属性。

使用headerRight选项在导航栏的右侧添加一个Button组件。这就是stock参数发挥作用的地方。如果这个值是0,因为stock中没有内容,你想要禁用购买按钮。

在我们的例子中,我们传递options作为一个函数,并读取stock屏幕参数来禁用按钮。这是向Screen组件传递选项的几种方法之一。我们将应用另一种方法到Details组件。

要了解股票 props 是如何传递的,请看这里的Home组件:

type Props = NativeStackScreenProps<RoutesParams, "Home">;
export default function Home({ navigation }: Props) {
  return (
    <View style={styles.container}>
      <StatusBar barStyle="dark-content" />
      <Button
        title="First Item"
        onPress={() =>
          navigation.navigate("Details", {
            title: "First Item",
            content: "First Item Content",
            stock: 1,
          })
        }
      />
      ...
    </View>
  );
} 

首先要注意的是,每个按钮都向Details组件传递了更多的路由参数:contentstock。你很快就会明白原因。

接下来,让我们看看Details组件:

type Props = NativeStackScreenProps<RoutesParams, "Details">;
export default function Details({ route, navigation }: Props) {
  const { content, title } = route.params;
  React.useEffect(() => {
    navigation.setOptions({ title });
  }, []);
  return (
    <View style={styles.container}>
      <StatusBar barStyle="dark-content" />
      <Text>{content}</Text>
    </View>
  );
} 

这次,Details组件渲染了route参数的内容。与App组件一样,我们向屏幕添加了额外的选项。在这种情况下,我们使用navigation.setOptions()方法更新screen选项。为了自定义标题,我们还可以通过App组件向该屏幕添加一个标题。

让我们看看所有这些是如何工作的,从主屏幕开始:

图片 5

图 19.5:主屏幕

现在导航栏中已经有了标题文本,这是通过Screen组件中的name属性设置的。

接下来,尝试按下第一个项目按钮:

图片 6

图 19.6:第一个项目屏幕

导航栏中的标题是基于传递给Details组件的title参数设置的,使用navigation.setOptions()方法。渲染在导航栏右侧的购买按钮是由放置在App组件中的Screen组件的options属性渲染的。它被启用,因为stock参数的值是1

现在,尝试返回到Home屏幕并按下第二个项目按钮:

图片 7

图 19.7:第二个项目屏幕

标题和页面内容都反映了传递给Details的新参数值,但Buy按钮也是如此。它处于禁用状态,因为库存参数值为0,这意味着不能购买。

现在你已经学会了如何使用导航标题,在下一节中,你将学习关于标签和抽屉导航的内容。

标签和抽屉导航

到目前为止,在本章中,每个示例都使用了Button组件来链接到应用中的其他屏幕。你可以使用react-navigation中的函数来自动为你创建tabdrawer导航,这些函数基于你提供的屏幕组件。

让我们创建一个示例,使用 iOS 上的底部 tab 导航和 Android 上的抽屉导航。

你不仅限于在 iOS 上使用标签导航或在 Android 上使用抽屉导航。我只是选择这两个来演示如何根据平台使用不同的导航模式。如果你更喜欢,你可以在两个平台上使用完全相同的导航模式。

对于这个示例,我们需要安装一些其他用于标签和抽屉导航器的包:

npm install @react-navigation/bottom-tabs @react-navigation/drawer 

此外,抽屉导航器需要一些原生模块。让我们来安装它们:

npx expo install react-native-gesture-handler react-native-reanimated 

然后,向babel.config.js文件添加一个插件。结果,文件应该看起来像以下这样:

module.exports = function (api) {
  api.cache(true);
  return {
    presets: ["babel-preset-expo"],
    plugins: ["react-native-reanimated/plugin"],
  };
}; 

现在,我们已经准备好继续编码。以下是App组件的样式:

const Tab = createBottomTabNavigator<Routes>();
const Drawer = createDrawerNavigator<Routes>();
export default function App() {
  return (
    <NavigationContainer>
      {Platform.OS === "ios" && (
        <Tab.Navigator>
          <Tab.Screen name="Home" component={Home} />
          <Tab.Screen name="News" component={News} />
          <Tab.Screen name="Settings" component={Settings} />
        </Tab.Navigator>
      )}
      {Platform.OS == "android" && (
        <Drawer.Navigator> 
          <Drawer.Screen name="Home" component={Home} />
          <Drawer.Screen name="News" component={News} />
          <Drawer.Screen name="Settings" component={Settings} />
        </Drawer.Navigator>
      )}
    </NavigationContainer>
  );
} 

你不是使用createNativeStackNavigator()函数来创建你的导航器,而是导入createBottomTabNavigator()createDrawerNavigator()函数:

import { createDrawerNavigator } from "@react-navigation/drawer";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; 

然后,你使用react-native中的Platform实用工具来决定使用哪个导航器。结果,根据平台,被分配给App。每个导航器都包含NavigatorScreen组件,你可以将它们传递给你的App。为你创建并渲染的tabdrawer导航将自动生成。

接下来,让我们看看Home屏幕组件:

export default function Home() {
  return (
    <View style={styles.container}>
      <Text>Home Content</Text>
    </View>
  );
} 

NewsSettings组件与Home组件基本相同。以下是 iOS 上底部tab导航的样式:

图片 8

图 19.8:标签导航器

你的应用由三个屏幕组成,列在底部。当前屏幕被标记为活动状态,你可以点击其他标签来移动。

现在,让我们看看 Android 上的drawer布局是什么样的:

图片 9

图 19.9:抽屉导航器

要打开drawer,你需要从屏幕左侧滑动。一旦打开,你会看到按钮,它们会带你到应用的各种屏幕。

从屏幕左侧滑动打开drawer是默认模式。你可以配置drawer从任何方向滑动打开。

现在,你已经学会了如何使用 tabdrawer 导航。接下来,我们将探讨仅基于文件定义导航的方法。

基于文件的导航

在本节中,我们将讨论 Expo Router,这是一个基于文件的路由器,其工作方式与 Next.js 中的路由类似。要添加新的屏幕,你只需在 app 文件夹中添加一个新的文件。它是建立在 React Navigation 之上的,因此路由具有相同的选项和参数。

更多关于 Expo Router 的信息和细节,请查看此链接:

docs.expo.dev/routing/introduction/

要尝试它,我们将使用以下命令安装一个新的项目:

npx create-expo-app –template 

要使用准备好 Expo Router 的项目进行安装,我们只需选择 Navigation (TypeScript) 模板:

 Blank
    Blank (TypeScript)
❯   Navigation (TypeScript) - File-based routing with TypeScript enabled
    Blank (Bare) 

安装完成后,你将找到项目的 app 文件夹。这个文件夹将用于所有你的屏幕。让我们尝试复制 导航基础 部分的示例。首先,我们需要在 app 文件夹内创建 _layout.tsx 文件。这个文件作为我们 approot 层工作。它看起来是这样的:

import { Stack } from "expo-router";
export default function RootLayout() {
  return <Stack />;
} 

然后,让我们创建包含 Home 屏幕的 index.tsx 文件。与 _layout.tsx 相比,它有一些不同,让我们看看:

import { Link } from "expo-router";
export default function Home() {
  return (
    <View style={styles.container}>
      <StatusBar barStyle="dark-content" />
      <Text>Home Screen</Text>
      <Link href="/settings" asChild>
        <Button title="Settings" />
      </Link>
    </View>
  );
} 

如你所见,我们没有使用 navigation 属性。我们而是使用一个接受 href 属性的 Link 组件,就像一个网页。点击那个按钮会带我们到 Settings 屏幕。

让我们创建 settings.tsx 文件:

import { Link } from "expo-router";
export default function Settings() {
  return (
    <View style={styles.container}>
      <StatusBar barStyle="dark-content" />
      <Text>Settings Screen</Text>
      <Link href="/" asChild>
        <Button title="Home" />
      </Link>
    </View>
  );
} 

在这里,我们使用与 index.tsx 文件相同的方法,但在 Link 中,我们将 href 设置为 “/”。

这就是我们可以如此轻松地以声明式方式定义屏幕,并且屏幕之间的导航 URL 方法是即插即用的。此外,我们在这里获得的一个好处是深度链接也是即插即用的;使用这种方法,我们可以通过应用链接打开特定的屏幕。

现在,你知道如何使用基于文件的路由,这可以提高你开发移动应用的经验,尤其是在基于 URL 和链接的 Web 态度下。

摘要

在本章中,你了解到移动应用需要导航,就像 Web 应用一样。尽管它们不同,Web 应用和移动应用导航在概念上有足够的相似性,使得移动应用的路由和导航不必成为麻烦。

旧版本的 React Native 尝试提供组件来帮助管理移动应用内的导航,但它们从未真正流行起来。相反,React Native 社区主导了这个领域。一个例子是 react-navigation 库:本章的重点。

你学习了如何使用 react-navigation 进行基本导航。然后,你学习了如何在导航栏中控制 header 组件。接下来,你了解了 tabdrawer 导航组件。这两个导航组件可以根据屏幕组件自动渲染你的应用的导航按钮。你还学习了如何与基于文件的 Expo Router 一起工作。

在下一章中,你将学习如何渲染数据列表。

第二十章:渲染项目列表

在本章中,你将学习如何处理项目列表。列表是常见的 Web 应用程序组件。虽然使用<ul><li>元素构建列表相对简单,但在本地移动平台上做类似的事情要复杂得多。

幸运的是,React Native 提供了一个项目列表接口,隐藏了所有的复杂性。首先,你将通过浏览一个示例来了解项目列表的工作方式。然后,你将学习如何构建更改列表中显示的数据的控件。最后,你将看到几个从网络获取项目的示例。

本章我们将涵盖以下主题:

  • 渲染数据集合

  • 排序和过滤列表

  • 获取列表数据

  • 懒加载列表

  • 实现下拉刷新

技术要求

你可以在 GitHub 上找到本章的代码文件,地址为github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter20

渲染数据集合

列表是显示大量信息最常见的方式:例如,你可以显示你的朋友列表、消息和新闻。许多应用程序包含具有数据集合的列表,React Native 提供了创建这些组件的工具。

让我们从示例开始。你将使用 React Native 组件FlatList来渲染列表,它在 iOS 和 Android 上工作方式相同。列表视图接受一个data属性,它是一个对象数组。这些对象可以具有你喜欢的任何属性,但它们确实需要一个key属性。如果你没有key属性,你可以将keyExtractor属性传递给Flatlist组件,并指示使用什么代替keykey属性类似于在<ul>元素内部渲染<li>元素的要求。这有助于列表在列表数据更改时高效渲染。

现在我们来实现一个基本的列表。以下是渲染一个包含 100 项的基本列表的代码:

const data = new Array(100)
  .fill(null)
  .map((v, i) => ({ key: i.toString(), value: `Item ${i}` }));
export default function App() {
  return (
    <View style={styles.container}>
      <FlatList
        data={data}
        renderItem={({ item }) => <Text style={styles.item}>{item.value}</Text>}
      />
    </View>
  );
} 

让我们逐步了解这里发生的事情,从data常量开始。它包含一个 100 项的数组。这是通过填充一个包含 100 个null值的新数组,然后将其映射到一个新数组,该数组包含要传递给<FlatList>的对象来创建的。每个对象都有一个key属性,因为这是一个要求;其他任何内容都是可选的。在这种情况下,你决定添加一个value属性,该属性将在列表渲染时使用。

接下来,你将渲染<FlatList>组件。它位于<View>容器中,因为列表视图需要高度才能正确工作。datarenderItem属性被传递给<FlatList>,这最终决定了渲染的内容。

初看之下,FlatList组件似乎并没有做太多。您是否需要弄清楚项的外观?嗯,是的,FlatList组件应该是通用的。它应该擅长处理更新,并将滚动功能嵌入到列表中。以下是用于渲染列表的样式:

import { StyleSheet } from "react-native";
export default StyleSheet.create({
  container: {
    flex: 1,
    flexDirection: "column",
    paddingTop: 40,
  },
  item: {
    margin: 5,
    padding: 5,
    color: "slategrey",
    backgroundColor: "ghostwhite",
    textAlign: "center",
  },
}); 

在这里,您正在为列表中的每个项设置样式。否则,每个项都将是纯文本,这将很难区分其他列表项。container样式通过将flex设置为1来设置列表的高度。

让我们看看现在列表看起来像什么:

图片 1

图 20.1:渲染数据集合

如果您在模拟器中运行此示例,您可以在屏幕上的任何地方单击并按住鼠标按钮,就像用手指一样,然后上下滚动通过项。

在下一节中,您将学习如何添加排序和过滤列表的控件。

排序和过滤列表

现在您已经学习了FlatList组件的基础知识,包括如何传递数据,让我们向在渲染数据集合部分实现的列表添加一些控件。FlatList组件可以与其他组件一起渲染:例如,列表控件。它帮助您操作数据源,这最终决定了屏幕上渲染的内容。

在实现列表控制组件之前,回顾这些组件的高级结构可能会有所帮助,这样代码就有更多的上下文。以下是您将要实现的组件结构的示意图:

图片 2

图 20.2:组件结构

每个这些组件负责的内容如下:

  • ListContainer:列表的整体容器;它遵循熟悉的 React 容器模式

  • List:一个无状态组件,将相关的状态片段传递给ListControls和 React Native 的ListView组件

  • ListControls:一个组件,它包含各种控件,这些控件可以更改列表的状态

  • ListFilter:用于过滤项目列表的控件

  • ListSort:用于更改列表排序顺序的控件

  • FlatList:实际渲染项的 React Native 组件

在某些情况下,将列表的实现拆分开来可能会增加开销。然而,我认为如果您的列表需要控件,那么您可能正在实现一些将从良好的组件架构中受益的东西。

现在,让我们深入探讨这个列表的实现,从ListContainer组件开始:

function mapItems(items: string[]) {
  return items.map((value, i) => ({ key: i.toString(), value }));
}
const array = new Array(100).fill(null).map((v, i) => `Item ${i}`);
function filterAndSort(text: string, asc: boolean): string[] {
  return array
    .filter((i) => text.length === 0 || i.includes(text))
    .sort(
      asc
        ? (a, b) => (a > b ? 1 : a < b ? -1 : 0)
        : (a, b) => (b > a ? 1 : b < a ? -1 : 0)
    );
} 

在这里,我们定义了一些实用函数和我们将使用的初始数组。

然后,我们将定义ascfilter来管理排序和过滤列表,分别使用useMemo钩子实现的data变量:

export default function ListContainer() {
  const [asc, setAsc] = useState(true);
  const [filter, setFilter] = useState("");
  const data = useMemo(() => {
    return filterAndSort(filter, asc);
  }, [filter, asc]); 

它给我们一个避免手动更新的机会,因为当filterasc依赖项更新时,它将自动重新计算。它还有助于我们在filterasc未更改时避免不必要的重新计算。

这就是我们将此逻辑应用于List组件的方式:

return (
  <List
    data={mapItems(data)}
    asc={asc}
    onFilter={(text) => {
      setFilter(text);
    }}
    onSort={() => {
      setAsc(!asc);
    }}
  />
); 

如果这看起来有点多,那是因为确实如此。此容器组件有很多状态要处理。它还有一些需要对其子组件提供的不平凡的行为。如果您从封装状态的角度来看,它将更容易接近。它的任务是使用状态数据填充列表,并提供在此状态下操作的功能。

在理想的世界里,此容器的子组件应该是简单而优雅的,因为它们不需要直接与状态交互。让我们看看下一个List组件:

export default function List({ data, ...props }: Props) {
  return (
    <FlatList
      data={data}
      ListHeaderComponent={<ListControls {...props}/>}
      renderItem={({ item }) => <Text style={styles.item}>{item.value}</Text>}
    />
  );
} 

此组件将来自ListContainer组件的状态作为属性,并渲染一个FlatList组件。与上一个示例相比,这里的主要区别是ListHeaderComponent属性。它渲染了List组件的控件。这个属性特别有用,因为它在可滚动列表内容之外渲染控件,确保控件始终可见。让我们看看下一个ListControls组件:

type Props = {
  onFilter: (text: string) => void;
  onSort: () => void;
  asc: boolean;
};
export default function ListControls({ onFilter, onSort, asc }: Props) {
  return (
    <View style={styles.controls}>
      <ListFilter onFilter={onFilter} />
      <ListSort onSort={onSort} asc={asc} />
    </View>
  );
} 

此组件将ListFilterListSort控件结合在一起。因此,如果您要添加另一个列表控件,您应该在这里添加。

现在让我们看看ListFilter的实现:

type Props = {
  onFilter: (text: string) => void;
};
export default function ListFilter({ onFilter }: Props) {
  return (
    <View>
      <TextInput
        autoFocus
        placeholder="Search"
        style={styles.filter}
        onChangeText={onFilter}
      />
    </View>
  );
} 

筛选控件是一个简单的文本输入,通过用户类型筛选项目列表。处理此操作的onFilter函数来自ListContainer组件。

让我们看看下一个ListSort组件:

const arrows = new Map([
  [true, "▼"],
  [false, "▲"],
]);
type Props = {
  onSort: () => void;
  asc: boolean;
};
export default function ListSort({ onSort, asc }: Props) {
  return <Text onPress={onSort}>{arrows.get(asc)}</Text>;
} 

下面是结果的列表:

图片 3

图 20.3:排序和筛选列表

默认情况下,整个列表按升序渲染。当用户尚未提供任何内容时,您可以看到占位符搜索文本。让我们看看当您输入筛选器和更改排序顺序时,它看起来会是什么样子:

图片 4

图 20.4:排序顺序和搜索值已更改的列表

此搜索包括包含1的项,并按降序排序结果。请注意,您可以先更改顺序,然后输入筛选器。筛选器和排序顺序都是ListContainer状态的一部分。

在下一节中,您将学习如何从 API 端点获取列表数据。

获取列表数据

通常,您将从某个 API 端点获取列表数据。在本节中,您将了解如何在 React Native 组件中发起 API 请求。好消息是,React Native 已经填充了fetch() API,因此您在移动应用程序中的网络代码应该看起来和感觉就像在您的 Web 应用程序中一样。

首先,让我们为我们的列表项构建一个mock API,使用返回 promise 的函数,就像fetch()一样:

const items = new Array(100).fill(null).map((v, i) => `Item ${i}`);
function filterAndSort(data: string[], text: string, asc: boolean) {
  return data
    .filter((i) => text.length === 0 || i.includes(text))
    .sort(
      asc
        ? (a, b) => (b > a ? -1 : a === b ? 0 : 1)
        : (a, b) => (a > b ? -1 : a === b ? 0 : 1)
    );
}
export function fetchItems(
  filter: string,
  asc: boolean
): Promise<{ json: () => Promise<{ items: string[] }> }> {
  return new Promise((resolve) => {
    resolve({
      json: () =>
        Promise.resolve({
          items: filterAndSort(items, filter, asc),
        }),
    });
  });
} 

在 mock API 函数就位后,让我们对ListContainer组件做一些修改。现在,你不再使用本地数据源,而是可以使用fetchItems()函数从 mock API 加载数据。让我们看看并定义ListContainer组件:

export default function ListContainer() {
  const [asc, setAsc] = useState(true);
  const [filter, setFilter] = useState("");
  const [data, setData] = useState<MappedList>([]);
  useEffect(() => {
    fetchItems(filter, asc)
      .then((resp) => resp.json())
      .then(({ items }) => {
        setData(mapItems(items));
      });
  }, []); 

我们使用useStateuseEffect钩子定义了状态变量来获取初始列表数据。

现在,让我们来看看我们在List组件中新的处理器的用法:

 return (
    <List
      data={data}
      asc={asc}
      onFilter={(text) => {
        fetchItems(text, asc)
          .then((resp) => resp.json())
          .then(({ items }) => {
            setFilter(text);
            setData(mapItems(items));
          });
      }}
      onSort={() => {
        fetchItems(filter, !asc)
          .then((resp) => resp.json())
          .then(({ items }) => {
            setAsc(!asc);
            setData(mapItems(items));
          });
      }}
    />
  );
} 

任何修改列表状态的行动都需要在 promise 解析后调用fetchItems()并设置适当的状态。

在接下来的部分,你将学习如何懒加载列表数据。

懒加载列表

在本节中,你将实现一种不同类型的列表:一个可以无限滚动的列表。有时,用户实际上并不知道他们在寻找什么,所以过滤或排序并不能帮助。想想当你登录账户时看到的 Facebook 新闻源;它是应用程序的主要功能,你很少会寻找特定的事物。你需要通过滚动列表来查看发生了什么。

要使用FlatList组件实现这一点,你需要能够在用户滚动到列表末尾时获取更多的 API 数据。为了理解这是如何工作的,你需要大量的 API 数据来操作,生成器在这方面非常出色。所以,让我们修改你在获取列表数据部分的示例中创建的 mock,让它持续响应新数据:

function* genItems() {
  let cnt = 0;
  while (true) {
    yield `Item ${cnt++}`;
  }
}
let items = genItems();
export function fetchItems({ refresh }: { refresh?: boolean }) {
  if (refresh) {
    items = genItems();
  }
  return Promise.resolve({
    json: () =>
      Promise.resolve({
        items: new Array(30).fill(null).map(() => items.next().value as string),
      }),
  });
} 

使用fetchItems,你现在可以在每次到达列表末尾时发起一个新的 API 请求以获取新数据。最终,当内存耗尽时,这将会失败,但我只是想从一般的角度展示你可以在 React Native 中实现无限滚动的方案。现在,让我们看看带有fetchItemsListContainer组件的样子:

import React, { useState, useEffect } from "react";
import * as api from "./api";
import List from "./List";
export default function ListContainer() {
  const [data, setData] = useState([]);
  function fetchItems() {
    return api
      .fetchItems({})
      .then((resp) => resp.json())
      .then(({ items }) => {
        setData([
          ...data,
          ...items.map((value) => ({
            key: value,
            value,
          })),
        ]);
      });
  }
  useEffect(() => {
    fetchItems();
  }, []);
  return <List data={data} fetchItems={fetchItems} />;
} 

每次调用fetchItems()时,响应都会与data数组连接。这成为新的列表数据源,而不是像早期示例中那样替换它。

现在,让我们来看看List组件,看看如何响应到达列表的末尾:

type Props = {
  data: { key: string; value: string }[];
  fetchItems: () => Promise<void>;
  refreshItems: () => Promise<void>;
  isRefreshing: boolean;
};
export default function List({
  data,
  fetchItems
}: Props) {
  return (
    <FlatList
      data={data}
      renderItem={({ item }) => <Text style={styles.item}>{item.value}</Text>}
      onEndReached={fetchItems}
    />
  );
} 

FlatList接受onEndReached处理程序属性,它将在你滚动到列表末尾时被调用。

如果你运行这个示例,你会看到,当你滚动到屏幕底部时,列表会不断增长。

实现下拉刷新

下拉刷新手势是移动设备上的一种常见操作。它允许用户在不离开屏幕或手动重新打开应用的情况下,只需下拉即可刷新视图内容,从而触发页面刷新。Tweetie(后来成为 iPhone 上的 Twitter)和 Letterpress 的创造者 Loren Brichter 在 2009 年引入了这一手势。这一手势变得如此流行,以至于苹果将其集成到其 SDK 中,作为UIRefreshControl

要在FlatList应用中使用下拉刷新,我们只需传递一些属性和处理器。让我们看看我们的List组件:

type Props = {
  data: { key: string; value: string }[];
  fetchItems: () => Promise<void>;
  refreshItems: () => Promise<void>;
  isRefreshing: boolean;
};
export default function List({
  data,
  fetchItems,
  refreshItems,
  isRefreshing,
}: Props) {
  return (
    <FlatList
      data={data}
      renderItem={({ item }) => <Text style={styles.item}>{item.value}</Text>}
      onEndReached={fetchItems}
      onRefresh={refreshItems}
      refreshing={isRefreshing}
    />
  );
} 

由于我们提供了onRefreshrefreshing属性,我们的FlatList组件自动启用了下拉刷新手势。当你下拉列表时,将调用onRefresh处理器,而refreshing属性将启用加载指示器以反映加载状态。

要在List组件中应用定义的属性,让我们在ListContainer组件中实现带有isRefreshing状态的refreshItems函数:

 const [isRefreshing, setIsRefreshing] = useState(false);
  function fetchItems() {
    return api
      .fetchItems({})
      .then((resp) => resp.json())
      .then(({ items }) => {
        setData([
          ...data,
          ...items.map((value) => ({
            key: value,
            value,
          })),
        ]);
      });
  } 

refreshItems以及fetchItems方法中,我们获取列表项,但将它们保存为一个新的列表。此外,请注意,在调用 API 之前,我们更新isRefreshing状态将其设置为true值,并在最后的代码块中将其设置为false,以向FlatList提供信息,表明加载已完成。

摘要

在本章中,你学习了 React Native 中的FlatList组件。这个组件是通用的,因为它不对渲染的项目外观施加任何特定的要求。相反,列表的外观由你决定,让FlatList组件帮助高效地渲染数据源。FlatList组件还为其渲染的项目提供了一个可滚动的区域。

你实现了一个利用列表视图中的部分标题的示例。这是一个渲染静态内容(如列表控件)的好地方。然后你学习了如何在 React Native 中进行网络调用;它就像在其他任何 Web 应用中使用fetch()一样。

最后,你实现了通过仅在滚动到已渲染内容的底部后加载新项目来实现无限滚动的懒列表。此外,我们还添加了一个通过下拉手势刷新该列表的功能。

在下一章中,你将学习如何显示网络调用的进度,以及其他内容。

第二十一章:地理位置和地图

在本章中,您将了解 React Native 的地理位置和地图功能。您将从学习如何使用地理位置 API开始,然后继续使用MapView组件来绘制兴趣点和区域。为此,我们将使用react-native-maps包来实现地图。

本章的目标是概述 React Native 中可用的地理位置功能以及react-native-maps中的地图功能。

在本章中,我们将介绍我们将要涵盖的主题列表:

  • 使用地理位置 API

  • 渲染地图

  • 标注兴趣点

技术要求

您可以在 GitHub 上找到本章的代码文件,链接为github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter21.

使用地理位置 API

网络应用用来确定用户位置的地理位置 API 也可以由 React Native 应用使用,因为相同的 API 已经被 polyfilled。除了地图之外,此 API 对于从移动设备的 GPS 获取精确坐标非常有用。然后,您可以使用这些信息向用户显示有意义的地理位置数据。

不幸的是,地理位置 API 返回的数据本身用处不大。您的代码必须进行一些工作,将其转换为有用的东西。例如,纬度和经度对用户来说没有意义,但您可以使用这些数据查找对用户有用的信息。这可能只是显示用户当前的位置那么简单。

让我们实现一个示例,使用 React Native 的地理位置 API查找坐标,然后使用这些坐标从 Google Maps API 中查找可读的地理位置信息。

在我们开始编码之前,让我们使用npx create-expo-app创建一个项目,然后添加位置模块:

npx expo install expo-location 

接下来,我们需要在应用中配置位置权限。在移动应用中访问用户的位置需要用户明确授权。在本例的后续部分,我们将通过调用Location.requestForegroundPermissionsAsync()方法来实现这一点。这将向用户显示一个权限对话框,询问他们是否允许或拒绝位置访问。在继续使用位置方法之前,检查返回的状态以查看是否已授予权限非常重要。如果权限被拒绝,您应该在代码中优雅地处理它,并在必要时提示用户在应用设置中授权。

在真实的应用中,在我们请求权限之前,我们应该首先在应用配置中设置这些权限。我们可以通过向app.json文件添加插件来完成此操作:

{
  "expo": {
    "plugins": [
      [
        "expo-location",
        {
          "locationAlwaysAndWhenInUsePermission": "Allow $(PRODUCT_NAME) to use your location."
        }
      ]
    ]
  }
} 

你应该尽早请求位置权限,例如当你的应用首次启动或当用户首次导航到需要位置信息的屏幕时。通过提前请求权限并妥善处理用户的选项,你可以确保你的应用按预期工作,同时尊重用户的隐私偏好。

当你有一个准备好的项目时,让我们看看 App 组件,你可以在这里找到它:github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter22/where-am-i/App.tsx。这个组件的目标是在屏幕上渲染地理位置 API 返回的属性,以及查找用户的特定位置并显示它。

要从应用中获取位置,我们需要授予权限。在 App.tsx 中,我们已经调用了 Location.requestForegroundPermissionsAsync() 来实现这一点。

setPosition() 函数在几个地方用作回调,其任务是设置组件的状态。首先,setPosition() 设置经纬度坐标。通常,你不会直接显示这些数据,但这是一个示例,展示了作为地理位置 API 一部分可用的数据。其次,它使用 latitudelongitude 值来查找用户当前所在地的名称,使用的是 Google Maps API。

在示例中,API_KEY 值为空,你可以在以下链接获取:developers.google.com/maps/documentation/geocoding/start

setPosition() 回调与 getCurrentPosition() 一起使用,它仅在组件挂载时调用一次。你还在 watchPosition() 中使用 setPosition(),它会在用户的位置发生变化时调用回调。

iOS 模拟器和 Android Studio 允许你通过菜单选项更改位置。你不必每次想要测试更改位置时都在物理设备上安装你的应用。

让我们看看当位置数据加载后这个屏幕看起来像什么:

图片 1

图 21.1:位置数据

获取的地址信息可能比经纬度数据更有用,对于需要查找周围建筑或公司的应用来说效果很好。甚至比物理地址文本更好的是,在地图上可视化用户的物理位置;你将在下一节中学习如何做到这一点。

渲染地图

来自 react-native-mapsMapView 组件是你在 React Native 应用中渲染地图的主要工具。它提供了广泛的工具来渲染地图、标记、多边形、热图等。

你可以在网站上找到有关 react-native-maps 的更多信息:github.com/react-native-maps/react-native-maps

现在我们来实现一个基本的MapView组件,看看你能够得到什么:

import { View, StatusBar } from "react-native";
import MapView from "react-native-maps";
import styles from "./styles";
StatusBar.setBarStyle("dark-content");
export default () => (
  <View style={styles.container}>
    <MapView style={styles.mapView} showsUserLocation followsUserLocation />
  </View>
); 

你传递给MapView的两个布尔属性为你做了很多工作。showsUserLocation属性会激活地图上的标记,表示运行此应用程序的设备的物理位置。followsUserLocation属性告诉地图在设备移动时更新位置标记。

这里是生成的地图:

图片 2

图 21.2:当前位置

设备的当前位置在地图上被清楚地标记出来。默认情况下,兴趣点也会在地图上渲染。这些是用户附近的事物,以便他们可以看到周围的情况。

通常,在设置showsUserLocation时使用followsUserLocation是一个好主意。这使得地图缩放到用户所在的位置。

在下一节中,你将学习如何在你的地图上注释兴趣点。

注释兴趣点

注释正如其名:在基本地图地理之上渲染的附加信息。当你渲染MapView组件时,默认情况下你会得到注释。MapView组件可以渲染用户的当前位置以及用户周围的兴趣点。这里的挑战可能是你想要显示与你的应用程序相关的兴趣点,而不是默认渲染的那些。

在本节中,你将学习如何在地图上绘制特定位置的标记,以及如何在地图上绘制区域。

绘制点

让我们绘制一些当地的酿酒厂!这是你如何将注释传递给MapView组件的方法:

<MapView
  style={styles.mapView}
  showsPointsOfInterest={false}
  showsUserLocation
  followsUserLocation
>
  <Marker
    title="Duff Brewery"
    description="Duff beer for me, Duff beer for you"
    coordinate={{
      latitude: 43.8418728,
      longitude: -79.086082,
    }}
  />
  {...}
</MapView> 

在这个例子中,我们通过将showsPointsOfInterest属性设置为false来放弃了这一功能。让我们看看这些酿酒厂的位置在哪里:

图片 3

图 21.3:绘制点

当你按下显示地图上酿酒厂位置的标记时,会显示呼出窗口。你给<Marker>提供的titledescription属性值用于渲染此文本。

绘制叠加层

在本章的最后部分,你将学习如何渲染区域叠加层。将区域想象为连接几个点的连线画,而一个点是一个单独的纬度/经度坐标。

区域可以服务于许多目的。在我们的例子中,我们将创建一个区域,显示我们更有可能找到 IPA 饮用者还是黑啤饮用者的地方。你可以点击此链接查看完整代码的样子:github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter22/plotting-overlays/App.tsx。以下是代码的 JSX 部分的样子:

 <View style={styles.container}>
      <View>
        <Text style={ipaStyles} onPress={onClickIpa}>
          IPA Fans
        </Text>
        <Text style={stoutStyles} onPress={onClickStout}>
          Stout Fans
        </Text>
      </View>
      <MapView
        style={styles.mapView}
        showsPointsOfInterest={false}
        initialRegion={{
          latitude: 43.8486744,
          longitude: -79.0695283,
          latitudeDelta: 0.002,
          longitudeDelta: 0.04,
        }}
      >
        {overlays.map((v, i) => (
          <Polygon
            key={i}
            coordinates={v.coordinates}
            strokeColor={v.strokeColor}
            strokeWidth={v.strokeWidth}
          />
        ))}
      </MapView>
    </View> 

区域数据由几个定义区域形状和位置的 纬度/经度 坐标组成。区域被放置在 overlays 状态变量中,我们将它们映射到 Polygon 组件。其余的代码主要关于处理当两个文本链接被按下时的状态。

默认情况下,IPA 区域的渲染方式如下:

图片 4

图 21.4:IPA 粉丝

当按下 Stout 粉丝 按钮,IPA 覆盖层将从地图中移除,并添加 stout 区域:

图片 5

图 21.5:Stout 粉丝

当你需要突出显示一个区域而不是一个 纬度/经度 点或地址时,覆盖层非常有用。例如,它可能是一个用于在所选区域或社区中寻找出租公寓的应用程序。

摘要

在本章中,你学习了 React Native 中的地理位置和地图绘制。地理位置 API 与其网络版本的工作方式相同。在 React Native 应用程序中使用地图的唯一可靠方法是安装第三方 react-native-maps 包。

你看到了基本的 MapView 组件配置以及它们如何跟踪用户的位置并显示相关的兴趣点。然后,你看到了如何绘制你自己的兴趣点和感兴趣的区域。

在下一章中,你将学习如何使用类似于 HTML 表单控件的 React Native 组件来收集用户输入。

第二十二章:收集用户输入

在 Web 应用中,你可以从所有浏览器上看起来和表现相似的 HTML 表单元素中收集用户输入。在使用原生 UI 平台时,收集用户输入更为复杂。

在本章中,你将学习如何使用各种 React Native 组件来收集用户输入。这些包括文本输入、从选项列表中选择、复选框和日期/时间选择器。所有这些都在注册或登录流程以及购买表单的每个应用中使用。创建此类表单的经验非常有价值,本章将帮助你了解如何在未来的应用中创建任何表单。你将了解 iOS 和 Android 之间的差异以及如何为你的应用实现适当的抽象。

本章将涵盖以下主题:

  • 收集文本输入

  • 从选项列表中选择

  • 在开和关之间切换

  • 收集日期/时间输入

技术要求

你可以在 GitHub 上找到本章的代码文件,地址为github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter22。

收集文本输入

实现文本输入时有很多需要考虑的因素。例如,是否应该有占位文本?这是否是敏感数据,不应该在屏幕上显示?是否应该在用户移动到另一个字段时处理文本?

在 Web 应用中,有一个特殊的<input>HTML 元素,允许你收集用户输入。在 React Native 中,我们使用TextInput组件来达到这个目的。让我们构建一个示例,渲染几个<TextInput>组件的实例:

function Input(props: InputProps) {
  return (
    <View style={styles.textInputContainer}>
      <Text style={styles.textInputLabel}>{props.label}</Text>
      <TextInput style={styles.textInput} {...props} />
    </View>
  );
} 

我们已经实现了Input组件,我们将多次重用它。让我们看看几个文本输入的使用案例:

export default function CollectingTextInput() {
  const [changedText, setChangedText] = useState("");
  const [submittedText, setSubmittedText] = useState("");
  return (
    <View style={styles.container}>
      <Input label="Basic Text Input:" />
      <Input label="Password Input:" secureTextEntry />
      <Input label="Return Key:" returnKeyType="search" />
      <Input label="Placeholder Text:" placeholder="Search" />
      <Input
        label="Input Events:"
        onChangeText={(e) => {
          setChangedText(e);
        }}
        onSubmitEditing={(e) => {
          setSubmittedText(e.nativeEvent.text);
        }}
        onFocus={() => {
          setChangedText("");
          setSubmittedText("");
        }}
      />
      <Text>Changed: {changedText}</Text>
      <Text>Submitted: {submittedText}</Text>
    </View>
  );
} 

我不会深入探讨每个<TextInput>组件的功能;Input组件中有标签解释了这一点。让我们看看这些组件在屏幕上的样子:

图片 1

图 22.1:文本输入的变体

纯文本输入显示已输入的文本。密码输入字段不显示任何字符。占位文本在输入为空时显示。已更改的文本状态也显示出来。你无法看到已提交的文本状态,因为我没有在虚拟键盘上按下已提交按钮之前截图。

让我们看看通过returnKeyType属性更改返回键文本的输入元素的虚拟键盘:

图片 2

图 22.2:按键文本已更改的键盘

当键盘的返回键反映了用户按下它时将要发生的事情,用户会感到与应用程序更加协调。

另一个常见的用例是更改键盘类型。通过将keyboardType属性提供给TextInput组件,你将看到不同类型的键盘。当你需要输入 PIN 码或电子邮件地址时,这很方便。以下是一个numeric键盘的示例:

图片 3

图 22.3:数字键盘类型

现在你已经熟悉了收集文本输入,是时候学习如何从选项列表中选择一个值了。

从选项列表中选择

在 Web 应用程序中,你通常使用<select>元素让用户从选项列表中进行选择。React Native 自带一个Picker组件,它在 iOS 和 Android 上都可用,但为了减少 React Native 应用程序的大小,Meta 团队决定在未来版本中删除它,并将Picker提取到自己的包中。要使用该包,首先,在一个干净的项目中运行以下命令:

npx expo install @react-native-picker/picker 

根据用户所在的平台,对这个组件进行样式设置涉及一些技巧,所以让我们将这些内容全部隐藏在一个通用的Select组件中。以下是Select.ios.js模块:

export default function Select(props: SelectProps) {
  return (
    <View style={styles.pickerHeight}>
      <View style={styles.pickerContainer}>
        <Text style={styles.pickerLabel}>{props.label}</Text>
        <Picker style={styles.picker} {...props}>
          {props.items.map((i) => (
            <Picker.Item key={i.label} {...i} />
          ))}
        </Picker>
      </View>
    </View>
  );
} 

对于一个简单的Select组件来说,这确实是一个很大的开销。实际上,要样式化 React Native 的Picker组件相当困难,因为它在 iOS 和 Android 上的外观完全不同。尽管如此,我们仍然希望使其更加跨平台。

这里是Select.android.js模块:

export default function Select(props: SelectProps) {
  return (
    <View>
      <Text style={styles.pickerLabel}>{props.label}</Text>
      <Picker {...props}>
        {props.items.map((i) => (
          <Picker.Item key={i.label} {...i} />
        ))}
      </Picker>
    </View>
  );
} 

这是样式的样子:

container: {
    flex: 1,
    flexDirection: "column",
    backgroundColor: "ghostwhite",
    justifyContent: "center",
  },
  pickersBlock: {
    flex: 2,
    flexDirection: "row",
    justifyContent: "space-around",
    alignItems: "center",
  },
  pickerHeight: {
    height: 250,
  }, 

如同通常的containerpickersBlock样式,我们定义了屏幕的基本布局。接下来,让我们看看Select组件的样式:

 pickerContainer: {
    flex: 1,
    flexDirection: "column",
    alignItems: "center",
    backgroundColor: "white",
    padding: 6,
    height: 240,
  },
  pickerLabel: {
    fontSize: 14,
    fontWeight: "bold",
  },
  picker: {
    width: 150,
    backgroundColor: "white",
  },
  selection: {
    flex: 1,
    textAlign: "center",
  }, 

现在,你可以渲染你的Select组件。以下是App.js文件的样子:

const sizes = [
  { label: "", value: null },
  { label: "S", value: "S" },
  { label: "M", value: "M" },
  { label: "L", value: "L" },
  { label: "XL", value: "XL" },
];
const garments = [
  { label: "", value: null, sizes: ["S", "M", "L", "XL"] },
  { label: "Socks", value: 1, sizes: ["S", "L"] },
  { label: "Shirt", value: 2, sizes: ["M", "XL"] },
  { label: "Pants", value: 3, sizes: ["S", "L"] },
  { label: "Hat", value: 4, sizes: ["M", "XL"] },
]; 

在这里,我们为我们的Select组件定义了默认值。让我们看看最终的SelectingOptions组件:

export default function SelectingOptions() {
  const [availableGarments, setAvailableGarments] = useState<typeof garments>(
    []
  );
  const [selectedSize, setSelectedSize] = useState<string | null>(null);
  const [selectedGarment, setSelectedGarment] = useState<number | null>(null); 

使用这些钩子,我们已经实现了选择器的状态。接下来,我们将使用并将它们传递到组件中:

 <View style={styles.container}>
      <View style={styles.pickersBlock}>
        <Select
          label="Size"
          items={sizes}
          selectedValue={selectedSize}
          onValueChange={(size: string) => {
            setSelectedSize(size);
            setSelectedGarment(null);
            setAvailableGarments(
              garments.filter((i) => i.sizes.includes(size))
            );
          }}
        />
        <Select
          label="Garment"
          items={availableGarments}
          selectedValue={selectedGarment}
          onValueChange={(garment: number) => {
            setSelectedGarment(garment);
          }}
        />
      </View>
      <Text style={styles.selection}>{selectedSize && selectedGarment && `${selectedSize} ${garments.find((i) => i.value === selectedGarment)?.label}`}</Text>
    </View> 

这个示例的基本思想是第一个选择器中选中的选项会改变第二个选择器中可用的选项。当第二个选择器改变时,标签会显示selectedSizeselectedGarment作为字符串。以下是屏幕的显示方式:

图片 4

图 22.4:从选项列表中选择

Size选择器显示在屏幕的左侧。当Size值改变时,屏幕右侧Garment选择器中可用的值会改变,以反映尺寸的可用性。两个选择器之后会以字符串的形式显示当前的选择。

这是我们app在 Android 设备上的样子:

图 22.5:在 Android 上从选项列表中选择

Picker组件的 iOS 版本渲染一个可滚动的选项列表时,Android 版本只提供打开对话框模态以选择选项的按钮。

在接下来的部分,你将了解在开和关状态之间切换的按钮。

在开和关之间切换

在网页表单中,您还会看到另一个常见元素,即复选框。例如,想想在您的设备上切换 Wi-Fi 或蓝牙。React Native 有一个Switch组件,在 iOS 和 Android 上都能工作。幸运的是,这个组件比Picker组件更容易样式化。让我们看看您可以实现的简单抽象,为您的开关提供标签:

type CustomSwitchProps = SwitchProps & {
  label: string;
};
export default function CustomSwitch(props: CustomSwitchProps) {
  return (
    <View style={styles.customSwitch}>
      <Text>{props.label}</Text>
      <Switch {...props} />
    </View>
  );
} 

现在,让我们学习如何使用几个开关来控制应用程序状态:

export default function TogglingOnAndOff() {
  const [first, setFirst] = useState(false);
  const [second, setSecond] = useState(false);
  return (
    <View style={styles.container}>
      <Switch
        label="Disable Next Switch"
        value={first}
        disabled={second}
        onValueChange={setFirst}
      />
      <Switch
        label="Disable Previous Switch"
        value={second}
        disabled={first}
        onValueChange={setSecond}
      />
    </View>
  );
} 

这两个开关相互切换对方的disabled属性。当第一个开关被切换时,会调用setFirst()函数,这将更新第一个状态值。根据first的当前值,它将被设置为truefalse。第二个开关的工作方式相同,但它使用setSecond()和第二个状态值。

打开一个开关将禁用另一个开关,因为我们已经将每个开关的disabled属性值设置为另一个开关的状态。例如,第二个开关有disabled={first},这意味着当第一个开关打开时,它将被禁用。以下是 iOS 上的屏幕截图:

图片 5

图 22.6:iOS 上的开关切换

这是 Android 上的相同屏幕截图:

图片 6

图 22.7:Android 上的开关切换

如您所见,我们的CustomSwitch组件在 Android 和 iOS 上实现了相同的功能,同时使用了一个组件来处理这两个平台。在下一节中,您将了解如何收集日期/时间输入。

收集日期/时间输入

在本章的最后部分,您将学习如何实现日期/时间选择器。React Native 文档建议使用@react-native-community/datetimepicker独立日期/时间选择器组件,这意味着处理组件之间的跨平台差异取决于您。

要安装datetimepicker,请在项目中运行以下命令:

npx expo install @react-native-community/datetimepicker 

因此,让我们从 iOS 的DatePicker组件开始:

export default function DatePicker(props: DatePickerProps) {
  return (
    <View style={styles.datePickerContainer}>
      <Text style={styles.datePickerLabel}>{props.label}</Text>
      <DateTimePicker
        mode="date"
        display="spinner"
        value={props.value}
        onChange={(event, date) => {
          if (date) {
            props.onChange(date);
          }
        }}
      />
    </View>
  );
} 

这个组件没有太多内容;它只是给DateTimePicker组件添加了一个标签。Android 版本的工作方式略有不同;更好的方法是使用命令式 API。让我们看看实现方式:

export default function DatePicker({label, value, onChange }: DatePickerProps) {
  return (
    <View style={styles.datePickerContainer}>
      <Text style={styles.datePickerLabel}>{label}</Text>
      <Text
        onPress={() => {
          DateTimePickerAndroid.open({
            value: value,
            mode: "date",
            onChange: (event, date) => {
              if (event.type === "set" && date) {
                onChange(date);
              }
            },
          });
        }}
      >
        {value.toLocaleDateString()}
      </Text>
    </View>
  );
} 

两个日期选择器的关键区别在于,Android 版本不使用 iOS 中类似的 React Native 组件DateTimePicker。相反,我们必须使用命令式DateTimePickerAndroid.open() API。当用户点击我们组件渲染的日期文本并打开日期选择器对话框时,将触发此 API。好消息是,我们组件的这个部分隐藏了这个 API 在声明式组件后面。

我还实现了一个遵循此精确模式的时间选择器组件。因此,而不是在这里列出代码,我建议您从 github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter2 下载本书的代码,这样您可以看到细微的差异并运行示例。

现在,让我们学习如何使用我们的日期和时间选择器组件:

export default function CollectingDateTimeInput() {
  const [date, setDate] = useState(new Date());
  const [time, setTime] = useState(new Date());
  return (
    <View style={styles.container}>
      <DatePicker
        label="Pick a date, any date:"
        value={date}
        onChange={setDate}
      />
      <TimePicker
        label="Pick a time, any time:"
        value={time}
        onChange={setTime}
      />
    </View>
  );
} 

太棒了!现在,我们有了 DatePickerTimePicker 组件,可以帮助我们在应用中选择日期和时间。此外,它们在 iOS 和 Android 上都适用。让我们看看选择器在 iOS 上的样子:

图片 7

图 22.8:iOS 日期和时间选择器

如您所见,iOS 日期和时间选择器使用了您在本章早期学习过的 Picker 组件。Android 的选择器看起来大不相同;现在让我们来看看:

图片 8

图 22.9:Android 日期选择器

Android 版本与 iOS 日期/时间选择器的做法完全不同,但我们可以在两个平台上使用相同的 DatePicker 组件。这就结束了本章的内容。

摘要

在本章中,我们学习了各种类似于我们习惯的网页表单元素的 React Native 组件。我们首先学习了文本输入以及每个文本输入都有自己的虚拟键盘需要考虑。接下来,我们学习了 Picker 组件,它允许用户从选项列表中选择一个项目。然后,我们学习了 Switch 组件,它有点像复选框。有了这些组件,您将能够构建任何复杂性的表单。

在最后一节中,我们学习了如何实现适用于 iOS 和 Android 的通用日期/时间选择器。在下一章中,我们将学习 React Native 中的模态对话框。

第二十三章:响应用户手势

你在这本书中迄今为止实现的全部示例都依赖于用户手势。在传统的网络应用中,你主要处理鼠标事件。然而,触摸屏依赖于用户用手指操纵元素,这与鼠标操作有根本的不同。

在本章中,首先,你将学习滚动。这可能是除了触摸之外最常见的手势。然后,你将学习在用户与你的组件交互时提供适当的反馈级别。最后,你将实现可滑动的组件。

本章的目标是向你展示 React Native 内部的 手势响应系统 如何工作,以及该系统通过组件暴露的一些方式。

在本章中,我们将涵盖以下主题:

  • 用手指滚动

  • 提供触觉反馈

  • 使用可滑动和可取消的组件

技术要求

你可以在 GitHub 上找到本章的代码文件,地址为 github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter2

用手指滚动

在网络应用中,滚动是通过使用鼠标指针来回拖动滚动条或上下滚动,或者通过旋转鼠标滚轮来完成的。这在移动设备上不起作用,因为没有鼠标。所有操作都由屏幕上的手势控制。

例如,如果你想向下滚动,你用你的拇指或食指通过在屏幕上移动手指来物理地向上拉内容。

这样的滚动很难实现,但它变得更复杂。当你在一个移动屏幕上滚动时,会考虑拖动动作的速度。你快速拖动屏幕,然后放手,屏幕会根据你移动手指的速度继续滚动。你也可以在滚动过程中触摸屏幕来停止滚动。

幸运的是,你不必处理大部分这些事情。ScrollView 组件为你处理了大部分滚动复杂性。实际上,你已经在 第二十章渲染项目列表 中使用了 ScrollView 组件。ListView 组件已经内置了 ScrollView

你可以通过实现手势生命周期方法来调整用户交互的低级部分。你可能永远不需要这样做,但如果你对此感兴趣,你可以在 reactnative.dev/docs/gesture-responder-system 上阅读有关内容。

你可以在 ListView 之外使用 ScrollView。例如,如果你只是渲染任意内容,如文本和其他小部件:不是列表,换句话说:你只需将其包裹在 <ScrollView> 中。以下是一个例子:

export default function App() {
  return (
    <View style={styles.container}>
      <ScrollView style={styles.scroll}>
        {new Array(20).fill(null).map((v, i) => (
          <View key={i}>
            <Text style={[styles.scrollItem, styles.text]}>Some text</Text>
            <ActivityIndicator style={styles.scrollItem} size="large" />
            <Switch style={styles.scrollItem} />
          </View>
        ))}
      </ScrollView>
    </View>
  );
} 

ScrollView 组件本身并不很有用:它存在是为了包裹其他组件。它需要高度才能正确地工作。以下是滚动样式的样子:

scroll: {
    height: 1,
    alignSelf: "stretch",
  }, 

height属性设置为1,但alignSelf的拉伸值允许项目正确显示。以下是最终结果的样子:

图 23.1:ScrollView

当你向下拖动内容时,屏幕右侧有一个垂直滚动条。如果你运行这个示例,你可以尝试各种手势,比如让内容自动滚动然后停止。

当用户在屏幕上滚动内容时,他们会收到视觉反馈。用户在触摸屏幕上的某些元素时也应该收到视觉反馈。

提供触摸反馈

在这本书中,你迄今为止使用的 React Native 示例已经使用了纯文本作为按钮或链接。在 Web 应用程序中,为了让文本看起来可以点击,你只需用适当的链接将其包裹起来。React Native 中没有链接组件,所以你可以将文本样式设置为按钮样式。

尝试在移动设备上将文本样式设置为链接的问题在于它们太难按了。按钮提供了更大的目标供手指操作,并且更容易应用触摸反馈。

让我们给一些文本设置按钮样式。这是一个很好的第一步,因为它使文本看起来可以触摸。但你也想当用户开始与按钮交互时给用户提供视觉反馈。React Native 提供了几个组件来帮助实现这一点:

  • TouchableOpacity

  • TouchableHighlight

  • 可按压 API

但在深入代码之前,让我们看看当用户与这些组件交互时,它们在视觉上看起来是什么样子,从TouchableOpacity开始:

图片 2

图 23.2:TouchableOpacity

这里渲染了三个按钮。顶部的一个,标有不透明度,当前正被用户按下。按钮在被按下时变暗,这为用户提供重要的视觉反馈。

让我们看看当按下高亮按钮时它看起来是什么样子:

图片 3

图 23.3:可触摸高亮

与在按下时改变不透明度不同,TouchableHighlight组件在按钮上添加了一个高亮层。在这种情况下,它使用的是比字体和边框颜色中使用的板岩灰色更透明的版本。

最后一个按钮的例子是由Pressable组件提供的。Pressable API 被引入作为核心组件包装器,允许对其定义的任何子组件的不同按下交互阶段。使用这样的组件,我们可以处理onPressInonPressOut(我们将在下一章中探讨)和onLongPress回调,并实现我们想要的任何触摸反馈。让我们看看点击PressableButton时它看起来是什么样子:

图片 4

图 23.4:可按压按钮

如果我们继续按住这个按钮,我们将得到一个onLongPress事件,按钮将更新:

图片 5

图 23.5:长按按钮

实际上使用哪种方法并不重要。重要的是,你为用户提供了适当的触摸反馈,让他们在与按钮交互时感到舒适。实际上,你可能会在同一个应用中使用所有这些方法,但用于不同的事情。

让我们创建一个OpacityButtonHighlightButton组件,这使得使用前两种方法变得容易:

type ButtonProps = {
  label: string;
  onPress: () => void;
};
export const OpacityButton = ({ label, onPress }: ButtonProps) => {
  return (
    <TouchableOpacity
      style={styles.button}
      onPress={onPress}
      activeOpacity={0.5}
    >
      <Text style={styles.buttonText}>{label}</Text>
    </TouchableOpacity>
  );
};
export const HighlightButton = ({ label, onPress }: ButtonProps) => {
  return (
    <TouchableHighlight
      style={styles.button}
      underlayColor="rgba(112,128,144,0.3)"
      onPress={onPress}
    >
      <Text style={styles.buttonText}>{label}</Text>
    </TouchableHighlight>
  );
}; 

这里是创建此按钮所使用的样式:

button: {
    padding: 10,
    margin: 5,
    backgroundColor: "azure",
    borderWidth: 1,
    borderRadius: 4,
    borderColor: "slategrey",
  },
  buttonText: {
    color: "slategrey",
  }, 

现在让我们看看基于 Pressable API 的按钮:

const PressableButton = () => {
  const [text, setText] = useState("Not Pressed");
  return (
    <Pressable
      onPressIn={() => setText("Pressed")}
      onPressOut={() => setText("Press")}
      onLongPress={() => {
        setText("Long Pressed");
      }}
      delayLongPress={500}
      style={({ pressed }) => [
        {
          opacity: pressed ? 0.5 : 1,
        },
        styles.button,
      ]}
    >
      <Text>{text}</Text>
    </Pressable>
  );
}; 

这里是如何将这些按钮放入主应用模块中的:

export default function App() {
  return (
    <View style={styles.container}>
      <OpacityButton onPress={() => {}} label="Opacity" />
      <HighlightButton onPress={() => {}} label="Highlight" />
      <PressableButton />
    </View>
  );
} 

注意,onPress 回调实际上并没有做任何事情:我们传递它们是因为它们是一个必需的属性。

在下一节中,你将了解当用户在屏幕上滑动元素时提供反馈。

使用可滑动和可取消组件

与移动网页应用相比,原生移动应用更容易使用的一部分原因是它们感觉更直观。使用手势,你可以快速了解事物的工作方式。例如,用手指在屏幕上滑动一个元素是一个常见的动作,但这个动作必须是可发现的。

假设你正在使用一个应用,并且你并不完全确定屏幕上的某个功能是什么。所以,你用手指按下并尝试拖动元素。它开始移动。不确定会发生什么,你抬起手指,元素就回到了原位。你刚刚发现了这个应用程序的一部分是如何工作的。

你将使用Scrollable组件来实现这种可滑动可取消的行为。你可以创建一个相对通用的组件,允许用户将文本从屏幕上滑动掉,当这种情况发生时,调用回调函数。在我们查看通用组件本身之前,让我们看看将渲染滑动组件的代码:

export default function SwipableAndCancellable() {
  const [items, setItems] = useState(
    new Array(10).fill(null).map((v, id) => ({ id, name: "Swipe Me" }))
  );
  function onSwipe(id: number) {
    return () => {
      setItems(items.filter((item) => item.id !== id));
    };
  }
  return (
    <View style={styles.container}>
      {items.map((item) => (
        <Swipeable
          key={item.id}
          onSwipe={onSwipe(item.id)}
          name={item.name}
          width={200}
        />
      ))}
    </View>
  );
} 

这将在屏幕上渲染 10 个<Swipeable>组件。让我们看看这会是什么样子:

图片 6

图 23.6:带有可滑动组件的屏幕

现在,如果你开始向左滑动这些项目之一,它就会移动。下面是这个动作的样子:

图片 7

图 23.7:已滑动的组件

如果你没有滑动足够远,手势将被取消,项目将回到原位,正如预期的那样。如果你完全滑动,项目将从列表中完全移除,屏幕上的项目将填充空出的空间。

现在,让我们看看Swipeable组件本身:

type SwipeableProps = {
  name: string;
  width: number;
  onSwipe: () => void;
};
export default function Swipeable({ name, width, onSwipe }: SwipeableProps) {
  function onScroll(e: NativeSyntheticEvent<NativeScrollEvent>) {
    console.log(e.nativeEvent.contentOffset.x);
    e.nativeEvent.contentOffset.x >= width && onSwipe();
  }
  return (
    <View style={styles.swipeContainer}>
      <ScrollView
        horizontal
        snapToInterval={width}
        showsHorizontalScrollIndicator={false}
        scrollEventThrottle={10}
        onScroll={onScroll}
      >
        <View style={[styles.swipeItem, { width }]}>
          <Text style={styles.swipeItemText}>{name}</Text>
        </View>
        <View style={[styles.swipeBlank, { width }]} />
      </ScrollView>
    </View>
  );
} 

组件接受width属性来指定宽度本身,snapToInterval来创建具有滑动取消的翻页行为,以及处理我们可以在其中调用onSwipe回调以从列表中删除项目的距离。

要启用向左滑动,我们需要在包含文本的组件旁边添加一个空白组件。以下是用于此组件的样式:

 swipeContainer: {
    flex: 1,
    flexDirection: "row",
    width: 200,
    height: 30,
    marginTop: 50,
  },
  swipeItem: {
    height: 30,
    backgroundColor: "azure",
    justifyContent: "center",
    borderWidth: 1,
    borderRadius: 4,
    borderColor: "slategrey",
  },
  swipeItemText: {
    textAlign: "center",
    color: "slategrey",
  },
  swipeItemBlank: {
    height: 30,
  }, 

swipeItemBlank样式与swipeItem具有相同的高度,但除此之外没有其他内容。它是不可见的。

我们现在已经涵盖了本章的所有主题。

摘要

在本章中,我们介绍了在原生平台上的手势与移动网页平台相比有显著差异的观点。我们首先查看ScrollView组件,以及它如何通过为包装组件提供原生滚动行为来简化生活。

接下来,我们花了一些时间来实现带有触觉反馈的按钮。这是在移动网页上正确实现的一个棘手领域。我们学习了如何使用TouchableOpacityTouchableHighlightPressable API 组件来完成这项工作。

最后,我们实现了一个通用的Swipeable组件。滑动是一个常见的移动模式,它允许用户发现事物的工作方式,而不会感到害怕。

在下一章中,我们将学习如何使用 React Native 控制动画。

第二十四章:显示进度

本章全部关于向用户传达进度。React Native 有不同类型的组件,用于处理你想要传达的不同类型的进度。首先,你将学习为什么需要在应用中传达进度。然后,你将学习如何实现进度指示器和进度条。最后,你将看到具体的示例,展示如何在数据加载时使用进度指示器进行导航,以及如何使用进度条来传达一系列步骤中的当前位置。

本章涵盖了以下内容:

  • 理解进度和可用性

  • 指示进度

  • 测量进度

  • 探索导航指示器

  • 步骤进度

技术要求

你可以在 GitHub 上找到本章的代码文件,地址为github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter2

理解进度和可用性

想象一下,你有一个没有窗户且不发出声音的微波炉。与它交互的唯一方式是按下标有“烹饪”的按钮。这个设备听起来可能很荒谬,但这就是许多软件用户面临的情况:没有进度指示。微波炉在烹饪吗?如果是,我们怎么知道它什么时候会完成?

改善微波炉状况的一种方法是在其中添加蜂鸣声。这样,用户在按下烹饪按钮后就能得到反馈。你已经克服了一个障碍,但用户仍然会问,“我的食物什么时候能准备好?”在你破产之前,你最好添加某种进度测量显示,比如计时器。

并非 UI 程序员不理解这种可用性关注的基本原则;只是他们有事情要做,这类事情在优先级上只是被忽略了。在 React Native 中,有组件可以给用户提供不确定的进度反馈和精确的进度测量。如果你想要良好的用户体验,始终将这些事情作为首要任务是明智的。

现在你已经理解了进度在可用性中的作用,是时候学习如何在 React Native UI 中指示进度了。

指示进度

在本节中,你将学习如何使用ActivityIndicator组件。正如其名称所暗示的,当你需要向用户指示正在发生某事时,你将渲染此组件。实际的进度可能是不确定的,但至少你有一个标准化的方式来显示正在发生某事,尽管目前还没有结果可以显示。

让我们创建一个示例,以便你可以看到这个组件的外观。以下是App组件:

import React from "react";
import { View, ActivityIndicator } from "react-native";
import styles from "./styles";
export default function App() {
  return (
    <View style={styles.container}>
      <ActivityIndicator size="large" />
    </View>
  );
} 

<ActivityIndicator />组件是平台无关的。以下是它在 iOS 上的外观:

图片 1

图 24.1:iOS 上的活动指示器

它在屏幕中间渲染一个动画旋转器。这是在size属性中指定的较大旋转器。ActivityIndicator旋转器也可以是小的,如果您在另一个较小的元素内部渲染它,这更有意义。

现在,让我们看看这在一个 Android 设备上的样子:

图片 2

图 24.2:Android 上的活动指示器

旋转器的样子不同,正如它应该的那样,但您的应用在两个平台上传达了相同的信息:您正在等待某事。

此示例会无限旋转。但不用担心:接下来会有一个更现实的进度指示器示例,向您展示如何处理导航和加载 API 数据。

探索导航指示器

在本章的早期部分,您已经了解了ActivityIndicator组件。在本节中,您将学习如何在加载数据的程序导航中使用它。例如,用户从页面或屏幕一导航到页面二。然而,页面二需要从 API 获取数据,以便向用户显示。因此,当这个网络调用正在进行时,显示一个进度指示器比显示一个没有有用信息的屏幕更有意义。

做这件事实际上有点棘手,因为您必须确保每次用户导航到该屏幕时,屏幕所需的数据都是从 API 中获取的。您的目标应该是以下这些:

  • Navigator组件自动获取即将渲染的场景的 API 数据。

  • 使用 API 调用返回的 promise 作为显示旋转器和在 promise 解析后隐藏它的手段。

由于您的组件可能不关心是否显示旋转器,让我们将其实现为一个通用的Wrapper组件:

export function LoadingWrapper({ children }: Props) {
  const [loading, setLoading] = useState(true);
  useEffect(() => {
    setTimeout(() => {
      setLoading(false);
    }, 1000);
  }, []);
  if (loading) {
    return (
      <View style={styles.container}>
        <ActivityIndicator size="large" />
      </View>
    );
  } else {
    return children;
  }
} 

LoadingWrapper组件接受一个children组件,并在loading条件下返回它(即渲染)。它有一个带有超时的useEffect()钩子,当它解析时,它会将loading状态更改为false。如您所见,loading状态决定了是渲染旋转器还是children组件。

LoadingWrapper组件已经就位的情况下,让我们看看您将使用react-navigation的第一个屏幕组件:

const First = ({ navigation }: Props) => (
  <LoadingWrapper>
    <View style={styles.container}>
      <Button title="Second" onPress={() => navigation.navigate("Second")} />
      <Button title="Third" onPress={() => navigation.navigate("Third")} />
    </View>
  </LoadingWrapper>
); 

此组件渲染了一个由我们之前创建的LoadingWrapper组件包裹的布局。它包裹了整个屏幕,以便在setTimeout方法挂起时显示一个旋转器。这是一种有用的方法,可以在一个地方隐藏额外的逻辑,并在每个页面上重用它。在实际应用中,您可以通过传递额外的属性到LoadingWrapper来完全控制该屏幕的loading状态。

测量进度

仅指示进度正在进行的缺点是用户看不到结束的迹象。这会导致不安的感觉,就像你在没有计时器的微波炉里等待食物烹饪时一样。当你知道已经完成了多少进度以及还剩下多少进度时,你会感觉更好。这就是为什么在可能的情况下,始终使用确定性进度条会更好。

ActivityIndicator组件不同,React Native 中没有跨平台的进度条组件。因此,我们将使用react-native-progress库来渲染进度条。

在过去,React-Native 有专门用于显示 iOS 和 Android 进度条的组件,但由于 React-Native 的大小优化,Meta 团队正在努力将这些组件移动到单独的包中。因此,ProgressViewIOSProgressBarAndroid已经被移动到 React-Native 库之外。

现在,让我们构建应用程序将使用的ProgressBar组件:

import * as Progress from "react-native-progress";
type ProgressBarProps = {
  progress: number;
};
export default function ProgressBar({ progress }: ProgressBarProps) {
  return (
    <View style={styles.progress}>
      <Text style={styles.progressText}>{Math.round(progress * 100)}%</Text>
      <Progress.Bar width={200} useNativeDriver progress={progress} />
    </View>
  );
} 

ProgressBar组件接受progress属性并渲染标签和进度条。《Progress.Bar />组件接受一组属性,但我们只需要widthprogressuseNativeDriver(用于更好的动画)。现在,让我们将这个组件用于App`组件:

export default function MeasuringProgress() {
  const [progress, setProgress] = useState(0);
  useEffect(() => {
       let timeoutRef: NodeJS.Timeout | null = null;
    function updateProgress() {
         setProgress((currentProgress) => {
        if (currentProgress < 1) {
          return currentProgress + 0.01;
        } else {
          return 0;
        }
      });
    timeoutRef = setTimeout(updateProgress, 100);
    }
    updateProgress();
    return () => {
      timeoutRef && clearTimeout(timeoutRef);
    };
  }, []);
  return (
    <View style={styles.container}>
      <ProgressBar progress={progress} />
    </View>
  );
} 

初始时,<ProgressBar>组件渲染为 0%。在useEffect()钩子中,updateProgress()函数使用计时器来模拟你想要显示进度的真实过程。

在现实世界中,你可能永远不会使用计时器的模拟。然而,在某些特定场景下,这种方法可能非常有价值,例如在显示统计数据或监控文件上传到服务器的进度时。在这些情况下,即使你并不依赖于直接的计时器,你仍然可以访问到当前进度值,并可以使用它。

下面是这个屏幕的样子:

图片 3

图 24.3:进度条

显示进度的定量度量很重要,这样用户就可以判断某件事需要多长时间。在下一节中,你将学习如何使用步骤进度条来显示用户在导航屏幕中的位置。

步骤进度

在这个最后的例子中,你将构建一个应用程序,显示用户通过预定义步骤的进度。例如,将表单分成几个逻辑部分并按这种方式组织它们,当用户完成一个部分时,他们就可以移动到下一个步骤。进度条对用户来说是一个有用的反馈。

你将在导航栏中插入进度条,位于标题下方,这样用户就可以知道他们已经走了多远以及还剩下多远。你还将重用本章前面使用的ProgressBar组件。

让我们先看看结果。在这个应用程序中,用户可以导航的屏幕有四个。这是第一页(场景)的样子:

图片 4

图 24.4:第一个屏幕

标题下的进度条反映了用户已经完成了导航的 25%。让我们看看第三个屏幕的样子:

图片 5

图 24.5:第三个屏幕

进度更新以反映用户在路由堆栈中的位置。让我们看看这里的App组件:github.com/PacktPublishing/React-and-React-Native-5E/blob/main/Chapter21/step-progress-new/App.tsx

此应用有四个屏幕。渲染每个屏幕的组件存储在routes常量中,然后使用createNativeStackNavigator()配置堆栈导航器。创建routes数组的原因是它可以由initialParams传递给每个路由的progress参数使用。为了计算进度,我们取当前路由索引作为路由长度的值。

例如,Second位于数字 2 的位置(索引为 1 + 1),数组的长度为 4。这将使进度条达到 50%。

此外,下一页上一页按钮调用navigation.navigate()时必须传递routeName,因此我们在screenOptions处理程序中添加了nextRouteNameprevRouteName变量。

摘要

在本章中,你学习了如何向用户展示幕后正在发生的事情。首先,我们讨论了为什么显示进度对于应用程序的可用性很重要。然后,我们实现了一个基本的屏幕,指示正在进行的进度。之后,我们实现了一个ProgressBar组件,用于测量特定的进度量。

指示器适用于不确定的进度。我们实现了在网络调用挂起时显示进度指示器的导航。在最后一节中,我们实现了一个进度条,显示了用户在预定义的步骤中的位置。

在下一章中,我们将探讨 React Native 地图和地理位置数据在实际中的应用。

第二十五章:显示模态屏幕

本章的目标是向您展示如何以不干扰当前页面的方式向用户展示信息。页面使用View组件并将其直接渲染到屏幕上。然而,有时会有一些重要的信息用户需要看到,但你又不想让他们离开当前页面。

你将从学习如何显示重要信息开始。通过了解哪些信息是重要的以及何时使用它,你将学习如何获取用户的确认:既适用于错误场景也适用于成功场景。然后,你将实现被动通知,向用户显示发生了某些事情。最后,你将实现模态视图,显示后台正在发生的事情。

本章将涵盖以下主题:

  • 术语定义

  • 获取用户确认

  • 被动通知

  • 活动模态

技术要求

你可以在 GitHub 上找到本章的代码文件,链接为github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter2

术语定义

在你开始实现警告、通知和确认之前,让我们花几分钟时间思考一下每一项的含义。我认为这很重要,因为如果你只是被动地通知用户关于错误的信息,它很容易被忽略。以下是我对您希望显示的信息类型的定义:

  • 警告:刚刚发生了一些重要的事情,你需要确保用户能看到正在发生的情况。可能的话,用户需要确认这个警告。

  • 确认:这是警告的一部分。例如,如果用户刚刚执行了一个操作,然后想要在继续之前确保操作成功,他们必须确认他们已经看到了信息,以便关闭模态框。确认也可以存在于警告中,提醒用户即将执行的操作。

  • 通知:发生了一些事情,但并不足以完全阻止用户正在进行的活动。这些通常会在自己消失。

技巧在于尝试在信息值得了解但不是关键的情况下使用通知。只有在功能的工作流程无法在没有用户确认正在发生的事情的情况下继续时,才使用确认。在接下来的章节中,你将看到用于不同目的的警告和通知的示例。

获取用户确认

在本节中,你将学习如何显示模态视图以获取用户的确认。首先,你将学习如何实现一个成功的场景,其中操作产生了一个成功的成果,你希望用户意识到这一点。然后,你将学习如何实现一个错误场景,其中出了问题,你不想让用户在没有确认问题的情况下继续前进。

显示成功确认

让我们从实现一个作为用户成功执行操作的结果显示的模态视图开始。这是Modal组件,用于向用户展示确认模态

type Props = ModalProps & {
  onPressConfirm: () => void;
  onPressCancel: () => void;
};
export default function ConfirmationModal({
  onPressConfirm,
  onPressCancel,
  ...modalProps
}: Props) {
  return (
    <Modal transparent onRequestClose={() => {}} {...modalProps}>
      <View style={styles.modalContainer}>
        <View style={styles.modalInner}>
          <Text style={styles.modalText}>Dude, srsly?</Text>
          <Text style={styles.modalButton} onPress={onPressConfirm}>
            Yep
          </Text>
          <Text style={styles.modalButton} onPress={onPressCancel}>
            Nope
          </Text>
        </View>
      </View>
    </Modal>
  );
} 

传递给ConfirmationModal的属性被转发到 React Native 的Modal组件。你很快就会明白原因。首先,让我们看看这个确认模态的外观:

图片 1

图 25.1:确认模态

用户完成操作后显示的模态使用我们自己的样式和确认消息。它还有两个操作,但根据这个确认是预操作还是后操作,可能只需要一个。以下是用于此模态的样式:

 modalContainer: {
    flex: 1,
    justifyContent: "center",
    alignItems: "center",
  },
  modalInner: {
    backgroundColor: "azure",
    padding: 20,
    borderWidth: 1,
    borderColor: "lightsteelblue",
    borderRadius: 2,
    alignItems: "center",
  },
  modalText: {
    fontSize: 16,
    margin: 5,
    color: "slategrey",
  },
  modalButton: {
    fontWeight: "bold",
    margin: 5,
    color: "slategrey",
  }, 

使用 React Native 的Modal组件,你几乎可以随心所欲地设计你的确认模态视图的外观。把它们想象成常规视图,唯一的区别是它们是在其他视图之上渲染的。

大多数时候,你可能不会关心自己模态视图的样式。例如,在网页浏览器中,你可以简单地调用alert()函数,该函数在浏览器设置的窗口中显示文本。React Native 有类似的东西:Alert.alert()。这就是我们如何打开原生警告框的方式:

function toggleAlert() {
    Alert.alert("", "Failed to do the thing...", [
      {
        text: "Dismiss",
      },
    ]);
  } 

这是 iOS 上警告的显示效果:

图片 2

图 25.2:iOS 上的确认警告

在功能方面,这里并没有什么真正的区别。这里有一个标题和其下的文本,但如果你想要的话,这些可以很容易地添加到模态视图中。真正的区别在于,这个模态看起来像 iOS 模态,而不是由应用设置的样式。让我们看看这个警告在 Android 上的显示效果:

图片 3

图 25.3:Android 上的确认警告

这个模态看起来像 Android 模态,你不需要为其设置样式。我认为大多数情况下,使用警告框而不是模态框是更好的选择。让某些东西看起来像是 iOS 或 Android 的一部分是有意义的。然而,有时你需要更多控制模态的外观,例如在显示错误确认时。

渲染模态的方法与渲染警告的方法不同。然而,它们仍然是基于属性值变化的声明式组件。

错误确认

显示成功确认部分学到的所有原则,在你需要用户确认错误时都是适用的。如果你需要更多控制显示方式,请使用模态框。例如,你可能想让模态框看起来是红色的,令人害怕的,就像这样:

图片 4

图 25.4:错误确认模态

这是创建此外观所使用的样式。也许你想要更微妙一些,但重点是你可以按照自己的意愿来制作这个外观:

 modalInner: {
    backgroundColor: "azure",
    padding: 20,
    borderWidth: 1,
    borderColor: "lightsteelblue",
    borderRadius: 2,
    alignItems: "center",
  }, 

modalInner样式属性中,我们定义了屏幕样式。接下来,我们将定义模态样式:

 modalInnerError: {
    backgroundColor: "lightcoral",
    borderColor: "darkred",
  },
  modalText: {
    fontSize: 16,
    margin: 5,
    color: "slategrey",
  },
  modalTextError: {
    fontSize: 18,
    color: "darkred",
  },
  modalButton: {
    fontWeight: "bold",
    margin: 5,
    color: "slategrey",
  },
  modalButtonError: {
    color: "black",
  }, 

你用于成功确认的相同模态样式仍然在这里。这是因为错误确认模态需要许多相同的样式属性。

下面是如何将两者应用到Modal组件上:

const innerViewStyle = [styles.modalInner, styles.modalInnerError];
const textStyle = [styles.modalText, styles.modalTextError];
const buttonStyle = [styles.modalButton, styles.modalButtonError];
type Props = ModalProps & {
  onPressConfirm: () => void;
  onPressCancel: () => void;
};
export default function ErrorModal({
  onPressConfirm,
  onPressCancel,
  ...modalProps
}: Props) {
  return (
    <Modal transparent onRequestClose={() => {}} {...modalProps}>
      <View style={styles.modalContainer}>
        <View style={innerViewStyle}>
          <Text style={textStyle}>Epic fail!</Text>
          <Text style={buttonStyle} onPress={onPressConfirm}>
            Fix it
          </Text>
          <Text style={buttonStyle} onPress={onPressCancel}>
            Ignore it
          </Text>
        </View>
      </View>
    </Modal>
  );
} 

样式在传递给style组件属性之前被组合成数组。错误样式总是放在最后,因为如backgroundColor这样的冲突样式属性将被数组中后面的样式覆盖。

除了错误确认中的样式外,你还可以包括你想要的任何高级控件。这完全取决于你的应用程序如何让用户处理错误:例如,可能有一些可以采取的行动方案。

然而,更常见的情况是出了些问题,除了确保用户意识到这种情况外,你无能为力。在这些情况下,你可能只需显示一个警告:

图片 5

图 25.5:错误警告

现在你已经能够显示需要用户参与的错误通知了,是时候了解不那么激进的、不会打断用户当前操作的通知了。

被动通知

在本章中你检查到的所有通知都需要用户的输入。这是出于设计考虑,因为这是重要的信息,你正在强迫用户查看。然而,你不想做得太过分。对于重要但忽略后不会改变生活的重要性的通知,你可以使用被动通知。这些通知以一种不那么引人注目的方式显示,并且不需要任何用户操作来关闭它们。

在本节中,你将创建一个使用react-native-root-toast库提供的Toast API的应用程序。之所以称为 Toast API,是因为显示的信息看起来像一块弹出的吐司。Toast是 Android 中显示一些不需要用户响应的基本信息的常用组件。由于 iOS 没有 Toast API,我们将使用一个在两个平台上都运行良好的类似 API 的库。

下面是App组件的样式:

export default function PassiveNotifications() {
  return (
    <RootSiblingParent>
      <View style={styles.container}>
        <Text
          onPress={() => {
            Toast.show("Something happened!", {
              duration: Toast.durations.LONG,
            });
          }}
        >
          Show Notification
        </Text>
      </View>
    </RootSiblingParent>
  );
} 

首先,我们应该将我们的应用程序包裹在RootSiblingParent组件中,然后我们就可以开始使用 Toast API 了。要打开一个 Toast,我们调用Toast.show方法。

下面是 Toast 通知的样式:

图片 B19636_25_06

图 25.6:Android 的 Toast

在屏幕底部显示一条通知,内容为发生了某些事情!,并在短时间内消失。关键是通知不会太引人注目。

让我们看看相同的 Toast 在 iOS 设备上的样子:

图片 B19636_25_07

图 25.7:iOS 的通知

在下一节中,你将了解活动模态,它向用户显示正在发生的事情。

活动模态

在本章的最后部分,您将实现一个显示进度指示器的模态框。想法是显示模态框,然后在 promise 解析时隐藏它。以下是通用Activity组件的代码,它显示带有ActivityIndicator的模态框:

type ActivityProps = {
  visible: boolean;
  size?: "small" | "large";
};
export default function Activity({ visible, size = "large" }: ActivityProps) {
  return (
    <Modal visible={visible} transparent>
      <View style={styles.modalContainer}>
        <ActivityIndicator size={size} />
      </View>
    </Modal>
  );
} 

您可能会想将 promise 传递给组件,以便它在 promise 解析时自动隐藏。我认为这不是一个好主意,因为那样您就必须将状态引入此组件。此外,它将依赖于 promise 才能运行。按照您实现此组件的方式,您可以根据visible属性单独显示或隐藏模态框。

这是 iOS 上活动模态的显示效果:

图片 8

图 25.8:活动模态

在覆盖主视图的模态窗口上有一个半透明的背景,其中包含Fetch Stuff...链接。点击此链接,我们将看到活动加载器。以下是styles.js中创建此效果的方法:

modalContainer: {
    flex: 1,
    justifyContent: "center",
    alignItems: "center",
    backgroundColor: "rgba(0, 0, 0, 0.2)",
  }, 

而不是将实际的Modal组件设置为透明,您可以在backgroundColor中设置透明度,这样看起来就像是一个覆盖层。现在,让我们看看控制此组件的代码:

export default function App() {
  const [fetching, setFetching] = useState(false);
  const [promise, setPromise] = useState(Promise.resolve());
  function onPress() {
    setPromise(
      new Promise((resolve) => setTimeout(resolve, 3000)).then(() => {
        setFetching(false);
      })
    );
    setFetching(true);
  }
  return (
    <View style={styles.container}>
      <Activity visible={fetching} />
      <Text onPress={onPress}>Fetch Stuff...</Text>
    </View>
  );
} 

当按下获取链接时,会创建一个新的 promise 来模拟异步网络活动。然后,当 promise 解析时,您可以更改fetching状态回false,以便隐藏活动对话框。

摘要

在本章中,我们学习了向移动用户显示重要信息的需求。这有时需要用户的明确反馈,即使只是承认消息。在其他情况下,被动通知效果更好,因为它们比确认模态不那么侵扰。

我们可以使用两种工具向用户显示消息:模态框和警告框。模态框更灵活,因为它们就像常规视图一样。警告框适合显示纯文本,并且会为我们处理样式问题。在 Android 上,我们还有ToastAndroid接口。我们看到了在 iOS 上也可以这样做,但这需要更多的工作。

在下一章中,我们将更深入地探讨 React Native 内部的手势响应系统,这比浏览器能提供的移动体验更好。

第二十六章:使用动画

动画可以用来提升移动应用程序的用户体验。它们通常帮助用户快速识别出变化,或者帮助他们关注重要的事情。它们提升了用户体验和用户满意度。此外,动画看起来也很有趣。例如,在 Instagram 应用中点赞帖子时的心跳反应,或者在 Snapchat 刷新页面时的幽灵动画。

在 React Native 中处理和控制动画有几种不同的方法。首先,我们将看看我们可以使用的动画工具,发现它们的优缺点,并进行比较。然后,我们将实现几个示例,以更好地了解 API。

在这个章节中,我们将涵盖以下主题:

  • 使用 React Native Reanimated

  • 动画化布局组件

  • 动画化组件样式

技术要求

你可以在 GitHub 上找到这个章节的代码文件,链接为 github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter26

使用 React Native Reanimated

在 React Native 世界中,我们有大量的库和方案来动画化我们的组件,包括内置的 Animated API。但在这个章节中,我想要选择一个名为 React Native Reanimated 的库,并将其与 Animated API 进行比较,以了解为什么它是最佳选择。

Animated API

Animated API 是在 React Native 中用于动画化组件最常用的工具。它提供了一系列方法,帮助你创建动画对象,控制其状态,并处理它。主要好处是它可以与任何组件一起使用,而不仅仅是像 View 或 Text 这样的动画组件。

但是,同时,这个 API 已经在 React Native 的旧架构中实现了。JavaScript 和 UI Native 线程之间的异步通信使用 Animated API,导致更新至少延迟一帧,持续大约 16 毫秒。有时,如果 JavaScript 线程正在运行 React 的 diff 算法,同时比较或处理网络请求,延迟可能会更长。React Native Reanimated 库可以解决这个问题,它基于新的架构,并在 UI 线程中从 JavaScript 线程处理所有业务逻辑。

React Native Reanimated

React Native Reanimated 可以用来提供对 Animated API 的更全面的抽象,以便与 React Native 一起使用。它提供了一个具有多阶段动画和自定义过渡的命令式 API,同时提供了一个声明式 API,可以用来以类似 CSS 过渡的方式描述简单的动画和过渡。它是建立在 React Native Animated 之上的,并在原生线程上重新实现了它。这允许你在使用最高性能和最简单的 API 的同时,利用熟悉的 JavaScript 语言。

此外,React Native Reanimated 定义了 worklets,这些是可以在 UI 线程中同步执行的 JavaScript 函数。这允许在不等待新帧的情况下进行即时动画。让我们看看一个简单的 worklet 是什么样子:

function simpleWorklet() {
  "worklet";
  console.log("Hello from UI thread");
} 

要在 UI 线程内调用 simpleWorklet 函数,只需在 function 块的顶部添加 worklet 指令即可。

React Native Reanimated 提供了各种钩子和方法,帮助我们处理动画:

  • useSharedValue:这个钩子返回一个 SharedValue 实例,这是在 UI 线程上下文中存在的主要有状态数据对象,其概念与核心 Animated API 中的 Animated.Value 类似。当 SharedValue 发生变化时,会触发 Reanimated 动画。主要好处是共享值的更新可以在 React Native 和 UI 线程之间同步,而不会触发重新渲染。这使复杂的动画能够在 60 FPS 下平稳运行,而不会阻塞 JS 线程。

  • useDerivedValue:这个钩子创建了一个新的共享值,当其计算中使用的共享值发生变化时,它会自动更新。它允许你创建依赖于其他共享值的共享值,同时保持它们的所有反应性。useDerivedValue 用于在 UI 线程上运行的 worklet 中创建 派生 状态,该状态基于源共享值的更新。然后,这个派生状态可以驱动动画或其他副作用,而不会在 JS 线程上触发重新渲染。

  • useAnimatedStyle:这个钩子允许你创建一个可以基于共享值动画其属性的风格对象。它将共享值更新映射到相应的视图属性。useAnimatedStyle 是将共享值连接到视图并启用在 UI 线程上运行的平滑动画的主要方式。

  • withTimingwithSpringwithDecay:这些是动画实用方法,它们使用各种曲线和物理方式以平滑、动画的方式更新共享值。它们允许你通过指定目标值和动画配置来声明式地定义动画。

我们已经了解了 React Native Reanimated 是什么以及它与 Animated API 的不同之处。接下来,让我们尝试安装它并将其应用到我们的应用中。

安装 React Native Reanimated 库

要安装 React Native Reanimated 库,请在您的 Expo 项目内部运行此命令:

expo install react-native-reanimated 

安装完成后,我们需要将 Babel 插件添加到 babel.config.js 文件中:

module.exports = function(api) {
  api.cache(true);
  return {
    presets: ['babel-preset-expo'],
    plugins: ['react-native-reanimated/plugin'],
  };
}; 

该插件的主要目的是将我们的 JavaScript worklet 函数转换为将在 UI 线程中工作的函数。

添加 Babel 插件后,重新启动您的开发服务器并清除 bundler 缓存:

expo start --clear 

本节向我们介绍了 React Native Reanimated 库。我们了解到为什么它比内置的 Animated API 更好。在下一节中,我们将使用实际示例来展示它。

布局组件的动画

一个常见的用例是动画化组件的进入和退出布局。这意味着当您的组件首次渲染时以及当您卸载组件时,它将以动画形式出现。React Native Reanimated 是一个 API,允许您动画化布局并添加如 FadeInBounceInZoomIn 等动画。

React Native Reanimated 还提供了一个特殊的 Animated 组件,它与 Animated API 中的 Animated 组件相同,但具有额外的属性:

  • entering:在组件挂载和渲染时接受预定义的动画

  • exiting:接受相同的动画对象,但将在组件卸载时被调用

让我们创建一个简单的待办事项列表,其中包含创建任务的按钮和允许我们在点击时删除任务的功能。

截图中无法看到动画,所以我建议你打开代码并尝试实现动画以查看结果。

首先,让我们看看我们的待办事项应用的主屏幕以及当前项目是如何渲染的:

图片 1

图 26.1:待办事项列表

这是一个简单的示例,包含任务项列表和一个用于添加新任务的按钮。当我们快速多次按下 添加 按钮时,列表项会以动画形式从屏幕左侧出现:

图片 2

图 26.2:具有动画渲染的待办事项列表

魔法是在 TodoItem 组件中实现的。让我们看看它:

export const TodoItem = ({ id, title, onPress }) => {
  return (
    <Animated.View entering={SlideInLeft}
      exiting={SlideOutRight}>
      <TouchableOpacity onPress={() => onPress(id)}
        style={styles.todoItem}>
        <Text>{title}</Text>
      </TouchableOpacity>
    </Animated.View>
  );
}; 

如您所见,没有复杂的逻辑,代码也不是很多。我们只是将 Animated 组件作为动画的根组件,并将 React Native Reanimated 库中预定义的动画传递给 enteringexiting 属性。

要看到项目如何从屏幕上消失,我们需要按下待办事项项目,这样退出动画就会运行。我已经按下了几个项目,并尝试在下面的屏幕截图中捕捉结果:

图片 3

图 26.3:从屏幕上删除待办事项

让我们检查 App 组件以了解整个情况:

export default function App() {
  const [todoList, setTodoList] = useState([]);
  const addTask = () => {
    setTodoList([
      ...todoList,
      { id: String(new Date().getTime()), title: "New task"
        },
    ]);
  };
  const deleteTask = (id) => {
    setTodoList(todoList.filter((todo) => todo.id !== id));
  }; 

我们使用 useState 钩子和添加和删除任务的处理器函数创建了一个 todoList 状态。接下来,让我们看看动画将如何应用于布局:

 return (
    <View style={styles.container}>
      <View style={{ flex: 1 }}>
        {todoList.map(({ id, title }) => (
          <TodoItem key={id} id={id} title={title}
            onPress={deleteTask} />
        ))}
      </View>
      <Button onPress={addTask} title="Add" />
    </View>
  );
} 

在这个示例中,我们学习了一种简单的方法来应用动画,使我们的应用看起来更好。然而,React Native Reanimated 库比我们想象的要强大得多。下一个示例将说明我们如何通过直接将动画应用于组件的样式来动画化和创建自己的动画。

组件样式动画

在一个更复杂的示例中,我建议创建一个具有美丽可触摸反馈的按钮。这个按钮将使用我们在第二十三章“响应用户手势”中学到的Pressable组件来构建。该组件接受onPressInonLongPressonPressOut事件。由于这些事件,我们将能够看到我们的触摸如何在按钮上反映。

让我们先定义SharedValueAnimatedStyle

 const radius = useSharedValue(30);
  const opacity = useSharedValue(1);
  const scale = useSharedValue(1);
  const color = useSharedValue(0);
  const backgroundColor = useDerivedValue(() => {
    return interpolateColor(color.value, [0, 1], ["orange",      "red"]);
     });
  const animatedStyles = useAnimatedStyle(() => {
    return {
      opacity: opacity.value,
      borderRadius: radius.value,
      transform: [{ scale: scale.value }],
      backgroundColor: backgroundColor.value,
    };
  }, []); 

为了动画化样式属性,我们使用useSharedValue钩子创建了一个SharedValue对象。它接受默认值作为参数。接下来,我们使用useAnimatedStyle钩子创建了样式对象。该钩子接受一个回调,该回调应返回一个样式对象。useAnimatedStyle钩子与useMemo钩子类似,但所有计算都在 UI 线程中执行,并且所有SharedValue的变化都将触发钩子重新计算样式对象。按钮的背景色是通过useDerivedValue创建的,通过在橙色和红色之间插值来提供平滑的过渡。

接下来,让我们创建处理函数,这些函数将根据按钮的按下状态更新样式属性:

 const onPressIn = () => {
    radius.value = withSpring(20);
    opacity.value = withSpring(0.7);
    scale.value = withSpring(0.9);
  };
  const onLongPress = () => {
    scale.value = withSpring(0.8);
    color.value = withSpring(1);
  };
  const onPressOut = () => {
    radius.value = withSpring(30);
    opacity.value = withSpring(1);
    scale.value = withSpring(1, { damping: 50 });
    color.value = withSpring(0);
  }; 

第一个处理函数onPressInborderRadiusopacityscale从它们的默认值更新。我们同样使用withSpring来更新这些值,这使得更新样式更加平滑。像第一个处理函数一样,其他处理函数也会以不同的方式更新按钮的样式。onLongPress将按钮变为红色并使其变小。onPressOut将所有值重置为它们的默认值。

我们已经实现了所有必要的逻辑,现在可以将其应用于布局:

 <View style={styles.container}>
      <Animated.View style={[styles.buttonContainer,
        animatedStyles]}>
        <Pressable
          onPressIn={onPressIn}
          onPressOut={onPressOut}
          onLongPress={onLongPress}
          style={styles.button}
        >
          <Text style={styles.buttonText}>Press me</Text>
        </Pressable>
      </Animated.View>
    </View> 

最后,让我们看看结果:

图片 4

图 26.4:具有默认、按下和长按样式的按钮

图 26.4中,你可以看到按钮的三个状态:默认、按下和长按。

摘要

在本章中,我们学习了如何使用 React Native Reanimated 库向布局和组件添加动画。我们了解了库的基本原理,并发现了它在底层的工作方式以及它是如何在不使用 Bridge 连接应用 JavaScript 和原生层的情况下在 UI 线程中执行代码的。

我们还通过两个使用 React Native Reanimated 库的示例进行了说明。在第一个示例中,我们学习了如何使用预定义的声明式动画来应用布局动画,使我们的组件以美丽的方式出现和消失。在第二个示例中,我们使用useSharedValueuseAnimatedStyle钩子来动画化按钮的样式。

动画组件和布局的技巧将帮助您使您的应用更加美观和响应。在下一章中,我们将学习如何在我们的应用中控制图像。

第二十七章:控制图像显示

到目前为止,本书中的示例还没有在移动屏幕上渲染任何图像。这并不反映移动应用程序的现实。Web 应用程序显示了很多图像。如果有什么不同的话,原生移动应用程序比 Web 应用程序更依赖于图像,因为当您有限的空间时,图像是一种强大的工具。

在本章中,您将学习如何使用 React Native 的Image组件,从加载不同来源的图像开始。然后,您将学习如何使用Image组件来调整图像大小,以及如何为懒加载的图像设置占位符。最后,您将学习如何使用@expo/vector-icons包实现图标。这些部分涵盖了在应用程序中使用图像和图标的最常见用例。

我们将在本章中介绍以下主题:

  • 加载图像

  • 调整图像大小

  • 懒加载图像

  • 渲染图标

技术要求

您可以在 GitHub 上找到本章的代码和图像文件,网址为github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter27

加载图像

让我们首先弄清楚如何加载图像。您可以像任何其他 React 组件一样渲染<Image>组件并传递其属性。但这个特定的组件需要图像 blob 数据才能发挥作用。BLOB(代表Binary Large Object,即二进制大对象)是一种用于存储大型、非结构化二进制数据的数据类型。BLOB 通常用于存储多媒体文件,如图像、音频和视频。

让我们看看一些代码:

const reactLogo = "https://reactnative.dev/docs/assets/favicon.png";
const relayLogo = require("./assets/relay.png");
export default function App() {
  return (
    <View style={styles.container}>
      <Image style={styles.image} source={{ uri: reactLogo }} />
      <Image style={styles.image} source={relayLogo} />
    </View>
  );
} 

有两种方法可以将 blob 数据加载到<Image>组件中。第一种方法是从网络中加载图像数据。这是通过将具有URI属性的对象传递给source代码来完成的。本例中的第二个<Image>组件正在使用本地图像文件。它是通过调用require()并将结果传递给source代码来实现的。

现在,让我们看看渲染结果是什么样的:

图片 1

图 27.1:图像加载

这是这些图像使用的样式:

 image: {
    width: 100,
    height: 100,
    margin: 20,
  }, 

注意,如果没有设置widthheight样式属性,图像将不会渲染。在下一节中,您将了解当设置widthheight值时图像缩放是如何工作的。

调整图像大小

Image组件的widthheight样式属性决定了屏幕上渲染的大小。例如,您可能需要在某个时候处理分辨率比您希望在 React Native 应用程序中显示的更大的图像。只需在Image上设置widthheight样式属性就足以正确缩放图像。

让我们看看一些代码,这些代码允许您使用控件动态调整图像的尺寸:

export default function App() {
  const source = require("./assets/flux.png");
  const [width, setWidth] = useState(100);
  const [height, setHeight] = useState(100);
  return (
    <View style={styles.container}>
      <Image source={source} style={{ width, height }} />
      <Text>Width: {width}</Text>
      <Text>Height: {height}</Text>
      <Slider
        style={styles.slider}
        minimumValue={50}
        maximumValue={150}
        value={width}
        onValueChange={(value) => {
          setWidth(value);
          setHeight(value);
        }}
      />
    </View>
  );
} 

如果您使用默认的 100 x 100 维度,图像看起来是这样的:

图片 2

图 27.2:100 x 100 图像

这里是缩小后的图像版本:

图片 3

图 27.3:50 x 50 图像

最后,这里是一个放大后的图像版本:

图片 4

图 27.4:150 x 150 图像

有一个resizeMode属性可以传递给Image组件。这决定了缩放后的图像如何适应实际组件的尺寸。您将在本章的渲染图标部分看到这个属性的用法。

如您所见,图像的尺寸由widthheight样式属性控制。在应用运行时,可以通过更改这些值来调整图像大小。在下一节中,您将学习如何懒加载图像。

懒加载图像

有时,您可能不希望在图像渲染的精确时刻加载图像;例如,您可能正在渲染屏幕上尚未可见的内容。大多数时候,在图像实际可见之前从网络上获取图像源是完全可以接受的。但如果您正在微调应用程序并发现通过网络加载大量图像会导致性能问题,您可以使用懒加载策略。

我认为在移动环境中更常见的用例是处理您已经渲染了一个或多个图像,但网络响应缓慢的场景。在这种情况下,您可能希望渲染一个占位图,以便用户立即看到一些内容,而不是空白空间。那么,让我们开始吧。

首先,您可以实现一个抽象,它封装了加载后要显示的实际图像。以下是该代码:

const placeholder = require("./assets/placeholder.png");
type PlaceholderProps = {
  loaded: boolean;
  style: StyleProp<ImageStyle>;
};
function Placeholder({ loaded, style }: PlaceholderProps) {
  if (loaded) {
    return null;
  } else {
    return <Image style={style} source={placeholder} />;
  }
} 

现在,在这里,您可以看到占位图仅在原始图像未加载时才会被渲染:

type Props = {
  style: StyleProp<ImageStyle>;
  resizeMode: ImageProps["resizeMode"];
  source: ImageSourcePropType | null;
};
export default function LazyImage({ style, resizeMode, source }: Props) {
  const [loaded, setLoaded] = useState(false);
  return (
    <View style={style}>
      {!!source ? (
        <Image
          source={source}
          resizeMode={resizeMode}
          style={style}
          onLoad={() => {
            setLoaded(true);
          }}
        />
      ) : (
        <Placeholder loaded={loaded} style={style} />
      )}
    </View>
  );
} 

此组件渲染一个包含两个Image组件的View组件。它还有一个加载状态,初始值为false。当loadedfalse时,将渲染占位图。当调用onLoad()处理程序时,将loaded状态设置为true。这意味着占位图将被移除,主图像将显示出来。

现在,让我们使用我们刚刚实现的LazyImage组件。您将渲染没有source的图像,并且应该显示占位图。让我们添加一个按钮,为懒加载图像提供一个source。当它加载时,占位图应该被替换。以下是主app模块的外观:

const remote = "https://reactnative.dev/docs/assets/favicon.png";
export default function LazyLoading() {
  const [source, setSource] = useState<ImageSourcePropType | null>(null);
  return (
    <View style={styles.container}>
      <LazyImage
        style={{ width: 200, height: 150 }}
        resizeMode="contain"
        source={source}
      />
      <Button
        label="Load Remote"
        onPress={() => {
          setSource({ uri: remote });
        }}
      />
    </View>
  );
} 

这就是屏幕的初始状态:

图片 5

图 27.5:图像的初始状态

然后,点击加载远程按钮,最终看到我们真正想要的图像:

图片 6

图 27.6:已加载图像

您可能会注意到,根据您的网络速度,占位符图像在您点击 加载远程 按钮后仍然可见。这是设计上的考虑,因为您不希望在确定实际图像准备好显示之前移除占位符图像。现在,让我们在我们的 React Native 应用程序中渲染一些图标。

渲染图标

在本章的最后部分,您将学习如何在 React Native 组件中渲染图标。使用图标来表示意义可以使网络应用程序更易于使用。那么,为什么原生移动应用程序会有所不同呢?

我们将使用 @expo/vector-icons 包将各种矢量字体包拉入您的 React Native 应用程序。这个包已经是我们在应用程序基础上使用的 Expo 项目的组成部分,现在,您可以导入 Icon 组件并将它们渲染出来。让我们实现一个示例,根据选择的图标类别渲染几个 FontAwesome 图标:

export default function RenderingIcons() {
  const [selected, setSelected] = useState<IconsType>("web_app_icons");
  const [listSource, setListSource] = useState<IconName[]>([]);
  const categories = Object.keys(iconNames);
  function updateListSource(selected: IconsType) {
    const listSource = iconNames[selected] as any;
    setListSource(listSource);
    setSelected(selected);
  }
  useEffect(() => {
    updateListSource(selected);
  }, []); 

在这里,我们已经定义了所有必要的逻辑来存储和更新图标数据。接下来,我们将将其应用于布局:

 return (
    <View style={styles.container}>
      <View style={styles.picker}>
        <Picker selectedValue={selected} onValueChange={updateListSource}>
          {categories.map((category) => (
            <Picker.Item key={category} label={category} value={category} />
          ))}
        </Picker>
      </View>
      <FlatList
        style={styles.icons}
        data={listSource.map((value, key) => ({ key: key.toString(), value }))}
        renderItem={({ item }) => (
          <View style={styles.item}>
            <Icon name={item.value} style={styles.itemIcon} />
            <Text style={styles.itemText}>{item.value}</Text>
          </View>
        )}
      />
    </View>
  );
} 

当您运行此示例时,您应该看到以下内容:

图片 7

图 27.7:渲染图标

摘要

在本章中,我们学习了如何在 React Native 应用程序中处理图像。在原生移动环境中,图像与在 Web 环境中一样重要:它们改善了用户体验。

我们学习了加载图像的不同方法,以及如何调整图像大小。我们还学习了如何实现懒加载图像,在实际图像加载时显示占位符图像。最后,我们学习了如何在 React Native 应用程序中使用图标。这些技能将帮助您管理图像,并使您的应用程序更具信息性。

在下一章,我们将学习 React Native 中的本地存储,当我们的应用程序离线时,这非常有用。

第二十八章:离线操作

用户期望应用程序在不稳定的网络连接下无缝运行。如果您的移动应用程序无法处理短暂的网络问题,您的用户将使用其他应用程序。当没有网络时,您必须在设备上本地持久化数据。或者,也许您的应用程序甚至不需要网络访问,在这种情况下,您仍然需要本地存储数据。

在本章中,您将学习如何使用 React Native 完成这三件事。首先,您将学习如何检测网络连接状态。其次,您将学习如何本地存储数据。最后,您将学习如何在网络再次连接后同步因网络问题而存储的本地数据。

在本章中,我们将涵盖以下主题:

  • 检测网络状态

  • 存储应用程序数据

  • 同步应用程序数据

技术要求

您可以在 GitHub 上找到本章的代码文件,地址为github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter28

检测网络状态

如果您的代码在断开连接的情况下尝试使用fetch()发起网络请求,例如,将会发生错误。您可能已经为这些场景准备了错误处理代码,因为服务器可能会返回其他类型的错误。

然而,在出现连接问题时,你可能想在用户尝试发起网络请求之前检测这个问题。

主动检测网络状态有两个潜在原因。第一个原因是防止用户在网络检测到应用程序重新上线之前执行任何网络请求。为此,您可以向用户显示一条友好的消息,说明由于网络断开,他们无法进行任何操作。早期网络状态检测的另一个可能好处是,您可以准备离线执行操作,并在网络再次连接时同步应用程序状态。

让我们看看一些使用来自@react-native-community/netinfo包的NetInfo实用工具处理网络状态变化的代码:

const connectedMap = {
  none: "Disconnected",
  unknown: "Disconnected",
  cellular: "Connected",
  wifi: "Connected",
  bluetooth: "Connected",
  ethernet: "Connected",
  wimax: "Connected",
  vpn: "Connected",
  other: "Connected",
} as const; 

connectedMap涵盖了所有连接状态,并将帮助我们将其渲染到屏幕上。现在让我们看看App组件:

export default function App() {
  const [connected, setConnected] = useState("");
  useEffect(() => {
    function onNetworkChange(connection: NetInfoState) {
      const type = connection.type;
      setConnected(connectedMap[type]);
    }
    const unsubscribe = NetInfo.addEventListener(onNetworkChange);
    return () => {
      unsubscribe();
    };
  }, []);
  return (
    <View style={styles.container}>
      <Text>{connected}</Text>
    </View>
  );
} 

此组件将根据connectedMap中的字符串值渲染网络状态。NetInfo对象的onNetworkChange事件将导致连接状态改变。

例如,当您第一次运行此应用程序时,屏幕可能看起来像这样:

图片 1

图 28.1:连接状态

然后,如果您关闭主机的网络连接,模拟设备上的网络状态也会改变,从而导致我们应用程序的状态改变,如下所示:

图片 2

图 28.2:断开连接状态

这就是如何在应用中使用网络状态检测。正如讨论的那样,除了显示消息外,你还可以使用网络状态来防止用户发起 API 请求。另一种有价值的方法是在网络恢复在线之前将用户输入本地保存。让我们在下一节中探讨它。

存储应用程序数据

要在设备上存储数据,有一个特殊的跨平台解决方案,称为AsyncStorage API。它在 iOS 和 Android 平台上工作方式相同。你会使用这个 API 来处理最初不需要任何网络连接的应用程序,或者存储最终将通过网络可用时使用 API 端点同步的数据。

要安装async-storage包,请运行以下命令:

npx expo install @react-native-async-storage/async-storage 

让我们看看一些允许用户输入一个key和一个value并将它们存储起来的代码:

export default function App() {
  const [key, setKey] = useState("");
  const [value, setValue] = useState("");
  const [source, setSource] = useState<KeyValuePair[]>([]); 

keyvaluesource值将处理我们的状态。为了将其保存到AsyncStorage,我们需要定义函数:

 function setItem() {
    return AsyncStorage.setItem(key, value)
      .then(() => {
        setKey("");
        setValue("");
      })
      .then(loadItems);
  }
  function clearItems() {
    return AsyncStorage.clear();
  }
  async function loadItems() {
    const keys = await AsyncStorage.getAllKeys();
    const values = await AsyncStorage.multiGet(keys);
    setSource([...values]);
  }
  useEffect(() => {
    loadItems();
  }, []); 

我们已经定义了处理程序来保存输入中的值、清除AsyncStorage以及在启动应用时加载已保存的项目。以下是App组件渲染的标记:

 return (
    <View style={styles.container}>
      <Text>Key:</Text>
      <TextInput
        style={styles.input}
        value={key}
        onChangeText={(v) => {
          setKey(v);
        }}
      />
      <Text>Value:</Text>
      <TextInput
        style={styles.input}
        value={value}
        onChangeText={(v) => {
          setValue(v);
        }}
      />
      <View style={styles.controls}>
        <Button label="Add" onPress={setItem} />
        <Button label="Clear" onPress={clearItems} />
      </View> 

上一段代码块中的标记表示为创建、保存和删除项的输入和按钮。接下来,我们将使用FlatList组件渲染项目列表:

 <View style={styles.list}>
        <FlatList
          data={source.map(([key, value]) => ({
            key: key.toString(),
            value,
          }))}
          renderItem={({ item: { value, key } }) => (
            <Text>
              {value} ({key})
            </Text>
          )}
        />
      </View>
    </View>
  ); 

在我们遍历这段代码的功能之前,让我们看看以下屏幕,因为它将解释我们将在存储应用程序数据时覆盖的大部分内容:

图片 3

图 28.3:存储应用程序数据

如你在图 28.3中看到的,有两个输入字段和两个按钮。这些字段允许用户输入新的keyvalue添加按钮允许用户将这个键值对本地存储在他们的设备上,而清除按钮则清除之前存储的任何现有项。

AsyncStorage API 在 iOS 和 Android 上工作方式相同。在底层,AsyncStorage根据其运行的平台而有很大不同。React Native 能够在两个平台上公开相同的存储 API 的原因在于其简单性:它只是键值对。任何比这更复杂的东西都留给应用开发者处理。

在这个例子中,围绕AsyncStorage创建的抽象是最小的。想法是设置和获取项。然而,即使是这样的直接操作也值得有一个抽象层。例如,你在这里实现的setItem()方法将异步调用AsyncStorage并在完成更新后更新项的状态。加载项甚至更复杂,因为你需要作为两个单独的异步操作获取键和值。

我们这样做是为了保持 UI 的响应性。如果在数据写入磁盘时需要发生待处理的屏幕重绘,阻止这些操作会导致用户体验不佳。

在下一节中,你将学习如何在设备重新上线后,同步设备离线时存储的数据与远程服务。

同步应用程序数据

到目前为止,在本章中,你已经学习了如何在 React Native 应用程序中检测网络连接状态以及如何本地存储数据。现在,是时候将这两个概念结合起来,实现一个可以检测网络中断并继续工作的应用程序。

基本的想法是在确定设备在线时才进行网络请求。如果你知道它不在线,你可以将任何状态更改保存在本地。然后,当你再次在线时,你可以使用远程 API 同步这些已保存的更改。

让我们实现一个简化的 React Native 应用程序来完成这个任务。第一步是实现一个位于 React 组件和网络调用存储数据之间的抽象。我们将把这个模块称为 store.ts

export function set(key: Key, value: boolean) {
  return new Promise((resolve, reject) => {
    if (connected) {
      fakeNetworkData[key] = value;
      resolve(true);
    } else {
      AsyncStorage.setItem(key, value.toString()).then(
        () => {
          unsynced.push(key);
          resolve(false);
        },
        (err) => reject(err)
      );
    }
  });
} 

set 方法依赖于 connected 变量,并且根据是否有互联网连接,它处理不同的逻辑。实际上,get 方法也遵循相同的方法:

export function get(key?: Key): Promise<boolean | typeof fakeNetworkData> {
  return new Promise((resolve, reject) => {
    if (connected) {
      resolve(key ? fakeNetworkData[key] : fakeNetworkData);
    } else if (key) {
      AsyncStorage.getItem(key)
        .then((item) => resolve(item === "true"))
        .catch((err) => reject(err));
    } else {
      AsyncStorage.getAllKeys()
        .then((keys) =>
          AsyncStorage.multiGet(keys).then((items) =>
            resolve(Object.fromEntries(items) as any)
          )
        )
        .catch((err) => reject(err));
    }
  });
} 

本模块导出两个函数,set()get()。它们分别负责设置和获取数据。由于这只是演示如何在本地存储和网络端点之间同步的一种方式,因此本模块仅使用 fakeNetworkData 对象模拟实际的网络。

让我们先看看 set() 函数。它是一个异步函数,它总是返回一个解析为布尔值的承诺。如果是真的,这意味着你在线上,并且网络调用是成功的。如果是假的,这意味着你离线,使用了 AsyncStorage 来保存数据。

get() 函数使用相同的方法。它返回一个解析为布尔值的承诺,该值指示网络的状态。如果提供了键参数,则查找该键的值。否则,返回所有值,无论是从网络还是从 AsyncStorage

除了这两个函数之外,本模块还做两件事:

NetInfo.fetch().then(
  (connection) => {
    connected = ["wifi", "unknown"].includes(connection.type);
  },
  () => {
    connected = false;
  }
);
NetInfo.addEventListener((connection) => {
  connected = ["wifi", "unknown"].includes(connection.type);
  if (connected && unsynced.length) {
    AsyncStorage.multiGet(unsynced).then((items) => {
      items.forEach(([key, val]) => set(key as Key, val === "true"));
      unsynced.length = 0;
    });
  }
}); 

它使用 NetInfo.fetch() 来设置连接状态。然后,它添加一个监听器来监听网络状态的变化。这就是在你离线时保存的项在再次连接时与网络同步的方式。

现在,让我们检查使用这些函数的主要应用程序:

export default function App() {
  const [message, setMessage] = useState<string | null>(null);
  const [first, setFirst] = useState(false);
  const [second, setSecond] = useState(false);
  const [third, setThird] = useState(false);
  const setters = new Map([
    ["first", setFirst],
    ["second", setSecond],
    ["third", setThird],
  ]); 

在这里,我们定义了将在 Switch 组件中使用的状态变量:

 function save(key: Key) {
    return (value: boolean) => {
      set(key, value).then(
        (connected) => {
          setters.get(key)?.(value);
          setMessage(connected ? null : "Saved Offline");
        },
        (err) => {
          setMessage(err);
        }
      );
    };
  } 

save() 函数帮助我们重用在不同的 Switch 组件中的逻辑。接下来,我们有 useEffect 钩子,用于在页面首次渲染时获取保存的数据:

 useEffect(() => {
    NetInfo.fetch().then(() =>
      get().then(
        (items) => {
          for (let [key, value] of Object.entries(items)) {
            setters.get(key)?.(value);
          }
        },
        (err) => {
          setMessage(err);
        }
      )
    );
  }, []); 

接下来,让我们看看页面的最终标记:

 return (
    <View style={styles.container}>
      <Text>{message}</Text>
      <View>
        <Text>First</Text>
        <Switch value={first} onValueChange={save("first")} />
      </View>
      <View>
        <Text>Second</Text>
        <Switch value={second} onValueChange={save("second")} />
      </View>
      <View>
        <Text>Third</Text>
        <Switch value={third} onValueChange={save("third")} />
      </View>
    </View>
  ); 

App组件的职责是保存三个Switch组件的状态,当你向用户提供在线和离线模式之间的无缝过渡时,这很困难。幸运的是,你的set()get()抽象,这些抽象在另一个模块中实现,隐藏了应用功能的大部分细节。

然而,请注意,在尝试加载任何项目之前,你需要在此模块中检查网络状态。如果你不这样做,那么get()函数将假设你处于离线状态,即使连接良好。

这就是应用的外观:

图片 4

图 28.4:同步应用数据

注意,你实际上只有在更改 UI 中的内容后才会看到“已保存离线”的消息。

摘要

本章向我们介绍了在 React Native 应用程序中离线存储数据。我们想要本地存储数据的主要原因是当设备离线且我们的应用无法与远程 API 通信时。然而,并非所有应用都需要 API 调用,AsyncStorage可以用作通用存储机制。我们只需要在它周围实现适当的抽象。

我们还学习了如何检测 React Native 应用的网络状态变化。知道设备何时离线很重要,这样我们的存储层就不会进行无谓的网络调用。相反,我们可以让用户知道设备处于离线状态,然后在有连接可用时同步应用状态。

在下一章中,我们将学习如何从 NativeBase 库中导入和使用 UI 组件。

加入我们的 Discord 频道!

与其他用户和作者本人一起阅读这本书。提出问题,为其他读者提供解决方案,与作者聊天,等等。扫描二维码或访问链接加入社区。

packt.link/ReactAndReactNative5e

posted @ 2025-09-08 13:02  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报