React-挂钩实战-全-

React 挂钩实战(全)

原文:React Hooks in Action

译者:飞龙

协议:CC BY-NC-SA 4.0

前置内容

前言

作为一名高中教师和程序员,我处于一个很好的位置来开发支持学校内教学、学习和组织的应用程序。我可以亲眼看到学生、教师和支持人员的需求,并与他们合作构建直观的应用程序和工具,使计划、沟通、理解和娱乐变得更加容易。我开始用 JavaScript 编写测验应用程序和匹配游戏,然后创建了利用 jQuery 和模板的教案规划和资源预订应用程序。然后,科学部门希望有一种订购教学设备的方法,领导团队希望有一种让员工传达公告的方法,而 ICT 技术人员希望有一种让员工报告和管理软件和硬件问题的方法。如何关于一个座位安排应用程序、网站新闻故事的内容管理系统、定制日历、交互式值班表或体育比赛日记,所有这些都具有一致的外观和感觉?

虽然每个项目都有自己的要求,但它们之间有很多重叠,并且可以在各个应用程序中使用类似的方法。为了加快进度,我转向使用 Node.js、Express、Handlebars、Backbone 和 Marionette 进行端到端的 JavaScript 开发。大部分工作都进行得很顺利,尽管在需求变化时进行更新有时会有些麻烦。特别是,模型、视图和控制器之间的数据流并不总是流畅。用户很满意,但我能看到代码中潜在的问题,并知道我迟早需要回到这些问题上,理清其中的曲折。

然后,我遇到了 React,所有问题都得到了解决!好吧,不是完全解决了。但 React 的组件、属性、状态和自动重新渲染的模式以一种之前任何框架都没有的方式触动了我。一个接一个,我将现有的应用程序转换为 React。每次转换,它们都变得更加简单、更容易理解、更容易维护。常见的组件可以重用,我可以快速、有信心地进行更改和添加新功能。虽然我不是一个 React 狂热者(我是一个框架多样性的粉丝),但我确实是一个皈依者,并享受开发体验和用户反馈。

现在有了 React Hooks,我的代码在简洁性方面又迈出了积极的一步。原本分散在类组件生命周期方法中的代码现在可以集中在一起,无论是在函数组件内部还是在外部自定义钩子中。对于特定功能,无论是设置文档标题、访问本地存储、管理上下文值、测量屏幕元素、订阅服务或获取数据,隔离、维护和共享代码都变得容易起来。而且,连接到现有库(如 React Router、Redux、React Query 和 React Spring)的功能也变得更加简单。使用 React Hooks 为我们提供了关于 React 组件的新思考方式,尽管它有一些需要留意的初始问题,但在我看来,这无疑是一个向好的转变。

转向 hooks 是 React 将来工作方式的一个根本性变化的组成部分。并发模式将成为新常态,它将实现时间分片魔法,其中渲染不会阻塞主线程,并且可以立即渲染高优先级更新,如用户输入,即使其他组件的 UI 正在构建。选择性水合将允许 React 在用户交互时及时加载组件代码,而 Suspense API 将让开发者能够更仔细地指定加载状态,在代码和资源加载时。

React 团队专注于构建出色的开发者体验,以便开发者能够构建出色的用户体验。进一步的更改仍在进行中,最佳实践将继续出现,但我希望《React Hooks in Action with Suspense and Concurrent Mode》能让你对现有变化有一个坚实的掌握,并为你准备即将到来的激动人心的进展。

致谢

这是我通常会感谢朋友和家人在我在地堡里锁着,疯狂地敲打打字机键盘,创作我的杰作时给予的耐心,而其他人则像往常一样继续生活。但是,由于 2020 年的一些事情,这远非正常生活。因此,我想感谢任何以任何方式使周围人的生活变得更好的人,无论大小,在困难时期。

感谢我的编辑 Helen Stergius,她的耐心和鼓励;写一本书是一个漫长的过程,但在像 Helen 这样优秀的编辑的支持和建议下,这个过程会容易得多。还要感谢 John Guthrie 和 Clive Harber,他们注重细节,提供了诚实、建设性的反馈;他们真的帮助使代码和解释更加清晰和一致。我还要感谢我的生产编辑 Deirdre Hiam、我的校对员 Sharon Wilkey、我的校对员 Keri Hales 和我的审阅编辑 Aleksandar Dragosavljevic´。

致所有审稿人:Annie Taylor Chen、Arnaud Castelltort、Bruno Sonnino、Chunxu Tang、Clive Harber、Daniel Couper、Edin Kapic、Gustavo Filipe Ramos Gomes、Isaac Wong、James Liu、Joe Justesen、Konstantinos Leimonis、Krzysztof Kamyczek、Rob Lacey、Rohit Sharma、Ronald Borman、Ryan Burrows、Ryan Huber、Sairam Sai,您的建议帮助使这本书更加完善。

关于这本书

《React Hooks 实战:使用 Suspense 和并发模式》 是一本面向经验丰富的 React 开发者的书籍。它介绍了现在已内置到 React 中的 hooks,并展示了如何在开发使用 React 函数组件的应用程序时使用它们,管理组件内和跨组件的状态,以及通过外部 API 同步组件状态。它展示了 hooks 方法在封装和重用、简化组件代码以及为未来的变化做准备方面是多么出色。它还探讨了 React 团队仍在开发的某些更实验性的 Suspense 和并发模式 API。

适合阅读这本书的人

如果您之前使用过 React,并想看看 hooks 如何帮助改进您的代码,将组件从基于类的转换为基于函数的,并与 Suspense 和并发模式集成以改善开发者和用户体验,那么这本书将为您指明道路。您应该已经能够使用create-react-app创建新应用,并使用 npm(或 Yarn)安装包。代码示例使用了现代 JavaScript 语法和模式,如解构、默认参数、扩展运算符和可选链操作符,因此,尽管它们在首次使用时会有简短的解释,但您对这些用法越熟悉,效果越好。

本书组织结构:路线图

《React Hooks 实战》 分为两部分,共有 13 章。在 Manning 网站上这本书的页面还包括一些文章,提供了额外的示例和解释,这些内容没有包含在书籍的主流程中。

第一部分介绍了新、稳定、非实验性的内置 React Hooks 的语法和使用方法。它还展示了如何创建自定义 hooks 并充分利用现有 React 库提供的第三方 hooks:

  • 我们从第一章开始,概述了 React 最近的和即将到来的变化,特别关注 React Hooks 如何帮助您组织、维护和共享组件代码。

  • 第二章介绍了我们的第一个 hook,useState。组件可以使用它来管理状态值,并在值发生变化时触发重新渲染。

  • 有时多个状态值相互关联,一个值的变化会导致其他值的变化。第三章中介绍的useReducer hook 提供了一种在单个位置管理多个状态变化的方法。

  • React 旨在保持 UI 与你的应用状态同步。有时你的应用需要从其他地方检索状态或在外部显示,例如在浏览器标题中。当你的应用通过访问其组件外部执行副作用时,你应该使用第四章中讨论的useEffect钩子来包裹代码,以保持所有部分同步。

  • 第五章使用useRef钩子在不引起重新渲染(例如,在处理定时器 ID 时)的情况下更新状态,并保持对页面元素(如表单中的文本框)的引用。

  • 我们的应用使用多个组件,第六章探讨了共享状态的战略,通过 props 向下传递。该章节展示了如何共享useStateuseReducer的更新器和分发函数,以及如何使用useCallback钩子创建对函数的不变引用。

  • 组件有时依赖于函数以某种方式生成或转换数据。如果这些函数执行时间相对较长,你只想在绝对必要时调用它们。第七章展示了如何利用useMemo钩子来限制昂贵函数的运行时机。

  • 有时相同的州值被应用中的许多组件广泛使用。第八章解释了如何使用 React 的 Context API 和useContext钩子来共享状态,而无需通过多个组件层级传递 props。

  • React Hooks 只是函数。你可以将调用 hooks 的代码移动到组件外部的函数中。这些函数,或自定义 hooks,可以然后在组件之间和项目之间共享。第九章解释了为什么以及如何创建自定义 hooks,并提供了大量示例,同时强调了 Hooks 规则。

  • 流行的 React 库已更新以支持 hooks。第十章利用了 React Router 的第三方 hooks 来管理 URL 中的状态,以及 React Query 来无缝同步你的 UI 与存储在服务器上的状态。

第二部分解释了如何更有效地加载大型应用的组件代码,并使用Suspense组件和错误边界来组织资源加载时的后备 UI。然后深入实验性 API,用于将数据加载与 Suspense 集成并在并发模式中工作:

  • 第十一章讨论了代码拆分,结合React.lazy进行组件的懒加载,Suspense组件在组件懒加载时显示后备 UI,以及错误边界在出现问题时显示后备 UI。

  • 在第十二章中,我们进入更实验性的领域,探讨图书馆如何将数据获取和图像加载与 Suspense 结合。

  • 最后,在第十三章中,我们探讨了只在并发模式下工作的某些易变 API。useTransitionuseDeferredValue 钩子和 SuspenseList 组件都是为了改善你的应用程序在状态变化期间的用户体验而设计的。它们的确切工作方式仍在变化,但本章为你提供了关于它们试图解决的问题的提示。

虽然本书的主要示例应用程序是在本书的整个过程中构建起来的,但如果你想要直接跳到某个特定的章节或钩子,你不会有任何问题。如果你想运行单个代码示例,你可以查看相应的仓库分支,然后从那里开始。

这些章节还包括练习,以练习刚刚提出的思想。它们主要要求你在示例应用程序的一页上复制另一页上的方法。例如,本书可能展示了如何更新 Bookables 页面,然后要求你为 Users 页面做同样的操作。通过代码实践是许多人的有效学习策略,但如果你需要,你始终可以从仓库中查看解决方案代码。

关于代码

本书包含一个持续进行的示例,一个预订应用程序,我们从一章到另一章构建它。这个示例为讨论 React 钩子和观察它们在实际操作中提供了很好的背景。但本书的重点是钩子,而不是预订应用程序,因此,尽管应用程序的大部分代码都在书中,但一些更新的列表可在示例应用程序的 GitHub 仓库中找到,但书中没有展示。该仓库位于 github.com/jrlarsen/react-hooks-in-action。当需要查看最新更改时,我会指出。示例应用程序的开发里程碑在仓库中的单独分支上。

一些简短的示例也不是主要预订应用程序的一部分。它们的代码要么在 CodeSandbox 上(针对基于 React 的示例),要么在 JS Bin 上(针对纯 JavaScript 示例)。书中的代码列表包括指向 GitHub、CodeSandbox 或 JS Bin 的链接,视情况而定。

所有示例都使用 React 17.0.1 进行了彻底的测试。第十三章是一个例外;它使用 React 的实验性发布版本,因此其示例不保证与该仓库分支上使用的版本以外的任何版本兼容。

本书包含许多源代码示例,无论是编号列表还是与普通文本混排。在这两种情况下,源代码都使用 fixed-width font like this 格式化,以将其与普通文本区分开来。有时代码也会用粗体显示,以突出显示章节中从先前步骤更改的代码,或者因为它是周围讨论的重点。

在某些情况下,原始源代码已被重新格式化;我们添加了换行符并重新调整了缩进,以适应书籍中的可用页面空间。在极少数情况下,即使这样也不够,列表中还包括了行续符(➥)。此外,当代码在文本中描述时,源代码中的注释通常已从列表中删除。许多列表旁边都有代码注释,突出显示重要概念。

liveBook 讨论论坛

购买《React Hooks in Action》包括免费访问由 Manning Publications 运营的私人网络论坛,您可以在那里对书籍发表评论、提出技术问题,并从作者和其他用户那里获得帮助。要访问论坛,请访问livebook.manning.com/book/react-hooks-in-action/discussion。您还可以在livebook.manning.com/#!/discussion了解更多关于 Manning 的论坛和行为准则。

Manning 对我们读者的承诺是提供一个场所,在那里个人读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未支付报酬)。我们建议您尝试向作者提出一些挑战性的问题,以免他的兴趣转移!只要书籍在印刷中,论坛和先前讨论的存档将可通过出版商的网站访问。

其他在线资源

官方 React 文档位于reactjs.org,这是一份详尽、文笔良好的资源,目前正在重写中。绝对值得一读。本书的页面www.manning.com/books/react-hooks-in-action也有一些文章扩展了某些部分和想法。

关于作者

约翰·拉森自 1980 年代开始编程,最初使用 Commodore VIC-20 上的 BASIC 语言,后来转向 Java、PHP、C#和 JavaScript。他是《用 JavaScript 编程入门》一书的作者,该书也由 Manning 出版。在英国担任数学教师 25 年,他教授高中生计算机课程,并开发了基于网络的程序,以支持学校的教学、学习和沟通。最近,约翰在日本教授英语,并努力提高自己的日语水平。

关于封面插图

《React Hooks in Action》封面上的女性形象被标注为“卡里亚女子”,或称“来自卡里亚的女性”。这幅插图取自雅克·Grasset de Saint-Sauveur(1757-1810)的作品集,名为《不同国家的服饰》,于 1797 年在法国出版。每一幅插图都是手工精心绘制和着色的。Grasset de Saint-Sauveur 收藏中的丰富多样性生动地提醒我们,仅仅 200 年前,世界的城镇和地区在文化上有多么不同。人们彼此孤立,说着不同的方言和语言。在街道或乡村,仅凭他们的服饰就能轻易识别他们居住的地方以及他们的职业或社会地位。

自那以后,我们的着装方式发生了变化,当时地区间的多样性已经消失。现在,很难区分不同大陆、城镇、地区或国家的人们。也许我们用文化多样性换取了更加多样化的个人生活——当然,是为了更加多样化且节奏更快的技术生活。

在难以区分一本计算机书籍与另一本的时候,曼宁通过书籍封面上的设计,庆祝了计算机行业的创新和进取精神。这些设计基于两百年前丰富多样的地区生活,通过 Grasset de Saint-Sauveur 的画作得以重现。

第一部分

React Hooks in Action with Suspense and Concurrent Mode》的第一部分介绍了 React Hooks,并涵盖了 React 17 的第一个稳定版本中的关键 Hooks。您将了解如何在函数组件中管理状态,如何与子组件和更深层的后代共享状态,以及如何与外部服务、服务器和 API 同步状态。您还将学习如何创建自己的 Hooks(同时遵循规则),并充分利用来自 React Router、React Query 和 React Spring 等成熟库的第三方 Hooks。

预订应用作为展示示例的一致上下文,您将看到如何加载数据和管理数据,以及如何编排组件之间的交互并响应用户的操作。但首先,什么是 Hooks,为什么它们是正确方向的一步?

1 React 正在不断发展

React 是一个用于构建美观用户界面的 JavaScript 库。React 团队希望开发者的体验尽可能出色,以便开发者能够受到启发并能够创建令人愉悦、高效的用户体验。"React Hooks in Action with Suspense and Concurrent Mode" 是您了解库中最新添加功能的指南,这些功能可以简化您的代码,提高代码重用性,并帮助使您的应用程序更加流畅和响应迅速,从而让开发者和用户都更加满意。

本章简要概述了 React 及其新功能,以激发您对书中后续详细内容的兴趣。

1.1 什么是 React?

假设您正在创建一个用于网页、桌面、智能手机或甚至虚拟现实(VR)体验的用户界面(UI)。您希望您的页面或应用能够显示随时间变化的各种数据,如认证用户信息、可筛选的产品列表、数据可视化或客户详情。您期望用户与该应用交互,选择过滤器、数据集和客户进行查看,填写表单字段,甚至探索 VR 空间!或者,也许您的应用将从网络或互联网中获取数据,如社交媒体更新、股票行情或产品可用性。React 正在这里帮助。

React 使得构建可组合、可重用且对数据变化和用户交互做出反应的用户界面组件变得简单。社交媒体网站的一页可能包括按钮、帖子、评论、图片和视频等众多界面组件。React 帮助更新界面,当用户滚动页面、打开帖子、添加评论或切换到其他视图时。页面上的某些组件可能有重复的 子组件,即具有相同结构但内容不同的页面元素。而这些子组件也可以由组件组成!这里有图片缩略图、重复的按钮、可点击文本和图标。整体来看,页面有数百个这样的元素。但通过将这些丰富的界面分解为可重用组件,开发团队能够更容易地专注于特定功能区域,并将组件用于多个页面。

使定义和重用组件,并将它们组合成复杂但易于理解和使用的界面变得简单,这是 React 的核心目的之一。其他前端库也存在(如 AngularJS、Vue.js 和 Ember.js),但这是一本关于 React 的书,所以我们专注于 React 如何处理组件、数据流和代码重用。

在接下来的几节中,我们将从高层次的角度了解 React 如何帮助开发者构建这样的应用程序,突出其五个关键特性:

  • 从可重用、可组合的组件构建用户界面

  • 使用 JSX 描述 UI——HTML 风格的模板与 JavaScript 的结合

  • 在不引入太多惯用约束的情况下充分利用 JavaScript

  • 智能同步状态和 UI

  • 帮助管理代码、资源和数据的获取

1.1.1 从组件构建 UI

社交媒体网站展示了丰富的、分层的、多层次的用户界面,React 可以帮助你设计和编码。但就目前而言,让我们从一个稍微简单一些的例子开始,以了解 React 的功能。

假设你想构建一个测验应用来帮助学习者测试他们所学习的事实。你的组件应该能够显示和隐藏问题,以及显示和隐藏答案。一个问题及答案对可能看起来像图 1.1。

图片

图 1.1 显示问题及答案的测验应用的一部分

你可以为问题部分创建一个组件,为答案部分创建另一个组件。但这两个组件的结构是相同的:每个组件都有一个标题,一些显示和隐藏的文本,以及一个用于显示和隐藏的按钮。React 使得定义一个单一的组件变得容易,比如一个TextToggle组件,你可以用它来处理问题和答案。你将标题、文本以及是否显示文本传递给每个TextToggle组件。你将这些值作为属性(或 props)传递,类似于这样:

<TextToggle title="Question" text="Who created JavaScript?" show={true} />

<TextToggle title="Answer" text="Brendan Eich" show={false} />

等等!现在是什么?这是 HTML?XML?JavaScript?嗯,用 React 编程就是用 JavaScript 编程。但 React 提供了一个类似于 HTML 的语法来描述你的 UI,称为 JSX。在运行你的应用之前,JSX 需要预处理以将其转换为创建用户界面元素的实际 JavaScript。一开始这似乎有点奇怪,将 HTML 与 JavaScript 混合,但事实证明这种便利性是一个很大的优点。一旦你的代码最终在浏览器(或其他环境)中运行,它实际上就是 JavaScript。一个名为Babel的包几乎总是用来将你编写的代码编译成将要运行的代码。你可以在babeljs.io了解更多关于 Babel 的信息。

本章仅提供了一个 React 的高级概述,因此我们在这里不会进一步探讨 JSX。不过,提前提一下是值得的,因为它是 React 开发中广泛使用的一部分。实际上,在我看来,React 的 JavaScript 特性是其吸引力之一——尽管其他观点也是可用的——而且,在大多数情况下,它并没有引入很多限制。尽管最佳实践已经出现并且仍在继续出现,但成为一名优秀的 JavaScript 程序员和成为一名优秀的 React 程序员是非常相似的技术。

假设你已经创建了TextToggle组件;接下来是什么?使用 React,你可以定义由现有组件组成的新组件。你可以将问题卡(显示问题和答案)封装成自己的QuestionCard组件。如果你想同时显示多个问题,你的Quiz组件 UI 可以由多个QuestionCard组件组成。

图 1.2 显示了由两个 QuestionCard 组件组成的 Quiz 组件。Quiz 组件是 QuestionCard 的容器,除了包含的卡片外,没有可见的存在。

图 1.2 显示两个 QuestionCard 组件的 Quiz 组件

因此,Quiz 组件由 QuestionCard 组件组成,而它们又由 TextToggle 组件组成,这些组件由标准的 HTML 元素组成——例如 h2pbutton。最终,Quiz 组件包含所有原生 UI 元素。图 1.3 显示了您的 Quiz 组件的简单层次结构。

图 1.3 Quiz 组件层次结构

React 使这种组件创建和组合变得容易得多。一旦您制作了组件,您就可以轻松地重用和共享它们。想象一个学习资源网站,它为不同的主题提供不同的页面。在每一页上,您都可以包含您的 Quiz 组件,只需传递该主题的测验数据即可。

许多 React 组件可以在 npm 等包管理存储库中下载。当有现成的、经过良好使用和测试的示例,如下拉菜单、日期选择器、富文本编辑器和可能还有测验模板时,无需重新创建常见用例,无论是简单还是复杂。

React 还提供了机制和模式,用于将您的应用程序数据传递给需要它们的组件。实际上,这种状态和 UI 的同步是 React 的核心所在,也是它所做的事情。

1.1.2 同步状态和 UI

React 保持应用程序的用户界面与其数据同步。在任何时刻存储在您应用程序中的数据被称为应用程序的 状态,可能包括,例如,当前帖子、登录用户的详细信息、是否显示或隐藏评论,或文本输入字段的内 容。如果通过网络到达新的数据或用户通过按钮或文本输入更新值,React 会计算出需要对显示进行哪些更改,并高效地更新它。

React 智能地安排更新顺序和时机,以优化应用程序的感知性能并提高用户体验。图 1.4 代表了这个想法,即 React 通过重新渲染用户界面来响应用件状态的变化。

图 1.4 当组件的状态中的值发生变化时,React 会重新渲染用户界面。

但更新状态和重新渲染不是一个一次性任务。使用您应用程序的访客可能会引起大量的状态变化,React 将需要反复询问您的组件以获取表示最新状态值的最新 UI。您的组件的职责是将它们的状 态和属性(传递给它们的属性)转换为它们用户界面的描述。然后 React 会根据需要将这些 UI 描述安排到浏览器文档对象模型 (DOM) 中进行更新。

循环图

为了表示状态变化和 UI 更新的持续周期,本书使用圆形周期图来展示您的组件与 React 之间的交互。图 1.5 是一个简单的例子,展示了组件首次出现时以及用户更新值时 React 如何调用您的组件代码。

图 1.5 React 调用并重新调用您的组件以使用最新的状态生成其 UI 的描述。

周期图旁边附有表格,如表 1.1,更详细地描述了图的步骤。图和表的配对不一定涵盖所有发生的事情,但提取了关键步骤,以帮助您理解与组件在不同场景下工作相关的相似之处和不同之处。

例如,本节中的图示没有显示事件处理程序如何与 React 一起更新状态;这个细节将在介绍相关 React Hooks 的后续图中添加。

表 1.1 当 React 调用并重新调用函数组件时的关键步骤

步骤 发生了什么? 讨论
1 React 调用组件。 为了生成页面的 UI,React 遍历组件树,调用每个组件。React 将传递给每个组件任何在 JSX 中设置的属性作为 props。
2 组件指定了一个事件处理程序。 事件处理程序可能监听用户点击、计时器触发或资源加载等。处理程序将在稍后运行时更改状态。React 将在步骤 4 更新 DOM 时将处理程序连接到 DOM。
3 组件返回其用户界面。 组件使用当前状态值来生成其用户界面并将其返回,完成其工作。
4 React 更新 DOM。 React 将组件返回的 UI 描述与当前的应用程序 UI 描述进行比较。它高效地做出任何必要的 DOM 更改,并根据需要设置或更新事件处理程序。
5 事件处理程序被触发。 发生了一个事件,并且处理程序运行。处理程序更改了状态。
6 React 调用组件。 React 知道状态值已更改,因此必须重新计算 UI。
7 组件指定了一个事件处理程序。 这是一个新版本的处理程序,可能使用新更新的状态值。
8 组件返回其用户界面。 组件使用当前状态值来生成其用户界面并将其返回,完成其工作。
9 React 更新 DOM。 React 将组件返回的 UI 描述与应用程序 UI 的先前描述进行比较。它高效地做出任何必要的 DOM 更改,并根据需要设置或更新事件处理程序。

这些插图还使用一致的图标来表示周围文本中讨论的关键对象和动作,例如组件、状态值、事件处理程序和用户界面。

问答应用中的状态

社交媒体页面,如本章开头讨论的那样,通常需要大量的状态,包括加载新帖子、用户点赞帖子、添加评论以及以各种方式与组件交互。其中一些状态,如当前用户,可能被许多组件共享,而其他状态,如评论,可能仅限于帖子本身。

在 Quiz 应用中,你有一个问答组件,即 QuestionCard,如图 1.6 所示。用户可以显示和隐藏每个问题和答案,并移动到下一个可用的问题。

图 1.6 答案隐藏的问答组件

QuestionCard 组件的状态包括显示当前问题和答案所需的信息:

  • 问题编号

  • 问题的数量

  • 问题文本

  • 答案文本

  • 问题是隐藏还是显示

  • 答案是否隐藏或显示

点击答案的显示按钮会改变组件的状态。可能一个 isAnswerShown 变量从 false 切换到 true。React 会注意到状态已更改,将更新显示的组件以显示答案文本,并将按钮的文本从显示切换到隐藏(图 1.7)。

图 1.7 显示答案的问答组件

点击“下一步”按钮会改变问题编号。它将从问题 1 切换到问题 2,如图 1.8 所示。如果整个测验的问题和答案都在内存中,React 可以立即更新显示。如果它们需要从文件或服务中加载,React 可以在更新 UI 之前等待数据被检索,或者在网络较慢时显示一个加载指示器,如旋转器。

图 1.8 显示第二个问题的问答组件。答案已被隐藏。

简单的 Quiz 应用示例不需要太多状态来执行其任务。大多数现实世界的应用更为复杂。决定状态应该放在哪里——是否一个组件应该管理自己的状态,是否某些组件应该共享状态,以及是否某些状态应该全局共享——是构建应用的重要部分。React 为这三种场景提供了机制,例如,像 Redux、MobX、React Query 和 Apollo Client 这样的发布包,提供了通过组件外的数据存储管理状态的方法。

在过去,组件是否管理自己的状态决定了你会使用哪种组件创建方法;React 提供了两种主要方法:函数组件和类组件,如下一节所述。

1.1.3 理解组件类型

要定义一个组件,React 允许你使用两种 JavaScript 结构:一个函数或一个类。在 React Hooks 之前,当组件不需要任何本地状态时(你会通过 props 传递给它所有数据):

function MyComponent (props) {
  // Maybe work with the props in some way.
  // Return the UI incorporating prop values.
}

当组件需要管理自己的状态、执行副作用(如加载数据或与 DOM 交互)或直接响应事件时,你会使用类:

class MyComponent extends React.Component {
  constructor (props) {
    super(props);

    this.state = {
      // Set up state here.                         ❶
    };
  }

  componentDidMount () {
    // Perform a side effect like loading data.     ❷
  }

  render () {
    // Return the UI using prop values and state.   ❸
  }
}

❶ 类组件在其构造函数中设置其状态。

❷ 类组件可以包括其生命周期各个阶段的函数。

❸ 类组件有一个返回其 UI 的render方法。

React Hooks 的添加意味着你现在可以使用函数组件来管理状态和副作用:

function MyComponent (props) {
  // Use local state.
  const [value, setValue] = useState(initialValue);             ❶
  const [state, dispatch] = useReducer(reducer, initialState);  ❶

  useEffect(() => {
    // Perform side effect.                                     ❷
  });

  return (
    <p>{value} and {state.message}</p>                          ❸
  );
}

❶ 使用钩子来管理状态。

❷ 使用钩子来管理副作用。

❸ 直接从函数中返回 UI。

React 团队建议在新项目中使用函数组件(尽管没有计划移除类组件,因此无需对现有项目进行大规模重写)。表 1.2 列出了组件类型及其描述。

表 1.2 组件类型及其描述 (继续)

组件类型 描述
无状态函数组件 一个接收属性并返回 UI 的 JavaScript 函数
函数组件 一个接收属性并使用钩子来管理状态和执行副作用的 JavaScript 函数,同时返回 UI
类组件 包含一个返回 UI 的render方法的 JavaScript 类。它也可能在其构造函数中设置状态,并在其生命周期方法中管理状态和执行副作用。

函数组件只是返回其用户界面描述的 JavaScript 函数。在编写组件时,开发者通常使用 JSX 来指定 UI。UI 可能取决于传递给函数的属性。对于无状态函数组件,故事就到这里结束了;它们将属性转换为 UI。更普遍地说,函数组件现在可以包含状态并处理副作用。

类组件是使用 JavaScript 类语法构建的,从React.ComponentReact.PureComponent基类扩展而来。它们有一个构造函数,可以在其中初始化状态,并且 React 会在组件生命周期中调用它们的方法;例如,当 DOM 使用最新的组件 UI 更新时或当传递给组件的属性更改时。它们还有一个render方法,返回组件 UI 的描述。类组件是创建可以引起副作用的具有状态组件的方法。

我们将在第 1.3 节中看到,具有钩子的函数组件在创建有状态组件和管理副作用方面比类组件提供了一种更好的方法。首先,让我们更广泛地了解一下 React 的新特性以及这些新特性如何使使用 React 变得更加出色。

组件副作用

React 组件通常将状态转换为 UI。当组件代码执行与此主要焦点无关的操作时——例如从网络获取数据(如博客文章或股票价格)、设置对在线服务的订阅或直接与 DOM 交互以聚焦表单字段或测量元素尺寸——我们把这些操作描述为组件的 副作用

我们希望我们的应用程序及其组件的行为可预测,因此应确保任何必要的副作用都是故意的且可见的。正如你在第四章中将要看到的,React 提供了 useEffect hook 来帮助我们设置和管理函数组件中的副作用。

1.2 React 的新特性有哪些?

React 16 包含了对核心功能的重写,这为稳定推出新的库功能和方法的实施铺平了道路。在接下来的章节中,我们将探讨其中的一些最新增补。新功能包括以下内容:

  • 带状态的函数组件(useStateuseReducer

  • 上下文 API (useContext)

  • 更干净的副作用管理(useEffect

  • 简单但强大的代码重用模式(自定义 hooks)

  • 代码拆分(lazy

  • 更快的初始加载和智能渲染(并发模式—实验性)

  • 更好的加载状态反馈(SuspenseuseTransition

  • 强大的调试、检查和性能分析(开发工具和性能分析器)

  • 目标化错误处理(错误边界)

以 use 开头的单词—useStateuseReduceruseContextuseEffectuseTransition—是 React Hooks 的例子。它们是你可以从 React 函数组件中调用的函数,并且可以钩入关键的 React 功能:状态、生命周期和上下文。React Hooks 允许你在函数组件中添加状态,干净地封装副作用,并在你的项目中重用代码。通过使用 hooks,你可以摆脱对类的需求,以优雅的方式减少和整合你的代码。第 1.3 节将更详细地讨论 React 组件和 hooks。

并发模式和 Suspense 提供了更细致地控制代码、数据和资产加载的手段,以及以协调的方式处理加载状态和回退内容(如加载指示器)。目标是提高应用程序加载和状态变化时的用户体验,并改善开发者体验,使开发者更容易接入这些新行为。React 可以暂停渲染昂贵但非紧急的组件,转而执行紧急任务,如响应用户交互,以保持应用程序的响应性,并平滑用户生产力的感知路径。

reactjs.org 上的 React 文档是一个极好的资源,提供了对哲学、API 和库推荐使用的清晰、结构化的解释,以及团队博客文章、指向实时代码示例、关于新特性的会议演讲和其他与 React 相关的资源链接。虽然这本书将专注于 hooks、Suspense 和并发模式,但请务必查看官方文档,以了解更多关于 React 其他新增功能的信息。特别是,请查看关于 React 17 的博客文章(reactjs.org/blog/2020/10/20/react-v17.html)。React 的下一个主要版本于 2020 年 10 月发布,但没有包含面向开发者的新功能。相反,它包括了一些更改,使得逐步升级 React 应用程序变得更加容易,以及并发模式和其 API 的进一步实验性开发。

1.3 React Hooks 可以给函数组件添加状态

如 1.1.2 节所述,React 的核心优势之一是它如何同步应用程序和组件状态与 UI。随着状态的变化,基于用户交互或来自系统或网络的数据更新,React 会智能且高效地计算出在浏览器中的 DOM 或更广泛环境中的 UI 应该进行哪些更改。

状态可以是组件本地的,提升到树中的更高组件,通过属性在兄弟组件间共享,或者全局的,并通过 React 的 Context 4 访问,并返回一个包装了传入组件的新组件,但具有额外的功能)。为了使组件具有状态,过去通常使用一个从 React.Component 扩展的 JavaScript 类组件。现在,有了 React Hooks,你可以在函数组件中添加状态。

1.3.1 带状态的函数组件:更少的代码,更好的组织

与类相比,使用 hooks 的函数组件鼓励编写更干净、更精简的代码,这种代码易于测试、维护和重用。函数组件 是一个返回其用户界面描述的 JavaScript 函数。该 UI 依赖于传入的属性以及组件管理或访问的状态。图 1.9 展示了一个表示函数组件的图表。

图 1.9 一个具有状态和封装了加载数据以及管理服务订阅的 Quiz 功能组件

该图展示了执行了几个副作用的 Quiz 组件:

  • 它加载自己的问题数据——包括初始数据和当用户选择新的问题集时的新问题。

  • 它订阅用户服务——该服务提供有关当前在线的其他 quiz 用户的更新,以便用户可以加入团队或挑战对手。

在 JavaScript 中,函数可以包含其他函数,因此组件可以包含响应用户与 UI 交互的事件处理器,例如显示、隐藏或提交答案,或移动到下一个问题。在组件内部,你可以轻松封装副作用,如获取问题数据或订阅用户服务。你还可以包括清理代码以取消任何未完成的数据获取并从用户服务取消订阅。使用钩子,这些功能甚至可以提取到组件外部的函数中,以便重用或共享。

下面是使用新的函数组件方法而不是旧的基于类的方法的某些结果:

  • 代码更少

  • 代码组织更好,相关代码与任何清理代码一起保持在一起

  • 将功能提取到外部函数中,以便重用和共享

  • 更容易测试的组件

  • 在类构造函数中不需要调用super()函数

  • 不需要与this和绑定处理器一起工作

  • 更简单的生命周期模型

  • 处理器、副作用函数和返回的 UI 作用域内的本地状态

列表中的所有项目都有助于编写更易于理解的代码,因此更容易使用和维护。这并不是说细微差别可能不会让第一次使用新方法的开发者感到困惑,但当我们深入探讨每个概念及其在本书中各部分之间的联系时,我会突出这些细微差别。

React Hooks in Action》概述了组件构建的函数式方法,而不是使用类。但有时将新方法与旧方法进行比较是有意义的,以激发采用并因为看到差异很有趣(在钩子的情况下,这有点酷!)。如果你是 React 的新手,从未见过类组件的代码,请不要担心。请放心,本书余下的部分我们将使用函数组件作为首选方法。以下讨论应该仍然能让你了解这种新方法如何简化并组织创建 React 组件所需的代码。

本节的标题是“有状态的函数组件:更少的代码,更好的组织。”比什么更好?嗯,使用类组件时,状态是在构造函数中设置的,事件处理器绑定到this,副作用代码分散在多个生命周期方法(componentDidMountcomponentWillUnmountcomponentWillUpdate等)中。在生命周期方法中,与不同效果和功能相关的代码通常并排放置。你可以在图 1.10 中看到Quiz类组件代码如何跨方法分割,以及某些方法如何包含两个任务的代码混合。

图 1-10

图 1.10 一个代码分布在生命周期方法中的类组件,以及具有相同功能但代码更少、更有组织的函数组件

带有钩子的函数组件不再需要所有生命周期方法,因为效果可以被封装到钩子中。这种变化导致了更整洁、更有组织的代码,如图 1.10 中的 Quiz 函数组件所示。代码已经被更合理地组织,两个副作用被分开,并且每个效果的相关代码都集中在一个地方。这种改进的组织使得查找特定效果代码、了解组件的工作方式以及未来维护它变得更加容易。实际上,将功能或效果的代码放在一个地方使得将其提取为外部函数变得更加容易,这正是我们接下来要讨论的。

1.3.2 自定义钩子:更简单的代码重用

带有钩子的函数组件鼓励你将相关的副作用逻辑放在一个地方。如果副作用是许多组件都需要的功能,你可以进一步组织代码,将其提取到自己的外部函数中;你可以创建所谓的 自定义钩子

图 1.11 展示了如何将 Quiz 功能组件的题目加载和用户服务订阅任务移动到它们自己的自定义钩子中。任何仅用于这些任务的任何状态都可以移动到相应的钩子中。

图 1.11 获取问题数据和订阅用户服务的代码可以提取到自定义钩子中。相关的状态也可以由钩子管理。

这里没有魔法;这只是函数在 JavaScript 中通常工作的方式:函数从组件中提取出来,然后从组件中调用。一旦你有了自定义钩子,你就不再局限于从原始组件中调用它。你可以在许多组件中使用它,与你的团队共享,或者发布供他人使用。

图 1.12 展示了使用 useUsers 自定义钩子和 useFetch 自定义钩子实现的新超级瘦 Quiz 功能组件,它执行了之前由自身独立完成的用户服务订阅和问题获取任务。但现在,第二个组件 Chat 也开始使用 useUsers 自定义钩子。钩子在 React 中使得这种功能共享变得更加容易;自定义钩子可以在你的应用程序组合中任何需要的地方导入和使用。

图 1.12 你可以将代码提取到自定义钩子中以实现重用和共享。Quiz 组件调用 useUsersuseFetch 钩子。Chat 组件调用 useUsers 钩子。

每个自定义钩子都可以维护自己的状态,无论它需要执行什么任务。由于钩子只是函数,如果组件需要访问钩子的任何状态,钩子可以将状态包含在其返回值中。例如,一个用于为指定 ID 获取用户信息的自定义钩子可以本地存储获取到的用户数据,但将其返回给调用钩子的任何组件。每个钩子调用都封装了自己的状态,就像任何其他函数一样。

要了解程序员如何轻松地将常见任务抽象为自定义钩子的多样性,请查看usehooks.com网站(图 1.13)。

图 1.13 useHooks 网站有许多自定义钩子的示例。

它展示了易于使用的配方,包括以下内容:

  • useRouter—包装 React Router 提供的新的钩子

  • useAuth—允许任何组件获取当前的认证状态,并在状态改变时重新渲染

  • useEventListener—抽象了向组件添加和删除事件监听器的过程

  • useMedia—使在组件逻辑中使用媒体查询变得容易

在自己实现钩子之前,在 useHooks 或 npm 等包仓库网站上研究是否存在适合您用例的钩子是非常值得的。如果您已经使用了用于常见场景(如数据获取或状态管理)的库或框架,请检查最新版本,看看它们是否引入了钩子以简化使用。我们将在下一节中查看一些这样的包。

1.3.3 第三方钩子提供现成的、经过良好测试的功能

在组件之间共享功能并非新事物;它已经是一段时间内 React 开发的一个基本组成部分。钩子提供了一种比旧方法(如高阶组件和渲染属性)更干净的方式来共享代码和连接到功能,后者往往导致高度嵌套的代码(“包装地狱”)和虚假的代码层次结构。

与 React 一起工作的第三方库迅速发布了新版本,充分利用了钩子更简单的 API 和更直接的集成方法。我们将在本节中简要介绍三个示例:

  • React Router 用于页面导航

  • Redux 作为应用程序数据存储

  • React Spring 用于动画

React Router

React Router 提供了组件,帮助开发者管理他们应用中页面之间的导航。它的自定义钩子使得访问导航中涉及的常见对象变得容易:useHistoryuseLocationuseParamsuseRouteMatch。例如,useParams 允许访问页面 URL 中匹配的任何参数:

URL:     /quiz/:title/:qnum
Code:    const {title, qnum} = useParams();

Redux

对于某些应用程序,可能需要一个单独的状态存储。Redux 是创建此类存储的流行库,通常与 React Redux 库结合使用。自 7.1 版本以来,React Redux 提供了钩子,使与存储的交互更加容易:useSelectoruseDispatchuseStore。例如,useDispatch 允许你向存储发送一个动作来更新状态。假设你有一个用于构建测验问题集的应用程序,并且你想添加一个问题:

const dispatch = useDispatch();
dispatch({type: "add question", payload: /* question data */});

新的自定义钩子消除了与将 React 应用程序连接到 Redux 存储相关的部分样板代码。React 还有一个内置的钩子 useReducer,它可能提供了一个更简单的模型来分发动作以更新状态,并在某些情况下消除对 Redux 的感知需求。

React Spring

React Spring 是一个基于 Spring 的动画库,目前提供了五个钩子来访问其功能:useSpringuseSpringsuseTrailuseTransitionuseChain。例如,要在这两个值之间进行动画,你可以选择 useSpring

const props = useSpring({opacity: 1, from: {opacity: 0}});

React Hooks 使得库作者能够为开发者提供更简单的 API,这些 API 不会在他们的代码中引入可能深度嵌套的虚假组件层次结构。同样,React 的其他几个新特性,如并发模式和 Suspense,使库作者和应用程序开发者能够更好地管理代码中的异步过程,并提供更平滑、更响应式的用户体验。

1.4 并发模式和 Suspense 提供更好的用户体验

我们希望为用户提供出色的体验,帮助他们顺畅愉快地与我们的应用程序互动。这可能意味着他们在生产力应用程序中完成工作,在社交平台上与朋友联系,或者在游戏中捕捉到一个晶体。无论他们的目标是什么,我们设计和编写的界面应该是达到目的的手段,而不是障碍。但是,我们的应用程序可能需要加载大量代码,获取大量数据,并尝试操纵数据以提供用户所需的信息,即使他们在快速切换视图时也是如此,滚动、点击和触摸。

React 在 16 和 17 版本中的重写大部分动机是为了构建一个架构,以应对用户界面在加载和处理数据时面临的多种需求,同时用户继续与应用程序交互。并发模式是这一新架构的核心部分,Suspense 组件自然地适应了这种新模式。但它们解决了哪些问题?

假设你有一个应用程序,它在一个长列表中显示产品,并有一个文本框,用户可以在其中输入以过滤列表。当用户输入时,应用程序会更新列表。每个按键都会触发代码重新过滤列表,需要 React 将更新的列表组件绘制到屏幕上。昂贵的过滤过程、重新计算和更新 UI 占用了处理时间,降低了文本框的响应性。用户的体验就像是一个滞后、缓慢的文本框,不会显示用户输入的文字。图 1.14,虽然显然不是浏览器可能调度代码运行的完美表示,但它说明了长时间运行的操作可能会减慢屏幕更新,从而使用户体验变差。

图 1.14 没有并发模式时,像按键这样的交互会被长时间运行的更新所阻塞。

如果应用程序能够优先处理文本框更新并保持用户体验平滑,暂停和重新启动围绕输入的过滤任务,那岂不是很好?欢迎来到并发模式!

1.4.1 并发模式

并发模式 下,React 可以以更细粒度的方式调度任务,暂停构建元素、检查差异和更新前一个状态的 DOM,以确保它能够响应用户交互,例如。在前面的过滤应用程序示例中,React 可以暂停渲染过滤列表,以确保用户输入的文字出现在文本框中。

那么并发模式是如何实现这种魔法的呢?新的 React 架构将任务分解成更小的单元工作,为浏览器或操作系统提供常规的点,以便通知应用程序用户正在尝试与之交互。React 的调度器可以根据每个任务的优先级来决定执行哪些任务。对组件树的一部分进行协调和提交更改可以暂停或放弃,以确保优先级更高的组件首先更新,如图 1.15 所示。

图 1.15 在并发模式下,React 可以暂停较长时间运行的更新,以便快速响应用户交互。

不仅用户交互可以从这种智能调度中受益;对传入数据的响应、懒加载的组件或媒体,或其他异步过程也可以享受到更平滑的用户界面升级。React 可以在内存中渲染更新状态的 UI 时继续显示一个完全交互的现有 UI(而不是旋转器),当足够多的 UI 准备就绪时,切换到新的 UI。并发模式启用了一组新的钩子,useTransitionuseDeferredValue,这些钩子可以改善用户体验,平滑从一个视图到另一个视图或从一个状态到另一个状态的转换。它还与 Suspense 一起使用,Suspense 既是渲染回退内容的组件,也是指定组件正在等待某些内容(如加载数据)的机制。

1.4.2 悬疑

正如你所见,React 应用程序是由组件按层次结构构建的。为了在屏幕上显示您应用程序的当前状态(例如使用 DOM),React 遍历您的组件并在内存中创建元素树,即预期 UI 的描述。它将最新的树与之前的树进行比较,并智能地决定需要做出哪些 DOM 更新来实现预期的 UI。并发模式允许 React 暂停处理元素树的一部分,要么是为了处理更高优先级任务,要么是因为当前组件尚未准备好被处理。

建造用来与Suspense一起工作的组件现在可以挂起,如果它们还没有准备好返回它们的 UI(记住,组件要么是函数,要么有渲染方法,并将属性和状态转换为 UI)。它们可能正在等待组件代码、资源或数据加载,但还没有它们需要完全描述 UI 所需的信息。React 可以暂停挂起组件的处理,并继续遍历元素树。但在屏幕上看起来会怎样?你的用户界面会出现漏洞吗?

除了为组件指定挂起机制外,React 还提供了一个Suspense组件,您可以使用它来填补挂起组件在您的用户界面中留下的空缺。将您的 UI 部分包裹在Suspense组件中,并使用它们的fallback属性让 React 知道如果被包裹的组件之一挂起,应该显示什么内容:

<Suspense fallback={<MySpinner />}>
   <MyFirstComponent />
   <MySecondComponent />
</Suspense>

Suspense允许开发者故意管理多个组件的加载状态,无论是为单个组件、组件组还是整个应用程序显示回退内容。它为库作者提供了一个机制来更新他们的 API 以与Suspense组件一起工作,这样他们的异步功能就可以充分利用Suspense提供的加载状态管理。

1.5 React 的新发布渠道

为了使应用程序开发者和库作者能够充分利用生产中的稳定功能,同时为即将到来的功能做准备,React 团队已经开始在单独的渠道发布代码:

  • 最新版—稳定的 semver 发布

  • 下一个—追踪 React 开发的 master 分支

  • 实验性—包括实验性 API 和功能

对于生产环境,开发者应该坚持使用最新版本;这是当你从 npm(或其他包管理器)安装 React 时获得的那一个。在撰写本文时,大部分并发模式和用于数据获取的 Suspense 都处于实验频道。它们正在开发中,但 API 的变化可能会发生。React 和 Relay(用于数据获取)团队已经在新 Facebook 网站上使用了许多实验性功能一段时间了。这种积极的使用使它们能够对新的方法在具体环境和规模上进行深入理解。通过尽早开放对新功能的讨论并在实验频道提供这些功能,React 团队使得库作者能够测试集成和新 API,以及应用开发者开始适应新的思维方式和细微差别。

1.6 这本书是为谁而写的?

这本书是为想要了解 React 最新特性的经验丰富的 JavaScript 开发者而写的。它专注于 React Hooks、并发模式和 Suspense,使用大量的代码示例来帮助你快速掌握这些特性,并准备好在自己的项目中使用它们(尽管目前这些特性可能还无法在生产环境中使用)。除了提供简单实用的示例外,本书还会花一些时间深入探讨一些特性的背后的推理以及开发者应该注意的细微差别。

这本书并不是对 React 整体介绍的入门书籍,也不会详细涵盖 React 生态系统、构建工具、样式或测试。读者应该对 React 的基本概念有所了解,并且能够创建、构建和运行一个 React 应用程序。本书偶尔会使用类组件示例来与新的函数组件方法进行比较,但不会深入教授基于类的编程方法、高阶组件或渲染属性。(如果你不熟悉所有这些术语,不用担心;你不需要了解它们来学习新概念。)

读者应该熟悉一些较新的 JavaScript 语法添加,如constlet,对象和数组解构,默认参数,扩展运算符以及数组方法如mapfilterreduce。一些与类组件的比较显然会使用 JavaScript 的类语法,因此熟悉这一点会有所帮助,但不是必需的。

1.7 开始学习

书中主要示例,一个预订应用,的代码示例在 GitHub 上,网址为github.com/jrlarsen/react-hooks-in-action,并且可以从 Manning 网站的书页上下载(www.manning.com/books/react-hooks-in-action)。示例应用开发的每个步骤都在单独的 Git 分支上,书中的代码列表包括了相关分支的名称。更小的、独立的 React 示例托管在 CodeSandbox 上(codesandbox.io),以及一些简单的纯 JavaScript 示例在 JS Bin 上(jsbin.com)。与沙盒和 Bin 相关的链接将伴随书中的列表。

摘要

  • 使用 React 创建可重用的组件,通过将状态转换为 UI 来构建应用。

  • 使用 JSX 和 props 以类似 HTML 的语法描述 UI。

  • 创建函数组件,将相关的代码和功能集中在一起。

  • 使用 React Hooks 来封装和共享组件的功能,执行副作用,以及钩入组件的生命周期中的时刻。

  • 创建你自己的自定义钩子,并使用第三方库提供的钩子。

  • 使用Suspense组件为需要时间返回其 UI 的组件提供后备。

  • 探索实验性的并发模式,以在内存中处理多个版本的 UI,使得在状态变化时从一个界面平滑过渡到另一个界面变得更加容易。

  • 注意 React 的三个发布渠道:最新版、下个版本和实验版。

  • 查阅 React 官方文档,网址为reactjs.org

2 使用 useState 钩子管理组件状态

本章涵盖

  • 通过调用useState请求 React 管理组件状态值

  • 使用更新函数更改状态值并触发重新渲染

  • 使用前一个状态来帮助生成新的状态值

  • 管理多个状态值

  • 考虑 React 和组件如何交互以持久化和更新状态以及同步状态和 UI

如果你正在构建 React 应用程序,你期望应用程序使用的数据会随时间变化。无论是完全服务器渲染、移动应用,还是全部在浏览器中,应用程序的用户界面应该表示渲染时的当前数据,或状态。有时应用程序中的多个组件会使用这些数据,有时一个组件不需要分享其秘密,可以独立管理其状态,而不需要巨无霸级的应用级状态存储器的帮助。在本章中,我们将关注个人化,专注于自我管理的组件,而不考虑周围的其他组件。

图 2.1 是一个非常基本的 React 工作原理的说明:它应该使用当前状态来渲染 UI。如果状态发生变化,React 应该重新渲染 UI。插图显示了一个友好的消息中的名称。当名称值发生变化时,React 更新 UI 以显示消息中的新名称。我们通常希望状态和 UI 保持同步(尽管我们可能选择在状态转换期间延迟同步——例如在获取最新数据时)。

图 2-1

图 2.1 当你在组件中更改一个值时,React 应该更新 UI。

React 提供了一些函数,或钩子,以使其能够跟踪组件中的值并保持状态和 UI 同步。对于单个值,React 提供了useState钩子,这也是我们在本章中要探索的钩子。

我们将探讨如何调用钩子,它返回什么,以及如何使用它来更新状态,触发 React 更新 UI。组件通常需要多个状态值来完成其工作,因此我们将看到如何多次调用useState来处理多个值。这不仅仅是记录useStateAPI(你可以去官方 React 文档中查看)。我们将通过讨论useState钩子来帮助你更好地理解函数组件是什么以及它们是如何工作的。为此,我们将以回顾我们在代码列表中遇到的关键概念来结束本章。

说到代码列表,在本章中,我们将开始构建将在整本书中作为主要示例的应用程序。这个例子作为一个一致的上下文,我们使用 React Hooks 来解决常见的编码问题。设置应用程序需要一点家务管理,但一旦完成,我们就能专注于本章剩余部分的单个组件。

2.1 设置预订管理器应用程序

您的有趣而专业的公司拥有众多资源,员工可以预订:会议室、视听设备、技术人员时间、桌球,甚至派对用品。有一天,老板要求您为公司网络搭建一个应用程序的框架,让员工可以预订这些资源。该应用程序应包含三个页面,用于预订、可预订资源和用户,如图 2.2 所示。(技术上,它是一个单页应用程序,页面实际上是组件,但我们仍将它们称为“页面”,因为从用户的角度来看,他们是在页面之间切换。)

图片

图 2.2 预订应用程序有三个页面:预订、可预订资源和用户。

到本节结束时,您将能够显示每个页面,并使用链接在它们之间导航。本节末尾的项目文件夹将包括类似于图 2.3 所示的 public 和 src 文件夹。

图片

图 2.3 初始设置后的 public 和 src 文件夹

您可以看到组件文件夹内的子文件夹如何对应于三个页面。为了使应用程序达到图中的形状,我们共有六个任务要做:

  1. 使用create-react-app为我们的预订应用程序生成框架。

  2. 删除我们将不会使用的create-react-app生成的文件。

  3. 编辑 public 和 src 文件夹中剩余的四个文件。

  4. 安装一些来自 npm 的包。

  5. 添加一个数据库文件,为应用程序提供一些要显示的数据。

  6. 为每个页面创建子文件夹,并将页面组件放入其中。

或者,您可以在 GitHub 上找到正在进行的预订示例应用程序的代码示例,网址为github.com/jrlarsen/react-hooks-in-action,为代码的每次演变设置了分支。每个示例应用程序的列表都包括要检出分支的名称,并在电子书中链接到 GitHub 存储库。例如,在您克隆存储库后,要获取第一个分支的代码,请输入以下命令:

git checkout 0201-pages

使用以下命令安装项目依赖项:

npm i

使用以下命令运行项目:

npm start

然后,您可以跳转到第 2.2 节。

对于那些想从零开始构建大部分应用程序的人来说,我们首先需要的是一个 React 应用程序。

2.1.1 使用 create-react-app 生成应用程序框架

React 的create-react-app实用工具会生成具有预设的 linting 和编译工作流程的项目。它还附带了一个开发服务器,这对于我们在应用程序不断演变的各个阶段工作来说非常完美。让我们使用create-react-app来生成一个名为react-hooks-in-action的新 React 项目。在运行之前,我们不需要使用 npm 安装create-react-app;我们可以通过使用npx命令从其存储库运行它:

npx create-react-app react-hooks-in-action

命令执行需要一些时间,您最终会在 react-hooks-in-action 文件夹中获得大量生成的文件。当我运行 create-react-app 命令时,我的电脑使用 npm 安装文件。如果您已安装 Yarn,create-react-app 将使用 Yarn,您将获得 yarn.lock 文件而不是 package-lock.json。(npx 是当您安装 npm 时包含的一个方便的命令。它的作者 Kat Marchán 在 Medium 文章“Introducing npx”中解释了其背后的思考,见 mng.bz/RX2j.

我们不需要为我们的应用程序安装的所有文件,所以让我们快速删除几个。从 react-hooks-in-action 文件夹内的公共文件夹中,删除除 index.html 之外的所有文件。从 src 文件夹中,删除除 App.css、App.js 和 index.js 之外的所有文件。图 2.4 突出了需要删除的文件。

图 2.4 我们的项目不需要 create-react-app 生成的许多默认文件。

图 2.5 显示了在公共和 src 文件夹中留下的四个主要文件。我们使用它们来运行我们的应用程序,导入我们在本书中构建的组件。

图 2.5 我们需要在公共和 src 文件夹中设置的四个文件

这四个文件是为 React 的演示页面设置的,而不是我们的预订应用程序。是时候进行一些调整了。

2.1.2 编辑四个关键文件

我们的小工作马文件将使应用程序启动并运行。让我向您介绍一下:

  • /public/index.html——包含应用程序的网页

  • /src/App.css——用于在页面上组织元素的样式

  • /src/components/App.js——包含所有其他组件的根组件

  • /src/index.js——导入 App 组件并将其渲染到 index.html 页面的文件

index.html

在公共文件夹内,编辑 index.html 文件。create-react-app 生成的许多样板代码可以删除。具有 idrootdiv 元素必须保留;它是应用程序的容器元素。React 将将 App 组件渲染到该 div 中。您还可以设置页面的标题,如下所示。

分支:0201-pages,文件:/public/index.html

列表 2.1 预订应用程序的 HTML 骨架

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Bookings App</title>      ❶
  </head>
  <body>
    <div id="root"></div>            ❷
  </body>
</html>

❶ 设置页面的标题。

❷ 确保有一个具有 id 为 root 的 div。

这就是我们网页所需的所有内容。App 组件将出现在 div 中,而我们所有的其他组件——包括可预订项目、预订、用户及其单独的页面——将由 App 组件管理。

App.css

本书并非旨在教你层叠样式表(CSS),因此它不会专注于样式列表。有时,CSS 将与组件中的事件(例如加载数据时)结合使用,相关样式将在这些时刻突出显示。样式表将随着时间的推移而发展,因此,如果你感兴趣,请查看仓库。初始样式可以在 分支:0201-pages,文件:/src/App.css 中找到。(如果你对整个项目中 CSS 的发展不感兴趣,但想与 JavaScript 一起编码,只需从完成的项目中获取 App.css 文件即可。)

样式使用 CSS 网格属性定位每个页面上的主要组件,并使用一些 CSS 变量定义文本和背景的常用颜色。

App.js

App 组件是我们应用程序的根组件。它显示带有其链接和用户选择器下拉菜单的页眉,如图 2.6 所示。

图 2.6

图 2.6 带有三个链接和一个下拉列表的页眉

App 组件还设置了通往三个主要页面的路由,如列表 2.2 所示。通过将 URL 与页面组件匹配,路由器向用户显示适当的页面。App.js 文件已移动到新的组件文件夹中。它导入了一些我们在本章后面创建的组件。

分支:0201-pages,文件:/src/components/App.js

列表 2.2 App 组件

import {                                                                ❶
  BrowserRouter as Router,                                              ❶
  Routes,                                                               ❶
  Route,                                                                ❶
  Link                                                                  ❶
} from "react-router-dom";                                              ❶

import "../App.css";

import {FaCalendarAlt, FaDoorOpen, FaUsers} from "react-icons/fa";      ❷

import BookablesPage from "./Bookables/BookablesPage";                  ❸
import BookingsPage from "./Bookings/BookingsPage";                     ❸
import UsersPage from "./Users/UsersPage";                              ❸
import UserPicker from "./Users/UserPicker";                            ❸

export default function App () {
  return (
    <Router>                                                            ❹
      <div className="App">
        <header>
          <nav>
            <ul>
              <li>
                <Link to="/bookings" className="btn btn-header">        ❺
                  <FaCalendarAlt/>                                      ❺
                  <span>Bookings</span>                                 ❺
                </Link>                                                 ❺
              </li>
              <li>
                <Link to="/bookables" className="btn btn-header">       ❻
                  <FaDoorOpen/>
                  <span>Bookables</span>
                </Link>
              </li>
              <li>
                <Link to="/users" className="btn btn-header">
                  <FaUsers/>                                            ❼
                  <span>Users</span>
                </Link>
              </li>
            </ul>
          </nav>

          <UserPicker/>                                                 ❽
        </header>

        <Routes>                                                        ❾
          <Route path="/bookings" element={<BookingsPage/>}/>           ❿
          <Route path="/bookables" element={<BookablesPage/>}/>         ⓫
          <Route path="/users" element={<UsersPage/>}/>                 ⓬
        </Routes>
      </div>
    </Router>
  );
}

❶ 从 react-router-dom 导入路由元素。

❷ 导入导航链接的图标。

❸ 导入单独的页面组件和 UserPicker 组件。

❹ 将应用包裹在 Router 组件中以启用路由。

❺ 与 Router 一起使用 Link 组件。

❻ 使用“to”属性指定链接的地址。

❼ 使用导入的图标装饰链接。

❽ 将 UserPicker 放在页眉中。

❾ 将 Route 组件的集合包裹在 Routes 组件中。

❿ 为每个要匹配的路径使用一个 Route。

⓫ 将路径匹配到特定的页面组件。

⓬ 指定要显示的匹配路径的组件。

注意,列表顶部没有 import React from "react"。React 组件过去需要这一行才能在 JSX 转换为常规 JavaScript 时正常工作。但是,像 create-react-app 这样的编译 React 的工具可以在 React 最新版本中转换 JSX,而无需导入语句。关于这一变化,请参阅 React 博客(mng.bz/2ew8)。

该应用使用 React Router 版本 6 来管理其三个页面的显示。在撰写本文时,React Router 6 是通过 React Router 的 Next 频道提供的测试版。安装方法如下:

npm i history react-router-dom@next

在其 GitHub 页面 (github.com/ReactTraining/react-router) 上了解更多关于 React Router 的信息。我们使用 Link 组件在标题中显示页面链接,并使用 Route 元素根据匹配的 URL 有条件地显示页面组件。例如,如果用户访问 /bookings,则显示 BookingsPage 组件:

<Route path="/bookings" element={<BookingsPage/>}/>

目前,你不需要担心 React Router;它只是管理链接和页面组件的显示。我们将在第十章中使用它提供的自定义钩子来访问匹配的 URL 和查询字符串参数,那时我们会更多地使用它。

如图 2.7 所示,我们已经用 Font Awesome (fontawesome.com) 的图标装饰了标题链接。

图 2.7 标题栏中每个链接旁边都有 Font Awesome 图标。

这些图标作为 react-icons 包的一部分提供,因此我们需要安装该包:

npm i react-icons

react-icons 的 GitHub 页面 (github.com/react-icons/react-icons) 包含了包中可用的图标集的详细信息,以及相关许可信息的链接。

App 组件还导入了三个页面组件—BookablesPageBookingsPageUsersPage—以及 UserPicker 组件。我们在 2.1.4 节中创建了这些组件。

index.js

React 需要一个 JavaScript 文件作为应用程序的起点。在 src 文件夹中,编辑 index.js 文件,使其看起来如下所示。它导入了 App 组件并将其渲染到 listing 2.1 中 index.html 文件中看到的根 div 中。

分支:0201-pages,文件:/src/index.js

列表 2.3 顶级 JavaScript 文件

import ReactDOM from "react-dom";
import App from "./components/App";    ❶

ReactDOM.render(
 <App />,                             ❷
  document.getElementById("root")      ❸
);

❶ 导入 App 组件。

❷ 指定 App 作为要渲染的组件。

❸ 指定渲染 App 组件的位置。

这样,四个现有的文件就调整好了!我们仍然需要为 App 组件创建页面组件以及用于标题的 UserPicker 下拉菜单。首先,应用需要一些可预订项和用户来显示。让我们给它一些数据。

2.1.3 为应用程序添加数据库文件

我们的应用程序需要几种类型的数据,包括用户、可预订项和预订。我们首先从单个 JavaScript 对象表示法 (JSON) 文件 static.json 中导入所有数据。我们只需要在列表中显示一些可预订项和用户,所以初始数据文件并不复杂,如下所示。(您可以通过访问指定的文件来复制 GitHub 上列表分支的数据。)

分支:0201-pages,文件:/src/static.json

列表 2.4 预订应用的数据结构

{
 "bookables": [ /* array of bookable objects */ ],  ❶

 "users": [ /* array of user objects */ ],     ❷

 "bookings": [],                                    ❸

 "sessions": [                                      ❹
    "Breakfast",
    "Morning",
    "Lunch",
    "Afternoon",
    "Evening"
  ],

 "days": [                                          ❺
    "Sunday",
    "Monday",
    "Tuesday",
    "Wednesday",
    "Thursday",
    "Friday",
    "Saturday"
  ]
}

❶ 将可预订项数据数组分配给 bookables 属性。

❷ 指定可以使用该应用的用户。

❸ 目前暂时不设置预订。

❹ 配置可用的会话。

❺ 配置星期几。

可预订数组中的每个元素都是一个类似以下的对象:

{
  "id": 3,
  "group": "Rooms",
  "title": "Games Room",
  "notes": "Table tennis, table football, pinball! Please tidy up!",
  "sessions": [0, 2, 4],
  "days": [0, 2, 3, 4, 5, 6]
}

可预订项目存储在一个包含可预订对象的数组中,分配给bookables属性。每个可预订项目都有idgrouptitlenotes属性。书籍代码仓库中的数据注释略长,但结构相同。每个可预订项目还指定了可以预订的天数和时段。

用户也以对象的形式存储,结构如下:

{
  "id": 1,
  "name": "Mark",
  "img": "user1.png",
  "title": "Envisioning Sculptor",
  "notes": "With the company for 15 years, Mark has consistently..."
}

可预订项目将由BookablesPage组件列出,用户将由UsersPage组件列出。我们最好把这些页面建好!

2.1.4 创建页面组件和 UserPicker.js 文件

随着我们对应用添加功能,我们使用组件来封装这些功能并展示使用 hooks 的技术。我们将我们的组件放在与它们所在的页面相关的文件夹中。在组件文件夹内创建三个新的文件夹,分别命名为 Bookables、Bookings 和 Users。对于骨架应用,创建三个与以下列表中类似的结构相同的占位符页面。将它们命名为BookablesPageBookingsPageUsersPage

分支:0201-pages,文件:/src/components/Bookables/BookablesPage.js

列表 2.5 BookablesPage组件

export default function BookablesPage () {
  return (
    <main className="bookables-page">    ❶
      <p>Bookables!</p>
    </main>
  );
}

❶ 为每个页面分配一个类,以便 CSS 文件可以适当地设置页面样式。

我们通过以下列表中的UserPicker组件来完成应用设置的收尾工作。目前,它只是在下拉列表中显示单词“用户”。我们将在本章的后面用数据填充它。

分支:0201-pages,文件:/src/components/Users/UserPicker.js

列表 2.6 UserPicker组件

export default function UserPicker () {
  return (
    <select>
      <option>Users</option>
    </select>
  );
}

我们对预订应用上下文中的 hooks 进行持续探索的所有部件都已就绪。通过启动create-react-app开发服务器来测试它是否正常工作:

npm start

如果一切顺利,你可以在三个页面之间导航,每个页面都会向你喊出它的身份:可预订的!预订!用户!让我们通过显示数据库中的可预订项目来平息“可预订”页面。

2.2 使用 useState 存储、使用和设置值

你的 React 应用负责维护一定的状态:在用户界面中显示的值或帮助管理显示的内容。状态可能包括论坛上的帖子、那些帖子的评论以及是否显示评论等。当用户与应用交互时,他们会改变应用的状态。他们可能会加载更多帖子、切换评论的可见性,或者添加自己的评论。React 确保状态和 UI 保持同步。当状态改变时,React 需要运行使用该状态的组件。组件通过使用最新的状态值返回它们的 UI。React 将返回的 UI 与现有的 UI 进行比较,并高效地更新必要的 DOM。

一些状态在应用程序中共享,一些状态由几个组件共享,还有一些状态由组件本身本地管理。如果组件只是函数,它们如何跨渲染持久化其状态?它们的变量在执行完毕后不是丢失了吗?React 又是如何知道变量发生变化的?如果 React 忠实地试图匹配状态和 UI,它肯定需要知道状态的变化,对吧?

在调用组件时持久化状态并保持 React 在状态改变时处于循环中的最简单方法是使用useState钩子。useState钩子是一个函数,它请求 React 的帮助来管理状态值。当你调用useState钩子时,它返回最新的状态值以及一个用于更新值的函数。使用更新函数使 React 保持循环并让它完成其同步任务。

本节介绍了useState钩子,涵盖了为什么我们需要它以及如何使用它。特别是,我们查看以下内容:

  • 为什么仅仅将值赋给变量不能让 React 完成其工作

  • useState如何返回一个值和一个用于更新该值的函数

  • 为状态设置一个初始值,无论是直接作为值还是作为函数的懒加载

  • 使用更新函数让 React 知道你想要更改状态

  • 确保在调用更新函数时你有最新的状态,并且需要使用现有值来生成新值

这个列表可能看起来有点吓人,但useState钩子非常容易使用(你将大量使用它!),所以不要担心;我们只是在覆盖所有基础。在我们第一次调用useState之前,让我们看看如果我们只是尝试自己管理状态会发生什么。

2.2.1 将新值赋给变量不会更新 UI

图 2.8 显示了我们对BookablesList组件第一次尝试想要的结果:一个包含四个可预订房间的列表,并且选定了讲堂。

图片

图 2.8 显示了BookablesList组件,其中突出显示了选中的房间列表

要显示房间列表,BookablesList组件需要获取列表的数据。它从我们的静态.json 数据库文件中导入数据。组件还需要跟踪当前选中的可预订项。列表 2.7 显示了组件的代码,通过将bookableIndex设置为1来硬编码房间选择。(注意我们正在一个新的 Git 分支上;使用命令 git checkout 0202-hard-coded 切换到该分支。)

分支:0202-hard-coded,文件:/src/components/Bookables/BookablesList.js

列表 2.7 带有硬编码选择的BookablesList组件

import {bookables} from "../../static.json";                            ❶

export default function BookablesList () {

 const group = "Rooms";                                                ❷

 const bookablesInGroup = bookables.filter(b => b.group === group);    ❸

  const bookableIndex = 1;                                              ❹

  return (
    <ul className="bookables items-list-nav">
      {bookablesInGroup.map((b, i) => (                                 ❺
        <li
          key={b.id} 
          className={i === bookableIndex ? "selected" : null}           ❻
        >
          <button
            className="btn"
          >
            {b.title}
          </button>
        </li>
      ))}
    </ul>
  );
}

❶ 使用对象解构将可预订数据分配给一个局部变量。

❷ 设置要显示的可预订项组。

❸ 过滤可预订项以仅显示该组中的项。

❹ 硬编码选中可预订项的索引。

❺ 对可预订项进行映射以创建每个项的列表项。

❻ 通过比较当前索引和所选索引来设置类。

代码将来自静态.json 文件的图书可预约项数组分配给一个名为 bookables 的局部变量。我们本可以采取额外的步骤:

import data from "../../static.json";

const {bookables} = data;

但我们不需要其他任何数据,所以我们直接在导入中为 bookables 进行了赋值:

import {bookables} from "../../static.json";

这种 解构 方法是我们在这本书中经常使用的方法。

拥有图书可预约项数组后,我们过滤它以获取指定组中的那些图书可预约项:

const group = "Rooms";

const bookablesInGroup = bookables.filter(b => b.group === group);

filter 方法返回一个新数组,我们将它分配给 bookablesInGroup 变量。然后我们对 bookablesInGroup 数组进行映射以生成显示的图书可预约项列表。在映射函数中,我使用了简短的变量名,b 代表图书可预约项,i 代表索引,因为它们在分配后立即使用,并且靠近它们的分配位置。我认为它们的含义是清晰的,但你可能更喜欢更具描述性的变量名。

为了显示我们的新组件,我们需要将其连接到 BookablesPage 组件。以下列表显示了所需的两个更改。

分支:0202-hard-coded,文件:/src/components/Bookables/BookablesPage.js

列表 2.8 显示 BookablesListBookablesPage 组件

import BookablesList from "./BookablesList";    ❶

export default function BookablesPage () {
  return (
    <main className="bookables-page">
      <BookablesList/>                          ❷
    </main>
  );
}

❶ 导入新的组件。

❷ 将占位文本替换为组件。

尝试更改 BookablesList 中的硬编码索引值。组件将始终突出显示具有指定索引的图书可预约项——到目前为止,一切顺利。但是,更改代码以更改突出显示的房间是很好的,但我们真正想要的是让用户通过点击图书可预约项来更改它,所以让我们为每个列表项按钮添加一个事件处理器。点击图书可预约项应该选择它,并且 UI 应该更新以突出显示所选项。以下列表包括一个 changeBookable 函数和一个调用它的 onClick 事件处理器。

分支:0203-direct-change,文件:/src/components/Bookables/BookablesList.js

列表 2.9 向 BookablesList 组件添加事件处理器

import {bookables} from "../../static.json";

export default function BookablesList () {
  const group = "Rooms";
  const bookablesInGroup = bookables.filter(b => b.group === group);

 let bookableIndex = 1;                        ❶

  function changeBookable (selectedIndex) {     ❷
 bookableIndex = selectedIndex;              ❷
    console.log(selectedIndex);                 ❷
 }                                             ❷

  return (
    <ul className="bookables items-list-nav">
      {bookablesInGroup.map((b, i) => (
        <li
          key={b.id}
          className={i === bookableIndex ? "selected" : null}          
        > 
          <button
            className="btn"
            onClick={() => changeBookable(i)}   ❸
          >
            {b.title}
          </button>
        </li>
      ))}
    </ul>
  );
}

❶ 使用 let 声明变量,因为它将被分配新的值。

❷ 声明一个函数,将点击的图书可预约项的索引分配给 bookableIndex 变量。

❸ 包含一个 onClick 处理器,将点击的图书可预约项的索引传递给 changeBookable 函数。

现在点击其中一个房间会将该房间的索引分配给 bookableIndex 变量。瞧! 哦。等等……如果你运行列表 2.9 中的代码并尝试点击不同的房间,你会看到突出显示没有改变。但是,代码 确实 更新了 bookableIndex 的值!你可以检查控制台以查看正在记录的索引。为什么新的选择没有显示在屏幕上?为什么 React 没有更新 UI?为什么人们总是忽略我?

没问题,深呼吸。记住,组件是返回 UI 的函数。React 调用这些函数以获取 UI 的描述。React 是如何知道何时调用函数并更新 UI 的呢?仅仅因为你在组件函数中更改了变量的值,并不意味着 React 会注意到。如果你想引起注意,你不能只是在心里对人说“你好,世界!”;你必须大声说出来。图 2.9 显示了在组件中直接更改值时会发生什么:React 没有注意到。它很快乐,吹着口哨,打磨着它的小玩意儿——UI 保持坚如磐石,没有变化。

图 2.9 直接在我们的组件代码中更改变量不会更新 UI。

那么,我们如何吸引 React 的注意并让它知道它有工作要做?我们调用 useState 钩子。

2.2.2 调用 useState 返回一个值和一个更新函数

我们想通知 React,组件内使用的值已更改,以便它可以重新运行组件并更新 UI。仅仅直接更新变量是不够的。我们需要一种更改该值的方法,某种类型的更新函数,它可以触发 React 调用组件并使用新值获取更新的 UI,如图 2.10 所示。

图 2.10 而不是直接更改一个值,我们调用一个更新函数。更新函数更改值,React 使用从组件重新计算的用户界面来更新显示。

为了避免组件代码运行完成后组件状态值消失,我们让 React 为我们管理这个值。这就是 useState 钩子的作用。每次 React 调用我们的组件以获取其 UI 时,组件都可以请求 React 提供最新的状态值和更新值的函数。组件可以在生成其 UI 时使用该值,并在更改值时使用更新函数,例如,在用户点击列表中的项目时。

如图 2.11 所示,调用 useState 返回一个值和其更新函数,包含两个元素的数组。

图 2.11 useState 函数返回一个包含两个元素的数组:一个值和一个更新函数。

您可以将返回的数组分配给一个变量,然后通过索引单独访问两个元素,如下所示:

const selectedRoomArray = useState();           ❶

const selectedRoom = selectedRoomArray[0];      ❷

const setSelectedRoom = selectedRoomArray[1];   ❸

useState 函数返回一个数组。

❷ 第一个元素是值。

❸ 第二个元素是用于更新值的函数。

但更常见的是使用数组解构,并在一步中将返回的元素分配给变量:

const [selectedRoom, setSelectedRoom] = useState();

数组解构允许我们将数组中的元素分配给我们的变量。selectedRoomsetSelectedRoom 这些名称是任意选择的,虽然通常会将第二个元素(更新函数)的变量名以 set 开头。以下也是可行的:

const [myRoom, updateMyRoom] = useState();

如果你想为变量设置一个初始值,请将初始值作为参数传递给useState函数。当 React 首次运行你的组件时,useState将像往常一样返回一个两元素数组,但会将初始值分配给数组的第一个元素,如图 2.12 所示。

图 2.12 当组件首次运行时,React 将传递给useState的初始值分配给selected变量。

当以下代码行在组件中首次执行时,React 将返回数组中的第一个元素Lecture Hall作为值Lecture Hall。代码将该值分配给selected变量:

const [selected, setSelected] = useState("Lecture Hall");

让我们更新BookablesList组件,使用useState钩子请求 React 管理选定项索引的值。我们将其1作为初始索引传递。你应该看到当BookablesList组件首次出现在屏幕上时,演讲厅被突出显示,如图 2.13 再次所示。

图 2.13 选择演讲厅的BookablesList组件

下面的列表显示了组件的更新代码。它包括一个onClick事件处理程序,该处理程序使用分配给setBookableIndex的更新器函数在用户点击可预订项时更改选定的索引。

分支:0204-set-index,文件:/src/components/Bookables/BookablesList.js

列表 2.10 在更改选定房间时触发 UI 更新

import {useState} from "react";                                     ❶
import {bookables} from "../../static.json";

export default function BookablesList () { 
  const group = "Rooms";
  const bookablesInGroup = bookables.filter(b => b.group === group);
  const [bookableIndex, setBookableIndex] = useState(1);            ❷

  return (
    <ul className="bookables items-list-nav">
      {bookablesInGroup.map((b, i) => (
        <li
          key={b.id}
          className={i === bookableIndex ? "selected" : null}       ❸
        > 
          <button
            className="btn"
            onClick={() => setBookableIndex(i)}                     ❹
          >
            {b.title}
          </button>
        </li>
      ))}
    </ul>
  );
}

❶ 导入useState钩子。

❷ 调用useState并将返回的状态值和更新器函数分配给变量。

❸ 在生成 UI 时使用状态值。

❹ 使用更新器函数更改状态值。

React 运行BookablesList组件代码,从useState调用返回bookableIndex的值。组件使用该值在生成 UI 时为每个li元素设置正确的className属性。当用户点击可预订项时,onClick事件处理程序使用更新器函数setBookableIndex告诉 React 更新它所管理的值。如果值已更改,React 知道它将需要一个新版本的 UI。React 再次运行BookablesList代码,将更新的状态值分配给bookableIndex,让组件生成更新的 UI。React 可以比较新生成的 UI 与旧版本,并决定如何高效地更新显示。

使用useState,React 现在正在监听。我不再感到那么孤独了。它正在实现其保持状态与 UI 同步的承诺。BookablesList组件描述了特定状态下的 UI,并为用户提供了一种更改状态的方法。React 随后施展其魔法,检查新的 UI 是否与旧的不同(diffing),批量处理和安排更新,决定以高效的方式更新 DOM 元素,然后为我们代表执行操作并接触 DOM。我们专注于状态;React 执行 diffing 并更新 DOM。

挑战 2.1

创建一个UsersList组件,显示从数据库中获取的用户列表。启用用户选择,并将组件连接到UsersPage。(记住,如果你还没有这样做,你可以从应用的 GitHub 仓库中复制完整的数据库文件。)

挑战 2.2

更新UserPicker下拉列表组件,使其显示列表中的用户选项。现在不必担心连接任何事件处理程序。挑战任务在 0205-user-lists 分支中实现。

在列表 2.10 中,我们向useState传递了一个初始值1。当用户点击不同的可预订项时,该值会被另一个数字替换。如果我们想存储更复杂的东西,比如对象,作为状态,我们更新状态时就需要更加小心。让我们看看原因。

2.2.3 调用更新函数替换之前的状态值

如果你从 React 中基于类的组件构建方法转换过来,你习惯于状态是一个对象,具有不同属性的不同状态值。转换到函数组件时,你可能试图复制这种状态作为对象的方法。拥有一个单一的状态对象并且新的状态更新与现有状态合并可能感觉更自然。

useState钩子易于使用且易于多次调用,一次用于每个你希望 React 监控的状态值。值得养成为每个状态属性单独调用useState的习惯,如第 2.4 节中进一步讨论的,而不是坚持熟悉的做法。如果你需要以对象作为状态值或想要将一些相关值组合在一起(比如长度和宽度),你应该意识到函数组件的更新函数setState与类组件中使用的this.setState的不同。在本节中,我们将简要探讨在两种组件类型中更新对象状态。

类组件方法

使用类时,你在构造函数(或类上的静态属性)中设置状态为一个对象:

class BookablesList extends React.Component {
  constructor (props) {
    super(props);

    this.state = {
      bookableIndex: 1,
      group: "Rooms"
    };
  }
}

要更新状态(例如在事件处理程序中),你调用this.setState,传递一个包含任何你想做的更改的对象:

handleClick (index) {
  this.setState({
    bookableIndex: index
  });
}

React 将你传递给setState的对象与现有状态合并。在上面的例子中,它更新了bookableIndex属性,但group属性保持不变,如图 2.14 所示。

图 2.14 在类组件中,调用更新函数(this.setState)会将新属性与现有状态对象合并。

函数组件方法

相比之下,对于新的 hooks 方法,更新函数会将之前的状态值替换为你传递给函数的值。如果你有简单的状态值,这很简单,就像这样:

const [bookableIndex, setBookableIndex] = useState(1);

setBookableIndex(3);  // React replaces the value 1 with 3.

但如果你决定在状态中存储 JavaScript 对象,你需要小心行事。更新函数将完全替换旧对象。比如说,你这样初始化状态:

function BookablesList () {
  const [state, setState] = useState({
    bookableIndex: 1,
    group: "Rooms"
  });
}

如果你只使用改变的 bookableIndex 属性调用更新器函数 setState,那么你将丢失 group 属性:

function handleClick (index) {
  setState({
    bookableIndex: index
  });
}

旧状态对象被新对象替换,如图 2.15 所示。

图 2.15 在函数组件中,调用更新器函数(由 useState 返回)用你传递给更新器函数的内容替换旧状态值。

因此,如果你确实需要使用带有 useState 钩子的对象,在设置新属性值时,请复制旧对象的所有属性:

function handleClick (index) {
  setState({
    ...state,
    bookableIndex: index
  });
}

注意在前面的代码片段中如何使用展开运算符 ...state 来复制旧状态的所有属性到新状态。实际上,为了确保在基于旧状态设置新值时你有最新的状态,你可以将一个函数作为参数传递给更新器函数,如下所示:

function handleClick (index) {
  setState(state => { ❶
 return {
 ...state, ❷
 bookableIndex: index
 };
 });
}

❶ 将函数传递给 setState

❷ 在设置新值时使用旧状态值。

React 将传递最新的状态作为第一个参数。这个更新器函数的函数版本将在 2.2.5 节中更详细地讨论。

在处理对象的问题得到简要说明后,我们还需要在多次调用 useState 之前提到 useState 钩子 API 的另一个特性。偶尔,你可能需要推迟计算昂贵的初始值。为此有一个函数。

2.2.4 将函数传递给 useState 作为初始值

有时一个组件可能需要做一些工作来计算某个状态片段的初始值。也许组件从遗留存储系统中接收了一串复杂的数据,并需要从错综复杂的结中提取有用的信息。解开这根线可能需要一段时间,而你只想做一次。这种方法是浪费的:

function untangle (aFrayedKnot) {
  // perform expensive untangling manoeuvers
  return nugget;
}

function ShinyComponent ({tangledWeb}) {
  const [shiny, setShiny] = useState(untangle(tangledWeb));

  // use shiny value and allow new shiny values to be set
}

每当 ShinyComponent 运行时,可能是在设置另一状态片段的响应中,昂贵的 untangle 函数也会运行。但是 useState 只在第一次调用时使用其初始值参数。在第一次调用之后,它不会使用 untangle 返回的值。反复运行昂贵的 untangle 函数是浪费时间。

幸运的是,useState 钩子接受一个函数作为其参数,一个懒初始化状态,如图 2.16 所示。

图 2.16 你可以将函数传递给 useState 作为初始值。React 将使用函数的返回值作为初始值。

React 只在组件首次渲染时执行该函数。它使用函数的返回值作为初始状态:

function ShinyString ({tangledWeb}) {
  const [shiny, setShiny] = useState(() => untangle(tangledWeb));

  // use shiny value and allow new shiny values to be set
}

如果你需要执行昂贵的操作来生成某个状态片段的初始值,请使用懒初始化状态。

2.2.5 使用旧状态设置新状态

如果用户能够更轻松地在BookablesList组件中的可读内容之间循环,那就太好了。让我们添加一个“下一步”按钮来实现循环,如图 2.17 所示。如果我们把焦点移到“下一步”按钮上,用户可以通过键盘激活它。

图 2.17 点击“下一步”按钮选择列表中的下一个可读内容。

“下一步”按钮需要增加bookableIndex状态值,当它超过最后一个可读内容时,会回绕到 0。下面的列表显示了“下一步”按钮的实现。

分支:0206-next-button,文件:/src/components/Bookables/BookablesList.js

列表 2.11 向setBookableIndex传递一个函数。

import {useState} from "react"; 
import {bookables} from "../../static.json";
import {FaArrowRight} from "react-icons/fa";                        ❶

export default function BookablesList () {
  const group = "Rooms";
  const bookablesInGroup = bookables.filter(b => b.group === group);
  const [bookableIndex, setBookableIndex] = useState(1);

  function nextBookable () {                                        ❷
 setBookableIndex(i => (i + 1) % bookablesInGroup.length);       ❸
 }

  return (
    <div>
      <ul className="bookables items-list-nav">
        {bookablesInGroup.map((b, i) => (
          <li
            key={b.id}
            className={i === bookableIndex ? "selected" : null}
          >
            <button
              className="btn"
              onClick={() => setBookableIndex(i)}
            >
              {b.title}
            </button>
          </li>
        ))}
      </ul>
      <p>
 <button
 className="btn"
 onClick={nextBookable}                                   ❹
 autoFocus
 >
 <FaArrowRight/>
 <span>Next</span>
 </button>
 </p>
    </div>
  );
}

❶ 导入一个 Font Awesome 图标。

❷ 为“下一步”按钮创建一个事件处理程序。

❸ 将更新函数传递给一个用于增加索引的函数。

❹ 包含一个按钮来调用nextBookable函数。

在“下一步”按钮的事件处理程序nextBookable中,我们调用更新函数setBookableIndex,并传递给它一个函数:

setBookableIndex(i => (i + 1) % bookablesInGroup.length);

该函数使用%运算符,它给出除法时的余数。当i + 1与可读内容的数量bookablesInGroup.length相同时,余数为0,索引回绕到开始。但为什么不用我们已有的状态值作为索引呢?

setBookableIndex((bookableIndex + 1) % bookablesInGroup.length);

通过使用钩子将我们状态值的管理权交给 React,我们不仅请求它更新值并触发重新渲染;我们还赋予它高效安排任何更新何时发生的权限。React 可以智能地将更新批量处理在一起,并忽略冗余的更新。

当我们想要基于其前一个值更新一个状态值时,就像我们的“下一步”按钮示例中那样,我们不是传递一个要设置的值给更新函数,而是传递一个函数。React 将传递当前状态值给这个函数,并将该函数的返回值用作新的状态值。所有这些都在图 2.18 中展示。

图 2.18 将一个函数传递给更新函数,该函数使用旧的状态值并返回一个新的状态值。

通过传递一个函数,我们确保任何基于旧值的新的值都有最新的信息来工作。

列表 2.11 使用一个单独的函数nextBookable来响应“下一步”按钮的点击,但在onClick属性中将响应点击可读内容的处理程序内联。这只是一个个人选择;当处理程序执行的操作不仅仅是调用一个简单的更新函数时,我倾向于将其放在自己的函数中而不是内联。在列表 2.11 的情况下,我们同样可以将“下一步”按钮的处理程序内联或可读内容点击处理程序放在自己的命名函数中。

因此,我们可以调用useState来请求 React 为我们管理一个值。但,当然,在我们的组件中我们可能需要不止一个状态值。让我们看看如何处理多个状态值,当我们给用户在BookablesList组件中选择组的能力时。

2.3 多次调用 useState 以处理多个值

在详细了解了useState的工作原理后,现在是时候物尽其用了。我们不仅限于单一的信息,甚至不是一个具有许多属性的单一对象。如果我们对多个值感兴趣,以驱动组件的 UI,我们只需继续调用该钩子:useState用于这个,useState用于那个,useState用于其他。我们可以使用useState来处理所有事情!

在本节中,我们向BookablesList组件添加功能,首先让用户在可预订项的组之间切换,然后显示所选可预订项的详细信息。记住,我们的任务是专注于状态,因此我们需要与几个值一起工作:

  • 选定的组

  • 选定的可预订项

  • 组件是否显示可预订项的可用性(天数和时段)

到本节结束时,我们为所有三个状态值调用useState。我们将返回的值嵌入到我们的 UI 中,并使用更新函数在用户选择组或可预订项或切换详细信息显示时更改状态。

2.3.1 使用下拉列表设置状态

让我们从更新BookablesList组件开始,使用户能够选择要预订的资源类型:房间或工具包。图 2.19 显示了该组件的两个实例,第一个显示了房间组中的可预订项,第二个显示了工具包组中的可预订项。

图片

图 2.19 BookablesList组件的两个视图:带有下拉列表选择可预订项类型的视图:第一个选择了房间,第二个选择了工具包

我们希望用户进行两次选择:要显示的组,房间或工具包,以及组内的可预订项。更改任何变量都应更新显示,因此我们希望 React 跟踪它们两个。我们应该创建某种状态对象通过useState钩子传递给 React 吗?嗯,不。最简单的方法是调用useState两次:

const [group, setGroup] = useState("Kit");
const [bookableIndex, setBookableIndex] = useState(0);

React 使用调用顺序来确定哪个跟踪变量是哪个。在前面的代码片段中,每次 React 调用组件代码时,useState的第一次调用将第一个跟踪值分配给group变量,而useState的第二次调用将第二个跟踪值分配给bookableIndex变量。setBookableIndex更新第二个跟踪值,而setGroup更新第一个。

您的老板一直在看着您,所以让我们为BookablesList组件实现组选择功能。以下列表显示了最新的代码。

分支:0207-groups,文件:/src/components/Bookables/BookablesList.js

列表 2.12 BookablesList组件使用两次useState调用

import {useState} from "react"; 
import {bookables} from "../../static.json";
import {FaArrowRight} from "react-icons/fa";

export default function BookablesList () {
  const [group, setGroup] = useState("Kit");                              ❶
  const bookablesInGroup = bookables.filter(b => b.group === group);
  const [bookableIndex, setBookableIndex] = useState(0);                  ❷
  const groups = [...new Set(bookables.map(b => b.group))];               ❸

  function nextBookable () {
    setBookableIndex(i => (i + 1) % bookablesInGroup.length);
  }

  return (
    <div>
      <select
 value={group}
 onChange={(e) => setGroup(e.target.value)}                        ❹
 >
 {groups.map(g => <option value={g} key={g}>{g}</option>)}         ❺
 </select>

      <ul className="bookables items-list-nav">
        {bookablesInGroup.map((b, i) => (
          <li
            key={b.id}
            className={i === bookableIndex ? "selected" : null}
          >
            <button
              className="btn"
              onClick={() => setBookableIndex(i)}
            >
              {b.title}
            </button>
          </li>
        ))}
      </ul>
      <p>
        <button
          className="btn"
          onClick={nextBookable}
          autoFocus
        >
          <FaArrowRight/>
          <span>Next</span>
        </button>
      </p>
    </div>
  );
}

❶ 使用第一个跟踪状态值来保存所选组。

❷ 使用第二个跟踪状态值来保存所选可预订项索引。

❸ 将一组唯一的组名分配给groups变量。

❹ 包含一个事件处理程序来更新所选组。

❺ 创建一个下拉列表以显示可预订数据中的每个组。

代码将group变量分配给初始值Kit,因此组件一开始就显示 Kit 组中的可预订项列表。当用户从下拉列表中选择新组时,setGroup更新器函数让 React 知道值已更改。要获取下拉列表中的组名,我们将可预订数据通过几个转换。首先,我们创建一个仅包含组名的数组:

bookables.map(b => b.group)  // array of group names

然后,我们从组名数组创建一个Set。集合只包含唯一值,因此任何重复项都将被丢弃:

new Set(bookables.map(b => b.group))  // set of unique group names

最后,我们创建一个新的数组并将Set元素展开到其中。新数组只包含唯一的组名。这正是我们想要的!

[...new Set(bookables.map(b => b.group))]  // array of unique group names

如果 JS-Fu 有点密集,你可以始终创建一个getUniqueValues实用函数来使事物更易于阅读:

function getUniqueValues (array, property) {
  const propValues = array.map(element => element[property]);
  const uniqueValues = new Set(propValues);
  const uniqueValuesArray = [...uniqueValues];

  return uniqueValuesArray;
}

const groups = getUniqueValues(bookables, "group");

我们将坚持使用简洁版本,因为它永远不会改变。

我希望你们同意,使用两件状态项工作相当简单。我们只需调用两次useState。要更新状态,我们调用适当的更新器函数。用户进行选择,事件处理程序更新状态,React 执行差异比较并触发 DOM。让我们再来一次!

2.3.2 使用复选框设置状态

我们接下来的任务是向组件添加一个详细信息部分,以便我们的办公室同事对每个可预订项有更多了解。我们使每个可预订项的可用性显示为可选。图 2.20 显示了带有已勾选的“显示详细信息”复选框的BookablesList组件;可预订项可用的日期和时段是可见的。

图 2.20 BookablesList组件的可用性显示。标题右侧的“显示详细信息”复选框已勾选。

图 2.21 显示了未勾选复选框的组件;日期和时段被隐藏。

图 2.21 BookablesList组件的可用性被隐藏。标题右侧的“显示详细信息”复选框未勾选。

除了选定的组和选定的可预订项索引外,我们现在还有第三件状态项:我们需要跟踪是否显示所选可预订项的详细信息。以下列表显示了通过useState钩子调用跟踪我们的三个变量的BookablesList组件。

分支:0208-bookable-details,文件:/src/components/Bookables/BookablesList.js

列表 2.13 Bookables组件跟踪三个变量

import {useState, Fragment} from "react";                          ❶
import {bookables, sessions, days} from "../../static.json";
import {FaArrowRight} from "react-icons/fa";

export default function BookablesList () {
  const [group, setGroup] = useState("Kit");
  const bookablesInGroup = bookables.filter(b => b.group === group);
  const [bookableIndex, setBookableIndex] = useState(0);
  const groups = [...new Set(bookables.map(b => b.group))];

 const bookable = bookablesInGroup[bookableIndex];                ❷

  const [hasDetails, setHasDetails] = useState(false);             ❸

  function nextBookable () {
    setBookableIndex(i => (i + 1) % bookablesInGroup.length);
  }
  return (
    <Fragment>
      <div>
        /* unchanged UI for list of bookables */
      </div>
      {bookable && (                                               ❹
        <div className="bookable-details">                         ❺
          <div className="item">
            <div className="item-header">
              <h2>
                {bookable.title}
              </h2>
              <span className="controls">
                <label>
                  <input                                           ❻
                    type="checkbox"                                ❻
                    checked={hasDetails}
                    onChange={() => setHasDetails(has => !has)}    ❼
                  />
                  Show Details
                </label>
              </span>
            </div>
            <p>{bookable.notes}</p>
            {hasDetails && (                                       ❽
              <div className="item-details">
                <h3>Availability</h3>
                <div className="bookable-availability">
                  <ul>                                             ❾
                    {bookable.days                                 ❾
                      .sort()                                      ❾
                      .map(d => <li key={d}>{days[d]}</li>)        ❾
                    }                                              ❾
                  </ul>                                            ❾
                  <ul>                                             ❿
                    {bookable.sessions                             ❿
                      .map(s => <li key={s}>{sessions[s]}</li>)    ❿
                    }                                              ❿
                  </ul>                                            ❿
                </div>
              </div>
            )}
          </div>
        </div>
      )}
    </Fragment>
  );
}

❶ 导入React.Fragment以包裹多个元素。

❷ 将当前选定的可预订项分配给其自己的变量。

❸ 使用第三个跟踪状态值来保存是否显示详细信息。

❹ 仅当选择可预订项时显示详细信息。

❺ 包含一个新 UI 部分以显示所选可预订项的详细信息。

❻ 允许用户通过复选框切换详细信息。

❼ 包含一个事件处理程序以更新是否显示详细信息。

❽ 仅当hasDetails为真时显示详细信息。

❾ 显示可用日期列表。

❿ 显示可用会话的列表。

组件使用当前的 bookableIndexbookablesInGroup 数组中访问所选可预订项:

const bookable = bookablesInGroup[bookablesIndex];

没有必要调用 useState 来存储可预订对象本身,因为它可以从状态中已有的索引值推导出来。UI 包括一个新部分来显示所选可预订项的详细信息。但是,组件仅在存在可显示的可预订项时显示该部分:

{bookable && (
  <div className="bookable-details">
    // details UI
  </div>
)}

类似地,只有当 hasDetails 状态值为 true 时,所选可预订项的额外信息才可见;换句话说,复选框被勾选:

{hasDetails && (
  <div className="item-details">
    // Bookable availability
  </div>
)}

看起来我们在 BookablesList 组件上的工作已经完成。我们有了当前所选组中的可预订项列表,并且能够切换所选可预订项的详细信息显示。但在你自我表扬并预订游戏室和派对用品之前,请遵循以下三个步骤:

  1. 选择游戏室;然后显示其详细信息。

  2. 将组切换到 Kit。显示可预订的设备列表,但没有选择任何可预订项,并且详细信息消失。(选择了哪个可预订项?)

  3. 点击“下一步”按钮。Kit 的第二个项目,无线麦克风,被选中,并显示其详细信息。

空气中有一丝陈旧的数据味道。你能找出发生了什么吗?我们希望用户交互导致状态的可预测变化。有时这意味着单个交互应该导致多个状态的变化。下一章将探讨这个问题,并介绍 reducers,这是一种协调更复杂状态变化并消除陈旧味道的机制。但在我们切换钩子之前,我们将回顾构建 BookablesList 组件所教给我们的关于函数组件的一般知识。在此之前,这里有一个挑战!

挑战 2.3

更新 UsersList 组件以显示所选用户的详细信息。显示用户姓名、职称和备注。一种可能的方法如图 2.22 所示,代码位于书籍 GitHub 仓库的 0209-user-details 分支中。

图片

图 2.22 显示所选用户详细信息的 UsersList 组件

2.4 回顾一些函数组件概念

到目前为止,我们的 BookablesList 组件非常简单。但一些基本概念已经在发挥作用,这些概念是我们对函数组件和 React 钩子理解的基础。对这些概念有牢固的掌握将使我们在本书中的未来讨论以及你对钩子的专家级使用变得更加容易。特别是,这里有五个关键概念:

  • 组件是接受 props 并返回其 UI 描述的函数。

  • React 调用组件。作为函数,组件运行其代码然后结束。

  • 一些变量可能存在于事件处理器创建的闭包中。其他变量在函数结束时被销毁。

  • 我们可以使用钩子让 React 帮我们管理值。React 可以将最新的值和更新器函数传递给组件。

  • 通过使用更新函数,我们让 React 知道值的变化。它可以重新运行组件以获取最新的 UI 描述。

图 2.23 中的组件周期图显示了当我们的BookablesList组件运行并且用户点击可预订项时涉及的一些步骤。表 2.1 讨论了每个步骤。

表 2.1 使用useState时的关键步骤

步骤 发生了什么? 讨论
1 React 调用组件。 为了生成页面的 UI,React 遍历组件树,调用每个组件。React 将传递给每个组件任何在 JSX 中设置的属性作为 props。
2 组件第一次调用useState 组件将初始值传递给useState函数。React 从该组件设置该useState调用的当前值。
3 React 将当前值和更新函数作为一个数组返回。 组件代码将值和更新函数分配给变量以供以后使用。第二个变量名通常以set开头(例如,valuesetValue)。
4 组件设置事件处理器。 事件处理器可能监听用户点击,例如。处理器将在稍后运行时更改状态。React 将在第 6 步更新 DOM 时将处理器连接到 DOM。
5 组件返回其 UI。 组件使用当前状态值来生成其用户界面并返回它,完成其工作。
6 React 更新 DOM。 React 使用所需的任何更改更新 DOM。
7 事件处理器调用更新函数。 一个事件被触发,处理器运行。处理器使用更新函数来更改状态值。
8 React 更新状态值。 React 用更新函数传递的值替换状态值。
9 React 调用组件。 React 知道状态值已更改,因此必须重新计算 UI。
10 组件第二次调用useState 这次,React 将忽略初始值参数。
11 React 返回当前状态值和更新函数。 React 已更新状态值。组件需要最新的值。
12 组件设置事件处理器。 这是处理器的新版本,可能使用新更新的状态值。
13 组件返回其 UI。 组件使用当前状态值来生成其用户界面并返回它,完成其工作。
14 React 更新 DOM。 React 将新返回的 UI 与旧的 UI 进行比较,并高效地使用所需的任何更改更新 DOM。

图片

图 2.23 使用useState时的关键时刻步骤

为了清晰和精确地讨论概念,我们不时地回顾迄今为止遇到的词汇和对象。表 2.2 描述了我们遇到的一些术语。

表 2.2 我们遇到的一些关键术语

图标 术语 描述
组件 接受 props 并返回其 UI 描述的函数。
初始值 组件将此值传递给 useState。React 在组件首次运行时将状态值设置为这个初始值。
更新函数 组件调用此函数来更新状态值。
事件处理器 在响应某种事件时运行的函数——例如,用户点击一个可预订项。事件处理器通常会调用更新函数来改变状态。
UI 用户界面组成的元素描述。状态值通常包含在 UI 的某个地方。

摘要

  • 当你想让 React 管理组件的值时,调用 useState 钩子。它返回一个包含两个元素的数组:状态值和更新函数。如果需要,你可以传递一个初始值:

    const [value, setValue] = useState(initialValue);
    
  • 如果你需要执行一个昂贵的计算来生成初始状态,可以在函数中将它传递给 useState。React 会在第一次调用组件时运行这个函数来获取这个懒加载的初始状态:

    const [value, setValue] = useState(() => { return initialState; });
    
  • 使用 useState 返回的更新函数来设置新值。新值将替换旧值。如果值已更改,React 将安排重新渲染:

    setValue(newValue);
    
  • 如果你的状态值是一个对象,确保在更新函数仅更新属性子集时,从上一个状态复制未更改的属性:

    setValue({
      ...state,
      property: newValue
    });
    
  • 为了确保在调用更新函数并基于旧值设置新值时使用最新的状态值,将一个函数作为参数传递给更新函数。React 将最新的状态值分配给函数参数:

    setValue(value => { return newValue; });
    
    setValue(state => {
      return {
        ...state,
        property: newValue
      };
    });
    
  • 如果你有多件状态,你可以多次调用 useState。React 使用调用顺序来一致地分配值和更新函数到正确的变量:

    const [index, setIndex] = useState(0);                     // call 1
    const [name, setName] = useState("Jamal");                 // call 2
    const [isPresenting, setIsPresenting] = useState(false);   // call 3
    
  • 专注于状态以及事件如何更新状态。React 将同步状态和 UI 的任务完成:

    function Counter () {
      const [count, setCount] = useState(0);                           ❶
    
      return (
        <p>{count}                                                     ❷
          <button onClick={() => setCount(c => c + 1)}> + </button>    ❸
        </p>
      );
    }
    

    ❶ 考虑组件需要什么状态。

    ❷ 显示状态。

    ❸ 根据事件更新状态。

3 使用 useReducer 钩子管理组件状态

本章涵盖

  • 通过调用 useReducer 请求 React 管理多个相关状态值

  • 将组件状态管理逻辑放在一个位置

  • 通过向 reducer 发送动作来更新状态和触发重新渲染

  • 使用初始化参数和初始化函数初始化状态

随着你的应用程序的增长,某些组件处理更多状态是很自然的,尤其是如果它们向多个子组件提供该状态的不同部分。当你发现你总是需要一起更新多个状态值,或者你的状态更新逻辑分布得太广,难以追踪时,可能就是时候定义一个函数来为你管理状态更新了:一个 reducer 函数。

一个简单、常见的例子是加载数据。比如说,一个组件需要加载在疫情期间被困在家时可以做的事情的博客帖子。你希望在请求新帖子时显示加载用户界面,如果出现问题则显示错误用户界面,当帖子到达时显示帖子本身。组件的状态包括以下值:

  • 加载状态——你正在加载新的帖子吗?

  • 任何错误——服务器返回了错误,或者网络是否已断开?

  • 帖子——检索到的帖子列表。

当组件请求帖子时,你可能将加载状态设置为 true,错误状态设置为 null,并将帖子设置为空数组。一个事件导致三个状态值的变化。当帖子返回时,你可能将加载状态设置为 false 并将帖子设置为返回的帖子。一个事件导致两个状态值的变化。你当然可以通过调用 useState 钩子来管理这些状态值,但是,当你总是用对多个更新函数的调用(例如 setIsLoadingsetErrorsetPosts)来响应事件时,React 提供了一个更干净的替代方案:useReducer 钩子。

在本章中,我们首先解决预订应用程序中 BookablesList 组件的问题:我们的状态管理有些问题。然后,我们介绍 reducer 和 useReducer 钩子作为管理我们状态的一种方式。3.3 节展示了如何使用函数初始化 reducer 的状态,当我们开始一个新的组件 WeekPicker 的工作时。我们以回顾 useReducer 钩子如何与我们对函数组件的理解相匹配来结束本章。

你能闻到那味道吗?空气中有一股明显的异味。有些东西被遗漏了,应该被整理一下。有些陈旧的。让我们清除那些分散注意力的声音!

3.1 对单个事件响应更新多个状态值

你可以随意多次调用useState,每次为 React 需要管理的每个状态片段调用一次。但一个组件可能需要持有许多状态值,而且通常这些状态片段是相关的;你可能希望对单个用户操作响应时更新多个状态片段。你不希望当它们应该被整理时,有些状态片段被忽视。

当用户从一个组切换到另一个组时,我们的BookablesList组件目前存在一个问题。这不是一个大问题,但在这个部分中,我们将讨论这个问题是什么,为什么它是问题,以及我们如何通过使用useState钩子来解决它。这为我们设置了 3.2 节中的useReducer钩子。

3.1.1 使用不可预测的状态变化将用户从电影中拉出来

我们不希望出现笨拙、不可预测的界面,阻碍用户完成任务。如果用户界面不断将他们的注意力从他们想要的焦点上拉走,或者让他们在没有反馈的情况下等待,或者将他们引向死胡同,他们的思维过程就会被打断,他们的工作会变得更加困难,他们的这一天就会被毁了。

这就像你在看电影时,突然的摄像机运动,或者疯狂的剪辑,或者明显的产品植入,或者艾德·希兰将你从故事中拉出来。你的思维链断了。你过度意识到这是一部电影,有些地方不太对劲。或者当你阅读一本编程书时,一个痛苦的比喻,勉强的幽默尝试,令人困惑的旁白,或者元幽默将你从解释中拉出来。你过度意识到你正在读一个绝望的作者,有些地方不太对劲。

好的,抱歉。回到房间。让我们看看一个例子。在前一章的 2.3 节末尾,我们诊断了我们的BookablesList组件 UI 中轻微的卡顿。用户可以选择一个组,然后从该组中选择一个可预订书籍。然后显示该可预订书籍的详细信息。但是,一些可预订书籍和组选择组合会导致 UI 更新有些不正常。如果你遵循这三个步骤,你应该会看到图 3.1 中显示的 UI 更新:

  1. 选择游戏室;然后显示其详细信息。

  2. 将组切换到 Kit。Kit 可预订书籍列表显示时没有选择任何可预订书籍,并且详细信息消失。

  3. 点击下一步按钮。Kit 的第二个项目,无线麦克风,被选中,并显示其详细信息。

图 3-1

图 3.1 选择一个可预订书籍,切换组,然后点击下一步按钮可能导致不可预测的状态变化。

从房间组切换到 Kit 组,组件似乎失去了跟踪哪个可预订书籍被选中。点击下一步按钮然后选择第二个项目,跳过了第一个。这不是一个大问题——用户仍然可以选择可预订书籍——但这可能足以让用户从他们的专注流程中跳出来。发生了什么?

结果表明,在我们状态中,所选的可预订项和所选的组并不是完全独立的值。当用户选择游戏室时,bookableIndex状态值被设置为 2;它是列表中的第三项。如果他们然后切换到只有两个项目(索引为 0 和 1)的套件组,bookableIndex值就不再与一个可预订项匹配。UI 最终没有选择任何可预订项,也没有显示任何详细信息。我们需要仔细考虑用户选择一个组后我们希望 UI 处于的状态。那么,我们如何修复我们的陈旧索引问题并平滑用户的路径?

3.1.2 保持用户在电影中,通过可预测的状态变化

为我们的同事构建预订应用,我们希望尽可能使其使用无摩擦。比如说,同事 Akiko 下周有客户来访。她正在为访问安排日程,需要在下午预订会议室,然后下班后预订游戏室。Akiko 的关注点在于她的任务:整理日程并为一次伟大的客户访问做准备。预订应用应该让她继续专注于她的任务。她应该想着,“我会预订那些房间,然后订购餐饮,”而不是“嗯,等等,哪个按钮?我点击了吗?它冻结了吗?啊,我讨厌电脑!”

这就像你在看电影时完全投入到一个角色的困境中。你不会注意到摄像机的移动和剪辑,因为它们有助于平滑地将你带入故事。你已经不再在电影院;你进入了电影的世界。技巧消失了,故事就是一切。或者当你读书时,它奇特但引人入胜的角色和推动性的情节把你带入叙事。几乎就像书消失了,你占据了角色的思想、感受、地点和行动。最终,你注意到自己,意识到你已经读了 100 页,天都快黑了……

好的,抱歉。回到房间。让我们回到例子。在用户选择一个组之后,我们希望 UI 处于一个可预测的状态。我们不希望突然取消选择和跳过的可预订项。一个简单且合理的方法是在用户选择新组时始终选择列表中的第一个可预订项,如图 3.2 所示。

图 3.2 选择可预订项,切换组,然后点击下一步按钮会导致可预测的状态变化。

groupbookableIndex状态值是相互关联的;当我们更改组时,我们也更改索引。在图 3.2 的第 2 步中,注意当切换组时,列表中的第一项(投影仪)会自动选中。下面的列表显示了changeGroup函数在设置新组时将bookableIndex设置为 0。

分支:0301-related-state,文件:/src/components/Bookables/BookablesList.js

列表 3.1 在更改组时自动选择可预订项

import {useState, Fragment} from "react"; 
import {bookables, sessions, days} from "../../static.json";
import {FaArrowRight} from "react-icons/fa";

export default function BookablesList () { 
  const [group, setGroup] = useState("Kit");
  const bookablesInGroup = bookables.filter(b => b.group === group);
  const [bookableIndex, setBookableIndex] = useState(0);
  const groups = [...new Set(bookables.map(b => b.group))];
  const bookable = bookablesInGroup[bookableIndex];
  const [hasDetails, setHasDetails] = useState(false);

  function changeGroup (event) {       ❶
    setGroup(event.target.value);      ❷
    setBookableIndex(0);               ❸
  }

  function nextBookable () {
    setBookableIndex(i => (i + 1) % bookablesInGroup.length);
  }

  return (
    <Fragment>
      <div>
        <select
          value={group}
          onChange={changeGroup}       ❹
        >
          {groups.map(g => <option value={g} key={g}>{g}</option>)}
        </select>

        <ul className="bookables items-list-nav">
          /* unchanged list UI */
        </ul>
        <p>
          /* unchanged button UI */
        </p>
      </div>

      {bookable && (
        <div className="bookable-details">
          /* unchanged bookable details UI */
        </div>
      )}
    </Fragment>
  );
}

❶ 创建一个处理函数来响应组选择。

❷ 更新组。

❸ 在新组中选择第一个可预订项。

❹ 将新函数指定为 onChange 处理程序。

每当组发生变化时,我们将可预订索引设置为零;当我们调用setGroup时,我们总是随后调用setBookableIndex

setGroup(newGroup);
setBookableIndex(0);

这是一个相关状态的简单例子。当组件开始变得复杂,由多个事件引起多个状态变化时,跟踪这些变化并确保所有相关状态值一起更新变得越来越困难。

当状态值以这种方式相互关联时,要么相互影响,要么经常一起改变,将状态更新逻辑移动到单个位置可能会有所帮助,而不是将执行更改的代码分散在事件处理函数中,无论是内联定义还是单独定义。React 通过useReducer钩子为我们提供了帮助,以管理这种状态更新逻辑的组合,我们将在下一节中查看该钩子。

3.2 使用 useReducer 管理更复杂的状态

就目前而言,BookablesList组件示例足够简单,你可以继续使用useState,并在changeGroup事件处理程序中调用每个状态片段的相应更新函数。但是,当你有多个相互关联的状态时,使用reducer可以使状态变化更容易理解和实现。在本节中,我们介绍了以下主题:

  • Reducer 帮助你以集中化、定义良好的方式管理状态变化,具有对状态执行操作的清晰动作。

  • Reducer 通过使用动作从上一个状态生成一个新的状态,这使得指定更复杂的更新变得更容易,这些更新可能涉及多个相互关联的状态。

  • React 提供了useReducer钩子,允许你的组件指定初始状态,访问当前状态,并派发动作以更新状态并触发重新渲染。

  • 派发定义良好的动作使跟踪状态变化和了解组件如何对不同事件响应的状态交互变得更加容易。

我们从 3.2.1 节开始,描述了一个 reducer 和一个简单的 reducer 示例,该 reducer 管理计数器的增加和减少。在 3.2.2 节中,我们为BookablesList组件构建了一个 reducer,执行切换组、选择可预订项和切换可预订详情等必要的状态变化。最后,在 3.2.3 节中,我们通过使用 React 的useReducer钩子将我们新铸造的 reducer 集成到BookablesList组件中。

3.2.1 使用预定义动作集的 reducer 更新状态

reducer是一个函数,它接受一个状态值和一个动作值。它根据传入的两个值生成一个新的状态值。然后,它返回新的状态值,如图 3.3 所示。

图 3-3

图 3.3 一个 reducer 接受一个状态和一个动作,并返回一个新的状态。

状态和动作可以是简单的、原始的值,如数字或字符串,或者更复杂的对象。使用还原器,你将所有更新状态的方式集中在一个地方,这使得管理状态变化变得更容易,尤其是在单个动作影响多个状态时。

在一个超级简单的例子之后,我们将回到BookablesList组件。比如说,你的状态只是一个计数器,你只能执行两种动作:增加计数器或减少计数器。下面的列表显示了一个管理此类计数器的还原器。count变量的值从 0 开始,变为 1,然后变为 2,然后又回到 1。

代码在 JS Bin 上:jsbin.com/capogug/edit?js,console

列表 3.2 一个简单的计数器还原器

let count = 0;

function reducer (state, action) {         ❶
  if (action === "inc") {                  ❷
    return state + 1;
  }
  if (action === "dec") {                  ❷
    return state - 1;
  }
  return state;                            ❸
}

count = reducer(count, "inc");             ❹
count = reducer(count, "inc");
count = reducer(count, "dec");             ❺

❶ 创建一个接受现有状态和动作的还原器函数。

❷ 检查指定的动作,并相应地更新状态。

❸ 处理缺失或不识别的动作。

❹ 使用还原器来增加计数器。

❺ 使用还原器来减少计数器。

还原器处理增量和减量动作,并且对于任何其他指定的动作,只返回未更改的计数。(而不是默默地忽略未识别的动作,你可以根据应用程序的需求和还原器所扮演的角色抛出一个错误。)

对于我们这两个小小的动作来说,这似乎有点过度,但有了还原器,扩展它就变得容易了。让我们再添加三个动作,用于将任意数字加到计数器上或从计数器上减去,以及将计数器设置为指定的值。为了能够用我们的动作指定额外值,我们需要稍微增强它——让我们将其制作成一个具有类型和有效载荷的对象。比如说,我们想将 3 加到计数器上;我们的动作看起来像这样:

{
  type: "add",
  payload: 3
}

下面的列表显示了具有额外功能和传递给还原器的动作的新还原器。count变量的值从 0 开始,变为 3,然后变为-7,接着变为 41,最后变为 42。

代码在 JS Bin 上:jsbin.com/kokumux/edit?js,console

列表 3.3 添加更多动作并指定额外值

let count = 0;

function reducer (state, action) {
  if (action.type === "inc") {                              ❶
    return state + 1;                                       ❶
  }                                                         ❶

  if (action.type === "dec") {                              ❶
    return state - 1;                                       ❶
  }                                                         ❶

  if (action.type === "add") {                              ❷
    return state + action.payload;                          ❷
  }                                                         ❷

  if (action.type === "sub") {                              ❷
    return state - action.payload;                          ❷
  }                                                         ❷

  if (action.type === "set") {                              ❷
    return action.payload;                                  ❷
  }                                                         ❷

  return state;
}

count = reducer(count, { type: "add", payload: 3 });        ❸
count = reducer(count, { type: "sub", payload: 10 });       ❸
count = reducer(count, { type: "set", payload: 41 });       ❸
count = reducer(count, { type: "inc" });                    ❸

❶ 现在检查两个原始动作的动作类型。

❷ 使用动作有效载荷来执行新的动作。

❸ 通过传递一个对象来指定每个动作。

列表 3.3 的最后一个还原器调用指定了增量动作。增量动作不需要任何额外信息。它总是将 1 加到count上,因此动作不包括有效载荷属性。

让我们在构建BookablesList组件的还原器时将这些关于状态和动作类型及有效载荷的想法付诸实践。然后我们可以看到如何利用 React 的帮助来使用该还原器来管理组件的状态。

3.2.2 为 BookablesList 组件构建还原器

BookablesList 组件有四项状态:groupbookableIndexhasDetailsbookables(从 static.json 导入)。该组件还有四个动作来执行该状态:设置组、设置索引、切换 hasDetails 和移动到下一个可预订项。为了管理四项状态,我们可以使用具有四个属性的对象。如图 3.4 所示,通常将状态和动作都表示为对象。

图片

图 3.4 将状态对象和动作对象传递给 reducer。reducer 根据动作类型和有效负载更新状态。reducer 返回新的、更新后的状态。

BookablesList 组件从静态的 static.json 文件中导入可预订项数据。当 BookablesList 组件挂载时,这些数据不会改变,并且我们将其包含在初始状态中,用于在 reducer 中查找每个组中的可预订项数量。

以下列表显示了使用对象作为状态和动作的 BookablesList 组件的 reducer。我们将其从其自己的文件 reducer.js 导出,位于 /src/components/Bookables 文件夹中。

分支:0302-reducer,文件:/src/components/Bookables/reducer.js

列表 3.4 BookablesList 组件的 reducer

export default function reducer (state, action) {
 switch (action.type) {                                    ❶❷

    case "SET_GROUP":                                       ❸
      return {
        ...state,
        group: action.payload,                              ❹
 bookableIndex: 0
      };

    case "SET_BOOKABLE":
      return {
        ...state,                                           ❺
        bookableIndex: action.payload
      };

    case "TOGGLE_HAS_DETAILS":
      return {
        ...state,
        hasDetails: !state.hasDetails                      ❻
      };

    case "NEXT_BOOKABLE":
      const count = state.bookables.filter(
 b => b.group === state.group
 ).length;                ❼

      return {
        ...state,
        bookableIndex: (state.bookableIndex + 1) % count   ❽
      };

    default:                                               ❾
      return state;
  }
}

❶ 使用 switch 语句组织每个动作类型的代码。

❷ 将动作类型指定为每个 case 的比较。

❸ 为每个动作类型创建一个 case 块。

❹ 更新组并将可预订项索引设置为 0。

❺ 使用扩展运算符复制现有的状态属性。

❻ 使用任何更改覆盖现有的状态属性。

❼ 计算当前组中的可预订项数量。

❽ 使用计数从最后一个索引到第一个索引进行包装。

❾ 总是包含一个默认情况。

每个 case 块返回一个新的 JavaScript 对象;前一个状态不会被修改。使用对象扩展运算符来复制旧状态中的属性到新状态。然后,在对象上设置需要更新的属性值,覆盖前一个状态中的值,如下所示:

return {
  ...state,                  ❶
  group: action.payload,     ❷
  bookableIndex: 0           ❷
};

❶ 将旧状态对象的属性扩展到新对象中。

❷ 覆盖任何需要更新的属性。

在我们的状态中总共只有四个属性,我们可以明确地设置它们:

return {
  group: action.payload,
  bookableIndex: 0,
  hasDetails: state.hasDetails,    ❶
  bookables: state.bookables       ❶
};

❶ 复制未更改属性的先前值。

使用扩展运算符可以保护代码在演变过程中的安全性;状态可能会在未来获得新的属性,并且它们都需要被复制过来。

注意到 SET_GROUP 动作更新了两个属性。除了更新要显示的组外,它还将选定的可预订项索引设置为 0。当切换到新组时,动作会自动选择第一个可预订项,并且只要组中至少有一个可预订项,如果已检查显示详情切换,组件将显示第一个可预订项的详情。

Reducer 还处理一个NEXT_BOOKABLE动作,从Bookables组件中移除了在从一个可预订项移动到下一个时计算索引的责任。这就是为什么在 reducer 的状态中包含可预订数据是有帮助的;我们使用组中可预订的数量来在增加bookableIndex时从最后一个可预订项包裹到第一个:

case "NEXT_BOOKABLE":
  const count = state.bookables.filter(                   ❶
    b => b.group === state.group
  ).length;

  return {
    ...state,
    bookableIndex: (state.bookableIndex + 1) % count      ❷
  };

❶ 使用可预订数据来计算当前组中的可预订数量。

❷ 使用取模运算符从最后一个索引包裹到第一个。

我们已经设置了一个 reducer,但如何将其整合到我们的组件中?我们如何访问状态对象并使用我们的动作调用 reducer?我们需要useReducer钩子。

3.2.3 使用 useReducer 访问组件状态和发送动作

useState钩子让我们请求 React 管理我们组件的单个值。使用useReducer钩子,我们可以通过传递 reducer 和组件的初始状态来给 React 提供更多帮助来管理值。当我们的应用程序中发生事件时,我们不是给 React 提供新值来设置,而是发送一个动作,React 使用 reducer 中相应的代码来生成一个新的状态,在调用组件以获取最新的 UI 之前。

在调用useReducer钩子时,我们传递给它 reducer 和一个初始状态。钩子返回当前状态和一个用于发送动作的函数,如图 3.5 所示的两个数组元素。

图 3.5 使用 reducer 调用useReducer。它返回当前状态和 dispatch 函数。使用 dispatch 函数向 reducer 发送动作。

正如我们使用useState一样,在这里使用useReducer时,我们使用数组解构将返回数组的两个元素分配给两个我们选择的变量名。第一个元素,当前状态,我们分配给一个我们称为state的变量,第二个元素,dispatch 函数,我们分配给一个我们称为dispatch的变量:

const [state, dispatch] = useReducer(reducer, initialState);

React 只关注第一次调用组件时传递给useReducer(在我们的情况下,reducerinitialState)的参数。在随后的调用中,它忽略这些参数,但仍然返回当前状态和 reducer 的 dispatch 函数。

让我们在BookablesList组件中使用useReducer钩子并开始发送一些动作!以下列表显示了更改。

分支:0302-reducer,文件:/src/components/Bookables/BookablesList.js

列表 3.5 使用 reducer 的BookablesList组件

import {useReducer, Fragment} from "react";                        ❶
import {bookables, sessions, days} from "../../static.json";
import {FaArrowRight} from "react-icons/fa";

import reducer from "./reducer";                                   ❷

const initialState = {                                             ❸
 group: "Rooms",                                                  ❸
 bookableIndex: 0,                                                ❸
 hasDetails: true,                                                ❸
 bookables                                                        ❸
};                                                                 ❸

export default function BookablesList () {
 const [state, dispatch] = useReducer(reducer, initialState);     ❹

 const {group, bookableIndex, bookables, hasDetails} = state;     ❺

  const bookablesInGroup = bookables.filter(b => b.group === group);
  const bookable = bookablesInGroup[bookableIndex];
  const groups = [...new Set(bookables.map(b => b.group))];

  function changeGroup (e) {
 dispatch({                                                     ❻
 type: "SET_GROUP",                                           ❻
 payload: e.target.value                                      ❻
 });                                                            ❻
 }

  function changeBookable (selectedIndex) {
 dispatch({
 type: "SET_BOOKABLE",
 payload: selectedIndex
 });
 } 

  function nextBookable () {
    dispatch({ type: "NEXT_BOOKABLE" });                           ❼
  }

  function toggleDetails () {
 dispatch({ type: "TOGGLE_HAS_DETAILS" });
 }

  return (
    <Fragment>
      <div>
        // group picker

        <ul className="bookables items-list-nav">
          {bookablesInGroup.map((b, i) => (
            <li
              key={b.id}
              className={i === bookableIndex ? "selected" : null}
            >
              <button
                className="btn"
                onClick={() => changeBookable(i)}                  ❽
              >
                {b.title}
              </button>
            </li>
          ))}
        </ul>

        // Next button
      </div>

      {bookable && (
        <div className="bookable-details">
          <div className="item">
            <div className="item-header">
              <h2>
                {bookable.title}
              </h2>
              <span className="controls">
                <label>
                  <input
                    type="checkbox"
                    checked={hasDetails}
                    onChange={toggleDetails}                     ❾
                  />
                  Show Details
                </label>
              </span>
            </div>
            <p>{bookable.notes}</p>
            {hasDetails && (
              <div className="item-details">
                // details
              </div>
            )}
          </div>
        </div>
      )}
    </Fragment>
  );
}

❶ 导入 useReducer 钩子。

❷ 从列表 3.4 中导入 reducer。

❸ 指定一个初始状态。

❹ 调用 useReducer,传递 reducer 和初始状态。

❺ 将状态值分配给局部变量。

❻ 发送一个带有类型和负载的动作。

❼ 发送一个不需要负载的动作。

❽ 调用新的 changeBookable 函数。

❾ 调用新的 toggleDetails 函数。

列表 3.5 导入了我们在列表 3.4 中创建的 reducer,设置了一个初始状态对象,然后在组件代码中,将 reducer 和初始状态传递给useReducer。接下来,useReducer返回当前状态和dispatch函数,我们使用数组解构将它们分配给变量statedispatch。该列表使用一个中间的state变量,然后将状态对象解构为单个变量——groupbookableIndexbookableshasDetails——但你也可以直接在数组解构中执行对象解构:

const [
  {group, bookableIndex, bookables, hasDetails},
  dispatch
] = useReducer(reducer, initialState);

在事件处理程序中,BookablesList组件现在发送动作而不是通过useState更新单个状态值。我们使用单独的事件处理函数(changeGroupchangeBookablenextBookabletoggleDetails),但你也可以在 UI 内轻松地内联发送动作。例如,你可以设置显示详情复选框如下:

 <label>
   <input
     type="checkbox"
     checked={hasDetails}
     onChange={() => dispatch({ type: "TOGGLE_HAS_DETAILS" })}
   />
   Show Details
 </label>

任何一种方法都可以,只要你觉得代码易于阅读和理解。

虽然示例很简单,但你应该欣赏 reducer 如何帮助你结构化代码、状态变更以及理解,尤其是当组件状态变得更加复杂时。如果你的状态复杂,或者初始状态设置成本很高,或者是由你希望重用或导入的函数生成的,useReducer钩子有一个第三个参数你可以使用。让我们来看看。

3.3 使用函数生成初始状态

你在第二章中看到,我们可以通过将函数传递给钩子来生成useState的初始状态。同样,对于useReducer,除了将初始化参数作为第二个参数外,我们还可以将初始化函数作为第三个参数。初始化函数使用初始化参数来生成初始状态,如图 3.6 所示。

图片

图 3.6 useReducer的初始化函数使用初始化参数来生成 reducer 的初始状态。

与往常一样,useReducer返回一个包含两个元素的数组:状态和dispatch函数。在第一次调用时,状态是初始化函数的返回值。在后续调用中,它是调用时的状态:

const [state, dispatch] = useReducer(reducer, initArgument, initFunction);

使用dispatch函数向 reducer 发送动作。对于useReducer的特定调用,React 将始终返回相同的dispatch函数。(当重新渲染可能依赖于变化的 props 或依赖项时,拥有一个不变的函数很重要,你将在后面的章节中看到。)

在本节中,我们开始为预订应用开发第二个组件,即WeekPicker组件,我们将工作分为五个小节:

  • 介绍WeekPicker组件

  • 创建用于处理日期和周的工作函数

  • 构建 reducer 来管理组件的日期

  • 创建 WeekPicker,将初始化函数传递给 useReducer 钩子

  • BookingsPage 更新为使用 WeekPicker

3.3.1 介绍 WeekPicker 组件

到目前为止,在预订应用中,我们一直专注于 BookablesList 组件,显示可预订项目的列表。为了为实际预订资源打下基础,我们需要开始考虑日历;在完成的应用中,我们的用户将从一个预订网格日历中选择日期和时段,如图 3.7 所示。

图 3.7 预订页面将包括可预订项目的列表、预订网格和周选择器。

让我们从简单开始,只考虑在一周和下一周之间切换的界面。图 3.8 显示了在预订网格中显示周的选择可能界面。它包括以下内容:

  • 选择周的开始和结束日期

  • 用于移动到下一周和上一周的按钮

  • 一个按钮用于显示包含今天日期的那一周

图 3.8 WeekPicker 组件显示了所选周的起始和结束日期,并具有在周之间导航的按钮。

在本书的后面部分,我们将添加一个直接跳转到特定日期的输入。现在,我们将坚持使用我们的三个按钮和周日期文本。为了获取指定周的起始和结束日期,我们需要几个实用函数来处理 JavaScript 的日期对象。让我们首先创建这些函数。

3.3.2 创建用于处理日期和周的实用函数

我们的预订网格将一次显示一周,从周日到周六。在特定日期,我们显示包含该日期的那一周。让我们创建代表一周的对象,包括一周中的特定日期以及一周的开始和结束日期:

week = {
  date,       ❶
  start,      ❷
  end         ❸
};

❶ 指定日期的 JavaScript Date 对象

❷ 包含特定日期的一周开始日期的日期对象

❸ 一周结束日期的日期对象

例如,考虑 2020 年 4 月 1 日星期三。一周的开始是 2020 年 3 月 29 日星期日,一周的结束是 2020 年 4 月 4 日星期六:

week = {
  date,   // 2020-04-01     ❶
  start,  // 2020-03-29     ❶
  end     // 2020-04-04     ❶
};

❶ 为指定的日期分配一个 JavaScript Date 对象。

下面的列表显示了几个实用函数:一个用于从旧日期创建新日期,偏移指定天数,另一个用于生成周对象。该文件名为 date-wrangler.js,位于新的/src/utils 文件夹中。

分支:0303-week-picker,文件:/src/utils/date-wrangler.js

列表 3.6 日期处理实用函数

export function addDays (date, daysToAdd) {
  const clone = new Date(date.getTime());
  clone.setDate(clone.getDate() + daysToAdd);        ❶
  return clone;
}

export function getWeek (forDate, daysOffset = 0) {
  const date = addDays(forDate, daysOffset);         ❷
  const day = date.getDay();                         ❸

  return {
    date,
    start: addDays(date, -day),                      ❹
    end: addDays(date, 6 - day)                      ❺
  };
}

❶ 将日期按指定天数移动。

❷ 立即移动日期。

❸ 获取新日期的天数索引,例如,星期二=2。

❹ 例如,如果今天是星期二,则后退 2 天。

❺ 例如,如果今天是星期二,则向前推进 4 天。

getWeek函数使用 JavaScript 的Date对象的getDay方法来获取指定日期的星期索引:星期天是 0,星期一是 1,...,星期六是 6。为了到达一周的开始,函数减去与日索引相同的日期数:对于星期天,它减去 0 天;对于星期一,它减去 1 天;...;对于星期六,它减去 6 天。一周的结束是一周开始后的 6 天,因此为了得到一周的结束,函数执行与一周开始相同的减法,但也要加上 6。我们可以使用getWeek函数为给定日期生成一个周对象:

const today = new Date();
const week = getWeek(today);     ❶

❶ 获取包含今天日期的周的周对象。

如果我们想获取相对于第一个参数中日期的日期的周对象,我们可以将偏移天数作为第二个参数指定:

const today = new Date();
const week = getWeek(today, 7);     ❶

❶ 获取包含今天日期一周的周对象。

getWeek函数让我们在预订应用中从一周导航到另一周时生成周对象。让我们在 reducer 中用它来做这件事。

3.3.3 为组件构建 reducer 以管理日期

Reducer 帮助我们集中管理WeekPicker组件的状态逻辑。在一个地方,我们可以看到所有可能的操作以及它们如何更新状态:

  • 通过将当前日期加上七天来移动到下个星期。

  • 通过从当前日期减去七天来移动到上一个星期。

  • 通过将当前日期设置为今天的日期来移动到今天。

  • 通过将当前日期设置为操作的负载来移动到指定的日期。

对于每个操作,reducer 返回一个与上一节中描述的周对象。虽然我们实际上只需要跟踪一个日期,但我们可能需要在某个时候生成周对象,并且将周对象生成与 reducer 一起抽象化对我来说似乎是合理的。您可以在以下列表中看到可能的状态变化如何转换为 reducer。我们将 weekReducer.js 文件放在 Bookings 文件夹中。

分支:0303-week-picker,文件:/src/components/Bookings/weekReducer.js

列表 3.7 WeekPicker的 reducer

import {getWeek} from "../../utils/date-wrangler";          ❶

export default function reducer (state, action) {
  switch (action.type) {
    case "NEXT_WEEK":
      return getWeek(state.date, 7);                        ❷
    case "PREV_WEEK":
      return getWeek(state.date, -7);                       ❸
    case "TODAY":
      return getWeek(new Date());                           ❹
    case "SET_DATE":
      return getWeek(new Date(action.payload));             ❺
    default:
      throw new Error(`Unknown action type: ${action.type}`)
  }
}

❶ 导入getWeek函数。

❷ 返回 7 天后的周对象。

❸ 返回 7 天前的周对象。

❹ 返回今天的周对象。

❺ 返回指定日期的周对象。

Reducer 导入getWeek函数以生成每个状态变化时的周对象。有getWeek函数可供导入意味着我们也可以在调用WeekPicker组件中的useReducer钩子时将其用作初始化函数。

3.3.4 将初始化函数传递给useReducer钩子

WeekPicker 组件允许用户从周切换到周,在公司预订资源。我们在前面的部分设置了 reducer;现在是时候使用它了。reducer 需要一个初始状态,一个周对象。下面的列表展示了我们如何使用 getWeek 函数从传递给 WeekPicker 作为属性的日期生成初始周对象。WeekPicker.js 文件也在 Bookings 文件夹中。

分支:0303-week-picker,文件:/src/components/Bookings/WeekPicker.js

列表 3.8 WeekPicker 组件

import {useReducer} from "react";
import reducer from "./weekReducer";
import {getWeek} from "../../utils/date-wrangler";                   ❶
import {FaChevronLeft, FaCalendarDay, FaChevronRight} from "react-icons/fa";

export default function WeekPicker ({date}) {                        ❷
  const [week, dispatch] = useReducer(reducer, date, getWeek);       ❸

  return (
    <div>
      <p className="date-picker">
        <button
          className="btn"
          onClick={() => dispatch({type: "PREV_WEEK"})}              ❹
        >
          <FaChevronLeft/>
          <span>Prev</span>
        </button>

        <button
          className="btn" 
          onClick={() => dispatch({type: "TODAY"})}                  ❹
        >
          <FaCalendarDay/>
          <span>Today</span>
        </button>
        <button
          className="btn"
          onClick={() => dispatch({type: "NEXT_WEEK"})}              ❹
        >
          <span>Next</span>
          <FaChevronRight/>
        </button>
      </p>
      <p>
        {week.start.toDateString()} - {week.end.toDateString()}      ❺
      </p>
    </div>
  );
}

❶ 导入 getWeek 日期处理函数。

❷ 接收初始日期作为属性。

❸ 生成初始状态,将日期传递给 getWeek。

❹ 向 reducer 发送动作以切换周。

❺ 使用当前状态来显示日期信息。

我们调用 useReducer 时,将指定的日期传递给 getWeek 函数。getWeek 函数返回一个设置为初始状态的周对象。我们将 useReducer 返回的状态分配给一个名为 week 的变量:

const [week, dispatch] = useReducer(reducer, date, getWeek);

除了让我们能够重用 getWeek 函数来生成状态(在 reducer 和 WeekPicker 组件中),初始化函数(useReducer 的第三个参数)还允许我们在 useReducer 的初始调用中仅运行一次昂贵的状态生成函数。

最后,一个新的组件!让我们将其连接到 BookingsPage

3.3.5 将 BookingsPage 更新为使用 WeekPicker

下面的列表展示了更新后的 BookingsPage 组件,它导入并渲染了 WeekPicker 组件。结果页面如图 3.9 所示。

分支:0303-week-picker,文件:/src/components/Bookings/BookingsPage.js

列表 3.9 使用 WeekPickerBookingsPage 组件

import WeekPicker from "./WeekPicker";        ❶

export default function BookingsPage () {
  return (
    <main className="bookings-page">
      <p>Bookings!</p>
      <WeekPicker date={new Date()}/>         ❷
    </main>
  );
}

❶ 导入 WeekPicker 组件。

❷ 在 UI 中包含 WeekPicker,并传递当前日期。

图 3.9 带有 WeekPicker 组件的 BookingsPage 组件

BookingsPage 将当前日期传递给 WeekPicker 组件。周选择器首先显示当前周的起始和结束日期,从周日到周六。尝试从周切换到周,然后点击 Today 按钮回到当前周。这是一个简单的组件,但有助于推动后续章节中的预订网格。它还提供了一个 useReducer 初始化函数参数的例子。

在本章正式的总结部分之前,让我们简要回顾一下我们遇到的一些关键概念,以加深你对函数组件和钩子的理解。

3.4 回顾一些 useReducer 概念

讨论中混入了一些术语,所以如果所有的动作、reducer 和 dispatch 函数让你感到头晕,表 3.1 用例子描述了这些术语。喘口气吧!

表 3.1 我们遇到的一些关键术语

图标 术语 描述 示例
图片 初始状态 组件首次运行时变量和属性的值 {``group: "Rooms",``bookableIndex: 0,``hasDetails: false``}
图片 动作 Reducer 使用的用于更新状态的信息 {``type: "SET_BOOKABLE",``payload: 1``}
图片 Reducer React 传递当前状态和动作的函数。根据动作创建新的状态。 (state, action) => {``// 检查动作``// 根据动作类型和``// 动作负载更新状态``// 返回新状态``};
图片 状态 执行特定点的变量和属性的值 {``group: "Rooms",``bookableIndex: 1,``hasDetails: false``}
图片 分发函数 用于向 reducer 分发动作的函数。用它来告诉 reducer 要采取什么动作。 dispatch({``type: "SET_BOOKABLE",``payload: 1``});

一旦我们通过调用useReducer将 reducer 和初始状态传递给 React,它就会为我们管理状态。我们只需分发动作,React 将根据接收到的动作使用 reducer 来更新状态。记住,我们的组件代码返回其 UI 的描述。更新了状态后,React 知道它可能需要更新 UI,因此它将再次调用我们的组件代码,当组件调用useReducer时,传递给它最新的状态和分发函数。为了强调组件的函数性,图 3.10 展示了 React 首次调用BookablesList组件时,用户通过选择组、选择可预订项或切换显示详细信息复选框来触发事件时的每个步骤。

表 3.2 列出了图 3.10 中的步骤,描述了正在发生的事情,并对每个步骤进行了简要讨论。

图片

图 3.10 使用useReducer时的关键时刻步骤

每次需要 UI 时,React 都会调用组件代码。组件函数运行至完成,并在执行过程中创建局部变量,当函数结束时,这些变量会被销毁或在闭包中被引用。函数返回组件的 UI 描述。组件使用钩子,如useStateuseReducer,来在调用之间保持状态,并接收更新器和分发函数。事件处理程序在用户操作时调用更新器函数或分发动作,React 可以更新状态并再次调用组件代码,重新开始循环。

表 3.2 使用useReducer时的关键步骤

步骤 发生了什么? 讨论
1 React 调用组件。 为了生成页面的 UI,React 遍历组件树,调用每个组件。React 将传递给每个组件任何在 JSX 中设置的属性作为 props。
2 组件第一次调用 useReducer 组件将初始状态和 reducer 传递给 useReducer 函数。React 将 reducer 的当前状态设置为初始状态。
3 React 返回当前状态和 dispatch 函数作为一个数组。 组件代码将状态和 dispatch 函数分配给变量以供后续使用。这些变量通常被称为 statedispatch,或者我们可能将状态进一步解构为其他变量。
4 组件设置一个事件处理器。 事件处理器可能监听用户点击、计时器触发或资源加载等。处理器将派发一个动作来改变状态。
5 组件返回其 UI。 组件使用当前状态来生成其用户界面并返回它,完成其工作。React 将新 UI 与旧 UI 进行比较并更新 DOM。
6 事件处理器派发一个动作。 发生了一个事件,处理器运行。处理器使用 dispatch 函数来派发一个动作。
7 React 调用 reducer。 React 将当前状态和已派发的动作传递给 reducer。
8 reducer 返回新的状态。 reducer 使用动作来更新状态并返回新版本。
9 React 调用组件。 React 知道状态已更改,因此必须重新计算 UI。
10 组件第二次调用 useReducer 这次,React 将忽略这些参数。
11 React 返回当前状态和 dispatch 函数。 状态已被 reducer 更新,组件需要最新的值。dispatch 函数与 React 在前一次 useReducer 调用返回的函数完全相同。
12 组件设置一个事件处理器。 这是一个新版本的事件处理器,可能使用一些新更新的状态值。
13 组件返回其 UI。 组件使用当前状态来生成其用户界面并返回它,完成其工作。React 将新 UI 与旧 UI 进行比较并更新 DOM。

摘要

  • 如果你有多块相互关联的状态,考虑使用 reducer 来清晰地定义可以改变状态的动作。reducer 是一个函数,你向它传递当前状态和动作。它使用动作来生成新状态。它返回新状态:

    function reducer (state, action) {
      // use the action to generate a new state from the old state.
      // return newState.
    }
    
  • 当你想要 React 为组件管理状态和 reducer 时,调用 useReducer 钩子。传递给它 reducer 和初始状态。它返回一个包含两个元素的数组,即状态和 dispatch 函数:

    const [state, dispatch] = useReducer(reducer, initialState);
    
  • 使用带有初始化参数和初始化函数的useReducer钩子来生成初始状态,当钩子首次被调用时。钩子会自动将初始化参数传递给初始化函数。初始化函数返回 reducer 的初始状态。这在初始化成本高昂或您想使用现有函数初始化状态时很有用:

    const [state, dispatch] = useReducer(reducer, initArg, initFunc);
    
  • 使用dispatch函数派发一个动作。React 会将当前状态和动作传递给 reducer。如果状态已更改,它将用 reducer 生成的新状态替换状态,并重新渲染:

    dispatch(action);
    
  • 对于比最基本动作更复杂的情况,考虑遵循常见做法,并将动作指定为具有typepayload属性的 JavaScript 对象:

    dispatch({ type: "SET_NAME", payload: "Jamal" });
    
  • React 总是为组件中特定调用useReducer返回相同的dispatch函数。(如果dispatch函数在调用之间发生变化,当作为 prop 传递或作为其他钩子的依赖项包含时,可能会引起不必要的重新渲染。)

  • 在 reducer 中,使用ifswitch语句检查派发的动作类型:

    function reducer (state, action) {
      switch (action.type) {
        case "SET_NAME":
          return {
            ...state,
            name: action.payload
          }
        default:
          return state;
          // or return new Error(`Unknown action type: ${action.type}`)
      }
    }
    

    默认情况下,要么返回未更改的状态(如果 reducer 将与其他 reducer 组合,例如)或者抛出错误(如果 reducer 不应该接收未知动作类型)。

4 处理副作用

本章涵盖了

  • 在组件中识别副作用的类型

  • 使用 useEffect 钩子包裹副作用

  • 通过指定依赖项列表来控制效果何时运行

  • 从效果中返回一个清理函数

  • 使用效果为组件获取数据

React 将我们的数据转换为 UI。每个组件都扮演自己的角色,将其对整体用户界面的贡献返回。React 构建元素树,将其与已渲染的内容进行比较,并将任何必要的更改提交到 DOM。当状态发生变化时,React 会再次执行此过程以更新 UI。React 在高效地决定应该更新什么以及安排任何更改方面做得非常好。

然而,有时我们需要我们的组件超出这个数据流过程,并直接与其他 API 交互。以某种方式影响外部世界的行为称为 副作用。常见的副作用包括以下内容:

  • 命令式设置页面标题

  • setIntervalsetTimeout 等定时器一起工作

  • 测量 DOM 中元素的宽度、高度或位置

  • 将消息记录到控制台或其他服务

  • 在本地存储中设置或获取值

  • 获取数据或订阅和取消订阅服务

无论我们的组件试图实现什么,它们简单地忽略 React 并盲目地执行任务都是一种风险。更好的做法是请求 React 的帮助,有效地安排这些副作用,考虑它们何时以及多久运行一次,即使 React 在渲染每个组件并将更改提交到屏幕的过程中也在工作。React 提供了 useEffect 钩子,以便我们更好地控制副作用并将它们集成到组件的生命周期中。

在本章中,我们深入了解 useEffect 钩子的工作原理。我们从第 4.1 节开始,尝试一些简单的示例,突出调用钩子、控制其运行时机以及指定在组件卸载时清理任何效果的方法。在第 4.2 节中,我们在预订应用程序示例中设置一个简单的服务器用于数据,并创建组件来练习获取这些数据。最后,在第 4.3 节中,我们将预订应用程序从导入数据库文件切换到从服务器获取数据。

useEffect 钩子是我们与外部世界安全交互的门户。让我们踏上这条道路的第一步。

4.1 使用简单示例探索 useEffect API

我们的一些 React 组件非常友好,它们会主动向 React 之外的 API 和服务打招呼。尽管这些组件永远乐观,喜欢对所有遇到的人抱最好的期望,但还有一些安全措施需要遵循。在本节中,我们探讨以不会失控的方式设置副作用。特别是,我们探索以下四种场景:

  • 在每次渲染后运行副作用

  • 仅在组件挂载时运行效果

  • 通过返回一个函数来清理副作用

  • 通过指定依赖项来控制效果运行的时间

为了专注于 API,我们将创建一些超级简单的组件示例,而不是直接跳入预订应用作为上下文。首先,让我们说,“Bonjour, les side-effects。”

4.1.1 在每次渲染后运行副作用

假设你想在浏览器页面的标题中添加一个随机的问候语。点击你友好的组件的“说你好”按钮应该生成一个新的问候语并更新标题。图 4.1 显示了三个这样的问候语。

图片

图 4.1 点击“说你好”按钮使用随机的问候语更新页面标题。

文档标题不是文档正文的一部分,并且不会被 React 渲染。但是标题可以通过窗口的document属性访问。你可以这样设置标题:

document.title = "Bonjour";

以这种方式访问浏览器 API 被认为是副作用。我们可以通过将代码包裹在useEffect钩子中来使其明确:

useEffect(() => {
  document.title = "Bonjour";
});

下面的列表显示了一个SayHello组件,当用户点击“说你好”按钮时,它会使用随机的问候语更新页面标题。

直播: jhijd.csb.app, 代码: codesandbox.io/s/sayhello-jhijd

列表 4.1 更新浏览器标题

import React, { useState, useEffect } from "react";      ❶

export default function SayHello () {
  const greetings = ["Hello", "Ciao", "Hola", "こんにちは"];

  const [index, setIndex] = useState(0);

  useEffect(() => {                                      ❷
    document.title = greetings[index];                   ❸
  });

  function updateGreeting () {
    setIndex(Math.floor(Math.random() * greetings.length));
  }

  return <button onClick={updateGreeting}>Say Hi</button>
}

❶ 导入useEffect钩子。

❷ 将函数,即效果,传递给useEffect钩子。

❸ 在效果内部更新浏览器标题。

组件使用随机生成的索引从数组中选择一个问候语。每当updateGreeting函数调用setIndex时,React 会重新渲染组件(除非索引值没有变化)。

React 在每次渲染后,在浏览器重新绘制页面后,在useEffect钩子内运行效果函数,以更新页面标题。注意,效果函数可以访问组件内的变量,因为它处于相同的范围。特别是,它使用了greetingsindex变量的值。图 4.2 显示了如何将效果函数作为第一个参数传递给useEffect钩子。

图片

图 4.2 将效果函数传递给useEffect钩子

当你以这种方式调用useEffect钩子时,没有第二个参数,React 会在每次渲染后运行效果。但如果你只想在组件挂载时运行一个效果怎么办?

4.1.2 仅在组件挂载时运行效果

假设你想使用浏览器窗口的宽度和高度,可能用于一个酷炫的动画效果。为了测试读取尺寸,你创建了一个小组件来显示当前的宽度和高度,就像图 4.3 所示。

图片

图 4.3 显示窗口的宽度和高度,当它被调整大小时

下面的列表显示了组件的代码。它尝试读取window对象的innerWidthinnerHeight属性,因此,我们再次使用useEffect钩子。

实时预览: gn80v.csb.app/代码: codesandbox.io/s/windowsize-gn80v

列表 4.2 调整窗口大小

import React, { useState, useEffect } from "react";

export default function WindowSize () {
  const [size, setSize] = useState(getSize());

  function getSize () {                                  ❶
    return {
      width: window.innerWidth,                          ❷
      height: window.innerHeight                         ❷
    };
  }

  useEffect(() => {
    function handleResize () {
      setSize(getSize());                                ❸
    }

    window.addEventListener('resize', handleResize);     ❹
  }, []);                                                ❺

  return <p>Width: {size.width}, Height: {size.height}</p>
}

❶ 定义一个返回窗口尺寸的函数。

❷ 从窗口对象中读取尺寸。

❸ 更新状态,触发重新渲染。

❹ 注册调整大小事件的监听器。

❺ 将空数组作为依赖参数传递。

useEffect内部,组件注册了一个用于调整大小事件的监听器:

window.addEventListener('resize', handleResize);

当用户调整浏览器窗口大小时,handleResize处理程序通过调用setSize来更新状态,以获取新的尺寸:

function handleResize () {
  setSize(getSize());
}

通过调用更新器函数,组件会启动重新渲染。我们不希望每次 React 调用组件时都重新注册事件监听器。那么我们如何防止效果在每次渲染后都运行呢?秘诀是将空数组作为useEffect的第二个参数传递,如图 4.4 所示。

图 4.4 通过传递空依赖项数组,使效果函数在组件挂载时运行一次。

正如我们在 4.1.4 节中看到的,第二个参数是用于依赖项列表的。React 通过检查列表中的值自上次组件调用效果以来是否已更改来确定是否运行效果。通过将列表设置为空数组,列表将永远不会更改,我们导致效果仅在组件首次挂载时运行一次。

但是等等;警钟应该已经响起。我们注册了一个事件监听器……我们不应该让那个监听器一直监听,就像一个僵尸在墓穴中永远蹒跚而行。我们需要进行一些清理并注销监听器。让我们驯服那些僵尸。

4.1.3 通过返回函数清理副作用

我们在设置长时间运行的副作用,如订阅、数据请求、计时器和事件监听器时必须小心,以免弄乱。为了避免僵尸吞噬我们的大脑,我们的记忆开始泄漏,或者幽灵意外地移动家具,我们应该仔细撤销可能引起我们行动的幽灵般回声的任何效果。

useEffect钩子包含一个简单的清理我们效果的机制。只需从效果中返回一个函数。React 在需要整理时运行返回的函数。以下列表更新了我们的窗口测量应用程序,以便在不再需要时移除调整大小监听器。

实时预览: b8wii.csb.app/代码: codesandbox.io/s/windowsizecleanup-b8wii

列表 4.3 返回清理函数以移除监听器

import React, { useState, useEffect } from "react";

export default function WindowSize () {
  const [size, setSize] = useState(getSize());

  function getSize () {
    return {
      width: window.innerWidth,
      height: window.innerHeight
    };
  }

  useEffect(() => {
    function handleResize () {
      setSize(getSize());
    }

    window.addEventListener('resize', handleResize);

    return () => window.removeEventListener('resize', handleResize);    ❶
  }, []);

  return <p>Width: {size.width}, Height: {size.height}</p>
}

❶ 从效果中返回一个清理函数。

因为代码将空数组作为第二个参数传递给 useEffect,所以效果只会运行一次。当效果运行时,它会注册一个事件监听器。React 保留效果返回的函数,并在需要清理时调用它。在列表 4.3 中,返回的函数移除了事件监听器。我们的内存不会泄漏。我们的思维不会受到僵尸效果的影响。

图 4.5 展示了我们对 useEffect 钩子最新了解的这一步:返回一个清理函数。

图 4.5 从效果中返回一个函数。React 将运行该函数来清理效果。

因为清理函数是在效果内部定义的,所以它可以访问效果作用域内的变量。在列表 4.3 中,清理函数可以移除 handleResize 函数,因为 handleResize 也是在同一个效果内部定义的:

useEffect(() => {
  function handleResize () {                                           ❶
    setSize(getSize());
  }

  window.addEventListener('resize', handleResize);

  return () => window.removeEventListener('resize', handleResize);     ❷
}, []);

❶ 定义 handleResize 函数。

❷ 在清理函数中引用 handleResize 函数。

React Hooks 方法,其中组件和钩子只是函数,很好地利用了 JavaScript 的固有特性,而不是过度依赖与底层语言概念上分离的特异 API 层。但这确实意味着,你需要很好地掌握作用域和闭包,以最好地理解在哪里放置你的变量和函数。

当 React 卸载组件时,它会运行清理函数。但这并不是它运行清理函数的唯一时间。每当组件重新渲染时,React 都会在运行效果函数之前调用清理函数,如果效果需要再次运行的话。如果有多个效果需要再次运行,React 会调用那些效果的清理函数。清理完成后,React 会根据需要重新运行效果函数。

我们已经看到了两种极端情况:只在第一次运行效果和每次渲染后都运行效果。如果我们想要对效果运行的时间有更多的控制呢?还有一个情况需要考虑。让我们填充那个依赖数组。

4.1.4 通过指定依赖项来控制效果运行的时间

图 4.6 是我们对 useEffect API 的最终说明,包括作为第二个参数传递的数组中的依赖值。

图 4.6 当调用 useEffect 时,你可以指定一个依赖项列表并返回一个清理函数。

每次 React 调用一个组件时,它都会记录 useEffect 调用的依赖数组中的值。如果自上次调用以来值数组已更改,React 会运行效果。如果值未更改,React 会跳过效果。这可以防止效果在它依赖的值未更改且任务结果将保持不变时运行。

让我们看看一个例子。假设你有一个用户选择器,允许你从下拉菜单中选择一个用户。你想要将选定的用户存储在浏览器的本地存储中,以便页面能够记住每次访问时选定的用户,如图 4.7 所示。

图 4.7 一旦选择了一个用户,刷新页面会自动重新选择相同的用户。

以下列表显示了实现所需效果的代码。它包括对useEffect的两个调用,一个用于从本地存储获取任何存储的用户,另一个用于在值更改时保存所选用户。

实时: c987h.csb.app/代码: codesandbox.io/s/userstorage-c987h

列表 4.4 使用本地存储

import React, { useState, useEffect } from "react";

export default function UserStorage () {
  const [user, setUser] = useState("Sanjiv");

  useEffect(() => {
    const storedUser = window.localStorage.getItem("user");   ❶

    if (storedUser) {
      setUser(storedUser);
    }
  }, []);                                                     ❷

  useEffect(() => {                                           ❸
    window.localStorage.setItem("user", user);                ❹
  }, [user]);                                                 ❺

  return (
    <select value={user} onChange={e => setUser(e.target.value)}>
      <option>Jason</option>
      <option>Akiko</option>
      <option>Clarisse</option>
      <option>Sanjiv</option>
    </select>
  );
}

❶ 从本地存储读取用户。

❷ 仅当组件首次挂载时运行此效果。

❸ 指定第二个效果。

❹ 将用户保存到本地存储。

❺ 当用户更改时运行此效果。

组件按预期工作,将更改保存到本地存储,并在页面重新加载时自动选择保存的用户。

但为了更好地理解函数组件及其钩子如何管理所有这些部分,让我们按照组件渲染和重新渲染的步骤以及页面访问者从列表中选择用户的步骤来运行。我们关注两个关键场景:

  1. 访问者首先加载页面。本地存储中没有用户值。访问者从列表中选择一个用户。

  2. 访问者刷新页面。本地存储中有一个用户值。

在我们通过步骤进行时,注意两个效果依赖列表如何确定效果函数的运行时机。

访问者首先加载页面

当组件首次运行时,它渲染带有 Sanjiv 选择的用户下拉列表。然后第一个效果运行。本地存储中没有用户,所以没有发生任何事情。然后第二个效果运行。它将 Sanjiv 保存到本地存储。以下是步骤:

  1. 用户加载页面。

  2. React 调用组件。

  3. useState调用将user的值设置为Sanjiv。(这是组件第一次调用useState,所以使用初始值。)

  4. React 渲染带有 Sanjiv 选择的用户列表。

  5. 效果 1 运行,但没有存储的用户。

  6. 效果 2 运行,将Sanjiv保存到本地存储。

React 按照组件代码中出现的顺序调用效果函数。当效果运行时,React 会记录依赖列表中的值,在这个例子中是[]["Sanjiv"]

当访问者选择一个新的用户(比如 Akiko)时,onChange处理程序调用setUser更新函数。React 更新状态并再次调用组件。这次,效果 1 没有运行,因为它的依赖列表没有变化;它仍然是[]。但效果 2 的依赖列表已从["Sanjiv"]变为["Akiko"],所以效果 2 再次运行,更新本地存储中的值。以下步骤继续:

  1. 用户选择 Akiko。

  2. 更新函数将用户状态设置为 Akiko。

  3. React 调用组件。

  4. useState调用将user的值设置为 Akiko。(这是组件第二次调用useState,所以使用第 8 步设置的最新值。)

  5. React 渲染带有 Akiko 选择的用户列表。

  6. Effect 1 不会运行([] = [])。

  7. Effect 2 运行(["Sanjiv"] != ["Akiko"]),将 Akiko 保存到本地存储。

访问者刷新页面

当本地存储设置为 Akiko 时,如果用户重新加载页面,effect 1 将用户状态设置为存储的值,Akiko,正如我们在图 4.7 中所看到的。但在 React 调用组件的新状态值之前,effect 2 仍然需要使用旧值运行。以下是步骤:

  1. 用户刷新页面。

  2. React 调用组件。

  3. useState 调用将 user 的值设置为 Sanjiv。(这是组件第一次调用 useState,因此使用初始值。)

  4. React 渲染带有 Sanjiv 选择的用户列表。

  5. Effect 1 运行,从本地存储中加载 Akiko 并调用 setUser

  6. Effect 2 运行,将 Sanjiv 保存到本地存储。

  7. React 调用组件(因为 effect 1 调用了 setUser,改变了状态)。

  8. useState 调用将 user 的值设置为 Akiko

  9. React 渲染带有 Akiko 选择的用户列表。

  10. Effect 1 不会运行([] = [])。

  11. Effect 2 运行(["Sanjiv"] != ["Akiko"]),将 Akiko 保存到本地存储。

在步骤 6 中,effect 2 被定义为初始渲染的一部分,因此它仍然使用初始的 user 值,Sanjiv

通过将 user 包含在 effect 2 的依赖项列表中,我们能够控制 effect 2 的运行时机:只有当 user 的值发生变化时才会运行。

4.1.5 总结调用 useEffect 钩子的方式

表 4.1 收集了 useEffect 钩子的各种用例到一个地方,展示了不同的代码模式如何导致不同的执行模式。

表 4.1 useEffect 钩子的各种用例

调用模式 代码模式 执行模式
没有第二个参数 useEffect(() => {``// 执行效果``}); 在每次渲染后运行。
作为第二个参数的空数组 useEffect(() => {``// 执行效果``}, []); 只运行一次,当组件挂载时。
作为第二个参数的依赖数组 useEffect(() => {``// 执行效果``// 使用 dep1 和 dep2``}, [dep1, dep2]); 当依赖数组中的任何值发生变化时运行。
返回一个函数 useEffect(() => {``// 执行效果``return () => {/* 清理 */};``}, [dep1, dep2]); React 将在组件卸载时运行清理函数,并在重新运行效果之前。

挑战 4.1

在 CodeSandbox(或你喜欢的任何地方),创建一个应用,当窗口大小调整时更新文档标题。它应该显示“小”、“中等”或“大”,具体取决于窗口的大小。

4.1.6 在浏览器重绘之前调用 useLayoutEffect 来运行效果

大多数时候,我们通过调用 useEffect 来同步副作用和状态。React 在组件渲染后和浏览器重绘屏幕之前运行效果。偶尔,我们可能在 React 更新 DOM 但浏览器尚未重绘之前想要对状态进行进一步的更改。我们可能想以某种方式使用 DOM 元素的尺寸来设置状态,例如。在 useEffect 中进行更改将使用户看到中间状态,该状态将立即更新。

我们可以通过调用 useLayoutEffect 钩子而不是 useEffect 来避免这种状态变化的闪烁。这个钩子具有与 useEffect 相同的 API,但在 React 更新 DOM 和浏览器重绘之前同步运行。如果效果对状态进行了进一步的更新,中间状态不会被绘制到屏幕上。通常你不需要 useLayoutEffect,但如果遇到问题(可能是一个元素在状态之间闪烁),你可以尝试将 useEffect 更换为有问题的效果。

既然我们已经看到了 useEffect 钩子能做什么,是时候获取一些数据了。让我们让我们的应用数据通过服务器而不是文件导入的方式变得可用。

4.2 获取数据

到目前为止,在这本书中,我们一直在从 static.json 文件导入预订应用示例的数据。但通常情况下,我们会从服务器获取数据。为了让我们的示例更加真实,让我们开始这样做。我们不会连接到公共服务器,而是在本地运行一个 JSON 服务器,使用位于 src 文件夹外的新的 db.json 文件。然后我们将创建一个从该服务器获取数据的组件。我们将涵盖以下内容:

  • 创建新的 db.json 文件

  • 使用 json-server 包设置 JSON 服务器

  • 构建一个组件从我们的服务器获取数据,显示用户列表

  • 在效果中使用 asyncawait 时要注意

4.2.1 创建新的 db.json 文件

在第二章和第三章中,我们从 static.json 文件中导入数据。对于我们的服务器,将预订、用户和可预订数据复制到项目根目录下的新的 db.json 文件中。不要复制 static.json 中的 dayssessions 数组;我们将这些视为配置信息,并继续导入。(在我们更新了当前正在使用它的组件之后,我们将从 static.json 中删除重复的数据。)

// db.json
{
  bookings: [/* empty */],
  users: [/* user objects */],
  bookables: [/* bookable objects */]
}

// static.json
{
  days: [/* names of days */],
  sessions: [/* session names */]
}

在后面的章节中,我们将开始通过发送 POST 和 PUT 请求来更新数据库文件。create-react-app 开发服务器在 src 文件夹内的文件更改时重启。将 db.json 文件放在 src 文件夹之外可以避免在测试添加新的可预订项目和进行预订时发生不必要的重启。

4.2.2 设置 JSON 服务器

到目前为止,我们一直从 JSON 文件 static.json 中导入 BookablesListUsersListUserPicker 组件的数据:

import {bookables} from "../../static.json";
import {users} from "../../static.json";

为了更好地展示我们在实际应用中执行的数据获取任务,我们希望通过 HTTP 提供我们的数据。幸运的是,我们不需要为我们的数据启动一个真实的数据库。我们可以使用 json-server npm 包。这个包是一个非常方便、简单的方式来提供 JSON 数据作为模拟 REST API。在 github.com/typicode/json-server 有一个用户指南,您可以查看这个包的灵活性。要使用 npm 全局安装此包,请输入以下命令:

npm install -g json-server

然后,从我们项目的根目录开始,使用以下命令启动服务器:

json-server --watch db.json --port 3001

您应该能够在 localhost:3001 上查询我们的数据库。图 4.8 显示了我启动服务器时机器上的终端输出。

图 4.8 运行 json-server 的输出。db.json 文件中的属性已被转换为可获取资源的端点。

我们已经通过 URL 端点将 db.json 文件中的 JSON 数据公开。将文件中的数据与图 4.8 进行比较,您可以看到服务器已经将 JSON 对象的每个属性转换为端点。例如,要获取用户列表,请导航到 localhost:3001/users;要获取 ID 为 1 的用户,请导航到 localhost:3001/users/1。太棒了!

您可以在浏览器中测试这些请求。前面提到的两个请求的结果如图 4.9 所示:首先是数组中的用户对象列表,其次是具有 ID 为 1 的用户对象。

图 4.9 显示两个浏览器响应,我们的预订应用数据现在可以通过 HTTP 获取

让我们尝试我们的服务器,并从 useEffect 钩子中获取一些数据。

4.2.3 在 useEffect 钩子中获取数据

为了介绍从 useEffect 钩子中进行数据获取,我们更新了 UserPicker 组件,从我们的 JSON 数据库中获取用户。图 4.10 显示了包含四个用户的展开下拉列表。

图 4.10 显示从数据库中获取的用户列表

记住,React 在渲染后调用 effect 函数,因此数据在第一次渲染时不可用;我们将用户列表的空列表设置为初始值,并返回替代 UI,一个新的 Spinner 组件,用于加载状态。以下列表显示了获取用户列表并将其显示在下拉列表中的代码。

分支:0401-user-picker,文件:/src/components/Users/UserPicker.js

列表 4.5 UserPicker 组件获取数据

import {useState, useEffect} from "react"; 
import Spinner from "../UI/Spinner";

export default function UserPicker () {
  const [users, setUsers] = useState(null);

 useEffect(() => {                          ❶

    fetch("http://localhost:3001/users")     ❷
      .then(resp => resp.json())             ❸
      .then(data => setUsers(data));         ❹

  }, []);                                    ❺

  if (users === null) {
    return <Spinner/>                        ❻
  }

  return (
    <select>
      {users.map(u => (
        <option key={u.id}>{u.name}</option>
      ))}
    </select>
  );
}

❶ 在 effect 函数内部获取数据。

❷ 使用浏览器的 fetch API 向数据库发送请求。

❸ 将返回的 JSON 字符串转换为 JavaScript 对象。

❹ 更新状态以包含已加载的用户。

❺ 包含一个空的依赖数组,以便在组件首次挂载时加载数据。

❻ 在用户加载时返回替代 UI。

UserPicker 代码使用浏览器的 fetch API 从数据库中检索用户列表,通过使用 resp.json 方法将响应解析为 JSON,并调用 setUsers 以更新本地状态。组件最初渲染一个 Spinner 占位符(来自存储库中新的 /src/components/UI 文件夹),然后将其替换为用户列表。如果您想向 fetch 调用添加延迟,以更好地查看任何加载状态,请使用带有 delay 标志启动 JSON 服务器。此代码片段将响应延迟 3000 毫秒,即 3 秒:

json-server --watch db.json --port 3001 --delay 3000

列表 4.5 中的效果只在其组件挂载时运行一次。我们预计用户列表不会改变,因此不需要管理列表的重新加载。以下列表显示了以这种方式从效果中获取数据的步骤:

  1. React 调用组件。

  2. useState 调用将 users 变量设置为 null

  3. useEffect 调用将数据获取效果函数注册到 React 中。

  4. users 变量是 null,因此组件返回旋转图标。

  5. React 运行效果,从服务器请求数据。

  6. 数据到达,效果调用 setUsers 更新器函数,触发重新渲染。

  7. React 调用组件。

  8. useState 调用将 users 变量设置为返回的用户列表。

  9. useEffect 的空依赖数组 [] 未改变,因此钩子调用不会重新注册效果。

  10. users 数组有四个元素(它不是 null),因此组件返回下拉 UI。

这种在组件渲染之前启动数据请求的获取数据方法被称为 渲染时获取。其他方法有时可以为用户提供更平滑的体验,我们将在第二部分中查看其中一些。但根据数据源复杂性和稳定性以及应用程序需求,在 useEffect 钩子调用中获取数据的简单性可能完全足够,并且非常吸引人。

挑战 4.2

更新 UsersPage 上的 UsersList 组件以从服务器获取用户数据。0402-users-list 分支包含更新组件的挑战解决方案代码。

4.2.4 使用 async 和 await

列表 4.5 中的 fetch 调用返回一个承诺,列表使用承诺的 then 方法来处理响应:

fetch("http://localhost:3001/users")
  .then(resp => resp.json())
  .then(data => setUsers(data)); 

JavaScript 还提供了 async 函数和 await 关键字来处理异步响应,但与 useEffect 钩子结合使用时有一些注意事项。作为将我们的数据获取转换为 async-await 的初始尝试,我们可能会尝试这样做:

useEffect(async () => {
  const resp = await fetch("http://localhost:3001/users");
  const data = await (resp.json());
  setUsers(data);
}, []);

但这种方法会触发 React 在控制台显示警告,如图 4.11 所示。

图 4.11 我们使用 async-await 的数据获取效果导致 React 发布了一些警告。

浏览器传来的关键信息如下:

  • 效果回调是同步的,以防止竞争条件。将异步函数放在里面。

async 函数默认返回一个承诺。将效果函数设置为 async 会导致问题,因为 React 正在寻找效果返回值应该是一个清理函数。为了解决问题,请记住将 async 函数放在效果函数内部,而不是使效果函数本身 async

useEffect(() => {
  async function getUsers() {           ❶
    const resp = await fetch(url);      ❷
    const data = await (resp.json());   ❷
    setUsers(data);
  }
 getUsers();                           ❸
}, []);

❶ 定义一个异步函数。

❷ 等待异步结果。

❸ 调用异步函数。

现在我们已经设置了 JSON 服务器,尝试了使用 useEffect 钩子的 fetch-on-render 数据获取方法的示例,并花了一点时间考虑 async-await 语法,我们准备更新预订应用程序以获取 BookablesList 组件的数据。

4.3 为 BookablesList 组件获取数据

在前面的章节中,我们看到了一个组件如何在初始渲染后通过在 useEffect 钩子调用中包含获取代码来加载数据。更复杂的应用程序由许多组件和多个数据查询组成,这些查询可能使用多个端点。您可能会尝试通过将状态及其相关数据获取操作移动到单独的数据存储中,然后连接组件到存储来简化这种复杂性。但对于您的应用程序,将数据获取放在消耗数据的组件中可能是一个更直接和易于理解的方法。我们将在第九章中考虑不同的方法,当时我们将查看自定义钩子,以及在第二部分中查看数据获取的模型。

目前,我们将保持简单,让 BookablesList 组件加载自己的数据。我们将通过四个步骤开发其数据获取功能:

  • 检查数据加载过程

  • 更新 reducer 以管理加载和错误状态

  • 创建一个辅助函数来加载数据

  • 加载可预订项

4.3.1 检查数据加载过程

在 4.2 节中,UserPicker 组件使用了 fetch API 从 JSON 数据库服务器加载数据列表。对于 BookablesList 组件,我们考虑了加载和错误状态以及可预订项本身。我们希望更新的组件具体做什么?

在组件首次渲染后,它将发出对所需数据的请求。在此阶段,在任何数据加载之前,我们没有可预订项或组来显示,因此组件将显示一个加载指示器,如图 4.12 所示。

图 4.12 当数据正在加载时,BookablesList 组件显示一个加载指示器。

如果在加载数据时出现问题——可能是网络、服务器、授权或缺少文件问题——组件将显示如图 4.13 所示的错误消息。

图 4.13 BookablesList 组件在加载数据时显示错误消息。

如果一切顺利并且数据到达,它将在我们第二章和第三章中开发的 UI 中显示。来自“房间”组的“会议室”可预订项被选中,其详细信息正在显示。图 4.14 显示了预期的结果。

图 4.14 BookablesList组件显示了数据加载后的可预订项列表。

到这个时候,用户将能够与应用程序交互,选择组和可预订项,使用“下一步”按钮遍历可预订项,以及使用“显示详细信息”复选框切换可预订项的详细信息。

在第三章中,我们创建了一个 reducer 来帮助管理BookablesList组件的状态。我们应该如何更新 reducer 以应对新的功能?

4.3.2 更新 reducer 以管理加载和错误状态

我们已经看到了我们试图实现的目标。现在我们必须考虑驱动此类界面的组件状态。为了启用加载指示器和错误消息,我们在状态中添加了两个额外的属性:isLoadingerror。我们还把可预订项设置为空数组。完整的初始状态现在看起来是这样的:

{
    group: "Rooms",
    bookableIndex: 0,
    hasDetails: true,
    bookables: [],
    isLoading: true,
 error: false 
}

组件将在第一次渲染后开始加载数据,所以我们从一开始就将isLoading设置为true。我们的初始 UI 将是加载指示器。

为了响应数据获取事件而更改状态,我们在 reducer 中添加了三个新的动作类型:

  • FETCH_BOOKABLES_REQUEST—组件初始化请求。

  • FETCH_BOOKABLES_SUCCESS—可预订项从服务器到达。

  • FETCH_BOOKABLES_ERROR—出了些问题。

在以下列表之后,我们进一步讨论了新的动作类型,它们在我们的更新 reducer 中显示。

分支:0403-bookables-list,文件:/src/components/Bookables/reducer.js

列表 4.6 在 reducer 中管理加载和错误状态

export default function reducer (state, action) {
  switch (action.type) {
    case "SET_GROUP": return { /* unchanged */ }
    case "SET_BOOKABLE": return { /* unchanged */ }
    case "TOGGLE_HAS_DETAILS": return { /* unchanged */ }
    case "NEXT_BOOKABLE": return { /* unchanged */ }

    case "FETCH_BOOKABLES_REQUEST":
      return {
        ...state,
        isLoading: true,
        error: false,
        bookables: []                 ❶
      };

    case "FETCH_BOOKABLES_SUCCESS":
      return {
        ...state,
        isLoading: false,
        bookables: action.payload     ❷
      };

    case "FETCH_BOOKABLES_ERROR":
      return {
        ...state,
        isLoading: false,
        error: action.payload         ❸
      };

    default:
      return state;
  }
}

❶ 请求新数据时清除可预订项。

❷ 通过负载将加载的可预订项传递给 reducer。

❸ 通过负载将错误传递给 reducer。

FETCH_BOOKABLES_REQUEST

当组件发送其请求获取可预订项数据时,我们希望在 UI 中显示加载指示器。除了将isLoading设置为true外,我们确保没有现有的可预订项,并清除任何错误消息。

FETCH_BOOKABLES_SUCCESS

哇哦!可预订项已经到达,并且位于动作的负载中。我们希望显示它们,所以将isLoading设置为false并将负载分配给bookables状态属性。

FETCH_BOOKABLES_ERROR

哎!出了些问题,错误消息在动作的负载中。我们希望显示错误消息,所以将isLoading设置为false并将负载分配给error状态属性。

你可以看到,对于每个动作,都有很多相互关联的状态变化在进行;有一个 reducer 来分组和集中这些变化是非常有帮助的。

4.3.3 创建一个辅助函数来加载数据

UserPicker组件获取其数据时,它没有担心加载状态或错误消息;它直接在useEffect钩子内部调用fetch。现在我们做了一些更多的事情,在数据加载时给用户一些反馈,可能最好创建一些专门的数据获取函数。我们希望我们的数据代码执行三个关键任务:

  • 发送请求

  • 检查响应是否存在错误

  • 将响应转换为 JavaScript 对象

下面的列表中的getData函数执行了所需的三个任务。在列表之后,我们将更详细地讨论每个任务。在 utils 文件夹中已添加文件 api.js。

分支:0403-bookables-list,文件:/src/utils/api.js

列表 4.7 获取数据的函数

export default function getData (url) {                       ❶

  return fetch(url)                                           ❷
    .then(resp => {

      if (!resp.ok) {                                         ❸
        throw Error("There was a problem fetching data."); ❹
      }

      return resp.json();                                     ❺
    });
}

❶ 接受一个 URL 参数。

❷ 将 URL 传递给浏览器的 fetch 函数。

❸ 检查响应是否存在问题。

❹ 对于任何问题抛出错误。

❺ 将响应的 JSON 字符串转换为 JavaScript 对象。

发送请求

getData函数接受一个参数,即url,并将其传递给fetch函数。(fetch函数还接受第二个参数,即init对象,但我们现在不会使用它。)您可以在 MDN 上了解更多关于 fetch API 的信息:mng.bz/1r81fetch返回一个 promise,它应该解析为从响应对象中获取我们的数据。

检查响应是否存在错误

我们在 fetch 返回的 promise 上调用then,设置一个函数来对响应进行一些初始处理:

return fetch(url)
  .then(resp => {
    // do some initial processing of the response
  });

首先,我们检查响应的状态,如果状态不是ok(HTTP 状态码不在 200 到 299 的范围内),则抛出错误:

if (!resp.ok) {
  throw Error("There was a problem fetching data.");
}

状态码不在 200 到 299 范围内的响应是有效的,fetch不会自动为它们抛出任何错误。我们进行自己的检查,并在必要时抛出错误。我们在这里不捕获任何错误;调用代码应设置它需要的任何catch块。

将响应转换为 JavaScript 对象

如果响应通过检查,我们将服务器返回的 JSON 字符串转换为 JavaScript 对象,通过调用响应的json方法。json方法返回一个 promise,它解析为我们的数据对象,然后我们从函数中返回这个 promise:

return resp.json();

getData函数对fetch的响应进行了一些预处理,有点像中间件。使用getData的组件不需要自己进行这些预处理检查和更改。让我们看看BookablesList组件如何使用我们的数据获取函数来加载用于显示的 bookables。

4.3.4 加载 bookables

是时候享受所有这些准备带来的好处了。列表 4.8 显示了最新的BookablesList组件文件。代码导入了我们新的getData函数,并在组件首次挂载时运行的useEffect钩子中使用它。它还包括isLoadingerror状态值以及一些相关的 UI,用于数据加载或显示错误消息时。

分支:0403-bookables-list,文件:/src/components/Bookables/BookablesList.js

列表 4.8 BookablesList组件加载其自身的数据

import {useReducer, useEffect, Fragment} from "react"; 
import {sessions, days} from "../../static.json";             ❶
import {FaArrowRight} from "react-icons/fa";
import Spinner from "../UI/Spinner";
import reducer from "./reducer";

import getData from "../../utils/api";                        ❷

const initialState = {
  group: "Rooms",
  bookableIndex: 0,
  hasDetails: true,
  bookables: [],                                              ❸
  isLoading: true,                                            ❹
  error: false                                                ❹
};

export default function BookablesList () {
  const [state, dispatch] = useReducer(reducer, initialState); 

  const {group, bookableIndex, bookables} = state;
  const {hasDetails, isLoading, error} = state;               ❺

  const bookablesInGroup = bookables.filter(b => b.group === group);
  const bookable = bookablesInGroup[bookableIndex];
  const groups = [...new Set(bookables.map(b => b.group))];

  useEffect(() => {

    dispatch({type: "FETCH_BOOKABLES_REQUEST"});              ❻

    getData("http://localhost:3001/bookables")                ❼

      .then(bookables => dispatch({                           ❽
        type: "FETCH_BOOKABLES_SUCCESS",                      ❽
        payload: bookables                                    ❽
      }))

      .catch(error => dispatch({                              ❾
        type: "FETCH_BOOKABLES_ERROR",                        ❾
        payload: error                                        ❾
      }));

  }, []);

  function changeGroup (e) {}
  function changeBookable (selectedIndex) {}
  function nextBookable () {}
  function toggleDetails () {}

  if (error) {                                                ❿
    return <p>{error.message}</p>                             ❿
  }                                                           ❿

  if (isLoading) {                                            ⓫
    return <p><Spinner/> Loading bookables...</p>             ⓫
  }                                                           ⓫

  return ( /* unchanged UI for bookables and details */ );
}

❶ 不再导入 bookables。

❷ 导入 getData 函数。

❸ 将 bookables 设置为空数组。

❹ 将新属性添加到初始状态中。

❺ 从状态中解构新属性。

❻ 分发一个动作以开始数据获取。

❼ 获取数据。

❽ 在状态中保存加载的预订项。

❾ 更新状态以包含任何错误。

❿ 如果有错误,返回一些简单的错误 UI。

⓫ 在等待数据时返回一些简单的加载 UI。

getData的调用在效果函数中。在第 4.3.3 节中,我们看到了getData如何返回一个 promise 并可以抛出错误。因此,在第 4.8 节中,我们使用了thencatch方法,从每个中分发适当的动作,这些动作在第 4.3.2 节中进行了讨论。最后,我们使用if语句来返回加载和错误条件下的 UI。如果没有错误且isLoadingfalse,我们返回现有的可预订列表和可预订详情的 UI。

挑战 4.3

更新UsersList组件以使用getData函数并管理加载和错误状态。可能的解决方案代码位于 0404-users-errors 分支。

我们将在第六章回到数据获取,当我们扩展预订应用中的组件列表时。在那之前,在下一章中,我们将研究在组件中管理状态的另一种方法:useRef钩子。

摘要

  • 有时我们的组件会超出 React 数据流过程,直接与其他 API 交互,最常见的是在浏览器中。以某种方式影响外部世界的动作被称为副作用

  • 常见的副作用包括强制设置页面标题、使用定时器如setIntervalsetTimeout、测量 DOM 中元素的宽度或高度或位置、将消息记录到控制台、在本地存储中设置或获取值、获取数据或订阅和取消订阅服务。

  • 将副作用放在效果函数内部,作为useEffect钩子的第一个参数:

    useEffect(() => {
      // perform effect
    });
    

    React 在每次渲染后都会运行效果函数。

  • 要管理效果函数何时运行,将依赖项数组作为useEffect钩子的第二个参数传递。

  • 传递一个空的依赖项数组,使 React 在组件挂载时运行一次效果函数:

    useEffect(() => {
      // perform effect
    }, []);
    
  • 在依赖数组中包含效果函数的所有依赖项,以便 React 在指定依赖项的值发生变化时运行效果函数:

    useEffect(() => {
      // perform effect
      // that uses dep1 and dep2
    }, [dep1, dep2]);
    
  • 从效果中返回一个清理函数,React 将在重新运行效果函数之前以及组件卸载时运行此函数:

    useEffect(() => {
      // perform effect
      return () => {/* clean-up */};
    }, [dep1, dep2]);
    
  • 如果您使用的是渲染时获取数据的方法,请在效果内部获取数据。React 将渲染组件,然后触发数据获取代码。当数据到达时,它将重新渲染组件:

    useEffect(() => {
      fetch("http://localhost:3001/users")
        .then(resp => resp.json())
        .then(data => setUsers(data));
    }, []);
    
  • 为了避免竞争条件和遵循从效果函数返回空或清理函数的约定,将async函数放在效果函数内部。根据需要,您可以立即调用它们:

    useEffect(() => {
      async function getUsers() {
        const resp = await fetch(url);
        const data = await (resp.json());
        setUsers(data);
      }
     getUsers();
    }, []);
    
  • 将单独的副作用放入单独的 useEffect 调用中。这样会更易于理解每个副作用的作用,更易于通过使用单独的依赖项列表来控制副作用何时运行,并且更易于将副作用提取到自定义钩子中。

  • 如果在重新渲染时,多个副作用将要运行,React 将在运行任何自身效果之前,调用所有正在重新运行的效果的清理函数。

5 使用useRef钩子管理组件状态

本章涵盖

  • 调用useRef钩子以获取引用

  • 通过将值分配给其current属性来更新引用

  • 无触发重新渲染的状态更新

  • 在 JSX 中设置ref属性以将 DOM 元素引用分配给引用

  • 通过引用访问 DOM 元素属性和方法

虽然你组件存储的大多数值将直接表示在应用程序的用户界面中,但有时你可能会使用一个变量仅用于应用程序的机制,而不是供用户消费。你可能需要使用setTimeoutsetInterval作为动画的一部分,因此你需要保留它们返回的 ID。或者你可能想以非受控输入的形式与 DOM 表单元素一起工作,因此你需要保留对这些元素的引用。无论如何,你可能不需要向用户显示这些值,因此更改它们不应自动触发重新渲染。

本章从两个示例开始,探讨在不更新 UI 的情况下改变状态:首先是比较使用useStateuseRef管理状态,然后是一个更长的示例,展示如何为BookablesList组件的新演示模式管理计时器。本章的后半部分有两个更多示例,这次是探索对 DOM 元素的引用:在BookablesList组件中自动设置焦点,以及从文本框中读取WeekPicker组件的日期。这些示例的混合将帮助你更好地理解useRef钩子如何帮助你管理组件中的状态。

好的,1,2,3,我们出发吧!

5.1 无重新渲染状态更新

在本节中,我们使用一个简单的Counter组件来介绍引用作为在渲染之间持久化状态的一种方式。使用useState钩子,调用状态值的更新器函数通常会导致重新渲染。使用useRef钩子,我们可以更新我们的值而不需要相应的 UI 变化。我们首先看看当用户点击其按钮时Counter组件的行为,增加计数器(但不一定是 UI),以及实现这种行为的代码。然后,在看到useRef的实际应用后,我们关注新钩子的 API。

5.1.1 比较更新状态值时使用useStateuseRef

图 5.1 显示了Counter组件 UI 的四个截图,其中有两个按钮,一个标有count,另一个标有ref.current。每个按钮旁边还有一个附加到按钮文本的计数器。按钮的行为方式不同。

点击计数按钮会增加其计数,如图所示,图中显示了原始组件和点击三次的结果。按钮计数从 1 增加到 2,再到 3,然后到 4。每次增加都伴随着重新渲染,因此Counter组件显示了最新的值。

图片

图 5.1 每次点击 Count 按钮都会将计数增加 1。因为事件处理程序通过调用其更新函数来增加计数,所以 React 在每次更改后都会重新渲染组件。

图 5.2 显示了随后点击 Ref.current 按钮三次的结果。其计数器似乎没有变化。组件显示 1,然后 1,然后 1。实际上,值确实在增加,从 1 增加到 2,然后到 3,最后到 4。只是改变ref.current的值并不会导致 React 重新渲染,所以Counter组件继续显示旧值。

图 5.2 点击 Ref.current 按钮三次似乎没有效果。实际上,事件处理程序确实将ref.current增加到 2,然后是 3,然后是 4,但 React 没有重新渲染组件。

再次点击 Count 按钮将计数器从 4 增加到 5。React 重新渲染组件以显示最新的值,如图 5.3 所示。这样做也会更新 Ref.current 按钮显示的值,并跳转到 4,其当前值。

图 5.3 再次点击 Count 按钮将计数增加到 5。React 重新渲染组件,现在显示了countref.current的最新值。

在前面的章节中,你已经看到了如何通过使用useState钩子来实现像 Count 按钮这样的按钮。我们如何实现 Ref.current 按钮,其中状态在渲染之间保持不变,但更新 ref 不会导致重新渲染?以下列表显示了按钮示例的代码,包括第一次调用useRef钩子。

Live: gh6xz.csb.app/, Code: codesandbox.io/s/counterstatevsref-gh6xz

列表 5.1 比较更新状态时useStateuseRef

import React, { useRef, useState } from "react";

function Counter() {
  const [count, setCount] = useState(1);                                ❶
  const ref = useRef(1);                                                ❷

 const incCount = () => setCount(c => c + 1);                          ❸

  const incRef = () => ref.current++;                                   ❹

  return (
    <div className="App">
      <button onClick={incCount}>count: {count}</button>                ❺
      <hr />
      <button onClick={incRef}>ref.current: {ref.current}</button>     ❻
    </div>
  );
}

❶ 使用 useState 初始化计数值。

❷ 使用 useRef 初始化 ref 值。

❸ 定义一个处理程序,该处理程序调用 setCount 来增加计数。

❹ 定义一个更新 ref 的“current”属性的处理器。

❺ 调用计数值的处理程序。

❻ 调用 ref 值的处理程序。

那么,为什么按钮的行为不同呢?嗯,一个使用useState钩子,另一个使用useRef钩子。

Count 按钮通过调用useState来让 React 管理其计数状态值。按钮的事件处理程序使用状态值的更新函数setCount来更改计数器。调用更新函数会改变状态并触发重新渲染。React 在渲染之间保持状态,每次都将它传回组件,其中它被分配给count变量。

Ref.current 按钮通过调用useRef让 React 管理其计数状态值。这个钩子返回一个对象,一个ref,我们用它来存储状态值。改变存储在 ref 上的值不会触发重新渲染。React 在渲染之间保持状态,每次都将相同的 ref 对象传递回组件,其中它被分配给ref变量。

列表 5.1 中的两个按钮都包含在按钮文本中的状态值,{count}{ref.current},并在用户点击它们时调用一个处理函数。但.current是什么意思?让我们更仔细地看看如何使用useRef

5.1.2 调用 useRef

在列表 5.1 中,我们通过调用useRef并传递一个初始值1从 React 获取一个 ref。我们将 ref 分配给一个变量,ref

const ref = useRef(1);

useRef函数返回一个具有current属性的对象,如图 5.4 所示。每次 React 运行组件代码时,每次对useRef的调用都将返回相同的 ref 对象。

图片

图 5.4 useRef返回一个具有 current 属性的对象。

第一次 React 调用组件代码时,它将你传递给useRef函数的初始值分配给 ref 对象的current属性:

const ref1 = useRef("Towel");
const ref2 = useRef(42);

ref1.current;  // "Towel"
ref2.current;  // 42

在后续的渲染中,React 根据useRef调用的顺序将相同的 ref 对象分配给相应的变量。你可以通过将它们分配给 refs 的current属性来持久化状态值:

ref1.current = "Babel Fish";
ref2.current = "1,000,000,000,000";

将新值分配给 ref 对象的current属性不会触发重新渲染。但作为 React 总是返回相同的 ref 对象,新值在组件再次运行时是可用的。

好吧,按钮示例有点简单,也有点奇怪——谁会想要坏掉的按钮?现在是时候增加一点复杂性了。

5.2 使用 ref 存储计时器 ID

在上一节中,你看到了如何使用useRef钩子在渲染之间为我们的函数组件保持状态。要更新useRef返回的 ref,我们将它的current属性设置为我们要存储的值。以这种方式更改current属性不会导致组件重新渲染。在本节中,我们来看一个稍微复杂一点的例子,使用useRef钩子来请求 React 帮助管理计时器的 ID。我们回到预订应用作为我们的上下文。

假设你的老板想要你为BookablesList组件创建一个演示模式。在你点击停止按钮之前,组件应该自动依次选择每个可预订项,显示其详细信息,就像你在图 5.5 中看到的那样。你的老板认为这会非常适合公司去年购买的接待室屏幕。

图片

图 5.5 在演示模式下,应用程序将自动依次前进到每个可预订项,显示其详细信息,直到你点击停止按钮(右上角)。

在演示模式下独立运行时,组件会遍历组中的所有可预订项,当它离开最后一个时,会回到第一个。我们将使用定时器来安排组件何时移动到下一个可预订项。如果用户点击停止按钮,演示模式结束,我们将取消任何正在运行的定时器。以下列表显示了用于存储定时器 ID 的 ref、设置定时器的新效果以及停止按钮的 UI。

分支:0501-timer-ref,文件:/src/components/Bookables/BookablesList.js

列表 5.2 使用 ref 保持演示模式下的定时器 ID

import {useReducer, useEffect, useRef, Fragment} from "react";    ❶
import {sessions, days} from "../../static.json";
import {FaArrowRight} from "react-icons/fa";
import Spinner from "../UI/Spinner";
import reducer from "./reducer";
import getData from "../../utils/api";

const initialState = { /* unchanged */ };

export default function BookablesList () {

  // unchanged variable setup

 const timerRef = useRef(null);                                  ❷

  useEffect(() => { /* load data */ }, []);

 useEffect(() => {                                               ❸

    timerRef.current = setInterval(() => {                        ❹
 dispatch({ type: "NEXT_BOOKABLE" });
 }, 3000);

 return stopPresentation;                                 ❺

  }, []); 
  function stopPresentation () {                                  ❻
 clearInterval(timerRef.current);                              ❻
 }                                                               ❻

  function changeGroup (e) { /* unchanged */ }
  function changeBookable (selectedIndex) { /* unchanged */ }
  function nextBookable () { /* unchanged */ }
  function toggleDetails () { /* unchanged */ }

  // unchanged UI for error and loading

  return (
    <Fragment>
      <div>
      { /* list of bookables */ }
      </div>

      {bookable && (
        <div className="bookable-details">
          <div className="item">
            <div className="item-header">
              <h2>
                {bookable.title}
              </h2>
              <span className="controls">
                <label>
                  <input
                    type="checkbox"
                    checked={hasDetails}
                    onChange={toggleDetails}
                  />
                  Show Details
                </label>
                <button                                         ❼
 className="btn"
 onClick={stopPresentation}                    ❽
 >
 Stop
 </button>
              </span>
            </div>

            { /* further details */ }
          </div>
        </div>
      )}
    </Fragment>
  );
}

❶ 导入 useRef 钩子。

❷ 将 ref 分配给 timerRef 变量。

❸ 当组件首次挂载时运行一个效果。

❹ 启动一个间隔定时器,并将其 ID 分配给 ref 的current属性。

❺ 返回一个清除定时器的函数。

❻ 使用定时器 ID 清除定时器。

❼ 包含一个停止按钮。

❽ 从按钮调用 stopPresentation 函数。

当我们在新效果中设置定时器时,浏览器的setInterval方法返回一个 ID。如果需要(如果用户点击停止按钮或导航到应用中的另一个页面),我们可以使用该 ID 来清除定时器。stopPresentation函数需要访问 ID 以便它可以清除定时器。我们需要存储 ID,但当我们开始或停止定时器时,无需重新渲染组件,因此我们不想使用useState钩子。我们使用useRef钩子,因此我们需要导入它:

import {useReducer, useEffect, useRef, Fragment} from "react";

我们调用useRef,将其null作为初始值传递,因为还没有定时器。每次组件运行时,useRef都会返回相同的 ref 对象,我们将它分配给timerRef变量:

const timerRef = useRef(null);

我们使用 ref 来存储我们的定时器 ID,通过将 ID 分配给 ref 的current属性:

timerRef.current = setInterval(/* wibbly-wobbly, timey-wimey stuff */, 3000);

stopPresentation函数使用存储在timerRef.current中的 ID 来清除定时器并结束演示模式。该函数在用户点击停止按钮时运行,并且由于第二个效果将其作为清理函数返回,当用户导航到应用中的另一个页面且组件卸载时:

function stopPresentation () {
 window.clearInterval(timerRef.current);
} 

本节介绍了使用 ref 存储状态以避免更新状态导致组件重新渲染的另一个示例。在设置和清除定时器 ID 时,无需重新运行组件代码,因此使用 ref 来存储其值是有意义的。下一节将探讨 refs 的一个非常常见的用例,即保持对 DOM 元素的引用。

5.3 保持对 DOM 元素的引用

如果你是处理 refs 的老手,你可能会对我们在第 5.2 节中使用的它们感到惊讶,在那里我们更新状态而不重新渲染。如果是这样,你将回到本节,我们将调用useRef钩子来帮助我们存储对按钮和表单字段的引用。这样的 DOM 元素引用让我们能够直接与元素交互,绕过通常的 React 状态到 UI 的流程。特别是,我们来看两个常见的用例:

  • 响应事件对元素设置焦点

  • 读取未受控文本框的值

我们看到如何让 React 自动将 DOM 元素引用分配给我们的 refs 的 current 属性,这样我们就可以直接操作或读取这些元素。这两个例子都使用了预订应用程序的组件。在第 5.3.2 节中,我们在 WeekPicker 组件中添加了一个文本框。但首先,我们关注 BookablesList 组件,使用户能够通过键盘从一个可预订项切换到下一个,变得更加容易。

5.3.1 响应事件对元素设置焦点

您的老板又提出了关于预订应用程序的新建议。忘记演示模式吧!当用户选择一个可预订项时,焦点自动转移到“下一步”按钮不是很好吗?然后用户只需按空格键就可以从一个可预订项切换到另一个!图 5.6 展示了这种情况。

图 5.6 当用户选择一个可预订项时,焦点会自动设置在“下一步”按钮上。

我们可以添加一个额外的状态,比如 nextHasFocus,并在它改变时重新渲染,以给“下一步”按钮聚焦。但是浏览器有一个 focus 方法,所以如果我们只有一个按钮元素的引用,我们可以调用 focus 并完成工作:

const nextButtonEl = document.getElementById("nextButton");

nextButtonEl.focus();

但是,既然我们选择了使用 React,我们更倾向于尽可能保持在它的状态到 UI 流程中。直接使用 getElementById 调用 DOM 的时机可能会变得复杂,因为 React 会根据状态变化更新 DOM。此外,在应用程序中,同一个组件可能会被多次使用,因此使用多个实例的应该是唯一的 id 属性来识别组件元素,这最终会导致问题而不是解决问题。幸运的是,React 提供了一种方法,可以自动将 DOM 元素引用分配给使用 useRef 钩子创建的 refs。

列表 5.3 展示了 BookablesList 组件代码,增加了三个功能以实现我们想要的“下一步”按钮聚焦行为。我们做了以下操作:

  1. 创建一个新的 ref,nextButtonRef,用于保存对“下一步”按钮元素的引用。

  2. 使用 JSX 中的特殊 ref 属性来请求 React 自动将一个引用分配给按钮元素到 nextButtonRef.current

  3. 使用我们的引用,nextButtonRef.current,来设置对“下一步”按钮的焦点。

分支:0502-set-focus,文件:/src/components/Bookables/BookablesList.js

列表 5.3 使用 ref 设置焦点

import {useReducer, useEffect, useRef, Fragment} from "react";
import {sessions, days} from "../../static.json";
import {FaArrowRight} from "react-icons/fa";
import Spinner from "../UI/Spinner";
import reducer from "./reducer";
import getData from "../../utils/api";

const initialState = { /* unchanged */ };

export default function BookablesList () {
  // unchanged variable setup
  const nextButtonRef = useRef();            ❶

  useEffect(() => { /* load data */ }, []);

  // remove timer effect and stopPresentation function

  function changeGroup (e) { */ unchanged */ }

  function changeBookable (selectedIndex) {
    dispatch({
      type: "SET_BOOKABLE",
      payload: selectedIndex
    });
    nextButtonRef.current.focus();           ❷
  }

  function nextBookable () { /* unchanged */ }
  function toggleDetails () { /* unchanged */}

  if (error) {
    return <p>{error.message}</p>
  }

  if (isLoading) {
    return <p><Spinner/> Loading bookables...</p>
  }

  return (
    <Fragment>
      <div>
        <select value={group} onChange={changeGroup}>
          {groups.map(g => <option value={g} key={g}>{g}</option>)}
        </select>
        <ul className="bookables items-list-nav">
          { /* unchanged */ }
        </ul>
        <p>
          <button
            className="btn"
            onClick={nextBookable}
            ref={nextButtonRef}            ❸
            autoFocus
          >
            <FaArrowRight/>
            <span>Next</span>
          </button>
        </p>
      </div>

      {bookable && (
        <div className="bookable-details">
          { /* Stop button removed */ }
        </div>
      )}
    </Fragment>
  );
}

❶ 调用 useRef 并将 ref 分配给 nextButtonRef 变量。

❷ 使用 ref 来聚焦“下一步”按钮。

❸ 将 nextButtonRef 分配给 JSX 中的 ref 属性。

在列表 5.3 中,我们调用 useRef 钩子并将它返回的 ref 分配给 nextButtonRef 变量:

const nextButtonRef = useRef();

我们没有分配一个初始值;我们将让 React 自动为我们分配 nextButtonRef.current 属性的值。我们需要聚焦“Next”按钮,所以,而不是我们自己深入 DOM,我们将我们的引用分配给用户界面 JSX 中按钮的特殊 ref 属性:

<button
  className="btn"
  onClick={nextBookable}
 ref={nextButtonRef}
 autoFocus
>
  <FaArrowRight/>
  <span>Next</span>
</button>

一旦 React 为 DOM 创建了按钮元素,它就会将对该元素的引用分配给 nextButtonRef.current 属性。我们在 changeBookable 函数中使用这个引用,通过调用元素的 focus 方法来聚焦按钮:

function changeBookable (selectedIndex) {
  dispatch({
    type: "SET_BOOKABLE",
    payload: selectedIndex
  });
  nextButtonRef.current.focus();
}

组件在用户直接在可预订列表中选择可预订时调用 changeBookable 函数。因此,直接选择可预订将焦点转移到“Next”按钮上。这正是老板想要的!做得好。

这个例子展示了如何使用 useRef 钩子创建一个引用,然后让 React 将 DOM 元素的引用分配给该引用。我必须承认这有点牵强,但它确实展示了涉及的步骤。当程序化设置页面上的元素焦点时,请务必小心;确保它不会混淆用户的期望,使您的应用更难使用。这是一个有效的技术,但可能需要仔细的用户测试。

5.3.2 通过引用管理文本框

第三章介绍了 WeekPicker 组件,作为在预订应用中从一周导航到另一周的方式。用户可以点击“Prev”和“Next”按钮来切换周次,或者点击“Today”按钮来显示包含当前日期的周。第三章的 WeekPicker 版本如图 5.7 所示。

图 5.7 第三章的 WeekPicker 组件,带有切换周次和跳转到包含今天日期的周的按钮

但是,如果公司里有人在几个月后要预订会议室举办活动,他们必须一次又一次地点击“Next”按钮,直到达到他们想要的日期。输入特定日期并直接跳转到该周会更好。图 5.8 显示了一个改进的带有文本框和“Go”按钮的 WeekPicker UI。

图 5.8 带有文本框和“Go”按钮的 WeekPicker 组件,用于直接输入日期

WeekPicker 组件的 reducer 已经有了 SET_DATE 动作;让我们来使用它。在下面的列表中,我们为具有文本框和“Go”按钮的 UI 添加了 WeekPicker,文本框的引用,以及“Go”按钮的 goToDate 处理函数。

分支:0503-text-box,文件:/src/components/Bookings/WeekPicker.js

列表 5.4 带有文本框和“Go”按钮的 WeekPicker

import {useReducer, useRef} from "react";
import reducer from "./weekReducer";
import {getWeek} from "../../utils/date-wrangler";
import {
  FaChevronLeft,
  FaCalendarDay,
  FaChevronRight,
  FaCalendarCheck
} from "react-icons/fa";

export default function WeekPicker ({date}) {
  const [week, dispatch] = useReducer(reducer, date, getWeek);
  const textboxRef = useRef();               ❶

  function goToDate () {                     ❷
    dispatch({                               ❸
      type: "SET_DATE",                      ❸
      payload: textboxRef.current.value      ❹
    });
  }

  return (
    <div>
      <p className="date-picker">
        // Prev button
        // Today button

        <span>
          <input
            type="text"
            ref={textboxRef}                 ❺
            placeholder="e.g. 2020-09-02"
            defaultValue="2020-06-24"
          />

          <button                            ❻
            className="go btn"
 onClick={goToDate}               ❼
          >
            <FaCalendarCheck/>
            <span>Go</span>
          </button>
        </span>

        // Next button
      </p>
      <p>
        {week.start.toDateString()} - {week.end.toDateString()}
      </p>
    </div>
  );
}

❶ 创建一个引用来保存文本框的引用。

❷ 为“Go”按钮定义一个处理程序。

❸ 分发 SET_DATE 动作。

❹ 使用引用获取文本框中的文本值。

❺ 将具有引用属性的文本框添加到 UI 中。

❻ 将“Go”按钮添加到 UI 中。

❼ 将处理程序分配给设置日期。

在渲染组件并更新 DOM 之后,React 将输入元素、我们的文本框的引用分配给 textboxRef 变量的 current 属性。goToDate 函数使用这个引用在用户点击“Go”按钮时从文本框中获取文本:

function goToDate () {
  dispatch({
    type: "SET_DATE",
    payload: textboxRef.current.value
  });
}

因此,textboxRef.current 持有一个对输入元素、文本框的引用,然后 textboxRef.current.value 就是文本框中的文本。

未受控组件

WeekPicker 文本框中的文本是我们组件状态的一部分。在这个例子中,我们的组件没有管理文本框的状态。当用户在文本框中输入字符时,组件并不感兴趣,尽管浏览器会在用户输入时显示新字符。只有当用户点击“Go”按钮时,我们才会通过我们的 ref 从 DOM 中读取文本状态并将其发送到 reducer。允许 DOM 以这种方式管理其状态的组件被称为 未受控组件

虽然 WeekPicker 示例演示了如何使用 ref 与表单字段一起使用,但这种方法并不真正符合使用 useStateuseReducer 管理状态并在 UI 中显示状态的哲学。React 推荐使用 受控组件,以充分利用 React 管理状态的帮助。

受控组件

要将 WeekPicker 组件转换为完全受控组件,我们可以通过调用 useState 钩子从 DOM 中取回文本框的状态:

const [dateText, setDateText] = useState("2020-06-24");

然后,我们可以将 dateText 状态设置为文本框的 value 属性,并使用随附的更新函数 setDateText 在用户在文本框中输入时更改状态:

return (
  <div>
    <input
      type="text"
      value={dateText}                                 ❶
      onChange={(e) => setDateText(e.target.value)}    ❷
    />
    <button onClick={goToDate}>Go</button>
  </div>
);

❶ 使用 dateText 状态作为文本框的值。

❷ 当用户在文本框中输入时,更新 dateText 状态。

最后,在 goToDate 函数中,我们不再需要文本框的引用,可以直接将 dateText 值发送到 reducer:

function goToDate () {
  dispatch({
    type: "SET_DATE",
    payload: dateText
  });
}

在受控组件中,数据流是从组件到 DOM,这与标准的 React 方法一致。

摘要

  • 当您希望 React 管理一个状态值但不想更改值触发重新渲染时,请调用 useRef 钩子。例如,用于存储 setTimeoutsetInterval 的 ID 或 DOM 元素的引用。如果需要,您可以传递一个初始值。它返回一个具有 current 属性的对象,该属性设置为初始值:

    const ref = useRef(initialValue);
    ref.current; // initialValue
    
  • 每次组件运行时,useRef 调用都会返回相同的 ref 对象。通过将它们分配给 ref 的 current 属性来在渲染之间持久化 ref 中的值:

    ref.current = valueToStore;
    
  • React 可以自动将 DOM 元素引用分配给 ref 的 current 属性。将您的 ref 变量分配给 JSX 中元素的 ref 属性:

    const myRef = useRef();                     ❶
    
    ...
    
    return (
      <button ref={myRef}>Click Me!</button>    ❷
    );
    
    ...
    
    myRef.current;                              ❸
    

    ❶ 创建一个 ref。

    ❷ 在 JSX 中指定 ref 属性。

    ❸ 现在的 current 属性将引用按钮元素。

  • 使用 ref 与 DOM 元素交互。例如,设置元素的焦点:

    myRef.current.focus();
    
  • 从 DOM 中读取状态的组件被称为 非受控组件。您可以使用 refs 来访问和更新状态。

  • React 推荐您使用 受控组件。使用 useState 钩子或 useReducer 钩子来管理状态,并让 React 使用最新的状态值更新 DOM。您的组件将成为唯一的真相来源,而不是在组件和 DOM 之间分割状态。

6 管理应用程序状态

本章涵盖

  • 将共享状态传递给需要它的组件

  • 当状态未向下传递时的应对策略——缺少属性

  • 将状态提升到组件树中以提高其可用性

  • 将分派和更新函数传递给子组件

  • 使用 useCallback 钩子保持函数身份

到目前为止,我们已经看到了组件如何使用 useStateuseReduceruseRef 钩子来管理自己的状态,以及如何使用 useEffect 钩子加载数据状态。然而,组件通常需要协同工作,使用共享状态值来生成它们的 UI。每个组件可能都有一个嵌套在其内部的整个子组件层级,它们在等待数据时发出声音,因此状态值可能需要深入到子组件的深处。

在本章中,我们研究如何通过将状态提升到公共父组件来决定如何管理需要消费状态值的子组件的状态值可用性。在第八章中,我们将看到 React 的 Context API 如何被用来直接将值提供给需要它们的组件。在这里,我们坚持使用属性将状态向下传递给子组件。

我们从第 6.1 节开始,介绍一个新的 Colors 组件,该组件与三个子组件共享一个选中的颜色。我们看到如何从子组件更新由父组件管理的共享状态。本章的其余部分使用预订应用程序示例来探讨两种共享状态的方法:将状态对象和分派函数传递给子组件,以及将单个状态值及其更新函数传递给子组件。这两种方法都是常见的模式,有助于突出一些关于状态、属性、效果和依赖关系的常见问题。我们最后将查看 useCallback 钩子,这个钩子允许我们请求 React 的帮助来保持我们作为属性传递的函数的身份,尤其是在子组件将这些函数视为依赖项时。

对于我们的第一个技巧,让我们先复习一下属性的知识:选择一种颜色,任何颜色……

6.1 将共享状态传递给子组件

当不同的组件使用相同的数据来构建它们的 UI 时,最明确地共享该数据的方式是将它作为从父组件到子组件的属性传递。本节通过查看一个新示例,即图 6.1 所示的 Colors 组件,介绍了传递属性(特别是传递由 useState 返回的状态值和更新函数)的方法。该组件包括三个 UI 部分:

  • 一个带有选中颜色高亮的颜色列表

  • 显示所选颜色的文本

  • 一个背景设置为所选颜色的条

图片 6-1

图 6.1 Colors 组件。当用户选择一种颜色时,菜单、文本和颜色条都会更新。当选择金盏花时,其菜单圆圈更大,文本显示“……金盏花!”并且条的颜色是金盏花。

点击列表中的颜色(其中一个圆圈)会突出显示该选择并更新文本和颜色条。您可以在 CodeSandbox 上看到组件的实际操作效果 (hgt0x.csb.app/)。

6.1.1 通过在子组件上设置属性从父组件传递状态

列表 6.1 显示了 Colors 组件的代码。它导入了三个子组件:ColorPickerColorChoiceTextColorSample。每个子组件都需要所选颜色,因此 Colors 组件持有该状态并将其作为属性传递给它们,即 JSX 中的属性。它还传递了可用颜色和 setColor 更新函数到 ColorPicker 组件。

实时预览: hgt0x.csb.app/, 代码: codesandbox.io/s/colorpicker-hgt0x

列表 6.1 Colors 组件

import React, {useState} from "react";

import ColorPicker from "./ColorPicker";                                ❶
import ColorChoiceText from "./ColorChoiceText";                        ❶
import ColorSample from "./ColorSample";                                ❶

export default function Colors () {
  const availableColors = ["skyblue", "goldenrod", "teal", "coral"];    ❷
                                                                        ❷
  const [color, setColor] = useState(availableColors[0]);               ❷

  return (
    <div className="colors">
      <ColorPicker
        colors={availableColors}                                        ❸
        color={color}                                                   ❸
        setColor={setColor}                                             ❸
      />
      <ColorChoiceText color={color} />                                 ❸
      <ColorSample color={color} />                                     ❸
    </div>
  );
}

❶ 导入子组件。

❷ 定义状态值。

❸ 将适当的状态值作为属性传递给子组件。

Colors 组件向下传递两种类型的属性:用于子组件 UI 的状态值 colorscolor;以及一个更新共享状态的函数 setColor。让我们首先看看状态值。

6.1.2 将状态作为属性从父组件接收

ColorChoiceText 组件和 ColorSample 组件都显示当前所选颜色。ColorChoiceText 将其包含在其消息中,而 ColorSample 使用它来设置背景颜色。它们从 Colors 组件接收颜色值,如图 6.2 所示。

图片

图 6.2 Colors 组件将当前颜色状态值传递给子组件。

Colors 是共享状态的子组件最近的共享父组件,因此我们在 Colors 中管理状态。图 6.3 显示了 ColorChoiceText 组件显示包含所选颜色的消息。该组件只需将颜色值作为其 UI 的一部分即可;它不需要更新该值。

图片

图 6.3 ColorChoiceText 组件在其消息中包含所选颜色。

ColorChoiceText 组件的代码在列表 6.2 中。当 React 调用该组件时,它将其作为组件的第一个参数传递,即一个包含父组件设置的 所有属性的对象。这里的代码解构了属性,将 color 属性分配给同名的局部变量。

实时预览: hgt0x.csb.app/, 代码: codesandbox.io/s/colorpicker-hgt0x

列表 6.2 ColorChoiceText 组件

import React from "react";

export default function ColorChoiceText({color}) {     ❶
  return color ? (                                     ❷
    <p>The selected color is {color}!</p>              ❸
  ) : (
    <p>No color has been selected!</p>                 ❹
  )
}

❶ 从父组件接收颜色状态作为属性。

❷ 检查是否存在颜色。

❸ 在 UI 中使用属性。

❹ 如果父组件未设置颜色,则返回备用 UI。

如果父组件未设置 color 属性会怎样?ColorChoiceText 组件对没有 color 属性感到高兴;它返回备用 UI,表示没有选择颜色。

如图 6.4 所示的 ColorSample 组件显示一个背景设置为所选颜色的条形。

图片

图 6.4 ColorSample 组件显示所选颜色的条形。

ColorSample 对缺失属性采取了不同的方法。它根本不返回任何 UI!在下面的列表中,你可以看到组件正在检查 color 值。如果它缺失,组件返回 null,React 在元素树中的该点不渲染任何内容。

直播: hgt0x.csb.app/, 代码: codesandbox.io/s/colorpicker-hgt0x

列表 6.3 ColorSample 组件

import React from "react";

export default function ColorSample({color}) {    ❶
  return color ? (                                ❷
    <div
      className="colorSample"
      style={{ background: color }}
    />
  ) : null;                                       ❸
}

❶ 将状态从父组件作为属性接收。

❷ 检查是否存在颜色。

❸ 如果没有颜色,则不渲染任何 UI。

你可以在属性解构中将 color 的默认值设置为部分。也许如果父组件没有指定颜色,那么它应该是白色?

function ColorSample({color = "white"}) {         ❶
  return (
    <div
      className="colorSample"
      style={{ background: color }}
    />
  );
}

❶ 为属性指定默认值。

对于某些组件,默认值可能就足够了,但对我们需要共享状态的基于颜色的组件,我们必须确保所有默认值都是相同的。因此,我们要么有替代的 UI,要么没有 UI。如果组件没有属性就无法工作,并且默认值没有意义,你可以抛出一个错误,解释属性缺失的原因。

虽然我们在这本书中不会探讨它们,但你也可以使用 PropTypes 来指定预期的属性及其类型。React 将使用 PropTypes 在开发期间警告问题(reactjs.org/docs/typechecking-with-proptypes.html)。或者,使用 TypeScript 而不是 JavaScript,并对整个应用程序进行类型检查(www.typescriptlang.org)。

6.1.3 从父组件作为属性接收更新器函数

ColorPicker 组件使用两个状态值来生成其 UI:可用颜色列表和所选颜色。它显示可用颜色值作为列表项,应用程序使用 CSS 将它们样式化为一行彩色圆圈,如图 6.5 所示。图中的所选项目,goldenrod,比其他项目样式更大。

图片

图 6.5 ColorPicker 组件显示颜色列表并突出显示所选颜色。

Colors 组件将使用的两个状态值传递给 ColorPicker 组件。Colors 还需要提供一种更新所有三个子组件所选颜色的方式。它通过传递 setColor 更新器函数将此责任委托给 ColorPicker 组件,如图 6.6 所示。

图片

图 6.6 Colors 组件将两个状态值传递给 ColorPicker。它还传递了 setColor 更新器函数,因此可以从子组件设置颜色状态值。

以下列表显示了 ColorPicker 组件解构其属性参数,将三个属性分配给局部变量:colorscolorsetColor

Live: hgt0x.csb.app/, Code: codesandbox.io/s/colorpicker-hgt0x

列表 6.4 ColorPicker 组件

import React from "react";

export default function ColorPicker({colors = [], color, setColor}) {      ❶
  return (
    <ul>
      {colors.map(c => (
        <li
          key={c}
          className={color === c ? "selected" : null}
          style={{ background: c }}
          onClick={() => setColor(c)}                                      ❷
        >
          {c}
        </li>
      ))}
    </ul>
  );
}

❶ 将状态和更新函数从父组件作为属性接收。

❷ 使用更新函数设置父组件的状态。

解构语法为 colors 包含一个默认值:

{colors = [], color, setColor}

ColorPicker 组件遍历 colors 数组以为每个可用颜色创建一个列表项。使用空数组作为默认值会导致组件在父组件未设置 colors 属性时返回一个空的未排序列表。

对于一本关于 React Hooks 的书来说,更有趣的是 colorsetColor 属性。这些属性来自父组件中对 useState 的调用:

const [color, setColor] = useState(availableColors[0]);

ColorPicker 不关心它们来自哪里;它只期望有一个 color 属性来保存当前颜色,以及一个 setColor 属性,它是一个可以调用来设置颜色的函数。ColorPicker 使用每个列表项的 onClick 处理器中的 setColor 更新函数。通过调用 setColor 函数,子组件 ColorPicker 能够设置父组件 Colors 的状态。然后父组件重新渲染,使用新选定的颜色更新所有子组件。

我们从头创建了 Colors 组件,因为我们知道我们需要共享状态来传递给子组件。有时我们与现有组件一起工作,随着项目的开发,我们会意识到它们持有的状态其他兄弟组件也可能需要。接下来的几节将探讨几种将状态从子组件提升到父组件的方法,使其更广泛可用。

6.2 将组件拆分为更小的部分

React 通过 useStateuseReducer 钩子为我们提供了两种在应用程序中管理状态的方法。每个钩子都提供了一种更新状态的方式,从而触发重新渲染。随着我们的应用程序的发展,我们在能够直接从单个组件的效果、处理函数和 UI 中访问本地状态的便利性与该组件的状态变得膨胀和混乱的不便之间取得平衡,其中一个部分的 UI 的状态变化会触发整个组件的重新渲染。

应用程序中的新组件可能想要分享现有状态的一部分,因此我们现在需要共享之前由一个组件封装的状态。我们是将状态值和更新函数提升到父组件吗?或者可能是提升减少器和分发函数?移动状态会如何改变现有组件的结构?

在本节中,我们继续构建预订应用程序示例,作为这些问题的背景。特别是,我们探索以下内容:

  • 将组件视为更大应用程序的一部分

  • 在页面 UI 中组织多个组件

  • 创建 BookableDetails 组件

遇到的概念对现有的 React 开发者来说并不新鲜。我们的目标是考虑在使用 React Hooks 时,它们是否以及如何发生变化。

6.2.1 将组件视为更大应用的一部分

在第五章中,我们让BookablesList组件承担双重职责:显示所选组的可预订项列表,并显示所选可预订项的详情。图 6.7 显示了具有列表和详情的组件。

图 6.7 第五章中的先前BookablesList组件显示了可预订项列表和所选可预订项的详情。

该组件管理所有状态:可预订项、所选组、所选可预订项,以及用于显示详情、加载状态和错误的标志。作为一个没有子组件的单个函数组件,所有状态都在本地作用域中,并在生成返回的 UI 时可用。但是,切换“显示详情”复选框会导致整个组件重新渲染,并且在使用演示模式时,我们必须仔细考虑在渲染之间持久化计时器 ID。

我们还需要在预订页面上有一个可预订项列表。各种组件将争夺屏幕空间,我们希望有灵活性,能够将可预订项列表与详情分开显示,如图 6.8 所示,其中可预订项列表位于左侧。实际上,如图所示,我们可能根本不想显示可预订详情,将此信息保留在专门的“可预订”页面上。

图 6.8 可预订项列表(在左侧)也用于预订页面上。

为了能够独立使用BookableList UI 的列表和详情部分,我们将为所选可预订项的详情创建一个单独的组件。BookablesList组件将继续显示组、可预订项列表和“下一步”按钮,但新的BookableDetails组件将显示详情并管理“显示详情”复选框。

当前BookablesPage组件导入并渲染BookablesList组件。我们需要做一些调整,以便使用新的列表版本以及BookableDetails组件。

6.2.2 在页面 UI 中组织多个组件

BookablesListBookableDetails组件都需要访问所选的可预订项。我们创建了一个BookablesView组件来包装列表和详情,并管理共享状态。表 6.1 列出了我们日益增长的组件,并概述了它们如何协同工作。

表 6.1 可预订组件及其协同工作方式

组件 目的
BookablesPage 显示BookablesView组件(以及稍后用于添加和编辑可预订项的表单)
BookablesView BookablesListBookableDetails组件分组,并管理它们的共享状态
BookablesList 通过分组显示可预订项列表,并允许用户通过点击可预订项或使用“下一步”按钮来选择可预订项
BookableDetails 显示选定可预订项的详细信息,并带有用于切换可预订项可用性显示的复选框

在第 6.3 节和第 6.4 节中,我们将探讨两种将状态提升到BookablesView组件的方法:

  • 将现有的 reducer 从BookablesList提升到BookablesView组件

  • 将选定的可预订项从BookablesList提升到BookablesView组件

首先,如以下列表所示,我们更新页面组件以导入并显示BookablesView而不是BookablesList

分支:0601-lift-reducer,文件:src/components/Bookables/BookablesPage.js

列表 6.5 BookablesPage组件

import BookablesView from "./BookablesView";     ❶

export default function BookablesPage () {
  return (
    <main className="bookables-page">
      <BookablesView/>                           ❷
    </main>
  );
}

❶ 导入新组件。

❷ 使用新组件。

在不同的仓库分支上,我们将为两种状态共享方法中的每一种创建一个不同的BookablesView组件版本。BookableDetails组件将保持不变,所以让我们先构建它。

6.2.3 创建可预订详情组件

新的BookableDetails组件执行与旧BookablesList组件 UI 的后半部分完全相同的任务;它显示选定可预订项的详细信息以及一个用于切换部分信息的复选框。图 6.9 显示了带有复选框、可预订项标题、备注和可用性的BookableDetails组件。

图 6.9 带有复选框、标题、备注和可用性的BookableDetails组件

如图 6.10 所示,BookablesView组件传递选定的可预订项,以便BookableDetails能够显示所需的信息。

图 6.10 BookablesView管理共享状态并将选定的可预订项传递给BookableDetails

新组件的代码如下所示。该组件接收选定的可预订项作为属性,但管理自己的hasDetails状态值。

分支:0601-lift-reducer,文件:src/components/Bookables/BookableDetails.js

列表 6.6 BookableDetails组件

import {useState} from "react"; 
import {days, sessions} from "../../static.json";
export default function BookableDetails ({bookable}) {    ❶
  const [hasDetails, setHasDetails] = useState(true);     ❷

  function toggleDetails () {
    setHasDetails(has => !has);                           ❸
  }
  return bookable ? (
    <div className="bookable-details item">
      <div className="item-header">
        <h2>{bookable.title}</h2>
        <span className="controls">
          <label>
            <input
              type="checkbox"
              onChange={toggleDetails}                    ❹
              checked={hasDetails}                        ❺
            />
            Show Details
          </label>
        </span>
      </div>
      <p>{bookable.notes}</p>
      {hasDetails && (                                    ❻
        <div className="item-details">
          <h3>Availability</h3>
          <div className="bookable-availability">
            <ul>
              {bookable.days
                .sort()
                .map(d => <li key={d}>{days[d]}</li>)
              }
            </ul>
            <ul>
              {bookable.sessions
                .map(s => <li key={s}>{sessions[s]}</li>)
              }
            </ul>
          </div>
        </div>
      )}
    </div>
  ) : null;
}

❶ 通过属性接收当前可预订项。

❷ 使用本地状态来保存hasDetails标志。

❸ 使用更新器函数来切换hasDetails标志。

❹ 当点击复选框时切换hasDetails标志。

❺ 使用hasDetails标志来设置复选框。

❻ 使用hasDetails标志来显示或隐藏可用性部分。

BookablesView组件中的其他组件不关心hasDetails状态值,因此将其完全封装在BookableDetails中是很有意义的。如果一个组件是某个特定状态的唯一使用者,那么将那个状态放在组件中似乎是一个明显的做法。

BookableDetails是一个简单的组件,它只显示选定的可预订项。只要它接收到那个状态值,它就满意了。BookablesView组件如何管理这个状态则是一个更开放的问题;它应该调用useStateuseReducer还是两者都调用?接下来的两个部分将探讨两种方法。第 6.4 节对 reducer 进行了相当多的修改以去除它。但首先,第 6.3 节采取了一条更简单的路径,并使用了现有的BookablesList中的 reducer,将其提升到BookablesView组件中。

6.3 从 useReducer 共享状态和 dispatch 函数

我们已经有一个 reducer 来管理BookablesList组件的所有状态变化。reducer 管理的状态包括可预订项数据、选定的组以及选定可预订项的索引,以及加载和错误状态的属性。如果我们把 reducer 提升到BookablesView组件中,我们可以使用 reducer 返回的状态来推导出选定的可预订项并将其传递给子组件,如图 6.11 所示。

图 6.11 BookablesView使用 reducer 管理状态,并将选定的可预订项或整个状态传递给其子组件。

虽然BookableDetails只需要选定的可预订项,但BookablesList需要 reducer 返回的其余状态以及用户选择可预订项和切换组时继续 dispatch 动作的方式。图 6.11 还显示了BookablesView将 reducer 的状态和 dispatch 函数传递给BookablesList

将状态从BookablesList提升到BookablesView组件相对直接。我们分三步完成它:

  • BookablesView组件中管理状态

  • 从 reducer 中移除一个动作

  • BookablesList组件中接收状态和 dispatch

让我们先更新BookablesView组件以控制状态。

6.3.1 在 BookablesView 组件中管理状态

BookablesView组件需要导入它的两个子组件。然后它可以向它们传递它们所需的状态以及更新该状态的途径。在下面的列表中,你可以看到新组件的导入、BookablesView管理的状态、对useReducer钩子的调用,以及作为 JSX 的 UI,状态值和 dispatch 函数被设置为 props。

分支:0601-lift-reducer,文件:src/components/Bookables/BookablesView.js

列表 6.7 将可预订项状态移动到BookablesView组件

import {useReducer, Fragment} from "react";

import BookablesList from "./BookablesList";                     ❶
import BookableDetails from "./BookableDetails";                 ❶

import reducer from "./reducer";                                 ❷

const initialState = {                                           ❸
  group: "Rooms",
  bookableIndex: 0,
  bookables: [],
  isLoading: true,
  error: false
};

export default function BookablesView () { 
 const [state, dispatch] = useReducer(reducer, initialState);   ❹

  const bookablesInGroup = state.bookables.filter(               ❺
    b => b.group === state.group                                 ❺
  );                                                             ❺
  const bookable = bookablesInGroup[state.bookableIndex];        ❺

  return (
    <Fragment>
      <BookablesList state={state} dispatch={dispatch}/>         ❻
      <BookableDetails bookable={bookable}/>                     ❼
    </Fragment>
  ); 
}

❶ 导入构成 UI 的所有组件。

❷ 导入 BookablesList 使用的 reducer。

❸ 设置初始状态,不包含 hasDetails。

❹ 在 BookablesView 中管理状态和 reducer。

❺ 从状态中推导出选定的可预订项。

❻ 将状态和 dispatch 传递给 BookablesList。

❼ 将选定的可预订项传递给 BookableDetails。

BookablesView组件导入它需要的子组件并设置初始状态,该状态原本位于BookablesList组件中。我们已经从状态中移除了hasDetails属性;新的BookableDetails组件管理是否显示详细信息的自身状态。

6.3.2 从减法器中删除动作

随着BookableDetails组件愉快地切换自己的详细信息,减法器不再需要处理用于切换共享hasDetails状态值的动作,因此可以从 reducer.js 中删除以下情况:

case "TOGGLE_HAS_DETAILS":
  return {
    ...state,
    hasDetails: !state.hasDetails
  };

除了这些,减法器可以保持原样。太棒了!

6.3.3 在 BookablesList 组件中接收状态和分发

BookablesList组件需要进行一些调整。它现在依赖于BookablesView组件(或任何其他渲染它的父组件),而不是依赖于它自己的本地减法器和动作。BookablesList的代码相对较长,所以我们按部分考虑。代码的结构看起来像这样:

export default function BookablesList ({state, dispatch}) {
  // 1\. Variables
  // 2\. Effect
  // 3\. Handler functions
  // 4\. UI
}

以下四个小节讨论了必要的任何更改。如果您将这些部分拼接在一起,您将拥有完整的组件。

变量

除了两个新属性statedispatch外,BookablesList组件中的变量没有其他添加。但是,由于减法器提升到BookablesView组件,并且不再需要显示可预订的详细信息,因此有一些删除。以下列表显示了剩余的内容。

分支:0601-lift-reducer,文件:src/components/Bookables/BookablesList.js

列表 6.8 BookablesList:1. 变量

import {useEffect, useRef} from "react";
import {FaArrowRight} from "react-icons/fa";
import Spinner from "../UI/Spinner";
import getData from "../../utils/api";

export default function BookablesList ({state, dispatch}) {          ❶
  const {group, bookableIndex, bookables} = state;
  const {isLoading, error} = state;

  const bookablesInGroup = bookables.filter(b => b.group === group);
  const groups = [...new Set(bookables.map(b => b.group))];

  const nextButtonRef = useRef();

  // 2\. Effect
  // 3\. Handler functions
  // 4\. UI
}

❶ 将状态和分发属性分配给局部变量。

减法器及其初始状态以及hasDetails标志都不再存在。最后,我们不再需要显示可预订的详细信息,因此我们移除了bookable变量。

影响

除了一个小细节外,效果几乎没有任何变化。在以下列表中,您可以看到我们已将dispatch函数添加到效果依赖数组中。

分支:0601-lift-reducer,文件:src/components/Bookables/BookablesList.js

列表 6.9 BookablesList:2. 影响

export default function BookablesList ({state, dispatch}) {   ❶
  // 1\. Variables

  useEffect(() => {
    dispatch({type: "FETCH_BOOKABLES_REQUEST"});

    getData("http://localhost:3001/bookables")
      .then(bookables => dispatch({
        type: "FETCH_BOOKABLES_SUCCESS",
        payload: bookables
      }))
      .catch(error => dispatch({
        type: "FETCH_BOOKABLES_ERROR",
        payload: error
      }));
  }, [dispatch]);                                              ❷

  // 3\. Handler functions
  // 4\. UI
}

❶ 将分发属性分配给局部变量。

❷ 将分发函数包含在效果的依赖数组中。

在上一个版本中,当我们从BookablesList组件内部调用useReducer并将分发函数分配给dispatch变量时,React 知道分发函数的身份永远不会改变,因此不需要将其声明为效果的依赖项。现在,由于父组件将dispatch作为属性传入,BookablesList不知道它从何而来,因此无法确定它不会改变。省略dispatch会导致浏览器控制台出现如图 6.12 所示的警告。

图 6.12 React 在分发从依赖数组中缺失时警告我们。

在依赖数组中包含dispatch是一种良好的实践;我们知道它不会改变(至少现在是这样),因此效果不会不必要地运行。注意图 6.12 中的警告说“如果dispatch变化得太频繁,找到定义它的父组件,并将该定义包裹在useCallback中。”我们将在第 6.5 节中查看使用useCallback钩子来保持函数依赖项的标识。

处理函数

现在选定的可预订项的详细信息由不同的组件显示,我们可以移除toggleDetails处理函数。其他一切保持不变。简单!

UI

再见,bookableDetails div!我们完全切除了 UI 的第二部分,用于显示可预订的详细信息。以下列表显示了更新后的、超级精简的BookablesList UI。

分支:0601-lift-reducer,文件:src/components/Bookables/BookablesList.js

列表 6.10 BookablesList:4. UI

export default function BookablesList ({state, dispatch}) {
  // 1\. Variables
  // 2\. Effect     
  // 3\. Handler functions

  if (error) {
    return <p>{error.message}</p>
  }

  if (isLoading) {
    return <p><Spinner/> Loading bookables...</p>
  }

  return (
    <div>
      <select value={group} onChange={changeGroup}>
        {groups.map(g => <option value={g} key={g}>{g}</option>)}
      </select>
      <ul className="bookables items-list-nav">
        {bookablesInGroup.map((b, i) => (
          <li
            key={b.id}
            className={i === bookableIndex ? "selected" : null}
          >
            <button
              className="btn"
              onClick={() => changeBookable(i)}
            >
              {b.title}
            </button>
          </li>
        ))}
      </ul>
      <p>
        <button
          className="btn"
          onClick={nextBookable}
          ref={nextButtonRef}
          autoFocus
        >
          <FaArrowRight/>
          <span>Next</span>
        </button>
      </p>
    </div>
  );
}

UI 中剩下的只是可预订列表及其关联的分组选择器和下一步按钮。因此,我们也移除了将两个大块 UI 分组的Fragment组件。

当可预订的详细信息独立行动,并且 reducer 提升到父组件时,对BookablesList组件的更改主要采取了删除的形式。一个关键的增加是将dispatch包含在数据加载效果的依赖数组中。将状态存储在BookablesView组件中(或者甚至更高层次的组件树中)看起来很简单。把所有数据都放在那里,并将 dispatch 函数传递给任何需要更改状态的子组件。这是一个有效的方法,有时也被 Redux 等流行状态存储库的用户使用。但在将所有状态提升到应用顶部之前,即使大多数组件不关心最终存储在那里的大多数状态,让我们先调查一个替代方案。

6.4 从 useState 共享状态值和更新函数

在本节中,我们尝试了一种不同的方法。我们只提升需要共享的状态:选定的可预订项。图 6.13 显示了BookablesView组件将选定的可预订项传递给其两个子组件。BookableDetailsBookablesList组件仍然得到他们需要的,而且BookablesList不会给BookablesView提供它不需要共享的大量状态,而是将剩余的状态和所需的功能管理起来:加载指示器和错误。

图 6.13 BookablesView只管理共享状态。它将可预订项传递给BookableDetails组件。它将可预订项及其更新函数传递给BookablesList

将选定的可预订项从BookablesList提升到BookablesView组件在BookablesView中需要做的工作要少得多,但在BookablesList中需要进行许多更改。我们分两步完成这些更改:

  • BookablesView组件中管理选定的可预订项

  • BookablesList中接收可预订项和更新函数

BookablesList组件仍然需要一种方式来让BookablesView知道用户已选择一个新的可预订项。BookablesView将选中的可预订项的更新函数传递给BookablesList。让我们更仔细地看看BookablesView组件的最新代码。

6.4.1 在BookablesView组件中管理选中的可预订项

如列表 6.11 所示,这个版本的BookablesView组件非常简单;它不需要处理 reducer、初始状态或从状态中推导出选中的可预订项。它包含一个对useState钩子的调用,以管理选中的可预订项状态值。然后,它将选中的可预订项传递给子组件和更新函数到BookablesList。当用户选择一个可预订项时,BookablesList组件可以使用更新函数让BookablesView知道状态已更改。

分支:0602-lift-bookable,文件:/src/components/Bookables/BookablesView.js

列表 6.11 将选中的可预订项放入BookablesView组件

import {useState, Fragment} from "react";

import BookablesList from "./BookablesList";
import BookableDetails from "./BookableDetails";

export default function BookablesView () { 
  const [bookable, setBookable] = useState();                           ❶

  return (
    <Fragment>
      <BookablesList bookable={bookable} setBookable={setBookable}/>    ❷
      <BookableDetails bookable={bookable}/>                            ❸
    </Fragment>
  );
}

❶ 将选中的可预订项作为状态值管理。

❷ 将可预订项及其更新函数向下传递。

❸ 将可预订项向下传递。

BookablesView不再需要为当前组过滤可预订项或从该过滤列表中获取当前可预订项。让我们看看BookablesList如何适应新的方法。

6.4.2 在BookablesList中接收可预订项和更新函数

通过让BookablesView组件管理选中的可预订项,我们改变了BookablesList组件的工作方式。在 reducer 版本中,BookablesViewbookableIndexgroup作为状态的一部分存储。现在,由于BookablesList直接接收可预订项,这些状态值不再需要。选中的可预订项看起来像这样:

{
  "id": 1,
  "group": "Rooms",
  "title": "Meeting Room",
  "notes": "The one with the big table and interactive screen.",
  "days": [1, 2, 3],
  "sessions": [1, 2, 3, 4, 5, 6]
}

它包括一个id和一个group属性。无论选中的可预订项在哪个组中,都是当前组;我们不需要单独的group状态值。此外,很容易在组内的可预订项数组中找到选中可预订项的索引;我们不需要bookableIndex状态值。由于不再需要groupbookableIndexhasDetails状态值,结果是一个更小、更简单的状态,让我们切换回使用useState调用而不是 reducer。

BookablesList组件的所有部分都进行了更改,因此我们按部分考虑代码。代码的结构看起来像这样:

export default function BookablesList ({bookable, setBookable}) {
  // 1\. Variables
  // 2\. Effect
  // 3\. Handler functions
  // 4\. UI
}

接下来的四个小节中的每一个都讨论了一个代码部分。如果你将这些部分拼接在一起,你将拥有完整的组件。

变量

BookablesList组件现在接收选中的可预订项作为属性。选中的可预订项包括一个id和一个group属性。我们使用group属性来过滤列表,并使用id来突出显示选中的可预订项。

以下列表显示了更新的 BookablesList 组件接收 bookablesetBookable 作为属性,并通过三次调用 useState 设置三件本地状态。

分支:0602-lift-bookable,文件:/src/components/Bookables/BookablesList.js

列表 6.12 BookablesList:1. 变量

import {useState, useEffect, useRef} from "react";                    ❶
import {FaArrowRight} from "react-icons/fa"; 
import Spinner from "../UI/Spinner";
import getData from "../../utils/api";

export default function BookablesList ({bookable, setBookable}) {     ❷
  const [bookables, setBookables] = useState([]);                     ❸
  const [error, setError] = useState(false);                          ❸
  const [isLoading, setIsLoading] = useState(true);                   ❸

 const group = bookable?.group;                                      ❹

  const bookablesInGroup = bookables.filter(b => b.group === group);
  const groups = [...new Set(bookables.map(b => b.group))];
  const nextButtonRef = useRef();
  // 2\. Effect
  // 3\. Handler functions
  // 4\. UI
}

❶ 导入 useState 而不是 useReducer

❷ 接收选定的可预订项和更新函数作为属性。

❸ 通过对 useState 钩子的调用管理状态。

❹ 从选定的可预订项获取当前组。

列表 6.12 通过使用 可选链操作符?.,从 JavaScript 中最近添加的一个,获取选定的可预订项的当前组:

const group = bookable?.group;

如果没有选择可预订项,表达式 bookable?.group 返回 undefined。它在我们访问 group 属性之前避免了检查可预订项是否存在:

const group = bookable && bookable.group;

在选择可预订项之前,组将是 undefined,而 bookablesInGroup 将是一个空数组。我们需要在将可预订项数据加载到组件中后立即选择一个可预订项。让我们看看加载过程。

影响

以下列表显示了更新的影响代码。现在它使用更新函数而不是发送动作。

分支:0602-lift-bookable,文件:/src/components/Bookables/BookablesList.js

列表 6.13 BookablesList:2. 影响

export default function BookablesList ({bookable, setBookable}) {
  // 1\. Variables

  useEffect(() => {
    getData("http://localhost:3001/bookables")

      .then(bookables => {
        setBookable(bookables[0]);       ❶
        setBookables(bookables);         ❷
 setIsLoading(false);
      })

      .catch(error => {
 setError(error); ❸
        setIsLoading(false)
      });

  }, [setBookable]);                     ❹

  // 3\. Handler functions
  // 4\. UI
}

❶ 使用 setBookable 属性选择第一个可预订项。

❷ 使用本地更新函数设置可预订项状态。

❸ 如果有错误,设置错误状态。

❹ 将外部函数包含在依赖列表中。

第一个影响仍然使用在第四章中创建的 getData 工具函数来加载可预订项。但是,它不是向 reducer 发送动作,而是使用列表中的所有四个更新函数:setBookable(作为属性传入)和 setBookablessetIsLoading 以及 setError(通过本地调用 useState)。

当数据加载时,它将数据分配给可预订项状态值,并使用数组中的第一个可预订项调用 setBookable

setBookable(bookables[0]);
setBookables(bookables);
setIsLoading(false);

React 能够合理地响应多个状态更新调用,如刚刚列出的三个。它可以批量更新以有效地安排所需的任何重新渲染和 DOM 更改。

正如我们在第 6.3 节中关于 reducer 版本的 dispatch 属性所看到的,React 不信任作为属性传入的函数在每个渲染中都是相同的。在这个版本中,BookingsViewsetBookable 函数作为属性传入,因此我们将其包含在第一个影响的依赖数组中。实际上,我们有时可能定义自己的更新函数而不是直接使用 useState 返回的函数。我们将在第 6.5 节中介绍如何使这些函数作为依赖项很好地工作,那里我们将介绍 useCallback 钩子。

如果在加载数据的过程中抛出了错误,catch 方法将其设置为错误状态值:

.catch(error => {
  setError(error);
  setIsLoading(false);
);

处理函数

BookablesList 组件的先前版本中,处理函数向 reducer 发送动作。在这个新版本中,处理函数的主要任务是设置可预订项。在下面的列表中,注意每个处理函数都包含对 setBookable 的调用。

分支:0602-lift-bookable,文件:/src/components/Bookables/BookablesList.js

列表 6.14 BookablesList: 3. 处理函数

export default function BookablesList ({bookable, setBookable}) {
  // 1\. Variables
  // 2\. Effect

  function changeGroup (e) {
    const bookablesInSelectedGroup = bookables.filter(
      b => b.group === event.target.value                   ❶
    );
    setBookable(bookablesInSelectedGroup[0]);               ❷
  }

  function changeBookable (selectedBookable) {
    setBookable(selectedBookable);
    nextButtonRef.current.focus();
  }

  function nextBookable () {
    const i = bookablesInGroup.indexOf(bookable);
    const nextIndex = (i + 1) % bookablesInGroup.length;
    const nextBookable = bookablesInGroup[nextIndex];
    setBookable(nextBookable);
  }

  // 4\. UI
}

❶ 过滤所选组。

❷ 将可预订项设置为新组中的第一个。

当前组是从所选可预订项派生出来的;我们不再有 group 状态值。因此,当用户从下拉列表中选择一个组时,changeGroup 函数不会直接设置新组。相反,它选择所选组中的第一个可预订项:

setBookable(bookablesInSelectedGroup[0]);

setBookable 更新器函数来自 BookablesView 组件,并触发 BookablesView 的重新渲染。BookablesView 然后,重新渲染 BookablesList 组件,并将新选定的可预订项作为属性传递给它。BookablesList 组件使用可预订项的 groupid 属性来在下拉列表中选择正确的组,仅显示该组中的可预订项,并在列表中突出显示所选的可预订项。

changeBookable 函数没有惊喜:它设置了所选的可预订项并将焦点移动到“下一步”按钮。除了将可预订项设置为当前组中的下一个之外,nextBookable 如果需要会回滚到第一个。

UI

我们不再在状态中有 bookableIndex 值。下面的列表显示了我们是如何使用可预订项 id 的。

分支:0602-lift-bookable,文件:/src/components/Bookables/BookablesList.js

列表 6.15 BookablesList: 4. UI

export default function BookablesList ({bookable, setBookable}) {
  // 1\. Variables
  // 2\. Effect
  // 3\. Handler functions

  if (error) {
    return <p>{error.message}</p>
  }

  if (isLoading) {
    return <p><Spinner/> Loading bookables...</p>
  }

  return (
    <div>
      <select value={group} onChange={changeGroup}>
        {groups.map(g => <option value={g} key={g}>{g}</option>)}
      </select>

      <ul className="bookables items-list-nav">
        {bookablesInGroup.map(b => (
          <li
            key={b.id}
            className={b.id === bookable.id ? "selected" : null}   ❶
          >
            <button
              className="btn"
              onClick={() => changeBookable(b)}                    ❷
            >
              {b.title}
            </button>
          </li>
        ))}
      </ul>
      <p>
        <button
          className="btn"
          onClick={nextBookable}
          ref={nextButtonRef}
          autoFocus
        >
          Next
        </button>
      </p>
    </div>
  );
}

❶ 使用 ID 检查是否应该突出显示可预订项。

❷ 将可预订项传递给 changeBookable 处理函数。

在可预订项列表中发生了一些关键的 UI 变化。代码遍历与所选可预订项相同的组中的可预订项。一个接一个地,组中的可预订项被分配给 b 变量。bookable 变量代表所选的可预订项。如果 b.idbookable.id 相同,列表中的当前可预订项应该被突出显示,因此我们将其类设置为 selected

className={b.id === bookable.id ? "selected" : null}

当用户点击可预订项以选择它时,onClick 处理函数将整个可预订项对象 b 传递给 changeBookable 函数,而不仅仅是可预订项的索引:

onClick={() => changeBookable(b)}

再次没有使用 reducer 的 BookablesList 组件。虽然做了一些改动,但鉴于其仅列出可预订项的更专注的角色,整体上它也更简单。

你觉得哪种方法更容易理解?在父组件中将动作派发到 reducer,还是在使用它的组件中管理大部分状态?在第一种方法中,我们没有做太多修改就将 reducer 移到了 BookablesView 组件。我们能否以与第二种方法中变量相同的方式简化 reducer 中持有的状态?无论你更喜欢哪种实现方式,本章都给了你练习调用 useStateuseReduceruseEffect 钩子,并考虑传递给子组件的派发和更新函数的一些细微差别的机会。

挑战 6.1

UsersList 组件拆分为 UsersListUserDetails 组件。使用 UsersPage 组件来管理选定的用户,将其传递给 UsersListUserDetails。在 0603-user-details 分支中找到解决方案。

6.5 将函数传递给 useCallback 以避免重新定义它们

现在我们应用程序正在增长,并且组件正在协同工作以提供功能,将状态值向下传递给子组件作为属性是很自然的。正如我们在本章中看到的,这些值可以包括函数。如果这些函数是来自 useStateuseReducer 的更新器或派发函数,React 保证它们的身份将是稳定的。但对于我们自己定义的函数,组件作为 React 调用的函数的本质意味着我们的函数将在每次渲染时定义。在本节中,我们探讨了这种重新定义可能引起的问题,并查看了一个新的钩子 useCallback,它可以帮助解决这些问题。

6.5.1 依赖于我们传递给属性中的函数

在上一节中,所选可预订项的状态由 BookablesView 组件管理。它将可预订项及其更新器函数 setBookable 传递给 BookablesListBookablesList 在用户选择可预订项时调用 setBookable,并在包含数据获取代码的效果中调用,这里没有包含 catch 块:

useEffect(() => {
  getData("http://localhost:3001/bookables")
    .then(bookables => {
      setBookable(bookables[0]);     ❶
      setBookables(bookables);
      setIsLoading(false);
    });
}, [setBookable]);                   ❷

❶ 数据到达后,将当前可预订项设置为第一个。

❷ 将 setBookable 函数作为依赖项包含。

我们将 setBookable 更新器函数作为依赖项包含。每当其依赖项列表中的值发生变化时,该效果会重新运行。但到目前为止,setBookable 一直是由 useState 返回的更新器函数,因此保证其值不会改变;数据获取效果只运行一次。

父组件 BookablesView 将更新器函数分配给 setBookable 变量,并将其直接设置为 BookablesList 的属性之一。但在更新状态之前进行某种类型的验证或处理值并不罕见。假设 BookablesView 想要检查是否存在可预订项,如果存在,则在更新状态之前添加一个时间戳属性。以下列表显示了这样的自定义设置器。

列表 6.16 在设置状态之前验证和增强 BookablesView 中的值

import {useState, Fragment} from "react";

import BookablesList from "./BookablesList";
import BookableDetails from "./BookableDetails";

export default function BookablesView () {
  const [bookable, setBookable] = useState();

  function updateBookable (selected) {
    if (selected) {                                                        ❶
      selected.lastShown = Date.now();                                     ❷
      setBookable(selected);                                               ❸
    }
  }

  return (
    <Fragment>
      <BookablesList bookable={bookable} setBookable={updateBookable}/>    ❹
      <BookableDetails bookable={bookable}/>
    </Fragment>
  );
}

❶ 检查是否存在可预订项。

❷ 添加时间戳属性。

❸ 设置状态。

❹ 将我们的处理函数作为更新属性传递。

BookablesView 现在将自定义的 updateBookable 函数作为 setBookable 属性分配给 BookablesListBookablesList 组件对此毫不在意,并且随时调用新的更新函数来选择可预订项。那么,问题是什么?

如果你更新代码以使用新的更新函数并加载可预订项页面,开发者工具的网络标签页将突出显示一些令人不安的活动:可预订项被反复获取,如图 6.14 所示。

图片

图 6.14 开发者工具的网络标签页显示了可预订项被反复获取。

父组件 BookablesView 管理所选可预订项的状态。每当 BookablesList 加载可预订项数据并设置可预订项时,BookablesView 会重新渲染;React 会再次运行其代码,重新定义 updateBookable 函数并将新版本的函数传递给 BookablesListBookablesList 中的 useEffect 调用会看到 setBookable 属性是一个新函数,并再次运行效果,重新获取可预订项数据并再次设置可预订项,重新启动循环。我们需要一种方法来保持我们的更新函数的身份,使其在渲染之间不发生变化。

6.5.2 使用 useCallback 钩子保持函数身份

当我们想要在渲染之间使用相同的函数,但又不希望每次都重新定义它时,我们可以将函数传递给 useCallback 钩子。React 将在每次渲染时从钩子返回相同的函数,只有在函数的依赖项之一发生变化时才会重新定义它。使用钩子的方式如下:

const stableFunction = useCallback(funtionToCache, dependencyList);

当依赖项列表中的值不改变时,useCallback 返回的函数是稳定的。当依赖项改变时,React 会重新定义、缓存并返回使用新依赖项值的函数。下面的列表显示了如何使用新的钩子来解决我们的无限获取问题。

列表 6.17 使用 useCallback 保持稳定的函数身份

import {useState, useCallback, Fragment} from "react";                    ❶

import BookablesList from "./BookablesList";
import BookableDetails from "./BookableDetails";

export default function BookablesView () {
  const [bookable, setBookable] = useState();

  const updateBookable = useCallback(selected => {                        ❷
    if (selected) {
      selected.lastShown = Date.now();
      setBookable(selected);
    }
  }, []);                                                                 ❸

  return (
    <Fragment>
      <BookablesList bookable={bookable} setBookable={updateBookable}/>   ❹
      <BookableDetails bookable={bookable}/>
    </Fragment>
  );
}

❶ 导入 useCallback 钩子。

❷ 将更新函数传递给 useCallback

❸ 指定依赖项。

❹ 将稳定的函数作为属性分配。

将我们的更新函数包裹在 useCallback 中意味着 React 将在每次渲染时返回相同的函数,除非依赖项的值发生变化。但我们已经使用了一个空的依赖项列表,因此值永远不会改变,React 将始终返回完全相同的函数。BookablesList 中的 useEffect 调用现在会看到其 setBookable 依赖项是稳定的,并且它将停止无限期地重新获取可预订项数据。

当与仅在它们的属性更改时重新渲染的组件一起工作时,useCallback 钩子可以非常有用。这些组件可以使用 React 的 memo 函数创建,这在 React 文档中有描述:reactjs.org/docs/react-api.html#reactmemo

useCallback允许我们缓存函数。为了更普遍地防止值的重新定义或重新计算,React 还提供了useMemo钩子,我们将在下一章中探讨这一点。

摘要

  • 如果组件共享相同的状态值,将值提升到组件树中最接近的共享祖先组件,并通过属性传递状态:

    const [bookable, setBookable] = useState();
    return (
      <Fragment>
        <BookablesList bookable={bookable}/>
        <BookableDetails bookable={bookable}/>
      </Fragment>
    );
    
  • 如果子组件需要更新共享状态,请将useState返回的更新函数传递给它们:

    const [bookable, setBookable] = useState();
    return <BookablesList bookable={bookable} setBookable={setBookable} />
    
  • 解构属性参数,将属性分配给局部变量:

    export default function ColorPicker({colors = [], color, setColor}) {
      return (
        // UI that uses colors, color and setColor
      );
    } 
    
  • 考虑为属性使用默认值。如果属性未设置,将使用默认值:

    export default function ColorPicker({colors = [], color, setColor}) {
      return (
        // iterate over colors array
      );
    }
    
  • 检查undefinednull属性值。如果适当,返回替代 UI:

    export default function ChoiceText({color}) {
      return color ? (
        <p>The selected color is {color}!</p>
      ) : (
        <p>No color has been selected!</p>
      );
    }
    
  • 当适当渲染无内容时,返回null

  • 要让子组件更新由父组件管理的状态,向子组件传递一个更新函数或一个分发函数。如果函数在效果中使用,请将函数包含在效果依赖列表中。

  • 通过将函数包装在useCallback钩子的调用中来保持函数在渲染之间的身份。React 仅在依赖项更改时重新定义函数:

    const stableFunction = useCallback(functionToCache, dependencyList);
    

7 使用 useMemo 管理性能

本章涵盖

  • 使用 useMemo 钩子避免重新运行昂贵的计算

  • 使用依赖数组控制 useMemo

  • 考虑用户体验作为你的应用重新渲染

  • 在获取数据时处理竞态条件

  • 使用 JavaScript 的可选链语法与方括号

React 在以高效、吸引人和响应式的方式显示数据方面做得很好。但将原始数据直接扔到屏幕上是很罕见的。无论我们的应用是统计的、金融的、科学的、娱乐的还是异想天开的,我们几乎总是在将其呈现出来之前操纵我们的数据。

有时这种操作可能很复杂或耗时。如果花费的时间和资源是使数据生动起来的必要条件,那么结果可能会弥补成本。但如果我们的计算降低了用户体验,我们需要考虑简化代码的方法。也许寻找更高效的算法会带来回报,或者也许我们的算法已经足够高效,没有方法使它们更快。无论如何,如果我们知道它们的输出将不会改变,我们就根本不应该执行这些计算。在这种情况下,React 提供了 useMemo 钩子来帮助我们避免不必要的和浪费的工作。

我们从本章开始就故意浪费资源,冒着用一些资源密集型的字母组合生成操作使浏览器崩溃的风险。我们调用 useMemo 来保护用户免受一些严重缓慢的 UI 更新的影响。然后我们在示例应用程序中将预订内容生动起来,这次调用 useMemo 来避免无理由地重新生成预订格子的网格。在获取所选周和可预订的预订时,我们检查在 useEffect 调用内部处理多个请求和响应的方法。

7.1 节标题有点混乱;让我们找出它试图教我们关于 React 钩子的什么。

7.1 通过喊“哦,短饼!”来打破厨师的心

假设你正在尝试开发一个寻找单词、名字和短语有趣字母组合的应用程序。开发过程还处于早期阶段,到目前为止,你有一个可以找到某些源文本中所有字母组合的应用程序。在图 7.1 中,你的初出茅庐的应用程序正在显示源文本 ball 的 12 个不同的字母组合。该应用程序已在 CodeSandbox 上实时运行 (codesandbox.io/s/anagrams-djwuy)。

图片

图 7.1 Anagrams 应用程序计算并显示用户输入文本的字母组合。用户可以计算所有字母组合或仅计算不同的字母组合,并且可以切换字母组合的显示。

你可以在全部排列和不同排列之间切换。例如,因为“ball”有一个重复的字母“l”,你可以交换它们的位置,仍然得到单词“ball”。在全部排列类别中,这两个相同的单词被单独计算,但在不同排列类别中则不是。你还可以隐藏生成的排列,让你在输入源文本时,应用在幕后找到新的排列,而无需在输入时渲染新的排列。

注意!随着源文本中字母数量的增加,排列的数量会急剧增加。有 n!(n 的阶乘)种 n 个字母的组合。对于四个字母,那是 4 × 3 × 2 × 1 = 24 种组合。对于十个字母,有 10!,即 3,628,800 种组合,如图 7.2 所示。应用限制为十个字符——移除限制请自行承担风险!

图片

图 7.2 注意!随着源文本长度的增加,排列的数量会迅速增加。一个 10 个字母的单词有超过 350 万个排列。

7.1.1 使用昂贵的算法生成排列

同事为你提供了查找排列的代码。算法如下所示。它当然可以改进。但无论算法如何,你只想在绝对必要时才执行这种昂贵的计算。

Live: djwuy.csb.app/Code: codesandbox.io/s/anagrams-djwuy

列表 7.1 查找排列

export function getAnagrams(source) {             ❶
  if (source.length < 2) {
    return [...source];
  }
  const anagrams = [];
  const letters = [...source];

  letters.forEach((letter, i) => {
    const without = [...letters];
    without.splice(i, 1);
    getAnagrams(without).forEach(anagram => {     ❷
      anagrams.push(letter + anagram);
    });
  });

  return anagrams;
}

export function getDistinct(anagrams) {           ❸
  return [...new Set(anagrams)];
}

❶ 创建一个函数来查找某些源文本中字母的所有组合。

❷ 在移除一个字母后,递归地调用源文本上的函数。

❸ 创建一个从数组中删除重复项的函数。

该算法取单词中的每个字母,并附加剩余字母的所有排列。所以,对于“ball”来说,它会找到以下内容:

“b” + “all” 的排列

“a” + “bll” 的排列

“l” + “bal” 的排列

“l” + “bal” 的排列

主应用调用 getAnagramsgetDistinct 来获取显示所需的信息。以下列表是一个早期的实现。你能发现任何问题吗?

列表 7.2 修复前的排列应用

import React, { useState } from "react";
import "./styles.css";
import { getAnagrams, getDistinct } from "./anagrams";                    ❶

export default function App() {
  const [sourceText, setSourceText] = useState("ball");                   ❷
  const [useDistinct, setUseDistinct] = useState(false);                  ❸
  const [showAnagrams, setShowAnagrams] = useState(false);                ❸

  const anagrams = getAnagrams(sourceText);                               ❹
  const distinct = getDistinct(anagrams);                                 ❹

  return (
    <div className="App">
      <h1>Anagrams</h1>
      <label htmlFor="txtPhrase">Enter some text...</label>
      <input
        type="text"
        value={sourceText}
        onChange={e => setSourceText(e.target.value.slice(0, 10))}        ❺
      />
      <div className="count">                                             ❻
        {useDistinct ? (                                                  ❻
          <p>                                                             ❻
            There are {distinct.length} distinct anagrams.                ❻
          </p>                                                            ❻
        ) : (                                                             ❻
          <p>                                                             ❻
            There are {anagrams.length} anagrams of "{sourceText}".       ❻
          </p>                                                            ❻
        )}                                                                ❻
      </div>                                                              ❻

      <p>
        <label>
          <input
            type="checkbox"
            checked={useDistinct}
            onClick={() => setUseDistinct(s => !s)}
          />
          Distinct
        </label>
      </p>
      <p>
        <label>
          <input
            type="checkbox"
            checked={showAnagrams}
            onChange={() => setShowAnagrams(s => !s)}
          />
          Show
        </label>
      </p>

      {showAnagrams && (                                                  ❼
        <p className="anagrams">                                          ❼
          {distinct.map(a => (                                            ❼
            <span key={a}>{a}</span>                                      ❼
          ))}                                                             ❼
        </p>                                                              ❼
      )}                                                                  ❼
    </div>
  );
}

❶ 导入排列查找函数。

❷ 管理源文本状态。

❸ 包含用于切换不同排列和排列显示的标志。

❹ 使用排列函数生成数据。

❺ 限制字母的数量。

❻ 显示排列的数量。

❼ 显示排列列表。

关键问题是代码在每次渲染时都会调用昂贵的排列函数。但是,只有当源文本改变时,排列才会改变。当用户点击任一复选框,在全部排列和不同排列之间切换,或者显示和隐藏列表时,你真的不应该再次生成排列。以下是当前对排列函数的调用:

export default function App() {
  // variables

  const anagrams = getAnagrams(sourceText);   ❶
  const distinct = getDistinct(anagrams);     ❶

  return ( /* UI */ )
}

❶ 昂贵的函数在每次渲染时运行。

我们需要一种方法来请求 React 仅在输出可能不同时运行昂贵的函数。对于getAnagrams,这是当sourceText值改变时。对于getDistinct,这是当anagrams数组改变时。

7.1.2 避免冗余函数调用

以下列表显示了实时示例的代码。它将昂贵的函数包装在useMemo钩子的调用中,为每个调用提供依赖项数组。

Live: djwuy.csb.app/, Code: codesandbox.io/s/anagrams-djwuy

列表 7.3 使用useMemo的字母组合应用

import React, {useState, useMemo} from "react";                   ❶
import "./styles.css";
import {getAnagrams, getDistinct} from "./anagrams";

export default function App() {
  const [sourceText, setSourceText] = useState("ball");
  const [useDistinct, setUseDistinct] = useState(false);
  const [showAnagrams, setShowAnagrams] = useState(false);

  const anagrams = useMemo(                                       ❷
    () => getAnagrams(sourceText),                                ❸
    [sourceText]                                                  ❹
  );

  const distinct = useMemo(                                       ❺
    () => getDistinct(anagrams),                                  ❻
    [anagrams]                                                    ❼
  );

  return ( /* UI */ )
}

❶ 导入useMemo钩子。

❷ 调用useMemo

❸ 将昂贵的函数传递给useMemo

❹ 指定依赖项列表。

❺ 将getDistinct返回的值赋给一个变量。

❻ 在另一个函数中包装对getDistinct的调用。

❼ 仅当字母组合数组改变时重新运行getDistinct函数。

在这个版本中,React 应该在sourceText改变时调用getAnagrams,在anagrams改变时调用getDistinct。用户可以随意切换,而不会导致应用在重建相同的百万个字母组合时产生一系列昂贵的调用。

你可以看到最后一个例子,决定没有更多东西可以学习了,然后埋头于沙子中——一些鸸鹋。或者过于胆怯而不敢询问更多细节——老鼠?但是,要勇敢,依靠 React,平息那些昂贵的调用——useMemo

7.2 使用useMemo缓存昂贵的函数调用

如果我们有一个函数expensiveFn,它需要时间和资源来计算其返回值,那么我们只想在绝对必要时调用该函数。通过在useMemo钩子内部调用该函数,我们请求 React 为给定的一组参数存储由该函数计算出的值。如果我们再次在useMemo内部调用该函数,使用与上次调用相同的参数,它应该返回存储的值。如果我们传递不同的参数,它将使用该函数计算新值,并在返回新值之前更新其存储。为给定参数集存储结果的过程称为记忆化

当调用useMemo时,传递给它一个创建函数和依赖项列表,如图 7.3 所示。

图 7.3 使用函数和依赖项列表调用useMemo钩子。

依赖项列表是一个值数组,应包括函数在其计算中使用的所有值。在每次调用中,useMemo会将依赖项列表与之前的列表进行比较。如果每个列表都包含相同顺序的相同值,useMemo可能会返回存储的值。如果列表中的任何值已更改,useMemo将调用函数,存储并返回函数的返回值。再次强调,useMemo可能返回存储的值。React 保留清除其存储以释放内存的权利。因此,即使依赖项未更改,它也可能调用昂贵的函数。

如果您省略了依赖列表,useMemo总是会运行您的函数,这有点违背了初衷!如果您传递一个空数组,列表中的值永远不会改变,因此useMemo可以始终返回存储的值。然而,它可能仍然决定清除其存储并再次运行您的函数。几乎可以肯定,最好避免这种可能或可能不的行为。

这就是useMemo的工作方式。我们再次在 7.4 节的预订示例应用中看到它的作用,用于生成预订槽位的网格。首先,我们使用我们的状态共享和 React Hooks 技能将 Bookings 页面组件放在一起,并传递它们需要一起良好工作的各个部分。

7.3 组织 Bookings 页面上的组件

到目前为止,Bookables 和 Users 页面在预订应用中受到了所有关注;是时候让 Bookings 页面得到一些关注了!我们需要将第六章中的共享状态概念付诸实践,并决定哪些组件将管理哪些状态,当我们让用户查看不同可预订项目和不同周次的预订时。

图 7.4 显示了 Bookings 页面的布局,左侧是可预订项目的列表,页面的其余部分是预订信息。我们有一个BookingsPage组件用于页面本身,一个BookablesList组件用于左侧的列表,以及一个Bookings组件用于页面的其余部分。预订信息包括周选择器、显示预订网格的区域以及显示所选预订详情的区域。

图 7.4 为预订网格和预订详情提供了占位符。我们将在第 7.4 节中使预订网格变得生动,并引入useMemo钩子。我们将在第八章中填充预订详情并介绍useContext钩子。在本节中,我们将页面上的各个部分放在一起。

图片 7-4

图 7.4 Bookings 页面包括两个组件:一个用于可预订项目的列表,另一个包含周选择器、预订网格和预订详情。

本书使用预订应用来教您关于 React Hooks 的知识。为了节省您的时间和精力,我更专注于教授钩子,而不是教授您如何编写预订应用,这可能会变得非常重复,并且不会对学习 React 有所帮助。因此,有时本书会设置挑战,并将您指向示例的 GitHub 仓库以获取某些组件的最新代码。随着 Bookings 页面的示例应用变得越来越复杂,仓库中的更改案例在书中并未全部列出;当您需要检查仓库时,我会明确指出。

表 7.1 列出了 Bookings 页面中涉及到的组件,以及它们的主要功能和它们管理的共享状态。在第八章中,我们将使用useContext钩子从BookingDetails组件访问当前用户;尽管我们在这章中没有与App组件打交道,但它包含在表中,以便您可以看到组件的完整层次结构。

表 7.1 预订页面组件

组件 角色 管理的状态 钩子
App 渲染带有页面链接的标题。渲染用户选择器。使用路由渲染正确的页面。 当前用户 useState + 上下文 API—见第八章
BookingsPage 渲染 BookablesListBookings 组件。 已选可预订项 useState
BookablesList 渲染可预订项列表并允许用户选择可预订项。
Bookings 渲染 WeekPickerBookingsGridBookingDetails 组件。 已选周和已选预订 useReduceruseState
WeekPicker 允许用户切换周次以查看。
BookingsGrid 显示所选可预订项和周的预订时段网格。用任何现有的预订填充网格。突出显示所选预订。
BookingDetails 显示所选预订的详细信息。

我们将从 BookingsPage 开始工作;列表应该能给你一个很好的页面结构和状态在组件层次结构中流动的感觉。讨论分为两个小节,以共享状态为重点:

  • 使用 useState 管理所选可预订项

  • 使用 useReduceruseState 管理所选周和预订

表 7.1 中显示的所有组件都需要在应用返回到工作状态之前就位,但列表并不长,所以我们很快就能到达那里。

7.3.1 使用 useState 管理所选可预订项

我们的第一块共享状态是已选可预订项。它被 BookablesListBookings 组件使用。(记住,Bookings 组件是 WeekPickerBookingsGridBookingDetails 组件的容器。)它们最近的共享父级是预订页面本身。

列表 7.4 展示了 BookingsPage 组件调用 useState 来管理所选可预订项。BookingsPage 还将更新函数 setBookable 传递给 BookablesList,以便用户可以从列表中选择可预订项。它不再直接导入 WeekPicker

分支:0701-bookings-page,文件:src/components/Bookings/BookingsPage.js

列表 7.4 BookingsPage 组件

import {useState} from "react";
import BookablesList from "../Bookables/BookablesList";
import Bookings from "./Bookings";

export default function BookingsPage () {
  const [bookable, setBookable] = useState(null);     ❶

  return (
    <main className="bookings-page">
      <BookablesList
        bookable={bookable}                           ❷
        setBookable={setBookable}                     ❸
      />
      <Bookings
        bookable={bookable}                           ❹
      />
    </main>
  );
}

❶ 使用 useState 钩子管理所选可预订项。

❷ 将可预订项传递下去,以便在列表中突出显示。

❸ 传递更新函数,以便用户可以选择可预订项。

❹ 让预订组件显示所选可预订项的预订。

页面将所选可预订项传递给(稍后创建的)Bookings 组件,以便它可以显示可预订项的预订。为了显示正确的预订(并让用户创建新的预订),Bookings 组件还需要知道所选周。让我们看看它是如何管理这个状态的。

7.3.2 使用 useReduceruseState 管理所选周和预订

用户可以通过使用周选择器来切换周。他们可以向前或向后导航一周,也可以直接跳转到包含今天日期的那一周。他们还可以在文本框中输入一个日期并转到该日期的周。为了与预订网格共享选定的日期,我们将周选择器的 reducer 提升到 Bookings 组件中,如下所示。

分支:0701-bookings-page,文件:src/components/Bookings/Bookings.js

列表 7.5 Bookings 组件

import {useState, useReducer} from "react";
import {getWeek} from "../../utils/date-wrangler";

import WeekPicker from "./WeekPicker";
import BookingsGrid from "./BookingsGrid";
import BookingDetails from "./BookingDetails";

import weekReducer from "./weekReducer";             ❶

export default function Bookings ({bookable}) {      ❷

  const [week, dispatch] = useReducer(               ❸
    weekReducer, new Date(), getWeek
  );

  const [booking, setBooking] = useState(null);      ❹

  return (
    <div className="bookings">
      <div>
        <WeekPicker
          dispatch={dispatch}
        />

        <BookingsGrid
          week={week}
          bookable={bookable}
          booking={booking}
          setBooking={setBooking}
        />
      </div>

      <BookingDetails
        booking={booking}
        bookable={bookable}
      />
    </div>
  );
}

❶ 导入现有的周选择器 reducer。

❷ 从属性中解构当前可预订项。

❸ 管理选定周的共享状态。

❹ 管理选定的预订的共享状态。

Bookings 组件导入 reducer 并在调用 useReducer 钩子时传递它。它还调用 useState 钩子来管理 BookingsGridBookingDetails 组件的共享选定预订状态。

挑战 7.1

更新 WeekPicker 组件,使其接收 dispatch 作为属性,不再自己调用 useReducer。它不需要显示选定的日期,因此从返回的 UI 末尾移除该功能,并移除任何多余的导入。检查仓库以获取最新版本(src/components/Bookings/WeekPicker.js)。

在 7.4 节中,我们构建预订网格以显示实际预订。对于当前仓库分支,我们只需添加几个占位符组件来检查页面结构是否工作良好。以下列表显示了我们的临时预订网格。

分支:0701-bookings-page,文件:src/components/Bookings/BookingsGrid.js

列表 7.6 BookingsGrid 占位符

export default function BookingsGrid (props) {
  const {week, bookable, booking, setBooking} = props;

  return (
    <div className="bookings-grid placeholder">
      <h3>Bookings Grid</h3>
      <p>{bookable?.title}</p>
      <p>{week.date.toISOString()}</p>
    </div>
  );
}

以下列表显示了我们的临时详情组件。

分支:0701-bookings-page,文件:src/components/Bookings/BookingDetails.js

列表 7.7 BookingDetails 占位符

export default function BookingDetails () {
  return (
    <div className="booking-details placeholder">
      <h3>Booking Details</h3>
    </div>
  );
}

现在应该一切就绪,应用应该恢复正常工作。预订页面应该看起来像图 7.4(如果您有最新的 CSS,或者为占位符自己创建一个)。

挑战 7.2

BookablesList 进行小幅修改,移除移动焦点到“下一步”按钮的代码。这将仅简化组件以供未来的更改。更新位于当前分支:/src/components/Bookables/BookablesList.js。

在所有组件就绪并且对页面如何管理每一块共享状态有了一定的了解之后,是时候向预订应用引入一个新的 React 钩子了。useMemo 钩子将帮助我们仅在必要时运行昂贵的计算。让我们看看为什么我们需要它以及它是如何帮助的。

7.4 使用 useMemo 高效构建预订网格

在 Bookings 页面结构和层次结构就绪后,我们准备构建迄今为止最复杂的组件,即 BookingsGrid。在本节中,我们开发网格,使其能够显示给定周和地点的预订时段,并将任何现有预订放入网格中。图 7.5 显示了具有三个行(会话)和五个列(日期)的网格。网格中有四个现有预订,用户已选择其中一个预订。

图片 7-5

图 7.5 显示了所选可预订项目和周的预订网格。网格中的一个预订已被选中。

我们在五个阶段中开发组件:

  1. 生成会话和日期的网格——我们希望将数据转换,以便更容易查找空预订时段。

  2. 生成预订查找——我们希望将数据转换,以便更容易查找现有预订。

  3. 提供一个 getBookings 数据加载函数——它将处理构建我们向 JSON 服务器请求的查询字符串。

  4. 创建 BookingsGrid 组件——这是本节的核心内容,也是我们请求 useMemo 帮助的地方。

  5. 处理在 useEffect 中获取数据时的竞态响应。

在第 5 阶段,我们将了解如何在 useEffect 钩子调用中管理多个请求和响应,以及如何管理错误。有很多内容需要消化,所以让我们从将天数和会话列表转换为二维预订网格开始。

7.4.1 生成会话和日期的网格

预订网格以表格形式显示空预订时段和现有预订,其中会话为行,日期为列。图 7.6 展示了 Meeting Room 可预订项目的预订时段示例网格。

图片 7-6

图 7.6 显示了 Meeting Room 可预订项目的预订网格。它为每个会话有行,为每个日期有列。

用户为不同会话和周的不同日子预订不同的可预订项目。当用户选择新的可预订项目时,BookingsGrid 组件需要生成一个新的网格,用于最新的会话和日期。图 7.7 显示了用户切换到 Lounge 可预订项目时生成的网格。

图片 7-7

图 7.7 显示了 Lounge 可预订项目的预订网格。Lounge 在每周的每一天都提供五个时段。

网格中的每个单元格都对应一个预订时段。我们希望网格数据结构化,以便轻松访问特定预订时段的数据。例如,要访问 2020 年 8 月 3 日早餐会话的数据,我们使用以下方法:

grid["Breakfast"]["2020-08-03"]

对于空预订时段,预订数据看起来像这样:

{
  "session": "Breakfast",
  "date": "2020-08-03",
  "bookableId": 4,
  "title": ""
}

在数据库的数据中,每个可预订项目指定了可以预订的会话和日子。以下是 Meeting Room 的数据:

"id": 1,
"group": "Rooms",
"title": "Meeting Room",
"notes": "The one with the big table and interactive screen.",
"sessions": [1, 2, 3],
"days": [1, 2, 3, 4, 5]

days 代表一周中的天数,其中周日 = 0,周一 = 1,...,周六 = 6。因此,会议室可以在周一至周五预订会话 1、2 和 3,正如我们在图 7.6 中所看到的。要获取预订的具体日期,而不仅仅是天数,我们还需要显示周的开始日期。要获取具体的会话名称,我们需要从配置文件 static.json 中导入会话名称数组。

网格生成函数 getGrid 在以下列表中。调用代码将 getGrid 的当前可预订和所选周的起始日期传递给它。

分支:0702-bookings-memo,文件:/src/components/Bookings/grid-builder.js

列表 7.8 网格生成器

import {sessions as sessionNames} from "../../static.json";        ❶
import {addDays, shortISO} from "../../utils/date-wrangler";

export function getGrid (bookable, startDate) {                    ❷

  const dates = bookable.days.sort().map(                          ❸
    d => shortISO(addDays(startDate, d))                           ❸
  );                                                               ❸

  const sessions = bookable.sessions.map(i => sessionNames[i]);    ❹

  const grid = {};

  sessions.forEach(session => {
    grid[session] = {};                                            ❺
    dates.forEach(date => grid[session][date] = {                  ❻
      session,
      date,
      bookableId: bookable.id,
      title: ""
    });
  });

  return {                                                         ❼
    grid,                                                          ❼
    dates,                                                         ❼
    sessions                                                       ❼
  };                                                               ❼
}

❶ 将会话名称分配给 sessionNames 变量。

❷ 将当前可预订的书籍和周开始日期作为参数接受。

❸ 使用天数和起始日期创建一周的日期数组。

❹ 使用会话名称和数字创建一个会话名称数组。

❺ 为每个会话分配一个对象到网格中。

❻ 为每个会话分配一个预订对象到每个日期。

❼ 除了网格之外,为了方便,返回日期和会话数组。

getGrid 函数首先将日期和会话索引映射到日期和会话名称。它使用截断的 ISO 8601 格式表示日期:

const dates = bookable.days.sort().map(
  d => shortISO(addDays(startDate, d))
);                                                                     

shortISO 函数已被添加到包含 addDays 函数的 utils/date-wrangler.js 文件中。shortISO 返回给定日期的 ISO-字符串的日期部分:

export function shortISO (date) {
  return date.toISOString().split("T")[0];
}

例如,对于表示 2020 年 8 月 3 日的 JavaScript 日期对象,shortISO 返回字符串 "2020-08-03"

列表中的代码还从 static.json 导入会话名称并将它们分配给 sessionNames 变量。会话数据如下所示:

"sessions": [
  "Breakfast",
  "Morning",
  "Lunch",
  "Afternoon",
  "Evening"
]

将可预订的每个会话索引映射到其会话名称:

const sessions = bookable.sessions.map(i => sessionNames[i]);

因此,如果所选的可预订的是会议室,那么 bookable.sessions 是数组 [1, 2, 3],而 sessions 变为 ["Morning", "Lunch", "Afternoon"]

在获取了日期和会话名称之后,getGrid 然后使用嵌套的 forEach 循环来构建预订会话的网格。您也可以在这里使用 reduce 数组方法,但我发现在这种情况下 forEach 语法更容易理解。(不要担心,reduce 粉丝;下一个列表将使用其服务。)

7.4.2 生成预订查找

我们还希望有一个简单的方法来查找现有预订。图 7.8 展示了一个包含四个单元格中现有预订的预订网格。

图 7.8 包含四个单元格中现有预订的预订网格

我们希望使用会话名称和日期来访问现有预订的数据,如下所示:

bookings["Morning"]["2020-06-24"]

查找表达式应返回 Movie Pitch! 预订的数据,具有以下结构:

{
  "id": 1,
  "session": "Morning",
  "date": "2020-06-24",
  "title": "Movie Pitch!", 
  "bookableId": 1,
  "bookerId": 2
}

但服务器以数组的形式返回预订数据。我们需要将预订数组转换为方便的查找对象。列表 7.9 向列表 7.8 中的 grid-builder.js 文件添加了一个新函数 transformBookings

分支:0702-bookings-memo,文件:/src/components/Bookings/grid-builder.js

列表 7.9 transformBookings 函数

export function transformBookings (bookingsArray) {

  return bookingsArray.reduce((bookings, booking) => {    ❶

    const {session, date} = booking;                      ❷

    if (!bookings[session]) {                             ❸
      bookings[session] = {};                             ❸
    }                                                     ❸

    bookings[session][date] = booking;                    ❹

    return bookings;
  }, {});                                                 ❺
}

❶ 使用 reduce 遍历每个预订并构建预订查找。

❷ 解构当前预订的会话和日期。

❸ 为每个新会话添加一个属性。

❹ 将预订分配给其会话和日期。

❺ 将预订查找作为空对象开始。

transformBookings 函数使用 reduce 方法遍历数组中的每个预订并构建 bookings 查找对象,将当前预订分配给其分配的查找槽位。transformBookings 创建的查找对象只包含现有预订的条目,不一定包含预订网格中的每个单元格。

我们现在有了生成网格和将预订数组转换为查找对象的函数。但预订在哪里?

7.4.3 提供一个 getBookings 数据加载函数

BookingsGrid 组件需要一些预订来显示所选的可预订项和周。我们可以在 BookingsGrid 组件中的效果内部使用现有的 getData 函数,并在那里构建必要的 URL。相反,让我们将数据访问函数保留在 api.js 文件中。以下列表显示了更新文件中我们的新 getBookings 函数的部分。

分支:0702-bookings-memo,文件:/src/utils/api.js

列表 7.10 getBookings API 函数

import {shortISO} from "./date-wrangler";                        ❶

export function getBookings (bookableId, startDate, endDate) {   ❷

  const start = shortISO(startDate);                             ❸
  const end = shortISO(endDate);                                 ❸

  const urlRoot = "http://localhost:3001/bookings";

  const query = `bookableId=${bookableId}` +                     ❹
 `&date_gte=${start}&date_lte=${end}`; ❹

  return getData(`${urlRoot}?${query}`);                         ❺
}

❶ 导入一个格式化日期的函数。

❷ 导出新的 getBookings 函数。

❸ 格式化查询字符串的日期。

❹ 构建查询字符串。

❺ 获取预订,返回一个承诺。

getBookings 函数接受三个参数:bookableIdstartDateendDate。它使用这些参数来构建所需预订的查询字符串。例如,要获取 2020 年 6 月 21 日星期日到 2020 年 6 月 27 日星期六会议室的预订,查询字符串如下:

bookableId=1&date_gte=2020-06-21&date_lte=2020-06-27

我们运行的 json-server 将解析查询字符串并返回请求的预订数组,以便转换为查找对象。

在放置好辅助函数后,是时候将它们用于构建 BookingsGrid 组件了。

7.4.4 创建 BookingsGrid 组件并调用 useMemo

对于给定的可预订项和周,BookingsGrid 组件获取预订并显示它们,突出显示任何选定的预订。它使用三个 React Hooks:useStateuseEffectuseMemo。我们将组件的代码拆分到多个列表中,在本小节和下一节中,从以下列表中的导入和组件骨架开始。

分支:0702-bookings-memo,文件:/src/components/Bookings/BookingsGrid.js

列表 7.11 BookingsGrid 组件:骨架

import {useEffect, useMemo, useState, Fragment} from "react";    ❶

import {getGrid, transformBookings} from "./grid-builder";       ❷

import {getBookings} from "../../utils/api";                     ❸

import Spinner from "../UI/Spinner";

export default function BookingsGrid () {

  // 1\. Variables
  // 2\. Effects
  // 3\. UI helper
  // 4\. UI

}

❶ 导入 useMemo 以缓存网格。

❷ 导入新的网格函数。

❸ 导入新的数据加载函数。

代码导入了之前创建的辅助函数和三个钩子。正如你将在接下来的几个列表中看到的,我们使用 useState 钩子来管理预订和任何错误的状态,使用 useEffect 钩子从服务器获取预订数据,使用 useMemo 钩子减少生成网格数据次数。

变量

Bookings 组件将所选的可预订项、所选周和当前所选的预订及其更新函数传递给 BookingsGrid 组件,如下面的列表所示。

分支:0702-bookings-memo,文件:/src/components/Bookings/BookingsGrid.js

列表 7.12 BookingsGrid 组件:1. 变量

export default function BookingsGrid (
 {week, bookable, booking, setBooking}                     ❶
) {
  const [bookings, setBookings] = useState(null);           ❷
  const [error, setError] = useState(false);                ❸

  const {grid, sessions, dates} = useMemo(                  ❹

    () => bookable ? getGrid(bookable, week.start) : {},    ❺

    [bookable, week.start]                                  ❻
  );

  // 2\. Effects
  // 3\. UI helper
  // 4\. UI  
}

❶ 解构 props。

❷ 本地处理预订数据。

❸ 本地处理加载错误。

❹ 使用 useMemo 将网格生成函数包装起来。

❺ 只有在有可预订的情况下才调用网格生成器。

❻ 当可预订项或周发生变化时,重新生成网格。

BookingsGrid 使用两个 useState 钩子本身处理预订和错误状态。然后它使用 7.4.2 节中的 getGrid 函数生成网格,将返回的网格、会话和日期数据分配给局部变量。我们决定将 getGrid 视为一个昂贵的函数,并用 useMemo 包装它。为什么它可能值得这样的处理?

当用户在预订页面上选择可预订项时,Bookings 组件显示可预订项可用会话和日期的预订时段网格。它根据可预订项的属性和所选周生成网格数据。正如我们将在下一个列表中看到的,BookingsGrid 组件使用在渲染时请求数据的数据加载策略,在初始渲染后发送数据请求。网格(如图 7.9 所示)在左上角单元格显示加载指示器,并在数据到达之前降低主体单元格的不透明度。

图 7.9 BookingsGrid 组件在其左上角单元格显示加载指示器,并在数据请求进行时降低网格单元格的不透明度。

当数据到达时,网格会重新渲染,隐藏加载指示器并显示所选周的预订。图 7.10 显示了网格中的四个预订。

图 7.10 显示四个预订的预订网格

在预订就绪后,用户现在可以自由选择现有的预订或空预订时段。在图 7.11 中,用户选择了“电影提案”预订,并且组件再次重新渲染,突出显示单元格。

图 7.11 显示已选择的预订的预订网格

组件在状态变化时渲染,如表 7.2 所示,尽管预订时段的底层网格数据并未改变。

表 7.2 不同事件的预订网格渲染行为

事件 渲染方式
初始渲染 空网格
数据获取 加载指示器
数据加载 单元格中的预订
已选预订 高亮选择

对于列出的活动,我们不想在每次重新渲染时重新生成底层的网格数据,因此我们使用 useMemo 钩子,指定可预订和周的开始日期作为依赖项:

const {grid, sessions, dates} = useMemo(
  () => bookable ? getGrid(bookable, week.start) : {},
  [bookable, week.start]
);

通过将 getGrid 包裹在 useMemo 中,我们要求 React 存储生成的网格查找,并且只有在可预订或开始日期更改时才再次调用 getGrid。对于表 7.2 中的三个重新渲染场景(不是初始渲染),React 应该返回存储的网格,避免不必要的计算。

在现实中,对于我们生成的网格大小,我们实际上并不需要 useMemo。现代浏览器、JavaScript 和 React 几乎不会注意到所需的工作。此外,要求 React 存储函数、返回值和依赖值也有一些开销,因此我们不想对所有内容进行记忆化。然而,正如我们在本章前面看到的字母表排列示例中看到的那样,有时昂贵的函数可能会对性能产生不利影响,所以拥有 useMemo 钩子是个好主意。

尽管本章的主要重点是 useMemo 钩子,但在 useEffect 调用中进行数据获取的有用技术值得用小节标题标记。让我们看看如何避免得到多个请求和响应的纠缠。

7.4.5 在 useEffect 中获取数据时处理竞态响应

当与预订应用交互时,用户可能会变得有点点击狂热,快速在可预订和周之间切换,引发一系列数据请求。我们只想显示他们的最后选择的数据。不幸的是,我们无法控制数据从服务器返回的时间,一个较旧请求可能在较新的请求之后解决,导致显示与用户的选择不同步。

我们可以尝试实现一种取消进行中的请求的方法。然而,如果数据响应不是太大,简单地让请求按其流程运行并忽略到达的不想要的日期会更简单。在本小节中,我们完成 BookingsGrid 组件,获取预订数据,并构建用于显示的 UI。

影响

BookingsGrid 组件加载所选可预订和周次的预订。列表 7.13 显示了在 useEffect 调用中包裹我们的辅助函数 getBookingstransformBookings 的调用。效果在周或可预订更改时运行。

分支:0702-bookings-memo,文件:/src/components/Bookings/BookingsGrid.js

列表 7.13 BookingsGrid 组件:2. 影响

export default function BookingsGrid (
  {week, bookable, booking, setBooking}
) {
  // 1\. Variables

  useEffect(() => {
    if (bookable) {
      let doUpdate = true;                              ❶

      setBookings(null);
      setError(false);
      setBooking(null);

      getBookings(bookable.id, week.start, week.end)    ❷
        .then(resp => {
          if (doUpdate) {                               ❸
            setBookings(transformBookings(resp));       ❹
          }
        })
        .catch(setError);

      return () => doUpdate = false;                    ❺
    }
  }, [week, bookable, setBooking]);                     ❻

  // 3\. UI helper
  // 4\. UI
}

❶ 使用变量跟踪预订数据是否最新。

❷ 调用我们的 getBookings 数据获取函数。

❸ 检查预订数据是否最新。

❹ 创建一个预订查找并将其分配给状态。

❺ 返回一个清理函数以使数据无效。

❻ 当可预订或周发生变化时运行效果。

代码使用 doUpdate 变量来匹配每个请求及其数据。该变量最初设置为 true

let doUpdate = true;

对于特定的请求,then 子句中的回调函数只有在 doUpdate 仍然是 true 时才会更新状态:

if (doUpdate) {
  setBookings(transformBookings(resp));
}

当用户选择新的可预订项或切换到新的一周时,React 会重新运行组件,并再次运行效果以加载新选择的数据。之前请求的飞行数据不再需要。在重新运行效果之前,React 会调用之前效果调用的任何相关清理函数。我们的效果使用清理函数来使飞行数据无效:

return () => doUpdate = false;

当之前请求的预订到达时,getBookings 相关调用的 then 子句会看到数据已过时,不会更新状态。

如果预订是当前的,then 子句通过传递响应到 transformBookings 函数将预订的线性数组转换为查找结构。查找对象通过 setBookings 分配到本地状态。

UI 辅助函数

预订网格中单元格的内容和行为取决于是否有要显示的预订以及用户是否选择了单元格。图 7.12 显示了一些空单元格和一个现有预订单元格,Movie Pitch!。

图 7.12 网格中的单元格表示存在的预订,如果存在,或者只是会话和日期的底层网格数据。

当用户选择一个单元格时,无论单元格显示的是现有预订还是空预订槽,该单元格都应该被突出显示。图 7.13 显示了用户选择 Movie Pitch! 预订后的网格。CSS 样式和单元格的 class 属性用于改变单元格的外观。

图 7.13 使用不同的 CSS 样式显示所选单元格。

列表 7.14 包含一个 cell 辅助函数的代码,该函数返回预订网格中单个单元格的 UI。它使用两个查找对象 bookingsgrid 来获取单元格的数据,设置单元格的类,并在有预订的情况下附加事件处理程序。cell 函数在 BookingsGrid 的作用域内,可以访问 bookingbookingsgridsetBookings 变量。

分支:0702-bookings-memo,文件:/src/components/Bookings/BookingsGrid.js

列表 7.14 BookingsGrid 组件:3. UI 辅助工具

export default function BookingsGrid (
  {week, bookable, booking, setBooking}
) {
  // 1\. Variables
  // 2\. Effects

  function cell (session, date) {
    const cellData = bookings?.[session]?.[date]                  ❶
 || grid[session][date];                                     ❶

    const isSelected = booking?.session === session               ❷
 && booking?.date === date;                                  ❷

    return (
      <td
        key={date}
        className={isSelected ? "selected" : null}
        onClick={bookings ? () => setBooking(cellData) : null}    ❸
      >
        {cellData.title}
      </td>
    );
  }

  // 4\. UI
}

❶ 首先检查预订查找,然后是网格查找。

❷ 使用可选链,因为可能没有预订。

❸ 仅当预订已加载时设置处理程序。

图 7.14 单元格的显示取决于网格是否处于活动状态以及单元格是否已被选中。在加载预订时,UI 显示加载指示器,网格不处于活动状态。

单元格的数据要么来自 bookings 查找中的现有预订,要么来自 grid 查找中的空预订时段数据。代码使用方括号表示法的可选链语法将正确的值分配给 cellData 变量:

const cellData = bookings?.[session]?.[date] || grid[session][date]; 

bookings 查找只包含现有预订的数据,但 grid 查找包含每个会话和日期的数据。我们需要为 bookings 使用可选链,但不为 grid 使用。

只有在存在预订时,我们才在单元格上设置点击处理程序。当预订正在加载时,当用户切换可预订项或周次时,处理程序设置为 null,用户无法与网格交互。

UI

BookingsGrid 拼图的最后一部分返回 UI。一如既往,UI 由状态驱动。我们检查预订时段的网格是否已生成,预订是否已加载,以及是否存在错误。然后我们返回替代 UI(加载文本)或附加 UI(错误消息),或者设置类名以显示、隐藏或突出显示元素。图 7.14 展示了三种状态下的预订网格:

  1. 没有预订。网格显示加载指示器。网格处于非活动状态,用户无法与网格交互。

  2. 预订已加载。网格隐藏了加载指示器。网格处于活动状态,用户可以与网格交互。

  3. 预订已加载。网格隐藏了加载指示器。网格处于活动状态,用户已选择一个单元格。

图 7.15 BookingsGrid 组件在网格上方显示任何错误。

在图 7.15 中,你可以看到错误信息直接显示在网格日期标题上方。

以下列表显示了错误部分,使用类名来控制网格是否处于活动状态,并调用我们的 UI 辅助函数 cell 来获取每个表格单元格的 UI。

分支:0702-bookings-memo,文件:/src/components/Bookings/BookingsGrid.js

列表 7.15 BookingsGrid 组件:4. UI

export default function BookingsGrid (
  {week, bookable, booking, handleBooking}
) {
  // 1\. Variables
  // 2\. Effects
  // 3\. UI helper
  if (!grid) {
    return <p>Loading...</p>
  }
  return (
    <Fragment>
      {error && (
        <p className="bookingsError">                                    ❶
          {`There was a problem loading the bookings data (${error})`}   ❶
        </p>                                                             ❶
      )}
      <table
        className={bookings ? "bookingsGrid active" : "bookingsGrid"}    ❷
      >
        <thead>
        <tr>
          <th>
 <span className="status">
 <Spinner/>                                                 ❸
 </span>
          </th>
          {dates.map(d => (
            <th key={d}>
              {(new Date(d)).toDateString()}
            </th>
          ))}
        </tr>
        </thead>
        <tbody>
        {sessions.map(session => (
          <tr key={session}>
            <th>{session}</th>
            {dates.map(date => cell(session, date))}                     ❹
          </tr>
        ))}
        </tbody>
      </table>
    </Fragment>
  );
}

❶ 如果有错误,在网格顶部显示错误部分。

❷ 当预订数据已加载时,包含一个“活动”类。

❸ 在左上角单元格中包含一个加载指示器。

❹ 使用 UI 辅助函数生成每个表格单元格。

如果 bookings 不是 null,则将 active 类分配给表格。应用的 CSS 隐藏加载指示器,并在网格处于活动状态时将单元格不透明度设置为 1。

在代码中,我们自行检查状态并决定从组件内部返回什么 UI。也可以使用 React 的 错误边界 来指定错误 UI,以及使用 React 的 Suspense 组件来指定数据加载时的回退 UI,这些操作与单个组件分开进行。在第二部分中,我们使用错误边界来捕获错误,并使用 Suspense 组件来捕获承诺(加载数据)。

在此之前,我们需要创建我们的 BookingDetails 组件来显示用户点击的任何预订时段或现有预订的详细信息。新组件需要访问应用中的当前用户,存储在根组件 App 中。而不是通过多层组件属性向下传递用户值,我们将利用 React 的 Context API 和 useContext 钩子来寻求帮助。

摘要

  • 尽量避免通过将它们包装在 useMemo 钩子中来不必要地重新运行昂贵的计算。

  • 将要缓存的昂贵函数传递给 useMemo

    const value = useMemo(
     () => expensiveFn(dep1, dep2),
      [dep1, dep2]
    );
    
  • useMemo 钩子传递给昂贵函数的依赖项列表:

    const value = useMemo(
      () => expensiveFn(dep1, dep2),
     [dep1, dep2]
    );
    
  • 如果依赖数组中的值在连续调用之间没有变化,useMemo 可以返回其存储的昂贵函数的结果。

  • 不要依赖 useMemo 总是使用缓存值。如果 React 需要释放内存,它可能会丢弃存储的结果。

  • 使用 JavaScript 的可选链式语法(方括号)来访问可能为 undefined 的变量的属性。即使在处理方括号时,也要包含一个点:

    const cellData = bookings?.[session]?.[date]
    
  • useEffect 调用中获取数据时,将局部变量和清理函数结合起来,以匹配数据请求及其响应:

    useEffect(() => {
      let doUpdate = true;
    
      fetch(url).then(resp => {
        if (doUpdate) {
          // perform update with resp
        }
      });
    
      return () => doUpdate = false;
    }, [url]);
    

    如果组件使用新的 url 重新渲染,前一次渲染的清理函数会将前一次渲染的 doUpdate 变量设置为 false,防止前一次的 then 方法回调使用过时的数据进行更新。

8 使用 Context API 管理状态

本章涵盖

  • 通过 Context API 及其Provider组件提供状态

  • 使用useContext钩子消费上下文状态

  • 避免在更新状态值时进行不必要的重新渲染

  • 创建自定义上下文提供者

  • 在多个上下文中分割共享状态

我们已经看到状态被封装在组件中,提升到共享父组件中,在表单字段中,跨渲染持久化,以及从数据库中拉取,我们使用了很多钩子来帮助我们设置和使用该状态。我们的方法是将状态尽可能靠近使用它的组件。但是,对于许多嵌套在多个分支上的组件来说,渴望同样的美味虫子,同样的应用状态碎片,如主题、本地化信息或认证用户详情,并不少见。嗯嗯,碎片……React 的 Context API 是一种将美味的状态碎片直接送到你的巢穴而不通过多层中间代理传递的方法,这些中间代理更喜欢玉米卷而不是碎片。

在本章中,我们介绍了 Context API、其上下文对象、Provider组件和useContext钩子。我们关注我们的预订应用示例,其中多个组件需要同样的美味碎片:当前用户的详细信息。这为 Context API 的机制概述设定了场景,我们看到为什么、何时、何地以及如何向组件子树提供值,以及useContext钩子如何简化这些值的消费。我们通过将上下文功能封装到我们自己的自定义上下文和提供者组件中结束,这讨论加深了对 React 渲染行为的理解,尤其是在使用特殊的children属性时。

你能听到吗?那是嵌套组件在啁啾着寻找美味碎片。是喂食时间了!

8.1 需要从组件树的上层获取状态

在我们的示例应用中,预订页面允许访客选择可预订的房间和周。页面上的预订网格随后显示可用的预订时段,并用任何现有的预订填充适当的单元格。图 8.1 显示了访客选择会议室可预订和电影提案!预订后的预订页面。

图 8.1 用户选择预订后,预订详情组件(在右侧)显示所选预订的信息。

在第七章中,我们处理了BookingDetails组件的占位符,该组件用于显示关于所选预订的更多信息。图 8.1 也展示了本章中我们对BookingDetails组件的目标:列出所选预订的一些属性,如标题和预订日期。但是,当页面首次加载时,没有预订被选中,组件显示一条消息鼓励访客选择一个预订或预订时段,如图 8.2 所示。

图 8.2 在用户选择预订之前,预订详情组件(在右侧)显示消息“选择一个预订或预订时段。”

在本章中,我们减轻了BookingDetails组件的占位符职责,并提升其执行以下三个任务:

  • 在页面首次加载时显示操作消息

  • 当访客选择预订时显示预订信息

  • 显示用户预订的编辑按钮

第三个任务促使我们研究 Context API,以便将当前用户值传递给应用中的组件。为什么BookingDetails组件需要知道用户?让我们找出答案。

8.1.1 在页面首次加载时显示操作消息

当预订页面加载,但在访客选择预订之前,BookingDetails组件将显示操作消息,如图 8.3 所示。

图 8-3

图 8.3 当页面首次加载时,BookingDetails组件向用户显示“选择一个预订或预订时段”的消息。

列表 8.1 展示了BookingDetails组件如何检查预订并返回操作消息的 UI 或现有预订的 UI。现有预订的 UI 由另一个组件Booking处理;我们将在 8.1.2 节中探讨这一点。

分支:0801-预订详情,文件:/src/components/Bookings/BookingDetails.js

列表 8.1 BookingDetails组件显示预订或消息

import Booking from "./Booking";                                   ❶

export default function BookingDetails ({booking, bookable}) {     ❷
  return (
    <div className="booking-details">
      <h2>Booking Details</h2>

      {booking ? (                                                 ❸
        <Booking                                                   ❹
          booking={booking}
          bookable={bookable}
        />
      ) : (
        <div className="booking-details-fields">
          <p>Select a booking or a booking slot.</p>               ❺
        </div>
      )}
    </div>
  );
}

❶ 导入Booking组件。

❷ 将预订和可预订属性分配给局部变量。

❸ 仅当选择预订时显示预订信息。

❹ 使用Booking组件来显示信息。

❺ 如果没有选择预订,则显示消息。

列表 8.1 使用 JavaScript 三元运算符(a ? b : c)返回适当的 UI,预订或消息:

{booking ? (
  // return booking UI if there’s a booking
) : (
  // return message UI if there’s not a booking
)}

在后续章节中,我们将为带有输入字段和提交按钮的表单添加第三个 UI 可能性。目前,这是一个或/或的情况:预订或消息。让我们看看预订 UI 的代码。

8.1.2 当访客选择预订时显示预订信息

一旦用户注意并选择了一个现有预订,组件将显示其信息;电影提案!预订的详情如图 8.4 所示。(如果您没有预订数据,请从仓库中获取 db.json。如果您也需要最新的 App.css,请获取。)

图 8-4

图 8.4 显示所选预订和可预订信息的BookingDetails组件

信息包括预订的多个字段和一个可预订的字段。以下列表显示了Booking组件接收所选预订和可预订作为属性,并以标签和段落序列返回预订详情。

分支:0801-预订详情,文件:/src/components/Bookings/Booking.js

列表 8.2 Booking组件

import {Fragment} from "react";

export default function Booking ({booking, bookable}) {     ❶

  const {title, date, session, notes} = booking;            ❷

  return (
    <div className="booking-details-fields">
      <label>Title</label>
      <p>{title}</p>

      <label>Bookable</label>
      <p>{bookable.title}</p>                               ❸

      <label>Booking Date</label>
      <p>{(new Date(date)).toDateString()}</p>              ❹

      <label>Session</label>
      <p>{session}</p>

      {notes && (                                           ❺
        <Fragment>
          <label>Notes</label>
          <p>{notes}</p>
        </Fragment>
      )}
    </div>
  )
}

❶ 将预订和可预订属性分配给局部变量。

❷ 将预订属性分配给局部变量。

❸ 显示所选可预订的信息。

❹ 将日期属性格式化得更好。

❺ 仅当预订有备注时显示“备注”字段。

BookingDetails 组件现在成功地在用户尚未选择预订时的行动号召信息与用户做出选择后的 Booking 组件之间切换。这是组件三个任务中的两个已经完成。做得好!但第三个任务更复杂。问题是什么?

8.1.3 为用户的预订显示编辑按钮:问题

我们新创建的 BookingDetails 组件成功显示了所选可预订的信息。这很好!但计划可能会改变,会议可能会取消或日期冲突。用户应该能够编辑自己的预订以更新详细信息或直接删除它们。我们需要添加一个按钮,就像图 8.5 右侧预订详情标题旁边的按钮一样,以便用户可以切换到编辑预订。

图片

图 8.5 当用户选择自己的其中一个预订时,预订详情组件(在右侧)在标题右侧显示一个带有编辑图标的编辑按钮。

图 8.6 将 BookingDetails 组件隔离出来,并显示组件标题右侧的编辑按钮(一个文档编辑图标)。问题是,我们只想在当前用户自己预订所选预订时显示该按钮。对于其他用户,按钮应该被隐藏。BookingDetails 组件需要知道当前用户的 id,以便它可以与所选预订的 bookerId 进行比较。

图片

图 8.6 显示在标题右侧带有编辑按钮的预订详情组件

当前用户的状态位于应用程序组件层次结构的顶部,在 App 组件中。我们可以通过中间组件(AppBookingsPageBookingsBookingDetails)向下传递用户,但这些组件对用户状态不感兴趣,而且 UserPicker 组件(以及很快还将被 UsersPage 组件使用)也需要这个状态。在这种情况下,应用程序中分散的多个组件都需要一个状态。

Context API 提供了一种将状态提供给多个消费者的替代方法。我们如何提供我们想要共享的状态?

8.1.4 为用户的预订显示编辑按钮:解决方案

我们希望将当前用户信息与所有需要该信息的功能组件共享,因此让我们使用 React 的 Context API 创建一个 UserContext 对象。我们将共享的上下文放在其自己的文件中,即 /src/components/Users/UserContext.js。提供用户值的组件 App 以及消费用户值的组件,包括 BookingDetails,都可以导入上下文来设置或读取其值。代码如下所示。

分支:0802-user-context,文件:/src/components/Users/UserContext.js

列表 8.3 创建并导出用于用户值的上下文对象

import {createContext} from "react";

const UserContext = createContext();

export default UserContext;

是的,就是这样!我们使用createContext方法并将它返回的上下文对象分配给UserContext变量。这个上下文对象,UserContext,是跨应用共享当前用户值的关键:App组件将使用它来设置值,而消费组件将使用它,以及useContext钩子,来读取值。

要使用新的上下文对象为预订应用提供用户状态,我们以三种关键方式更新App组件:

  1. 导入上下文对象。

  2. 通过调用useState钩子来管理当前用户的状态。

  3. 使用上下文的Provider组件包裹Router组件。

以下列表显示了所做的更新。

分支:0802-user-context,文件:/src/components/App.js

列表 8.4 在App中导入上下文对象并提供其值

import {useState} from "react";                            ❶

// unchanged imports
import UserContext from "./Users/UserContext";             ❷
export default function App () {
  const [user, setUser] = useState();                      ❸

  return (
    <UserContext.Provider value={user}>                    ❹
      <Router>
        <div className="App">
          <header>
            <nav>
              // unchanged nav
            </nav>

            <UserPicker user={user} setUser={setUser}/>    ❺
          </header>

          <Routes>
            // unchanged routes
          </Routes>
        </div>
      </Router>
    </UserContext.Provider>
  );
}

❶ 导入 useState 钩子。

❷ 导入要共享的上下文。

❸ 使用 useState 钩子管理用户状态。

❹ 将应用 UI 包裹在上下文提供者中。

❺ 将用户状态及其更新函数传递给 UserPicker。

App组件导入UserContext对象,然后将其 UI 包裹在上下文的Provider组件中,使user状态值对所有树中的组件可用:

<UserContext.Provider value={user}>
  // all app UI
</UserContext.Provider>

提供者不必包裹整个组件树。按照目前的代码,由于应用将usersetUser作为属性传递给UserPicker组件,我们只需将路由包裹在提供者中即可:

<Router>
  <div className="App">
    <header>
      // nav and user picker
    </header>
    <UserContext.Provider value={user}>
      <Routes>
        // routes
      </Routes>
    </UserContext.Provider>
  </div>
</Router>

但在后面的部分,我们将用户选择器切换到使用上下文而不是属性,因此将整个组件树包裹在提供者中是有用的。现在,UserPicker组件接收选定的用户及其更新函数作为属性。以下列表显示了它是如何使用这些属性的。

分支:0802-user-context,文件:/src/components/Users/UserPicker.js

列表 8.5 在UserPicker中接收用户和更新函数

import {useEffect, useState} from "react";
import Spinner from "../UI/Spinner";
export default function UserPicker ({user, setUser}) {              ❶
  const [users, setUsers] = useState(null);
  useEffect(() => {
    fetch("http://localhost:3001/users")
      .then(resp => resp.json())
      .then(data => {
        setUsers(data);
        setUser(data[0]);                                           ❷
      });
  }, [setUser]);                                                    ❸
  function handleSelect(e) {
    const selectedID = parseInt(e.target.value, 10);                ❹
    const selectedUser = users.find(u => u.id === selectedID);      ❹

    setUser(selectedUser);                                          ❺
  }

  if (users === null) {
    return <Spinner/>
  }

  return (
    <select
      className="user-picker"
      onChange={handleSelect}                                       ❻
      value={user?.id}                                              ❼
    >
      {users.map(u => (
        <option key={u.id} value={u.id}>{u.name}</option>           ❽
      ))}
    </select>
  );
}

❶ 将用户和 setUser 属性分配给局部变量。

❷ 一旦用户加载完成,将当前用户设置为第一个。

❸ 将 setUser 作为依赖项包括在内。

❹ 使用 id 查找选定的用户对象。

❺ 设置选定的用户。

❻ 为下拉指定事件处理器。

❼ 设置当前选择。

❽ 为每个选项设置一个值。

UserPicker组件从数据库中加载数据。一旦它有了数据,它就会调用作为属性接收的setUser来设置当前用户。由于状态的更新,App组件会重新渲染,将更新的用户值作为用户上下文提供者的值。由于App重新渲染,它的所有子组件也会重新渲染。这包括任何消费上下文的子组件,它们将获取新的上下文值。《UserPicker》也会显示选定的用户,将其设置为 UI 中 HTML select元素的值。(注意每个option元素现在都有一个value属性设置为用户的 ID。)

图片

图 8.7 再次显示带有标题右侧编辑按钮的BookingDetails组件

要查看整个更新的上下文过程在实际中的应用,我们需要一个消费用户上下文值的组件。让我们从以下列表中所述的BookingDetails组件开始。记住,我们需要用户值来决定是否显示编辑按钮,如图 8.7 所示。

分支:0802-user-context,文件:/src/components/Bookings/BookingDetails.js

列表 8.6 BookingDetails组件从上下文中读取用户

import {useContext} from "react";                                         ❶

import {FaEdit} from "react-icons/fa";                                    ❷

import Booking from "./Booking";

import UserContext from "../Users/UserContext";                           ❸

export default function BookingDetails ({booking, bookable}) {

  const user = useContext(UserContext);                                   ❹

  const isBooker = booking && user && (booking.bookerId === user.id);     ❺

  return (
    <div className="booking-details">
      <h2>
        Booking Details
        {isBooker && (                                                    ❻
          <span className="controls">
            <button                                                       ❼
              className="btn"
            >
              <FaEdit/>                                                   ❽
            </button>
          </span>
        )}
      </h2>

      {booking ? (
        // booking
      ) : (
        // message
      )}
    </div>
  );
}

❶ 导入useContext钩子。

❷ 导入编辑按钮的图标。

❸ 导入我们的共享上下文。

❹ 使用共享上下文调用useContext并将值赋给user变量。

❺ 检查预订是否属于用户。

❻ 仅当预订属于用户时才显示编辑按钮。

❼ 渲染一个按钮,但不要附加处理程序。

❽ 使用导入的编辑图标作为按钮。

组件导入UserContext上下文对象并将其传递给useContext钩子,将钩子返回的值赋给user变量。一旦BookingDetails组件有了用户和预订信息,它就可以检查预订是否由该用户进行:

const isBooker = booking && user && (booking.bookerId === user.id);

如果当前用户预订了该预订,isBooker将为true,组件将在标题后显示编辑按钮:

<h2>
  Booking Details
  {isBooker && (
    // edit button UI
  )}
</h2>

按钮目前没有任何功能,但它应该只在当前用户(在用户选择器中选中的用户)是所选预订的预订者时出现。通过选择不同的用户然后选择不同的预订来测试显示和隐藏逻辑。(当你加载预订页面时,在周选择器中点击 Go 将带你到默认日期——如果你使用的是从仓库中的 db.json,它将设置一些预订。)

挑战 8.1

更新用户页面,以便在切换到页面时,它自动显示当前用户的详细信息。例如,如图 8.8 所示,如果当前用户是 Clarisse,你切换到用户页面,将显示 Clarisse 的详细信息,并在用户列表中选择 Clarisse。

图片

图 8.8 在用户选择器(右上角)中选择了 Clarisse 作为当前用户。当访客切换到用户页面时,Clarisse 会自动在用户列表(左侧)中选中,并显示其详细信息(右侧)。

使用与BookingDetails组件相同的UserContext对象,并调用useContext以获取当前用户。完成的挑战位于 GitHub 仓库的 0803-users-page 分支。

React 的上下文 API 在共享预订应用中选择的用户方面表现良好。但它引发了一些问题:如果我们有多个要共享的值怎么办?或者一个具有许多属性的更复杂值?我们能否在调用setUser时避免触发整个组件树的重新渲染?让我们在寻找这些问题的答案时,更深入地挖掘 React 渲染的细微差别。

8.2 使用自定义提供者和多个上下文

我们已经成功地将共享状态的美味片段喂给了我们应用程序树中嵌套较深的组件。我们使用上下文对象的 Provider 组件来提供值,并且消费组件通过调用 useContext 并传入相同的上下文对象来访问该值。每当值发生变化时,消费者会重新渲染。如果只有消费者重新渲染通过上下文共享的值,那就太好了。在预订应用程序中,App 组件中更新用户状态会导致整个树重新渲染。不仅仅是品尝碎片的人更新;那些吃玉米卷的人(不关心用户的组件)也会更新。

在本节中,我们探讨扩展我们对上下文使用的方法。第一种,使用对象作为值,可能会引起问题。第二种和第三种,使用自定义提供者和多个上下文,可能有助于我们解决问题。最后一种方法允许我们为上下文指定一个默认值。

8.2.1 将对象作为上下文提供者的值

在列表 8.4 中,我们的 App 组件利用 useState 钩子来管理当前用户状态。它通过设置上下文对象 Provider 组件的 value 属性,使得 user 值可供子组件使用:

<UserContext.Provider value={user}>
  // app JSX
</UserContext.Provider/>

这些子组件之一,UserPicker,需要 user 状态值以及其更新函数 setUser。因为它需要的不仅仅是 user 值,所以我们使用传统的属性来满足其需求:

<UserPicker user={user} setUser={setUser}/>

传递属性没有问题。当前版本的程序运行良好,数据流易于跟踪。但是,鉴于我们已经在应用程序的上下文中有了 user 状态值,让我们更新 UserPicker 组件以消费该状态。我们想要的是:

<UserPicker/>

UserPicker 需要的 user 值和 setUser 函数。我们能将它们两个都放在上下文中吗?当然可以!

<UserContext.Provider value={{user, setUser}}>
  // app JSX
</UserContext.Provider/>

现在我们将 JavaScript 对象作为值赋给提供者,消费这些值的组件必须从值对象中解构它们需要的属性。例如,BookingDetails 组件将像这样获取用户值:

const {user} = useContext(UserContext);

现在赋值时,user 变量名周围有花括号。这并不糟糕。但关于 UsersPage 组件(在挑战 8.1 中更新过)呢?它之前将上下文值分配给了一个 loggedInUser 变量。没问题:

const {user : loggedInUser} = useContext(UserContext);

冒号语法允许我们在解构对象时将属性分配给不同命名的变量。在上面的代码片段中,上下文值的 user 属性被分配给名为 loggedInUser 的变量。

最后一个使用上下文值的组件是 UserPicker 组件。实际上,因为它需要 usersetUser 更新函数,这也是我们为什么将值切换为对象的原因。这没问题;在解构时,我们可以将所有需要的属性分配给局部变量:

const {user, setUser} = useContext(UserContext);

这三个不同的组件以三种不同的方式使用上下文值。在第 8.2.2 节中,我们进一步发展,为用户上下文开发我们自己的自定义提供者。如果你想要切换上下文值到对象的代码,如刚才讨论的,请查看挑战 8.2 的解决方案分支。

8.2 挑战

更新App.js,使App组件将一个对象(包含usersetUser属性)作为value属性传递给用户上下文的Provider组件。更新BookingDetailsUsersPageUserPicker组件,通过解构使用新的对象值。完成挑战的代码位于 GitHub 仓库的 0804-object-value 分支。

8.2.2 将状态移动到自定义提供者

当前用户由预订应用的UserPicker组件确定(尽管在实际应用中,用户会进行登录)。当前用户的状态值由App组件管理;这就是上下文提供者UserContext.Provider包裹组件树的地方。当网站访客在用户选择器中选择用户时,UserPicker组件会调用setUser来更新App组件中的user状态值。React 注意到状态已更改,并重新渲染管理该状态的组件App。因为App重新渲染,所以它的所有子组件也会重新渲染,如图 8.9 所示。

图片

图 8.9 在App组件中调用setUser重新渲染整个树。Provider 之后的组件周围的灰色带代表上下文:UserPickerBookingDetails从上下文中访问用户值。

重新渲染本身并不是坏事——我们专注于状态,React 调用组件,执行 diffing 操作,并刺激 DOM——如果你的应用性能良好,就没有必要使代码复杂化。但是,如果树中有较慢、更复杂的组件,你可能想要避免不会改变 UI 的重新渲染。我们希望有一种方法来更新上下文提供者的值,而不会在整个组件树中引发一系列更新。我们希望上下文消费者(调用useContext的组件)在提供者值变化时重新渲染,而不仅仅是由于整个树重新渲染。我们能否避免在App组件中更新状态?

回答那个问题需要很好地理解 React 的渲染行为。我们将在以下四个小节中讨论这些概念以及如何应用它们:

  • 创建自定义提供者

  • 使用children属性渲染包装组件

  • 避免不必要的重新渲染

  • 使用自定义提供者

创建自定义提供者

如果 App 只管理 user 状态以便将其传递给 UserContext.Provider,而我们已经有了一个单独的 UserContext 文件,为什么不在与上下文相同的地方管理状态呢?我们能构建一个 UserProvider 组件,用它来包裹组件树并管理用户状态本身吗?当然可以!下面的列表展示了我们自己的自定义提供者组件,UserProvider

分支:0805-custom-provider,文件:/src/components/Users/UserContext.js

列表 8.7 导出自定义提供者与用户上下文

import {createContext, useState} from "react";

const UserContext = createContext();
export default UserContext;                             ❶

export function UserProvider ({children}) {             ❷
  const [user, setUser] = useState(null);               ❸

  return (
    <UserContext.Provider value={{user, setUser}}>      ❹
      {children}                                        ❺
    </UserContext.Provider>
  );
}

❶ 导出上下文对象,以便其他组件可以导入它。

❷ 将特殊的 children 属性分配给一个局部变量。

❸ 在组件内管理用户状态。

❹ 设置一个对象作为上下文值。

❺ 在提供者内部渲染子组件。

UserContext 仍然是默认导出,所以不需要更改直接导入和使用它的文件。但是,上下文文件现在有一个命名导出,UserProvider,我们的自定义提供者组件。自定义提供者调用 useState 来管理用户值并获取一个更新函数。它将值和函数(封装在一个对象中)传递给 UserContext.Provider 组件作为上下文共享的值:

<UserContext.Provider value={{user, setUser}}>
  {children}
</UserContext.Provider>

当我们使用我们的自定义提供者时,我们用 JSX 将它包裹在应用的部分或全部周围。所有被包裹的组件都可以访问提供者设置的值(如果它们使用 UserContext 调用 useContext):

<UserProvider>
  // app components
</UserProvider>

让我们更详细地看看那个 children 属性。

使用 children 属性渲染包裹的组件

每当组件包裹其他组件时,React 会将包裹的组件分配给包装器的 children 属性。例如,这里有一个 Wrapper 组件,它有一个作为子组件的 MyComponent 组件:

<Wrapper>
  <MyComponent/>
</Wrapper>

当 React 调用 Wrapper 来获取其 UI 时,它将子组件 MyComponent 传递给 Wrapper,并将其分配给 children 属性。(React 一直都在这样做;我们只是直到现在还没有使用 children 属性。)

function Wrapper ({children}) {                       ❶

  return <div className="wrapped">{children}</div>    ❷

}

❶ React 将任何子组件分配给 children 属性。

❷ 在返回 UI 时使用子组件。

当返回其 UI 时,Wrapper 使用 React 分配给 children 的组件。UI 变成以下内容:

<div className="wrapped"><MyComponent/></div>

对于 Wrapper 示例,children 是一个单个组件。如果 Wrapper 包裹多个兄弟组件,那么 children 是组件数组。更多关于在 React 文档中处理 children 属性的信息:reactjs.org/docs/react-api.html#reactchildren

在我们的 App 组件中,React 将 UserProviderchildren 属性分配给 UserProvider 包裹的组件。UserProvider 使用 children 属性确保 UserContext.Provider 组件仍然渲染我们的自定义 UserProvider 组件现在包裹的组件:

export function UserProvider ({children}) {              ❶
  const [user, setUser] = useState(null);

  return (
    <UserContext.Provider value={{user, setUser}}>
      {children}                                         ❷
    </UserContext.Provider>
  );
}

❶ React 将包裹的组件分配给 children 属性。

❷ 在上下文中提供者内部渲染包裹的组件。

它将子组件包裹在用户上下文的提供者中,并设置提供者的value,使得user值和setUser函数对被包裹的子组件可用。现在上下文和状态在同一位置。这对组织、理解和可维护性来说是个优点。但还有优化好处。

避免不必要的重新渲染

当一个后代(例如用户选择器)调用setUser来更新UserProvider组件中的user状态值时,React 会注意到状态已更改并重新渲染管理该状态组件UserProvider。但对于UserProvider,所有子组件都不会重新渲染,如图 8.10 所示。

图片

图 8.10 当UserProvider重新渲染时,只有上下文消费者,而不是整个树,会重新渲染。

这可能令人意外,但这是标准的 React 渲染行为;这里没有应用特殊的记忆化函数。是什么让UserProviderApp组件管理用户状态时表现得不同?什么阻止 React 渲染提供者的子组件?

这是因为UserProvider将其子组件作为 prop 访问,并且在组件内部更新状态不会改变其 props。当后代调用setUser时,children的身份不会改变。它正是之前相同的对象。没有必要重新渲染所有子组件,所以 React 不会。

除了上下文消费者!上下文消费者在其上下文最近的提供者值更改时总是重新渲染。我们的自定义提供者为消费者提供了一个更新函数。当组件调用更新函数时,自定义提供者会重新渲染,更新其上下文值。React 知道提供者的子组件没有变化,所以不会重新渲染它们。然而,任何消费上下文的组件都会在提供者值更改时重新渲染,而不是因为整个组件树已经重新渲染。

使用自定义提供者

现在我们自定义提供者负责用户状态,我们可以简化App组件,移除导入和调用useState以及设置提供者值的需求。下面的列表显示了更简洁的代码。注意,我们也不再在UserPicker上设置 props;在挑战 8.2 中,它已经切换到使用上下文。

分支:0805-custom-provider,文件:/src/components/App.js

列表 8.8 在App组件中使用自定义提供者

// remove import for useState
// unchanged imports

import {UserProvider} from "./Users/UserContext";     ❶

export default function App () {
  return (
    <UserProvider>                                    ❷
      <Router>
        <div className="App">
          <header>
            // nav

            <UserPicker/>                             ❸
          </header>

          <Routes>
            // routes
          </Routes>
        </div>
      </Router>
    </UserProvider>                                   ❹
  );
}

❶ 导入自定义提供者。

❷ 将应用 UI 包裹在提供者中。

❸ 不要向用户选择器传递 props。

❹ 将应用 UI 包裹在提供者中。

因为在 JSX 中,UserProvider 包裹了 Router,所以 Router 组件被分配给 UserProvider 组件的 children 属性,而我们的自定义提供者 UserProvider 则将其包裹在 UserContext.Provider 中,这就是实际的上下文提供组件。这样,应用中的每个组件都可以访问用户上下文。在第九章中,我们将看到如何使用自定义钩子从消费者角度更轻松地与 Context API 一起工作。

我们的定制提供者将一个对象 {user, setUser} 分配为上下文提供组件的值。在下一节中,我们将探讨以这种方式使用对象的不利之处。

8.2.3 与多个上下文一起工作

现在我们有了在整个应用中共享值的方法,你可能会想创建一个单一、庞大的存储库来存储应用的状态,并让任何地方的组件消费它提供的庞大、有气味的值。但——正如你从上一句夸张的表述中猜到的——这并不总是最好的主意。如果一个组件需要一些状态,尽量在组件中管理它。

将状态与使用它的组件保持在一起,使其更容易使用和重用组件。如果应用发展,并且一个兄弟组件现在需要相同的状态,将状态提升到共享父组件中,并通过属性传递下来。如果在状态和某些使用它的组件之间引入了额外的嵌套组件层级,在伸手去拿 Context API 之前,考虑一下 组件组合。React 文档中有些关于组合的信息:mng.bz/PPjY

如果你发现你确实有一些不经常改变且在应用的不同层级被多个组件使用的状态,那么 Context API 看起来是一个很好的选择。但即便如此,由上下文提供的单个状态对象可能效率不高。假设你的上下文状态值如下所示:

value = {
  theme: "lava",
  user: 1,
  language: "en",
  animal: "Red Panda"
};

<MyContext.Provider value={value}><App/></MyContext.Provider>

在你的组件层次结构中,一些组件使用主题,一些使用用户,其他使用语言,还有一些使用动物。问题是,如果单个属性值发生变化(比如主题从 lava 变为 cute),那么消费上下文的 所有 组件都将重新渲染,即使它们对更改的值不感兴趣。一个渴望仅获取最精华状态片段的嵌套组件得到了玉米卷、木薯粉和一大块羊肉塔吉锅!幸运的是,有一个简单的解决办法。(尽管,如果我能保留塔吉锅,我会开心好几天。嗯嗯,塔吉锅……)

在多个提供者之间拆分上下文值

你可以根据需要使用任意多的上下文,嵌套组件只需在其消费的上下文中调用 useContext 钩子。如果每个共享值都有自己的提供者,那么提供者看起来是这样的:

<ThemeContext.Provider value="lava">
  <UserContext.Provider value=1>
    <LanguageContext.Provider value="en">
      <AnimalContext.Provider value="Red Panda">
        <App/>
      </AnimalContext.Provider>
    </LanguageContext.Provider>
  </UserContext.Provider>
</ThemeContext.Provider>

然后,嵌套组件只消费它们需要的值,并在所选值改变时重新渲染。这里有两组组件,每组访问一对上下文值:

function InfoPage (props) {
  const theme = useContext(ThemeContext);
  const language = useContext(LanguageContext);

  return (/* UI */);
}

function Messages (props) {
  const theme = useContext(ThemeContext);
  const user = useContext(UserContext);

  // subscribe to messages for user

  return (/* UI */);
}

使用自定义提供者为多个上下文提供支持

你希望将提供者尽可能靠近消费其上下文的组件,包装子树而不是整个应用。有时,上下文确实在整个应用中使用,提供者可以放在或接近根位置。根部的代码通常变化不大,所以不必担心嵌套多个提供者;你不必将嵌套视为“包装地狱”或“末日金字塔”。如果你愿意,并且提供者可能保持在一起,你始终可以创建一个自定义提供者,将多个提供者组合在一个地方,如下所示:

function AppProvider ({children}) {
  // maybe manage some state here

  return (
    <ThemeContext.Provider value="lava">
      <UserContext.Provider value=1>
        <LanguageContext.Provider value="en">
          <AnimalContext.Provider value="Red Panda">
            {children}
          </AnimalContext.Provider>
        </LanguageContext.Provider>
      </UserContext.Provider>
    </ThemeContext.Provider>
  );
}

然后应用可以使用自定义提供者(s):

<AppProvider>
  <App/>
</AppProvider>

正如我们在 8.2.2 节中看到的,使用具有 children 属性的自定义提供者也有助于减少不必要的子组件重新渲染。

使用单独的上下文为状态值及其更新函数

当上下文提供者的值发生变化时,其消费者会重新渲染。提供者也可能因为其父组件重新渲染而重新渲染。如果提供者的值是一个代码每次提供者渲染时都创建的对象,则值在每个渲染时都会变化,即使分配给对象的属性值保持不变。

再看看我们的预订应用中的自定义 UserProvider 组件:

export function UserProvider ({children}) { 
  const [user, setUser] = useState(null);

  return (
    <UserContext.Provider value={{user, setUser}}>     ❶
      {children}
    </UserContext.Provider>
  );
}

❶ 每次渲染都会将一个新的对象分配给值属性。

我们将一个对象 {user, setUser} 分配给提供者的 value 属性。每次组件渲染时,都会分配一个新的对象,即使两个属性 usersetUser 是相同的。上下文的消费者——UserPickerUsersPageBookingDetails——在 UserProvider 重新渲染时也会重新渲染。

此外,通过使用对象作为值,如果嵌套组件只使用对象上的一个属性,那么当其他属性变化时,它仍然会重新渲染(又是零碎和玉米卷)。在我们的例子中,这不是问题;setUser 从不改变,唯一使用它的组件 UserPicker 也使用 user 属性。但如果我们构建一个合适的登录系统,我们可以轻松地创建一个不需要当前用户但需要调用 setUser 的注销按钮。没有必要每次用户更改时都重新渲染按钮。

因此,我们有两个问题:

  • 每次渲染都会将一个新的对象分配给提供者值。

  • 在值上更改一个属性会重新渲染可能不会消费该值的消费者。

我们可以通过在自定义提供者中使用两个上下文而不是一个来解决这两个问题,如下所示。

分支:0806-multiple-contexts,文件:/src/components/Users/UserContext.js

列表 8.9 使用单独的提供者为值及其更新函数

import {createContext, useState} from "react";

const UserContext = createContext();
export default UserContext;

export const UserSetContext = createContext();     ❶

export function UserProvider ({children}) {
  const [user, setUser] = useState(null);

  return (
    <UserContext.Provider value={user}>            ❷
      <UserSetContext.Provider value={setUser}>    ❸
        {children}
 </UserSetContext.Provider>
    </UserContext.Provider>
  );
}

❶ 为设置当前用户创建一个单独的上下文。

❷ 将用户设置为值。

❸ 将更新函数作为值设置在其自己的提供者上。

usersetUser 并不是每次渲染时都重新创建,我们现在为每个值使用单独的上下文和提供者,因此一个值的消费者不会受到另一个值变化的影响。

最新分支也更新了消费者组件;它们不需要从值对象中解构值,UserPicker 导入并使用了新的 UserSetContext 上下文对象。

8.2.4 为上下文指定默认值

使用 Context API 涉及提供者和消费者:提供者设置一个值,消费者读取这个值。但与两个独立部分一起工作可能需要一点信任。如果我们调用 useContext 并传入一个上下文对象,但树中没有设置相应的提供者怎么办?如果适当,在创建上下文对象时,我们可以指定一个默认值以应对这种情况,如下所示:

const MyContext = createContext(defaultValue);

如果没有设置相应提供者来设置该上下文的值,useContext 钩子将返回上下文对象的默认值。如果您的应用程序使用默认语言或主题,这可能很有用;可以使用提供者来覆盖默认值,但如果未包含提供者,一切仍然可以正常工作。

摘要

  • 对于许多组件使用的很少改变的价值,考虑使用 Context API。

  • 创建一个上下文对象来管理组件将要访问的特定值:

    const MyContext = createContext();
    
  • 导出上下文对象以使其对其他组件可用。(或者创建与提供者和消费者组件相同作用域的上下文对象。)

  • 将上下文对象导入提供者和消费者组件文件中。

  • 将需要访问共享状态值的组件树包裹在上下文对象的提供者组件中,将值作为属性设置:

    <MyContext.Provider value={value}>
      <MyComponent />
    </MyContext.Provider>
    
  • 使用 useContext 钩子访问上下文值,传入上下文对象:

    const localValue = useContext(MyContext);
    

    每当上下文值发生变化时,消费组件将重新渲染。

  • 可选地,在创建上下文时指定一个默认值:

    const MyContext = createContext(defaultValue);
    

    如果在组件树中没有设置上下文的提供者,useContext 钩子将返回默认值。

  • 对于通常不一起消费的值,使用多个上下文。消费一个值的消费者组件可以独立于消费另一个值的组件重新渲染。

  • 创建自定义提供者来管理共享值的州。

  • 在自定义组件中使用 children 属性以避免重新渲染不消费上下文的子组件。

9 创建你自己的 hooks

本章涵盖

  • 将功能提取到自定义 hooks 中

  • 遵循 Hooks 规则

  • 使用自定义 hook 消费上下文值

  • 使用自定义 hook 封装数据获取

  • 探索自定义 hooks 的更多示例

React Hooks 承诺简化组件代码并促进封装、可重用性和可维护性。它们让函数组件能够与 React 紧密合作来管理状态,并挂钩到生命周期事件以进行挂载、渲染和卸载。带有 hooks 的函数组件将相关代码集中在一起,并消除了在类组件的单独生命周期方法中混合无关代码的需要。

图 9.1 对比了基于类和基于函数的Quiz组件中代码的位置,该组件加载问题数据并订阅用户服务。而类组件将功能分散在其方法中,Quiz函数组件通过调用useStateuseReducer来管理本地状态,并在单独的调用中使用useEffect来封装问题数据的加载和用户服务的订阅。

图 9.1 React Hooks 让我们将相关代码移动到单个位置,并停止在生命周期方法中混合无关代码。

我们可以在这里停止,让函数组件包含由 hooks 管理的状态和效果。Quiz函数组件看起来比类组件更整洁,更容易理解。但就像我们将较长的函数拆分成多个较短的函数一样,我们也可以将 hooks 执行的工作提取到组件外部的自定义 hooks中,以简化组件代码并准备功能以供重用。例如,对于Quiz组件,我们可以使用useFetchhook 来加载问题数据,并使用useUsershook 来订阅服务。

本章包括自定义 hooks,一些基于我们之前看到的代码(第四章中的useEffect示例,包括用于获取数据的 hooks)和一些扩展了之前的代码(我们创建一个 hook 来访问第八章中的上下文值)。这些示例说明了自定义 hooks 如何通过参数变得灵活,并可以通过函数、数组和对象提供有用的返回值。但 hooks 的整洁性和灵活性也带来了一些限制,这些限制在 9.2 节中总结为Hooks 规则

在我们深入研究规则或对预订应用进行深入探讨之前,让我们先详细了解一下为什么自定义 hooks 是一件好事,以及我们的前两个自定义 hooks,useRandomTitleuseDocumentTitle

9.1 将功能提取到自定义 hooks 中

React 钩子让我们在函数组件中管理本地状态,通过上下文访问应用程序状态,并挂钩到生命周期事件以执行和清理副作用。通过将相关代码放在一个地方,而不是分散在各种类方法中,我们可以更好地利用函数。我们可以将常用代码提取到单独的函数中,简化我们的组件。图 9.2 展示了Quiz组件的关键功能,加载问题和订阅用户服务,可以被提取到两个函数中,或者自定义钩子useFetchuseUsers

图片

图 9.2 使用自定义钩子,我们可以将一些状态和功能移动到单独的函数中。

使用合适的自定义钩子命名,Quiz组件的代码变得更短,更容易理解,如图 9.3 的左侧所示。应该很明显,Quiz组件通过调用useUsers来访问用户信息,并通过调用useFetch来获取数据。

图片

图 9.3 许多组件可以调用我们的自定义钩子。

将功能移动到自定义钩子中,也让我们可以在多个组件中重用该功能,图 9.3 展示了第二个组件Chat调用相同的useUsers钩子。特别有用的钩子可以在团队间共享,甚至发布并使全球的开发者可用。

库的作者可以创建钩子,使关键功能对函数组件可用,我们在第十章中查看了一些示例——使用 React Router 进行路由和用 React Query 获取数据。在本节中,我们再次向第四章中的一个简单组件问好,该组件在效果内部访问文档的标题。我们考虑以下内容:

  • 识别可共享的功能

  • 在组件外部定义自定义钩子

  • 从自定义钩子中调用自定义钩子

在创建我们的第一个示例时,我们遇到了自定义钩子的命名约定,这需要在第 9.2 节中设置一些规则。

9.1.1 识别可共享的功能

我们有一个SayHello组件,它在文档的标题中显示问候语。首次加载时,组件显示一个随机问候语和一个“说你好”按钮。每次点击按钮时,它会更新问候语,如图 9.4 所示。

图片

图 9.4 以不同问候语为标题的浏览器文档的三个视图

组件执行两个主要任务:

  • 从列表中选择问候语

  • 将文档标题设置为所选问候语

在接下来的小节中,我们将标题设置代码提取到我们的第一个自定义钩子useDocumentTitle中,并将随机标题选择提取到第二个useRandomTitle中。SayHello组件的原始代码再次在列表 9.1 中显示,其中可以看到调用useEffect钩子来设置文档的标题。(此列表中的效果现在指定了index作为依赖项;它仅在index更改时设置标题。)

实时演示: jhijd.csb.app代码: codesandbox.io/s/sayhello-jhijd

列表 9.1 更新浏览器标题

import React, {useState, useEffect} from "react";                  ❶

function SayHello () {
  const greetings = ["Hello", "Ciao", "Hola", "こんにちは"];
  const [index, setIndex] = useState(0);

  useEffect(() => {                                                ❷
    document.title = greetings[index];                             ❸
  }, [index]);                                                     ❹

  function updateGreeting () {
    setIndex(Math.floor(Math.random() * greetings.length));
  }

  return <button onClick={updateGreeting}>Say Hi</button>
}

❶ 导入 useEffect 钩子。

❷ 将函数传递给 useEffect 钩子,作为效果。

❸ 在效果内部更新浏览器标题。

❹ 仅当索引发生变化时更新标题。

设置文档的标题是我们可能在多个页面和多个项目中想要使用的功能。作为函数,钩子让我们可以轻松地提取和共享功能。组件可以将参数传递给我们的钩子,钩子可以返回状态值和函数,以赋予组件完成任务所需的权力。让我们看看如何。

9.1.2 在组件外部定义自定义钩子

设置文档标题是一个简单的例子,你可以在需要该功能时轻松地重新创建效果。但它的简单性让我们可以专注于将提取到自定义钩子中,而不必与效果本身相关的任何认知压力。列表 9.2 显示了与列表 9.1 中相同的友好的 SayHello 组件,这次效果被移动到一个单独的函数 useDocumentTitle 中,该函数位于组件定义之外。

列表 9.2 将效果提取到 useDocumentTitle 钩子中

import React, {useState, useEffect} from "react";

function useDocumentTitle (title) {                                ❶
  useEffect(() => {                                                ❷
    document.title = title;
  }, [title]);                                                     ❸
}

export default function SayHello () {
  const greetings = ["Hello", "Ciao", "Hola", "こんにちは"];
  const [index, setIndex] = useState(0);

  function updateGreeting () {
    setIndex(Math.floor(Math.random() * greetings.length));
  }

  useDocumentTitle(greetings[index]);                              ❹

  return <button onClick={updateGreeting}>Say Hi</button>
}

❶ 定义一个以“use”开头的函数作为自定义钩子。

❷ 在自定义钩子内部调用原始的 useEffect 钩子。

❸ 仅当标题发生变化时更新文档标题。

❹ 调用自定义钩子,并传递要显示的标题。

在列表 9.2 中,自定义钩子是在组件外部定义的,但在同一文件中。你可以,并且通常这样做,将自定义钩子移动到自己的文件(或包含多个实用钩子的文件)中,并将其导入任何需要它的组件中。

我们将自定义钩子命名为 useDocumentTitle。在使用钩子时,有一些规则需要遵循以保持组件的平稳运行,如第 9.2 节所述,并且所有钩子的名称都以“use”开头有助于强制执行这些规则。这是一个重要的命名约定,值得拥有自己的侧边栏。

自定义钩子的名称应以“use”开头

为了明确一个函数是自定义钩子并且应该遵循钩子的规则,请以 use 开头命名,例如,useDocumentTitleuseFetchuseUsersuseLocalStorage

我们不仅可以通过调用自定义钩子来增强组件的功能。我们的自定义钩子也可以充分利用额外的功能!毕竟,最终只是函数调用函数。

9.1.3 在自定义钩子中调用自定义钩子

在执行他们的工作时,你新塑造的钩子可能会执行一些有用的任务,这些任务可以被提取成它们自己的自定义钩子,其中一个钩子调用一个或多个其他钩子。并且你的钩子可以向调用组件返回值,这些值可以用于 UI 中,或者用于更新由钩子控制的州。例如,对于列表 9.2 中的SayHello组件,我们还可以提取“选择一个随机问候语”的功能。列表 9.3 显示了我们的标题设置组件SayHello的最终、紧凑形式,其中关键标题设置功能被提取到一个从另一个文件导入的useRandomTitle钩子中(如列表 9.4 所示)。

Live: ynmc2.csb.app/, Code: codesandbox.io/s/userandomtitle-ynmc2

列表 9.3 一个紧凑的标题设置SayHello组件

import React from "react";
import useRandomTitle from "./useRandomTitle";              ❶

const greetings = ["Hello", "Ciao", "Hola", "こんにちは"];

export default function SayHello () {
 const nextTitle = useRandomTitle(greetings);              ❷

  return <button onClick={nextTitle}>Say Hi</button>        ❸
}

❶ 导入我们的自定义钩子。

❷ 将自定义钩子传递给要使用的问候语,并将它返回的函数分配给一个变量。

❸ 使用钩子返回的函数来更新文档标题,每当按钮被点击时。

在列表 9.3 中,我们向useRandomTitle钩子传递了用于选择文档标题的问候语列表。钩子返回一个我们调用的函数来生成下一个标题。我们已经将标题的生成过程抽象到钩子中,但通过使用合理的钩子和变量名称,组件代码易于理解。图 9.5 显示了组件调用一个钩子,该钩子又调用另一个。

图 9.5 简化的SayHello组件调用了useRandomTitle钩子,该钩子又调用了useDocumentTitle钩子。

列表 9.4 显示了useRandomTitle钩子的代码。它包括它自己的两个钩子调用,一个是对内置的useState钩子的调用,另一个是对我们之前提到的useDocumentTitle自定义钩子的调用,现在已移动到自己的文件中(如列表 9.5 所示)。

Live: ynmc2.csb.app/, Code: codesandbox.io/s/userandomtitle-ynmc2

列表 9.4 useRandomTitle自定义钩子调用useDocumentTitle

import {useState} from "react";
import useDocumentTitle from "./useDocumentTitle";                      ❶

const getRandomIndex = length => Math.floor(Math.random() * length);    ❷

export default function useRandomTitle (titles = ["Hello"]) {           ❸

  const [index, setIndex] = useState(
    () => getRandomIndex(titles.length)                                 ❹
  );

  useDocumentTitle(titles[index]);                                      ❺

  return () => setIndex(getRandomIndex(titles.length));                 ❻
}

❶ 导入我们的自定义钩子。

❷ 在钩子外部定义此函数。

❸ 提供一个默认的问候语列表。

❹ 提供一个函数来为初始状态选择一个随机的问候语索引。

❺ 调用我们导入的自定义钩子来更新文档标题。

❻ 返回一个函数,以便使用此钩子的代码可以更新标题。

useRandomTitle自定义钩子使用useState钩子来管理要显示的标题的索引。使用钩子的代码不需要知道钩子如何管理当前标题;它只需要能够请求显示一个新的标题。钩子返回一个函数,以便使用钩子的代码可以请求下一个标题。useRandomTitle自定义钩子还调用了我们之前提到的useDocumentTitle自定义钩子,以下列表显示了从其自己的文件中导出的该自定义钩子。

Live: ynmc2.csb.app/, Code: codesandbox.io/s/userandomtitle-ynmc2

列表 9.5 从其自己的文件导出的 useDocumentTitle hook

import {useEffect} from "react";

export default function useDocumentTitle (title) {     ❶
  useEffect(() => {
    document.title = title;                            ❷
  }, [title]);                                         ❸
}

❶ 为标题指定一个参数。

❷ 将文档的标题设置为传入的值。

❸ 仅当标题值改变时更新文档标题。

列表 9.3、9.4 和 9.5 一起展示了自定义 hooks 如何调用自定义 hooks 并仅返回组件使用时所需的内容。但在我们被我们的提取/抽象热情冲昏头脑之前,我们需要了解一下 React 如何管理这些 hook 调用以及如何确保它们按预期工作。是的,有规则!

9.2 遵循 Hooks 的规则

到目前为止,这本书和这一章中我们已经看到了 hooks 的许多优点。它们帮助组织和澄清代码的方式以及它们承诺的高效代码抽象和重用都是非常有吸引力的。但是,为了让 hooks 履行其承诺,React 团队做出了一些有趣的实现决策。虽然 React 通常不会对你的 JavaScript 强加太多惯例,但与 hooks 一起,团队已经制定了一些规则:

  • 自定义 hooks 的名称以 “use.” 开头。

  • 只在顶层调用 hooks。

  • 只在 React 函数中调用 hooks。

当你调用像 useStateuseEffect 这样的 hooks 时,你是在请求 React 的帮助来管理状态和副作用、批量更新、计算 UI 差异以及安排 DOM 变更。为了 React 能够成功且可靠地跟踪组件的状态,这些组件中的 hook 调用需要保持一致性和数量。hooks 的三个规则就是为了确保你的 hooks 调用顺序在渲染之间不会改变。

Hooks 的规则

  • 以 “use.” 开头命名 hooks。

  • 只在顶层调用 hooks。

  • 只在 React 函数中调用 hooks。

让我们更详细地看看最后两个规则。

9.2.1 只在顶层调用 hooks

组件每次运行时都一致地调用 hooks 非常重要。你不应该在某些情况下调用 hooks,而在其他情况下不调用,也不应该在组件每次运行时以不同的次数调用它们。为了确保你的 hook 调用一致,遵循以下约定:

  • 不要 在条件语句中放置 hooks。

  • 不要 在循环中放置 hooks。

  • 不要 在嵌套函数中放置 hooks。

这三个场景中的任何一个都可能导致你跳过 hooks 的调用或改变组件调用 hooks 的次数。

如果你有一个只在特定条件下运行的副作用,并且这些条件没有被依赖数组覆盖,将条件放在副作用函数中。不要这样做

if (condition) {
  useEffect(() => {       ❶
    // perform effect     ❶
  }, [dep1, dep2]);       ❶
}

❶ 不要在条件中将 hook 调用放在里面。

在条件中隐藏一个副作用可能会根据条件跳过该副作用。但我们的副作用必须 始终 运行。相反,这样做

useEffect(() => {
  if (condition) {       ❶
    // perform task.     ❶
  }                      ❶
}, [dep1, dep2]);

❶ 将条件放在 hook 调用中。

此代码始终调用钩子,但在执行副作用任务之前检查条件。

9.2.2 只从 React 函数中调用钩子

钩子允许函数组件拥有状态,并管理它们何时使用或引起副作用。使用钩子的组件应该易于理解、维护和共享。它们的状态应该是可预测和可靠的。预期的状态变化应该在组件内部可见,尽管你可能将那些状态变化的精确实现提取到自定义钩子中。为了帮助你的组件合理工作:

  • 确实可以从 React 函数组件中调用钩子。

  • 确实可以从自定义钩子(以“use”开头命名)中调用钩子。

不要在其他常规 JavaScript 函数中调用钩子。请将钩子调用保持在函数组件和自定义钩子内。

9.2.3 使用 ESLint 插件为钩子规则提供支持

毫无疑问,这些“规则”可能会引起一些疑问。但我认为钩子的优点超过了三条规则的缺点。为了帮助你在代码中找出可能忽略规则的情况,有一个名为eslint-plugin-react-hooks的 ESLint 插件。如果你使用create-react-app生成项目骨架,该插件已经就位。

9.3 提取自定义钩子的更多示例

在第四章中,我们看到了一些其他副作用示例:获取窗口大小、使用本地存储和获取数据。尽管我们将副作用包裹在useEffect的调用中,但它们仍然在使用它们的组件的范围内。但是,这种功能值得分享,所以让我们提取并导出它。

在本节中,我们创建了更多自定义钩子:

  • useWindowSize—返回文档窗口的高度和宽度

  • useLocalStorage—使用浏览器的本地存储 API 获取和设置值

在第 9.4 节中,我们通过自定义钩子访问上下文,在第 9.5 节中,我们设置一个自定义钩子以简化数据获取。

作为函数,钩子可以返回任何所需的值以暴露其功能。我们已经看到了useDocumentTitle没有返回值,以及useRandomTitle的函数返回值。接下来的两个示例返回两种进一步类型的值:useWindowSize返回一个具有属性的对象,而useLocalStorage返回一个数组。当你阅读这些示例时,请考虑不同的返回类型如何适用于自定义钩子和使用钩子的组件。首先是一个返回单个对象属性(窗口长度和宽度)的钩子。

9.3.1 使用useWindowSize钩子访问窗口尺寸

假设你想测量浏览器窗口的宽度和高度,并在屏幕上显示这些尺寸,如果用户调整窗口大小,则自动更新它们。图 9.6 显示了同一窗口在两个不同尺寸下报告其尺寸。

图 9.6 显示窗口尺寸随其调整而变化

正如我们在第四章中看到的,这需要向窗口的调整大小事件添加和移除事件监听器。使用自定义钩子,我们可以简化使用尺寸的组件。以下列表显示了 WindowSizer 组件变得多么简单。

Live: zswj6.csb.app/, Code: codesandbox.io/s/usewindowsize-zswj6

列表 9.6 一个紧凑的组件,显示窗口宽度和高度

import React from "react";
import useWindowSize from "./useWindowSize";         ❶
export default function WindowSizer () {
  const {width, height} = useWindowSize();           ❷
  return <p>Width: {width}, Height: {height}</p>     ❸
}

❶ 导入自定义钩子。

❷ 调用钩子并将返回的尺寸赋值给变量。

❸ 在 UI 中使用尺寸。

WindowSizer 组件通过一行代码获取窗口尺寸。它不关心值是如何得到的,也不需要自己设置和拆除任何事件监听器:

const {width, height} = useWindowSize();

任何需要尺寸的项目和组件都可以导入和使用自定义钩子。钩子的抽象魔法在列表 9.7 中显示。它执行与第四章中尺寸报告组件相同的操作,但现在将 useEffect 调用和与事件相关的代码从任何单个组件中分离出来。

Live: zswj6.csb.app/, Code: codesandbox.io/s/usewindowsize-zswj6

列表 9.7 useWindowSize 自定义钩子

import {useState, useEffect} from "react";

function getSize () {                                                     ❶
  return {
    width: window.innerWidth,                                             ❷
    height: window.innerHeight                                            ❷
  };
}

export default function useWindowSize () {
  const [size, setSize] = useState(getSize());
  useEffect(() => {
    function handleResize () {
      setSize(getSize());                                                 ❸
    }

    window.addEventListener('resize', handleResize);                      ❹

    return () => window.removeEventListener('resize', handleResize);      ❺
  }, []);                                                                 ❻

  return size;                                                            ❼
}

❶ 定义一个返回窗口尺寸的函数。

❷ 从窗口对象中读取尺寸。

❸ 更新状态,触发重新渲染。

❹ 注册一个调整大小事件的监听器。

❺ 返回一个清理函数以移除监听器。

❻ 将空数组作为依赖参数传递。

❼ 返回包含尺寸的对象。

useEffect 的调用包括一个空依赖数组(它只在调用组件首次挂载时运行),并返回一个清理函数(它在调用组件卸载时移除事件监听器)。useWindowSize 自定义钩子返回一个具有 widthheight 属性的对象。下一个自定义钩子 useLocalStorage 采用不同的方法,返回一个包含两个元素的数组,就像 useState 钩子一样。

9.3.2 使用 useLocalStorage 钩子获取和设置值

我们的第四个自定义钩子来自第四章中的第三个 useEffect 示例。我们有一个用户选择器,允许我们从下拉菜单中选择用户。我们将所选用户存储在浏览器的本地存储中,以便页面能够记住每次访问所选的用户,如图 9.7 所示。

图 9.7 一旦选择了一个用户,刷新页面会自动重新选择相同的用户。

我们希望我们的自定义钩子能够管理从本地存储中设置和检索所选用户。如下所示,useLocalStorage 钩子将用户和一个更新函数作为数组中的两个元素返回给 UserPicker 组件。

实时查看: zkl7p.csb.app/代码: codesandbox.io/s/uselocalstorage-zkl7p

列表 9.8 使用本地存储的用户选择器组件

import React from "react";
import useLocalStorage from "./useLocalStorage";                      ❶

export default function UserPicker () {
  const [user, setUser] = useLocalStorage("user", "Sanjiv");          ❷

  return (
    <select value={user} onChange={e => setUser(e.target.value)}>     ❸
      <option>Jason</option>
      <option>Akiko</option>
      <option>Clarisse</option>
      <option>Sanjiv</option>
    </select>
  );
}

❶ 导入自定义钩子。

❷ 使用键和初始值调用钩子。

❸ 使用钩子返回的状态和更新函数。

UserPicker 组件使用数组解构将保存的用户和更新函数分配给局部变量 usersetUser。同样,组件不关心自定义钩子是如何工作的;它只关心保存的用户(以便可以在下拉列表中选择适当的选项)和更新函数(以便任何对选择的更改都可以保存)。以下列表显示了我们将提取到自定义钩子中的代码。

实时查看: zkl7p.csb.app/代码: codesandbox.io/s/uselocalstorage-zkl7p

列表 9.9 useLocalStorage 自定义钩子

import {useEffect, useState} from "react";

export default function useLocalStorage (key, initialValue) {    ❶
  const [value, setValue] = useState(initialValue);              ❷

  useEffect(() => {
    const storedValue = window.localStorage.getItem(key);        ❸

    if (storedValue) {
      setValue(storedValue);                                     ❹
    }
  }, [key]);                                                     ❺

  useEffect(() => {
    window.localStorage.setItem(key, value);                     ❻
  }, [key, value]);                                              ❼

  return [value, setValue];                                      ❽
}

❶ 接受一个键和一个初始值。

❷ 本地管理状态。

❸ 获取键的任何本地存储值。

❹ 如果从本地存储中有值,则更新本地状态。

❺ 如果键更改,则重新运行此效果。

❻ 将最新值保存到本地存储。

❼ 重新运行此效果以使用新的键或值。

❽ 返回一个数组。

代码调用 useState 钩子来管理本地选择的用户状态。它还使用了两个 useEffect 钩子的调用,用于从本地存储检索任何保存的值以及保存更改的值。如果您想了解这两个效果如何一起使用本地存储来保存和检索所选用户,请回顾第四章。在本章中,我们将继续探讨在第八章首次遇到的上文。

9.4 使用自定义钩子消耗上下文值

在第八章中,我们看到了如何使用 React 的 Context API 通过将组件包裹在上下文提供者中并设置提供者的 value 属性来在应用程序或应用程序的子树中共享值。任何消耗上下文值的组件都需要导入提供者对应的上下文对象并将其传递给 useContext 钩子。在预订应用中,多个组件需要访问当前用户,我们创建了一个自定义提供者来使该值在整个应用程序中可用。

消费组件不需要知道值来自何处或使用什么机制使它们可用;我们可以通过自定义钩子抽象这些细节。对于预订应用,让我们创建一个 useUser 钩子,为需要设置用户的任何组件提供当前用户和更新函数。我们将像这样使用它:

const [user, setUser] = useUser();

或者,对于只需要值的组件,我们这样做:

const [user] = useUser();

以下列表扩展了第八章中提到的自定义用户提供者。该文件导出现有的提供者和我们新的自定义钩子。

分支:0901-context-hook,文件:/src/components/Users/UserContext.js

列表 9.10 useUser 自定义钩子

import {createContext, useContext, useState} from "react";

const UserContext = createContext();                     ❶
const UserSetContext = createContext();                  ❶

export function UserProvider ({children}) {
  const [user, setUser] = useState(null);

  return (
    <UserContext.Provider value={user}>
      <UserSetContext.Provider value={setUser}>
        {children}
      </UserSetContext.Provider>
    </UserContext.Provider>
  );
}

export function useUser () {                             ❷
  const user = useContext(UserContext);                  ❸
  const setUser = useContext(UserSetContext);            ❸

  if (!setUser) {
    throw new Error("The UserProvider is missing.");     ❹
  }

  return [user, setUser];                                ❺
}

❶ 不要导出上下文。

❷ 导出自定义钩子。

❸ 在钩子内部消耗上下文。

❹ 如果提供者缺失,则抛出错误。

❺ 以数组的形式返回两个上下文值。

自定义钩子useUser消耗在提供者中设置的两个上下文值,返回用户值和更新函数作为数组中的两个元素。它执行检查以确保在组件树的上层使用了提供者组件,如果缺失则抛出错误。

我们的自定义钩子已经准备好,我们可以简化需要访问当前用户的组件:UserPickerUsersPageBookingDetails。它们不再需要导入它们消耗的上下文;它们只需导入并调用useUser钩子。以下列表显示了UserPicker组件。

分支:0901-context-hook,文件:/src/components/Users/UserPicker.js

列表 9.11 从UserPicker组件调用useUser钩子

import {useEffect, useState} from "react";     ❶
import Spinner from "../UI/Spinner";

import {useUser} from "./UserContext";         ❷

export default function UserPicker () {
  const [user, setUser] = useUser();           ❸
  const [users, setUsers] = useState(null);

  useEffect(() => {
    // unchanged
  }, [setUser]);

  function handleSelect (e) { /* unchanged */ }

  if (users === null) {
    return <Spinner/>
  }

  return ( /* unchanged UI */ );
}

❶ 删除未使用的导入。

❷ 导入自定义钩子。

❸ 调用钩子并将用户和更新函数分配给局部变量。

因为useUser的调用返回一个数组,我们可以通过使用我们选择的变量名来解构返回值。UserPicker组件使用usersetUser

BookingDetails组件只需要用户,所以它的useUser调用可以看起来像这样:

const [user] = useUser();

UsersPage组件从上下文loggedInUser中命名当前用户,所以它的useUser调用可以看起来像这样:

const [loggedInUser] = useUser();

随着更多功能被移动到自定义钩子中,组件本身变得更加简单,开始类似于仅接收和显示状态的展示组件。在钩子之前,展示组件会将任何业务逻辑留给包装组件。有了钩子,业务逻辑可以更容易地封装、重用和共享。

挑战 9.1

更新UsersPageBookingDetails组件以调用useUser钩子而不是useContext钩子。当前分支 0901-context-hook 已经包含了最新的代码。

9.5 使用自定义钩子封装数据获取

在应用程序中,多个组件显示数据是很常见的,通常是从网络或互联网上的数据源获取的。随着我们的应用程序变大,组件消耗的数据开始交叉,我们可能需要使用集中式数据存储,这些存储可以有效地管理检索、缓存和更新。(我们将在第十章中查看 React Query 库。)但许多应用程序在组件使用自己的数据时运行得很好,通常是在useEffect钩子的调用中。通常,组件之间唯一的变化是从中获取数据的 URL。

在本节中,我们创建了一个用于获取数据的自定义钩子。我们向钩子提供 URL,它返回数据,以及一个状态值,如果出现错误,可能还会返回一个错误对象。我们这样使用钩子:

const {data, status, error} = useFetch(url);

正如你所见,钩子返回了一个包含我们需要的三个属性的对象。钩子在我们的示例应用程序中对于获取用户或可预订项同样有效。然而,预订需要一些额外的工作才能变得最有用,因此我们将创建一个专门的钩子来获取这些。本节分为三个部分:

  • 创建 useFetch 钩子

  • 使用 useFetch 钩子返回的数据和状态值

  • 创建更专业的数据获取钩子:useBookings

useFetch 钩子可以在多个项目中使用,因此让我们详细地看看它。

9.5.1 创建 useFetch 钩子

我们的定制 useFetch 钩子接受一个 URL 并返回一个包含 datastatuserror 属性的对象,如列表 9.12 所示。它使用 useState 钩子来管理数据(可能是 undefined、基本类型、对象或数组),状态(可以是 idleloadingsuccesserror),以及错误对象(可以是 null 或 JavaScript 错误对象)。钩子使用我们来自 useEffect 内部的 getData API 函数,就像我们之前的章节中组件所做的那样。

分支:0902-use-fetch,文件:/src/utils/useFetch.js

列表 9.12 useFetch 钩子

import {useEffect, useState} from "react";
import getData from "./api";

export default function useFetch (url) {
  const [data, setData] = useState();
  const [error, setError] = useState(null);
 const [status, setStatus] = useState("idle");    ❶

  useEffect(() => {
    let doUpdate = true;

    setStatus("loading");                          ❷
    setData(undefined);
    setError(null);

    getData(url)
      .then(data => {
        if (doUpdate) {
          setData(data);
          setStatus("success");                    ❸
        }
      })
      .catch(error => {
        if (doUpdate) {
          setError(error);                         ❹
          setStatus("error");
        }
      });

    return () => doUpdate = false;
  }, [url]);

  return {data, status, error};
}

❶ 将初始状态设置为“idle。”

❷ 在发送请求之前,将状态设置为“loading。”

❸ 如果数据成功返回,将状态设置为“success。”

❹ 如果在获取数据时出现问题,将状态设置为“error。”

与使用 isLoadingisError 这样的布尔值不同,useFetch 钩子使用一个 status 值,设置为字符串。(而不是在应用程序中散布字符串,最好是将可能的状态值作为变量从它们自己的文件中导出,并在需要的地方导入。但为了示例应用程序的目的,我们将坚持使用更简单、但稍微容易出错的无装饰字符串方法。)调用 useFetch 的组件可以检查状态以决定返回什么 UI。为了看到 useFetch 的实际应用,让我们更新 BookablesList 组件,利用 status 值。

9.5.2 使用 useFetch 钩子返回的数据、状态和错误值

我们设计 useFetch 钩子不仅返回数据,还提供了一个 status 字符串和一个 error 对象。状态对于决定显示什么 UI 非常有用,列表 9.13 中的更新后的 BookablesList 组件就使用了它来在错误消息、加载指示器或可预订项列表之间进行选择。

分支:0902-use-fetch,文件:src/components/Bookables/BookablesList.js

列表 9.13 从 BookablesList 组件调用 useFetch 钩子

import {useEffect} from "react";
import {FaArrowRight} from "react-icons/fa";
import Spinner from "../UI/Spinner";

import useFetch from "../../utils/useFetch";                              ❶

export default function BookablesList ({bookable, setBookable}) {

 const {data : bookables = [], status, error} = useFetch(                ❷
    "http://localhost:3001/bookables"                                     ❷
 );

  const group = bookable?.group;
  const bookablesInGroup = bookables.filter(b => b.group === group);
  const groups = [...new Set(bookables.map(b => b.group))];

  useEffect(() => {                                                       ❸
 setBookable(bookables[0]);
 }, [bookables, setBookable]);

  function changeGroup (event) { /* unchanged */ }
  function nextBookable () { /* unchanged */ }

  if (status === "error") {                                               ❹
    return <p>{error.message}</p>                                         ❺
  }

  if (status === "loading") {                                             ❻
    return <p><Spinner/> Loading bookables...</p>
  }

  return ( /* unchanged UI */ );
}

❶ 导入我们新的 useFetch 钩子。

❷ 调用 useFetch 并解构返回的对象。

❸ 当可预订项加载时选择第一个可预订项。

❹ 检查状态以查看是否发生错误。

❺ 显示错误对象的 message 属性。

❻ 检查状态以查看可预订项是否正在加载。

列表 9.13 调用useFetch并解构返回的对象,将属性分配给局部变量:

const {data : bookables = [], status, error} = useFetch(
 "http://localhost:3001/bookables"
);

它将data属性赋值给bookables变量,并在data属性为undefined时包含一个空数组的默认值:

data : bookables = []

如果你检查我们的useFetch实现(列表 9.12),你会看到没有为data值传递给useState的初始值,并且每次启动新的数据获取时都明确将其设置为undefineduseFetch返回从服务器获取的数据或undefined。在解构对象时,JavaScript 会在属性值为undefined时分配指定的默认值。"BookablesList"利用这种行为,当dataundefined时,将空数组分配给bookables

挑战 9.2

更新UserPickerUsersList组件以调用useFetch从数据库获取用户列表。使用status值来确定要显示的 UI。再次强调,当前分支 0902-use-fetch 已经包含了更改的文件。

9.5.3 创建更专业的数据获取钩子:useBookings

自定义钩子使得在组件之间封装和共享功能变得容易,我们的useFetch钩子使得从任何组件中获取数据变得简单。我们有BookablesListUserPickerUsersList组件都调用useFetch来管理它们的数据加载,仅在它们首次挂载时。对于交互式应用程序,尽管如此,我们仍然在用户选择时继续获取数据。例如,预订网格显示所选可预订项和周的预订,如图 9.8 所示,用户可以随意选择新的可预订项和新的周,因此我们需要获取新的数据以保持一切同步。

图 9.8 BookingsGrid显示了所选可预订项(会议室)和周(包含 2020-06-24)的预订。

要显示一个填充了预订的网格,我们为所选可预订项和周生成网格,并将预订数据转换成在填充网格时易于参考的形式。所以,这不仅仅是获取预订那么简单。我们将代码分为三个部分:

  • useBookings——一个自定义钩子,用于加载和转换预订数据

  • useGrid——一个自定义钩子,用于生成空的预订时段网格

  • BookingsGrid——调用两个自定义钩子的更新组件

三个部分的相互关系如图 9.9 所示,其中包括一个组件、三个自定义钩子和两个内置的 React 钩子。

图 9.9 BookingsGrid组件调用useBookingsuseGrid自定义钩子,useBookings钩子调用useFetch自定义钩子。React 的useMemouseEffect钩子也被使用。

好的,让我们深入代码,从新的 bookingsHooks.js 文件中的两个自定义钩子useBookingsuseGrid开始。

useBookings

这就是更专业的自定义钩子可以派上用场的地方。列表 9.14 展示了 useBookings 钩子。它为指定的可预订 ID、开始日期和结束日期获取预订数据。它还使用我们之前创建的 transformBookings 函数,以预订网格易于处理的数据格式返回数据。

分支:0903-use-bookings,文件:/src/components/Bookings/bookingsHooks.js

列表 9.14 useBookings 钩子

import {shortISO} from "../../utils/date-wrangler";
import useFetch from "../../utils/useFetch";                       ❶
import {transformBookings} from "./grid-builder";

export function useBookings (bookableId, startDate, endDate) {     ❷
  const start = shortISO(startDate);
  const end = shortISO(endDate);

  const urlRoot = "http://localhost:3001/bookings";

  const queryString = `bookableId=${bookableId}` +                 ❸
 `&date_gte=${start}&date_lte=${end}`;                          ❸

  const query = useFetch(`${urlRoot}?${queryString}`);             ❹

  return {
    bookings: query.data ? transformBookings(query.data) : {},     ❺
    ...query
  };
}

❶ 导入我们的 useFetch 自定义钩子。

❷ 使用参数指定要获取的数据。

❸ 为指定数据构建查询字符串。

❹ 使用特定的 URL 调用 useFetch 钩子。

❺ 在返回之前转换加载的数据。

useBookings 钩子使用转换为字符串的 bookableIdstartDateendDate 值,即 startend,来构建我们想要获取的特定数据的 URL,这是我们的数据服务器 json-server 能够理解的形式:

const queryString = `bookableId=${bookableId}&date_gte=${start}&date_lte=${end}`;

列表 9.14 将查询字符串拆分为两行以适应本书的格式,但它是一个相同的字符串。然后我们的 useBookings 钩子将生成的 URL 传递给我们的 useFetch 钩子,以获取 datastatuserror 值,这些值被封装在一个对象中,该对象被分配给 query

const query = useFetch(`${urlRoot}?${queryString}`);

最后,就像 useFetch 钩子一样,useBookings 钩子返回一个包含数据和状态以及错误值的对象。作为一个更专业的数据获取钩子,我们将 data 属性重命名为 bookings。我们可以将其称为 data 以保持与 useFetch 的一致性,但鉴于我们只使用它来获取预订,将其称为 bookings 看起来是一个不错的选择,并且 data 属性仍然会在返回对象中,因为查询对象(datastatuserror)也被扩展到返回对象中。

useGrid

每个可预订项目只能在每周的某些天和一天中的某些时段进行预订。BookingsGrid 组件显示当前可预订项目的适当网格。但是,为了仅在可预订项目更改时运行网格创建逻辑,我们将对 getGrid 的调用包裹在 useMemo 钩子中。尽管这段代码可以愉快地保留在 BookingsGrid 本身中,但我们将其拉入自己的自定义钩子 useGrid,如下所示,以继续我们的组件简化过程。

分支:0903-use-bookings,文件:/src/components/Bookings/bookingsHooks.js

列表 9.15 useGrid 钩子

import {useMemo} from "react";
import {getGrid} from "./grid-builder";

export function useGrid (bookable, startDate) {
  return useMemo(
    () => bookable ? getGrid(bookable, startDate) : {},
    [bookable, startDate]
  );
}

useGriduseBookings 钩子可以保留在同一个文件中,与 BookingsGrid 一起,因为它们只在那里使用,但本书后面会有更多的预订钩子和实用函数,所以一个专门的 bookingsHooks.js 文件对我们来说将更合适。我主要倾向于将函数、钩子和组件分开到各自的文件中,以便于本书的代码列表。但请不要认为这是一个推荐;你可以遵循对你和你的团队最有意义且最有用的课程。

我们的钩子已经准备就绪,现在让我们在BookingsGrid中充分利用bookingsstatuserror以及gridsessionsdates返回的值。

BookingsGrid

在我们的两个新自定义钩子useBookingsuseGrid就位后,我们可以更新BookingsGrid组件以调用它们。如下所示,随着功能被隐藏在自定义钩子中,BookingsGrid本身几乎只关注显示网格及其预订。

分支:0903-use-bookings,文件:/src/components/Bookings/BookingsGrid.js

列表 9.16 BookingsGrid组件

import {Fragment, useEffect} from "react";
import Spinner from "../UI/Spinner";

import {useBookings, useGrid} from "./bookingsHooks";                      ❶

export default function BookingsGrid (
  {week, bookable, booking, setBooking}
) {
 const {bookings, status, error} = useBookings(                           ❷
 bookable?.id, week.start, week.end                                     ❷
 ); ❷

 const {grid, sessions, dates} = useGrid(bookable, week.start);           ❸

 useEffect(() => {
    setBooking(null);                                                      ❹
 }, [bookable, week.start, setBooking]);

  function cell (session, date) {
    const cellData = bookings[session]?.[date]
      || grid[session][date];

    const isSelected = booking?.session === session
      && booking?.date === date;

    return (
      <td
        key={date}
        className={isSelected ? "selected" : null}
        onClick={
          status === "success"                                             ❺
            ? () => setBooking(cellData)
            : null
        }
      >
        {cellData.title}
      </td>
    );
  }

  if (!grid) {
    return <p>Waiting for bookable and week details...</p>
  }

  return (
    <Fragment>
      {status === "error" && (                                             ❻
        <p className="bookingsError">
          {`There was a problem loading the bookings data (${error})`}     ❼
        </p>
      )}
      <table
        className={
          status === "success"                                             ❽
            ? "bookingsGrid active"
            : "bookingsGrid"
        }
      >
        <thead>{ /* unchanged */ }</thead>
        <tbody>{ /* unchanged */ }</tbody>
      </table>
    </Fragment>
  );
}

❶ 导入我们的新自定义钩子。

❷ 使用指定的可预订项和日期调用useBookings钩子。

❸ 使用指定的可预订项和日期调用useGrid钩子。

❹ 在切换周或可预订项时取消预订。

❺ 使用状态值检查预订是否可用。

❻ 使用状态值检查是否有错误。

❼ 显示错误消息。

❽ 使用状态值设置网格的类。

该组件现在使用useBookings返回的statuserror值来与网格交互,并在出现问题时显示消息。

将功能移动到自定义钩子中使我们的组件更简单,并使跨组件和项目共享功能变得更容易。我们的自定义钩子示例在章节过程中变得越来越复杂,但它们只是触及了可以实现的表面。在第十章中,我们介绍了用于路由和数据获取的第三方钩子,并开始看到自定义钩子如何让我们轻松访问现有第三方库的力量。

摘要

  • 为了简化组件并共享使用 React Hooks 的功能,请在组件外部创建自定义钩子。

  • 为了明确一个函数是自定义钩子并且应该遵循钩子规则,请以“use.”开头命名它。例如包括useDocumentTitleuseFetchuseUsersuseLocalStorage

  • 组件每次运行时调用钩子的一致性很重要。你不应该在某些情况下调用钩子,而在其他情况下不调用,也不应该在组件每次运行时以不同的次数调用它们。为了确保你的钩子调用一致,请遵循以下约定:

    • 不要将钩子放在条件语句内。

    • 不要将钩子放在循环内。

    • 不要将钩子放在嵌套函数内。

  • 如果需要在某些条件下运行副作用代码,请将条件检查放在副作用内:

    useEffect(() => {
      if (condition) {
        // perform task.
      }
    }, [dep1, dep2]);
    
  • 不要在常规 JavaScript 函数中调用钩子;请将钩子调用保持在函数组件和自定义钩子内。

  • 为了帮助你发现你可能在代码中误用了钩子,请使用名为eslint-plugin-react-hooks的 ESLint 插件。如果你已经使用create-react-app生成了你的项目骨架,该插件已经就位。

  • 在钩子内部管理与钩子功能相关的状态和效果,并仅返回组件需要的值:

    function useWindowSize () {
      const [size, setSize] = useState(getSize());
    
      useEffect(() => {/* perform effect */}, []);     
    
      return size;
    }
    
  • 传递它们需要的钩子值,并返回空值、原语、函数、对象或数组—— whatever is most useful:

    useDocumentTitle("No return value");
    const nextTitle = useRandomTitle(greetings);
    const [user, setUser] = useUser();
    const {data, status, error} = useFetch(url);
    

10 使用第三方钩子

本章涵盖了

  • 充分利用第三方钩子

  • 使用 React Router 的useParamsuseSearchParams钩子访问 URL 中的状态

  • 使用 React Router 的useNavigate钩子切换到新路由

  • 使用 React Query 的useQuery钩子高效获取和缓存数据

  • 使用 React Query 的useMutation钩子更新服务器上的数据

第九章介绍了自定义钩子作为从组件中提取功能的一种方式,使功能可重用并简化组件。自定义钩子提供了一种简单、易读的方式来从函数组件访问各种功能,无论是简单的任务,如更改文档标题或使用本地存储管理状态值,还是越来越复杂的任务,如获取数据或与应用程序状态管理器一起工作。许多现有的库都迅速提供了钩子,允许函数组件充分利用库的功能,本章尝试了一些钩子来改进预订示例应用。

预订应用一直使用 React Router 在预订、可预订和用户页面组件之间切换。但 React Router 可以处理更复杂的场景,在第 10.1 节和第 10.2 节中,我们介绍了其三个钩子。第一个是useParams,它允许我们通过在 URL 路径中包含其 ID 来指定要在可预订页面上显示的可预订项。第二个是useNavigate,它允许我们在用户点击“下一步”按钮或选择不同的组时导航到新的 URL。第三个是useSearchParams,它允许我们在 URL 的查询字符串中获取和设置搜索参数,以指定预订页面上可预订的 ID 和日期。

我们一直使用自己的useFetch钩子来加载数据,而没有考虑缓存或重新获取数据,这些技术可以帮助我们更有效地检索数据并更新 UI。是时候提升我们的数据游戏了,React Query 库可以通过最小的设置为我们做一些很棒的事情。在第 10.3 节中,我们尝试使用其useQuery钩子,并为通过useMutation钩子发送更改到服务器铺平道路。

让我们介绍我们的第一个第三方自定义钩子,并看看我们如何访问在 URL 中指定的状态。

10.1 使用 React Router 访问 URL 中的状态

React Router 为我们提供了导航组件(例如RouterRoutesRouteLink),我们使用这些组件将 UI 与 URL 路由相匹配。当用户导航到一个 URL 时,React Router 会显示该路由关联的 React 组件,并且,正如您将看到的,通过钩子使 URL 中的任何参数对嵌套组件可用。图 10.1 显示了reactrouter.com的首页,在那里您可以了解更多信息。

图 10.1 React Router 的网页:一次学习,任意路由

预订应用包括三个页面——预订、Bookables 和用户,并且我们已经使用 React Router 根据 URL 显示相应的页面:/bookings、/bookables 和/users。URL 与页面组件的关联在 App.js 文件中,该文件包含以下代码:

<Routes>
  <Route path="/bookings" element={<BookingsPage/>}/>
  <Route path="/bookables" element={<BookablesPage/>}/>
  <Route path="/users" element={<UsersPage/>}/>
</Routes>

但你的老板回来了,并决定如果访客可以直接导航到特定的预订和日期,那会很好。例如,要显示 ID 为 3 的预订,访客将使用这个 URL:

/bookables/3

要查看 2020 年 6 月 24 日同一预订的预订情况,访客将使用这个:

/bookings?bookableId=3&date=2020-06-24

这些 URL 包含应用程序的状态,无论是作为 URL 路径的一部分(/bookables/3)还是作为查询字符串中的搜索参数(bookableId=3&date=2020-06-24)。在第 10.2 节中,我们将更新预订页面以使用查询字符串和useSearchParams钩子。在本节中,我们从 Bookables 页面开始,关注 URL 路径和useParams以及useNavigate钩子。本节分为四个小节,每个小节处理一个组件,如表 10.1 所示。

表 10.1 本节中我们将更改的四个组件

部分 组件 更改
10.1.1 App 设置路由以启用嵌套
10.1.2 BookablesPage 向“Bookables”页面添加嵌套路由
10.1.3 BookablesView 使用useParams钩子访问 URL 参数
10.1.4 BookablesList 使用useNavigate钩子进行导航

让我们通过更新 App.js 以接受我们即将添加的新路由,开始参数化路径的第一步。

10.1.1 设置路由以启用嵌套

为了显示预订的详细信息以及编辑和创建预订,用户将导航到如下 URL:

/bookables/3           ❶
/bookables/3/edit      ❷
/bookables/new         ❸

❶ 显示 ID 为 3 的预订的详细信息。

❷ 编辑 ID 为 3 的预订。

❸ 创建一个新的预订。

与三个路由关联的三个组件中的两个在图 10.2 中:具有 ID 为 3 的预订的详细信息视图和创建新预订的表单。

图 10.2 不同的视图与不同的 URL 相关联。/bookables/3显示了 ID 为 3 的预订的详细信息,而/bookables/new显示了创建新预订的表单。

现在我们有多个以/bookables开头的路由,我们需要更新 App.js 以确保为所有这些路由渲染BookablesPage组件。以下列表显示了更改后的path属性,包括附加的/*

分支:1001-bookables-routes,文件:/src/components/App.js

列表 10.1 在App组件中扩展BookablesPage路由

// imports

export default function App () {
  return (
    <UserProvider>
      <Router>
        <div className="App">
          <header>
            {/* unchanged */}
          </header>
          <Routes>
            <Route path="/bookings" element={<BookingsPage/>}/>
            <Route path="/bookables/*" element={<BookablesPage/>}/>     ❶
            <Route path="/users" element={<UsersPage/>}/>
          </Routes>
        </div>
      </Router>
    </UserProvider>
  );
}

❶ 匹配以“bookables”开头的任何 URL。

现在,任何以/bookables/开头的路径都将渲染BookablesPage组件。这个小小的改动使得组件能够设置我们需要的三个嵌套路由。

10.1.2 向“Bookables”页面添加嵌套路由

React Router 允许我们根据 位置 或 URL 来渲染不同的组件。我们使用 Route 组件来匹配一个 path 与一个要渲染的组件。在列表 10.1 中,我们指定了任何以 /bookables 开头的路径都应该渲染 BookablesPage 组件。列表 10.2 设置了一些嵌套路由,以在 Bookables 页面上显示更具体的组件。(我们还在存储库中添加了 BookableEditBookableNew 组件,以便应用程序可以编译。我们将在第 10.3 节中讨论它们。)

分支:1001-bookables-routes,文件:/src/components/Bookables/BookablesPage.js

列表 10.2 BookablesPage 组件中的嵌套路由

import {Routes, Route} from "react-router-dom";
import BookablesView from "./BookablesView";
import BookableEdit from "./BookableEdit";
import BookableNew from "./BookableNew";
export default function BookablesPage () {
  return (
    <Routes>                        ❶
      <Route path="/:id">           ❷
        <BookablesView/>
      </Route>
      <Route path="/">              ❸
        <BookablesView/>
      </Route>
      <Route path="/:id/edit">      ❹
        <BookableEdit/>
      </Route>
      <Route path="/new">           ❺
        <BookableNew/>
      </Route>
    </Routes>
  );
}

❶ 指定一组嵌套路由。

❷ 使用参数来捕获指定的可预订 ID。

❸ 即使没有指定 ID,也渲染 BookablesView 组件。

❹ 使用参数来显示指定可预订 ID 的编辑表单。

❺ 包含一个用于新可预订表单的单独路由。

在列表中,我们使用开闭 Route 标签,而不是 element 属性,只是为了显示你可以将匹配路由的 UI 指定为封装的 JSX,而不是作为属性。我们添加了两个渲染 BookablesView 组件的路由和两个用于创建和编辑可预订项目的路由。列表 10.2 中的第一个 Route 包含一个用于捕获要显示的可预订 ID 的参数:

<Route path="/:id"> ❶
  <BookablesView/>
</Route>

❶ 匹配形式为 /bookables/:id 的 URL。

因为这些路由嵌套在 BookablesPage 组件中,该组件在 URL 匹配 /bookables/* 时由 React Router 渲染,所以这个路由会为形式为 /bookables/:id 的 URL 进行渲染。例如,当导航到 /bookables/3 时,React Router 将渲染 BookablesPage 组件,然后渲染其中的 BookablesView 组件。React Router 还会将 id 参数设置为 3。那么,我们如何在渲染的组件中访问这个参数呢?这就是我们的第一个第三方自定义钩子!

10.1.3 使用 useParams 钩子访问 URL 参数

React Router 的 useParams 钩子返回一个对象,其属性对应于在 Route 组件的 path 属性中设置的 URL 参数。比如说,我们有一个像这样的 Route 组件:

<Route path="/milkshake/:flavor/:size" element={<Milkshake/>}/>

它的 path 属性包括两个参数,flavorsize。比如说,一个喜欢鸡尾酒的人会访问这个网址:

/milkshake/vanilla/medium

React Router 将渲染 Milkshake 组件。当 Milkshake 组件调用 useParams 时,钩子将返回一个对象,其属性对应于两个参数:

{
  flavor: "vanilla",
  size: "medium"
}

Milkshake 组件可以通过将它们分配给局部变量来访问这些参数:

const {flavor, size} = useParams();

嘘,现在我想要一杯奶昔。我得等一下;我们还有可预订的项目要看……

Bookables 页面渲染三个组件中的一个。其中两个组件,BookablesViewBookableEdit,需要知道它们正在处理哪个可预订项。该可预订项的 ID 在 URL 中指定。列表 10.3 展示了 BookablesView 组件。它过去只使用 useState 管理所选的可预订项,但现在使用第九章中的 useFetch 钩子获取所有可预订项的数据,并通过访问 URL 中的 id 参数来管理所选的可预订项。(这些更改将暂时破坏应用程序。)

分支:1001-bookables-routes,文件:/src/components/Bookables/BookablesView.js

列表 10.3 BookablesView 从 URL 获取 ID

import {Link, useParams} from "react-router-dom";             ❶
import {FaPlus} from "react-icons/fa";

import useFetch from "../../utils/useFetch";                  ❷

import BookablesList from "./BookablesList";
import BookableDetails from "./BookableDetails";
import PageSpinner from "../UI/PageSpinner";

export default function BookablesView () {
  const {data: bookables = [], status, error} = useFetch(     ❸
    "http://localhost:3001/bookables"
 );

  const {id} = useParams();                                   ❹

  const bookable = bookables.find(
    b => b.id === parseInt(id, 10)                            ❺
  ) || bookables[0];

  if (status === "error") {
    return <p>{error.message}</p>
  }

  if (status === "loading") {
    return <PageSpinner/>
  }

  return (
    <main className="bookables-page">
      <div>
        <BookablesList
          bookable={bookable}
          bookables={bookables}
          getUrl={id => `/bookables/${id}`}                   ❻
        />

        <p className="controls">
          <Link                                               ❼
            to="/bookables/new"
            replace={true}
            className="btn">
            <FaPlus/>
            <span>New</span>
          </Link>
        </p>
      </div>

      <BookableDetails bookable={bookable}/>
    </main>
  );
}

❶ 导入 useParams 钩子。

❷ 导入我们自定义的 useFetch 钩子。

❸ 使用 useFetch 获取可预订项。

❹ 将 ID 参数值分配给本地变量。

❺ 使用 ID 获取指定的可预订项。

❻ 提供一个生成可预订项 URL 的函数。

❷ 在表单中包含创建新可预订项的链接。

BookablesView 组件调用 React Router 的 useParams 钩子来获取一个包含 URL 中设置的所有参数的对象。它使用对象解构将 id 参数分配给具有相同名称的本地变量:

const {id} = useParams();

参数作为字符串返回,但每个可预订项的 id 属性是数字,因此在查找所有可预订项集合中指定的可预订项时使用 parseInt

const bookable = bookables.find(
  b => b.id === parseInt(id, 10)
) || bookables[0];

如果找不到可预订项,则选择集合中的第一个可预订项,即 bookables[0]。一旦可预订项加载完成,并且假设没有错误,BookablesView 将渲染 BookablesListBookableDetails 组件。它将用于生成每个可预订项 URL 的函数传递给 BookablesList。让我们看看这个函数是如何使用的,并介绍第二个 React Router 自定义钩子 useNavigate

10.1.4 使用 useNavigate 钩子进行导航

React Router 的 useNavigate 钩子返回一个函数,我们可以使用它来设置一个新的 URL,提示路由器渲染与新的路径相关联的任何 UI。(记住,我们正在使用 React Router 6 的测试版,因此 API 可能会发生变化。如果发生这种情况,我将在 GitHub 仓库中添加一些额外的更新列表。)假设一个应用当前显示的是 Milkshake 组件。(抱歉,我实在无法将这些从我的脑海中排除出去。所以……奶油……)假设,用户是一个神经痛恐惧者,更喜欢珍珠奶茶。为了提供一个从奶昔页面导航到珍珠奶茶页面的方式,Milkshake 组件可以这样做:

const navigate = useNavigate();      ❶

navigate("/bubbletea");              ❷

❶ 将 URL 设置函数分配给 navigate 变量。

❷ 使用该函数设置一个新的 URL。

将 URL 设置函数分配给本地 navigate 变量,给组件提供了一个在事件处理程序中设置 URL 的方法。它也可以渲染一些指向新 URL 的链接。让我们在预订应用中使用这两种方法。

BookablesView 组件中,我们不再通过调用 useState 来获取选定的可预订项及其更新函数,而是现在在 URL 中指定选择项。以下是具有 ID 为 1 的可预订项的 URL:

/bookables/1

要切换到新的可预订项,我们设置一个新的 URL:

/bookables/2

要更新状态,我们需要一个指向新 URL 的链接或一个导航到新 URL 的函数(例如,由下一个按钮触发):

// JSX
<Link to="/bookables/2">Lecture Hall</Link>     ❶

// js
navigate("/bookables/2");                       ❷

❶ 使用 React Router 的 Link 组件。

❷ 使用 React Router 的 useNavigate 钩子返回的函数。

图 10.3 显示了用户导航到 /bookables/1 后的应用程序。左侧的 BookablesList 组件显示了组选择器、当前组中的可预订项链接列表和下一个按钮。BookablesView 组件还渲染了一个位于 BookablesList 外部的“新建”按钮。

图 10.3 BookablesView 组件显示“新建”按钮和每个可预订项作为链接,这些链接指向新的 URL。通过调用函数,下一个按钮和组选择器会更改 URL。

可预订项链接和“新建”按钮使用 Link 组件渲染,组下拉菜单和下一个按钮在事件处理程序中导航。表 10.2 列出了元素及其功能。

表 10.2 导航中使用的元素和组件

元素/组件 文本 操作
select 例如,房间 调用导航函数
Link 会议室 设置链接到 /bookables/1
Link 讲堂 设置链接到 /bookables/2
Link 游戏室 设置链接到 /bookables/3
Link 休息室 设置链接到 /bookables/4
button 下一个 调用导航函数
Link 新建 设置链接到 /bookables/new

列表 10.4 展示了 BookablesList 组件使用两种导航方法。BookablesList 组件用于不同的页面(可预订页面和预订页面),它们使用不同的 URL 结构。该组件需要知道如何从可预订项 ID 生成 URL,因此其父组件必须传递一个 getUrl 函数用于此目的。

分支:1001-bookables-routes,文件:/src/components/Bookables/BookablesList.js

列表 10.4 使用两种导航方法的 BookablesList

import {Link, useNavigate} from "react-router-dom";                        ❶
import {FaArrowRight} from "react-icons/fa";

export default function BookablesList ({bookable, bookables, getUrl}) {    ❷
  const group = bookable?.group;
  const bookablesInGroup = bookables.filter(b => b.group === group);
  const groups = [...new Set(bookables.map(b => b.group))];

  const navigate = useNavigate();                                          ❸

  function changeGroup (event) {
    const bookablesInSelectedGroup = bookables.filter(
      b => b.group === event.target.value
    );
    navigate(getUrl(bookablesInSelectedGroup[0].id));                      ❹
  }

  function nextBookable () {
    const i = bookablesInGroup.indexOf(bookable);
    const nextIndex = (i + 1) % bookablesInGroup.length;
    const nextBookable = bookablesInGroup[nextIndex];
    navigate(getUrl(nextBookable.id));                                     ❺
  }

  return (
    <div>
      <select value={group} onChange={changeGroup}>
        {groups.map(g => <option value={g} key={g}>{g}</option>)}
      </select>

      <ul className="bookables items-list-nav">
        {bookablesInGroup.map(b => (
          <li
            key={b.id}
            className={b.id === bookable.id ? "selected" : null}
          >
            <Link                                                          ❻
              to={getUrl(b.id)}                                            ❼
              className="btn"
              replace={true}
            >
              {b.title}
            </Link>
          </li>
        ))}
      </ul>
      <p>
        <button
          className="btn"
          onClick={nextBookable}
          autoFocus
        >
          <FaArrowRight/>
          <span>Next</span>
        </button>
      </p>
    </div>
  );
}

❶ 导入 useNavigate 钩子。

❷ 接受当前的可预订项、可预订项列表和 getUrl 函数作为 props。

❸ 调用 useNavigate 钩子,将导航函数分配给一个变量。

❹ 导航到新组中第一个可预订项的 URL。

❺ 导航到当前组中下一个可预订项的 URL。

❻ 使用 React Router 的 Link 组件指定链接。

❼ 使用 getUrl 函数生成每个链接的 URL。

(在此阶段,你应该能够加载 Bookables 页面和 Users 页面,但不能加载 Bookings 页面。)React Router 的 useNavigate 钩子返回一个函数,我们使用该函数来更新 URL,切换到选定的可预订项。列表 10.4 将该函数分配给一个名为 navigate 的局部变量,changeGroupnextBookable 函数调用 navigate(而不是来自先前 BookablesList 版本的 setBookable 更新函数)。例如,以下是 changeGroup 函数调用 navigate 并传递新选定组中第一个可预订项的 URL:

function changeGroup (event) {
  const bookablesInSelectedGroup = bookables.filter(
    b => b.group === event.target.value
  );
  navigate(getUrl(bookablesInSelectedGroup[0].id));
}

changeGroup 使用我们传递给 BookablesListgetUrl 函数作为属性。在 Bookables 页面上,getUrl 函数看起来像这样:

id => `/bookables/${id}`

它只是将 id 追加到 URL 的末尾。Bookings 页面将使用不同的 getUrl 属性,该属性与该页面使用 URL 指定状态的方式相匹配。它使用查询字符串和 React Router 的 useSearchParams 钩子。现在让我们去那里:

navigate("/react-hooks-in-action/10/2");     ❶

❶ 前往本章的 10.2 节。

10.2 获取和设置查询字符串搜索参数

在前面的章节中,你看到了如何使用 Route 组件的 path 属性来提取我们应用的状态值。本节介绍另一种在 URL 中存储状态的方法:查询字符串中的搜索参数。这是一个带有两个搜索参数的 URL:

/path/to/page?key1=value1&key2=value2

查询字符串(粗体)位于 URL 的末尾,以问号开头。搜索参数是 key1key2。指定每个参数值的键值对由 & 字符分隔。如果需要,我们可以附加更多参数,并且很容易包含或省略它们。在指定 URL 中的状态时,请记住以下三点:

  • 你希望将哪些状态值作为参数

  • 如何处理缺失或无效的参数

  • 当状态需要改变时如何更新 URL

在你看到 React Router 如何让我们处理搜索参数(在第 10.2.1 节中获取它们,在第 10.2.2 节中设置它们)之前,让我们简要地考虑一下上面列出的三个点如何与示例应用中 Bookings 页面的需求相关。

在 Bookings 页面上,为了显示包含 2020 年 6 月 24 日(指定为 2020-06-24)的周会议室的预订网格(ID 为 1 的可预订项),我们希望导航到以下位置:

/bookings?bookableId=1&date=2020-06-24

因此,我们 URL 中的搜索参数是 datebookableId。图 10.4 展示了该 URL 的 Bookings 页面,左侧突出显示了指定的可预订项,并在预订网格中显示了指定的日期。

图 10.4 使用 URL 中的键值对指定可预订项和日期的 Bookings 页面

但用户输入的 URL 可能不包括日期或可预订项 ID,因此我们需要在参数缺失或使用合理的默认值时抛出或报告错误。我们将使用表 10.3 中概述的状态值策略。

表 10.3 不同 URL 的状态值策略

URL 状态
/bookings?bookableId=1&date=2020-06-24 使用指定的日期和可预订项。
/bookings?date=2020-06-24 使用指定的日期和第一个可预订项。
/bookings?bookableId=1 使用今天的日期和指定的可预订项。
/bookings 使用今天的日期和第一个可预订项。

与任何用户输入的状态一样,我们需要确保它是有效的。date参数必须是一个日期,而bookableId必须是一个整数。我们将把无效值视为缺失值,并遵循表中规定的政策。

通过点击列表中的一个可预订项、切换组或点击“下一步”按钮来选择一个可预订项,或者通过点击周选择器中的按钮移动到不同的周,应该更新 URL,以设置适当的datebookableId状态值,并重新渲染页面。

与查询字符串一起工作涉及获取和设置搜索参数值。React Router 提供了useSearchParams钩子用于这两个操作,我们将在更新 Bookings 页面以使用 URL 中的状态时,在接下来的两个小节中探讨获取和设置细节。

10.2.1 从查询字符串获取搜索参数

为了完成其工作,BookingsPage组件需要知道用户想要查看的选定可预订项和一周中的日期。这两个状态值都将包含在页面的 URL 中,如下所示:

/bookings?bookableId=1&date=2020-08-20

我们希望通过加粗的名称访问查询字符串中的每个参数:

searchParams.get("date");
searchParams.get("bookableId");

我们如何获取访问searchParams对象?React Router 提供了useSearchParams钩子,该钩子返回一个包含一个具有get方法的对象数组和用于设置它们的函数:

const [searchParams, setSearchParams] = useSearchParams();

由于我们不再使用useState管理状态,并允许用户在 URL 中输入状态,我们需要更仔细地检查该状态的有效性。而不是直接从组件中访问参数,让我们创建一个自定义钩子来获取和清理它们,在将此钩子用于BookingsPageBookings组件之前。

创建useBookingsParams钩子

在以下列表中,我们的新钩子useBookingsParams在 URL 中查找datebookableId参数,并检查date是否是一个有效的日期,以及bookableId是否是一个整数。我们将此钩子添加到bookingsHooks.js文件中。

分支:1002-get-querystring,文件:/src/components/Bookings/bookingsHooks.js

列表 10.5 使用useBookingsParams钩子访问搜索参数

import {useSearchParams} from "react-router-dom"; 
import {shortISO, isDate} from "../../utils/date-wrangler";

export function useBookingsParams () {
  const [searchParams] = useSearchParams();              ❶

  const searchDate = searchParams.get("date");           ❷
  const bookableId = searchParams.get("bookableId");     ❸

  const date = isDate(searchDate)                        ❹
    ? new Date(searchDate)
    : new Date();                                        ❺

  const idInt = parseInt(bookableId, 10);                ❻
  const hasId = !isNaN(idInt);

  return {
    date,
    bookableId: hasId ? idInt : undefined                ❼
  };
}

❶ 获取一个searchParams对象。

❷ 使用searchParams对象来访问日期参数。

❸ 使用searchParams对象来访问bookableId参数。

❹ 检查日期参数是否是一个有效的日期。

❺ 如果日期参数无效,请使用今天的日期。

❻ 尝试将bookableId转换为整数。

❼ 如果bookableId不是整数,则将其设置为undefined

我们在第 10.2.2 节中将 useBookingsParams 钩子升级为 设置 查询字符串参数。目前我们不需要设置查询字符串,所以列表 10.5 中的钩子代码只解构了 useSearchParams 返回的数组中的第一个元素:

const [searchParams] = useSearchParams();

一旦我们有了 searchParams 对象,我们调用它的 get 方法来检索查询字符串中任何参数的值。为了获取我们感兴趣的键的值,我们使用以下方法:

const searchDate = searchParams.get("date");
const bookableId = searchParams.get("bookableId");

在检查了两个值的有效性之后,新的钩子返回一个包含 datebookableId 属性的对象。调用钩子的组件可以解构返回值:

const {date, bookableId} = useBookingsParams();

在预订页面组件中使用查询参数

例如,BookingsPage 组件必须添加以下单行代码来访问它需要的两个查询字符串搜索参数,如下所示列表。

分支:1002-get-querystring,文件:/src/components/Bookings/BookingsPage.js

列表 10.6 BookingsPage 访问查询字符串搜索参数

import useFetch from "../../utils/useFetch";
import {shortISO} from "../../utils/date-wrangler";
import {useBookingsParams} from "./bookingsHooks";             ❶

import BookablesList from "../Bookables/BookablesList";
import Bookings from "./Bookings";
import PageSpinner from "../UI/PageSpinner";

export default function BookingsPage () {
  const {data: bookables = [], status, error} = useFetch(
    "http://localhost:3001/bookables"
  );

  const {date, bookableId} = useBookingsParams();              ❷

  const bookable = bookables.find(
    b => b.id === bookableId                                   ❸
  ) || bookables[0];

  function getUrl (id) {
    const root = `/bookings?bookableId=${id}`;
    return date ? `${root}&date=${shortISO(date)}` : root;     ❹
  }

  if (status === "error") {
    return <p>{error.message}</p>
  }

  if (status === "loading") {
    return <PageSpinner/>
  }

  return (
    <main className="bookings-page">
      <BookablesList
        bookable={bookable}
        bookables={bookables}
        getUrl={getUrl}
      />
      <Bookings
        bookable={bookable}
      />
    </main>
  );
}

❶ 导入 useBookingsParams 自定义钩子。

❷ 调用 useBookingsParams 并解构它返回的对象。

❸ 使用 bookableId 参数查找选定的可预订项。

❹ 在使用之前检查日期值是否已定义。

如果 bookableId 值是 undefined(它未出现在 URL 中或无法解析为整数)或者没有带有该 ID 的预订,我们将回退到服务器返回的可预订列表中的第一个可预订项:

const bookable = bookables.find(
  b => b.id === bookableId)
) || bookables[0];

如果你发现当用户指定一个无效的 ID 时,用户会感到困惑,但他们仍然看到了默认可预订的预订,你可以选择对无效值抛出错误或报告错误。

BookingsPage 组件将 getUrl 函数传递给 BookablesList 组件(我们在第 10.1 节中更新了该组件以接受此类属性),因此列表可以生成当前页面的正确格式的 URL:

function getUrl (id) {
  const root = `/bookings?bookableId=${id}`;
  return date ? `${root}&date=${shortISO(date)}` : root;
}

getUrl 使用从 URL 搜索参数派生的 date 值,因此在将其包含在生成的 URL 中之前确保 date 不是假值。

在预订组件中使用日期查询参数

Bookings 组件也使用指定的日期;它生成一个表示包含日期的周的对象。然后它以三种方式使用 week 对象:

  1. 它获取指定周的预订。

  2. 如果用户切换到另一个周,它将选定的预订设置为 null

  3. 它将周对象传递给 BookingsGrid 组件。

以下列表显示了 Bookings 组件调用新的 useBookingsParams 钩子以从 URL 获取日期,并突出显示与周相关的代码,加粗显示。

分支:1002-get-querystring,文件:/src/components/Bookings/Bookings.js

列表 10.7 Bookings 组件访问查询字符串搜索参数

import {useEffect, useState} from "react";

import {getWeek, shortISO} from "../../utils/date-wrangler";
import {useBookingsParams, useBookings} from "./bookingsHooks";            ❶

import WeekPicker from "./WeekPicker";
import BookingsGrid from "./BookingsGrid";
import BookingDetails from "./BookingDetails";

export default function Bookings ({bookable}) {
  const [booking, setBooking] = useState(null);

  const {date} = useBookingsParams();                                      ❷
 const week = getWeek(date);                                              ❸
  const weekStart = shortISO(week.start);                                  ❹

  const {bookings} = useBookings(bookable?.id, week.start, week.end);      ❺
  const selectedBooking = bookings?.[booking?.session]?.[booking.date];

  useEffect(() => {
    setBooking(null);
  }, [bookable, weekStart]);                                               ❻

  return (
    <div className="bookings">
      <div>
        <WeekPicker/>                                                      ❼

        <BookingsGrid
          week={week}                                                      ❽
          bookable={bookable}
          booking={booking}
          setBooking={setBooking}
        />
      </div>

      <BookingDetails
        booking={selectedBooking || booking}
        bookable={bookable}
      />
    </div>
  );
}

❶ 导入 useBookingsParams 自定义钩子。

❷ 调用 useBookingsParams 并将日期分配给局部变量。

❸ 使用日期生成一个周对象。

❹ 创建一个日期字符串作为依赖项。

❺ 获取指定周的预订。

如果开始日期发生变化,将当前选定的预订设置为 null。

④ 从 WeekPicker 中移除 props。

将周对象传递给 BookingsGrid。

如果用户在网格中选中了一个预订,然后切换到另一个可预订项或周,列表中的效果会将选定的预订重新设置为 null。它使用依赖列表中的简单日期字符串weekStart,而不是分配给week.startDate对象。每次渲染都会分配一个新的Date对象给week.start,即使对象可能代表相同的日期,效果在比较其依赖列表元素时也会将其视为新对象。我们不希望在每次渲染后都将选定的预订设置为null!尝试将依赖列表中的weekStart更改为week.start,以亲自查看问题。

BookingsBookingsPage组件现在可以通过从 URL 中获取状态来继续执行其工作。如果你尝试切换可预订项或手动更新 URL 到新日期,你应该看到页面加载适当的预订。但是,在 UI 中切换日期是由WeekPicker组件管理的。它过去使用 reducer 来管理其状态。让我们看看如何更新它,以便在用户点击其按钮时与查询字符串一起工作。

10.2.2 设置查询字符串

WeekPicker组件允许用户移动到上一周、下一周、包含特定日期的那一周,或者包含今天日期的那一周。图 10.5 显示了带有其四个按钮和文本框的WeekPicker UI。

图 10-5

图 10.5 WeekPicker组件具有用于切换到不同周的按钮。

当前选定日期的状态存储在查询字符串中。比如说,是 2020 年,用户导航到预订页面以显示包含 7 月 20 日的周的预订。URL 如下所示:

/bookings?bookableId=1&date=2020-07-20

如果今天是 9 月 1 日,而周选择器文本框中的日期是 6 月 24 日,我们希望WeekPicker按钮将 URL 设置为表 10.4 中显示的值。

表 10.4 按钮与 URL 的匹配

按钮 URL
上一个 /bookings?bookableId=1&date=2020-07-13
下一个 /bookings?bookableId=1&date=2020-07-27
今天 /bookings?bookableId=1&date=2020-09-01
前往 /bookings?bookableId=1&date=2020-06-24

我们可以将WeekPicker按钮转换为指向表中 URL 的链接。但是,我们不知道“前往”按钮的日期,直到用户将其输入到文本框中。作为链接的替代方案,我们将保留所有按钮,并在按钮被点击时使用函数设置查询字符串。在第 10.1.4 节中,你看到了 React Router 的useNavigate钩子返回一个函数,我们使用该函数来设置整个 URL。useSearchParams钩子提供了一种仅设置查询字符串的方法。它返回一个数组,其第二个元素是一个我们可以用于此目的的函数。例如,在这里,我们将设置函数分配给一个名为setSearchParams的变量:

const [searchParams, setSearchParams] = useSearchParams();

要通过查询字符串更新 URL 中的新搜索参数,我们传递 setSearchParams 一个具有将构成参数的属性的对象。例如,要生成此 URL

/bookings?bookableId=3&date=2020-06-24

我们将传递 setSearchParams 这个对象:

{
  bookableId: 3,
  date: "2020-06-24"  
}

在 10.2.1 节的开始,我们创建了 useBookingsParams 钩子以获取 datebookableId 参数(为了确保质量,其中混合了一些简单的验证)。现在我们想要设置 date 参数,我们需要更新钩子。以下列表向钩子添加了一个 setBookingsDate 函数,使新的函数作为钩子返回的对象上的一个属性可用。

分支:1003-set-querystring,文件:/src/components/Bookings/bookingsHooks.js

列表 10.8 使用 useBookingsParams 提供设置搜索参数的方法

export function useBookingsParams () {
  const [searchParams, setSearchParams] = useSearchParams();
  const searchDate = searchParams.get("date");
  const bookableId = searchParams.get("bookableId");

  const date = isDate(searchDate)
    ? new Date(searchDate)
    : new Date();
  const idInt = parseInt(bookableId, 10);
  const hasId = !isNaN(idInt);

  function setBookingsDate (date) {                           ❶
    const params = {};                                        ❷

    if (hasId) {params.bookableId = bookableId}               ❸
 if (isDate(date)) {params.date = date}                    ❸

    if (params.date || params.bookableId !== undefined) {
      setSearchParams(params, {replace: true});               ❹
    }
  }

  return {
    date,
    bookableId: hasId ? idInt : undefined,
    setBookingsDate                                           ❺
  };
}

❶ 创建一个函数以更新参数为新日期。

❷ 创建一个空对象来保存参数。

❸ 仅包含有效值作为参数。

❹ 使用新参数更新 URL。

❺ 将新函数包含在钩子的返回值中。

新的 setBookingsDate 函数创建一个参数对象,并为指定的日期和现有的 bookableId 值添加属性(如果它们有效)。如果它至少设置了一个属性,该函数将参数对象传递给 setSearchParams,更新 URL 以匹配新参数的查询字符串:

setSearchParams(params, {replace: true});

消费搜索参数的组件将重新渲染,使用最新的值作为最新状态。{replace: true} 选项导致浏览器用新 URL 替换其历史记录中的当前 URL。这将防止每个访问的日期出现在浏览器的历史记录中。浏览器的后退按钮不会通过在 WeekPicker 中选择的每个日期进行回退。如果您认为对于您的应用程序用户来说,能够通过每个选定的日期导航回退会有用,则可以省略选项参数。

列表 10.9 显示 WeekPicker 组件调用 useBookingsParams 以获取 date 参数和设置函数 setBookingsDate。它使用设置函数(将其重命名为 goToDate)来更新查询字符串,当用户点击其按钮之一时。

分支:1003-set-querystring,文件:/src/components/Bookings/WeekPicker.js

列表 10.9 WeekPicker 获取和设置搜索参数

import {useRef} from "react";
import {
  FaChevronLeft,
  FaCalendarDay,
  FaChevronRight,
  FaCalendarCheck
} from "react-icons/fa";
import {addDays, shortISO} from "../../utils/date-wrangler";
import {useBookingsParams} from "./bookingsHooks";                      ❶

export default function WeekPicker () {
  const textboxRef = useRef();

  const {date, setBookingsDate : goToDate} = useBookingsParams();       ❷

  const dates = {                                                       ❸
 prev: shortISO(addDays(date, -7)),                                  ❸
 next: shortISO(addDays(date, 7)),                                   ❸
 today: shortISO(new Date())                                         ❸
 }; ❸

  return (
    <div>
      <p className="date-picker">
        <button
          className="btn"
          onClick={() => goToDate(dates.prev)}                          ❹
        >
          <FaChevronLeft/>
          <span>Prev</span>
        </button>

        <button
          className="btn"
          onClick={() => goToDate(dates.today)}
        >
          <FaCalendarDay/>
          <span>Today</span>
        </button>

        <span>
          <input
            type="text"
            ref={textboxRef}
            placeholder="e.g. 2020-09-02"
            id="wpDate"
            defaultValue="2020-06-24"
          />

          <button
            onClick={() => goToDate(textboxRef.current.value)}          ❺
            className="go btn"
          >
            <FaCalendarCheck/>
            <span>Go</span>
          </button>
        </span>

        <button
          className="btn"
          onClick={() => goToDate(dates.next)}
        >
          <span>Next</span>
          <FaChevronRight/>
        </button>
      </p>
    </div>
  );
}

❶ 导入 useBookingsParams 自定义钩子。

❷ 调用钩子以获取日期和设置函数

❸ 为前一周、下一周和今天的周创建日期查找。

❹ 使用适当的日期调用设置函数

❺ 使用文本框日期调用设置函数

可预订项和预订页面现在都在 URL 中管理它们的一些状态。可预订项页面使用不同的 URL 来创建和编辑可预订项。然而,预订页面并没有为创建和编辑预订使用不同的 URL。这是因为预订、可预订项和日期之间的相互关系稍微复杂一些,用户可能不需要直接导航到单个预订的编辑表单。如果您认为用户直接导航到应用中特定状态视图会有所帮助,您现在有工具来实现这一功能。

无论您选择哪种路径来指定可预订项、日期和预订,您都需要加载相关数据。到目前为止,我们一直在使用我们自己的、相当天真的useFetch钩子来获取数据。是时候通过添加更多第三方钩子来提升我们的数据处理能力了。

10.3 使用 React Query 简化数据获取

预订应用的数据需求相当有限。数据最密集的组件是预订网格,但即使是它也一次只加载一个预订网格。但我们可以进行改进,使应用在网络速度慢时感觉更响应。而且,如果您的应用数据需求增加,这类改进可以在用户对应用性能的看法上产生重大影响——没有人希望在每次交互后都在屏幕上看到一串加载指示器!

预订应用是一个单页应用——尽管我们称我们的三个主要视图(预订、可预订项和用户)为应用内的页面。它使用 React Router 来显示不同 URL 的不同组件。其中一些组件使用相同的数据;BookablesList在预订页面和可预订项页面都从数据库中获取所有可预订项,用户选择器和用户页面都获取所有用户。如果可预订项页面已经加载了可预订项,那么在切换到预订页面时,我们就不需要等待它们再次加载。本节介绍了 React Query 并使用其useQueryuseMutation钩子。有四个子节:

  • 介绍 React Query——它是什么?为什么它有帮助?我们从哪里获取它?

  • 使组件能够访问 React Query 客户端——创建客户端实例并将其设置为包裹组件树的提供者组件的属性。

  • 使用useQuery获取数据——定义查询、指定查询键,并使用状态和错误属性。后台重新获取和请求去重。

  • 使用useMutation更新服务器状态——定义突变、在突变完成后采取行动,以及与查询缓存一起工作。

10.3.1 介绍 React Query

React Query 是一个用于管理 React 应用程序中服务器状态的库。它具有默认设置,无需配置即可产生出色的结果。图 10.6 显示了 React Query 网站的首页,react-query.tanstack.com/,在那里您可以找到文档、示例以及进一步学习资源的链接。(React Query 的作者,Tanner Linsley,创建了开源的 React 包,以帮助处理表单、表格、图表等。查看他的 GitHub 页面 github.com/tannerlinsley。)

图 10.6 React Query 的网页:为 React 提供高性能和强大的数据同步

React Query 的文档列出了一些它可以改进我们自己的 useFetch 钩子的方式。包括以下内容:

  • 缓存

  • 将对同一数据的多个请求去重为单个请求

  • 在后台更新过时的数据

  • 了解数据何时过时

  • 尽快反映数据更新

从我们的 useFetch 切换到 React Query 的 useQuery 是简单的。首先,我们需要掌握 React Query 包。您可以使用 npm 包管理器来安装它,如下所示:

npm install react-query

对于预订应用程序,React Query 将提供缓存、合并多个请求、后台获取最新数据以及用于让用户了解的有用状态码和标志。如果您需要,它有一系列配置选项,可以帮助您创建强大但精简的数据驱动应用程序。但为什么我们需要像 React Query 这样的东西来为我们的预订应用程序服务?

如果你没有延迟运行 json-server,你可能没有注意到任何问题。从一页切换到另一页,从可预订切换到可预订,都是迅速而敏捷的——多么棒的应用!但尝试添加那个延迟;像这样重新启动 json-server

json-server db.json --port 3001 --delay 3000

延迟的情况下,当我们点击可预订页面的链接时,我们会得到图 10.7 中所示的加载指示器。

图 10.7 当导航到可预订页面时,随着可预订数据的加载,我们会得到一个加载指示器。

三秒后,可预订内容已加载,预期的显示出现了 BookablesListBookableDetails 组件。如果网络速度慢,有一个加载指示器没有问题;我们只需要耐心等待。但是,如果我们从可预订页面导航到预订页面,我们会再次得到加载指示器,因为预订页面正在重新加载可预订内容。实际上,每个页面都会在用户交互后重新加载现有数据。以下是一些用户交互后三种主要数据类型重新加载的方式列表:

  • 可预订内容——预订页面和可预订页面都会获取完整的可预订内容列表。

  • 预订BookingsBookingsGrid 组件加载相同的预订列表。在预订页面上,从一本书切换到另一本书然后再切换回来会重新加载第一本书的预订,而从一周切换到下一周然后再切换回来会重新加载第一周的预订。

  • 用户—即使 UserPicker 组件已经加载了用户列表,切换到用户页面也会再次加载它们。

为了防止这种数据获取重复,我们应该将所有数据获取代码移动到一个中央存储中,并从需要它的组件中访问这个单一来源吗?使用 React Query,我们不需要做创建此类存储所涉及的所有工作。它让我们将数据获取代码保留在需要数据的组件中,但在幕后它管理数据缓存,当组件请求时传递已获取的数据。让我们看看如何让我们的组件访问这个缓存。

10.3.2 通过 React Query 客户端给组件提供访问权限

为了让组件访问共享的 React Query 缓存,我们通过将我们的应用 JSX 包裹在提供者组件中来提供缓存。React Query 使用 客户端 对象来持有缓存和配置,并提供进一步的功能。以下列表显示了如何创建客户端并将其传递给包裹应用组件树的提供者组件。

分支:1004-use-query,文件:/src/components/App.js

列表 10.10 将应用包裹在 QueryClientProvider 组件中

import {QueryClient, QueryClientProvider} from "react-query";      ❶

// other imports

const queryClient = new QueryClient();                             ❷

export default function App () {
  return (
    <QueryClientProvider client={queryClient}>                     ❸
      <UserProvider>
        <Router>
          {/* unchanged JSX */}
        </Router>
      </UserProvider>
    </QueryClientProvider>                                         ❸
  );
}

❶ 从 React Query 导入客户端构造函数和提供者组件。

❷ 创建客户端实例。

❸ 将应用包裹在提供者中,设置客户端作为属性。

将组件树包裹在提供者中,使得当我们在子组件中调用钩子时,客户端对象对 React Query 的钩子可用。让我们从使用 useQuery 钩子获取数据开始。

10.3.3 使用 useQuery 获取数据

我们自己的 useFetch 自定义钩子是一个简单的数据获取解决方案,在网络速度快时效果很好,但当引入延迟时显示出其局限性。为了创建始终感觉响应的应用程序并避免不必要的加载状态,我们希望组件能够从服务器获取数据,而无需等待之前获取的数据。React Query 将为我们管理缓存并提供 useQuery 钩子用于获取数据。

React Query 的 useQuery 钩子与我们的 useFetch 钩子类似,因为它返回一个包含数据、状态和错误对象的属性对象。但与我们传递给 useFetch 的 URL 不同,我们传递给 useQuery 的是一个键和一个异步函数,该函数返回数据:

const {data, status, error} = useQuery(key, () => fetch(url));

useQuery 使用键来识别其缓存中的数据;它可以立即返回对应现有键的数据,然后在后台从服务器获取最新数据。键可以是一个字符串,也可以是一个更复杂的数组或对象,它可以被序列化。

使用字符串作为查询键

我们可以向 useQuery 传递的最简单的键是像字符串这样的原始值。例如,在预订应用中,我们可以这样获取 bookables 列表:

const {data: bookables = [], status, error} = useQuery(
  "bookables", ❶
  () => getData("http://localhost:3001/bookables") ❷
);

❶ 为查询指定一个键。

❷ 提供一个异步数据获取函数。

我们使用字符串 "bookables" 作为键。每当任何组件随后调用 useQuery 并将 "bookables" 作为键时,React Query 将从其缓存中返回之前获取的 bookables 数据,然后在后台获取最新数据。这种行为使得 UI 看起来超级响应。在我们更新 BookablesViewBookingsPage 以调用 useQuery 而不是 useFetch 来从服务器获取 bookables 列表之后,你将能够看到这种行为。以下列表首先更新 BookablesView 组件。

分支:1004-use-query,文件:/src/components/Bookables/BookablesView.js

列表 10.11 使用 useQueryBookablesView

import {Link, useParams} from "react-router-dom";
import {FaPlus} from "react-icons/fa";

import {useQuery} from "react-query";                         ❶
import getData from "../../utils/api";                        ❷

import BookablesList from "./BookablesList";
import BookableDetails from "./BookableDetails";
import PageSpinner from "../UI/PageSpinner";

export default function BookablesView () {
  const {data: bookables = [], status, error} = useQuery(     ❸
    "bookables",                                              ❹
    () => getData("http://localhost:3001/bookables")          ❺
  );

  const {id} = useParams();
  const bookable = bookables.find(
    b => b.id === parseInt(id, 10)
  ) || bookables[0];

  /* unchanged UI */
}

❶ 导入 useQuery 钩子。

❷ 导入我们的数据获取实用函数。

❸ 调用 useQuery 钩子。

❹ 为查询指定一个键。

❺ 提供一个异步数据获取函数。

列表 10.11 中对 BookablesView 的唯一更改是从 useFetch 切换到 useQuery

挑战 10.1

这是一个简单的例子!将 BookingsPage 组件修改为调用 useQuery 来加载 bookables。使用 bookables 作为查询键。此更改已在 1004-use-query 分支上完成。

由于 Bookables 页面和 Bookings 页面使用相同的查询键,无论哪个先加载,都将能够从缓存中获取 bookables 数据。当 BookablesViewBookingsPage 都使用相同的键调用 useQuery 时,要查看缓存的作用,请按照以下步骤操作:

  1. 以两到三秒的延迟启动 json-server

  2. 导航到 /bookables 下的 Bookables 页面。你应该会看到页面级加载指示器,然后是带有第一个可预订项的 bookables 列表。

  3. 点击页面左上角的“Bookings”链接。Bookings 页面将立即渲染,无需页面级加载指示器。React Query 已使用其缓存中的 bookables 数据。

  4. 点击页面左上角的“Bookables”链接。Bookables 页面将立即渲染。同样,React Query 会从其缓存中提供 bookables 数据。

由于 Bookings 页面和 Bookables 页面使用相同的查询键 bookables,在调用 useQuery 钩子时,React Query 会立即返回该键的现有数据。当最新数据到达时,它会在后台重新获取数据并重新渲染组件。useQuery 钩子可以接受更复杂的键。例如,BookingsBookingsGrid 组件根据多个变量获取预订数据。让我们看看如何将多个变量折叠到传递给 useQuery 的查询键中。

使用数组作为查询键

预订页面在开始日期和结束日期之间获取可预订项的预订数据。React Query 需要能够跟踪每个可预订项、开始日期和结束日期组合的缓存数据,因此,在获取预订时,我们指定查询键为一个数组,如下所示:

["bookings", bookableId, start, end]

如果我们指定了一个之前已经使用过的键(比如点击一个可预订项),然后是第二个可预订项,然后又回到第一个可预订项,React Query 可以从缓存中返回与该键匹配的数据。

以下列表显示了 BookingsBookingsGrid 使用来获取预订的更新后的 useBookings 自定义钩子。

分支:1004-use-query,文件:/src/components/Bookings/bookingsHooks.js

列表 10.12 useBookings 调用 useQuery

import {useQuery} from "react-query";

export function useBookings (bookableId, startDate, endDate) {
  const start = shortISO(startDate);
  const end = shortISO(endDate);

  const urlRoot = "http://localhost:3001/bookings";

  const queryString = `bookableId=${bookableId}` +
    `&date_gte=${start}&date_lte=${end}`;

  const query = useQuery(                           ❶
    ["bookings", bookableId, start, end],           ❷
    () => getData(`${urlRoot}?${queryString}`)      ❸
  );

  return {
    bookings: query.data ? transformBookings(query.data) : {},
    ...query
  };
}

❶ 调用 useQuery 钩子。

❷ 将数组指定为查询键。

❸ 提供一个异步的数据获取函数。

BookingsBookingsGrid 组件使用相同的参数调用 useBookings,导致键相等。现在我们已经切换到调用 useQuery 而不是 useFetch,React Query 会看到键相等并将重复的请求合并为一个。

导航到预订页面,尝试在可预订项之间或在一周与另一周之间切换,然后再切回来。因为查询缓存已经包含了请求的数据,所以切换回之前选定的可预订项或周应该会立即渲染该选择的预订。这种用户界面的流畅性会导致用户满意度提升!

挑战 10.2

UserPickerUsersList 组件都从数据库中获取用户列表。更新它们的代码以调用 useQuery 而不是 useFetch。通过导航到可预订页面然后点击用户链接来测试这个更改。在 1005-query-users 分支上的用户页面应该立即渲染,没有加载指示器。

10.3.4 查询缓存中的数据访问

可预订页面现在通过调用 useQuery 钩子并使用查询键 bookables 来获取可预订项的完整列表。在一段时间内,React Query 将该键与其缓存中的可预订项数据关联起来(请查阅文档以了解有关如何以及何时将缓存数据标记为过时的详细信息)。如果需要直接访问获取的数据或以某种方式操作它,React Query 会使缓存可用。在 10.3.4 节中,我们更新缓存以突变服务器状态。在本节中,我们访问缓存以提高编辑可预订表单的响应性。

在 10.1 节中,我们使用 React Router 在可预订页面设置嵌套路由。这些路由允许用户查看可预订项、创建新的可预订项以及编辑现有的可预订项。要编辑 ID 为 3 的可预订项,用户需要导航到 /bookables/3/edit。有两种导航方式:

  • 在可预订页面,选择一个可预订项并点击编辑按钮。

  • 直接在浏览器地址栏中输入 URL。

两种选项都显示了 BookableEdit 组件,其中填充了指定可预订项的详细信息以及删除、取消和保存按钮,如图 10.8 所示。第二种选项将需要从服务器加载可预订项数据,在可预订项加载时显示加载指示器。但第一种选项,可预订项页面已经加载了可预订项的完整列表。表单能否直接从缓存中获取现有数据,从而避免加载指示器?

图 10.8 编辑可预订项表单填充了所选可预订项的数据。

React Query 通过我们分配给 10.3.2 节中提供者的客户端对象使缓存对组件可用。调用 React Query 的 useQueryClient 钩子以获取客户端对象:

const queryClient = useQueryClient();

我们可以使用相关的查询键和 getQueryData 方法来访问已获取的数据。例如,要获取缓存中的可预订项列表:

const bookables = queryClient.getQueryData("bookables");

如果我们想要一个具有指定 ID 的可预订项,我们调用 find 数组方法,如下所示:

const bookable = bookables?.find(b => b.id === id);

因此,我们可以从缓存中检索特定的预订,但如何告诉 useQuery 返回现有的预订而不是从服务器获取它?以下列表显示了 BookableEdit 组件调用 useQuery 并带有第三个参数,一个包含 initialData 属性的配置对象。我们将在列表之后讨论它。

分支:1006-query-cache,文件:/src/components/Bookables/BookableEdit.js

列表 10.13 BookableEdit 组件访问缓存

import {useParams} from "react-router-dom";
import {useQueryClient, useQuery} from "react-query";            ❶

import useFormState from "./useFormState";
import getData from "../../utils/api";

import BookableForm from "./BookableForm";
import PageSpinner from "../UI/PageSpinner";

export default function BookableEdit () {
  const {id} = useParams();
  const queryClient = useQueryClient();                          ❷

  const {data, isLoading} = useQuery(                            ❸
    ["bookable", id],
    () => getData(`http://localhost:3001/bookables/${id}`),
    {
      initialData:                                               ❹
 queryClient.getQueryData("bookables")                    ❺
        ?.find(b => b.id === parseInt(id, 10))                   ❻
    }
  );

  const formState = useFormState(data);                          ❼

  function handleDelete() {}
  function handleSubmit() {}

  if (isLoading) {                                               ❽
    return <PageSpinner/>
  }

  return (
    <BookableForm
      formState={formState}
      handleSubmit={handleSubmit}
      handleDelete={handleDelete}
    />
  );
}

❶ 导入 useQueryClient 钩子。

❷ 调用钩子并将客户端分配给一个变量。

❸ 将初始数据和后续获取的数据分配给数据变量。

❹ 使用 initialData 属性为数据分配一个初始值。

❺ 使用键从缓存中获取特定数据。

❻ 查找指定 ID 的可预订项。

❼ 将可预订项数据设置为表单的状态。

❽ 使用由 useQuery 返回的 isLoading 布尔值。

React Query 的 useQuery 钩子接受一个配置对象作为第三个参数:

const {data, isLoading} = useQuery(key, asyncFunction, config);

配置允许调用代码控制各种查询相关功能,例如缓存过期、在发生获取错误时的重试策略、回调函数、是否与 Suspense 和错误边界(见第十一章)一起工作,以及设置初始数据。BookableEdit 设置 initialData 配置属性,以便在首次调用时,如果存在初始数据,useQuery 就不会麻烦从服务器获取数据:

const {data, isLoading} = useQuery(
  ["bookable", id],
  () => getData(`http://localhost:3001/bookables/${id}`),
  {
    initialData:
 queryClient.getQueryData("bookables")
      ?.find(b => b.id === parseInt(id, 10))
  }
);

如果初始数据为 undefined(例如,当用户通过直接导航到编辑可预订项表单来加载预订应用时)或在下一次渲染中,useQuery 将继续获取数据。useQuery 在它返回的对象上设置属性,包括不同状态值的布尔值。例如,BookableEdit 组件使用 isLoading 布尔值来检查 status === "loading"

编辑可预订和新可预订表单使用自定义的 useFormState 钩子和 BookableForm 组件来管理和显示表单字段。在这里完善表单不会教给我们任何关于钩子的新知识,所以这是那种我要求你查看存储库以获取必要代码并了解它们如何工作的时刻。注意,BookableDetails 组件现在还包括一个编辑按钮,用于打开编辑可预订表单。请随意将更改视为挑战,并在检查存储库之前尝试实现表单。

因此,我们有一个显示现有可预订数据的编辑表单。但我们如何将我们对数据库所做的任何更改保存到数据库中?我们不是调用 useQuery,而是调用 useMutation。让我们让它为新可预订工作。

10.3.5 使用 useMutation 更新服务器状态

React Query 帮助我们同步我们的 React 应用程序 UI 与服务器上存储的状态。我们已经看到了 useQuery 如何简化获取该状态的过程,并在浏览器中临时将其缓存。我们还想在服务器上更新状态,React Query 提供了 useMutation 钩子来实现这个目的。

在 Bookables 页面上,我们可以打开新的可预订表单并在其字段中输入信息,但我们无法保存我们的创建。我们想要突变那个状态!我们需要一个将新的可预订发送到服务器的函数,类似于以下内容:

createBookable(newBookableFields);

以下列表显示了 BookableNew 组件在其 handleSubmit 函数中调用 createBookable。它通过调用 useMutation 获取 createBookable 突变函数,我们将在列表之后讨论必要的语法。

分支:1007-use-mutation,文件:/src/components/Bookables/BookableNew.js

列表 10.14 BookableNew 使用 useMutation 将数据保存到服务器

import {useNavigate} from "react-router-dom";
import {useQueryClient, useMutation} from "react-query";             ❶

import useFormState from "./useFormState";
import {createItem} from "../../utils/api";                          ❷

import BookableForm from "./BookableForm";
import PageSpinner from "../UI/PageSpinner";

export default function BookableNew () {
  const navigate = useNavigate();
  const formState = useFormState();
  const queryClient = useQueryClient();

  const {mutate: createBookable, status, error} = useMutation(       ❸

    item => createItem("http://localhost:3001/bookables", item),     ❹

    {
      onSuccess: bookable => {                                       ❺
        queryClient.setQueryData(                                    ❻
 "bookables",
 old => [...(old || []), bookable]
 );

        navigate(`/bookables/${bookable.id}`);                       ❼
      }
    }
  );

  function handleSubmit() {
    createBookable(formState.state);                                 ❽
  }

  if (status === "error") {
    return <p>{error.message}</p>
  }

  if (status === "loading") {
    return <PageSpinner/>
  }

  return (
    <BookableForm
      formState={formState}
      handleSubmit={handleSubmit}
    />
  );
}

❶ 导入 React Query 钩子。

❷ 导入 createItem API 函数。

❸ 调用 useMutation,将突变函数分配给 createBookable 变量。

❹ 向 useMutation 传递一个异步函数。

❺ 设置一个 onSuccess 回调。

❻ 将新的可预订添加到“bookables”查询缓存中。

❼ 导航到新创建的可预订。

❽ 使用新可预订的字段调用 createBookable 突变函数。

useMutation 钩子返回一个包含 mutate 函数和状态值的对象:

const {mutate, status, error} = useMutation(asyncFunction, config);

当你调用 mutate 时,React Query 会运行 asyncFunction 并更新状态属性(例如,statuserrordataisLoading)。当调用 useMutation 时,BookableNew 组件将突变函数分配给一个名为 createBookable 的变量:

const {mutate: createBookable, status, error} = useMutation(...);

BookableNew 将一个异步函数传递给 useMutation,用于将新可预订的字段发布到服务器。它使用来自 /src/utils/api.js 的 createItem 函数:

const {mutate: createBookable, status, error} = useMutation(

  item => createItem("http://localhost:3001/bookables", item),

  { /* config */ }
);

配置对象包括一个 onSuccess 属性,一个在服务器状态成功更改后运行的函数。该函数将新的可预订添加到 bookables 缓存中,并导航到新的可预订:

onSuccess: bookable => {                       ❶
  queryClient.setQueryData(
 "bookables",
 old => [...(old || []), bookable] ❷
 );

  navigate(`/bookables/${bookable.id}`); ❸
}

❶ 从服务器接收新创建的可预订。

❷ 将新的可预订项追加到可预订项的缓存中。

❸ 在 UI 中导航到新的可预订项。

挑战 10.3

BookableEdit组件连接起来,以便它保存对可预订项的更改并允许删除可预订项。为每个操作创建单独的突变,并在handleSavehandleDelete函数中调用它们。(您可以在 api.js 中添加editItemdeleteItem方法,并在突变中调用这些方法。)1008-edit-bookable 分支包含带有大量注释的解决方案代码。

挑战 10.4

这是一个大项目!实现一个BookingForm组件,以便用户可以在预订页面创建、编辑和删除预订。BookingDetails组件应显示选定的预订的非可编辑详情的Booking组件,或者当用户想要编辑或创建预订时显示BookingForm组件。解决方案分支 1009-booking-form 在 bookingsHooks.js 文件中创建了三个自定义钩子(useCreateBookinguseUpdateBookinguseDeleteBooking)。

注意:我在预订应用中尚未实现任何表单验证。在实际应用中,我们会在客户端和服务器上添加验证。

注意:该仓库有两个更多分支用于本章,1010-react-spring 和 1011-spring-challenge,它们使用 React Spring 库为预订页面添加动画过渡,滑动预订网格以切换预订项和日期。这是第三方钩子的一个有趣额外用途。

摘要

  • 使用 React Router,为以指定路径开始的路线渲染相同的组件。例如,为以/bookables/开始的 URL 渲染BookablesPage

      <Route path="/bookables/*" element={<BookablesPage/>}/>
    
  • 作为element属性的替代,将 JSX 包裹在打开和关闭Route标签之间:

    <Route path="/bookables/*">
      <BookablesPage/>
    </Route>
    
  • 通过在组件树中更高位置的Route组件中包含Routes组件来嵌套路由。例如,BookablesPage可以包含其自己的嵌套路由,URL 为/bookables 和/bookables/new,通过返回如下 UI:

        <Routes>
          <Route path="/">
            <BookablesView/>
          </Route>
          <Route path="/new">
            <BookableNew/>
          </Route>
        </Routes>
    
  • 在路由中使用参数,通过在参数名称前加冒号:

      <Route path="/:id" element={<BookablesView/>}/>
    
  • 通过调用 React Router 的useParams钩子来在组件中访问参数。useParams返回一个包含参数及其值的对象。从对象中解构参数:

      const {id} = useParams();
    
  • 使用 React Router 的useNavigate钩子进行导航:

    const navigate = useNavigate();
    navigate("/url/of/page");
    
  • 使用 React Router 的useSearchParams钩子访问 URL 查询字符串中的搜索参数:

      const [searchParams, setSearchParams] = useSearchParams();
    

    对于 URL /bookings?bookableId=1&date=2020-08-20,可以这样访问参数:

      searchParams.get("date");
      searchParams.get("bookableId");
    
  • 通过将对象传递给setSearchParams来设置查询字符串:

      setSearchParams({
        date: "2020-06-26",
        bookableId: 3
      });
    
  • 使用 React Query 在浏览器中高效地获取和缓存服务器状态。将您的 app JSX 包裹在一个提供者中,并将客户端对象传递给提供者:

    const queryClient = new QueryClient();
    
    export default function App () {
      return (
        <QueryClientProvider client={queryClient}>
          {/* app JSX */}
        </QueryClientProvider>
      );
    }
    
  • 要获取数据,将键和获取函数传递给useQuery钩子:

      const {data, status, error} = useQuery(key, () => fetch(url));
    
  • 将配置对象作为第三个参数传递给useQuery

      const {data, isLoading} = useQuery(key, asyncFunction, config);
    
  • 通过使用配置对象来设置初始数据:

      const {data, isLoading} = useQuery(key, asyncFn, {initialData: [...]});
    
  • 使用getQueryData方法和键从queryClient对象访问之前获取的数据:

    const queryClient = useQueryClient();
    const {data, isLoading} = useQuery(
      currentKey,
      asyncFunction,
      {
        initialData: queryClient.getQueryData(otherKey)
      }
    );
    
  • 创建一个用于通过调用 React Query 的 useMutation 函数来更新服务器状态的突变函数:

      const {mutate, status, error} = useMutation(asyncFunction, config);
    
  • 使用突变函数在服务器上更新状态:

      mutate(updatedData);
    

第二部分

React 的进化不仅仅局限于钩子。React 团队正努力通过实施灵活但强大的 API,提供安全且合理的默认值,来使开发和使用 React 应用的经验尽可能直观和愉快。团队的动力很大一部分来自于开发 Facebook 应用,但他们也仔细倾听社区的声音,并花时间确保新兴模型正确无误。

团队一直在致力于并发模式的开发,第二部分将为您揭示即将到来的内容。并发模式允许 React 同时处理您 UI 的多个版本——暂停、重启和丢弃渲染任务,以使您的应用看起来尽可能的响应和可预测。

第十一章展示了您如何使用Suspense组件和错误边界来解耦回退 UI 与用于懒加载和错误报告及恢复的组件。第十二章和第十三章随后进入更实验性的领域,探讨了数据获取和图像加载如何与 Suspense 集成,以及您如何使用两个额外的钩子useTransitionuseDeferredValue,来根据您应用中的状态变化向用户提供最佳的 UI。

11 使用 Suspense 进行代码拆分

本章涵盖

  • 使用import函数动态导入代码

  • 使用React.lazy按需加载组件

  • 使用Suspense组件声明性地指定回退 UI

  • 理解lazySuspense如何协同工作

  • 使用错误边界声明性地指定错误回退 UI

应用用户通常与某些组件的交互比与其他组件更多。例如,在预订应用中,用户经常访问预订页面而不切换到可预订或用户页面,在可预订页面上,他们可能永远不会打开新建或编辑表单。为了管理浏览器在任何时候加载的代码量,我们可以使用一种称为代码拆分的技术;而不是一次性加载应用的所有代码,我们按需加载它为

到目前为止,本书中的所有示例都使用了静态导入。在每个 JavaScript 文件顶部,我们包含import语句来指定依赖项,即当前文件使用的来自外部文件的代码。在构建时,webpack 等打包工具检查我们的代码,跟踪导入文件的路径,并生成一个,一个包含应用实际使用所有代码的文件。然后,我们的网页请求这个包。

这种摇树优化过程,它避免了重复代码并丢弃未使用的代码,可以帮助保持包的良好组织并尽可能小。对于较大的应用和/或较慢的连接,"尽可能小"仍然可能足够大,需要一段时间才能加载。也许一开始就加载所有代码并不是最好的主意。如果应用的部分不太可能被使用或包含特别庞大的组件,减少初始包的大小并在用户访问特定路由或发起特定交互时才加载进一步的包可能是有帮助的。

在 React 中,我们与组件一起工作。我们希望在需要时才动态地将一些组件导入到我们的应用中。但 React 在渲染它们的时间调用组件。如果组件在渲染时未加载,React 应该怎么做?我们不希望整个应用暂停等待组件。对于较大的组件或那些通常不参与初始用户交互的组件,我们可以做以下四件事:

  • 仅在我们尝试渲染组件时加载组件代码。

  • 在组件加载时显示占位符。

  • 继续渲染应用的其余部分。

  • 在加载完成后,用组件替换占位符。

在本章中,我们通过使用 React 的lazy方法和Suspense组件来探讨如何将这些四点付诸实践。我们对占位符 UI 的讨论也将引导我们到错误边界,这是一种在发生错误时给 React 提供渲染内容的方式。首先,了解 JavaScript 如何让我们动态导入代码将很有用。

11.1 使用导入函数动态导入代码

在本节中,我们探讨从模块中动态导入 JavaScript 到另一个模块。我们不会使用 React,但这些概念对于我们在 React 应用中动态加载组件时非常重要。共有四个小节:

  • 设置网页在点击按钮时加载 JavaScript

  • 使用默认和命名导出从文件中使 JavaScript 可用

  • 使用静态导入加载 JavaScript

  • 调用import函数动态加载 JavaScript

11.1.1 设置网页在点击按钮时加载 JavaScript

假设我们有一个显示按钮的应用。当我们点击按钮时,会显示两条消息,如图 11.1 所示。

图片

图 11.1 点击按钮显示两条消息。

为了演示模块导入,让我们将应用拆分为三个文件:index.html、index.js 和 helloModule.js。以下列表显示了 HTML,包括按钮、两个段落以保存两条消息,以及一个脚本元素以加载将按钮连接到显示消息的代码文件 index.js。

实时查看: vg0ke.csb.app, 代码: codesandbox.io/s/jsstaticimport-vg0ke

列表 11.1 用于显示两条消息的 HTML 文件(index.html)

<!DOCTYPE html>
<html>
  <head>
    <title>Dynamic Imports</title>
    <meta charset="UTF-8" />
  </head>

  <body>
    <button id="btnMessages">Show Messages</button>      ❶
    <hr />
    <p id="messagePara"></p>                             ❷
    <p id="hiPara"></p>                                  ❷

    <script src="src/index.js"></script>                 ❸
  </body>
</html>

❶ 包含一个按钮以显示两条消息。

❷ 包含段落元素作为消息的目标。

❸ 加载将按钮连接起来的脚本。

我们还没有 index.js 文件,但我们知道它将使用一些方便的实用函数来将文本注入现有的 HTML 元素。这些实用函数位于它们自己的模块中。让我们看看这个模块以及它是如何使函数可用的。

11.1.2 使用默认和命名导出

我们方便的实用函数位于 JavaScript 模块 helloModule.js 中。该模块如下所示,并使用exportdefault关键字指定其他文件可以导入的值。其中一个消息函数是默认导出,另一个是命名导出。

实时查看: vg0ke.csb.app, 代码: codesandbox.io/s/jsstaticimport-vg0ke

列表 11.2 创建具有默认和命名导出的模块(helloModule.js)

export default function sayMessage (id, msg) {       ❶
  document.getElementById(id).innerHTML = msg;
}

export function sayHi (id) {                         ❷
  sayMessage(id, "Hi");
}

❶ 将 sayMessage 函数设为默认导出。

❷ 将 sayHi 函数设为命名导出。

文件可以有一个默认导出和多个命名导出。它们不需要导出所有内容,只需导出那些它们希望其他文件能够导入的值(在我们的例子中是函数)。我们准备好了超级方便的消息注入函数,所以让我们开始导入吧!

11.1.3 使用静态导入加载 JavaScript

当用户点击按钮时,我们的应用程序执行显示消息的关键任务。我们需要最后一个文件,即index.js,它设置一个事件处理器来将按钮与显示消息的动作连接起来。但它不是从头开始的;毕竟我们有一些方便的实用函数可用。因此,index.jshelloModule.js模块导入消息函数,并在事件处理器中调用它们。从其他模块导入导出值的标准方法是在文件顶部静态导入,如下面的列表所示。

实时演示: vg0ke.csb.app代码: codesandbox.io/s/jsstaticimport-vg0ke

列表 11.3 静态导入(index.js)

import showMessage, {sayHi} from "./helloModule";     ❶

function handleClick () {
  showMessage("messagePara", "Hello World!");         ❷
  sayHi("hiPara");                                    ❷
}

document
  .getElementById("btnMessages")
  .addEventListener("click", handleClick);            ❸

❶ 导入两个消息函数。

❷ 调用导入的函数。

❸ 当按钮被点击时调用处理器。

我们将helloModule模块的默认导出分配给本地变量showMessage(我们可以选择变量名),并将命名导出sayHi分配给一个使用匹配括号内变量名的本地变量——在helloModule.js中它被命名为sayHi,因此我们在index.js中必须使用sayHi

所有这些都按预期工作;这是一个简单的例子。但是,如果我们想导入的模块是一个更大的文件(至少现在假装是这样),而且大多数用户不太经常点击按钮,我们能避免在不需要时加载这个庞大的模块吗?这将真正帮助我们更快地加载主应用程序。

11.1.4 使用导入函数动态加载 JavaScript

如果只有当按钮被点击时才加载按钮使用的代码怎么办?以下列表显示了index.js如何使用import函数动态加载代码。

实时演示: n41cc.csb.app/代码: codesandbox.io/s/jsdynamicimport-n41cc

列表 11.4 使用导入函数动态加载代码(index.js)

function handleClick() {
  import("./helloModule")                                ❶
    .then((module) => {                                  ❷
      module.default("messagePara", "Hello World!");     ❸
      module.sayHi("hiPara");                            ❸
    });
}

document
  .getElementById("btnMessages")
  .addEventListener("click", handleClick);

❶ 调用导入函数以动态加载一个模块。

❷ 将模块分配给一个本地变量。

❸ 使用模块属性调用导出的函数。

如果不会使用,就没有必要加载一个大文件,因此模块只有在点击按钮时才会加载。handleClick函数使用import函数来加载模块:

import("./helloModule")

import函数返回一个解析为导出模块的 promise。我们调用 promise 的then方法在模块加载后与之交互:

import("./helloModule").then((module) => { /* use module */ });

或者,我们可以使用async/await语法:

async function handleClick() {
  const module = await import("./helloModule");
  // use module
}

导出的值(在我们的例子中是函数)作为module对象的属性可用。默认导出分配给default属性,命名导出分配给同名属性。helloModule.js文件有一个默认导出和一个名为sayHi的命名导出,因此这些作为module.defaultmodule.sayHi可用:

module.default("messagePara", "Hello World!");
module.sayHi("hiPara");                                       

我们可以像以下列表所示那样解构模块对象,而不是将函数作为模块对象的函数调用。

列表 11.5 从动态导入中解构模块属性

function handleClick() {
  import("./helloModule")
    .then(({default: showMessage, sayHi}) => {       ❶
      showMessage("messagePara", "Hello World!");    ❷
      sayHi("hiPara");                               ❷
    });
}

document
  .getElementById("btnMessages")
  .addEventListener("click", handleClick);

❶ 解构模块,将导出的函数分配给局部变量。

❷ 使用局部变量调用导出的函数。

在解构过程中,我们将默认导出分配给一个更合适的变量名,showMessage。再次强调,async/await版本相当简洁:

async function handleClick() {
  const {default: showMessage, sayHi} = await import("./helloModule");
  showMessage("messagePara", "Hello World!");
  sayHi("hiPara");
}

因此,这就是对动态导入的快速介绍。但是,我们想要动态导入 React 组件;如何在不破坏 React 渲染过程的情况下延迟组件的导入?现在我们需要这些知识,让我们来了解一下关于懒加载的详细信息。

11.2 使用lazySuspense动态导入组件

在前面的章节中,我们使用了import函数来动态加载 JavaScript 代码。我们仅在需要时加载代码,即用户点击按钮时。但我们也在控制渲染;我们通过调用addEventListenergetElementById以及设置innerHTML属性来强制性地附加事件处理程序并调整 DOM。

当使用 React 工作时,我们应该专注于更新状态,让 React 管理 DOM。如何将懒加载组件与 React 需要控制渲染过程的需求结合起来?我们需要一种方法,以声明方式让 React 知道如果它想要渲染的组件尚未准备好,应该怎么做。本节将探讨我们可以用来解决问题的两个部分,首先是分别探讨,然后一起探讨,最后将解决方案应用于预订应用示例。我们的四个小节如下:

  • 使用lazy函数将组件转换为懒加载组件

  • 使用Suspense组件指定回退内容

  • 理解lazySuspense如何协同工作

  • 在应用的路线上进行代码拆分

首先,在一个新闻应用中,我们有一个带有过大日历组件的日期。在这种情况下,懒惰是一种美德。

11.2.1 使用lazy函数将组件转换为懒加载组件

假设我们公司有一个应用,显示最新的公司新闻和公告。同事们经常检查这个应用以保持最新。该应用还包括一个功能齐全的日历组件,它可以在主页上与其他内容一起查看,或者在其自己的视图中打开。

然而,同事们只是偶尔检查日历。我们不想在应用首次加载时包含日历组件代码,而只想在用户点击显示日历按钮时加载日历代码。图 11.2 大致说明了设置,包括主应用区域和两种打开日历的方式。

图 11.2 我们的公司新闻应用仅在用户点击其中一个显示日历按钮时加载Calendar组件代码。

我们将使用相同的组件CalendarWrapper作为主应用程序下的两个日历区域(但想象一个会在当前位置打开日历,另一个会替换当前视图)。以下列表显示了应用程序 UI 的 JSX,包括主区域和两个日历区域。

列表 11.6 应用程序包括一个主区域和两个日历区域

<div className="App">
  <main>Main App</main>
  <aside>
    <CalendarWrapper />
    <CalendarWrapper />
  </aside>
</div>

以下列表中的CalendarWrapper组件代码。组件首先显示“显示日历”按钮。当用户点击按钮时,CalendarWrapper切换到显示LazyCalendar组件。

列表 11.7 包含显示日历按钮的组件

function CalendarWrapper() {
  const [isOn, setIsOn] = useState(false);
  return isOn ? (
    <LazyCalendar />                           ❶
  ) : (
    <div>
      <button onClick={() => setIsOn(true)}>Show Calendar</button>
    </div>
  );
}

❶ 包含一个延迟加载的组件。

列表 11.7 使用了LazyCalendar组件,这是一个特殊的组件,它直到首次渲染时才被导入。但它是从哪里来的?假设我们已经在名为 Calendar.js 的模块中有一个Calendar组件,我们可以结合动态导入和 React 的lazy函数将Calendar转换为LazyCalendar

const LazyCalendar = lazy(() => import("./Calendar.js"));

我们传递给lazy一个返回承诺的函数。更一般地说,这个过程看起来像这样:

const getPromise = () => import(modulePath);     ❶

const LazyComponent = lazy(getPromise);          ❷

❶ 创建一个返回承诺的函数。

❷ 将生成承诺的函数传递给 React.lazy。

我们传递给lazy一个函数getPromise,当 React 需要首次渲染该组件时调用。getPromise函数返回一个解析为模块的承诺。模块的默认导出必须是一个组件。

但我们没有Calendar模块(我们想象它是一个大文件),所以为了我们的示例,并加强模块是具有默认和命名属性的对象的这一概念,让我们模拟一个模块,并使用以下代码使其延迟加载。

列表 11.8 创建一个模拟模块并使其组件延迟加载

const module = {
  default: () => <div>Big Calendar</div>                   ❶
};

function getPromise() {
  return new Promise(                                      ❷
    (resolve) => setTimeout(() => resolve(module), 3000)   ❷
  );
}

const LazyCalendar = lazy(getPromise);                     ❸

❶ 将一个函数组件分配给默认属性。

❷ 返回一个解析为我们模块的承诺。

❸ 通过将getPromise传递给lazy来创建一个延迟加载的组件。

太好了!我们已经准备好所有部件来尝试我们的第一个延迟加载组件:

  • 一个“巨大的”日历组件(() => <div>Big Calendar</div>)

  • 将日历组件分配给其默认属性的模块

  • 一个解析为模块的承诺(三秒后)

  • 一个创建并返回承诺的函数getPromise

  • 通过将getPromise传递给lazy创建的延迟组件LazyCalendar

  • 一个包装组件CalendarWrapper,仅在用户点击按钮后显示LazyCalendar

  • 包含两个CalenderWrapper组件的App组件

以下列表将所有部件放在一起。它是 CodeSandbox 上的一个 React 应用程序的一部分。创建和使用延迟组件的代码以粗体显示。

实时: 9qj5f.csb.app代码: codesandbox.io/s/lazycalendarnosuspense-9qj5f

列表 11.9 使用延迟组件运行我们的应用程序

import React, { lazy, useState } from "react";
import "./styles.css";

const module = {
  default: () => <div>Big Calendar</div>                     ❶
};

function getPromise() {
  return new Promise(
    (resolve) => setTimeout(() => resolve(module), 3000)     ❷
  );
}

const LazyCalendar = lazy(getPromise);                       ❸

function CalendarWrapper() {
  const [isOn, setIsOn] = useState(false);
  return isOn ? (
    <LazyCalendar />                                         ❹
  ) : (
    <div>
      <button onClick={() => setIsOn(true)}>Show Calendar</button>
    </div>
  );
}

export default function App() {
  return (
    <div className="App">
      <main>Main App</main>
      <aside>
        <CalendarWrapper />
        <CalendarWrapper />
      </aside>
    </div>
  );
}

❶ 将组件作为模块的默认导出。

❷ 使用模块解析一个 promise。

❸ 将组件解析的 promise 转换为懒加载组件。

❹ 如同其他组件一样使用懒加载组件。

记住,对于真正的模块,我们使用动态导入;我们传递一个函数给lazy,该函数调用import函数。所以,如果Calendar组件是从 Calendar.js 模块的默认导出,我们会创建懒加载组件如下所示:

const LazyCalendar = lazy(() => import("./Calendar.js"));

但等等!如果你点击链接到 CodeSandbox 并点击其中一个显示日历按钮,你会看到一个问题,一个邪恶的错误!(实际上,就像大多数 React 错误一样,它相当友好;它告诉我们确切需要做什么。)错误如图 11.3 所示。它告诉我们“在树中添加一个<Suspense fallback=. . .>组件,以提供加载指示器或占位符来显示。”让我们遵循它的建议。

图片

图 11.3  我们的应用一开始运行良好,但点击显示日历按钮会导致错误:“渲染时挂起的 React 组件,但没有指定回退 UI。”

11.2.2 使用 Suspense 组件指定回退内容

加载组件需要时间,我们想象的Calendar组件代码是一个大而健壮的文件。当需要显示日历但尚未加载时,我们的应用应该做什么?我们需要某种类型的加载指示器来让用户知道日历正在路上。可能就像图 11.4 中那样简单,只是显示“加载中...”这样的文本。

图片

图 11.4 当用户第一次点击显示日历按钮时,应用显示加载指示器,直到组件加载完成。

幸运的是,正如图 11.3 中的错误所指出的,React 提供了一个简单的方式来指定回退 UI:Suspense组件。使用Suspense组件来包装包含一个或多个懒加载组件的 UI:

<Suspense fallback={<div>Loading...</div>}>
  <CalendarWrapper />
</Suspense>

使用fallback属性指定Suspense组件在所有懒加载子组件返回一些 UI 之前要渲染的内容。在下面的列表中,我们将两个CalendarWrapper组件都包装在自己的Suspense组件中,这样应用就知道如果其中一个包装器的LazyCalendar组件正在加载时应该做什么。

实时: h0hgg.csb.app代码: codesandbox.io/s/lazycalendar-h0hgg

列表 11.10 使用Suspense组件包装两个日历区域

<div className="App">
  <main>Main App</main>
  <aside>
    <Suspense fallback={<div>Loading...</div>}>      ❶
      <CalendarWrapper />
    </Suspense>
    <Suspense fallback={<div>Loading...</div>}>      ❷
      <CalendarWrapper />
    </Suspense>
  </aside>
</div>

❶ 将包含懒加载组件的 UI 包装在 Suspense 组件中。

❷ 使用 fallback 属性指定占位符 UI。

如果你点击 CodeSandbox 上的新版本链接并点击显示日历按钮,你会看到图 11.4 中的“加载中...”回退持续三秒钟,然后Calendar组件将渲染,显示“大日历”,如图 11.5 所示。

图片

图 11.5 一旦Calendar组件加载,它将替换回退内容。

一旦Calendar组件加载完成,它就不需要再次加载,因此点击第二个显示日历按钮将立即渲染第二个Calendar组件。在列表 11.10 中,每个CalendarWrapper组件都被包裹在其自己的Suspense组件中。但可能只需要一个Suspense组件。以下代码片段显示了两个CalendarWrapper组件的单个Suspense组件。

<Suspense fallback={<div>Loading...</div>}>
  <CalendarWrapper />
  <CalendarWrapper />
</Suspense>

如果你以这种方式包裹两个组件,第一次点击显示日历按钮将显示图 11.6 中所示的共享“加载中...”回退内容。

图 11.6 多个组件可以包裹在单个Suspense组件中。如果任何子组件正在加载,将显示回退内容。

当懒加载组件首次渲染时,React 会沿着组件树向上查找,并使用它找到的第一个Suspense组件。该Suspense组件将渲染其回退 UI 以替换其子组件。如果没有找到Suspense组件,React 将抛出我们在图 11.3 中看到的错误。

能够指定与加载组件分开的回退 UI,这在我们调整 UI 以获得最佳用户体验时提供了更大的灵活性。但是,这些单独的组件是如何协同工作的?React 是如何查找Suspense组件的组件树的?懒加载组件使用什么机制来渲染已加载的组件或将渲染传递给父组件?嗯,我就在这里帮助你们。我会告诉你们它们是如何做到的,这是一个承诺。

11.2.3 理解懒加载和 Suspense 如何协同工作

我们可以将懒加载组件视为具有内部状态:未初始化、挂起、已解析或拒绝。当 React 首次尝试渲染懒加载组件时,组件处于未初始化状态,但 React 会调用一个返回 promise 的函数来加载模块。例如,这里的返回 promise 的函数是getPromise

const getPromise = () => import("./Calendar");
const LazyCalendar = lazy(getPromise);

这个 promise 应该解析为一个模块,其default属性是组件。一旦解析完成,React 可以将懒加载组件的状态设置为已解析并返回组件,准备渲染,类似于以下这样:

if (status === "resolved") {
  return component;
} else {
  throw promise;
}

else子句包含了与树中更上层的Suspense组件通信的关键:如果 promise 尚未解析,React 将抛出它,就像你会抛出一个错误一样。Suspense组件被设置为捕获 promise,如果 promise 处于挂起状态,则渲染回退 UI。

总结一下,表 11.1 显示了 React 在树中遇到懒加载组件时采取的步骤。它执行它能够执行的第一步操作。

表 11.1 React 遇到懒加载组件时采取的步骤

如果 LazyComponent 对象包含 动作
组件 调用组件。
未解析的 promise 抛出 promise。
一个返回 Promise 的函数 调用函数以获取 Promise。将 Promise 存储在 LazyComponent 对象中。调用 Promise 的 then 方法,以便当 Promise 解析时,组件存储在 LazyComponent 对象中。抛出 Promise。

经验丰富的 Promise 管理员可能会想知道如果 Promise 被拒绝会发生什么,可能是因为网络错误。Suspense 组件不处理错误 UI;这是错误边界的工作范围,我们在第 11.3 节中讨论。在此之前,让我们将预订应用程序拆分为懒加载路由。

11.2.4 在其路由上拆分应用程序的代码

您现在知道如何通过懒加载一些组件将我们的应用程序拆分为单独的包。如果代码不会被使用,就没有必要加载大量代码。相反,当用户选择使用某些功能时,可以加载该功能的代码,同时显示一些回退 UI 以在加载时显示。

我们的预订应用程序示例已经根据预订、可预订项目和用户拆分为单独的路由。路由似乎是开始拆分代码的合理位置。下面的列表更新了 App 组件,懒加载每个页面组件,并将 Routes 组件包裹在 Suspense 组件中。

分支:1101-lazy-suspense,文件:/src/components/App.js

列表 11.11 在 App 中懒加载页面组件

import {lazy, Suspense} from "react";                                     ❶

// previous imports with the three pages removed

import PageSpinner from "./UI/PageSpinner";

const BookablesPage = lazy(() => import("./Bookables/BookablesPage"));    ❷
const BookingsPage = lazy(() => import("./Bookings/BookingsPage"));       ❷
const UsersPage = lazy(() => import("./Users/UsersPage"));                ❷

const queryClient = new QueryClient();

export default function App () {
  return (
    <QueryClientProvider client={queryClient}>
      <UserProvider>
        <Router>
          <div className="App">
            <header>
              <nav>
                {/* unchanged */}
              </nav>

              <UserPicker/>
            </header>

            <Suspense fallback={<PageSpinner/>}>                          ❸
              <Routes>
                <Route path="/bookings" element={<BookingsPage/>}/>       ❹
                <Route path="/bookables/*" element={<BookablesPage/>}/>   ❹
                <Route path="/users" element={<UsersPage/>}/>             ❹
              </Routes>
            </Suspense>
          </div>
        </Router>
      </UserProvider>
    </QueryClientProvider>
  );
}

❶ 导入懒加载函数和 Suspense 组件。

❷ 懒加载三个页面组件。

❸ 将页面路由包裹在带有 PageSpinner 回退的 Suspense 组件中。

❹ 就像使用任何其他懒加载页面组件一样使用。

现在,如果用户首先访问用户页面,比如,只有 App 组件、UsersPage 组件及其依赖项的代码被加载。BookingsPageBookablesPage 的代码不包括在内。在组件加载时,我们的常用 PageSpinner 组件在顶部菜单栏下渲染。

BookablesPage 组件包含一些嵌套路由,用户可能直接导航到其中的任何一个,而无需选择访问其他路由。一次性加载所有代码是不必要的,所以让我们在下面的列表中再次进行懒加载。

分支:1101-lazy-suspense,文件:/src/components/Bookables/BookablesPage.js

列表 11.12 为 BookablesPage 懒加载嵌套组件

import {lazy} from "react";
import {Routes, Route} from "react-router-dom";

const BookablesView = lazy(() => import("./BookablesView"));    ❶
const BookableEdit = lazy(() => import("./BookableEdit"));      ❶
const BookableNew = lazy(() => import("./BookableNew"));        ❶

export default function BookablesPage () {
  return (
    <Routes>
      <Route path="/:id">
        <BookablesView/>                                        ❷
      </Route>
      <Route path="/">
        <BookablesView/>                                        ❷
      </Route>
      <Route path="/:id/edit">
        <BookableEdit/>                                         ❷
      </Route>
      <Route path="/new">
        <BookableNew/>                                          ❷
      </Route>
    </Routes>
  );
}

❶ 懒加载组件。

❷ 按照之前完全相同的方式使用组件。

这次,我们不在路由中包裹 Suspense 组件。我们现有的 App 中的回退将愉快地处理树中任何正在挂起的组件(抛出挂起 Promise 的组件)。PageSpinner 是一个合适的回退,因为三个组件——BookablesViewBookablesEditBookablesNew——都是页面级组件。它们都替换了它们之前的页面上的任何内容(不包括顶部始终存在的菜单栏)。请随意尝试在嵌套路由周围添加 Suspense 组件;一条“正在加载编辑表单……”的消息可能很有用。

Suspense 组件处理挂起的承诺。当组件抛出一个拒绝的承诺或更传统地,在渲染时抛出错误时会发生什么?如果 Suspense 组件不想知道,那会是谁呢?是时候为那些讨厌的错误设定一些边界了。

11.3 使用错误边界捕获错误

React 没有提供用于捕获子组件抛出的错误的组件。但它确实提供了一些生命周期方法,类组件可以实现这些方法来捕获和报告错误。如果你的一个类组件实现了其中之一或两个方法,它就被认为是错误边界

如果你将组件树的全部或部分包裹在错误边界中,如果其中一个包装组件抛出错误,它将渲染回退用户界面。图 11.7 显示了在页面组件或其子组件之一抛出错误时,预订应用程序可能使用的回退用户界面类型。

图 11.7 与卸载应用程序不同,如果发生错误,错误边界可以显示一些回退用户界面。

假设我们有一个这样的错误边界组件 ErrorBoundary,我们希望它能够捕获预订应用程序中任何路由的错误。我们希望能够指定错误边界的位置,以及当抛出错误时哪些组件被回退 UI 替换。我们希望像这样使用 ErrorBoundary

<UserProvider>
  <Router>
    <div className="App">
      <header>{/* unchanged menu */}</header>     ❶

      <ErrorBoundary>                             ❷
        <Suspense fallback={<PageSpinner/>}>      ❸
          <Routes>{/* unchanged */}</Routes>      ❹
        </Suspense>
      </ErrorBoundary>

    </div>
  </Router>
</UserProvider>

❶ 在错误边界之外留下一些 UI。

❷ 使用错误边界来捕获包装组件中的错误。

❸ 捕获包装组件中的承诺。

❹ 当一切正常时渲染包装组件。

只有页面组件被回退替换;应用程序继续在 header 元素中显示菜单,如图 11.7 的顶部所示。该图还显示了回退 UI,应用程序在子组件中发生错误时显示的消息“出了点问题”。

但那个 UI 从哪里来?我们在错误边界类组件中必须实现哪些生命周期方法?(一如既往地)一个好的起点是查看 React 文档。

11.3.1 检查 React 文档中的错误边界示例

为了捕获子组件渲染时抛出的任何错误,我们需要一个实现了生命周期方法 getDerivedStateFromErrorcomponentDidCatch 的类组件。以下列表展示了来自 reactjs.org 的 React 文档中实现这些方法的错误边界组件。它显示了图 11.7 中显示的硬编码的回退用户界面。

React 文档:reactjs.org/docs/error-boundaries.html

列表 11.13 reactjs.org 上的 ErrorBoundary 组件

class ErrorBoundary extends React.Component {                          ❶
  constructor(props) {
    super(props);
    this.state = { hasError: false };                                  ❷
  }

  static getDerivedStateFromError(error) {                             ❸
    // Update state so the next render will show the fallback UI.
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {                                ❹  
    // You can also log the error to an error reporting service
    logErrorToMyService(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // You can render any custom fallback UI
      return <h1>Something went wrong.</h1>;                           ❺
    }
    return this.props.children;                                        ❻
  }
}

❶ 将 React 的 Component 类扩展以创建一个错误边界。

❷ 在状态中包含一个 hasError 属性。

❸ 当捕获到错误时返回新状态。

❹ 如果捕获到错误,则记录错误。

❺ 如果有错误,则渲染回退 UI。

❻ 如果没有错误,则渲染包装组件。

组件使用hasError属性管理状态,该属性标记组件是否捕获了错误。componentDidCatch方法还将任何错误信息记录到外部日志服务。最后,render方法返回包裹的组件,或者如果getDerivedStateFromError方法已将错误标志设置为true,则返回硬编码的回退 UI:

<h1>Something went wrong.</h1>

但列表 11.13 只是一个示例错误边界。让我们自己创建一个。

11.3.2 创建我们自己的错误边界

React 文档中的错误边界只是一个可能性。我们可能想要为我们的应用定制更多。例如,图 11.8 中的回退 UI 包括一个指示用户“尝试重新加载页面”的说明。

图片

图 11.8 我们的ErrorBoundary组件允许我们在发生错误时指定自定义 UI 作为回退。

而不是仅仅切换一个硬编码的消息,让我们尝试实现一个错误边界,这样我们就可以在每次使用时指定不同的回退。下面的列表显示了一个这样的组件。我们不会记录任何错误,因此省略了componentDidCatch方法,并且组件的使用者可以在fallback属性中指定 UI。

分支:1102-error-boundary,文件:/src/components/UI/ErrorBoundary.js

列表 11.14 一个简单、可定制的ErrorBoundary组件

import {Component} from "react";

export default class ErrorBoundary extends Component {
  constructor (props) {
    super(props);
    this.state = {hasError: false};
  }

  static getDerivedStateFromError () {
    return {hasError: true};
  }

  render() {
    const {
      children,                                          ❶
      fallback = <h1>Something went wrong.</h1>          ❷
    } = this.props;

    return this.state.hasError ? fallback : children;    ❸
  }
}

❶ 从 props 中获取包裹的组件。

❷ 从 props 中获取回退,或使用默认回退。

❸ 渲染回退或包裹的组件。

我们将立即在预订应用中使用新的错误边界,作为对任何三个页面抛出的错误的通用捕获。在下面的列表中,App组件现在使用ErrorBoundary包裹了SuspenseRoutes组件。

分支:1102-error-boundary,文件:/src/components/App.js

列表 11.15 带有错误边界的App

// other imports, including Fragment

import ErrorBoundary from "./UI/ErrorBoundary";                            ❶

const BookablesPage = lazy(() => import("./Bookables/BookablesPage"));
const BookingsPage = lazy(() => import("./Bookings/BookingsPage"));
const UsersPage = lazy(() => import("./Users/UsersPage"));

const queryClient = new QueryClient();

export default function App () {
  return (
    <QueryClientProvider client={queryClient}>
      <UserProvider>
        <Router>
          <div className="App">
            <header>{/* unchanged */}</header>

            <ErrorBoundary                                                 ❷
              fallback={                                                   ❸
                <Fragment>
                  <h1>Something went wrong!</h1>
                  <p>Try reloading the page.</p>                           ❹
                </Fragment>
              }
            >
              <Suspense fallback={<PageSpinner/>}>
                <Routes>
                  <Route path="/bookings" element={<BookingsPage/>}/>
                  <Route path="/bookables/*" element={<BookablesPage/>}/>
                  <Route path="/users" element={<UsersPage/>}/>
                </Routes>
              </Suspense>
            </ErrorBoundary>                                               ❺

          </div>
        </Router>
      </UserProvider>
    </QueryClientProvider>
  );
}

❶ 导入我们的错误边界。

❷ 将主要路由包裹在错误边界中。

❸ 提供一些回退 UI。

❹ 可能包括一些操作建议。

❺ 将主要路由包裹在错误边界中。

我们将应用的主要路由包裹在一个错误边界中。为了测试它,让我们从一个子组件抛出一个错误。在BookableForm组件中,在其返回 UI 之前,添加以下行:

throw new Error("Noooo!");

现在,重新加载应用,导航到可预订页面,然后在可预订列表下点击“新建”按钮或点击可预订详情页顶部的右上角的“编辑”按钮。关闭图 11.9 中显示的错误覆盖层;这是由 Create React App 添加的,在生产环境中不会出现。你应该看到图 11.8 中的回退 UI。

图片

图 11.9 在开发模式下,Create React App 的服务器使用错误消息覆盖页面。按 Esc 键或点击 X 以关闭覆盖层,显示错误边界的回退 UI。

如果应用的单页包含多个组件,并且一个组件的失败不会影响其他组件——用户可以继续使用该页面——那么考虑将该组件包裹在其自己的错误边界中。当错误边界可以安全地隔离不稳定的组件时,没有必要阻止用户在其他地方使用功能。不过,如果能稳定这些不稳定的组件就更好了,我们可以进一步自定义错误边界组件,使其更容易从错误中恢复。

11.3.3 从错误中恢复

要求用户刷新页面是处理组件树下方未捕获的错误可能有效的一种方法。但是,特别是对于主应用中特定小部件周围的错误边界,如聊天窗口或股票行情,或社交媒体流,您可能希望给用户提供一个按钮来尝试重置或重新加载应用中的特定组件。在第十二章中,我们将使用从 npm 下载的预构建错误边界包 react-error-boundary。它提供了方便的额外功能,使其错误边界更加灵活和可重用。在 GitHub 上查看它:github.com/bvaughn/react-error-boundary

第十二章继续本章的主题,即当 React 等待最终 UI 准备就绪时,给它一些可以渲染的内容。我们不会等待组件加载,而是等待数据或图像。加入我,我们将探索实验性的 React 功能。

摘要

  • 在 JavaScript 文件的顶部将依赖项作为静态导入。像 webpack 这样的打包器可以执行摇树优化来创建一个包,一个包含应用使用所有代码的单个文件。

  • 为了仅在用户操作或其他事件响应时加载 JavaScript 依赖项,使用 import 函数动态加载模块:

    function handleClick() {
      import("./helloModule")
        .then(module => {/* use module */});
    }
    
  • 动态导入返回一个返回模块的承诺。在模块对象上访问默认和命名导出:

    function handleClick() {
      import("./helloModule")
        .then(module => {
          module.default("messagePara", "Hello World!");
          module.sayHi("hiPara");
        });
    }
    
  • 使用 React.lazy 仅在组件首次渲染时加载组件。将 lazy 传递一个返回动态导入承诺的函数。承诺必须解析到一个模块,其默认属性是一个组件:

    const LazyComponent = React.lazy(() => import("./MyComponent"));
    
  • 使用 Suspense 组件来告诉 React 在等待懒加载组件加载时应该渲染什么。(Suspense 组件捕获尚未加载的组件抛出的挂起承诺。)

    <Suspense fallback={<p>Just one moment...</p>}>
      { /* UI that could contain a lazy component */ }
    </Suspense>
    
  • 使用错误边界组件来告诉 React 在渲染子组件时发生错误时应该渲染什么。错误边界是实现了 getDerivedStateFromErrorcomponentDidCatch 生命周期方法之一的类组件:

    <ErrorBoundary>
      { /* App or subtree */ }
    </ErrorBoundary>
    
  • 自定义错误边界以提供定制的回退 UI 和错误恢复策略。

12 将数据获取与 Suspense 集成

本章节涵盖了

  • 包装承诺以访问其状态

  • 在获取数据时抛出承诺和错误

  • 使用Suspense组件在加载数据和图像时指定回退 UI

  • 尽早获取数据和资源

  • 使用错误边界恢复错误

React 团队有一个使命,即维护和开发一个产品,使开发者尽可能容易地创建出色的用户体验。除了编写全面的文档、提供直观且富有教育意义的开发者工具、编写描述性且易于操作的错误消息以及确保增量升级路径外,该团队希望 React 能够使提供快速加载、响应和可扩展的应用程序变得容易。并发模式和 Suspense 提供了改善用户体验的方法,协调代码和资源的加载,实现更简单、有意的加载状态,并优先处理允许用户继续工作或玩耍的更新。

但 React 团队不希望并发模式的挂钩成为开发者的负担;他们希望尽可能多的好处是自动的,任何新的 API 都是直观的,并与现有思维模式保持一致。因此,并发模式仍然被标记为实验性,因为 API 正在测试和调整。希望我们不会悬而未决太久![不!我们同意,不要开关于悬而未决的玩笑——编辑]

在第十三章中,我们将深入探讨并发模式的哲学和承诺。本章是第十一章中稳定的生产使用懒加载组件和 Suspense 与第十三章中延迟渲染、过渡和SuspenseList组件的试验性 API 之间的桥梁。在这里,我们使用关于抛出承诺的想法来考虑使用 Suspense 进行数据获取可能的样子。代码示例不是用于生产,但可以提供关于库作者为了与并发模式和 Suspense 良好协作可能需要考虑的内容的见解。

12.1 使用 Suspense 进行数据获取

在第十一章中,我们看到了当Suspense组件捕获抛出的承诺时,它们会显示回退 UI。在那里,我们正在懒加载组件,React 通过lazy函数和动态导入协调承诺的抛出:

const LazyCalendar = lazy(() => import("./Calendar"));

当尝试渲染懒加载组件时,React 首先检查组件的状态;如果动态导入的组件已加载,React 将直接渲染它,但如果它是挂起的,React 将抛出动态导入的承诺。如果承诺被拒绝,我们需要错误边界来捕获错误并显示适当的回退 UI:

<ErrorBoundary>
  <Suspense fallback="Loading...">
    <LazyCalendar/>
  </Suspense>
</ErrorBoundary>

当到达LazyCalendar组件时,React 可以使用已加载的组件、抛出一个现有的挂起承诺,或者开始动态导入并抛出一个新的挂起承诺。

我们希望对于从服务器加载数据的组件也有类似的功能。比如说我们有一个Message组件,它加载并显示一个消息。在图 12.1 中,Message组件已经加载了消息“Hello Data!”并正在显示它。

图 12.1 Message组件加载一个消息并显示它。

当数据正在加载时,我们希望使用Suspense组件显示一个回退,就像图 12.2 中的那样,上面写着“正在加载消息...”。

图 12.2 数据正在加载时,Suspense组件显示一个回退消息。

如果出现错误,我们希望ErrorBoundary组件显示一个回退,就像图 12.3 中的那样,上面写着“哎呀!”

图 12.3 如果有错误,ErrorBoundary组件显示一个错误消息。

符合我们预期的 JSX 将类似于以下内容:

<ErrorBoundary fallback="Oops!">
  <Suspense fallback="Loading message...">
    <Message/>
  </Suspense>
</ErrorBoundary>

但是,尽管我们有lazy函数用于懒加载组件,但没有稳定、内置的机制用于正在加载数据的组件。(有一个react-cache包,但它处于实验性且不稳定的状态。)

我们可能可以想出一个方法来加载数据,根据需要抛出承诺或错误。这样做,我们将对数据获取库需要实现的一些步骤有一些了解,但这只是一个了解,绝对不是对生产代码的建议。(一旦并发模式和 React 的数据获取策略确定下来,并且实战测试击败了现实世界的问题和边缘情况,可以查看像 Relay、Apollo 和 React Query 这样的库,它们提供高效、灵活、完全集成的数据获取。)看看以下关于我们的Message组件的列表。它包括一个假设的getMessageOrThrow函数。

列表 12.1 Message组件调用一个函数来检索数据

function Message () {
  const data = getMessageOrThrow();                     ❶
  return <p className="message">{data.message}</p>;     ❷
}

❶ 调用一个返回数据或抛出承诺或错误的函数。

❷ 在 UI 中包含数据。

我们希望getMessageOrThrow函数在数据可用时返回数据。如果有一个尚未解决为我们数据的承诺,该函数应该抛出它。如果承诺已被拒绝,该函数应该抛出一个错误。

问题在于,如果有一个我们的数据承诺(例如浏览器 fetch API 返回的承诺),我们没有检查其状态的方法。它是挂起的吗?它已经解决了吗?它已经被拒绝了?我们需要在代码中包装承诺,以报告其状态。

12.1.1 将承诺升级以包含其状态

要与SuspenseErrorBoundary组件一起工作,我们需要使用承诺的状态来决定我们的行动。表 12.1 将状态与所需行动相匹配。

表 12.1 每个承诺状态的行动

承诺状态 行动
进行中 抛出承诺。
已解决 返回已解决的价值——我们的数据。
拒绝 抛出拒绝错误。

承诺不会报告自己的状态,因此我们想要某种checkStatus函数,该函数返回承诺的当前状态以及可用的已解析值或拒绝错误。类似于以下内容:

const {promise, status, data, error} = checkStatus();

或者,因为我们永远不会同时得到dataerror,所以类似于以下内容:

const {promise, status, result} = checkStatus();

我们将能够使用条件语句如if(status === "pending")来决定是否抛出承诺或错误或返回值。

以下列表显示了一个getStatusChecker函数,它接受一个承诺并返回一个函数,该函数让我们能够访问承诺的状态。

列表 12.2 获取访问承诺状态的函数

export function getStatusChecker (promiseIn) {     ❶
  let status = "pending";                          ❷
  let result;                                      ❸

  const promise = promiseIn
    .then((response) => {                          ❹
      status = "success";
      result = response;
    })
    .catch((error) => {                            ❺
      status = "error";
      result = error;
    });

  return () => ({promise, status, result});        ❻
}

❶ 传递我们想要跟踪状态的承诺。

❷ 设置一个变量来保存承诺的状态。

❸ 为已解析值或拒绝错误设置一个变量。

❹ 在成功时,将已解析值赋值给结果。

❺ 在出错时,将拒绝错误赋值给结果。

❻ 返回一个函数来访问当前状态和结果。

使用getStatusChecker函数,我们可以获取我们需要来跟踪承诺状态并相应反应的checkStatus函数。例如,如果我们有一个fetchMessage函数返回一个承诺并加载消息数据,我们可以得到一个状态跟踪函数如下:

const checkStatus = getStatusChecker(fetchMessage());

好的,那太好了;我们有一个承诺状态跟踪函数。为了与 Suspense 集成,我们需要我们的数据获取函数使用该承诺状态来返回数据、抛出承诺或抛出错误。

12.1.2 使用承诺状态与 Suspense 集成

这里是我们的Message组件再次出现:

function Message () {
  const data = getMessageOrThrow();
  return <p className="message">{data.message}</p>;
}

我们希望能够调用一个数据获取函数——在这个例子中,是getMessageOrThrow——该函数能够通过适当地抛出承诺或错误或在其加载后返回我们的数据来自动与 Suspense 集成。以下列表显示了makeThrower函数,它接受一个承诺并返回这样一个函数,该函数使用承诺的状态来适当地行动。

列表 12.3 返回一个根据需要抛出异常的数据获取函数

export function makeThrower (promiseIn) {                ❶
  const checkStatus = getStatusChecker(promiseIn);       ❷

  return function () {                                   ❸
    const {promise, status, result} = checkStatus();     ❹

    if (status === "pending") throw promise;             ❺
    if (status === "error") throw result;                ❺
    return result;                                       ❺
  };
}

❶ 将数据获取的承诺传递进来。

❷ 获取一个用于跟踪承诺状态的功能。

❸ 返回一个可以抛出异常的函数。

❹ 每次函数被调用时获取最新的状态。

❺ 使用状态来抛出或返回。

对于Message组件,我们将使用makeThrowerfetchMessage函数返回的承诺转换成一个可以抛出承诺或错误的 数据获取函数:

const getMessageOrThrow = makeThrower(fetchMessage());

但我们何时开始获取?在哪里放置那行代码?

12.1.3 尽早获取数据

我们不必等到组件渲染完成才开始加载数据。我们可以在组件外部启动获取,使用 fetch 承诺构建一个可以抛出的数据访问函数,组件可以使用它。列表 12.4 展示了 Message 组件的完整 App 示例。浏览器在加载时执行代码,开始数据获取。一旦 React 渲染 App 和嵌套的 MessageMessage 将调用 getMessageOrThrow,该函数访问现有的承诺。

实时演示: t1lsy.csb.app, 代码: codesandbox.io/s/suspensefordata-t1lsy

列表 12.4 使用 Message 组件

import React, {Suspense} from "react";
import {ErrorBoundary} from "react-error-boundary";
import fetchMessage from "./api";
import {makeThrower} from "./utils";
import "./styles.css";

function ErrorFallback ({error}) {
  return <p className="error">{error}</p>;
}

const getMessageOrThrow = makeThrower(fetchMessage());                ❶

function Message () {
  const data = getMessageOrThrow();                                   ❷
  return <p className="message">{data.message}</p>;                   ❸
}

export default function App () {
  return (
    <div className="App">
      <ErrorBoundary FallbackComponent={ErrorFallback}>               ❹
        <Suspense                                                     ❺
          fallback={<p className="loading">Loading message...</p>}
        >
          <Message />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

❶ 尽快开始获取。

❷ 访问数据或抛出错误或承诺。

❸ 如果有数据,则使用数据。

❹ 捕获抛出的错误。

❺ 捕获抛出的承诺。

我们的错误边界是第十一章中提到的 react-error-boundary 包中的 ErrorBoundary 组件。我们通过设置 FallbackComponent 属性来指定其回退。fetchMessage 函数接受两个参数以帮助您测试 SuspenseErrorBoundary 的回退:一个以毫秒为单位的 delay 和一个表示随机引发错误的 canError 布尔值。如果您希望请求持续三秒并有时失败,则将调用更改为以下内容:

const getMessageOrThrow = makeThrower(fetchMessage(3000, true));

在列表 12.4 中,Message 组件可以调用 getMessageOrThrow,因为它处于相同的范围。这并不总是情况,因此您可能希望将数据访问函数作为属性传递给 Message。您还可能希望根据用户操作加载新数据。让我们看看如何使用属性和事件使数据获取更加灵活。

12.1.4 获取新数据

假设我们想要升级我们的 Message 组件以包括一个“下一步”按钮,如图 12.4 所示。

图 12.4 Message 组件现在显示了一个“下一步”按钮。

点击“下一步”按钮将加载并显示一条新消息。在新消息加载期间,Message挂起getMessageOrThrow 函数或其等效函数将抛出其承诺),Suspense 组件将再次显示图 12.2 中的“加载消息...”回退 UI。一旦承诺解决,Message 将显示新加载的消息,“Bonjour”,如图 12.5 所示。

图 12.5 点击“下一步”按钮加载一条新消息。

对于我们加载的每一条新消息,我们需要一个新的承诺和一个可以抛出错误的新数据获取函数。在列表 12.6 中,我们将更新 Message 组件以接受数据获取函数作为属性。首先,列表 12.5 展示了 App 组件在状态中管理当前的数据获取函数并将其传递给 Message

实时演示: xue0l.csb.app, 代码: codesandbox.io/s/suspensefordata2-xue0l

列表 12.5 App 组件持有当前的 getMessage 函数

const getFirstMessage = makeThrower(fetchMessage());                      ❶

export default function App () {
  const [getMessage, setGetMessage] = useState(() => getFirstMessage);    ❷

  function next () {
    const nextPromise = fetchNextMessage();                               ❸
    const getNextMessage = makeThrower(nextPromise);                      ❹
    setGetMessage(() => getNextMessage);                                  ❺
  }

  return (
    <div className="App">
      <ErrorBoundary FallbackComponent={ErrorFallback}>
        <Suspense
          fallback={<p className="loading">Loading message...</p>}
        >
          <Message
            getMessage={getMessage}                                       ❻
            next={next}                                                   ❼
          />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

❶ 立即获取第一条消息。

❷ 保持当前的数据获取函数在状态中。

❸ 开始获取下一条消息。

❹ 获取一个可以抛出承诺或错误的 data-fetching 函数。

❺ 更新状态以保存最新的数据获取函数。

❻ 将当前的数据获取函数传递给 Message 组件。

❻ 给 Message 组件一个请求下一条消息的方法。

我们向 useState 传递一个初始化函数,该函数返回第一条消息的数据获取函数 getFirstMessage。注意,我们不是调用 getFirstMessage;我们返回它,将其设置为初始状态。

App 还提供了一个 next 函数,用于加载下一条消息并将新的数据获取函数放入状态。next 函数的第一件事是开始获取下一条消息:

const nextPromise = fetchNextMessage();

我们在 CodeSandbox 上的 API 包含 fetchNextMessage 函数,该函数请求下一条消息并返回一个承诺。为了通过抛出挂起的承诺与 Suspense 集成,next 需要获取一个用于数据获取承诺的抛出承诺的函数:

const getNextMessage = makeThrower(nextPromise);

最后一步是更新状态;它持有当前的抛出承诺的函数。useState 和它返回的更新函数(在这种情况下为 setGetMessage),都接受一个函数作为参数。如果你向它们传递一个函数,它们会调用 useState 来获取其初始状态,并调用 setGetMessage 来获取新状态。因为我们试图存储的状态值本身就是一个函数,所以我们不能直接将这些状态设置函数传递给它。我们不这样做:

useState(getFirstMessage); // NOT THIS

我们不这样做:

setGetMessage(getNextMessage);  // NOT THIS

相反,我们传递 useStatesetGetMessage 函数,这些函数返回我们想要设置为状态的函数:

useState(() => getFirstMessage);  // Return the initial state, a function

我们使用这个:

setGetMessage(() => getNextMessage);  // Return the new state, a function

我们不想在这里调用 getNextMessage;我们只想将其设置为新的状态值。设置状态值会导致 App 重新渲染,将最新的数据获取函数作为 getMessage prop 传递给 Message

更新的 Message 组件如下所示。它显示了组件接受 getMessagenext 作为 props,并在 UI 中包含 Next 按钮。

实时: xue0l.csb.app代码: codesandbox.io/s/suspensefordata2-xue0l

列表 12.6 为数据获取传递 Message props

function Message ({getMessage, next}) {            ❶
  const data = getMessage();
  return (
    <>
      <p className="message">{data.message}</p>
      <button onClick={next}>Next</button>         ❷
    </>
  );
}

❶ 接受数据获取函数和按钮处理程序作为 props。

❷ 在 UI 中包含一个 Next 按钮。

Message 调用 getMessage,它返回新的消息数据或抛出异常。当用户点击 Next 按钮时,Message 调用 next,立即开始获取下一条消息。并且立即重新渲染。我们正在使用 render-as-you-fetch 方法,为 React 指定 SuspenseErrorBoundary 作为当组件抛出承诺或错误时的回退。

说到错误,我们的 App 组件正在使用来自 react-error-boundary 包的 ErrorBoundary 组件。它还有一些额外的技巧,包括简单的错误恢复。让我们施展下一个咒语。

12.1.5 从错误中恢复

图 12.6 显示了我们想要的结果;当发生错误时,我们希望给用户提供一个可点击的重试按钮,以重置错误状态并再次尝试渲染应用程序。

图 12.6 ErrorBoundary 组件 UI 现在包括一个重试按钮,用于重置错误边界并加载下一条消息。

在列表 12.5 中,我们将 ErrorFallback 组件分配为 ErrorBoundaryFallbackComponent 属性:

<ErrorBoundary FallbackComponent={ErrorFallback}>
  {/* app UI */}
</ErrorBoundary>

以下列表显示了我们 ErrorFallback 组件的新版本。当 ErrorBoundary 捕获错误并渲染回退时,它会自动将 resetErrorBoundary 函数传递给 ErrorFallback

实时: 7i89e.csb.app/代码: codesandbox.io/s/errorrecovery-7i89e

列表 12.7 向 ErrorFallback 添加按钮

function ErrorFallback ({error, resetErrorBoundary}) {            ❶
  return (
    <>
      <p className="error">{error}</p>
      <button onClick={resetErrorBoundary}>Try Again</button>     ❷
    </>
  );
}

❶ 从 ErrorBoundary 作为属性接收 resetErrorBoundary 函数。

❷ 包含一个调用 resetErrorBoundary 的按钮。

ErrorFallback UI 现在包括一个重试按钮,该按钮调用 resetErrorBoundary 函数来移除错误状态并渲染错误边界的子组件,而不是错误回退 UI。除了在错误边界上重置错误状态外,resetErrorBoundary 还会调用我们分配给错误边界的 onReset 属性的任何重置函数。在下面的列表中,我们告诉 ErrorBoundary 在重置边界时调用我们的 next 函数并加载下一条消息。

实时: 7i89e.csb.app/代码: codesandbox.io/s/errorrecovery-7i89e

列表 12.8 向 ErrorBoundary 添加 onReset 属性

export default function App () {
  const [getMessage, setGetMessage] = useState(() => getFirstMessage);
  function next () {/* unchanged */}
  return (
    <div className="App">
      <ErrorBoundary
        FallbackComponent={ErrorFallback}
        onReset={next}                         ❶
      >
        <Suspense
          fallback={<p className="loading">Loading message...</p>}
        >
          <Message getMessage={getMessage} next={next} />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

❶ 包含一个 onReset 函数,如果重置,ErrorBoundary 将会调用它。

错误边界现在尝试做一些事情来尝试消除应用程序的错误状态:它尝试加载下一条消息。以下是当 Message 组件在尝试加载消息时抛出错误时它所经历的步骤:

  1. Message 组件抛出错误。

  2. ErrorBoundary 捕获错误并渲染 ErrorFallback 组件,包括重试按钮。

  3. 用户点击重试按钮。

  4. 按钮调用 resetErrorBoundary,从边界中移除错误状态。

  5. 错误边界重新渲染其子组件并调用 next 来加载下一条消息。

查看 GitHub 上的 react-error-boundary 仓库以了解其其他超级有用的错误相关技巧:github.com/bvaughn/react-error-boundary

12.1.6 检查 React 文档

在我们简要探索将数据获取与 Suspense 集成的一种实验方法时,我们创建了两个关键函数:

  • getStatusChecker—提供对承诺状态的窗口

  • makeThrower—将一个承诺升级为返回数据或抛出错误或承诺的承诺

我们使用 makeThrower 创建了 getMessageOrThrow 等函数,这些函数被 Message 组件用来获取最新消息、抛出错误或抛出承诺(挂起)。我们将数据获取函数存储在状态中并通过属性传递给子组件。

React 文档还有一个实验性的、仅供信息参考的、请务必小心——真的要小心——示例,展示了如何将我们自己的承诺与 Suspense 集成,如下所示,这个示例完成了我们的 getStatusCheckermakeThrower 函数在 wrapPromise 函数中的工作。在文档中阅读代码背后的理由:mng.bz/JDBK

代码:codesandbox.io/s/frosty-hermann-bztrp?file=/src/fakeApi.js

列表 12.9 来自 React 文档示例的 wrapPromise 函数

// Suspense integrations like Relay implement             ❶
// a contract like this to integrate with React.
// Real implementations can be significantly more complex.
// Don't copy-paste this into your project!
function wrapPromise(promise) {
  let status = "pending";
  let result;
  let suspender = promise.then(                           ❷
    r => {
      status = "success";
      result = r;
    },
    e => {
      status = "error";
      result = e;
    }
  );
  return {
 read() {                                              ❸
      if (status === "pending") {
        throw suspender;
      } else if (status === "error") {
        throw result;
      } else if (status === "success") {
        return result;
      }
    }
  };
}

❶ 这段代码仅用于兴趣,而不是用于生产用途。

❷ 代码将包装的承诺命名为 suspender。

❸ 该函数返回一个具有读取方法的对象。

wrapPromise 函数不是直接返回一个函数;它返回一个具有 read 方法的对象。因此,而不是将一个 函数 分配给一个局部变量,getMessage,如下所示

const getMessage = makeThrower(fetchMessage());    ❶

function Message () {
  const data = getMessage();                       ❷
  // return UI that shows data
}

❶ 将数据获取函数分配给 getMessage

❷ 调用 getMessage 来获取数据或抛出。

我们将一个 对象 分配给一个局部变量,messageResource,如下所示:

const messageResource = wrapPromise(fetchMessage());    ❶

function Message () {
  const data = messageResource.read();                  ❷

  // return UI that shows data
}

❶ 将具有数据获取方法的对象分配给 messageResource

❷ 调用读取方法来获取数据或抛出。

哪种方法更好?嗯,我敢打赌 React 团队对其示例进行了深思熟虑,并考虑了许多更多场景,在这些场景中,具有 read 方法的 资源 概念被发现比直接存储、传递和调用裸数据获取函数更容易思考和操作。话虽如此,我认为我们对将数据获取与 Suspense 集成所涉及的概念和程序进行的逐步探索是有用的。

最终,这些都仍然是理论性和实验性的,并且很可能会有所改变。除非你自己是数据获取库的作者,否则你会发现细节将由你使用的库来处理。我们一直在使用 React Query 进行我们的数据工作;它是否与 Suspense 集成?

12.2 使用 React Query 和错误边界

React Query 提供了一个实验性的配置选项来为查询启用 Suspense。而不是返回状态和错误信息,查询将抛出承诺和错误。你可以在 React Query 文档中了解更多关于实验性的 Suspense 集成的信息 (mng.bz/w9A2)。

对于预订应用,我们一直在使用 useQuery 返回的 status 值来有条件地渲染加载指示器和错误消息。我们所有的数据加载组件都有如下代码:

  const {data, status, error} = useQuery(     ❶
    "key",
    () => getData(url)
  );

  if (status === "error") {                   ❷
    return <p>{error.message}</p>
  }

  if (status === "loading") {                 ❷
    return <PageSpinner/>
  }

  return ({/* UI with data */});

❶ 当加载数据时,将状态值分配给一个局部变量。

❷ 检查状态并返回适当的 UI。

但我们已经看到了 SuspenseErrorBoundary 组件如何让我们将加载和错误 UI 与单个组件解耦。预订应用已经设置了页面级别的 SuspenseErrorBoundary 组件,所以让我们将查询切换到使用现有的组件。

分支:1201-suspense-data,文件:/src/components/Bookables/BookablesView.js

列表 12.10 带有 Suspense 集成的 BookablesView 组件

import {Link, useParams} from "react-router-dom";
import {FaPlus} from "react-icons/fa";

import {useQuery} from "react-query";
import getData from "../../utils/api";

import BookablesList from "./BookablesList";
import BookableDetails from "./BookableDetails";
// no need to import PageSpinner

export default function BookablesView () {
  const {data: bookables = []} = useQuery(
    "bookables",
    () => getData("http://localhost:3001/bookables"),
    {
      suspense: true                          ❶
    }
  );

  const {id} = useParams();
  const bookable = bookables.find(
    b => b.id === parseInt(id, 10)
  ) || bookables[0];

 // no status checks or loading/error UI     ❷

  return ({/* unchanged UI */});
}

❶ 传递一个将 suspense 设置为 true 的配置对象。

❷ 删除加载和错误状态的检查代码。

更新的 BookablesView 组件在加载可预订数据时将配置选项传递给 useQuery

const {data: bookables = []} = useQuery(
  "bookables",
  () => getData("http://localhost:3001/bookables"),
  {
    suspense: true 
  }
);

该配置选项告诉 useQuery 在加载其初始数据时挂起(抛出一个承诺),如果出现问题则抛出错误。

挑战 12.1

更新 BookingsPageUsersList 组件,在加载数据时使用 Suspense。删除组件内嵌入的任何不必要的加载和错误状态 UI。当前分支包括这些更改:1201-suspense-data。

12.3 使用 Suspense 加载图像

Suspense 与懒加载组件配合得很好,并且至少在试验性上可以与加载数据时自然出现的承诺集成。那么其他资源,比如脚本和图像,怎么办呢?关键是承诺:如果我们能将我们的请求包裹在承诺中,我们就可以(至少在实验上)与 Suspense 和错误边界一起工作,以提供后备 UI。让我们看看一个将图像加载与 Suspense 集成的场景。

您的老板希望您使用户页面更加有用,希望您为每个用户添加头像图片,稍后,再添加每个用户的预订和任务详情。我们将在下一章中介绍预订和任务。这里,我们旨在添加一个像图 12.7 中所示的日本城堡的头像图片。

图 12.7 UserDetails 组件为每个用户包含一个头像图片。

GitHub 仓库的 1202-user-avatar 分支包括用户列表和所选用户详情的独立组件,分别是 UsersListUserDetails,在 UsersPage 组件中管理所选用户。仓库在 /public/img 文件夹中也有头像图片。UsersPage 现在只传递所选用户的 ID 给 UserDetails,然后 UserDetails 组件加载用户的详细信息,并将头像渲染为标准的 img 元素:

<div className="user-avatar">
  <img src={`http://localhost:3001/img/${user.img}`} alt={user.name}/>
</div>

不幸的是,在慢速网络速度和大型头像图片文件的情况下,图像可能需要一段时间才能加载,导致图 12.8 中显示的糟糕用户体验,其中图像(一朵花上的蝴蝶)逐渐出现。您可以使用浏览器开发者工具来限制网络速度。

图 12.8 当切换用户时,头像图片可能需要一段时间才能加载,可能会导致糟糕的用户体验。这里,图像只加载了一半。

在本节中,我们探讨了几种提高用户界面中慢加载图像用户体验的方法:

  • 使用 React Query 和 Suspense 提供图像加载回退

  • 使用 React Query 预加载图像和数据

两种方法结合使用,有助于为用户提供一个可预测的用户界面,其中,希望慢加载的资产不会引起注意,从而降低使用应用时的体验。

12.3.1 使用 React Query 和 Suspense 提供图像加载回退

我们希望在图像加载时显示某种回退,可能是一个具有小文件大小的共享头像占位符,就像图 12.9 中显示的头像轮廓图像。

图片

图 12.9 当头像图像加载时,我们可以显示一个具有小文件大小且可以提前加载的占位符图像。

要与 Suspense 集成,我们需要一个图像加载过程,直到图像准备好使用时才抛出 promise。我们手动创建这个 promise,类似于围绕 DOM HTMLImageElement Image 构造函数这样做:

const imagePromise = new Promise((resolve) => {
  const img = new Image();                        ❶
  img.onload = () => resolve(img);                ❷
  img.src = "path/to/image/image.png"             ❸
});

❶ 创建一个新的图像对象。

❷ 当图像加载完成时解析 promise。

❸ 通过指定其源来开始加载图像。

我们还需要一个图像加载函数,在挂起时抛出 promise:

const getImageOrThrow = makeThrower(imagePromise);

最后,一个调用函数并渲染图像的 React 组件,在图像加载后显示:

function Img () {
  const imgObject = getImageOrThrow();                ❶

  return <img src={imgObject.src} alt="avatar" />     ❷
}

❶ 获取图像对象或抛出一个 promise。

❷ 一旦图像可用,渲染一个标准的 img 元素。

但我们不想在每次渲染时不断重新加载图像,因此我们需要某种类型的缓存。嗯,我们已经在 React Query 中有一个这样的内置缓存。所以,我们不必构建自己的缓存和抛出自己的 promise,而是连接到 React Query 的 Suspense 集成(不要忘记它是实验性的)。以下列表显示了一个抛出挂起 promise 直到图像加载的 Img 组件。

分支:1203-suspense-images,文件:/src/components/Users/Avatar.js

列表 12.11 使用 React Query 的 Img 组件

function Img ({src, alt, ...props}) {
  const {data: imgObject} = useQuery(                        ❶
    src,                                                     ❷
    () => new Promise((resolve) => {                         ❸
      const img = new Image();
      img.onload = () => resolve(img);
      img.src = src;
    }),
    {suspense: true}                                         ❹
  );

  return <img src={imgObject.src} alt={alt} {...props}/>     ❺
}

❶ 使用 React Query 进行缓存、去重和抛出。

❷ 使用图像 src 作为查询键。

❸ 向 useQuery 传递一个创建图像加载 promise 的函数。

❹ 抛出挂起的 promise 和错误。

❺ 在图像加载完成后返回一个标准的 img 元素。

使用具有相同源的多 Img 组件不会尝试多次加载图像;React Query 将返回缓存的 Image 对象。(图像本身将由浏览器缓存。)

在预订应用中,我们希望有一个使用 Suspense 来显示回退的 Avatar 组件,在图像加载时显示。以下列表使用 Img 组件以及一个 Suspense 组件来实现我们的目标。

分支:1203-suspense-images,文件:/src/components/Users/Avatar.js

列表 12.12 使用 ImgSuspenseAvatar 组件

import {Suspense} from "react";
import {useQuery} from "react-query";

export default function Avatar ({src, alt, fallbackSrc, ...props}) {   ❶
  return (
    <div className="user-avatar">
      <Suspense
        fallback={<img src={fallbackSrc} alt="Fallback Avatar"/>}      ❷
      >
        <Img src={src} alt={alt} {...props}/>                          ❸
      </Suspense>
    </div>
  );
}

❶ 指定 fallbackSrc 和 src 属性。

❷ 使用 fallbackSrc 属性显示一个作为 Suspense 回退的图像。

❸ 使用 Img 组件与 Suspense 组件集成。

UserDetails 组件现在可以使用 Avatar 来显示备用图像,直到所需的图像加载完成,如下面的列表所示。

分支:1203-suspense-images,文件:/src/components/Users/UserDetails.js

列表 12.13 在 UserDetails 中使用 Avatar 组件

import {useQuery} from "react-query";
import getData from '../../utils/api';
import Avatar from "./Avatar";

export default function UserDetails ({userID}) {              ❶
  const {data: user} = useQuery(
    ["user", userID],
    () => getData(`http://localhost:3001/users/${userID}`),   ❷
    {suspense: true}
  );
  return (
    <div className="item user">
      <div className="item-header">
        <h2>{user.name}</h2>
      </div>
      <Avatar                                                 ❸
 src={`http://localhost:3001/img/${user.img}`}
 fallbackSrc="http://localhost:3001/img/avatar.gif"
 alt={user.name}
 />
      <div className="user-details">
        <h3>{user.title}</h3>
        <p>{user.notes}</p>
      </div>
    </div>
  )
}

❶ 传入要显示的用户 ID。

❷ 加载指定用户的图像数据。

❸ 显示一个头像,指定图像和备用源。

我们甚至可以通过在页面的 head 元素中添加一个带有 rel="prefetch"link 元素,或者在父组件中强制预加载,来预加载备用图像。现在让我们看看如何预加载数据和图像。

12.3.2 使用 React Query 预取图像和数据

目前,UserDetails 组件在用户数据加载完成之前不会渲染 Avatar。我们在请求所需的图像之前等待用户数据,从而创建了一个 瀑布效应,如图 12.10 所示。

图 12.10 水瀑布面板显示,用户 2 的图像(user2.png)在用户 2 的数据加载完成之前不会请求。

第二行显示用户 2 的数据正在加载。第三行显示用户 2 的图像(user2.png)正在加载。以下是我们从点击到图像的步骤,当我们从用户列表中选择一个用户时:

  1. 用户被选中。

  2. UserDetails 加载用户信息,挂起直到数据加载完成。

  3. 一旦数据加载完成,UserDetails 组件渲染其 UI,包括 Avatar 组件。

  4. Avatar 组件渲染 Img 组件,该组件请求图像并在图像加载完成前挂起。

  5. 一旦图像加载完成,Img 组件渲染其 UI,一个 img 元素。

图像只有在用户数据到达后才开始加载。但图像文件名是可以预测的。我们能否像图 12.11 的最后两行所示,在用户信息的同时开始加载图像?

图 12.11 我们希望用户 2 的图像和数据能够并发加载,如图表中的最后两行所示。

用户选择由 UsersPage 组件中的 switchUser 函数管理。为了得到图 12.11 所示的并发加载效果,让我们让 React Query 同时获取用户数据和图像。以下列表中包含了两个新的 prefetchQuery 调用。

分支:1204-prefetch-query,文件:/src/components/Users/UsersPage.js

列表 12.14 在用户页面预加载数据和图像

// other imports

import {useQueryClient} from "react-query";
import getData from "../../utils/api";

export default function UsersPage () {
  const [loggedInUser] = useUser();
  const [selectedUser, setSelectedUser] = useState(null);
  const user = selectedUser || loggedInUser;
  const queryClient = useQueryClient();

  function switchUser (nextUser) {
    setSelectedUser(nextUser);

    queryClient.prefetchQuery(                                       ❶
      ["user", nextUser.id],
      () => getData(`http://localhost:3001/users/${nextUser.id}`)
    );

    queryClient.prefetchQuery(                                       ❷
      `http://localhost:3001/img/${nextUser.img}`,
      () => new Promise((resolve) => {
        const img = new Image();
        img.onload = () => resolve(img);
        img.src = `http://localhost:3001/img/${nextUser.img}`;
      })
    );
  }

  return user ? (
    <main className="users-page">
      <UsersList user={user} setUser={switchUser}/>

      <Suspense fallback={<PageSpinner/>}>
        <UserDetails userID={user.id}/>                              ❸
      </Suspense>
    </main>
  ) : null;
}

❶ 预取用户信息。

❷ 预取用户头像图像。

❸ 渲染用户详细信息,包括头像。

通过尽早获取数据和图像,我们不会让用户等待太久,并减少了需要我们的后备图像的机会。但如果用户之前没有查看过该用户,切换到新用户仍然会在较慢的连接上给访客带来一个加载旋转器(如图 12.12 所示)。从详情面板切换到加载旋转器,然后再切换回下一个详情面板,并不是最流畅的体验。

图 12.12 切换到另一个用户在较慢的连接上会显示一个加载旋转器。

与用旋转器替换详情的刺耳体验相比,如果我们能推迟,直接从一组用户详情切换到另一组,避免退化的加载状态,以及返回到旋转器的感受,那就更好了。React 的并发模式承诺将使这种延迟转换变得容易得多,你将在第十三章中看到,当我们介绍我们最后的两个钩子:useTransitionuseDeferredValue时。

摘要

  • 尝试 Suspense 的数据获取集成,但不要在生产代码中使用它;它还不稳定,可能会发生变化。

  • 当时候到来时,使用经过良好测试、可靠的数据库获取库来为您管理 Suspense 集成。

  • 为了尝试使用 Suspense 进行数据获取,用可以检查其状态的函数包装承诺:

    const checkStatus = getStatusChecker(promise);
    
  • 要与 Suspense 集成,数据获取函数应该抛出挂起的承诺和错误或返回已加载的数据。创建一个将数据获取承诺转换为必要时抛出的承诺的函数:

    const getMessageOrThrow = makeThrower(promise);
    
  • 在组件中使用准备好的数据获取函数来获取 UI 数据或适当地抛出:

    function Message () {
      const data = getMessageOrThrow();
      return <p>{data.message}</p>
    }
    
  • 尽早开始加载数据,也许在事件处理程序中。

  • 提供让用户从错误状态中恢复应用的方法。像react-error-boundary这样的库可以提供帮助。

  • 查阅 React 文档及其链接示例,以深入了解这些技术及其使用read方法的资源( mng.bz/q9AJ)。

  • 使用类似的承诺处理技术来加载其他资源,如图像或脚本。

  • 利用像 React Query(在 Suspense 模式下)这样的库来管理数据或图像获取时的缓存和多个请求。

  • 通过调用 React Query 的queryClient.prefetchQuery方法来尽早加载资源。

  • 如果可能,避免瀑布式,即后续的数据获取在开始之前等待之前的数据获取。

13 尝试使用 useTransition、useDeferredValue 和 SuspenseList

本章涵盖

  • 使用useTransition钩子延迟 UI 更新

  • 使用isPending布尔值标记不一致的状态和 UI

  • 使用useDeferredValue钩子同时使用旧值和新值

  • 使用SuspenseList组件管理多个回退

  • 理解并发模式的承诺

并发模式让 React 同时处理我们 UI 的多个版本,显示仍然完全交互的旧版本,直到新版本准备就绪。这意味着在短暂的时间内,最新状态可能不匹配浏览器中的当前 UI,React 给我们一些钩子和组件来管理我们向用户提供的反馈。目标是提高我们应用程序的用户体验,使它们感觉更响应,并编排更新,让我们的用户立即理解什么是过时的,什么是正在更新的,什么是新鲜的。

并发模式仍然是实验性的,因此在本章中引入的两个新钩子useTransitionuseDeferredValue也是实验性的。它们允许 React 在组件加载新数据或计算新值时继续显示旧 UI 或旧值。这有助于我们避免退缩状态,即 UI 从一个有用的、交互式的组件回退到之前的加载状态。

在前两个章节中,我们花费了大量时间包装可以在Suspense组件中挂起的组件,并指定适当的回退。随着页面中Suspense组件数量的增加,我们可能会让我们的用户感染上回退热,导致一切开始旋转。一种潜在的疗法是SuspenseList组件,这是一种舒缓的绷带,它控制着旋转器,将它们从疾病状态转变为健康状态的标志。

让我们探索这些实验性解决方案,随着我们在预订应用中改进用户页面。

13.1 在状态之间实现更平滑的过渡

当我们首次加载用户页面时,我们会看到一个旋转器,因为当前用户的详情正在加载。这是可以接受的;我们可能预期在首次加载页面时会有一些旋转器。但当我们第一次选择新用户时,UI 会回到显示旋转器,如图 13.1 所示。(如果需要,可以延迟启动json-server来模拟较慢的连接。)

图片

图 13.1 在用户列表中选择新用户(Clarisse)会导致旋转器替换用户详情面板。这可能会让人感到震惊;感觉像是倒退了一步。我们称之为退缩状态。

等待数据加载可能是不可避免的,但我们可以通过避免显示旋转器,并在新数据加载时继续显示旧数据来尝试提高页面的感知响应速度。

在本节中,我们探讨了最后两个内置钩子 useTransitionuseDeferredValue,作为通过延迟 UI 更新来改善用户体验的方法。要使用这些钩子,我们的应用需要处于并发模式,为此我们需要 React 的实验版本。按照以下方式安装它:

npm install react@experimental react-dom@experimental

如果 React Query 坚持要使用稳定版本的 React 存在问题,您可以在安装 React 的实验版本之前卸载 React Query。然后,使用 -force 标志重新安装 React Query,如下所示:

npm install react-query --force

然后,更新 index.js 以渲染应用,如下所示。

分支:1301-use-transition,文件:/src/index.js

列表 13.1 启用并发模式

import ReactDOM from 'react-dom';
import App from './components/App.js';

const root = document.getElementById('root');
ReactDOM
  .unstable_createRoot(root)                    ❶
  .render(<App />);                             ❷

❶ 使用具有 ID “root” 的元素作为应用的根。

❷ 将 App 组件渲染到根元素中。

13.1.1 使用 useTransition 避免回退状态

图 13.2 展示了在用户页面选择新用户时的改进 UI 体验。在查看 Mark 的详细信息时,我们已点击用户列表中的 Clarisse,但右侧的用户详情面板继续显示旧 UI,Mark 的信息,而不是回退到加载指示器。

图片

图 13.2 选择了一个新用户(Clarisse),但不是立即显示回退的加载指示器,UI 继续显示旧用户(Mark)。

以下列表展示了如何使用 useTransition 钩子给予 React 显示旧 UI 的权限,如果状态改变(例如切换用户)导致子组件挂起。

分支:1301-use-transition,文件:/src/components/Users/UsersPage.js

列表 13.2 在 UsersPage 上使用转换来改善 UX

import {
  useState,
 unstable_useTransition as useTransition,                      ❶
  Suspense
} from "react";

// unchanged imports

export default function UsersPage () {
  const [loggedInUser] = useUser();
  const [selectedUser, setSelectedUser] = useState(null);
  const user = selectedUser || loggedInUser;
  const queryClient = useQueryClient();

  const [startTransition] = useTransition() ❷

  function switchUser (nextUser) {
    startTransition(() => setSelectedUser(nextUser));           ❸

    queryClient.prefetchQuery(/* prefetch user details */);
    queryClient.prefetchQuery(/* prefetch user image */);
  }

  return user ? (
    <main className="users-page">
      <UsersList user={user} setUser={switchUser}/>

      <Suspense fallback={<PageSpinner/>}>                      ❹
        <UserDetails userID={user.id}/>
      </Suspense>
    </main>
  ) : <PageSpinner/>;
}

❶ 导入 useTransition 钩子。

❷ 获取转换函数,startTransition。

❸ 将用户状态改变包裹在转换中。

❹ 在第一个用户加载时显示加载指示器。

为了强调这全部都是实验性的,钩子有一个 unstable 前缀,因此我们从 react 包中导入 unstable_useTransition,并将其重命名为 useTransition

useTransition 钩子返回一个数组,其第一个元素是一个我们用来包裹可能使组件挂起的状态改变的函数。我们将该函数分配给 startTransition 变量:

const [startTransition] = useTransition();

我们的状态改变发生在 switchUser 函数中。切换到新用户可能会使 UserDetails 组件挂起,如果 React Query 尚未加载该用户的数据。将状态改变包裹在 startTransition 中告诉 React 保持显示旧 UI 而不是 Suspense 回退,直到数据加载完成。如果没有旧 UI(组件尚未挂载),React 将在等待数据时显示 Suspense 回退:

startTransition(() => setSelectedUser(nextUser));

在新状态的数据加载时间不长的情况下,不回退到加载指示器是一个改进。如果加载时间较长,用户就会陷入困境,盯着旧 UI。应用崩溃了吗?我们需要给他们一些反馈,告诉他们应用正在加载数据。

13.1.2 使用 isPending 给用户反馈

useTransition 钩子允许 React 在状态变化时显示旧 UI。但如果它们持续太长时间,不一致的 UI 可能会导致困惑。如果 UI 继续显示一些旧值,那么给我们的用户提供一些变化正在发生的反馈将会很好。

我们追求的是类似于图 13.3 的东西;我们在用户列表中点击了新用户,Clarisse,但我们的转换仍然保留了旧用户 Mark 的详细信息在屏幕上,同时 Clarisse 的信息正在加载。我们降低用户详细信息面板的不透明度以显示详细信息已过时。

图 13.3 在转换期间,使用 isPending 值设置用户详细信息面板的类,允许通过 CSS 减少其不透明度。我们没有缩小的旋转器,但确实表示了转换。

有助于,useTransition 还在其数组中返回一个布尔值,以指示转换正在进行。我们可以将布尔值分配给一个局部变量,isPending

const [startTransition, isPending] = useTransition();

然后,我们可以使用 isPending 在用户详细信息面板上设置一个类名,例如,如列表 13.3 中的 UsersPage 和列表 13.4 中的 UserDetails 所示。

分支:1302-is-pending,文件:/src/components/Users/UsersPage.js

列表 13.3 在转换期间解构 isPending 值以设置属性

export default function UsersPage () {
  // set up state

  const [startTransition, isPending] = useTransition();            ❶

  function switchUser (nextUser) {
    startTransition(() => setSelectedUser(nextUser));              ❷

    // prefetch user details and image
  }

  return user ? (
    <main className="users-page">
      <UsersList user={user} setUser={switchUser}/>

      <Suspense fallback={<PageSpinner/>}>
        <UserDetails userID={user.id} isPending={isPending}/>      ❸
      </Suspense>
    </main>
  ) : <PageSpinner/>;
}

❶ 将挂起标志分配给局部变量。

❷ 开始转换。

❸ 将挂起标志传递给 UserDetails。

分支:1302-is-pending,文件:/src/components/Users/UserDetails.js

列表 13.4 在 UserDetails 中使用 isPending 设置类名

export default function UserDetails ({userID, isPending}) {             ❶
  const {data: user} = useQuery(/* fetch user details */);

  return (
    <div
      className={isPending ? "item user user-pending" : "item user"}    ❷
    >
      {/* unchanged UI */}
    </div>
  );
}

❶ 从 props 获取 isPending 标志。

❷ 使用 isPending 标志有条件地设置类。

对于新的 userID 值,UserDetails 组件将在获取用户数据时挂起。然而,在转换进行时,React 将继续使用旧用户的 UI,但会将其重新渲染为 isPending 设置为 true。React 同时管理同一组件的两个版本。

13.1.3 将转换与常见组件集成

当并发模式和其 API 变得稳定时,我们预计会大量使用转换来平滑可能运行时间较长的更新状态。但与其在代码库的各个地方插入 useTransition 调用,React 文档建议我们将这些调用集成到我们的设计系统中。例如,我们的按钮可以将其事件处理程序包裹在转换中。

让我们尝试在我们的 UsersList 组件中使用准备好的转换按钮;我们可以从 UsersPageUserDetails 中移除转换和 isPending 代码。图 13.4 显示了我们点击新用户 Clarisse 时会发生什么。按钮开始转换到 Clarisse,并使用 isPending 状态来提供反馈,显示正在加载用户的旋转器。在转换进行时,旧用户 Mark 仍然被突出显示,并且他的详细信息显示在右侧。

图 13.4 用户列表中的按钮在其转换进行时显示旋转器。

以下列表展示了我们新的 UI 组件 ButtonPending。它渲染一个按钮,同时也封装了过渡代码。点击按钮开始过渡,在过渡处于挂起状态时,按钮会显示旋转器。

分支:1303-button-pending,文件:/src/components/UI/ButtonPending.js

列表 13.5 使用过渡的 ButtonPending 组件

import {unstable_useTransition as useTransition} from 'react';
import Spinner from "./Spinner";

export default function ButtonPending ({children, onClick, ...props}) {    ❶
  const [startTransition, isPending] = useTransition();

  function handleClick () {
    startTransition(onClick);                                              ❷
  }

  return (
    <button onClick={handleClick} {...props}>
      {isPending && <Spinner/>}                                            ❸
      {children}
      {isPending && <Spinner/>}                                            ❸
    </button>
  );
}

❶ 传递一个需要过渡的处理程序。

❷ 在处理程序周围包裹一个过渡。

❸ 使用挂起标志来指示过渡正在进行中。

UsersList 组件中的 button 替换为 ButtonPending(实际上交换名称)。使用这个特殊按钮可以启用过渡!CSS 设置在几百毫秒后淡入旋转器;对于快速加载数据,您将看不到旋转器。

13.1.4 使用 useDeferredValue 保持旧值

在我们介绍并发用户界面的过程中,我们还将介绍一个工具:useDeferredValue 钩子。我们维护值的旧版本和新版本,并在我们的 UI 中使用这两个版本。图 13.5 展示了当我们从 Mark 切换到 Clarisse 用户时会发生什么。用户列表立即突出显示新用户,并显示一个旋转器,而详情面板继续显示旧用户的详细信息。

图 13.5 UsersList 显示了最新的选择(Clarisse)和内联旋转器,但用户详情面板仍然显示旧用户(Mark),即延迟值。

如果切换到新用户 Clarisse 导致详情面板渲染延迟,它将继续使用旧值 Mark,直到新值的 UI 可以渲染。新值已被延迟。以下列表更新了 UsersPage,这次是为用户传递给 UserDetails 的延迟值。

分支:1304-deferred-value,文件:/src/components/Users/UsersPage.js

列表 13.6 将延迟值传递给 UserDetails

import {
  useState,
 unstable_useDeferredValue as useDeferredValue, 
  Suspense
} from "react";

// other imports

export default function UsersPage () {
  const [loggedInUser] = useUser();
  const [selectedUser, setSelectedUser] = useState(null);
  const user = selectedUser || loggedInUser;
  const queryClient = useQueryClient();
 const deferredUser = useDeferredValue(user) || user;                ❶

 const isPending = deferredUser !== user;                            ❷

  function switchUser(nextUser) {
    setSelectedUser(nextUser);                                        ❸

    queryClient.prefetchQuery(/* prefetch user details */);
    queryClient.prefetchQuery(/* prefetch user image */);
  }

  return user ? (
    <main className="users-page">
      <UsersList
        user={user}                                                   ❹
        setUser={switchUser}
        isPending={isPending}                                         ❺
      />

      <Suspense fallback={<PageSpinner/>}>
        <UserDetails
          userID={deferredUser.id}                                    ❻
          isPending={isPending}                                       ❼
        />
      </Suspense>
    </main>
  ) : <PageSpinner/>;
}

❶ 跟踪用户值:如果新值延迟渲染,则返回旧值。

❷ 创建一个表示延迟值过时的标志。

❸ 更新用户值。

❹ 让列表知道新用户。

❺ 让列表知道其用户信息与 UserDetails 不一致。

❻ 在等待新用户信息的同时显示旧用户信息。

❼ 让 UserDetails 知道其用户信息已过时。

UsersPage 获取 useDeferredValue 钩子来管理旧用户和新用户值。我们使用以下方式调用 useDeferredValue 来跟踪一个值:

const deferredValue = useDeferredValue(value);

该钩子跟踪一个值。如果值从旧值更改为新值,钩子可以返回任一值。如果 React 可以成功使用新值渲染新的 UI,并且没有子组件挂起或延迟渲染,则钩子返回新值,React 更新 UI。如果新值导致 React 在完成渲染之前等待某个过程完成,则钩子返回旧值,React 使用旧值显示 UI(同时在内存中处理新值的 UI)。deferredValue 初始为 undefined,因此我们在末尾添加 || user 以确保一旦设置初始 user 值就立即使用:

const deferredUser = useDeferredValue(user) || user;

在列表 13.6 中,我们向 UsersList 传递新选定的用户值,同时向 UserDetails 传递可能过时的 deferredUser 值:

<UsersList
  user={user}
  setUser={switchUser}
  isPending={isPending}
/>

<Suspense fallback={<PageSpinner/>}>
  <UserDetails
    userID={deferredUser.id}
    isPending={isPending}
  />
</Suspense>

UserDetails 组件在加载新用户信息时,会继续显示之前用户的信息。当两个用户值不一致时,我们将 isPending 标志设置为 trueUsersList 将显示加载指示器,而 UserDetails 将降低其不透明度,以提供额外的视觉反馈,以吸引注意不一致的用户界面状态。

13.2 使用 SuspenseList 管理多个后备方案

当我们在 UI 中有多个 Suspense 组件时,对何时以及如何显示它们的后备方案有更多控制可能很有用;我们不希望屏幕上出现满载的旋转器和杂技组件。我们需要一个马戏团老板来指挥它们,以有序的方式介绍各个部分。这个马戏团老板就是 SuspenseList 组件。

假设用户页面现在包括所选用户的预订以及分配给他们的任何待办事项。UI 可能类似于图 13.6,其中用户信息、预订和待办事项作为用户详细信息面板的一部分显示。

图 13.6 用户详细信息现在包括预订和待办事项。

在本节中,我们首先更新 UserDetails 以在单独的 Suspense 组件中显示新信息。然后我们将 Suspense 组件包裹在一个 SuspenseList 中,以更好地控制显示其后备方案的顺序。

13.2.1 显示来自多个来源的数据

我们希望 UserDetails 组件显示用户预订和待办事项。当数据加载时,我们可能会看到类似于图 13.7 的内容,其中 Suspense 组件显示后备信息,“正在加载用户预订……”和“正在加载用户待办事项……”。

图 13.7 显示加载预订和待办事项的后备方案

列表 13.7 向 UserDetails UI 添加 UserBookingsUserTodos 组件。每个组件都加载自己的数据,因此我们将它们包裹在具有适当后备信息的 Suspense 组件中。请检查仓库中新组件的实现;对于当前讨论来说这不重要。

分支:1305-multi-suspense,文件:/src/components/Users/UserDetails.js

列表 13.7 在 UserDetails 中包含预订和待办事项

import {Suspense} from "react";
// other imports
import UserBookings from "./UserBookings";
import UserTodos from "./UserTodos";

export default function UserDetails ({userID, isPending}) {
  const {data: user} = useQuery(/* load user info */);

  return (
    <div className={isPending ? "item user user-pending" : "item user"}>
      <div className="item-header">
        <h2>{user.name}</h2>
      </div>

      <Avatar
        src={`http://localhost:3001/img/${user.img}`}
        fallbackSrc="http://localhost:3001/img/avatar.gif"
        alt={user.name}
      />

      <div className="user-details">
        <h3>{user.title}</h3>
        <p>{user.notes}</p>
      </div>

      <Suspense fallback={<p>Loading user bookings...</p>}>     ❶
        <UserBookings id={userID}/>                             ❷
      </Suspense>

      <Suspense fallback={<p>Loading user todos...</p>}>        ❸
        <UserTodos id={userID}/>                                ❹
      </Suspense>
    </div>
  );
}

❶ 为预订添加 Suspense 后备方案。

❷ 显示预订信息。

❸ 包含一个 Suspense 后备用于待办事项。

❹ 显示待办事项。

由于我们无法预测每个新组件加载数据需要多长时间,可能会出现一个 UI 马戏团问题。图 13.8 显示了如果待办事项首先加载会发生什么:待办事项列表被渲染,但预订的后备仍然显示在列表上方。当预订最终加载时,我们可能正在尝试阅读的待办事项将被即将到来的预订向下推到页面底部。

图片

图 13.8 待办事项正在显示,但预订仍在加载。一旦预订加载完成,它们将把待办事项向下推。

如果我们可以同时显示两个组件,或者确保预订首先显示,我们将提高用户体验。让我们看看SuspenseList如何帮助我们让马戏团离开。

13.2.2 使用 SuspenseList 控制多个后备

为了避免当上面的组件渲染较慢时组件向下移动,我们可以指定组件按顺序、自上而下显示,即使较晚组件的数据首先加载。对于用户页面,我们希望首先显示用户的预订,如图 13.9 所示,即使待办事项的数据可能加载得更快。

图片

图 13.9 使用SuspenseList,我们可以设置显示顺序以强制预订首先显示。

我们将使用SuspenseList组件来管理我们的后备。它目前被导入为unstable_SuspenseList

import {Suspense, unstable_SuspenseList as SuspenseList} from "react";

以下列表显示了UserBookingsUserTodos组件及其后备组件,它们被包裹在一个设置了revealOrderforwardsSuspenseList中。

分支:1306-suspense-list,文件:/src/components/Users/UserDetails.js

列表 13.8 将两个Suspense组件包裹在SuspenseList

<SuspenseList                       ❶
 revealOrder="forwards"            ❷
>
  <Suspense fallback={<p>Loading user bookings...</p>}>
    <UserBookings id={userID}/>
  </Suspense>

  <Suspense fallback={<p>Loading user todos...</p>}>
    <UserTodos id={userID}/>
  </Suspense>
</SuspenseList>

❶ 将 Suspense 组件包裹在 SuspenseList 中。

❷ 指定显示顺序。

我们也可以将revealOrder设置为backwards以首先显示待办事项,或者设置为together以同时显示预订和待办事项。

我们可能不希望显示多个后备,SuspenseList还有一个tail属性,如果设置为collapsed,则一次只显示一个后备:

<SuspenseList revealOrder="forwards" tail="collapsed">
  {/* UI with Suspense components */}
</SuspenseList>

图 13.10 显示了当我们设置SuspenseList上的tail属性时用户详情面板。用户详情面板只显示“正在加载用户预订...”的后备。如图 13.9 所示,“正在加载用户待办事项...”的后备只在预订渲染后出现。

图片

图 13.10 一次只显示一个后备:首先显示预订的后备,然后显示待办事项的后备。

SuspenseList仍然是实验性的,它将帮助我们编排加载状态的方式将在接下来的几个月内演变。我们的用户页面示例可以通过一些明智的数据预取和更仔细地组合本章中使用的所有技术来改进。但示例应该已经让你对 React 即将到来的一些新功能有了很好的感觉。

13.3 并发模式和未来

使用并发模式,React 可以在内存中同时渲染多个 UI 版本,并且只使用最适合当前状态的版本来更新 DOM,这可能是一个正在变化的过程,等待耗时的更新。这种灵活性允许 React 在需要更高优先级更新,如用户与表单字段交互时,中断渲染。这有助于保持应用响应,并提高应用的感知性能。

能够在内存中准备更新也使 React 能够在准备好足够的新 UI 时切换到更新后的 UI,无论是新页面、筛选后的列表还是用户的详细信息。旧 UI 仍然可以更新以显示挂起指示器,让用户知道正在发生变更。避免回退状态和令人不适的加载指示器可以使与我们的应用交互感觉更平滑,帮助用户专注于他们的任务,而不是对应用感到沮丧。

并发模式为更精确、有目的的代码、数据和资源加载铺平了道路,更平滑地整合了服务器端渲染与客户端组件的激活,及时注入资源,以便在用户交互时使组件即时响应。

图 13.11 展示了并发模式所承诺的许多特性。它来自 React 文档,网址为 mng.bz/7VRe,其中包括第三种模式,阻塞模式,这是采用并发模式的一个中间步骤,你可以在那里了解更多信息,以及本书第二部分中我们讨论的特性。

图 13.11

图 13.11 来自 React 文档页面关于采用并发模式的并发模式、阻塞模式和传统模式的特性比较

摘要

  • 记住,这些 API 是实验性的,可能会发生变化。

  • 通过更新应用最初渲染到浏览器的方式启用并发模式。使用 ReactDOM.unstable_createRootrender,如下所示:

    const root = document.getElementById('root');
    ReactDOM.unstable_createRoot(root).render(<App />);
    
  • 通过调用 useTransition 钩子延迟等待数据的新的 UI 的渲染:

    const [startTransition, isPending] = useTransition();
    
  • 将可能导致组件暂停的状态更改包装在 startTransition 函数中。React 可以继续显示旧 UI,直到新 UI 准备好。

    startTransition(() => setSelectedUser(nextUser));
    
  • 使用 isPending 布尔值,即 useTransition 返回数组中的第二个元素,来更新旧 UI,让用户知道状态正在更新。

  • 创建设计系统组件,如自定义按钮,通过将事件处理程序包装在 startTransition 中来自动从一个状态过渡到另一个状态。

  • 在更新状态时,如果新值导致延迟,继续使用旧值,通过调用 useDeferredValue 钩子来跟踪值:

    const deferredValue = useDeferredValue(value);
    
  • 可以立即渲染的组件可以使用新状态,而可能暂停的组件可以使用延迟值:

    <QuickComponent value={value}/>
    
    <Suspense fallback={<PageSpinner/>}>
      <UserDetails value={deferredValue}/>
    </Suspense>
    
  • 使用 SuspenseList 组件来管理 Suspense 组件显示其后备内容的顺序。指定 revealOrderforwardsbackwardstogether,并且可选地通过设置 tail 属性一次只显示一个后备内容。

    <SuspenseList revealOrder="forwards" tail="collapsed">
      <Suspense fallback={<p>Loading 1...</p>}><Component1/></Suspense>
      <Suspense fallback={<p>Loading 2...</p>}><Component2/></Suspense>
    </SuspenseList>
    
  • 记住,这些 API 是实验性的,很可能会发生变化。

posted @ 2025-11-18 09:36  绝不原创的飞龙  阅读(9)  评论(0)    收藏  举报