React-学习指南第二版-全-

React 学习指南第二版(全)

原文:zh.annas-archive.org/md5/bb1fa05e6dba18dbe8c74ca203e48d95

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书适用于希望学习 React 库同时了解当前 JavaScript 语言新技术的开发者。现在是成为 JavaScript 开发者的激动人心时刻。生态系统正在涌现出许多新工具、语法和最佳实践,承诺解决我们的许多开发问题。我们的目标是通过本书组织这些技术,让你可以立即开始使用 React。我们将介绍状态管理、React Router、测试和服务器渲染,因此承诺不仅介绍基础知识然后让你自己摸索。

本书不假设读者对 React 有任何了解。我们将从零开始介绍 React 的所有基础知识。同样,我们不会假设你已经熟悉最新的 JavaScript 语法。这将在第二章中作为后续章节的基础介绍。

如果你对 HTML、CSS 和 JavaScript 感到熟悉,你将更好地准备好本书的内容。在深入学习 JavaScript 库之前,熟悉这三大要素通常是最佳选择。

在学习过程中,请查看GitHub 仓库。所有示例都在那里,可以让你进行实际操作练习。

本书使用的约定

本书中使用以下排版约定:

斜体

表示新术语、网址、电子邮件地址、文件名和文件扩展名。

等宽字体

用于程序列表,以及在段落内引用程序元素,如变量或函数名、数据库、数据类型、环境变量、语句和关键字。

等宽字体粗体

显示用户应该按字面输入的命令或其他文本。

提示

这个元素表示提示或建议。

注释

这个元素表示一般注释。

警告

这个元素表示警告或注意事项。

使用代码示例

可以下载补充材料(代码示例、练习等)https://github.com/moonhighway/learning-react

如果你有技术问题或在使用代码示例时遇到问题,请发送电子邮件至bookquestions@oreilly.com

本书旨在帮助你完成工作任务。一般而言,如果本书提供了示例代码,你可以在你的程序和文档中使用它。除非你要复制本书的大部分代码,否则不需要联系我们寻求许可。例如,编写一个使用本书多个代码片段的程序不需要许可。销售或分发 O’Reilly 图书示例需要许可。通过引用本书回答问题并引用示例代码不需要许可。将本书大量示例代码整合到产品文档中需要许可。

我们感激,但通常不要求署名。署名通常包括标题、作者、出版商和 ISBN 号。例如:“学习 React by Alex Banks and Eve Porcello (O’Reilly). Copyright 2020 Alex Banks and Eve Porcello, 978-1-492-05172-5.”

如果您认为您对代码示例的使用超出了合理使用范围或上述许可,请随时通过permissions@oreilly.com联系我们。

奥莱利在线学习

注意

超过 40 年来,奥莱利传媒 一直为企业提供技术和商业培训、知识和洞察,帮助其成功。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专长。奥莱利的在线学习平台为您提供按需访问直播培训课程、深入学习路径、交互式编码环境以及来自奥莱利和其他 200 多个出版商的大量文本和视频。有关更多信息,请访问http://oreilly.com

如何联系我们

请将有关本书的评论和问题发送至出版商:

  • 奥莱利传媒有限公司

  • 1005 Gravenstein Highway North

  • CA 95472 Sebastopol

  • 800-998-9938(美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

我们为这本书设有网页,列出勘误、示例和任何额外信息。您可以访问https://oreil.ly/learningReact_2e

电子邮件bookquestions@oreilly.com评论或询问有关本书的技术问题。

有关我们的书籍和课程的新闻和信息,请访问http://oreilly.com

在 Facebook 上找到我们:http://facebook.com/oreilly

在 Twitter 上关注我们:http://twitter.com/oreillymedia

在 YouTube 上观看我们:http://www.youtube.com/oreillymedia

致谢

如果没有一些老式的幸运,我们的 React 之旅不会开始。在我们在雅虎内部创建全栈 JavaScript 程序的培训材料时,我们使用了 YUI。然后在 2014 年 8 月,YUI 的开发结束了。我们不得不更改所有课程文件,但是改用什么?现在前端应该使用什么?答案是:React。我们并不是立即爱上 React;我们花了几个小时才被它迷住。看起来 React 可能会彻底改变一切。我们很早就加入并非常幸运。

我们感谢 Angela Rufino 和 Jennifer Pollock 在开发第二版过程中提供的所有支持。我们还要感谢 Ally MacDonald 在第一版中的所有编辑帮助。我们感激我们的技术审阅员 Scott Iwako, Adam Rackis, Brian Sletten, Max Firtman 和 Chetan Karande。

这本书也离不开 Sharon Adams 和 Marilyn Messineo 的支持。她们合谋购买了 Alex 的第一台计算机,一台 Tandy TRS 80 彩色计算机。此外,没有 Jim 和 Lorri Porcello 以及 Mike 和 Sharon Adams 的爱心支持和鼓励,这本书也无法问世。

我们还要感谢加利福尼亚州塔霍市的 Coffee Connexion 提供我们完成这本书所需的咖啡,以及其老板 Robin,他给了我们这样的建议:“写一本关于编程的书?听起来很无聊!”

第一章:欢迎来到 React

什么才算是一个好的 JavaScript 库?是 GitHub 上的星星数量吗?是 npm 上的下载次数吗?每天 ThoughtLeaders™ 写的推特数量重要吗?我们如何选择最好的工具来构建最好的东西?我们如何知道它值得我们的时间?我们如何知道它是好的?

当 React 刚发布时,围绕它是否好的讨论很多,也有很多怀疑论者。它是新的,新东西常常会让人感到不安。

为了回应这些批评,React 团队的 Pete Hunt 写了一篇文章叫做《为什么选择 React?》,建议你在认为这个团队的方法太狂野之前先“给它(React)五分钟时间”。他希望鼓励人们首先尝试使用 React,而不是立刻认为这个团队的方法太过激进。

是的,React 是一个小型库,它并没有一切你可能需要的东西来直接构建你的应用程序。给它五分钟时间。

是的,在 React 中,你会在 JavaScript 代码中编写类似 HTML 的代码。是的,这些标签需要预处理才能在浏览器中运行。而且你可能需要像 webpack 这样的构建工具。给它五分钟时间。

当 React 接近十年的使用历史时,许多团队决定它很不错,因为他们花了五分钟时间。我们说的是 Uber、Twitter、Airbnb 和 Twitter 这样的巨型公司,它们尝试了 React 并意识到它能帮助团队更快地构建更好的产品。说到底,这不就是我们在这里的原因吗?不是为了推特,不是为了星星,也不是为了下载量。我们在这里是为了用我们喜欢使用的工具来构建酷炫的东西。我们在这里是为了能够自豪地说我们建造的东西。如果你喜欢做这些事情,你可能会喜欢使用 React。

强大的基础

无论你是全新于 React 还是希望通过本书了解一些最新特性,我们希望本书能为你未来在这个库上的工作奠定坚实的基础。本书的目标是通过按照一定顺序来放置事物,即学习路线图,避免在学习过程中造成混乱。

在深入了解 React 之前,了解 JavaScript 是很重要的。不是所有 JavaScript,也不是每一种模式,但在跳进这本书之前对数组、对象和函数有一定的熟悉将会很有用。

在接下来的章节中,我们将会看一些新的 JavaScript 语法,让你熟悉最新的 JavaScript 特性,特别是那些经常与 React 一起使用的特性。然后我们将会介绍函数式 JavaScript,以便你理解产生 React 的范式。与 React 协作的一个好处是它可以通过促进可读性、可重用性和可测试性的模式来使你成为更强大的 JavaScript 开发者。有点像温和而有帮助的洗脑。

从那里开始,我们将涵盖 React 基础知识,以理解如何使用组件构建用户界面。然后,我们将学习如何组合这些组件,并通过 props 和 state 添加逻辑。我们将介绍 React Hooks,这允许我们在组件之间重用有状态的逻辑。

一旦基础就位,我们将构建一个新的应用程序,允许用户添加、编辑和删除颜色。我们将学习如何使用 Hooks 和 Suspense 来进行数据获取。在构建该应用程序的过程中,我们将介绍来自更广泛 React 生态系统的各种工具,用于处理常见问题,如路由、测试和服务器端渲染。

我们希望通过这种方式更快地让您了解 React 生态系统,不仅仅是浅尝辄止,而是装备您构建真实世界 React 应用程序所需的工具和技能。

React 的过去与未来

React 最初由 Facebook 的软件工程师 Jordan Walke 创建。它首次被整合到 Facebook 的新闻提要中是在 2011 年,后来在 2012 年被 Facebook 收购 Instagram 时也加入了。在 2013 年的 JSConf 上,React 被开源,并加入了像 jQuery、Angular、Dojo、Meteor 等 UI 库的拥挤领域。当时,React 被描述为“MVC 中的 V”。换句话说,React 组件充当了 JavaScript 应用程序的视图层或用户界面。

从那时起,社区开始广泛采用。在 2015 年 1 月,Netflix 宣布他们正在使用 React 来驱动他们的 UI 开发。当月晚些时候,发布了 React Native,这是一个使用 React 构建移动应用程序的库。Facebook 还发布了 ReactVR,另一个将 React 带到更广泛渲染目标的工具。在 2015 年和 2016 年,出现了大量流行的工具,如 React Router、Redux 和 Mobx,用于处理路由和状态管理等任务。毕竟,React 被宣传为一个库:专注于实现特定的功能集,而不是为每个用例提供工具。

时间线上的另一个重大事件是 2017 年发布的 React Fiber。Fiber 是 React 渲染算法的重写,在执行上有点神奇。它是 React 内部的完全重写,几乎没有改变公共 API。这是一种使 React 更现代化和高效的方式,而不影响其用户。

更近期的是在 2019 年,我们看到了 Hooks 的发布,这是一种在组件之间添加和共享有状态逻辑的新方法。我们还看到了 Suspense 的发布,这是一种优化 React 异步渲染的方法。

未来,我们无疑会看到更多变化,但 React 成功的原因之一是多年来致力于该项目的强大团队。团队既雄心勃勃又谨慎,推动前瞻性优化,同时不断考虑对库进行任何更改所产生的社区连锁反应。

随着 React 及其相关工具的更新,有时会出现兼容性问题。事实上,未来版本的这些工具可能会使本书中的某些示例代码失效。您仍然可以参考代码示例。我们将在package.json文件中提供确切的版本信息,以便您安装正确版本的这些包。

除了本书外,您还可以通过关注官方React 博客来跟踪变化。当发布新版本的 React 时,核心团队将撰写详细的博客文章和变更日志。该博客还已被翻译成多种语言,并不断扩展,因此如果英语不是您的母语,您可以在文档站点的语言页面上找到本地化版本的文档。

学习 React:第二版变更

这是学习 React的第二版。我们认为更新本书非常重要,因为 React 在过去几年里已经发生了很大变化。我们打算关注 React 团队推崇的所有当前最佳实践,但也将分享有关弃用 React 功能的信息。有很多使用旧风格编写的仍然运行良好且必须维护的 React 代码。在所有情况下,我们将在侧边栏中提及这些功能,以防您发现自己在处理传统的 React 应用程序。

处理文件

在本节中,我们将讨论如何处理本书的文件以及如何安装一些有用的 React 工具。

文件库

与本书相关的GitHub 存储库提供了按章节组织的所有代码文件。

React 开发者工具

我们强烈建议安装 React 开发者工具来支持您在 React 项目上的工作。这些工具可以作为 Chrome 和 Firefox 的浏览器扩展程序,并作为独立应用程序用于 Safari、IE 和 React Native。一旦安装了开发工具,您将能够检查 React 组件树,查看 props 和 state 的详细信息,甚至查看当前生产环境中正在使用 React 的网站。在调试和学习 React 在其他项目中使用时,这些工具非常有用。

要安装,请访问GitHub 存储库。在那里,您将找到链接到ChromeFirefox 扩展程序

安装后,您将能够查看哪些网站正在使用 React。只要在浏览器工具栏中如图 1-1 所示看到 React 图标亮起,您就会知道该网站在页面上使用了 React。

图片

图 1-1. 在 Chrome 中查看 React 开发者工具

接着,当你打开开发者工具时,会看到一个名为 React 的新标签,如图 1-2 所示。点击该标签将显示当前页面中所有组成部分的组件。

image

图 1-2. 使用 React 开发者工具检查 DOM

安装 Node.js

Node.js 是用于构建全栈应用程序的 JavaScript 运行时环境。Node 是开源的,可以安装在 Windows、macOS、Linux 和其他平台上。在第十二章中构建 Express 服务器时,我们将使用 Node。

你需要安装 Node,但不需要成为 Node 专家才能使用 React。如果不确定你的机器上是否安装了 Node.js,可以打开终端或命令提示符窗口,输入:

node -v

运行此命令后,应该会返回一个 Node 版本号,理想情况下是 8.6.2 或更高。如果你输入这个命令后看到一个显示“命令未找到”的错误消息,说明 Node.js 没有安装。这很容易通过从Node.js 网站安装 Node.js 来解决。只需按照安装程序的自动步骤操作,再次输入node -v命令时,你会看到版本号。

npm

当你安装 Node.js 时,也安装了 npm,即 Node 包管理器。在 JavaScript 社区中,工程师们分享开源代码项目,以避免重写框架、库或辅助函数。React 本身就是一个有用的 npm 库的例子。在本书中,我们将使用 npm 安装各种包。

今天你遇到的大多数 JavaScript 项目都包含各种文件以及一个package.json文件。这个文件描述了项目及其所有依赖项。如果你在包含package.json文件的文件夹中运行npm install,npm 会安装项目中列出的所有包。

如果你从头开始创建自己的项目并想要包含依赖项,只需运行命令:

npm init -y

这将初始化项目并创建一个package.json文件。从那里,你可以用 npm 安装自己的依赖项。要使用 npm 安装包,你将运行:

npm install package-name

要用 npm 移除一个包,你将运行:

npm remove package-name

Yarn

npm 的一个替代品是 Yarn。它于 2016 年由 Facebook 与 Exponent、Google 和 Tilde 合作发布。该项目帮助 Facebook 和其他公司可靠地管理它们的依赖关系。如果你熟悉 npm 的工作流程,学习使用 Yarn 相对简单。首先,用 npm 全局安装 Yarn:

npm install -g yarn

然后,你就可以准备安装包了。当从package.json安装依赖项时,可以用yarn代替npm install

要用yarn安装特定的包,运行:

yarn add package-name

要移除一个依赖项,命令也很熟悉:

yarn remove package-name

Facebook 在生产中使用 Yarn,并且它被包含在像 React、React Native 和 Create React App 这样的项目中。如果你在一个项目中发现了yarn.lock文件,那么这个项目在使用 Yarn。类似于npm install命令,你可以通过输入yarn来安装项目的所有依赖项。

现在你已经配置好了 React 开发环境,可以开始踏上学习 React 的道路了。在第二章,我们将迅速掌握最常见于 React 代码中的最新 JavaScript 语法。

第二章:JavaScript 用于 React

自 1995 年发布以来,JavaScript 经历了许多变化。起初,我们使用 JavaScript 为网页添加交互元素:按钮点击、悬停状态、表单验证等。后来,JavaScript 通过 DHTML 和 AJAX 变得更加强大。如今,通过 Node.js,JavaScript 已经成为一种真正用于构建全栈应用程序的软件语言。JavaScript 无处不在。

JavaScript 的演变由使用 JavaScript 的公司的个人、浏览器供应商和社区领导者组成的团体指导。负责在多年来引导 JavaScript 变更的委员会是欧洲计算机制造商协会(ECMA)。语言的变更是由社区驱动的,起源于由社区成员撰写的提案。任何人都可以向 ECMA 委员会提交提案。ECMA 委员会的责任是管理和优先处理这些提案,决定每个规范包含什么内容。

ECMAScript 的第一个版本是 1997 年的 ECMAScript1。接着在 1998 年发布了 ECMAScript2。ECMAScript3 于 1999 年发布,添加了正则表达式、字符串处理等功能。就 ECMAScript4 达成协议的过程而言,变得混乱、政治化,最终被证明是不可能的。它从未发布。2009 年,ECMAScript5(ES5)发布,引入了新的数组方法、对象属性以及对 JSON 的库支持。

自那时起,这个领域的发展势头非常强劲。在 ES6 或 ES2015 于 2015 年发布后,每年都会发布新的 JS 功能。任何属于阶段建议的东西通常被称为 ESNext,简单来说,这是即将成为 JavaScript 规范一部分的新东西。

建议从明确定义的阶段开始,从阶段 0 代表最新的建议,一直到阶段 4 代表完成的建议。当一个建议获得认可时,就由像 Chrome 和 Firefox 等浏览器供应商来实现这些功能。考虑const关键字。在创建变量时,我们过去在所有情况下都使用var。ECMA 委员会决定应该有一个const关键字来声明常量(本章稍后详细介绍)。当const首次引入时,你不能简单地在 JavaScript 代码中写const并期望它在浏览器中运行。现在可以了,因为浏览器供应商已经改变了浏览器以支持它。

这一章中我们将讨论的许多功能已经得到最新浏览器的支持,但我们还将讲述如何编译您的 JavaScript 代码。这是将浏览器不识别的新语法转换为浏览器理解的旧语法的过程。kangax 兼容性表 是一个了解最新 JavaScript 功能及其在各浏览器支持程度的好去处。

在本章中,我们将展示本书中将使用的所有 JavaScript 语法。我们希望提供 JavaScript 语法知识的良好基础,这将贯穿您的 React 工作。如果您尚未切换到最新的语法,请现在开始。如果您已经对最新的语言特性感到满意,请跳到下一章。

声明变量

在 ES2015 之前,声明变量的唯一方法是使用var关键字。现在我们有几种不同的选项,提供了改进的功能。

const关键字

常量是一种不能被覆盖的变量。一旦声明,您不能更改其值。在 JavaScript 中,我们创建许多变量不应该被覆盖,因此我们将经常使用const。与其他语言一样,JavaScript 在 ES6 中引入了常量。

在常量出现之前,我们只有变量,并且变量可以被覆盖:

var pizza = true;
pizza = false;
console.log(pizza); // false

我们无法重置常量变量的值,如果尝试覆盖值,会生成控制台错误(如图 2-1 所示):

const pizza = true;
pizza = false;

覆盖常量

图 2-1. 尝试覆盖常量

let关键字

JavaScript 现在具有词法变量作用域。在 JavaScript 中,我们使用大括号({})创建代码块。在函数中,这些大括号会阻止使用var声明的任何变量的作用域。另一方面,考虑if/else语句。如果您来自其他语言,您可能会假设这些块也会阻止变量作用域。直到let出现之前,情况并非如此。

如果在if/else块内部创建变量,则该变量不会作用域于该块:

var topic = "JavaScript";

if (topic) {
  var topic = "React";
  console.log("block", topic); // block React
}

console.log("global", topic); // global React

if块内部的topic变量重置了块外的topic的值。

使用let关键字,我们可以将变量作用域限定在任何代码块中。使用let可以保护全局变量的值:

var topic = "JavaScript";

if (topic) {
  let topic = "React";
  console.log("block", topic); // React
}

console.log("global", topic); // JavaScript

topic的值在块外部不会被重置。

另一个大括号不阻止变量作用域的区域是for循环:

var div,
  container = document.getElementById("container");

for (var i = 0; i < 5; i++) {
  div = document.createElement("div");
  div.onclick = function() {
    alert("This is box #" + i);
  };
  container.appendChild(div);
}

在这个循环中,我们创建了五个div,它们出现在一个容器内。每个div都被分配了一个onclick处理程序,用于创建一个警报框来显示索引。在for循环中声明i创建了一个名为i的全局变量,然后迭代直到其值达到5。当您点击这些框中的任何一个时,警报显示所有divi均等于5,因为全局i的当前值为5(参见图 2-2)。

图片

图 2-2. 每个框中的i均等于 5

使用let声明循环计数器i而不是var可以保护i的作用域。现在点击任何框都会显示与循环迭代作用域关联的i的值(见图 2-3):

const container = document.getElementById("container");
let div;
for (let i = 0; i < 5; i++) {
  div = document.createElement("div");
  div.onclick = function() {
    alert("This is box #: " + i);
  };
  container.appendChild(div);
}

图片

图 2-3. 使用let保护i的作用域

使用let保护i的作用域。

模板字符串

模板字符串为我们提供了替代字符串连接的方法。它们还允许我们在字符串中插入变量。你会听到这些被称为模板字符串、模板文字或字符串模板,可以互换使用。

传统的字符串连接使用加号来使用变量值和字符串来组合字符串:

console.log(lastName + ", " + firstName + " " + middleName);

通过模板,我们可以创建一个字符串,并通过${ }将变量值插入其中:

console.log(`${lastName}, ${firstName} ${middleName}`);

任何返回值的 JavaScript 都可以在模板字符串的${ }之间添加到模板字符串中。

模板字符串遵循空白字符,使得更容易草拟电子邮件模板、代码示例或任何包含空白字符的内容。现在,你可以拥有跨越多行的字符串而不会破坏你的代码:

const email = `
Hello ${firstName},

Thanks for ordering ${qty} tickets to ${event}.

Order Details
${firstName} ${middleName} ${lastName}
     ${qty} x $${price} = $${qty*price} to ${event}

You can pick your tickets up 30 minutes before
the show.

Thanks,

${ticketAgent}
`

以前,在 JavaScript 代码中直接使用 HTML 字符串并不容易,因为我们需要将其连成一行。现在,空白字符被识别为文本,你可以插入格式化的 HTML,这样更容易阅读和理解:

document.body.innerHTML = `
<section>
 <header>
 <h1>The React Blog</h1>
 </header>
 <article>
 <h2>${article.title}</h2>
      ${article.body}
 </article>
 <footer>
 <p>copyright ${new Date().getYear()} | The React Blog</p>
 </footer>
</section>
`;

注意,我们还可以为页面标题和文章文本包含变量。

创建函数

每当你想用 JavaScript 执行一些可重复的任务时,你可以使用函数。让我们看看可以用来创建函数的不同语法选项以及这些函数的解剖学。

函数声明

函数声明或函数定义以function关键字开头,后面跟着函数名logCompliment。作为函数的一部分的 JavaScript 语句定义在花括号之间:

function logCompliment() {
  console.log("You're doing great!");
}

一旦声明了函数,你将调用它以查看其执行结果:

function logCompliment() {
  console.log("You're doing great!");
}

logCompliment();

一旦调用,你会看到赞美语句被记录在控制台上。

函数表达式

另一个选项是使用函数表达式。这只涉及将函数创建为变量:

const logCompliment = function() {
  console.log("You're doing great!");
};

logCompliment();

结果是相同的,You're doing great!被记录在控制台上。

在决定使用函数声明还是函数表达式时要注意的一件事是,函数声明是被提升的,而函数表达式不是。换句话说,你可以在编写函数声明之前调用函数。你不能调用由函数表达式创建的函数。这将导致错误。例如:

// Invoking the function before it's declared
hey();
// Function Declaration
function hey() {
  alert("hey!");
}

这有效。你会看到警报出现在浏览器中。它有效是因为该函数被提升,或者说被移动到文件作用域的顶部。如果尝试使用函数表达式进行相同的练习,将会导致错误:

// Invoking the function before it's declared
hey();
// Function Expression
const hey = function() {
  alert("hey!");
};
TypeError: hey is not a function

这显然是一个小例子,但在项目中导入文件和函数时偶尔会出现这种 TypeError。如果你看到它,你可以随时重构为声明。

传递参数

logCompliment函数当前不接受任何参数或参数。如果我们想为函数提供动态变量,可以通过将它们添加到括号中来将命名参数传递给函数。让我们首先添加一个firstName变量:

const logCompliment = function(firstName) {
  console.log(`You're doing great, ${firstName}`);
};

logCompliment("Molly");

现在当我们调用logCompliment函数时,发送的firstName值将添加到控制台消息中。

我们可以通过创建一个名为message的新参数来进一步扩展此功能。现在,我们不会硬编码消息。我们将作为参数传入一个动态值:

const logCompliment = function(firstName, message) {
  console.log(`${firstName}: ${message}`);
};

logCompliment("Molly", "You're so cool");

函数返回

logCompliment函数当前将赞美语句记录到控制台,但更常见的是,我们将使用函数来返回一个值。让我们为这个函数添加一个return语句。return语句指定函数返回的值。我们将函数重命名为createCompliment

const createCompliment = function(firstName, message) {
  return `${firstName}: ${message}`;
};

createCompliment("Molly", "You're so cool");

如果你想检查函数是否按预期执行,只需在console.log中包裹函数调用:

console.log(createCompliment("You're so cool", "Molly"));

默认参数

包括 C++和 Python 在内的语言允许开发人员为函数参数声明默认值。ES6 规范中包含默认参数,因此如果未为参数提供值,则将使用默认值。

例如,我们可以为参数nameactivity设置默认字符串:

function logActivity(name = "Shane McConkey", activity = "skiing") {
  console.log(`${name} loves ${activity}`);
}

如果logActivity函数没有提供参数,它将使用默认值正确运行。默认参数可以是任何类型,不仅仅是字符串:

const defaultPerson = {
  name: {
    first: "Shane",
    last: "McConkey"
  },
  favActivity: "skiing"
};

function logActivity(person = defaultPerson) {
  console.log(`${person.name.first} loves ${person.favActivity}`);
}

箭头函数

箭头函数是 ES6 的一个有用的新特性。使用箭头函数,可以创建函数而无需使用function关键字。通常也不需要使用return关键字。让我们考虑一个函数,它接受一个firstName并返回一个字符串,将这个人变成一个领主。任何人都可以成为一个领主:

const lordify = function(firstName) {
  return `${firstName} of Canterbury`;
};

console.log(lordify("Dale")); // Dale of Canterbury
console.log(lordify("Gail")); // Gail of Canterbury

使用箭头函数,我们可以极大地简化语法:

const lordify = firstName => `${firstName} of Canterbury`;

有了箭头,我们现在可以将整个函数声明放在一行上。移除了function关键字。我们还移除了return,因为箭头指向了应该返回的内容。另一个好处是,如果函数只接受一个参数,我们可以移除参数周围的括号。

多个参数应该用括号括起来:

// Typical function
const lordify = function(firstName, land) {
  return `${firstName} of ${land}`;
};
// Arrow Function
const lordify = (firstName, land) => `${firstName} of ${land}`;

console.log(lordify("Don", "Piscataway")); // Don of Piscataway
console.log(lordify("Todd", "Schenectady")); // Todd of Schenectady

我们可以将这个函数保持为一行,因为只有一个语句需要返回。如果有多行,你将使用大括号:

const lordify = (firstName, land) => {
  if (!firstName) {
    throw new Error("A firstName is required to lordify");
  }

  if (!land) {
    throw new Error("A lord must have a land");
  }

  return `${firstName} of ${land}`;
};

console.log(lordify("Kelly", "Sonoma")); // Kelly of Sonoma
console.log(lordify("Dave")); // ! JAVASCRIPT ERROR

这些if/else语句被括号包围,但仍然从箭头函数的更短语法中受益。

返回对象

如果你想返回一个对象会发生什么?考虑一个名为person的函数,它基于传入的参数firstNamelastName构建一个对象:

const person = (firstName, lastName) =>
    {
        first: firstName,
        last: lastName
    }

console.log(person("Brad", "Janson"));

一旦运行,你会看到错误:Uncaught SyntaxError: Unexpected token :。要解决这个问题,只需用括号包裹你返回的对象:

const person = (firstName, lastName) => ({
  first: firstName,
  last: lastName
});

console.log(person("Flad", "Hanson"));

这些缺少的括号是 JavaScript 和 React 应用程序中无数错误的根源,因此记住这一点非常重要!

箭头函数和作用域

普通函数不会阻塞 this。例如,在 setTimeout 回调中,this 变成了其他内容,而不是 tahoe 对象:

const tahoe = {
  mountains: ["Freel", "Rose", "Tallac", "Rubicon", "Silver"],
  print: function(delay = 1000) {
    setTimeout(function() {
      console.log(this.mountains.join(", "));
    }, delay);
  }
};

tahoe.print(); // Uncaught TypeError: Cannot read property 'join' of undefined

抛出此错误是因为它试图在 this 上使用 .join 方法。如果我们记录 this,我们会发现它指向 Window 对象:

console.log(this); // Window {}

为了解决这个问题,我们可以使用箭头函数语法来保护 this 的作用域:

const tahoe = {
  mountains: ["Freel", "Rose", "Tallac", "Rubicon", "Silver"],
  print: function(delay = 1000) {
    setTimeout(() => {
      console.log(this.mountains.join(", "));
    }, delay);
  }
};

tahoe.print(); // Freel, Rose, Tallac, Rubicon, Silver

这按预期工作,我们可以用逗号.join连接度假胜地。请注意始终牢记作用域。箭头函数不会阻止 this 的作用域:

const tahoe = {
  mountains: ["Freel", "Rose", "Tallac", "Rubicon", "Silver"],
  print: (delay = 1000) => {
    setTimeout(() => {
      console.log(this.mountains.join(", "));
    }, delay);
  }
};

tahoe.print(); // Uncaught TypeError: Cannot read property 'join' of undefined

print 函数更改为箭头函数意味着 this 实际上是 window

编译 JavaScript

当一个新的 JavaScript 特性被提出并获得支持时,社区经常希望在所有浏览器都支持之前开始使用它。确保您的代码能够正常工作的唯一方法是在浏览器运行之前将其转换为更广泛兼容的代码。这个过程称为编译。JavaScript 编译的最流行工具之一是Babel

在过去,使用最新的 JavaScript 特性的唯一方法是等待几周、几个月,甚至几年,直到浏览器支持它们。现在,Babel 使得立即使用 JavaScript 的最新特性成为可能。编译步骤使 JavaScript 变得类似于其他语言。它并不完全是传统的编译:我们的代码不会被编译成二进制代码。相反,它被转换为可以被更广泛范围的浏览器解释的语法。此外,JavaScript 现在有源代码,这意味着你的项目中会有一些文件,在浏览器中不会运行。

例如,让我们看一个带有一些默认参数的箭头函数:

const add = (x = 5, y = 10) => console.log(x + y);

如果我们对这段代码运行 Babel,它将生成以下内容:

"use strict";

var add = function add() {
  var x =
    arguments.length <= 0 || arguments[0] === undefined ? 5 : arguments[0];
  var y =
    arguments.length <= 1 || arguments[1] === undefined ? 10 : arguments[1];
  return console.log(x + y);
};

Babel 添加了一个“use strict”声明以运行严格模式。变量 xy 使用 arguments 数组进行了默认设置,这是您可能熟悉的一种技术。生成的 JavaScript 得到了更广泛的支持。

了解 Babel 工作原理的一个好方法是查看文档网站上的Babel REPL。在左侧输入一些新语法,然后查看创建的一些旧语法。

JavaScript 编译的过程通常由像 webpack 或 Parcel 这样的构建工具自动化。我们稍后会更详细地讨论这个问题。

对象和数组

自 ES2016 起,JavaScript 语法支持在对象和数组中创建变量作用域的创造性方式。这些创造性技术在 React 社区中被广泛使用。让我们看看其中一些,包括解构、对象字面量增强和展开运算符。

解构对象

解构赋值允许您在对象内部局部范围字段,并声明将使用哪些值。考虑 sandwich 对象。它有四个键,但我们只想使用两个的值。我们可以将 breadmeat 限定为在本地使用:

const sandwich = {
  bread: "dutch crunch",
  meat: "tuna",
  cheese: "swiss",
  toppings: ["lettuce", "tomato", "mustard"]
};

const { bread, meat } = sandwich;

console.log(bread, meat); // dutch crunch tuna

代码从对象中提取 breadmeat,并为它们创建局部变量。此外,由于我们使用 let 声明了这些解构变量,因此可以更改 breadmeat 变量,而不影响原始三明治:

const sandwich = {
  bread: "dutch crunch",
  meat: "tuna",
  cheese: "swiss",
  toppings: ["lettuce", "tomato", "mustard"]
};

let { bread, meat } = sandwich;

bread = "garlic";
meat = "turkey";

console.log(bread); // garlic
console.log(meat); // turkey

console.log(sandwich.bread, sandwich.meat); // dutch crunch tuna

我们还可以从传入的函数参数中解构。考虑这个函数,它将一个人的名字记录为贵族:

const lordify = regularPerson => {
  console.log(`${regularPerson.firstname} of Canterbury`);
};

const regularPerson = {
  firstname: "Bill",
  lastname: "Wilson"
};

lordify(regularPerson); // Bill of Canterbury

我们可以通过解构 regularPerson 来获取我们需要的值,而不是使用点符号语法深入对象:

const lordify = ({ firstname }) => {
  console.log(`${firstname} of Canterbury`);
};

const regularPerson = {
  firstname: "Bill",
  lastname: "Wilson"
};

lordify(regularPerson); // Bill of Canterbury

让我们进一步反映数据变化。现在,regularPerson 对象在 spouse 键上有一个新的嵌套对象:

const regularPerson = {
  firstname: "Bill",
  lastname: "Wilson",
  spouse: {
    firstname: "Phil",
    lastname: "Wilson"
  }
};

如果我们想要将配偶的名字昇华为贵族,我们需要稍微调整函数的解构参数:

const lordify = ({ spouse: { firstname } }) => {
  console.log(`${firstname} of Canterbury`);
};

lordify(regularPerson); // Phil of Canterbury

使用冒号和嵌套大括号,我们可以从 spouse 对象中解构出 firstname

数组解构

值也可以从数组中解构出来。想象一下,我们想要将数组的第一个值分配给一个名为 name 的变量:

const [firstAnimal] = ["Horse", "Mouse", "Cat"];

console.log(firstAnimal); // Horse

我们还可以使用 列表匹配 来跳过不必要的值,使用逗号。当逗号代替应该跳过的元素时,列表匹配发生。使用相同的数组,我们可以通过用逗号替换前两个值来访问最后一个值:

const [, , thirdAnimal] = ["Horse", "Mouse", "Cat"];

console.log(thirdAnimal); // Cat

在本节后面,我们将通过结合数组解构和扩展操作符来进一步说明这个例子。

对象文字增强

对象文字增强与解构相反。它是重新构造或将对象重新组合的过程。使用对象文字增强,我们可以从全局范围内获取变量并将它们添加到一个对象中:

const name = "Tallac";
const elevation = 9738;

const funHike = { name, elevation };

console.log(funHike); // {name: "Tallac", elevation: 9738}

nameelevation 现在是 funHike 对象的键。

我们还可以使用对象文字增强或重构来创建对象方法:

const name = "Tallac";
const elevation = 9738;
const print = function() {
  console.log(`Mt. ${this.name} is ${this.elevation} feet tall`);
};

const funHike = { name, elevation, print };

funHike.print(); // Mt. Tallac is 9738 feet tall

注意我们使用 this 来访问对象键。

在定义对象方法时,不再需要使用 function 关键字:

// Old
var skier = {
  name: name,
  sound: sound,
  powderYell: function() {
    var yell = this.sound.toUpperCase();
    console.log(`${yell} ${yell} ${yell}!!!`);
  },
  speed: function(mph) {
    this.speed = mph;
    console.log("speed:", mph);
  }
};

// New
const skier = {
  name,
  sound,
  powderYell() {
    let yell = this.sound.toUpperCase();
    console.log(`${yell} ${yell} ${yell}!!!`);
  },
  speed(mph) {
    this.speed = mph;
    console.log("speed:", mph);
  }
};

对象文字增强允许我们将全局变量引入对象,并通过使 function 关键字不再必要来减少输入。

扩展操作符

扩展操作符是三个点(...),执行多种不同的任务。首先,扩展操作符允许我们合并数组的内容。例如,如果我们有两个数组,我们可以创建一个第三个数组,将这两个数组合并成一个:

const peaks = ["Tallac", "Ralston", "Rose"];
const canyons = ["Ward", "Blackwood"];
const tahoe = [...peaks, ...canyons];

console.log(tahoe.join(", ")); // Tallac, Ralston, Rose, Ward, Blackwood

peakscanyons 中的所有项推入一个名为 tahoe 的新数组。

让我们看看扩展操作符如何帮助我们解决问题。使用前面示例中的 peaks 数组,假设我们想要抓取数组的最后一个项目而不是第一个项目。我们可以使用 Array.reverse 方法结合数组解构来反转数组:

const peaks = ["Tallac", "Ralston", "Rose"];
const [last] = peaks.reverse();

console.log(last); // Rose
console.log(peaks.join(", ")); // Rose, Ralston, Tallac

看到发生了什么了吗?reverse 函数实际上已经改变或突变了数组。在有了扩展操作符的世界中,我们不必改变原始数组。相反,我们可以创建一个数组副本,然后反转它:

const peaks = ["Tallac", "Ralston", "Rose"];
const [last] = [...peaks].reverse();

console.log(last); // Rose
console.log(peaks.join(", ")); // Tallac, Ralston, Rose

由于我们使用扩展操作符复制了数组,所以peaks数组仍然保持原样,可以稍后以其原始形式使用。

扩展操作符也可以用于获取数组中的剩余项:

const lakes = ["Donner", "Marlette", "Fallen Leaf", "Cascade"];

const [first, ...others] = lakes;

console.log(others.join(", ")); // Marlette, Fallen Leaf, Cascade

我们还可以使用三个点语法将函数参数收集为数组。在函数中使用时,这些称为rest parameters。在这里,我们构建一个函数,使用扩展操作符接收n个参数,然后使用这些参数打印一些控制台消息:

function directions(...args) {
  let [start, ...remaining] = args;
  let [finish, ...stops] = remaining.reverse();

  console.log(`drive through ${args.length} towns`);
  console.log(`start in ${start}`);
  console.log(`the destination is ${finish}`);
  console.log(`stopping ${stops.length} times in between`);
}

directions("Truckee", "Tahoe City", "Sunnyside", "Homewood", "Tahoma");

directions函数使用扩展操作符接收参数。第一个参数分配给start变量。使用Array.reverse将最后一个参数分配给finish变量。然后使用arguments数组的长度来显示我们要经过的城镇数量。停靠的数量是arguments数组长度减去finish停靠的数量。这提供了 incredible flexibility,因为我们可以使用directions函数处理任意数量的停靠。

扩展操作符也可用于对象(请参阅 GitHub 页面上的Rest/Spread Properties)。与数组一样,使用对象的扩展操作符是相似的。在这个例子中,我们将以与合并两个数组为同样的方式使用它,但不是数组,而是对象:

const morning = {
  breakfast: "oatmeal",
  lunch: "peanut butter and jelly"
};

const dinner = "mac and cheese";

const backpackingMeals = {
  ...morning,
  dinner
};

console.log(backpackingMeals);

// {
//   breakfast: "oatmeal",
//   lunch: "peanut butter and jelly",
//   dinner: "mac and cheese"
// }

异步 JavaScript

到目前为止,本章节的代码示例都是同步的。当我们编写同步 JavaScript 代码时,我们提供了一系列立即按顺序执行的指令。例如,如果我们想要使用 JavaScript 处理一些简单的 DOM 操作,我们会这样写代码:

const header = document.getElementById("heading");
header.innerHTML = "Hey!";

这些是指示。“嘿,去选择那个 id 为heading的元素。然后当你完成时,把它的内部 HTML 设置为。”它同步工作。在每个操作正在进行时,不会发生其他事情。

在现代网络中,我们需要执行异步任务。这些任务通常必须等待某些工作完成后才能完成。我们可能需要访问数据库。我们可能需要流式传输视频或音频内容。我们可能需要从 API 获取数据。使用 JavaScript,异步任务不会阻塞主线程。JavaScript 可以在等待 API 返回数据时做其他事情。JavaScript 在过去几年中已经发展了很多,使处理这些异步操作变得更容易。让我们探讨一些使这成为可能的功能。

使用 Fetch 进行简单的 Promise 处理

以前,向 REST API 发出请求相当麻烦。我们必须编写 20 多行嵌套代码才能将一些数据加载到我们的应用中。然后出现了fetch()函数,简化了我们的生活。感谢 ECMAScript 委员会使 fetch 成为可能。

让我们从 randomuser.me API 获取一些数据。这个 API 提供了假成员的电子邮件地址、姓名、电话号码、位置等信息,非常适合用作虚拟数据。fetch 接受此资源的 URL 作为其唯一参数:

console.log(fetch("https://api.randomuser.me/?nat=US&results=1"));

当我们记录这个时,我们看到有一个待处理的 promise。Promises 给了我们在 JavaScript 中理解异步行为的一种方式。Promise 是一个对象,表示异步操作是挂起、已完成还是失败。可以把这看作浏览器说,“嘿,我会尽力去获取这些数据。无论如何,我会回来告诉你结果的。”

所以回到 fetch 结果。待处理的 promise 表示在获取数据之前的状态。我们需要链接一个名为 .then() 的函数。这个函数将接收一个回调函数,如果上一个操作成功,就会运行这个函数。换句话说,获取一些数据,然后做其他事情。

我们想要做的另一件事是将响应转换为 JSON:

fetch("https://api.randomuser.me/?nat=US&results=1").then(res =>
  console.log(res.json())
);

then 方法在 promise 解析后将调用回调函数。从这个函数返回的任何内容都成为下一个 then 函数的参数。因此,我们可以链式调用 then 函数来处理已成功解析的 promise:

fetch("https://api.randomuser.me/?nat=US&results=1")
  .then(res => res.json())
  .then(json => json.results)
  .then(console.log)
  .catch(console.error);

首先,我们使用 fetch 发起 GET 请求到 randomuser.me。如果请求成功,我们将把响应主体转换为 JSON。接下来,我们将获取的 JSON 数据返回结果,然后将结果发送给 console.log 函数,它将把它们记录到控制台中。最后,还有一个 catch 函数,如果 fetch 未能成功解析,将调用回调。任何从 randomuser.me 获取数据时发生的错误都将基于该回调。在这里,我们简单地使用 console.error 将错误记录到控制台。

异步/等待

处理 promise 的另一种流行方法是创建一个异步函数。一些开发人员更喜欢异步函数的语法,因为它看起来更像是同步函数中的代码。与等待 promise 解析并使用一系列 then 函数处理不同,async 函数可以等待 promise 解析后再执行函数中找到的任何代码。

让我们再发起一个 API 请求,但用异步函数来包装功能:

const getFakePerson = async () => {
  let res = await fetch("https://api.randomuser.me/?nat=US&results=1");
  let { results } = res.json();
  console.log(results);
};

getFakePerson();

注意,getFakePerson 函数是使用 async 关键字声明的。这使它成为一个异步函数,可以等待 promise 解析后再执行任何后续代码。在 promise 调用之前使用 await 关键字。这告诉函数等待 promise 解析。这段代码完成了与前一节使用 then 函数的代码几乎相同的任务……

const getFakePerson = async () => {
  try {
    let res = await fetch("https://api.randomuser.me/?nat=US&results=1");
    let { results } = res.json();
    console.log(results);
  } catch (error) {
    console.error(error);
  }
};

getFakePerson();

就这样——现在这段代码完成了与上一节使用then函数的代码完全相同的任务。如果fetch调用成功,结果会被记录到控制台。如果失败,我们将使用console.error将错误记录到控制台。当使用asyncawait时,需要在trycatch块中包裹你的承诺调用,以处理由于未解决的承诺可能发生的任何错误。

构建承诺

在进行异步请求时,可能发生两种情况:一切如我们所希望的进行,或者出现错误。成功或失败的请求可能有许多不同类型。例如,我们可以尝试多种方式获取数据以达到成功的目的。我们也可能收到多种类型的错误。承诺为我们提供了一种简化为简单通过或失败的方法。

getPeople函数返回一个新的承诺。该承诺向 API 发出请求。如果承诺成功,数据将加载。如果承诺失败,将发生错误:

const getPeople = count =>
  new Promise((resolves, rejects) => {
    const api = `https://api.randomuser.me/?nat=US&results=${count}`;
    const request = new XMLHttpRequest();
    request.open("GET", api);
    request.onload = () =>
      request.status === 200
        ? resolves(JSON.parse(request.response).results)
        : reject(Error(request.statusText));
    request.onerror = err => rejects(err);
    request.send();
  });

随 有了这个,承诺已经创建,但尚未使用。我们可以通过调用getPeople函数并传入应该加载的成员数量来使用承诺。then函数可以链式调用,以在承诺完成后执行某些操作。当承诺被拒绝时,任何细节都会传递回catch函数,或者在使用async/await语法时传递到catch块:

getPeople(5)
  .then(members => console.log(members))
  .catch(error => console.error(`getPeople failed: ${error.message}`))
);

承诺使处理异步请求变得更加容易,这是很好的,因为我们在 JavaScript 中需要处理很多异步性。对异步行为的扎实理解对于现代 JavaScript 工程师至关重要。

在 ES2015 之前,JavaScript 规范中没有官方的类语法。当类被引入时,人们对类的语法与传统的面向对象语言如 Java 和 C++的相似性感到兴奋。过去几年中,React 库在构建用户界面组件时大量依赖类。如今,React 开始逐渐摆脱类的使用,改用函数来构建组件。你仍然会在各处看到类,特别是在遗留的 React 代码中以及 JavaScript 的世界中,所以让我们快速看一下它们。

JavaScript 使用一种叫做原型继承的东西。可以利用这种技术创建感觉像面向对象的结构。例如,我们可以创建一个需要使用new操作符调用的Vacation构造函数:

function Vacation(destination, length) {
  this.destination = destination;
  this.length = length;
}

Vacation.prototype.print = function() {
  console.log(this.destination + " | " + this.length + " days");
};

const maui = new Vacation("Maui", 7);

maui.print(); // Maui | 7 days

这段代码创建了一种感觉上像是面向对象语言中的自定义类型的东西。Vacation有属性(目的地、长度),并且有一个方法(print)。通过原型,maui实例继承了print方法。如果您是或曾经是习惯于更标准类的开发人员,这可能会让您充满深深的愤怒。ES2015 引入了类声明以平息这种愤怒,但肮脏的秘密是 JavaScript 仍然以同样的方式工作。函数是对象,并且通过原型处理继承。类提供了一种语法糖,覆盖了那个复杂的原型语法:

class Vacation {
  constructor(destination, length) {
    this.destination = destination;
    this.length = length;
  }

  print() {
    console.log(`${this.destination} will take ${this.length} days.`);
  }
}

当您创建一个类时,类名通常大写。一旦创建了类,可以使用new关键字创建类的新实例。然后可以调用类的自定义方法:

const trip = new Vacation("Santiago, Chile", 7);

trip.print(); // Chile will take 7 days.

现在已创建了一个类对象,可以随意使用它多次创建新的假期实例。类也可以被扩展。当扩展一个类时,子类将继承父类的属性和方法。这些属性和方法可以从这里操作,但默认情况下将全部继承。

您可以使用Vacation作为抽象类来创建不同类型的假期。例如,Expedition可以扩展Vacation类以包括装备:

class Expedition extends Vacation {
  constructor(destination, length, gear) {
    super(destination, length);
    this.gear = gear;
  }

  print() {
    super.print();
    console.log(`Bring your ${this.gear.join(" and your ")}`);
  }
}

这是简单的继承:子类继承了父类的属性。通过调用Vacationprint方法,我们可以在Expeditionprint方法打印的内容后添加一些新内容。创建新实例的方法与此完全相同——创建一个变量并使用new关键字:

const trip = new Expedition("Mt. Whitney", 3, [
  "sunglasses",
  "prayer flags",
  "camera"
]);

trip.print();

// Mt. Whitney will take 3 days.
// Bring your sunglasses and your prayer flags and your camera

ES6 模块

JavaScript 的模块是可重复使用的代码片段,可以轻松地并入其他 JavaScript 文件而不会引起变量冲突。JavaScript 模块存储在单独的文件中,每个模块一个文件。创建和导出模块有两个选项:可以从单个模块中导出多个 JavaScript 对象,或者每个模块导出一个 JavaScript 对象。

text-helpers.js中,导出了两个函数:

export const print=(message) => log(message, new Date())

export const log=(message, timestamp) =>
  console.log(`${timestamp.toString()}: ${message}`)

export可以用于导出将在另一个模块中使用的任何 JavaScript 类型。在此示例中,导出了print函数和log函数。在text-helpers.js中声明的任何其他变量将局限于该模块。

模块还可以导出单个主变量。在这些情况下,可以使用export default。例如,mt-freel.js文件可以导出特定的远征:

export default new Expedition("Mt. Freel", 2, ["water", "snack"]);

当您希望只导出一个类型时,可以使用export default代替export。同样,exportexport default可以用于任何 JavaScript 类型:原始类型、对象、数组和函数。

可以使用import语句在其他 JavaScript 文件中消耗模块。具有多个导出的模块可以利用对象解构。使用export default的模块将导入到单个变量中:

import { print, log } from "./text-helpers";
import freel from "./mt-freel";

print("printing a message");
log("logging a message");

freel.print();

您可以在不同变量名称下本地化模块变量作用域:

import { print as p, log as l } from "./text-helpers";

p("printing a message");
l("logging a message");

您还可以使用 * 将所有内容导入到单个变量中:

import * as fns from './text-helpers`

importexport 语法尚未完全被所有浏览器或 Node 支持。但与任何新兴的 JavaScript 语法一样,它受到 Babel 的支持。这意味着您可以在源代码中使用这些语句,而 Babel 将知道在编译的 JavaScript 中找到您想要包含的模块。

CommonJS

CommonJS 是由所有 Node 版本支持的模块模式(参见Node.js 模块文档)。您仍然可以通过 Babel 和 webpack 使用这些模块。在 CommonJS 中,JavaScript 对象使用 module.exports 导出。

例如,在 CommonJS 中,我们可以将 printlog 函数导出为一个对象:

const print(message) => log(message, new Date())

const log(message, timestamp) =>
console.log(`${timestamp.toString()}: ${message}`}

module.exports = {print, log}

CommonJS 不支持 import 语句。而是使用 require 函数导入模块:

const { log, print } = require("./txt-helpers");

JavaScript 确实在迅速发展,并适应工程师们对语言日益增长的需求,浏览器也在快速实现新特性。有关最新的兼容性信息,请参阅ESNext 兼容性表。许多最新 JavaScript 语法中包含的特性是因为它们支持函数式编程技术。在函数式 JavaScript 中,我们可以将代码视为一组可以组合成应用程序的函数。在接下来的章节中,我们将更详细地探讨函数式技术,并讨论为何您可能希望使用它们。

第三章:JavaScript 函数式编程

当您开始探索 React 时,您可能会注意到函数式编程这个话题经常被提及。函数式技术在 JavaScript 项目中被越来越多地使用,特别是在 React 项目中。

很可能您已经在不知不觉中编写了函数式 JavaScript 代码。如果您对数组进行了映射或减少操作,那么您已经在成为函数式 JavaScript 程序员的路上了。函数式编程技术不仅是 React 的核心,也是 React 生态系统中许多库的核心。

如果您想知道这种函数式趋势是从哪里来的,答案是 20 世纪 30 年代,随着lambda 演算或λ-演算的发明^(1)。函数自 17 世纪以来一直是微积分的一部分。函数可以作为参数传递给函数,或者作为函数的结果返回。更复杂的函数,称为高阶函数,可以操作函数并将它们用作参数或结果,或者两者兼而有之。在 20 世纪 30 年代,阿隆佐·丘奇在普林斯顿大学进行这些高阶函数的实验时发明了λ-演算。

在 20 世纪 50 年代末,约翰·麦卡锡将从λ-演算中得出的概念应用到了一种名为 Lisp 的新编程语言中。Lisp 实现了高阶函数的概念和作为一等公民的函数。当函数可以像变量一样被声明并作为参数传递给函数时,该函数被认为是一等公民。这些函数甚至可以从函数中返回。

在本章中,我们将介绍函数式编程的一些关键概念,并讨论如何在 JavaScript 中实现函数式技术。

什么是函数式编程意味着

JavaScript 支持函数式编程,因为 JavaScript 函数是一等公民。这意味着函数可以像变量一样执行相同的操作。最新的 JavaScript 语法增加了语言改进,可以增强您的函数式编程技术,包括箭头函数、Promise 和展开操作符。

在 JavaScript 中,函数可以表示应用程序中的数据。您可能已经注意到,您可以使用varletconst关键字声明函数,就像可以声明字符串、数字或任何其他变量一样:

var log = function(message) {
  console.log(message);
};

log("In JavaScript, functions are variables");

// In JavaScript, functions are variables

我们可以使用箭头函数来编写相同的函数。函数式程序员编写许多小函数,箭头函数语法使这一过程变得更加容易:

const log = message => {
  console.log(message);
};

既然函数是变量,我们可以将它们添加到对象中:

const obj = {
  message: "They can be added to objects like variables",
  log(message) {
    console.log(message);
  }
};

obj.log(obj.message);

// They can be added to objects like variables

这两个语句都执行同样的操作:它们将一个函数存储在名为log的变量中。此外,使用const关键字声明了第二个函数,这将防止它被覆盖。

我们还可以在 JavaScript 中向数组添加函数:

const messages = [
  "They can be inserted into arrays",
  message => console.log(message),
  "like variables",
  message => console.log(message)
];

messages1; // They can be inserted into arrays
messages3; // like variables

函数可以像其他变量一样作为参数传递给其他函数:

const insideFn = logger => {
  logger("They can be sent to other functions as arguments");
};

insideFn(message => console.log(message));

// They can be sent to other functions as arguments

它们也可以像变量一样从其他函数中返回:

const createScream = function(logger) {
  return function(message) {
    logger(message.toUpperCase() + "!!!");
  };
};

const scream = createScream(message => console.log(message));

scream("functions can be returned from other functions");
scream("createScream returns a function");
scream("scream invokes that returned function");

// FUNCTIONS CAN BE RETURNED FROM OTHER FUNCTIONS!!!
// CREATESCREAM RETURNS A FUNCTION!!!
// SCREAM INVOKES THAT RETURNED FUNCTION!!!

最后两个例子是高阶函数:可以接受或返回其他函数的函数。我们可以用箭头描述同样的createScream高阶函数:

const createScream = logger => message => {
  logger(message.toUpperCase() + "!!!");
};

如果在函数声明过程中看到多个箭头使用,这意味着你在使用高阶函数。

我们可以说 JavaScript 支持函数式编程,因为它的函数是一等公民。这意味着函数是数据。它们可以像变量一样被保存、检索或在你的应用程序中流动。

命令式与声明式

函数式编程是更大编程范式的一部分:声明式编程。声明式编程是一种编程风格,其结构化应用程序优先描述发生了什么而不是定义如何发生

为了理解声明式编程,我们将其与命令式编程进行对比,后者仅关注如何使用代码实现结果。让我们考虑一个常见任务:使字符串符合 URL 规范。通常,这可以通过用连字符替换字符串中的所有空格来完成,因为空格不符合 URL 规范。首先,让我们查看这个任务的命令式方法:

const string = "Restaurants in Hanalei";
const urlFriendly = "";

for (var i = 0; i < string.length; i++) {
  if (string[i] === " ") {
    urlFriendly += "-";
  } else {
    urlFriendly += string[i];
  }
}

console.log(urlFriendly); // "Restaurants-in-Hanalei"

在这个例子中,我们循环遍历字符串中的每个字符,替换空格。这个程序的结构只关心如何实现这样的任务。我们使用for循环和if语句,并用等号操作符设置值。仅仅看代码本身并不能告诉我们太多信息。命令式程序需要大量注释才能理解正在发生的事情。

现在让我们看看相同问题的声明式方法:

const string = "Restaurants in Hanalei";
const urlFriendly = string.replace(/ /g, "-");

console.log(urlFriendly);

在这里,我们使用string.replace和正则表达式来替换字符串中所有空格的实例为连字符。使用string.replace描述了应该发生的事情:字符串中的空格应该被替换。如何处理空格的细节被抽象在replace函数内部。在声明式程序中,语法本身描述了应该发生什么,如何发生的细节被抽象了起来。

由于代码本身描述了正在发生的事情,声明式程序易于推理。例如,阅读以下示例中的语法。它详细描述了从 API 加载成员后发生的事情:

const loadAndMapMembers = compose(
  combineWith(sessionStorage, "members"),
  save(sessionStorage, "members"),
  scopeMembers(window),
  logMemberInfoToConsole,
  logFieldsToConsole("name.first"),
  countMembersBy("location.state"),
  prepStatesForMapping,
  save(sessionStorage, "map"),
  renderUSMap
);

getFakeMembers(100).then(loadAndMapMembers);

声明式方法更易读,因此更容易推理。如何实现每个函数的细节都被抽象化了。这些小函数命名良好,并以描述数据成员如何从加载到保存并打印在地图上的方式组合在一起,这种方法不需要太多注释。基本上,声明式编程产生了更易于推理的应用程序,当一个应用程序更容易推理时,它就更容易扩展。有关声明式编程范式的更多详细信息,请参阅声明式编程 wiki

现在,让我们考虑构建文档对象模型(DOM)的任务,或者DOM。命令式方法关注 DOM 如何构建:

const target = document.getElementById("target");
const wrapper = document.createElement("div");
const headline = document.createElement("h1");

wrapper.id = "welcome";
headline.innerText = "Hello World";

wrapper.appendChild(headline);
target.appendChild(wrapper);

该代码关注创建元素,设置元素并将它们添加到文档中。在构建 DOM 的 10,000 行代码中进行更改,添加功能或扩展将非常困难。

现在让我们看看如何使用 React 组件声明性地构建 DOM:

const { render } = ReactDOM;

const Welcome = () => (
  <div id="welcome">
    <h1>Hello World</h1>
  </div>
);

render(<Welcome />, document.getElementById("target"));

React 是声明性的。在这里,Welcome组件描述了应该渲染的 DOM。render函数使用组件中声明的指令构建 DOM,抽象出 DOM 如何被渲染的细节。我们可以清楚地看到我们想要将Welcome组件渲染到 ID 为target的元素中。

Functional Concepts

现在您已经了解了函数式编程及其“功能性”或“声明性”含义,我们将介绍函数式编程的核心概念:不可变性、纯度、数据转换、高阶函数和递归。

不可变性

变异是改变的意思,所以不可变意味着不可改变。在功能性程序中,数据是不可变的。它永远不会改变。

如果您需要与公众分享您的出生证明,但又想删除或遮盖私人信息,您基本上有两个选择:您可以拿一支大尖笔在原始出生证明上涂抹并划掉您的私人数据,或者您可以找一台复印机。找到一台复印机,复印您的出生证明,并用大尖笔在复印件上写字会更可取。这样,您就可以有一个被遮盖的出生证明可以分享,而您的原件则完好无损。

这就是应用程序中不可变数据的工作方式。我们不改变原始数据结构,而是构建这些数据结构的更改副本并使用它们。

要理解不可变性如何工作,让我们看看什么是数据变异。考虑一个代表颜色lawn的对象:

let color_lawn = {
  title: "lawn",
  color: "#00FF00",
  rating: 0
};

我们可以构建一个函数来评估颜色,并使用该函数来更改color对象的评级:

function rateColor(color, rating) {
  color.rating = rating;
  return color;
}

console.log(rateColor(color_lawn, 5).rating); // 5
console.log(color_lawn.rating); // 5

在 JavaScript 中,函数参数是对实际数据的引用。像这样设置颜色的评分会改变或突变原始颜色对象。 (想象一下,如果你让一家企业涂黑重要细节并分享你的出生证明,他们返回的是原件,你会希望企业有常识复印你的出生证明并将原件无损返回。)我们可以重写rateColor函数,使其不会损害原始物品(color对象):

const rateColor = function(color, rating) {
  return Object.assign({}, color, { rating: rating });
};

console.log(rateColor(color_lawn, 5).rating); // 5
console.log(color_lawn.rating); // 0

在这里,我们使用Object.assign来更改颜色评分。Object.assign就像复印机。它获取一个空对象,将颜色复制到该对象中,并在副本上覆盖评分。现在我们可以得到一个新评分的颜色对象,而无需更改原始对象。

我们可以使用箭头函数和对象扩展运算符来编写相同的函数。这个rateColor函数使用扩展运算符将颜色复制到一个新对象中,然后覆盖其评分:

const rateColor = (color, rating) => ({
  ...color,
  rating
});

这个rateColor函数的版本与之前的完全相同。它将颜色视为不可变对象,语法更少,看起来更清晰一些。请注意,我们用括号包裹返回的对象。使用箭头函数时,这是一个必需的步骤,因为箭头不能简单地指向对象的花括号。

让我们考虑一个颜色名称的数组:

let list = [{ title: "Rad Red" }, { title: "Lawn" }, { title: "Party Pink" }];

我们可以创建一个使用Array.push将颜色添加到该数组的函数:

const addColor = function(title, colors) {
  colors.push({ title: title });
  return colors;
};

console.log(addColor("Glam Green", list).length); // 4
console.log(list.length); // 4

然而,Array.push并非是一个不可变函数。这个addColor函数通过向其添加另一个字段来改变原始数组。为了保持colors数组的不可变性,我们必须改用Array.concat

const addColor = (title, array) => array.concat({ title });

console.log(addColor("Glam Green", list).length); // 4
console.log(list.length); // 3

Array.concat用于连接数组。在这种情况下,它获取一个带有新颜色标题的新对象,并将其添加到原始数组的副本中。

您还可以使用扩展运算符来连接数组,就像它用于复制对象一样。这是先前addColor函数的新兴 JavaScript 等效版本:

const addColor = (title, list) => [...list, { title }];

这个函数将原始列表复制到一个新数组中,然后将包含颜色标题的新对象添加到该副本中。它是不可变的。

纯函数

纯函数是根据其参数计算值并返回的函数。纯函数至少接受一个参数并始终返回一个值或另一个函数。它们不会引起副作用,不会设置全局变量,也不会改变应用程序状态的任何内容。它们将其参数视为不可变数据。

为了理解纯函数,让我们先看一个不纯的函数:

const frederick = {
  name: "Frederick Douglass",
  canRead: false,
  canWrite: false
};

function selfEducate() {
  frederick.canRead = true;
  frederick.canWrite = true;
  return frederick;
}

selfEducate();
console.log(frederick);

// {name: "Frederick Douglass", canRead: true, canWrite: true}

selfEducate函数不是一个纯函数。它不接受任何参数,也不返回值或函数。它还在其范围之外更改了一个变量:Frederick。一旦调用selfEducate函数,某些关于“世界”的东西就会改变。它引起了副作用:

const frederick = {
  name: "Frederick Douglass",
  canRead: false,
  canWrite: false
};

const selfEducate = person => {
  person.canRead = true;
  person.canWrite = true;
  return person;
};

console.log(selfEducate(frederick));
console.log(frederick);

// {name: "Frederick Douglass", canRead: true, canWrite: true}
// {name: "Frederick Douglass", canRead: true, canWrite: true}

纯函数是可测试的

纯函数自然是可测试的。它们不改变其环境或“世界”的任何内容,因此不需要复杂的测试设置或拆卸。纯函数运行所需的一切都通过参数访问。测试纯函数时,您可以控制参数,从而可以估计结果。此selfEducate函数也是不纯的:它会引起副作用。调用此函数会改变发送到它的对象。如果我们能将发送到此函数的参数视为不可变数据,则会得到纯函数。

让我们让这个函数接受一个参数:

const frederick = {
  name: "Frederick Douglass",
  canRead: false,
  canWrite: false
};

const selfEducate = person => ({
  ...person,
  canRead: true,
  canWrite: true
});

console.log(selfEducate(frederick));
console.log(frederick);

// {name: "Frederick Douglass", canRead: true, canWrite: true}
// {name: "Frederick Douglass", canRead: false, canWrite: false}

最后,此版本的selfEducate是一个纯函数。它根据发送到它的参数——person来计算一个值。它返回一个新的person对象,而不是改变发送到它的参数,因此没有副作用。

现在让我们来看一个会改变 DOM 的不纯函数的例子:

function Header(text) {
  let h1 = document.createElement("h1");
  h1.innerText = text;
  document.body.appendChild(h1);
}

Header("Header() caused side effects");

Header函数创建一个标题——一个具有特定文本的元素,并将其添加到 DOM 中。此函数是不纯的。它不返回函数或值,并且引起副作用:更改的 DOM。

在 React 中,UI 是用纯函数表示的。在以下示例中,Header是一个纯函数,可以用来创建h1元素,就像前面的示例中一样。但是,此函数本身不会引起副作用,因为它不会改变 DOM。此函数将创建一个h1元素,应用程序的其他部分将使用该元素来更改 DOM:

const Header = props => <h1>{props.title}</h1>;

纯函数是函数式编程的另一个核心概念。它们将极大地简化您的生活,因为它们不会影响应用程序的状态。编写函数时,请尝试遵循以下三条规则:

  1. 该函数应至少接受一个参数。

  2. 该函数应返回一个值或另一个函数。

  3. 该函数不应更改或改变其任何参数。

数据转换

如果数据是不可变的,应用程序中的任何内容如何变化?函数式编程完全是关于将数据从一种形式转换为另一种形式。我们将使用函数生成转换后的副本。这些函数使我们的代码更少命令式,从而减少了复杂性。

您不需要特殊的框架来理解如何生成基于其他数据集的数据集。JavaScript 已经内置了执行此任务所需的工具。有两个核心函数必须掌握以精通函数式 JavaScript:Array.mapArray.reduce

在本节中,我们将看看这些和其他一些核心函数如何将数据从一种类型转换为另一种类型。

考虑这个高中数组:

const schools = ["Yorktown", "Washington & Liberty", "Wakefield"];

我们可以使用Array.join函数获得这些和其他一些字符串的逗号分隔列表:

console.log(schools.join(", "));

// "Yorktown, Washington & Liberty, Wakefield"

Array.join 是一个内置的 JavaScript 数组方法,我们可以使用它从数组中提取一个带有分隔符的字符串。原始数组仍然完好无损;join 只是提供了不同的视角。产生此字符串的具体细节对程序员来说是抽象的。

如果我们想创建一个函数,用于创建一个以字母“W”开头的学校的新数组,我们可以使用 Array.filter 方法:

const wSchools = schools.filter(school => school[0] === "W");

console.log(wSchools);
// ["Washington & Liberty", "Wakefield"]

Array.filter 是 JavaScript 中的内置函数,可以从源数组生成一个新数组。这个函数接受一个 谓词 作为其唯一参数。谓词是一个始终返回布尔值(truefalse)的函数。Array.filter 对数组中的每个项目调用一次这个谓词。该项目作为参数传递给谓词,返回值用于决定是否将该项目添加到新数组中。在这种情况下,Array.filter 正在检查每个学校是否以“W”开头的名字。

当需要从数组中删除项目时,应该使用 Array.filter 而不是 Array.popArray.splice,因为 Array.filter 是不可变的。在下一个示例中,cutSchool 函数返回新的数组,过滤掉特定的学校名称:

const cutSchool = (cut, list) => list.filter(school => school !== cut);

console.log(cutSchool("Washington & Liberty", schools).join(", "));

// "Yorktown, Wakefield"

console.log(schools.join("\n"));

// Yorktown
// Washington & Liberty
// Wakefield

在这种情况下,cutSchool 函数用于返回一个不包含“Washington & Liberty”的新数组。然后,使用这个新数组与 join 函数一起创建包含剩余两个学校名称的字符串。cutSchool 是一个纯函数。它接受学校列表和应该删除的学校名称,然后返回一个不包含特定学校的新数组。

函数式编程中另一个关键的数组函数是 Array.mapArray.map 方法不像谓词那样接受一个谓词,而是接受一个函数作为其参数。这个函数将为数组中的每个项目调用一次,并将其返回值添加到新数组中:

const highSchools = schools.map(school => `${school} High School`);

console.log(highSchools.join("\n"));

// Yorktown High School
// Washington & Liberty High School
// Wakefield High School

console.log(schools.join("\n"));

// Yorktown
// Washington & Liberty
// Wakefield

在这种情况下,map 函数用于在每个学校名称后附加“High School”。schools 数组仍然完好无损。

在最后的例子中,我们从一个字符串数组生成了一个字符串对象数组。map 函数可以生成对象、值、数组、其他函数——任何 JavaScript 类型的数组。这是 map 函数为每个学校返回一个对象的示例:

const highSchools = schools.map(school => ({ name: school }));

console.log(highSchools);

// [
// { name: "Yorktown" },
// { name: "Washington & Liberty" },
// { name: "Wakefield" }
// ]

从包含字符串的数组生成了一个包含对象的数组。

如果需要创建一个纯函数,用于更改对象数组中的一个对象,也可以使用 map。在下面的例子中,我们将名为“Stratford”的学校改名为“HB Woodlawn”,而不会改变 schools 数组:

let schools = [
  { name: "Yorktown" },
  { name: "Stratford" },
  { name: "Washington & Liberty" },
  { name: "Wakefield" }
];

let updatedSchools = editName("Stratford", "HB Woodlawn", schools);

console.log(updatedSchools[1]); // { name: "HB Woodlawn" }
console.log(schools[1]); // { name: "Stratford" }

schools 数组是一个包含对象的数组。updatedSchools 变量调用 editName 函数,我们向其发送要更新的学校、新学校和 schools 数组。这会更改新数组,但不会编辑原始数组:

const editName = (oldName, name, arr) =>
  arr.map(item => {
    if (item.name === oldName) {
      return {
        ...item,
        name
      };
    } else {
      return item;
    }
  });

editName 中,使用 map 函数基于原始数组创建一个新对象数组。 editName 函数可以完全在一行中编写。 下面是使用简写 if/else 语句的相同函数示例:

const editName = (oldName, name, arr) =>
  arr.map(item => (item.name === oldName ? { ...item, name } : item));

如果需要将数组转换为对象,可以结合使用 Array.mapObject.keysObject.keys 是一个方法,用于从对象中返回键数组。

假设我们需要将 schools 对象转换为一个学校数组:

const schools = {
  Yorktown: 10,
  "Washington & Liberty": 2,
  Wakefield: 5
};

const schoolArray = Object.keys(schools).map(key => ({
  name: key,
  wins: schools[key]
}));

console.log(schoolArray);

// [
// {
// name: "Yorktown",
// wins: 10
// },
// {
// name: "Washington & Liberty",
// wins: 2
// },
// {
// name: "Wakefield",
// wins: 5
// }
// ]

在这个例子中,Object.keys 返回一个学校名称数组,我们可以对该数组使用 map 来生成一个相同长度的新数组。新对象的 name 将使用键设置,而 wins 则设置为相应的值。

到目前为止,我们已经学习了如何使用 Array.mapArray.filter 转换数组。 我们还学习了如何通过组合 Object.keysArray.map 将数组转换为对象。 在我们的功能工具中,还需要一个工具,即将数组转换为基本类型和其他对象的能力。

reducereduceRight 函数可以用于将数组转换为任何值,包括数字、字符串、布尔值、对象,甚至是函数。

假设我们需要在一个数字数组中找到最大数。 我们需要将数组转换为一个数字,因此可以使用 reduce

const ages = [21, 18, 42, 40, 64, 63, 34];

const maxAge = ages.reduce((max, age) => {
  console.log(`${age} > ${max} = ${age > max}`);
  if (age > max) {
    return age;
  } else {
    return max;
  }
}, 0);

console.log("maxAge", maxAge);

// 21 > 0 = true
// 18 > 21 = false
// 42 > 21 = true
// 40 > 42 = false
// 64 > 42 = true
// 63 > 64 = false
// 34 > 64 = false
// maxAge 64

ages 数组已被减少为单个值:最大年龄 64reduce 接受两个参数:回调函数和原始值。 在本例中,原始值为 0,将最初的最大值设置为 0。 每个数组项都会调用回调函数一次。 第一次调用此回调时,age 等于数组中的第一个值 21max 等于初始值 0。 回调返回两个数中较大的那个,21 将成为下一次迭代期间的 max 值。 每次迭代都会将每个 agemax 值进行比较,并返回两者中较大的值。 最后,比较并返回数组中的最后一个数字。

如果我们从前面的函数中移除 console.log 语句,并使用简写 if/else 语句,则可以使用以下语法计算任何数字数组中的最大值:

const max = ages.reduce((max, value) => (value > max ? value : max), 0);

Array.reduceRight

Array.reduceRight 的工作方式与 Array.reduce 相同;区别在于它从数组末尾开始减少,而不是从开头开始。

有时我们需要将数组转换为对象。 以下示例使用 reduce 将包含颜色的数组转换为哈希:

const colors = [
  {
    id: "xekare",
    title: "rad red",
    rating: 3
  },
  {
    id: "jbwsof",
    title: "big blue",
    rating: 2
  },
  {
    id: "prigbj",
    title: "grizzly grey",
    rating: 5
  },
  {
    id: "ryhbhsl",
    title: "banana",
    rating: 1
  }
];

const hashColors = colors.reduce((hash, { id, title, rating }) => {
  hash[id] = { title, rating };
  return hash;
}, {});

console.log(hashColors);

// {
// "xekare": {
// title:"rad red",
// rating:3
// },
// "jbwsof": {
// title:"big blue",
// rating:2
// },
// "prigbj": {
// title:"grizzly grey",
// rating:5
// },
// "ryhbhsl": {
// title:"banana",
// rating:1
// }
// }

在这个例子中,发送给reduce函数的第二个参数是一个空对象。这是我们的哈希的初始值。在每次迭代中,回调函数使用方括号表示法向哈希添加一个新的键,并将该键的值设置为数组的id字段。Array.reduce可以这样使用,将数组减少为一个单一值,即在本例中是一个对象。

我们甚至可以使用reduce将数组转换为完全不同的数组。考虑将一个包含多个相同值实例的数组减少为一个唯一值数组。可以使用reduce方法完成这个任务:

const colors = ["red", "red", "green", "blue", "green"];

const uniqueColors = colors.reduce(
  (unique, color) =>
    unique.indexOf(color) !== -1 ? unique : [...unique, color],
  []
);

console.log(uniqueColors);

// ["red", "green", "blue"]

在这个例子中,colors数组被减少为一个包含不同值的数组。发送给reduce函数的第二个参数是一个空数组。这将是distinct的初始值。当distinct数组中还没有包含特定颜色时,它将被添加进去。否则,它将被跳过,并返回当前的distinct数组。

mapreduce是任何函数式程序员的主要工具,JavaScript 也不例外。如果你想成为一名熟练的 JavaScript 工程师,那么你必须掌握这些函数。从一个数据集创建另一个数据集的能力是一项必备技能,对任何类型的编程范式都有用。

高阶函数

使用高阶函数对于函数式编程也是必不可少的。我们已经提到过高阶函数,甚至在本章中使用了一些。高阶函数是可以操作其他函数的函数。它们可以接受函数作为参数,或者返回函数,或者两者兼而有之。

第一类高阶函数是期望其他函数作为参数的函数。Array.mapArray.filterArray.reduce都接受函数作为参数。它们都是高阶函数。

让我们看看如何实现一个高阶函数。在下面的例子中,我们创建了一个invokeIf回调函数,它将测试一个条件,并在条件为真时调用一个回调函数,在条件为假时调用另一个回调函数:

const invokeIf = (condition, fnTrue, fnFalse) =>
  condition ? fnTrue() : fnFalse();

const showWelcome = () => console.log("Welcome!!!");

const showUnauthorized = () => console.log("Unauthorized!!!");

invokeIf(true, showWelcome, showUnauthorized); // "Welcome!!!"
invokeIf(false, showWelcome, showUnauthorized); // "Unauthorized!!!"

invokeIf期望两个函数:一个用于真,一个用于假。通过将showWelcomeshowUnauthorized都发送到invokeIf来演示这一点。当条件为真时,将调用showWelcome。当条件为假时,将调用showUnauthorized

返回其他函数的高阶函数可以帮助我们处理 JavaScript 中的异步复杂性。它们可以帮助我们创建可以在我们方便时使用或重复使用的函数。

柯里化是一种涉及高阶函数使用的函数式技术。

下面是柯里化的一个例子。userLogs 函数保存了一些信息(用户名),并返回一个函数,当其他信息(消息)可用时可以使用和重复使用该函数。在这个例子中,所有的日志消息都将以相关的用户名作为前缀。请注意,我们在这里使用了getFakeMembers函数,它从第二章返回一个承诺:

const userLogs = userName => message =>
  console.log(`${userName} -> ${message}`);

const log = userLogs("grandpa23");

log("attempted to load 20 fake members");
getFakeMembers(20).then(
  members => log(`successfully loaded ${members.length} members`),
  error => log("encountered an error loading members")
);

// grandpa23 -> attempted to load 20 fake members
// grandpa23 -> successfully loaded 20 members

// grandpa23 -> attempted to load 20 fake members
// grandpa23 -> encountered an error loading members

userLogs 是高阶函数。log 函数是从 userLogs 生成的,每次使用 log 函数时,“grandpa23”都会被添加到消息前面。

递归

递归是一种涉及创建函数并调用自身的技术。通常,在面对需要循环的挑战时,可以使用递归函数代替。考虑从 10 开始倒数的任务。我们可以创建一个for循环来解决这个问题,或者我们可以使用递归函数。在这个例子中,countdown就是递归函数:

const countdown = (value, fn) => {
  fn(value);
  return value > 0 ? countdown(value - 1, fn) : value;
};

countdown(10, value => console.log(value));

// 10
// 9
// 8
// 7
// 6
// 5
// 4
// 3
// 2
// 1
// 0

countdown 函数期望一个数字和一个函数作为参数。在这个例子中,它被调用时传入了一个值为10和一个回调函数。当调用countdown时,会调用回调函数,该函数记录当前值。接下来,countdown检查该值是否大于0。如果是,则countdown使用递减后的值重新调用自身。最终,值将为0,并且countdown将该值返回到调用堆栈的顶部。

递归是一种特别适合异步过程的模式。当数据可用或计时器完成时,函数可以在准备好时重新调用自身。

countdown 函数可以修改为带有延迟的倒计时。这个修改后的countdown函数可以用来创建一个倒计时时钟:

const countdown = (value, fn, delay = 1000) => {
  fn(value);
  return value > 0
    ? setTimeout(() => countdown(value - 1, fn, delay), delay)
    : value;
};

const log = value => console.log(value);
countdown(10, log);

在这个例子中,我们通过最初使用数字10调用countdown一次,并在一个记录倒计时的函数中创建了一个 10 秒的倒计时。而不是立即重新调用自身,countdown函数等待一秒钟再重新调用自身,从而创建一个时钟。

递归是搜索数据结构的一个好技术。你可以使用递归来迭代子文件夹,直到找到只包含文件的文件夹为止。你也可以使用递归来迭代 HTML DOM,直到找到不包含任何子元素的元素为止。在下一个例子中,我们将使用递归深入迭代对象,以检索嵌套的值:

const dan = {
  type: "person",
  data: {
    gender: "male",
    info: {
      id: 22,
      fullname: {
        first: "Dan",
        last: "Deacon"
      }
    }
  }
};

deepPick("type", dan); // "person"
deepPick("data.info.fullname.first", dan); // "Dan"

deepPick 可以用来访问 Dan 的类型,在第一个对象中立即存储,或者深入到嵌套对象中查找 Dan 的名字。通过发送使用点符号表示法的字符串,我们可以指定在对象中查找嵌套值的位置:

const deepPick = (fields, object = {}) => {
  const [first, ...remaining] = fields.split(".");
  return remaining.length
    ? deepPick(remaining.join("."), object[first])
    : object[first];
};

deepPick 函数要么返回一个值,要么反复调用自身,直到最终返回一个值。首先,该函数将点符号字段字符串拆分为数组,并使用数组解构将第一个值与剩余值分开。如果有剩余值,deepPick 会使用略有不同的数据重新调用自身,从而使其深入挖掘一层。

此函数继续调用自身,直到字段字符串不再包含点号,这意味着没有更多的剩余字段。在此示例中,您可以看到 deepPick 迭代时 firstremainingobject[first] 的值如何变化:

deepPick("data.info.fullname.first", dan); // "Dan"

// First Iteration
// first = "data"
// remaining.join(".") = "info.fullname.first"
// object[first] = { gender: "male", {info} }

// Second Iteration
// first = "info"
// remaining.join(".") = "fullname.first"
// object[first] = {id: 22, {fullname}}

// Third Iteration
// first = "fullname"
// remaining.join("." = "first"
// object[first] = {first: "Dan", last: "Deacon" }

// Finally...
// first = "first"
// remaining.length = 0
// object[first] = "Deacon"

递归是一种强大的函数技术,实现起来很有趣。

组合

函数式程序将其逻辑分解为小的纯函数,这些函数专注于特定的任务。最终,您需要将这些较小的函数组合在一起。具体来说,您可能需要将它们组合、按顺序或并行调用,或者将它们组合成更大的函数,直到最终形成一个应用程序。

在组合方面,有许多不同的实现、模式和技术。您可能熟悉的其中一种是链式调用。在 JavaScript 中,可以使用点符号将函数链接在一起,以在前一个函数的返回值上执行操作。

字符串有一个替换方法。替换方法返回一个模板字符串,该模板字符串也将有一个替换方法。因此,我们可以使用点符号将替换方法链在一起,以转换字符串:

const template = "hh:mm:ss tt";
const clockTime = template
  .replace("hh", "03")
  .replace("mm", "33")
  .replace("ss", "33")
  .replace("tt", "PM");

console.log(clockTime);

// "03:33:33 PM"

在此示例中,模板是一个字符串。通过将替换方法链接到模板字符串的末尾,我们可以用新值替换字符串中的小时、分钟、秒和时间。模板本身保持不变,可以重复使用以创建更多时钟时间显示。

both 函数是一个将值传递到两个单独函数的管道函数。平民小时的输出成为 appendAMPM 的输入,并且我们可以将这两个函数结合成一个来改变日期:

const both = date => appendAMPM(civilianHours(date));

然而,这种语法难以理解,因此难以维护或扩展。当我们需要将值通过 20 个不同的函数时会发生什么?

更优雅的方法是创建一个高阶函数,我们可以用它来将函数组合成更大的函数:

const both = compose(
  civilianHours,
  appendAMPM
);

both(new Date());

这种方法看起来好多了。它易于扩展,因为我们可以在任何时候添加更多函数。这种方法还使得更改组合函数的顺序变得容易。

compose 函数是一个高阶函数。它接受函数作为参数并返回单个值:

const compose = (...fns) => arg =>
  fns.reduce((composed, f) => f(composed), arg);

compose接受函数作为参数并返回一个单一函数。在这个实现中,展开运算符用于将这些函数参数转换为一个名为fns的数组。然后返回一个函数,该函数期望一个参数arg。当调用此函数时,fns数组从我们想要通过函数发送的参数开始进行管道传输。参数成为compose的初始值,然后每个缩减回调的迭代返回。请注意,回调函数接受两个参数:组合的值和一个函数f。每个函数都使用compose调用,这是前一个函数输出的结果。最终,将调用最后一个函数并返回最后的结果。

这是一个简单的compose函数示例,旨在说明组合技术。当处理超过一个参数或处理非函数参数时,该函数变得更加复杂。

把一切放在一起

现在我们已经介绍了函数式编程的核心概念,让我们将这些概念付诸实践,并为我们构建一个小型 JavaScript 应用程序。

我们的挑战是构建一个滴答作响的时钟。时钟需要以民用时间显示小时、分钟、秒和上午/下午时间。每个字段必须始终有两位数,这意味着需要为类似于 1 或 2 的单个数字值应用前导零。时钟必须每秒滴答一次并更改显示。

首先,让我们回顾一下时钟的命令式解决方案:

// Log Clock Time every Second
setInterval(logClockTime, 1000);

function logClockTime() {
  // Get Time string as civilian time
  let time = getClockTime();

  // Clear the Console and log the time
  console.clear();
  console.log(time);
}

function getClockTime() {
  // Get the Current Time
  let date = new Date();
  let time = "";

  // Serialize clock time
  let time = {
    hours: date.getHours(),
    minutes: date.getMinutes(),
    seconds: date.getSeconds(),
    ampm: "AM"
  };

  // Convert to civilian time
  if (time.hours == 12) {
    time.ampm = "PM";
  } else if (time.hours > 12) {
    time.ampm = "PM";
    time.hours -= 12;
  }

  // Prepend a 0 on the hours to make double digits
  if (time.hours < 10) {
    time.hours = "0" + time.hours;
  }

  // prepend a 0 on the minutes to make double digits
  if (time.minutes < 10) {
    time.minutes = "0" + time.minutes;
  }

  // prepend a 0 on the seconds to make double digits
  if (time.seconds < 10) {
    time.seconds = "0" + time.seconds;
  }

  // Format the clock time as a string "hh:mm:ss tt"
  return time.hours + ":" + time.minutes + ":" + time.seconds + " " + time.ampm;
}

这个解决方案有效,注释帮助我们理解发生了什么。然而,这些函数很大且复杂。每个函数做了很多事情。它们难以理解,需要注释,并且难以维护。让我们看看函数式方法如何产生一个更可扩展的应用程序。

我们的目标是将应用程序逻辑分解为更小的部分:函数。每个函数将专注于一个单一任务,并将它们组合成更大的函数,以便我们可以用来创建时钟。

首先,让我们创建一些给出值并管理控制台的函数。我们需要一个给出一秒的函数,一个给出当前时间的函数,以及几个将在控制台上记录消息并清除控制台的函数。在函数式程序中,我们应该尽可能使用函数而不是值。在需要时,我们将调用函数来获取值:

const oneSecond = () => 1000;
const getCurrentTime = () => new Date();
const clear = () => console.clear();
const log = message => console.log(message);

接下来,我们需要一些用于转换数据的函数。这三个函数将用于将Date对象变异为一个可用于我们时钟的对象:

serializeClockTime

获取一个日期对象并返回一个包含小时、分钟和秒的时钟时间对象。

civilianHours

获取时钟时间对象并返回一个对象,其中小时转换为民用时间。例如:1300 变为 1:00。

appendAMPM

接收时钟时间对象并将上午或下午时间附加到该对象。

const serializeClockTime = date => ({
  hours: date.getHours(),
  minutes: date.getMinutes(),
  seconds: date.getSeconds()
});

const civilianHours = clockTime => ({
  ...clockTime,
  hours: clockTime.hours > 12 ? clockTime.hours - 12 : clockTime.hours
});

const appendAMPM = clockTime => ({
  ...clockTime,
  ampm: clockTime.hours >= 12 ? "PM" : "AM"
});

这三个函数用于在不改变原始数据的情况下转换数据。它们将其参数视为不可变对象。

接下来,我们需要几个高阶函数:

display

采取一个目标函数,并返回一个函数,该函数将时间发送到目标。在这个例子中,目标是console.log

formatClock

采取一个模板字符串,并使用它根据字符串中的标准返回格式化的时钟时间。在这个例子中,模板是“hh:mm:ss tt”。从那里,formatClock将用小时、分钟、秒和日间时间替换占位符。

prependZero

采取一个对象的键作为参数,并在该对象键下存储的值前加零。如果值小于 10,它将键入一个特定字段并在值前加零。

const display = target => time => target(time);

const formatClock = format => time =>
  format
    .replace("hh", time.hours)
    .replace("mm", time.minutes)
    .replace("ss", time.seconds)
    .replace("tt", time.ampm);

const prependZero = key => clockTime => ({
  ...clockTime,
  key: clockTime[key] < 10 ? "0" + clockTime[key] : clockTime[key]
});

这些高阶函数将被调用来创建用于格式化每次滴答的时钟时间的函数。formatClockprependZero将在一开始时调用,设置所需的模板或关键字。它们返回的内部函数将每秒调用一次,用于格式化显示时间。

现在我们已经拥有了构建滴答时钟所需的所有函数,我们需要将它们组合在一起。我们将使用在上一节定义的compose函数来处理组合:

convertToCivilianTime

一个单独的函数,接受时钟时间作为参数,并使用平民时间的两个小时将其转换为平民时间。

doubleDigits

一个单独的函数,接收平民时钟时间,并确保小时、分钟和秒显示为双位数字,在需要的位置前加零。

startTicking

通过设置一个间隔,每秒调用一个回调函数来启动时钟。回调函数由我们所有的函数组成。每秒钟,控制台将被清空,currentTime将被获取、转换、平民化、格式化并显示。

const convertToCivilianTime = clockTime =>
  compose(
    appendAMPM,
    civilianHours
  )(clockTime);

const doubleDigits = civilianTime =>
  compose(
    prependZero("hours"),
    prependZero("minutes"),
    prependZero("seconds")
  )(civilianTime);

const startTicking = () =>
  setInterval(
    compose(
      clear,
      getCurrentTime,
      serializeClockTime,
      convertToCivilianTime,
      doubleDigits,
      formatClock("hh:mm:ss tt"),
      display(log)
    ),
    oneSecond()
  );

startTicking();

这个声明式版本的时钟实现了与命令式版本相同的结果。然而,这种方法有许多好处。首先,这些函数都易于测试和重用。它们可以用于未来的时钟或其他数字显示器。此外,这个程序易于扩展。没有副作用。除了函数本身之外,没有全局变量。仍然可能存在 bug,但它们会更容易找到。

在本章中,我们介绍了函数式编程的原则。整本书在讨论 React 的最佳实践时,我们将继续演示许多 React 概念是基于函数技术的。在下一章,我们将带着对指导其开发的原则有了更深入的理解,正式深入 React。

^(1) Dana S. Scott, “λ-Calculus: Then & Now”.

第四章:React 的工作原理

到目前为止,你已经温习了最新的语法。你已经复习了引导 React 创建的函数式编程模式。这些步骤已经为你迈出下一步,为你来这里所要做的事情做好了准备:学习 React 的工作原理。让我们开始写一些真正的 React 代码。

当你使用 React 时,很可能会使用 JSX 创建应用程序。JSX 是一种基于标签的 JavaScript 语法,看起来非常像 HTML。在接下来的章节中,我们将深入探讨这种语法,并在本书的其余部分继续使用它。然而,要真正理解 React,我们需要了解它最基本的单元:React 元素。从那里开始,我们将通过查看如何创建组成其他组件和元素的自定义组件来深入了解 React 组件。

页面设置

要在浏览器中使用 React,我们需要包含两个库:React 和 ReactDOM。React 是用于创建视图的库。ReactDOM 是用于在浏览器中实际渲染 UI 的库。这两个库都可以从 unpkg CDN 作为脚本获取(链接在下面的代码中)。让我们来设置一个 HTML 文档:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>React Samples</title>
  </head>
  <body>
    <!-- Target container -->
    <div id="root"></div>

    <!-- React library & ReactDOM (Development Version)-->
    <script
  src="https://unpkg.com/react@16/umd/react.development.js">
  </script>
    <script
  src="https://unpkg.com/react-dom@16/umd/react-dom.development.js">
  </script>

    <script>
      // Pure React and JavaScript code
    </script>
  </body>
</html>

这些是在浏览器中使用 React 的最低要求。你可以将你的 JavaScript 放在一个单独的文件中,但它必须在加载 React 之后的页面某处加载。我们将使用开发版本的 React,以便在浏览器控制台中看到所有的错误消息和警告。你可以选择使用被压缩的生产版本 react.production.min.jsreact-dom.production.min.js,这样将剥离掉这些警告。

React 元素

HTML 只是浏览器在构建 DOM 时遵循的一组指令。构成 HTML 文档的元素在浏览器加载 HTML 并渲染用户界面时成为 DOM 元素。

假设你需要为一个食谱构建一个 HTML 层次结构。这样一个任务的可能解决方案可能看起来像这样:

<section id="baked-salmon">
  <h1>Baked Salmon</h1>
  <ul class="ingredients">
    <li>2 lb salmon</li>
    <li>5 sprigs fresh rosemary</li>
    <li>2 tablespoons olive oil</li>
    <li>2 small lemons</li>
    <li>1 teaspoon kosher salt</li>
    <li>4 cloves of chopped garlic</li>
  </ul>
  <section class="instructions">
    <h2>Cooking Instructions</h2>
    <p>Preheat the oven to 375 degrees.</p>
    <p>Lightly coat aluminum foil with oil.</p>
    <p>Place salmon on foil</p>
    <p>Cover with rosemary, sliced lemons, chopped garlic.</p>
    <p>Bake for 15-20 minutes until cooked through.</p>
    <p>Remove from oven.</p>
  </section>
</section>

在 HTML 中,元素之间以类似于家族树的层次结构相关联。我们可以说根元素(在本例中是一个部分)有三个子元素:一个标题,一个无序的配料列表,和一个用于说明的部分。

在过去,网站由独立的 HTML 页面组成。当用户导航这些页面时,浏览器会请求和加载不同的 HTML 文档。AJAX(异步 JavaScript 和 XML)的发明带来了单页应用程序或SPA。由于浏览器可以使用 AJAX 请求和加载小数据片段,整个 Web 应用程序现在可以在单个页面上运行,并依赖 JavaScript 更新用户界面。

在单页应用程序中,浏览器最初加载一个 HTML 文档。当用户通过站点导航时,他们实际上停留在同一个页面上。JavaScript 在用户与应用程序交互时销毁并创建新的用户界面。可能感觉好像你从页面跳转到页面,但实际上你仍然在同一个 HTML 页面上,JavaScript 正在进行重量级的工作。

DOM API 是 JavaScript 可以用来与浏览器交互以修改 DOM 的一组对象。如果你使用过 document.createElementdocument.appendChild,你已经使用过 DOM API。

React 是一个设计用来为我们更新浏览器 DOM 的库。我们不再需要关注构建高性能单页应用程序所涉及的复杂性,因为 React 可以为我们完成这些工作。使用 React,我们不直接与 DOM API 交互。相反,我们提供了关于我们希望 React 构建的指令,React 将负责渲染和协调我们指示它创建的元素。

浏览器 DOM 由 DOM 元素组成。类似地,React DOM 由 React 元素组成。DOM 元素和 React 元素看起来可能相同,但它们实际上是非常不同的。React 元素描述了实际 DOM 元素应该如何看起来。换句话说,React 元素是构建浏览器 DOM 的指令。

我们可以使用 React.createElement 创建一个表示 h1 的 React 元素:

React.createElement("h1", { id: "recipe-0" }, "Baked Salmon");

第一个参数定义了我们要创建的元素类型。在这种情况下,我们想创建一个 h1 元素。第二个参数表示元素的属性。这个 h1 当前有一个 idrecipe-0。第三个参数表示元素的子节点:在开放和闭合标签之间插入的任何节点(在本例中只是一些文本)。

在渲染过程中,React 将这个元素转换为一个实际的 DOM 元素:

<h1 id="recipe-0">Baked Salmon</h1>

属性同样适用于新的 DOM 元素:属性被添加到标签作为属性,子文本被添加为元素内的文本。React 元素只是一个告诉 React 如何构建 DOM 元素的 JavaScript 字面量。

如果你要记录这个元素,它会像这样:

{
  $$typeof: Symbol(React.element),
  "type": "h1",
  "key": null,
  "ref": null,
  "props": {id: "recipe-0", children: "Baked Salmon"},
  "_owner": null,
  "_store": {}
}

这是一个 React 元素的结构。有些字段被 React 使用:_owner_store$$typeofkeyref 字段对于 React 元素很重要,但我们稍后会介绍它们。现在,让我们更仔细地看看 typeprops 字段。

React 元素的 type 属性告诉 React 要创建哪种类型的 HTML 或 SVG 元素。props 属性表示构建 DOM 元素所需的数据和子元素。children 属性用于将其他嵌套元素显示为文本。

创建元素

我们来看看 React.createElement 返回的对象。你不会手动创建这些元素;相反,你会使用 React.createElement 函数。

ReactDOM

创建 React 元素后,我们希望在浏览器中看到它。ReactDOM 包含在浏览器中渲染 React 元素所需的工具。ReactDOM 是我们将找到render方法的地方。

我们可以使用ReactDOM.render将 React 元素(包括其子元素)渲染到 DOM 中。要渲染的元素作为第一个参数传递,第二个参数是应该渲染元素的目标节点:

const dish = React.createElement("h1", null, "Baked Salmon");

ReactDOM.render(dish, document.getElementById("root"));

将标题元素渲染到 DOM 会将h1元素添加到具有idrootdiv中,我们已经在 HTML 中定义了该div。我们在body标签中构建此div

<body>
  <div id="root">
    <h1>Baked Salmon</h1>
  </div>
</body>

与将元素渲染到 DOM 相关的任何内容都可以在ReactDOM包中找到。在 React 16 之前的版本中,只能将一个元素渲染到 DOM 中。今天,也可以渲染数组。当这一功能在 2017 年的 ReactConf 上宣布时,每个人都鼓掌欢呼。这是一件大事。具体效果如下:

const dish = React.createElement("h1", null, "Baked Salmon");
const dessert = React.createElement("h2", null, "Coconut Cream Pie");

ReactDOM.render([dish, dessert], document.getElementById("root"));

这将在root容器内将这两个元素呈现为同级。希望你刚才鼓掌欢呼!

在下一节中,我们将了解如何使用props.children

子元素

React 使用props.children渲染子元素。在前一节中,我们将文本元素作为h1元素的子元素呈现,因此props.children设置为Baked Salmon。我们也可以渲染其他 React 元素作为子元素,从而创建元素树。这就是为什么我们使用术语元素树:树有一个根元素,从它长出许多分支。

让我们考虑包含成分的无序列表:

<ul>
  <li>2 lb salmon</li>
  <li>5 sprigs fresh rosemary</li>
  <li>2 tablespoons olive oil</li>
  <li>2 small lemons</li>
  <li>1 teaspoon kosher salt</li>
  <li>4 cloves of chopped garlic</li>
</ul>

在此示例中,无序列表是根元素,它有六个子元素。我们可以使用React.createElement表示此ul及其子元素:

React.createElement(
  "ul",
  null,
  React.createElement("li", null, "2 lb salmon"),
  React.createElement("li", null, "5 sprigs fresh rosemary"),
  React.createElement("li", null, "2 tablespoons olive oil"),
  React.createElement("li", null, "2 small lemons"),
  React.createElement("li", null, "1 teaspoon kosher salt"),
  React.createElement("li", null, "4 cloves of chopped garlic")
);

每个传递给createElement函数的额外参数都是另一个子元素。React 创建这些子元素的数组,并将props.children的值设置为该数组。

如果我们检查生成的 React 元素,我们将看到每个列表项由一个 React 元素表示,并添加到名为props.children的数组中。如果你控制台记录这个元素:

const list = React.createElement(
  "ul",
  null,
  React.createElement("li", null, "2 lb salmon"),
  React.createElement("li", null, "5 sprigs fresh rosemary"),
  React.createElement("li", null, "2 tablespoons olive oil"),
  React.createElement("li", null, "2 small lemons"),
  React.createElement("li", null, "1 teaspoon kosher salt"),
  React.createElement("li", null, "4 cloves of chopped garlic")
);

console.log(list);

结果如下所示:

{
    "type": "ul",
    "props": {
    "children": [
    { "type": "li", "props": { "children": "2 lb salmon" } … },
    { "type": "li", "props": { "children": "5 sprigs fresh rosemary"} … },
    { "type": "li", "props": { "children": "2 tablespoons olive oil" } … },
    { "type": "li", "props": { "children": "2 small lemons"} … },
    { "type": "li", "props": { "children": "1 teaspoon kosher salt"} … },
    { "type": "li", "props": { "children": "4 cloves of chopped garlic"} … }
    ]
    ...
    }
}

现在我们可以看到每个列表项都是一个子元素。在本章前面,我们介绍了一个以section元素为根的整个食谱的 HTML。要使用 React 创建此内容,我们将使用一系列createElement调用:

React.createElement(
  "section",
  { id: "baked-salmon" },
  React.createElement("h1", null, "Baked Salmon"),
  React.createElement(
    "ul",
    { className: "ingredients" },
    React.createElement("li", null, "2 lb salmon"),
    React.createElement("li", null, "5 sprigs fresh rosemary"),
    React.createElement("li", null, "2 tablespoons olive oil"),
    React.createElement("li", null, "2 small lemons"),
    React.createElement("li", null, "1 teaspoon kosher salt"),
    React.createElement("li", null, "4 cloves of chopped garlic")
  ),
  React.createElement(
    "section",
    { className: "instructions" },
    React.createElement("h2", null, "Cooking Instructions"),
    React.createElement("p", null, "Preheat the oven to 375 degrees."),
    React.createElement("p", null, "Lightly coat aluminum foil with oil."),
    React.createElement("p", null, "Place salmon on foil."),
    React.createElement(
      "p",
      null,
      "Cover with rosemary, sliced lemons, chopped garlic."
    ),
    React.createElement(
      "p",
      null,
      "Bake for 15-20 minutes until cooked through."
    ),
    React.createElement("p", null, "Remove from oven.")
  )
);

React 中的 className

任何具有 HTMLclass属性的元素都使用className作为该属性的属性,而不是class。由于class是 JavaScript 中的保留字,我们必须使用className来定义 HTML 元素的class属性。这个示例展示了纯 React 的样子。纯 React 最终在浏览器中运行。React 应用程序是从单个根元素衍生出的 React 元素树。React 元素是 React 将用于在浏览器中构建 UI 的指令。

使用数据构造元素

使用 React 的主要优势在于其将数据与 UI 元素分离的能力。由于 React 只是 JavaScript,我们可以添加 JavaScript 逻辑来帮助我们构建 React 组件树。例如,成分可以存储在一个数组中,然后我们可以将该数组映射到 React 元素中。

让我们回过头来思考一下无序列表:

React.createElement(
  "ul",
  null,
  React.createElement("li", null, "2 lb salmon"),
  React.createElement("li", null, "5 sprigs fresh rosemary"),
  React.createElement("li", null, "2 tablespoons olive oil"),
  React.createElement("li", null, "2 small lemons"),
  React.createElement("li", null, "1 teaspoon kosher salt"),
  React.createElement("li", null, "4 cloves of chopped garlic")
);

此成分列表中使用的数据可以轻松地使用 JavaScript 数组表示:

const items = [
  "2 lb salmon",
  "5 sprigs fresh rosemary",
  "2 tablespoons olive oil",
  "2 small lemons",
  "1 teaspoon kosher salt",
  "4 cloves of chopped garlic"
];

我们希望使用此数据生成正确数量的列表项,而无需硬编码每个列表项。我们可以映射数组,并为所需的成分创建列表项:

React.createElement(
  "ul",
  { className: "ingredients" },
  items.map(ingredient => React.createElement("li", null, ingredient))
);

此语法为数组中的每个成分创建一个 React 元素。每个字符串作为文本显示在列表项的子元素中。每个成分的值显示为列表项。

运行此代码时,您将看到一个类似于图 4-1 中显示的控制台警告。

image

图 4-1. 控制台警告

当我们通过数组迭代构建子元素列表时,React 喜欢每个元素都有一个key属性。key属性被 React 用来帮助它高效更新 DOM。通过为每个列表项元素添加一个唯一的key属性,可以消除这种警告。可以使用每个成分的数组索引作为该唯一值:

React.createElement(
  "ul",
  { className: "ingredients" },
  items.map((ingredient, i) =>
    React.createElement("li", { key: i }, ingredient)
  )
);

当我们讨论 JSX 时,我们将更详细地讨论键,但在每个列表项中添加这个将清除控制台警告。

React 组件

无论其大小、内容或用于创建它的技术如何,用户界面都由部分组成。按钮。列表。标题。将这些部分放在一起,就组成了用户界面。考虑一个包含三个不同菜谱的食谱应用程序。每个盒子中的数据不同,但创建食谱所需的部分相同(参见图 4-2)。

image

图 4-2. 食谱应用程序

在 React 中,我们将这些部分描述为组件。组件允许我们重用相同的结构,然后我们可以用不同的数据集填充这些结构。

在考虑使用 React 构建用户界面时,寻找将元素分解为可重复使用部件的机会。例如,图 4-3 中的食谱具有标题、成分列表和说明。所有这些都是较大的食谱或应用程序组件的一部分。我们可以为每个突出显示的部分创建一个组件:成分、说明等。

image

图 4-3. 每个组件都有轮廓:AppIngredientsListInstructions

想想这有多可扩展。如果我们想显示一个食谱,我们的组件结构将支持此操作。如果我们想显示 10,000 个食谱,我们只需创建 10,000 个该组件的新实例。

我们将通过编写一个函数来创建一个组件。该函数将返回用户界面的可重用部分。让我们创建一个返回无序食材列表的函数。这次,我们将使用名为IngredientsList的函数制作甜点:

function IngredientsList() {
  return React.createElement(
    "ul",
    { className: "ingredients" },
    React.createElement("li", null, "1 cup unsalted butter"),
    React.createElement("li", null, "1 cup crunchy peanut butter"),
    React.createElement("li", null, "1 cup brown sugar"),
    React.createElement("li", null, "1 cup white sugar"),
    React.createElement("li", null, "2 eggs"),
    React.createElement("li", null, "2.5 cups all purpose flour"),
    React.createElement("li", null, "1 teaspoon baking powder"),
    React.createElement("li", null, "0.5 teaspoon salt")
  );
}

ReactDOM.render(
  React.createElement(IngredientsList, null, null),
  document.getElementById("root")
);

组件的名称是IngredientsList,函数输出的元素看起来像这样:

<IngredientsList>
  <ul className="ingredients">
    <li>1 cup unsalted butter</li>
    <li>1 cup crunchy peanut butter</li>
    <li>1 cup brown sugar</li>
    <li>1 cup white sugar</li>
    <li>2 eggs</li>
    <li>2.5 cups all purpose flour</li>
    <li>1 teaspoon baking powder</li>
    <li>0.5 teaspoon salt</li>
  </ul>
</IngredientsList>

这很酷,但我们已经将这些数据硬编码到组件中了。如果我们能够构建一个组件,然后将数据作为属性传递给该组件呢?然后,如果该组件能够动态呈现数据呢?也许有一天会发生!

开玩笑的那一天就是现在。这里是组合食谱所需的secretIngredients数组:

const secretIngredients = [
  "1 cup unsalted butter",
  "1 cup crunchy peanut butter",
  "1 cup brown sugar",
  "1 cup white sugar",
  "2 eggs",
  "2.5 cups all purpose flour",
  "1 teaspoon baking powder",
  "0.5 teaspoon salt"
];

然后我们将调整IngredientsList组件,对这些items进行映射,为items数组中的项目创建一个li

function IngredientsList() {
  return React.createElement(
    "ul",
    { className: "ingredients" },
    items.map((ingredient, i) =>
      React.createElement("li", { key: i }, ingredient)
    )
  );
}

然后我们将这些secretIngredients作为名为items的属性传递,这是在createElement中使用的第二个参数:

ReactDOM.render(
  React.createElement(IngredientsList, { items: secretIngredients }, null),
  document.getElementById("root")
);

现在,让我们看看 DOM。数据属性items是一个包含八种食材的数组。因为我们使用循环创建了li标签,我们能够使用循环的索引添加一个唯一的键:

<IngredientsList items="[...]">
  <ul className="ingredients">
    <li key="0">1 cup unsalted butter</li>
    <li key="1">1 cup crunchy peanut butter</li>
    <li key="2">1 cup brown sugar</li>
    <li key="3">1 cup white sugar</li>
    <li key="4">2 eggs</li>
    <li key="5">2.5 cups all purpose flour</li>
    <li key="6">1 teaspoon baking powder</li>
    <li key="7">0.5 teaspoon salt</li>
  </ul>
</IngredientsList>

以这种方式创建我们的组件将使组件更加灵活。无论items数组是一个项目还是一百个项目长,组件都会将每个项目呈现为列表项。

我们还可以在这里做另一个调整,即从 React props 引用items数组。我们将items数组映射到全局items上,将itemsprops对象上可用。首先将props传递给函数,然后对props.items进行映射:

function IngredientsList(props) {
  return React.createElement(
    "ul",
    { className: "ingredients" },
    props.items.map((ingredient, i) =>
      React.createElement("li", { key: i }, ingredient)
    )
  );
}

我们还可以通过从props中解构items来清理代码:

function IngredientsList({ items }) {
  return React.createElement(
    "ul",
    { className: "ingredients" },
    items.map((ingredient, i) =>
      React.createElement("li", { key: i }, ingredient)
    )
  );
}

IngredientsList相关的所有 UI 都封装在一个组件中。我们需要的一切都在那里。

React 组件:历史之旅

在有函数组件之前,还有其他创建组件的方式。虽然我们不会花费太多时间在这些方法上,但了解 React 组件的历史是很重要的,特别是在处理遗留代码库中的这些 API 时。让我们来一次 React API 的历史之旅。

旅行站 1:createClass

当 React 在 2013 年首次开源时,创建组件的方法只有一种:createClass。使用React.createClass创建组件的方式如下:

const IngredientsList = React.createClass({
  displayName: "IngredientsList",
  render() {
    return React.createElement(
      "ul",
      { className: "ingredients" },
      this.props.items.map((ingredient, i) =>
        React.createElement("li", { key: i }, ingredient)
      )
    );
  }
});

使用createClass的组件将具有描述应该返回和呈现的 React 元素的render()方法。组件的概念是相同的:我们将描述一个可重用的 UI 部分来呈现。

在 React 15.5(2017 年 4 月)中,如果使用createClass,React 开始抛出警告。在 React 16(2017 年 9 月)中,React.createClass被正式弃用,并移至自己的包create-react-class

旅行站 2:类组件

当 ES 2015 引入类语法到 JavaScript 中时,React 介绍了一种新的方法来创建 React 组件。React.Component API 允许您使用类语法来创建一个新的组件实例:

class IngredientsList extends React.Component {
  render() {
    return React.createElement(
      "ul",
      { className: "ingredients" },
      this.props.items.map((ingredient, i) =>
        React.createElement("li", { key: i }, ingredient)
      )
    );
  }
}

仍然可以使用类语法来创建 React 组件,但请注意,React.Component 也在被弃用的路径上。虽然它仍然受支持,但可以预期它会像 React.createClass 一样逐渐被淘汰,这是另一个曾经对你产生影响但现在已经不常见的老朋友。从现在开始,在本书中我们将使用函数来创建组件,并仅简要指出旧的模式以供参考。

第五章:JSX 与 React

在上一章中,我们深入探讨了 React 的工作原理,将我们的 React 应用程序拆分成称为组件的小型可重复使用部件。这些组件渲染元素和其他组件的树形结构。使用createElement函数是了解 React 工作原理的一种好方法,但作为 React 开发人员,这不是我们所做的。我们不会简单地组合复杂、难以阅读的 JavaScript 语法树然后称之为有趣。为了有效地使用 React,我们还需要一件东西:JSX。

JSX 结合了 JavaScript 的JS和 XML 的X。它是一种 JavaScript 扩展,允许我们使用基于标签的语法直接在我们的 JavaScript 代码中定义 React 元素。有时 JSX 与 HTML 混淆,因为它们看起来很相似。JSX 只是创建 React 元素的另一种方式,因此您无需在复杂的createElement调用中寻找遗失的逗号而抓狂。

在本章中,我们将讨论如何使用 JSX 构建 React 应用程序。

React 元素作为 JSX

Facebook 的 React 团队在发布 React 时发布了 JSX,以提供一种用于创建带属性的复杂 DOM 树的简洁语法。他们还希望使 React 更像 HTML 和 XML 一样可读。在 JSX 中,元素的类型由标签指定。标签的属性表示属性。元素的子元素可以添加在开放和关闭标签之间。

您还可以将其他 JSX 元素添加为子级。如果您有一个无序列表,您可以使用 JSX 标记将子列表项元素添加到其中。这看起来非常类似于 HTML:

<ul>
  <li>1 lb Salmon</li>
  <li>1 cup Pine Nuts</li>
  <li>2 cups Butter Lettuce</li>
  <li>1 Yellow Squash</li>
  <li>1/2 cup Olive Oil</li>
  <li>3 Cloves of Garlic</li>
</ul>

JSX 也适用于组件。只需使用类名定义组件。我们使用 JSX 将食材数组作为属性传递给IngredientsList,如图 5-1 所示。

image

图 5-1. 使用 JSX 创建 IngredientsList

当我们将食材数组传递给此组件时,我们需要用花括号括起来。这被称为 JavaScript 表达式,在将 JavaScript 值作为属性传递给组件时必须使用这些表达式。组件属性将有两种类型:字符串或 JavaScript 表达式。JavaScript 表达式可以包括数组、对象,甚至函数。为了包含它们,您必须将它们用花括号括起来。

JSX 提示

JSX 可能看起来很熟悉,大多数规则导致的语法与 HTML 类似。但是,在使用 JSX 时,有几点需要注意。

嵌套组件

JSX 允许您将组件添加为其他组件的子级。例如,在IngredientsList内部,我们可以多次渲染称为Ingredient的另一个组件:

<IngredientsList>
  <Ingredient />
  <Ingredient />
  <Ingredient />
</IngredientsList>

className

由于class在 JavaScript 中是保留字,因此使用className来定义class属性:

<h1 className="fancy">Baked Salmon</h1>

JavaScript 表达式

JavaScript 表达式被大括号包裹,指示变量在哪里被计算,并返回它们的结果值。例如,如果我们想要在一个元素中显示 title 属性的值,我们可以使用 JavaScript 表达式插入该值。变量将被计算并返回其值:

<h1>{title}</h1>

除了字符串之外的类型的值也应出现为 JavaScript 表达式:

<input type="checkbox" defaultChecked={false} />

评估

大括号中添加的 JavaScript 将被计算。这意味着会执行连接或加法等操作。这也意味着 JavaScript 表达式中找到的函数将被调用:

<h1>{"Hello" + title}</h1>

<h1>{title.toLowerCase().replace}</h1>

使用 JSX 映射数组

JSX 就是 JavaScript,因此可以直接将 JSX 嵌入到 JavaScript 函数中。例如,你可以将数组映射为 JSX 元素:

<ul>
  {props.ingredients.map((ingredient, i) => (
    <li key="{i}">{ingredient}</li>
  ))}
</ul>

JSX 看起来干净易读,但不能直接在浏览器中解释。所有的 JSX 必须转换为 createElement 调用。幸运的是,有一个非常好的工具可以完成这项任务:Babel

Babel

许多软件语言要求您编译源代码。JavaScript 是一种解释性语言:浏览器将代码解释为文本,因此不需要编译 JavaScript。但不是所有的浏览器都支持最新的 JavaScript 语法,也没有浏览器支持 JSX 语法。由于我们想要使用 JavaScript 的最新功能以及 JSX,我们需要一种将我们的源代码转换为浏览器可以解释的东西的方法。这个过程称为编译,这正是 Babel 的设计初衷。

该项目的第一个版本被称为 6to5,并于 2014 年 9 月发布。6to5 是一个工具,可以将 ES6 语法转换为更广泛支持的 ES5 语法,以便 Web 浏览器使用。随着项目的发展,它的目标是支持 ECMAScript 的所有最新变化。它还增加了支持将 JSX 转换为 JavaScript 的功能。该项目于 2015 年 2 月更名为 Babel。

Babel 在 Facebook、Netflix、PayPal、Airbnb 等地的生产环境中被广泛使用。在此之前,Facebook 曾经创建了一个 JSX 转换器作为他们的标准,但很快就被 Babel 取代了。

有许多使用 Babel 的方法。开始的最简单方法是直接在你的 HTML 中包含 Babel CDN 的链接,这将编译任何带有“text/babel”类型的 script 块中的代码。Babel 将在客户端运行之前编译源代码。虽然这可能不是生产环境的最佳解决方案,但这是开始使用 JSX 的一个很好的方法:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>React Examples</title>
  </head>
  <body>
    <div id="root"></div>

    <!-- React Library & React DOM -->
    <script
  src="https://unpkg.com/react@16.8.6/umd/react.development.js">
  </script>
    <script
  src="https://unpkg.com/react-dom@16.8.6/umd/react-dom.development.js">
  </script>
    <script
  src="https://unpkg.com/@babel/standalone/babel.min.js">
  </script>

    <script type="text/babel">
      // JSX code here. Or link to separate JavaScript file that contains JSX.
    </script>
  </body>
</html>

在浏览器中使用 In-Browser Babel 时会看到控制台警告。

当使用浏览器内转换器时,你会看到一个警告,提示要为生产环境预编译脚本。对于这个和其他小型演示项目,不必担心这个警告。我们将在本章后面升级到生产准备好的 Babel。

JSX 作为食谱

JSX 为我们提供了一种漂亮而干净的方式来在代码中表达 React 元素,这对我们来说很有意义,并且对开发人员来说是立即可读的。JSX 的缺点是它对浏览器不可读。在我们的代码可以被浏览器解释之前,它需要从 JSX 转换为 JavaScript。

此数据数组包含两个食谱,并代表我们应用程序的当前状态:

const data = [
  {
    name: "Baked Salmon",
    ingredients: [
      { name: "Salmon", amount: 1, measurement: "l lb" },
      { name: "Pine Nuts", amount: 1, measurement: "cup" },
      { name: "Butter Lettuce", amount: 2, measurement: "cups" },
      { name: "Yellow Squash", amount: 1, measurement: "med" },
      { name: "Olive Oil", amount: 0.5, measurement: "cup" },
      { name: "Garlic", amount: 3, measurement: "cloves" }
    ],
    steps: [
      "Preheat the oven to 350 degrees.",
      "Spread the olive oil around a glass baking dish.",
      "Add the yellow squash and place in the oven for 30 mins.",
      "Add the salmon, garlic, and pine nuts to the dish.",
      "Bake for 15 minutes.",
      "Remove from oven. Add the lettuce and serve."
    ]
  },
  {
    name: "Fish Tacos",
    ingredients: [
      { name: "Whitefish", amount: 1, measurement: "l lb" },
      { name: "Cheese", amount: 1, measurement: "cup" },
      { name: "Iceberg Lettuce", amount: 2, measurement: "cups" },
      { name: "Tomatoes", amount: 2, measurement: "large" },
      { name: "Tortillas", amount: 3, measurement: "med" }
    ],
    steps: [
      "Cook the fish on the grill until cooked through.",
      "Place the fish on the 3 tortillas.",
      "Top them with lettuce, tomatoes, and cheese."
    ]
  }
];

数据表达为一个包含两个 JavaScript 对象的数组。每个对象包含食谱的名称、所需的配料列表和烹饪食谱所需的步骤列表。

我们可以使用两个组件创建这些食谱的 UI:一个 Menu 组件用于列出食谱,一个 Recipe 组件用于描述每个食谱的 UI。我们将渲染 Menu 组件到 DOM 中。我们将我们的数据作为名为 recipes 的属性传递给 Menu 组件:

// The data, an array of Recipe objects
const data = [ ... ];

// A function component for an individual Recipe
function Recipe (props) {
  ...
}

// A function component for the Menu of Recipes
function Menu (props) {
  ...
}

// A call to ReactDOM.render to render our Menu into the current DOM
ReactDOM.render(
  <Menu recipes={data} title="Delicious Recipes" />,
  document.getElementById("root")
);

Menu 组件中的 React 元素被表达为 JSX。所有内容都包含在 article 元素内。使用 header 元素、h1 元素和 div.recipes 元素来描述我们菜单的 DOM。title 属性的值将作为文本显示在 h1 元素内:

function Menu(props) {
  return (
    <article>
      <header>
        <h1>{props.title}</h1>
      </header>
      <div className="recipes" />
    </article>
  );
}

div.recipes 元素内部,我们为每个食谱添加一个组件:

<div className="recipes">
  {props.recipes.map((recipe, i) => (
    <Recipe
      key={i}
      name={recipe.name}
      ingredients={recipe.ingredients}
      steps={recipe.steps}
    />
  ))}
</div>

为了在 div.recipes 元素内列出食谱,我们使用花括号添加一个 JavaScript 表达式,该表达式将返回一个子元素数组。我们可以在 props.recipes 数组上使用 map 函数,为数组中的每个对象返回一个组件。如前所述,每个食谱包含名称、一些配料和烹饪说明(步骤)。我们需要将这些数据作为 props 传递给每个 Recipe。同时记住,我们应该使用 key 属性来唯一标识每个元素。

您还可以重构此处以使用展开语法。JSX 展开操作符类似于对象展开操作符。它将 recipe 对象的每个字段添加为 Recipe 组件的属性。这里的语法将向组件提供所有属性:

{
  props.recipes.map((recipe, i) => <Recipe key={i} {...recipe} />);
}

记住,这种快捷方式将向 Recipe 组件提供所有属性。这可能是一件好事,但也可能使组件添加过多的属性。

我们可以在接受 props 参数的 Menu 组件的另一个位置进行语法改进。我们可以使用对象解构将变量作用域限定到这个函数中。这使我们可以直接访问 titlerecipes 变量,而无需再以 props 为前缀:

function Menu({ title, recipes }) {
  return (
    <article>
      <header>
        <h1>{title}</h1>
      </header>
      <div className="recipes">
        {recipes.map((recipe, i) => (
          <Recipe key={i} {...recipe} />
        ))}
      </div>
    </article>
  );
}

现在让我们为每个单独的食谱编写组件:

function Recipe({ name, ingredients, steps }) {
  return (
    <section id={name.toLowerCase().replace(/ /g, "-")}>
      <h1>{name}</h1>
      <ul className="ingredients">
        {ingredients.map((ingredient, i) => (
          <li key={i}>{ingredient.name}</li>
        ))}
      </ul>
      <section className="instructions">
        <h2>Cooking Instructions</h2>
        {steps.map((step, i) => (
          <p key={i}>{step}</p>
        ))}
      </section>
    </section>
  );
}

每个食谱都有一个名称字符串,一个对象数组作为配料,以及一个字符串数组作为步骤。使用对象解构,我们可以告诉这个组件通过名称将这些字段本地作用域化,这样我们可以直接访问它们,而不必使用 props.nameprops.ingredientsprops.steps

我们看到的第一个 JavaScript 表达式用于为根section元素设置id属性。它将配方名称转换为小写字符串,并全局替换空格为破折号。结果是“烤鲑鱼”将被转换为“baked-salmon”(同样地,如果我们有一个名为“Boston Baked Beans”的配方,它将被转换为“boston-baked-beans”),然后用作 UI 中的id属性。name的值也作为文本节点显示在h1中。

在无序列表内部,JavaScript 表达式正在将每个成分映射到显示成分名称的li元素。在我们的说明部分中,我们看到相同的模式被用来返回一个段落元素,其中显示每个步骤。这些map函数返回子元素的数组。

应用程序的完整代码应如下所示:

const data = [
  {
    name: "Baked Salmon",
    ingredients: [
      { name: "Salmon", amount: 1, measurement: "l lb" },
      { name: "Pine Nuts", amount: 1, measurement: "cup" },
      { name: "Butter Lettuce", amount: 2, measurement: "cups" },
      { name: "Yellow Squash", amount: 1, measurement: "med" },
      { name: "Olive Oil", amount: 0.5, measurement: "cup" },
      { name: "Garlic", amount: 3, measurement: "cloves" }
    ],
    steps: [
      "Preheat the oven to 350 degrees.",
      "Spread the olive oil around a glass baking dish.",
      "Add the yellow squash and place in the oven for 30 mins.",
      "Add the salmon, garlic, and pine nuts to the dish.",
      "Bake for 15 minutes.",
      "Remove from oven. Add the lettuce and serve."
    ]
  },
  {
    name: "Fish Tacos",
    ingredients: [
      { name: "Whitefish", amount: 1, measurement: "l lb" },
      { name: "Cheese", amount: 1, measurement: "cup" },
      { name: "Iceberg Lettuce", amount: 2, measurement: "cups" },
      { name: "Tomatoes", amount: 2, measurement: "large" },
      { name: "Tortillas", amount: 3, measurement: "med" }
    ],
    steps: [
      "Cook the fish on the grill until hot.",
      "Place the fish on the 3 tortillas.",
      "Top them with lettuce, tomatoes, and cheese."
    ]
  }
];

function Recipe({ name, ingredients, steps }) {
  return (
    <section id={name.toLowerCase().replace(/ /g, "-")}>
      <h1>{name}</h1>
      <ul className="ingredients">
        {ingredients.map((ingredient, i) => (
          <li key={i}>{ingredient.name}</li>
        ))}
      </ul>
      <section className="instructions">
        <h2>Cooking Instructions</h2>
        {steps.map((step, i) => (
          <p key={i}>{step}</p>
        ))}
      </section>
    </section>
  );
}

function Menu({ title, recipes }) {
  return (
    <article>
      <header>
        <h1>{title}</h1>
      </header>
      <div className="recipes">
        {recipes.map((recipe, i) => (
          <Recipe key={i} {...recipe} />
        ))}
      </div>
    </article>
  );
}

ReactDOM.render(
  <Menu recipes={data} title="Delicious Recipes" />,
  document.getElementById("root")
);

当我们在浏览器中运行此代码时,React 将使用我们的说明和配方数据构建 UI,如图 5-2 所示。

如果您正在使用 Google Chrome 并安装了 React 开发者工具扩展程序,您可以查看组件树的当前状态。要做到这一点,请打开开发者工具并选择组件选项卡,如图 5-3 所示。

这里我们可以看到Menu及其子元素。data数组包含两个配方对象,我们有两个Recipe元素。每个Recipe元素都有配方名称、成分和步骤属性。成分和步骤作为data传递给它们自己的组件。

这些组件是基于应用程序传递给Menu组件的数据构建的属性。如果我们更改recipes数组并重新渲染我们的Menu组件,React 会尽可能高效地更改这个 DOM。

image

图 5-2. 美味食谱输出

image

图 5-3. React 开发者工具中的结果虚拟 DOM

React Fragments

在前一节中,我们渲染了Menu组件,一个渲染Recipe组件的父组件。我们想花点时间看一个使用 React 片段渲染两个兄弟组件的小例子。让我们从创建一个称为Cat的新组件开始,在root上将其呈现到 DOM 中:

function Cat({ name }) {
  return <h1>The cat's name is {name}</h1>;
}

ReactDOM.render(<Cat name="Jungle" />, document.getElementById("root"));

这将按预期渲染h1,但如果我们在Cat组件的与h1同级添加了p标签,会发生什么呢?

function Cat({ name }) {
  return (
    <h1>The cat's name is {name}</h1>
 <p>He's good.</p>
  );
}

立即,在控制台中我们将看到一个错误,显示相邻的 JSX 元素必须包裹在一个封闭标签中并建议使用片段。这就是片段发挥作用的地方!React 不会将两个或更多相邻或兄弟元素渲染为一个组件,所以我们过去必须将它们包装在像div这样的封闭标签中。然而,这导致创建了许多不必要的标签和一堆没有太多用途的包装器。如果我们使用 React 片段,我们可以模仿包装器的行为,而不实际创建一个新标签。

首先用一个React.Fragment标签包装相邻的标签,比如h1p

function Cat({ name }) {
  return (
    <React.Fragment>
      <h1>The cat's name is {name}</h1>
 <p>He's good.</p>
    </React.Fragment>
  );
}

添加这个可以清除警告。您还可以使用片段的简写方式,使其看起来更加清晰:

function Cat({ name }) {
  return (
    <>
      <h1>The cat's name is {name}</h1>
 <p>He's good.</p>
    </>
  );
}

如果您查看 DOM,您将看不到片段在生成树中的存在:

<div id="root">
  <h1>The cat's name is Jungle</h1>
  <p>He's good</p>
</div>

片段是 React 的一个相对较新的功能,摒弃了需要额外包装标签的需求,这些标签可能会污染 DOM。

webpack 简介

一旦我们在实际项目中开始使用 React,就会有很多问题需要考虑:我们如何处理 JSX 和 ESNext 转换?我们如何管理我们的依赖关系?如何优化我们的图像和 CSS?

出现了许多不同的工具来解决这些问题,包括 Browserify、gulp、Grunt、Prepack 等等。由于其功能和大公司的广泛采用,webpack也成为了捆绑工具中的领先者之一。

React 生态系统已经发展成包括 create-react-app、Gatsby 和 Code Sandbox 等工具。当您使用这些工具时,关于代码如何被编译的细节被抽象掉了很多。在当前这个时代,了解您的 JavaScript/React 代码是如何通过 webpack 之类的工具进行编译是至关重要的,但知道如何通过 webpack 之类的工具编译您的 JavaScript/React 并不是那么重要。如果您想跳过这部分,我们完全理解。

Webpack 被宣传为一个模块捆绑工具。模块捆绑工具将我们的各种不同文件(JavaScript、LESS、CSS、JSX、ESNext 等)转换为单一文件。捆绑的两个主要好处是模块化网络性能

模块化将允许您将源代码分解为更容易在团队环境中使用的部分或模块。

网络性能通过在浏览器中仅需加载一个依赖项来获得:捆绑包。每个script标签都会发起一个 HTTP 请求,并且每个 HTTP 请求都会有延迟惩罚。将所有依赖项捆绑到一个单一文件中,可以通过一个 HTTP 请求加载所有内容,从而避免额外的延迟。

除了代码编译之外,webpack 还可以处理:

代码分割

将您的代码分割成不同的块,需要时可以加载这些块。有时这些被称为rollupslayers;其目的是根据不同的页面或设备需要分割代码。

缩小

删除空白、换行、冗长的变量名和不必要的代码以减少文件大小。

功能标志

发送代码到一个或多个环境(但不是所有环境)以测试功能。

热模块替换(HMR)

监视源代码的更改。仅更新模块会立即进行更改。

我们在本章早些时候构建的 Recipes 应用程序存在一些限制,webpack 将帮助我们缓解这些限制。使用类似 webpack 的工具静态构建客户端 JavaScript 使得团队能够共同开发大型 Web 应用程序成为可能。通过集成 webpack 模块打包器,我们还可以获得以下好处:

模块化

使用模块模式导出模块,这些模块稍后将被应用程序的另一部分导入或要求,使源代码更易于理解。它允许开发团队一起工作,通过允许他们创建和使用将在发送到生产之前静态组合成单个文件的单独文件。

组合

使用模块,我们可以构建小型、简单、可重用的 React 组件,可以高效地组合到应用程序中。较小的组件更容易理解、测试和重用。在增强应用程序时,它们也更容易替换。

速度

将应用程序的所有模块和依赖项打包成一个单一的客户端包将减少应用程序的加载时间,因为每个 HTTP 请求都有延迟。将所有内容打包到单个文件中意味着客户端只需发出一个请求。在包中进行代码缩小也会改善加载时间。

一致性

由于 webpack 将编译 JSX 和 JavaScript,我们可以开始使用未来的 JavaScript 语法。Babel 支持广泛的 ESNext 语法,这意味着我们不必担心浏览器是否支持我们的代码。它允许开发人员始终使用最新的 JavaScript 语法。

创建项目

为了演示如何从头开始设置一个 React 项目,让我们在计算机上创建一个名为recipes-app的新文件夹:

mkdir recipes-app
cd recipes-app

对于这个项目,我们将按以下步骤进行:

  1. 创建项目。

  2. 将配方应用程序分解为存储在不同文件中的组件。

  3. 设置一个集成了 Babel 的 webpack 构建。

create-react-app

有一个工具叫做 Create React App,可以用来自动配置预先配置的 React 项目。在使用工具之前,我们将更仔细地看看幕后发生了什么。

1. 创建项目

接下来,我们将使用 npm 创建项目和package.json文件,发送-y标志以使用所有默认值。我们还将安装 webpack,webpack-cli,react 和 react-dom:

npm init -y
npm install react react-dom serve

如果我们使用 npm 5,则在安装时不需要发送--save标志。接下来,我们将创建以下目录结构来存放组件:

recipes-app (folder)
  > node_modules (already added with npm install command)
  > package.json (already added with npm init command)
  > package-lock.json (already added with npm init command)
  > index.html
  > /src (folder)
    > index.js
    > /data (folder)
      > recipes.json
    > /components (folder)
      > Recipe.js
      > Instructions.js
      > Ingredients.js

文件组织

没有一种固定的方法可以组织 React 项目中的文件。这只是一种可能的策略。

2. 将组件拆分为模块

目前,Recipe 组件做了相当多的工作。我们显示了食谱的名称,构建了一个无序的食材列表,并显示了说明,每个步骤都有自己的段落元素。此组件应该放置在 Recipe.js 文件中。在任何使用 JSX 的文件中,我们都需要在顶部导入 React:

// ./src/components/Recipe.js

import React from "react";

export default function Recipe({ name, ingredients, steps }) {
  return (
    <section id="baked-salmon">
      <h1>{name}</h1>
      <ul className="ingredients">
        {ingredients.map((ingredient, i) => (
          <li key={i}>{ingredient.name}</li>
        ))}
      </ul>
      <section className="instructions">
        <h2>Cooking Instructions</h2>
        {steps.map((step, i) => (
          <p key={i}>{step}</p>
        ))}
      </section>
    </section>
  );
}

Recipe 组件的功能性方法更改为将其分解为更小、更专注的函数组件,并将它们组合在一起会更加有效。我们可以从中将说明提取出来,并在单独的文件模块中创建一个我们可以用于任何说明集的模块。

在名为 Instructions.js 的新文件中,创建以下组件:

// ./src/components/Instructions.js

import React from "react";

export default function Instructions({ title, steps }) {
  return (
    <section className="instructions">
      <h2>{title}</h2>
      {steps.map((s, i) => (
        <p key={i}>{s}</p>
      ))}
    </section>
  );
}

在这里,我们创建了一个名为 Instructions 的新组件。我们将标题和步骤传递给这个组件。这样,我们可以为“烹饪说明”、“烘焙说明”、“准备说明”或“预先烹饪清单”等任何具有步骤的内容重复使用此组件。

现在考虑食材。在 Recipe 组件中,我们仅显示食材的名称,但是食谱数据中的每个食材还具有数量和测量单位。我们将创建一个名为 Ingredient 的组件来处理这个问题:

// ./src/components/Ingredient.js

import React from "react";

export default function Ingredient({ amount, measurement, name }) {
  return (
    <li>
      {amount} {measurement} {name}
    </li>
  );
}

在这里,我们假设每个食材都有数量、测量单位和名称。我们从 props 对象中解构这些值,并分别在独立的 span 元素中显示它们。

使用 Ingredient 组件,我们可以构建一个 IngredientsList 组件,可以在需要显示食材列表的任何时候使用:

// ./src/components/IngredientsList.js

import React from "react";
import Ingredient from "./Ingredient";

export default function IngredientsList({ list }) {
  return (
    <ul className="ingredients">
      {list.map((ingredient, i) => (
        <Ingredient key={i} {...ingredient} />
      ))}
    </ul>
  );
}

在这个文件中,我们首先导入 Ingredient 组件,因为我们将在每个食材中使用它。食材被作为名为 list 的属性的数组传递给此组件。数组中的每个食材将被映射到 Ingredient 组件。使用 JSX 展开运算符将所有数据传递给 Ingredient 组件作为 props。

使用展开运算符:

<Ingredient {...ingredient} />

是另一种表达方式:

<Ingredient
  amount={ingredient.amount}
  measurement={ingredient.measurement}
  name={ingredient.name}
/>

因此,给定具有以下字段的成分:

let ingredient = {
  amount: 1,
  measurement: "cup",
  name: "sugar"
};

我们得到:

<Ingredient amount={1} measurement="cup" name="sugar" />

现在我们已经有了用于食材和说明的组件,我们可以使用这些组件组合食谱:

// ./src/components/Recipe.js

import React from "react";
import IngredientsList from "./IngredientsList";
import Instructions from "./Instructions";

function Recipe({ name, ingredients, steps }) {
  return (
    <section id={name.toLowerCase().replace(/ /g, "-")}>
      <h1>{name}</h1>
      <IngredientsList list={ingredients} />
      <Instructions title="Cooking Instructions" steps={steps} />
    </section>
  );
}

export default Recipe;

首先,我们导入我们将要使用的组件:IngredientsListInstructions。现在我们可以使用它们来创建 Recipe 组件。与在一个地方构建整个食谱的一堆复杂代码不同,我们通过组合较小的组件更声明式地表达了我们的食谱。不仅代码简洁易懂,而且阅读起来也很舒适。这告诉我们,一个食谱应该显示食谱的名称、食材列表和一些烹饪说明。我们将显示食材和说明的含义抽象成了更小更简单的组件。

在模块化方法中,Menu组件看起来会非常相似。关键区别在于它将存在于自己的文件中,导入它需要使用的模块,并将自身导出:

// ./src/components/Menu.js

import React from "react";
import Recipe from "./Recipe";

function Menu({ recipes }) {
  return (
    <article>
      <header>
        <h1>Delicious Recipes</h1>
      </header>
      <div className="recipes">
        {recipes.map((recipe, i) => (
          <Recipe key={i} {...recipe} />
        ))}
      </div>
    </article>
  );
}

export default Menu;

我们仍然需要使用 ReactDOM 来渲染Menu组件。项目的主文件是index.js。这将负责将组件渲染到 DOM 中。

让我们创建这个文件:

// ./src/index.js

import React from "react";
import { render } from "react-dom";
import Menu from "./components/Menu";
import data from "./data/recipes.json";

render(<Menu recipes={data} />, document.getElementById("root"));

前四个语句导入了我们应用程序工作所需的模块。我们不是通过script标签加载reactreact-dom,而是导入它们,以便 webpack 将它们添加到我们的捆绑包中。我们还需要Menu组件和已移至单独模块的样本数据数组。它仍然包含两个食谱:烤三文鱼和鱼肉玉米饼。

我们所有导入的变量都是局限于index.js文件。当我们渲染Menu组件时,我们将食谱数据数组作为属性传递给此组件。

数据来自recipes.json文件。这与我们本章早些时候使用的数据相同,但现在遵循有效的 JSON 格式化规则:

// ./src/data/recipes.json

[
  {
    "name": "Baked Salmon",
    "ingredients": [
      { "name": "Salmon", "amount": 1, "measurement": "lb" },
      { "name": "Pine Nuts", "amount": 1, "measurement": "cup" },
      { "name": "Butter Lettuce", "amount": 2, "measurement": "cups" },
      { "name": "Yellow Squash", "amount": 1, "measurement": "med" },
      { "name": "Olive Oil", "amount": 0.5, "measurement": "cup" },
      { "name": "Garlic", "amount": 3, "measurement": "cloves" }
    ],
    "steps": [
      "Preheat the oven to 350 degrees.",
      "Spread the olive oil around a glass baking dish.",
      "Add the yellow squash and place in the oven for 30 mins.",
      "Add the salmon, garlic, and pine nuts to the dish.",
      "Bake for 15 minutes.",
      "Remove from oven. Add the lettuce and serve."
    ]
  },
  {
    "name": "Fish Tacos",
    "ingredients": [
      { "name": "Whitefish", "amount": 1, "measurement": "lb" },
      { "name": "Cheese", "amount": 1, "measurement": "cup" },
      { "name": "Iceberg Lettuce", "amount": 2, "measurement": "cups" },
      { "name": "Tomatoes", "amount": 2, "measurement": "large" },
      { "name": "Tortillas", "amount": 3, "measurement": "med" }
    ],
    "steps": [
      "Cook the fish on the grill until cooked through.",
      "Place the fish on the 3 tortillas.",
      "Top them with lettuce, tomatoes, and cheese."
    ]
  }
]

现在我们已经将代码拆分为单独的模块和文件,让我们使用 webpack 创建一个构建过程,将所有内容重新组合到一个单一文件中。你可能会想,“等等,我们刚刚把所有工作都分开了,现在我们要使用一个工具把它们重新放在一起?为什么会这样……?”将项目拆分为单独文件通常使得更大的项目更易于管理,因为团队成员可以在没有重叠的情况下工作在不同的组件上。这也意味着文件可能更容易测试。

3. 创建 webpack 构建

为了使用 webpack 创建静态构建过程,我们需要安装一些东西。我们需要的所有内容都可以通过 npm 安装:

npm install --save-dev webpack webpack-cli

请记住,我们已经安装了 React 和 ReactDOM。

要使此模块化 Recipes 应用程序正常工作,我们需要告诉 webpack 如何将我们的源代码捆绑成一个单独的文件。从版本 4.0.0 开始,webpack 不需要配置文件来捆绑项目。如果我们不包含配置文件,webpack 将运行默认设置来打包我们的代码。不过,使用配置文件意味着我们可以自定义我们的设置。此外,这也展示了 webpack 的一些魔法而不是将其隐藏起来。默认的 webpack 配置文件始终是webpack.config.js

我们的 Recipes 应用程序的起始文件是 index.js。它导入了 React、ReactDOM 和 Menu.js 文件。这是我们首先要在浏览器中运行的内容。无论 webpack 在哪里找到 import 语句,它都会在文件系统中找到关联的模块并将其包含在捆绑包中。index.js 导入 Menu.jsMenu.js 导入 Recipe.jsRecipe.js 导入 Instructions.jsIngredientsList.jsIngredientsList.js 导入 Ingredient.js。Webpack 将遵循这个导入树并在捆绑包中包含所有必要的模块。遍历所有这些文件创建了所谓的 依赖图。依赖只是我们应用程序需要的东西,例如组件文件、像 React 这样的库文件或图像。想象一下,我们需要的每个文件都是图上的一个圆圈,webpack 会在这些圆圈之间画线来创建图。这个图就是捆绑包。

导入语句

我们正在使用 import 语句,这在大多数浏览器或 Node.js 中目前不受支持。import 语句能工作的原因是,Babel 将它们转换为我们最终代码中的 require('module/path'); 语句。require 函数通常是加载 CommonJS 模块的方式。

当 webpack 构建我们的捆绑包时,我们需要告诉它将 JSX 转换为 React 元素。

webpack.config.js 文件只是另一个模块,它导出一个描述 webpack 应采取的操作的 JavaScript 字面对象。配置文件应保存在项目的根文件夹中,就在 index.js 文件旁边:

// ./webpack.config.js

var path = require("path");

module.exports = {
  entry: "./src/index.js",
  output: {
    path: path.join(__dirname, "dist", "assets"),
    filename: "bundle.js"
  }
};

首先,我们告诉 webpack 我们的客户端入口文件是 ./src/index.js。它将根据该文件中的 import 语句自动构建依赖图。接下来,我们指定我们希望输出到 ./dist/bundle.js 的捆绑 JavaScript 文件。这是 webpack 将最终打包的 JavaScript 放置的地方。

接下来,让我们安装必要的 Babel 依赖项。我们需要 babel-loader@babel/core

npm install babel-loader @babel/core --save-dev

webpack 的下一组指令包括要在指定模块上运行的加载器列表。这将添加到配置文件的 module 字段下:

module.exports = {
  entry: "./src/index.js",
  output: {
    path: path.join(__dirname, "dist", "assets"),
    filename: "bundle.js"
  },
  module: {
    rules: [{ test: /\.js$/, exclude: /node_modules/, loader: "babel-loader" }]
  }
};

rules 字段是一个数组,因为有许多类型的加载器可以与 webpack 配合使用。在这个例子中,我们只集成了 babel-loader。每个加载器都是一个 JavaScript 对象。test 字段是一个正则表达式,匹配加载器应该操作的每个模块的文件路径。在这种情况下,我们在所有导入的 JavaScript 文件上运行 babel-loader,除了在 node_modules 文件夹中找到的那些文件。

此时,我们需要为运行 Babel 指定预设。当我们设置预设时,我们告诉 Babel 应该执行哪些转换。换句话说,我们可以说,“嘿,Babel。如果你在这里看到一些 ESNext 语法,请将该代码转换为我们确保浏览器理解的语法。如果你看到一些 JSX,请也进行转换。”首先安装预设:

npm install @babel/preset-env @babel/preset-react --save-dev

然后在项目的根目录创建另一个文件:.babelrc

{
  "presets": ["@babel/preset-env", "@babel/preset-react"]
}

好了!我们创建了一个看起来像是真正的 React 应用程序的项目!让我们继续运行 webpack 来确保它可以工作。

Webpack 是静态运行的。通常,在将应用程序部署到服务器之前就会创建捆绑文件。你可以使用 npx 命令行运行它:

npx webpack --mode development

Webpack 要么成功创建一个捆绑包,要么失败并显示错误。大多数错误与损坏的导入引用有关。在调试 webpack 错误时,仔细查看 import 语句中使用的文件名和文件路径。

你还可以在 package.json 文件中添加一个 npm 脚本来创建一个快捷方式:

  "scripts": {
    "build": "webpack --mode production"
  },

然后你可以运行一个快捷方式来生成捆绑包:

npm run build

加载捆绑包

我们有了一个捆绑包,现在怎么办?我们将捆绑包导出到 dist 文件夹中。这个文件夹包含我们希望在 Web 服务器上运行的文件。dist 文件夹是应该放置 index.html 文件的地方。这个文件需要包含一个目标 div 元素,用于安装 React Menu 组件。它还需要一个单独的 script 标签来加载我们捆绑的 JavaScript:

// ./dist/index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>React Recipes App</title>
  </head>
  <body>
    <div id="root"></div>
    <script src="bundle.js"></script>
  </body>
</html>

这是你的应用程序的主页。它将从一个文件中加载所有需要的内容,一个 HTTP 请求:bundle.js。你需要将这些文件部署到你的 Web 服务器上,或者构建一个 Web 服务器应用程序,使用类似 Node.js 或 Ruby on Rails 这样的工具来提供这些文件。

源映射

将我们的代码打包成一个单独的文件可能导致在浏览器中调试应用程序时遇到一些问题。我们可以通过提供一个 source map 来消除这个问题。源映射是一个将捆绑文件映射到原始源文件的文件。使用 webpack,我们只需在 webpack.config.js 文件中添加几行代码即可。

//webpack.config.js with source mapping

module.exports = {
  ...
  devtool: "#source-map" // Add this option for source mapping
};

devtool 属性设置为 '#source-map' 告诉 webpack 你想要使用源映射。下次运行 webpack 时,你会看到生成并添加到 dist 文件夹中的两个输出文件:原始的 bundle.jsbundle.js.map

使用源映射将让你可以使用原始的源文件进行调试。在浏览器开发者工具的 Sources 标签页中,你应该会找到一个名为 webpack:// 的文件夹。在这个文件夹中,你会看到捆绑包中的所有源文件,如 图 5-4 所示。

image

图 5-4. Chrome 开发者工具的 Sources 面板

你可以使用浏览器的逐步调试器从这些文件中调试。点击任何行号都会添加一个断点。刷新浏览器将会在你的源文件中的任何断点处暂停 JavaScript 处理。你可以在 Scope 面板中检查作用域变量或在 Watch 面板中添加要监视的变量。

创建 React 应用程序

作为 React 开发者的一个非常棒的工具是 Create React App,一个命令行工具,可以自动生成 React 项目。Create React App 的灵感来自于Ember CLI 项目,它使开发者可以快速开始 React 项目,无需手动配置 webpack、Babel、ESLint 和相关工具。

要开始使用 Create React App,请全局安装该包:

npm install -g create-react-app

然后,使用命令和您想要创建应用程序的文件夹的名称:

create-react-app my-project

npx

您还可以使用 npx 运行 Create React App,而无需全局安装。只需运行npx create-react-app my-project

这将在该目录中创建一个 React 项目,仅依赖于三个库:React、ReactDOM 和react-scriptsreact-scripts也是由 Facebook 创建的,是真正魔力发生的地方。它安装了 Babel、ESLint、webpack 等,让您无需手动配置它们。在生成的项目文件夹中,您还会找到一个src文件夹,其中包含一个App.js文件。在这里,您可以编辑根组件并导入其他组件文件。

my-react-project文件夹内部,您可以运行npm start。如果您喜欢,也可以运行yarn start。这将在端口 3000 上启动您的应用程序。

您可以使用npm testyarn test来运行测试。这将以交互模式运行项目中的所有测试文件。

您还可以运行npm run build命令。使用 yarn,运行yarn build

这将创建一个经过转换和缩小的生产就绪捆绑包。

Create React App 不仅适合初学者,也适合有经验的 React 开发者。随着工具的发展,可能会增加更多功能,您可以关注GitHub上的变化。另一个不必担心设置自定义 webpack 构建的 React 起步方式是使用 CodeSandbox。CodeSandbox 是一个在线运行的 IDE,网址为https://codesandbox.io

在本章中,我们通过学习 JSX 提升了我们的 React 技能。我们创建了组件。我们将这些组件分解成一个项目结构,并且我们更多地了解了 Babel 和 webpack。现在我们准备将组件知识提升到下一个级别。是时候谈谈 Hooks 了。

第六章:React 状态管理

数据是使我们的 React 组件焕发生机的关键。我们在上一章中构建的食谱用户界面没有食谱数组是毫无用处的。正是食谱和配料以及清晰的说明使这样的应用程序值得使用。我们的用户界面是创作者用来生成内容的工具。为了为我们的内容创作者构建最佳工具,我们需要知道如何有效地操纵和更改数据。

在上一章中,我们构建了一个 组件树:一个数据能够通过属性流动的组件层次结构。属性是画面的其中一半,状态是另一半。React 应用程序的 状态 是由具有改变能力的数据驱动的。向食谱应用程序引入状态可以使厨师能够创建新的食谱,修改现有的食谱,并删除旧的食谱。

状态和属性相互关联。当我们使用 React 应用程序时,我们会优雅地组合基于这种关系的组件。当组件树的状态发生变化时,属性也会发生变化。新的数据通过树流动,导致特定的叶子和分支呈现以反映新的内容。

在本章中,我们将通过引入状态来使应用程序生动起来。我们将学习如何创建有状态组件,以及如何将状态传递到组件树中,并将用户交互返回到组件树中。我们将学习收集用户表单数据的技巧。我们还将看看在应用程序中通过引入有状态的上下文提供者来分离关注点的各种方式。

构 构建星级评分组件

如果没有五颗星的评分系统,我们都会吃到糟糕的食物,观看糟糕的电影。如果我们计划让用户主导我们网站上的内容,我们需要一种方式来了解这些内容是否好用。这使得 StarRating 组件成为你将要构建的最重要的 React 组件之一(参见图 6-1)。

lrc2 0601

图 6-1. 星级评分组件

StarRating 组件将允许用户根据特定数量的星星对内容进行评分。无用的内容得一星。高度推荐的内容得五颗星。用户可以通过点击特定的星星来设置特定内容的评分。首先,我们需要一个星星,我们可以从 react-icons 获取一个:

npm i react-icons

react-icons 是一个 npm 库,包含数百个 SVG 图标,作为 React 组件分发。通过安装它,我们就安装了几个流行的图标库,包含数百个常见的 SVG 图标。你可以在库中浏览所有图标。我们将使用 Font Awesome 集合中的星形图标:

import React from "react";
import { FaStar } from "react-icons/fa";

export default function StarRating() {
  return [
    <FaStar color="red" />,
    <FaStar color="red" />,
    <FaStar color="red" />,
    <FaStar color="grey" />,
    <FaStar color="grey" />
  ];
}

在这里,我们创建了一个 StarRating 组件,它渲染了五个 SVG 星星,我们从 react-icons 中导入了这些星星。前三颗星星填充为红色,最后两颗为灰色。我们首先渲染这些星星,因为看到它们可以为我们接下来的构建提供路线图。选中的星星应该填充为红色,未选中的星星应该变灰色。让我们创建一个组件,根据选中的属性自动填充星星:

const Star = ({ selected = false }) => (
  <FaStar color={selected ? "red" : "grey"} />
);

Star 组件渲染单个星星,并使用 selected 属性来填充合适的颜色。如果未传递 selected 属性给此组件,我们将假定该星星不被选中,默认填充灰色。

5 星评级系统非常流行,但 10 星评级系统更加详细。当开发人员将此组件添加到他们的应用程序中时,我们应该允许他们选择希望使用的星星总数。可以通过向 StarRating 组件添加 totalStars 属性来实现这一目标:

const createArray = length => [...Array(length)];

export default function StarRating({ totalStars = 5 }) {
  return createArray(totalStars).map((n, i) => <Star key={i} />);
}

在这里,我们从第二章中添加了 createArray 函数。我们只需提供要创建的数组的长度,就可以得到一个新的数组。我们使用 totalStars 属性调用此函数来创建具有特定长度的数组。一旦我们有了数组,我们就可以对其进行映射并渲染 Star 组件。默认情况下,totalStars 等于 5,这意味着此组件将渲染 5 个灰色星星,如图 6-2 所示。

lrc2 0602

图 6-2. 显示了五颗星

useState Hook

现在是时候让 StarRating 组件可点击,这将允许用户更改 rating。由于 rating 是一个会变化的值,我们将使用 React 状态来存储和更改该值。我们使用 React 功能称为 Hooks 将状态集成到函数组件中。 Hooks 包含与组件树分离的可重用代码逻辑。它们允许我们将功能连接到我们的组件上。React 默认提供了几个内置的 hook 可供我们直接使用。在这种情况下,我们希望向我们的 React 组件添加状态,因此我们将首先使用 React 的 useState hook。这个 hook 已经包含在 react 包中,我们只需导入它:

import React, { useState } from "react";
import { FaStar } from "react-icons/fa";

用户选择的星星代表评级。我们将创建一个名为 selectedStars 的状态变量,它将保存用户的评级。我们将通过直接向 StarRating 组件添加 useState hook 来创建此变量:

export default function StarRating({ totalStars = 5 }) {
  const [selectedStars] = useState(3);
  return (
    <>
      {createArray(totalStars).map((n, i) => (
        <Star key={i} selected={selectedStars > i} />
      ))}
      <p>
        {selectedStars} of {totalStars} stars
      </p>
    </>
  );
}

我们刚刚使用了状态将这个组件连接起来。useState钩子是一个可以调用以返回数组的函数。数组的第一个值是我们想要使用的状态变量。在这种情况下,那个变量是selectedStars,或者StarRating要渲染成红色的星星数。useState返回一个数组。我们可以利用数组解构,可以给我们的状态变量起任何我们喜欢的名字。我们发送给useState函数的值是状态变量的默认值。在这种情况下,selectedStars最初将被设置为3,如图 6-3 所示。

lrc2 0603

图 6-3. 五颗星中有三颗被选中

为了从用户那里收集不同的评分,我们需要允许他们点击我们的星星之一。这意味着我们需要通过给FaStar组件添加一个onClick处理程序来使星星可点击:

const Star = ({ selected = false, onSelect = f => f }) => (
  <FaStar color={selected ? "red" : "grey"} onClick={onSelect} />
);

这里,我们修改了星星组件,添加了一个onSelect属性。来看看:这个属性是一个函数。当用户点击FaStar组件时,我们将调用这个函数,它可以通知其父组件星星被点击了。这个函数的默认值是f => f。这只是一个什么都不做的假函数;它只是返回传递给它的任何参数。然而,如果我们不设置默认函数并且onSelect属性未定义,当点击FaStar组件时将会出现错误,因为onSelect的值必须是一个函数。即使f => f什么都不做,它也是一个函数,这意味着可以调用它而不会出错。如果onSelect属性未定义,也没问题。React 将简单地调用这个假函数,什么都不会发生。

现在我们的Star组件可以点击了,我们将用它来改变StarRating的状态:

export default function StarRating({ totalStars = 5 }) {
  const [selectedStars, setSelectedStars] = useState(0);
  return (
    <>
      {createArray(totalStars).map((n, i) => (
        <Star
          key={i}
          selected={selectedStars > i}
          onSelect={() => setSelectedStars(i + 1)}
        />
      ))}
      <p>
        {selectedStars} of {totalStars} stars
      </p>
    </>
  );
}

为了改变StarRating组件的状态,我们需要一个可以修改selectedStars值的函数。由useState钩子返回的数组中的第二项是一个可以用来改变状态值的函数。再次通过解构这个数组,我们可以将这个函数命名为任何我们喜欢的名字。在这种情况下,我们称之为setSelectedStars,因为它的作用就是设置selectedStars的值。

关于 Hooks 最重要的事情是它们可以导致它们所附着到的组件重新渲染。每当我们调用setSelectedStars函数来改变selectedStars的值时,StarRating函数组件将被钩子重新调用,并且将以新的selectedStars值再次渲染。这就是 Hooks 如此强大的原因。当 hook 内的数据发生变化时,它们有能力使用新数据重新渲染它们所附着到的组件。

每当用户点击Star时,StarRating组件都将重新渲染。当用户点击Star时,会调用该星星的onSelect属性。当调用onSelect属性时,我们将调用setSelectedStars函数,并将刚刚选中的星星数传递给它。我们可以使用map函数中的变量i来帮助我们计算该数值。当map函数渲染第一个Star时,变量i的值为0。这意味着我们需要将此值加1以获取正确的星星数量。当调用setSelectedStars时,StarRating组件将使用selectedStars的值调用,如图 6-4 所示。

lrc2 0604

图 6-4. React 开发者工具中的 Hooks

在 React 开发者工具中,你可以看到哪些 Hooks 与特定组件集成。当我们在浏览器中渲染StarRating组件时,可以通过在开发者工具中选择它来查看关于该组件的调试信息。在右侧列中,我们可以看到StarRating组件集成了一个状态 Hook,其值为2。当我们与应用交互时,可以观察状态值的变化以及组件树根据选择的星星数量重新渲染。

为高级可重用性重构

现在,Star 组件已经准备好投入生产。当你需要从用户那里获取评分时,可以在多个应用程序中使用它。然而,如果我们要将此组件发布到 npm,以便全世界的任何人都可以使用它来获取用户的评分,我们可能需要考虑处理更多的使用案例。

首先,让我们考虑style属性。此属性允许您向元素添加 CSS 样式。未来的开发人员,甚至是你自己,可能会有必要修改整个容器的样式。他们可能会尝试做类似这样的事情:

export default function App() {
  return <StarRating style={{ backgroundColor: "lightblue" }} />;
}

所有 React 元素都有样式属性。许多组件也有样式属性。因此,尝试修改整个组件的样式似乎是合理的。

我们只需收集这些样式并将它们传递给StarRating容器。目前,StarRating没有单一的容器,因为我们正在使用 React 片段。为了使其正常工作,我们需要从片段升级到一个div元素,并将样式传递给该div

export default function StarRating({ style = {}, totalStars = 5 }) {
  const [selectedStars, setSelectedStars] = useState(0);
  return (
    <div style={{ padding: "5px", ...style }}>
      {createArray(totalStars).map((n, i) => (
        <Star
          key={i}
          selected={selectedStars > i}
          onSelect={() => setSelectedStars(i + 1)}
        />
      ))}
      <p>
        {selectedStars} of {totalStars} stars
      </p>
    </div>
  );
}

在上述代码中,我们将片段替换为一个div元素,然后将样式应用于该div元素。默认情况下,我们为该div分配5px的填充,然后使用展开运算符将style对象的其余属性应用于div样式。

此外,我们可能会发现开发人员尝试为整个星级评分实现其他常见属性属性:

export default function App() {
  return (
    <StarRating
      style={{ backgroundColor: "lightblue" }}
      onDoubleClick={e => alert("double click")}
    />
  );
}

在这个示例中,用户正在尝试为整个StarRating组件添加双击方法。如果我们认为有必要,我们也可以将此方法与任何其他属性一起传递给包含它的div

export default function StarRating({ style = {}, totalStars = 5, ...props }) {
  const [selectedStars, setSelectedStars] = useState(0);
  return (
    <div style={{ padding: 5, ...style }} {...props}>
      ...
    </div>
  );
}

第一步是收集用户可能尝试添加到StarRating的任何和所有属性。我们使用展开运算符...props收集这些属性。接下来,我们将所有这些剩余的属性传递给div元素:{...props}

通过这样做,我们做出了两个假设。首先,我们假设用户只会添加 div 元素支持的属性。其次,我们假设我们的用户不能向组件添加恶意属性。

这并不是适用于所有组件的普遍规则。事实上,只有在某些情况下才添加此级别的支持是一个好主意。真正的重点在于重要性思考消费者可能在未来如何尝试使用组件。

组件树中的状态

在每个组件中使用状态并不是一个好主意。将状态数据分散到太多的组件中将使得在应用程序中跟踪错误和进行更改变得更加困难。这是因为很难跟踪状态值在组件树中的位置。如果您从一个位置管理应用程序的状态或特定功能的状态,那么理解应用程序的状态会更容易。有几种方法可以实现这种方法,我们将分析的第一种方法是将状态存储在组件树的根部,并通过 props 将其传递给子组件。

让我们构建一个可以用来保存颜色列表的小应用程序。我们将称此应用程序为“颜色组织者”,它将允许用户将一系列颜色与自定义标题和评分关联起来。为了开始,一个示例数据集可能如下所示:

[
  {
    "id": "0175d1f0-a8c6-41bf-8d02-df5734d829a4",
    "title": "ocean at dusk",
    "color": "#00c4e2",
    "rating": 5
  },
  {
    "id": "83c7ba2f-7392-4d7d-9e23-35adbe186046",
    "title": "lawn",
    "color": "#26ac56",
    "rating": 3
  },
  {
    "id": "a11e3995-b0bd-4d58-8c48-5e49ae7f7f23",
    "title": "bright red",
    "color": "#ff0000",
    "rating": 0
  }
]

color-data.json文件包含一个包含三种颜色的数组。每种颜色都有一个idtitlecolorrating。首先,我们将创建一个 UI,由 React 组件组成,用于在浏览器中显示这些数据。然后,我们将允许用户添加新颜色,以及对列表中的颜色进行评分和删除。

向组件树传递状态

在这个迭代中,我们将状态存储在“颜色组织者”的根部,即App组件中,并将颜色传递给子组件以处理渲染。App组件将是我们应用程序中唯一拥有状态的组件。我们将使用useState钩子将颜色列表添加到App中:

import React, { useState } from "react";
import colorData from "./color-data.json";
import ColorList from "./ColorList.js";

export default function App() {
  const [colors] = useState(colorData);
  return <ColorList colors={colors} />;
}

App组件位于我们的树的根部。将useState添加到此组件将其与颜色状态管理连接起来。在此示例中,colorData是上述示例颜色的数组。App组件使用colorData作为colors的初始状态。从那里,colors被传递给一个名为ColorList的组件:

import React from "react";
import Color from "./Color";

export default function ColorList({ colors = [] }) {
  if(!colors.length) return <div>No Colors Listed.</div>;
  return (
    <div>
      {
        colors.map(color => <Color key={color.id} {...color} />)
      }
    </div>
  );
}

ColorList 作为 App 组件的 props 接收颜色。如果列表为空,此组件将向用户显示消息。当我们有一个颜色数组时,我们可以对其进行映射,并将每种颜色的详细信息传递到 Color 组件:

export default function Color({ title, color, rating }) {
  return (
    <section>
      <h1>{title}</h1>
      <div style={{ height: 50, backgroundColor: color }} />
      <StarRating selectedStars={rating} />
    </section>
  );
}

Color 组件期望三个属性:titlecolorrating。这些值在每个 color 对象中找到,并使用扩展运算符 <Color {...color} /> 将它们作为具有相同名称的属性传递给此组件。Color 组件显示这些值。titleh1 元素中呈现。color 值显示为 div 元素的 backgroundColorrating 被传递到更深层次的 StarRating 组件,它将以选定的星星形式可视化显示评级:

export default function StarRating({ totalStars = 5, selectedStars = 0 }) {
  return (
    <>
      {createArray(totalStars).map((n, i) => (
        <Star
          key={i}
          selected={selectedStars > i}
        />
      ))}
      <p>
        {selectedStars} of {totalStars} stars
      </p>
    </>
  );
}

这个 StarRating 组件已经被修改。我们把它变成了一个纯组件。纯组件是一个不包含状态的函数组件,给定相同的 props 将呈现相同的用户界面。我们将此组件变为纯组件,因为颜色评级的状态存储在树的根部的 colors 数组中。请记住,此迭代的目标是在单个位置存储状态,而不是在树中的许多不同组件中分布状态。

注意

StarRating 组件有可能保持其自己的状态,并通过 props 从父组件接收状态。当向社区分发组件以供更广泛使用时,通常需要这样做。在下一章中,我们在讨论 useEffect 钩子时将演示这种技术。

到目前为止,我们已经从 App 组件向每个填充红色以直观表示每种颜色 ratingStar 组件传递了状态。如果我们根据先前列出的 color-data.json 文件渲染应用程序,我们应该在浏览器中看到我们的颜色,如图 6-5 所示。

lrc2 0605

图 6-5. 在浏览器中呈现的颜色组织器

将交互发送回组件树的顶层

到目前为止,我们已通过将数据从父组件传递到子组件的方式,通过组合 React 组件渲染了 colors 数组的 UI 表示。如果我们想要从列表中删除颜色或更改颜色的评级,会发生什么? colors 存储在树的根部的状态中。我们需要从子组件收集交互并将其发送回树的根组件,以便我们可以更改状态。

例如,假设我们想要在每种颜色的标题旁边添加一个删除按钮,允许用户从状态中删除颜色。我们将该按钮添加到 Color 组件中:

import { FaTrash } from "react-icons/fa";

export default function Color({ id, title, color, rating, onRemove = f => f }) {
  return (
    <section>
      <h1>{title}</h1>
      <button onClick={() => onRemove(id)}>
        <FaTrash />
      </button>
      <div style={{ height: 50, backgroundColor: color }} />
      <StarRating selectedStars={rating} />
    </section>
  );
}

在这里,我们通过添加一个按钮来修改颜色,允许用户移除颜色。首先,我们从react-icons中导入了一个垃圾桶图标。接下来,我们将FaTrash图标包装在一个按钮中。为此按钮添加一个onClick处理程序,允许我们调用添加到我们属性列表中的onRemove函数属性,该属性与id一起被添加。当用户点击删除按钮时,我们将调用removeColor并传递要移除的颜色的id。这也是为什么从Color组件的属性中收集到id值。

这个解决方案很棒,因为我们保持了Color组件的纯净性。它没有状态,并且可以轻松地在应用程序的不同部分或完全不同的另一个应用程序中重用。Color组件并不关心用户点击删除按钮后会发生什么。它只关心通知父组件此事件已发生,并传递关于用户希望移除哪种颜色的信息。现在,处理此事件的责任属于父组件:

export default function ColorList({ colors = [], onRemoveColor = f => f }) {
  if (!colors.length) return <div>No Colors Listed. (Add a Color)</div>;

return (
    colors.map(color => (
          <Color key={color.id} {...color} onRemove={onRemoveColor} />
        )
      }
    </div>
  );
}

Color组件的父组件是ColorList。这个组件也没有访问状态的权限。它不是移除颜色,而是简单地将事件传递给其父组件。它通过添加一个onRemoveColor函数属性来实现这一点。如果Color组件调用onRemove属性,ColorList将依次调用其onRemoveColor属性,并将移除颜色的责任传递给其父组件。颜色的id仍然会传递给onRemoveColor函数。

ColorList的父组件是App。这个组件是已经与状态连接的组件。这是我们可以捕获颜色id并在状态中移除颜色的地方:

export default function App() {
  const [colors, setColors] = useState(colorData);
  return (
    <ColorList
      colors={colors}
      onRemoveColor={id => {
        const newColors = colors.filter(color => color.id !== id);
        setColors(newColors);
      }}
    />
  );
}

首先,我们添加一个变量setColors。记住,useState返回的数组的第二个参数是一个函数,我们可以用它来修改状态。当ColorList触发onRemoveColor事件时,我们捕获要从参数中移除的颜色的id,并使用它来过滤颜色列表,以排除用户想要移除的颜色。接下来,我们改变状态。我们使用setColors函数将颜色数组更改为新过滤的数组。

改变colors数组的状态会导致App组件重新渲染,展示新的颜色列表。这些新的颜色会传递给ColorList组件,该组件也会重新渲染。它将为剩余的颜色渲染Color组件,我们的 UI 将反映出我们通过渲染少一个颜色来做出的更改。

如果我们想对存储在App组件状态中的colors进行评级,我们将不得不重复使用onRate事件的过程。首先,我们将从单击的星级中收集新的评分,并将该值传递给StarRating的父组件:

export default function StarRating({
  totalStars = 5,
  selectedStars = 0,
  onRate = f => f
}) {
  return (
    <>
      {createArray(totalStars).map((n, i) => (
        <Star
          key={i}
          selected={selectedStars > i}
          onSelect={() => onRate(i + 1)}
        />
      ))}
    </>
  );
}

然后,我们将从我们添加到StarRating组件的onRate处理程序中获取评分。然后,我们将新的评分与要评分的颜色的id一起通过另一个onRate函数属性传递给Color组件的父组件:

export default function Color({
  id,
  title,
  color,
  rating,
  onRemove = f => f,
  onRate = f => f
}) {
  return (
    <section>
      <h1>{title}</h1>
      <button onClick={() => onRemove(id)}>
        <FaTrash />
      </button>
      <div style={{ height: 50, backgroundColor: color }} />
      <StarRating
        selectedStars={rating}
        onRate={rating => onRate(id, rating)}
      />
    </section>
  );
}

ColorList组件中,我们需要捕获来自各个颜色组件的onRate事件,并通过onRateColor函数属性将其传递给其父组件:

export default function ColorList({
  colors = [],
  onRemoveColor = f => f,
  onRateColor = f => f
}) {
if (!colors.length) return <div>No Colors Listed. (Add a Color)</div>;
  return (
    <div className="color-list">
      {
        colors.map(color => (
          <Color
            key={color.id}
            {...color}
            onRemove={onRemoveColor}
            onRate={onRateColor}
          />
        )
      }
    </div>
  );
}

最后,在所有这些组件中传递事件之后,我们将到达App,在那里存储状态并可以保存新的评分:

export default function App() {
  const [colors, setColors] = useState(colorData);
  return (
    <ColorList
      colors={colors}
      onRateColor={(id, rating) => {
        const newColors = colors.map(color =>
          color.id === id ? { ...color, rating } : color
        );
        setColors(newColors);
      }}
      onRemoveColor={id => {
        const newColors = colors.filter(color => color.id !== id);
        setColors(newColors);
      }}
    />
  );
}

ColorList调用带有颜色id和新评分的onRateColor属性时,App组件将更改颜色评分。我们将使用这些值构建一个新颜色数组,通过映射现有颜色并为匹配id属性的颜色更改评分。一旦我们将newColors发送到setColors函数,colors的状态值将更改,并且App组件将使用colors数组的新值被调用。

一旦我们的colors数组的状态发生变化,UI 树就会用新数据重新渲染。新的评分通过红色星星反馈给用户。正如我们通过 props 向组件树下传递数据一样,交互也可以通过函数属性将数据传回树上。

构建表单

对于我们许多人来说,作为网页开发人员意味着从用户那里收集大量信息。如果这听起来像是你的工作,那么你将会使用 React 构建大量的表单组件。所有在 DOM 中可用的 HTML 表单元素也可以作为 React 元素使用,这意味着你可能已经知道如何使用 JSX 渲染表单:

<form>
  <input type="text" placeholder="color title..." required />
  <input type="color" required />
  <button>ADD</button>
</form>

这个form元素有三个子元素:两个input元素和一个button。第一个input元素是一个文本输入,将用于收集新颜色的title值。第二个input元素是一个 HTML 颜色输入,允许用户从颜色选择器中选择color。我们将使用基本的 HTML 表单验证,因此我们已将两个输入标记为required。ADD 按钮用于添加新颜色。

使用 Refs

当在 React 中构建表单组件时,有几种可用的模式供选择。其中一种模式涉及使用 React 的一个特性直接访问 DOM 节点,这个特性称为 refs。在 React 中,ref 是一个对象,用于在组件的生命周期内存储值。有几种使用 refs 的用例。在本节中,我们将看看如何使用 ref 直接访问 DOM 节点。

React 为我们提供了一个useRef钩子,我们可以用它来创建一个ref。在构建AddColorForm组件时,我们将使用这个钩子:

import React, { useRef } from "react";

export default function AddColorForm({ onNewColor = f => f }) {
  const txtTitle = useRef();
  const hexColor = useRef();

  const submit = e => { ... }

  return (...)
}

首先,在创建此组件时,我们还将使用useRef钩子创建两个引用。txtTitle引用将用于引用我们添加到表单中以收集颜色标题的文本输入。hexColor引用将用于从 HTML 颜色输入中访问十六进制颜色值。我们可以直接在 JSX 中使用ref属性为这些引用设置值:

  return (
    <form onSubmit={submit}>
      <input ref={txtTitle} type="text" placeholder="color title..." required />
      <input ref={hexColor} type="color" required />
      <button>ADD</button>
    </form>
  );
}

在这里,我们通过在 JSX 中为这些输入元素添加ref属性来设置txtTitlehexColor引用的值。这会在我们的引用对象上创建一个current字段,直接引用 DOM 元素。这使我们可以访问 DOM 元素,这意味着我们可以捕获它的值。当用户通过单击“ADD”按钮提交此表单时,我们将调用submit函数:

const submit = e => {
  e.preventDefault();
  const title = txtTitle.current.value;
  const color = hexColor.current.value;
  onNewColor(title, color);
  txtTitle.current.value = "";
  hexColor.current.value = "";
};

当我们提交 HTML 表单时,默认情况下它们会将表单元素的值存储在请求体中,发送 POST 请求到当前 URL。我们不想这样做。这就是为什么submit函数中的第一行代码是e.preventDefault(),它阻止浏览器尝试使用 POST 请求提交表单。

接下来,我们使用它们的引用捕获每个表单元素的当前值。这些值随后通过onNewColor函数属性传递到此组件的父组件。新颜色的标题和十六进制值都作为函数参数传递。最后,我们重置两个输入的value属性以清除数据,并准备收集另一种颜色。

你是否注意到使用引用(refs)发生的微妙范式转变?我们通过将 DOM 节点的value属性直接设置为""空字符串来直接修改它们。这是命令式的代码。AddColorForm现在被称为非受控组件,因为它使用 DOM 来保存表单值。有时候,使用非受控组件可以解决问题。例如,你可能希望将表单及其值与 React 之外的代码共享。然而,受控组件是更好的方法。

受控组件

受控组件中,表单值由 React 管理,而不是由 DOM 管理。它们不要求我们使用引用。它们不要求我们编写命令式的代码。在使用受控组件时,添加诸如强大的表单验证功能要容易得多。让我们通过使AddColorForm控制表单状态来修改它:

import React, { useState } from "react";

export default function AddColorForm({ onNewColor = f => f }) {
  const [title, setTitle] = useState("");
  const [color, setColor] = useState("#000000");

  const submit = e => { ... };

  return ( ... );
}

首先,我们将不再使用引用,而是使用 React 状态保存titlecolor的值。我们将为titlecolor创建变量。此外,我们将定义可以用于更改状态的函数:setTitlesetColor

现在组件控制了titlecolor的值,我们可以通过设置value属性在表单输入元素内显示它们。一旦我们设置了输入元素的value属性,我们将无法再通过表单更改它。此时改变值的唯一方法是每次用户在输入元素中键入新字符时更改状态变量。这正是我们要做的事情:

<form onSubmit={submit}>
  <input
    value={title}
    onChange={event => setTitle(event.target.value)}
    type="text"
    placeholder="color title..."
    required
  />
  <input
    value={color}
    onChange={event => setColor(event.target.value)}
    type="color"
    required
  />
  <button>ADD</button>
</form>
}

这个受控组件现在使用状态中的titlecolor值来设置两个input元素的值。每当这些元素引发onChange事件时,我们可以使用event参数访问新值。event.target是对 DOM 元素的引用,因此我们可以使用event.target.value获取该元素的当前值。当title改变时,我们将调用setTitle来改变状态中的标题值。改变该值将导致此组件重新渲染,我们现在可以在input元素内显示新的title值。更改颜色的方式完全相同。

当提交表单时,我们可以简单地将titlecolor的状态值作为参数传递给onNewColor函数属性在调用时。setTitlesetColor函数可以用于在将新颜色传递给父组件后重置值:

const submit = e => {
  e.preventDefault();
  onNewColor(title, color);
  setTitle("");
  setColor("");
};

它被称为受控组件,因为 React 控制表单的状态。值得指出的是,受控表单组件经常重新渲染。想想看:每在title字段中键入一个新字符都会导致AddColorForm重新渲染。在颜色选择器中使用颜色轮会导致此组件重新渲染次数比title字段多得多,因为颜色值随着用户在颜色轮周围拖动鼠标而重复变化。这没问题——React 被设计用来处理这种类型的工作负载。希望知道受控组件经常重新渲染将阻止您向此组件添加一些长时间和昂贵的过程。至少,在优化 React 组件时,这种知识会很有用。

创建自定义钩子

当你有一个包含许多input元素的大表单时,你可能会忍不住复制并粘贴这两行代码:

value={title}
onChange={event => setTitle(event.target.value)}

看起来通过简单地复制粘贴这些属性到每个表单元素中,并在途中调整变量名,你可能会感觉自己工作速度更快。然而,每当你复制粘贴代码时,你应该听到头脑中响起一个微小的警报声。复制粘贴代码意味着有些东西足够冗余,可以在一个函数中抽象掉。

我们可以将创建受控表单组件所需的详细信息打包到自定义钩子中。我们可以创建自己的useInput钩子,在其中可以抽象掉创建受控表单输入所涉及的冗余部分:

import { useState } from "react";

export const useInput = initialValue => {
  const [value, setValue] = useState(initialValue);
  return [
    { value, onChange: e => setValue(e.target.value) },
    () => setValue(initialValue)
  ];
};

这是一个自定义钩子。它不需要很多代码。在这个钩子内部,我们仍然使用useState钩子来创建一个状态value。接下来,我们返回一个数组。数组的第一个值是一个对象,该对象包含了我们曾试图复制粘贴的相同属性:从状态中获取的value以及一个onChange函数属性,用于更改状态中的该值。数组的第二个值是一个函数,用于将value重置为其初始值。我们可以在AddColorForm内部使用我们的钩子:

import React from "react";
import { useInput } from "./hooks";

export default function AddColorForm({ onNewColor = f => f }) {
  const [titleProps, resetTitle] = useInput("");
  const [colorProps, resetColor] = useInput("#000000");

  const submit = event => { ... }

  return ( ... )
}

useState钩子被封装在我们的useInput钩子内部。我们可以通过解构从返回的数组的第一个值获取titlecolor的属性。数组的第二个值包含一个函数,我们可以使用它来将value属性重置为其初始值,即空字符串。titlePropscolorProps已准备好扩展到相应的输入元素中:

return (
  <form onSubmit={submit}>
    <input
      {...titleProps}
      type="text"
      placeholder="color title..."
      required
    />
    <input {...colorProps} type="color" required />
    <button>ADD</button>
  </form>
);
}

从我们的自定义钩子中扩展这些属性比粘贴它们更有趣。现在标题和颜色输入都接收到了关于它们的值和onChange事件的属性。我们使用我们的钩子创建了受控表单输入,而不用担心底层实现细节。我们需要做的唯一其他更改是在提交此表单时:

const submit = event => {
  event.preventDefault();
  onNewColor(titleProps.value, colorProps.value);
  resetTitle();
  resetColor();
};

submit函数内部,我们需要确保从它们的属性中获取titlecolorvalue。最后,我们可以使用从useInput钩子返回的自定义重置函数。

钩子被设计用于在 React 组件内部使用。我们可以在其他钩子中组合钩子,因为最终自定义的钩子将在组件内部使用。在这个钩子内改变状态仍会导致AddColorForm重新渲染,并传入titlePropscolorProps的新值。

将颜色添加到状态

控制表单组件和非控制表单组件都通过onNewColor函数将titlecolor的值传递给父组件。父组件不关心我们使用的是受控组件还是非受控组件;它只想要新颜色的值。

让我们将AddColorForm添加到App组件中,无论您选择哪个。当调用onNewColor属性时,我们将保存新的颜色到状态中:

import React, { useState } from "react";
import colorData from "./color-data.json";
import ColorList from "./ColorList.js";
import AddColorForm from "./AddColorForm";
import { v4 } from "uuid";

export default function App() {
  const [colors, setColors] = useState(colorData);
  return (
    <>
      <AddColorForm
        onNewColor={(title, color) => {
          const newColors = [
            ...colors,
            {
              id: v4(),
              rating: 0,
              title,
              color
            }
          ];
          setColors(newColors);
        }}
      />
      <ColorList .../>
    </>
  );
}

当添加新颜色时,将调用 onNewColor 属性。将新颜色的 title 和十六进制值作为参数传递给此函数。我们使用这些参数来创建一个新的颜色数组。首先,我们将当前状态中的 colors 展开到新数组中。然后,我们使用 titlecolor 值添加一个全新的颜色对象。此外,我们将新颜色的 rating 设置为 0,因为它尚未被评级。我们还使用 uuid 包中的 v4 函数生成新的唯一 id 来标识该颜色。一旦我们有了包含新颜色的颜色数组,我们通过调用 setColors 将其保存到状态中。这将导致 App 组件使用新的颜色数组重新渲染。我们将在列表底部看到新的颜色。

With this change, we’ve completed the first iteration of the Color Organizer. Users can now add new colors to the list, remove colors from the list, and rate any existing color on that list.

React 上下文

将状态存储在树的根节点的一个位置上是一个重要的模式,这帮助我们所有人在 React 的早期版本中更成功。学会通过属性在组件树上下传递状态是任何 React 开发者的必经之路——这是我们所有人都应该知道如何做的事情。然而,随着 React 的发展和我们的组件树变得更大,遵循这一原则逐渐变得更不现实。对于许多开发者来说,在组件树的根节点处维护状态对于复杂的应用程序是困难的。通过几十个组件传递状态是繁琐且容易出错的。

大多数我们工作的 UI 元素都很复杂。树的根节点通常离叶子节点很远。这使得应用程序所依赖的数据与使用数据的组件之间相隔多层。每个组件必须接收它们仅传递给子组件的 props。这会使我们的代码变得臃肿,使我们的 UI 难以扩展。

将状态数据通过每个组件作为 props 传递,直到它达到需要使用它的组件,就像乘坐从旧金山到华盛顿的火车一样。在火车上,你会经过每一个州,但直到你到达目的地才会下车(见图 6-6)。

lrc2 0607

图 6-6. 从旧金山到华盛顿的火车

显然,从旧金山到华盛顿飞行更有效率。这样,你就不必经过每一个州——你只需飞过它们(见图 6-7)。

lrc2 0608

图 6-7. 从旧金山到华盛顿的飞行

在 React 中,上下文就像是为您的数据乘坐喷气式飞机。 您可以通过创建上下文提供者将数据放入 React 上下文中。 上下文提供者是您可以包装在整个组件树或特定组件树部分周围的 React 组件。 上下文提供者是您的数据登机的出发机场。 它也是航空公司的枢纽。 所有航班从那个机场起飞到不同的目的地。 每个目的地都是一个上下文消费者。 上下文消费者是从上下文中检索数据的 React 组件。 这是您的数据降落、卸货并开始工作的目的地机场。

使用上下文仍然允许我们将状态数据存储在一个位置,但不需要将这些数据传递给不需要它的一堆组件。

将颜色放入上下文中

要在 React 中使用上下文,我们必须首先将一些数据放入上下文提供者中,并将该提供者添加到我们的组件树中。 React 带有一个名为createContext的函数,我们可以使用它来创建一个新的上下文对象。 这个对象包含两个组件:一个上下文Provider和一个Consumer

让我们将color-data.json文件中找到的默认颜色放入上下文中。 我们将上下文添加到index.js文件中,这是我们应用程序的入口点:

import React, { createContext } from "react";
import colors from "./color-data";
import { render } from "react-dom";
import App from "./App";

export const ColorContext = createContext();

render(
  <ColorContext.Provider value={{ colors }}>
    <App />
  </ColorContext.Provider>,
  document.getElementById("root")
);

使用createContext,我们创建了一个名为ColorContext的新实例 React 上下文。 颜色上下文包含两个组件:ColorContext.ProviderColorContext.Consumer。 我们需要使用提供者将颜色放入状态中。 通过设置Providervalue属性来向上下文添加数据。 在这种情况下,我们向上下文添加了包含colors的对象。 由于我们将整个App组件与提供者包装在一起,因此colors数组将在我们整个组件树中的任何上下文消费者中都可用。 需要注意的是,我们还从此位置导出了ColorContext。 这是必要的,因为当我们想要从上下文中获取colors时,我们将需要访问ColorContext.Consumer

注意

上下文Provider并不总是需要包装整个应用程序。 将特定部分的组件用上下文Provider包装起来不仅可以接受,而且可以使您的应用程序更高效。 该Provider仅将上下文值提供给其子元素。

使用多个上下文提供者是可以的。 实际上,您可能已经在您的 React 应用程序中使用上下文提供者,而不需要知道。 许多设计用于与 React 一起工作的 npm 包在幕后使用上下文。

现在我们在上下文中提供colors值,App组件不再需要保持状态并将其作为 props 传递给其子组件。我们已经使App组件成为一个“过渡”组件。ProviderApp组件的父组件,它在上下文中提供colorsColorListApp组件的子组件,它可以自行直接获取colors。因此,应用程序根本不需要触及颜色,这很好,因为App组件本身与颜色无关。责任已经委托给了树的更深处。

我们可以从App组件中删除大量代码行。它只需要渲染AddColorFormColorList。它不再需要担心数据:

import React from "react";
import ColorList from "./ColorList.js";
import AddColorForm from "./AddColorForm";

export default function App() {
  return (
    <>
      <AddColorForm />
      <ColorList />
    </>
  );
}

使用 useContext 检索颜色

Hooks 的加入使得处理上下文变得愉快。useContext钩子用于从上下文中获取值,并且它获取我们从上下文Consumer中需要的那些值。ColorList组件不再需要从其属性中获取colors数组。它可以通过useContext钩子直接访问它们:

import React, { useContext } from "react";
import { ColorContext } from "./";
import Color from "./Color";

export default function ColorList() {
  const { colors } = useContext(ColorContext);
  if (!colors.length) return <div>No Colors Listed. (Add a Color)</div>;
  return (
    <div className="color-list">
      {
        colors.map(color => <Color key={color.id} {...color} />)
      }
    </div>
  );
}

这里,我们修改了ColorList组件,并移除了colors=[]属性,因为colors是从上下文中获取的。使用useContext钩子需要上下文实例来从中获取值。ColorContext实例从index.js文件中导入,该文件中创建了上下文并将提供者添加到我们的组件树中。ColorList现在可以根据上下文提供的数据构建用户界面。

使用上下文消费者

ConsumeruseContext钩子中访问,这意味着我们不再需要直接使用消费者组件。在 Hooks 之前,我们必须使用称为render props的模式从上下文消费者中获取颜色。渲染 props 作为参数传递给子函数。以下示例展示了如何使用消费者从上下文中获取颜色:

export default function ColorList() {
  return (
    <ColorContext.Consumer>
      {context => {
      if (!context.colors.length)
      return <div>No Colors Listed. (Add a Color)</div>;
        return (
          <div className="color-list">
            {
              context.colors.map(color =>
  <Color key={color.id} {...color} />)
            }
          </div>
        )
      }}
    </ColorContext.Consumer>
  )
}

有状态上下文提供者

上下文提供者可以将对象放入上下文中,但不能单独改变上下文中的值。它需要从父组件获得帮助。关键是创建一个有状态组件来渲染上下文提供者。当有状态组件的状态改变时,它将使用新的上下文数据重新渲染上下文提供者。任何上下文提供者的子级也将使用新的上下文数据重新渲染。

渲染上下文提供者的有状态组件是我们的自定义提供者。也就是说:当需要将我们的App与提供者包装在一起时,将使用该组件。在一个全新的文件中,让我们创建一个ColorProvider

import React, { createContext, useState } from "react";
import colorData from "./color-data.json";

const ColorContext = createContext();

export default function ColorProvider ({ children }) {
  const [colors, setColors] = useState(colorData);
  return (
    <ColorContext.Provider value={{ colors, setColors }}>
      {children}
    </ColorContext.Provider>
  );
};

ColorProvider 是一个渲染 ColorContext.Provider 的组件。在此组件内部,我们使用 useState 钩子为 colors 创建了一个状态变量。colors 的初始数据仍然来自 color-data.json。接下来,ColorProvider 使用 ColorContext.Providervalue 属性将状态中的 colors 添加到上下文中。在 ColorProvider 中呈现的任何子元素都将被 ColorContext.Provider 包裹,并且可以从上下文中访问 colors 数组。

你可能已经注意到,setColors 函数也被添加到上下文中。这使得上下文消费者可以改变颜色的值。每当调用 setColors 时,colors 数组将会改变。这将导致 ColorProvider 重新渲染,我们的 UI 将更新以显示新的 colors 数组。

setColors 添加到上下文中可能不是一个好主意。这会在以后使用它时邀请其他开发人员和你犯错误。在更改 colors 数组的值时,只有三个选项:用户可以添加颜色、移除颜色或者对颜色进行评分。最好为每个操作添加一个函数到上下文中,而不是将 setColors 函数暴露给消费者。这样做,只暴露给他们可以进行更改的函数,会更好:

export default function ColorProvider ({ children }) {
  const [colors, setColors] = useState(colorData);

  const addColor = (title, color) =>
    setColors([
      ...colors,
      {
        id: v4(),
        rating: 0,
        title,
        color
      }
    ]);

  const rateColor = (id, rating) =>
    setColors(
      colors.map(color => (color.id === id ? { ...color, rating } : color))
    );

  const removeColor = id => setColors(colors.filter(color => color.id !== id));

  return (
    <ColorContext.Provider value={{ colors, addColor, removeColor, rateColor }}>
      {children}
    </ColorContext.Provider>
  );
};

看起来更好了。我们为所有可以在颜色数组上进行的操作添加了函数到上下文中。现在,我们树中的任何组件都可以消耗这些操作,并使用我们可以文档化的简单函数来更改颜色。

使用上下文的自定义钩子

我们还可以做出一个更大的改变。引入 Hooks 已经使得我们根本不必将上下文暴露给消费者组件。面对现实吧:对于没有阅读这本书的团队成员来说,上下文可能会很令人困惑。我们可以通过在自定义钩子中包装上下文来为他们简化一切。我们可以创建一个名为 useColors 的钩子,而不是暴露 ColorContext 实例,它返回上下文中的颜色:

import React, { createContext, useState, useContext } from "react";
import colorData from "./color-data.json";
import { v4 } from "uuid";

const ColorContext = createContext();
export const useColors = () => useContext(ColorContext);

这个简单的变更对架构有着巨大的影响。我们将所有与有状态颜色渲染和操作所需的功能封装在一个单独的 JavaScript 模块中。上下文被包含在此模块中,但通过钩子公开。这是因为我们使用 useContext 钩子返回上下文,在本文件中局部访问 ColorContext 是适当的。现在可以将此模块重命名为 color-hooks.js,并将此功能分发给社区更广泛地使用。

使用 ColorProvideruseColors 钩子消费颜色是一件非常愉快的事情。这就是为什么我们编程。让我们在当前的 Color Organizer 应用程序中试试这个钩子。首先,我们需要用自定义的 ColorProvider 包装我们的 App 组件。我们可以在 index.js 文件中这样做:

import React from "react";
import { ColorProvider } from "./color-hooks.js";
import { render } from "react-dom";
import App from "./App";

render(
  <ColorProvider>
    <App />
  </ColorProvider>,
  document.getElementById("root")
);

现在,任何作为 App 的子组件的组件都可以通过 useColors hook 获取 colorsColorList 组件需要访问 colors 数组以在屏幕上渲染颜色:

import React from "react";
import Color from "./Color";
import { useColors } from "./color-hooks";

export default function ColorList() {
  const { colors } = useColors();
  return ( ... );
}

我们已经从这个组件中删除了任何关于上下文的引用。现在,它所需的一切都是通过我们的钩子提供的。Color 组件可以使用我们的钩子直接获取评分和删除颜色的功能:

import React from "react";
import StarRating from "./StarRating";
import { useColors } from "./color-hooks";

export default function Color({ id, title, color, rating }) {
  const { rateColor, removeColor } = useColors();
  return (
    <section>
      <h1>{title}</h1>
      <button onClick={() => removeColor(id)}>X</button>
      <div style={{ height: 50, backgroundColor: color }} />
      <StarRating
        selectedStars={rating}
        onRate={rating => rateColor(id, rating)}
      />
    </section>
  );
}

现在,Color 组件不再需要通过函数属性将事件传递给父组件。它可以通过上下文轻松获取 rateColorremoveColor 函数。它们可以通过 useColors hook 轻松获取。这非常有趣,但我们还没有完成。AddColorForm 也可以从 useColors hook 中受益:

import React from "react";
import { useInput } from "./hooks";
import { useColors } from "./color-hooks";

export default function AddColorForm() {
  const [titleProps, resetTitle] = useInput("");
  const [colorProps, resetColor] = useInput("#000000");
  const { addColor } = useColors();

  const submit = e => {
    e.preventDefault();
    addColor(titleProps.value, colorProps.value);
    resetTitle();
    resetColor();
  };

  return ( ... );
}

AddColorForm 组件可以直接使用 addColor 函数添加颜色。当添加、评分或删除颜色时,上下文中 colors 值的状态会改变。当此变化发生时,ColorProvider 的子组件将使用新的上下文数据重新渲染。所有这些都是通过一个简单的 hook 实现的。

Hooks 为软件开发者提供了他们需要保持动力并享受前端编程的刺激。这主要是因为它们是一个很棒的用于分离关注点的工具。现在,React 组件只需要关心渲染其他 React 组件并保持用户界面的更新。React Hooks 则负责处理使应用程序工作所需的逻辑。UI 和 Hooks 可以分开开发、单独测试,甚至可以单独部署。对于 React 来说,这些都是非常好的消息。

我们只是挖掘了 Hooks 能实现的冰山一角。在下一章中,我们将深入探讨一些内容。

第七章:用 Hooks 增强组件

渲染是 React 应用程序的核心。当某些东西改变(props、state),组件树重新渲染,反映最新的数据作为用户界面。到目前为止,useState 已经是我们描述组件应如何渲染的工具。但我们可以做得更多。有更多的 Hooks 定义了渲染何时以及为什么应该发生。还有更多的 Hooks 来增强渲染性能。总有更多的 Hooks 来帮助我们。

在上一章中,我们介绍了 useStateuseRefuseContext,并看到我们可以将这些 Hooks 组合成我们自己的自定义 Hooks:useInputuseColors。然而,这还不是全部。React 还提供了更多的 Hooks。在本章中,我们将更详细地看看 useEffectuseLayoutEffectuseReducer。在构建应用程序时,所有这些都是至关重要的。我们还将研究 useCallbackuseMemo,它们可以帮助优化我们的组件性能。

介绍 useEffect

现在我们对渲染组件发生了什么有了很好的理解。组件只是渲染用户界面的函数。当应用程序首次加载以及 props 和 state 值改变时会发生渲染。但当我们需要在渲染后执行某些操作时会发生什么?让我们仔细看一下。

考虑一个简单的组件,Checkbox。我们使用 useState 来设置 checked 值和一个函数来改变 checked 值:setChecked。用户可以勾选和取消勾选框,但我们如何通知用户框已被勾选?让我们尝试使用 alert,因为它是阻塞线程的绝佳方法:

import React, { useState } from "react";

function Checkbox() {
  const [checked, setChecked] = useState(false);

  alert(`checked: ${checked.toString()}`);

  return (
    <>
      <input
        type="checkbox"
        value={checked}
        onChange={() => setChecked(checked => !checked)}
      />
      {checked ? "checked" : "not checked"}
    </>
  );
};

我们在渲染前添加了 alert 来阻塞渲染。在用户点击警告框的确定按钮之前,组件不会渲染。因为警告是阻塞的,所以我们直到点击确定后才看到复选框的下一个状态被渲染。

这不是目标,所以也许我们应该在返回之后放置警报?

function Checkbox {
  const [checked, setChecked] = useState(false);

  return (
    <>
      <input
        type="checkbox"
        value={checked}
        onChange={() => setChecked(checked => !checked)}
      />
      {checked ? "checked" : "not checked"}
    </>
  );

  alert(`checked: ${checked.toString()}`);
};

刮掉。我们不能在渲染后调用 alert,因为代码永远不会被执行到。为了确保我们按预期看到 alert,我们可以使用 useEffect。将 alert 放在 useEffect 函数内意味着该函数将在渲染后作为副作用被调用:

function Checkbox {
  const [checked, setChecked] = useState(false);

  useEffect(() => {
    alert(`checked: ${checked.toString()}`);
  });

  return (
    <>
      <input
        type="checkbox"
        value={checked}
        onChange={() => setChecked(checked => !checked)}
      />
      {checked ? "checked" : "not checked"}
    </>
  );
};

当渲染需要引起副作用时,我们使用 useEffect。将副作用视为函数返回之外的事物。函数是 CheckboxCheckbox 函数渲染 UI。但我们可能希望组件做更多事情。除了返回 UI 外,我们希望组件执行的这些事情称为效果

alertconsole.log 或与浏览器或本地 API 的交互不是渲染的一部分。它不是返回的一部分。在 React 应用中,渲染确实影响其中一个事件的结果。我们可以使用 useEffect 等待渲染,然后将值提供给 alertconsole.log

useEffect(() => {
  console.log(checked ? "Yes, checked" : "No, not checked");
});

类似地,在渲染时,我们可以检查 checked 的值,然后将其设置为 localStorage 中的值:

useEffect(() => {
  localStorage.setItem("checkbox-value", checked);
});

我们还可以使用 useEffect 来集中焦点于已添加到 DOM 的特定文本输入。React 将渲染输出,然后调用 useEffect 来聚焦元素:

useEffect(() => {
  txtInputRef.current.focus();
});

在渲染时,txtInputRef 将具有一个值。我们可以在效果中访问该值以应用焦点。每次我们渲染时,useEffect 都可以访问来自该渲染的最新值:属性、状态、引用等。

useEffect 视为在渲染后发生的函数。当渲染触发时,我们可以在组件内访问当前状态值,并使用它们来执行其他操作。然后,一旦我们再次渲染,整个过程重新开始。新值、新渲染、新效果。

依赖数组

useEffect 被设计用于与其他有状态 Hooks(如 useState 和前面未提及的 useReducer)协同工作,我们承诺在本章后面讨论。React 在状态改变时重新渲染组件树。正如我们所学,useEffect 将在这些渲染后被调用。

考虑以下情况,App 组件具有两个单独的状态值:

import React, { useState, useEffect } from "react";
import "./App.css";

function App() {
  const [val, set] = useState("");
  const [phrase, setPhrase] = useState("example phrase");

  const createPhrase = () => {
    setPhrase(val);
    set("");
  };

  useEffect(() => {
    console.log(`typing "${val}"`);
  });

  useEffect(() => {
    console.log(`saved phrase: "${phrase}"`);
  });

  return (
    <>
      <label>Favorite phrase:</label>
      <input
        value={val}
        placeholder={phrase}
        onChange={e => set(e.target.value)}
      />
      <button onClick={createPhrase}>send</button>
    </>
  );
}

val 是表示输入字段值的状态变量。每当输入字段值变化时,val 也会变化。这会导致每次用户输入新字符时组件重新渲染。当用户点击发送按钮时,文本区域的 val 保存为 phrase,并且 val 被重置为 "",这会清空文本字段。

这个方法按预期工作,但 useEffect 钩子被调用的次数多于应有的次数。每次渲染后,都会调用两次 useEffect 钩子:

typing ""                             // First Render
saved phrase: "example phrase"        // First Render
typing "S"                            // Second Render
saved phrase: "example phrase"        // Second Render
typing "Sh"                           // Third Render
saved phrase: "example phrase"        // Third Render
typing "Shr"                          // Fourth Render
saved phrase: "example phrase"        // Fourth Render
typing "Shre"                         // Fifth Render
saved phrase: "example phrase"        // Fifth Render
typing "Shred"                        // Sixth Render
saved phrase: "example phrase"        // Sixth Render

我们不希望每次渲染时都调用每个效果。我们需要将 useEffect 钩子与特定数据变化关联起来。为了解决这个问题,我们可以加入依赖数组。依赖数组可以用来控制何时调用效果:

useEffect(() => {
  console.log(`typing "${val}"`);
}, [val]);

useEffect(() => {
  console.log(`saved phrase: "${phrase}"`);
}, [phrase]);

我们已经向这两个效果添加了依赖数组以控制它们何时被调用。第一个效果仅在 val 值更改时被调用。第二个效果仅在 phrase 值更改时被调用。现在,当我们运行应用程序并查看控制台时,我们将看到更有效的更新发生:

typing ""                              // First Render
saved phrase: "example phrase"         // First Render
typing "S"                             // Second Render
typing "Sh"                            // Third Render
typing "Shr"                           // Fourth Render
typing "Shre"                          // Fifth Render
typing "Shred"                         // Sixth Render
typing ""                              // Seventh Render
saved phrase: "Shred"                  // Seventh Render

通过输入更改 val 值仅导致第一个效果触发。当我们点击按钮时,phrase 被保存,而 val 被重置为 ""

毕竟它是一个数组,因此可以检查依赖数组中的多个值。假设我们想在 valphrase 发生变化时运行特定效果:

useEffect(() => {
  console.log("either val or phrase has changed");
}, [val, phrase]);

如果这些值中的任何一个发生变化,效果将再次被调用。也可以将空数组作为 useEffect 函数的第二个参数。空依赖数组只会在初始渲染后调用一次效果:

useEffect(() => {
  console.log("only once after initial render");
}, []);

因为数组中没有依赖项,所以该效果在初始渲染时被调用。没有依赖项意味着没有变化,因此该效果永远不会再次被调用。仅在首次渲染时被调用的效果对于初始化非常有用:

useEffect(() => {
  welcomeChime.play();
}, []);

如果你从效果中返回一个函数,该函数将在组件从树中移除时被调用:

useEffect(() => {
  welcomeChime.play();
  return () => goodbyeChime.play();
}, []);

这意味着您可以使用 useEffect 进行设置和拆卸。空数组意味着欢迎提示音将在首次渲染时仅播放一次。然后,我们将返回一个函数作为清理函数,在组件从树中移除时播放告别提示音。

在许多情况下,这种模式非常有用。也许我们会在首次渲染时订阅新闻源。然后,我们将使用清理函数取消订阅新闻源。更具体地说,我们将首先创建一个名为 posts 的状态值和一个名为 setPosts 的函数来修改该值。然后,我们将创建一个名为 addPosts 的函数,用于接收最新的帖子并将其添加到数组中。然后,我们可以使用 useEffect 订阅新闻源并播放提示音。此外,我们可以返回清理函数,用于取消订阅和播放告别提示音:

const [posts, setPosts] = useState([]);
const addPost = post => setPosts(allPosts => [post, ...allPosts]);

useEffect(() => {
  newsFeed.subscribe(addPost);
  welcomeChime.play();
  return () => {
    newsFeed.unsubscribe(addPost);
    goodbyeChime.play();
  };
}, []);

useEffect 中有很多内容。我们可能希望为新闻订阅事件使用一个单独的 useEffect,并为提示音事件使用另一个 useEffect

useEffect(() => {
  newsFeed.subscribe(addPost);
  return () => newsFeed.unsubscribe(addPost);
}, []);

useEffect(() => {
  welcomeChime.play();
  return () => goodbyeChime.play();
}, []);

将功能拆分为多个 useEffect 调用通常是一个好主意。但让我们进一步增强它。我们要创建的功能是订阅新闻源并为订阅、取消订阅以及每当有新帖子时播放不同的时髦音效。每个人都喜欢很多大声音乐,对吧?这是一个自定义钩子的情况。也许我们应该称其为 useJazzyNews

const useJazzyNews = () => {
  const [posts, setPosts] = useState([]);
  const addPost = post => setPosts(allPosts => [post, ...allPosts]);

  useEffect(() => {
    newsFeed.subscribe(addPost);
    return () => newsFeed.unsubscribe(addPost);
  }, []);

  useEffect(() => {
    welcomeChime.play();
    return () => goodbyeChime.play();
  }, []);

  return posts;
};

我们的自定义钩子包含处理时髦新闻源的所有功能,这意味着我们可以轻松地与组件共享此功能。在一个名为 NewsFeed 的新组件中,我们将使用自定义钩子:

function NewsFeed({ url }) {
  const posts = useJazzyNews();

  return (
    <>
      <h1>{posts.length} articles</h1>
      {posts.map(post => (
        <Post key={post.id} {...post} />
      ))}
    </>
  );
}

深度检查依赖项

到目前为止,我们在数组中添加的依赖项都是字符串。JavaScript 的原始类型如字符串、布尔值、数字等是可比较的。字符串将如预期般相等:

if ("gnar" === "gnar") {
  console.log("gnarly!!");
}

然而,当我们开始比较对象、数组和函数时,比较就不同了。例如,如果我们比较两个数组:

if ([1, 2, 3] !== [1, 2, 3]) {
  console.log("but they are the same");
}

这些数组 [1,2,3][1,2,3] 不相等,即使它们在长度和条目上看起来相同。这是因为它们是两个不同的类似数组实例。如果我们创建一个变量来保存此数组值,然后进行比较,我们将看到预期的输出:

const array = [1, 2, 3];
if (array === array) {
  console.log("because it's the exact same instance");
}

在 JavaScript 中,只有当数组、对象和函数完全相同时,它们才相同。那么这如何与 useEffect 的依赖数组相关联呢?为了证明这一点,我们需要一个我们可以随意强制重新渲染的组件。让我们构建一个钩子,每当按键时都会导致组件重新渲染:

const useAnyKeyToRender = () => {
  const [, forceRender] = useState();

  useEffect(() => {
    window.addEventListener("keydown", forceRender);
    return () => window.removeEventListener("keydown", forceRender);
  }, []);
};

至少,我们只需调用状态更改函数即可强制重新渲染。我们不关心状态值。我们只需要状态函数:forceRender。(这就是我们使用数组解构添加逗号的原因。记住,来自第二章?)组件首次渲染时,我们将监听 keydown 事件。按键时,我们通过调用 forceRender 强制组件重新渲染。如以前所做的那样,我们将返回一个清理函数,停止监听 keydown 事件。通过将此钩子添加到组件中,我们只需按键即可强制重新渲染它。

使用自定义钩子构建后,我们可以在 App 组件中(以及任何其他组件!Hooks 很酷。)使用它:

function App() {
  useAnyKeyToRender();

  useEffect(() => {
    console.log("fresh render");
  });

  return <h1>Open the console</h1>;
}

每次按键时,都会重新渲染 App 组件。useEffect 通过在每次 App 渲染时将 “fresh render” 记录到控制台来演示这一点。让我们调整 App 组件中的 useEffect,以引用 word 值。如果 word 改变,我们将重新渲染:

const word = "gnar";
useEffect(() => {
  console.log("fresh render");
}, [word]);

不要在每个 keydown 事件上调用 useEffect,我们只在第一次渲染后和 word 值变化时调用它。它不会改变,所以不会发生后续的重新渲染。在依赖数组中添加一个原始值或数字的效果与预期相同。该效果仅调用一次。

如果我们使用单词数组而不是单个单词会发生什么?

const words = ["sick", "powder", "day"];
useEffect(() => {
  console.log("fresh render");
}, [words]);

变量 words 是一个数组。因为每次渲染时都声明了一个新数组,JavaScript 认为 words 已经改变,从而触发“fresh render”效果。数组每次都是新实例,这会注册为应触发重新渲染的更新。

App 的作用域之外声明 words 将解决问题:

const words = ["sick", "powder", "day"];

function App() {
  useAnyKeyToRender();
  useEffect(() => {
    console.log("fresh render");
  }, [words]);

  return <h1>component</h1>;
}

在这种情况下,依赖数组指的是在函数外部声明的 words 的一个实例。“fresh render”效果在第一次渲染后不会再次被调用,因为 words 与上次渲染的实例相同。对于这个例子,这是一个很好的解决方案,但并不总是可能(或建议)在函数作用域外定义变量。有时传递给依赖数组的值需要函数作用域内的变量。例如,我们可能需要从类似 children 的 React 属性创建 words 数组:

function WordCount({ children = "" }) {
  useAnyKeyToRender();

  const words = children.split(" ");

  useEffect(() => {
    console.log("fresh render");
  }, [words]);

  return (
    <>
      <p>{children}</p>
      <p>
        <strong>{words.length} - words</strong>
      </p>
    </>
  );
}

function App() {
  return <WordCount>You are not going to believe this but...</WordCount>;
}

App 组件包含一些作为 WordCount 组件子节点的单词。WordCount 组件将 children 作为属性输入。然后我们在组件中将 words 设置为调用 .split 后的单词数组。我们希望组件仅在 words 改变时重新渲染,但是一旦按键,我们就会看到控制台中出现可怕的“fresh render”单词。

让我们用平静的感觉代替恐惧感,因为 React 团队已经为我们提供了一种避免这些额外渲染的方法。他们不会像那样把我们搭在半空中。解决此问题的方法正如你所期望的另一个钩子:useMemo

useMemo 调用一个函数来计算一个记忆化的值。在计算机科学中,记忆化是一种用来提高性能的技术。在一个记忆化的函数中,函数调用的结果被保存和缓存。然后,当再次使用相同输入调用函数时,返回缓存的值。在 React 中,useMemo 允许我们将缓存的值与其自身进行比较,以查看它是否实际上已经改变。

useMemo 的工作原理是,我们传递一个函数给它,用于计算和创建一个记忆化的值。只有当依赖项之一发生变化时,useMemo 才会重新计算该值。首先,让我们导入 useMemo 钩子:

import React, { useEffect, useMemo } from "react";

然后,我们将使用该函数来设置 words

const words = useMemo(() => {
  const words = children.split(" ");
  return words;
}, []);

useEffect(() => {
  console.log("fresh render");
}, [words]);

useMemo 调用发送给它的函数,并将 words 设置为该函数的返回值。与 useEffect 类似,useMemo 依赖于依赖项数组:

const words = useMemo(() => children.split(" "));

当我们不包含依赖项数组与 useMemo 时,单词将在每次渲染时计算。依赖项数组控制回调函数应该何时被调用。发送给 useMemo 函数的第二个参数是依赖项数组,应包含 children 值:

function WordCount({ children = "" }) {
  useAnyKeyToRender();

  const words = useMemo(() => children.split(" "), [children]);

  useEffect(() => {
    console.log("fresh render");
  }, [words]);

  return (...);
}

words 数组依赖于 children 属性。如果 children 发生变化,我们应该计算一个反映该变化的新值给 words。此时,当组件首次渲染并且 children 属性发生变化时,useMemo 将为 words 计算一个新值。

useMemo 钩子是在创建 React 应用程序时理解的一个很好的函数。

useCallback 可以像 useMemo 一样使用,但它是用来记忆化函数而不是值。例如:

const fn = () => {
  console.log("hello");
  console.log("world");
};

useEffect(() => {
  console.log("fresh render");
  fn();
}, [fn]);

fn 是一个函数,它依次记录 “Hello” 和 “World”。它是 useEffect 的一个依赖项,但与 words 一样,JavaScript 认为 fn 在每次渲染时都是不同的。因此,它会在每次渲染时触发效果。这导致了每次按键都会产生一个 “新鲜的渲染”,这并不理想。

首先通过 useCallback 封装该函数:

const fn = useCallback(() => {
  console.log("hello");
  console.log("world");
}, []);

useEffect(() => {
  console.log("fresh render");
  fn();
}, [fn]);

useCallbackfn 的函数值进行了记忆化。与 useMemouseEffect 一样,它也期望一个依赖项数组作为第二个参数。在这种情况下,因为依赖项数组为空,所以我们仅创建了一次记忆化的回调函数。

现在我们对 useMemouseCallback 的用途和区别有了理解,让我们改进我们的 useJazzyNews 钩子。每当有新的帖子时,我们将调用 newPostChime.play()。在这个钩子中,posts 是一个数组,所以我们需要使用 useMemo 来记忆化该值:

const useJazzyNews = () => {
  const [_posts, setPosts] = useState([]);
  const addPost = post => setPosts(allPosts => [post, ...allPosts]);

  const posts = useMemo(() => _posts, [_posts]);

  useEffect(() => {
    newPostChime.play();
  }, [posts]);

  useEffect(() => {
    newsFeed.subscribe(addPost);
    return () => newsFeed.unsubscribe(addPost);
  }, []);

  useEffect(() => {
    welcomeChime.play();
    return () => goodbyeChime.play();
  }, []);

  return posts;
};

现在,每当有新帖子时,useJazzyNews 钩子会播放一个提示音。我们通过对钩子进行几处更改实现了这一点。首先,const [posts, setPosts] 被重命名为 const [_posts, setPosts]。每次 _posts 改变时,我们将计算一个新值给 posts

接下来,我们添加了一个效果,每次 post 数组改变时播放提示音。我们在新闻源上监听新的帖子。当添加新帖子时,这个钩子被重新调用,_posts 反映了新帖子。然后,因为 _posts 已经改变,post 的新值被记忆化。然后,由于这个效果依赖于 posts,所以提示音会播放。它只在帖子改变时播放,而帖子列表只在添加新帖子时改变。

在本章后面,我们将讨论 React Profiler,这是一个用于测试 React 组件性能和渲染的浏览器扩展。在那里,我们将更详细地讨论何时使用 useMemouseCallback。(剧透警告:节俭使用!)

何时使用 useLayoutEffect

我们知道渲染总是在 useEffect 之前发生。首先发生渲染,然后所有效果按顺序运行,并完全访问来自渲染的所有值。快速查看 React 文档将指出还有另一种类型的效果钩子:useLayoutEffect

useLayoutEffect 在渲染周期中的特定时刻被调用。事件序列如下:

  1. 渲染

  2. useLayoutEffect 被调用

  3. 浏览器绘制:组件元素实际添加到 DOM 的时间

  4. useEffect 被调用

可以通过添加一些简单的控制台消息来观察这一点:

import React, { useEffect, useLayoutEffect } from "react";

function App() {
  useEffect(() => console.log("useEffect"));
  useLayoutEffect(() => console.log("useLayoutEffect"));
  return <div>ready</div>;
}

App 组件中,useEffect 是第一个 Hook,紧随其后的是 useLayoutEffect。我们看到 useLayoutEffectuseEffect 之前被调用:

useLayoutEffect
useEffect

useLayoutEffect 在渲染之后但在浏览器绘制更改之前被调用。在大多数情况下,useEffect 是合适的工具,但如果你的效果对于浏览器绘制(UI 元素在屏幕上的出现或位置)很重要,你可能需要使用 useLayoutEffect。例如,当窗口调整大小时,你可能想要获取元素的宽度和高度:

function useWindowSize {
  const [width, setWidth] = useState(0);
  const [height, setHeight] = useState(0);

  const resize = () => {
    setWidth(window.innerWidth);
    setHeight(window.innerHeight);
  };

  useLayoutEffect(() => {
    window.addEventListener("resize", resize);
    resize();
    return () => window.removeEventListener("resize", resize);
  }, []);

  return [width, height];
};

窗口的 widthheight 是组件可能在浏览器绘制之前需要的信息。useLayoutEffect 用于在绘制之前计算窗口的 widthheight。另一个需要使用 useLayoutEffect 的例子是跟踪鼠标位置:

function useMousePosition {
  const [x, setX] = useState(0);
  const [y, setY] = useState(0);

  const setPosition = ({ x, y }) => {
    setX(x);
    setY(y);
  };

  useLayoutEffect(() => {
    window.addEventListener("mousemove", setPosition);
    return () => window.removeEventListener("mousemove", setPosition);
  }, []);

  return [x, y];
};

很可能在绘制屏幕时会使用鼠标的 xy 位置。useLayoutEffect 可用于在绘制之前准确计算这些位置。

使用 Hooks 的规则

在使用 Hooks 时,有一些指南需要牢记,这可以帮助避免错误和异常行为:

Hooks 只在组件的作用域内运行

Hooks 应该只从 React 组件中调用。它们也可以添加到自定义 Hooks 中,最终添加到组件中。Hooks 不是常规 JavaScript —— 它们是 React 的一种模式,但它们开始被建模并纳入其他库中。

将功能分解成多个 Hooks 是个好主意

在我们之前的例子中,与 Jazzy News 组件相关的一切都被分成一个效果,与声音效果相关的一切被分成另一个效果。这立即使代码更易读,但这样做还有另一个好处。由于 Hooks 按顺序调用,保持它们小是个好主意。一旦调用,React 就会将 Hooks 的值保存在数组中,以便跟踪这些值。考虑以下组件:

function Counter() {
  const [count, setCount] = useState(0);
  const [checked, toggle] = useState(false);

  useEffect(() => {
    ...
  }, [checked]);

  useEffect(() => {
    ...
  }, []);

  useEffect(() => {
    ...
  }, [count]);

  return ( ... )
}

每次渲染时,Hook 调用的顺序都是相同的:

[count, checked, DependencyArray, DependencyArray, DependencyArray]

Hooks 应该只在顶层调用

Hooks 应该在 React 函数的顶层使用。它们不能放置在条件语句,循环或嵌套函数中。让我们调整计数器:

function Counter() {
  const [count, setCount] = useState(0);

  if (count > 5) {
    const [checked, toggle] = useState(false);
  }

  useEffect(() => {
    ...
  });

  if (count > 5) {
    useEffect(() => {
      ...
    });
  }

  useEffect(() => {
    ...
  });

  return ( ... )
}

当我们在if语句中使用useState时,我们的意思是只有当count值大于 5 时才调用该钩子。这会使数组的值混乱。有时数组会是:[count, checked, DependencyArray, –0—, DependencyArray]。其他时候是:[count, DependencyArray, –1—]。在该数组中,效果的索引对 React 很重要。这是值保存的方式。

等等,我们是在说在 React 应用程序中我们再也不能使用条件逻辑了吗?当然不是!我们只是要以不同的方式组织这些条件。我们可以在钩子内嵌套if语句,循环和其他条件:

function Counter() {
  const [count, setCount] = useState(0);
  const [checked, toggle] =
  useState(
  count => (count < 5)
  ? undefined
  : !c,
  (count < 5) ? undefined
  );

  useEffect(() => {
    ...
  });

  useEffect(() => {
    if (count < 5) return;
    ...
  });

  useEffect(() => {
    ...
  });

  return ( ... )
}

在这里,checked的值是基于count大于 5 的条件。当count小于 5 时,checked的值是undefined。将此条件嵌套在钩子内意味着钩子保持在顶层,但结果类似。第二个效果强制执行相同的规则。如果count小于 5,则返回语句将阻止效果继续执行。这样可以保持钩子值数组完整:[countValue, checkedValue, DependencyArray, DependencyArray, DependencyArray]

与条件逻辑一样,您需要将异步行为嵌套在钩子内部。useEffect以函数作为第一个参数,而不是一个 Promise。因此,您不能将异步函数作为第一个参数使用:useEffect(async () => {})。但是,您可以在嵌套函数内创建异步函数,就像这样:

useEffect(() => {
  const fn = async () => {
    await SomePromise();
  };
  fn();
});

我们创建了一个变量fn来处理异步/等待,然后我们调用函数作为返回值。你可以给这个函数起一个名字,或者你可以将异步效果作为匿名函数使用:

useEffect(() => {
  (async () => {
    await SomePromise();
  })();
});

如果遵循这些规则,可以避免 React Hooks 的一些常见陷阱。如果您正在使用 Create React App,那么其中包含的 ESLint 插件称为 eslint-plugin-react-hooks 将提供警告提示,如果您违反了这些规则。

改善使用useReducer的代码

考虑 Checkbox 组件。这个组件是一个简单状态的完美例子。方框要么被选中,要么未选中。checked 是状态值,setChecked 是一个用于改变状态的函数。当组件首次渲染时,checked 的值将为 false

function Checkbox() {
  const [checked, setChecked] = useState(false);

  return (
    <>
      <input
        type="checkbox"
        value={checked}
        onChange={() => setChecked(checked => !checked)}
      />
      {checked ? "checked" : "not checked"}
    </>
  );
}

这个方法很有效,但这个函数的一个方面可能会引起警惕:

onChange={() => setChecked(checked => !checked)}

仔细看看。乍一看感觉还好,但我们在这里是不是在惹麻烦呢?我们发送了一个函数,它接受 checked 的当前值并返回相反值 !checked。这可能比必要的复杂。开发者可能会轻易地发送错误的信息并破坏整个功能。为什么不提供一个函数作为切换的方式,而不是这样处理它呢?

让我们添加一个名为 toggle 的函数,它将做同样的事情:调用 setChecked 并返回 checked 当前值的相反值:

function Checkbox() {
  const [checked, setChecked] = useState(false);

  function toggle() {
    setChecked(checked => !checked);
  }

  return (
    <>
      <input type="checkbox" value={checked} onChange={toggle} />
      {checked ? "checked" : "not checked"}
    </>
  );
}

这更好。onChange 设置为一个可预测的值:toggle 函数。我们知道每次在任何地方使用它时,它会做什么。我们仍然可以进一步进行,以在每次使用 checkbox 组件时产生更可预测的结果。记住我们在 toggle 函数中发送给 setChecked 的函数?

setChecked(checked => !checked);

现在我们将通过另一个名称来引用这个函数,checked => !checked:一个 reducer。一个 reducer 函数的最简单定义是它接受当前状态并返回一个新状态。如果 checkedfalse,它应该返回相反的 true。我们可以将这个行为抽象到一个 reducer 函数中,而不是将这个行为硬编码到 onChange 事件中,它将始终产生相同的结果。我们将不再在组件中使用 useState,而是使用 useReducer

function Checkbox() {
  const [checked, toggle] = useReducer(checked => !checked, false);

  return (
    <>
      <input type="checkbox" value={checked} onChange={toggle} />
      {checked ? "checked" : "not checked"}
    </>
  );
}

useReducer 接受 reducer 函数和初始状态 false。然后,我们将 onChange 函数设置为 setChecked,这将调用 reducer 函数。

我们之前的 reducer,checked => !checked,就是一个很好的例子。如果给定相同的输入,应该期望得到相同的输出。这个概念源自 JavaScript 中的 Array.reducereduce 基本上与 reducer 做的事情一样:它接受一个函数(用于将所有值减少为单个值)和一个初始值,并返回一个值。

Array.reduce 接受一个 reducer 函数和一个初始值。对于 numbers 数组中的每个值,直到返回一个值为止,都会调用 reducer:

const numbers = [28, 34, 67, 68];

numbers.reduce((number, nextNumber) => number + nextNumber, 0); // 197

发送给 Array.reduce 的 reducer 接受两个参数。你也可以向 reducer 函数发送多个参数:

function Numbers() {
  const [number, setNumber] = useReducer(
    (number, newNumber) => number + newNumber,
    0
  );

  return <h1 onClick={() => setNumber(30)}>{number}</h1>;
}

每次点击 h1 时,我们将总数加 30。

使用 useReducer 处理复杂状态

当状态变得更加复杂时,useReducer 可以帮助我们更可预测地处理状态更新。考虑一个包含用户数据的对象:

const firstUser = {
  id: "0391-3233-3201",
  firstName: "Bill",
  lastName: "Wilson",
  city: "Missoula",
  state: "Montana",
  email: "bwilson@mtnwilsons.com",
  admin: false
};

然后我们有一个名为 User 的组件,将 firstUser 设置为初始状态,并且组件显示相应的数据:

function User() {
  const [user, setUser] = useState(firstUser);

  return (
    <div>
      <h1>
        {user.firstName} {user.lastName} - {user.admin ? "Admin" : "User"}
      </h1>
      <p>Email: {user.email}</p>
      <p>
        Location: {user.city}, {user.state}
      </p>
      <button>Make Admin</button>
    </div>
  );
}

在管理状态时常见的错误是覆盖状态:

<button
  onClick={() => {
    setUser({ admin: true });
  }}
>
  Make Admin
</button>

这样做将覆盖firstUser的状态,并用我们传递给setUser函数的内容替换:{admin: true}。 可以通过从用户当前值中扩展当前值,然后覆盖admin值来解决这个问题:

<button
  onClick={() => {
    setUser({ ...user, admin: true });
  }}
>
  Make Admin
</button>

这将获取初始状态并推入新的键/值:{admin: true}。 我们需要在每次onClick时重新编写此逻辑,这样容易出错(明天再回到应用程序时可能会忘记这样做):

function User() {
  const [user, setUser] = useReducer(
    (user, newDetails) => ({ ...user, ...newDetails }),
    firstUser
  );
  ...
}

然后,我们将新的状态值newDetails发送到 reducer,并将其推送到对象中:

<button
  onClick={() => {
    setUser({ admin: true });
  }}
>
  Make Admin
</button>

当状态具有多个子值或下一个状态取决于上一个状态时,此模式非常有用。 教大家如何传播,他们会在一天内传播。 教大家使用useReducer,他们会终身传播。

改进组件性能

在 React 应用程序中,组件通常会被渲染很多次。 改进性能包括防止不必要的渲染并减少渲染传播所需的时间。 React 提供了工具来帮助我们防止不必要的渲染:memouseMemouseCallback。 我们之前在章节中看过useMemouseCallback,但在本节中,我们将更详细地讨论如何使用这些 Hooks 来提升网站性能。

memo函数用于创建纯组件。 如在第三章中讨论的那样,我们知道,给定相同的参数,纯函数总是返回相同的结果。 纯组件也是如此。 在 React 中,纯组件是一个在给定相同属性时始终呈现相同输出的组件。

让我们创建一个名为Cat的组件:

const Cat = ({ name }) => {
  console.log(`rendering ${name}`);
  return <p>{name}</p>;
};

Cat是一个纯组件。 输出始终是显示名称属性的段落。 如果提供的名称作为属性相同,则输出将相同:

function App() {
  const [cats, setCats] = useState(["Biscuit", "Jungle", "Outlaw"]);
  return (
    <>
      {cats.map((name, i) => (
        <Cat key={i} name={name} />
      ))}
      <button onClick={() => setCats([...cats, prompt("Name a cat")])}>
        Add a Cat
      </button>
    </>
  );
}

此应用程序使用Cat组件。 初始渲染后,控制台显示:

rendering Biscuit
rendering Jungle
rendering Outlaw

单击“添加猫”按钮后,提示用户添加一只猫。

如果我们添加一个名为“Ripple”的猫,我们会看到所有Cat组件都重新渲染:

rendering Biscuit
rendering Jungle
rendering Outlaw
rendering Ripple
警告

此代码之所以有效,是因为prompt是阻塞的。 这只是一个例子。 在真实应用中不要使用prompt

每次添加猫时,每个Cat组件都会被重新渲染,但Cat组件是一个纯组件。 给定相同的属性,输出不会改变,因此不应为每个属性重新渲染。 memo函数可用于创建仅在其属性更改时才会重新渲染的组件。 首先从 React 库导入它,然后将其用于包装当前的Cat组件:

import React, { useState, memo } from "react";

const Cat = ({ name }) => {
  console.log(`rendering ${name}`);
  return <p>{name}</p>;
};

const PureCat = memo(Cat);

在这里,我们创建了一个名为PureCat的新组件。 PureCat仅在属性更改时才会导致Cat重新渲染。 然后,我们可以在App组件中用PureCat替换Cat组件:

cats.map((name, i) => <PureCat key={i} name={name} />);

现在,每当我们添加一个新的猫名字,比如“Pancake”,我们在控制台中只看到一次渲染:

rendering Pancake

因为其他猫的名字没有改变,我们不会渲染那些Cat组件。这对于name属性效果很好,但如果我们向Cat组件引入一个函数属性会怎么样?

const Cat = memo(({ name, meow = f => f }) => {
  console.log(`rendering ${name}`);
  return <p onClick={() => meow(name)}>{name}</p>;
});

每当点击猫时,我们可以使用此属性将meow记录到控制台中:

<PureCat key={i} name={name} meow={name => console.log(`${name} has meowed`)} />

当我们添加了这个更改后,PureCat不再按预期工作。它始终渲染每个Cat组件,即使name属性保持不变也是如此。这是因为增加了meow属性。不幸的是,每次我们将meow属性定义为一个函数时,它总是一个新函数。对于 React 来说,meow属性已经改变,因此组件会重新渲染。

memo函数将允许我们在何时重新渲染此组件周围定义更具体的规则:

const RenderCatOnce = memo(Cat, () => true);
const AlwaysRenderCat = memo(Cat, () => false);

第二个参数传递给memo函数的是一个predicate。谓词是一个仅返回truefalse的函数。此函数决定是否重新渲染猫。当它返回false时,Cat会重新渲染。当此函数返回true时,Cat将不会重新渲染。无论如何,Cat至少渲染一次。这就是为什么在RenderCatOnce中,它会渲染一次,然后再也不会。通常,此函数用于检查实际值:

const PureCat = memo(
  Cat,
  (prevProps, nextProps) => prevProps.name === nextProps.name
);

我们可以使用第二个参数来比较属性并决定是否应重新渲染Cat。谓词接收先前的属性和下一个属性。这些对象用于比较name属性。如果name发生变化,组件将重新渲染。如果name相同,那么不管 React 如何看待meow属性,它都将重新渲染。

shouldComponentUpdate 和 PureComponent

我们讨论的概念对 React 并不新鲜。memo函数是解决一个常见问题的新方法。在 React 的早期版本中,有一种称为shouldComponentUpdate的方法。如果存在于组件中,它将告诉 React 在哪些情况下组件应该更新。shouldComponentUpdate描述了哪些 props 或 state 需要更改以便重新渲染组件。一旦shouldComponentUpdate成为 React 库的一部分,许多人都认为它是一个有用的特性。如此有用,以至于 React 团队决定创建一个创建类组件的替代方法。类组件看起来像这样:

class Cat extends React.Component {
  render() {
    return (
      {name} is a good cat!
    )
  }
}

一个PureComponent看起来像这样:

class Cat extends React.PureComponent {
  render() {
    return (
      {name} is a good cat!
    )
  }
}

PureComponentReact.memo相同,但PureComponent仅适用于类组件;React.memo仅适用于函数组件。

useCallbackuseMemo可用于记忆化对象和函数属性。让我们在Cat组件中使用useCallback

const PureCat = memo(Cat);
function App() {
  const meow = useCallback(name => console.log(`${name} has meowed`, []);
  return <PureCat name="Biscuit" meow={meow} />
}

在这种情况下,我们没有为 memo(Cat) 提供属性检查谓词。相反,我们使用 useCallback 确保 meow 函数未发生更改。在处理组件树中过多重新渲染时,使用这些函数会很有帮助。

何时重构

我们讨论的最后两个 Hook,useMemouseCallback,以及 memo 函数,通常被过度使用。React 设计用于快速渲染组件。优化性能的过程从您决定首次使用 React 开始。它很快。进一步的重构应该是最后一步。

重构存在权衡。仅仅因为觉得使用 useCallbackuseMemo 是个好主意,实际上可能会降低应用程序的性能。您会增加代码行数和开发人员工时。在为性能重构时,设定一个目标非常重要。也许您想要防止屏幕冻结或闪烁。也许您知道某些昂贵的函数无缘无故地减慢了应用程序的速度。

React Profiler 可用于测量每个组件的性能。该分析器与您可能已经安装的 React 开发者工具一起提供(可在ChromeFirefox上使用)。

在重构之前,请确保您的应用程序可以正常工作,并且您对代码库感到满意。过度重构或在应用程序工作之前进行重构可能会引入难以发现的怪异 bug,而且也可能不值得花费您的时间和精力来引入这些优化。

在过去的两章中,我们介绍了许多随 React 一起提供的 Hook。您已经看到了每个 Hook 的用例,并通过组合其他 Hook 创建了自己的自定义 Hook。接下来,我们将通过整合额外的库和高级模式,进一步构建这些基础技能。

第八章:整合数据

数据是我们应用的生命线。它像水一样流动,为我们的组件提供营养。我们组合的用户界面组件是数据的容器。我们通过互联网为应用程序注入数据。我们收集、创建并发送新的数据到互联网。我们应用程序的价值不在于组件本身,而是流经这些组件的数据。

当我们谈论数据时,这听起来有点像在谈论水或食物。 是我们发送和接收数据的丰富无穷的来源。它就是互联网。它是网络、服务、系统和数据库,在这些地方我们操作和存储着赫兹级别的数据。云以最新鲜的数据从源头“水合”我们的客户端。我们在本地处理这些数据,甚至本地存储它。但是当我们的本地数据与源头不同步时,它就会失去新鲜度,被称为陈旧

这些都是我们作为开发人员在处理数据时面临的挑战。我们需要确保我们的应用程序通过云中的新鲜数据保持“水合”。在本章中,我们将探讨从源头加载和处理数据的各种技术。

请求数据

在电影《星球大战》中,机器人 C-3P0 是一个协议机器人。他的专长当然是沟通。他能说六百万种语言。毫无疑问,C-3P0 知道如何发送 HTTP 请求,因为超文本传输协议是互联网上传输数据的最流行方式之一。

HTTP 提供了我们互联网通信的支柱。每当我们在浏览器中加载 http://www.google.com 时,我们都在请求 Google 发送一个搜索表单。用于我们搜索的文件通过 HTTP 传输到浏览器。当我们通过搜索“猫照片”与 Google 交互时,我们要求 Google 为我们找到猫照片。Google 以数据形式回应,图像通过 HTTP 传输到我们的浏览器。

在 JavaScript 中,发起 HTTP 请求的最流行方式是使用 fetch。如果我们想要向 GitHub 请求有关 Moon Highway 的信息,我们可以发送一个 fetch 请求:

fetch(`https://api.github.com/users/moonhighway`)
  .then(response => response.json())
  .then(console.log)
  .catch(console.error);

fetch 函数返回一个 promise。在这里,我们正在向特定的 URL 发送一个异步请求:https://api.github.com/users/moonhighway。该请求需要一段时间来穿越互联网,并响应相关信息。当响应返回时,将使用 .then(callback) 方法将信息传递给回调函数。GitHub API 将会以 JSON 数据形式响应,但这些数据被包含在 HTTP 响应体中,因此我们调用 response.json() 来获取并解析这些数据。获取到数据后,我们将其记录到控制台中。如果出现任何问题,我们将会通过 console.error 方法输出错误信息。

GitHub 将以 JSON 对象形式响应此请求:

{
  "login": "MoonHighway",
  "id": 5952087,
  "node_id": "MDEyOk9yZ2FuaXphdGlvbjU5NTIwODc=",
  "avatar_url": "https://avatars0.githubusercontent.com/u/5952087?v=4",
  "bio": "Web Development classroom training materials.",

  ...

}

在 GitHub 上,用户帐户的基本信息可以通过他们的 API 获取。继续尝试搜索您自己:https://api.github.com/users/<YOUR_GITHUB_USER_NAME>

另一种处理 promises 的方法是使用 async/await。由于 fetch 返回一个 promise,我们可以在 async 函数内 await 一个 fetch 请求:

async function requestGithubUser(githubLogin) {
  try {
    const response = await fetch(
      `https://api.github.com/users/${githubLogin}`
    );
    const userData = await response.json();
    console.log(userData);
  } catch (error) {
    console.error(error);
  }
}

此代码实现与之前使用 .then 函数链接到请求的完全相同的结果。当我们 await 一个 promise 时,直到 promise 解决后,下一行代码才会执行。这种格式为我们提供了在代码中处理 promises 的一种良好方式。我们将在本章的剩余部分中使用这两种方法。

发送请求时发送数据

很多请求要求我们在请求中上传数据。例如,我们需要收集有关用户的信息以创建一个帐户,或者我们可能需要新的用户信息来更新他们的帐户。

通常,我们在创建数据时使用 POST 请求,在修改数据时使用 PUT 请求。fetch 函数的第二个参数允许我们传递一个选项对象,fetch 在创建 HTTP 请求时可以使用这些选项:

fetch("/create/user", {
  method: "POST",
  body: JSON.stringify({ username, password, bio })
});

此 fetch 使用 POST 方法来创建新用户。usernamepassword 和用户的 bio 作为字符串内容传递在请求的 body 中。

使用 fetch 上传文件

上传文件需要不同类型的 HTTP 请求:一个 multipart-formdata 请求。此类请求告知服务器请求主体中包含一个或多个文件。要在 JavaScript 中进行此请求,我们只需在请求主体中传递一个 FormData 对象:

const formData = new FormData();
formData.append("username", "moontahoe");
formData.append("fullname", "Alex Banks");
forData.append("avatar", imgFile);

fetch("/create/user", {
  method: "POST",
  body: formData
});

这一次,当我们创建用户时,我们将 usernamefullnameavatar 图像作为 formData 对象随请求传递。虽然这些值在此处是硬编码的,但我们可以轻松地从表单收集它们。

授权请求

有时,我们需要授权才能进行请求。通常需要授权以获取个人或敏感数据。此外,几乎总是需要授权才能让用户通过 POST、PUT 或 DELETE 请求在服务器上执行操作。

用户通常通过向请求添加一个唯一令牌来标识自己,服务可以使用此令牌来识别用户。此令牌通常添加为 Authorization 标头。在 GitHub 上,如果您在请求中发送令牌,可以查看您的个人帐户信息:

fetch(`https://api.github.com/users/${login}`, {
  method: "GET",
  headers: {
    Authorization: `Bearer ${token}`
  }
});

令牌通常在用户通过提供其用户名和密码登录服务时获取。还可以通过第三方如 GitHub 或 Facebook 使用一个称为 OAuth 的开放标准协议来获取令牌。

GitHub 允许您生成个人用户令牌。您可以通过登录 GitHub 并导航到:设置 > 开发者设置 > 个人访问令牌来生成一个。在这里,您可以创建具有特定读写规则的令牌,然后使用这些令牌从 GitHub API 获取个人信息。如果生成个人访问令牌并在 fetch 请求中发送它,GitHub 将提供有关您帐户的额外私密信息。

从 React 组件内获取数据需要我们编排useStateuseEffect钩子。useState钩子用于将响应存储在状态中,而useEffect钩子用于发出 fetch 请求。例如,如果我们想在组件中显示关于 GitHub 用户的信息,我们可以使用以下代码:

import React, { useState, useEffect } from "react";

function GitHubUser({ login }) {
  const [data, setData] = useState();

  useEffect(() => {
    if (!login) return;
    fetch(`https://api.github.com/users/${login}`)
      .then(response => response.json())
      .then(setData)
      .catch(console.error);
  }, [login]);

  if (data)
    return <pre>{JSON.stringify(data, null, 2)}</pre>;

  return null;
}

export default function App() {
  return <GitHubUser login="moonhighway" />;
}

在这段代码中,我们的App呈现了一个GitHubUser组件,并显示关于moonhighway的 JSON 数据。在第一次渲染时,GitHubUser使用useState钩子为data设置了一个状态变量。然后,因为data最初为null,组件返回null。从组件返回null告诉 React 不要渲染任何内容。这不会导致错误;我们只会看到一个黑屏。

组件渲染后,将调用useEffect钩子。这是我们发出 fetch 请求的地方。当我们得到响应时,我们获取并解析该响应中的数据为 JSON。现在我们可以将该 JSON 对象传递给setData函数,这会导致我们的组件再次渲染,但这次它会有数据。除非login的值发生变化,否则不会再次调用此useEffect钩子。当它改变时,我们需要从 GitHub 请求更多关于不同用户的信息。

当有data时,我们将其渲染为pre元素中的 JSON 字符串。JSON.stringify方法接受三个参数:要转换为字符串的 JSON 数据,可以用来替换 JSON 对象属性的替换函数,以及格式化数据时要使用的空格数。在本例中,我们将null作为替换器发送,因为我们不想替换任何内容。2表示在格式化代码时要使用的空格数。这将使 JSON 字符串缩进两个空格。使用pre元素保留空格,因此最终呈现的是可读的 JSON。

本地保存数据

我们可以使用 Web Storage API 将数据保存在浏览器中。数据可以通过使用window.localStoragewindow.sessionStorage对象来保存。sessionStorage API 仅为用户的会话保存数据。关闭选项卡或重新启动浏览器将清除保存在sessionStorage中的任何数据。另一方面,localStorage将永久保存数据,直到您删除它为止。

JSON 数据应作为字符串保存在浏览器存储中。这意味着在保存之前将对象转换为 JSON 字符串,并在加载时将该字符串解析为 JSON。处理将 JSON 数据保存和加载到浏览器的函数可能如下所示:

const loadJSON = key =>
  key && JSON.parse(localStorage.getItem(key));
const saveJSON = (key, data) =>
  localStorage.setItem(key, JSON.stringify(data));

函数loadJSON使用keylocalStorage加载一个项目。使用localStorage.getItem函数加载数据。如果项目存在,则在返回之前将其解析为 JSON。如果不存在,则函数loadJSON将返回null

函数saveJSON将使用唯一的key标识符将一些数据保存到localStorage中。可以使用localStorage.setItem函数将数据保存到浏览器中。在保存数据之前,我们需要将其转换为 JSON 字符串。

从 Web 存储加载数据、保存数据到 Web 存储、字符串化数据和解析 JSON 字符串都是同步任务。loadJSONsaveJSON函数都是同步的。因此要小心——频繁调用这些函数并处理大量数据可能会导致性能问题。通常建议为了性能考虑对这些函数进行节流或防抖处理。

我们可以保存从 GitHub 请求接收到的用户数据。然后,下次请求同一用户时,我们可以使用保存到localStorage的数据,而不是向 GitHub 发送另一个请求。我们将添加以下代码到GitHubUser组件中:

const [data, setData] = useState(loadJSON(`user:${login}`));
useEffect(() => {
  if (!data) return;
  if (data.login === login) return;
  const { name, avatar_url, location } = data;
  saveJSON(`user:${login}`, {
    name,
    login,
    avatar_url,
    location
  });
}, [data]);

函数loadJSON是同步的,因此当我们调用useState设置数据的初始值时可以使用它。如果在user:moonhighway下保存了用户数据到浏览器中,我们将使用该值来初始化数据。否则,data将最初为null

当从 GitHub 加载数据后,如果data在这里变化,我们将调用saveJSON仅保存我们需要的用户详细信息:nameloginavatar_urllocation。当我们不使用其余用户对象时,无需保存其余数据。当对象为空时,我们也跳过保存data,即!data。此外,如果当前登录和data.login相等,则我们已经为该用户保存了数据。我们将跳过再次保存该数据的步骤。

这里是使用localStorage在浏览器中保存数据的整个GitHubUser组件的示例:

import React, { useState, useEffect } from "react";

const loadJSON = key =>
  key && JSON.parse(localStorage.getItem(key));
const saveJSON = (key, data) =>
  localStorage.setItem(key, JSON.stringify(data));

function GitHubUser({ login }) {
  const [data, setData] = useState(
    loadJSON(`user:${login}`)
  );

  useEffect(() => {
    if (!data) return;
    if (data.login === login) return;
    const { name, avatar_url, location } = data;
    saveJSON(`user:${login}`, {
      name,
      login,
      avatar_url,
      location
    });
  }, [data]);

  useEffect(() => {
    if (!login) return;
    if (data && data.login === login) return;
    fetch(`https://api.github.com/users/${login}`)
      .then(response => response.json())
      .then(setData)
      .catch(console.error);
  }, [login]);

  if (data)
    return <pre>{JSON.stringify(data, null, 2)}</pre>;

  return null;
}

注意GitHubUser组件现在有两个useEffect钩子。第一个钩子用于将数据保存到浏览器中。每当data的值变化时调用它。第二个钩子用于从 GitHub 请求更多数据。当已经为该用户在本地保存了数据时,不会发送 fetch 请求。这由第二个useEffect钩子中的第二个if语句处理:if (data && data.login === login) return;。如果有data并且该数据的loginlogin属性匹配,则无需向 GitHub 发送额外的请求。我们只需使用本地数据。

第一次运行应用程序时,如果login设置为moonhighway,将呈现以下对象到页面上:

{
  "login": "MoonHighway",
  "id": 5952087,
  "node_id": "MDEyOk9yZ2FuaXphdGlvbjU5NTIwODc=",
  "avatar_url": "https://avatars0.githubusercontent.com/u/5952087?v=4",
  "gravatar_id": "",
  "url": "https://api.github.com/users/MoonHighway",
  "html_url": "https://github.com/MoonHighway",

  ...

}

这是来自 GitHub 的响应。我们可以知道,因为此对象包含了关于用户的大量额外信息,我们不需要。第一次运行此页面时,我们将看到这个冗长的响应。但第二次运行页面时,响应就会短得多:

{
  "name": "Moon Highway",
  "login": "moonhighway",
  "avatar_url": "https://avatars0.githubusercontent.com/u/5952087?v=4",
  "location": "Tahoe City, CA"
}

这次,我们为moonhighway本地保存的数据正在被渲染到浏览器上。由于我们只需要四个字段的数据,因此我们只保存了四个字段的数据。直到我们清除存储之前,我们将始终看到这个较小的离线对象:

localStorage.clear();

sessionStoragelocalStorage都是 Web 开发者的重要工具。当我们离线时,我们可以处理这些本地数据,并且它们允许我们通过发送更少的网络请求来提高应用程序的性能。然而,我们必须知道何时使用它们。实施离线存储会增加应用程序的复杂性,并且在开发中可能会让它们难以处理。此外,我们不需要使用 Web 存储来缓存数据。如果我们只是寻求性能提升,我们可以尝试让 HTTP 处理缓存。如果我们在头部添加Cache-Control: max-age=<EXP_DATE>,我们的浏览器将自动缓存内容。EXP_DATE定义了内容的过期日期。

处理 Promise 状态

HTTP 请求和 promises 都有三种状态:挂起、成功(已完成)和失败(已拒绝)。当我们发出请求并等待响应时,请求处于挂起状态。该响应只能以两种方式之一进行:成功或失败。如果响应成功,这意味着我们已成功连接到服务器并收到了数据。在 promise 的世界中,成功的响应意味着承诺已经解析。如果在此过程中出现问题,我们可以说 HTTP 请求已失败或者 promise 已被拒绝。在这两种情况下,我们将收到一个error来解释发生了什么。

在进行 HTTP 请求时,我们真的需要处理这三种状态。我们可以修改 GitHub 用户组件以渲染不仅仅是成功的响应。我们可以在请求挂起时添加一个“加载中…”消息,或者在出现error时渲染错误详情:

function GitHubUser({ login }) {
  const [data, setData] = useState();
  const [error, setError] = useState();
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!login) return;
    setLoading(true);
    fetch(`https://api.github.com/users/${login}`)
      .then(data => data.json())
      .then(setData)
      .then(() => setLoading(false))
      .catch(setError);
  }, [login]);

  if (loading) return <h1>loading...</h1>;
  if (error)
    return <pre>{JSON.stringify(error, null, 2)}</pre>;
  if (!data) return null;

  return (
    <div className="githubUser">
      <img
        src={data.avatar_url}
        alt={data.login}
        style={{ width: 200 }}
      />
      <div>
        <h1>{data.login}</h1>
        {data.name && <p>{data.name}</p>}
        {data.location && <p>{data.location}</p>}
      </div>
    </div>
  );
}

当此请求成功时,Moon Highway 的信息将渲染到用户屏幕上,如图 8-1 所示。

示例输出

图 8-1. 示例输出

如果发生了什么问题,我们将简单地将error对象显示为一个 JSON 字符串。在生产环境中,我们可能会对错误进行更多处理。也许我们会追踪它、记录它,或者尝试发出另一个请求。而在开发过程中,渲染错误详情是可以的,这可以为开发者提供即时反馈。

最后,在请求挂起时,我们只需使用一个h1显示“加载中…”消息。

有时 HTTP 请求可能会成功但带有错误。这种情况发生在请求成功——成功连接到服务器并收到响应——但响应体包含错误时。有时服务器会将附加错误作为成功响应传递。

处理这三种状态会使我们的代码变得有点臃肿,但在每个请求中这样做是至关重要的。请求需要时间,并且可能会出现很多问题。因为所有请求——和承诺——都有这三种状态,所以可以使用可重用的钩子、组件,甚至是称为 Suspense 的 React 特性来处理所有 HTTP 请求。我们将涵盖每种方法,但首先必须介绍渲染属性的概念。

渲染属性

渲染属性 正如其名,即被渲染的属性。这可以是作为属性传递的组件,在满足特定条件时进行渲染,或者可以是返回将被渲染的组件的函数属性。在第二种情况下,当它们是函数时,数据可以作为参数传递,并在渲染返回的组件时使用。

在异步组件中最大化重用性时,渲染属性非常有用。通过这种模式,我们可以创建抽象复杂机制或单调样板代码,这些对应用程序开发是必要的。

考虑显示列表的任务:

import React from "react";

const tahoe_peaks = [
  { name: "Freel Peak", elevation: 10891 },
  { name: "Monument Peak", elevation: 10067 },
  { name: "Pyramid Peak", elevation: 9983 },
  { name: "Mt. Tallac", elevation: 9735 }
];

export default function App() {
  return (
    <ul>
      {tahoe_peaks.map((peak, i) => (
        <li key={i}>
          {peak.name} - {peak.elevation.toLocaleString()}ft
        </li>
      ))}
    </ul>
  );
}

在这个例子中,塔霍最高的四个峰被渲染成无序列表。这段代码是有意义的,但是在映射一个数组以单独渲染每个项目时引入了一些代码复杂性。映射数组中的项目也是一个相当常见的任务。我们可能会经常重复这种模式。我们可以创建一个 List 组件,以便在需要渲染无序列表时重复使用这个解决方案。

在 JavaScript 中,数组要么包含值,要么为空。当列表为空时,我们需要向用户显示一条消息。然而,该消息可能会根据实现方式而变化。别担心——我们可以传递一个组件,在列表为空时进行渲染:

function List({ data = [], renderEmpty }) {
  if (!data.length) return renderEmpty;
  return <p>{data.length} items</p>;
}

export default function App() {
  return <List renderEmpty={<p>This list is empty</p>} />;
}

List 组件期望有两个属性:datarenderEmpty。第一个参数 data 表示要映射的项目数组。其默认值是一个空数组。第二个参数 renderEmpty 是一个组件,如果列表为空时将进行渲染。所以当 data.length0 时,List 组件通过返回传递的 renderEmpty 属性来进行渲染。

在这种情况下,用户会看到以下消息:This list is empty

renderEmpty 是一个渲染属性,因为它包含一个组件,当特定条件满足时将会渲染——在这种情况下,当列表为空或者 data 属性没有提供时。

我们可以将实际的 data 数组发送给这个组件:

export default function App() {
  return (
    <List
      data={tahoe_peaks}
      renderEmpty={<p>This list is empty</p>}
    />
  );
}

现在这样做只会渲染在数组中找到的项目数目:4 items

我们还可以告诉我们的List组件在数组中找到的每个项应该渲染什么。例如,我们可以发送一个renderItem属性:

export default function App() {
  return (
    <List
      data={tahoe_peaks}
      renderEmpty={<p>This list is empty</p>}
      renderItem={item => (
        <>
          {item.name} - {item.elevation.toLocaleString()}ft
        </>
      )}
    />
  );
}

这次,渲染属性是一个函数。数据(即项本身)作为参数传递给此函数,以便在决定为每个塔霍峰渲染什么时使用。在这种情况下,我们渲染一个显示项的nameelevation的 React 片段。如果数组是tahoe_peaks,我们期望调用renderItem属性四次:每次为数组中的一个峰。

这种方法允许我们抽象化映射数组的机制。现在List组件将处理映射;我们只需告诉它要渲染什么:

function List({ data = [], renderItem, renderEmpty }) {
  return !data.length ? (
    renderEmpty
  ) : (
    <ul>
      {data.map((item, i) => (
        <li key={i}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

data数组不为空时,List组件会渲染一个无序列表<ul>。它使用.map方法映射数组中的每个项,并为数组中的每个值渲染一个列表项<li>List组件确保每个列表项都收到一个唯一的key。在每个<li>元素内部,调用renderItem属性并将项本身作为参数传递给该函数属性。结果是一个无序列表,显示了每个塔霍最高峰的名称和海拔。

好消息是我们有一个可重复使用的List组件,可以在需要渲染无序列表时使用。坏消息是我们的组件有点简陋。有更好的组件可以处理这个任务。

虚拟化列表

如果我们的工作是开发一个用于渲染列表的可重用组件,那么需要考虑和实施许多不同的用例和解决方案。其中最重要的一点是当列表非常大时会发生什么。在生产中,我们处理的许多数据点可能会感觉无限。Google 搜索会产生一页又一页的结果。在 Airbnb 上搜索塔霍的住所会产生一个似乎永远不会结束的房屋和公寓列表。生产应用通常有大量需要渲染的数据,但我们不能一次性全部渲染出来。

浏览器能够渲染的内容是有限的。渲染需要时间、处理能力和内存,这三者都有各自的限制。在开发可重复使用的列表组件时,应考虑这一点。当data数组非常大时,我们应该怎么办?

尽管我们寻找住所的搜索可能产生了一千个结果,但我们不可能同时查看所有这些结果——屏幕空间不足以显示所有的图像、名称和价格。我们可能一次只能看到大约五个结果。当滚动时,可以看到更多的结果,但必须向下滚动很远才能看到一千个结果。在可滚动的层中渲染一千个结果对手机来说要求很多。

不要一次性渲染 1000 个结果,如果我们只渲染 11 个会怎么样?记住用户一次只能看到大约五个结果。所以我们渲染用户能看到的五个项目,并在可见窗口之上和之下渲染六个屏幕外的项目。在可见窗口之上和之下渲染项目可以让用户在两个方向上滚动。我们可以在图 8-2 中看到这一点。

窗口化示意图

图 8-2. 带有屏幕外内容的窗口化

当用户滚动时,我们可以卸载已经查看过的结果,并渲染新的在屏幕外的结果,等待用户通过滚动来显示。这种解决方案意味着浏览器一次只会渲染 11 个元素,而其余元素的数据则等待着被渲染。这种技术被称为窗口化虚拟化。它允许我们滚动非常大,甚至无限的数据列表而不会导致浏览器崩溃。

在构建虚拟列表组件时需要考虑很多因素。幸运的是,我们不必从头开始;社区已经为我们开发了许多虚拟列表组件供我们使用。在浏览器中最流行的这些组件包括react-windowreact-virtualized。虚拟列表非常重要,以至于 React Native 甚至默认包含一个:FlatList。大多数人不需要自己构建虚拟列表组件,但我们确实需要知道如何使用它们。

要实现虚拟列表,我们需要大量的数据——在这种情况下,是假数据:

npm i faker

安装faker将允许我们创建大量的假数据数组。在这个例子中,我们将使用假用户。我们将随机创建五千个假用户:

import faker from "faker";

const bigList = [...Array(5000)].map(() => ({
  name: faker.name.findName(),
  email: faker.internet.email(),
  avatar: faker.internet.avatar()
}));

bigList变量是通过映射一个包含五千个空值的数组来创建的,并用faker提供的函数将这些空值替换为关于假用户的信息。每个用户的nameemailavatar都是随机生成的。

如果我们使用上一节创建的List组件,它会一次性渲染所有五千个用户:

export default function App() {
  const renderItem = item => (
    <div style={{ display: "flex" }}>
      <img src={item.avatar} alt={item.name} width={50} />
      <p>
        {item.name} - {item.email}
      </p>
    </div>
  );

  return <List data={bigList} renderItem={renderItem} />;
}

这段代码为每个用户创建一个div元素。在每个div中,会渲染一个用于用户照片的img元素,并且用户的nameemail会用段落元素进行渲染,如图 8-3 所示。

性能结果

图 8-3. 性能结果

React 和现代浏览器的结合已经非常惊人了。我们很可能能够渲染所有五千个用户,但这需要一些时间。在这个例子中,确切地说是 52 毫秒。随着列表中用户数量的增加,这个时间也会增加,直到最终达到一个临界点。

让我们使用react-window来渲染同样的假用户列表:

npm i react-window

react-window是一个库,提供了几个组件用于渲染虚拟列表。在这个例子中,我们将使用react-window中的FixSizeList组件:

import React from "react";
import { FixedSizeList } from "react-window";
import faker from "faker";

const bigList = [...Array(5000)].map(() => ({
  name: faker.name.findName(),
  email: faker.internet.email(),
  avatar: faker.internet.avatar()
}));

export default function App() {
  const renderRow = ({ index, style }) => (
    <div style={{ ...style, ...{ display: "flex" } }}>
      <img
        src={bigList[index].avatar}
        alt={bigList[index].name}
        width={50}
      />
      <p>
        {bigList[index].name} - {bigList[index].email}
      </p>
    </div>
  );

  return (
    <FixedSizeList
      height={window.innerHeight}
      width={window.innerWidth - 20}
      itemCount={bigList.length}
      itemSize={50}
    >
      {renderRow}
    </FixedSizeList>
  );
}

FixedSizeList与我们的List组件稍有不同。它需要列表中的总项目数以及每行需要的像素数作为itemSize属性。这种语法中的另一个重大区别是将渲染 prop 作为children属性传递给FixedSizeList。这种渲染 prop 模式经常被使用。

因此,让我们看看当使用FixSizeList组件渲染五千个虚拟用户时会发生什么(见图 8-4)。

这次,不是所有的用户一次性被渲染出来。只有用户能看到或轻松滚动到的那些行才会被渲染。请注意,这个初始渲染只需要 2.6 毫秒。

当您向下滚动以显示更多用户时,FixedSizeList会辛勤工作,渲染屏幕外的更多用户并移除已经滚动出屏幕的用户。这个组件自动处理双向滚动。这个组件可能会频繁地进行渲染,但是渲染速度很快。而且我们数组中有多少用户都不重要:FixedSizeList都能处理。

性能结果

图 8-4. 这次渲染用时 2.6 毫秒

创建一个 Fetch 钩子

我们知道请求要么是进行中、成功或失败。我们可以通过创建一个自定义钩子来重用进行 fetch 请求所需的逻辑。我们将称这个钩子为useFetch,并且我们可以在应用程序的各个组件中使用它来进行 fetch 请求:

import React, { useState, useEffect } from "react";

export function useFetch(uri) {
  const [data, setData] = useState();
  const [error, setError] = useState();
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    if (!uri) return;
    fetch(uri)
      .then(data => data.json())
      .then(setData)
      .then(() => setLoading(false))
      .catch(setError);
  }, [uri]);

  return {
    loading,
    data,
    error
  };
}

这个自定义钩子是通过组合useStateuseEffect钩子创建的。在这个钩子中,fetch 请求的三种状态分别是:pending(进行中)、success(成功)和 error(错误)。当请求处于 pending 状态时,钩子将返回loadingtrue。当请求成功并且检索到data时,数据将从这个钩子传递给组件。如果出现问题,这个钩子将返回错误。

所有这三种状态都在useEffect钩子内管理。每当uri的值发生变化时,都会调用这个钩子。如果没有uri,则不会进行 fetch 请求。当有uri时,fetch 请求开始。如果请求成功,我们将结果 JSON 传递给setData函数,改变data的状态值。之后,我们将loading的状态值改为 false,因为请求成功了(即不再处于 pending 状态)。最后,如果出现任何问题,我们会捕获并传递给setError,从而改变error的状态值。

现在我们可以使用这个钩子在我们的组件中进行 fetch 请求。每当loadingdataerror的值发生变化时,这个钩子会导致GitHubUser组件重新渲染以展示这些新值:

function GitHubUser({ login }) {
  const { loading, data, error } = useFetch(
    `https://api.github.com/users/${login}`
  );

  if (loading) return <h1>loading...</h1>;
  if (error)
    return <pre>{JSON.stringify(error, null, 2)}</pre>;

  return (
    <div className="githubUser">
      <img
        src={data.avatar_url}
        alt={data.login}
        style={{ width: 200 }}
      />
      <div>
        <h1>{data.login}</h1>
        {data.name && <p>{data.name}</p>}
        {data.location && <p>{data.location}</p>}
      </div>
    </div>
  );
}

虽然组件现在逻辑更少,但仍处理所有三种状态。假设我们有一个SearchForm组件准备好从用户那里收集搜索字符串,我们可以将GitHubUser组件添加到我们的主App组件中:

import React, { useState } from "react";
import GitHubUser from "./GitHubUser";
import SearchForm from "./SearchForm";

export default function App() {
  const [login, setLogin] = useState("moontahoe");

  return (
    <>
      <SearchForm value={login} onSearch={setLogin} />
      <GitHubUser login={login} />
    </>
  );
}

主要的App组件在状态中存储 GitHub 用户的用户名。改变这个值的唯一方法是使用搜索表单来搜索新用户。每当login的值发生变化时,发送给useFetch的值也会发生变化,因为它依赖于 login 属性:https://api.github.com/users/${login}。这将改变我们钩子中的uri并触发对新用户登录的 fetch 请求。我们已经创建了一个自定义钩子,并成功地用它创建了一个小应用程序,可以用来查找和显示 GitHub 用户的详细信息。在迭代这个应用程序时,我们将继续使用这个钩子。

创建一个 Fetch 组件

钩子通常允许我们在组件之间重用功能。在处理我们组件内的渲染时,有时我们会发现自己重复相同的模式。例如,我们选择渲染的加载旋转器可能是我们希望在整个应用程序中每当 fetch 请求挂起时都渲染的相同旋转器。我们处理 fetch 请求错误的方式可能也在整个应用程序中保持一致。

在我们的应用程序中,我们可以创建一个组件来渲染一致的加载旋转器,并在整个领域内一致地处理所有错误,而不是在多个组件中复制相同的代码。让我们创建一个Fetch组件:

function Fetch({
  uri,
  renderSuccess,
  loadingFallback = <p>loading...</p>,
  renderError = error => (
    <pre>{JSON.stringify(error, null, 2)}</pre>
  )
}) {
  const { loading, data, error } = useFetch(uri);
  if (loading) return loadingFallback;
  if (error) return renderError(error);
  if (data) return renderSuccess({ data });
}

自定义钩子,useFetch,是一层抽象:它抽象了进行 fetch 请求的机制。Fetch组件是另一层抽象:它抽象了处理渲染内容的机制。当请求正在加载时,Fetch组件将渲染传递给可选的loadingFallback属性。当请求成功时,JSON 响应数据将传递给renderSuccess属性。如果出现错误,将使用可选的renderError属性进行渲染。loadingFallbackrenderError属性提供了一个可选的定制层。然而,当它们未提供时,它们将回退到它们的默认值。

有了Fetch组件,我们可以真正简化我们GitHubUser组件中的逻辑:

import React from "react";
import Fetch from "./Fetch";

export default function GitHubUser({ login }) {
  return (
    <Fetch
      uri={`https://api.github.com/users/${login}`}
      renderSuccess={UserDetails}
    />
  );
}

function UserDetails({ data }) {
  return (
    <div className="githubUser">
      <img
        src={data.avatar_url}
        alt={data.login}
        style={{ width: 200 }}
      />
      <div>
        <h1>{data.login}</h1>
        {data.name && <p>{data.name}</p>}
        {data.location && <p>{data.location}</p>}
      </div>
    </div>
  );
}

GitHubUser组件接收一个在 GitHub 上查找的用户的login。我们使用该登录来构建发送给fetch组件的uri属性。如果成功,将呈现UserDetails组件。当Fetch组件正在加载时,将显示默认的“加载中…”消息。如果出现问题,则自动显示错误详细信息。

我们可以为这些属性提供自定义值。以下是我们如何替代使用我们灵活组件的示例:

<Fetch
  uri={`https://api.github.com/users/${login}`}
  loadingFallback={<LoadingSpinner />}
  renderError={error => {
    // handle error
    return <p>Something went wrong... {error.message}</p>;
  }}
  renderSuccess={({ data }) => (
    <>
      <h1>Todo: Render UI for data</h1>
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </>
  )}
/>

这次,Fetch组件将渲染我们自定义的加载旋转器。如果出现问题,我们将隐藏错误详细信息。当请求成功时,我们选择替代地呈现原始数据以及一个给我们自己的 TODO 消息。

注意:无论是通过钩子还是组件,额外的抽象层都可能会增加我们代码的复杂性。我们的工作是在任何可能的地方减少复杂性。然而,在这种情况下,通过将可重复使用的逻辑抽象成组件和钩子,我们已经减少了复杂性。

处理多个请求

一旦我们开始从互联网请求数据,就无法停止。往往我们需要发出多个 HTTP 请求来获取所有需要的数据来满足我们应用的需求。例如,我们目前正在请求 GitHub 提供关于用户账户的信息。我们还需要获取有关该用户仓库的信息。这两个数据点通过单独的 HTTP 请求获取。

GitHub 用户通常拥有许多仓库。关于用户仓库的信息以对象数组的形式传递。我们将创建一个名为useIterator的特殊自定义钩子,允许我们迭代任何对象数组:

export const useIterator = (
  items = [],
  initialIndex = 0
) => {
  const [i, setIndex] = useState(initialIndex);

  const prev = () => {
    if (i === 0) return setIndex(items.length - 1);
    setIndex(i - 1);
  };

  const next = () => {
    if (i === items.length - 1) return setIndex(0);
    setIndex(i + 1);
  };

  return [items[i], prev, next];
};

这个钩子将允许我们循环遍历任何数组。因为它返回数组内的项目,我们可以利用数组解构为这些值赋予有意义的名称:

const [letter, previous, next] = useIterator([
  "a",
  "b",
  "c"
]);

在这种情况下,初始的letter是“b”。如果用户调用next,组件将重新渲染,但这次,letter的值将是“b”。再调用两次nextletter的值将再次变为“a”,因为此迭代器会循环回到数组的第一个项,而不是让index越界。

useIterator钩子接受一个items数组和一个初始索引。这个迭代器钩子的关键值是索引i,它是用useState钩子创建的。i用于标识数组中的当前项。此钩子返回当前项item[i],以及用于在数组中进行迭代的函数:prevnextprevnext函数通过调用setIndex来减少或增加i的值。此操作会导致使用新index重新渲染钩子。

缓存值

useIterator钩子非常酷。但我们可以通过缓存item的值以及prevnext的函数做得更好:

import React, { useCallback, useMemo } from "react";

export const useIterator = (
  items = [],
  initialValue = 0
) => {
  const [i, setIndex] = useState(initialValue);

  const prev = useCallback(() => {
    if (i === 0) return setIndex(items.length - 1);
    setIndex(i - 1);
  }, [i]);

  const next = useCallback(() => {
    if (i === items.length - 1) return setIndex(0);
    setIndex(i + 1);
  }, [i]);

  const item = useMemo(() => items[i], [i]);

  return [item || items[0], prev, next];
};

在这里,prevnext都是使用useCallback钩子创建的。这确保了prev的函数在i的值不变时始终相同。同样,item的值将始终指向同一个对象,除非i的值发生变化。

缓存这些值并不能给我们带来巨大的性能提升,或者至少不足以证明代码复杂性。然而,当消费者使用useIterator组件时,缓存的值始终指向完全相同的对象和函数。这使得当消费者需要比较这些值或在他们自己的依赖数组中使用它们时更容易。

现在,我们将创建一个存储库菜单组件。在此组件中,我们将使用useIterator钩子允许用户循环浏览他们的存储库列表:

< learning-react >

如果他们点击“下一个”按钮,他们将看到下一个存储库的名称。同样,如果他们点击“上一个”按钮,他们将看到上一个存储库的名称。RepoMenu是我们将创建的组件,提供此功能:

import React from "react";
import { useIterator } from "../hooks";

export function RepoMenu({
  repositories,
  onSelect = f => f
}) {
  const [{ name }, previous, next] = useIterator(
    repositories
  );

  useEffect(() => {
    if (!name) return;
    onSelect(name);
  }, [name]);

  return (
    <div style={{ display: "flex" }}>
      <button onClick={previous}>&lt;</button>
      <p>{name}</p>
      <button onClick={next}>&gt;</button>
    </div>
  );
}

RepoMenu接收一个repositories列表作为属性。然后从当前存储库对象中解构name,从useIterator中解构previousnext函数。&lt;是“小于”的实体,显示为小于号“<”。&gt;也是同理,表示大于。这些是上一个和下一个的指示器,当用户点击其中任何一个指示器时,组件将重新渲染为新的存储库名称。如果name发生变化,那么用户已选择了不同的存储库,因此我们调用onSelect函数,并将新存储库的name作为参数传递给该函数。

请记住,数组解构允许我们随意命名项目。尽管我们在钩子中将这些函数命名为prevnext,但在这里,当我们使用该钩子时,我们可以将它们的名称更改为previousnext

现在我们可以创建UserRepositories组件。该组件应首先请求 GitHub 用户的存储库列表,一旦收到,将该列表传递给RepoMenu组件:

import React from "react";
import Fetch from "./Fetch";
import RepoMenu from "./RepoMenu";

export default function UserRepositories({
  login,
  selectedRepo,
  onSelect = f => f
}) {
  return (
    <Fetch
      uri={`https://api.github.com/users/${login}/repos`}
      renderSuccess={({ data }) => (
        <RepoMenu
          repositories={data}
          selectedRepo={selectedRepo}
          onSelect={onSelect}
        />
      )}
    />
  );
}

UserRepositories组件需要一个login来使用,以便发出获取存储库列表的 fetch 请求。该login用于创建 URI 并将其传递给Fetch组件。一旦 fetch 成功解析,我们将渲染RepoMenu以及从Fetch组件返回的存储库列表作为data。当用户选择不同的存储库时,我们只需将该新存储库的名称传递给父对象:

function UserDetails({ data }) {
  return (
    <div className="githubUser">
      <img src={data.avatar_url} alt={data.login} style={{ width: 200 }} />
      <div>
        <h1>{data.login}</h1>
        {data.name && <p>{data.name}</p>}
        {data.location && <p>{data.location}</p>}
      </div>
      <UserRepositories
        login={data.login}
        onSelect={repoName => console.log(`${repoName} selected`)}
      />
    </div>
  );

现在我们需要将新组件添加到UserDetails组件中。当渲染UserDetails组件时,我们还将渲染该用户的存储库列表。假设login值为eveporcello,上述组件的渲染输出将类似于图 8-5。

存储库输出

图 8-5. 存储库输出

为了获取 Eve 账户的信息以及她的存储库列表,我们需要发送两个单独的 HTTP 请求。作为 React 开发人员,我们的大部分时间都将花在这里:请求信息并将所有接收到的信息组合成美观的用户界面应用程序。请求两次信息只是个开始。在下一节中,我们将继续向 GitHub 发出更多请求,以便查看所选存储库的 README.md。

瀑布请求

在上一节中,我们进行了两个 HTTP 请求。第一个请求是用户详细信息的请求,然后一旦我们有了这些详细信息,我们就会为该用户的存储库进行第二个请求。这些请求依次进行,依次发生。

最初获取用户详细信息时进行第一个请求:

<Fetch
  uri={`https://api.github.com/users/${login}`}
  renderSuccess={UserDetails}
/>

一旦我们获得了该用户的详细信息,UserDetails组件就会被渲染。它反过来渲染UserRepositories,然后发送一个请求以获取该用户的存储库:

<Fetch
  uri={`https://api.github.com/users/${login}/repos`}
  renderSuccess={({ data }) => (
    <RepoMenu repositories={data} onSelect={onSelect} />
  )}
/>

我们称这些请求为瀑布请求,因为它们依次发生——它们彼此依赖。如果用户详细信息请求出现问题,那么用户存储库的请求将不会被发出。

让我们为这个瀑布再添加一些层次(水?)。首先,我们请求用户的信息,然后是他们的存储库列表,然后一旦我们有了他们的存储库列表,我们就会请求第一个存储库的 README.md 文件。随着用户在存储库列表中循环,我们将为每个存储库的相关 README 进行额外的请求。

存储库 README 文件使用 Markdown 编写,这是一种可以使用ReactMarkdown组件轻松呈现为 HTML 的文本格式。首先,让我们安装react-markdown

npm i react-markdown

请求存储库 README 文件的内容还需要一系列的请求瀑布。首先,我们必须向存储库的 README 路由发出数据请求:https://api.github.com/repos/\({login}/\){repo}/readme。GitHub 将通过此路由响应有关存储库 README 文件的详细信息,但不提供该文件的内容。它确实为我们提供了一个download_url,我们可以使用它来请求 README 文件的内容。但是要获取 Markdown 内容,我们需要进行额外的请求。这两个请求可以在单个异步函数内完成:

const loadReadme = async (login, repo) => {
  const uri = `https://api.github.com/repos/${login}/${repo}/readme`;
  const { download_url } = await fetch(uri).then(res =>
    res.json()
  );
  const markdown = await fetch(download_url).then(res =>
    res.text()
  );

  console.log(`Markdown for ${repo}\n\n${markdown}`);
};

要找到存储库的 README,我们需要存储库所有者的login和存储库的名称。这些值用于构建一个唯一的网址:https://api.github.com/repos/moonhighway/learning-react/readme。当这个请求成功时,我们从其响应中解构出download_url。现在,我们可以使用这个值来下载 README 的内容;我们只需获取download_url。我们将此文本解析为文本——res.text()——而不是 JSON,因为响应的主体是 Markdown 文本。

一旦我们有了 Markdown,让我们通过将loadReadme函数包装在一个 React 组件内来呈现它:

import React, {
  useState,
  useEffect,
  useCallback
} from "react";
import ReactMarkdown from "react-markdown";

export default function RepositoryReadme({ repo, login }) {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState();
  const [markdown, setMarkdown] = useState("");

  const loadReadme = useCallback(async (login, repo) => {
    setLoading(true);
    const uri = `https://api.github.com/repos/${login}/${repo}/readme`;
    const { download_url } = await fetch(uri).then(res =>
      res.json()
    );
    const markdown = await fetch(download_url).then(res =>
      res.text()
    );
    setMarkdown(markdown);
    setLoading(false);
  }, []);

  useEffect(() => {
    if (!repo || !login) return;
    loadReadme(login, repo).catch(setError);
  }, [repo]);

  if (error)
    return <pre>{JSON.stringify(error, null, 2)}</pre>;
  if (loading) return <p>Loading...</p>;

  return <ReactMarkdown source={markdown} />;
}

首先,我们使用useCallback钩子将loadReadme函数添加到组件中,以在组件初始渲染时记忆函数。该函数在进行 fetch 请求之前将加载状态更改为true,并在请求完成后将其更改回false。接收到 Markdown 后,使用setMarkdown函数将其保存在状态中。

接下来,我们需要实际调用loadReadme,因此我们在组件初始渲染后添加了一个useEffect钩子来加载 README 文件。如果因某些原因repologin的属性不存在,则 README 将不会被加载。此钩子中的依赖数组包含[repo]。这是因为我们希望在repo的值发生变化时加载另一个 README。如果在加载 README 时发生任何错误,它将被捕获并发送到setError函数。

注意,我们必须处理与每次获取请求相同的三种渲染状态:挂起,成功和失败。最后,在成功响应时,Markdown 本身使用ReactMarkdown组件呈现。

所有需要做的就是在RepoMenu组件内部呈现RepositoryReadme组件。当用户使用RepoMenu组件循环浏览仓库时,每个仓库的 README 也会被加载并显示:

export function RepoMenu({ repositories, login }) {
  const [{ name }, previous, next] = useIterator(
    repositories
  );
  return (
    <>
      <div style={{ display: "flex" }}>
        <button onClick={previous}>&lt;</button>
        <p>{name}</p>
        <button onClick={next}>&gt;</button>
      </div>
      <RepositoryReadme login={login} repo={name} />
    </>
  );
}

现在我们的应用程序真的正在发起多个请求;最初它会发起四个请求:一个是用户详细信息的请求,然后是该用户的仓库列表的请求,接着是所选仓库 README 信息的请求,最后还有一次文本内容的 README 请求。这些都是瀑布式请求,因为它们一个接一个地发生。

另外,随着用户与应用程序的交互,还会发起更多的请求。每当用户更改当前仓库时,都会发起两次瀑布式请求以获取 README 文件。每当用户搜索不同的 GitHub 账户时,都会再次发起四次初始瀑布式请求。

减慢网络速度

所有这些请求都可以在开发者工具的网络选项卡中看到。从这个选项卡中,您可以看到每个请求,并可以通过减缓网络速度来查看这些请求在慢网络上的展开情况。如果您想看到瀑布式请求是如何一个接一个发生的,可以减慢网络速度并查看加载消息。

大多数主流浏览器的开发者工具中都有网络选项卡。要在 Google Chrome 中减慢网络速度,选择“在线”旁边的箭头,如图 8-6 所示。

更改速度

图 8-6. 改变网络请求的速度

这将打开一个菜单,您可以在图 8-7 中看到各种速度选项。

选择速度

图 8-7. 选择网络请求的速度

选择“Fast 3G”或“Slow 3G”将显著减慢您的网络请求。

此外,网络选项卡还显示了所有 HTTP 请求的时间线。您可以将此时间线筛选为仅查看“XHR”请求。这意味着它只会显示使用fetch进行的请求(见图 8-8)。

请求的瀑布图

图 8-8. 请求的瀑布图

在这里,我们看到连续进行了四次请求。请注意加载图形的标题为“瀑布”。这表明每个请求在前一个请求完成后进行。

并行请求

有时,通过同时发送所有请求,可以使应用程序更快。与瀑布式一次发出每个请求不同,我们可以并行发送请求,即同时进行。

当前我们应用程序产生请求瀑布的原因是这些组件相互嵌套渲染。GitHubUser 最终渲染 UserRepositories,后者最终渲染 RepositoryReadme。请求直到每个组件被渲染后才会发生。

进行这些并行请求将需要不同的方法。首先,我们需要从 RepoMenu 的渲染函数中删除 <RepositoryReadme />。这是一个不错的举措。RepoMenu 应只专注于创建用户可以循环浏览的存储库菜单的逻辑。RepositoryReadme 组件应在不同的组件中处理。

接下来,我们需要从 UserRepositoriesrenderSuccess 属性中删除 <RepoMenu />。同样,需要从 UserDetails 组件中删除 <UserRepositories />

我们不再将这些组件嵌套在彼此内部,而是将它们全部放置在同一级别并排放置,都在 App 组件中:

import React, { useState } from "react";
import SearchForm from "./SearchForm";
import GitHubUser from "./GitHubUser";
import UserRepositories from "./UserRepositories";
import RepositoryReadme from "./RepositoryReadme";

export default function App() {
  const [login, setLogin] = useState("moonhighway");
  const [repo, setRepo] = useState("learning-react");
  return (
    <>
      <SearchForm value={login} onSearch={setLogin} />
      <GitHubUser login={login} />
      <UserRepositories
        login={login}
        repo={repo}
        onSelect={setRepo}
      />
      <RepositoryReadme login={login} repo={repo} />
    </>
  );
}

GitHubUserUserRepositoriesRepositoryReadme 组件都会向 GitHub 发送 HTTP 请求以获取数据。将它们并排渲染在同一级别会导致所有这些请求同时进行,即并行进行。

每个组件需要特定的信息才能发出请求。我们需要一个 login 来获取 GitHub 用户。我们需要一个 login 来获取用户存储库列表。RepositoryReadme 需要 loginrepo 才能正常工作。为了确保所有组件都能获取到发起请求所需的信息,我们初始化应用程序以显示用户“moonhighway”的详细信息和存储库“learning-react”的详细信息。

如果用户使用 SearchForm 搜索另一个 GitHubUserlogin 的值将更改,这将触发组件中的 useEffect 钩子,导致它们发出额外的数据请求。如果用户在存储库列表中循环,则将调用 UserRepositoriesonSelect 属性,这将导致 repo 值发生变化。更改 repo 值将触发 RepositoryReadme 组件内部的 useEffect 钩子,并请求新的 README。

RepoMenu 组件始终从第一个存储库开始,无论如何。我们必须查看是否有 selectedRepo 属性。如果有,我们需要使用它来找到要显示的存储库的初始索引:

export function RepoMenu({ repositories, selected, onSelect = f => f }) {
  const [{ name }, previous, next] = useIterator(
    repositories,
    selected ? repositories.findIndex(repo => repo.name === selected) : null
  );
  ...
}

useIterator钩子的第二个参数是要从哪里开始的初始索引。如果有selected属性,那么我们将根据name搜索选定的存储库的索引。这是必需的,以确保存储库菜单最初显示正确的存储库。我们还需要从UserRepositories将此selected属性传递给该组件:

<Fetch
  uri={`https://api.github.com/users/${login}/repos`}
  renderSuccess={({ data }) => (
    <RepoMenu
      repositories={data}
      selected={repo}
      onSelect={onSelect}
    />
  )}
/>

现在repo属性被传递给RepoMenu,菜单应选择初始仓库,即我们的例子中是“learning-react”。

如果您查看网络选项卡,您将注意到我们已经进行了三个并行请求,如图 8-9 所示。

创建并行请求

图 8-9. 创建并行请求

因此,每个组件同时发出其请求。RepoReadme组件仍然必须进行串行请求以获取 README 文件的内容。这没问题。很难使应用程序在初始渲染时立即发出每个请求。并行和串行请求可以结合使用。

等待值

当前,我们将loginrepo的值初始化为“moonhighway”和“learning-react”。我们可能无法总是猜测要首先渲染哪些数据。在这种情况下,直到组件所需的数据可用之前,我们才简单地不渲染该组件:

export default function App() {
  const [login, setLogin] = useState();
  const [repo, setRepo] = useState();
  return (
    <>
      <SearchForm value={login} onSearch={setLogin} />
      {login && <GitHubUser login={login} />}
      {login && (
        <UserRepositories
          login={login}
          repo={repo}
          onSelect={setRepo}
        />
      )}
      {login && repo && (
        <RepositoryReadme login={login} repo={repo} />
      )}
    </>
  );
}

在这种情况下,直到它们所需的 props 具有值之前,没有一个组件会被渲染。最初,只有SearchForm组件被渲染。搜索用户将更改login的值,导致UserRepositories组件被渲染。当此组件查找存储库时,它将选择列表中的第一个存储库,导致调用setRepo。最后,我们有了loginrepo,因此将渲染RepositoryReadme组件。

取消请求

更深思考我们的应用,我们意识到用户可能会清空搜索字段并搜索没有用户。在这种情况下,我们还希望确保repo的值也为空。让我们添加一个handleSearch方法,确保在login没有值时repo值发生变化:

export default function App() {
  const [login, setLogin] = useState("moonhighway");
  const [repo, setRepo] = useState("learning-react");

  const handleSearch = login => {
    if (login) return setLogin(login);
    setLogin("");
    setRepo("");
  };

  if (!login)
    return (
      <SearchForm value={login} onSearch={handleSearch} />
    );

  return (
    <>
      <SearchForm value={login} onSearch={handleSearch} />
      <GitHubUser login={login} />
      <UserRepositories
        login={login}
        repo={repo}
        onSelect={setRepo}
      />
      <RepositoryReadme login={login} repo={repo} />
    </>
  );
}

我们已经添加了一个handleSearch方法。现在,当用户清除搜索字段并搜索空字符串时,repo 值也将设置为空字符串。如果因某种原因没有登录,我们只渲染一个组件:SearchForm。当我们有了login的值时,我们将渲染所有四个组件。

现在,从技术上讲,我们的应用程序有两个屏幕。一个屏幕仅显示搜索表单。另一个屏幕仅在搜索表单包含值时显示,此时显示所有四个组件。我们已经设置好了根据用户交互来挂载或卸载组件的条件。假设我们正在查看“moonhighway”的详细信息。如果用户清空搜索字段,那么 GitHubUserUserRepositoriesRepositoryReadme 组件将被卸载,并且不再显示。但是如果在这些组件在加载数据时被卸载会发生什么呢?

你可以试试:

  1. 将网络节流至“慢速 3G”,以便有足够的时间引起问题

  2. 将搜索字段的值从 “moonhighway” 更改为 “eveporcello”

  3. 在数据加载时,搜索一个空字符串,“”

在这些步骤中,当它们正在进行 fetch 请求时,GitHubUserUserRepositoriesRepositoryReadme 将会变为未挂载状态。最终,当 fetch 请求有响应时,这些组件将不再挂载。在未挂载的组件中尝试更改状态值将导致 图 8-10 中显示的错误。

mounted error

图 8-10. 挂载错误

每当我们的用户通过慢速网络加载数据时,这些错误可能会发生。但是我们可以保护自己。首先,我们可以创建一个钩子,告诉我们当前的组件是否已挂载:

export function useMountedRef() {
  const mounted = useRef(false);
  useEffect(() => {
    mounted.current = true;
    return () => (mounted.current = false);
  });
  return mounted;
}

useMountedRef 钩子使用了一个引用。当组件卸载时,状态被清除,但引用仍然可用。上述的 useEffect 没有依赖数组;它在每次组件渲染时被调用,并确保引用的值为 true。每当组件卸载时,会调用从 useEffect 返回的函数,该函数将引用的值更改为 false

现在我们可以在 RepoReadme 组件内使用这个钩子。这将确保在应用任何状态更新之前,检查组件是否已挂载:

const mounted = useMountedRef();

const loadReadme = useCallback(async (login, repo) => {
  setLoading(true);
  const uri = `https://api.github.com/repos/${login}/${repo}/readme`;
  const { download_url } = await fetch(uri).then(res =>
    res.json()
  );
  const markdown = await fetch(download_url).then(res =>
    res.text()
  );
  if (mounted.current) {
    setMarkdown(markdown);
    setLoading(false);
  }
}, []);

现在我们有了一个引用,告诉我们组件是否已挂载。这两个请求完成需要时间。完成后,我们检查组件是否仍处于挂载状态,然后调用 setMarkdownsetLoading

让我们将相同的逻辑添加到我们的 useFetch 钩子中:

const mounted = useMountedRef();

useEffect(() => {
  if (!uri) return;
  if (!mounted.current) return;
  setLoading(true);
  fetch(uri)
    .then(data => {
      if (!mounted.current) throw new Error("component is not mounted");
      return data;
    })
    .then(data => data.json())
    .then(setData)
    .then(() => setLoading(false))
    .catch(error => {
      if (!mounted.current) return;
      setError(error);
    });

useFetch 钩子用于在我们的应用程序中进行其余的 fetch 请求。在这个钩子中,我们使用 thenables,可链式的 .then() 函数来组成 fetch 请求,而不是 async/await。当 fetch 完成时,在第一个 .then 回调中检查组件是否已挂载。如果组件已挂载,则返回 data 并调用其余的 .then 函数。当组件未挂载时,第一个 .then 函数会抛出错误,阻止其余的 .then 函数执行。相反,会调用 .catch 函数,并将新错误传递给该函数。.catch 函数将在尝试调用 setError 之前检查组件是否已挂载。

我们已成功取消了我们的请求。我们并没有阻止 HTTP 请求本身发生,但我们确实保护了请求解决后进行的状态调用。测试你的应用在慢网络条件下总是一个好主意。这些 bug 将被发现并消除。

介绍 GraphQL

就像 React 一样,GraphQL 是在 Facebook 设计的。而且,就像 React 是一个声明式的用户界面组合解决方案一样,GraphQL 是用于与 API 通信的声明式解决方案。当我们进行并行数据请求时,我们尝试立即获取所有需要的数据。GraphQL 就是为此而设计的。

要从 GraphQL API 获取数据,我们仍然需要向特定的 URI 发送 HTTP 请求。但是,我们还需要发送一个查询以及请求。GraphQL 查询是对我们请求的数据的声明性描述。服务将解析此描述,并将我们请求的所有数据打包到一个响应中。

GitHub GraphQL API

要在您的 React 应用程序中使用 GraphQL,您通信的后端服务需要按照 GraphQL 规范构建。幸运的是,GitHub 也提供了一个 GraphQL API。大多数 GraphQL 服务提供一种探索 GraphQL API 的方法。在 GitHub,这称为 GraphQL Explorer。要使用 Explorer,您必须使用您的 GitHub 帐户登录。

Explorer 的左侧面板是我们草拟 GraphQL 查询的地方。在此面板内,我们可以添加一个查询,以获取关于单个 GitHub 用户的信息:

query {
  user(login: "moontahoe") {
    id
    login
    name
    location
    avatarUrl
  }
}

这是一个 GraphQL 查询。我们要获取关于 GitHub 用户“moontahoe”的信息。与其获取所有公开信息不同,我们只获取我们想要的数据:idloginavatarUrlnamelocation。当我们在此页面按下播放按钮时,我们将此查询作为 HTTP POST 请求发送到 https://api.github.com/graphql。所有 GitHub GraphQL 查询都发送到此 URI。GitHub 将解析此查询并仅返回我们请求的数据:

{
  "data": {
    "user": {
      "id": "MDQ6VXNlcjU5NTIwODI=",
      "login": "MoonTahoe",
      "name": "Alex Banks",
      "location": "Tahoe City, CA",
      "avatarUrl": "https://github.com/moontahoe.png"
    }
  }
}

我们可以将这个 GraphQL 查询正式化为一个名为 findRepos 的可重用操作。每当我们想要查找有关用户及其仓库的信息时,我们可以通过向该查询发送一个 login 变量来实现:

query findRepos($login: String!) {
  user(login: $login) {
    login
    name
    location
    avatar_url: avatarUrl
    repositories(first: 100) {
      totalCount
      nodes {
        name
      }
    }
  }
}

现在我们已经创建了一个正式的 findRepos 查询,可以通过简单地链式传递 $login 变量的值来重用。我们使用查询变量面板中显示的 图 8-11 来设置此变量。

GitHub GraphQL 资源浏览器

图 8-11. GitHub GraphQL 资源浏览器

除了获取有关用户的详细信息外,我们还要求获取该用户的前 100 个仓库。我们询问了查询返回的仓库数,即totalCount,以及每个仓库的name。GraphQL 仅返回我们要求的数据。在这种情况下,我们仅获取每个仓库的name,而不获取其他任何信息。

还有一项我们对此查询所做的更改:我们为avatarUrl使用了一个别名。获取用户头像的 GraphQL 字段称为avatarUrl,但我们希望该变量被命名为avatar_url。别名告诉 GitHub 在数据响应中重命名该字段。

GraphQL 是一个广阔的话题。我们为此撰写了一本整书:学习 GraphQL。在这里我们只是浅尝辄止,但是对于任何开发者来说,了解 GraphQL 的基础知识越来越重要。

发起 GraphQL 请求

一个 GraphQL 请求是一个包含查询的 HTTP 请求体。您可以使用 fetch 来发起 GraphQL 请求。还有许多库和框架可以帮助您处理这些请求的详细信息。在接下来的部分中,我们将看到如何使用名为 graphql-request 的库来获取 GraphQL 数据来充实我们的应用程序。

GraphQL 并不限于 HTTP。它是一种规范,用于在网络上传递数据请求。从技术上讲,它可以与任何网络协议一起工作。此外,GraphQL 与编程语言无关。

首先,让我们安装 graphql-request

npm i graphql-request

GitHub 的 GraphQL API 要求在客户端应用程序中发送请求时进行身份验证。为了完成下面的示例,您必须从 GitHub 获取一个个人访问令牌,并且该令牌必须随每个请求一起发送。

要获取用于 GraphQL 请求的个人访问令牌,请导航至 设置 > 开发者设置 > 个人访问令牌。在这个表单上,您可以创建具有特定权限的访问令牌。为了进行 GraphQL 请求,该令牌必须具有以下读取权限:

  • 用户

  • public_repo

  • repo

  • repo_deployment

  • repo:status

  • read:repo_hook

  • read:org

  • read:public_key

  • read:gpg_key

我们可以使用 graphql-request 在 JavaScript 中发出 GraphQL 请求:

import { GraphQLClient } from "graphql-request";

const query = `
 query findRepos($login:String!) {
 user(login:$login) {
 login
 name
 location
 avatar_url: avatarUrl
 repositories(first:100) {
 totalCount
 nodes {
 name
 }
 }
 }
 }
`;

const client = new GraphQLClient(
  "https://api.github.com/graphql",
  {
    headers: {
      Authorization: `Bearer <PERSONAL_ACCESS_TOKEN>`
    }
  }
);

client
  .request(query, { login: "moontahoe" })
  .then(results => JSON.stringify(results, null, 2))
  .then(console.log)
  .catch(console.error);

我们使用 GraphQLClient 构造函数从 graphql-request 发送此请求。在创建客户端时,我们使用 GitHub 的 GraphQL API 的 URI:https://api.github.com/graphql。我们还发送一些额外的头信息,其中包含我们的个人访问令牌。这个令牌用于识别我们,在使用 GitHub 的 GraphQL API 时是必需的。现在我们可以使用 client 来进行我们的 GraphQL 请求。

为了发起 GraphQL 请求,我们需要一个 query。该 query 简单地是包含上述 GraphQL 查询的字符串。我们将 query 与可能需要的任何变量一起发送给 request 函数。在这种情况下,query 需要一个名为 $login 的变量,因此我们发送一个包含在 login 字段中为 $login 提供值的对象。

在这里,我们只是将生成的 JSON 转换为字符串并记录在控制台中:

{
  "user": {
    "id": "MDQ6VXNlcjU5NTIwODI=",
    "login": "MoonTahoe",
    "name": "Alex Banks",
    "location": "Tahoe City, CA",
    "avatar_url": "https://avatars0.githubusercontent.com/u/5952082?v=4",
    "repositories": {
      "totalCount": 52,
      "nodes": [
        {
          "name": "snowtooth"
        },
        {
          "name": "Memory"
        },
        {
          "name": "snowtooth-status"
        },

        ...

      ]
    }
  }
}

就像 fetch 一样,client.request 也返回一个承诺。在 React 组件内部获取这些数据将感觉非常类似于从路由获取数据:

export default function App() {
  const [login, setLogin] = useState("moontahoe");
  const [userData, setUserData] = useState();
  useEffect(() => {
    client
      .request(query, { login })
      .then(({ user }) => user)
      .then(setUserData)
      .catch(console.error);
  }, [client, query, login]);

  if (!userData) return <p>loading...</p>;

  return (
    <>
      <SearchForm value={login} onSearch={setLogin} />
      <UserDetails {...userData} />
      <p>{userData.repositories.totalCount} - repos</p>
      <List
        data={userData.repositories.nodes}
        renderItem={repo => <span>{repo.name}</span>}
      />
    </>
  );
}

我们在 useEffect 钩子中执行 client.request。如果 clientquerylogin 发生变化,useEffect 钩子将发起另一个请求。然后,我们将使用 React 渲染结果的 JSON,如 图 8-12 所示。

GraphQL 应用

图 8-12. GraphQL 应用

此示例并未关注处理 loadingerror 状态,但我们可以将本章其余部分学到的内容应用到 GraphQL 中。React 实际上并不关心我们如何获取数据。只要我们理解如何在组件内部处理像承诺这样的异步对象,我们就能应对任何情况。

从互联网加载数据是一个异步任务。当我们请求数据时,需要一些时间才能传递,而且可能会出错。在 React 组件中处理承诺的 pendingsuccessfail 状态是使用 useEffect 钩子进行状态管理的协作。

在本章的大部分内容中,我们讨论了承诺、fetch 和 HTTP。这是因为 HTTP 仍然是从互联网请求数据的最流行方式,而承诺与 HTTP 请求非常匹配。有时,您可能会使用不同的协议,如 WebSockets。不用担心:通过使用状态钩子和 useEffect,可以轻松实现这一点。

这里是一个简短的示例,演示了如何将 socket.io 集成到自定义的 useChatRoom 钩子中:

const reducer = (messages, incomingMessage) => [
  messages,
  ...incomingMessage
];

export function useChatRoom(socket, messages = []) {
  const [status, setStatus] = useState(null);
  const [messages, appendMessage] = useReducer(
    reducer,
    messages
  );

  const send = message => socket.emit("message", message);

  useEffect(() => {
    socket.on("connection", () => setStatus("connected"));
    socket.on("disconnecting", () =>
      setStatus("disconnected")
    );
    socket.on("message", setStatus);
    return () => {
      socket.removeAllListeners("connect");
      socket.removeAllListeners("disconnect");
      socket.removeAllListeners("message");
    };
  }, []);

  return {
    status,
    messages,
    send
  };
}

此钩子提供了一组聊天 messages 数组,websocket 的 connection 状态,以及一个用于向套接字广播新消息的函数。所有这些值都受到 useEffect 钩子中定义的监听器的影响。当套接字触发 connectiondisconnecting 事件时,status 的值会发生变化。当接收到新消息时,通过 useReducer 钩子,它们将被追加到消息数组中。

在本章中,我们讨论了一些处理应用程序中异步数据的技术。这是一个非常重要的话题,在接下来的章节中,我们将展示悬念可能会在这一领域带来未来的变革。

第九章:Suspense

这是本书中最不重要的章节。至少,这是 React 团队告诉我们的。他们并没有明确地说,“这是最不重要的章节,不要写它。”他们只是发布了一系列警告教育者和传道者,说他们在这个领域的大部分工作很快就会过时。所有这些都会改变。

可以说,React 团队在 Fiber、Suspense 和并发模式方面的工作代表了 Web 开发的未来。这项工作可能会改变浏览器解释 JavaScript 的方式。听起来相当重要。我们说这是本书中最不重要的章节,因为 Suspense 在社区中的热度很高;我们需要这样说来平衡您的期望。构成 Suspense 的 API 和模式并非定义所有大大小小事务如何运作的单一支配理论。

Suspense 只是一个特性。您可能永远不需要使用它。它被设计来解决 Facebook 在规模上的具体问题。我们并不都面临与 Facebook 相同的问题,因此在将其作为解决方案之前,我们可能需要三思。它们可能会在不必要的地方引入复杂性。而且,所有这些都将会改变。并发模式是一个实验性特性,React 团队已经发出了严厉的警告,不要试图在生产环境中使用它。事实上,大多数涉及 Suspense 的概念都涉及使用钩子。如果您不经常开发自定义钩子,您可能永远不需要了解这些特性。Suspense 的许多机制可以在钩子中进行抽象化。

鉴于这三段话的贬低,本章涵盖的概念是令人兴奋的。如果正确使用,它们可能有助于我们在未来创建更好的用户体验。如果您拥有或维护一个 React 钩子和/或组件库,您可能会发现这些概念很有价值。它们将帮助您调优自定义钩子,以实现更好的反馈和优先级设置。

在本章中,我们将构建另一个小应用程序来演示其中一些特性。本质上,我们将重新构建来自第八章的应用程序,但这次会更有结构。例如,我们将使用一个SiteLayout组件:

export default function SiteLayout({
  children,
  menu = c => null
}) {
  return (
    <div className="site-container">
      <div>{menu}</div>
      <div>{children}</div>
    </div>
  );
}

SiteLayout将在App组件中被渲染,以帮助我们组合我们的 UI:

export default function App() {
  return (
    <SiteLayout menu={<p>Menu</p>}>
      <>
        <Callout>Callout</Callout>
        <h1>Contents</h1>
        <p>This is the main part of the example layout</p>
      </>
    </SiteLayout>
  );
}

此组件将用于为我们的布局增添一些样式,如图 9-1 所示。

具体而言,它将使我们能够清楚地看到特定组件何时何地被渲染。

示例布局

图 9-1。示例布局

错误边界

到目前为止,我们在处理错误方面做得还不够好。在组件树中的任何地方抛出错误都会导致整个应用程序崩溃。较大的组件树进一步复杂化了我们的项目,并增加了调试的复杂性。有时候,很难准确定位错误发生的位置,特别是当它们发生在我们没有编写的组件中时。

错误边界是可以用来防止错误崩溃整个应用程序的组件。它们还允许我们在生产环境中渲染合理的错误消息。由于错误可以由单个组件处理,因此它们有可能跟踪应用程序中的错误并将其报告给问题管理系统。

目前,创建错误边界组件的唯一方法是使用类组件。就像本章的大多数主题一样,这种情况也将会改变。将来,可能会通过钩子或其他不需要创建类的解决方案来创建错误边界。现在,这里有一个ErrorBoundary组件的示例:

import React, { Component } from "react";

export default class ErrorBoundary extends Component {
  state = { error: null };

  static getDerivedStateFromError(error) {
    return { error };
  }

  render() {
    const { error } = this.state;
    const { children, fallback } = this.props;

    if (error) return <fallback error={error} />;
    return children;
  }
}

这是一个类组件。它以不同的方式存储状态,并且不使用钩子。相反,它可以访问在组件生命周期中不同时间调用的特定方法。getDerivedStateFromError就是其中之一。在渲染过程中的任何children中发生错误时,将调用此方法。当发生错误时,将设置state.error的值。如果有错误,将渲染fallback组件,并将该错误作为属性传递给组件。

现在,我们可以在我们的组件树中使用这个组件来捕获错误,并在发生错误时渲染一个fallback组件。例如,我们可以用错误边界包装整个应用程序:

function ErrorScreen({ error }) {
  //
  // Here you can handle or track the error before rendering the message
  //

  return (
    <div className="error">
      <h3>We are sorry... something went wrong</h3>
      <p>We cannot process your request at this moment.</p>
      <p>ERROR: {error.message}</p>
    </div>
  );
}

<ErrorBoundary fallback={ErrorScreen}>
  <App />
</ErrorBoundary>;

ErrorScreen为我们的用户提供了一个友好的消息,说明发生了错误。它渲染了关于错误的一些细节。它还为我们提供了一个可能跟踪整个应用程序中任何地方发生的错误的地方。如果应用程序发生错误,将渲染此组件,而不是黑屏。我们可以通过一些 CSS 使这个组件看起来更漂亮:

.error {
  background-color: #efacac;
  border: double 4px darkred;
  color: darkred;
  padding: 1em;
}

为了测试这个功能,我们将创建一个可以故意引发错误的组件。BreakThings总是会抛出一个错误:

const BreakThings = () => {
  throw new Error("We intentionally broke something");
};

错误边界可以进行组合。当然,我们将App组件包装在ErrorBoundary中,但我们也可以将App中的各个组件单独包装在Error中:

  return (
    <SiteLayout
      menu={
        <ErrorBoundary fallback={ErrorScreen}>
          <p>Site Layout Menu</p>
          <BreakThings />
        </ErrorBoundary>
      }
    >
      <ErrorBoundary fallback={ErrorScreen}>
        <Callout>Callout<BreakThings /></Callout>
      </ErrorBoundary>
      <ErrorBoundary fallback={ErrorScreen}>
        <h1>Contents</h1>
        <p>this is the main part of the example layout</p>
      </ErrorBoundary>
    </SiteLayout>

每个ErrorBoundary在其子组件中的任何地方发生错误时都会渲染一个fallback。在这种情况下,我们在菜单和Callout中使用了BreakThings组件。这将导致渲染ErrorScreen两次,正如我们在图 9-2 中所看到的。

我们可以看到 ErrorBoundaries 被渲染在原位。请注意,已发生的两个错误已被限制在它们的区域内。这些边界就像墙壁一样,阻止这些错误攻击其余的应用程序。尽管故意抛出了两个错误,但内容仍然正常渲染。

错误边界

图 9-2. 错误边界

在 图 9-3 中,我们可以观察当我们将 BreakThings 组件移动到仅包含内容时发生了什么。

error

图 9-3. 错误

现在我们看到菜单和呼叫被渲染出来,但内容渲染了一个错误以通知用户发生了错误。

ErrorBoundary 类组件的 render 方法中,我们可以将 fallback 属性设为可选。当未包含时,我们将简单地使用我们的 ErrorScreen 组件:

render() {
  const { error } = this.state;
  const { children } = this.props;

  if (error && !fallback) return <ErrorScreen error={error} />;
  if (error) return <fallback error={error} />;

  return children;
}

这是处理应用程序中错误的一个好方法。现在,我们只需用 ErrorBoundary 包装组件树的特定部分,让组件处理其余部分:

<ErrorBoundary>
  <h1>&lt;Contents /&gt;</h1>
  <p>this is the main part of the example layout</p>
  <BreakThings />
</ErrorBoundary>

错误边界不仅是一个好主意 —— 它们在生产环境中是必不可少的,它们会防止一些相对不重要的组件中的小错误导致整个应用崩溃。

代码拆分

如果您现在处理的应用程序很小,那么它们很可能不会保持现状。您处理的大多数应用程序最终会包含大量的代码库,可能甚至包含数百,甚至数千个组件。您的大多数用户可能通过手机访问您的应用程序,可能在潜在的慢网络上。他们不能等待应用程序的整个代码库成功下载,然后 React 完成第一次渲染。

代码拆分 为我们提供了一种将代码库拆分为可管理的块,然后根据需要加载这些块的方法。为了展示代码拆分的强大功能,我们将在我们的应用程序中添加一个用户协议屏幕:

export default function Agreement({ onAgree = f => f }) {
  return (
    <div>
      <p>Terms...</p>
      <p>These are the terms and stuff. Do you agree?</p>
      <button onClick={onAgree}>I agree</button>
    </div>
  );
}

接下来,我们将将我们代码库的其余部分从名为 App 的组件移动到名为 Main 的组件,并将该组件放在自己的文件中:

import React from "react";
import ErrorBoundary from "./ErrorBoundary";

const SiteLayout = ({ children, menu = c => null }) => {
  return (
    <div className="site-container">
      <div>{menu}</div>
      <div>{children}</div>
    </div>
  );
};

const Menu = () => (
  <ErrorBoundary>
    <p style={{ color: "white" }}>TODO: Build Menu</p>
  </ErrorBoundary>
);

const Callout = ({ children }) => (
  <ErrorBoundary>
    <div className="callout">{children}</div>
  </ErrorBoundary>
);

export default function Main() {
  return (
    <SiteLayout menu={<Menu />}>
      <Callout>Welcome to the site</Callout>
      <ErrorBoundary>
        <h1>TODO: Home Page</h1>
        <p>Complete the main contents for this home page</p>
      </ErrorBoundary>
    </SiteLayout>
  );
}

因此,Main 是当前网站布局被渲染的地方。现在我们将修改 App 组件,以便在用户同意之前渲染 Agreement,一旦他们同意,我们将卸载 Agreement 组件并渲染 Main 网站组件:

import React, { useState } from "react";
import Agreement from "./Agreement";
import Main from "./Main";
import "./SiteLayout.css";

export default function App() {
  const [agree, setAgree] = useState(false);

  if (!agree)
    return <Agreement onAgree={() => setAgree(true)} />;

  return <Main />;
}

最初,只有 Agreement 组件被渲染出来。一旦用户同意,agree 的值就会变为 true,然后 Main 组件被渲染出来。问题在于,Main 组件及其所有子组件的所有代码都打包到一个 JavaScript 文件中:捆绑包。这意味着用户必须等待这些代码完全下载后,才能最初渲染 Agreement 组件。

我们可以通过使用 React.lazy 声明而不是最初导入来推迟加载主组件直到它已经渲染出来:

const Main = React.lazy(() => import("./Main"));

我们告诉 React 在初始渲染时等待加载Main组件的代码库。当它被渲染时,将使用import函数在那时导入它。

在运行时导入代码就像从互联网加载任何其他内容一样。首先,JavaScript 代码的请求是挂起状态。然后要么成功,返回一个 JavaScript 文件,要么失败,导致错误发生。正如我们需要通知用户我们正在加载数据的过程一样,我们需要让用户知道我们正在加载代码的过程。

介绍:Suspense组件

再次发现自己在处理异步请求的情况下。这次,我们有Suspense组件来帮助我们。Suspense组件的工作方式与ErrorBoundary组件类似。我们将它包裹在树中特定的组件周围。当发生错误时,Suspense组件不会回退到错误消息,而是在延迟加载发生时渲染加载消息。

我们可以修改应用程序,使用以下代码来延迟加载Main组件:

import React, { useState, Suspense, lazy } from "react";
import Agreement from "./Agreement";
import ClimbingBoxLoader from "react-spinners/ClimbingBoxLoader";

const Main = lazy(() => import("./Main"));

export default function App() {
  const [agree, setAgree] = useState(false);

  if (!agree)
    return <Agreement onAgree={() => setAgree(true)} />;

  return (
    <Suspense fallback={<ClimbingBoxLoader />}>
      <Main />
    </Suspense>
  );
}

现在应用程序初始时只加载React的代码库、Agreement组件和ClimbingBoxLoader。React 将推迟加载Main组件的代码库,直到用户同意协议。

Main组件已经被包裹在Suspense组件中。一旦用户同意协议,我们就开始加载Main组件的代码库。因为对该代码库的请求是挂起状态,Suspense组件会在加载成功之前渲染ClimbingBoxLoader。加载成功后,Suspense组件将卸载ClimbingBoxLoader并渲染Main组件。

注意

React Spinners 是一个动画加载旋转器库,用于指示加载或应用程序运行中的状态。在本章的其余部分,我们将从这个库中尝试不同的加载器组件。确保你安装了这个库:npm i react-spinners

如果在尝试加载Main组件之前网络连接断开会发生什么?好吧,我们将面临一个错误。我们可以通过在Suspense组件周围包裹ErrorBoundary来处理这个问题:

<ErrorBoundary fallback={ErrorScreen}>
  <Suspense fallback={<ClimbingBoxLoader />}>
    <Main />
  </Suspense>
</ErrorBoundary>

这三个组件的组合为我们处理大多数异步请求提供了一种方法。对于挂起状态,Suspense组件将在源代码请求挂起时渲染加载动画。对于失败状态,如果在加载Main组件时发生错误,它将被ErrorBoundary捕获和处理。甚至对于成功状态,如果请求成功,我们将渲染Main组件。

使用 Suspense 处理数据

在上一章中,我们构建了一个useFetch钩子和一个Fetch组件来帮助我们处理在进行 GitHub 请求时涉及的三种状态:挂起、成功和失败。那是我们的解决方案。我们觉得它相当酷。然而,在最后一节中,我们通过优雅地组合ErrorBoundarySuspense组件来处理这三种状态。那是为了延迟加载 JavaScript 源代码,但我们可以使用相同的模式来帮助我们加载数据。

假设我们有一个能够渲染某种状态消息的Status组件:

import React from "react";

const loadStatus = () => "success - ready";

function Status() {
  const status = loadStatus();
  return <h1>status: {status}</h1>;
}

这个组件调用loadStatus函数来获取当前的状态消息。我们可以在App组件中渲染Status组件:

export default function App() {
  return (
    <ErrorBoundary>
      <Status />
    </ErrorBoundary>
  );
}

如果我们按原样运行这段代码,我们将看到我们的成功状态消息,如 Figure 9-4 所示。

成功:一切正常

Figure 9-4. 成功:一切正常

当我们在App组件中渲染Status组件时,我们是优秀的 React 开发者,因为我们将Status组件包装在错误边界内部。现在如果在加载状态时出现问题,ErrorBoundary将退回到默认的错误界面。为了演示这一点,让我们在loadStatus函数内部引发一个错误:

const loadStatus = () => {
  throw new Error("something went wrong");
};

现在当我们运行我们的应用程序时,我们看到了预期的输出。ErrorBoundary捕获了我们的错误,并向用户呈现了一条消息(Figure 9-5)。

失败:错误边界触发

Figure 9-5. 失败:错误边界触发

到目前为止,一切都按预期工作。我们将Status组件组合在ErrorBoundary内部,这两个组件的结合处理了三种 Promise 状态中的两种:成功或被拒绝。“被拒绝”是一个表示失败或错误状态的官方 Promise 术语。

我们已经涵盖了三种状态中的两种。第三种状态呢?挂起?这种状态可以通过抛出 Promise 来触发:

const loadStatus = () => {
  throw new Promise(resolves => null);
};

如果我们从loadStatus函数中抛出一个 Promise,我们将在浏览器中看到一种特殊类型的错误(Figure 9-6)。

这个错误告诉我们触发了一个挂起状态,但是树中某个地方没有配置Suspense组件。当我们从 React 应用程序中抛出 Promise 时,我们需要一个Suspense组件来处理渲染一个回退界面:

export default function App() {
  return (
    <Suspense fallback={<GridLoader />}>
      <ErrorBoundary>
        <Status />
      </ErrorBoundary>
    </Suspense>
  );
}

抛出 Promise

Figure 9-6. 抛出 Promise

现在我们有了正确的组件组合来处理所有三种状态。loadStatus函数仍然在抛出一个 Promise,但是现在树的更高级别配置了一个Suspense组件来处理它。当我们抛出 Promise 时,我们告诉 React 我们正在等待一个挂起的 Promise。React 通过渲染回退的GridLoader组件来响应(Figure 9-7)。

网格加载器

Figure 9-7. 网格加载器

loadStatus 成功返回结果时,我们将按计划渲染 Status 组件。如果出现问题(如果 loadStatus 抛出错误),我们将通过 ErrorBoundary 进行处理。当 loadStatus 抛出一个 promise 时,我们会触发挂起状态,由 Suspense 组件处理。

这是一个非常酷的模式,但等等……你说的“抛出一个 promise”是什么意思?

抛出 Promises

在 JavaScript 中,throw 关键字在技术上是用于错误的。你可能在自己的代码中多次使用过它:

throw new Error("inspecting errors");

这行代码会导致一个错误。当这个错误未被处理时,它会导致整个应用崩溃,正如 图 9-8 中所演示的。

抛出一个错误

图 9-8. 抛出一个错误

在浏览器中看到的错误屏幕是 Create React App 的开发模式特性。当你处于开发模式时,未处理的错误会被捕获并直接显示在屏幕上。如果你通过点击右上角的“X”关闭此屏幕,你将看到当出现错误时你的生产用户看到的:什么都没有,一个空白的白屏。

未处理的错误始终会在控制台可见。我们在控制台看到的所有红色文本都是关于我们抛出的错误的信息。

JavaScript 是一门非常自由的语言。它允许我们做很多传统类型语言无法做到的事情。例如,在 JavaScript 中,我们可以抛出任何类型:

throw "inspecting errors";

在这里,我们抛出了一个字符串。浏览器会告诉我们发生了未捕获的情况,但这不是一个错误(见 图 9-9)。

网格加载器

图 9-9. GridLoader

这一次,当我们抛出一个字符串时,Create React App 错误屏幕没有在浏览器内渲染。React 知道错误和字符串之间的区别。

JavaScript 允许我们抛出任何类型,这意味着我们可以抛出一个 promise:

throw new Promise(resolves => null);

现在浏览器告诉我们发生了未捕获的情况。这不是一个错误,而是一个 promise,如 图 9-10 所示。

抛出一个 promise

图 9-10. 抛出一个 promise

要在 React 组件树中抛出一个 promise,我们首先要在 loadStatus 函数中这样做:

const loadStatus = () => {
  console.log("load status");
  throw new Promise(resolves => setTimeout(resolves, 3000));
};

如果我们在 React 组件内使用这个 loadStatus 函数,会抛出一个 promise,然后在树的更高层被 Suspense 组件捕获。没错,JavaScript 允许我们抛出任何类型,这也意味着我们可以捕获任何类型。

考虑以下例子:

safe(loadStatus);

function safe(fn) {
  try {
    fn();
  } catch (error) {
    if (error instanceof Promise) {
      error.then(() => safe(fn));
    } else {
      throw error;
    }
  }
}

我们将 loadStatus 函数发送给一个 safe 函数,这使得 safe 成为一个高阶函数。loadStatussafe 函数的作用域内成为 fnsafe 函数尝试调用作为参数传递的 fn。在这种情况下,safe 尝试调用 loadStatus。当它这样做时,loadStatus 抛出一个 promise,即三秒钟的延迟。该 promise 立即被捕获,并在 catch 块的作用域内成为 error。我们可以检查 error 是否是一个 promise,在这种情况下它是。现在我们可以等待该 promise 解决,然后尝试再次用相同的 loadStatus 函数调用 safe

当我们使用一个创建导致三秒延迟的 promise 的函数递归调用 safe 函数时,我们期望会发生什么?我们得到了一个延迟循环,如 图 9-11 所示。

一个不幸的循环

图 9-11. 一个不幸的循环

safe 函数被调用,promise 被捕获,我们等待三秒钟直到 promise 解决,然后再次用相同的函数调用 safe,循环重新开始。每三秒钟,字符串“load status”被打印到控制台。你能观察到这种情况发生的次数,这取决于你的耐心。

我们制作这个无限递归循环并不是为了测试你的耐心;我们是为了证明一个观点。看看当我们将这个新的 loadStatus 函数与之前的 Status 组件一起使用时会发生什么:

const loadStatus = () => {
  console.log("load status");
  throw new Promise(resolves => setTimeout(resolves, 3000));
};

function Status() {
  const status = loadStatus();
  return <h1>status: {status}</h1>;
}

export default function App() {
  return (
    <Suspense fallback={<GridLoader />}>
      <ErrorBoundary>
        <Status />
      </ErrorBoundary>
    </Suspense>
  );
}

因为 loadStatus 抛出了一个 promise,所以 GridLoader 动画会在屏幕上渲染。当你查看控制台时,结果再次测试你的耐心(图 9-12)。

悬念递归

图 9-12. 悬念递归

我们看到与 safe 函数相同的模式。Suspense 组件知道 promise 被抛出了。它将渲染 fallback 组件。然后 Suspense 组件等待抛出的 promise 被解决,就像 safe 函数所做的那样。一旦解决,Suspense 组件重新渲染 Status 组件。当 Status 再次渲染时,它调用 loadStatus,整个过程重复。我们看到“load status”每三秒钟无休止地被打印到控制台。

通常不期望出现无限循环。对于 React 也是如此。重要的是要知道,当我们抛出一个 promise 时,它会被 Suspense 组件捕获,并且我们进入 pending 状态,直到 promise 被解决。

构建悬念数据源

悬念数据源需要提供一个处理与加载数据相关的所有状态的函数:pending(进行中)、success(成功)和 error(错误)。loadStatus 函数每次只能返回或抛出一种类型。当数据正在加载时,我们需要 loadStatus 函数抛出一个 promise,当数据成功时返回一个 response,或者在出现问题时抛出错误:

function loadStatus() {
  if (error) throw error;
  if (response) return response;
  throw promise;
}

我们需要一个地方声明 errorresponsepromise。我们还需要确保这些变量的作用域适当,并且不会与其他请求发生冲突。解决方案是使用闭包定义 loadStatus

const loadStatus = (function() {
  let error, promise, response;

  return function() {
    if (error) throw error;
    if (response) return response;
    throw promise;
  };
})();

这是一个闭包。errorpromiseresponse 的作用域从定义它们的函数外部封闭。当我们声明 loadStatus 时,一个匿名函数被声明并立即调用:fn() 等同于 (fn)()loadStatus 的值成为返回的内部函数。现在,loadStatus 函数可以访问 errorpromiseresponse,但是我们 JavaScript 世界的其余部分无法访问它们。

现在,我们只需要处理 errorresponsepromise 的值。承诺将在三秒钟内挂起,然后成功解决。承诺解决时,response 的值将设置为“success”。我们将捕获任何错误或承诺拒绝,并使用它们来设置 error 值:

const loadStatus = (function() {
  let error, response;
  const promise = new Promise(resolves =>
    setTimeout(resolves, 3000)
  )
    .then(() => (response = "success"))
    .catch(e => (error = e));
  return function() {
    if (error) throw error;
    if (response) return response;
    throw pending;
  };
})();

我们创建了一个三秒钟等待的承诺。如果在此期间的任何时候调用 loadStatus 函数,承诺本身将被抛出。三秒后,承诺成功解决,response 被分配一个值。如果现在调用 loadStatus,它将返回响应:“success”。如果出现问题,loadStatus 函数将返回 error

loadStatus 函数是我们悬念数据源。它能够与悬念架构通信。loadStatus 的内部工作是硬编码的。它始终解析相同的三秒延迟承诺。然而,处理 errorresponsepromise 的机制是可重复的。我们可以用这种技术包装任何承诺来生成悬念数据源。

创建悬念数据源所需的全部是一个承诺,因此我们可以创建一个函数,将一个承诺作为参数,并返回一个悬念数据源。在这个例子中,我们称之为 createResource 函数:

const resource = createResource(promise);
const result = resource.read();

此代码假设 createResource(promise) 将成功创建一个 resource 对象。该对象有一个 read 函数,我们可以随意调用 read 函数。当承诺解决时,read 将返回结果数据。当承诺挂起时,read 将抛出 promise。如果出现任何问题,read 将抛出错误。这个数据源准备好与悬念一起工作。

createResource 函数看起来很像我们之前的匿名函数:

function createResource(pending) {
  let error, response;
  pending.then(r => (response = r)).catch(e => (error = e));
  return {
    read() {
      if (error) throw error;
      if (response) return response;
      throw pending;
    }
  };
}

此函数仍然封闭了 errorresponse 的值,但允许消费者传入一个名为 pending 的承诺作为参数。当挂起的承诺解决时,我们使用 .then 函数捕获结果。如果承诺被拒绝,我们将捕获错误并用它来为 error 变量分配一个值。

createResource 函数返回一个资源对象。该对象包含一个名为 read 的函数。如果 promise 仍在等待中,则 errorresponse 将为未定义状态。因此 read 会抛出 promise。在 error 有值时调用 read 会导致该 error 被抛出。最后,在有响应时调用 read 将会返回 promise 解析的任何数据。无论我们调用 read 多少次,它都能准确报告我们 promise 的状态。

为了在组件中进行测试,我们需要一个 promise,最好是听起来像 80 年代滑雪电影名称的一个 promise:

const threeSecondsToGnar = new Promise(resolves =>
  setTimeout(() => resolves({ gnar: "gnarly!" }), 3000)
);

threeSecondsToGnar promise 在解析前会等待三秒钟,然后返回一个具有 gnar 字段和值的对象。让我们使用这个 promise 创建一个 Suspenseful 数据资源,并在一个小的 React 应用程序中使用该数据资源:

const resource = createResource(threeSecondsToGnar);

function Gnar() {
  const result = resource.read();
  return <h1>Gnar: {result.gnar}</h1>;
}

export default function App() {
  return (
    <Suspense fallback={<GridLoader />}>
      <ErrorBoundary>
        <Gnar />
      </ErrorBoundary>
    </Suspense>
  );
}

React 组件可以渲染很多内容。在 Gnar 组件实际返回响应之前,会多次进行渲染。每次渲染 Gnar 时,都会调用 resource.read()。第一次渲染 Gnar 时,会抛出一个 promise。该 promise 由 Suspense 组件处理,会渲染一个 fallback 组件。

当 promise 解析后,Suspense 组件将尝试再次渲染 GnarGnar 将再次调用 resource.read(),但这次假设一切顺利,resource.read() 将成功返回 Gnar,用于在 h1 元素中呈现 Gnar 的状态。如果出现问题,resource.read() 将会抛出一个错误,该错误将由 ErrorBoundary 处理。

正如你可以想象的那样,createResource 函数可以变得非常强大。我们的资源可以尝试处理错误。也许在网络错误时,资源可以等待几秒钟,然后自动尝试重新加载数据。我们的资源可以与其他资源进行通信。也许我们可以记录所有资源背后的性能统计数据。天空是极限。只要我们有一个函数可以用来读取资源当前状态,我们可以随心所欲地处理资源本身。

目前,这就是 Suspense 的工作方式。这是我们可以使用 Suspense 组件处理任何类型的异步资源的方法。这一切可能会发生变化,而且我们期望它会改变。然而,无论最终的 Suspense API 是什么样子,它肯定会处理三种状态:pending(等待)、success(成功)和 fail(失败)。

对这些 Suspense API 的讨论比较高级,这是有意为之的,因为这些东西还在试验阶段。它会变化。从本章中要记住的重点是,React 总是在尝试各种方法来使 React 应用更快。

在 React 本身工作的背后,有很多这样的工作——特别是其称为 Fiber 的调和算法。

Fiber

在这本书中,我们把 React 组件称为返回 UI 数据的函数。每当这些数据改变(props、state、远程数据等),我们依赖 React 重新渲染组件。如果我们点击星星来评价一个颜色,我们假设我们的 UI 会改变,而且会发生得很快。我们之所以这样假设,是因为我们信任 React 能够实现这一点。不过,这究竟是如何工作的呢?为了了解 React 如何高效地更新 DOM,让我们仔细看看 React 的工作原理。

想象一下,你正在为公司的博客写一篇文章。你希望得到反馈,所以你在发布之前将文章发送给同事。他们建议做一些快速的更改,现在你需要将这些更改整合进来。你创建了一个全新的文档,从头开始键入整篇文章,然后加入这些编辑内容。

你可能会因为这种不必要的额外工作而叹气,但这是许多库以前的工作方式。为了进行更新,我们会摆脱一切,然后从头开始重建 DOM。

现在,你又在写一篇博客文章,并再次把它发送给你的同事。这一次,你现代化了你的文章撰写过程,采用了 GitHub。你的同事检出了一个 GitHub 分支,进行了更改,并在完成后合并了该分支。更快更高效。

这个过程类似于 React 的工作原理。当发生变化时,React 会将组件树复制为 JavaScript 对象。它寻找需要更改的树的部分,并仅更改这些部分。完成后,复制的树(称为工作中的树)取代现有的树。重申一下,它使用的是树中已经存在的部分。例如,如果我们需要将列表中的项目从 red 更新为 green

<ul>
  <li>blue</li>
  <li>purple</li>
  <li>red</li>
</ul>

React 不会摆脱第三个 li,而是将其子节点(red 文本)替换为 green 文本。这是一种高效的更新方式,也是 React 自从诞生以来更新 DOM 的方式。然而,这里存在一个潜在的问题。更新 DOM 是一项昂贵的任务,因为它是同步的。我们必须等待所有更新完成并渲染后,才能在主线程上执行其他任务。换句话说,我们必须等待 React 递归地遍历所有更新,这可能会导致用户体验显得不够响应。

React 团队的解决方案是对 React 协调算法的全面重写,称为 Fiber。Fiber 在 16.0 版本中发布,通过采用更加异步的方式改写了 DOM 更新的工作方式。16.0 的第一个变化是将渲染器和协调器分离开来。渲染器是处理渲染的库的一部分,而协调器则是管理更新的一部分。

将渲染器与协调器分离是一件大事。协调算法保留在 React 核心中(安装以使用 React 的软件包),每个渲染目标负责自己的渲染。换句话说,ReactDOM、React Native、React 360 等都将负责渲染逻辑,并可以插入到 React 核心的协调算法中。

React Fiber 的另一个重大变化是其对协调算法的改变。还记得我们那些阻塞主线程的昂贵 DOM 更新吗?这些长时间的更新称为work —— 使用 Fiber 后,React 将工作分解为称为fibers的更小工作单元。一个 fiber 是一个 JavaScript 对象,用于跟踪其正在协调的内容及其更新周期中的位置。

一旦一个 fiber(工作单元)完成,React 就会与主线程通信,以确保没有重要的事情要处理。如果有重要的工作要处理,React 将控制权交给主线程。完成重要工作后,React 将继续其更新。如果主线程没有需要跳转的重要工作,React 将继续处理下一个工作单元,并将这些更改渲染到 DOM 中。

以先前的 GitHub 示例为例,每个 fiber 代表分支上的一个提交,当我们将分支检入主分支时,这表示更新的 DOM 树。通过将更新工作分解为块,Fiber 允许优先任务立即由主线程处理。其结果是用户体验更为响应。

如果只有这些,Fiber 已经是一项成功的工作了,但事实并非如此!除了将工作分解为更小单元的性能优势外,重写还为未来设定了令人兴奋的可能性。Fiber 提供了优先处理更新的基础设施。从长远来看,开发者甚至可以调整默认设置,并决定哪些类型的任务应该优先处理。对工作单元进行优先处理的过程称为scheduling;这一概念为实验性的并发模式打下了基础,最终将允许这些工作单元并行执行。

对于在生产环境中使用 React,了解 Fiber 并非至关重要,但重写其协调算法提供了有趣的洞察力,深入了解 React 的工作原理以及其贡献者对未来的思考。

第十章:React 测试

为了跟上我们的竞争对手,我们必须在确保质量的同时快速前进。一个至关重要的工具是单元测试。单元测试使我们能够验证我们应用程序的每个部分或单元是否按预期功能。^(1)

实践函数式技术的一个好处是它们适合编写可测试的代码。纯函数天然适合测试。不可变性易于测试。将应用程序组合成设计用于特定任务的小函数可以生成可测试的函数或代码单元。

在这一节中,我们将演示可以用来单元测试 React 应用程序的技术。这一章不仅涵盖了测试,还包括可以用来帮助评估和改进代码及测试的工具。

ESLint

在大多数编程语言中,在运行任何东西之前,代码需要编译。编程语言对编码风格有严格的规则,并且在代码格式化合适之前不会编译。JavaScript 没有这些规则,也没有编译器。我们编写代码,跨越手指,然后在浏览器中运行它,看它是否工作。好消息是,有工具可以用来分析我们的代码,并使我们坚持特定的格式指南。

分析 JavaScript 代码的过程称为hintinglinting。JSHint 和 JSLint 是最初用于分析 JavaScript 并提供关于格式的反馈的工具。ESLint 是支持新兴 JavaScript 语法的最新代码检查工具。此外,ESLint 是可插拔的。这意味着我们可以创建并分享可以添加到 ESLint 配置中以扩展其功能的插件。

在 Create React App 中,ESLint 受到原生支持,我们已经看到 lint 警告和错误出现在控制台中。

我们将使用一个名为 eslint-plugin-react 的插件。此插件将分析我们的 JSX 和 React 语法,以及我们的 JavaScript。

让我们将 eslint 安装为开发依赖项。我们可以通过 npm 安装 eslint

npm install eslint --save-dev

# or

yarn add eslint --dev

在使用 ESLint 之前,我们需要定义一些配置规则,以便我们同意遵循这些规则。我们将这些规则定义在项目根目录下的配置文件中。这个文件可以格式化为 JSON 或 YAML。YAML 是一种数据序列化格式,类似于 JSON,但语法更简单,对人类更友好。

ESLint 包含一个工具,帮助我们设置配置。有几家公司已经创建了 ESLint 配置文件,我们可以用作起点,或者我们可以创建自己的配置文件。

我们可以通过运行 eslint --init 并回答关于我们编码风格的一些问题来创建一个 ESLint 配置:

npx eslint --init

How would you like to configure ESLint?
To check syntax and find problems

What type of modules does your project use?
JavaScript modules (import/export)

Which framework does your project use?
React

Does your project use TypeScript?
N

Where does your code run? (Press space to select, a to toggle all,
i to invert selection)
Browser

What format do you want your config file to be in?
JSON

Would you like to install them now with npm?
Y

运行 npx eslint --init 后,会发生三件事情:

  1. eslint-plugin-react 被安装在本地的 ./node_modules 文件夹中。

  2. 这些依赖项会自动添加到 package.json 文件中。

  3. 一个配置文件 .eslintrc.json 被创建并添加到我们项目的根目录中。

如果我们打开 .eslintrc.json,我们会看到一个设置对象:

{
  "env": {
    "browser": true,
    "es6": true
  },
  "extends": [
    "eslint:recommended",
    "plugin:react/recommended"
  ],
  "globals": {
    "Atomics": "readonly",
    "SharedArrayBuffer": "readonly"
  },
  "parserOptions": {
    "ecmaFeatures": {
      "jsx": true
    },
    "ecmaVersion": 2018,
    "sourceType": "module"
  },
  "plugins": ["react"],
  "rules": {}
}

重要的是,如果查看 extends 键,我们会看到我们的 --init 命令初始化了 eslintreact 的默认配置。这意味着我们不必手动配置所有规则,而是提供给我们这些规则。

让我们通过创建一个 sample.js 文件来测试我们的 ESLint 配置和这些规则:

const gnar = "gnarly";

const info = ({
  file = __filename,
  dir = __dirname
}) => (
  <p>
    {dir}: {file}
  </p>
);

switch (gnar) {
  default:
    console.log("gnarly");
    break;
}

这个文件有一些问题,但不会导致浏览器出错。从技术上讲,这段代码完全可以运行。让我们在这个文件上运行 ESLint,看看基于我们自定义规则得到的反馈:

npx eslint sample.js

3:7 error 'info' is assigned a value but never used no-unused-vars
4:3 error 'file' is missing in props validation react/prop-types
4:10 error 'filename' is not defined no-undef
5:3 error 'dir' is missing in props validation react/prop-types
5:9 error 'dirname' is not defined no-undef
7:3 error 'React' must be in scope when using JSX react/react-in-jsx-scope

✖ 6 problems (6 errors, 0 warnings)

ESLint 已经对我们的代码进行了静态分析,并根据我们的配置选择报告了一些问题。关于属性验证有错误,ESLint 还抱怨 __filename__dirname,因为它不会自动包含 Node.js 全局对象。最后,ESLint 的默认 React 警告告诉我们,在使用 JSX 时,必须在作用域中包含 React。

命令 eslint . 将会检查我们整个目录。为此,我们很可能需要让 ESLint 忽略一些 JavaScript 文件。.eslintignore 文件就是我们可以添加文件或目录让 ESLint 忽略的地方:

dist/assets/
sample.js

这个 .eslintignore 文件告诉 ESLint 忽略我们的新 sample.js 文件,以及 dist/assets 文件夹中的任何内容。如果不忽略 assets 文件夹,ESLint 将分析客户端 bundle.js 文件,并且很可能在该文件中找到许多问题。

让我们在 package.json 文件中为运行 ESLint 添加一个脚本:

{
  "scripts": {
    "lint": "eslint ."
  }
}

现在,我们可以随时使用 npm run lint 运行 ESLint,并且它将分析我们项目中除了被忽略的文件之外的所有文件。

ESLint 插件

有很多插件可以添加到你的 ESLint 配置中,以帮助你编写代码。对于 React 项目,你肯定会想要安装 eslint-plugin-react-hooks,这是一个用于强制执行 React Hooks 规则的插件。这个包是由 React 团队发布的,旨在帮助修复与 Hooks 使用相关的 bug。

首先要安装它:

npm install eslint-plugin-react-hooks --save-dev

# OR

yarn add eslint-plugin-react-hooks --dev

然后,打开 .eslintrc.json 文件,并添加以下内容:

{
  "plugins": [
    // ...
    "react-hooks"
  ],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}

这个插件将检查以“use”开头的函数(假设是一个 hook)是否遵循了 Hooks 的规则。

一旦添加了这些内容,我们将编写一些示例代码来测试这个插件。调整 sample.js 中的代码。即使这段代码无法运行,我们也在测试插件是否正常工作:

function gnar() {
  const [nickname, setNickname] = useState(
    "dude"
  );
  return <h1>gnarly</h1>;
}

从这段代码中将会弹出几个错误,但最重要的是,有一个错误告诉我们,在不是组件或者 hook 的函数中尝试调用 useState

4:35 error React Hook "useState" is called in function "gnar" that is neither
a React function component nor a custom React Hook function
react-hooks/rules-of-hooks

在我们学习如何使用 Hooks 的过程中,这些示例将帮助我们。

另一个有用的 ESLint 插件是 eslint-plugin-jsx-a11y。A11y 是一个数字符号,意味着在“a”和“y”之间有 11 个字母,用于表示可访问性。当我们考虑无障碍性时,我们构建工具、网站和技术,可以被残障人士使用。

此插件将分析你的代码,并确保它不违反任何无障碍规则。无障碍应该是我们所有人关注的一个领域,使用这个插件将促进编写无障碍 React 应用程序的良好实践。

要安装,我们将再次使用 npm 或 yarn:

npm install eslint-plugin-jsx-a11y

// or

yarn add eslint-plugin-jsx-a11y

接下来我们将在我们的配置文件中添加 .eslintrc.json

{
  "extends": [
    // ...
    "plugin:jsx-a11y/recommended"
  ],
  "plugins": [
    // ...
    "jsx-a11y"
  ]
}

现在让我们来测试一下。我们将调整我们的 sample.js 文件,添加一个没有 alt 属性的图像标签。为了使图像通过 lint 检查,它必须具有 alt 属性,或者如果图像不影响用户对内容的理解,则为空字符串:

function Image() {
  return <img src="/img.png" />;
}

如果我们再次运行 npm run lint 进行 lint,我们会看到一个由 jsx/a11y 插件引发的新错误:

5:10 error img elements must have an alt prop, either with meaningful text,
or an empty string for decorative images

还有许多其他 ESLint 插件可以用来静态分析你的代码,你可以花费几周的时间调整你的 ESLint 配置,使其达到完美。如果你想把你的配置提升到下一个水平,可以在 Awesome ESLint 仓库 中找到许多有用的资源。

Prettier

Prettier 是一款具有意见的代码格式化工具,适用于各种项目。自发布以来,Prettier 对 Web 开发人员日常工作的影响非常显著。根据历史记录,争论语法占据了平均 JavaScript 开发人员日常工作时间的 87%,但现在 Prettier 负责代码格式化,并定义了每个项目应使用的代码语法规则。节省的时间是显著的。此外,如果你曾经在 Markdown 表格上使用过 Prettier,你会看到它快速、清晰的格式化效果,真的是一种难以置信的景象。

ESLint 曾负责许多项目的代码格式化,但现在责任已经明确划分。ESLint 处理代码质量问题,而 Prettier 负责代码格式化。

要使 Prettier 与 ESLint 协作,我们需要稍微调整项目的配置。你可以全局安装 Prettier 来开始使用:

sudo npm install -g prettier

现在你可以在任何项目中使用 Prettier。

按项目配置 Prettier

要向项目添加 Prettier 配置文件,你可以创建一个 .prettierrc 文件。该文件将描述项目的默认设置:

{
  "semi": true,
  "trailingComma": none,
  "singleQuote": false,
  "printWidth": 80
}

这些是我们首选的默认设置,但当然,选择最合理的选项。有关更多 Prettier 格式化选项,请查看 Prettier 文档

让我们用一些代码来替换当前 sample.js 文件中的内容,以便进行格式化:

console.log("Prettier Test")

现在让我们尝试从终端或命令提示符中运行 Prettier CLI:

prettier --check "sample.js"

Prettier 运行测试并显示以下消息:在上述文件中找到了代码样式问题。忘记运行 Prettier? 要从 CLI 运行它,我们可以传递 write 标志:

prettier --write "sample.js"

一旦我们这样做了,我们会看到一个输出,显示 Prettier 格式化文件所花费的某些毫秒数。如果我们打开文件,我们会看到根据 .prettierrc 文件中提供的默认值内容已经改变了。如果你觉得这个过程看起来很费力且可以加快速度,那么你是对的。让我们开始自动化吧!

首先,我们将通过安装配置工具和插件集成 ESLint 和 Prettier:

npm install eslint-config-prettier eslint-plugin-prettier --save-dev

配置 (eslint-config-prettier) 关闭任何可能与 Prettier 冲突的 ESLint 规则。插件 (eslint-plugin-prettier) 将 Prettier 规则整合到 ESLint 规则中。换句话说,当我们运行我们的 lint 脚本时,Prettier 也会运行。

我们将这些工具整合到 .eslintrc.json 中:

{
  "extends": [
    // ...
    "plugin:prettier/recommended"
  ],
  "plugins": [
    //,
  "prettier"],
  "rules": {
    // ...
    "prettier/prettier": "error"
  }
}

确保在你的代码中打破一些格式规则,以确保 Prettier 正常工作。例如,在 sample.js 中:

console.log("Prettier Test");

运行 lint 命令 npm run lint 将产生以下输出:

1:13 error Replace `'Prettier·Test')` with `"Prettier·Test");` prettier/prettier

所有的错误都被找到了。现在你可以运行 Prettier 写命令,并为一个文件进行格式化:

prettier --write "sample.js"

或者在特定文件夹中的所有 JavaScript 文件中:

prettier --write "src/*.js"

VSCode 中的 Prettier

如果你在使用 VSCode,强烈建议在你的编辑器中设置 Prettier。配置非常快速,并且在编写代码时将为你节省大量时间。

你首先需要安装 VSCode 的 Prettier 扩展。只需按照 此链接 并点击安装。安装完成后,你可以使用 Mac 上的 Control + Command + P 或 PC 上的 Ctrl + Shift + P 手动格式化文件或突出显示的代码片段。为了获得更好的结果,你可以在保存时格式化你的代码。这需要在 VSCode 中添加一些设置。

要访问这些设置,请选择 Code 菜单,然后选择 Preferences,然后选择 Settings。(如果你着急的话,可以在 Mac 上使用 Command + 逗号或在 PC 上使用 Ctrl + 逗号。)然后你可以点击右上角的小纸片图标打开 VSCode 的 JSON 设置。在这里,你需要添加一些有用的键:

{
  "editor.formatOnSave": true
}

现在,当你保存任何文件时,Prettier 将根据 .prettierrc 的默认设置格式化它!相当不错。如果你希望强制执行格式化,即使你的项目中没有 .prettierrc 配置文件,你也可以在设置中搜索 Prettier 选项来设置默认值。

如果你使用不同的编辑器,Prettier 也可能支持它。有关其他代码编辑器的具体说明,请查看文档中的编辑器集成部分

React 应用程序的类型检查

当您处理较大的应用程序时,可能希望包含类型检查以帮助精确定位某些类型的错误。在 React 应用程序中,有三种主要的类型检查解决方案:prop-types库、Flow 和 TypeScript。在下一节中,我们将更详细地看看如何设置这些工具以提高代码质量。

PropTypes

在本书的第一版中,PropTypes 是核心 React 库的一部分,并且是向应用程序添加类型检查的推荐方式。如今,由于 Flow 和 TypeScript 等其他解决方案的出现,该功能已移至自己的库中,以减小 React 的捆绑包大小。尽管如此,PropTypes 仍然是广泛使用的解决方案。

要将 PropTypes 添加到您的应用程序中,请安装prop-types库:

npm install prop-types --save-dev

我们将通过创建一个最小的App组件来测试这一点,该组件将渲染一个库的名称:

import React from "react";
import ReactDOM from "react-dom";

function App({ name }) {
  return (
    <div>
      <h1>{name}</h1>
    </div>
  );
}

ReactDOM.render(
  <App name="React" />,
  document.getElementById("root")
);

然后我们将导入prop-types库,并使用App.propTypes来定义每个属性的类型:

import PropTypes from "prop-types";

function App({ name }) {
  return (
    <div>
      <h1>{name}</h1>
    </div>
  );
}

App.propTypes = {
  name: PropTypes.string
};

App组件有一个名为name的属性,应始终是一个字符串。如果将不正确类型的值作为名称传递,则会抛出错误。例如,如果我们使用布尔值:

ReactDOM.render(
  <App name="React" />,
  document.getElementById("root")
);

我们的控制台会向我们报告一个问题:

Warning: Failed prop type: Invalid prop name of type boolean supplied to App,
expected string. in App

当为属性提供不正确类型的值时,警告只会在开发模式下出现。在生产环境中,警告和错误渲染不会出现。

当验证属性时,当然还有其他类型可用。例如,我们可以为公司是否使用技术添加一个布尔值:

function App({ name, using }) {
  return (
    <div>
      <h1>{name}</h1>
      <p>
        {using ? "used here" : "not used here"}
      </p>
    </div>
  );
}

App.propTypes = {
  name: PropTypes.string,
  using: PropTypes.bool
};

ReactDOM.render(
  <App name="React" using={true} />,
  document.getElementById("root")
);

较长的类型检查列表包括:

  • PropTypes.array

  • PropTypes.object

  • PropTypes.bool

  • PropTypes.func

  • PropTypes.number

  • PropTypes.string

  • PropTypes.symbol

另外,如果要确保提供了值,可以将.isRequired链接到任何这些选项的末尾。例如,如果必须提供字符串,则会使用:

App.propTypes = {
  name: PropTypes.string.isRequired
};

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

然后,如果未为此字段提供值,将在控制台中显示以下警告:

index.js:1 Warning: Failed prop type: The prop name is marked as required in App,
but its value is undefined.

也许还有一些情况,您不在乎值是什么,只要提供了一个值就行。在这种情况下,您可以使用any。例如:

App.propTypes = {
  name: PropTypes.any.isRequired
};

这意味着可以提供布尔值、字符串、数字——任何东西。只要name不是undefined,类型检查就会成功。

除了基本的类型检查外,还有一些其他实用程序在许多实际情况下都非常有用。考虑一个组件,其中有两个status选项:OpenClosed

function App({ status }) {
  return (
    <div>
      <h1>
        We're {status === "Open" ? "Open!" : "Closed!"}
      </h1>
    </div>
  );
}

ReactDOM.render(
  <App status="Open" />,
  document.getElementById("root")
);

状态是一个字符串,所以我们可能倾向于使用字符串检查:

App.propTypes = {
  status: PropTypes.string.isRequired
};

这样做效果很好,但如果传入的字符串值除了OpenClosed之外还有其他值,则将验证该属性。我们实际希望执行的类型检查是枚举检查。枚举类型是特定字段或属性的受限选项列表。我们将调整propTypes对象如下所示:

App.propTypes = {
  status: PropTypes.oneOf(["Open", "Closed"])
};

现在,如果传递给PropTypes.oneOf的数组之外的任何值,都会出现警告。

对于在 React 应用程序中配置 PropTypes 的所有选项,请查看文档

Flow

Flow 是一个由 Facebook 开源项目使用和维护的类型检查库。它是一个通过静态类型注解来检查错误的工具。换句话说,如果您创建了一个特定类型的变量,Flow 将检查确保使用的值是正确的类型。

让我们启动一个 Create React App 项目:

npx create-react-app in-the-flow

然后我们将 Flow 添加到项目中。Create React App 不假设您想使用 Flow,因此不会随库一起发布,但是将其整合非常顺利:

npm install --save flow-bin

安装完成后,我们将添加一个 npm 脚本,在输入 npm run flow 时运行 Flow。在 package.json 中,只需将以下内容添加到 scripts 键:

{
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "flow": "flow"
  }
}

现在运行 flow 命令将对我们的文件进行类型检查。但在使用之前,我们需要创建一个 .flowconfig 文件。为此,我们运行:

npm run flow init

这将创建一个如下所示的配置文件的框架:

[ignore]

[include]

[libs]

[lints]

[options]

[strict]

在大多数情况下,您会将此留空以使用 Flow 的默认设置。如果您想要超出基础设置的 Flow 配置,请在文档中探索更多选项。

Flow 最酷的功能之一是您可以逐步采用 Flow。必须将整个项目添加类型检查可能会让人感到不知所措。但是使用 Flow,这不是必需的。您只需在要进行类型检查的文件顶部添加一行 //@flow,然后 Flow 将自动仅检查这些文件。

另一种选项是添加 VSCode 扩展程序来帮助处理代码补全和参数提示中的 Flow 语法。如果已设置了 Prettier 或 linting 工具,则这将帮助您的编辑器处理 Flow 的意外语法。您可以在市场中找到它

让我们打开 index.js 文件,为了简单起见,将所有内容保留在同一个文件中。确保在文件顶部添加 //@flow

//@flow

import React from "react";
import ReactDOM from "react-dom";

function App(props) {
  return (
    <div>
      <h1>{props.item}</h1>
    </div>
  );
}

ReactDOM.render(
  <App item="jacket" />,
  document.getElementById("root")
);

现在我们将为属性定义类型:

type Props = {
  item: string
};

function App(props: Props) {
  //...
}

然后运行 Flow npm run flow。在某些版本的 Flow 中,您可能会看到以下警告:

Cannot call ReactDOM.render with root bound to container because null [1] is
incompatible with Element [2]

这个警告的存在是因为如果 document.getElementById("root") 返回 null,应用程序将崩溃。为了防范此类情况(并清除错误),我们可以采取以下两种方法之一。第一种方法是使用 if 语句检查 root 是否不为 null

const root = document.getElementById("root");

if (root !== null) {
  ReactDOM.render(<App item="jacket" />, root);
}

另一种选项是使用 Flow 语法为根常量添加类型检查:

const root = document.getElementById("root");

ReactDOM.render(<App item="jacket" />, root);

无论哪种情况,您都将清除错误并看到您的代码没有错误!

无错误!

我们可以完全信任这个,但尝试打破它感觉是个好主意。让我们向应用程序传递一个不同的属性类型:

ReactDOM.render(<App item={3} />, root);

太棒了,我们搞砸了!现在我们得到一个读取如下的错误:

Cannot create App element because number [1] is incompatible with string [2]
in property item.

让我们切换回去并为数字添加另一个属性。我们还将调整组件和属性定义:

type Props = {
  item: string,
  cost: number
};

function App(props: Props) {
  return (
    <div>
      <h1>{props.item}</h1>
      <p>Cost: {props.cost}</p>
    </div>
  );
}

ReactDOM.render(
  <App item="jacket" cost={249} />,
  root
);

运行这个工作,但如果我们移除了 cost 值会怎么样?

ReactDOM.render(<App item="jacket" />, root);

我们会立即收到一个错误:

Cannot create App element because property cost is missing in props [1] but
exists in Props [2].

如果cost确实不是一个必需的值,我们可以在属性定义中使用问号在属性名称后面,cost?来将其定义为可选的:

type Props = {
  item: string,
  cost?: number
};

如果我们再次运行它,我们就不会看到错误。

这只是 Flow 提供的所有不同功能的冰山一角。要了解更多信息并了解库中的更改,请访问文档站点

TypeScript

TypeScript 是 React 应用程序中的另一个流行的类型检查工具。它是 JavaScript 的开源超集,这意味着它为语言添加了额外的功能。由 Microsoft 创建,TypeScript 旨在用于大型应用程序,帮助开发人员发现错误并更快地迭代项目。

TypeScript 有越来越多的支持者,因此生态系统中的工具不断改进。我们已经熟悉的一个工具是 Create React App,它有一个我们可以使用的 TypeScript 模板。让我们设置一些基本的类型检查,类似于我们之前使用 PropTypes 和 Flow 做的,以了解如何在我们自己的应用程序中开始使用它。

我们将从生成另一个 Create React App 开始,这次使用一些不同的标志:

npx create-react-app my-type --template typescript

现在让我们浏览我们的脚手架项目的功能。请注意,在src目录中,现在文件扩展名是.ts.tsx。我们还会找到一个.tsconfig.json文件,其中包含所有我们的 TypeScript 设置。稍后再详细介绍。

此外,如果您查看package.json文件,会发现列出并安装了与 TypeScript 相关的新依赖项,如库本身和用于 Jest、React、ReactDOM 等的类型定义。任何以@types/开头的依赖项描述了库的类型定义。这意味着库中的函数和方法都有类型,因此我们不必描述库的所有类型。

注意

如果您的项目不包括 TypeScript 功能,可能是使用旧版本的 Create React App。要摆脱这个问题,您可以运行npm uninstall -g create-react-app

让我们尝试将我们的组件从流程课程中放入我们的项目中。只需将以下内容添加到index.ts文件中:

import React from "react";
import ReactDOM from "react-dom";

function App(props) {
  return (
    <div>
      <h1>{props.item}</h1>
    </div>
  );
}

ReactDOM.render(
  <App item="jacket" />,
  document.getElementById("root")
);

如果我们用npm start运行项目,我们应该会看到我们的第一个 TypeScript 错误。这在目前阶段是预期的:

Parameter 'props' implicitly has an 'any' type.

这意味着我们需要为这个App组件添加类型规则。我们将从为流程组件定义类型时一样开始为AppProps类型添加字符串item

type AppProps = {
  item: string;
};

ReactDOM.render(
  <App item="jacket" />,
  document.getElementById("root")
);

然后我们将在组件中引用AppProps

function App(props: AppProps) {
  return (
    <div>
      <h1>{props.item}</h1>
    </div>
  );
}

现在组件将无错误地渲染,如果需要,我们也可以解构props

function App({ item }: AppProps) {
  return (
    <div>
      <h1>{item}</h1>
    </div>
  );
}

我们可以通过将不同类型的值作为item属性的值传递来打破它:

ReactDOM.render(
  <App item={1} />,
  document.getElementById("root")
);

这会立即触发一个错误:

Type 'number' is not assignable to type 'string'.

错误还告诉了我们出问题的确切行数。这在调试过程中非常有用。

TypeScript 不仅有助于属性验证,还可以使用 TypeScript 的类型推断来帮助我们对钩子值进行类型检查。

考虑一个fabricColor的状态值,初始状态为purple。组件可能看起来像这样:

type AppProps = {
  item: string;
};

function App({ item }: AppProps) {
  const [fabricColor, setFabricColor] = useState(
    "purple"
  );
  return (
    <div>
      <h1>
        {fabricColor} {item}
      </h1>
      <button
        onClick={() => setFabricColor("blue")}
      >
        Make the Jacket Blue
      </button>
    </div>
  );
}

注意,我们还没有向类型定义对象添加任何内容。相反,TypeScript 推断fabricColor的类型应与其初始状态的类型匹配。如果我们尝试用数字而不是另一个字符串颜色blue设置fabricColor,将会抛出错误:

<button onClick={() => setFabricColor(3)}>

错误看起来像这样:

Argument of type '3' is not assignable to parameter of type string.

TypeScript 为我们提供了一种相当低成本的类型检查方式。当然,你可以进一步定制,但这应该能让你开始为应用程序添加类型检查。

了解更多关于 TypeScript,请查看官方文档和 GitHub 上惊艳的React+TypeScript Cheatsheets

测试驱动开发

测试驱动开发(TDD)是一种实践,而不是一种技术。这并不意味着你简单地为你的应用程序编写测试。相反,它是让测试驱动开发过程的实践。为了实践 TDD,您应遵循以下步骤:

首先编写测试

这是最关键的一步。你首先在测试中声明你要构建的内容及其应该如何工作。测试的步骤包括红、绿和金。

运行测试并观察它们失败(红色)

在编写代码之前运行测试并观察它们失败。

编写使测试通过所需的最小代码(绿色)

特别专注于使每个测试通过;不要添加超出测试范围之外的任何功能。

重构代码和测试(金色)

一旦测试通过,现在是时候仔细查看您的代码和测试了。尽量以尽可能简单和美观的方式表达您的代码。^(2)

TDD 为我们提供了一种优秀的方式来处理 React 应用程序,特别是在测试 Hooks 时。在实际编写之前,想象一个 Hook 应该如何工作通常更容易。实践 TDD 将使您能够独立于 UI 构建和验证功能或应用程序的整个数据结构。

TDD 与学习

如果您是 TDD 的新手,或者是您正在测试的语言的新手,可能会发现在编写代码之前编写测试有些挑战。这是可以预期的,可以在您熟悉之前先编写代码而不是测试。尝试分批处理:少量代码,少量测试,依此类推。一旦习惯编写测试,编写测试就会更容易。

在本章的其余部分中,我们将编写已存在代码的测试。严格来说,我们并未实践 TDD。但在下一节中,我们将假装我们的代码不存在,以便体验 TDD 工作流程的感觉。

整合 Jest

在我们开始编写测试之前,我们需要选择一个测试框架。你可以使用任何 JavaScript 测试框架为 React 编写测试,但是官方的 React 文档建议使用 Jest,这是一个 JavaScript 测试运行器,让你可以通过 JSDOM 访问 DOM。访问 DOM 是很重要的,因为你想要检查 React 渲染的内容,以确保你的应用程序正常工作。

创建 React 应用和测试

使用 Create React App 初始化的项目已经安装了 jest 包。我们可以创建另一个 Create React App 项目来开始,或者使用现有项目:

npx create-react-app testing

现在我们可以开始考虑使用一个小例子进行测试。我们将在 src 文件夹中创建两个新文件:functions.jsfunctions.test.js。请记住,Jest 已经配置并安装在 Create React App 中,所以你只需开始编写测试即可。在 functions.test.js 中,我们将存根化测试。换句话说,我们将写下我们认为函数应该做的事情。

我们希望我们的函数接受一个值,将其乘以二,并返回它。所以我们将在测试中模拟这个过程。test 函数是 Jest 提供的用于测试单个功能的函数:

functions.test.js

test("Multiplies by two", () => {
  expect();
});

第一个参数 Multiplies by two 是测试名称。第二个参数是包含应该测试的内容的函数,第三个(可选)参数指定超时。默认超时是五秒。

接下来我们要做的是存根化将数字乘以二的函数。这个函数将被称为我们的“系统正在测试的对象”(SUT)。在 functions.js 中创建函数:

export default function timesTwo() {...}

我们将它导出,以便我们可以在测试中使用 SUT。在测试文件中,我们希望导入函数,并使用 expect 来编写断言。在断言中,我们会说,如果我们将 4 传递给 timesTwo 函数,我们期望它应该返回 8:

import { timesTwo } from "./functions";

test("Multiplies by two", () => {
  expect(timesTwo(4)).toBe(8);
});

Jest 的“匹配器”由 expect 函数返回,并用于验证结果。为了测试函数,我们将使用 .toBe 匹配器。这会验证结果对象是否与发送给 .toBe 的参数匹配。

让我们运行测试并使用 npm testnpm run test 观察它们失败。Jest 将提供每个失败的具体细节,包括堆栈跟踪:

FAIL  src/functions.test.js
  ✕ Multiplies by two (5ms)

  ● Multiplies by two

    expect(received).toBe(expected) // Object.is equality

    Expected: 8
    Received: undefined

      2 |
      3 | test("Multiplies by two", () => {
    > 4 |   expect(timesTwo(4)).toBe(8);
        |                       ^
      5 | });
      6 |

      at Object.<anonymous> (src/functions.test.js:4:23)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        1.048s
Ran all test suites related to changed files.

花时间编写测试并运行它们以查看它们失败,这显示了我们的测试按预期工作。这种失败反馈代表了我们的待办事项列表。我们的任务是编写最小量的代码,使我们的测试通过。

现在,如果我们在 functions.js 文件中添加适当的功能,我们就可以让测试通过:

export function timesTwo(a) {
  return a * 2;
}

.toBe 匹配器帮助我们测试单个值的相等性。如果我们想测试对象或数组,我们可以使用 .toEqual。让我们通过测试再进行一次循环。在测试文件中,我们将测试对象数组的相等性。

我们有一个来自 Guy Fieri 在拉斯维加斯餐厅的菜单项目列表。重要的是我们建立一个他们点菜单项的对象,以便顾客可以得到他们想要的并知道他们应该支付多少。我们将首先存根化测试:

test("Build an order object", () => {
  expect();
});

然后我们会存根化我们的函数:

export function order(items) {
  // ...
}

现在我们将在测试文件中使用订单函数。我们还假设我们有一个订单的初始数据列表,我们需要进行转换:

import { timesTwo, order } from "./functions";

const menuItems = [
  {
    id: "1",
    name: "Tatted Up Turkey Burger",
    price: 19.5
  },
  {
    id: "2",
    name: "Lobster Lollipops",
    price: 16.5
  },
  {
    id: "3",
    name: "Motley Que Pulled Pork Sandwich",
    price: 21.5
  },
  {
    id: "4",
    name: "Trash Can Nachos",
    price: 19.5
  }
];

test("Build an order object", () => {
  expect(order(menuItems));
});

记住,我们将使用 toEqual,因为我们正在检查对象的值而不是数组。我们希望结果等于什么?嗯,我们想要创建一个看起来像这样的对象:

const result = {
  orderItems: menuItems,
  total: 77
};

所以我们只需将其添加到测试中并在断言中使用它:

test("Build an order object", () => {
  const result = {
    orderItems: menuItems,
    total: 77
  };
  expect(order(menuItems)).toEqual(result);
});

现在我们将完成 functions.js 文件中的函数:

export function order(items) {
  const total = items.reduce(
    (price, item) => price + item.price,
    0
  );
  return {
    orderItems: items,
    total
  };
}

当我们检查终端时,我们会发现我们的测试现在通过了!现在这可能看起来像一个微不足道的例子,但如果你正在获取数据,很可能你会测试数组和对象的形状匹配。

Jest 中另一个常用的函数是 describe()。如果你使用过其他测试库,你可能以前见过类似的函数。这个函数通常用于包装几个相关的测试。例如,如果我们对一些类似功能进行了几个测试,我们可以将它们包装在一个 describe 语句中:

describe("Math functions", () => {
  test("Multiplies by two", () => {
    expect(timesTwo(4)).toBe(8);
  });
  test("Adds two numbers", () => {
    expect(sum(4, 2)).toBe(6);
  });
  test("Subtracts two numbers", () => {
    expect(subtract(4, 2)).toBe(2);
  });
});

当你用 describe 语句包装测试时,测试运行器会创建一个测试块,这样在终端中的测试输出看起来更加有组织和易读:

Math functions
    ✓ Multiplies by two
    ✓ Adds two numbers
    ✓ Subtracts two numbers (1ms)

随着你写更多的测试,将它们分组在 describe 块中可能是一个有用的增强。

这个过程代表了典型的 TDD 循环。我们先写测试,然后写代码使测试通过。一旦测试通过,我们可以仔细查看代码,看看是否有任何值得重构以提高清晰度或性能的地方。在处理 JavaScript(或者其他任何语言)时,这种方法非常有效。

测试 React 组件

现在我们对编写测试背后的过程有了基本的理解,我们可以开始将这些技术应用于 React 组件测试。

React 组件提供了创建和管理 DOM 更新时,React 需要遵循的指令。我们可以通过渲染它们并检查结果的 DOM 来测试这些组件。

我们不是在浏览器中运行我们的测试;我们是在使用 Node.js 在终端中运行它们。Node.js 没有标准浏览器自带的 DOM API。Jest 包含了一个名为 jsdom 的 npm 包,用于在 Node.js 中模拟浏览器环境,这对于测试 React 组件是至关重要的。

对于每个组件测试,我们可能需要将我们的 React 组件树渲染到一个 DOM 元素中。为了演示这个工作流程,让我们重新审视我们的 Star 组件在 Star.js 中:

import { FaStar } from "react-icons/fa";

export default function Star({ selected = false }) {
  return (
    <FaStar color={selected ? "red" : "grey"} id="star" />
  );
}

然后在 index.js 中,我们将导入并渲染这个 star:

import Star from "./Star";

ReactDOM.render(
  <Star />,
  document.getElementById("root")
);

现在让我们来编写我们的测试。我们已经编写了星星的代码,所以这里我们不会参与 TDD。如果你必须将测试整合到现有的应用程序中,这是你会这样做的方式。在一个名为 Star.test.js 的新文件中,首先导入 React、ReactDOM 和 Star

import React from "react";
import ReactDOM from "react-dom";
import Star from "./Star";

test("renders a star", () => {
  const div = document.createElement("div");
  ReactDOM.render(<Star />, div);
});

我们还要编写测试。记住,我们供给 test 的第一个参数是测试的名称。然后我们将通过创建一个我们可以将星星渲染到其中的 div 来执行一些设置,使用 ReactDOM.render。创建元素后,我们可以编写断言:

test("renders a star", () => {
  const div = document.createElement("div");
  ReactDOM.render(<Star />, div);
  expect(div.querySelector("svg")).toBeTruthy();
});

我们期望,如果我们试图选择创建的 div 内部的 svg 元素,结果将是真的。当我们运行测试时,我们应该看到测试通过了。为了验证我们在不应该得到有效断言时是否不会得到有效断言,我们可以将选择器更改为找到假的东西,观察测试失败:

expect(
  div.querySelector("notrealthing")
).toBeTruthy();

文档提供了关于所有可用自定义匹配器的更多详细信息,以便你可以精确测试你想要测试的内容。

当你生成你的 React 项目时,你可能已经注意到除了像 React 和 ReactDOM 这样的基础包之外,还安装了一些来自 @testing-library 的包。React Testing Library 是由 Kent C. Dodds 发起的项目,旨在推广良好的测试实践,并扩展了 React 生态系统中的测试工具。Testing Library 是一个涵盖了许多库的测试包的大伞——不仅仅是针对 React。

选择 React Testing Library 的一个潜在原因是在测试失败时获得更好的错误消息。当我们测试断言时看到的当前错误:

expect(
  div.querySelector("notrealthing")
).toBeTruthy();

是:

expect(received).toBeTruthy()

Received: null

添加 React Testing Library可以让我们的 Create React App 项目更加强大。我们已经安装好了。首先,我们需要从 @testing-library/jest-dom 中导入 toHaveAttribute 函数:

import { toHaveAttribute } from "@testing-library/jest-dom";

接下来,我们要扩展 expect 的功能,以包含这个函数:

expect.extend({ toHaveAttribute });

现在我们不再使用 toBeTruthy,这将给我们提供难以阅读的消息,我们可以使用 toHaveAttribute

test("renders a star", () => {
  const div = document.createElement("div");
  ReactDOM.render(<Star />, div);
  expect(
    div.querySelector("svg")
  ).toHaveAttribute("id", "hotdog");
});

现在当我们运行测试时,我们会看到一个错误告诉我们具体是什么问题:

    expect(element).toHaveAttribute("id", "hotdog")
    // element.getAttribute("id") === "hotdog"

    Expected the element to have attribute:
      id="hotdog"
    Received:
      id="star"

现在应该很容易修复这个问题了:

expect(div.querySelector("svg")).toHaveAttribute(
  "id",
  "star"
);

使用多个自定义匹配器意味着你需要导入、扩展和使用:

import {
  toHaveAttribute,
  toHaveClass
} from "@testing-library/jest-dom";

expect.extend({ toHaveAttribute, toHaveClass });

expect(you).toHaveClass("evenALittle");

不过,还有一种更快的方法。如果你发现自己导入了太多这些匹配器以至于无法列出或跟踪它们,你可以导入 extend-expect 库:

import "@testing-library/jest-dom/extend-expect";

// Remove this --> expect.extend({ toHaveAttribute, toHaveClass });

断言将继续按预期运行(有点双关意味)。关于 Create React App 的另一个有趣事实是,在随 CRA 一起提供的 setupTests.js 文件中,已经包含了 extend-expect 辅助函数的一行。如果你查看 src 文件夹,你会看到 setupTests.js 包含:

// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import "@testing-library/jest-dom/extend-expect";

所以如果你正在使用 Create React App,你甚至不必在测试文件中包含导入。

查询

查询是 React 测试库的另一个特性,允许您根据特定条件进行匹配。为了演示如何使用查询,让我们调整Star组件以包含一个标题。这将允许我们编写一种常见的测试样式——基于文本匹配的测试:

export default function Star({ selected = false }) {
  return (
    <>
      <h1>Great Star</h1>
      <FaStar
        id="star"
        color={selected ? "red" : "grey"}
      />
    </>
  );
}

让我们暂停一下思考我们要测试的内容。我们希望组件被渲染,现在我们想要测试看看h1是否包含正确的文本。React 测试库的一部分功能render将帮助我们做到这一点。render将替换我们使用ReactDOM.render()的需要,因此测试会有所不同。首先从 React Testing Library 中导入render

import { render } from "@testing-library/react";

render将接受一个参数:我们要渲染的组件或元素。该函数返回一个查询对象,可用于检查该组件或元素中的值。我们将使用的查询是getByText,它将为查询找到第一个匹配的节点,并在没有匹配元素时抛出错误。要返回所有匹配节点的列表,请使用getAllBy返回一个数组:

test("renders an h1", () => {
  const { getByText } = render(<Star />);
  const h1 = getByText(/Great Star/);
  expect(h1).toHaveTextContent("Great Star");
});

getByText通过传递给它的正则表达式找到h1元素。然后我们使用 Jest 的匹配器toHaveTextContent来描述h1应包含的文本。

运行测试,它们会通过。如果我们更改传递给toHaveTextContent()函数的文本,测试将失败。

测试事件

编写测试的另一个重要部分是测试组件的事件。让我们使用和测试我们在第七章中创建的Checkbox组件:

export function Checkbox() {
  const [checked, setChecked] = useReducer(
    checked => !checked,
    false
  );

  return (
    <>
      <label>
        {checked ? "checked" : "not checked"}
        <input
          type="checkbox"
          value={checked}
          onChange={setChecked}
        />
      </label>
    </>
  );
}

这个组件使用useReducer来切换复选框。我们的目标是创建一个自动化测试,点击这个复选框并将checked的值从默认的false更改为true。编写一个检查复选框的测试也会触发useReducer并测试这个钩子。

让我们来桩测试:

import React from "react";

test("Selecting the checkbox should change the value of checked to true", () => {
  // .. write a test
});

我们需要做的第一件事是选择我们想要在其上触发事件的元素。换句话说,我们想在自动化测试中点击哪个元素?我们将使用 Testing Library 的查询之一来找到我们要找的元素。由于输入具有标签,我们可以使用getByLabelText()

import { render } from "@testing-library/react";
import { Checkbox } from "./Checkbox";

test("Selecting the checkbox should change the value of checked to true", () => {
  const { getByLabelText } = render(<Checkbox />);
});

当组件首次渲染时,其标签文本为not checked,因此我们可以通过正则表达式搜索匹配该字符串:

test("Selecting the checkbox should change the value of checked to true", () => {
  const { getByLabelText } = render(<Checkbox />);
  const checkbox = getByLabelText(/not checked/);
});

目前,这个正则表达式是区分大小写的,所以如果您想搜索任何大小写,可以在末尾添加一个i。根据您希望的查询选择的宽松程度,谨慎使用这种技术:

const checkbox = getByLabelText(/not checked/i);

现在我们已经选择了我们的复选框。现在我们只需要触发事件(点击复选框)并编写一个断言,确保在点击复选框时checked属性设置为true

mport { render, fireEvent } from "@testing-library/react"

test("Selecting the checkbox should change the value of checked to true", () => {
  const { getByLabelText } = render(<Checkbox />);
  const checkbox = getByLabelText(/not checked/i);
  fireEvent.click(checkbox);
  expect(checkbox.checked).toEqual(true);
});

您还可以通过再次触发事件并检查属性是否在切换时设置为false来为此复选框测试添加反向切换。我们改变了测试的名称以更准确地描述它:

test("Selecting the checkbox should toggle its value", () => {
  const { getByLabelText } = render(<Checkbox />);
  const checkbox = getByLabelText(/not checked/i);
  fireEvent.click(checkbox);
  expect(checkbox.checked).toEqual(true);
  fireEvent.click(checkbox);
  expect(checkbox.checked).toEqual(false);
});

在这种情况下,选择复选框非常容易。我们有一个标签可以用来找到我们想要检查的输入。如果您无法轻松访问 DOM 元素,Testing Library 提供了另一个实用工具,可用于检查任何 DOM 元素。您将首先向要选择的元素添加一个属性:

<input
  type="checkbox"
  value={checked}
  onChange={setChecked}
  data-testid="checkbox" // Add the data-testid= attribute
/>

然后使用查询getByTestId

test("Selecting the checkbox should change the value of checked to true", () => {
  const { getByTestId } = render(<Checkbox />);
  const checkbox = getByTestId("checkbox");
  fireEvent.click(checkbox);
  expect(checkbox.checked).toEqual(true);
});

这样做的效果相同,但在访问其他难以访问的 DOM 元素时特别有用。

一旦测试了这个Checkbox组件,我们就可以自信地将其整合到应用程序的其余部分并重复使用它。

使用代码覆盖率

代码覆盖率是报告实际测试了多少行代码的过程。它提供了一个度量标准,可以帮助您确定何时已编写足够的测试。

Jest 随附了 Istanbul,这是一个 JavaScript 工具,用于审查您的测试并生成描述已覆盖多少语句、分支、函数和行的报告。

要运行带有代码覆盖率的 Jest,只需在运行jest命令时添加coverage标志:

npm test -- --coverage

这份报告告诉您在测试过程中每个文件中的代码执行了多少,并报告所有已导入测试的文件。

Jest 还生成了一个报告,您可以在浏览器中运行,该报告提供了有关测试覆盖了哪些代码的更多详细信息。运行 Jest 并生成覆盖率报告后,您会注意到根目录下添加了一个coverage文件夹。在 Web 浏览器中打开此文件:/coverage/lcov-report/index.html。它将以交互式报告显示您的代码覆盖率。

这份报告告诉您代码的覆盖率,以及每个子文件夹的单独覆盖情况。您可以深入查看子文件夹,了解其中的各个文件的覆盖情况。如果您选择components/ui文件夹,您将看到测试覆盖您用户界面组件的程度。

您可以通过单击文件名查看哪些行在单个文件中已被覆盖。

代码覆盖率是衡量测试覆盖范围的重要工具。这是帮助您了解何时已为代码编写足够单元测试的一个基准。在每个项目中都达到 100%的代码覆盖率并不常见。以 85%以上为目标是一个不错的指标。^(3)

测试通常会感觉像是一个额外的步骤,但围绕 React 测试的工具从未如此完善。即使您不对所有代码进行测试,开始考虑如何将测试实践纳入其中也能帮助您在构建可投入生产的应用程序时节省时间和金钱。

^(1) 要了解单元测试的简要介绍,请参阅马丁·福勒的文章,“单元测试”

^(2) 欲了解更多有关这一开发模式的信息,请参阅杰夫·麦克沃特和詹姆斯·本德的“红-绿-重构”

^(3) 查看马丁·福勒的文章,“测试覆盖率”

第十一章:React 路由器

当网络开始时,大多数网站由一系列用户可以通过请求和打开单独文件来浏览的页面组成。当前文件或资源的位置在浏览器的位置栏中列出。浏览器的前进和后退按钮将按预期工作。书签内容深入到网站将允许用户保存对特定文件的引用,该文件可以在用户请求时重新加载。在基于页面或服务器渲染的网站上,浏览器的导航和历史功能都能按预期工作。

在单页面应用程序中,所有这些功能变得棘手。记住,在单页面应用程序中,所有内容都发生在同一个页面上。JavaScript 正在加载信息并改变用户界面。像浏览器历史记录、书签以及前进和后退按钮这样的功能,如果没有路由解决方案将无法正常工作。路由是定义客户端请求端点的过程。^(1) 这些端点与浏览器的位置和历史对象结合使用。它们用于标识请求的内容,以便 JavaScript 可以加载和渲染适当的用户界面。

与 Angular、Ember 或 Backbone 不同,React 没有标准的路由器。认识到路由解决方案的重要性,工程师 Michael Jackson 和 Ryan Florence 创建了一个名为 React Router 的简单路由器。React Router 已被社区广泛接受为 React 应用程序的热门路由解决方案。^(2) 它被包括 Uber、Zendesk、PayPal 和 Vimeo 在内的公司使用。^(3)

在本章中,我们将介绍 React 路由器,并利用其功能来处理客户端的路由。

整合路由器

为了展示 React Router 的功能,我们将建立一个经典的起始网站,包括关于我们、事件、产品和联系我们等部分。尽管这个网站会感觉像有多个页面,但实际上只有一个——它是一个 SPA,即单页面应用程序(见图 11-1)。

lrc2 1101

图 11-1. 带有链接导航的简单网站

这个网站的站点地图包括主页、每个部分的页面,以及处理 404 未找到错误的错误页面(见图 11-2)。

lrc2 1102

图 11-2. 带有本地链接的站点地图

路由器将允许我们为网站的每个部分设置路由。每个路由都是可以输入到浏览器位置栏的终点。当请求某个路由时,我们可以渲染适当的内容。

首先,让我们安装 React Router 和 React Router DOM。React Router DOM 用于使用 DOM 的常规 React 应用程序。如果您正在编写一个 React Native 应用程序,您将使用react-router-native。我们将安装这些包的实验版本,因为在本打印时,React Router 6 尚未正式发布。一旦发布,您可以在没有这些标记的情况下使用这些包。

npm install react-router@experimental react-router-dom@experimental

我们还需要一些占位符组件来代表站点地图中的每个部分或页面。我们可以从名为pages.js的单个文件中导出这些组件:

import React from "react";

export function Home() {
  return (
    <div>
      <h1>[Company Website]</h1>
    </div>
  );
}

export function About() {
  return (
    <div>
      <h1>[About]</h1>
    </div>
  );
}

export function Events() {
  return (
    <div>
      <h1>[Events]</h1>
    </div>
  );
}

export function Products() {
  return (
    <div>
      <h1>[Products]</h1>
    </div>
  );
}

export function Contact() {
  return (
    <div>
      <h1>[Contact]</h1>
    </div>
  );
}

在这些页面被填充出来后,我们需要调整index.js文件。我们将不再渲染App组件,而是渲染Router组件。Router组件将当前位置的信息传递给任何嵌套在其中的子组件。Router组件应该只用一次,并放在我们组件树的根附近:

import React from "react";
import { render } from "react-dom";
import App from "./App";

import { BrowserRouter as Router } from "react-router-dom";

render(
  <Router>
    <App />
  </Router>,
  document.getElementById("root")
);

注意,我们正在导入BrowserRouter as Router。接下来我们需要做的是设置我们的路由配置。我们将把这些放在App.js文件中。用于渲染任何我们想要的路由的包装组件叫做Routes。在Routes内部,我们将为每个想要渲染的页面使用Route组件。我们还想从./pages.js文件中导入所有页面:

import React from "react";
import { Routes, Route } from "react-router-dom";
import {
  Home,
  About,
  Events,
  Products,
  Contact
} from "./pages";

function App() {
  return (
    <div>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route
          path="/about"
          element={<About />}
        />
        <Route
          path="/events"
          element={<Events />}
        />
        <Route
          path="/products"
          element={<Products />}
        />
        <Route
          path="/contact"
          element={<Contact />}
        />
      </Routes>
    </div>
  );
}

这些路由告诉 Router 当窗口位置改变时渲染哪个组件。每个Route组件都有pathelement属性。当浏览器的位置匹配path时,将显示element。当位置为/时,路由将渲染Home组件。当位置为/products时,路由将渲染Products组件。

在这一点上,我们可以运行应用程序,并在浏览器的位置栏中直接输入路由,观察内容的变化。例如,输入http://localhost:3000/about到位置栏,观察About组件的渲染。

期望用户通过在位置栏中键入路由来导航网站可能不太现实。react-router-dom提供了一个Link组件,我们可以用它来创建浏览器链接。

让我们修改主页,以包含一个导航菜单,每个路由都有一个链接:

import { Link } from "react-router-dom";

export function Home() {
  return (
    <div>
      <h1>[Company Website]</h1>
      <nav>
        <Link to="about">About</Link>
        <Link to="events">Events</Link>
        <Link to="products">Products</Link>
        <Link to="contact">Contact Us</Link>
      </nav>
    </div>
  );
}

现在用户可以通过点击链接从主页访问每个内部页面。浏览器的返回按钮将带他们回到主页。

Router 属性

React Router 会将属性传递给它渲染的组件。例如,我们可以通过一个属性获取当前位置。让我们使用当前位置来帮助我们创建一个 404 未找到组件。首先,我们将创建这个组件:

export function Whoops404() {
  return (
    <div>
      <h1>Resource not found</h1>
    </div>
  );
}

然后我们将在App.js中的路由配置中添加这个。如果我们访问一个不存在的路由,比如highway,我们希望显示Whoops404组件。我们将使用*作为路径值,组件作为元素:

function App() {
  return (
    <div>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route
          path="/about"
          element={<About />}
        />
        <Route
          path="/events"
          element={<Events />}
        />
        <Route
          path="/products"
          element={<Products />}
        />
        <Route
          path="/contact"
          element={<Contact />}
        />
        <Route path="*" element={<Whoops404 />} />
      </Routes>
    </div>
  );
}

现在,如果我们访问 localhost:3000/highway,我们将看到 404 页面组件的渲染。我们还可以通过使用位置值显示我们访问的路由的值。由于我们生活在一个具有 React Hooks 的世界中,因此有一个钩子可以做到这一点。在 Whoops404 组件中,创建一个名为 location 的变量,返回当前位置的值(即关于您正在导航到哪个页面的属性)。然后使用 location.pathname 的值来显示正在访问的路由:

export function Whoops404() {
  let location = useLocation();
  console.log(location);
  return (
    <div>
      <h1>
        Resource not found at {location.pathname}
      </h1>
    </div>
  );
}

如果记录 location,可以进一步探索该对象。

本节介绍了实现和使用 React Router 的基础知识。Router 只使用一次,并包装所有将使用路由的组件。所有 Route 组件都需要包装在一个 Routes 组件中,该组件根据当前窗口的位置选择要渲染的组件。可以使用 Link 组件来便捷导航。这些基础知识可以让你走得很远,但只是揭开了路由器功能的表面。

嵌套路由

Route 组件与应仅在匹配特定 URL 时显示的内容一起使用。这个特性使我们能够将我们的 Web 应用程序组织成优雅的层次结构,促进内容的重用。

有时,当用户浏览我们的应用程序时,我们希望保持一些 UI 不变。在过去,诸如页面模板和主页面之类的解决方案已帮助 Web 开发人员重用 UI 元素。

让我们考虑一个简单的入门网站。我们可能想为 About 页面创建子页面,用于显示额外的内容。当用户选择 About 部分时,他们应默认到该部分下的 Company 页面。大纲如下:

  • 主页

    • 关于公司

      • 公司(默认)

      • 历史

      • 服务

      • 位置

    • 事件

    • 产品

    • 联系我们

  • 404 错误页面

我们需要创建的新路由将反映这种层次结构:

我们还需要记住为我们的新部分 CompanyServicesHistoryLocation 创建存根占位符组件。例如,这是 Services 组件的一些文本示例,您可以重用它们:

export function Services() {
  <section>
    <h2>Our Services</h2>
    <p>
      Lorem ipsum dolor sit amet, consectetur
      adipiscing elit. Integer nec odio. Praesent
      libero. Sed cursus ante dapibus diam. Sed
      nisi. Nulla quis sem at nibh elementum
      imperdiet. Duis sagittis ipsum. Praesent
      mauris. Fusce nec tellus sed augue semper
      porta. Mauris massa. Vestibulum lacinia arcu
      eget nulla. Class aptent taciti sociosqu ad
      litora torquent per conubia nostra, per
      inceptos himenaeos. Curabitur sodales ligula
      in libero.
    </p>
  </section>;
}

创建了这些组件后,我们可以从 App.js 文件开始配置路由器。如果您想使用路由创建页面层次结构,只需将 Route 组件嵌套在彼此内部即可:

import {
  Home,
  About,
  Events,
  Products,
  Contact,
  Whoops404,
  Services,
  History,
  Location
} from "./pages";

function App() {
  return (
    <div>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="about" element={<About />}>
          <Route
            path="services"
            element={<Services />}
          />

          <Route
            path="history"
            element={<History />}
          />
          <Route
            path="location"
            element={<Location />}
          />
        </Route>
        <Route
          path="events"
          element={<Events />}
        />
        <Route
          path="products"
          element={<Products />}
        />
        <Route
          path="contact"
          element={<Contact />}
        />
        <Route path="*" element={<Whoops404 />} />
      </Routes>
    </div>
  );
}

一旦您使用 About Route 组件包装了嵌套路由,您可以访问这些页面。如果您打开 http://localhost:3000/about/history,您将只看到 About 页面的内容,但是 History 组件不会显示。为了使其显示,我们将使用 React Router DOM 的另一个功能:Outlet 组件。Outlet 将允许我们渲染这些嵌套组件。我们只需将它放在任何我们想要渲染子内容的地方。

pages.jsAbout 组件中,我们将在 <h1> 下添加这个:

import {
  Link,
  useLocation,
  Outlet
} from "react-router-dom";

export function About() {
  return (
    <div>
      <h1>[About]</h1>
      <Outlet />
    </div>
  );
}

现在这个 About 组件将在整个部分中被重复使用,并且会显示嵌套的组件。位置会告诉应用程序应该渲染哪个子部分。例如,当位置是 http://localhost:3000/about/history 时,History 组件将被渲染在 About 组件内部。

使用重定向

有时您希望将用户从一个路由重定向到另一个路由。例如,我们可以确保如果用户尝试通过 http://localhost:3000/services 访问内容,他们将被重定向到正确的路由:http://localhost:3000/about/services

让我们修改我们的应用程序以包含重定向,以确保用户可以访问正确的内容:

import {
  Routes,
  Route,
  Redirect
} from "react-router-dom";

function App() {
  return (
    <div>
      <Routes>
        <Route path="/" element={<Home />} />
        // Other Routes
        <Redirect
          from="services"
          to="about/services"
        />
      </Routes>
    </div>
  );
}

Redirect 组件允许我们将用户重定向到特定路由。

在生产应用程序中更改路由时,用户仍然会尝试通过旧路由访问旧内容。这通常是由于书签引起的。Redirect 组件为我们提供了一种方法,即使用户通过旧书签访问我们的站点,也能加载适当的内容。

在本节中,我们使用 Route 组件创建了一个路由配置。如果您喜欢这种结构,请随意忽略下一节,但我们希望确保您知道如何以不同的方式创建路由配置。还可以使用钩子 useRoutes 来配置应用程序的路由。

如果我们想重构我们的应用程序以使用 useRoutes,我们将在 App 组件(或任何设置路由的地方)进行调整。让我们重新设计它:

import { useRoutes } from "react-router-dom";

function App() {
  let element = useRoutes([
    { path: "/", element: <Home /> },
    {
      path: "about",
      element: <About />,
      children: [
        {
          path: "services",
          element: <Services />
        },
        { path: "history", element: <History /> },
        {
          path: "location",
          element: <Location />
        }
      ]
    },
    { path: "events", element: <Events /> },
    { path: "products", element: <Products /> },
    { path: "contact", element: <Contact /> },
    { path: "*", element: <Whoops404 /> },
    {
      path: "services",
      redirectTo: "about/services"
    }
  ]);
  return element;
}

官方文档将配置称为 element,但您可以选择任何您喜欢的名称来称呼它。使用这种语法是完全可选的。RouteuseRoutes 的包装器,因此无论如何您都在使用它。选择最适合您的语法和样式!

路由参数

React Router 的另一个有用功能是设置 路由参数 的能力。路由参数是从 URL 中获取值的变量。它们在数据驱动的 Web 应用程序中非常有用,用于过滤内容或管理显示偏好。

让我们重新审视颜色组织器,并通过添加使用 React Router 选择并显示一种颜色的功能来改进它。当用户通过点击选择颜色时,应用程序应该渲染该颜色并显示其 titlehex 值。

使用路由器,我们可以通过 URL 获取颜色 ID。例如,这是我们将使用的 URL 来显示颜色“lawn”,因为 lawn 的 ID 被传递在 URL 中:

http://localhost:3000/58d9caee-6ea6-4d7b-9984-65b145031979

首先,让我们在index.js文件中设置路由器。我们将导入Router并包装App组件:

import { BrowserRouter as Router } from "react-router-dom";

render(
  <Router>
    <App />
  </Router>,
  document.getElementById("root")
);

包装App将路由器的所有属性传递给组件及其内部的任何其他组件。从那里开始设置路由配置。我们将使用RoutesRoute组件而不是useRoutes,但请记住,如果您更喜欢该语法,这始终是一个选项。首先导入RoutesRoute

import { Routes, Route } from "react-router-dom";

然后将其添加到App。这个应用将有两个路由:ColorListColorDetails。我们还没有建立ColorDetails,但是让我们先引入它:

import { ColorDetails } from "./ColorDetails";

export default function App() {
  return (
    <ColorProvider>
      <AddColorForm />
      <Routes>
        <Route
          path="/"
          element={<ColorList />}
        />
        <Route
          path=":id"
          element={<ColorDetails />}
        />
      </Routes>
    </ColorProvider>
  );
}

ColorDetails组件将根据颜色的id动态显示。让我们在名为ColorDetails.js的新文件中创建ColorDetails组件。首先,它将是一个占位符:

import React from "react";

export function ColorDetails() {
  return (
    <div>
      <h1>Details</h1>
    </div>
  );
}

我们如何知道这是否有效?检查的最简单方法是打开 React 开发者工具并找到正在渲染的颜色之一的id。如果您尚未拥有颜色,请添加一个并查看其id。一旦您有了id,您可以将其附加到localhost:3000的 URL 中。例如,localhost:3000/00fdb4c5-c5bd-4087-a48f-4ff7a9d90af8

现在,您应该看到ColorDetails页面出现了。现在我们知道路由器和我们的路由正在工作,但是我们希望它更加动态化。在ColorDetails页面上,我们希望根据 URL 中找到的id显示正确的颜色。为此,我们将使用useParams钩子:

import { useParams } from "react-router-dom";

export function ColorDetails() {
  let params = useParams();
  console.log(params);
  return (
    <div>
      <h1>Details</h1>
    </div>
  );
}

如果我们记录params,我们将看到这是一个包含路由器上可用任何参数的对象。我们将解构此对象以获取id,然后我们可以使用该idcolors数组中找到正确的颜色。让我们使用我们的useColors钩子来实现这一点:

import { useColors } from "./";

export function ColorDetails() {
  let { id } = useParams(); // destructure id

  let { colors } = useColors();

  let foundColor = colors.find(
    color => color.id === id
  );
  console.log(foundColor);

  return (
    <div>
      <h1>Details</h1>
    </div>
  );
}

记录foundColor会显示我们已经找到了正确的颜色。现在我们只需要在组件中显示关于该颜色的数据:

export function ColorDetails() {
  let { id } = useParams();
  let { colors } = useColors();

  let foundColor = colors.find(
    color => color.id === id
  );

  return (
    <div>
      <div
        style={{
          backgroundColor: foundColor.color,
          height: 100,
          width: 100
        }}
      ></div>
      <h1>{foundColor.title}</h1>
      <h1>{foundColor.color}</h1>
    </div>
  );
}

我们希望为颜色组织者添加的另一个功能是,通过单击列表中的颜色来导航到ColorDetails页面的能力。让我们将此功能添加到Color组件中。当我们单击组件时,我们将使用另一个名为useNavigate的路由器钩子来打开详细页面。我们首先从react-router-dom中导入它:

import { useNavigate } from "react-router-dom";

然后,我们将调用useNavigate,它将返回一个函数,我们可以用它来导航到另一个页面:

let navigate = useNavigate();

现在在section中,我们将添加一个onClick处理程序,以便根据颜色的id导航到路由:

let navigate = useNavigate();

return (
  <section
    className="color"
    onClick={() => navigate(`/${id}`)}
  >
    // Color component
  </section>
);

现在,当我们单击section时,我们将被路由到正确的页面。

路由参数是获取影响用户界面呈现数据的理想工具。但是,只有在希望用户在 URL 中捕获这些细节时才应使用它们。例如,在颜色组织者的情况下,用户可以向其他用户发送特定颜色或按特定字段排序的所有颜色的链接。用户还可以将这些链接加为书签,以返回特定数据。

在本章中,我们回顾了 React Router 的基本用法。在下一章中,我们将学习如何在服务器上使用路由。

^(1) Express.js 文档,“基本路由”

^(2) 该项目在 GitHub 上已被星标超过 20,000 次。

^(3) 参见 “使用 React Router 的网站”

第十二章:React 和服务器

到目前为止,我们已经用 React 构建了完全在浏览器中运行的小型应用程序。它们在浏览器中收集数据并使用浏览器存储保存数据。这是有道理的,因为 React 是一个视图层;它的目的是渲染 UI。然而,大多数应用程序至少需要某种形式的后端存在,我们需要了解如何以服务器为重心来构建应用程序。

即使您的客户端应用程序完全依赖云服务作为后端,您仍然需要获取并发送数据到这些服务。应该在特定的地方进行这些交易,并且有助于处理与 HTTP 请求相关的延迟的库。

另外,React 可以同构地渲染,这意味着它可以在浏览器之外的平台上运行。这意味着我们可以在将 UI 发送到浏览器之前在服务器上呈现我们的 UI。利用服务器渲染,我们可以提高应用程序的性能、可移植性和安全性。

我们从比较同构和普遍主义的差异开始这一章,并探讨这两个概念如何与 React 相关联。接下来,我们将看看如何使用通用 JavaScript 制作同构应用程序。最后,我们将通过添加服务器并首先在服务器上呈现 UI 来改进颜色组织者。

同构与通用的区别

同构通用这两个术语经常用来描述在客户端和服务器上都可以工作的应用程序。尽管这些术语可以互换使用来描述同一应用程序,但它们之间有一个微妙的差别值得探讨。同构应用程序是可以在多个平台上渲染的应用程序。通用代码意味着完全相同的代码可以在多个环境中运行。^(1)

Node.js 将允许我们在其他应用程序(如服务器、CLI 甚至原生应用程序)中重用我们在浏览器中编写的相同代码。让我们来看看一些通用的 JavaScript:

const userDetails = response => {
  const login = response.login;
  console.log(login);
};

printNames 函数是通用的。完全相同的代码可以在浏览器或服务器上调用。这意味着如果我们使用 Node.js 构建服务器,我们可以在这两个环境之间重新使用代码。通用 JavaScript 是指可以在服务器或浏览器上运行而不出错的 JavaScript(见图 12-1)。

lrc2 1201

图 12-1。客户端和服务器域

客户端和服务器域

服务器和客户端是完全不同的领域,因此我们所有的 JavaScript 代码不会自动在它们之间工作。让我们看看如何在浏览器中创建 AJAX 请求:

fetch("https://api.github.com/users/moonhighway")
  .then(res => res.json())
  .then(console.log);

在这里,我们正在向 GitHub API 发出 fetch 请求,将响应转换为 JSON,然后在 JSON 结果上调用一个函数来解析它。

然而,如果我们尝试在 Node.js 中运行完全相同的代码,我们会得到一个错误:

fetch("https://api.github.com/users/moonhighway")
^

ReferenceError: fetch is not defined
at Object.<anonymous> (/Users/eveporcello/Desktop/index.js:7:1)
at Module.\_compile (internal/modules/cjs/loader.js:1063:30)
at Object.Module.\_extensions..js (internal/modules/cjs/loader.js:1103:10)
at Module.load (internal/modules/cjs/loader.js:914:32)
at Function.Module.\_load (internal/modules/cjs/loader.js:822:14)
at Function.Module.runMain (internal/modules/cjs/loader.js:1143:12)
at internal/main/run_main_module.js:16:11

这个错误是因为 Node.js 没有像浏览器那样内置的fetch函数。在 Node.js 中,我们可以使用 npm 中的isomorphic-fetch,或者使用内置的https模块。由于我们已经使用了fetch语法,让我们集成isomorphic-fetch

npm install isomorphic-fetch

然后我们只需导入isomorphic-fetch,代码无需更改:

const fetch = require("isomorphic-fetch");

const userDetails = response => {
  const login = response.login;
  console.log(login);
};

fetch("https://api.github.com/users/moonhighway")
  .then(res => res.json())
  .then(userDetails);

使用 Node.js 从 API 加载数据需要使用核心模块。这需要不同的代码。在这些示例中,userDetails函数是通用的,因此相同的函数可以在两种环境中使用。

此 JavaScript 文件现在是同构的。它包含通用的 JavaScript。所有代码都不是通用的,但文件本身将在两种环境中工作。可以在 Node.js 中运行它,或者将其包含在浏览器的<script>标签中。

让我们来看看Star组件。这个组件是通用的吗?

function Star({
  selected = false,
  onClick = f => f
}) {
  return (
    <div
      className={
        selected ? "star selected" : "star"
      }
      onClick={onClick}
    ></div>
  );
}

当然可以;记住,JSX 编译为 JavaScript。Star组件只是一个简单的函数:

function Star({
  selected = false,
  onClick = f => f
}) {
  return React.createElement("div", {
    className: selected
      ? "star selected"
      : "star",
    onClick: onClick
  });
}

我们可以直接在浏览器中渲染此组件,或者在不同的环境中渲染并捕获 HTML 输出为字符串。ReactDOM有一个renderToString方法,我们可以用它来将 UI 渲染为 HTML 字符串:

// Renders html directly in the browser
ReactDOM.render(<Star />);

// Renders html as a string
let html = ReactDOM.renderToString(<Star />);

我们可以构建同构应用程序,在不同平台上渲染组件,并且可以以一种方式设计这些应用程序,以便在多个环境中重用 JavaScript 代码。此外,我们还可以使用其他语言(如 Go 或 Python)来构建同构应用程序——我们不限于 Node.js。

服务器端渲染 React

使用ReactDOM.renderToString方法允许我们在服务器上渲染 UI。服务器功能强大,可以访问各种浏览器无法访问的资源。服务器可以安全地访问并获取安全数据。通过在服务器上首次渲染初始内容,可以利用所有这些额外的优势。

我们将要服务端渲染的应用程序是我们在第五章中构建的 Recipes 应用程序。你可以运行 Create React App 并将此代码放置在index.js文件的内容之上:

import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import { Menu } from "./Menu";

const data = [
  {
    name: "Baked Salmon",
    ingredients: [
      {
        name: "Salmon",
        amount: 1,
        measurement: "lb"
      },
      {
        name: "Pine Nuts",
        amount: 1,
        measurement: "cup"
      },
      {
        name: "Butter Lettuce",
        amount: 2,
        measurement: "cups"
      },
      {
        name: "Yellow Squash",
        amount: 1,
        measurement: "med"
      },
      {
        name: "Olive Oil",
        amount: 0.5,
        measurement: "cup"
      },
      {
        name: "Garlic",
        amount: 3,
        measurement: "cloves"
      }
    ],
    steps: [
      "Preheat the oven to 350 degrees.",
      "Spread the olive oil around a glass baking dish.",
      "Add the yellow squash and place in the oven for 30 mins.",
      "Add the salmon, garlic, and pine nuts to the dish.",
      "Bake for 15 minutes.",
      "Remove from oven. Add the lettuce and serve."
    ]
  },
  {
    name: "Fish Tacos",
    ingredients: [
      {
        name: "Whitefish",
        amount: 1,
        measurement: "l lb"
      },
      {
        name: "Cheese",
        amount: 1,
        measurement: "cup"
      },
      {
        name: "Iceberg Lettuce",
        amount: 2,
        measurement: "cups"
      },
      {
        name: "Tomatoes",
        amount: 2,
        measurement: "large"
      },
      {
        name: "Tortillas",
        amount: 3,
        measurement: "med"
      }
    ],
    steps: [
      "Cook the fish on the grill until hot.",
      "Place the fish on the 3 tortillas.",
      "Top them with lettuce, tomatoes, and cheese."
    ]
  }
];

ReactDOM.render(
  <Menu
    recipes={data}
    title="Delicious Recipes"
  />,
  document.getElementById("root")
);

这些组件将存放在一个名为Menu.js的新文件中:

function Recipe({ name, ingredients, steps }) {
  return (
    <section
      id={name.toLowerCase().replace(/ /g, "-")}
    >
      <h1>{name}</h1>
      <ul className="ingredients">
        {ingredients.map((ingredient, i) => (
          <li key={i}>{ingredient.name}</li>
        ))}
      </ul>
      <section className="instructions">
        <h2>Cooking Instructions</h2>
        {steps.map((step, i) => (
          <p key={i}>{step}</p>
        ))}
      </section>
    </section>
  );
}

export function Menu({ title, recipes }) {
  return (
    <article>
      <header>
        <h1>{title}</h1>
      </header>
      <div className="recipes">
        {recipes.map((recipe, i) => (
          <Recipe key={i} {...recipe} />
        ))}
      </div>
    </article>
  );
}

在整本书中,我们在客户端渲染了组件。客户端渲染通常是构建应用程序时我们会首先使用的方法。我们提供 Create React App 的build文件夹,浏览器运行 HTML 并调用script.js文件以加载任何 JavaScript。

这样做可能会耗费时间。用户可能需要等待几秒钟才能看到任何加载,这取决于他们的网络速度。使用带有 Express 服务器的 Create React App,我们可以创建客户端和服务器端渲染的混合体验。

我们正在渲染一个Menu组件,用于显示几个菜谱。这个应用程序的第一个变更是使用ReactDOM.hydrate而不是ReactDOM.render

这两个函数相同,除了使用hydrate将内容添加到由ReactDOMServer渲染的容器中。操作顺序如下所示:

  1. 渲染应用程序的静态版本,允许用户看到发生了某些事情并且页面已经“加载”。

  2. 发出对动态 JavaScript 的请求。

  3. 用动态内容替换静态内容。

  4. 用户点击某物并且它起作用。

我们正在对服务器端渲染后的应用程序进行重新水合作。通过重新水合作,我们指的是将内容静态加载为静态 HTML,然后加载 JavaScript。这允许用户体验到感知性能。他们将看到页面上正在发生的事情,这使他们希望留在页面上。

接下来,我们需要设置项目的服务器,我们将使用 Express,一个轻量级的 Node 服务器。首先安装它:

npm install express

然后我们将创建一个名为 server 的服务器文件夹,并在其中创建一个名为 index.js 的文件。此文件将构建一个服务器,将提供 build 文件夹,但还将预加载一些静态 HTML 内容:

import express from "express";
const app = express();

app.use(express.static("./build"));

这会导入并静态提供 build 文件夹。接下来,我们想要使用 ReactDOM 中的 renderToString 将应用程序呈现为静态 HTML 字符串:

import React from "react";
import ReactDOMServer from "react-dom/server";
import { Menu } from "../src/Menu.js";

const PORT = process.env.PORT || 4000;

app.get("/*", (req, res) => {
  const app = ReactDOMServer.renderToString(
    <Menu />
  );
});

app.listen(PORT, () =>
  console.log(
    `Server is listening on port ${PORT}`
  )
);

我们将把 Menu 组件传递给此函数,因为这是我们想要静态渲染的内容。然后,我们想要从构建的客户端应用程序中读取静态 index.html 文件,将应用程序的内容注入到 div 中,并将其作为响应发送给请求:

app.get("/*", (req, res) => {
  const app = ReactDOMServer.renderToString(
    <Menu />
  );

  const indexFile = path.resolve(
    "./build/index.html"
  );

  fs.readFile(indexFile, "utf8", (err, data) => {
    return res.send(
      data.replace(
        '<div id="root"></div>',
        `<div id="root">${app}</div>`
      )
    );
  });
});

完成后,我们需要对 webpack 和 Babel 进行一些配置。请记住,Create React App 可以处理编译和构建,但我们需要在服务器项目中设置和强制执行不同的规则。

首先安装一些依赖项(好的,很多依赖项):

npm install @babel/core @babel/preset-env babel-loader nodemon npm-run-all
webpack webpack-cli webpack-node-externals

安装了 Babel 后,让我们创建一个包含一些预设的 .babelrc

{
  "presets": ["@babel/preset-env", "react-app"]
}

你将添加 react-app 因为项目使用了 Create React App,并且已经安装好了。

接下来,添加一个名为 webpack.server.js 的服务器 webpack 配置文件:

const path = require("path");
const nodeExternals = require("webpack-node-externals");

module.exports = {
  entry: "./server/index.js",
  target: "node",
  externals: [nodeExternals()],
  output: {
    path: path.resolve("build-server"),
    filename: "index.js"
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: "babel-loader"
      }
    ]
  }
};

babel-loader 会按预期转换 JavaScript 文件,并且 nodeExternals 会扫描 node_modules 文件夹中所有 node_modules 名称。然后,它将构建一个外部函数,告诉 webpack 不要捆绑这些模块或任何子模块。

此外,您可能会遇到 webpack 错误,因为安装的版本与 Create React App 安装的版本之间存在冲突。要解决冲突,只需在项目的根目录添加一个 .env 文件,并添加以下内容:

SKIP_PREFLIGHT_CHECK=true

最后,我们可以添加几个额外的 npm 脚本来运行我们的开发命令:

{
  "scripts": {
    //...
    "dev:build-server": "NODE_ENV=development webpack --config webpack.server.js
 --mode=development -w",
    "dev:start": "nodemon ./server-build/index.js",
    "dev": "npm-run-all --parallel build dev:*"
  }
}
  1. dev:build-server: 将 development 作为环境变量传递,并使用新的服务器配置运行 webpack

  2. dev:start: 使用 nodemon 运行服务器文件,它将监听任何更改。

  3. dev: 并行运行两个进程。

现在当我们运行 npm run dev 时,这两个进程都会运行。您应该能够在 localhost:4000 上看到应用程序正在运行。当应用程序运行时,内容将按顺序加载,首先作为预渲染的 HTML,然后加载 JavaScript 打包文件。

使用这样的技术可以意味着更快的加载时间,并将提升用户的感知性能。用户期望页面加载时间在两秒或更短,任何性能改进都可能意味着用户选择使用您的网站还是跳转到竞争对手。

使用 Next.js 进行服务器渲染

另一个在服务器渲染生态系统中强大且广泛使用的工具是 Next.js。Next 是由 Zeit 发布的开源技术,旨在帮助工程师更轻松地编写服务器渲染的应用程序。这包括直观的路由功能、静态优化、自动分割等特性。在下一节中,我们将更详细地介绍如何使用 Next.js 在我们的应用程序中启用服务器渲染。

首先,我们将创建一个全新的项目,运行以下命令:

mkdir project-next
cd project-next
npm init -y
npm install --save react react-dom next
mkdir pages

然后,我们将创建一些 npm 脚本,以便更轻松地运行常见命令:

{
  //...
  "scripts": {
    "dev": "next",
    "build": "next build",
    "start": "next start"
  }
}

pages文件夹中,我们将创建一个index.js文件。我们将编写我们的组件,但不用担心导入 React 或 ReactDOM。相反,我们只需编写一个组件:

export default function Index() {
  return (
    <div>
      <p>Hello everyone!</p>
    </div>
  );
}

一旦我们创建了这个,我们可以运行npm run dev来查看页面运行在localhost:3000。它将显示预期的组件。

你还会注意到屏幕右下角有一个小闪电符号。将鼠标悬停在上面将显示一个按钮,上面写着Prerendered Page。点击它将带您进入关于静态优化指示器的文档。这意味着页面符合自动静态优化的标准,即可以预渲染。没有数据需求会阻塞它。如果页面自动静态优化(这听起来有点复杂,但非常实用!),加载速度更快,因为不需要服务器端的工作。页面可以从 CDN 流式传输,提供超快的用户体验。您不必做任何操作即可获得这种性能增强。

如果页面确实有数据需求怎么办?如果页面无法预渲染怎么办?为了探讨这个问题,让我们将我们的应用程序变得更加健壮,并构建一个组件,从 API 获取一些远程数据。在一个名为Pets.js的新文件中:

export default function Pets() {
  return <h1>Pets!</h1>;
}

首先,我们将渲染一个h1。现在我们可以访问localhost:3000/pets,看到我们的页面现在加载在那个路由上。这很好,但我们可以通过添加链接和布局组件来改进这一点,该组件将显示每个页面的正确内容。我们将创建一个可以在两个页面上使用的头部,并显示链接:

import Link from "next/link";

export default function Header() {
  return (
    <div>
      <Link href="/">
        <a>Home</a>
      </Link>
      <Link href="/pets">
        <a>Pets</a>
      </Link>
    </div>
  );
}

Link组件是一种包装器,围绕着一些链接。这些看起来类似于我们使用 React Router 创建的链接。我们还可以为每个<a>标签添加样式:

const linkStyle = {
  marginRight: 15,
  color: "salmon"
};

export default function Header() {
  return (
    <div>
      <Link href="/">
        <a style={linkStyle}>Home</a>
      </Link>
      <Link href="/pets">
        <a style={linkStyle}>Pets</a>
      </Link>
    </div>
  );
}

接下来,我们将把Header组件整合到一个名为Layout.js的新文件中。这将根据正确的路由动态显示组件:

import Header from "./Header";

export function Layout(props) {
  return (
    <div>
      <Header />
      {props.children}
    </div>
  );
}

Layout 组件将接收 props 并在组件下方显示任何额外的内容在 Header 下方。然后,在每个页面中,当渲染时可以创建可以传递给 Layout 组件的内容块。例如,index.js 文件现在看起来是这样的:

import Layout from "./Layout";

export default function Index() {
  return (
    <Layout>
      <div>
        <h1>Hello everyone!</h1>
      </div>
    </Layout>
  );
}

我们将在 Pets.js 文件中执行相同的操作:

import Layout from "./Layout";

export default function Pets() {
  return (
    <Layout>
      <div>
        <h1>Hey pets!</h1>
      </div>
    </Layout>
  );
}

现在,如果我们访问首页,我们应该看到页眉,然后当我们点击 Pets 链接时,我们应该看到 Pets 页面。

当我们点击右下角的闪电按钮时,我们会注意到这些页面仍在预渲染。这是可以预期的,因为我们继续渲染静态内容。让我们使用 Pets 页面加载一些数据,看看这如何改变。

首先,我们将像在本章开头那样安装 isomorphic-unfetch

npm install isomorphic-unfetch

我们将使用此功能从 Pet Library API 进行获取调用。首先,在 Pages.js 文件中导入它:

import fetch from "isomorphic-unfetch";

然后,我们将添加一个名为 getInitialProps 的函数。这将处理获取和加载数据:

Pets.getInitialProps = async function() {
  const res = await fetch(
    `http://pet-library.moonhighway.com/api/pets`
  );
  const data = await res.json();
  return {
    pets: data
  };
};

当我们将数据作为 pets 的值返回时,我们可以在组件中对数据进行映射。

调整组件以映射 pets 属性:

export default function Pets(props) {
  return (
    <Layout>
      <div>
        <h1>Pets!</h1>
        <ul>
          {props.pets.map(pet => (
            <li key={pet.id}>{pet.name}</li>
          ))}
        </ul>
      </div>
    </Layout>
  );
}

如果组件中存在 getInitialProps,Next.js 将根据每个请求渲染页面。这意味着页面将以服务器端渲染的方式呈现,而不是静态预渲染,因此每个请求都将使用 API 中的最新数据。

一旦我们对应用程序的状态感到满意,我们可以使用 npm run build 运行构建。Next.js 关注性能,因此它将为我们每个文件的存在的千字节数提供完整的详细信息。这是对异常大文件的快速检查。

在每个文件旁边,我们将看到一个图标,指示站点在运行时是否是服务器端渲染(λ),自动呈现为 HTML(○),或自动生成为静态 HTML + JSON(●)。

一旦您构建了应用程序,您可以部署它。Next.js 是 Zeit 的开源产品,一个云托管提供商,因此使用 Zeit 进行部署的体验是最直接的。但是,您可以使用许多不同的托管提供商来部署您的应用程序。

总结一下,在开始构建自己的应用程序时,理解一些重要的术语是很重要的:

CSR(客户端端渲染)

在浏览器中呈现应用程序,通常使用 DOM。这就是我们在未修改的 Create React App 中所做的事情。

SSR(服务器端渲染)

将客户端或通用应用程序呈现为服务器上的 HTML。

再水合作用

在客户端加载 JavaScript 视图以重用服务器渲染的 HTML 的 DOM 树和数据。

预渲染

在构建时运行客户端应用程序并捕获静态 HTML 的初始状态。

Gatsby

另一个基于 React 的流行站点生成器是 Gatsby。 Gatsby 作为创建内容驱动网站的简单方式正在全球流行。 它旨在提供更智能的默认设置来管理性能、可访问性、图像处理等问题。 如果你正在阅读这本书,很可能在某个时候你会参与一个 Gatsby 项目!

Gatsby 用于各种项目,但通常用于构建内容驱动的网站。 换句话说,如果你有一个博客或静态内容,Gatsby 是一个很好的选择,特别是现在你了解了 React。 Gatsby 还可以处理来自 API 的动态内容、与框架的集成等。

在本节中,我们将开始构建一个快速的 Gatsby 站点来演示其工作原理。 基本上,我们将构建我们的小型 Next.js 应用作为一个 Gatsby 应用程序:

npm install -g gatsby-cli
gatsby new pets

如果你全局安装了 yarn,命令行界面会询问你是否使用 yarn 还是 npm。 两者都可以。 然后你会进入pets文件夹:

cd pets

现在你可以使用gatsby develop启动项目。 当你访问localhost:8000时,你将看到你的 Gatsby 起始站点正在运行。 现在你可以开始浏览文件。

如果你打开项目的src文件夹,你会看到三个子文件夹:componentsimagespages

pages文件夹内,你会找到一个404.js错误页面,一个index.js页面(访问localhost:8000时渲染的页面),以及一个page-2.js,用于呈现第二页的内容。

如果你访问components文件夹,这就是 Gatsby 的魔力所在。 还记得我们在 Next.js 中构建了HeaderLayout组件吗? 这两个组件已经作为模板在components文件夹中创建了。

一些特别有趣的事情需要注意:

layout.js

这包含了Layout组件。 它使用useStaticQuery钩子来通过 GraphQL 查询关于站点的一些数据。

seo.js

这个组件让我们能够访问页面的元数据,用于搜索引擎优化。

如果你向pages文件夹添加额外的页面,这将在你的站点上添加额外的页面。让我们试试,在pages文件夹中添加一个page-3.js文件。然后我们将在该文件中添加以下代码来快速创建一个页面:

import React from "react";
import { Link } from "gatsby";

import Layout from "../components/layout";
import SEO from "../components/seo";

const ThirdPage = () => (
  <Layout>
    <SEO title="Page three" />
    <h1>Hi from the third page</h1>
    <Link to="/">Go back to the homepage</Link>
  </Layout>
);

export default ThirdPage;

我们将使用Layout组件来包装内容,以便它显示为childrenLayout不仅显示动态内容,而且一旦我们创建它,页面就会自动生成。

这只是你可以使用 Gatsby 做的一小部分,但我们会为你留下一些关于其额外功能的信息:

静态内容

你可以将站点构建为静态文件,这些文件可以在没有服务器的情况下部署。

CDN 支持

可以将你的站点缓存在全球各地的 CDN 上,以提高性能和可用性。

响应式和渐进式图像

Gatsby 将图像加载为模糊的占位符,然后淡入完整的资源。这种策略,由 Medium 推广,允许用户在完整资源可用之前看到正在渲染的内容。

预取链接页面

在你点击下一个链接之前,加载下一页所需的所有内容将在后台加载。

所有这些功能和更多功能都用于确保无缝的用户体验。Gatsby 已经为你做出了许多决定。这可能是好事或坏事,但这些限制旨在让你专注于你的内容。

未来的 React

尽管 Angular、Ember 和 Vue 在 JavaScript 生态系统中仍然拥有大量市场份额,但很难否认 React 目前是构建 JavaScript 应用程序最广泛使用和具有影响力的库。除了库本身,更广泛的 JavaScript 社区,特别是通过 Next.js 和 Gatsby 所体现的,已经将 React 作为首选工具。

那么我们接下来该怎么做呢?我们鼓励你利用这些技能来构建自己的项目。如果你想要构建移动应用程序,可以尝试使用 React Native。如果你想要声明式地获取数据,可以尝试使用 GraphQL。如果你想要构建基于内容的网站,可以深入了解 Next.js 和 Gatsby。

你可以选择多种途径,但是你在 React 中掌握的这些技能将在你着手构建自己的应用程序时发挥作用。在这个过程中,我们希望这本书能成为你的参考和基础。尽管 React 及其相关库几乎肯定会发生变化,但这些是你可以立即放心使用的稳定工具。使用 React 和功能性、声明式 JavaScript 构建应用程序非常有趣,我们迫不及待地想看到你将会构建什么。

^(1) Gert Hengeveld,“同构 vs 通用 JavaScript”,Medium。

posted @ 2025-11-19 09:21  绝不原创的飞龙  阅读(22)  评论(0)    收藏  举报