React-实战-全-
React 实战(全)
原文:React in Action
译者:飞龙
第一部分. 认识 React
如果你过去两年在开发前端 JavaScript 应用程序,你很可能听说过 React。即使你刚开始构建用户界面,你也可能听说过它。即使你在这本书中第一次听到 React,我仍然会照顾你:有许多非常流行的应用程序使用 React。如果你使用 Facebook,观看 Netflix,或者通过 Khan Academy 学习计算机科学,你已经使用了一个用 React 构建的应用程序。
React 是一个用于构建用户界面的库。它是由 Facebook 的工程师创建的,自发布以来在 JavaScript 社区中引起了轰动。在过去几年中,它的受欢迎程度有所上升,并且是许多团队和工程师构建动态用户界面的首选工具。事实上,React 的 API、思维模型和强大的社区的结合导致了 React 在其他平台上的开发,包括移动和甚至虚拟现实。
在这本书中,你将探索 React,了解为什么它是一个如此成功和有用的开源项目。在第一部分中,你将从 React 的基础开始学习。由于构建健壮的 JavaScript UI 应用程序所涉及的工具可能非常复杂,我们将避免陷入工具的泥潭,专注于学习 React API 的细节。我们还将避免“魔法”,并努力对 React 及其工作原理有一个具体和深入的理解。
在第一章中,你将从高层次了解 React。我们将涵盖一些重要思想,如组件、虚拟 DOM 以及 React 的一些权衡。在第二章第二章中,你将快速浏览 React 的 API,并构建一个简单的评论框组件,以便亲身体验 React。
第一章. 认识 React
本章涵盖
-
介绍 React
-
React 的一些高级概念和范式
-
虚拟 DOM
-
React 中的组件
-
React 团队
-
使用 React 的权衡
如果你作为科技行业的 Web 工程师工作,你很可能听说过 React。也许是在 Twitter 或 Reddit 等在线平台。也许是一个朋友或同事向你提起,或者你在一次 Meetup 上听到了关于它的演讲。无论在哪里,我敢打赌你听到的可能是赞扬或有点怀疑。大多数人对于像 React 这样的技术都有强烈的看法。有影响力和影响力的技术往往会引起这种反应。对于这些技术,通常在技术流行并扩展到更广泛的受众之前,只有少数人最初“理解”它。React 就是这样开始的,但现在在 Web 工程领域享有巨大的流行度和使用率。而且它之所以受欢迎,有很好的理由:它有很多东西可以提供,并且可以重新激发、更新甚至改变你对用户界面思考和构建的看法。
1.1. 认识 React
React 是一个用于在各种平台上构建用户界面的 JavaScript 库。React 为你提供了一个强大的心智模型来工作,并帮助你以声明性和组件驱动的方式构建用户界面。我们将在本书的其余部分详细探讨这些想法以及更多内容,这就是 React 在广义上最简短的解释。
React 在更广泛的网络工程世界中处于什么位置?你经常会听到 React 与 Vue、Preact、Angular、Ember、Webpack、Redux 和其他知名 JavaScript 库和框架一起被提及。React 通常是前端应用程序的主要部分,并且与上述其他库和框架具有相似的功能。事实上,许多流行的前端技术现在在微妙的方式上更像是 React,而不是过去。曾经有一段时间,React 的方法是新颖的,但其他技术后来受到了 React 的组件驱动、声明式方法的影响。React 继续保持着重新思考既定最佳实践的精神,主要目标是向开发者提供一个表达性的心智模型和性能良好的技术来构建 UI 应用程序。
什么使 React 的心智模型强大?它借鉴了计算机科学和软件工程技术的深层次领域。React 的心智模型广泛借鉴了函数式和面向对象编程的概念,并专注于组件作为构建的主要单元。在 React 应用程序中,你从组件创建接口。React 的渲染系统管理这些组件,并为你保持应用程序视图的一致性。组件通常对应于用户界面的某些方面,如日期选择器、标题、导航栏等,但它们也可以负责客户端路由、数据格式化、样式和其他客户端应用程序的责任。
React 中的组件应该易于思考和与其他 React 组件集成;它们遵循可预测的生命周期,可以维护自己的内部状态,并且与“常规的 JavaScript”协同工作。我们将在本书的其余部分深入探讨这些想法,但我们可以现在从高层次上看看它们。图 1.1 为你概述了构建 React 应用程序的主要成分。让我们简要地看看每个部分:
-
组件—封装的功能单元,是 React 中的基本单元。它们利用数据(属性和状态)来渲染 UI 作为输出;我们将在第二章及以后章节中探讨 React 组件如何与数据协同工作。某些类型的 React 组件还提供了一组生命周期方法,你可以将其钩入。在 React 中,渲染过程(基于你的数据输出和更新 UI)是可预测的,并且你的组件可以使用 React 的 API 将其钩入。
-
React 库—React 使用一组核心库。核心 React 库与
react-dom和react-native库协同工作,专注于组件规范和定义。它允许你构建一个组件树,浏览器或其他平台的渲染器可以使用。react-dom是这样一个渲染器,旨在浏览器环境和服务器端渲染。React Native 库专注于原生平台,并允许你为 iOS、Android 和其他平台创建 React 应用。图 1.1. React 允许你从组件创建用户界面。组件维护自己的状态,使用并配合“纯”JavaScript 编写,并从 React 继承了许多有用的 API。大多数 React 应用是为基于浏览器的环境编写的,但也可以在 iOS 和 Android 等原生环境中使用。有关 React Native 的更多信息,请参阅 Nader Dabit 的《React Native in Action》,该书也由 Manning 出版。
![图片 1.1]()
-
第三方库—React 并不自带数据建模、HTTP 请求、样式库或其他前端应用常见方面的工具。这让你可以自由地在应用中使用额外的代码、模块或其他你偏好的工具。尽管这些常见技术并未与 React 一同打包,但围绕 React 的更广泛生态系统充满了极其有用的库。在这本书中,我们将使用其中的一些库,并在第十章和第十一章中探讨 Redux,这是一个用于状态管理的库。
-
运行 React 应用—你的 React 应用运行在你构建的平台之上。这本书专注于 Web 平台,并构建基于浏览器和服务器端的应用,但其他项目如 React Native 和 React VR 也为你的应用在其它平台上运行打开了可能性。
在这本书中,我们将花费大量时间探索 React 的方方面面,但在开始之前,你可能会有一些疑问。React 是否适合你?还有谁在使用 React?使用 React 或不使用 React 的一些权衡是什么?在采用这项新技术之前,这些问题对于新技术的了解非常重要。
1.1.1. 这本书面向的对象
这本书适合任何正在构建用户界面或对此感兴趣的人。实际上,它适合任何对 React 感兴趣的人,即使你不从事 UI 工程工作。如果你有一些使用 JavaScript 构建前端应用的经验,你将能从这本书中获得最大收益。
只要你掌握了 JavaScript 的基础知识并有一些构建 Web 应用程序的经验,你就可以学习如何使用 React 来构建应用程序。在这本书中,我不会涵盖 JavaScript 的基础知识。像原型继承、ES2015+代码、类型强制转换、语法、关键字、异步编码模式如 async/await 以及其他基本主题都不在本书的范围之内。我会简要地介绍与 React 特别相关的任何内容,但不会深入探讨 JavaScript 作为一门语言。
这并不意味着如果你不知道 JavaScript,就不能学习 React 或从这本书中学不到任何东西。但如果你花时间先学习 JavaScript,你会得到更多。在没有掌握 JavaScript 的实际知识的情况下盲目前进会使事情变得更难。你可能会遇到一些对你来说像是“魔法”的情况——事情会工作,但你不会明白为什么。这通常对作为开发者的你来说弊大于利,所以...最后警告:在学习 React 之前,先熟悉 JavaScript 的基础知识。它是一门非常表达性和灵活的语言。你会喜欢的!
你可能已经很好地了解了 JavaScript,甚至可能之前已经尝试过 React。鉴于 React 的流行程度,这并不令人惊讶。如果你是这样的人,你将能够更深入地理解 React 的一些核心概念。但如果你已经使用 React 一段时间,我不会涵盖你可能正在寻找的非常具体的话题。对于这些,请参阅其他与 React 相关的 Manning 书籍,如React Native in Action。
你可能不属于上述任何一组,可能想要对 React 有一个高级概述。这本书也适合你。你将学习 React 的基本概念,并且可以访问一个用 React 编写的示例应用程序——查看运行中的应用程序social.react.sh。你将能够看到在实践中构建 React 应用程序的基本方法以及它可能适合你的团队或下一个项目。
1.1.2. 关于工具的说明
如果你过去几年在前端应用程序方面的工作很多,你不会对应用程序周围的工具已经变得与框架和库本身一样成为开发过程的一部分而感到惊讶。你今天可能正在使用 Webpack、Babel 或其他工具。这些和其他工具在这个书中如何定位,你需要了解什么?
你不需要成为 Webpack、Babel 或其他工具的大师就能享受和阅读这本书。我创建的示例应用程序使用了几个重要的工具,你可以自由地阅读示例应用程序中的配置代码,但我不会在这本书中深入探讨这些工具。工具的变化很快,更重要的是,深入探讨这些主题会远远超出本书的范围。我会在工具与我们的讨论相关的地方确保注明,但除此之外,我会避免涉及。
我还觉得在学习像 React 这样的新技术时,工具可能会分散注意力。你已经在尝试理解一套全新的概念和范式了——为什么还要在学习复杂的工具上增加负担呢?这就是为什么第二章专注于首先学习“纯”React,然后再学习需要构建工具的 JSX 和 JavaScript 语言特性。你需要熟悉的一个工具领域是 npm。npm 是 JavaScript 的包管理工具,你将用它来安装项目的依赖项,并从命令行运行项目命令。你可能已经熟悉 npm 了,但如果不是,不要让这阻止你阅读这本书。你只需要最基础的终端和 npm 技能就可以继续前进。你可以在 docs.npmjs.com/getting-started/what-is-npm 上了解 npm。
1.1.3. 谁使用 React?
当涉及到开源软件时,谁在使用(以及谁没有使用)不仅仅是一个流行度的问题。它会影响你使用该技术的体验(包括支持、文档和安全修复的可用性),社区的创新水平,以及某个工具的潜在使用寿命。与拥有活跃社区、强大生态系统和多样化的贡献者经验和背景的工具一起工作通常更有趣、更容易,并且整体上更顺畅的体验。
React 最初是一个小项目,但现在拥有广泛的流行度和一个充满活力的社区。没有社区是完美的,React 的社区也不例外,但就开源社区而言,它拥有许多成功的重要元素。更重要的是,React 社区还包括其他开源社区的小子集。这可能令人望而生畏,因为生态系统可能看起来很庞大,但它也使社区变得强大和多样化。图 1.2 展示了 React 生态系统的地图。我在本书的整个过程中提到了各种库和项目,但如果你对了解 React 生态系统感兴趣,我已准备了一份指南 ifelse.io/react-ecosystem。我会随着时间的推移保持更新,并确保它与生态系统的演变保持一致。
图 1.2. React 生态系统图丰富多彩——甚至比我这里能展示的还要丰富。如果你想了解更多,请查看我的指南 ifelse.io/react-ecosystem,它将帮助你入门 React 生态系统。

你可能与 React 的主要互动方式可能是开源,但你可能每天都在使用用其构建的应用程序。许多公司以不同的方式使用 React。以下是一些使用 React 为其产品提供动力的公司:
-
Facebook
-
Netflix
-
New Relic
-
Uber
-
Wealthfront
-
Heroku
-
PayPal
-
BBC
-
Microsoft
-
NFL
-
以及更多!
-
Asana
-
ESPN
-
Walmart
-
Venmo
-
Codecademy
-
Atlassian
-
Asana
-
Airbnb
-
Khan Academy
-
FloQast
这些公司并不是盲目跟随 JavaScript 社区的潮流。它们有非凡的工程需求,这些需求影响了大量用户,并且必须在严格的截止日期内交付产品。有人会说,“我听说 React 很好;我们应该将一切 React 化!”这样的说法在经理或其他工程师那里是不会被接受的。公司和开发者想要好的工具,这些工具可以帮助他们更好地思考,快速行动,以便他们能够构建具有高影响力、可扩展和可靠的软件。
1.2. React 不做什么?
到目前为止,我一直在从高层次上谈论 React:谁在使用它,这本书是为谁写的,等等。我写这本书的主要目标是教你如何使用 React 构建应用程序,并赋予你作为工程师的权力。React 并不完美,但与它一起工作确实是一种乐趣,我看到了团队用它做了很多伟大的事情。我喜欢写关于它,用它构建,在会议上听关于它的演讲,偶尔就这个或那个模式进行热烈的辩论。
但如果我不谈论一些 React 的缺点并描述它不做什么,那我就对你不公平了。理解某物不能做什么和了解它能做什么一样重要。为什么?最好的工程决策和思考通常是基于权衡,而不是意见或绝对论(“React 在本质上比工具X更好,因为我更喜欢它”)。关于前者:你可能不是在处理两种完全不同的技术(COBOL 与 JavaScript);希望你甚至没有考虑那些与当前任务基本不匹配的技术。至于后者:构建伟大的项目和解决工程挑战永远不应该是关于意见的。并不是说人们的意见不重要——这当然是不正确的——而是意见并不能让事情变得更好或根本无法工作。
1.2.1. React 的权衡
如果权衡是良好软件评估和讨论的精髓,那么 React 有哪些权衡呢?首先,React 有时被称为仅仅是视图。这可能会被误解或理解错误,因为它可能会让你认为 React 只是一个像 Handlebars 或 Pug(原名 Jade)这样的模板系统,或者它必须作为 MVC(模型-视图-控制器)架构的一部分。这两者都不正确。React 可以是这两者,但还可以更多。为了使事情更简单,我将更多地从它是什么的角度来描述 React,而不是它不是什么(例如,“仅仅是视图”)。React 是一个声明式、组件化的库,用于构建用户界面,它可以在各种平台上工作:网页、原生、移动、服务器、桌面,甚至在未来还可以在虚拟现实平台上(React VR)。
这导致了我们的第一个权衡:React 主要关注 UI 的视图方面。这意味着它不是用来做更多全面框架或库的工作。与 Angular 等类似的东西的快速比较可能有助于阐明这一点。在其最近的主要版本中,Angular 在概念和设计方面与 React 的相似之处比以前更多,但在其他方面它覆盖的领域比 React 更广。Angular 包括以下方面的有偏见的解决方案:
-
HTTP 调用
-
表单构建和验证
-
路由
-
字符串和数字格式化
-
国际化
-
依赖注入
-
基本数据建模原语
-
自定义测试框架(尽管这与其他领域的区分并不那么重要)
-
默认包含服务工作者(一种执行 JavaScript 的工作者式方法)
这有很多,根据我的经验,人们通常有两种反应^([1])于框架带来的所有这些功能。要么是“哇,我不用自己处理所有这些”要么是“哇,我无法选择如何去做任何事情”。Angular、Ember 等框架的优点通常是做事有明确的方式。例如,Angular 中的路由是通过内置的 Angular Router 完成的,HTTP 任务都是通过内置的 HTTP 例程完成的,等等。
¹
并非有意为之的玩笑,但嘿,这是一本关于 React 的书,所以就这样吧。
这种方法并没有根本性的错误。我曾与使用这类技术的团队共事,也曾在更灵活的方向上工作的团队中工作,我们选择了“做好一件事”的技术。我们用这两种技术都取得了很好的成果,并且它们都很好地完成了它们的目的。我个人的偏好是选择自己的、做好一件事的方法,但这真的无关紧要;这完全是关于权衡。React 没有为 HTTP、路由、数据建模(尽管它确实对视图中的数据流有意见,我们将在后面讨论)或其他你可能看到在 Angular 中的事物提供有偏见的解决方案。如果你的团队认为这是在单一框架中绝对不能没有的东西,React 可能不是你的最佳选择。但根据我的经验,大多数团队希望 React 的灵活性加上它带来的心理模型和直观的 API。
React 灵活方法的优点之一是你可以自由地选择最适合工作的工具。不喜欢X HTTP 库的工作方式?没问题——用其他东西替换它。更喜欢用不同的方式做表单?实现它,没问题。React 为你提供了一套强大的原语来工作。公平地说,其他框架如 Angular 通常也会允许你替换东西,但事实上的和社区支持的做法通常是内置和包含的。
拥有更多自由显然的缺点是,如果你习惯于像 Angular 或 Ember 这样更全面的框架,你将需要为应用程序的不同领域提出或找到自己的解决方案。这可能是好事也可能是坏事,这取决于团队的开发者经验、工程管理偏好以及你具体情况的其他因素。对于“一刀切”以及“只做一件事做好”的方法有很多好的论据。我更倾向于那种让你随着时间的推移适应并灵活地做出工具决策的方法,这种方法将责任委托给工程团队来确定或创建正确的工具。还有那极其广泛的 JavaScript 生态系统需要考虑——你几乎找不到任何针对你正在解决的问题。但最终,事实仍然是,优秀的、有影响力的团队会使用这两种方法(有时同时使用!)来构建他们的产品。
在继续之前,如果不提一下锁定问题,那我就疏忽了。一个不可避免的事实是,JavaScript 框架很少真正是可互操作的;你通常不能有一个部分是 Angular,部分是 Ember,部分是 Backbone,部分是 React 的应用程序,至少在没有将每个部分分割开来或紧密控制它们交互的情况下是不可能的。当你能够避免这种情况时,将自己置于这种境地通常是没有意义的。你通常会为特定应用程序选择一个,最多再加上两个主要框架。
但是当你需要改变时会发生什么呢?如果你使用像 Angular 这样具有广泛职责的工具,迁移你的应用程序很可能会因为框架的深度惯用性而需要进行完全重写。你可以重写应用程序的较小部分,但你不能只是替换几个函数并期望一切都能正常工作。这正是 React 可以发光的地方。它采用了相对较少的“魔法”惯用性。这并不意味着迁移变得毫无痛苦,但它确实可以帮助你在迁移到或从 Angular 迁移时,避免承担紧密集成框架的成本。
当你选择 React 时,你做出的另一个权衡是,它主要是由 Facebook 开发和构建的,旨在满足 Facebook 的 UI 需求。如果你的应用程序与 Facebook 应用程序的 UI 需求在本质上不同,你可能会发现与 React 一起工作很困难。幸运的是,大多数现代 Web 应用程序都在 React 的技术领域内,但当然也有一些不在。这些可能还包括那些不适用于现代 Web 应用程序传统 UI 体系结构的程序,或者那些具有非常特定性能需求的程序(例如高速股票行情)。尽管如此,这些情况通常也可以通过 React 解决,尽管某些情况需要更具体的技术。
我们最后应该讨论的一个权衡是 React 的实现和设计。React 的核心中包含了处理组件数据变化时更新 UI 的系统。它们执行你可以通过某些称为生命周期方法的方法钩入的变化。我在后面的章节中详细介绍了这些内容。React 处理更新 UI 的系统使得构建模块化、健壮的组件变得容易,这些组件可以被你的应用使用。React 抽象出大部分保持 UI 与数据同步的工作,这是开发者喜欢与之合作以及为什么它是你手中的强大原语的一个重要原因。但不应假设与推动这项技术的“引擎”相关的没有缺点或权衡。
React 是一个抽象层,因此作为抽象层所带来的成本仍然存在。由于它是按照特定方式构建并通过 API 暴露的,因此你无法像使用其他系统那样深入了解你所使用的系统。这也意味着你需要以 React 的方式构建你的 UI。幸运的是,React 的 API 提供了“逃生门”,让你可以降级到更低的抽象级别。你仍然可以使用像 jQuery 这样的其他工具,但你需要以 React 兼容的方式使用它们。这又是一个权衡:以更简单的思维模型为代价,无法做到绝对地按照你的意愿做任何事情。
不仅你对底层系统的可见性有所降低,你还会接受 React 处理事情的方式。这通常会影响应用堆栈的一个较窄部分(只有视图而不是数据,特殊表单构建系统,数据建模等),但它确实会产生影响。我希望你能看到 React 的好处远远超过了学习的成本,而且在使用它时所做的权衡通常会使你作为一个开发者处于更好的位置。但假装 React 能神奇地解决你所有的工程挑战是不诚实的。
1.3. 虚拟 DOM
我们已经简要讨论了 React 的一些高级特性。我提出,React 可以帮助你和你的团队在创建用户界面方面变得更好,而这部分原因要归功于 React 提供的思维模型和 API。这一切背后的动力是什么?React 的一个主要主题是简化复杂的任务,并从开发者那里抽象出不必要的复杂性。React 试图做到足够多,以保持性能,同时让你有更多时间去思考应用的其他方面。它实现这一目标的主要方式之一就是鼓励你采用声明式而非命令式的方式。你可以声明你的组件在不同状态下应该如何表现和外观,而 React 的内部机制则处理管理更新、更新 UI 以反映变化等复杂性。
驱动这一技术的关键部分之一是虚拟 DOM。虚拟 DOM是一个数据结构或数据结构的集合,它模拟或反映了浏览器中存在的文档对象模型。我说a虚拟 DOM,因为其他框架如 Ember 使用它们自己类似技术的实现。一般来说,虚拟 DOM 将作为应用程序代码和浏览器 DOM 之间的中间层。虚拟 DOM 允许将变化检测和管理复杂性隐藏起来,并移动到专门的抽象层。在接下来的几节中,我们将从高层次的角度看看 React 中是如何实现这一点的。图 1.3 展示了我们将很快探讨的 DOM 和虚拟 DOM 关系的简化概述。
图 1.3. DOM 和虚拟 DOM。React 的虚拟 DOM 处理数据的变化检测,以及将浏览器事件转换为 React 组件可以理解和响应的事件。React 的虚拟 DOM 还旨在优化对 DOM 的更改,以提高性能。

1.3.1. DOM
确保我们理解 React 的虚拟 DOM 的最好方法是从检查我们对 DOM 的理解开始。如果你已经觉得自己对 DOM 有深刻的理解,那么请随意继续前进。但如果你还没有,让我们从一个重要的问题开始:什么是 DOM?DOM,或称文档对象模型,是一个编程接口,允许你的 JavaScript 程序与不同类型的文档(HTML、XML 和 SVG)进行交互。它有一套由标准驱动的规范,这意味着一个公开的工作组已经创建了一套标准的功能集和它应该如何表现的行为。尽管存在其他实现,但 DOM 在大多数情况下与像 Chrome、Firefox 和 Edge 这样的网络浏览器是同义的。
DOM 提供了一种结构化的方式来访问、存储和操作文档的不同部分。从高层次来看,DOM 是一个树结构,反映了 XML 文档的层次结构。这个树结构由子树组成,而这些子树又由节点组成。你可能知道这些是div和其他构成你的网页和应用程序的元素。
你可能之前已经使用过 DOM API,但你可能不知道你正在使用它。每次你使用 JavaScript 中的方法来访问、修改或存储与 HTML 文档中的某些内容相关的信息时,你几乎肯定正在使用 DOM 或其相关 API(有关更多关于 Web API 的信息,请参阅developer.mozilla.org/en-US/docs/Web/API)。这意味着你使用的并非 JavaScript 语言本身的全部方法(document.getElementById、querySelectorAll、alert等)。它们是更大的集合——Web API的一部分——包括 DOM 和其他浏览器中的 API,允许你与文档交互。图 1.4 展示了你可能在网页中见过的简化版 DOM 树结构。
图 1.4。这是一个使用你可能熟悉的元素的简单 DOM 树结构版本。暴露给 JavaScript 的 DOM API 允许你在树中对这些元素执行操作。

你可能使用过的一些常见方法或属性,用于更新或查询网页可能包括getElementById、parent.appendChild、querySelectorAll、innerHTML等。这些都是由宿主环境(在这种情况下,是浏览器)提供的,允许 JavaScript 与 DOM 交互。没有这种能力,我们将有远少于现在有趣的 Web 应用程序可以使用,也许就没有关于 React 的书可以写了!
与 DOM 交互通常很简单,但在大型 Web 应用程序的上下文中可能会变得复杂。幸运的是,当我们使用 React 构建应用程序时,我们通常不需要直接与 DOM 交互——我们主要让 React 来处理。有些情况下,我们想要超越虚拟 DOM 并直接与 DOM 交互,我们将在未来的章节中介绍这些情况。
1.3.2. 虚拟 DOM
浏览器中的 Web API 允许我们通过 DOM 使用 JavaScript 与 Web 文档交互。但如果我们已经可以这样做,为什么我们还需要在中间添加其他东西呢?首先我要声明,React 对虚拟 DOM 的实现并不意味着常规的 Web API 是不好或不如 React。没有它们,React 无法工作。然而,在大型 Web 应用程序中直接与 DOM 交互确实存在一些痛点。通常,这些痛点出现在变更检测的领域。当数据发生变化时,我们希望更新 UI 以反映这一点。以高效且易于思考的方式进行这一操作可能会很困难,因此 React 旨在解决这个问题。
导致该问题的部分原因是浏览器处理与 DOM 交互的方式。当一个 DOM 元素被访问、修改或创建时,浏览器通常会在一个结构化树中进行查询以找到指定的元素。这只是为了访问一个元素,这通常只是更新的一部分。更常见的情况是,它可能需要重新执行布局、尺寸和其他操作,作为突变的一部分——所有这些都可能具有计算成本。虚拟 DOM 不能解决这个问题,但它可以帮助优化 DOM 的更新,以考虑到这些限制。
当创建和管理一个处理随时间变化的数据的大规模应用程序时,可能需要对 DOM 进行许多更改,而且这些更改往往可能发生冲突或以非最佳方式完成。这可能导致一个过于复杂的系统,工程师难以工作,并且可能为用户提供的是次优体验——双方都输。因此,性能是 React 设计和实现中的另一个关键考虑因素。实现虚拟 DOM 有助于解决这个问题,但需要注意的是,它被设计成“足够快”。一个健壮的 API、简单的心理模型以及其他诸如跨浏览器兼容性等因素,最终成为 React 虚拟 DOM 比极端关注性能更为重要的成果。我提出这个观点的原因是,你可能会听到虚拟 DOM 被描述为一种性能的银弹。它是高效的,但它并不是魔法般的性能子弹,最终,许多其他的好处对于使用 React 来说更为重要。
1.3.3 更新和 diffing
虚拟 DOM 是如何工作的?React 的虚拟 DOM 在另一个软件世界中有一些建议相似之处:3D 游戏。3D 游戏有时采用一种渲染过程,大致如下:从游戏服务器获取信息,发送到游戏世界(用户看到的视觉表示),确定需要对视觉世界进行哪些更改,然后让显卡确定必要的最小更改。这种方法的一个优点是,你只需要处理增量更改的资源,通常可以比必须更新所有内容时更快地完成任务。
这是对 3D 游戏渲染和更新的方式的一种过于简化的描述,但总体思路为我们提供了一个很好的例子,当我们考虑 React 如何执行更新时。糟糕的 DOM 突变可能代价高昂,因此 React 试图在更新 UI 时保持高效,并采用类似于 3D 游戏的方法。
如图 1.5 所示,React 在内存中创建并维护一个虚拟 DOM,而像 React-DOM 这样的渲染器则负责根据变化更新浏览器 DOM。React 可以执行智能更新,并且只对已更改的部分进行工作,因为它可以使用 启发式差异比较 来计算内存中 DOM 的哪些部分需要更新到 DOM。理论上,这比“脏检查”或其他更粗暴的方法要流畅得多,但一个重要的实际影响是开发者有更简单的状态跟踪来推理。
图 1.5. React 的差异和更新过程。当发生更改时,React 确定实际 DOM 和内存中 DOM 之间的差异。然后它对浏览器 DOM 执行高效的更新。这个过程通常被称为 差异(“有什么变化?”)和补丁(“只更新变化的部分”)过程。

1.3.4. 虚拟 DOM:需要速度吗?
正如我之前提到的,虚拟 DOM 不仅仅是速度快。它通过设计就具有高性能,通常会产生快速、响应灵敏的应用程序,足以满足现代网络应用的需求。性能和更好的心理模型受到了工程师们的极大赞赏,以至于许多流行的 JavaScript 库都在创建它们自己的虚拟 DOM 版本或变体。即使在这些情况下,人们也倾向于认为虚拟 DOM 主要关注性能。性能是 React 的一个关键特性,但它是简单性的次级特性。虚拟 DOM 是使您能够推迟考虑复杂状态逻辑并专注于应用程序其他更重要部分的一部分。速度和简单性共同意味着用户和开发者都更快乐——双赢!
我已经花了一些时间讨论虚拟 DOM,但我不想给你一个印象,即它将是使用 React 的重要部分。实际上,你不需要深入思考虚拟 DOM 如何完成你的数据更新或如何改变你的应用程序。这是 React 简单性的一个部分:你被解放出来,可以专注于应用程序中最需要关注的部分。
1.4. 组件:React 的基本单元
React 不仅使用了一种新颖的方法来处理随时间变化的数据;它还关注组件作为组织应用程序的范例。组件是 React 最基本的单元。你可以用几种不同的方式使用 React 创建组件,未来的章节将介绍这些方法。从组件的角度思考对于理解 React 的预期工作方式以及如何在项目中最佳地使用它至关重要。
1.4.1. 组件概述
组件是什么?它是更大整体的一部分。组件的概念可能对你来说很熟悉,即使你可能没有意识到,你也很可能经常看到它们。在设计构建用户界面时使用组件作为思维和视觉工具可以导致更好的、更直观的应用程序设计和使用。组件可以是任何你决定的东西,尽管不是所有东西都适合作为组件。例如,如果你决定整个界面是一个组件,没有子组件或进一步的细分,你可能并没有帮到自己。相反,将界面的不同部分分解成可以组合、重用和轻松重组的部分是有帮助的。
为了开始从组件的角度思考,我们将查看一个示例界面并将其分解为其组成部分。图 1.6 展示了本书后面将要工作的界面示例。用户界面通常包含在其他界面部分中重复使用或重新定制的元素。即使它们没有被重复使用,它们至少是独特的。这些不同的元素,即界面的独特元素,可以被视为组件。图 1.6 中左侧的界面在右侧被分解为组件。
图 1.6. 将界面分解为组件的示例。每个独特的部分可以被视为一个组件。以统一的方式重复出现的项目可以被视为一个组件,该组件可以在不同的数据中重复使用。

组件思维
访问一个你经常使用且喜欢的网站(例如 GitHub)并将界面分解成组件。在这个过程中,你可能会发现自己将事物分成不同的部分。何时停止分解事物是有意义的?一个单独的字母是否应该是一个组件?何时一个小的组件是有意义的?何时将一组事物视为一个组件是有意义的?
1.4.2. React 中的组件:封装和可重用
React 组件具有良好的封装性、可重用性和可组合性。这些特性帮助实现了一种更简单、更优雅的方式来思考和构建用户界面。你的应用程序可以由清晰、简洁的组组成,而不是一团糟的意大利面代码。使用 React 构建你的应用程序几乎就像用乐高积木构建你的项目一样,只不过你不会用完积木。你可能会遇到错误,但幸运的是,没有积木可以踩到。
在练习 1.1 中,你练习了使用组件进行思考,并将接口分解为一些组成部分。你可以以任何数量种方式来做这件事,也许你可能并没有特别有组织或一致。这没关系。但是当你使用 React 中的组件时,考虑组件设计的组织和一致性将变得非常重要。你将希望设计出自我包含的组件,专注于特定的关注点或一系列相关关注点。
这使得组件具有可移植性、逻辑分组,并且易于在应用程序中移动和重用。即使它利用了其他库,一个设计良好的 React 组件应该相当自我包含。将你的 UI 分解为组件可以让你更轻松地在应用程序的不同部分工作。组件之间的边界意味着功能和组织可以很好地定义,而自我包含的组件意味着它们可以更容易地重用和移动。
React 中的组件旨在协同工作。这意味着你可以组合组件以形成新的复合组件。组件组合是 React 最强大的特性之一。你可以创建一个组件,并将其提供给应用程序的其他部分以供重用。这在大型应用程序中尤其有帮助。如果你在一个中到大型团队中,你可以将组件发布到私有注册表(npm 或其他),其他团队可以轻松拉取并用于新项目或现有项目。这可能不是所有规模团队的实际情况,但即使是小型团队也会从 React 组件促进的代码重用中受益。
React 组件的最后一个方面是生命周期方法。这些是在组件在其生命周期(挂载、更新、卸载等)的不同部分移动时可以使用的可预测、定义良好的方法。我们将在未来的章节中花费大量时间讨论这些方法。
1.5. React 团队
现在,你对 React 中的组件有了更多了解。React 可以使个人开发者的生活变得更轻松。但在团队中呢?总的来说,使 React 对个人开发者有吸引力的因素,也可能使其成为团队的绝佳选择。像任何技术一样,React 并不是每个用例或项目的完美解决方案,无论炒作如何,或者狂热的开发者可能试图让你相信什么。正如你之前看到的,React 有很多事情不做。但它所做的事情,它做得非常出色。
什么让 React 成为大型团队和大型应用的优秀工具?首先,使用它的简单性。简单性与易用性不同。易用的解决方案通常很脏、很快,最糟糕的是,它们可能会产生技术债务。真正简单的技术是灵活且健壮的。React 提供了强大的抽象,仍然可以在必要时深入到底层细节。简单技术更容易理解和操作,因为简化流程和去除不必要的部分的工作已经完成。在许多方面,React 让简单变得容易,提供了一个有效的解决方案,而没有引入有害的“黑魔法”或模糊不清的 API。
所有这些对个人开发者来说都是非常好的,但在更大的团队和组织中效果更明显。尽管 React 当然还有改进和继续发展的空间,但使其成为一个简单且灵活技术的辛勤工作对工程团队来说是值得的。具有良好心智模型的技术越简单,对工程师的心理负担就越小,让他们可以更快地工作并产生更大的影响。作为额外的好处,一套更简单的工具对新员工来说更容易学习。试图让新团队成员快速适应过于复杂的堆栈不仅会花费培训工程师的时间,而且新开发者可能需要一段时间才能做出有意义的贡献。因为 React 寻求仔细重新思考既定的最佳实践,所以在范式转换的初期会有成本,但之后通常是一个大型的、长期的胜利。
尽管 React 与其他同一领域中的工具相比确实不同,但在责任和功能方面,React 是一个相当轻量级的库。例如,Angular 可能要求你“接受”一个更全面的 API,而 React 只关注你的应用程序视图。这意味着它更容易与你的现有技术集成,并为你留下选择其他方面的空间。一些有偏见的框架和库要求全有或全无的采用立场,但 React 的“仅视图”范围和与 JavaScript 的通用互操作性意味着这并不总是如此。
你不必全盘接受,可以逐步将不同的项目或工具过渡到 React,而不必对结构、构建堆栈或其他相关领域进行剧烈的改变。这对几乎任何技术来说都是一个理想的特性,这也是 React 最初在 Facebook 上尝试的方式——在一个小的项目区域内。从那里,它逐渐成长并站稳脚跟,因为越来越多的团队看到了它的好处并亲身体验了它。这对你的团队意味着什么?这意味着你可以评估 React,而无需承担完全使用 React 重写产品的风险。
React 的简洁性、无偏见特性和性能使其非常适合大小项目。随着你对 React 的不断探索,你会看到它如何适合你的团队和项目。
1.6. 摘要
React 是一个用于创建用户界面的库,最初由 Facebook 构建并开源。它是一个以简洁、性能和组件为设计理念的 JavaScript 库。它不提供创建应用程序的完整工具集,而是允许你选择如何实现你的数据模型、服务器调用和其他应用程序关注点,以及使用什么来实现它们。这些关键原因以及其他原因使得 React 可以成为小型和大型应用程序和团队的优秀工具。以下是 React 对一些典型角色的简要总结:
-
个人开发者— 一旦你学会了 React,你的应用程序可以更容易地快速构建。它们将更容易为大型团队工作,并且复杂功能可以更容易地实现和维护。
-
工程经理— 开发者学习 React 时会有一个初始成本,但最终他们能够更容易、更快地开发复杂的应用程序。
-
CTO 或高级管理层— React,就像任何技术一样,是一个有风险的投入。但最终的生产力提升和减轻心理负担往往超过了投入的时间。并非每个团队都是这样,但对于许多团队来说,这是真的。
总的来说,React 对于工程师的入门相对容易,可以减少应用程序中不必要的复杂性的总量,并通过促进代码重用减少技术债务。花点时间回顾一下你到目前为止学到的关于 React 的内容:
-
React 是一个用于构建用户界面的库,最初由 Facebook 的工程师创建。
-
React 提供了一个简单、灵活的 API,它围绕组件构建。
-
组件是 React 的基本单元,并且在 React 应用程序中被广泛使用。
-
React 实现了一个虚拟 DOM,它位于你的程序和浏览器 DOM 之间。
-
虚拟 DOM 允许使用快速的 diffing 算法高效地更新 DOM。
-
虚拟 DOM 允许出色的性能,但最大的优势是它提供的心理模型。
现在你对 React 的背景和设计有了更多了解,我们可以真正深入研究了。在下一章中,你将创建你的第一个组件,并更深入地了解 React 的工作原理。你将了解更多关于虚拟 DOM、React 中的组件以及如何创建自己的组件。
第二章. :我们的第一个组件
本章涵盖
-
使用组件思考用户界面
-
React 中的组件
-
React 如何渲染组件
-
在 React 中创建组件的不同方法
-
在 React 中使用 JSX
第一章主要从理论角度介绍了 React。如果您是“给我看代码!”这类人,那么这一章就是为您准备的。我们将从本章开始近距离观察 React。随着我们深入了解 React 的一些 API,您将构建一个简单的评论框,这将帮助您看到 React 的实际运作机制,并开始巩固 React 工作原理的内心模型。我们将从构建没有任何“语法糖”或可能掩盖底层技术的便利性的 React 组件开始。我们将在本章末尾探讨 JSX(一种轻量级标记语言,有助于我们更轻松地构建 React 组件)。在后面的章节中,我们将更加复杂,并了解如何从 React 组件中创建完整的应用程序(Letters Social—请查看social.react.sh),但在这章中,我们将保持范围仅限于几个相关组件。
在深入之前,让我们再次简要地回顾一下 React,以便定位自己。图 2.1 为您概述了大多数 React 应用程序的核心方面。让我们看看每个部分:
-
组件—封装的功能单元是 React 的基本单元。这些是构成您视图的元素。它们是接收属性作为输入的 JavaScript 函数或类,并维护自己的内部状态。React 为某些类型的组件提供了一套生命周期方法,以便您可以在不同的组件管理步骤中挂钩。
-
React 库—**React 应用程序使用 React 库运行。核心 React 库(
react)由react-dom和react-native库支持。React DOM 处理浏览器或服务器端环境中的渲染,而 React Native 提供原生绑定,这意味着您可以为 iOS 或 Android 创建 React 应用程序。 -
第三方库—React 在数据建模、HTTP 调用、特定区域的样式(如外观和感觉)或其他应用程序方面不强制您有任何观点。对于这些,您将集成其他技术来构建您认为合适的应用程序。并非所有库都与 React 兼容,但您可以通过一些方法将大多数库与 React 集成。我们将在第四章、第十章和第十一章中探讨在 React 应用程序中使用非 React 代码。
-
运行 React 应用程序—**您从组件创建的 React 应用程序可以在您选择的平台上运行:Web、移动或原生。
图 2.1. 这是在非常高的层次上对 React 的描述,你可能从第一章中已经认识到了。使用 React,你可以使用组件来构建可以在浏览器和原生平台(如 iOS 和 Android)上运行的用户界面。它不是一个全面的框架——它给你自由选择用于数据建模、样式、HTTP 调用等方面的库。你可以在浏览器中运行 React 应用程序,并在 React Native 的帮助下在移动设备上运行。

2.1. 介绍 React 组件
组件是使用 React 编写的客户端应用程序的基本单元。你肯定会创建很多组件!在本章中,你将从组件构建一个简单的评论框,以便亲身体验并快速了解 React。但首先,让我们花一点时间来探索“以组件思考”的方式,看看这对你的评论框会有什么影响。在本书的大部分内容中,我们通常会直接进入代码编写,而不花太多时间去规划,但在这个 React 的首次尝试中,我们将进行一些规划,以调整我们的心态。请参阅图 2.2。
图 2.2. React 组件概述。在本书的剩余部分,我们将探索这些关键部分的每个部分。

在本书中,我们将假装自己是虚构初创公司 Letters 的员工。你将构建一个下一代社交网络(你可以发布、评论和点赞——真正具有革命性的)。在本章中,我们正在探索 React 作为你公司潜在的技术选择。你被分配了一个创建一组简单组件的任务,以了解这项技术。你有一些设计团队给你的非常粗糙的原型,但仅此而已。图 2.3 展示了你将构建的版本。
图 2.3. 粗略的评论框原型。你将创建一个用户可以添加评论并查看先前评论的用户界面。

你应该如何开始构建这个?让我们先了解你的应用程序需要的数据,然后看看你如何将其转换为组件。你应该如何将原型转换为组件?你可以直接跳入并尝试创建组件,而不了解 React 的任何知识,但如果不了解它们的工作原理或它们将服务的目的,你可能会创建出混乱或不符合 React 习惯的东西。在接下来的几节中,我们将进行一些规划,以便你更好地了解如何构建和设计你的评论框。
回顾你的界面分解
在继续之前,花点时间回顾一下上一章的一个练习。您查看了一个 Web 界面,并花了一些时间自己将其分解。花一分钟时间回顾一下相同的界面,看看现在您是否会对组件有更多的了解而做出不同的处理。您会以不同的方式将事物分组吗?以下是来自第一章的相同标记的 GitHub 个人资料界面,以帮助您回忆:

2.1.1. 理解应用程序数据
除了原型之外,在我们规划如何组织您的组件之前,我们还需要其他一些东西。我们需要知道 API 将向您的应用程序提供哪些信息。根据原型,您可能已经可以猜测一些可能返回的数据。对您的应用程序数据形状的感知将是我们在开始创建 UI 之前规划的重要部分。
Web APIs
您可能已经在工作中或学习中经常听到API这个词。如果您已经熟悉这个概念,请随意继续。如果不熟悉,本节可能对您有所帮助。什么是 API?API,或应用程序编程接口,是一组用于构建软件的例程和协议。这可能听起来有些模糊,这是一个相当通用的定义。API 是一个广泛的概念,适用于从公司平台到开源库的各个方面。
在 Web 开发和工程中,API 几乎成了远程、基于 Web 的公共API 的同义词。这意味着 API 通常是一种暴露定义好的与程序或平台交互方式的方法,通常通过互联网,供人们使用和消费。有许多例子,但其中两个更熟悉的是 Facebook 和 Stripe API。它们提供了一套通过 Web 与它们的程序和数据交互的方法。
我们虚构的公司 Letters 的后端基础设施团队为您创建了一个这样的 API。基于 Web 的 API 有许多不同的形式和类型,但您在本书中将使用的是RESTful JSON API。这意味着服务器将以 JSON 格式为您提供数据,并将可用的数据组织在用户、帖子、评论等资源周围。RESTful JSON API 是远程 API 的一种常见样式,所以如果您之前没有这样做过,这很可能不是您唯一一次与之合作。
以下列表显示了您将从 API 收到的数据示例,以及它如何与您的原型相匹配。
列表 2.1. 示例 JSON API
{
"id": 123, *1*
"content": "What we hope ever to do with ease, we must first learn to do
with diligence. — Samuel Johnson",
"user": {
"name": "Mark Thomas",
"id": 1
},
"comments": [{ *2*
"id": 0, *3*
"user": "David",
"content": "too. mainstream."
}, {
"id": 1,
"user": "Peter",
"content": "Who was Samuel Johnson?"
}, {
"id": 2,
"user": "Mitchell",
"content": "@Peter get off Letters and do your homework!"
}, {
"id": 3,
"user": "Peter",
"content": "@mitchell ok dad :P"
}]
}
-
1 这在视觉原型中并未出现,但这并不意味着您不需要这块数据。
-
2 您已收到一组评论对象。
-
3 评论也有 ID。
API 返回一个包含单个帖子的 JSON 响应。它包含一些重要的属性,包括 id、content、author 和 comments。id 是一个数字,content 和 author 是字符串,而 comments 是一个对象数组。每个评论都有自己的 ID、发表评论的用户和评论内容。
2.1.2. 多个组件:组合和父子关系
您拥有我们所需的数据和原型,但您如何着手构建组件以使用这些数据呢?首先,您需要了解组件如何与其他组件组织在一起。React 组件被组织成树状结构。就像 DOM 元素一样,React 组件可以嵌套,并包含其他组件。它们也可以“并列”于其他组件旁边,这意味着它们与其他组件处于同一级别(见图 2.4)。
图 2.4. 组件可以有不同的关系类型(父和子),可以用来创建其他组件,甚至可以独立存在。由于它们是自包含的,并且在移动时不会携带任何“负担”,因此它们被称为可组合的。

这引发了一个重要的问题:组件可以有什么样的关系?您可能会认为使用组件可以创建相当多的不同类型的关系,从某种意义上说,您是对的。组件可以以灵活的方式使用。因为它们是自包含的,并且通常不会携带任何“负担”,所以它们被称为可组合的。
可组合的组件通常很容易移动,并且可以重用来创建其他组件。您可以将它们想象成几乎像乐高积木。每个乐高积木都是自包含的,因此可以轻松移动——您不需要携带整个套装就可以移动一个积木,并且它们很容易与其他组件配合。便携性不是万能的,但它通常是设计良好的 React 组件的一个特性。
由于组件是可组合的,它们可以在应用程序的许多地方使用。无论组件在哪里使用,它都可能有助于形成某种类型的关系:父和子。如果一个组件包含另一个组件,那么它被称为父组件。另一个组件内部的组件被称为子组件。处于同一级别的组件之间没有直接的直接关系,尽管它们可能紧挨着。它们只“关心”它们的父亲和子代。
图 2.4 展示了组件如何以父子方式相互关联,并组合在一起以创建新的组件。请注意,尽管存在直接的父子关系,但两个兄弟组件之间没有直接的关系。当我们在 React 中探索数据流时,我会对此进行更多介绍。
2.1.3. 建立组件关系
我们已经对您界面的数据和视觉外观有了感知,以及父组件和子组件可以形成的关系组件。现在您可以开始定义您的组件层次结构了,这是应用您所学知识的过程。您将确定哪些将成为组件以及它们将位于何处。这个建立组件关系的过程对于每个团队或每个项目来说可能看起来都不一样。组件关系也可能会随时间而变化,所以不要期望第一次就能完美无缺。在 UI 中更容易进行迭代是 React 易于使用的一部分原因。
在我们继续之前,花一两分钟尝试将原型分解成组件。您已经这样做过几次了,但通过组件思考的练习将使使用 React 变得更加容易。在练习时,请记住以下几点:
-
确保组件以有意义的分组方式组织在一起;组件应该围绕相关的功能进行组织。如果无法在您的应用程序中移动组件,您可能创建了一个过于僵化的层次结构。这并不总是这种情况,但这是一个值得注意的地方。
-
如果您看到界面元素被多次重复,那么通常将其变成组件是一个很好的候选。
-
您第一次可能无法做到完美,这是完全可以接受的。迭代地改进您的代码是正常的。最初的规划并不是为了消除未来的变化,而是为了设定正确的起始方向。
在考虑这些指南的同时,您可以查看可用的数据和原型,并开始将它们分解成几个组件。图 2.5 展示了将界面分解成组件的一种方法。
图 2.5。您可以将界面分解成仅仅几个组件。请注意,您不一定需要为界面的每个元素都创建组件,尽管随着应用程序的增长,将更多部分分解成组件可能是有意义的。您还会注意到,相同的评论组件将被用于每个附加到帖子上的评论。此外,请注意,我为了可读性在这里的旁边绘制了图表;您可能直接在所有内容上方绘制线条。

使用 React,您可以在设计应用程序时保持灵活性。我们提出了四个组件,但您可能有多种方式来划分这些内容。React 强制组件之间建立父子关系,但除此之外,您可以根据您和您的团队认为最有意义的方式来定义您的层次结构。例如,可能会有这样的情况,您将 UI 的一个小部分分解成许多不同的部分。UI 的大小并不直接关系到它应该由多少或多少个组件组成。
现在我们已经完成了一些初步规划,你就可以开始深入创建你的评论框 UI 了。在下一节中,你将开始创建 React 组件。你将不会使用任何像 JSX 这样的语法辅助工具。相反,我们将专注于“原始”React,你将在使用这些辅助工具之前先了解该技术的核心机制。
你可能会因为不得不放弃在正常 React 开发中使用的某些辅助工具而感到沮丧。我很高兴这样,因为这可能意味着你将更加真诚地欣赏和理解你将与之工作的抽象。虽然并不总是如此,但根据我的经验,从新技术的基础元素开始通常能更好地为你长期使用该技术做好准备。我们当然不需要用汇编代码编写我们的 JavaScript 程序,但我们也不要使用一个对核心机制理解不完整的技术。
2.2. 在 React 中创建组件
在本节中,你将创建一些 React 组件并在浏览器中运行它们。目前,你不需要使用 node.js 或其他任何东西来设置和运行一切。你将通过 CodeSandbox (codesandbox.io) 在浏览器中运行代码。如果你更喜欢在本地编辑文件,你可以在 CodeSandbox 代码编辑器中点击下载,以获取该示例的代码。
你将使用三个库来创建你的第一个组件:React、React DOM 和 prop-types。React DOM 是从主 React 库中分离出来的一个渲染器,以更好地分离关注点;它负责将组件渲染到 DOM 或字符串(用于服务器端渲染)。prop-types 库是一个开发库,它将帮助你对你组件传递的数据进行类型检查。
你将通过首先创建一些组成部分来开始创建评论框组件。这将帮助你更好地理解 React 创建和渲染组件时整体会发生什么。你需要添加一个新的 DOM 元素,其 ID 为 root,以及一些使用 React DOM 的基本代码。以下列表显示了组件的裸骨起始点。对于每个列表,我都会包含一个链接,指向你可以轻松编辑和尝试的在线运行代码版本。
列表 2.2. 开始
//... index.js
const node = document.getElementById("root"); *1*
//... index.html
<div id="root"></div> *2*
-
1 保存对根元素的引用——你将在此 DOM 元素中渲染你的 React 应用程序。
-
2 在你创建的 index.html 文件中,你已经创建了一个具有 id ‘root’ 的 div 元素。
列表 2.2 的代码可在网上找到,链接为 codesandbox.io/s/vj9xkqzkvy。
2.2.1. 创建 React 元素
到目前为止,你的代码除了下载 React 库和找到root DOM 元素之外,不会做太多的事情。要使某些实质性的事情发生,你需要使用React DOM。你需要调用它的render方法,React 才能创建和管理你的组件。你将使用一个组件来调用这个方法,以及container(这将是之前存储在变量中的 DOM 元素)。ReactDOM.render的签名看起来像这样:
ReactDOM.render(
ReactElement element,
DOMElement container,
[function callback]
) -> ReactComponent
React DOM需要一个类型为ReactElement的元素和一个 DOM 元素。你已经创建了一个有效的 DOM 元素可以使用,但现在你需要创建一个 React 元素。但什么是 React 元素呢?
定义
React 元素是 React 中一个轻量级、无状态、不可变的原语。有两种类型:ReactComponentElement和ReactDOMElement。ReactDOMElements 是 DOM 元素的虚拟表示。ReactComponentElements 引用一个与 React 组件对应的函数或类。
元素是我们用来告诉 React 我们在屏幕上想要看到什么内容的描述符,并且在 React 中是一个核心概念。你大部分的组件都将是 React 元素的集合;它们会在你的 UI 的一部分周围创建一种“边界”,这样你就可以将功能、标记和样式组合在一起。但是,对于一个 React 元素来说,成为 DOM 元素的虚拟表示意味着什么呢?这意味着 React 元素对于 React 来说,就像 DOM 元素对于 DOM 一样——组成 UI 的基本原语。当你创建普通的 HTML 标记时,你使用各种元素类型(如div、span、section、p、img等)来包含和结构化信息。在 React 中,你可以使用 React 元素——这些元素告诉 React 你想要渲染的 React 组件或常规 DOM 元素——来组合和构建你的 UI。
可能 DOM 元素和 React 元素之间的平行关系一开始没有立即让你感到共鸣。没关系。记得 React 应该通过创建更好的心智模型来帮助你吗?DOM 元素和 React 元素之间的平行关系是它这样做的一种方式。这意味着你得到了一个熟悉的心智结构来工作:一个类似于常规 DOM 元素的元素树结构。图 2.6 将帮助你可视化 React 元素和 DOM 元素之间的一些相似之处。
图 2.6。虚拟的“真实”DOM 共享一个类似的树状结构,这使得你在 React 中以类似的方式思考组件和整体应用程序的结构变得容易。DOM 由DOMElements(HTMLElements和SVGElements)组成,而 React 的虚拟 DOM 由 React 元素组成。

另一种思考 React 元素的方式是将其视为一组基本指令,供 React 使用,就像 DOM 元素的蓝图。React 元素是 React DOM 将取用并用于更新 DOM 的。在 图 2.7 中,React 元素被用于 React 应用程序的整体过程中。
图 2.7. React 元素是 React 用于创建虚拟 DOM 的,React DOM 将会管理和使用它来协调和更新实际的 DOM。它们是 React 创建和管理元素的简单蓝图。

你现在对 React 元素有了更多的了解,但它们是如何被创建的,以及创建它们时发生了什么?你使用 React.createElement 来创建 React 元素—想想看!让我们看看它的函数签名,以了解如何使用它:
React.createElement(
String/ReactClass type,
[object props],
[children...]
) -> React Element
React.createElement 接收一个字符串或组件(要么是一个扩展 React.Component 的类,要么是一个函数),一个 props 对象,以及子元素,并返回一个 React 元素。记住,React 元素是你想要 React 渲染的东西的轻量级表示。它可以指示一个 DOM 元素或另一个 React 组件。
让我们更仔细地看看这些基本指令中的每一个:
-
type— 你可以传入一个字符串,它是要创建的 HTML 元素的标签名(例如"div","span","a"等)或一个 React 类,我们稍后会讨论。把这个参数想象成 React 在问,“我将要创建什么类型的东西?” -
props— 简称 属性。props对象提供了一种指定在 HTML 元素上定义哪些属性(如果是在ReactDOMElement的上下文中)或将可用于组件类实例的方法。 -
children...— 记得我之前说过 React 组件是 可组合 的吗?这就是你可以进行组合的地方。使用children...,在type和props之后传入的参数让你可以嵌套、排序,甚至进一步嵌套其他 React 元素。正如你在 列表 2.3 中可以看到的,你可以在children...中通过嵌套对React.createElement的调用来嵌套 React 元素。
React.createElement 会问,“我在创建什么?”,“我应该如何配置它?”,“它包含什么?”下面的列表展示了如何使用 React.createElement。
列表 2.3. 使用 React.createElement
...
import React, { Component } from 'react'; *1*
import { render } 'react-dom';
const node = document.getElementById('root');
const root = *2*
React.createElement('div', {}, // *3*
React.createElement('h1', {}, "Hello, world!", // *3*
React.createElement('a', {href: 'mailto:mark@ifelse.io'}, *4*
React.createElement('h1', {}, "React In Action"),
React.createElement('em', {}, "...and now it really is!") *5*
)
)
);
render(root, node); // *6*
...
-
1 导入 React 和 React DOM 以便使用。
-
2 React.createElement 返回一个单一的 React 元素,所以这就是你将存储在 root 中以供以后使用的内容。
-
3 空白字符有助于更好地显示嵌套,但不要错过你如何在各自的子...参数中嵌套多个 React.createElement 调用。
-
4 创建一个锚点链接—注意你设置的 mailto 属性,就像在常规 HTML 中做的那样。
-
5 子元素也可以传入内联文本。
-
6 调用我们之前提到的 render 方法。
列表 2.3 的代码可在网上找到,链接为 codesandbox.io/s/qxx7z86q4w。
2.2.2. 渲染你的第一个组件
现在,你应该能看到除了空白页面之外的内容,如图 2.8 所示。你刚刚创建了你第一个 React 组件!使用浏览器中的开发者工具,尝试打开页面并检查 HTML。你应该看到与使用 React 创建的 HTML 元素相对应的内容。请注意,你传递的属性也已经通过,所以你可以点击链接并发送电子邮件告诉我你对学习 React 有多喜爱。
图 2.8. 你的第一个组件。它不多,但你已成功使用 React 创建了一个组件。

这很好,但你可能想知道 React 是如何将你的许多React.createElement转换成你在屏幕上看到的内容的。React 使用你提供的 React 元素创建一个虚拟 DOM,React DOM可以使用它来管理浏览器 DOM。记得从图 2.4 中了解虚拟和真实 DOM 具有相似的结构吗?嗯,React 需要在开始工作之前,从你的 React 元素中形成自己的虚拟 DOM 树结构。
为了做到这一点,React 会递归地评估每个React.createElement调用中的所有children...属性,并将结果传递给父元素。你可以将 React 这样做想象成一个小孩反复地问,“X 是什么?”直到他们理解 X 的每一个细节。图 2.9 展示了你如何思考 React 评估嵌套的 React 元素。沿着箭头向下和向右移动,看看 React 是如何检查每个 React 元素的children...,直到它能形成一个完整的树。
图 2.9. React 会递归地评估一系列 React 元素,以确定它应该如何为你的组件形成虚拟 DOM 树结构。它还会检查children...中的更多 React 元素以进行评估。React 会遍历所有可能的路径,就像一个孩子问,“X 是什么?”直到他们知道一切。你可以沿着箭头向下和向右移动,以了解 React 如何评估嵌套的 React 元素以及每个参数在询问什么。

现在你已经创建了你的第一个组件,你可能会有一些疑问,甚至一些担忧。即使有一些格式化帮助,很明显,阅读嵌套几层深的组件将会很困难。我们将探讨更好的编写组件的方法,所以请不要担心——你不会将React.createElement嵌套数百次。现在使用它将帮助你更好地理解React.createElement的作用,并希望在你开始大量使用它时,能更欣赏 JSX。
您可能还担心您所创建的内容似乎过于简单。到目前为止,React 似乎像是一个冗长的 JavaScript 模板系统。但 React 可以做更多的事情:进入组件。
React 元素
在继续学习组件之前,检查您对 React 元素的了解。无论是在纸上还是在您的脑海中,列出一些 React 元素的特征。在继续学习之前,这里有一些 React 元素的特征来帮助您回忆:
-
React 元素接受一个字符串来创建一种 DOM 元素(例如
div、a、p等)。 -
您可以通过 props 对象向 React 元素提供配置;这些类似于 DOM 元素可以有的属性(例如
<img src="aUrl"/>)。 -
React 元素是可嵌套的,并且您可以为元素提供其他 React 元素作为子元素。
-
React 使用 React 元素来创建虚拟 DOM,而
React DOM可以在更新浏览器 DOM 时使用它。 -
React 元素是 React 中组件的构成部分。
2.2.3. 创建 React 组件
如您可能已经猜到的,仅使用 React 元素和 React.createElement 来创建 UI 的部分对您来说并没有什么帮助,除了管理 DOM。您仍然可以将事件处理程序作为 props 传递以处理点击或输入更改,传递其他数据以显示,甚至嵌套元素。但您仍然会错过 React 提供的 持久状态、生命周期方法,这些方法将为您提供与组件交互的预测性方式,以及组件可能提供的任何类型的逻辑分组。您肯定想找到一种方法来将 React 元素组合在一起。
您可以通过组件来实现这一点。组件的作用是将功能、标记、样式和其他相关的 UI 元素捆绑和分组在一起。它们充当 UI 部分的一种边界,这些部分还可以包含其他组件。组件可以是独立且可重用的部分,允许您单独考虑每个部分。
您可以使用函数和 JavaScript 类创建两种主要类型的组件。我将在未来的章节中介绍第一种类型,即无状态函数式组件。现在我们将讨论第二种类型:使用 JavaScript 类创建的 有状态 React 组件。从现在开始,当我提到 React 组件时,我指的是由类或函数创建的组件。
2.2.4. 创建 React 类
要真正开始构建某些内容,您需要的不仅仅是 React 元素;您还需要组件。如前所述,React 组件(由函数创建的组件)类似于 React 元素,但具有更多功能。React 中的组件是帮助将 React 元素和功能组合在一起的类。它们可以作为扩展 React.Component 基类或函数的类来创建。本节将探讨 React 类以及如何在 React 中使用此类组件。让我们看看如何创建一个 React 类:
class MyReactClassComponent extends Component {
render() {}
}
与您使用 React.createElement 时调用 React 库中的特定方法不同,从 React.Component 创建组件是通过声明一个继承自 React.Component 抽象基类的 JavaScript 类来完成的。这个继承类通常需要定义至少一个将返回单个 React 元素或 React 元素数组的渲染方法。创建 React 类的旧方法是通过 createClass 方法。随着 JavaScript 中类的出现,这种方法已经改变,现在不推荐使用,尽管您仍然可以使用在 npm 上可用的 create-react-class 模块。有关使用 ES2015+ JavaScript 之外的 React 的更多信息,请参阅 reactjs.org/docs/react-without-es6.html。
2.2.5. 渲染方法
我们将开始通过前面提到的 render 方法探索创建作为 React 类的组件。这是您在 React 应用程序中看到的最常见方法之一,几乎任何将内容渲染到屏幕上的组件都将有一个 render 方法。我们最终将探索那些不直接渲染任何内容,而是修改或增强其他组件(有时称为 高阶 组件)的组件。
渲染方法需要返回恰好一个 React 元素。以这种方式,渲染方法与创建 React 元素的方式相似——它们可以嵌套,但在最高级别只有一个节点。然而,与 React 元素不同,React 类的 render 方法可以访问嵌入的数据(持久内部组件状态)以及组件方法和从 React.Component 抽象基类继承的额外方法(所有这些我将在后面介绍)。我提到的持久状态对整个组件都是可用的,因为 React 为此类组件创建了一个“后备实例”。这也是为什么您会听到这些类型的组件被称为 有状态 组件的原因。
所有这些都意味着 React 将为 React 类的一个实例(不是蓝图本身)创建并跟踪一个特殊的数据对象,这个对象会随时间保持存在,并且可以通过特殊的 React 函数进行更新。我将在未来的章节中详细介绍这一点,但 图 2.10 展示了 React 类如何获得后备实例,而 React 元素则不会。
图 2.10. React 将在内存中为作为 React 组件类创建的组件创建一个后备实例。正如您所看到的,React 组件类会得到一个,而 React 元素和非 React 类组件则不会。请记住,React 元素是 DOM 的镜像,而组件是分组它们的方式。后备实例是为特定组件提供数据存储和访问的一种方式。存储在实例中的数据将通过特定的 API 方法提供给组件的渲染方法。这意味着您可以访问可以更改并且会随时间持久化的数据。

当使用 React 类创建组件时,你还可以访问 props——你可以传递给组件的数据,组件也可以将其传递给子组件。你可能记得这些 props 数据是传递给 React.createElement 的参数。和之前一样,你可以用它来指定组件在创建时的属性。props 不应在组件内部修改,但你很快就会发现更新 React 组件中数据的方法。
在下一节的 列表 2.5 中,你将看到 React 类组件的实际应用,以及你如何使用 this.props 创建更多嵌套的 React 元素并传递自定义数据。当你看到 React 类中使用 props 时,就好像你创建了一个自定义 HTML 元素,比如 Jedi,并给它一个自定义属性,比如“name”:<Jedi name="Obi Wan"/>。我将在未来的章节中更详细地介绍 this JavaScript 关键字,但请注意,在这种情况下,保留的 JavaScript 关键字 this 指的是组件实例。
2.2.6. 通过 PropTypes 进行属性验证
你知道 React 类组件可以自由使用自定义属性,这听起来很棒;就好像你可以创建自己的自定义 HTML 元素,但功能更强大。记住,权力越大,责任越大。你需要提供某种方式来验证你将使用的属性,以便防止错误并规划组件将使用的数据类型。为此,你可以使用 React 命名空间内提供的验证器:PropTypes。PropTypes 验证器集最初包含在 React 核心库中,但后来在 React 15.5 版本中被拆分并弃用。要使用 PropTypes,你需要安装 prop-types 包,这个包仍然是 React 工具链的一部分,但不再包含在核心库中。这个包将包含在应用源代码和你在本章中使用的 CodeSandbox 示例中。
prop-types 库提供了一套验证器,让你可以指定组件需要或期望的 props。例如,如果你要构建一个 ProfilePicture 组件,没有图片(或处理没有图片的逻辑)将毫无用处。你可以使用 PropTypes 来指定 ProfilePicture 组件需要哪些 props 来工作,以及这些 props 应该是什么样子。
你可以将 PropTypes 理解为提供一种合同,其他开发者和你未来的自己可以履行或违反这个合同。使用 PropTypes 并非 React 运作所必需的,但应该用于防止错误和简化调试。使用 PropTypes 的另一个好处是,如果你首先指定了你期望的 props,你就有机会思考你的组件需要什么才能工作。
当使用 PropTypes 时,您需要通过静态类属性或类定义后的简单属性赋值在 React.Component 类中添加一个 propTypes 属性。注意类属性的小写,而不是来自 React 对象的属性,因为这很容易混淆。 列表 2.4 展示了如何使用 PropTypes,以及从 React 类组件返回 React 元素。在这个列表中,您将结合几个方面:创建一个可以传递给 createElement 的 React 类,添加一个 render 方法,并指定 propTypes。
列表 2.4. 使用 PropTypes 和 render 方法
import React, { Component } from "react"; *1*
import { render } from "react-dom"; *1*
import PropTypes from "prop-types"; *1*
const node = document.getElementById('root');
class Post extends Component { *2*
render() {
return React.createElement(
'div',
{
className: 'post' *3*
},
React.createElement(
'h2',
{
className: 'postAuthor',
id: this.props.id
},
this.props.user, *4*
React.createElement(
'span',
{
className: 'postBody' *5*
},
this.props.content *6*
)
)
);
}
}
Post.propTypes = {
user: PropTypes.string.isRequired, *7*
content: PropTypes.string.isRequired, *7*
id: PropTypes.number.isRequired *7*
};
const App = React.createElement(Post, {
id: 1, *8*
content: ' said: This is a post!', *8*
user: 'mark' *8*
});
render(App, node);
...
-
1 导入 React、React DOM 和 prop-types。
-
2 创建一个 React 类作为您的 Post 组件。在这种情况下,您只指定了 propTypes 和一个 render 方法。
-
3 创建一个具有类名 'post' 的 div 元素。
-
4 在 JavaScript 中,这有时可能会令人困惑——在这里,它将指代组件实例,而不是您的 React 类蓝图。
-
5 使用 className 而不是 class 作为 DOM 元素的 CSS 类名
-
6 再次,内容属性是您创建的 span 元素的内部内容。
-
7 属性可以是可选的或必需的,具有类型,甚至可能需要具有某种“形状”(例如具有某些属性的对象)。
-
8 将 Post React 类及其一些属性传递给 React.createElement 以创建某些内容。React DOM 可以渲染——尝试更改数据以查看您的组件输出如何变化。
列表 2.4 的代码可在网上找到,网址为 codesandbox.io/s/3yj462omrq。
您应该看到一些文本出现:“mark said: This is a post!”如果您没有提供任何必需的属性,您将在开发者控制台中看到警告。由于某些组件需要工作,未提供某些属性可能会破坏您的应用程序,但验证步骤不会。换句话说,如果您忘记向应用程序提供关键数据,它可能会损坏,但使用 PropTypes 验证不会——它只会让您知道您忘记了属性。因为 PropTypes 只在开发模式下进行类型评估,所以您的生产环境应用程序不会额外消耗努力来执行 PropTypes 的工作。
现在你正在创建一个组件并传递一些数据,你可以尝试嵌套组件。我之前提到过这种可能性,这也是 React 让人愉快工作并如此强大的原因之一:你可以从其他组件中创建组件。列表 2.5 说明了这一点,并展示了 children 属性的特殊用法。我将在未来的章节中更详细地介绍这一点,当你处理路由和高级组件时。当你使用 this.props.children 属性时,它就像一个通道,让嵌套数据通过。在这种情况下,你将创建一个 Comment 组件,将其作为参数传递,并实现嵌套。
列表 2.5. 添加嵌套组件
//...
this.props.user,
React.createElement(
"span",
{
className: "postBody"
},
this.props.content
),
this.props.children *1*
//...
class Comment extends Component { *2*
render() {
return React.createElement(
'div',
{
className: 'comment'
},
React.createElement(
'h2',
{
className: 'commentAuthor'
},
this.props.user,
React.createElement(
'span',
{
className: 'commentContent'
},
this.props.content
)
)
);
}
}
Comment.propTypes = { *3*
id: PropTypes.number.isRequired,
content: PropTypes.string.isRequired,
user: PropTypes.string.isRequired
};
const App = React.createElement(
Post,
{
id: 1,
content: ' said: This is a post!',
user: 'mark'
},
React.createElement(Comment, { *4*
id: 2,
user: 'bob',
content: ' commented: wow! how cool!'
})
);
ReactDOM.render(App, node);
-
1 将 this.props.children 添加到 Post 组件中,以便它可以渲染子组件。
-
2 创建一个 Comment 组件,类似于创建 Post 组件的方式。
-
3 声明 propTypes。
-
4 在 Post 组件内部嵌套 Comment 组件。
列表 2.5 的代码可在网上找到,链接为 codesandbox.io/s/k2vn448pn3。
现在你已经创建了一个嵌套组件,你应该能在浏览器中看到更多内容。接下来,我们将看到如何使用之前提到的与 React 类一起提供的嵌入状态来创建动态组件。
逆向工程组件树
在继续之前,通过像 GitHub 这样的网站逆向工程一个组件树来检验你的理解。打开你的开发者工具,选择一个不太深层的 DOM 元素,并从中重构一个 React 类。考虑以下 DOM 元素:

你会如何在 React 中构建类似的组件结构?(请随意不添加每个 CSS 类名。)
2.3. 组件的生与死
在本节中,你将向你的 Post 和 Comment 组件添加交互功能。之前,我们发现作为 React 类创建的组件可以通过“后端实例”获得一些特殊的方式来存储和访问数据。为了理解这一点,让我们回顾一下 React 的工作原理的大致情况。图 2.11 总结了你迄今为止所学的知识。你可以从 React 类创建组件,这些类是由 React 元素(映射到 DOM 的元素)组成的。我所说的 React 类 是 React.Component 的子类,React.createElement 可以使用它。
图 2.11. React 中渲染的放大视图。React 类和 React 元素被 React 用于创建一个内存中的虚拟 DOM,该 DOM 管理着真实的 DOM。它还创建了一个“合成”的事件系统,这样你仍然可以响应来自浏览器的事件(如点击、滚动和其他由用户引起的事件)。

由 React 类创建的组件有后端实例,允许你存储数据,并且需要有一个返回精确一个 React 元素的 render 方法。React 会从 React 元素中创建一个内存中的虚拟 DOM,并处理 DOM 的管理和更新。
你已经为你的 React 类添加了 render 方法和一些 PropTypes 验证。但是,要创建动态组件,你需要更多。React 类可以有一些特殊的方法,这些方法会在 React 管理虚拟 DOM 时按特定顺序被调用。你用来返回 React 元素的 render 方法只是其中之一。
除了保留的生命周期方法,你还可以添加自己的方法。React 给你自由和灵活性,让你可以为组件添加任何需要的功能。几乎任何有效的 JavaScript 都可以在 React 中使用。如果你回顾一下第一章中的图 1.1,你会注意到生命周期方法、特殊属性和自定义代码构成了 React 组件的大部分。剩下的是什么?
2.3.1. React 状态思维
除了自定义方法和生命周期方法,React 类还提供了可以与组件持久化的状态(数据)。这来自于我提到的后端实例。状态是一个很大的主题——我不会在这一章中涵盖所有内容,但你现在可以学到足够多的知识,以便能够使你的组件交互和生动。什么是状态?另一种思考方式是将其视为关于某个特定时间的信息。例如,你可以通过问“你今天怎么样?”来获取你朋友的“状态”。
状态主要有两种类型:可变和不可变。简单来说,可以通过时间来区分它们。创建后能否改变?如果可以,那么它就是可变的。如果不可以,那么它就是不可变的。关于这些主题有深入研究的学术领域,所以在这里我不会深入探讨。
在 React 中,作为 JavaScript 类(developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes)创建的组件,如果扩展了 React.Component,则可能具有可变和不可变的状态,而由函数(无状态函数组件)创建的组件只能访问不可变状态(属性)。我将在未来的章节中介绍这些内容;现在,我将坚持使用继承自 React.Component 并获取状态和额外方法的组件。在这些类型的组件中,状态可以通过类的实例的 this.state 属性访问。你可以通过 this.props 访问提供的不可变状态,这你已经用来创建静态组件了。
this.props 不应在组件内部被修改。你将在未来的章节中看到如何向组件提供随时间变化的数据。现在,你需要知道的是,你不能直接修改 this.props。
你可能想知道如何在 React 中使用 state 和 props。答案基本上是你会如何使用传递给函数或函数中使用的数据。这包括计算、显示、解析、业务逻辑以及任何其他与数据相关的任务。实际上,props 和 state 是你在 UI 中利用动态或静态数据的主要方式(显示用户信息、传递数据到事件处理器等等)。
State 和 props 是构成你的应用程序并使其有用的数据载体。如果你正在创建一个社交网络应用程序(你将在未来的章节中这样做),你通常会使用 props 和 state 的组合来构建显示和更新用户信息、更新等组件。如果你使用 React 进行数据可视化,你可能将 props 和 state 作为可视化库(如 D3.js)的输入。无论你正在构建什么,你很可能会使用 state 和 props 来管理和在你的 React 应用程序中引导信息。
可变与不可变
在继续之前,通过思考 React 中两种主要数据类型之间的区别来检查你的理解:可变和不可变。将每个陈述标记为真或假:
-
可变意味着数据可以随时间改变:T | F
-
State 通过 React 中的
this.state属性访问:T | F -
props是 React 提供的可变对象:T | F -
不可变数据不会随时间改变:T | F
-
Props 通过
this.props访问:T | F
2.3.2. 设置初始状态
你应该在何时使用状态以及如何开始使用它?现在,简单的答案是 当你想要更改组件内部存储的数据时。我说过 props 是不可变的(不可修改的),所以如果你需要更改数据,你需要可变的状态。在 React 中,通常需要可变的数据往往来自用户输入(通常是文本、文件、切换选项等),但也可能是许多其他事物。为了跟踪用户与表单元素的交互,你需要提供一个初始状态,然后随着时间的推移改变该状态。你可以使用组件的构造函数来设置组件的初始状态,一个基于早期代码列表中思想和概念的评论框组件。它将允许你通过简单的表单添加评论。以下列表显示了如何设置组件和设置初始状态。
列表 2.6. 设置初始状态
//...
class CreateComment extends Component {
constructor(props) {
super(props); *1*
this.state = {
content: '', *1*
user: '' *1*
};
}
render() {
return React.createElement(
'form',
{
*2*
className: 'createComment'
},
React.createElement('input', {
type: 'text', *2*
placeholder: 'Your name',
value: this.state.user
}),
React.createElement('input', {
type: 'text',
placeholder: 'Thoughts?' *2*
}),
React.createElement('input', {
type: 'submit',
value: 'Post'
})
);
}
}
CreateComment.propTypes = {
content: React.PropTypes.string
};
//...
const App = React.createElement(
Post,
{
id: 1,
content: ' said: This is a post!',
user: 'mark'
},
React.createElement(Comment, {
id: 2,
user: 'bob',
content: ' commented: wow! how cool!'
}),
React.createElement(CreateComment) *3*
);
-
1 在类构造函数中调用 super 并将初始状态对象分配给类的状态属性实例——请注意,你通常不会以这种方式分配状态,除非是在组件类的构造函数中。
-
2 创建一个作为 React 类的组件,该组件将包含一些用户输入字段——我将在未来的章节中更详细地介绍表单。
-
3 将 CreateComment 添加到 App 组件中。
列表 2.6 的代码可在网上找到,链接为 codesandbox.io/s/p5r3kwqx5q。
要更新在组件类构造函数中初始化的状态,你需要使用一个特殊的方法;你不能像在非 React 情况下那样直接覆盖 this.state。这是因为 React 需要跟踪状态并确保虚拟 DOM 和真实 DOM 保持同步。要在 React 类组件中更新状态,你将使用 this.setState;查看基本用法。它接受一个用于更新状态的更新函数,并且不返回任何内容:
setState(
function(prevState, props) -> nextState,
callback
)-> void
this.setState 接收一个更新函数,该函数返回的对象将被浅合并到状态中。例如,如果你最初将 username 属性设置为空字符串,你会使用 this.setState 来为你的组件状态设置新的用户名值。React 将获取该值并更新后端实例和 DOM 中的新值。
JavaScript 中更新或重新分配值与使用 setState 之间的一个关键区别是,React 可以根据状态变化来批量更新,以最大化效率。这意味着当你调用 setState 来执行状态更新时,它不一定立即发生。更准确地说,这更像是一种确认,React 将尽可能高效地根据新状态更新 DOM,尽可能快地完成。
什么会导致 React 更新?JavaScript 是事件驱动的,所以它可能是对某种用户输入的响应(至少在浏览器中是这样)。这可能是一个点击、按键或浏览器支持的其他许多事件之一。事件如何与 React 一起工作?React 实现了一个合成事件系统,作为虚拟 DOM 的一部分,它将浏览器中的事件转换为 React 应用程序的事件。你可以设置事件处理程序来响应来自浏览器的事件,就像你通常在 JavaScript 中做的那样。一个区别是,React 事件处理程序是在 React 元素或组件本身上设置的(而不是使用 addEventListener)。你可以使用这些事件的数据(输入框中的文本、单选按钮的值,甚至是事件的目标)来更新组件的状态。
代码示例 2.7 展示了如何将你关于设置初始状态和设置事件处理器的知识应用到实践中。浏览器中可以监听许多不同的事件,几乎涵盖了所有可能的用户交互(点击、输入、表单、滚动等)。我们最关心的是两个主要事件:当表单输入值发生变化时,以及当表单被提交时。通过监听这些事件,你可以接收并使用数据来创建新的评论。
列表 2.7. 设置事件处理器
...
class CreateComment extends Component {
constructor(props) {
super(props);
this.state = {
content: '',
user: ''
};
this.handleUserChange = this.handleUserChange.bind(this); *1*
this.handleTextChange = this.handleTextChange.bind(this); *1*
this.handleSubmit = this.handleSubmit.bind(this); *1*
}
handleUserChange(event) { *2*
const val = event.target.value;
this.setState(() => ({
user: val
}));
}
handleTextChange(event) { *3*
const val = event.target.value;
this.setState(() => ({
content: val
}));
}
handleSubmit(event) { *4*
event.preventDefault();
this.setState(() => ({ *5*
user: '',
content: ''
}));
}
render() {
return React.createElement(
'form',
{
className: 'createComment',
onSubmit: this.handleSubmit
},
React.createElement('input', {
type: 'text',
placeholder: 'Your name',
value: this.state.user,
onChange: this.handleUserChange
}),
React.createElement('input', {
type: 'text',
placeholder: 'Thoughts?',
value: this.state.content,
onChange: this.handleTextChange
}),
React.createElement('input', {
type: 'submit',
value: 'Post'
})
);
}
}
CreateComment.propTypes = {
onCommentSubmit: PropTypes.func.isRequired,
content: PropTypes.string
};
...
-
1 由于使用类创建的组件不会自动绑定组件方法,你需要在构造函数中将它们绑定到 this 上。
-
2 为作者字段的变化分配一个事件处理器——你可以通过 event.target.value 获取输入元素的值,并使用 this.setState 来更新组件的状态。
-
3 为评论内容创建一个具有类似功能的事件处理器。
-
4 表单提交事件的事件处理器
-
5 提交后重置输入字段,以便用户可以提交更多评论。
代码示例 2.7 的代码可在网上找到,网址为codesandbox.io/s/x9mxo31pxp。
你注意到你在组件类的构造函数中使用了.bind了吗?在 React 的早期版本中,React 会为你自动绑定方法到组件的实例上。但是,随着 JavaScript 类的引入,你需要自己绑定方法。如果你定义了一个组件方法但它不起作用,请确认你已经正确地绑定了你的方法——在刚开始使用 React 时很容易忘记。
接下来,尝试省略onChange事件处理器,看看你是否能在表单输入中输入任何内容。你将无法做到这一点,因为 React 正在确保 DOM 与虚拟 DOM 保持同步,而虚拟 DOM 没有更新,因此不会允许 DOM 发生变化。如果你现在还不完全明白,不要担心——第五章和第六章更详细地介绍了表单。
现在你有了监听事件和修改组件状态的方法,你可以实现使用单向数据流创建新评论的方式。在 React 中,数据是自上而下流动的,作为从父组件到子组件的输入。当你创建复合组件时,你可以通过 props 将信息传递给子组件并在子组件中使用它。这意味着你可以在父组件中存储CreateComment组件的数据,并从那里将数据传递给子组件。但是,你如何在子组件中将新评论(用户输入文本的形式)的数据返回到父组件和子组件中?图 2.12 展示了你需要的数据流示例。
图 2.12。要添加帖子,你需要从输入字段捕获数据,并以某种方式将其发送到父组件,然后更新后的数据将用于渲染帖子。

如何实现这一点?我们还没有探讨过通过属性传递函数这一种数据类型。因为函数可以作为参数传递给 JavaScript 中的其他函数,你可以利用这一点。你可以在父组件上定义一个方法,并将其作为属性传递给子组件。这样,子组件就可以将数据发送回父组件,而无需知道父组件如何处理这些数据。如果你需要更改数据的行为,你不需要对CreateComment组件做任何操作。要执行作为属性传递的函数,子组件只需要调用该方法并传递任何数据给它。下面的列表展示了如何使用函数作为属性。
列表 2.8。使用函数作为属性
//...
class CreateComment extends Component {
constructor(props) {
super(props);
this.state = {
content: '',
user: ''
};
this.handleUserChange = this.handleUserChange.bind(this);
this.handleTextChange = this.handleTextChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleUserChange(event) {
this.setState(() => ({
user: event.target.value
}));
}
handleTextChange(event) {
this.setState(() => ({
content: event.target.value
}));
}
handleSubmit(event) {
event.preventDefault();
this.props.onCommentSubmit({ *1*
user: this.state.user.trim(), *1*
content: this.state.content.trim() *1*
});
this.setState(() => ({
user: '',
text: ''
}));
}
render() {
return React.createElement(
'form',
{
className: 'createComment',
onSubmit: this.handleSubmit *2*
},
React.createElement('input', {
type: 'text',
placeholder: 'Your name',
value: this.state.user,
onChange: this.handleUserChange
}),
React.createElement('input', {
type: 'text',
placeholder: 'Thoughts?',
value: this.state.content,
onChange: this.handleTextChange
}),
React.createElement('input', {
type: 'submit',
value: 'Post'
})
);
}
}
//...
-
1 调用由父组件传递的属性
onCommentSubmit函数——你正在将表单数据传递进去并重置表单,这样用户就知道他们的操作已经成功了。 -
2 不要忘记将你设置的方法绑定到
onSubmit事件上——没有它,正确的事件和你的方法之间将没有连接。
列表 2.8 的代码可在网上找到,网址为codesandbox.io/s/p3mk26v3lx。
现在既然你的组件可以将新的评论数据传递给父组件,你需要包含一些模拟数据,这样你就可以开始评论了。在未来的章节中,你将使用 Fetch API 和 RESTful JSON API,但使用你创建的一些假数据现在就足够了。下面的列表展示了你如何模拟一些基本的帖子数据及其关联的评论。
列表 2.9。模拟 API 数据
...
const data = { *1*
post: { *1*
id: 123, *1*
content:
'What we hope ever to do with ease, we must first learn to do
with diligence. — Samuel Johnson',
user: 'Mark Thomas',
},
comments: [
{
id: 0, *2*
user: 'David',
content: 'such. win.',
},
{
id: 1, *2*
user: 'Haley',
content: 'Love it.',
},
{
id: 2, *2*
user: 'Peter',
content: 'Who was Samuel Johnson?',
},
{
id: 3, *2*
user: 'Mitchell',
content: '@Peter get off Letters and do your homework',
},
{
id: 4, *3*
user: 'Peter',
content: '@mitchell ok :P',
},
],
};
...
-
1 为你的 CommentBox 组件设置模拟数据。
-
2 你将使用这些评论对象作为现有的评论。
-
3 你将使用这些评论对象作为现有的评论。
接下来,你需要一种方式来显示所有评论。在 React 中,这很容易做到。你已经有了一个可以显示评论的组件。因为与 React 组件一起工作只需要常规的 JavaScript,你可以使用.map()函数来返回一个新的 React 元素数组。你不能使用.forEach()内联,因为它不会返回一个数组,并且会留下React.createElement()没有可用的内容。然而,你可以使用forEach构建一个数组,然后将其传递进去。
除了迭代现有的评论外,你还需要定义一个可以传递给 CreateComment 组件的方法。它需要通过从子组件接收数据来修改其状态中的评论列表。提交方法和状态都需要放入一个新的父组件:CommentBox。下面的列表展示了如何创建该组件并设置这些方法。
列表 2.10. 处理评论提交和遍历元素
...
class CommentBox extends Component {
constructor(props) {
super(props);
this.state = {
comments: this.props.comments *1*
};
this.handleCommentSubmit = this.handleCommentSubmit.bind(this);
}
handleCommentSubmit(comment) {
const comments = this.state.comments; *2*
// note that we didn't directly modify state
comment.id = Date.now(); *2*
const newComments = comments.concat([comment]); *2*
this.setState({
comments: newComments *2*
});
}
render() {
return React.createElement(
'div',
{
className: 'commentBox'
},
React.createElement(Post, {
id: this.props.post.id, *3*
content: this.props.post.content, *3*
user: this.props.post.user *3*
}),
this.state.comments.map(function(comment) { *4*
return React.createElement(Comment, { *4*
key: comment.id, *4*
id: comment.id, *4*
content: comment.content, *4*
user: comment.user *4*
});
}),
React.createElement(CreateComment, {
onCommentSubmit: this.handleCommentSubmit *5*
})
);
}
}
CommentBox.propTypes = {
post: PropTypes.object,
comments: PropTypes.arrayOf(PropTypes.object)
};
const App = React.createElement(CreateComment);
ReactDOM.render( *6*
React.createElement(CommentBox, {
*6*
comments: data.comments, *6*
post: data.post *6*
}),
node
);
...
-
1 将评论数据在最高级别传递给 CommentBox。
-
2 永远不要直接修改状态——相反,创建一个副本。
-
3 如前所述,将数据变量在最高级别传递以访问帖子数据。
-
4 遍历 this.state.comments 中的评论,并为每个评论返回一个 React 元素。
-
5 将父组件的 handleComment-Submit 方法传递给 CreateComment 组件以使用。
-
6 将模拟数据作为属性传递给 CommentBox 组件。
列表 2.10 的代码可在网上找到,网址为codesandbox.io/s/z6o64oljn4。
到目前为止,你有一个外观不佳、未经测试但功能性的组件,它将对属性进行验证、更新状态,并允许你添加新的评论。它看起来不多,所以我把它留给你作为一个挑战,让你把评论框做得值得我们的虚构公司 Letters。
2.4. 了解 JSX
你已经创建了你的第一个动态 React 组件。如果你觉得很容易,那太好了!如果你发现代码中嵌套的React.createElement难以阅读,那也无所谓。我们即将讨论一些创建组件的更简单方法,但首先需要关注基础。几乎任何其他事情以相反的方式学习(“魔法”和容易先,基础和细节后)通常要容易得多,但长期来看可能会阻碍你,因为你没有做理解底层机制如何工作的艰苦工作。如果你回顾你的模拟数据,你可能记得这句话,它很及时:
我们希望轻松完成的事情,我们首先必须学会勤奋地去做。
塞缪尔·约翰逊
2.4.1. 使用 JSX 创建组件
精通基础很重要,但这并不意味着我们必须让自己难堪。实际上,创建 React 组件比仅使用React.createElement有更简单、更好的方法。那就是 JSX:更好的方法。
JSX 是什么?它是对 ECMAScript 的一种类似于 XML 的语法扩展,没有定义任何语义,专门用于预处理器的使用。换句话说,JSX 是 JavaScript 的一个扩展,类似于 XML,并且仅用于代码转换工具。这不是你会在任何 ECMAScript 规范中看到被整合的东西。
JSX 通过允许你使用 XML 风格的(想想 HTML)代码代替React.createClass来帮助你。换句话说,它让你写出的代码看起来像 HTML,但实际上不是。像 Babel 这样的 JSX 预处理器程序——一个将你的 JavaScript 代码转换为与旧浏览器兼容的代码的转换器——会遍历并转换你所有的 JSX 代码,就像我们之前写的那样。一个影响是,在浏览器中直接运行未经转换的 JSX 代码将不起作用——当你的 JavaScript 被解析时,你会得到各种语法错误。
在 JavaScript 中编写类似 XML、类似 HTML 的代码可能会触发你的警告本能,但使用 JSX 有很多很好的理由,我将在后面介绍。现在,查看列表 2.11 以了解你的注释框组件可能的样子。我已经省略了一些代码,以便更容易地关注 JSX 语法。请注意,Babel 是 CodeSandbox 环境的一部分。通常,你会使用像 Webpack 这样的构建工具来转换你的 JavaScript,但你也可以导入 Babel 并在没有构建步骤的情况下使用它。但这会慢得多,而且不应该在生产环境中这样做。你可以在babeljs.io了解更多信息。
列表 2.11. 使用 JSX 重写组件
...
class CreateComment extends Component {
constructor(props) {
super(props);
this.state = {
content: '',
user: ''
};
this.handleUserChange = this.handleUserChange.bind(this);
this.handleTextChange = this.handleTextChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
//...
render() {
return (
<form onSubmit={this.handleSubmit} className="createComment">
<input
value={this.state.user}
onChange={this.handleUserChange} *1*
placeholder="Your name" *1*
type="text"
/>
<input
value={this.state.content}
onChange={this.handleTextChange}
placeholder="Thoughts?"
type="text"
/>
<button type="submit">Post</button>
</form>
);
}
}
class CommentBox extends Component {
//...
render() {
return (
<div className="commentBox">
<Post
id={this.props.post.id} *2*
content={this.props.post.content} *2*
user={this.props.post.user}
/>
{this.state.comments.map(function(comment) { *3*
return (
<Comment
key={comment.id}
content={comment.content} *3*
user={comment.user} *3*
/>
);
})}
<CreateComment
onCommentSubmit={this.handleCommentSubmit} *4*
/>
</div>
);
}
}
CommentBox.propTypes = {
post: PropTypes.object,
comments: PropTypes.arrayOf(PropTypes.object)
};
ReactDOM.render(
<CommentBox
comments={data.comments} *5*
post={data.post}
/>,
node
);
......
-
1 在 JSX 中,你创建属性的方式与在 HTML 中一样——要传入表达式,请使用
{}语法。 -
2 这是你在之前创建的 Post React 类——注意现在它更清楚地表明它是一个你创建的自定义组件,看起来它非常适合在 HTML 中使用。
-
3 在{}内部使用常规 JavaScript 遍历注释并为每个注释创建一个注释组件。
-
4 将 handleComment-Submit 处理程序作为属性传入。
-
5 在顶级上,CommentBox 也是一个你提供属性并传递给 React DOM 以进行渲染的自定义组件。
列表 2.11 的代码可在网上找到,网址为codesandbox.io/s/vnwz6y28x5。
2.4.2. JSX 的优势和与 HTML 的区别
现在你已经看到了 JSX 的实际应用,你可能对它不那么怀疑了。如果你仍然谨慎,重要的是要考虑它为 React 中的组件工作带来的许多好处。以下是其中两个好处:
-
与 HTML 的相似性和更简单的语法— 如果反复编写
React.createElement感到繁琐,或者发现嵌套难以理解,你并不孤单。JSX 与 HTML 的相似性使得以熟悉的方式声明组件的结构变得容易得多,并且显著提高了可读性。 -
声明性和封装性— 通过将构成视图的代码以及任何相关的方法一起包含,你创建了一个功能组。本质上,关于组件你需要知道的一切都在一个地方。没有任何东西是不必要地隐藏起来的,这意味着你可以更容易地推理你的组件,并且可以更全面地了解它作为一个系统是如何工作的。
感觉像是回到了 90 年代末编写你的标记与 JavaScript 一起,但这并不意味着这是一个坏主意。
需要注意的是,JSX 不是 HTML(或 XML)——它只会编译成你迄今为止使用的常规 React 代码,并且它不共享完全相同的语法和约定。存在一些细微的差异,你需要留意。未来的章节将更全面地介绍这些差异,但我会简要地指出其中的一些:
-
HTML 标签与 React 组件—使用
React.createClass创建的自定义 React 组件按照约定首字母大写,这样你可以确定自定义和原生 HTML 组件之间的区别。 -
属性表达式—当你想将 JavaScript 表达式用作属性值时,将表达式包裹在一对大括号中(
<Comment a={this.props.b}/>),而不是使用引号(<User a="this.props.b"/>),如列表 2.8 所示。 -
布尔属性—省略属性的值(
<Plan active/>,<Input checked/>)会使 JSX 将其视为true。要传递一个假值,你必须使用属性表达式(attribute={false})。 -
嵌套表达式—要在元素内部插入表达式的值,你也使用一对大括号(
<p>{this.props.content}</p>)。
JSX 和偶尔的“陷阱”存在细微的差异,但后面的章节将涵盖所有这些内容,并且更多。你将在你的组件中广泛使用 JSX,现在你已经开始使用 JSX,你将能够更容易地创建、阅读和思考你的组件。
2.5. 摘要
在本章中,我们花了很多时间讨论组件,所以让我们回顾一些关键点:
-
我们在 React 中创建组件时主要使用两种类型的元素:React 元素和 React 类。React 元素是“你希望在屏幕上看到的内容”,可以与 DOM 元素相媲美。另一方面,React 类是从
React.Component类继承的 JavaScript 类。这些就是我们通常所说的 组件。它们可以是类(通常扩展React.Component)或函数(无状态函数组件,将在后面的章节中介绍)。 -
React 类可以访问随时间变化的状态(可变状态),但所有 React 元素都可以访问不应修改的 props(不可变状态)。
-
React 类还有称为 生命周期方法 的特殊方法,React 在渲染和更新过程中会按特定顺序调用这些方法。这使得你的组件更容易预测和操作,并允许你轻松地挂钩到组件更新过程。
-
React 类可以定义自定义方法来执行诸如修改状态等任务。
-
React 组件通过 props 进行通信,并具有父子关系。父组件可以向子组件传递数据,但子组件不能修改父组件。它们可以通过回调将数据传递回父组件,但不能直接访问父组件。
-
JSX 是 JavaScript 的一个类似 XML 的扩展,它允许你以更简单、更熟悉的方式编写组件。一开始在 JavaScript 中编写看起来像 HTML 的内容可能会感觉有些奇怪,但 JSX 可以使你在 React 中编写标记更加熟悉,并且通常比
React.createElement调用更容易阅读。
你已经创建了你的第一个组件,但你只是触及了 React 所能实现的一小部分。在下一章中,你将开始探索如何处理更复杂的数据,了解不同类型的组件,并随着我们扩展你的 React 视野,进一步深入研究状态。
第二部分. React 中的组件和数据
在第一部分中,您从高层次了解了 React,快速浏览了一些其 API,并构建了一些组件。希望这能给您一个更好的整体感觉,了解 React 是什么以及它作为一项技术是如何工作的。但快速浏览并不能让您充分利用 React,以便您可以用它构建健壮、动态的用户界面。
正是第二部分的用武之地。在第二部分中,您将开始更深入地探索 React,并仔细查看其 API。我们将探讨您如何创建组件以及您可以创建的不同类型的组件。在第三章(kindle_split_014_split_000.xhtml#ch03)中,我们将探讨数据如何在 React 应用中流动。这将帮助您了解 React 如何在组件中处理数据。
在第四章中,您将了解 React 中的生命周期方法,并开始构建您将在本书的剩余部分关注的工程项目:一个名为 Letters Social 的社会化应用。如果您想提前查看最终项目,可以访问social.react.sh。第四章将帮助您理解 React 组件 API,并展示如何设置构建 Letters Social 项目。
在第五章和第六章中,我们将探讨 React 中的表单。表单是大多数 Web 应用的一个重要部分,我们将探讨它们如何在 React 中工作。您将为 Letters Social 添加表单,并创建一个允许用户创建帖子并集成 Mapbox 以添加地图位置的界面。
在第七章和第八章中,我们将深入探讨路由。路由是现代前端 Web 应用的另一个关键部分。您将使用 React 从头开始构建一个路由器,并为 Letters Social 添加多个页面。在章节的末尾,您将集成 Firebase,以便用户可以登录到您的应用。
当我们在第九章中结束第二部分时,我们将专注于测试。测试是所有软件的重要部分,React 也不例外。您将探索使用 Jest 和 Enzyme 等工具来测试您的 React 组件。
第三章. React 中的数据和数据流
本章涵盖
-
可变和不可变状态
-
有状态和无状态组件
-
组件通信
-
单向数据流
第二章 对 React 进行了一次快速浏览。我们花了一些时间从高层次上学习 React,探讨了其设计和 API 背后的某些概念,甚至还通过 React 组件构建了一个简单的评论框。在 第四章 中,你将开始更深入地使用组件,并开始构建 Letters Social 示例项目。但在你这样做之前,你需要了解一些关于如何在 React 中处理数据以及它在 React 应用程序中如何流动的知识。这正是本章的内容。
3.1. 引入状态
第二章 给了你一些在 React 组件中处理数据的初步了解,但如果你想构建更实质性的 React 应用程序,我们需要花更多的时间专注于这一点。在本节中,你将学习以下内容:
-
状态
-
React 如何处理状态
-
数据如何在组件间流动
现代网络应用程序通常被构建为以数据为先的应用程序。诚然,有许多静态网站(我的博客就是一个——ifelse.io),但这些网站随着时间的推移也会更新,并且通常被认为与现代网络应用程序属于不同的类别。人们日常使用的多数网络应用程序都是高度动态的,并且充满了随时间变化的数据。
以 Facebook 这样的应用程序为例。作为一个社交网络,数据是它所有有用功能的生命线。它提供了多种方式在互联网上与其他人互动,而这些所有方式都是通过在浏览器(或其他平台)中修改和接收数据来实现的。许多其他应用程序包含极其复杂的数据,这些数据需要以人们可以理解和轻松使用的方式在 UI 中表示。开发者还需要能够维护和推理这些界面以及数据如何通过它们流动,因此应用程序处理数据的能力与它处理随时间变化的数据的能力一样重要。你将在下一章开始构建的示例应用程序 Letters Social(可在 social.react.sh 查看它),将使用大量变化的数据,尽管它不会像大多数消费者或商业应用程序那样复杂。我将在本章中对此进行更明确的说明,但我们将继续学习如何在 React 中处理数据,直到本书的结尾。
3.1.1. 什么是状态?
让我们简要地看一下状态,这样当我们研究 React 中的状态时,你可以有更好的理解。如果你以前从未明确思考或听说过程序中的状态,你可能至少见过它。你编写的大多数程序可能都有某种状态。如果你曾经使用过 Vue、Angular 或 Ember 这样的前端框架,你几乎肯定编写过具有状态方面的 UI。React 组件也可以有状态。但当我们说 状态 时,我们究竟在说什么呢?尝试这个定义:
状态
程序在某一时刻可访问的所有信息。
这只是一个简化的定义,可能忽略了某些学术上的细微差别,但对于我们的目的来说已经足够好了。许多学者都撰写了论文,致力于精确地定义计算机系统中的状态,但对我们来说,状态是指程序在某一时刻可访问的信息。这包括,但不仅限于,在某一时刻你可以引用的所有值,而不需要做任何进一步的赋值或计算——换句话说,它是对你在某一时刻对程序了解的快照。
例如,这可能包括你之前创建的任何变量或其他可用的值。当你改变一个变量(而不仅仅是获取其值)时,你改变了程序的状态,它就不再是之前的状态了。你可以在某一时刻通过仅获取或获取值来检索状态,但当你随着时间的推移改变某些东西时,程序的状态就发生了变化。技术上讲,你的机器的底层状态在你使用它的每一刻都在变化,但我们只关心程序的状态。
让我们看一下一些代码,并逐步分析下一列表中的简化程序状态。我们不会深入探讨幕后发生的所有底层分配或过程——我们只是试图更明确地思考程序中的数据,以便在思考 React 组件时更容易。
列表 3.1. 简单程序状态
const letters = 'Letters'; *1*
const splitLetters = letters.split(''); *2*
console.log("Let's spell a word!"); *3*
splitLetters.forEach(letter => console.log(letter)); *4*
-
1 在名为 letters 的变量中存储一个字符串。
-
2 将字母拆分成一个字符串数组。
-
3 打印一条消息。
-
4 打印出每个字母。
列表 3.1 展示了一个简单的脚本,它执行了一些基本的数据赋值和操作,并将结果输出。这很无聊,但我们可以用它来学习更多关于状态的知识。JavaScript 使用所谓的 运行至完成 语义,这意味着程序将从上到下,按照你认为的顺序执行。JavaScript 引擎通常会以你意想不到的方式优化你的代码,但它仍然应该以与你的原始代码一致的方式运行。
尝试从上到下逐行阅读 清单 3.1 中的代码。如果你想使用浏览器调试器来做这件事,请访问 codesandbox.io/s/n9mvol5x9p。你的浏览器开发工具应该会打开,你可以逐行执行代码并查看所有变量赋值等信息。
为了我们的目的,让我们考虑每一行代码都是一个时间点。根据我们对状态的简化定义“在给定时间点程序可用的所有信息”,你将如何描述在每一个给定时刻应用程序的状态?请注意,我们保持简单,省略了闭包、垃圾回收等:
-
letters是一个被赋予字符串 “Letters” 的变量。 -
splitLetters是通过从letters中拆分每个字符创建的,letters仍然可用。 -
步骤 1 和 2 中的所有信息仍然可用;一条消息被发送到控制台。
-
我们的程序遍历数组中的每个项目并输出一个字符。这个过程可能会在几个时间点发生,因此程序也有
Array.forEach方法提供的信息。
随着程序执行向前推进,状态随时间变化,并且由于你没有删除任何内容或更改引用,更多信息变得可用。表 3.1 展示了随着程序随时间向前推进,可用信息是如何增加的。
表 3.1. 逐步状态
| 步骤 | 程序可用的状态 |
|---|---|
| 1 | letters = “Letters” |
| 2 | letters = “Letters” splitLetters = [“L”, “e”, “t”, “t”, “e”, “r”, “s”] |
| 3 | letters = “Letters” splitLetters = [“L”, “e”, “t”, “t”, “e”, “r”, “s”] |
| 4 | letters = “Letters” splitLetters = [“L”, “e”, “t”, “t”, “e”, “r”, “s”] for sub-steps 0 through the length of splitLetters: letter = “L” (然后 “e”, “t”, 等。) |
尝试遍历你自己的代码并思考在每一行程序中可用的信息是什么。我们倾向于简化我们的代码——这是正确的,因为我们不必一次性考虑它的每一个可能的维度——但对于更简单的程序,也可能有大量的信息可用。
我们可以反思的一个观点是,当运行中的程序变得相对复杂(即使是大多数简单的 UI 也可能如此),推理它可能会变得困难。我的意思是,系统的复杂性可能很难一次性全部记住,系统中的逻辑可能会使思考变得困难。这对大多数程序都适用,但当涉及到构建 UI 时,这可能会特别困难。
现代浏览器应用的 UI 通常代表了多种技术的交集,包括提供数据、样式和布局 API 的服务器,JavaScript 框架,浏览器 API 等等。UI 框架的进步旨在简化这个问题,但它仍然是一个挑战。随着人们对 Web 应用期望的不断提高,这些应用变得越来越普遍,并嵌入到社会和日常生活中,这个挑战通常只会加剧。如果 React 要变得有用,它将需要通过减少或保护我们免受某些现代 UI 的极其复杂的状态的影响来帮助我们。我希望你能看到 React 确实做到了这一点。但它是如何做到的呢?一种方法是通过提供两个特定的 API 来处理数据:props 和 state。
3.1.2. 可变和不可变状态
在 React 应用中,你可以通过两种主要方式在组件中处理状态:通过你可以改变的状态,以及通过你不应该改变的状态。这里我们过于简化了:你的应用中将有多种类型的数据和状态存在。你可以用许多不同的方式表示数据,比如二叉树、Maps 或 Sets,或者常规的 JavaScript 对象。但你在 React 组件中与状态通信和交互的方式可以分为这两类。在 React 中,这些被称为状态(组件内可以改变的数据)和属性(组件接收的数据,不应该被组件改变)。
你可能听说过状态和属性被称为可变和不可变。这在某种程度上是正确的,因为 JavaScript 本身不支持真正的不可变对象(也许除了 Symbols)。在 React 组件中,状态通常是可变的,而属性不应该被改变。在我们深入探讨 React 特定的 API 之前,让我们更深入地探讨可变性和不可变性的概念。
你在第二章中看到,当我们说状态是可变的,我们的意思是我们可以覆盖或更新那些数据(例如,可以覆盖的变量)。另一方面,不可变状态是不可变的。也存在不可变的数据结构,这些数据结构可以改变,但只能以受控的方式进行(这有点像是 React 中状态 API 的工作方式)。当你使用 Redux 在第十章和第十一章时,你会模拟不可变数据结构。
我们可以稍微扩展我们对可变和不可变概念的理解,包括它们对应的数据结构类型:
-
不可变— 不可变、持久的数据结构在一段时间内支持多个版本,但不能直接被覆盖;不可变数据结构通常是持久的。
-
可变— 可变、短暂的数据结构在一段时间内只支持一个版本;当可变数据结构发生变化时,会被覆盖,并且不支持额外的版本。
图 3.1 展示了这些概念。
图 3.1. 不可变和可变数据结构中的持久性和短暂性。不可变或持久的数据结构通常记录历史并保持不变,但会创建随时间变化的内容的版本。另一方面,短暂的数据结构通常不记录历史,并且每次更新都会被清除。

另一种思考不可变数据结构和可变数据结构之间区别的方法是,将它们视为具有不同的容量或内存。短暂的数据结构只能存储一瞬间的数据,而持久的数据结构可以跟踪随时间的变化。这就是不可变数据结构的不可变性变得更为清晰的地方:只有状态副本被创建——它们不会被替换。旧状态被新状态所取代,但数据本身并没有被替换。图 3.2 展示了如何进行更改。
图 3.2. 使用可变和不可变数据结构处理更改。短暂的数据结构没有版本,所以当你对它们进行更改时,所有之前的状态都会消失。你可以这样说,它们活在当下,而不可变数据结构能够随着时间的推移而持续存在。

小贴士
另一种思考不可变性与可变性之间区别的方法是,将其与“保存”和“另存为”之间的区别相比较。在许多计算机程序中,你可以以当前状态保存文件,或者以不同的名称保存当前文件的副本。不可变性与此类似,当你保存到它时,你是在保存一个副本,而可变数据可以在原地被覆盖。
尽管 JavaScript 本身不支持真正的不可变数据结构,但 React 以可变的方式(通过setState进行更改)暴露组件状态,并将属性作为只读。关于不可变性和不可变数据结构,还有很多其他内容,但我们不需要比我们已经了解的更多地去关心它们。如果你仍然好奇想了解更多,有一整个学术研究领域都专注于这类问题。还有方法可以在你的 JavaScript 应用程序(无论是否是 React)中广泛使用不可变数据结构(例如,使用 Immutable JS 库,更多信息请参阅facebook.github.io/immutable-js/),但在 React 中,我们只会处理属性和状态 API。
3.2. React 中的状态
你已经对状态和(不可)可变性有了更多的了解。所有这些如何与 React 结合?嗯,我们在上一章已经看到了一些关于属性和状态 API 的内容,所以你可能已经猜到它们一定是构建组件的重要部分。实际上,它们是 React 组件处理数据和相互通信的两种主要方式。
3.2.1. React 中的可变状态:组件状态
让我们从状态 API 开始。虽然我们可以这样说,所有组件都有某种“状态”(一般概念),但并非 React 中的所有组件都有本地组件状态。从现在开始,当我提到状态时,我指的是 React API,而不是一般概念。继承自 React.Component 类的组件将获得访问此 API 的权限。React 将为以这种方式创建的组件创建并跟踪一个后端实例。这些组件还将获得下一章中讨论的一系列生命周期方法的访问权限。
你可以通过 this.state 访问继承自 React.Component 的组件中的状态。在这种情况下,this 指的是类的实例,而 state 是 React 将为你跟踪的特殊属性。你可能认为你可以通过直接赋值或修改其中的属性来更新 state,但这并不是情况。让我们看看以下列表中一个简单 React 组件的组件状态示例。你可以在你的本地机器上创建此代码,或者更简单地在 codesandbox.io/s/ovxpmn340y 上创建。
列表 3.2. 使用 setState 修改组件状态
import React from "react";
import { render } from "react-dom";
class Secret extends React.Component{ *1*
constructor(props) {
super(props);
this.state = {
name: 'top secret!', *2*
};
this.onButtonClick = this.onButtonClick.bind(this); *1*
}
onButtonClick() { *3*
this.setState(() => ({ *3*
name: 'Mark' *3*
}));
}
render() {
return (
<div>
<h1>My name is {this.state.name}</h1>
<button onClick={this.onButtonClick}>reveal the secret!</button> *4*
</div>
)
}
}
render(
<Secret/>,
document.getElementById('root') *5*
);
-
1 创建一个将在一段时间内访问持久组件状态的 React 组件——别忘了将你的类方法绑定到组件实例上。
-
2 为组件提供一个初始状态,以便在 render() 中访问它时不会返回 undefined 值或抛出错误。
-
3 我们第一次看到
setState,这是修改组件状态的特殊 API;调用setState并传入一个回调函数,该函数返回一个新状态对象供 React 使用。 -
4 将揭示名称的函数绑定到按钮发出的点击事件上。
-
5 将顶层组件渲染到应用程序最顶层的 HTML 元素中——你可以按你喜欢的方式标识容器,只要 ReactDOM 能够找到它。
列表 3.2 创建了一个简单的组件,当你点击按钮并使用 setState 更新组件状态时,它会揭示一个秘密名称。注意,setState 在 this 上可用,因为该组件继承自 React.Component 类。
当你点击按钮时,将触发一个点击事件,React 将执行你告诉它响应的函数。当它执行时,它将使用一个对象作为参数调用 setState 方法。该对象有一个 name 属性,指向一个字符串。React 将安排更新状态。当这项工作完成后,React DOM 将根据需要更新 DOM。你的 render 函数将被再次调用,但这次将使用不同的值提供给 JSX 表达式语法({})中的 this.state.name。它将读取“Mark”而不是“绝密!”我的秘密身份就会被揭露!
通常,当可能时,你应该尽量少用setState,因为这样会带来性能和复杂性的影响(React 需要为你跟踪其他东西,而你也需要在心理上跟踪另一份数据)。在 React 社区中,有一些流行的模式允许你几乎不用组件状态(包括 Redux、Mobx、Flux 等),这些模式作为你应用程序的选项是很好的探索对象——实际上,我们将在第十章和第十一章中查看 Redux。尽管通常最好使用无状态函数组件,或者依赖像 Redux 这样的模式,但使用setState API 本身并不是一个坏习惯——它仍然是 React 中更改组件数据的主要 API。
在继续之前,重要的是要注意,你永远不应该在 React 组件中直接修改this.state。如果你尝试直接修改this.state,调用setState()之后可能会替换你所做的修改,甚至更糟糕的是——React 将无法了解你对状态所做的更改。尽管你可以将组件状态视为可以改变的东西,但你应该将this.state对象视为在你的组件内部不可变的(就像 props 一样)。
这也很重要,因为setState()并不会立即修改this.state。相反,它创建了一个挂起的状态转换(关于渲染和变更检测的更多内容将在下一章中介绍)。在调用此方法之后访问this.state可能会返回现有值。所有这些因素都可能造成潜在的调试难题,所以请尽量使用setState()来修改组件状态。
即使是在列表 3.2 中的这样一个小型交互中,也有很多事情在进行。我们将在未来的章节中继续分解 React 在更新你的组件时发生的所有各种步骤,但此刻重要的是更仔细地看看你的组件的render方法。请注意,尽管你执行了状态突变并更改了数据,但它以一种相对可理解和可预测的方式进行。
更好的是,你可以一次性声明你想要的组件外观和结构的样子。你不必为它可能存在的两种不同状态做大量的额外工作(有或没有透露秘密名称)。React 处理了所有底层状态绑定和更新过程,而你只需要说,“名字应该在这里。”React 通过不强迫你在每个时间点都考虑每一块状态,就像在 3.1.1 节中必须做的那样,来帮助你。
让我们更仔细地看看setState API。它是更改 React 组件中动态状态的主要方式,你将在你的应用程序中经常使用它。让我们看看方法签名,看看你需要传递给它什么:
setState(
updater,
[callback]
) -> void
setState 接收一个用于设置组件新状态的函数以及一个可选的 callback 函数。updater 函数具有以下签名:
(prevState, props) => stateChange
在 React 的早期版本中,你可以将一个对象而不是函数作为 setState 的第一个参数传递。与当前版本的 React(16 及以上)的一个关键区别是,它可能意味着 setState 是同步的,而实际上,React 会安排状态的变化。callback 格式更好地传达了这一概念,并且通常与 React 的整体声明式异步范式更一致:你允许系统(React)安排更新,其中顺序但不是时间是有保证的。这与对 UI 的更声明式方法相一致,并且通常比在特定时间强制指定数据更新的命令式方法更容易思考。
如果你需要更新状态,该更新依赖于当前状态或属性,你可以通过 prevState 和 props 参数访问它们。当你想要执行类似切换布尔值并需要知道在执行更新之前的确切最后值时,这通常很有用。
让我们更深入地关注 setState 的机制。使用从你的 updater 函数返回的对象,它将对当前状态执行浅合并。这意味着你可以提供一个对象,React 将合并对象上的顶级属性到状态中。例如,假设你有一个具有属性 A 和 B 的对象。B 有一些深层嵌套的属性,而 A 只是一个字符串('hi!')。由于正在执行浅合并,只有顶级属性及其引用将被保留,而不是 B 的每个部分。React 不会为你找到 B 的某些深层嵌套属性以供更新。一种解决方法是对对象进行复制,深度更新它,然后使用它。你也可以使用像 immutable.js([facebook.github.io/immutable-js/](https://facebook.github.io/immutable-js/))这样的库来使在 React 中处理数据结构更容易。
setState 是一个使用简单的 API;你给你的 ReactClass 组件提供一些要合并到当前状态中的数据,React 会为你处理。如果你需要出于某种原因监听过程的完成,你可以通过可选的 callback 函数连接到它。列表 3.3 展示了 setState 浅合并的一个示例。像之前一样,你可以在 CodeSandbox 上轻松创建和运行你的 React 组件,网址为 codesandbox.io/s/0myo6ny4ww。这应该可以节省你在机器上设置一切的麻烦。
列表 3.3. 使用 setState 进行浅合并
import React from "react";
import { render } from "react-dom";
class ShallowMerge extends React.Component {
constructor(props) {
super(props);
this.state = {
user: {
name: 'Mark', // *1*
colors: {
favorite: '',
}
}
};
this.onButtonClick = this.onButtonClick.bind(this);
}
onButtonClick() {
this.setState({
user: { // *2*
colors: {
favorite: 'blue'
}
}
});
}
render() {
return (
<div>
<h1>My favorite color is {this.state.user.colors.favorite} and my
name is {this.state.user.name}</h1>
<button onClick={this.onButtonClick}>show the color!</button>
</div>
)
}
}
render(
<ShallowMerge />,
document.getElementById('root')
);
-
1 用户属性下的初始状态中存在一个名称...
-
2 ...但不在你设置的状态中——如果它高一个层级,浅合并就不会起作用。
在学习 React 的初期忘记浅合并可能会成为常见的错误来源。在这个例子中,当你点击按钮时,初始状态中嵌套在 user 键下的 name 属性将被覆盖,因为在新状态中它不存在。你想要保留这两部分状态,但最终一个覆盖了另一个。
关于 setState API 的思考
本章讨论了 React 组件中管理状态的组件 API。提到的一点是,你需要通过 setState API 来修改状态,而不是直接修改。你认为这会是什么问题,为什么不会起作用?尝试在 codesandbox.io/s/j7p824jxnw 中试试。
3.2.2. React 中的不可变状态:Props
我们已经讨论了 React 如何通过状态和 setState 让你以可变的方式处理数据,但 React 中的不可变数据呢?在 React 中,props 是传递不可变数据的主要方式。任何组件都可以接收 props(不仅仅是继承自 React.Component 的组件)并在它们的 constructor、render 和 lifecycle 方法中使用它们。
React 中的 props 大概是不可变的。你可以使用库和其他工具在你的组件中模拟不可变的数据结构,但 React 的 props API 本身是半不可变的。如果可用,React 会使用原生的 JavaScript Object.freeze 方法(developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze) 来防止向其添加新属性或从其移除现有属性。Object.freeze 还防止现有属性(或它们的枚举性、可配置性或可写性)被更改,并防止原型被更改。这有助于防止你修改 props 对象,但这在技术上并不是一个真正的不可变对象(尽管你可以基本上这样认为)。
Props 是传递给 React 组件的数据,无论是从父组件还是从组件本身的 defaultProps 静态方法。而组件状态是局部化的,仅限于单个组件,props 通常是从父组件传递的。如果你在想,“我能否在父组件中使用状态来传递 props 给子组件?”你是对的。一个组件的状态可以是另一个组件的 props。
Props 通常作为属性传递给 JSX,但如果你使用React.createElement,你可以通过该接口直接将它们传递给子组件。你可以将任何有效的 JavaScript 数据作为 prop 传递给另一个组件——甚至其他组件(毕竟它们只是类)。一旦 props 被传递给组件以供使用,你不应该从组件内部更改它们。你可以尝试,但你可能会得到一个像Uncaught TypeError: Cannot assign to read-only property '<myProperty>' of object '#<Object>'这样的错误——或者更糟,你的 React 应用可能不会按预期工作,因为你违反了预期的使用。
下一个部分中的列表 3.4 展示了你可以如何访问 props 以及如何不将它们分配给它们。正如之前所述,属性可能会随时间变化,但不是从组件内部。这是单向数据流的一部分——这是后续章节中讨论的主题。简而言之,单向意味着数据从父组件流向子组件,通过组件向下流动。使用状态(从React.Component继承)的父组件可以更改其状态,并且这种更改后的状态可以作为属性传递给子组件,从而改变属性。
在 render 方法中调用 setState
我们已经确定setState是更新组件状态的方法。你可以在哪里调用setState?我们将在下一章中探讨哪些组件生命周期点允许你调用setState,但现在是时候专注于render方法了。你认为在组件的render方法中调用setState会发生什么?尝试在codesandbox.io/s/48zv2nwqww中试试。
3.2.3. 与 props 一起工作:PropTypes 和默认 props
当使用 props 时,你有一些 API 可用,这些 API 可以在开发过程中帮助你:PropTypes 和默认 props。PropTypes 提供了一种类型检查功能,你可以指定组件在使用时预期接收到的 props 类型。你可以指定数据类型,甚至告诉组件消费者他们需要提供的数据形状(例如,一个具有用户属性且具有某些键的对象)。在 React 的早期版本中,PropTypes 是核心React库的一部分,但现在它作为一个独立的prop-types包存在(github.com/facebook/prop-types)。
prop-types库并不是魔法——它是一组函数和属性,可以帮助对输入进行类型检查。它也不特定于 React——你同样可以在其他库中使用它,如果你想在输入上进行类型检查的话。例如,你可以将prop-types引入另一个类似于 React 的组件驱动框架,如 Preact(preactjs.com),并类似地使用它。
要为组件设置 PropTypes,您需要在类上提供一个名为 propTypes 的静态属性。注意在 列表 3.4 中,您在组件类上设置的静态属性名称为小写,而您从 prop-types 库访问的对象名称为大写 (PropTypes)。要指定组件需要的 props,您添加您想要验证的 prop 名称,并将其分配给 prop-types 库默认导出的属性(import PropTypes from 'prop-types')。使用 PropTypes,您可以声明几乎任何类型的 props、形状和要求类型(可选或必需)。
另一个可以帮助您使开发体验更轻松的工具是默认 props。还记得您如何可以使用类 constructor 为组件提供一个初始状态吗?您也可以为 props 做类似的事情。您可以为组件提供一个名为 defaultProps 的静态属性,以提供默认 props。使用默认 props 可以帮助确保您的组件将拥有运行所需的一切,即使使用组件的人忘记提供 prop。
列表 3.4. React 组件中的不可变 props
import React from "react";
import { render } from "react-dom";
import PropTypes from "prop-types";
class Counter extends React.Component {
static propTypes = { *1*
incrementBy: PropTypes.number,
onIncrement: PropTypes.func.isRequired *2*
};
static defaultProps = {
incrementBy: 1
};
constructor(props) {
super(props);
this.state = {
count: 0
};
this.onButtonClick = this.onButtonClick.bind(this);
}
onButtonClick() {
this.setState(function(prevState, props) {
return { count: prevState.count + props.incrementBy };
});
}
render() {
return (
<div>
<h1>{this.state.count}</h1>
<button onClick={this.onButtonClick}>++</button>
</div>
);
}
}
render(<Counter incrementBy={1} />, document.getElementById("root"));
-
1 指定一个具有“形状”的对象。
-
2 您可以将任何与 isRequired 连接的 propTypes 链接起来,以确保如果属性未显示时显示警告。
以下列表展示了在组件中使用 PropTypes 和默认 props 的示例。在 codesandbox.io/s/31ml5pmk4m 运行代码。
如果您想创建一个仅使用 props 而不使用状态的简单组件,您会怎么做?实际上,这是一个常见的用例,尤其是在本书后面将要探讨的一些常见的 React 友好的应用程序架构模式中,如 Flux 和 Redux。在这些情况下,您通常希望将状态保存在集中位置,而不是分散在您的组件中。但仅使用 props 在其他情况下也很有用。如果 React 不必为您管理后端实例,那么您的应用程序的资源使用惩罚将会更小。
事实上,您可以创建一种仅使用 props 的组件:一个无状态函数组件。这些组件有时被开发者称为 无状态 组件、函数 组件和其他类似名称,这可能会让人难以跟踪正在讨论的内容。它们通常意味着同一件事:一个不继承自 React.Component 的 React 组件,因此无法访问组件状态或其他生命周期方法。
无状态函数组件,不出所料,就是这样:一个没有访问或使用 React 状态 API(或从React.Component继承的其他方法)的组件。它之所以是无状态的,并不是因为它完全没有任何类型的(一般)状态,而是因为它没有 React 为你管理的后端实例。这意味着没有生命周期方法(在第四章中介绍),没有组件状态,并且可能占用更少的内存。
无状态函数组件之所以是函数式的,是因为它们可以被编写为命名函数或匿名函数表达式,并分配给一个变量。它们只接受 props,并且因为它们基于给定的输入返回相同的输出,所以本质上被认为是纯函数。这使得它们运行速度快,因为 React 可能会通过避免不必要的生命周期检查或内存分配来进行优化。以下列表展示了无状态函数组件的一个简单示例。在codesandbox.io/s/l756002969运行代码。
列表 3.5. 无状态函数组件
import React from "react";
import { render } from "react-dom";
import PropTypes from "prop-types";
function Greeting(props) { *1*
return <div>Hello {props.for}!</div>;
}
Greeting.propTypes = { *2*
for: PropTypes.string.isRequired
};
Greeting.defaultProps = { *2*
for: 'friend'
};
render(<Greeting for="Mark" />, mountNode);
// Or using an arrow function
// const Greeting = (props) => <div>Hello {props.for}</div>; *1*
//... specify props and default props same as before
// render(<Greeting name="Mark" />, document.getElementById("root"));
-
1 无状态函数组件可以用函数或匿名函数创建。
-
2 对于无状态函数组件的任何形式,你都可以在函数或变量上指定 propTypes 和默认 props 作为属性。
无状态函数组件可以非常强大,尤其是在与具有后端实例的父组件结合使用时。你不必在多个组件之间设置状态,可以创建一个单一的状态父组件,并使用轻量级的子组件来完成其余工作。在第十章和第十一章中,我们将探讨如何使用 Redux 将这种模式提升到一个全新的水平。在使用 Redux 的 React 应用程序中,你通常创建较少的状态组件(尽管仍然有一些情况这样做是有意义的),并将状态集中在一个单一的位置(存储库)。
在一个组件中使用状态来修改另一个组件的 props
本章已经讨论了 props 和状态作为你在 React 组件中处理和传递数据的主要方式。你不应该直接修改状态或 props,但使用setState你可以告诉 React 更新组件的状态。你将如何在一个组件中使用状态来修改另一个组件的 props?前往codesandbox.io/s/38zq71q75尝试一下。
3.3. 组件通信
当您构建简单的评论框组件时,您可以看到您可以从其他组件中创建组件。这就是 React 很棒的一个原因。您可以从子组件轻松构建其他组件,同时保持事物很好地捆绑在一起。您还很容易表达组件之间的“是”和“有”的关系。这意味着您可以将组件视为既有“部分”也有“特定事物”。
您可以混合和匹配组件并灵活地构建事物,但这如何使它们相互通信呢?许多框架和库提供了一种特定于框架的方法,使应用程序的不同部分能够相互通信。在 Angular.js 或 Ember.js 中,您可能听说过或使用过服务来在应用程序的不同部分之间进行通信。通常这些是广泛可用、长期存在的对象,您可以在其中存储状态并在应用程序的不同部分访问它们。
React 使用服务或类似的东西吗?不。在 React 中,如果您想让组件相互通信,您传递 props,当您传递 props 时,您正在做两件简单的事情:
-
访问父组件中的数据(无论是状态还是属性)
-
将该数据传递给子组件
下面的列表展示了您熟悉的父子关系以及所有者-被所有者关系。在codesandbox.io/s/pm18mlz8jm运行它。
列表 3.6. 从父组件向子组件传递属性
import React from "react";
import { render } from "react-dom";
import PropTypes from "prop-types";
const UserProfile = props => { *1*
return <img src={`https://source.unsplash.com/user/${props.username}`} />;
};
UserProfile.propTypes = {
pagename: PropTypes.string *2*
};
UserProfile.defaultProps = {
pagename: "erondu" *2*
};
const UserProfileLink = props => {
return <a href={`https://ifelse.io/${props.user-
name}`}>{props.username}</a>;
};
const UserCard = props => { *3*
return (
<div>
<UserProfile username={props.username} />
<UserProfileLink username={props.username} />
</div>
);
};
render(<UserCard username="erondu" />, document.getElementById("root"));
-
1 创建一个无状态的函数组件,返回一个示例图像。
-
2 记住,即使在无状态的函数组件上,您也可以指定默认属性和 propTypes。
-
3 UserCard 是 UserProfile 和 UserProfileLink 的父组件。
3.4. 单向数据流
如果您之前使用过框架开发过 Web 应用程序,您可能对术语“双向数据绑定”很熟悉。数据绑定是建立应用程序 UI 与其他数据之间连接的过程。在实践中,这通常表现为类似库或框架连接应用程序数据(如模型(用户))到用户界面并保持它们同步的东西。它们是同步的,因此被绑定在一起。在 React 中,另一种更有帮助的思考方式是将其视为“投影”:UI 是将数据投影到视图中的数据,当数据发生变化时,视图也会随之变化,如图 3.3 所示。
图 3.3. 数据绑定通常指的是在您的应用程序数据和视图(数据的显示)之间建立连接的过程。另一种思考方式是将数据投影到用户可以看到的东西上(例如,一个视图)。

另一种思考数据绑定的方式是数据流动:数据是如何在应用程序的不同部分之间移动的?本质上,你是在问,“什么可以更新什么,从哪里,以及如何?”如果你想要有效地使用这些工具,了解这些工具如何塑造、操作和移动数据是非常重要的。不同的库和框架对数据流采取了不同的方法(React 在这方面也不例外,它有自己的看法)。
在 React 中,数据单向流动。这意味着,而不是在实体之间以水平方式流动,每个实体都可以更新另一个实体,而是建立了一个层次结构。你可以通过组件传递数据,但不能伸手修改其他组件的状态或属性,除非传递属性。你也不能修改父组件中的数据。
但你可以通过回调函数将数据向上传递到层次结构的更高层。当一个父组件从子组件接收到回调时,它可以更改其数据并将更改后的数据向下发送给子组件。即使在有回调的这种场景中,数据总体上仍然向下流动,并且由传递数据的父组件决定。这就是为什么我们说在 React 中数据是单向流动的,如图 3.4 所示 figure 3.4。
图 3.4. React 中数据单向流动。属性从父组件传递到子组件(从所有者传递到拥有者),子组件不能编辑父组件的状态或属性。每个具有后端实例的组件可以修改自己的状态,但不能修改自身之外的东西,除了设置其子组件的属性。

单向流在构建用户界面(UIs)时特别有用,因为它往往使人们更容易思考数据在应用程序中的流动方式。由于组件的层次结构和属性(props)以及状态(state)在组件中的局部化,通常更容易预测数据在应用程序中的流动方式。
在某些方面,避免这种层次结构并拥有从应用程序的任何部分修改任何内容的自由可能听起来很吸引人,但在实践中,这往往会导致难以思考的应用程序,并可能导致难以调试的情况。后面的章节将探讨像 Flux 和 Redux 这样的架构模式,这些模式允许你在协调跨组件或跨应用程序的操作的同时,保持单向数据流范式。
3.5. 摘要
本章讨论了以下主题:
-
状态是程序在某一时刻可用的信息。
-
不变状态不会改变,而可变状态会改变。
-
持久的不变数据结构不会改变——它们只记录它们的更改并复制自己。
-
当它们被更新时,短暂的可变数据结构会被清除。
-
React 使用可变数据(本地组件状态)和伪不可变数据(属性)。
-
属性是伪不可变的,一旦设置就不应该修改。
-
组件状态由一个后端实例跟踪,并且可以通过
setState进行修改。 -
setState执行数据的浅层合并并更新你的组件状态,保留任何未被覆盖的顶层属性。 -
在 React 中,数据单向流动,从父组件流向子组件。子组件可以通过回调向父组件返回数据,但不能直接修改父组件的状态,父组件也不能直接修改子组件的状态。组件交互是通过 props 完成的。
在第四章中,我们将基于你对 React 中状态的了解,探讨如何使用生命周期方法来挂钩 React 的渲染和更新过程。我们还将开始探索 React 中的变化检测,你将开始使用新学的 React 技能构建 Letters Social 应用程序!
第四章:React 中的渲染和生命周期方法
本章涵盖
-
配置应用程序仓库
-
渲染过程
-
生命周期方法
-
更新 React 组件
-
使用 React 创建新闻源
在本章中,你将开始整合我们之前所涵盖的一些概念和技能,以创建你的第一个 React 应用程序。在之前的章节中,我们讨论了在 React 中处理数据以及你可以以不同方式处理可变(可更改)和不可变(不可更改)数据。但要构建更健壮的组件,你需要充分利用完整的组件 API,深入了解生命周期方法,并学习 React 中的渲染过程。
我们将探讨渲染,这是 React 将你的数据转换为用户界面的过程,以及一些与组件在其生命周期中交互的方法,称为生命周期方法。你将结合你已知的关于在 React 中读取和修改数据(props 和 state)的知识,更新你的组件状态,并将数据传递到不同的组件。
4.1. 配置 Letters Social 仓库
在本章中,你将开始构建应用程序 Letters Social。我们将假装我们是一家初创公司,专注于创建下一个伟大的社交网络应用程序。我们的公司,Letters——巧妙地命名以区别于像 Alphabet 这样的网络巨头——正在开发 Social。你将在本书的过程中使用 React 来构建这个应用程序。到本书结束时,Letters Social 将使用服务器端渲染、Redux 和 React。该应用程序,如图 4.1 所示(kindle_split_015_split_001.xhtml#ch04fig01),支持一些值得注意的功能,以便你知道你将在本书的过程中构建什么:
-
创建包含文本的帖子
-
使用 Mapbox 向帖子添加位置
-
点赞和评论帖子
-
通过 GitHub 和 Firebase 提供 OAuth 身份验证
-
在新闻源中显示帖子
-
使用基本分页
图 4.1. Letters Social,您将在本书中构建的 React 应用程序。您可以在github.com/react-in-action/letters-social查看其源代码,并在social.react.sh查看应用程序。

我们将在本章和下一章中逐一介绍这些功能。为了使您更容易理解,我已为第四章至第十二章(kindle_split_015_split_000.xhtml#ch04 至 kindle_split_024_split_000.xhtml#ch12)创建了 Git 分支。每个章节(在某些情况下是成对的章节)代表了该章节结束时的代码状态。例如,如果您检查第五章(kindle_split_016_split_000.xhtml#ch05)和第六章(kindle_split_017_split_000.xhtml#ch06)的 Git 分支,您将拥有这些章节结束时的代码。这将让您可以提前查看,并且可以从任何章节开始。如果您想学习第九章(kindle_split_020_split_000.xhtml#ch09)(涵盖测试 React 应用程序),例如,您可以检查第七章(kindle_split_018_split_000.xhtml#ch07)和第八章(kindle_split_019_split_000.xhtml#ch08)的代码,并从那里开始。我已经尽力使您检查代码变得尽可能容易,但您可以使用 Git 仓库和分支以您喜欢的方式使用。如果您有任何问题,请随时通过 pull requests 提出,或者将其 fork 作为添加新功能的起点。您也可以通过 README 与我联系,如果您有任何问题(或者只是喜欢这本书!)。您可以通过 README 这样做。
您还可以在docs.react.sh阅读一些关于源代码中文件的基本文档。它不是全面的,但如果您想了解代码并喜欢 JSDoc 风格的文档,这些文档将是一个不错的选择。仓库的 README 也列出了许多有用的资源。一如既往,如果您有任何问题,请随时联系我(或者只是喜欢这本书!)。您可以通过 README 这样做。
4.1.1. 获取源代码
要获取源代码,请访问github.com/react-in-action/letters-social。这是存储与本书相关的所有源代码的仓库。React in Action GitHub 组织中还有其他几个仓库,您也可以随意查看。主要源代码位于github.com/react-in-action/letters-social。前往该地址,您可以下载源代码或使用以下命令克隆仓库:
git clone git@github.com:react-in-action/letters-social.git
git checkout chapter-4
这将在当前目录中克隆仓库并切换到起始分支(项目的起始分支)。下一步是安装依赖项。为了保持一致性,本书中将使用 npm (www.npmjs.com),但如果你更喜欢使用 yarn(另一个包装 npm 的依赖管理库,位于 yarnpkg.com),你也可以这样做。你只需确保使用 yarn 而不是 npm 进行安装。
你需要的所有模块都应该包含在应用程序源代码的 package.json 中。要安装,请在源代码目录中运行以下命令:
npm install
这将安装你需要的所有依赖项。如果你更改了 node 的版本(通过 nvm 或其他方式),你需要重新安装你的 node 模块,因为不同版本的 node 会以不同的方式编译不同的模块(如 node-sass)。
4.1.2. 我应该使用哪个版本的 node?
现在是讨论应该使用哪个版本的 node 的好时机。我建议使用最新稳定版本。在撰写本文时,那是 8.X 版本线。我不会支持低于 6.X 的 node 版本,而且支持 8.X 或更高版本更有意义,因为这不是一个商业或生产环境,你无法在不进行大量测试的情况下轻松切换版本。Node 8.X 还使用了更新的 npm 版本,并包含了对底层 V8 引擎的重大速度改进。
如果你电脑上没有这些版本的 node,请访问 nodejs.org 下载最新稳定版本的 node。另一个选项是使用 nvm 命令行工具在本地安装 node 的副本,并能够在它们之间切换。你可以在 github.com/creationix/nvm 查看 nvm 工具。
不同版本的 node 支持不同的 JavaScript 功能,因此了解你使用的版本支持哪些功能很重要。如果你想了解更多关于你的版本支持哪些功能以及其他版本支持哪些(或将要支持哪些)的信息,请访问 node.green 以查看不同版本的功能实现。
4.1.3. 工具和 CSS 的注意事项
正如我在本书的其他地方提到的,围绕 JavaScript 应用程序的工具可能是一个复杂且快速变化的目标。这也是一个值得单独讨论的领域。出于这些原因,我不会介绍如何设置 Webpack、Babel 或其他工具。应用程序源代码已经实现了开发和构建过程,你可以自由探索我设置的配置,但这超出了本书的范围,因此我不会涉及它。
另一个值得注意的点是关于 CSS 的。我已经介绍了您在 React 中处理内联样式的方法,但 CSS 通常也不在本书的范围之内。因此,我为您创建了所有需要的样式。您看到的任何 UI 标记都有为其创建的样式。某些样式依赖于特定的类型或层次结构,因此如果您移动不同的元素或更改 CSS 类名,您可能会发现应用看起来破损。我的目标是让您在学习 React 时少考虑一件事情,但如果您对玩转应用样式感兴趣,请随意进行。
4.1.4. 部署
运行在social.react.sh的应用已部署到zeit.co,但如果未来由于某些原因需要更改,我会根据当时最合理的云解决方案来保持应用运行。您无需担心应用托管在何处。如果在书的结尾您想对应用进行分支并添加以供自己学习和娱乐,您需要确定最适合自己部署应用的最佳方式。幸运的是,构建和运行过程都很直接,因此您应该会发现将其部署到其他地方相对容易。
4.1.5. API 服务器和数据库
为了避免您需要运行像 MongoDB 或 PostgreSQL 这样的数据库,我们将通过JSON-server库(github.com/typicode/json-server)使用模拟 REST API。我对默认服务器(您可以在仓库的 db 文件夹中看到)进行了一些修改,这有助于使项目变得更容易一些。您将不会与数据库交互,而是得到一个轻量级的数据库,它通过读取和修改 JSON 文件来工作。要创建示例数据或重置应用程序数据,您可以运行以下命令:
npm run db:seed
这将覆盖现有的 JSON 数据库,并用新的示例数据(用户、帖子以及评论都是基于星球大战主题——愿原力与你同在)替换它。在后续章节中,您在登录后将在数据库中创建一个用户。如果您重新运行数据库seed命令,您的用户将被覆盖,您需要注销并重新登录以解决问题。这种情况不应该发生,您可能不需要多次运行数据库命令,但您应该了解重置数据意味着什么,以防万一。
我包含了一些辅助工具,以便更容易地向 API 发送请求。你可以在 src/shared/http.js 中看到这些函数。我正在使用 isomorphic-fetch 库(github.com/matthew-andrews/isomorphic-fetch),因为它反映了浏览器中可用的标准 Fetch API,但也可以在服务器上运行。我将假设你有一些在浏览器中使用 HTTP 库的经验,如果没有,你可以使用包含的辅助文件作为学习 Fetch API 的起点(developer.mozilla.org/en-US/docs/Web/API/Fetch_API)。
4.1.6. 运行应用程序
开始以开发模式运行应用程序的最简单方法将是运行以下命令:
npm run dev
你还可以使用其他命令,但你会想要的主要是 dev。要查看其他可用的命令,你可以运行以下命令:
npm run
这应该会列出仓库中每个可用的命令。你可以随意尝试每个命令,看看它们如何适应。不过,你主要关心的将是 npm run dev 和 npm run db:seed。
4.2. 渲染过程和生命周期方法
如果你已经克隆了仓库并安装了依赖项,你应该拥有所需的一切。不过,在你开始构建 Letters Social 之前,你需要查看渲染和生命周期方法。这些是 React 的关键特性,一旦你了解了它们,你将更有能力开始构建 Letters Social 应用程序。
4.2.1. 介绍生命周期方法
在 第二章 中,你了解到你可以在组件内部创建并分配函数作为事件(点击、表单提交等)的处理程序。这很有用,因为你可以创建动态组件,它们可以响应用户事件(任何现代网络应用的关键方面)。但如果你想要更多呢?仅仅作为一个特性,似乎你仍然在使用常规的 HTML 和 JavaScript。比如说,你想从 API 获取用户数据或读取 cookie 以供以后使用,所有这些都不需要等待用户触发的事件。这些是在网络应用中需要做的常规事情——在某些情况下,你可能希望自动执行这些操作,那么这些事情会在哪里发生呢?答案是生命周期方法。
定义
生命周期方法 是附加到基于类型的 React 组件上的特殊方法,它们将在组件生命周期的特定点执行。生命周期 是思考组件的一种方式。具有生命周期的组件有一个比喻性的“生命”——它至少有一个开始、中间和结束。这种思维模型使思考组件变得更加容易,并为你提供了关于组件在其生命周期中的位置的上下文。生命周期方法并非 React 独有;许多 UI 技术由于它们的直观和有用性而采用它们。React 组件生命的主要部分是挂载、更新和卸载。图 4.2 展示了组件生命周期和渲染过程(React 如何随时间管理你的组件)的概述。
图 4.2. React 概览。React 将渲染(创建、管理)组件,并从中创建用户界面。

我在过去几章中提到了生命周期方法,但现在是我们真正深入探讨它们,以了解它们是什么以及如何使用它们的时候了。为了开始,再次从高层次上思考 React。看看 图 4.2 的顶部,以刷新你的记忆。我谈到了 React 中的状态,使用 React.createElement 和 JSX 创建组件,但我们仍然需要深入探讨生命周期方法。
让我们从过去的章节中唤醒你的记忆,并回顾一些概念。什么是渲染?渲染 的一种定义是“使成为或变为;使”。就我们的目的而言,你可以将渲染视为 React 为你创建和管理用户界面所做的事情。它是将你的应用程序显示在屏幕上的工作。这是 React 将你的组件转换为 UI 的过程。
你可以使用本章中学习的生命周期方法钩入此过程。这些方法为你提供了在组件生命周期的正确时刻执行所需操作的灵活性。它们仅适用于从扩展 React.Component 抽象基类的类创建的组件。
在 第三章 的末尾讨论的无状态函数组件没有可用的生命周期方法。你也不能在它们内部使用 this.setState,因为它们没有后盾实例;React 不跟踪它们的任何内部状态。它们仍然可以通过父组件的 props 更新其数据,但你无法访问生命周期方法。这可能看起来像是一种阻碍,或者像它们功能较弱,但在许多情况下,它们就是你需要的所有东西。
4.2.2. 生命周期方法的类型
本节将探讨 React 在不同组中提供的不同生命周期方法,并讨论每个方法的作用。生命周期方法可以分为两大类:
-
*方法—— 在某件事情发生前立即调用
-
*方法—— 在某件事情发生后立即调用
还有一些其他方法不适合归入上述任何一类。它们与初始化和错误处理有关,其中一个是用于更新的。然而,大多数方法都是 did 和 will 类型。
我们可以根据它们与生命周期哪个部分相关进一步将它们细分为更多类型(参见图 4.3)。组件有四个主要的生命周期部分,以及对应的生命周期方法:
-
初始化— 当一个组件类被实例化时。
-
挂载— 一个组件正在被插入到 DOM 中。
-
更新— 一个组件通过状态或属性使用新数据更新。
-
卸载— 一个组件正在从 DOM 中移除。
图 4.3. 渲染过程和组件生命周期的概述。这是 React 为你管理组件所使用的过程。组件的三个主要生命阶段是挂载、已挂载和卸载。组件挂载时正在被插入到 DOM 中,一旦插入,就变为已挂载,当它被移除时,就处于卸载状态。

在初始化、挂载、更新和卸载期间,都会调用一些生命周期方法。这些方法并不多,尤其是与其他库和框架相比,但在学习 React 时,很容易混淆它们。为它们形成有意义的心理分组将帮助你导航渲染过程的各个部分。图 4.4 显示了 React 整个渲染过程的概述,我们将在本章的其余部分更详细地探讨它。
图 4.4. React 中组件生命周期的概述。ReactDOM渲染一个组件,当 React 管理你的组件时,会调用某些生命周期方法。

记住,将用户界面和组件视为生命周期的一部分并不特指 React 或 JavaScript。其他技术也成功采用了这一理念,有时甚至是在受到 React 的启发之后(例如componentkit.org)。但这些特定的生命周期方法是React 独有的。要探索这些方法,你需要创建两个简单的组件——一个父组件和一个子组件——它们将实现我们将要查看的所有生命周期方法。前往codesandbox.io/s/2vxn9251xy查看如何添加这些组件。你仍然可以从 CodeSandbox 下载代码,并使用浏览器开发者工具检查控制台。列表 4.1 显示了这些组件的基本设置。
列表 4.1. 探索生命周期方法
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { render } from 'react-dom';
class ChildComponent extends Component { *1*
static propTypes = {
name: PropTypes.string *2*
};
static defaultProps = (function() { *3*
console.log('ChildComponent : defaultProps');
return {};
})();
constructor(props) {
super(props);
console.log('ChildComponent: state');
}
render() {
console.log('ChildComponent: render');
return (
<div>
Name: {this.props.name}
</div>
);
}
};
class ParentComponent extends Component { *4*
constructor() {
super(props);
this.state = { *5*
name: ''
}
this.onInputChange = this.onInputChange.bind(this); *5*
}
onInputChange(e) {
this.setState({ text: e.target.value }); *6*
}
render() {
console.log('ParentComponent: render');
return [
<h2 key="h2">Learn about rendering and lifecycle methods!</h2>,
<input key="input" value={this.state.text}
onChange={this.onInputChange} />,
<ChildComponent key="ChildComponent" name={this.state.text} /> *7*
];
}
};
render(
<ParentComponent />, *8*
document.getElementById('container')
);
-
1 声明一个子组件。
-
2 在类上设置 propTypes 作为静态方法。
-
3 设置默认属性——通常你会将其设置为对象而不是函数,但在这里你使用立即执行函数来注入 console.log 语句。
-
4 创建一个父组件。
-
5 在构造函数中绑定 onInputChange 方法,这样你就可以在 render 中引用该方法,并使其指向类实例,而不是定义。
-
6 使用表单输入中的数据更新状态。
-
7 在父组件中渲染子组件。
-
8 使用 React DOM 渲染父组件。
你不需要你的组件为你做很多事情来探索生命周期方法的工作方式。在这里,你已经设置了一个父组件和一个子组件。父组件监听输入字段的更改,并通过状态向子组件提供新的属性。
4.2.3. 初始和“将要”方法
首先要探索的一组与生命周期相关的属性是组件的初始属性。这些包括你已经了解的两个属性:defaultProps 和 state(初始)。这些属性有助于为你的组件提供初始数据。在继续之前,让我们快速回顾一下:
-
defaultProps——一个静态属性,为组件提供默认属性。如果父组件没有设置该属性,则设置在this.props上,在挂载任何组件之前访问,并且不能依赖于this.props.或this.state。因为defaultProps是一个静态属性,所以它从类中访问。 -
state(初始)——在构造函数中此属性的值将是为你组件的状态设置的初始值。这在需要提供占位符内容、设置默认值等情况非常有用。它与默认属性类似,但数据预期会被修改,并且仅在继承自React.Component的组件上可用。
即使设置初始状态和属性不是通过 React Component 类的特殊方法完成的(它们使用 JavaScript constructor 方法),它们仍然是组件生命周期的一部分。很容易在心中不小心忽略它们,但它们在为组件提供数据方面发挥着重要作用。
为了帮助说明渲染顺序和不同的生命周期方法,你接下来将创建两个简单的组件,你可以在这些组件上指定生命周期方法。你将创建一个父组件和一个子组件,这样你不仅可以看到不同方法调用的顺序,还可以看到这种顺序如何在父组件和子组件之间确定。为了使事情简单,你将只将信息输出到开发者控制台。图 4.5 展示了你完成后的开发者控制台将能够看到的内容。
图 4.5. 样本组件一旦被完善后的输出。生命周期方法会在每个步骤触发一个消息被记录到控制台,以及任何可用的方法参数。你可以在codesandbox.io/s/2vxn9251xy看到生命周期方法的作用。

4.2.4. 挂载组件
现在你已经创建了父组件和子组件,让我们看看挂载。挂载是 React 将组件插入 DOM 的过程。记住,组件只存在于虚拟 DOM 中,直到 React 在真实 DOM 中创建它们。参见图 4.6 以了解挂载和父组件及子组件渲染过程的概述。挂载方法将允许你“钩入”组件生命的开始和结束,并且只会触发一次,因为根据定义,组件只有一个开始和结束。
图 4.6. 样本父组件和子组件的渲染过程

定义
挂载是 React 将你的组件插入真实 DOM 的过程。一旦完成,你的组件就“准备好”了,通常这是一个执行 HTTP 调用或读取 cookie 的好时机。在这个时候,你也将能够通过一个称为ref的东西访问 DOM 元素,这将在未来的章节中讨论。
如果你回顾一下图 4.3,你会注意到在组件挂载之前,你只有一个机会改变状态。你可以通过使用componentWillMount来实现,这将为你提供一个在组件挂载之前设置状态或执行其他操作的机会。在这个方法中状态的变化不会触发重新渲染,这与会触发之前看到的更新过程的其它状态更新不同。了解哪些方法会触发重新渲染以及哪些不会,这对于理解你的应用行为以及调试出错时非常有用。图 4.7 展示了在我们在其中工作的生命周期概述背景下挂载方法。
图 4.7. 在更大的生命周期过程中挂载方法。组件被添加到 DOM 中,在这个过程中会触发几个特定的方法。

我接下来要介绍的方法是componentDidMount。当 React 调用这个方法时,你有机会使用componentDidMount以及访问组件的 refs。在这个方法中,你可以访问组件的状态和属性,以及你的组件已经准备好更新的知识。这意味着这是一个用从网络请求返回的数据更新组件状态的好地方。这也是与依赖于 DOM 的第三方库(如 jQuery 和其他库)一起工作的好地方。
如果你在其他方法(如render())中执行处理程序或其他函数,由于 React 的工作方式,你可能会得到不可预测和意外的结果。渲染方法需要是纯的(基于给定输入的一致性),并且通常在组件的生命周期中多次调用。React 甚至可能将更新批处理在一起,因此你不能保证渲染会在特定时间发生。
现在我们已经查看了一些与挂载相关的方 法,你将把它们添加到你的组件中,这样我们就可以看到组件的生命周期。下一个列表显示了如何将挂载方法添加到你的组件中。
列表 4.2. 挂载方法
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { render } from 'react-dom';
class ChildComponent extends Component {
static propTypes = {
name: PropTypes.string
};
static defaultProps = (function() {
console.log('ChildComponent : defaultProps');
return {};
})();
constructor(props) {
super(props);
console.log('ChildComponent: state');
this.state = {
name: 'Mark'
};
}
componentWillMount() { *1*
console.log('ChildComponent : componentWillMount');
}
componentDidMount() { *1*
console.log('ChildComponent : componentDidMount');
}
render() {
if (this.state.oops) {
throw new Error('Something went wrong');
}
console.log('ChildComponent: render');
return [
<div key="name">Name: {this.props.name}</div>
];
}
}
class ParentComponent extends Component {
static defaultProps = (function() {
console.log('ParentComponent: defaultProps');
return {
true: false
};
})();
constructor(props) {
super(props);
console.log('ParentComponent: state');
this.state = { text: '' };
this.onInputChange = this.onInputChange.bind(this);
}
componentWillMount() { *2*
console.log('ParentComponent: componentWillMount');
}
componentDidMount() { *2*
console.log('ParentComponent: componentDidMount');
}
onInputChange(e) {
const text = e.target.value;
this.setState(() => ({ text: text }));
}
render() {
console.log('ParentComponent: render');
return [
<h2 key="h2">Learn about rendering and lifecycle methods!</h2>,
<input key="input" value={this.state.text}
onChange={this.onInputChange} />,
<ChildComponent key="ChildComponent" name={this.state.text} />
];
}
}
render(<ParentComponent />, document.getElementById('root'));
-
1 将 component-DidMount 和 componentWillMount 添加到子组件中。
-
2 将 component-DidMount 和 componentWillMount 添加到父组件中。
深思渐增
组件已挂载意味着什么?
4.2.5. 更新方法
一旦你的组件挂载并在 DOM 中,你将想要更新它。在第三章中,你看到可以使用this.setState()将新数据浅度合并到组件状态中,但当你触发更新时,还有更多的事情发生。React 提供了几个你可以用来钩入更新过程的方法:shouldComponentUpdate、componentWillUpdate和componentDidUpdate。图 4.8 显示了之前查看的整体生命周期图中的更新部分。
图 4.8. 更新生命周期方法。当一个组件正在更新时,会触发几个钩子,这些钩子让你确定组件是否应该更新,如何更新,以及何时完成更新。

与我们之前看到的其他方法不同,你可以选择是否应该发生更新。更新方法和与挂载相关的方法之间的另一个区别是,它们为 props 和 state 提供了参数。你可以使用这些参数来确定是否应该发生更新或对变化做出反应。
如果由于某种原因shouldComponentUpdate返回false,则render()将跳过,直到下一个状态变化。这意味着你可以防止你的组件不必要地更新。因为组件不会更新,所以下一个方法componentWillUpdate和componentDidUpdate将不会被调用。
除非你明确指定,否则shouldComponentUpdate将始终返回true,但如果你总是小心地对待状态为不可变,并且在render()中只从 props 和状态中读取,那么你可以使用比较旧 props 和状态与其替代品的实现来覆盖shouldComponentUpdate。这可能对性能调整很有用,但应被视为一个逃生口。React 已经采用了复杂和高级的方法来确定什么应该更新以及何时更新。
如果您最终确实使用了 shouldComponentUpdate,那么它应该是在这些方法不足以满足某些原因的情况下。这并不意味着您永远不应该使用它,但您在刚开始使用 React 时可能不需要它。像所有生命周期方法一样,它是提供给您的,但只有在必要时才应使用。下一个列表显示了 React 的与更新相关的生命周期方法的示例。
列表 4.3. 更新方法
//...
class ChildComponent extends Component {
//...
componentWillReceiveProps(nextProps) { *1*
console.log('ChildComponent : componentWillReceiveProps()');
console.log('nextProps: ', nextProps);
}
shouldComponentUpdate(nextProps, nextState) { *2*
console.log('<ChildComponent/> - shouldComponentUpdate()');
console.log('nextProps: ', nextProps);
console.log('nextState: ', nextState);
return true;
}
componentWillUpdate(nextProps, nextState) { *2*
console.log('<ChildComponent/> - componentWillUpdate');
console.log('nextProps: ', nextProps);
console.log('nextState: ', nextState);
}
componentDidUpdate(previousProps, previousState) { *2*
console.log('ChildComponent: componentDidUpdate');
console.log('previousProps:', previousProps);
console.log('previousState:', previousState);
}
//...
render() {
console.log('ChildComponent: render');
return [
<div key="name">Name: {this.props.name}</div>
];
}
}
class ParentComponent extends Component {
//...
onInputChange(e) {
const text = e.target.value;
this.setState(() => ({ text: text }));
}
//...
render() {
console.log('ParentComponent: render');
return [
<h2 key="h2">Learn about rendering and lifecycle methods!</h2>,
<input key="input" value={this.state.text}
onChange={this.onInputChange} />,
<ChildComponent key="ChildComponent" name={this.state.text} />
];
}
}
//...
-
1 将更新方法添加到子组件中,以便您可以检查单个组件的更新过程。
-
2 将更新方法添加到子组件中,以便您可以检查单个组件的更新过程。
现在您已经为您的组件指定了更新方法,再次运行它们并在文本框中输入一些内容。您将在开发者控制台中看到级联输出(列表 4.4 显示了组件应该输出的内容)。花一分钟时间看看渲染的顺序。您注意到什么了吗?顺序应该与您在本章中学到的内容一致,但现在您可以看到子组件和父组件的顺序是如何重要的。您可能还记得从第二章中,React 在形成树和渲染事物时是递归的——它将通过询问每个组件及其所有子组件来彻底检查您的组件的每个部分。
因为它知道关于您的组件树所需的所有信息,React 可以智能地按正确顺序为您创建组件。您会在列表 4.4 中注意到,子组件的挂载发生在其父组件之前。如果您考虑挂载对父组件的意义,这就有道理了:在父组件的挂载被认为完成之前,必须先创建子组件。如果子组件尚未存在,则不能说父组件已挂载。
您还会注意到,当发生更新时,您会看到子组件接收属性,因为该子组件的属性已被父组件通过 this.setState() 改变。从那里开始,更新方法按顺序运行:shouldComponentUpdate、componentWillUpdate、componentDidUpdate。如果您出于某种原因告诉组件不要更新,通过从 shouldComponentUpdate 返回 false,这些步骤就会被跳过。
列表 4.4. 输入文本的组件更新输出
ChildComponent : defaultProps
ParentComponent : defaultProps
ParentComponent : get initial State
ParentComponent : componentWillMount
ParentComponent : render
ChildComponent : componentWillMount
ChildComponent : render
ChildComponent : componentDidMount
ParentComponent : componentDidMount
ParentComponent : render
ChildComponent : componentWillReceiveProps
Object {text: "Mark"} *1*
<ChildComponent/> : shouldComponentUpdate
nextProps: Object {text: "Mark"}
nextnextState: Object {name: "Mark"}
<ChildComponent/> : componentWillUpdate
nextProps: Object {text: "Mark"}
nextState: Object {name: "Mark"}
ChildComponent : render
ChildComponent : componentDidUpdate
previousProps: Object {text: ""}
previousState: Object {name: "Mark"}
>
- 1 “Mark” 被粘贴进去,这样您就不会为每个字母触发一系列更新。
4.2.6. 卸载方法
正如我们可以监听组件的挂载一样,我们也可以监听其卸载。卸载是从 DOM 中移除组件的过程。如果你的应用完全使用 React 编写,一个路由器(在第八章 chapters 8 和第九章 chapters 9 中探讨)会在你切换到不同页面时移除组件。但你也可以使用 React 与其他框架和库集成,因此你可能需要在组件卸载时执行一些其他操作(比如清除一个间隔,切换一个设置等)。无论是什么,你都可以利用 componentWillUnmount 在组件被移除时执行任何必要的清理。图 4.9 Figure 4.9 展示了卸载过程是如何发生的。
图 4.9. React DOM 负责组件的挂载和卸载。挂载是将组件插入 DOM 的过程,而卸载则是其相反过程:从 DOM 中移除组件的过程。一旦组件被卸载,它们就不再存在于 DOM 中。

根据到目前为止挂载的工作方式,你可能会期望有一个 componentDidUnmount 方法可用,但实际上并没有。这是因为一旦组件被移除,它的生命周期就结束了,因此它不应该能够从坟墓之外做任何事情。让我们将 componentWillUnmount 添加到我们的运行示例中,以便我们可以全面了解组件的生命周期。
列表 4.5. 卸载
//...
class ChildComponent extends Component {
//...
componentWillUnmount() {
console.log('ChildComponent: componentWillUnmount'); *1*
}
render() {
console.log('ChildComponent: render');
return [
<div key="name">Name: {this.props.name}</div>
];
}
}
class ParentComponent extends Component {
//...
componentWillUnmount() {
console.log('ParentComponent: componentWillUnmount'); *1*
}
onInputChange(e) {
const text = e.target.value;
this.setState(() => ({ text: text }));
}
componentDidCatch(err, errorInfo) {
console.log('componentDidCatch');
console.error(err);
console.error(errorInfo);
this.setState(() => ({ err, errorInfo }));
}
render() {
return [
<h2 key="h2">Learn about rendering and lifecycle methods!</h2>,
<input key="input" value={this.state.text}
onChange={this.onInputChange} />,
<ChildComponent key="ChildComponent" name={this.state.text} />
];
}
}
//...
- 1 将
componentWillUnmount方法添加到父组件和子组件中。*
4.2.7. 捕获错误
错误处理是编写清晰程序的重要组成部分。到目前为止,我们还没有看到 React 中处理错误的任何特殊方法。如果你长期使用 React,你可能还记得 React 的早期版本,如果 React 组件的 render 或生命周期方法中发生错误,整个应用会锁定。这通常是一个令人沮丧的原因,因为它意味着一个未捕获的错误可能会锁定整个应用。
更新版本的 React 引入了一个名为 错误边界 的新概念,以帮助处理这种情况。如果一个未捕获的异常在组件的 constructor、render 或生命周期方法中抛出,React 将从 DOM 中卸载该组件及其子组件。一开始这可能看起来有些困惑,但它的好处是能够将组件中的错误隔离,从而避免破坏应用的其他部分。
组件之间的差异
React 组件从抽象基类 React.Component 创建和从没有继承的纯函数创建之间有哪些不同之处?
您可以通过使用组件从 React.Component 继承的另一个方法来处理这些错误:componentDidCatch。该方法的意义与您在 JavaScript 中看到的 try...catch 行为类似。componentDidCatch 允许您访问抛出的错误和错误消息。使用这些信息,您可以确保组件适当地响应错误。在更大的应用程序中,您可能使用此方法为单个组件(可能是一个小部件、卡片或其他组件)或应用程序级别设置错误状态。以下列表显示了如何将 componentDidCatch 方法添加到父组件中。
列表 4.6. 处理错误
//...
class ChildComponent extends Component {
constructor(props) {
super(props);
console.log('ChildComponent: state');
this.oops = this.oops.bind(this); *1*
}
//...
oops() {
this.setState(() => ({ oops: true })); *2*
}
render() {
console.log('ChildComponent: render');
if (this.state.oops) {
throw new Error('Something went wrong'); *3*
}
return [
<div key="name">Name: {this.props.name}</div>,
<button key="error" onClick={this.oops}>
Create error
</button>
];
}
}
class ParentComponent extends Component {
//...
constructor(props) {
super(props);
console.log('ParentComponent: state');
this.state = { text: '' };
this.onInputChange = this.onInputChange.bind(this);
}
//...
componentDidCatch(err, errorInfo) { *4*
console.log('componentDidCatch');
console.error(err);
console.error(errorInfo);
this.setState(() => ({ err, errorInfo }));
}
render() {
console.log('ParentComponent: render');
if (this.state.err) { *5*
return (
<details style={{ whiteSpace: 'pre-wrap' }}>
{this.state.error && this.state.error.toString()} *5*
<br />
{this.state.errorInfo.componentStack} *5*
</details>
);
}
return [
<h2 key="h2">Learn about rendering and lifecycle methods!</h2>,
<input key="input" value={this.state.text}
onChange={this.onInputChange} />,
<ChildComponent key="ChildComponent" name={this.state.text} />
];
}
}
render(<ParentComponent />, document.getElementById('root'));
-
1 绑定类方法。
-
2 切换状态以抛出错误。
-
3 在 render 方法中抛出错误。
-
4 在父组件中添加 componentDidCatch 方法并使用它来更新组件状态。
-
5 如果抛出错误,显示错误和错误消息。
我们已经探讨了 React 提供给您不同的生命周期方法,并看到了您如何在各种情况下使用它们。如果您觉得有太多方法需要跟踪,您会很高兴地知道这些方法构成了 React 组件 API 的主要部分(您也可以使用 表 4.1 作为速查表)。核心 React API 不会有我们之前所涵盖的更多内容。更重要的是,您不必使用这些方法中的每一个;使用您需要的。表 4.1 展示了迄今为止涵盖的方法的总结(请注意,render 没有包括在内)。
表 4.1. React 组件生命周期方法总结
| 初始 | 将要 | 已做 | |
|---|---|---|---|
| 挂载 | defaultProps 参数—无,静态属性 功能—多次访问的静态版本。如果父组件没有设置该属性,则将其值设置为 this.props。 何时—在组件创建时调用,此时不能依赖于 this.props。返回的复杂对象在实例之间共享,而不是复制。 | componentWillMount 参数—无 功能—允许你在挂载过程发生之前操作组件数据。例如,如果你在这个方法中调用 setState,render()将看到更新的状态,并且尽管状态已更改,但只会执行一次。“最后的机会”来更改初始渲染数据。 何时—在客户端和服务器上仅调用一次(第十二章涵盖了服务器渲染),在初始渲染发生之前立即调用。 | componentDidMount 参数—无 功能—在组件被插入到 DOM 中后立即调用。此时,你可以访问 refs(在未来的章节中讨论的访问底层 DOM 表示的方法)。通常是一个执行“不纯”操作的好地方,如与其他 JavaScript 库集成、设置定时器(通过 setTimeout 或 setInterval)或发送 HTTP 请求。我们经常使用这个方法来替换组件中的占位符数据。 何时—在客户端上仅调用一次(不在服务器上!),在初始渲染发生之后立即调用。子组件的 componentDidMount()方法在父组件之前调用。 |
| 更新 | shouldComponentUpdate 参数—nextProps, nextState 功能—如果 shouldComponentUpdate 返回 false,则 render()将完全跳过,直到下一个状态变化。此外,componentWillUpdate 和 componentDidUpdate 也不会被调用。作为高级性能调优的“逃生门”。 何时—在组件接收到新属性或状态之前调用渲染时被调用。对于初始渲染不会调用。 | componentWillReceiveProps 参数—nextProps: 对象 功能—在调用 render()之前,利用这个机会在渲染之前对属性转换做出反应,可以通过使用 this.setState()更新状态。旧属性可以通过 this.props 访问。在这个函数内部调用 this.setState()不会触发额外的渲染。 何时—当组件接收到新属性时被调用。对于初始渲染不会调用此方法。 | componentDidUpdate 参数—prevProps: 对象,prevState: 对象 功能—在组件的更新被刷新到 DOM 之后立即调用。对于初始渲染不会调用此方法。 何时—在组件更新后,利用这个机会操作 DOM。 |
componentWillUpdate 参数—nextProps: 对象, nextState: 对象 作用—利用这个机会在更新发生之前进行准备。你不能使用 setState()。 触发时机—在接收到新属性或状态时,立即在渲染之前调用。对于初始渲染不会调用。 componentWillUnmount 参数—无 作用—在这个方法中执行任何必要的清理工作,例如取消定时器或清理在componentDidMount中创建的任何 DOM 元素。 触发时机—在组件卸载之前立即调用。 |
|||
| 错误 | componentDidCatch 参数—error, errorInfo 作用—处理组件中的错误。React 将卸载发生错误的树中及其下方的组件。 触发时机—在构造函数、生命周期方法或渲染方法中发生错误时调用 |
4.3. 开始创建 Letters Social
现在你对 React 的生命周期方法和它们的作用有了更多的了解,让我们将这些技能付诸实践。你将开始构建 Letters Social 应用程序。如果你还没有,请确保你已经阅读了本章的第一部分,以便了解如何使用 Letters Social 仓库。当你开始时,你应该在 start 分支上,但如果你想跳到本章的结尾,你可以检出 chapter-4 分支(git checkout chapter-4)。
到目前为止,你一直在 CodeSandbox 的浏览器上运行大部分代码。这对于学习来说是可以的,但你现在将切换上下文,开始在本地计算机上创建文件。你将想要使用仓库中包含的 Webpack 构建过程,原因有以下几点:
-
能够在许多文件中编写 JavaScript,这些文件输出为一个或少数几个文件,这些文件已经自动解决了依赖关系和导入顺序
-
能够处理和加工不同类型的文件(如 SCSS 或字体文件)
-
为了利用像 Babel 这样的其他构建工具,这样你就可以编写能在旧浏览器上运行的现代 JavaScript
-
通过移除死代码和压缩来优化 JavaScript
Webpack 是一个由许多团队和公司使用的强大工具。正如本章前面所述,我不会在这本书中介绍如何使用它。我在这本书中的一个希望是,你不需要学习 React 和每个相关的构建工具。一次学习太多的复杂性,而不是让学习变得容易。但如果你选择,你可以了解更多关于它的信息。如果你花些时间阅读关于 Webpack 的内容,就可以理解源代码中包含的构建过程。webpack.js.org。
你将通过创建一个 App 组件和一个主索引文件来开始构建 Letters Social,这个索引文件将作为进入应用程序的入口点(在这里调用 React DOM 的 render 方法)。App 组件将包含从 API 获取帖子的逻辑,并将渲染多个 Post 组件——你将在下一章创建帖子组件。仓库还包含了一些你不需要自己创建的组件。你现在将使用这些组件,并在未来的章节中使用。以下列表显示了入口点文件,src/index.js。
列表 4.7. 主应用程序文件(src/index.js)
import React, { Component } from 'react'; *1*
import { render } from 'react-dom'; *1*
import App from './app'; *2*
import './shared/crash'; *3*
import './shared/service-worker'; *3*
import './shared/vendor'; *3*
import './styles/styles.scss'; *3*
render(<App />, document.getElementById('app')); *4*
-
1 从 React DOM 中导入 React 和 render 方法——这个文件将是调用 React DOM 的 render 方法的主体调用所在的地方。
-
2 从 App 组件中导入默认导出——你将在下一列表中创建它。
-
3 导入一些与错误报告、服务工作者注册和样式(由仓库设置处理)相关的文件。
-
4 使用目标元素(HTML 模板可在 src/index.ejs 中找到)调用 render,以渲染主应用程序。
主应用程序文件包含一些样式引用,Webpack 可以导入,以及调用 React DOM 的 render 方法的主体调用。这是你的 React 应用程序“开始”的地方。当脚本由浏览器执行时,它将渲染主应用程序,React 将接管。如果没有这个调用,你的应用程序将不会执行。你可能还记得从前几章中,你在主应用程序文件的底部调用了这个方法。实际上并没有什么不同——你的应用程序将由许多不同的文件组成,Webpack 将知道如何将它们(多亏了你的 import/export 语句)组合在一起并在浏览器中运行。
现在你已经有了应用程序的入口点,让我们创建主 App 组件。你可以将此文件放在 src 目录中,作为 src/app.js。你将为 App 组件草拟一个基本框架,然后在过程中填充它。在本章中,你的目标是让主应用程序运行并显示多个帖子。在下一章中,你将开始实现更多功能,并添加创建帖子以及添加位置到帖子的能力。随着你在 React 中探索不同的主题,如测试、路由和应用程序架构(使用 Redux),你将不断向应用程序添加功能。以下列表显示了 App 组件的基本内容。
列表 4.8. 创建 App 组件(src/app.js)
import React, { Component } from 'react'; *1*
import PropTypes from 'prop-types'; *1*
import parseLinkHeader from 'parse-link-header'; *1*
import orderBy from 'lodash/orderBy'; *1*
import ErrorMessage from './components/error/Error'; *2*
import Loader from './components/Loader'; *2*
import * as API from './shared/http'; *3*
import Ad from './components/ad/Ad'; *4*
import Navbar from './components/nav/navbar'; *4*
import Welcome from './components/welcome/Welcome'; *4*
class App extends Component {
constructor(props) {
super(props);
this.state = { *5*
error: null, *5*
loading: false,
posts: [],
endpoint: `${process.env
.ENDPOINT}/posts?_page=1&_sort=date&_order=DESC&_embed=comments&_ *5*
expand=user&_embed=likes` *5*
};
}
static propTypes = {
children: PropTypes.node
};
render() {
return (
<div className="app">
<Navbar />
{this.state.loading ? ( *6*
<div className="loading">
<Loader /> *6*
</div>
) : (
<div className="home">
<Welcome /> *7*
<div>
<button className="block"> *8*
Load more posts
</button>
</div>
<div>
<Ad *7*
url="https://ifelse.io/book"
imageUrl="/static/assets/ads/ria.png"
/>
<Ad
url="https://ifelse.io/book"
imageUrl="/static/assets/ads/orly.jpg"
/>
</div>
</div>
)}
</div>
);
}
}
export default App; *9*
-
1 导入你需要的库用于 App 组件。
-
2 导入要使用的错误信息和加载器组件。
-
3 导入用于创建和获取帖子的 Letters API 模块。
-
4 导入现有的 Ad、Welcome 和 Navbar 组件。
-
5 为组件设置初始状态——你将跟踪帖子以及获取更多帖子的端点。
-
6 如果正在加载,则渲染加载器而不是应用程序主体。
-
7 渲染 Welcome 和 Ad 组件。
-
8 这是你添加用于显示帖子的组件的地方。
-
9 导出 App 组件。
有了这些,你可以运行开发命令 (npm run dev),你的应用至少应该能够启动并在浏览器中可用。如果你还没有这样做,请确保至少运行一次 npm run db:seed 以生成数据库的示例数据。运行 npm run dev 会为你做几件事情:
-
启动 Webpack 构建过程和开发服务器
-
启动 JSON-server API 以响应网络请求
-
创建一个开发服务器(在 第十二章 中用于服务器端渲染)
-
当发生更改时热重载你的应用(这样你就不必每次保存文件时都刷新应用)
-
通知你构建错误(这些错误应该在命令行和浏览器中显示,如果发生的话)
当应用以开发模式运行时,你应该能够在 http://localhost:3000 上查看正在运行的应用。如果你想要使用 Postman (www.getpostman.com) 或只是想通过浏览器导航到不同的资源,API 服务器正在运行在 http://localhost:3500。
在解决这些后勤问题之后,你应该将获取帖子的能力添加到 App 组件中。为此,你需要使用 Fetch API(包含在你拉入的 API 模块中)向 Letters Social API 发送网络请求。目前,你的组件没有做太多。你还没有在 constructor 和 render 方法之外定义任何生命周期方法,因此组件没有可以工作的数据。你需要从 API 获取数据,然后使用这些数据更新组件状态。你还将添加一个错误边界,以便在组件遇到错误时显示错误消息而不是整个应用卸载。下一个列表显示了如何向 App 组件添加类方法。
列表 4.9. 当 App 组件挂载时获取数据
//...
constructor(props) {
//...
this.getPosts = this.getPosts.bind(this); *1*
}
componentDidMount() {
this.getPosts(); *1*
}
componentDidCatch(err, info) { *2*
console.error(err); *2*
console.error(info);
this.setState(() => ({ *2*
error: err
}));
}
getPosts() {
API.fetchPosts(this.state.endpoint) *3*
.then(res => {
return res
.json() *4*
.then(posts => {
const links = parseLinkHeader(res.headers.get('Link')); *5*
this.setState(() => ({
posts: orderBy(this.state.posts.concat(posts),
'date', 'desc'), *6*
endpoint: links.next.url *7*
}));
})
.catch(err => {
this.setState(() => ({ error: err })); *8*
});
});
}
render() {
//...
<button className="block" onClick={this.getPosts}> *9*
Load more posts
</button>
//...
}
//...
-
1 绑定类方法并在组件挂载时使用它从 API 获取帖子。
-
2 为应用设置一个错误边界以便处理错误。
-
3 使用包含的 API 模块获取帖子。
-
4 API 模块使用 Fetch API,因此你需要解包 JSON 响应。
-
5 The Letters Social API 在头部返回分页信息,因此你可以使用 parse-link-header 来提取下一页帖子的 URL。
-
6 将新帖子添加到状态中,并确保它们被正确排序。
-
7 更新端点状态。
-
8 如果有错误,更新组件状态。
-
9 现在你已经定义了它,将 getPosts 方法分配给加载更多事件处理程序。
现在应用应该在挂载时获取帖子,并保持其本地组件状态。接下来,你需要创建一个 Post 组件来存放帖子数据。你将从源代码中附带的一组预存组件创建 Post 组件。这些主要是无状态的函数组件,你将在本书的其余部分在此基础上构建。查看 src/components/post 目录以熟悉它们。
你的帖子也将获取它们自己的内容,这样你可以在未来的章节中移动 Post 组件并单独渲染它。App 组件会发起请求获取帖子,但它真正关心的是帖子的 ID 和日期,而 Post 组件本身将负责加载其余内容。另一种方法是让 App 组件负责所有数据获取,并将数据传递给帖子。这种方法的优点是网络请求更少。为了说明目的,我们将使帖子负责额外的数据获取,因为我们仍然专注于学习生命周期方法,但我想要指出另一种方法以保持清晰。以下列表显示了 Post 组件。在 src/components/post/Post.js 中创建它。
列表 4.10. 创建 Post 组件(src/components/post/Post.js)
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import * as API from '../../shared/http'; *1*
import Content from './Content'; *2*
import Image from './Image'; *2*
import Link from './Link'; *2*
import PostActionSection from './PostActionSection'; *2*
import Comments from '../comment/Comments'; *2*
import Loader from '../Loader'; *2*
export class Post extends Component { *3*
static propTypes = { *4*
post: PropTypes.shape({
comments: PropTypes.array,
content: PropTypes.string,
date: PropTypes.number,
id: PropTypes.string.isRequired,
image: PropTypes.string,
likes: PropTypes.array,
location: PropTypes.object,
user: PropTypes.object,
userId: PropTypes.string
})
};
constructor(props) { *5*
super(props);
this.state = { *6*
post: null,
comments: [],
showComments: false,
user: this.props.user
};
this.loadPost = this.loadPost.bind(this); *7*
}
componentDidMount() {
this.loadPost(this.props.id); *8*
}
loadPost(id) {
API.fetchPost(id) *9*
.then(res => res.json())
.then(post => {
this.setState(() => ({ post })); *9*
});
}
render() {
if (!this.state.post) {
return <Loader />; *10*
}
return (
<div className="post">
<UserHeader date={this.state.post.date} *11*
user={this.state.post.user} /> *11*
<Content post={this.state.post} /> *11*
<Image post={this.state.post} /> *11*
<Link link={this.state.post.link} /> *11*
<PostActionSection showComments={this.state.showComments}/>*11*
<Comments *11*
comments={this.state.comments}
show={this.state.showComments}
post={this.state.post}
user={this.props.user}
/>
</div>
);
}
}
export default Post;
-
1 导入 API 模块,以便可以获取帖子。
-
2 导入 Post 的组成部分。
-
3 你需要生命周期方法,所以扩展 React.Component。
-
4 声明 prop 类型。
-
5 定义构造函数,以便可以设置状态和绑定类方法。
-
6 设置初始状态。
-
7 绑定类方法。
-
8 在挂载时加载帖子。
-
9 使用 API 获取单个帖子并更新状态。
-
10 如果帖子尚未加载,则显示加载组件。
-
11 为 CommentBox 组件设置模拟数据。
你需要做的最后一件事是迭代帖子以便它们被显示。记住,显示动态组件列表的方法是构造一个数组(通过 Array.map 或其他方法),并在 JSX 表达式中使用它。此外,不要忘记 React 需要你为迭代的每个项目传递一个 key 属性,以便它知道在动态列表中更新哪些组件。这对于在 render 方法中返回的任何组件数组都是正确的。下一个列表显示了如何更新 App 组件的 render 方法以迭代帖子。
列表 4.11. 迭代 Post 组件(src/app.js)
//...
import Post from './components/post/Post'; *1*
//...
<Welcome />
<div>
{this.state.posts.length && (
<div className="posts">
{this.state.posts.map(({ id }) => (
<Post id={id} key={id}
user={this.props.user} /> *2*
))} *3*
</div>
)}
<button className="block" onClick={this.getPosts}>
Load more posts
</button>
</div>
<div>
<Ad
url="https://ifelse.io/book"
imageUrl="/static/assets/ads/ria.png"
/>
<Ad
url="https://ifelse.io/book"
imageUrl="/static/assets/ads/orly.jpg"
/>
</div>
//...
-
1 导入 Post 组件。
-
2 迭代你获取的帖子并为每个帖子渲染一个 Post 组件。
-
3 不要忘记为迭代的每个项目添加一个 key 属性。
有了这些,你正在渲染帖子,并开始了 Letters Social 的构建,如图 4.10 所示。当然,还有很多改进的空间,但你已经在路上了。在下一章中,我们将探讨添加帖子以及为帖子添加位置。我们还将探索使用 refs——一种从你的 React 组件访问底层 DOM 元素的方法。
未捕获的错误
当 React 组件中发生未捕获的错误时会发生什么?有处理错误的方法吗?
图 4.10。我们对 Letters Social 的第一次尝试。帖子正在渲染,你可以加载更多。在下一章中,你将添加创建带位置帖子的功能。

4.4. 概述
让我们回顾一下本章所学的内容:
-
React 组件是由继承自
React.Component类并具有可钩入生命周期的 JavaScript 类创建的。这意味着它们在 React 管理它们的时间中有开始、中间和结束。由于它们继承自React.Component抽象基类,它们还可以访问无状态函数组件没有的特殊 React API。 -
React 提供了生命周期方法,你可以使用它们来钩入组件生命周期的不同部分。这意味着你的应用程序可以在 React 管理 UI 的过程中适当行动。这些生命周期方法并不都需要使用,并且只有在需要时才应该引入。很多时候,你可能只需要一个无状态函数组件。
-
React 提供了一种处理在组件的
constructor、render或生命周期方法中发生的错误的方法:componentDidCatch。使用此方法,你可以在应用程序中创建错误边界。这些行为类似于 JavaScript 中的try...catch语句。当 React 捕获到错误时,它将卸载发生错误的组件及其子组件从 DOM 中,以促进渲染稳定性并防止整个应用程序崩溃。 -
你已经开始构建 Letters Social,这是我们将在本书的剩余部分探索 React 主题的项目。项目的最终版本可在网上找到,网址为
social.react.sh,源代码可在github.com/react-in-action/letters-social找到。
在下一章中,你将开始为 Letters Social 添加更多功能。我们将专注于添加动态创建帖子的功能,甚至可以使用 Mapbox 为帖子添加位置。
第五章. 在 React 中使用表单
本章涵盖
-
在 React 中使用表单
-
React 中的受控和非受控表单组件
-
在 React 中验证和清理数据
到目前为止,您已经为使用 React 构建简单组件获得了一些基础知识:生命周期钩子、PropTypes 以及大部分顶级组件 API。您已经尝到了基础知识的味道,可以做一些基本的事情,比如更新本地组件状态和通过 props 在组件之间传递数据。您还介绍了组件结构、以组件为单位的思考方式以及生命周期方法。
在本章中,您将应用更多知识,并真正开始构建 Letters Social 示例应用。您将创建一个用户可以使用它来为 Letters Social 创建新帖子的组件。首先,我们将探讨整体问题并回顾数据需求。然后,我们将讨论 React 中的表单,并构建组件的功能。到本章结束时,您将学会如何在 React 应用中使用表单。
如何获取本章的代码?
与每一章一样,您可以通过访问 GitHub 仓库github.com/react-in-action/letters-social来获取本章的源代码。如果您想从一张白纸开始学习本章内容并跟随操作,您可以使用第四章中的现有代码(如果您跟随操作并自行构建了示例)或检出特定章节的分支(chapter-5-6)。
记住,每个分支都对应于章节末尾的代码(例如,chapter-5-6 对应于第五章和第六章末尾的代码)。您可以在您选择的目录中执行以下终端命令之一来获取当前章节的代码。如果您根本没有任何仓库,请输入以下命令:
git clone git@github.com:react-in-action/letters-social.git
如果您已经克隆了仓库:
git checkout chapter-5-6
您可能是从其他章节来到这里的,所以确保您已经安装了所有正确的依赖项总是一个好主意:
npm install
5.1. 在 Letters Social 中创建帖子
到目前为止,您的 React 应用 Letters 除了让您阅读内容之外,并没有做什么。一个只读的社会网络实际上更像是一个图书馆,而这并不是您虚构的投资者想要的。您需要创建的第一个功能是创建帖子的能力。您将为用户创建使用表单创建帖子并显示在新闻源中的功能。为了开始,让我们回顾数据需求并概述问题,以便您确切了解需要完成什么。
5.1.1. 数据需求
你将开始使用一些浏览器 HTTP 库向你的模拟 API 服务器发送数据。你可能已经对它们的工作原理以及如何从 JavaScript 中与 RESTful 和其他类型的 Web API 进行通信有所了解,所以这里不会深入讲解。如果你在浏览器中使用 HTTP 或与服务器通信方面没有经验,有许多优秀的资源可供参考,例如 Nicolas G. Bevacqua 的《JavaScript 应用程序设计》(Manning,2015 年)。
当与 API 一起工作时,你通常需要发送满足某种合同的数据。如果数据库期望用户信息,你可能需要包括你的姓名、电子邮件,也许还有个人照片。你的数据通常需要具有特定的形状,否则服务器会拒绝它。你应该做的第一件事就是弄清楚你的数据需要以何种方式呈现,以便服务器满意。
列表 5.1 显示了 Letters Social 中帖子的基本模式。这里我们使用一个简单的 JavaScript 类,因为服务器实际上会使用它。在创建帖子时,你发送给服务器的有效负载需要包含模式中定义的大部分内容。请注意,你的帖子可以包含许多有用的属性,包括位置——你将在第六章中创建一个添加位置的功能。服务器将为未指定的属性分配一些智能默认值,但会忽略未定义的其他属性。在浏览器中,你不需要创建一个唯一的 ID——服务器可以自己完成这项工作。
列表 5.1. 帖子模式(db/models.js)
export class Post {
constructor(config) {
this.id = config.id || uuid();
this.comments = config.comments || [];
this.content = config.content || null;
this.date = config.date || new Date().getTime();
this.image = config.image || null;
this.likes = config.likes || [];
this.link = config.link || null;
this.location = config.location || null;
this.userId = config.userId;
}
}
5.1.2. 组件概述和层次结构
现在你已经对将要处理的数据有所了解,你可以开始思考如何以组件形式表达这些数据。有许多关于你正在创建的社交网络应用的例子,所以思考你看到的例子应该不难。图 5.1 显示了你正在努力构建的最终产品,我们可以从中获得一些初步的灵感。
图 5.1. 你将构建的最终 Letters Social 应用程序。你能看到任何可以将事物分解为组件的方法吗?

我在本书前面讨论了建立组件层次结构和关系,并强调了它们在创建 React 应用程序中的重要性。在你开始创建组件之前,我们再次强调这一点。以下是你在 Letters 应用程序中目前拥有的内容:
-
从 API 可用的数据;一些帖子包含图片,其他帖子包含链接
-
每个帖子的用户数据,包括一些头像信息
-
一个充当整个应用程序的通用组件的 App 组件
-
一个 Post 组件,你将使用它迭代 API 中的数据
你需要添加创建帖子的功能,这些帖子可以与位置相关联,以及文本内容。你需要让用户选择这个位置,然后在新闻源中的每个帖子中显示该位置。CreatePost 组件应该放在哪里?根据原型和用户需求,它似乎应该作为迭代帖子的兄弟元素存在,所有这些都位于主 App 组件中,如 图 5.2 所示。
图 5.2. 现有和未来的组件。你已经创建了 Post 和 App 组件,用于获取和遍历数据。Create Post 组件将存在于用于显示帖子的组件之外。

让我们看看如何创建组件的骨架。你只需输入组件的基本元素,导入正确的工具,导出 Component 类,并设置 PropTypes 以供以后定义。以下列表显示了如何创建这个基本骨架。
列表 5.2. 创建组件骨架 (src/components/post/Create.js)
import React, { Component } from 'react'; *1*
import PropTypes from 'prop-types';
class CreatePost extends Component { *2*
static propTypes = { *3*
}
constructor(props) { *4*
super(props);
}
render () {
return (
<div className="create-post">
Create a post — coming (very) soon
</div>
);
}
}
export default CreatePost; *5*
-
1 导入 React 和 PropTypes 对象以便使用它。
-
2 创建一个 React 组件。
-
3 在类上声明 PropTypes 作为静态属性。
-
4 设置构造函数——你稍后会用到它。
-
5 导出组件以便在其他地方使用。
5.2. React 中的表单
本章中你构建的两个组件都涉及表单的使用。Web 表单仍然类似于纸质表单——它们是接收和记录输入的结构化方式。用纸张时,你用钢笔或铅笔记录信息。用浏览器表单时,你用键盘、鼠标和电脑上的文件来捕获信息。你可能熟悉许多表单元素,如 input、select 和 textarea 等。
大多数网络应用程序在某种程度上都涉及表单。我从未参与过没有涉及任何形式的应用程序的生产部署。我还发现,表单有时因为难以处理而名声不佳。也许正因为如此,许多框架都实施了一种“魔法”方法来处理表单,旨在减轻开发者的负担。React 并不采用魔法方法,但它可以使表单更容易处理。
5.2.1. 开始使用表单
在前端框架中处理表单没有标准方法。在某些框架和库中,你可以设置一个表单 模型,该模型在用户更改表单值时更新,并且其中内置了特殊方法来检测表单处于不同状态。其他框架在处理表单时实施不同的范例和技术。它们共同的特点是它们以不同的方式处理表单。
我们应该如何看待不同的方法?哪一个更好?很难说哪一个在本质上比另一个更好,但有时“更容易使用”的方法可能会掩盖你背后的机制和逻辑。这并不总是坏事——有时你不需要了解框架的内部工作。但你需要有足够的理解来支持一个心理模型,这将让你能够创建可维护的代码并在出现问题时修复错误。在我看来,这正是 React 的亮点。在表单方面,它没有给你太多“魔法”,这让你在知道太多和知道太少之间找到了一个很好的平衡。
幸运的是,React 中表单的心理模型更像是你已经学过的。没有特殊的 API 集可以使用——表单只是我们在 React 中看到的东西的更多内容:组件!你使用组件、状态和属性来创建表单。因为我们是在之前的学习基础上构建的,所以在继续之前,让我们回顾一下 React 心理模型的一些部分:
-
组件有两种主要的数据处理方式:状态和属性。
-
由于它们是 JavaScript 类,组件除了可以用来响应事件和做其他任何事情的生命周期钩子外,还可以有自定义类方法。
-
就像对待常规 DOM 元素一样,你可以在 React 组件中监听事件,如点击、输入更改和其他事件。
-
父组件(如表单元素)可以通过属性向子组件提供回调方法,使组件之间能够相互通信。
当你构建创建帖子的组件时,你会使用这些熟悉的 React 思想。
5.2.2. 表单元素和事件
要创建一个帖子,你需要确保帖子被持久化到你的数据库中,帖子用户界面得到更新,并且你更新了用户的帖子列表。首先,你将构建表单元素,就像你构建一个常规 HTML 表单一样。标记并不复杂——你只接收一个输入,不需要显示其他内容。以下列表显示了组件的初始阶段:渲染一个textarea输入。
列表 5.3. 向你的 CreatePost 组件添加内容(src/components/post/Create.js)
//...
class CreatePost extends Component {
render() {
return (
<div className="create-post">
<textarea
placeholder="What's on your mind?"
/>
</div>
<button>Post</button>
</div>
);
}
}
//...
现在你已经为你的基本表单创建了基本的标记,你可以开始连接这些元素。你可能还记得,从前面的章节中,React 让你以与常规浏览器 JavaScript 相同的方式与事件交互。它让你监听常规事件,如点击、滚动等,并对它们做出反应。当你处理表单时,你会利用这些事件。
注意
如果你已经从事前端应用开发一段时间,你会知道不同浏览器之间有很多不一致性,尤其是在事件方面。除了从它那里获得的所有其他好处之外,React 还做了很多工作来抽象化浏览器实现中的这些差异。这是一个不太引起注意的好处,但它可以是一个难以置信的帮助。不必过多担心浏览器之间的差异通常会让你更多地关注应用程序的其他领域,并且通常会导致开发者更加快乐。
由于用户交互,浏览器可以发生许多不同的事件——包括鼠标移动、键盘输入、点击等。在我们的应用中,我们特别关注这些类型的一些事件。对于我们的目的,你想要通过两个主要的事件处理程序——onChange和onClick——来监听:
-
onChange— 当输入元素发生变化时,这个事件会被触发。你可以使用event.target.value访问表单元素的新的值。 -
onClick— 当一个元素被点击时,这个事件会被触发。你会监听这个事件,以便知道用户何时想要将帖子发送到服务器。
接下来,你将为这些事件分配一些事件处理程序。目前,你将把这些函数放入一些控制台日志副作用中,这样我们就可以观察它们何时被触发。稍后,你会用真实的功能替换这些函数。以下列表显示了如何通过在组件类构造函数中绑定它们,然后在组件中分配它们来设置事件处理程序。
列表 5.4. 向 CreatePost 组件添加功能(src/components/post/Create.js)
class CreatePost extends Component {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this); *1*
this.handlePostChange = this.handlePostChange.bind(this); *2*
}
handlePostChange(e) { *3*
console.log('Handling an update to the post body!');
}
handleSubmit() {
console.log('Handling submission!');
}
render() {
return (
<div className="create-post">
<button onClick={this.handleSubmit}>Post</button> *4*
<textarea
value={this.state.content} *5*
onChange={this.handlePostChange} *4*
placeholder="What's on your mind?"
/>
</div>
);
}
}
-
1 绑定处理提交和帖子更改的类方法。
-
2 在类上声明当正文文本(即 onChange 事件)更新时要使用的方法。
-
3 声明处理提交事件的函数,React 会将事件传递给处理程序
-
4 将事件处理程序传递给按钮和文本区域组件。
-
5 组件的值将从组件状态中读取
事件处理程序接收一个合成事件作为参数,并且我们可以访问合成事件上的许多可用属性。表 5.1 展示了你可以访问的合成事件的一些属性。通过“合成”事件,我的意思是 React 将浏览器事件转换为你在 React 组件中可以操作的东西。
表 5.1. React 中合成事件可用的属性和方法
| 属性 | 返回类型 |
|---|---|
| bubbles | boolean |
| cancelable | boolean |
| currentTarget | DOMEventTarget |
| defaultPrevented | boolean |
| eventPhase | number |
| isTrusted | boolean |
| nativeEvent | DOMEvent |
| preventDefault() | |
| isDefaultPrevented() | boolean |
| stopPropagation() | |
| isPropagationStopped() | boolean |
| target | DOMEventTarget |
| timeStamp | number |
| type | string |
在我们继续之前,尝试一下:将 console.log(event) 添加到帖子组件的更改事件处理程序中。如果你在 textarea 元素中输入一些内容并打开浏览器开发者控制台,你应该会看到消息被记录出来(见图 5.3 以获取示例)。如果你检查这些对象或尝试访问表 5.1 中的某些属性,你应该会得到关于事件的详细信息。对我们来说,我们将关注返回的 target 属性。记住,event.target 只是对触发事件的 DOM 元素的引用,就像在正常的 JavaScript 中一样。
图 5.3. React 将一个合成事件传递给你设置的事件处理程序。它是一个标准化的事件,这意味着你可以访问与常规浏览器事件相同的属性和数据。

5.2.3. 在表单中更新状态
你现在可以监听事件,并观察你的组件如何监听更新和提交事件,但你还没有对数据进行任何操作。你需要对事件进行处理以更新你的应用程序状态。这是你在 React 中使用表单的关键方式:通过接收事件处理程序的事件,然后使用这些事件的数据来更新状态或属性。
状态和属性是 React 让你处理数据的两种主要方式。现在,如果你尝试在表单中输入一些内容,什么都不会发生。一开始这可能看起来像是一个错误,但这只是 React 在执行其工作。想想看:当你改变输入的值时,你正在修改 DOM,React 的主要工作之一就是确保 DOM 与从你的组件创建的内存中 DOM 版本保持同步。
由于你还没有在内存中的 DOM 中做任何改变(没有更新状态),React 不会用任何更改来更新实际的 DOM。这是一个 React 完美执行其工作的绝佳例子。如果你能够更新表单值,你可能会无意中把自己置于一个复杂的情况中,其中事物不同步,你需要回到更老的方式去做事情(这正是 React 最初改进的地方)。
要更新状态,你需要监听 React 在输入值改变时发出的事件。当这个事件被发出时,你会从中提取一个值,并使用这个值来更新组件状态。这给了你控制更新过程每个步骤的机会。
让我们看看如何将其付诸实践。列表 5.5 展示了如何设置事件处理程序来监听和更新用户更改数据值时组件的状态。稍后,你将使用之前工作过的 event.target 引用并访问 value 属性来使用 textarea 元素中的值更新你的状态。
列表 5.5. 使用输入更新组件状态(src/components/post/Create.js)
class CreatePost extends Component {
constructor(props) {
super(props);
// Set up state
this.state = {
content: '',
};
// Set up event handlers
this.handleSubmit = this.handleSubmit.bind(this);
this.handlePostChange = this.handlePostChange.bind(this);
}
handlePostChange(event) {
const content = event.target.value; *1*
this.setState(() => { *2*
return {
content,
};
});
}
handleSubmit() {
console.log(this.state); *3*
}
render() {
return (
<div className="create-post">
<button onClick={this.handleSubmit}>Post</button>
<textarea
value={this.state.content} *4*
onChange={this.handlePostChange}
placeholder="What's on your mind?"
/>
</div>
);
}
}
-
1 从 DOM 元素的值属性中获取 textarea 元素的值(您想用其更新状态)
-
2 使用该值设置状态,并使用新值更新它
-
3 要查看更新后的状态,请点击表单提交按钮并检查开发者控制台
-
4 为 textarea 元素提供新的值
5.2.4. 受控和非受控组件
这种在表单中更新组件状态的方法——通过使用事件和事件处理程序来紧密控制更新发生的方式——可能是处理 React 中表单的更常见方式。设计有此考虑的组件通常被称为 受控 组件。这是因为我们紧密控制组件以及状态如何改变。但还有另一种设计使用表单的组件的方法,称为 非受控 组件。图 5.4 展示了受控和非受控组件的工作概述,并说明了它们的一些区别。
图 5.4. 受控组件监听由 DOM 元素发出的事件,操作发出数据,然后更新组件状态并设置元素的值。这使一切都在组件的领域内,并创建了一个统一的状态宇宙。非受控组件维护其自己的内部状态,并在组件内创建了一个微观世界,切断了对该状态访问和控制。

在非受控组件中,而不是使用value属性来设置数据,组件维护其自己的内部状态。您仍然可以使用事件处理程序监听输入的更新,但您将不再管理输入的状态。列表 5.6 展示了使用非受控组件方法的一个示例。本书我们将坚持使用受控组件,但至少要知道这种模式在实际中的样子。
列表 5.6. 使用非受控组件(src/components/post/Create.js)
class CreatePost extends Component {
constructor(props) {
super(props);
this.state = {
content: '',
};
this.handleSubmit = this.handleSubmit.bind(this); *1*
this.handlePostChange = this.handlePostChange.bind(this); *1*
}
handlePostChange(event) { *1*
const content = event.target.value; *1*
this.setState(() => {
return {
content,
};
});
}
handleSubmit() { *1*
console.log(this.state);
}
render() {
return (
<div className="create-post">
<button onClick={this.handleSubmit}>Post</button>
<textarea
onChange={this.handlePostChange} *2*
placeholder="What's on your mind?"
/>
</div>
);
}
}
-
1 您的处理程序与之前相同,但改变状态的效果将不会相同。
-
2 如前所述,现在没有任何值元素会监听组件状态。
5.2.5. 表单验证和清理
使用表单记录和存储用户输入的一个重要部分是让用户知道他们何时违反了你设定的验证规则,以及何时提供不满足你应用程序的数据。希望接收来自你客户端应用程序数据的服务器应用程序已经实施了严格的数据验证和清理程序——你不能依赖浏览器应用程序在这个领域做所有的工作。即使你在服务器上实施了良好的数据清理和验证,你仍然需要在前端提供和执行良好的数据实践,以帮助用户,增加对恶意行为者的另一层防御,并促进数据完整性。如果不这样做,你可能会遇到困惑的用户、安全漏洞和没有意义的数据——这些都是你不想看到的事情。
如我们所见,使用表单更新组件状态涉及到状态、属性和组件方法,就像 React 中的许多其他方面一样。为了给你的组件添加验证和清理,你需要挂钩到更新过程。为此,你将编写通用的验证和清理函数,这些函数可以在任何可以使用 JavaScript 的地方使用,也许在大多数其他前端框架中也可以使用。
关于 React 事件和表单的思考
花点时间思考一下你到目前为止在 React 中学到的关于事件和表单的知识。React 中的事件与你在浏览器中处理的事件有何不同?如果有的话,它们是如何不同的?
幸运的是,你正在创建的 CreatePost 组件不需要大量的验证。你只需要检查最大长度并进行一些额外的验证,以确保组件不会向 API 服务器提交空帖子。我们使用简单的服务器设置用于学习和本地开发,因此它将接受大多数有效载荷而不进行太多验证。在服务器上编写应用程序是本书范围之外的另一个领域,所以我将只关注浏览器上的验证和清理。
在设置应用程序中表单和输入的验证时,你需要问自己几个问题:
-
应用程序的数据需求是什么?
-
根据这些限制,你如何帮助用户提供有意义的资料?
-
你有办法消除用户提供的数据中的不一致性吗?
首先,你需要找出业务或应用后端(如果有的话)设定的数据需求。你应该从这里开始,因为这方面的知识将帮助你建立如何处理数据的基本指南。因为我们已经确定你的服务器会乐意接受大多数东西,并且我们已经为帖子设定了基本的数据类型,我们可以继续下一个问题。
根据你的限制,你如何最好地帮助你的用户提供有意义的资料,并在你的应用中获得良好的体验?这通常涉及到检查数据的大小、字符类型,也许对于文件上传,还需要检查文件类型等。目前,你的 CreatePost 组件相对无害,除了长度之外没有太多需要验证的。接下来,你将检查最小和最大长度,并且只有当内容有效时才允许用户提交帖子。以下列表展示了如何为你的组件设置一些基本的验证。
列表 5.7. 添加基本验证(src/components/post/Create.js)
//...
class CreatePost extends Component {
constructor(props) {
super(props);
this.state = {
content: '',
valid: false, *1*
};
this.handleSubmit = this.handleSubmit.bind(this);
this.handlePostChange = this.handlePostChange.bind(this);
}
handlePostChange(event) {
const content = event.target.value;
this.setState(() => {
return {
content,
valid: content.length <= 280 *2*
};
});
}
handleSubmit() {
if (!this.state.valid) {
return;
}
const newPost = { *3*
content: this.state.content, *3*
};
console.log(this.state);
}
render() {
return (
<div className="create-post">
<button onClick={this.handleSubmit}>Post</button>
<textarea
value={this.state.content}
onChange={this.handlePostChange}
placeholder="What's on your mind?"
/>
</div>
);
}
}
-
1 在本地组件状态中创建一个简单的有效属性
-
2 通过在此处设置最大长度来确定帖子的有效性——280 展示了用法,但用户有时希望帖子更长
-
3 创建一个新的帖子对象。
我们已经解决了前两个问题(数据约束和验证)。现在我们可以着手解决最后一个方面:通过(非常)基本的数据清理来消除数据不一致性。验证是要求用户提供某些数据,而清理则是确保你得到的数据是安全的,并且格式正确,以及以某种方式存在,可以持久化。信息安全是一个庞大且非常重要的领域,这本书无法真正深入探讨正确的数据处理方法——但我们可以在 Letters 这个较小的领域进行探讨:攻击性内容。
你将使用一个名为 bad-words 的 JavaScript 模块,它可以从 npm(主要的 JavaScript 模块注册和服务——更多信息请访问www.npmjs.com/about)获取,来帮助我们。它应该已经安装在你的项目中。bad-words 接受一个字符串,并将黑名单上找到的任何单词替换为星号。以下列表中的示例主要是虚构的,但至少你可以防止人们在公共应用(social.react.sh)上发布可能具有攻击性的内容。记住,这是一个非常虚构的例子,并且绝对没有暗示或支持任何形式的审查。
列表 5.8. 添加基本内容清理(src/components/post/Create.js)
import PropTypes from 'prop-types';
import React from 'react';
import Filter from 'bad-words'; *1*
const filter = new Filter(); *2*
class CreatePost extends Component {
//...
handlePostChange(event) {
const content = filter.clean(event.target.value); *3*
this.setState(() => {
return {
content,
valid: content.length <= 280
};
});
}
//...
}
export default CreatePost;
-
1 从 bad-words 模块导入默认对象
-
2 使用构造函数创建新的过滤器实例
-
3 将表单值传递给过滤器的 .clean() 方法,并使用返回的值设置状态
5.3. 创建新帖子
现在你正在对帖子进行一些基本的验证和清理,你将想要通过将它们发送到服务器来创建它们。我们将引入稍微复杂一些的复杂性来实现这一点,所以我们将简要介绍每个步骤,然后查看整个过程的示意图。
要将你的帖子发送到 API,你除了需要做 CreatePost 组件已经做的事情(包括跟踪状态、进行一些基本验证和执行一些基本内容清理)之外,还需要做以下几件事情。
接下来,你需要做以下事情来将数据发送到你的 API:
-
捕获用户输入作为帖子内容,更新状态并执行你迄今为止实现的数据检查逻辑。
-
调用一个从父组件(在这个例子中是主 App 组件)传递过来的事件处理函数作为 props,并将帖子数据传递给它。
-
重置 CreatePost 组件的状态。
-
在父组件中,使用从 CreatePost 子组件传递的数据对服务器执行 HTTP
POST操作。 -
使用从服务器接收的新帖子更新本地组件状态。
-
为了更好地理解你将要做什么,请参阅 图 5.5 中的插图。
图 5.5. CreatePost 组件概述。CreatePost 组件接收一个作为 props 的函数,使用其内部状态作为该函数的输入,并在用户点击提交时调用它。这个函数是从父 App 组件传递过来的,它将数据发送到 API,更新本地帖子,并从 API 初始化帖子刷新。

你将从在父组件(App.js)中添加一个处理提交后操作的功能开始。这个功能有几个部分,所以你会逐个添加它们,并且我们会逐一讲解。列表 5.9 展示了如何将提交后操作功能添加到主 App 组件中。
列表 5.9. 处理帖子提交(src/app.js)
import * as API from './shared/http'; *1*
//...
export default class App extends Component {
//...
createNewPost(post) {
this.setState(prevState => {
return {
posts: orderBy(prevState.posts.concat(newPost),
'date', 'desc') *2*
};
});
}
//...
}
-
1 导入 Letters API 模块。
-
2 连接新的帖子并确保帖子排序。
你已经在父组件中设置了帖子创建处理函数,但在这个阶段它不会做任何事情,因为没有东西调用它。这是因为你需要将其传递给其子组件(你一直在工作的 CreatePost 组件)。还记得你可以如何将数据从父组件传递到子组件作为 props 吗?你也可以传递函数。这是至关重要的,因为它允许组件协作并一起工作。尽管组件可以交互,但它们并不是那么紧密地交织或耦合,以至于你永远不能移动它们;CreatePost 组件同样可以被移动到应用程序的另一个部分,并向另一个处理程序发出相同的数据。列表 5.10 展示了将回调函数作为 props 传递的示例。
受控和非受控组件
以下是一些 React 中受控组件和非受控组件之间的区别?什么决定了组件被认为是受控的还是非受控的?
列表 5.10. 通过 props 传递回调函数(src/app.js)
import CreatePost from './post/Create'; *1*
export default class App extends Component {
//...
render() {
return (
//...
<CreatePost onSubmit={this.createNewPost} /> *2*
//...
)
}
//...
}
-
1 导入组件以供使用。
-
2 使用 props 传递 handlePostSubmit 函数。
到目前为止,你已经在父组件中设置了事件处理器的基础,并将其传递给子组件。这有助于你分离关注点——CreatePost 组件只负责打包一些帖子数据,然后将其发送到父组件,由父组件决定如何处理这些数据,即将其发送到 API。第六章 讨论了这一点以及更多内容。
5.4. 概述
下面是本章你学到的主要内容:
-
在 React 中处理表单的方式与处理任何其他组件类似:你可以使用事件和事件处理器来传递数据并提交数据。
-
React 不提供任何“魔法”方式来处理表单。表单只是组件。
-
表单验证和清理工作在相同的 React 思维模型中,包括事件、组件更新、重新渲染、状态和 props 等。
-
你可以在组件之间传递函数作为 props,这是一个强大且有用的设计模式,它可以防止组件耦合但促进组件通信。
-
数据验证和清理不是“魔法”——React 允许你使用常规 JavaScript 和库来处理你的数据。
在下一章中,你将在此基础上继续构建,并开始将第三方库与 React 集成,以在你的应用中添加地图。
第六章. 将第三方库与 React 集成
本章涵盖
-
以 JSON 格式向远程 API 发送表单数据
-
构建一些新的组件,包括位置选择器、自动完成和显示地图
-
将你的 React 应用程序与 Mapbox 集成以搜索位置和显示地图
在 第五章 中,我们开始探讨表单及其在 React 中的工作方式。你在 CreatePost 组件中添加了事件处理器来更新组件状态。在本章中,你将在此基础上继续工作,并尝试添加创建新帖子的功能。你将开始与在上一章中提供帖子的 JSON API 进行更多交互。
通常,你会在涉及非 React 库的上下文中构建 React 应用程序,这些库也使用 DOM。这些可能包括 jQuery、jQuery 插件,甚至是其他前端框架。我们已经看到 React 为你管理 DOM,这可以简化你对用户界面的思考。然而,仍然有一些时候你需要与 DOM 交互,这通常是在使用 DOM 的第三方库的上下文中。在本章中,我们将探讨一些方法,在添加 Mapbox 地图到 Letters Social 帖子时,如何使用 React 来实现这一点。
我如何获取本章的代码?
与每一章一样,你可以通过访问 GitHub 仓库github.com/react-in-action/letters-social来查看本章的源代码。如果你想从这个章节开始一个全新的起点并跟随,你可以使用你从第四章(如果你跟随并自己构建了示例)或检出章节分支(chapter-5-6)。
记住,每个分支都对应于章节末尾或指示的章节中的代码——例如,分支 chapter-5-6 对应于本章末尾的代码。你可以在你选择的目录中执行以下终端命令来获取当前章节的代码。
如果你根本没有任何仓库,请输入以下内容:
git clone git@github.com:react-in-action/letters-social.git
如果你已经克隆了仓库:
git checkout chapter-5-6
你可能已经从其他章节来到这里,所以始终确保你有所有正确的依赖项安装:
npm install
6.1. 向 Letters 社交 API 发送帖子
如你所回忆的,从第二章,你创建了一个允许你添加评论的评论框组件。它将这些评论本地持久化,仅在内存中——当你刷新页面时,你添加的任何评论都会消失,因为它们与给定时间点的页面状态共存亡。你可以选择利用本地或会话存储,或者使用其他基于浏览器的存储技术(如 cookies、IndexedDB、WebSQL 等)。然而,这些仍然会将所有内容保持本地化。
你将要做的是将帖子数据格式化为 JSON 发送到你的 API 服务器,如列表 6.1 所示。它将处理存储帖子并响应新数据。当你克隆仓库时,在共享/http 文件夹中已经存在可以用于 Letters 社交项目的已创建函数。你正在使用 isomorphic-fetch 库进行网络请求。它遵循浏览器的 Fetch API,但具有优势,它也可以在服务器上工作。
列表 6.1. 向服务器发送帖子(src/components/app.js)
export default class App extends Component {
//...
createNewPost(post) {
return API.createPost(post) *1*
.then(res => res.json()) *2*
.then(newPost => { *3*
this.setState(prevState => {
return {
posts: orderBy(prevState.posts.concat(newPost),
'date', 'desc') *4*
};
});
})
.catch(err => {
this.setState(() => ({ error: err })); *5*
});
}
-
1 使用 Letters API 创建帖子。
-
2 获取 JSON 响应。
-
3 使用新的帖子更新状态。
-
4 确保帖子使用 Lodash 的 orderBy 方法排序。
-
5 如果有错误状态,请设置它。
因此,你只剩下一件事要做:在子组件中调用帖子创建方法。你已经传递了它,所以只需确保点击事件触发父方法的调用,并将帖子数据传递下去。以下列表显示了如何在子组件中调用作为 prop 传递的方法。
列表 6.2. 通过 props 调用函数
class CreatePost extends Component {
// ...
fetchPosts() {/* created in chapter 4 */}
handleSubmit(event) {
event.preventDefault(); *1*
if (!this.state.valid) {
return;
}
if (this.props.onSubmit) { *2*
const newPost = {
date: Date.now(),
// Assign a temporary key to the post; the API will create a real one
for us
id: Date.now(),
content: this.state.content,
};
this.props.onSubmit(newPost); *3*
this.setState({ *4*
content: '', *4*
valid: null, *4*
}); *4*
}
}
// ...
}
-
1 阻止默认事件并创建一个发送给父组件的对象
-
2 确保你有一个回调函数来处理。
-
3 调用通过 props 从父组件传递的 onSubmit 回调,传入新帖子
-
4 将状态重置为初始形式,以便用户有视觉提示表示帖子已提交
现在,如果您使用 npm run dev 在开发模式下运行应用程序,您应该能够添加帖子!它们应该立即出现在您的源中,但如果您刷新页面,您仍然应该能够看到您添加的帖子。它不会有像其他人那样的个人资料图片或链接预览,但您将在后面的章节中添加这些功能。
6.2. 使用地图增强您的组件
现在您已经为您的应用程序添加了创建帖子并将其发送到服务器的功能,您可以继续增强它。Letters Social 的虚构投资者一直在使用 Facebook 和 Twitter,并注意到这些平台允许您在帖子中添加位置。他们非常希望 Letters Social 具有这种功能,因此您将添加选择和显示位置的功能,以便在选择帖子时使用。您还将重用地图显示组件,以便在用户的新闻源中显示位置。图 6.1 展示了您将要构建的内容。
图 6.1. 您将为 Letters Social 创建的内容。您将增强当前的发帖能力,以便用户可以在帖子中添加位置。一旦完成,您在创建帖子时将能够搜索和选择位置。

您可能已经注意到在 图 6.1 中,您将使用 Mapbox 创建地图。Mapbox 是一个地图和地理服务平台,提供了一系列令人难以置信的地图和位置相关服务。您可以使用数据自定义地图,创建不同风格的地图和覆盖层,进行地理搜索,添加导航等等。我无法涵盖 Mapbox 所做的所有事情,但如果您想了解更多信息,请访问 www.mapbox.com。
6.2.1. 使用 refs 创建 DisplayMap 组件
您需要一种方式向用户展示位置,无论是他们在为新帖子选择位置时,还是当帖子在他们的新闻源中渲染时。我们将看到如何创建一个组件,它将服务于这两个目的,这样您就可以重用您的代码。您可能并不总是能够这样做,因为每个需要地图的地方可能有不同的需求。但在这个案例中,共享相同的组件将有效,并且可以为您节省额外的工作。首先,创建一个名为 src/components/map/DisplayMap.js 的新文件。您将把我们的所有地图相关组件放在这个目录中。
Mapbox 库是从哪里来的?在大多数其他情况下,我们使用的是从 npm 安装的库。您将在下一节中使用 Mapbox npm 模块,但您将使用不同的库来创建地图。如果您查看源代码中包含的 HTML 模板(src/index.ejs),您将看到对 Mapbox JS 库(mapbox.js)的引用:
...
<script src="https://api.mapbox.com/mapbox.js/v3.1.1/mapbox.js"></script>
...
这将使您的 React 应用程序能够与 Mapbox JS SDK 一起工作。请注意,Mapbox JS SDK 需要一个 Mapbox 令牌才能运行。我在 Letters Social 的应用程序源代码中包含了一个公共令牌,因此您不需要 Mapbox 账户。如果您有账户或想为了定制目的创建一个账户,您可以通过更改应用程序源代码中的配置目录中的值来添加您的令牌。
在您正在工作的项目或功能中,通常会有需要您将 React 与非 React 库集成的情形。您可能正在处理像 Mapbox(正如您在本章中所做的那样)这样的东西,或者可能是另一个没有考虑到 React 的第三方库。鉴于 React DOM 为您管理 DOM,您可能会想知道您是否可以这样做。好消息是 React 提供了一些很好的逃生舱,使得与这些库一起工作成为可能。
这就是引用发挥作用的地方。我在前面的章节中简要提到了引用,但在这里它们将特别有用。引用是 React 提供您访问底层 DOM 节点的方式。引用在 React 中可能很有用,但您不应过度使用它们。我们仍然想使用状态和属性作为使我们的应用程序交互和与数据交互的主要手段。但有一些很好的用例,其中引用很有用,包括以下内容:
-
为了管理焦点和命令式地与媒体元素如
<video>交互 -
为了命令式地触发动画
-
为了与在 React 之外使用 DOM 的第三方库交互(这是我们用例)
您如何在 React 中使用引用?在过去的版本中,您会给 React 元素添加一个字符串属性(<div ref="myref"></div>),但新的方法是使用内联回调,如下所示:
<div ref={ref => { this.MyNode = ref; } }></div>
当您想要引用底层的 DOM 元素时,您可以从您的类中引用它。您可以在 ref 回调函数中与之交互,但大多数时候您会想要在您的组件类中存储引用,以便在其他地方可用。
我应该指出几点。您不能在 React 中的无状态函数组件外部使用引用,因为该组件没有后端实例。例如,这不会工作:
<ACoolFunctionalComponent ref={ref => { this.ref = ref; } } />
但如果组件是一个类,您将获得组件的引用,因为它确实有一个后端实例。您还可以将引用作为属性传递给消耗它们的组件。大多数时候,您只会想要在您需要直接访问 DOM 节点时使用引用,所以除非您正在构建一个需要引用来工作的库,否则这种用例可能不会经常出现。
您将使用引用与 Mapbox JavaScript SDK 进行交互。Mapbox 的库负责为您创建地图,并在地图上设置许多事情,如事件处理程序、UI 控件等。它的地图 API 需要使用 DOM 元素引用或 ID 来在 DOM 中搜索。您将使用引用。以下列表显示了您的 DisplayMap 组件的骨架。
列表 6.3. 向您的地图组件添加引用(src/components/map/DisplayMap.js)
import React, { Component } from 'react';
import PropTypes from 'prop-types';
export default class DisplayMap extends Component {
render() {
return [ *1*
<div key="displayMap" className="displayMap"> *1*
<div
className="map" *2*
ref={node => {
this.mapNode = node; *2*
}}
>
</div>
</div>
];
}
}
-
1 从渲染中返回元素数组
-
2 Mapbox 将用于创建您的地图的 DOM 元素
这是在使地图与 React 一起工作方面的一个良好开端。接下来,您需要使用 Mapbox JS API 创建地图。您将创建一个方法,该方法将使用您在类中存储的引用。您还需要设置一些默认属性和状态,以便地图有一个默认区域可以平移,并且不会一开始就显示整个世界。您将在组件中记录一些状态,包括地图是否已加载以及一些位置信息(纬度、经度和地点名称)。注意,通过 React 与另一个 JavaScript 库交互是一件相当简单的事情。最难的部分是使用引用,但除此之外,库可以相当容易地协同工作。以下列表显示了如何设置 DisplayMap 组件。
列表 6.4. 使用 Mapbox 创建地图(src/components/map/DisplayMap.js)
import React, { Component } from 'react';
import PropTypes from 'prop-types';
export default class DisplayMap extends Component {
constructor(props) {
super(props);
this.state = { *1*
mapLoaded: false, *1*
location: { *1*
lat: props.location.lat,
lng: props.location.lng,
name: props.location.name
}
};
this.ensureMapExists = this.ensureMapExists.bind(this); *2*
}
static propTypes = {
location: PropTypes.shape({
lat: PropTypes.number,
lng: PropTypes.number,
name: PropTypes.string
}),
displayOnly: PropTypes.bool
};
static defaultProps = {
displayOnly: true,
location: {
lat: 34.1535641,
lng: -118.1428115,
name: null
}
};
componentDidMount() {
this.L = window.L; *3*
if (this.state.location.lng && this.state.location.lat) { *4*
this.ensureMapExists(); *4*
}
}
ensureMapExists() {
if (this.state.mapLoaded) return; *5*
this.map = this.L.mapbox.map(this.mapNode, 'mapbox.streets', { *6*
zoomControl: false, *6*
scrollWheelZoom: false *6*
});
this.map.setView(this.L.latLng(this.state.location.lat,
this.state.location.lng), 12); *7*
this.setState(() => ({ mapLoaded: true })); *8*
}
render() {
return [
<div key="displayMap" className="displayMap">
<div
className="map"
ref={node => {
this.mapNode = node;
}}
>
</div>
</div>
];
}
}
-
1 设置初始状态
-
2 绑定 ensureMapExists 类方法。
-
3 Mapbox 使用一个名为 Leaflet 的库(因此有“L”)。
-
4 检查地图是否有可用于工作的位置信息——如果有,设置地图。
-
5 确保您不会意外地重新创建已加载的地图。
-
6 使用 Mapbox 创建新地图并在组件中存储对其的引用(您正在禁用不需要的地图功能)
-
7 将地图视图设置为组件接收到的纬度和经度
-
8 更新状态以便您知道地图已加载
您的组件现在应显示一个仅用于显示目的的足够好的地图。不过,请记住,您想要创建一个可以指示特定位置并在用户选择新位置时更新给用户的地图组件。您需要做更多工作以启用这些功能:添加向地图添加标记的方法、更新地图位置以及确保地图正确更新。以下列表显示了如何将这些方法添加到您的组件中。
列表 6.5. 一个动态地图(src/components/map/DisplayMap.js)
import React, { Component } from 'react';
import PropTypes from 'prop-types';
export default class DisplayMap extends Component {
constructor(props) {
super(props);
this.state = {
mapLoaded: false,
location: {
lat: props.location.lat,
lng: props.location.lng,
name: props.location.name
}
};
this.ensureMapExists = this.ensureMapExists.bind(this); *1*
this.updateMapPosition = this.updateMapPosition.bind(this); *1*
}
//...
componentDidUpdate() { *2*
if (this.map && !this.props.displayOnly) {
this.map.invalidateSize(false); *2*
}
}
componentWillReceiveProps(nextProps) { *3*
if (nextProps.location) { *4*
const locationsAreEqual = Object.keys(nextProps.location).every(
k => nextProps.location[k] === this.props.location[k]
); *4*
if (!locationsAreEqual) { *4*
this.updateMapPosition(nextProps.location);
}
}
}
//...
ensureMapExists() {
if (this.state.mapLoaded) return;
this.map = this.L.mapbox.map(this.mapNode, 'mapbox.streets', {
zoomControl: false,
scrollWheelZoom: false
});
this.map.setView(this.L.latLng(this.state.location.lat,
this.state.location.lng), 12);
this.addMarker(this.state.location.lat, this.state.location.lng); *5*
this.setState(() => ({ mapLoaded: true }));
}
updateMapPosition(location) { *6*
const { lat, lng } = location;
this.map.setView(this.L.latLng(lat, lng)); *6*
this.addMarker(lat, lng); *6*
this.setState(() => ({ location })); *6*
}
addMarker(lat, lng) {
if (this.marker) {
return this.marker.setLatLng(this.L.latLng(lat, lng)); *7*
}
this.marker = this.L.marker([lat, lng], { *8*
icon: this.L.mapbox.marker.icon({
'marker-color': '#4469af'
})
});
this.marker.addTo(this.map); *8*
}
render() {
return [
<div key="displayMap" className="displayMap">
<div
className="map"
ref={node => {
this.mapNode = node;
}}
>
</div>
</div>
];
}
}
-
1 绑定类方法
-
2 告诉 Mapbox 使您的地图大小无效,防止在隐藏/显示地图时显示不正确
-
3 当要显示的位置发生变化时,您需要相应地做出反应
-
4 如果您有一个位置,检查当前位置和上一个位置以查看属性是否相同——如果不相同,您可以更新地图
-
5 在地图首次创建时添加标记
-
6 根据需要更新地图视图和组件状态。
-
7 更新现有标记而不是每次都创建一个。
-
8 创建标记并将其添加到地图中。
当你给组件添加每个方法时,你可能已经注意到了一个模式:使用第三方库做些事情,教 React 了解它,然后重复。这通常是我经验中与第三方库集成的做法。你通常会想要找到一个集成点,你可以从中获取库的数据或使用它的 API 来告诉它做事情——但所有这些都在 React 中完成。有许多例外,其中可能非常困难,但根据我的经验,React 的 refs 和一般的 JavaScript 互操作性使得与非 React 库一起工作并不像其他情况下那样糟糕(并且我希望你在未来的 React 应用中也找到同样的感觉)。
你还可以对你的组件进行至少一项改进。Mapbox 还允许你根据地理信息生成地图的静态图像。这在某些情况下可能很有用,你可能不想加载交互式地图。你将添加这个功能作为备用,以便用户可以立即看到地图。这将在第十二章(kindle_split_024_split_000.xhtml#ch12)中很有用,那时你将进行服务器端渲染。服务器将生成不会调用任何挂载相关方法的标记,因此即使在应用完全加载之前,用户仍然可以看到帖子中的位置。
你还需要在你的地图组件中添加一点小的 UI,以便地图可以在仅显示模式下显示其位置名称。我们之前提到过,你将向主要元素添加一个兄弟元素,这就是为什么你返回了一个元素数组。这就是你将添加这个小标记的地方。以下列表显示了如何向你的组件添加图像备用和位置名称显示。
列表 6.6. 添加备用地图图像(src/components/map/DisplayMap.js)
import React, { Component } from 'react';
import PropTypes from 'prop-types';
export default class DisplayMap extends Component {
constructor(props) {
super(props);
this.state = {
mapLoaded: false,
location: {
lat: props.location.lat,
lng: props.location.lng,
name: props.location.name
}
};
this.ensureMapExists = this.ensureMapExists.bind(this);
this.updateMapPosition = this.updateMapPosition.bind(this);
this.generateStaticMapImage = this.generateStaticMapImage.bind(this);*1*
}
//...
generateStaticMapImage(lat, lng) { *2*
return `https://api.mapbox.com/styles/v1/mapbox/streets-
v10/static/${lat},${lng},12,0,0/600x175?access_token=${process *2*
.env.MAPBOX_API_TOKEN}`;
}
render() {
return [
<div key="displayMap" className="displayMap">
<div
className="map"
ref={node => {
this.mapNode = node;
}}
>
{!this.state.mapLoaded && ( *3*
<img
className="map"
src={this.generateStaticMapImage(
this.state.location.lat,
this.state.location.lng
)}
alt={this.state.location.name}
/>
)}
</div>
</div>,
this.props.displayOnly && ( *4*
<div key="location-description" className="location-
description">
<i className="location-icon fa fa-location-arrow" />
<span className="location-
name">{this.state.location.name}</span> *4*
</div>
)
];
}
}
-
1 绑定类方法。
-
2 使用经纬度从 Mapbox 生成图像 URL。
-
3 显示位置图像。
-
4 如果你处于仅显示模式,显示位置名称和指示器。
6.2.2. 创建 LocationTypeAhead 组件
你可以在你的应用中显示地图,但你仍然无法创建它们。你需要构建另一个组件来支持这个功能:一个 位置自动完成 组件。在下一节中,你将使用这个组件在你的 CreatePost 组件中,以便用户可以搜索位置。这个组件将使用浏览器地理位置 API 以及 Mapbox API 来搜索位置。
你可以通过创建另一个文件开始,src/components/map/LocationTypeAhead.js。
展示了你将在本节中创建的自动完成组件。
一个你可以与你的地图组件一起使用,让用户向他们的帖子添加位置的自动完成组件

完成后,你的组件将具有以下基本功能:
-
显示一个位置列表供用户选择
-
将选定的位置传递给父组件以供使用
-
使用 Mapbox 和地理位置 API 允许用户选择他们的当前位置或通过地址搜索
接下来,你将开始创建组件的骨架,以确定其外观。列表 6.7 展示了它的第一个草图。你将再次使用 Mapbox,但这次你将使用一组不同的 API。在上一个章节中,你使用了地图显示 API,但在这里你将使用一组允许用户进行 反向地理编码 的 Mapbox API,这是一种更复杂的说法,即“通过文本搜索真实位置”。Mapbox 模块已经与项目一起安装,并将使用相同的公共 Mapbox 密钥来工作。如果你之前添加了你的 API 密钥,应用程序配置应在此处使用相同的密钥。
Mapbox 替代方案
你在本章中使用了 Mapbox,但还有其他映射库,例如 Google 地图。你将如何切换 Mapbox 为 Google 地图?你需要做哪些不同的操作?
列表 6.7. LocationTypeAhead 组件的起点
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import MapBox from 'mapbox'; *1*
export default class LocationTypeAhead extends Component {
static propTypes = {
onLocationUpdate: PropTypes.func.isRequired, *2*
onLocationSelect: PropTypes.func.isRequired *2*
};
constructor(props) {
super(props);
this.state = { *3*
text: '',
locations: [],
selectedLocation: null
};
this.mapbox = new MapBox(process.env.MAPBOX_API_TOKEN); *4*
}
render() {
return [ *5*
<div key="location-typeahead" className="location-typeahead">
<i className="fa fa-location-arrow"
onClick={this.attemptGeoLocation} /> *5*
<input
onChange={this.handleSearchChange} *5*
type="text"
placeholder="Enter a location..."
value={this.state.text}
/>
<button
disabled={!this.state.selectedLocation} *5*
onClick={this.handleSelectLocation} *5*
className="open"
>
Select
</button>
</div>
];
}
}
-
1 导入 Mapbox。
-
2 暴露两个方法,一个用于位置更新,一个用于位置选择。
-
3 设置初始状态
-
4 创建 Mapbox 客户端实例。
-
5 返回一个元素数组,这些元素将是你类型预测组件的标记。你需要实现事件处理器中引用的所有方法(onChange、onClick 等)。
现在,你可以开始填写你在组件的 render 方法中引用的方法。请注意,你需要一种处理搜索文本变化的方法、一个允许你选择位置按钮,以及一个允许用户选择当前位置的图标。我将在下一部分介绍该功能;现在,你需要允许用户通过文本搜索位置并选择位置的方法。列表 6.8 展示了如何添加这些方法。这些位置将从哪里来?你将使用 Mapbox API 根据用户输入搜索位置,并使用这些结果显示地址。这是你可以使用 Mapbox 的方法之一。你也可以做相反的操作——输入坐标并将其转换为地址。你将在下一个列表中使用该功能来处理地理位置 API。
列表 6.8. 搜索位置(src/components/map/LocationTypeAhead.js)
//...
constructor(props) {
super(props);
this.state = {
text: '',
locations: [],
selectedLocation: null
};
this.mapbox = new MapBox(process.env.MAPBOX_API_TOKEN);
this.handleLocationUpdate = this.handleLocationUpdate.bind(this); *1*
this.handleSearchChange = this.handleSearchChange.bind(this); *1*
this.handleSelectLocation = this.handleSelectLocation.bind(this); *1*
this.resetSearch = this.resetSearch.bind(this); *1*
}
componentWillUnmount() {
this.resetSearch(); *2*
}
handleLocationUpdate(location) {
this.setState(() => { *3*
return {
text: location.name,
locations: [],
selectedLocation: location
};
});
this.props.onLocationUpdate(location); *4*
}
handleSearchChange(e) {
const text = e.target.value; *5*
this.setState(() => ({ text })); *5*
if (!text) return;
this.mapbox.geocodeForward(text, {}).then(loc => { *6*
if (!loc.entity.features || !loc.entity.features.length) {
return; *7*
}
const locations = loc.entity.features.map(feature => { *8*
const [lng, lat] = feature.center;
return {
name: feature.place_name,
lat,
lng
};
});
this.setState(() => ({ locations })); *9*
});
}
resetSearch() { *10*
this.setState(() => {
return {
text: '', *10*
locations: [], *10*
selectedLocation: null *10*
};
});
}
handleSelectLocation() {
this.props.onLocationSelect(this.state.selectedLocation); *11*
}
//....
-
1 绑定类方法。
-
2 当组件卸载时,重置搜索。
-
3 当选择位置时,更新本地组件状态
-
4 同时,通过 props 回调将位置传递给父组件
-
5 从用户在搜索框中输入时接收的事件中提取文本
-
6 使用 Mapbox 客户端通过用户的文本搜索位置
-
7 如果没有结果,则不执行任何操作
-
8 将 Mapbox 结果转换为你在组件中更容易使用的格式。
-
9 使用新位置更新状态
-
10 允许重置组件状态(见 componentWillUnmount)
-
11 当位置被选中时,将当前选中位置传递上去
接下来,你想要让用户选择他们用于帖子的当前位置。为此,你将使用浏览器地理位置 API。即使你之前没有使用过地理位置 API,这也是可以的。在很长一段时间里,它是一个前沿特性,你只能在某些浏览器上使用它。现在它已经得到了广泛的应用,并且更加有用。
地理位置 API 基本上做了你想象中的事情:你可以询问用户是否可以在你的应用中使用他们的位置。到目前为止,几乎所有浏览器都支持地理位置 API (caniuse.com/#feat=geolocation),因此你可以利用它并让用户选择用于帖子的当前位置。请注意,地理位置 API 只能在安全上下文中使用,所以如果你尝试将 Letters Social 部署到未加密的主机,它将无法工作。
你需要再次使用 Mapbox API,因为地理位置 API 返回的只是坐标。记得你是如何使用用户的文本在 Mapbox 中搜索位置的?你可以做相反的事情:向 Mapbox 提供坐标,并获取匹配的地址。下面的列表显示了如何使用地理位置和 Mapbox API 让用户选择他们用于帖子的当前位置。
列表 6.9. 添加地理位置(src/components/map/LocationTypeAhead.js)
constructor(props) {
super(props);
this.state = {
text: '',
locations: [],
selectedLocation: null
};
this.mapbox = new MapBox(process.env.MAPBOX_API_TOKEN);
this.attemptGeoLocation = this.attemptGeoLocation.bind(this); *1*
this.handleLocationUpdate = this.handleLocationUpdate.bind(this);
this.handleSearchChange = this.handleSearchChange.bind(this);
this.handleSelectLocation = this.handleSelectLocation.bind(this);
this.resetSearch = this.resetSearch.bind(this);
}
//...
attemptGeoLocation() {
if ('geolocation' in navigator) { *2*
navigator.geolocation.getCurrentPosition( *3*
({ coords }) => { *4*
const { latitude, longitude } = coords; *4*
this.mapbox.geocodeReverse({ latitude, longitude },
{}).then(loc => { *5*
if (!loc.entity.features ||
!loc.entity.features.length) {
return; *5*
}
const feature = loc.entity.features[0]; *6*
const [lng, lat] = feature.center; *7*
const currentLocation = { *8*
name: feature.place_name,
lat,
lng
}; *8*
this.setState(() => ({
locations: [currentLocation], *8*
selectedLocation: currentLocation, *8*
text: currentLocation.name *8*
}));
this.handleLocationUpdate(currentLocation); *9*
});
},
null,
{
enableHighAccuracy: true, *10*
timeout: 5000, *10*
maximumAge: 0 *10*
}
);
}
}
//...
-
1 绑定类方法
-
2 检查浏览器是否支持地理位置
-
3 获取用户设备的当前位置
-
4 这将返回你可以使用的坐标。
-
5 使用 Mapbox 对坐标进行地理编码,如果未找到任何内容则提前返回。
-
6 获取第一个(最近的)特征来使用
-
7 提取纬度和经度
-
8 创建用于的定位有效载荷并使用它来更新组件状态
-
9 调用带有新位置的 handleLocationUpdate 属性
-
10 传递给地理位置 API 的选项
你的组件可以搜索 Mapbox 中的位置,并让用户通过地理位置 API 选择自己的位置。但是目前还没有向用户显示任何内容,所以你将解决这个问题。你需要使用位置结果,以便用户可以点击选择一个,如下面的列表所示。
列表 6.10. 向用户显示结果(src/components/map/LocationTypeAhead.js)
//...
render() {
return [
<div key="location-typeahead" className="location-typeahead">
<i className="fa fa-location-arrow"
onClick={this.attemptGeoLocation} />
<input
onChange={this.handleSearchChange}
type="text"
placeholder="Enter a location..."
value={this.state.text}
/>
<button
disabled={!this.state.selectedLocation}
onClick={this.handleSelectLocation}
className="open"
>
Select
</button>
</div>,
this.state.text.length && this.state.locations.length ? ( *1*
<div key="location-typeahead-results" className="location-
typeahead-results">
{this.state.locations.map(location => { *2*
return (
<div
onClick={e => { *3*
e.preventDefault(); *3*
this.handleLocationUpdate(location); *3*
}}
key={location.name} *4*
className="result"
>
{location.name} *5*
</div>
);
})}
</div>
) : null *6*
];
}
//...
-
1 如果有搜索查询并且你有匹配的结果,则显示结果。
-
2 遍历从 Mapbox 返回的位置。
-
3 如果用户点击位置,将其设置为选中位置
-
4 不要忘记为迭代的组件设置键。
-
5 显示位置名称
-
6 如果没有位置和搜索查询,则不执行任何操作。
6.2.3. 更新 CreatePost 并在帖子中添加地图
现在您已经创建了 LocationTypeAhead 和 DisplayMap 组件,可以将这些组件集成到您一直在工作的 CreatePost 组件中。这将结合您创建的功能,并允许用户创建带有位置的文章。还记得 CreatePost 组件如何将数据传递回父组件以执行实际的文章创建吗?您将与 type-ahead 和 DisplayMap 组件做同样的事情,但它们将来自 CreatePost。它们将协同工作,但不会如此紧密地绑定在一起,以至于您不能移动它们或在其他地方使用它们。
您需要更新 CreatePost 组件以与您之前创建的 LocationTypeAhead 和 DisplayMap 组件一起工作——记住,它们分别产生和接收位置。您将在 CreatePost 组件中跟踪位置,并使用您最近创建的两个组件作为位置数据源和目的地。以下列表显示了如何添加您需要添加到文章中的方法。
列表 6.11. 在 CreatePost 中处理位置(src/components/post/Create.js)
constructor(props) {
super(props);
this.initialState = {
content: '',
valid: false,
showLocationPicker: false, *1*
location: { *1*
lat: 34.1535641,
lng: -118.1428115,
name: null
},
locationSelected: false *1*
};
this.state = this.initialState;
this.filter = new Filter();
this.handlePostChange = this.handlePostChange.bind(this);
this.handleRemoveLocation = this.handleRemoveLocation.bind(this); *2*
this.handleSubmit = this.handleSubmit.bind(this);
this.handleToggleLocation = this.handleToggleLocation.bind(this); *2*
this.onLocationSelect = this.onLocationSelect.bind(this); *2*
this.onLocationUpdate = this.onLocationUpdate.bind(this); *2*
}
//...
handleRemoveLocation() { *3*
this.setState(() => ({
locationSelected: false,
location: this.initialState.location
}));
}
handleSubmit() {
if (!this.state.valid) {
return;
}
const newPost = {
content: this.state.content
};
if (this.state.locationSelected) { *4*
newPost.location = this.state.location;
}
this.props.onSubmit(newPost);
this.setState(() => ({
content: '',
valid: false,
showLocationPicker: false,
location: this.initialState.location,
locationSelected: false
}));
}
onLocationUpdate(location) {
this.setState(() => ({ location })); *5*
}
onLocationSelect(location) {
this.setState(() => ({ *5*
location,
showLocationPicker: false,
locationSelected: true
}));
}
handleToggleLocation(e) { *6*
e.preventDefault();
this.setState(state => ({ showLocationPicker:
!state.showLocationPicker }));
}
//...
-
1 为状态添加键,以便您可以跟踪位置和相关数据;设置一些默认位置数据
-
2 绑定类方法
-
3 允许用户从他们的文章中删除位置
-
4 提交文章时,如果存在,则将位置添加到有效负载中
-
5 处理来自 LocationTypeAhead 组件的位置更新。
-
6 切换显示位置选择器
CreatePost 组件现在可以与位置一起工作,因此您需要添加 UI 来实现这一功能。一旦添加了添加位置的关联 UI,您会发现 render 方法变得有些杂乱。这并不一定是一件坏事,标记并不是那么复杂,以至于您需要重构任何东西(我处理过数百行长的 render 方法),但这是一个探索 React 组件不同渲染技术的良好机会——我称之为 子渲染。
在其他地方使用 refs
在本章中,我们已经花了一些时间探讨如何在 React 中使用 refs。您能想到其他库或情况,其中 refs 可能会很有用吗?您在过去的项目中是否使用过 refs 来与 React 集成?
子渲染 方法涉及将您的 render 方法的一部分拆分到组件(或任何地方的函数)上的类方法中,然后在主 render 方法中的 JSX 表达式中调用它。如果您需要拆分较大的 render 方法,需要隔离渲染 UI 的特定部分的逻辑,或出于其他原因,可以使用此技术。您可能会发现其他有用的情况,但关键要点是您可以拆分渲染为多个部分,这些部分不必是其他组件。以下列表说明了如何将 render 方法拆分为更小的部分。
列表 6.12. 在组件中添加子渲染方法(src/components/post/Create.js)
constructor(props) {
//...
this.renderLocationControls = this.renderLocationControls.bind(this);*1*
}
renderLocationControls() { *1*
return (
<div className="controls">
<button onClick={this.handleSubmit}>Post</button>
{this.state.location && this.state.locationSelected ? ( *2*
<button onClick={this.handleRemoveLocation} *3*
className="open location-indicator">
<i className="fa-location-arrow fa" />
<small>{this.state.location.name}</small> *3*
</button>
) : ( *4*
<button onClick={this.handleToggleLocation} *4*
className="open">
{this.state.showLocationPicker ? 'Cancel' : 'Add loca-
tion'}{' '} *5*
<i
className={classnames(`fa`, { *5*
'fa-map-o': !this.state.showLocationPicker,
'fa-times': this.state.showLocationPicker
})}
/>
</button>
)}
</div>
);
}
render() {
return (
<div className="create-post">
<textarea
value={this.state.content}
onChange={this.handlePostChange}
placeholder="What's on your mind?"
/>
{this.renderLocationControls()} *6*
<div
className="location-picker"
style={{ display: this.state.showLocationPicker ? 'block' :
'none' }} *7*
>
{!this.state.locationSelected && [ *8*
<LocationTypeAhead
key="LocationTypeAhead" *8*
onLocationSelect={this.onLocationSelect}
onLocationUpdate={this.onLocationUpdate}
/>,
<DisplayMap
key="DisplayMap"
displayOnly={false}
location={this.state.location}
onLocationSelect={this.onLocationSelect}
onLocationUpdate={this.onLocationUpdate}
/>
]}
</div>
</div>
);
}
-
1 在构造函数中绑定类方法
-
2 如果选择了位置,则显示允许用户移除其位置的按钮
-
3 绑定移除位置方法并显示当前位置
-
4 显示将切换位置选择器组件的按钮
-
5 根据位置状态显示正确文本并使用正确边界方法
-
6 调用子渲染方法
-
7 根据状态显示或隐藏位置选择器组件
-
8 如果未选择位置,则显示位置选择器组件
最后,你需要将地图添加到具有位置的帖子中。你已经完成了构建 DisplayMap 组件并确保它可以在仅显示模式下工作的工作,所以你只需要将其包含在 Post 组件中。以下列表显示了如何做到这一点。
列表 6.13. 在帖子中添加地图(src/components/post/Post.js)
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import * as API from '../../shared/http';
import Content from './Content';
import Image from './Image';
import Link from './Link';
import PostActionSection from './PostActionSection';
import Comments from '../comment/Comments';
import DisplayMap from '../map/DisplayMap'; *1*
import UserHeader from '../post/UserHeader';
import Loader from '../Loader';
export class Post extends Component {
static propTypes = {
post: PropTypes.object
};
//...
render() {
if (!this.state.post) {
return <Loader />;
}
return (
<div className="post">
<UserHeader date={this.state.post.date}
user={this.state.post.user} />
<Content post={this.state.post} />
<Image post={this.state.post} />
<Link link={this.state.post.link} />
{this.state.post.location && <DisplayMap
location={this.state.post.location} />} *2*
<PostActionSection showComments={this.state.showComments} />
<Comments
comments={this.state.comments}
show={this.state.showComments}
post={this.state.post}
handleSubmit={this.createComment}
user={this.props.user}
/>
</div>
);
}
}
export default Post;
-
1 导入 DisplayMap 组件以供使用。
-
2 如果帖子与位置相关联,则显示它并打开 displayOnly 模式
通过这样,你为用户添加了在帖子中添加和显示位置的功能。你的投资者们一定会因为这样一个改变游戏规则的功能而感到高兴和印象深刻!
图 6.3. 本章工作的最终产品。你的用户可以创建帖子并将位置添加到它们中。

6.3. 摘要
本章你学到了以下内容:
-
在 React 中,ref 是对底层 DOM 元素的引用。当你需要一个逃生舱并需要与在 React 之外与 DOM 一起工作的库一起工作时,refs 非常有用。
-
组件可以是受控的或不受控的。受控组件让你完全控制组件的状态,并涉及一个完整的周期,即监听并设置输入值。不受控组件在内部维护自己的状态,不提供洞察或控制。
-
通过使用 refs,通常可以将 React 组件与也使用 DOM 的第三方库集成。当你需要接触并交互 DOM 元素时,refs 可以作为逃生舱。
在下一章中,你将开始为你的应用程序添加复杂性并创建基本路由,以便你有多个页面的可能性。
第七章. React 中的路由
本章涵盖
-
更高级的组件设计和使用
-
启用具有路由的多页 React 应用程序
-
使用 React 从零开始构建路由器
在本章中,你将通过添加路由来开始使你的应用更加健壮和可扩展。路由意味着用户将能够通过 URL 导航到应用的不同部分。到目前为止,应用仅限于一个页面,当你添加部分时,这会阻碍其增长。没有路由或其他机制来提供可管理的层次结构,大型应用会特别受拥挤的影响。我们将看到如何使用 React 为你的应用解决这个问题。你将从头开始构建一个简单的路由器,以便更好地理解你如何使用 React 应用进行路由。
我如何获取本章的代码?
与每一章一样,你可以通过访问 GitHub 仓库github.com/react-in-action/letters-social来获取本章的源代码。如果你想从一张白纸开始学习本章,并跟随操作,你可以使用第五章和第六章(如果你跟随并自己构建了示例)中的现有代码,或者检出特定章节的分支(chapter-7-8)。
记住,每个分支都对应于章节末尾的代码(例如,分支 chapter-7-8 对应于这些章节末尾的代码)。你可以在你选择的目录中执行以下终端命令之一来获取当前章节的代码。
如果你根本就没有仓库,请输入以下命令:
git clone git@github.com:react-in-action/letters-social.git
如果你已经克隆了仓库:
git checkout chapter-7-8
你可能是从另一章来到这里的,所以确保你已经安装了所有正确的依赖项总是一个好主意:
npm install
7.1. 什么是路由?
要真正了解路由,我们首先必须对它有一个概念。路由以某种方式是所有网站和 Web 应用的基石。它在最简单的静态 HTML 页面和最复杂的 React Web 应用中都扮演着核心角色。几乎在任何你想将 URL 映射到操作的时候,路由都会发挥作用。大多数应用都充满了 URL 链接,因为链接是网上移动的既定方式。想想看,一个用于查找东西的系统——URL 已经变得多么有效——它们几乎无处不在。为什么它们在网络上查找东西时如此有用?可能是因为我们习惯了像地址这样的路由系统,即使 URL 不需要逐个方向指示,它们也帮助我们找到我们想要的东西——在这种情况下,是应用或资源而不是位置。
定义
路由可以有多个不同的含义和实现。就我们的目的而言,它是一个资源导航系统。在抽象层面,路由可能是一个您熟悉的概念,在 Web 工程中很常见。如果您在浏览器中工作,您熟悉与 URL 和浏览器中的资源(图像、脚本等的路径)相关的路由。在服务器上,路由可以专注于匹配传入请求路径(如ifelse.io/react-ecosystem)到数据库中的资源。您正在学习如何使用 React,因此本书中的路由通常意味着将组件(人们想要的资源)与 URL(告诉系统他们想要的方式)匹配。
路由是 Web 应用的重要组成部分。比如说,您想要构建一个 Web 应用,让用户可以创建自定义筹款页面来为对他们重要的原因筹集资金。在这种情况下,您将需要路由,出于以下几个原因:
-
通常情况下,这样人们才能提供指向您的 Web 应用的链接。指向永久资源的 URL 应该是持久且随时间保持一致结构的。
-
公共筹款页面需要可靠地供每个人访问,因此您需要一个将它们引导到正确页面的 URL。
-
管理界面的不同部分将需要它。用户需要能够在其浏览历史中前后移动。
-
您网站的不同部分将需要它们自己的 URL,这样您就可以轻松地将人们引导到正确的部分(例如,/settings、/profile、/pricing 等等)。
-
通过页面分割代码有助于提高模块化,因此您也可以将应用分割开来。结合动态内容,这反过来可以减少在特定点必须加载的应用的大小。
7.1.1. 现代前端 Web 应用中的路由
在过去,Web 应用的基本架构涉及的路由方法与现代方式不同。较老的方法涉及服务器(比如用 Python、Ruby 或 PHP 创建的东西)生成 HTML 标记并发送到浏览器。用户可能会填写一个包含一些数据的表单,将其发送回服务器,并等待响应。这在使网络更强大方面是革命性的,因为您可以修改数据而不是仅仅查看它。
从那时起,Web 服务在设计和管理方面经历了许多发展。如今,JavaScript 框架和浏览器技术已经足够先进,Web 应用可以实现更明显的客户端-服务器分离。客户端应用(全部在浏览器中)由服务器发送,然后有效地“接管”。服务器随后负责发送原始数据,通常是 JSON 形式。图 7.1Figure 7.1 展示了并比较了这两种通用架构的工作方式。
图 7.1. 比较稍微旧一些和现代的 Web 应用程序架构。在旧的方式中,动态内容会在服务器上生成。服务器通常会从数据库中获取数据,并使用它来填充一个将被发送到客户端的 HTML 视图。现在,客户端有更多的应用程序逻辑,由 JavaScript(在这种情况下,是 React)管理。服务器最初发送 HTML、JavaScript 和 CSS 资产,但之后,客户端 React 应用程序接管。从那时起,除非用户手动刷新页面,否则服务器只需发送原始 JSON 数据。

到目前为止,你一直在使用现代架构来构建学习应用程序 Letters Social。一个 node.js 服务器发送你应用程序所需的 HTML、JavaScript 和 CSS。一旦加载,React 就接管了。进一步的数据请求被发送到示例 API 服务器。但你缺少架构中的一个关键部分:客户端路由。
关于路由的沉思
在我们深入构建你的 React 路由器之前,花点时间思考一下路由。你在过去的项目中遇到过哪些其他的路由示例?路由还有哪些其他用途?
7.2. 创建路由器
你将从头开始使用组件构建一个简单的路由器,以便更好地理解你如何使用 React 应用程序进行路由。以下是你将采取的步骤概述:
-
你将创建两个组件,Router 和 Route,它们将一起使用以实现客户端路由。
-
路由组件将由路由组件组成。
-
每个路由将代表一个 URL 路径(/、/posts/123)并将组件映射到该 URL。当你的用户访问 / 时,他们将看到一个对应的组件。
-
路由组件将看起来像一个正常的 React 组件(它将有一个
render方法、组件方法和使用 JSX),但它将允许你将组件映射到 URL。 -
路由组件可以指定参数,如
/users/:user,其中:user语法将表示传递给组件的值。 -
你还将创建一个链接组件,它将使你能够使用客户端路由器进行导航。
如果这些内容还没有完全理解,请不要担心。我们将逐一处理每个步骤。让我们看看你构建路由时将努力实现的一个示例。
列表 7.1 展示了你将构建的路由组件在其最终形式下的使用情况。阅读起来很容易,你可以思考一下:你有一个带有与组件相关联的路由的路由器。路由不一定是分层的——你可以创建混乱并任意嵌套资源——但通常是这样的。这意味着它可以相对容易地映射到 React 的组合语义。如果你是第一次开始学习 React,那么下面列表中的路由示例可能是你能够立即理解的最容易的组件之一。
列表 7.1. 路由最终结果(src/index.js)
//...
<Router location="/"> *1*
<Route path="/" component={App}> *2*
<Route path="posts/:post" component={SinglePost} /> *3*
<Route path="login" component={Login} />
</Route>
</Router>,
//...
-
1 路由器组件负责存储路由并返回用于渲染的正确组件
-
2 每个路由组件接收一个路径和一个组件,并将它们匹配起来,您可以在它们内部嵌套多个组件。
-
3 您可以向组件路径传递参数,这些参数代表动态值,这意味着您可以从路由中获取数据并将其用于组件。
这种路由结构易于阅读和思考。由于 React Router 的存在,它在 React 应用程序中也非常成熟。您将遵循同样的基本 API 来构建您的路由器。在这个过程中,我们将从由 TJ Holowaychuk 创建的一个小型、轻量级路由库中汲取灵感,这个库叫做 react-enroute。使用这个库,您可以在 React 中探索路由,而无需重新创建像 React Router 这样的整个开源库。
我们对您将要构建的内容以及它在使用中的样子有更多的了解,但我们应该从哪里开始呢?我们从 children 开始。
7.2.1. 组件路由
不,您不会招募年轻人来实现您应用程序中的路由。相反,您将使用特殊的组件属性 children。您可能还记得 children 属性,在之前的章节中,它是 React.createElement(type, props, children) 签名的一部分,或者作为可以组合组件的特殊属性。
以前,您只从输入的角度关心 children:您会将组件传递给另一个组件以组合它们。现在,您将从一个组件内部访问 children 并使用组件本身来设置您的路由。这就是您开始将组件映射到 URL 的工作的地方。如果网络开发中的路由是将 URL 映射到行为或视图,那么 React 中的路由就是将 URL 映射到特定组件。
7.2.2. 创建 组件
您将创建一个路由器组件,它将使用子组件将 URL 路由与组件匹配并渲染它们。如果您在思考这将是什么样子时遇到困难,请记住,我们将有意识地逐步进行,您不需要一开始就完全理解所有内容。
列表 7.2 展示了两种组件类型:路由器(Router)和路由(Route)。让我们从路由组件开始,您可以使用它来将组件与路由关联起来。列表 7.2 展示了如何创建路由组件。它看起来可能没有太多内容,但正如您很快就会看到的,这是可以的。路由器组件将承担大部分繁重的工作,而路由组件将主要作为您对 URL 和组件映射的数据容器。
列表 7.2. 创建路由组件(src/components/router/Route.js)
import PropTypes from 'prop-types';
import { Component } from 'react';
import invariant from 'invariant'; *1*
class Route extends Component {
static propTypes = {
path: PropTypes.string,
component: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
}; *2*
render() {
return invariant(false, "<Route> elements are for config only and
shouldn't be rendered"); *3*
}
}
export default Route; *4*
-
1 引入 invariant 库以确保路由组件永远不会被渲染,或者如果它被渲染了,您将抛出一个错误
-
2 每个路由都包含一个路径和一个函数,因此请使用 PropTypes 指定这些属性。
-
3 整个路由组件只是一个返回 invariant 库调用的函数——如果被调用,将抛出错误,你知道事情没有按正确的方式运行
-
4 使用命名导出使组件对外部模块可用
你可能已经注意到,这里导入了一个新的库,名为 invariant。这是一个简单的工具,你将用它来确保在特定条件不满足时抛出错误。要使用它,你需要传入一个值和一个消息。如果该值是 falsey (null、0、undefined、NaN、''(空字符串) 或 false),它将抛出错误。invariant 库在 React 中经常被使用,所以如果你在开发者工具控制台中看到类似“invariant violation”的警告或错误消息,那么它可能就是相关的。你将在这里使用它来确保路由组件不会渲染任何内容。
确实如此——路由组件不会渲染任何内容。如果它确实渲染了,invariant 工具将抛出错误。一开始这可能听起来有些奇怪。毕竟,到目前为止,你已经在组件中做了很多渲染。但这只是将路由和组件分组在一起的一种方式,React 可以理解,你也可以利用这种方式。你将使用路由组件来存储属性并传递你想要的子组件。随着你构建路由器组件,这将会变得更加清晰,但在继续之前,请先查看图 7.2 以检查你的理解。
图 7.2. 路由和路由组件将如何工作的概述。路由器(你将在下一节中构建),其子组件是路由组件。这些组件中的每一个都使用两个属性:一个 path 字符串和一个组件。<Router/> 将使用每个 <Route/> 来匹配 URL 并渲染正确的组件。因为一切都是 React 组件,你可以在渲染时传递属性给路由器,并使用这些属性作为顶级数据(如用户、认证状态等)的初始应用程序状态。

7.2.3. 开始构建 组件
要开始构建路由器,你需要再次通过创建组件的基础知识。现在这应该很熟悉了,尽管你最终将构建一个做一些你之前没有见过的独特事情的组件。好消息是,你不需要做任何“魔法”来创建你的路由器。你将使用 React 组件,向路由器组件添加一些逻辑,然后将其用作你的应用程序渲染的主要组件。
这可能看起来不是什么大问题。你可能正在想,“好吧,那是一个组件。毕竟这是 React,所以这似乎...很正常?”我指出这一点是因为它是一个很好的例子,展示了你可以用“仅仅”React 做到的强大而灵活的事情,而这些事情你可能不会立即想到去做。你不需要任何全新的工具。你只需要找到一种方法来记录 URL 和组件的映射,然后找到一种方法与正确的浏览器 API 交互。现在你可以开始构建这个事物了。
关于 React Router 呢?
如果你曾经使用过 React,你可能听说过React Router。它是开源中最受欢迎的 React 项目之一,也是 React 应用程序中最受欢迎的路由解决方案。你可能想知道为什么你不直接安装React Router并学习如何使用该 API。你可以这样做,但我认为你会错过看到你可以如何使用 React 组件做一些你可能不会想到的事情的机会(比如将 URL 映射到组件!)。通过自己构建一些东西,你将学到比简单地使用 npm 安装东西多得多的东西。
现在,这与你在商业环境或任何生产环境中可能做的事情不同。尽管从头开始自己构建路由器可能很有帮助,但作为工程师,你的主要角色(几乎总是)是为公司创造价值,而你可以通过构建或使用经过良好测试、性能良好且易于工作的工具来实现这一点。
考虑到这一点,你和你的团队可能会选择使用React Router而不是自己构建。选择一个维护良好、受欢迎的开源库来满足你的需求,通常是一个更好的工程和商业决策。当我们讨论第十二章中的服务器端渲染时,你会用React Router替换你的路由器,这样我们可以利用它的一些特性。
列表 7.3 展示了如何构建路由器组件。这里除了在组件上设置的routes属性之外,没有太多不寻常的地方。注意,因为你不想在运行时对路由做任何更改,所以你不会在 React 的本地组件状态中存储路由。可能有一些情况,你希望在运行时动态更改路由,比如用户正在积极自定义应用程序或类似的事情。在这些情况下,你可以使用组件的state接口。这里你没有这样的需求,所以你将路由放在组件上。
列表 7.3. 构建路由器(src/components/router/Router.js)
export default class Router extends Component {
static propTypes = { *1*
children: PropTypes.object,
location: PropTypes.string.isRequired
};
constructor(props) {
super(props);
this.routes = {}; *2*
}
render() {} *3*
}
-
1 路由组件将有一个 render()方法。
-
2 你将在路由组件中存储路由到一个对象中。
-
3 指定 PropTypes——路由器将接收子组件和一个位置来工作。
现在你有了 Router 组件的基本框架,你可以开始添加一些你将在组件的核心方法中使用的工具。
当处理路由时,你需要做一些事情。如果你仔细看了列表 7.2,你可能注意到你可以传递没有前面带有 / 的 path 属性。这可能看起来像一件小事,但你需要确保路由的使用者可以这样做。你还需要确保如果用户不小心或由于路由嵌套而包含过多的斜杠,任何双 // 都会被移除。
让我们看看如何创建两个辅助工具来解决这些问题。首先,你需要创建一个用于清理路径的工具。这将使用一个简单的正则表达式来将任何双斜杠替换为单个斜杠。如果你不熟悉正则表达式,你可以在网上找到许多很好的资源来了解更多关于它们的信息。它们是匹配文本中模式的一种强大方式,并且对于许多形式的软件开发至关重要。但它们也可能显得晦涩难懂,难以推理或学习。幸运的是,你只需要使用一个简单的正则表达式来查找和替换任何双斜杠(//)。下一个列表展示了如何实现简单的 cleanPath 方法。请注意,使用正则表达式清理字符串可能很棘手,因此不要期望你遇到的每个情况都这么简单。
列表 7.4. 向 Router 添加 cleanPath 工具(src/components/router/Router.js)
//...
cleanPath(path) {
return path.replace(/\/\//g, '/'); *1*
}
//...
- 1 cleanPath 使用 String.replace 从路径中移除任何双斜杠字符(/)。
我们不会深入探讨正则表达式,因为它们值得进行严肃的深入处理,但我们至少可以注意几点。首先,JavaScript 中的基本正则表达式语法是两个斜杠,其中包含一个在 /<正则表达式>/ 内的表达式。其次,尽管 \/\/ 这一系列字符看起来很神秘,坦白说,有点像 W,但它只是两个斜杠(//)加上转义字符(/),这样它们就不会被解释为注释或其他内容。最后,添加到正则表达式末尾的 g 字符是一个标志,表示匹配所有出现。要了解更多关于正则表达式的信息,请访问regexr.com/3eg8l,以获取关于正则表达式每个部分含义的详细见解,并练习匹配不同的模式。
现在你已经可以清理 // 的出现,你需要处理你添加的路由的几个其他情况。你可以称这个工具为 normalizeRoute,因为它将确保父路由和子路由在必要时以带有斜杠的正确字符串创建。这个函数将接受一个路径和一个可选的父路径。有了这两个输入,你可以处理几种情况。以下列表展示了 normalizeRoute 方法将如何工作。
列表 7.5. 创建 normalizeRoute 工具(src/components/router/Router.js)
//...
normalizeRoute(path, parent) { *1*
if (path[0] === '/') { *2*
return path; *2*
} *2*
if (parent == null) { *3*
return path; *3*
} *3*
return `${parent.route}/${path}`; *4*
}
//...
-
1 函数接收路径和父对象——路由属性是一个路径字符串。
-
2 如果路径只是 /,你可以直接返回它——我们不需要将它与父路径连接。
-
3 如果没有提供父路径,你可以直接返回路径,因为没有东西可以与之连接。
-
4 如果有父路径,你可以通过将它们连接起来将路径添加到父路径上。
7.2.4. 匹配 URL 路径和参数化路由
你已经创建了一些辅助工具,但目前还没有进行任何路由。为了开始匹配 URL 到组件,你需要向你的路由器添加路由。你打算怎么做到这一点?本质上,你需要找到一种方法,根据当前的 URL 渲染给定的组件——这就是我一直在说的“匹配”部分。这可能听起来工作量不大,但实际上涉及到的步骤不止几个。
首先,让我们看看浏览器前端路由系统的一个关键组件:路径匹配。你需要某种方法来评估路径字符串并将它们转换为你可以使用的有意义的数据。为了实现这一点,你将使用一个小型包 enroute,它本身就是一个微型的路由器,你可以用它来匹配路径到你的组件。内部,enroute 将字符串转换为可以用于匹配字符串的正则表达式(例如,你将要检查的 URL)。你还可以用它来指定路径 参数,这样你就可以创建一个像 /users/:user 这样的路径,并在 /users/1234 中访问用户 ID,就像在代码中的 route.params.user 一样。这种方法很常见,如果你曾经使用过 express.js,你可能见过类似的东西。
能够参数化 URL 很有用,因为这样你可以将 URL 视为另一种可以传递给路由器的数据输入形式。URL 很强大,使它们动态化是其中的一个原因。URL 可以有意义,并允许用户直接访问资源,而无需首先访问一个页面,然后导航多次才能到达他们想去的地方。
你不会使用参数化路由的全部功能,但让我们看看几个例子,以确保你知道你正在努力实现什么。表 7.1 展示了几个在常见网络应用中可能有用的 URL 路径示例。
表 7.1. 带参数的常见路由示例
| 路由 | 示例用途 |
|---|---|
| / | 应用的主页。 |
| /profile | 用户的个人资料页面;显示设置。 |
| /profile/settings | 设置路由;是个人资料页面的子路由;显示用户相关设置。 |
| /posts/:postID | postID 可用于代码;示例路由为 /posts/2391448。如果你想要创建指向特定帖子的公开链接,这很有用。 |
| /users/:userID | :userID 是路径参数;根据 ID 显示特定用户很有用。 |
| /users/:userID/posts | 显示某个用户的全部帖子;URL 中的:userID部分是动态的,并在您的代码中可用。 |
您在这里仅利用了参数化路由的一个方面,即使用:name语法,但有一些工具可以让您做更多的事情。如果您想了解更多关于参数化路由的信息,请查看path-to-regexp库,可在www.npmjs.com/package/path-to-regexp找到。这是一个非常好的工具,我们还可以花时间研究其他工具,但我们需要专注于当前的任务:使用 React 进行路由。
这些路由工具(enroute和path-to-regexp)的重要收获是您将使用它们来帮助匹配 URL 并处理 URL 中的某些路径参数。目前来说,您使用哪个工具或者是否想自己构建并不那么重要;您只需要一个能让您专注于基础的工具。React 的一个美妙之处在于,当您构建自己的应用程序时,您可以自由地根据自己的信息做出决定,选择想要使用的路由工具。
思考参数
参数化路由通常是一种将数据引入应用程序的有用方式。您能想到除了获取帖子 ID 之外,您可能还会用路由参数做些什么吗?
您将使用您的 URL 匹配库(enroute)来确定要渲染哪个路由,因此接下来您将在组件上设置它。目前,Router 组件的render方法没有任何作用,所以这似乎是一个很好的起点。以下列表显示了如何将enroute与路由器集成以及render方法的相应更改。
列表 7.6. 完成的 Router(src/components/router/Router.js)
import enroute from 'enroute'; *1*
import invariant from 'invariant';
export class Router extends Component {
static propTypes = { *2*
children: PropTypes.element.isRequired, *2*
location: PropTypes.string.isRequired, *2*
}
constructor(props) { *3*
super(props);
// We'll store the routes on the Router component
this.routes = {}; *4*
// Set up the router for matching & routing
this.router = enroute(this.routes); *5*
}
render() {
const { location } = this.props; *6*
invariant(location, '<Router/> needs a location to work'); *7*
return this.router(location); *8*
}
}
-
1 enroute 是一个小巧的功能性路由器,您用它来匹配 URL 字符串和参数化路由。
-
2 将 PropTypes 作为静态类属性设置。
-
3 设置组件的初始状态并初始化 enroute
-
4 四条路由最终将作为对象出现,其键为您的 URL 路径。
-
5 将路由传递给 enroute,Render 将使用 enroute 的返回值来匹配 URL 到组件。
-
6 将当前位置作为 prop 传递给路由器。
-
7 使用 invariant 确保您没有忘记提供位置。
-
8 最后,也是最重要的,您想要使用路由器来匹配位置并返回相应的组件。
您并没有添加太多代码,但路由器中一些最重要的部分现在已经就位。目前,没有为enroute提供任何路由,但基本机制已经存在。您想要尝试找到与路由关联的组件,然后使用路由器来渲染它。在下一节中,您将创建这些路由,以便路由器可以使用它们。
7.2.5. 向 Router 组件添加路由
要将路由添加到路由器,您需要两样东西:要使用的正确 URL 字符串和该 URL 的组件。您将在 Router 组件上创建一个方法,让您可以将这两者结合起来:addRoute。如果您快速查看github.com/lapwinglabs/enroute上的enroute使用示例,您将看到enroute是如何工作的。它接受一个对象,该对象以 URL 字符串为键,以函数为值,当匹配到其中一个路径时,它将调用该函数并传递一些额外的数据。列表 7.7 显示了如何在不使用 React 的情况下使用enroute库。使用enroute,您可以匹配接受参数和任何附加数据的函数到 URL 字符串。
列表 7.7. 路由配置示例(src/components/router/Router.js)
function edit_user (params, props) { *1*
return Object.assign({}, params, props) *1*
} *1*
const router = enroute({ *2*
'/users/new': create_user, *2*
'/users/:slug': find_user, *2*
'/users/:slug/edit': edit_user, *2*
'*': not_found *2*
});
enroute('/users/mark/edit', { additional: 'props' }) *3*
-
1 使用了两个参数:路由参数(如 /users/:user)和您传入的任何附加数据。
-
2 传入一个包含路径和函数的对象,这些函数用于处理这些路径。
-
3 使用时,传入一个位置和任何附加数据,正确的函数将被执行。
现在您对enroute除了 React 之外的工作方式有了些了解,让我们看看如何将其集成到您的路由器中并给它一些活力。与前面列表中返回对象的方式不同,您想要返回一个组件。但您目前没有访问路由的路径或组件的方法。还记得您创建了一个用于存储它们的 Route 组件,但没有渲染任何内容吗?您需要从父组件(Router)获取这些数据。这意味着您将需要使用children属性。
注意
您已经看到了如何在 React 中将组件组合在一起,通过在组件之间创建父子关系来创建新的组件。到目前为止,您只是通过将组件嵌套在彼此内部来“外部”使用子组件。每次您在嵌套和组合组件时,您都在利用 React 的子组件概念。但您还没有从父组件动态访问任何嵌套子组件。您可以通过组件的 props 中的children来访问传递给父组件的子组件,正如您所猜的,就是children。
每个 React 组件或元素上可用的children属性被称为不透明数据结构,因为它与 React 中的几乎所有其他内容不同,它不是一个数组或普通的 JavaScript 对象。这可能在 React 的将来版本中改变,但与此同时,这意味着 React 提供了一些工具,让您可以处理children属性。React.Children提供了一些方法,您可以使用它们来处理children不透明数据结构,包括以下内容:
-
React.Children.map— 与原生 JavaScript 中的Array.map类似,这个方法在children中的每个直接子元素上调用一个函数(这意味着它不会遍历每个可能的子组件,只是直接子元素)并返回它遍历的元素数组。如果children是null或undefined,则返回null或undefined而不是空数组:React.Children.map(children, function[(thisArg)]) -
React.Children.forEach— 与React.Children.map的工作方式类似,但它不返回数组:React.Children.forEach(children, function[(thisArg)]) -
React.Children.count— 返回在children中找到的组件总数。等于React.Children.map或React.Children.forEach在相同元素上调用其回调的次数:React.Children.count(children) -
React.Children.only— 返回children中的唯一子元素或抛出错误:React.Children.toArray(children) -
React.Children.toArray— 将children作为带有每个子元素键的扁平数组返回:React.Children.toArray(children)
因为你想在 Router 组件的this.routes上添加路由信息,所以你会使用React.Children.forEach来遍历 Router 的每个子元素(记住,那些是 Route 组件),并获取它们的属性。你将使用这些属性来设置你的路由并告诉enroute在哪个 URL 渲染哪个组件。
“React 中的自我消除组件”
当 React 16 发布时,它使组件能够在渲染时返回数组。这之前是不可能的,但它开启了一些有趣的可能性。其中之一是自我销毁或自我消除^([1])组件的想法。之前,当你只能从任何给定组件返回单个节点时,你经常会发现自己将组件包裹在 div 或 span 中,只是为了得到有效的 JavaScript 输出。一个常见的场景可能看起来像这样:
¹
非常感谢 Ben Ilegbodu 首先向我介绍这个想法!
export const Parent = () => {
return (
<Flex>
<Sidebar/> *1*
<Main /> *1*
<LinksCollection/> *1*
</Flex>
);
}
export const LinksCollection = () => {
return (
<div> *2*
<User />
<Group />
<Org />
</div>
);
}
-
1 使用 Flexbox(或 CSS 网格)并排排列的顶级组件
-
2 添加包装 div 是因为在 JavaScript 中,User、Group 和 Org 不能一起返回——它不支持多个返回值
这对许多团队来说是一个很大的烦恼,尽管这当然没有阻止人们使用 React。尽管如此,它造成的一个主要问题并不仅仅是包裹 div 看起来似乎是不必要的。正如你所看到的,应用程序是使用 Flexbox(或某些其他 CSS 布局 API,在这种情况下会中断)布局的。
包裹 div 造成的问题在于它迫使你将组件提升一个级别,这样它们就不会在单个节点中分组。当然,还有其他原因导致问题或强制采取折衷方案,但这是我多次遇到的一个问题。
然而,随着 React 16 及其后续版本的推出,现在可以返回数组,因此我们找到了一种绕过这个问题的方法。React 16 引入了许多其他强大的功能,但这个变化是受欢迎的。开发者现在可以这样做:
export const SelfEradicating = (props) => props.children
此组件充当一种传递组件,在渲染其子组件时让路或“自我消除”。使用这种方法,你可以在不涉及 CSS 布局技术等事项的情况下保持组件分离。具有“自我消除”组件的相同场景可能如下所示:
export const SelfEradicating = (props) => props.children
export const Parent = () => {
return (
<Flex>
<Sidebar/>
<Main />
<LinksCollection/>
</Flex>
);
}
export const LinksCollection = () => {
return (
<SelfEradicating>
<User />
<Group />
<Org />
</SelfEradicating>
);
}
记住,enroute 预期你为每个路由提供一个函数,以便它可以传递参数信息和其它数据给它。这个函数是你在其中告诉 React 创建组件并处理渲染额外子组件的地方。列表 7.8 展示了如何将 addRoute 和 addRoutes 方法添加到你的组件中。addRoutes 使用 React.Children.forEach 遍历子 Route 组件,获取它们的数据,并为 enroute 设置路由。这是路由器的核心部分——一旦你实现了这个,路由器就会启动并运行!
props.children
我们在本章中讨论了 React 的 props.children。props.children 和其他属性之间有什么区别?为什么可能会有区别?
列表 7.8. addRoute 和 addRoutes 方法 (src/components/router/Router.js)
addRoute(element, parent) {
const { component, path, children } = element.props; *1*
invariant(component, `Route ${path} is missing the "path" property`); *2*
invariant(typeof path === 'string', `Route ${path} is not a string`); *2*
const render = (params, renderProps) => { *3*
const finalProps = Object.assign({ params }, this.props, renderProps); *4*
const children = React.createElement(component, finalProps); *5*
return parent ? parent.render(params, { children }) : children; *6*
};
const route = this.normalizeRoute(path, parent); *7*
if (children) { *8*
this.addRoutes(children, { route, render }); *8*
} *8*
this.routes[this.cleanPath(route)] = render; *9*
}
//...
-
1 使用解构来获取组件、路径和子组件属性。
-
2 确保每个 Route 都有一个路径和组件属性,否则抛出错误。
-
3 render 是一个你将给 enroute 的函数,它接受与路由相关的参数和额外数据。
-
4 合并父组件和子组件的属性
-
5 创建一个新的组件,并合并属性。
-
6 如果有父组件,调用父参数的 render 方法,但使用你创建的子组件
-
7 使用 normalizeRoute 辅助函数确保 URL 路径设置正确
-
8 如果当前路由组件有更多嵌套子组件,重复此过程,并传入路由和父组件
-
9 使用 cleanPath 工具在路由对象上创建路径,并将你的完成函数分配给它
呼呼!在这些代码行中发生了很多事情。请随意多次回顾它,以确保你对这些概念感到舒适。一旦你添加了 addRoutes 方法,我们将回顾步骤并使用可视化进行复习。但首先,你需要添加 addRoutes 方法。相对而言,它相当简短。以下列表展示了如何实现它。
列表 7.9. addRoutes 方法 (/components/router/Router.js)
//...
constructor(props) { *1*
super(props);
this.routes = {};
this.addRoutes(props.children); *1*
this.router = enroute(this.routes);
} *1*
addRoutes(routes, parent) { *2*
React.Children.forEach(routes, route => this.addRoute(route, parent)); *3*
}
-
1 即使
addRoutes在addRoute方法中使用,也要将其添加到组件的构造函数中以启动设置路由。 -
2 在
addRoutes方法中使用时,如果有额外的子组件要遍历 -
3 使用 React.Children.forEach 工具遍历每个子组件,然后为每个子 Route 组件调用 addRoute。
图 7.3. 向你的路由器添加路由的过程。对于在 Router 组件中找到的每个 Route 组件,提取路径和组件属性,然后使用这些属性创建一个可以与 URL 路径配对的函数,供 enroute 使用。如果 Route 有子组件,则在继续之前对那些子组件运行相同的过程。完成后,routes 属性将设置所有正确的路由。

有了这些,你的路由器就完成了,准备投入使用。接下来的列表显示了最终状态的 Router 组件,为了简洁起见,省略了辅助工具(路径规范化、不变使用)。在下一章中,你将开始使用 Router 组件。
列表 7.10. 完成的 Router(src/components/router/Router.js)
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import enroute from 'enroute';
import invariant from 'invariant';
export default class Router extends Component {
static propTypes = {
children: PropTypes.array,
location: PropTypes.string.isRequired
};
constructor(props) {
super(props);
this.routes = {};
this.addRoutes(props.children);
this.router = enroute(this.routes);
}
addRoute(element, parent) {
const { component, path, children } = element.props;
invariant(component, `Route ${path} is missing the "path" property`);
invariant(typeof path === 'string', `Route ${path} is not a string`);
const render = (params, renderProps) => {
const finalProps = Object.assign({ params }, this.props,
renderProps);
const children = React.createElement(component, finalProps);
return parent ? parent.render(params, { children }) : children;
};
const route = this.normalizeRoute(path, parent);
if (children) {
this.addRoutes(children, { route, render });
}
this.routes[this.cleanPath(route)] = render;
}
addRoutes(routes, parent) {
React.Children.forEach(routes, route => this.addRoute(route,
parent));
}
cleanPath(path) {
return path.replace(/\/\//g, '/');
}
normalizeRoute(path, parent) {
if (path[0] === '/') {
return path;
}
if (!parent) {
return path;
}
return `${parent.route}/${path}`;
}
render() {
const { location } = this.props;
invariant(location, '<Router/> needs a location to work');
return this.router(location);
}
}
7.3. 摘要
在本章中,你开始将你的 React 应用程序从带有一些组件的简单页面转变为一个更健壮的应用程序,该应用程序处理路由和路由配置。我们覆盖了很多内容,并探讨了组件的高级用法,从头开始构建整个路由器:
-
在现代客户端应用程序中,路由不需要你执行完整的页面刷新。相反,它可以由像 React 这样的客户端应用程序处理。这可以减少浏览器加载时间,也可能减少服务器负载。
-
React 没有像某些框架那样内置的路由库。相反,你可以自由地从社区中选择一个,或者从头开始构建自己的路由(就像你做的那样!)。
-
React 为你提供了几个与不透明的
children数据结构一起工作的实用工具。你可以遍历多个组件,检查它们的数量,等等。 -
你可以使用你创建的路由设置动态地更改组件内部渲染的子组件。你正在监听浏览器位置的变化,并使用这些数据来渲染。
在下一章中,你将使用你的 Router 并使用 Firebase 为你的应用程序添加身份验证。
第八章. 更多路由和集成 Firebase
本章涵盖
-
使用你在 第七章 中构建的路由器
-
创建与路由相关的组件,如 Router、Route 和 Link
-
使用 HTML5 历史 API 来启用 push-state 路由
-
重新使用组件
-
集成用户身份验证和 Firebase
在上一章中,你从头开始构建了一个简单的路由器,以便更好地理解如何在 React 应用程序中实现路由。在这一章中,你将开始使用你构建的路由器,并将 Letters Social 应用程序拆分成更好的部分。到本章结束时,你将能够导航你的应用程序,查看单个帖子页面,并执行用户身份验证。
我如何获取本章的代码?
与每一章一样,您可以通过访问 GitHub 仓库github.com/react-in-action/letters-social来查看本章的源代码。如果您想从一张白纸开始学习本章内容并跟随操作,可以使用您从第五章和第六章(如果您跟随并自己构建了示例)中现有的代码,或者查看特定章节的分支(chapter-7-8)。
记住,每个分支都对应着章节末尾的代码(例如,分支 chapter-7-8 对应着本章末尾的代码)。您可以在您选择的目录中执行以下终端命令之一来获取当前章节的代码。
如果您根本没有任何仓库,请输入以下内容:
git clone git@github.com:react-in-action/letters-social.git
如果您已经克隆了仓库:
git checkout chapter-7-8
您可能是从其他章节来到这里的,所以确保您已经安装了所有正确的依赖项总是一个好主意:
npm install
8.1. 使用路由器
在上一章中,您使用 React 构建了一个工作路由器。在您在生产环境中开发 React 应用的情况下,您可能会选择像 React Router 这样的工具。幸运的是,React Router 遵循一个非常相似的 API,但它还提供了更多高级功能,让您能够进行更复杂的路由操作。不过,也许您并不需要所有这些功能,您构建的类似工具已经足够了。这完全没问题——选择最适合您解决问题的工具,而不是那些 GitHub 星标最多或 Hacker News 投票最高的工具。随着我们在第十二章中处理服务器端渲染,您的需求将会变化,因此我们将在这章中切换到 React Router。
让我们开始使用您的新鲜路由器。首先,您需要将路由器连接到 HTML5 历史 API(developer.mozilla.org/en-US/docs/Web/API/History),以便利用无需完整页面重新加载的导航。您将使用push state导航,因为您不需要每次都击中服务器进行完整页面的刷新。但您也可以使用基于 hash 的路由(更多内容请参阅github.com/ReactTraining/react-router/blob/v3/docs/guides/Histories.md)。
我们不会花太多时间探索 HTML5 API,因为它们值得单独处理。您将使用在 npm 上可用的知名history库,网址为www.npmjs.com/package/history。这个库将让您以可靠和可预测的方式跨浏览器使用 History API。为了确保它已安装,请运行npm install --save history。一旦安装,您需要修改 index.js 文件,该文件目前是整个应用程序的根。到目前为止,该文件是 React DOM 将您的整个应用程序渲染到 DOM 元素的地方。但是您启用了路由,并且您的 Router 组件期望一个位置(见第七章)。您需要找到一种方法来提供该位置,并利用history库的 HTML5 History API,而 index.js 是做这件事的完美地方。
比较客户端和服务器端路由
请花点时间考虑客户端路由和基于客户端-服务器 URL 的路由之间的区别。客户端路由和服务器端路由之间主要区别之一是什么?
除了利用history之外,您还需要设置您的路由。为此,您需要重构一些组件,这将使您感受到 React 中可组合性和模块化的好处。您将移动一些内容,但不需要从根本上改变组件的工作方式。让我们首先看看如何修复 App 组件。它需要作为一个容器来容纳子路由,因为您希望每个页面都有相同的侧边栏和导航栏,只有传递给children属性的内容会改变。图 8.1 展示了这种外观的示例。
图 8.1. 上面的截图中的方框区域将根据您根据 URL 决定渲染的视图而变化。随着时间的推移,您甚至可以进行更多嵌套并扩展该区域以包括侧边栏,这样您就可以在页面之间保持相同的导航栏,并拥有具有动态区域的其它路由。图 8.1 展示了这种外观的示例。

要实现这种嵌套,您需要重构 App 组件以动态显示children,如列表 8.1 所示。幸运的是,您不会删除我们完成的大部分工作——您只需移动它。在重构过程中,您将对您的应用程序文件进行一些重组。在src中创建一个名为pages的新目录。您将在这里放置那些通常只包含其他组件并提供数据的组件。当我们在后面的章节中开始探索 React 应用程序架构时,我会更多地讨论这个想法。
列表 8.1. 重构 App 组件(src/app.js)
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import ErrorMessage from './components/error/Error';
import Nav from './components/nav/navbar';
import Loader from './components/Loader';
class App extends Component {
constructor(props) {
super(props);
this.state = {
error: null,
loading: false
};
}
static propTypes = {
children: PropTypes.node
};
componentDidCatch(err, info) { *1*
console.error(err);
console.error(info);
this.setState(() => ({
error: err
}));
}
render() {
if (this.state.error) { *2*
return (
<div className="app">
<ErrorMessage error={this.state.error} />
</div>
);
}
return (
<div className="app">
<Nav user={this.props.user} /> *3*
{this.state.loading ? ( *4*
<div className="loading">
<Loader />
</div>
) : (
this.props.children *5*
)}
</div>
);
}
}
export default App;
-
1 使用 componentDidCatch 设置顶级错误边界,以便在出现错误时显示错误
-
2 如果有错误,则渲染错误。
-
3 传递用户属性——当你集成 Firebase 时将使用它。
-
4 如果应用处于加载状态,则渲染加载器
-
5 使用 props.children 输出当前激活的路由。
你需要创建一个主页面组件,以便用户可以看到帖子。创建一个名为 home.js 的文件,并将其放置在 pages 目录中。这个组件看起来应该很熟悉——它是你在将内容拆分为页面之前的主要组件。列表 8.2 显示了带有之前实现的方法逻辑的注释的 Home 组件。记住,就像所有章节一样,如果你想查看应用程序如何变化或章节末尾的样子,你可以查看每个章节的不同分支,可以在 github.com/react-in-action/letters-social 上查看。
列表 8.2. 重新构建的 Home 组件(src/pages/Home.js)
import React, { Component } from 'react';
import parseLinkHeader from 'parse-link-header';
import orderBy from 'lodash/orderBy';
import * as API from '../shared/http'; *1*
import Ad from '../components/ad/Ad'; *1*
import CreatePost from '../components/post/Create'; *1*
import Post from '../components/post/Post';
import Welcome from '../components/welcome/Welcome';
export class Home extends Component {
constructor(props) {
super(props);
this.state = { *2*
posts: [],
error: null,
endpoint: `${process.env
.ENDPOINT}/posts?_page=1&_sort=date&_order=DESC&_embed=comments&_expand=
user&_embed=likes`
};
this.getPosts = this.getPosts.bind(this);
this.createNewPost = this.createNewPost.bind(this);
}
componentDidMount() { *2*
this.getPosts();
}
getPosts() {
API.fetchPosts(this.state.endpoint)
.then(res => {
return res.json().then(posts => {
const links = parseLinkHeader(res.headers.get('Link'));
this.setState(() => ({
posts: orderBy(this.state.posts.concat(posts),
'date', 'desc'),
endpoint: links.next.url,
}));
});
})
.catch(err => {
this.setState(() => ({ error: err }));
});
}
createNewPost(post) {
post.userId = this.props.user.id;
return API.createPost(post)
.then(res => res.json())
.then(newPost => {
this.setState(prevState => {
return {
posts: orderBy(prevState.posts.concat(newPost),
'date', 'desc')
};
});
})
.catch(err => {
this.setState(() => ({ error: err }));
});
}
render() { *3*
return (
<div className="home">
<Welcome />
<div>
<CreatePost onSubmit={this.createNewPost} />
{this.state.posts.length && (
<div className="posts">
{this.state.posts.map(({ id }) => {
return <Post id={id} key={id}
user={this.props.user} />;
})}
</div>
)}
<button className="block" onClick={this.getPosts}>
Load more posts
</button>
</div>
<div>
<Ad url="https://ifelse.io/book"
imageUrl="/static/assets/ads/ria.png" />
<Ad url="https://ifelse.io/book"
imageUrl="/static/assets/ads/orly.jpg" />
</div>
</div>
);
}
}
export default Home;
-
1 不要忘记调整导入路径——组件位于不同的目录。
-
2 对于这些,逻辑完全相同——你只是在移动组件以适应新的层次结构。
-
3 对于这些,逻辑完全相同——你只是在移动组件以适应新的层次结构。
现在你已经将 Home 组件移动到位,你就可以配置你的路由并将 history 工具连接起来,以便你的 Router 能够响应用户浏览器位置的变化。通常,将单个模块作为实用工具提供给应用程序的其他部分,以便你不必重复工作是有帮助的。你将在本书的后面部分做更多这样的操作,你可能也已经自己这样做过了。你将使用 history 库来做这件事,如以下列表所示,因为你最终想用它(以及其他事情)来创建与你的 Router 一起工作的链接,而无需是正常的 <a href=""></> 标签。
列表 8.3. 设置历史库(src/history/history.js)
import createHistory from 'history/createBrowserHistory';
const history = createHistory(); *1*
const navigate = to => history.push(to); *2*
export { history, navigate }; *2*
-
1 创建历史库的单个实例以供你的应用程序使用。
-
2 导出 navigate 方法和历史实例(以防你以后需要直接访问)。
现在你已经设置了 history,你可以设置 index.js 的其余部分并配置你的 Router。以下列表显示了如何进行操作。
列表 8.4. 为路由设置 index.js(src/index.js)
import React from 'react';
import { render } from 'react-dom'; *1*
import { App } from './pages/App'; *2*
import { Home } from './pages/Home'; *2*
import Router from './components/router/Router'; *2*
import Route from './components/router/Route'; *2*
import { history } from './history; *3*
import './shared/crash';
import './shared/service-worker';
import './shared/vendor';
import './styles/styles.scss';
export const renderApp = (state, callback = () => {}) => { *4*
render( *4*
<Router {...state}> *5*
<Route path="" component={App}> *6*
<Route path="/" component={Home} /> *6*
</Route> *6*
</Router>,
document.getElementById('app'), *7*
callback
);
};
let state = { *8*
location: window.location.pathname, *8*
}; *8*
history.listen(location => { *9*
state = Object.assign({}, state, {
location: location.pathname
});
renderApp(state);
});
renderApp(state); *10*
-
1 导入 React DOM。
-
2 导入 App、Home、Router 和 Route 组件。
-
3 导入你刚刚创建的历史实用工具
-
4 创建一个你将调用的函数来渲染你的应用;包装 React DOM 的 render 方法,以便你可以传递位置数据和回调函数。
-
5 使用 JSX 扩展运算符将位置状态作为属性传递给你的 Router
-
6 为 App 和 Home 组件创建路由
-
7 将应用渲染到 index.html 中的目标 DOM 元素
-
8 创建一个状态对象来跟踪位置和用户
-
9 当位置变化时触发,并更新 Router,使应用程序使用新的状态数据重新渲染
-
10 渲染应用。
8.1.1. 创建帖子页面
你正在设置路由!在这个阶段,你已经做了很多工作来启用并使你的应用中的路由工作。但你还没有做任何事情来让用户能够在你的应用的不同部分之间移动。在这个阶段,你的应用可能会开始有更多的页面和页面子部分。如果你正在构建一个更复杂的社会化网络应用的版本,你可能会有个人资料页面、用户设置、消息等部分。但在这个案例中,你所需要做的只是显示单个帖子。你打算怎么做呢?你会从 URL 开始。还记得到目前为止在示例中多次使用的/posts/:postID路由吗?你的帖子页面将位于这个 URL。
你将从创建一个用于单个帖子的页面组件开始。在前面章节中,你构建了一个 Post 组件,它在加载后会获取其数据,因此创建这个单个帖子页面不应该有太多麻烦。你想要为这个页面创建一个新的组件,确保帖子被包含在内,并确保你正确地将它映射到路由。将会有所不同的一点是,你将从哪里获取帖子 ID。而不是从服务器进行初始获取,你将从 URL 中拉取它。你使用了一种特殊的语法来设置 URL,并且路由将使参数化路由数据对组件可用。以下列表显示了如何设置单个帖子页面。
列表 8.5. 创建 SinglePost 组件(src/pages/Post.js)
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Ad from '../components/ad/Ad';
import Post from '../components/post/Post';
export class SinglePost extends Component {
static propTypes = {
params: PropTypes.shape({
postId: PropTypes.string.isRequired *1*
})
};
render() {
return (
<div className="single-post">
<Post id={this.props.params.postId} /> *2*
<Ad
url="https://www.manning.com/books/react-in-action"
imageUrl="/static/assets/ads/ria.png"
/>
</div>
);
}
}
export default SinglePost;
-
1 导入在前面章节中创建的 Post 组件
-
2 从路由传递的 props 中获取帖子 ID
现在你有一个组件可以使用,你可以将其集成回路由中,这样用户就可以导航到单个帖子。列表 8.6 显示了如何将 Single-Post 组件添加到你的路由中。注意,你正在利用我们迄今为止在路由示例中看到的参数化路由。路径中的:post部分是作为params属性提供给你的组件的。
列表 8.6. 将单个帖子添加到路由中(src/index.js)
import React from 'react';
import { render } from 'react-dom';
import * as API from './shared/http';
import { history } from './history';
import Route from './components/router/Route';
import Router from './components/router/Router';
import App from './app';
import Home from './pages/home';
import SinglePost from './pages/post'; *1*
//...
export const renderApp = (state, callback = () => {}) => {
render(
<Router {...state}>
<Route path="" component={App}>
<Route path="/" component={Home} />
<Route path="/posts/:postId" component={SinglePost} /> *2*
</Route>
</Router>,
document.getElementById('app'),
callback
);
};
//...
-
1 为你的路由导入 SinglePost 组件
-
2 使用特殊的参数化路由语法(:post)配置 SinglePost 路由
8.1.2. 创建一个组件
如果你以开发模式运行你的应用并尝试点击,你会注意到尽管你仍然为单个帖子设置了路由,但如果你不知道帖子的 ID,你无法到达那里,然后将它放入 URL 中。这并不是很有用,对吧?
你需要创建一个自定义的链接组件,使其与你的history工具和路由器一起工作——否则,用户可能会很快放弃你的应用,你的投资者也会感到难过。你该如何实现这一点?普通的锚点标签(<a href="/">链接!</a>)是不够的,因为它会尝试重新加载整个页面,而这不是你想要的。你也可能想要从根本不是锚点标签的东西中创建链接,比如列表中的帖子或你不想用锚点标签包裹的东西。
注意
可访问性是指界面可被某人使用的程度。你可能之前听说过人们谈论“网络可访问性”,但你可能对此了解不多。没关系——这很容易学习。你想要确保你的应用尽可能多的人可以使用,无论他们是用鼠标和键盘、屏幕阅读器还是其他设备。我刚刚提到了使用链接组件使应用中的任意元素可导航——在从可访问性的角度考虑事情时,这应该谨慎进行。考虑到这一点,我想简要地提及一下这本书的可访问性。因为构建可访问的 Web 应用是一个庞大且重要的主题,它超出了这本书的范围。有些公司、应用和爱好项目将其视为工程的第一等维度。尽管你可以将 Letters Social 的源代码作为使用 React 组件构建应用的多种方式的集合来参考,但我们并没有处理你应用中可能出现的所有不同的可访问性问题。想了解更多关于网络可访问性的信息,请查看 WAI-ARIA 创作实践(www.w3.org/WAI/PF/aria-practices)或 MDN 关于 ARIA 的文档(developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA)。Ari Rizzitano 还就这个主题准备了一场精彩的演讲,特别关注 React 中的可访问性,名为“构建可访问组件”(speakerdeck.com/arizzitano/building-accessible-components)。
你需要再次使用你的history实用工具,并将其集成到一个你可以用来在应用内部启用 push-state 链接的链接组件中。还记得你之前暴露的navigate函数吗?使用这个函数,你现在可以编程地告诉history库更改用户的位置。要将这个功能转换成一个组件,你将使用一些 React 实用工具将其他组件包裹在一个可点击的链接组件中。你将使用React.cloneElement来创建目标元素的副本,然后附加一个点击处理程序,该处理程序将执行导航。React.cloneElement的签名看起来像这样:
ReactElement cloneElement(
ReactElement element,
[object props],
[children ...]
)
它需要一个要克隆的元素、要合并到新元素中的 props 以及它应该拥有的任何 children。你将使用这个实用工具来克隆你想要转换为 Link 的组件。并且你需要确保 Link 组件只有一个子节点,所以你将回过头来使用本章早些时候的 React.Children.only 工具。所有这些工具加在一起,将让你能够将其他组件转换为 Link 组件,帮助用户在应用中导航。以下列表显示了如何创建 Link 组件。
列表 8.7. 创建 Link 组件(src/components/router/Link.js)
import { PropTypes, Children, Component, cloneElement } from 'react'; *1*
import { navigate } from '../../history *2*
class Link extends Component {
static propTypes = {
to: PropTypes.string.isRequired, *3*
children: PropTypes.node, *3*
}
render() {
const { to, children } = this.props; *4*
return cloneElement(Children.only(children), { *5*
onClick: () => navigate(to), *6*
});
}
}
import PropTypes from 'prop-types'; *7*
import { Children, cloneElement } from 'react'; *7*
import { navigate } from '../../history'; *8*
function Link({ to, children }) { *9*
return cloneElement(Children.only(children), { *10*
onClick: () => navigate(to) *11*
});
}
Link.propTypes = { *12*
to: PropTypes.string,
children: PropTypes.node
};
export default Link;
-
1 导入你需要的库
-
2 重复使用你一直在使用的工具
-
3 to 和 children props 将分别持有目标 URL 和你正在 Link-化的组件
-
4 克隆 Link 组件的子组件,仅包裹一个节点(它可以有子节点)
-
5 在 props 对象中传递一个点击处理函数,该函数将使用历史记录导航到 URL
-
6 定义 propTypes
-
7 导入你需要的库
-
8 重复使用你一直在使用的工具
-
9 to 和 children props 将分别持有目标 URL 和你正在 Link-化的组件
-
10 克隆 Link 组件的子组件,仅包裹一个节点(它可以有子节点)
-
11 在 props 对象中传递一个点击处理函数,该函数将使用历史记录导航到 URL
-
12 定义 propTypes
要集成 Link 组件,你可以将单个帖子包裹在可重用的 Post 组件中,并确保 Link 获得一个 to prop,这将使用户导航到正确的页面(参见之前的关于可访问性的说明)。你可以遵循相同的模式以类似的方式包裹其他组件,并将它们转换为 Link-化的组件。以下列表显示了如何集成 Link 组件。
列表 8.8. 集成 Link 组件(src/components/post/Post)
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import * as API from '../../shared/http';
import Content from './Content';
import Image from './Image';
import Link from './Link';
import PostActionSection from './PostActionSection';
import Comments from '../comment/Comments';
import DisplayMap from '../map/DisplayMap';
import UserHeader from '../post/UserHeader';
import RouterLink from '../router/Link'; *1*
export class Post extends Component {
//...
render() {
return this.state.post ? (
<div className="post">
<RouterLink to={`/posts/${this.state.post.id}`}> *2*
<span>
<UserHeader date={this.state.post.date}
user={this.state.post.user} />
<Content post={this.state.post} />
<Image post={this.state.post} />
<Link link={this.state.post.link} />
</span>
</RouterLink> *2*
{this.state.post.location && <DisplayMap
location={this.state.post.location} />}
<PostActionSection showComments={this.state.showComments} />
<Comments
comments={this.state.comments}
show={this.state.showComments}
post={this.state.post}
handleSubmit={this.createComment}
user={this.props.user}
/>
</div>
) : null;
}
}
export default Post;
-
1 导入 Link 组件;将其别名为 RouterLink 以避免与我们在帖子中使用的 Link 组件命名冲突
-
2 将你想要可链接的 Post 组件的部分包裹起来,并给它正确的 ID
通过这样,你已完全将 Router 集成到你的应用中。现在用户可以查看单个帖子,这对于分享和一次关注一个帖子来说非常好。你的投资者会对你的下一轮融资感到满意并兴奋。然而,你还没有完成。在下一节中,我们将讨论当你无法将 URL 与组件匹配时应该做什么。
添加更多链接
尝试在应用程序中找到可能成为良好链接候选者的其他区域,并使用链接组件将它们转换为链接。提示:用户在导航到单个帖子后如何返回主页?随着你继续前进,试着考虑用户在应用程序中移动时的用户体验。对他们来说什么是有意义的?你将哪些转换为链接?有没有将某些内容转换为链接,而这些内容原本不是锚点标签的情况?查看应用程序源代码中的单个帖子页面,以查看添加简单返回按钮的示例。
8.1.3. 创建组件
尝试在 Letters 应用程序中导航到/oops,看看会发生什么。什么都没有?是的,根据你的代码,应该是这样,但这不是你希望用户看到的结果。目前,你的 Router 组件不处理任何“未找到”或“通配符”路由。你希望对用户友好,并假设他们(或你)可能在某个时候犯错并尝试导航到应用程序中不存在的路由。为了解决这个问题,你将创建一个简单的 NotFound 组件,并在创建 Router 实例时进行配置。以下列表显示了如何创建 NotFound 组件。
列表 8.9. 创建 NotFound 组件(src/pages/404.js)
import React from 'react';
import Link from '../components/router/Link'; *1*
export const NotFound = () => { *2*
return (
<div className="not-found">
<h2>Not found :(</h2>
<Link to="/"> *3*
<button>go back home</button>
</Link>
</div>
);
};
export default NotFound;
-
1 导入你创建的链接组件,以便用户可以返回主页
-
2 不需要组件状态,因此创建一个无状态函数组件
-
3 使用链接组件让用户返回主页
现在 NotFound 组件已经存在,你需要将其集成到你的路由配置中。你可能想知道你将如何告诉路由器它应该将用户发送到 NotFound 组件。答案是,在配置路由时使用*字符。该字符表示“匹配任何内容”,如果你将其放在配置的末尾,任何未匹配到其他内容的路由都将被发送到那里。务必注意这里的顺序:如果你将通配符路由放置得太高,它将匹配任何内容,而不是你想要的方式。以下列表显示了如何为你的路由器配置更多路由。
列表 8.10. 将单个帖子添加到路由器(src/index.js)
//...
import NotFound from './pages/404 '; *1*
//...
export const renderApp = (state, callback = () => {}) => {
render(
<Router {...state}>
<Route path="" component={App}>
<Route path="/" component={Home} />
<Route path="/posts/:postId" component={SinglePost} />
<Route path="*" component={NotFound} /> *2*
</Route>
</Router>,
document.getElementById('app'),
callback
);
};
//...
-
1 导入 NotFound 组件。
-
2 为 NotFound 组件设置路由,使其作为通配符路由
8.2. 集成 Firebase
在你的路由器完全构建并运行之后,我们想要在本章中解决的一个新领域是:启用用户登录和身份验证。你将使用流行的易于使用的“后端即服务”平台 Firebase (firebase.google.com) 来完成这项工作。Firebase 提供的服务可以抽象或取代处理用户数据、身份验证和其他关注点的后端 API。就我们的目的而言,你可以将其视为后端 API 的即插即用替代品。
您不会用它来完全替换应用程序的后端(您仍在使用您的 API 服务器),但您将使用 Firebase 来处理用户登录和用户管理。要开始使用 Firebase,请访问 firebase.google.com,如果您还没有账户,请创建一个账户。一旦您注册,请转到 Firebase 控制台 console.firebase.google.com,并为 Letters Social 创建一个新项目。一旦完成,点击“将 Firebase 添加到您的 Web 应用”按钮以打开一个模态覆盖层。您将看到一些用于您应用程序的配置信息,您将在稍后使用这些信息。请参阅图 8.2。
图 8.2. Firebase 控制台。为您的 Letters Social 应用程序实例创建一个新项目。

一旦您创建了项目并有权访问您的项目配置值,您就可以开始操作了。Firebase SDK 已经与示例应用程序代码一起安装,因此您可以继续操作并创建一个名为 core.js 的新文件,位于 src 中的新后端目录内(src/backend/core.js)。列表 8.11 展示了您将如何使用应用程序配置值设置 core.js。我已经在源代码中包含了公共 Firebase API 密钥,以便您可以在没有账户的情况下运行应用程序,但如果您想用自己的替换它,您可以轻松地更改配置目录中的值。
列表 8.11. 配置 Firebase 后端(src/backend/core.js)
import firebase from 'firebase';
const config = {
apiKey: process.env.GOOGLE_API_KEY, *1*
authDomain: process.env.FIREBASE_AUTH_DOMAIN *1*
};
try {
firebase.initializeApp(config); *2*
} catch (e) {
console.error('Error initializing firebase — check your source code');
console.error(e);
}
export { firebase }; *3*
-
1 值由 Webpack 注入—如果您想包含自己的值,请在配置目录中更改值
-
2 使用您的凭据初始化 Firebase。
-
3 将配置好的 Firebase 实例导出以供其他地方使用
因为您将使用 Firebase 进行身份验证,所以您需要设置一些代码,以便您可以利用该功能。要开始,请选择用于身份验证的平台,如图 8.3 所示。链接。选择 GitHub、Facebook、Google 或 Twitter 将允许已经拥有这些账户的用户无需管理另一个用户名/登录组合即可登录。我建议选择 GitHub,因为您和大多数将看到您的应用程序的人可能都有 GitHub 账户,但您完全可以自由地设置一个或多个其他平台。为了简单起见,我将使用 GitHub 作为我们的示例。一旦您做出决定,点击提供者并按照说明设置平台。
图 8.3. 使用 Firebase 设置身份验证方法。导航到身份验证部分并选择任何社交提供者。然后按照您选择的社交验证器的说明操作,并确保 Firebase 有权访问正确的凭据以与您选择的平台进行身份验证。

一旦你为 Firebase 设置了所选平台,你还需要设置一些代码,以便你可以与 firebase 交互以执行用户登录。Firebase 内置了各种社交平台的身份验证工具。如前所述,我将使用 GitHub,但你也可以自由使用你自行设置的任何提供者或提供者。它们都遵循相同的模式(例如,创建提供者对象,设置作用域等)。你可以在 firebase.google.com/docs/auth/ 上找到有关 Firebase 提供的认证服务的更多信息。以下列表显示了在 src/backend/auth.js 中设置认证工具。
列表 8.12. 设置认证工具(src/backend/auth.js)
import { firebase } from './core'; *1*
const github = new firebase.auth.GithubAuthProvider(); *2*
github.addScope('user:email'); *2*
export function logUserOut() {
return firebase.auth().signOut(); *3*
}
export function loginWithGithub() {
return firebase.auth().signInWithPopup(github); *4*
}
export function getFirebaseUser() { *5*
return new Promise(resolve => firebase.auth().onAuthStateChanged(user =>
resolve(user)));
}
export function getFirebaseToken() { *6*
const currentUser = firebase.auth().currentUser;
if (!currentUser) {
return Promise.resolve(null);
}
return currentUser.getIdToken(true);
}
-
1 导入你最近配置的 Firebase 库
-
2 使用 Firebase 设置 GitHub 身份验证提供者
-
3 创建一个包装 Firebase 登出方法的函数
-
4 创建一个简单的 loginWith-Github 工具,它返回一个 Firebase 身份验证操作 Promise
-
5 创建一个包装方法以获取 Firebase 用户
-
6 你稍后会需要这个令牌,所以创建一个帮助你获取它的方法。
现在我们已经设置好了一切,可以创建一个新的组件来处理登录。创建一个名为 src/pages/Login.js 的新文件。在这里,我们将创建一个简单的组件,告诉用户如何登录到 Letter Social。以下列表显示了登录页面组件。
列表 8.13. 登录组件(src/pages/Login.js)
import React, { Component } from 'react';
import { history } from '../history'; *1*
import { loginWithGithub } from '../backend/auth'; *1*
import Welcome from '../components/welcome/Welcome'; *1*
export class Login extends Component {
constructor(props) {
super(props);
this.login = this.login.bind(this); *2*
}
login() { *2*
loginWithGithub().then(() => { *3*
history.push('/');
});
}
render() {
return (
<div className="login">
<div className="welcome-container">
<Welcome /> *4*
</div>
<div className="providers">
<button onClick={this.login}> *5*
<i className={`fa fa-github`} /> log in with Github
</button>
</div>
</div>
);
}
}
export default Login;
-
1 导入此组件所需的库
-
2 创建并绑定登录方法
-
3 使用你之前创建的包装方法使用 GitHub 登录
-
4 渲染欢迎组件(包含在源代码中)或你想要的任何其他组件
-
5 确保当用户点击登录按钮时调用登录方法
8.2.1. 确保用户已登录
你的最后一个任务是确保未认证的用户被重定向到登录页面。对于你当前的应用状态,用户是否登录几乎没有区别,因为他们只能看到与现实生活中无关的虚拟数据(他们只会高兴地看到所有随机的星球大战引言和头像)。但在生产环境中,很可能用户绝对需要只能在他们有账户并登录的情况下才能看到数据。这是几乎所有网络应用的基本要求,尽管我们不会在这里关注安全性,但我们确实需要确保用户只有在登录的情况下才能看到社交网络。
实现这一功能有不同方法。在更健壮和成熟的工具如 React Router 中,当导航到特定路由时,你可以执行钩子——你可以检查用户是否已登录并继续。这只是其中一种方法,你的 Router 组件中并没有设置 hooks 功能,但你可以在主文件(index.js)中添加一些逻辑来检查用户的存在并确定他们应该被路由到何处。你将在后面的章节中过渡到使用 React Router 和这些钩子。你还需要将登录组件添加到你的 Router 中。
Firebase 替代方案
在这本书中,我们使用 Firebase 作为“后端即服务”。这对于学习目的来说大大简化了事情,但并不一定是团队中处理事情的方式。不深入探讨,你认为在你的应用程序中,什么会取代 Firebase?
当用户登录时,你想要确保他们也通过你的 API 被记录下来。我们正在使用 Firebase 进行身份验证,但你仍然想要存储用户信息,以便他们可以创建帖子、发表评论,并可以点赞(你将在后面的章节中添加评论和点赞功能)。你需要考虑用户是否存在,如果他们不存在,就在你的系统中创建一个用户。你将要构建的认证逻辑将考虑所有这些因素。我们还将稍微修改浏览器历史监听器函数,以便根据用户是否登录来重定向用户。
以下列表显示了如何在主索引文件(src/index.js)中添加此逻辑并修改历史监听器。
列表 8.14. 将登录容器添加到路由器(src/index.js)
export const renderApp = (state, callback = () => {}) => {
render(
<Router {...state}>
<Route path="" component={App}>
<Route path="/" component={Home} />
<Route path="/posts/:postId" component={SinglePost} />
<Route path="/login" component={Login} /> *1*
<Route path="*" component={NotFound} />
</Route>
</Router>,
document.getElementById('app'),
callback
);
};
let state = {
location: window.location.pathname,
user: { *2*
authenticated: false,
profilePicture: null,
id: null,
name: null,
token: null
}
};
renderApp(state);
history.listen(location => {
const user = firebase.auth().currentUser; *3*
state = Object.assign({}, state, {
location: user ? location.pathname : '/login' *3*
});
renderApp(state);
});
firebase.auth().onAuthStateChanged(async user => { *4*
if (!user) { *4*
state = { *5*
location: state.location,
user: {
authenticated: false
}
};
return renderApp(state, () => { *5*
history.push('/login');
});
}
const token = await getFirebaseToken(); *6*
const res = await API.loadUser(user.uid); *7*
let renderUser; *8*
if (res.status === 404) { *9*
const userPayload = { *10*
name: user.displayName,
profilePicture: user.photoURL,
id: user.uid
};
renderUser = await API.createUser(userPayload).then(res => res.json());*11*
} else {
renderUser = await res.json(); *12*
}
history.push('/'); *13*
state = Object.assign({}, state, { *14*
user: {
name: renderUser.name,
id: renderUser.id,
profilePicture: renderUser.profilePicture,
authenticated: true
},
token
});
renderApp(state); *15*
});
-
1 将登录页面添加到你的路由中
-
2 跟踪用户并相应地更新你创建的状态对象
-
3 在你的历史监听器中,首先检查是否有 Firebase 用户
-
4 使用异步函数响应 Firebase 用户状态变化
-
5 如果没有用户,更新状态并适当地渲染应用
-
6 如果有用户,使用 await 和 Firebase 工具获取他们的令牌
-
7 尝试从我们的 API 加载用户
-
8 声明一个用户变量进行分配
-
9 如果没有用户,你需要注册他们
-
10 创建你的 API 能理解的用户负载
-
11 向 API 发送请求并使用响应
-
12 如果用户已存在,使用他们渲染应用
-
13 将用户推送到主页
-
14 更新应用状态
-
15 使用新状态渲染应用
现在用户可以登录,并且可以即时为他们创建账户。你应该更新导航栏,让他们知道如何做,并且他们也可以看到注销选项。你可能记得,在本章的早期阶段,你甚至还没有存在时,就已经向 Navbar 组件传递了一个 user prop。现在它存在了,Navbar 组件可以根据他们的认证状态有条件地显示不同的视图。以下列表显示了如何对 Navbar 组件进行这些更改。
列表 8.15. 使用(src/components/nav/navbar.js)更新 Navbar 组件
import React from 'react';
import PropTypes from 'prop-types';
import Link from '../router/Link';
import Logo from './logo';
import { logUserOut } from '../../backend/auth';
export const Navigation = ({ user }) => (
<nav className="navbar">
<Logo />
{user.authenticated ? ( *1*
<span className="user-nav-widget">
<span>{user.name}</span> *1*
<img width={40} className="img-circle"
src={user.profilePicture} alt={user.name} /> *1*
<span onClick={() => logUserOut()}> *2*
<i className="fa fa-sign-out" />
</span>
</span>
) : (
<Link to="/login"> *3*
<button type="button">Log in or sign up</button>
</Link>
)}
</nav>
);
Navigation.propTypes = {
user: PropTypes.shape({ *4*
name: PropTypes.string,
authenticated: PropTypes.bool,
profilePicture: PropTypes.string
}).isRequired
};
export default Navigation; *5*
-
1 如果用户已认证,显示他们的个人资料信息(姓名、个人照片)
-
2 提供用户注销选项(使用我们之前创建的 Firebase 工具)
-
3 如果他们未登录,显示一个有用的链接。
-
4 声明 prop 类型
-
5 导出组件以供使用
8.3. 摘要
在本章中,你开始使用你构建的 Router 组件,向你的应用程序添加了一些更多与路由相关的组件,进行了一些重构,并使用 Firebase 添加了用户认证。以下是一些需要记住的事情:
-
Firebase 是一个“后端即服务”工具,它允许你验证用户、存储数据等。它可以在不进行任何后端开发的情况下让你走得很远,并且对于许多业余项目来说是一个很好的起点。
-
你可以将浏览器历史 API 与你的路由器集成。这也使你能够创建不需要完整页面重新加载的 Link 组件,而不是常规的锚点标签。
-
Firebase 可以为你处理认证和用户会话数据。当我们在后续章节中探讨 Flux、Redux 以及在服务器端渲染中使用 Firebase 时,我们将探索处理此类变化状态的高级方法。
测试是开发良好软件的一个极其重要的部分。在下一章中,我们将探讨如何使用 Jest 和 Enzyme 测试你的 React 组件。
第九章. 测试 React 组件
本章涵盖
-
测试前端应用程序
-
设置 React 的测试
-
测试 React 组件
-
设置测试覆盖率
在上一章中,你向你的应用程序添加了一些重要的功能。现在它有了路由和用户状态,并且你将其分解成更小的部分。你甚至添加了一些基本的认证,以便用户可以使用他们的 GitHub 个人资料登录。尽管你的应用程序可能不会让 Facebook 或 Twitter 的人担心,但它开始看起来更健壮了。你可以用 React 做的事情比我们最初开始时多得多。但因为我们专注于学习基础知识,所以我们省略了开发过程中的一个重要部分:测试。
我没有从一开始就介绍测试,以避免你在学习 React 和测试基础的同时承受心理负担。但这并不意味着它是学习或 Web 开发中不重要的一部分。在本章中,我们将专注于测试,因为它高质量软件开发解决方案的基本组成部分。然而,我们不会为每个组件演示测试,而是通过一个代表性样本,让你理解正在工作的基本原则,并能够编写自己的测试。
到本章结束时,你将了解一些测试 Web 应用程序的基本原则。你还将设置测试和测试运行器,与 Jest、Enzyme 和 React 测试渲染器一起工作,并学会使用和理解测试覆盖率工具。你将准备好开始测试你的应用程序,这将为你 React 开发技能增加另一个信心层次。
我如何获取本章的代码?
与每一章一样,你可以通过访问 GitHub 仓库github.com/react-in-action/letters-social来检查本章的源代码。如果你想从一张白纸开始学习本章,并跟随操作,你可以使用你现有的代码从第七章和第八章(如果你跟随并自己构建了示例)或检出特定章节的分支(chapter-9)。
记住,每个分支都对应于章节末尾的代码(例如,分支 chapter-7 对应于本章末尾的代码)。你可以在你选择的目录中执行以下终端命令之一来获取当前章节的代码。
如果你根本就没有仓库,请输入以下内容:
git clone git@github.com:react-in-action/letters-social.git
如果你已经克隆了仓库:
git checkout chapter-9
你可能已经从另一章来到这里,所以始终确保你已经安装了所有正确的依赖项:
npm install
软件开发中的测试是验证假设的过程。例如,假设你正在构建一个应用程序(如 Medium、Ghost 或 WordPress),允许用户撰写和创建博客文章。用户支付月费,并获得托管和运行自己博客的工具。在创建应用程序的前端时,它必须完成几个关键任务(以及其他任务),包括正确显示这些文章并允许用户编辑它们。
你如何确保你的应用程序正在做它需要做的事情?你可以亲自尝试并看看它是否工作。四处点击,编辑内容,尽可能多地以你想到的方式使用应用程序。这种手动过程效果相当不错,是防止错误和回归的第一道防线。你应该始终注意检查你正在工作的内容,但你无法快速或以完全一致的方式测试事物。
此外,随着你的应用程序增长,你需要手动测试的情况和功能数量将以惊人的速度增加。我参与过拥有数千个测试用例的应用程序,但还有很多应用程序,其测试用例数量会被轻易超越。在撰写本文时,React 库本身就有 4,855 个测试用例。想要测试 React 的人几乎不可能手动验证所有这些测试用例中涉及的假设。
幸运的是,你不必手动测试一切,你可以使用软件来测试软件。计算机在至少两个重要领域胜过我们:速度和一致性。我们可以使用软件以我们无法手动完成的方式测试我们的代码,即使有成千上万的人以各种可能的方式尝试。你可能认为,“我的项目很小,非常直接——不太可能出错。”但即使你的编码技能再高,错误是不可避免的。当你改变某些内容时(有时甚至没有改变),你的应用程序会崩溃并以不可预测的方式运行。
但我们不必对错误的不可避免性感到绝望,我们可以接受它们会发生,并采取措施来最小化它们的影响和频率。这就是测试的用武之地。你可能对测试有一些大致的了解,但为了开始,我们需要探索一些不同的测试类型。请记住,测试的世界非常庞大,我无法在这里涵盖所有内容。我不会深入探讨测试作为一个领域。我也不会深入探讨几种测试类型,包括集成测试、回归测试、测试自动化等。但到本章结束时,你应该足够熟悉,可以以几种不同的方式开始测试 React 组件。
9.1. 测试类型
正如我所说,测试软件是使用软件来验证你的假设的过程。因为你使用软件来测试软件,你最终会使用在构建软件时使用的相同基本元素:布尔值、数字、字符串、函数、对象等。重要的是要记住,这里没有魔法——只是更多的代码。
测试有多种类型,你将使用其中几种来测试你的 React 应用程序。它们涵盖了应用程序的不同方面,当共同使用并保持适当的比例时,它们应该能给你对应用程序的信心带来显著提升。不同类型的测试针对应用程序的不同部分和范围。一个经过良好测试的应用程序将测试构成应用程序基本部分的各个功能单元。它还将测试这些功能单元的集合,在最高层次上,测试所有事物汇聚的点(例如用户界面)。
这里有一些测试类型:
-
单元— 单元测试关注功能单元。例如,假设你有一个从服务器获取新帖子的实用方法。单元测试将只关注那个函数。它不关心其他任何事情。像组件一样,这些测试允许重构并促进模块化。
-
服务— 服务测试关注功能集合。这部分“测试光谱”可以包括各种粒度和焦点。然而,重点是你在测试的不是最高级别(参见下一节的集成测试)或功能最低级别的东西。一个服务测试的例子可能是一个使用几个功能单元但本身不在集成测试级别的工具。
-
集成— 集成测试关注更高的测试级别:应用程序各个部分的集成。它们测试服务和较低级别功能如何结合在一起。通常,这些测试通过用户界面测试应用程序,而不是通过用户界面背后的单个代码。这些测试可能模拟点击、用户输入和其他驱动应用程序的交互。
你可能想知道这些测试在代码中会是什么样子;我们很快就会讨论这个问题,但首先我们需要谈谈这些测试如何在整体测试方法中协同工作。如果你之前做过测试,你可能听说过测试金字塔。这个金字塔,如图图 9.1 所示,通常指的是你应该编写不同类型测试的比例。在本章中,你将只为你的组件编写单元测试。
图 9.1。测试金字塔是一种指导你在测试应用程序时编写多少以及哪些类型测试的方法。请注意,某些类型的测试需要更长的时间,因此在时间(以及因此财务成本)方面更为“昂贵”。

9.1.1。为什么要测试?
在某些软件开发范例中,测试是整个开发过程中的“一等公民”。这意味着测试非常重要,在开发过程的开始和整个过程中都被考虑,通常在确定何时认为某项工作已完成时发挥作用。诚然,共识是测试对软件开发来说是一件好事,但某些范例中,测试扮演了核心角色。例如,你可能听说过测试驱动开发(TDD)。在实践 TDD 时,正如其名所示,编写软件的过程是由测试驱动的。在工作时,开发者通常会编写一个失败的测试(一个尚未满足断言的测试),编写足够的代码使其通过,重构任何重复的部分,然后继续下一个功能,重复这个过程。
虽然你不必是 TDD 的严格实践者才能编写出优秀的软件,但在继续前进之前,考虑一下测试的一些好处。如果你已经了解了测试的优势,请随意进入下一节,我们将开始学习在 React 中进行测试。但我想问一个重要的问题:我们为什么要进行测试?
首先,我们希望编写出能够正常工作的软件。现代软件有许多相互关联的部分,假设软件堆栈的每个部分都会始终可靠地工作是不明智的。事情会出错,与其假设它们会一直工作,不如假设它们会失败。我们可以通过测试我们的假设来尽我们的一份力,以减少我们的软件可能出错的方式。测试迫使你审视(或重新审视)你对软件的假设。你通过不同的案例来测试它,并确保它能够适当地处理所有这些案例。
其次,测试你的软件的过程往往有助于你编写更好的代码。通过编写测试的过程会促使你思考你的代码做了什么,尤其是如果你在编写代码之前就做这件事(如在 TDD 中)。尽管这远不如前者可取,你也可以事后编写测试,这比完全没有测试要好。通过测试的过程将帮助你更好地理解你编写的代码,并验证你和其他人对事物工作方式的假设。
第三,将测试集成到你的软件开发工作流程中意味着你可以更频繁地发布代码。你可能之前在技术行业的人士中听说过“频繁发布”这个词。这通常意味着软件的增量发布和频繁发布。在过去,公司倾向于在经过广泛的过程后仅发布软件,而且一年中只有几次(或者至少相对较少)。
今天,人们的想法已经改变,他们已经意识到增量迭代通常会使软件的结果更好:你可以更快地从用户和其他人那里获得反馈,更容易地进行实验,等等。对经过良好测试的应用程序的信心是这个过程的关键部分。使用持续集成(CI)或持续部署工具,如 Circle CI (circleci.com)、Travis CI (travis-ci.org)或其他工具,你可以将测试作为软件部署过程的一部分。其理念是这样的:如果测试通过,它就会被部署。这些工具通常在一个干净的环境中运行你的测试,如果测试通过,就会将代码发送到运行你的应用程序的任何系统。图 9.2 展示了 Letters Social 应用用于测试和部署的过程。
图 9.2. 社交信件部署流程。当我(或任何贡献于该仓库的人)推送代码时,会触发 CI 构建。CI 提供商(在本例中为 Circle)使用 Docker 容器快速且可靠地运行你的测试。如果测试通过,代码将被部署到运行你的代码所使用的任何服务。在我们的例子中,那就是 Now。

最后,测试在你回过头来重构代码或移动代码时也会帮助你。比如说,如果你的需求改变了,你需要移动一些组件。如果你保持了组件的模块化并且它们有良好的测试,移动它们应该很容易。当然,未测试的代码也可以移动,但当你测试代码时,你对系统其他部分是否损坏的把握比没有测试时要小得多。
关于软件测试的好处和理论还有更多要说的,但这超出了本书的范围。如果你想了解更多,我推荐阅读 Roy Osherove 的 《单元测试的艺术》(第二版)(Manning Publications,2013)和 Nat Pryce 及 Steve Freeman 的 《通过测试指导面向对象软件增长》(Addison-Wesley,2009)。
9.2. 使用 Jest、Enzyme 和 React-test-renderer 测试 React 组件
测试软件只是更多的软件,由与你的正常程序相同的原语和基本元素组成,尽管人们已经开发了特殊工具来帮助测试过程。你可以尝试创建运行所有测试所需的工具,但开源社区已经投入了大量的工作,开发出大量强大的工具——所以你会使用那些工具。
你需要几种类型的库来测试你的 React 应用程序:
-
测试运行器— 你需要一些东西来运行你的测试。大多数测试可以作为常规 JavaScript 文件执行,但你可能想利用测试运行器的一些附加功能,例如同时运行多个测试并以更优雅的方式报告错误或成功信息。对于本书,你将使用 Jest 来测试的大多数方面。Jest 是由 Facebook 的工程师开发的测试库。一些具有较少内置功能的流行替代品,你可能可以考虑包括 Mocha (
mochajs.org) 和 Jasmine (jasmine.github.io)。Jest 通常用于测试 React 应用程序,但也在为其他框架创建适配器。源代码包括一个设置文件(test/setup.js),它调用 React 的适配器。 -
测试替身— 在编写测试时,你希望尽可能避免将你的测试与你的基础设施中其他脆弱或不可预测的部分绑定;你依赖的其他工具应该被模拟—用“假”函数替换,该函数以预期的行为运行。这种方式进行测试可以促进对测试代码的关注和模块化,因为你的测试不会绑定到给定时间点的代码的确切结构。你将使用 Jest 进行模拟和测试替身,但还有其他库也做这件事,例如 Sinon (
sinonjs.org)。 -
断言库— 你可以使用 JavaScript 来对你的代码进行断言(例如,X 是否等于 Y?),但你需要考虑到很多边缘情况。开发者已经创建了解决方案,使编写关于你代码的断言变得更容易。Jest 内置了断言方法,所以你会依赖这些方法。
-
环境助手— 在需要运行在浏览器环境中的代码上运行测试,对你提出了一些略微不同的要求。浏览器环境是独特的,包括 DOM、用户事件和 Web 应用的正常部分。这些测试工具将帮助确保你可以成功模拟浏览器环境。你将使用 Enzyme 和 React 测试渲染器来帮助测试你的 React 组件。Enzyme 使测试 React 组件变得更容易。它提供了一个强大的 API,让你可以查询不同类型的组件和 HTML 元素,设置和获取组件的 props,检查和设置组件状态,等等。React 测试渲染器做类似的事情,也可以生成组件的快照。我们不会深入探讨 Enzyme 或 React 测试渲染器 API 的每个方面,但你可以自由探索更多内容,请参阅
airbnb.io/enzyme和www.npmjs.com/package/react-test-renderer。 -
框架特定库— 有专门为 React(或其他框架)制作的库,这些库使编写特定框架的测试变得更容易。这些抽象通常是为了帮助测试库或框架而开发的,并处理设置框架所需的所有内容。在 React 中,几乎一切都是“仅仅是 JavaScript”,所以即使在这些工具中,也几乎看不到什么“魔法”。
-
覆盖率工具— 由于代码的确定性,人们已经找到了确定你的代码哪些部分被测试“覆盖”的方法。这很好,因为你可以得到一个指标,作为确定你的代码测试得有多好的指南。它不能替代逻辑和基本分析(100%的代码覆盖率并不意味着你不能有错误),但它可以指导你如何测试代码。你将使用 Jest 内置的覆盖率工具,该工具利用了一个流行的工具 Istanbul (
github.com/gotwarlost/istanbul)。
接下来,你将开始安装你将用于测试的工具。如果你从 GitHub 克隆了本书的仓库,这些工具应该已经安装。确保在更换章节时再次运行 npm install,以确保你有该章节的所有库。
9.3. 编写你的第一个测试
一旦安装了你需要的工具,你就可以开始编写一些测试了。在本节中,你将设置运行测试的命令并开始测试一些基本的 React 组件。你将对组件进行断言并查看测试组件渲染输出的方法。
但在深入之前,我应该注意一些关于 Jest 以及你的测试代码将运行在哪里的事情。Jest 可以根据你编写的测试类型配置在不同的环境中运行。如果你正在编写在浏览器中运行的 React 应用程序的测试,你将希望告诉 Jest 这样做,以便它能够提供你需要的虚拟浏览器环境来正确模拟真实浏览器。Jest 使用另一个库 jsdom 来实现这一点。如果你正在编写针对 node.js 应用程序的测试,你不需要 jsdom 环境的额外内存和负担——你只想测试你的服务器端代码。Jest 默认配置为运行面向浏览器的测试,所以你不需要覆盖任何内容。
审查测试类型
有几种不同的测试类型。为了复习,尝试将类型与测试类型的描述相匹配。
-
单元
-
服务
-
积分
__ 复杂、通常脆弱的测试,编写和运行它们需要很长时间。它们测试不同系统在高级别上如何协同工作。这类测试通常比其他类型的测试少。
__ 较简单的测试,测试特定系统的工作方式,但不与其他系统交互。
__ 低级、专注于测试小块功能的测试。这些应该是套件中最多的测试。
9.3.1. 开始使用 Jest
要运行测试,如前所述,你将使用 Jest。你可以从命令行运行 Jest,它将执行你的测试,所以你需要在 package.json 文件中添加一个脚本来运行它。下一个列表显示了如何将自定义脚本添加到 package.json 中。如果你从 GitHub 克隆了仓库,这个脚本应该已经可用。
列表 9.1. 设置自定义 npm 脚本(package.json)
{
//...
"scripts": {
//...
"test": "jest --coverage", *1*
"test:w": "jest –watch --coverage", *2*
"jest": {
"testEnvironment": "jsdom",
"setupFiles": ["raf/polyfill", "./test/setup.js"] *3*
},
"repository": {
"type": "git",
"url": "git+ssh://git@github.com/react-in-action/letters-social.git"
},
"author": "Mark Thomas <hello@ifelse.io>",
// ...
-
1 运行测试并输出测试覆盖率。
-
2 在监视模式下运行测试。
-
3 配置 Jest;一些测试辅助工具和存根包含在示例代码中。
现在你已经有一个命令来运行你的测试(npm test),试试看。你还不应该得到任何有用的信息,因为还没有测试可以运行(Jest 应该在你的终端中相应地警告你)。你也可以运行 npm run test:w 来在监视模式下运行 Jest。当你不想每次都手动运行测试时,这很有帮助。Jest 的沉浸式监视模式使其特别有用——它将只运行与更改的文件相关的测试。如果你有一个大的测试套件并且不想每次都运行每个测试,这很有帮助。你还可以提供正则表达式模式或通过文本字符串搜索来运行特定的测试。
工具很重要
在评估库时,测试库甚至整个测试有时会被放在最后考虑。这至少有两个不幸的原因。首先,不可用的测试库可能会使团队更难接受测试他们的代码,这可能导致他们完全放弃测试。反过来,这通常会导致代码更难以维护、更不稳定,并且整体上更难以工作。
另一个缺点是,如果你或你的团队花费大量时间编写测试,你的工具可能会对你的时间产生重大影响。这可能会迅速转化为业务因工程师完成工作所需时间更长而损失的钱。我亲眼见证了这两种结果。如果测试从一开始就没有被视为首要任务,那么随着时间的推移,它变得越来越困难,并被视为“某天”的事情。结果是代码可能更难以有信心地进行更改,因为关于功能性的假设不再由测试支持。
另一个原因是将你的测试工具视为重要的事情是,如果你确实测试了你的代码,这将涉及大量的时间投资。如果你有不可靠的测试或测试设置需要很长时间才能运行,你可能会在每天的基础上失去大量时间。这个问题没有神奇的解决方案,但将你的测试工具和设置视为一等事项通常会从长远来看极大地帮助你。
9.3.2. 测试无状态功能组件
是时候开始编写一些测试了。首先,我们将关注一个相对简单的组件测试示例。你将测试内容组件。它并不做什么;它只是处理渲染包含内容的段落。下一个列表显示了组件的结构。
列表 9.2. 内容组件(src/components/post/Content.test.js)
import React, { PropTypes } from 'react';
const Content = (props) => { *1*
const { post } = props;
return (
<p className="content"> *2*
{post.content} *3*
</p>
);
};
Content.propTypes = {
post: PropTypes.object,
};
export default Content; *4*
-
1 组件接收帖子属性对象并使用帖子的内容属性来渲染段落元素
-
2 为段落分配内容类别
-
3 段落元素的内部内容是帖子内容
-
4 组件已导出——这很重要,因为你在测试中需要导入组件
当你开始编写测试时,你可以做的第一件事是考虑你想要验证哪些假设。也就是说,一旦所有测试通过,它们应该向你确认某些事情,并作为一种保证。事实上,我最喜欢测试的一点是,我依赖它们在我对某个特定功能或系统的一部分进行更改时失败。这证实了我的假设,即我所做的更改代表了应用程序或系统的更改。这使得我在编写代码时感到更加舒适,因为一方面,我有关于事情应该如何工作的记录,另一方面,我可以了解我的更改对应用程序整体的影响。
让我们来看看你的组件,并思考你可能会如何测试它。关于这个组件,你想要验证的一些假设。首先,它需要渲染作为 prop 传递的一些内容。它还需要将类名分配给一个段落元素。除此之外,这个组件没有太多需要你关注的地方。这些应该足以让你开始编写测试。
你可能会注意到,“React 运作正常”并不是你在这里试图测试的事情之一。我们还排除了诸如“函数可以被执行”、“JSX 编译器将正常工作”以及关于你所使用技术的某些其他基本假设。这些事情确实很重要,但你所编写的测试永远无法充分或准确地验证这些假设。这些其他项目负责编写自己的测试并确保它们能够正常工作。这强调了选择可靠、经过良好测试并保持更新的软件的重要性。如果你对 React 的可靠性有严重的怀疑,这些怀疑可能是没有根据的。尽管并非完美,但 React 被用于世界上一些最受欢迎的 Web 应用程序中,包括 Facebook.com 和 Netflix.com,仅举两个例子。当然,肯定存在一些错误,但你几乎不可能在我们的简单情况下遇到它们。
你对想要验证的组件了解一些情况,但如果你是从零开始并且首先编写了测试,你也可以采取另一种方式。你可能这样想过:“我们需要一个显示内容的组件,具有特定的类型和类名,以便我们的 CSS 能够正常工作。”然后你可能继续编写验证这些条件的测试。由于你学习 React 的方式,你采取了另一种方式,但你可以看到从测试开始如何使事情变得简单:你开始时必须思考并规划你的组件。如前所述,测试驱动开发(TDD)是一种思想流派,它将编写测试作为软件开发的核心部分。
让我们看看如何测试这个组件。为了做到这一点,你需要编写一个测试 套件,它是一组测试。单个测试会做出 断言(关于代码的陈述,可以是真或假),以验证假设。例如,对你的组件的测试会 断言 正确的类名已设置。如果你的任何断言失败,测试就会失败。这就是你知道你的应用中某些东西意外改变或不再工作的方式。列表 9.3 展示了如何设置测试的骨架。
注意,组件的文件以.test.js结尾。这是一个你可以选择遵循的约定。Jest 会寻找以.spec.js 或.test.js 结尾的文件,并默认运行这些测试。如果你选择遵循不同的约定,你需要明确告诉 Jest 你想要运行哪些文件,通过将它们添加到命令行调用中(例如,jest --watch ./my.cool.test.file.js)。你将遵循.test.js 约定来运行所有测试。
还要注意测试文件的位置。有些人选择将所有测试放在一个名为“mirror”的目录中,通常位于项目的根目录。对于每个要测试的文件,他们会在 test 目录中创建一个相应的文件。这是一种很好的组织方式,但你也可以将测试文件放在它们的源文件旁边。你将采用这种方法,但两种方式都完全可行。
列表 9.3. Content 组件的测试骨架(src/components/post/Content.test.js)
import React from 'react'; *1*
import { shallow } from 'enzyme'; *2*
import renderer from 'react-test-renderer'; *2*
import { Content } from './Content'; *3*
describe('<Content/>', () => { *4*
test('should render correctly', () => { *5*
});
});
-
1 导入 React。
-
2 导入相关的辅助方法
-
3 导入要测试的组件
-
4 Jest 使用类似 describe 的 Jasmine 风格(
jasmine.github.io/)方法来分组测试。 -
5 真实测试——jest 也全局提供了 it 函数
你可能已经注意到,到目前为止describe函数没有什么特别之处。它们主要用于组织和确保你可以将测试分割成适当的块来测试代码的不同部分。这可能看起来对于这样一个小的文件来说不是特别需要,但我处理过长达 2,000-3,000 行(或更多)的测试文件,我可以从经验中讲:可读的测试有助于编写好的测试。
编写干净的测试!
你是否曾经阅读过那些没有得到与被测试代码相同待遇的测试代码?这种情况发生在我身上不止一次。阅读不干净的测试代码可能会让人感到困惑,甚至沮丧。测试代码只是更多的代码,它们仍然需要保持整洁和可读性,对吧?在本章中,我已经提到测试有时可能会被编写应用程序代码的优先级所压倒。测试代码可能被视为一项必须完成的任务,甚至是你与应用程序代码之间的障碍,因此标准会降低。这种倾向很容易陷入,但现实是,编写糟糕的测试代码和编写糟糕的应用程序代码一样糟糕。测试应该成为你代码的另一种文档形式,而且这种文档开发者仍然需要阅读。记住,测试代码仍然应该是干净的代码。
Jest 会查找要测试的文件,然后执行这些不同的describe和it函数,调用你提供给它们的回调函数。但你需要将什么放入其中呢?你需要设置断言。为此,你需要一些可以断言的东西。这就是 Enzyme 发挥作用的地方;它允许你创建一个可测试的组件版本,你可以检查并对其做出断言。你将使用 Enzyme 的浅渲染,这将创建一个轻量级的组件版本,它不会执行完整的挂载或插入到 DOM 中。你还需要为组件提供一些模拟(虚假)数据。下面的列表显示了如何将组件的测试版本添加到你的测试套件中。在你开始编写测试之前,请确保在终端中运行npm run test:w命令以启动测试运行器。
列表 9.4. 浅渲染(src/components/post/Content.test.js)
import React from 'react';
import { shallow } from 'enzyme';
import renderer from 'react-test-renderer';
import { Content } from './Content';
describe('<Content/>', () => {
describe('render methods', () => {
it('should render correctly', () => {
const mockPost = { *1*
content: 'I am learning to test React components', *1*
}; *1*
const wrapper = shallow(<Content post={mockPost} />); *2*
});
});
});
-
1 创建一个组件可以使用的虚拟帖子对象
-
2 对组件进行浅渲染并保存返回的包装器以供以后使用
现在你已经设置了一个可以对其做出断言的测试组件。为此,你将使用 Jest 内置的expect()函数。如果你使用的是不同的断言库,你可能需要使用其他函数。记得之前提到的,这些断言库是为了让断言更容易。例如,检查一个对象是否深度相等(意味着在它的每个属性上都是相等的)可能是一个复杂的过程。在编写测试时,你不应该专注于实现大量新功能来编写测试,而应该专注于被测试的代码。断言辅助工具和开源库使得这一点更容易实现。
要测试当前组件,你想要做出我们之前提到的几个断言:类名、内部内容,和元素类型。你还将使用 React 测试渲染器创建快照测试。快照测试是 Jest 的一个特性,它允许你以独特的方式测试组件的渲染输出。快照测试与视觉回归测试密切相关,这是一个过程,其中应用程序的视觉输出可以进行比较和检查差异。
如果发现图像有差异,你知道你的测试失败了,需要调整,或者至少输出快照需要更新。与图像不同,Jest 将为测试创建 JSON 输出,并将它们存储在特别命名的目录中。这些应该与你的所有其他代码一起添加到版本控制中。以下列表显示了如何使用 Jest、Enzyme 和 React 测试渲染器来做出这些断言。
列表 9.5. 做出断言(src/components/post/Content.test.js)
import React from 'react';
import { shallow } from 'enzyme'; *1*
import renderer from 'react-test-renderer'; *1*
import Content from '../../../src/components/post/Content'; *2*
describe('<Content/>', () => { *3*
test('should render correctly', () => { *3*
const mockPost = { *4*
content: 'I am learning to test React components'
};
const wrapper = shallow(<Content post={mockPost} />); *5*
expect(wrapper.find('p').length).toBe(1);
expect(wrapper.find('p.content').length).toBe(1);
expect(wrapper.find('.content').text()).toBe(mockPost.content);
expect(wrapper.find('p').text()).toBe(mockPost.content);
});
test('snapshot', () => { *6*
const mockPost = {
content: 'I am learning to test React components'
};
const component = renderer.create(<Content post={mockPost} />); *6*
const tree = component.toJSON();
expect(tree).toMatchSnapshot(); *6*
});
});
-
1 导入酶和 react-test-renderer。
-
2 导入你想要测试的组件
-
3 使用 Jasmine 风格的 describe 函数来分组测试
-
4 创建模拟帖子
-
5 使用 Enzyme 的浅渲染方法来渲染组件
-
6 使用 Jest 和 react-test-renderer 创建快照测试
如果你的测试运行器正在运行,你应该在终端中看到 Jest 的通过结果。自从测试运行器推出以来,Jest 的命令行工具已经得到了极大的改进,你应该能够在终端中看到有关你的测试的重要信息。
9.3.3. 不使用 Enzyme 测试 CreatePost 组件
现在你已经有一个测试工作正常,你可以继续测试更复杂的组件。就大部分而言,测试 React 组件应该是直接的。如果你发现自己创建了一个具有大量功能并且相应地具有巨大测试的组件,你可能想要考虑将其拆分成几个组件(尽管这并不总是可能的)。
你接下来想要测试的组件,CreatePost 组件,比 Content 组件具有更多的功能,你的测试将需要处理这些新增的功能。列表 9.6 展示了 CreatePost 组件,以便你在编写测试之前可以查看它。CreatePost 组件由 Home 组件用来触发新帖子的提交。它渲染出一个textarea,当用户输入时它会更新,并且有一个按钮,当用户点击时,它会提交包含数据的表单。当用户点击时,它调用由父组件传递的回调函数。你可以测试所有这些假设,并确保一切按预期工作。
列表 9.6. CreatePost 组件(src/components/post/Create.js)
import PropTypes from 'prop-types';
import React from 'react';
import Filter from 'bad-words';
import classnames from 'classnames';
import DisplayMap from '../map/DisplayMap';
import LocationTypeAhead from '../map/LocationTypeAhead';
class CreatePost extends React.Component {
static propTypes = {
onSubmit: PropTypes.func.isRequired
};
constructor(props) {
super(props);
this.initialState = {
content: '',
valid: false,
showLocationPicker: false,
location: {
lat: 34.1535641,
lng: -118.1428115,
name: null
},
locationSelected: false
};
this.state = this.initialState;
this.filter = new Filter();
this.handlePostChange = this.handlePostChange.bind(this);
this.handleRemoveLocation = this.handleRemoveLocation.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleToggleLocation = this.handleToggleLocation.bind(this);
this.onLocationSelect = this.onLocationSelect.bind(this);
this.onLocationUpdate = this.onLocationUpdate.bind(this);
this.renderLocationControls = this.renderLocationControls.bind(this);
}
handlePostChange(event) {
const content = this.filter.clean(event.target.value);
this.setState(() => {
return {
content,
valid: content.length <= 300
};
});
}
handleRemoveLocation() {
this.setState(() => ({
locationSelected: false,
location: this.initialState.location
}));
}
handleSubmit(event) {
event.preventDefault();
if (!this.state.valid) {
return;
}
const newPost = {
content: this.state.content
};
if (this.state.locationSelected) {
newPost.location = this.state.location;
}
this.props.onSubmit(newPost);
this.setState(() => ({
content: '',
valid: false,
showLocationPicker: false,
location: this.defaultLocation,
locationSelected: false
}));
}
onLocationUpdate(location) {
this.setState(() => ({ location }));
}
onLocationSelect(location) {
this.setState(() => ({
location,
showLocationPicker: false,
locationSelected: true
}));
}
handleToggleLocation(event) {
event.preventDefault();
this.setState(state => ({ showLocationPicker:
!state.showLocationPicker }));
}
renderLocationControls() {
return (
<div className="controls">
<button onClick={this.handleSubmit}>Post</button>
{this.state.location && this.state.locationSelected ? (
<button onClick={this.handleRemoveLocation}
className="open location-indicator">
<i className="fa-location-arrow fa" />
<small>{this.state.location.name}</small>
</button>
) : (
<button onClick={this.handleToggleLocation}
className="open">
{this.state.showLocationPicker ? 'Cancel' : 'Add
location'}{' '}
<i
className={classnames(`fa`, {
'fa-map-o': !this.state.showLocationPicker,
'fa-times': this.state.showLocationPicker
})}
/>
</button>
)}
</div>
);
}
render() {
return (
<div className="create-post">
<textarea
value={this.state.content}
onChange={this.handlePostChange}
placeholder="What's on your mind?"
/>
{this.renderLocationControls()}
<div
className="location-picker"
style={{ display: this.state.showLocationPicker ? 'block'
: 'none' }}
>
{!this.state.locationSelected && (
<LocationTypeAhead
onLocationSelect={this.onLocationSelect}
onLocationUpdate={this.onLocationUpdate}
/>
)}
<DisplayMap
displayOnly={false}
location={this.state.location}
onLocationSelect={this.onLocationSelect}
onLocationUpdate={this.onLocationUpdate}
/>
</div>
</div>
);
}
}
export default CreatePost;
这比你在前几章中创建的组件稍微复杂一些。使用它,你可以创建帖子并为这些帖子添加位置。根据我的经验,测试更大和更复杂的组件进一步突出了清晰、可读的测试的重要性。如果你无法阅读或推理你的测试文件,未来的你或其他开发者将如何做到呢?
列表 9.7 展示了为 CreatePost 组件建议的测试框架。你的方法并不多,所以阅读测试不会很困难,但如果组件更复杂,你甚至可以添加嵌套的 describe 块来使推理更容易。列表 9.7 中的函数将由测试运行器(在本例中为 Jest)执行,在这些测试中,你可以进行断言。大多数测试都遵循这种类似的模式。你导入要测试的代码,模拟任何依赖项以隔离测试到单个功能单元(因此称为 单元测试),然后测试运行器和断言库将一起运行你的测试。
列表 9.7. 测试 CreatePost 组件(src/components/post/Create.test.js)
jest.mock('mapbox');
import React from 'react';
import renderer from 'react-test-renderer';
import CreatePost from '../../../src/components/post/Create';
describe('CreatePost', () => { *1*
test('snapshot', () => {
});
test('handlePostChange', () => { *2*
});
test('handleRemoveLocation', () => { *2*
});
test('handleSubmit', () => { *2*
});
test('onLocationUpdate', () => {
});
test('handleToggleLocation', () => {
});
test('onLocationSelect', () => {
});
test('renderLocationControls', () => {
});
});
-
1 在这里使用一个 describe 调用,但在更大的测试文件中可以有多个,甚至可以嵌套
-
2 为你的组件中的每个方法创建一个测试,包括一个快照以确保它正确渲染
如果你遵循一个一致的考虑每个需要测试的组件部分的模式,你将在开发和测试组件时更加全面。请随意遵循对你最有意义的结构——这只是对我以及我所在团队有帮助的一个例子。我还发现,在编写任何其他测试之前,先为组件或模块编写不同的 describe 和 test 块来编写测试是有帮助的。我发现,如果一次性做所有这些,我可以更容易地思考我想覆盖的情况(有错误、无错误、有条件等)。
关于其他类型的测试呢?
你可能想知道关于用户流程、跨浏览器测试以及其他我没有在这里涵盖的测试类型。这些其他类型的测试通常由专注于特定测试形式的工程师或工程团队关注。QA 团队和 SET(软件测试工程师)通常有一系列专门的工具,允许他们模拟你的应用程序,并模拟可能存在的所有复杂的流程。
这类测试(集成测试)可能涉及一个或多个不同系统的交互。如果你还记得图 9.1 中的测试金字塔,这些测试可能需要花费很多时间来编写,难以维护,并且往往成本高昂。当你想到“测试前端应用”时,你可能认为这些测试会涉及其中。但我们已经看到情况并非如此(非 QA 工程师编写的多数测试是单元测试或低级集成测试)。如果你对这类工具感兴趣并想了解更多,以下是一些你可以作为学习高级测试的跳板:
-
Selenium—www.seleniumhq.org
-
Puppeteer—
github.com/GoogleChrome/puppeteer -
Protractor—www.protractortest.org/#/
在设置好这个骨架之后,你可以开始测试 CreatePost 组件,从构造函数开始。记住,构造函数是初始状态设置、类方法绑定和其他设置发生的地方。为了测试 CreatePost 组件的这一部分,我们需要引入我之前提到的一个工具:Sinon。你需要一些测试函数,你可以将它们提供给组件使用,这些函数不依赖于其他模块。使用 Jest,你可以创建模拟函数来帮助你的测试专注于组件本身,并防止你将所有代码绑定在一起。记得我之前说过测试应该在代码更改时中断吗?这是真的,但更改一个测试也不应该破坏其他测试。就像常规代码一样,你的测试应该是解耦的,并且只关心它们正在测试的代码片段。
Jest 的模拟函数不仅帮助我们隔离代码,还帮助我们做出更多断言。你可以对组件如何使用mock函数做出断言,包括它是否被调用,调用时传递了什么参数,等等。以下列表展示了如何为你的组件设置快照测试,并使用 Jest 模拟一些组件所需的基本属性。
列表 9.8. 编写你的第一个测试(src/components/post/Create.test.js)
jest.mock('mapbox'); *1*
import React from 'react';
import renderer from 'react-test-renderer';
import CreatePost from '../../../src/components/post/Create';
describe('CreatePost', () => { *2*
test('snapshot', () => { *2*
const props = { onSubmit: jest.fn() }; *3*
const component = renderer.create(<CreatePost {...props} />); *4*
const tree = component.toJSON(); *5*
expect(tree).toMatchSnapshot(); *6*
});
//...
});
-
1 使用 jest.mock 函数告诉 Jest 在运行测试时使用模拟而不是模块
-
2 在你之前创建的外部 describe 块内创建测试块
-
3 创建模拟属性对象并使用 Jest 创建模拟函数
-
4 使用 React 测试渲染器创建你的组件并传递属性
-
5 调用 toJSON 方法生成快照
-
6 断言快照匹配
现在你已经完成了一次测试,你可以测试组件的其他方面。该组件主要负责允许用户创建帖子并将位置附加到它们上,因此你需要测试这些功能区域。你将从测试帖子创建开始。下面的列表显示了如何在你的组件中测试帖子创建方法。
列表 9.9. 测试帖子创建(src/components/post/Create.test.js)
jest.mock('mapbox');
import React from 'react';
import renderer from 'react-test-renderer';
import CreatePost from '../../../src/components/post/Create';
describe('CreatePost', () => {
test('snapshot', () => {
const props = { onSubmit: jest.fn() };
const component = renderer.create(<CreatePost {...props} />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
test('handlePostChange', () => {
const props = { onSubmit: jest.fn() }; *1*
const mockEvent = { target: { value: 'value' } }; *1*
CreatePost.prototype.setState = jest.fn(function(updater) { *2*
this.state = Object.assign(this.state, updater(this.state)); *2*
});
const component = new CreatePost(props); *3*
component.handlePostChange(mockEvent); *3*
expect(component.setState).toHaveBeenCalled(); *4*
expect(component.setState.mock.calls.length).toEqual(1); *4*
expect(component.state).toEqual({ *4*
valid: true,
content: mockEvent.target.value,
location: {
lat: 34.1535641,
lng: -118.1428115,
name: null
},
locationSelected: false,
showLocationPicker: false
});
});
test('handleSubmit', () => {
const props = { onSubmit: jest.fn() };
const mockEvent = { *5*
target: { value: 'value' }, *5*
preventDefault: jest.fn()
};
CreatePost.prototype.setState = jest.fn(function(updater) { *6*
this.state = Object.assign(this.state, updater(this.state));
});
const component = new CreatePost(props); *7*
component.setState(() => ({ *7*
valid: true,
content: 'cool stuff!'
}));
component.state = { *8*
valid: true,
content: 'content',
location: 'place',
locationSelected: true
};
component.handleSubmit(mockEvent); *9*
expect(component.setState).toHaveBeenCalled(); *9*
expect(props.onSubmit).toHaveBeenCalledWith({ *9*
content: 'content',
location: 'place'
});
});
});
-
1 创建一个模拟的 props 集以使用
-
2 模拟 setState 以确保你的组件调用它,并且更新帖子正确地更新了状态。
-
3 直接实例化组件并调用其方法
-
4 断言你的组件调用了正确的方法,并且该方法正确地更新了状态
-
5 创建另一个模拟事件来模拟你的组件将接收的事件
-
6 再次模拟 setState。
-
7 实例化另一个组件并将组件的状态设置为模拟用户输入帖子内容
-
8 直接修改组件的状态(用于测试目的)
-
9 使用你创建的模拟事件处理帖子提交并断言模拟被调用
最后,你想要测试组件的其余功能。除了让用户创建帖子外,CreatePost 组件还处理用户选择位置。其他组件通过作为 props 传递的回调处理更新位置,但你仍然需要测试与该功能相关的 CreatePost 组件的方法。
记住你已经在 CreatePost 上实现了一个子渲染方法,你使用它来简化读取 CreatePost 的render方法输出,并减少混乱。你可以用类似测试使用 Enzyme 或 React 测试渲染器组件的方式测试它。下面的列表显示了 CreatePost 组件的其余测试。
列表 9.10. 测试帖子创建(src/components/post/Create.test.js)
jest.mock('mapbox');
import React from 'react';
import renderer from 'react-test-renderer';
import CreatePost from '../../../src/components/post/Create';
describe('CreatePost', () => {
test('handleRemoveLocation', () => {
const props = { onSubmit: jest.fn() };
CreatePost.prototype.setState = jest.fn(function(updater) {
this.state = Object.assign(this.state, updater(this.state));
}); *1*
const component = new CreatePost(props);
component.handleRemoveLocation(); *2*
expect(component.state.locationSelected).toEqual(false); *3*
});
test('onLocationUpdate', () => { *4*
const props = { onSubmit: jest.fn() };
CreatePost.prototype.setState = jest.fn(function(updater) {
this.state = Object.assign(this.state, updater(this.state));
});
const component = new CreatePost(props);
component.onLocationUpdate({
lat: 1,
lng: 2,
name: 'name'
});
expect(component.setState).toHaveBeenCalled();
expect(component.state.location).toEqual({
lat: 1,
lng: 2,
name: 'name'
});
});
test('handleToggleLocation', () => { *5*
const props = { onSubmit: jest.fn() };
const mockEvent = {
preventDefault: jest.fn()
};
CreatePost.prototype.setState = jest.fn(function(updater) {
this.state = Object.assign(this.state, updater(this.state));
});
const component = new CreatePost(props);
component.handleToggleLocation(mockEvent);
expect(mockEvent.preventDefault).toHaveBeenCalled();
expect(component.state.showLocationPicker).toEqual(true);
});
test('onLocationSelect', () => { *5*
const props = { onSubmit: jest.fn() };
CreatePost.prototype.setState = jest.fn(function(updater) {
this.state = Object.assign(this.state, updater(this.state));
});
const component = new CreatePost(props);
component.onLocationSelect({
lat: 1,
lng: 2,
name: 'name'
});
test('onLocationSelect', () => { *5*
const props = { onSubmit: jest.fn() };
CreatePost.prototype.setState = jest.fn(function(updater) {
this.state = Object.assign(this.state, updater(this.state));
});
const component = new CreatePost(props);
component.onLocationSelect({
lat: 1,
lng: 2,
name: 'name'
});
expect(component.setState).toHaveBeenCalled();
expect(component.state.location).toEqual({
lat: 1,
lng: 2,
name: 'name'
});
});
test('renderLocationControls', () => { *6*
const props = { onSubmit: jest.fn() };
const component = renderer.create(<CreatePost {...props} />);
let tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
});
-
1 模拟 setState
-
2 调用 handleRemove-Location 函数
-
3 断言你以正确的方式更新了状态
-
4 对组件的其余方法重复相同的过程
-
5 对组件的其余方法重复相同的过程
-
6 为你创建的子渲染方法创建另一个快照测试
9.3.4. 测试覆盖率
现在你已经测试了一些组件,让我们来看看测试覆盖率,看看你取得了哪些进展。在你的终端中,停止测试运行器并执行下一个列表中显示的命令。此命令将打开 Jest 中包含的覆盖率选项。
列表 9.11. 启用测试覆盖率(项目根目录)
> npm run test:w
一旦测试运行器执行完测试,它应该输出一个彩色表格,看起来可能像图 9.3(覆盖率较低)。该图显示了带有每列注释的 Jest 覆盖率输出。有不同形式的可读性代码覆盖率报告(例如 HTML),但在开发期间,终端输出最有用,因为它提供了即时反馈。
图 9.3. Jest 的测试覆盖率输出显示了项目中不同文件的覆盖率统计。每一列反映覆盖率的不同方面。对于每种覆盖率类型,Jest 都会显示覆盖的百分比。语句和函数仅仅是 JavaScript 语句和函数,而分支是逻辑分支。如果你的测试没有覆盖 if 语句的一部分,这应该在代码覆盖率中的未覆盖行列和分支的百分比覆盖率中反映出来。

Istanbul 是生成图 9.3 中统计的工具。如果你想看到更详细的覆盖率信息,打开由包含覆盖率选项的jest命令生成的覆盖率目录。在这个目录中,Istanbul 应该创建了一些文件。如果你在浏览器中打开./coverage/lcov-report/index.html,你应该会看到类似图 9.4 的内容。
图 9.4. Istanbul 以计算机可读和人类可读的格式生成覆盖率元数据。这里显示的覆盖率报告对于更详细地探索代码覆盖率很有用。你甚至可以根据不同的列进行排序,并优先考虑覆盖率低的文件。请注意,有关于语句、分支(if/else 语句)、函数(调用了哪些函数)和行(代码行)的列。

Istanbul 的输出很有用,但你也可以深入到不同的文件中,获取关于单个文件更深入的信息。每个文件都应该显示不同行被覆盖的次数以及哪些行没有被覆盖。大多数情况下,顶层摘要就足够了,但有时你可能想检查单个报告,就像图 9.5 中的那样。当我编写测试时,我喜欢在覆盖所有用例后至少查看一次这些文件,以确保我没有错过任何边缘情况或逻辑分支。
图 9.5. 由 Istanbul 生成的单个文件覆盖率报告。您可以看到不同行被覆盖或未被覆盖的次数,并了解代码中哪些部分被覆盖。

测试覆盖率是软件开发的重要且有用的工具,但不要将其视为代码工作的神奇保证。你可以达到 100%的覆盖率,但代码仍然可能出错。技术上,你也可以有 0%代码覆盖率但代码仍然可以工作。覆盖率 是确保你的测试正在执行代码的所有不同部分——不是保证没有错误或性能等问题——但它对此很有用,并且应该被视为考虑代码“完整性”时的重要数据点。我曾加入过一些团队,我们的成功定义包括,在特定用户故事或任务中,除了其他事项外,代码覆盖率超过 80%且总体覆盖率没有下降。将覆盖率用作指南,以确定你的代码哪些部分已经或尚未测试,并检查你的测试进度。
考虑覆盖率
我们在本章中讨论了测试覆盖率。100%的测试覆盖率是否意味着你的代码是完美的?代码覆盖率在测试中应该扮演什么角色?
9.4. 摘要
在本章中,你学习了测试背后的某些原则以及如何测试 React 应用程序:
-
测试 是验证关于软件所做假设的过程。它帮助你更好地规划组件,防止未来出现故障,并有助于提高你对代码的信心。它在快速开发过程中也发挥着重要作用。
-
手动测试扩展性不好,因为无论多少人,都无法快速或充分地测试复杂的软件。
-
在软件测试过程中,我们使用各种工具,从运行我们的测试的工具到确定我们的代码有多少被测试覆盖的工具。
-
不同类型的测试应以不同的比例出现。单元 测试应该是最常见的,它们容易编写、成本低、速度快。集成 测试测试系统的许多不同部分的交互,可能很脆弱,编写时间较长。它们应该较少出现。
-
你可以使用各种工具测试 React 组件。因为它们只是函数,你可以严格地按这种方式测试它们。但像 Enzyme 这样的工具使测试 React 组件变得更容易。
-
清洁的测试,就像任何清洁的代码一样,易于阅读和良好组织,并使用适当的单元、服务和集成测试比例。它们应该提供有意义的保证,说明事物以特定方式运行,并应保证你的组件更改可以被评估。
在下一章中,我们将探讨 Letters Social 应用程序的更健壮的实现,并探索 Redux 架构模式。在继续之前,看看你是否能继续磨练你的测试技能,并将应用程序的测试覆盖率提高到 90%以上!
第三部分. React 应用程序架构
到第二部分结束时,你将把 Letters Social 示例应用程序从裸骨静态页面转变为具有路由、身份验证和动态数据的动态用户界面。在第三部分中,你将通过探索 React 的一些高级主题来扩展你已创建的内容。
在第十章和第十一章中,你将探索 Flux 应用程序架构并实现 Redux。Redux 是 Flux 模式的一种变体,已成为大型 React 应用程序的事实上的状态管理解决方案。你将探索 Redux 的概念,并将你的 React 应用程序过渡到使用 Redux 作为状态管理解决方案。在这个过程中,你将继续为 Letters Social 添加功能,包括评论和点赞帖子。
在第十二章中,我们将更进一步,探讨如何使用 React 在服务器上。得益于 node.js 服务器运行时的可用性,你可以在服务器上执行 React 代码。你将探索使用 React 进行服务器端渲染,甚至将你的 Redux 状态管理集成到该过程中。你还将集成 React Router,这是一个流行的 React 路由库。
最后,在第十三章中,你将稍微偏离 React Web,并探索 React Native。React Native 是另一个 React 项目,它让你能够编写可以在 iOS 和 Android 移动设备上运行的 React 应用程序。
到第三部分结束时,你将创建一个充分利用 React、Redux 和服务器端渲染的完整应用程序。你将完成对 React 的初步探索,但将能够通过 React 进一步发展你的能力,并探索其他高级主题,如 React Native。
第十章. Redux 应用程序架构
本章涵盖
-
Redux 动作、存储、还原器和中间件
-
Redux 动作、存储、还原器和中间件的简单测试
到目前为止,你可以创建经过测试的 React 应用程序,处理动态数据,接受用户输入,并且可以与远程 API 通信。这已经很多了,涵盖了典型 Web 应用将执行的大部分内容;你可能觉得剩下的唯一事情就是练习。将你的技能付诸实践将帮助你掌握 React,但仍然有一个重要的领域你需要覆盖,那就是构建更大、更复杂的应用程序:应用程序架构。“应用程序架构”是“定义一个结构化解决方案的过程,该解决方案满足所有技术和管理要求,同时优化常见的质量属性,如性能、安全性和可管理性”(来自《微软应用程序架构指南》,第二版)。架构询问:“好吧,我们可以这样做,但现在我们如何更好地、更一致地做呢?”这关乎应用程序是如何组织的,数据是如何流动的,以及责任是如何委派给系统不同部分的。
每个应用程序都有某种隐含的架构,仅仅因为它有一个结构,并以特定的方式做事。我这里所说的是构建复杂应用程序的策略和范式。React 更倾向于成为一个更简约或无偏见的框架,专注于 UI,因此它没有内置的策略供你在构建更复杂的应用程序时遵循。
虽然没有内置的策略供你使用,但这并不意味着没有其他选择。有许多方法可以用 React 构建复杂的应用程序,其中许多方法基于 Facebook 工程师推广的 Flux 模型。Flux 与流行的 MVC 架构不同,它通过促进单向数据流、引入新概念(调度器、动作、存储)等方式进行区分。Flux 和 MVC 关注的是应用程序的外观“之上”的事情,甚至是一些特定的库或技术。它们更关注应用程序是如何组织的,数据是如何流动的,以及责任是如何委派给系统不同部分的。
本章探讨了 Flux 模式中最广泛使用和最受好评的变体之一:Redux。Redux 与 React 应用程序一起使用非常普遍,但实际上它可以与大多数 JavaScript 框架(内部或外部)一起使用。本章和下一章涵盖了 Redux 的核心概念(动作、中间件、还原器、存储和其他),然后是 Redux 与你的 React 应用程序的集成。Redux 中的动作代表正在执行的工作(获取用户数据、登录用户等),还原器决定状态应该如何改变,存储持有状态的中心化副本,而中间件允许你在过程中注入自定义行为。
我如何获取本章的代码?
与每一章一样,你可以通过访问 GitHub 仓库github.com/react-in-action/letters-social来查看本章的源代码。如果你想从这个章节开始,从零开始,并跟随操作,你可以使用第九章(如果你跟随并自己构建了示例)中的现有代码,或者查看特定章节的分支(chapter-10-11)。
记住,每个分支都对应于章节末尾的代码(例如,分支 chapter-10-11 对应于这些章节末尾的代码)。你可以在你选择的目录中执行以下终端命令之一来获取当前章节的代码。
如果你根本就没有仓库,请输入以下内容:
git clone git@github.com:react-in-action/letters-social.git
如果你已经克隆了仓库:
git checkout chapter-10-11
你可能是从另一章来到这里的,所以确保你已经安装了所有正确的依赖项总是一个好主意:
npm install
10.1. Flux 应用程序架构
现代应用程序必须比以往任何时候都要做更多的事情,因此它们在内部和外部都变得更加复杂。开发者们早已意识到,如果没有合适的设计模式,一个复杂的应用程序可能会变得一团糟。意大利面式的代码库不仅不便于工作,还会减慢开发者的工作效率,从而影响业务部门。你还记得上次不得不在一个充满一次性解决方案和 jQuery 插件的庞大代码库中工作的经历吗?可能不是那么愉快。为了对抗混乱,开发者们开发了像 MVC(模型-视图-控制器)这样的范式来组织应用程序的功能并指导开发。Flux(以及由此扩展的 Redux)是同一方向上的努力,它帮助你处理应用程序中增加的复杂性。
如果你对 MVC 范式不是特别熟悉,不必担心;我们不会花太多时间深入探讨它。但在我们讨论 Flux 和 Redux 之前,为了进行比较,简要地讨论一下 MVC 可能会有所帮助。如果你有兴趣了解更多,Jeff Atwood 在blog.codinghorror.com/understanding-model-view-controller/有一些有用的想法,网上还有很多其他资源。以下是基础知识:
-
模型— 您应用程序的数据。通常是一个名词,如 User、Account 或 Post 等。您的模型至少应该有操作相关数据的基本方法。在最抽象的意义上,模型代表原始数据或知识。它是数据与您的应用程序代码相交的地方。例如,数据库可能存储多个属性,如
access-Scopes、authenticated等。但模型将能够使用这些数据来执行isAllowedAccessForResource()等方法,这些方法将作用于底层数据以建模。模型是原始数据与您的应用程序代码相交的地方。 -
视图— 您模型的表示。视图通常是用户界面本身。视图不应包含任何与数据展示无关的逻辑。对于前端框架来说,这通常意味着特定的视图直接关联到一个资源,并且会与它关联 CRUD(创建、读取、更新、删除)操作。但现在的前端应用不再总是这样构建。
-
控制器— 控制器是绑定模型和视图的“胶水”。控制器通常只应该是胶水,而不应该是更多(例如,它们不应该包含复杂的视图或数据库逻辑)。您通常期望控制器比它们交互的模型具有更少的数据修改能力。
本章(Flux 和 Redux)我们将关注的范例与这些概念有所不同,但仍然旨在帮助您创建一个可扩展、合理且有效的应用程序架构。
Redux 的起源和设计归功于在 Facebook 流行的一种模式,称为 Flux。如果您熟悉 Ruby on Rails 和其他应用程序框架使用的流行 MVC 模式,Flux 可能与您习惯的不同。Flux 不是将应用程序的部分拆分为模型、视图和控制器,而是定义了几个不同的部分:
-
存储— 存储包含应用程序状态和逻辑;它们在传统 MVC 中类似于模型。然而,它们不是代表单个数据库记录,而是管理许多对象的状态。与模型不同,您可以根据需要表示数据,不受资源限制。
-
动作— 相比直接更新状态,Flux 应用通过创建修改状态的动作来改变其状态。
-
视图— 用户界面,通常是 React,但 Flux 不要求使用 React。
-
调度器— 行动和存储更新的中央协调者。
图 10.1 展示了 Flux 的概述。
图 10.1. 一个简单的 Flux 概述

在 Flux 模式中,如图 10.1 图 10.1 所示,动作是由视图创建的——这可能是用户点击了某个东西。从那里,分发器处理传入的动作。然后,动作被发送到适当的存储库以更新状态。状态发生变化后,会通知视图使用新的数据(如果适用)。注意,这与典型的 MVC 风格框架不同,在 MVC 风格框架中,视图和模型(如这里的存储库)都可以更新对方。这种双向数据流与 Flux 架构中典型的单向流不同。此外,请注意这里缺少了中间件:虽然在 Flux 中可以创建中间件,但它不如 Redux 中的第一类公民重要,所以我们在这里省略了它。
如果这些部分听起来很熟悉,那么如果你之前使用过 MVC 风格的应用程序,Flux 中数据流动的方式可能就不一样了。如前所述,在 Flux 范式下,数据流动更具有单向性,这与 MVC 类型实现通常强制执行的双向方式不同。这通常意味着在应用程序中没有单一的地方数据是从那里流动出来的;系统的许多不同部分都有修改状态的权限,并且状态通常在应用程序中分散。这种方法在许多情况下都很好用,但在大型应用中,调试和工作可能会变得令人困惑。
想象一下这在中型到大型应用中的样子。比如说,你有一组与自己的控制器和视图关联的模型(用户、账户和身份验证)。在应用程序的任何位置,确定状态的精确位置可能都很困难,因为状态分布在整个应用程序的各个部分(关于用户的信息可能存在于我提到的三个模型中的任何一个)。
这可能对小型应用来说不一定是个问题,甚至可以使其在大型应用中也能良好运行,但在非平凡的客户端应用中可能会变得更加困难。例如,当你需要修改模型在 50 个不同位置和 60 个不同控制器中的应用时,这些部分需要知道状态的变化会发生什么?使问题更加复杂的是,在某些前端框架中,视图有时会像模型一样行动(因此状态更加分散)。你的数据真相来源在哪里?如果它分布在视图和许多不同的模型中,并且在一个相当复杂的设置中,心理上跟踪所有这些内容将会很困难。这也可能导致应用程序状态不一致,从而引发应用程序错误,所以这不仅仅是一个“仅开发者”的问题——最终用户也会直接受到影响。
这之所以困难的部分原因是因为人们通常不擅长推理随时间发生的变化。为了说明这一点,想象一下你心中的跳棋盘。在脑海中保持可能一个或甚至几个棋盘快照并不难。但你能否在 20 次移动后跟踪棋盘的每个快照?30 次?整个游戏?我们应该构建更容易思考和使用的系统,因为随着时间的推移,跟踪数据异步变化是困难的。例如,考虑调用远程 API 并使用数据更新你的应用程序状态。对于少数情况来说很简单,但如果你必须调用 50 个不同的端点,并在用户仍在使用应用程序并做出可能导致更多 API 交互的更改时跟踪传入的响应,这可能会很困难。在心理上很难将它们全部排列成一行并预测变化的结果。
你可能已经注意到了 React 和 Flux 之间的某些相似之处。它们都是相对较新的构建用户界面的方法,它们都旨在改善开发者工作的心理模型。在每个中,变化应该是容易推理的,你应该能够以赋予你力量而不是阻碍你的方式构建你的 UI。
在代码中,Flux 看起来是什么样子?它主要是一种范式,因此有大量的库可供选择,它们实现了 Flux 的核心思想。它们在实现 Flux 的方式上彼此略有不同。Redux 也是如此,尽管它特有的 Flux 风格获得了最广泛的使用和认知。其他 Flux 库包括 Flummox、Fluxxor、Reflux、Fluxible、Lux、McFly 和 MartyJS(但在实践中,与 Redux 相比,你很少会看到这些库的使用)。
10.1.1. 认识 Redux:Flux 的一种变体
可能最广泛使用且知名度最高的实现 Flux 背后思想的库是 Redux。Redux 是一个以略微修改的方式实现 Flux 思想的库。Redux 的官方文档将其描述为“JavaScript 应用的预测状态容器。”具体来说,这意味着它是一个将 Flux 的概念和思想以自己的方式付诸实践的库。
在这里,精确定义什么是 Flux 以及什么不是 Flux 并不重要,但我应该介绍一些 Flux 和 Redux 范式之间的重要区别:
-
Redux 使用单个 store— 与在应用中的多个 store 中定位状态信息不同,Redux 应用将所有内容都保存在一个地方。在 Flux 中,你可以有多个不同的 store。Redux 打破了这种做法,并强制执行一个单一的全球 store。
-
Redux 引入了 reducers— Reducers 是一种更不可变的方法来处理变更。在 Redux 中,状态以可预测和确定的方式改变,一次只改变状态的一部分,并且只在一个地方(全局 store)。
-
Redux 引入了中间件—— 因为动作和数据以单向方式流动,您可以在 Redux 应用程序中添加中间件,并在数据更新时注入自定义行为。
-
Redux 动作与存储解耦—— 动作创建者不会向存储发送任何内容;相反,它们返回中央分发器使用的动作对象。
这些可能对您来说很微妙,但这没关系——您的目标是学习 Redux,而不是做“找出不同”的练习。图 10.2 展示了 Redux 架构的概览。您将深入研究每个不同的部分,探索它们是如何工作的,并为您的应用程序开发一个 Redux 架构。
图 10.2. Redux 概览

如您在图 10.2 中看到的,动作、存储和减少器构成了 Redux 架构的主体。Redux 使用一个单一的集中式状态对象,该对象以特定的、确定的方式更新。当您想要更新状态(通常是由于点击等事件)时,会创建一个动作。该动作将有一个特定减少器将处理的类型。处理给定动作类型的减少器将复制当前状态,使用动作中的数据对其进行修改,然后返回新状态。当存储更新时,视图层(在我们的案例中是 React)可以监听更新并相应地做出反应。此外,请注意,在图中,视图只是从存储中读取更新——它们不关心传达给它们的数据。《React-redux》库在存储更改时处理将新属性传递给组件,但视图仍然只是接收和显示数据。
10.1.2. 为 Redux 准备设置
Redux 是您应用程序架构的范式,但它也是一个您可以安装的库。这是 Redux 在“原始”Flux 实现上发光的一个领域。有如此多的 Flux 范式实现——Flummox、Fluxxor、Reflux、Fluxible、Lux、McFly 和 MartyJS 等——它们都有不同程度的社区支持以及不同的 API。Redux 拥有强大的社区支持,但 Redux 库本身有一个小而强大的 API,这有助于它成为 React 应用程序架构中最受欢迎和依赖的库之一。事实上,Redux 与 React 一起使用如此普遍,以至于每个库的核心团队通常相互交流,确保兼容性和功能意识。甚至有些人同时在两个团队中,因此项目之间通常有很好的可见性和沟通。
要设置使用 Redux,你需要做几件事情:
-
确保您已经使用当前章节的源代码运行了
npm install,以便所有正确的依赖项都已在本地上安装。在本章中,您将开始利用一些新的库,包括js-cookie、redux-mock-store和redux。 -
安装 Redux 开发者工具。您可以使用它们在浏览器中检查 Redux 存储和动作。
Redux 是按设计可预测的,这使得创建一些惊人的调试工具变得容易。像 Dan Abramov 这样的工程师以及其他在 Redux 和 React 库上工作的工程师已经帮助创建了一些用于处理 Redux 应用的强大工具。由于 Redux 中的状态以可预测的方式变化,因此以新的方式进行调试成为可能:您可以跟踪应用程序状态的个别更改,检查更改之间的差异,甚至可以在时间上回放和重播应用程序状态。Redux Dev Tools 扩展程序让您能够做到这一切,并且作为浏览器扩展程序捆绑提供。要为您的浏览器安装它,请遵循github.com/zalmoxisus/redux-devtools-extension上的说明。图 10.3 展示了 Redux Dev Tools 可用的预览。
图 10.3. Redux Dev Tools 扩展程序将 Dan Abramov 的流行 Redux Dev Tools 库打包成一个方便的浏览器扩展。有了它,您可以回放和重播您的 Redux 应用,逐个检查更改,检查状态更改之间的差异,在一个区域中查看您的整个应用程序状态,生成测试模板,等等。

安装扩展后,您应该在浏览器工具栏中看到新的 Dev Tools 图标。截至写作时,它仅在检测到开发模式下的 Redux 应用实例时才会着色显示,因此如果您访问没有设置 Redux 的应用或其他网站,扩展程序将无法工作。但一旦您配置了应用,您将看到带有颜色的图标出现,点击它将打开工具。
10.2. 在 Redux 中创建动作
在 Redux 中,动作是信息负载,用于将数据从您的应用程序发送到您的存储。除了动作之外,存储没有其他方式获取数据。动作在整个 Redux 应用程序中用于启动数据变化,尽管它们本身并不负责更新应用程序的状态(存储)。与架构的这一部分更相关的是 Reducers,我们将在动作之后查看它们。如果您习惯于按自己的喜好更新应用程序的状态,您可能一开始不会喜欢动作。它们可能需要一些时间来适应,但它们会导致应用程序通常更可预测且更容易调试。如果您的应用程序中数据变化的方式受到严格控制,您可以轻松预测应用程序中应该和不应该发生变化的内容。图 10.4 显示了动作在更广泛画面中的位置。我们从动作开始,将逐步通过 Redux 流,通过存储、Reducers,最终回到 React 以完成数据流。
图 10.4. 操作是 Redux 应用程序知道如何更改的方式;它们有一个类型和任何其他应用程序需要的附加信息。

Redux 操作看起来是什么样子?它是一个普通的 JavaScript 对象(POJO),包含一个必需的 type 键和任何其他你想要的内容。类型键将由 reducer 和其他 Redux 工具用来关联一系列更改。每个独特的操作类型都应该有一个独特的类型键。类型通常定义为字符串常量,你可以自由地使用你喜欢的任何独特名称,尽管制定一个遵循的命名模式是个好主意。列表 10.1 展示了你可能想出的几个操作类型名称的例子。
通常情况下,你应该确保你的操作只包含它们绝对需要的那些信息。这样,你就可以避免传递额外的数据,并且需要思考的信息也会更少。下面的列表展示了两个简单的操作,一个包含额外的数据,另一个则没有。请注意,你可以根据需要为操作上的额外键命名,但如果你不一致,这可能会造成混淆,尤其是在团队中尤其如此。
列表 10.1. 一些简单的 Redux 操作
{
type: 'UPDATE_USER_PROFILE', *1*
payload: {
email: 'hello@ifelse.io' *1*
}
}
{
type: 'LOADING' *2*
}
{
type: appName/dashboard/insights/load' *3*
}
-
1 一个操作可以包含信息,告诉你的应用程序如何更改,比如新的用户电子邮件地址、错误诊断或其他信息。
-
2 每个操作都必须有一个类型——没有类型,你的应用程序不知道需要对存储进行何种更改。
-
3 类型通常是大写字符串常量,这样你就可以在应用程序中将它们与常规值区分开来,但在这里我使用了一个命名空间方案来确保操作是唯一的但可读的。
10.2.1. 定义操作类型
虽然你可以在本章的后面添加更多内容,但你可以通过列出一些操作类型来开始将你的 Letters Social 应用程序过渡到 Redux 架构。这些类型通常映射到用户操作,如登录、登出、更改表单值等,但它们不一定是用户操作。你可能想要为打开、解决或错误的网络请求或其他不直接涉及用户的事情创建操作类型。
值得注意的是,在一个较小的应用程序中,你可能不一定需要在常量文件中定义你的操作类型;你可以在创建操作时记住传递它们,或者自己硬编码它们。缺点是,随着你的应用程序的增长,跟踪操作类型将是一个痛点,可能会导致困难的调试或重构情况。在大多数实际情况下,你会定义你的操作,所以这里你也会这样做。
您将草拟一些您预期会使用的动作类型,但您可以随时根据需要添加或删除它们。您将使用名称空间方法来处理动作类型,但请记住,在创建自己的动作时,只要它们是唯一的,您可以遵循您认为最好的任何模式。您还可以将类似动作类型“捆绑”到对象中,但它们也可以轻松地分散并作为单个常量导出。捆绑的优势在于,您可以将它们分组在一起,并使用更短的名字(如 GET、CREATE 等),而无需将它们构建到变量名本身中(如 UPDATE_USER_PROFILE、CREATE_NEW_POST 等)。列表 10.2 展示了如何创建您的初始动作类型。您将把它们放在 src/constants/types.js 中。您现在正在创建本章所需的全部动作,这样您可以引用它们,而无需不断回到文件中。
列表 10.2. 定义动作类型(src/contstants/types.js)
export const app = {
ERROR: 'letters-social/app/error',
LOADED: 'letters-social/app/loaded',
LOADING: 'letters-social/app/loading'
};
export const auth = {
LOGIN_SUCCESS: 'letters-social/auth/login/success',
LOGOUT_SUCCESS: 'letters-social/auth/logout/success'
};
export const posts = {
CREATE: 'letters-social/post/create',
GET: 'letters-social/post/get',
LIKE: 'letters-social/post/like',
NEXT: 'letters-social/post/paginate/next',
UNLIKE: 'letters-social/post/unlike',
UPDATE_LINKS: 'letters-social/post/paginate/update'
};
export const comments = {
CREATE: 'letters-social/comments/create',
GET: 'letters-social/comments/get',
SHOW: 'letters-social/comments/show',
TOGGLE: 'letters-social/comments/toggle'
};
当使用 Redux 开发者工具时,这些动作类型将显示在您应用程序状态变化的时序图中,因此像 列表 10.2 中的那样以 URL 样式分组名称可以使它们在您有许多动作和动作类型时更容易阅读。您也可以使用 : 字符来分隔它们(namespace:action_name:status)或使用您认为最有意义的任何约定。
10.2.2. 在 Redux 中创建动作
现在您已经定义了一些类型,您就可以开始使用动作做一些事情了。您将重用应用程序现有部分的逻辑,因此大部分代码可能对您来说都很熟悉。这实际上是一个值得简要反思的好点:Redux 应用程序的大部分内容不应该是对任何现有应用程序逻辑的完全重做。希望您能够对其进行清理,但将应用程序状态的不同方面映射到 Redux 强制执行的模式上可能只是转换到使用 Redux 的主要工作。无论如何,我们需要开始处理动作。
动作是您在 Redux 应用程序中启动状态变化的方式;您不能像在其他框架中那样直接修改属性。动作是通过 动作创建器(函数返回动作对象)创建的,并通过 dispatch 函数由存储库分发。
我们不想在这里走得太远。我首先会介绍动作创建器本身。您将从简单开始,创建一些动作,以指示您的应用程序何时开始和完成加载。在这个时候,您不需要传递任何额外的信息,但我会在下一节介绍参数化动作创建器。下一个列表显示了如何创建加载和加载动作的两个动作创建器。为了保持组织结构,您将把任何动作创建器放在动作目录下。对于其他与 Redux 相关的文件也是如此;还原器和存储库将有自己的目录。
列表 10.3. loading和loaded动作创建器(src/actions/loading.js)
import * as types from '../constants/types'; *1*
export function loading() {
return {
type: types.app.LOADING *2*
};
}
export function loaded() { *3*
return {
type: types.app.LOADED
};
}
-
1 从常量文件导入你的类型。
-
2 使用你之前定义的加载类型返回一个包含所需 type 键的动作对象
-
3 导出一个加载动作的动作创建器。
10.2.3. 创建 Redux store 和分发动作
动作创建器本身不会做任何事情来改变你的应用状态(它们只是返回对象)。你需要使用 Redux 提供的分发器,以便动作创建器产生任何效果。dispatch函数由 Redux store 本身提供,并将是你将动作发送到 Redux 以进行处理的方式。你将设置 Redux store,以便你可以使用其dispatch函数与你的动作一起使用。
在你设置 store 之前,你需要创建一个根 reducer 文件,这将允许你创建一个有效的 store;reducer 在稍后查看 reducer 并构建它们之前不会做任何事情。你将在 src 中创建一个名为 reducers 的文件夹,并在其中创建一个文件,名为 root.js。在这个文件中,你将使用 Redux 提供的combineReducers函数来设置未来 reducer 的位置。这个函数确实做了它听起来应该做的事情:将多个 reducer 合并为一个。
没有合并 reducer 的能力,你会遇到多个 reducer 之间的冲突问题,并需要找到合并 reducer 或路由动作的方法。这是 Redux 优势可以具体观察到的领域之一。设置一切需要更多的工作,但一旦完成工作,Redux 会使应用状态管理更容易扩展。下一个列表展示了如何创建根 reducer 文件。
列表 10.4. 创建根 store(src/reducers/root.js)
import { combineReducers } from 'redux'; *1*
const rootReducer = combineReducers({}); *2*
export default rootReducer; *3*
-
1 从 Redux 导入 combineReducers 工具。
-
2 使用 combineReducers 创建根 reducer,目前使用空对象
-
3 导出根 reducer。
现在你已经为 Redux 设置了一个 reducer,接下来你需要配置和设置 store。创建一个名为 store 的文件夹,并在其中创建几个文件:store.js、stores/store.prod.js 和 stores/store.dev.js。这些文件负责导出一个函数,为你创建 store,并在开发模式下集成开发者工具。列表 10.5 展示了在同一列表中创建与 store 相关的文件。在这里,你使用不同的文件为每个环境,因为你可能希望在开发环境和生产环境中包含不同的中间件和其他库。这只是一个约定——Redux 没有要求你必须将函数放在多个文件或一个文件中。
列表 10.5. 创建 Redux store
// src/store/configureStore.js
import { __PRODUCTION__ } from 'environs'; *1*
import prodStore from './configureStore.prod';
import devStore from './configureStore.dev';
export default __PRODUCTION__ ? prodStore : devStore; *1*
// src/store/configureStore.prod.js
import { createStore } from 'redux';
import rootReducer from '../reducers/root';
let store;
export default function configureStore(initialState) { *2*
if (store) {
return store;
}
store = createStore(rootReducer, initialState); *3*
return store;
}
// src/store/configureStore.dev.js
import thunk from 'redux-thunk';
import { createStore, compose} from 'redux'; *4*
import rootReducer from '../reducers/root';
let store;
export default initialState => {
if (store) { *5*
return store;
}
const createdStore = createStore(
rootReducer,
initialState,
compose(window.devToolsExtension()) *6*
);
store = createdStore;
return store;
};
-
1 此文件使得在使用你的应用中的 store 时,无需确定是使用开发环境还是生产环境的 store 变得更容易。
-
2 将初始状态传递给你的 Redux 配置
-
3 使用 Redux createStore 方法创建您的存储
-
4 从 Redux 中导入 compose 工具,这将允许您组合中间件
-
5 确保您始终访问相同的存储——这确保了如果另一个文件访问已创建的存储,则返回相同的存储。
-
6 如果已安装开发工具扩展,这将连接到它
现在您已经配置好并准备好使用存储,您可以尝试分发一些动作并查看它们的工作方式。不久,您将把 Redux 连接到 React,但请记住,您不必在 React 或任何库或框架中使用 Redux。还有其他开源项目使用 Redux 与 Angular、Vue 等框架集成。
Redux 存储公开了一些重要的函数,您将在整个使用 Redux 的过程中使用它们:getState 和 dispatch。getState 将用于在特定时间点获取 Redux 存储状态的快照,而 dispatch 是您向 Redux 存储发送动作的方式。调用 dispatch 方法时,您传入一个动作,该动作是调用动作创建器的结果。使用 store.dispatch() 方法是触发 Redux 中状态变化的唯一方式,因此您将到处使用它。接下来,您将尝试使用存储来分发一些动作,使用您之前设置的 loading 动作创建器。以下列表显示了如何使用临时文件(src/store/exampleUse.js)分发一些动作。此文件仅用于演示目的,不会用于使主应用工作。
列表 10.6. 分发动作(src/store/exampleUse.js)
import configureStore from './configureStore'; *1*
import { loading, loaded } from '../actions/loading';
const store = configureStore();
console.log('========== Example store ===========');
store.dispatch(loading()); *2*
store.dispatch(loaded()); *3*
store.dispatch(loading());
store.dispatch(loaded());
console.log('========== end example store ===========');
-
1 导入 configureStore 方法并使用它来创建存储
-
2 调用存储的 dispatch 方法并传入调用的动作创建器;将为 dispatch 方法返回一个对象
-
3 分发另一个动作。
要分发这些动作,您只需将 exampleUse 文件导入到主应用文件中,它将在您打开应用时运行。列表 10.7 显示您需要对 src/index.js 进行的一些微小修改。一旦将 Redux 连接到 React,您将通过 React 组件与 Redux 交互,而无需像在这里进行演示那样手动分发动作。
列表 10.7. 导入 exampleUse 文件(src/index.js)
import React from 'react';
import { render } from 'react-dom';
import { App } from './containers/App';
import { Home, SinglePost, Login, NotFound, Profile } from './containers';
import { Router, Route } from './components/router';
import { history } from './history';
import { firebase } from './backend';
import configureStore from './store/configureStore';
import initialReduxState from './constants/initialState';
import './store/exampleUse'; *1*
//...
- 1 导入存储文件,以便在打开应用时运行。
如果你以开发模式加载应用(使用 npm run dev),你应该会看到 Redux 开发者工具图标已启用。现在,当应用运行时,你导入的文件将运行并多次调用存储分发器,将动作发送到存储。目前,还没有为动作设置处理程序(通过减少器),而且你没有将任何东西连接到 React,所以不会有任何有意义的更改。但如果你打开开发者工具并查看动作历史,你应该会看到你分发的每个加载动作都分发了并记录了动作。图 10.5 显示了在您的图表上下文中分发的动作以及你应在 Redux 开发者工具中看到的输出。
图 10.5. 当你运行你的应用时,你创建的示例存储将接收你的动作创建者的结果并将它们分发给存储。目前,你没有设置任何减少器来执行任何操作,所以几乎不会发生什么。一旦你设置了减少器,Redux 将根据分发的动作类型确定需要做出的状态更改。

10.2.4. 异步操作和中间件
你可以分发动作,但现在是同步的。有许多情况下,你将想要根据异步动作更改你的应用。这些可能是一个网络请求,从浏览器中读取值(通过本地存储、cookie 存储等等),使用 WebSocket,或任何其他异步动作。Redux 默认不支持异步动作,因为它期望动作只是对象(不是 Promise 或其他)。但你可以通过集成你已安装的库来启用它:redux-thunk。
redux-thunk 是一个 Redux 中间件 库,这意味着它作为 Redux 的某种“途中”或传递机制。你可能已经使用过其他利用这个概念的 API,比如 Express 或 Koa(Node.js 的服务器端框架)。中间件通过以可组合的方式让你钩入某种周期或过程,这意味着你可以在单个项目中独立创建和使用多个中间件功能。
根据 Redux 文档的描述,Redux 中间件是“在分发动作和动作到达 reducer 之间的第三方扩展点。”这意味着在动作被 reducer 处理之前,您有一个或多个机会对动作进行操作或因为动作而采取行动。您将使用 Redux 中间件来创建一个错误处理解决方案,但现在您可以使用redux-thunk中间件来在应用程序中启用异步动作创建。列表 10.8 展示了如何将redux-thunk中间件集成到您的应用程序中。请注意,您应该将中间件添加到您的生产环境和开发存储(configureStore.prod.js 和 configureStore.dev.js)中。记住,您可以选择最适合您情况的任何生产/开发存储设置——我只是将它们分成两部分,以便清楚地说明每个环境使用哪个。
列表 10.8. 通过 redux-thunk 启用异步动作创建
import thunk from 'redux-thunk';
import { createStore, compose, applyMiddleware } from 'redux'; *1*
import rootReducer from '../reducers/root';
let store;
export default (initialState) => {
if (store) {
return store;
}
const createdStore = createStore(rootReducer, initialState, compose( *2*
applyMiddleware( *2*
thunk, *2*
), *2*
window.devToolsExtension()
)
);
store = createdStore;
return store;
};
-
1 要将中间件集成到您的 Redux 存储中,请引入 applyMiddleware 实用工具
-
2 在 applyMiddleware 函数中插入并排序 Redux 中的中间件——在这里,您正在将 redux-thunk 中间件插入到您的存储中。
现在您已经安装了redux-thunk中间件,可以创建异步动作创建器了。我为什么说异步动作创建器而不是异步动作?因为即使您在进行异步操作,如进行网络请求时,您创建的动作本身并不是异步任务。相反,redux-thunk教会您的 Redux 存储在动作通过时评估一个 Promise。这个 Promise 的流程就是您为存储分发动作的方式。Redux 实际上并没有发生真正的变化。动作仍然是同步的,但现在 Redux 知道当您将 Promise 传递到 dispatch 函数时,要等待 Promise 解决。
在前面的章节中,您使用isomorphic-fetch库创建了一些逻辑来从您的 API 获取帖子,并使用 React 来显示它们。这类执行异步工作的操作通常需要分发多个动作(通常是加载、成功和失败动作)。例如,假设您想让用户上传文件到服务器,该服务器在上传过程中发送进度数据。将动作映射到这个过程的不同部分的一种方法可以是创建一个动作来指示上传已开始,一个动作来告诉应用程序的其他部分正在加载,一个动作用于从服务器接收进度更新,一个动作用于上传完成,以及一个动作来处理错误。
redux-thunk 通过包装存储的 dispatch 方法来工作,使其能够处理除了普通对象(如 Promises、处理异步流的 API)之外的其他内容。中间件将异步地(例如在请求的开始和结束时)分发创建的动作,当 Promise 执行时让你适当地处理这些变化。正如已经提到的,这里的关键区别是动作本身仍然是同步的,但它们在分发到 reducers 时是异步的。图 10.6 展示了这是如何工作的。
图 10.6. 异步动作创建器由像 redux-thunk 这样的中间件库启用,它允许你分发除了动作之外的内容,如 Promise(这是 JavaScript 规范中异步工作的一部分)。它将解析 Promise,并允许你在 Promise 生命周期的不同点分发动作(在执行之前,在完成时,在出错时等等)。

接下来,你将使用你关于异步动作创建器的知识来编写一些将处理获取和创建帖子的动作创建器。因为 redux-thunk 包装了存储的 dispatch 方法,所以你可以从你的动作创建器中返回一个函数,该函数接收 dispatch 方法作为一个函数,这允许你在 Promise 执行过程中分发多个动作。列表 10.9 展示了这种类型的动作创建器看起来是什么样子。你将创建几个异步动作创建器和一个同步动作创建器。你将从创建一些你需要处理用户与帖子评论交互的动作开始。首先是错误动作,你将使用它来显示用户错误信息,如果发生错误。在一个较大的应用程序中,你可能需要创建多种处理错误的方法,但对我们来说,这应该足够了。你可以在这里使用这个错误动作,也可以在任何组件错误边界中使用它。componentDidCatch 将提供你可以分发到存储的错误信息。
列表 10.9. 创建错误动作(src/actions/error.js)
import * as types from '../constants/types';
export function createError(error, info) { *1*
return {
type: types.app.ERROR, *2*
error, *3*
info *3*
};
}
-
1 这个动作创建器是参数化的——你想要将错误信息发送到你的存储。
-
2 这个操作具有通用的应用程序错误类型——在较大的应用程序中,你会有许多类型的错误
-
3 传递实际错误和信息
现在你有了处理错误的方法,你可以开始编写一些异步动作创建器。你将从注释开始,然后转向帖子。帖子动作和评论动作在整体上应该看起来很相似,但在每个动作集的工作方式上会有一些细微的差别。你想要能够做一些与评论相关的事情:显示和隐藏它们,加载它们,并为给定的帖子创建一个新的评论。列表 10.10 展示了你将创建的评论动作。
当你创建这些和其他动作时,你将继续使用 isomorphic-fetch 库来进行网络请求,但它遵循的 Fetch API 正在浏览器中变得更加标准化,并且现在成为进行网络请求的事实标准。当可能时,你将继续使用 Web 平台 API 或遵循相同规范的库。
列表 10.10. 创建评论动作(src/actions/comments.js)
import * as types from '../constants/types';
import * as API from '../shared/http'; *1*
import { createError } from './error'; *1*
export function showComments(postId) { *2*
return {
type: types.comments.SHOW,
postId
};
}
export function toggleComments(postId) { *3*
return {
type: types.comments.TOGGLE,
postId
};
}
export function updateAvailableComments(comments) { *4*
return {
type: types.comments.GET,
comments
};
}
export function createComment(payload) { *5*
return dispatch => {
return API.createComment(payload)
.then(res => res.json()) *6*
.then(comment => {
dispatch({ *7*
type: types.comments.CREATE,
comment
});
})
.catch(err => dispatch(createError(err))); *8*
};
}
export function getCommentsForPost(postId) { *9*
return dispatch => {
return API.fetchCommentsForPost(postId)
.then(res => res.json()) *10*
.then(comments => dispatch(updateAvailableComments(comments)))
.catch(err => dispatch(createError(err))); *10*
};
}
-
1 导入你的 API 辅助函数。
-
2 创建参数化动作创建者,以便你可以显示特定的评论部分。
-
3 你想要能够切换评论部分。
-
4 创建获取评论的能力——你在这个文件中的异步动作创建者将使用此函数
-
5 从给定的有效负载中创建评论;返回一个函数而不是一个普通对象
-
6 Fetch API 实现了基于 Promise 的方法,如 json() 和 blob()。
-
7 使用从服务器获取的评论 JSON 创建派发评论动作
-
8 如果收到错误,请使用 createError 动作将其发送到存储
-
9 获取特定帖子的评论并使用 updateAvailable-Comments 动作
-
10 处理任何错误。
现在你已经创建了评论的动作,你可以继续创建帖子相关的动作。帖子相关的动作将与你刚刚创建的动作类似,但也会使用一些评论动作。能够在你的应用程序中混合和匹配不同的动作是 Redux 作为你的应用程序架构工作良好的另一个原因。它提供了一种结构化、可重复的方式来使用动作创建功能,然后在整个应用程序中利用这些功能。
接下来,你将继续创建动作,并为你的帖子添加一些功能。在早期章节中,你创建了获取和创建帖子的功能。现在你还将创建点赞和取消赞帖子的方式。下一个列表显示了与你的应用程序中的帖子相关的动作创建者。你现在将开始使用四个动作创建者,然后在下一个列表中探索更多。
列表 10.11. 创建异步和同步动作(src/actions/posts.js)
import parseLinkHeader from 'parse-link-header'; *1*
import * as types from '../constants/types';
import * as API from '../shared/http';
import { createError } from './error';
import { getCommentsForPost } from './comments';
export function updateAvailablePosts(posts) { *2*
return {
type: types.posts.GET,
posts
};
}
export function updatePaginationLinks(links) { *3*
return {
type: types.posts.UPDATE_LINKS,
links
};
}
export function like(postId) { *4*
return (dispatch, getState) => { *5*
const { user } = getState(); *5*
return API.likePost(postId, user.id)
.then(res => res.json())
.then(post => {
dispatch({ *6*
type: types.posts.LIKE,
post
});
})
.catch(err => dispatch(createError(err)));
};
}
export function unlike(postId) { *7*
return (dispatch, getState) => {
const { user } = getState();
return API.unlikePost(postId, user.id)
.then(res => res.json())
.then(post => {
dispatch({
type: types.posts.UNLIKE,
post
});
})
.catch(err => dispatch(createError(err)));
};
}
-
1 JSON API 使用 Link 头部来指示分页选项
-
2 就像你对评论所做的那样,这个动作创建者会将新的评论传递到存储中。
-
3 根据需要更新存储中的分页链接
-
4 使用帖子的 ID 点赞特定的帖子。
-
5 返回的函数将由 Redux 注入 dispatch 和 getState 方法
-
6 派发带有帖子的 LIKE 动作作为元数据
-
7 取消赞一个帖子涉及相同的流程,但派发不同的动作类型
你仍然需要为帖子创建更多动作类型。你可以点赞和取消赞帖子,但你还没有将之前创建的帖子创建迁移过来。你还需要一种方式来获取多个帖子以及单个帖子。列表 10.12 显示了你需要创建的相应的动作创建者。
希望到现在你已经开始掌握异步动作创建者的技巧了。在许多应用中,这类动作创建者相当常见。但可能性并不止于此。我发现仅使用redux-thunk本身对于大多数需要异步动作创建的应用来说已经足够了,但人们已经创建了大量的其他库来满足这一需求。例如,可以查看 Redux Saga 在github.com/redux-saga/redux-saga。
列表 10.12. 创建更多帖子动作创建者(src/actions/posts.js)
//...
export function createNewPost(post) {
return (dispatch, getState) => { *1*
const { user } = getState(); *1*
post.userId = user.id; *2*
return API.createPost(post)
.then(res => res.json())
.then(newPost => {
dispatch({ *3*
type: types.posts.CREATE,
post: newPost
});
})
.catch(err => dispatch(createError(err)));
};
}
export function getPostsForPage(page = 'first') {
return (dispatch, getState) => {
const { pagination } = getState(); *4*
const endpoint = pagination[page];
return API.fetchPosts(endpoint)
.then(res => {
const links = parseLinkHeader(res.headers.get('Link')); *5*
return res.json().then(posts => {
dispatch(updatePaginationLinks(links)); *6*
dispatch(updateAvailablePosts(posts)); *7*
});
})
.catch(err => dispatch(createError(err)));
};
}
export function loadPost(postId) {
return dispatch => { *8*
return API.fetchPost(postId)
.then(res => res.json())
.then(post => {
dispatch(updateAvailablePosts([post])); *8*
dispatch(getCommentsForPost(postId)); *8*
})
.catch(err => dispatch(createError(err)));
};
}
-
1 如前所述,使用
getState函数访问状态快照 -
2 在新帖子中嵌入用户 ID
-
3 分发创建帖子动作。
-
4 获取分页状态对象
-
5 使用链接头解析器并传入链接头
-
6 分发链接动作
-
7 分发更新帖子动作
-
8 从 API 加载帖子并获取其相关评论
10.2.5. 是使用 Redux 还是不使用 Redux?
在这些动作创建者完成之后,你已经创建了创建帖子评论的初始功能。尽管如此,你仍然缺少一个区域:用户的身份验证。在前面的章节中,你使用 Firebase 助手检查用户的身份验证状态,并使用它更新本地组件状态。你需要对身份验证做同样的事情吗?这又引出了另一个好问题:什么属于 Redux,什么不属于?在我们继续之前,让我们看看这个有些争议的问题。
React/Redux 社区中的观点从“把你想放什么进存储”到“绝对一切都必须放入存储”不等。还有一些工程师,他们只在 Redux 环境下使用 React,可能会认为这是唯一的方法,并将 React 和 Redux 视为一体。人们常常受限于他们的经验,但我希望我们能够花时间考虑事实和权衡,然后再形成不可动摇的观点。
首先,重要的是要记住,尽管 React 和 Redux 配合得很好,但这两项技术本身并没有内在的联系。你不需要 Redux 来构建 React 应用。我希望你在本书中已经看到了这一点。Redux 只是工程师可用的另一个工具——它不是构建 React 应用的唯一方式,当然也不是否定“正常”React 概念(例如本地组件状态)的东西。有些情况下,你可能只是通过将组件的状态带入 Redux 而增加了开销。
你应该怎么做?到目前为止,Redux 已经证明是给你的应用提供一个强大架构的绝佳方式,这已经帮助你更好地组织代码和功能(我们还没有谈到 reducers 呢!)。根据你到目前为止的经验,你可能倾向于迅速同意“绝对一切都应该在 Redux 存储中”的观点。但我想对此冲动提出警告,并看看权衡的结果。
根据我的经验,我们可以提出一些问题来指导关于哪些内容应该或不应该包含在 Redux 存储中的决策。第一个问题是这样的:应用的其他许多部分是否需要了解这一部分状态或功能?如果是这样,它可能应该放在 Redux 存储中。如果状态完全局限于一个组件,你应该考虑将其排除在 Redux 存储之外。一个例子是像下拉菜单这样的东西,除了用户之外不需要被控制。如果你的应用需要控制下拉菜单是打开还是关闭,并对其打开或关闭做出响应,那么这些状态变化可能应该通过存储进行。但如果不是这样,将状态保留在组件本地是完全可以的。
另一个问题是你正在处理的状态是否可以通过 Redux 简化或更好地表达。如果你正在将组件的状态和动作转换为 Redux,仅仅是为了这样做,你可能会为自己引入额外的复杂性,而从中得到的很少。但如果你的状态复杂或足够特殊,Redux 会使其更容易处理,你可能会希望将其包含在存储中。
考虑到这些因素,让我们重新审视是否应该将用户和身份验证逻辑集成到 Redux 中的问题。应用的其他部分是否需要了解用户?当然需要。你能否在 Redux 中更好地表达用户逻辑?如果不将其集中存储在存储中,你可能需要在应用的不同页面中复制逻辑,这可能不是最佳选择。目前来看,将用户和身份验证逻辑集成到 Redux 中似乎是合理的。
让我们看看如何创建一些操作!列表 10.13 展示了你将创建的用户相关操作。在这些例子中,你将使用 JavaScript 语言的现代特性 async/await。如果你对这部分语言的工作方式不熟悉,阅读 Mozilla 开发者网络文档(developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function)和 Dr. Axel Rauschmayer 所著的《探索 ES2016 和 ES2017》一书中关于 async/await 的章节(Leanpub,2017;exploringjs.com/es2016-es2017/ch_async-functions.html)可能会有所帮助。
列表 10.13. 创建用户相关操作(src/actions/auth.js)
import * as types from '../constants/types';
import { history } from '../history'; *1*
import { createError } from './error'; *1*
import { loading, loaded } from './loading'; *1*
import { getFirebaseUser, loginWithGithub, logUserOut, getFirebaseToken } *1*
from '../backend/auth'; *1*
export function loginSuccess(user, token) { *2*
return {
type: types.auth.LOGIN_SUCCESS,
user,
token
};
}
export function logoutSuccess() { *2*
return {
type: types.auth.LOGOUT_SUCCESS
};
}
export function logout() { *3*
return dispatch => {
return logUserOut() *3*
.then(() => {
history.push('/login'); *4*
dispatch(logoutSuccess()); *4*
window.Raven.setUserContext(); *4*
})
.catch(err => dispatch(createError(err)));
};
}
export function login() {
return dispatch => {
return loginWithGithub().then(async () => { *5*
try { *6*
dispatch(loading());
const user = await getFirebaseUser(); *7*
const token = await getFirebaseToken(); *7*
const res = await API.loadUser(user.uid); *8*
if (res.status === 404) { *8*
const userPayload = { *8*
name: user.displayName,
profilePicture: user.photoURL,
id: user.uid
};
const newUser = await API.createUser(userPayload).then(res *9*
=> res.json()); *9*
dispatch(loginSuccess(newUser, token)); *10*
dispatch(loaded()); *10*
history.push('/'); *10*
return newUser; *10*
}
const existingUser = await res.json(); *11*
dispatch(loginSuccess(existingUser, token)); *11*
dispatch(loaded()); *11*
history.push('/'); *11*
return existingUser;
} catch (err) {
createError(err); *12*
}
});
};
}
-
1 导入你将需要的用于身份验证相关动作的模块。
-
2 创建登录和注销动作创建者——登录动作将被参数化以接受用户和令牌
-
3 使用 Firebase 注销用户
-
4 将用户推送到登录页面,分发注销动作,并清除用户上下文(用于错误跟踪库)
-
5 使用 Firebase 登录用户
-
6 Async/await 使用 try...catch 错误处理语义
-
7 使用 await 从 Firebase 获取用户和令牌
-
8 尝试使用 API 查找从 Firebase 获取的用户——如果他们不存在(404),必须使用 Firebase 的信息注册他们
-
9 创建新用户
-
10 使用新用户分发登录动作并从函数中返回
-
11 如果用户已存在,则分发适当的登录操作并返回
-
12 在登录过程中捕获错误并将其分发到存储
在所有这些之后,你已经为用户相关动作、评论、帖子、加载和错误创建了动作。如果这看起来很多,你会很高兴知道你所做的是创建了应用程序原始功能的大部分。你仍然需要在下一节中教 Redux 如何通过还原器响应状态变化,然后将一切连接到 React,但你重新创建的动作代表了(你或用户)与你的应用程序交互的所有基本方式。这是 Redux 的另一个优点:你最终将功能转换为动作的工作,但最终你拥有一个相当全面的动作集合,这些动作是某人在你的应用程序中可以采取的。这比那些代码混乱、无法准确了解应用程序(更不用说不同的动作了)的代码库要清晰得多。
10.2.6. 测试动作
在我们继续到还原器之前,你将编写一些针对这些动作的快速测试。为了方便起见,我不会涵盖为每个设置的还原器或动作编写测试,但我想要确保你有一些代表性的例子,以便了解如何测试 Redux 应用程序的不同部分。如果你想看到更多示例,请查看应用程序源代码并查看测试目录。
Redux 使得测试动作创建者、还原器以及 Redux 架构的其他部分变得简单直接。更好的是,它们可以主要独立于前端框架进行测试和维护。这在大型应用程序中尤为重要,在这些应用程序中,测试是一项非平凡的任务(比如,一个商业应用程序而不是周末的副项目)。对于动作,一般的思想是断言预期的动作类型或类型,以及基于给定动作创建的任何必要的有效载荷信息。
大多数动作创建者都可以轻松测试,因为它们通常返回一个包含类型和有效载荷信息的对象。有时,尽管如此,你需要进行一些额外的设置来适应像异步动作创建者这样的东西。为了测试异步动作创建者,你将使用你在本章开头安装的模拟存储(redux-mock-store——更多信息请参阅github.com/arnaudbenard/redux-mock-store)并使用 redux-thunk 进行配置。这样,你可以断言异步动作创建者分发某些动作,并验证它是否按预期工作。下面的列表显示了如何在 Redux 中测试动作。
列表 10.14. 测试 Redux 中的动作(src/actions/comments.test.js)
jest.mock('../../src/shared/http'); *1*
import configureStore from 'redux-mock-store'; *2*
import thunk from 'redux-thunk'; *2*
import initialState from '../../src/constants/initialState';
import * as types from '../../src/constants/types';
import {
showComments,
toggleComments,
updateAvailableComments,
createComment,
getCommentsForPost
} from '../../src/actions/comments'; *3*
import * as API from '../../src/shared/http'; *4*
const mockStore = configureStore([thunk]); *5*
describe('login actions', () => {
let store; *5*
beforeEach(() => {
store = mockStore(initialState); *5*
});
test('showComments', () => {
const postId = 'id';
const actual = showComments(postId); *6*
const expected = { type: types.comments.SHOW, postId }; *6*
expect(actual).toEqual(expected); *6*
});
test('toggleComments', () => {
const postId = 'id';
const actual = toggleComments(postId);
const expected = { type: types.comments.TOGGLE, postId };
expect(actual).toEqual(expected);
});
test('updateAvailableComments', () => {
const comments = ['comments'];
const actual = updateAvailableComments(comments);
const expected = { type: types.comments.GET, comments };
expect(actual).toEqual(expected);
});
test('createComment', async () => {
const mockComment = { content: 'great post!' }; *7*
API.createComment = jest.fn(() => { *8*
return Promise.resolve({
json: () => Promise.resolve([mockComment]) *8*
});
});
await store.dispatch(createComment(mockComment)); *9*
const actions = store.getActions();
const expectedActions = [{ type: types.comments.CREATE, comment:
[mockComment] }]; *10*
expect(actions).toEqual(expectedActions);
});
test('getCommentsForPost', async () => {
const postId = 'id';
const comments = [{ content: 'great stuff' }];
API.fetchCommentsForPost = jest.fn(() => {
return Promise.resolve({
json: () => Promise.resolve(comments)
});
});
await store.dispatch(getCommentsForPost(postId));
const actions = store.getActions();
const expectedActions = [{ type: types.comments.GET, comments }];
expect(actions).toEqual(expectedActions);
});
});
-
1 使用 Jest 模拟 HTTP 文件以避免进行网络请求
-
2 导入模拟存储和 Redux 中间件,以便创建模拟存储以反映你的存储
-
3 导入你需要测试的动作
-
4 导入 API 以模拟其上的特定函数
-
5 创建模拟存储并在每次测试之前重新初始化它
-
6 断言动作创建者将输出具有正确类型和数据的动作
-
7 创建模拟评论以传递给动作创建者
-
8 使用 Jest 模拟 API 模块中的 createComment 方法
-
9 分发动作并使用 await 等待承诺解决
-
10 断言动作被创建为预期的那样
10.2.7. 创建用于崩溃报告的自定义 Redux 中间件
你已经创建了一些动作,但在你转向 reducer 之前,你可以添加一些自己的中间件。中间件 是 Redux 允许你钩入数据流过程(动作分发到存储,由 reducer 处理,状态更新,监听器通知)的方式。Redux 对中间件的方法类似于 Express 或 Koa 等其他工具(Node.js 的网络服务器框架),尽管它解决的是不同的问题。图 10.7 展示了在类似 Express 或 Koa 的东西中可能出现的以中间件为重点的流程示例。
图 10.7. 中间件位于进程的开始和结束点之间,允许你在其中进行各种操作。

有时你可能想要中断流程,将数据发送到另一个 API,或解决任何其他应用程序范围的问题。图 10.7 展示了中间件的几个不同用例:数据修改、流程中断和执行副作用。这里的一个关键点是中间件应该是可组合的——你应该能够重新排列这些中的任何一个,而不用担心它们会相互影响。
Redux 中间件允许你在动作被分发和到达 reducer 之间进行操作(参见 图 10.7 中的“中间件”部分)。这是一个关注 Redux 应用程序所有部分共同问题并避免在许多地方重复代码的绝佳地方。
定义
将术语与其定义匹配:
-
存储
-
Reducer
-
动作
-
动作创建者
___ Redux 中的中心状态对象;真相的来源。
___ 包含变更相关信息的对象。它们必须有一个类型,并且可以包含任何其他必要的信息来传达发生了什么。
___ Redux 用于根据某些事件计算状态变化的函数。
___ 用于创建有关应用程序中发生的事件的类型和有效负载信息的函数。
例如,使用中间件可以是一个集中处理错误、将分析数据发送到第三方 API、进行日志记录等的好方法。你将实现一个简单的崩溃报告中间件,确保任何未处理的异常都会报告给你的错误跟踪和管理系统。我正在使用 Sentry (sentry.io),一个跟踪和记录异常以供后续分析的应用程序,但你也可以使用对你或你的团队最佳的选择(Bugsnag 是另一个不错的选择——请访问 bugsnag.com)。列表 10.15 展示了如何创建一些基本的错误报告中间件,当 Redux 遇到错误时会记录并发送到 Sentry。通常,当应用程序中出现异常时,工程师会收到某种类型的通知(立即或在仪表板上);Sentry 记录这些错误并通知你它们发生的时间。
列表 10.15. 创建简单的崩溃报告 Redux 中间件
// ... src/middleware/crash.js
import { createError } from '../actions/error';
export default store => next => action => { *1*
try {
if (action.error) {
console.error(action.error);
console.error(action.info);
}
return next(action); *2*
} catch (err) { *3*
const { user } = store.getState(); *4*
console.error(err);
window.Raven.setUserContext(user); *4*
window.Raven.captureException(err);
return store.dispatch(createError(err)); *4*
}
};
//... src/store/configureStore.prod.js
import thunk from 'redux-thunk';
import { createStore, compose, applyMiddleware } from 'redux';
import rootReducer from '../reducers/root';
import crashReporting from '../middleware/crash'; *5*
let store;
export default function configureStore(initialState) {
if (store) {
return store;
}
store = createStore(rootReducer, initialState, compose(
applyMiddleware(thunk, crashReporting) *6*
));
return store;
}
-
1 Redux 中间件由 Redux 将其注入的组成函数组成。
-
2 如果没有错误,则移动到下一个动作
-
3 报告错误,如果有
-
4 获取用户并发送错误;将错误派发到 store
-
5 拉入用于生产的中间件。
-
6 为生产环境添加中间件
这只是 Redux 中间件可以做到的一小部分。丰富的文档包含了大量的 Redux 信息,以及对设计和 API 使用的见解,同时还提供了优秀的示例。有关 Redux 中间件的更多优秀示例,请参阅 redux.js.org/docs/advanced/Middleware.html#seven-examples。
10.3. 摘要
本章涵盖了以下主要要点:
-
Redux 是一个库和应用程序架构,不需要与任何特定的库或框架一起使用。它与 React 工作得特别出色,在许多 React 应用程序中作为状态管理和应用程序架构的首选工具而广受欢迎。
-
Redux 专注于可预测性并强制执行严格的数据处理方式。
-
store 是一个对象,作为应用程序的真相来源;它是应用程序的全局状态。
-
Flux 允许你拥有多个 store,但 Redux 只允许一个。
-
Reducers 是 Redux 用于根据给定操作计算状态变化的函数。
-
Redux 在许多方面与 Flux 相似,但引入了 reducers 的概念,拥有单个 store,并且其动作创建者不会直接派发动作。
-
动作包含有关发生的事情的信息。它们必须有一个类型,但可以包含您的 store 和 reducers 需要的任何其他信息,以确定如何更新状态。在 Redux 中,整个应用程序有一个单一的状态树;所有状态都生活在同一个区域,并且只能通过特定的 API 进行更新。
-
Action creators 是返回可以由存储分发的动作的函数。在有某些中间件(见下一条)的情况下,你可以创建异步动作创建器,这对于调用远程 API 等操作非常有用。
-
Redux 允许你编写中间件,这是一个将自定义行为注入 Redux 状态管理过程的地方。中间件在触发 reducer 之前执行,并允许你执行副作用或为你的应用实现全局解决方案。
在下一章中,你将在学习 reducer 并将其集成到你的 React 应用中时继续使用 Redux。
第十一章. 更多 Redux 和将 Redux 与 React 集成
本章涵盖
-
Reducer,Redux 确定状态如何变化的方式
-
使用 Redux 与 React
-
将 Letters Social 转换为使用 Redux 应用架构
-
为你的应用添加点赞和评论功能
在本章中,你将继续上一章的工作,构建你的 Redux 架构的基本元素。你将努力将 React 与你的 Redux 动作和存储进行集成,并探索 reducer 的工作原理。Redux 是一个针对 React 设计的 Flux 模式的变体,它与 React 的单向数据流和 API 一起工作得很好。尽管它不是通用的选择,但许多大型 React 应用在实现状态管理解决方案时,会将 Redux 作为首选方案之一。你将效仿此做法,并在 Letters Social 中这样做。
我如何获取本章的代码?
与每一章一样,你可以通过访问 GitHub 仓库 github.com/react-in-action/letters-social 来查看本章的源代码。如果你想从一张白纸开始,并跟随学习,你可以使用你现有的 第七章 和 第八章 的代码(如果你跟随并自己构建了示例)或检出特定章节的分支(chapter-10-11)。
记住,每个分支都对应着章节末尾的代码(例如,分支 chapter-10-11 对应着本章末尾的代码)。你可以在你选择的目录中执行以下终端命令之一,以获取当前章节的代码。
如果你根本就没有仓库,请输入以下内容:
git clone git@github.com:react-in-action/letters-social.git
如果你已经克隆了仓库:
git checkout chapter-10-11
你可能从另一章来到这里,所以始终确保你已经安装了所有正确的依赖项:
npm install
11.1. Reducer 确定状态如何变化
你可以创建和分发动作并处理错误,但这些动作还没有做任何事情来改变你的状态。要处理传入的动作,你需要设置 reducer。记住,动作只是描述发生了什么以及发生了什么的一些信息的方式,但仅此而已。reducer 的职责是指定状态应该如何响应这些动作。
展示了 reducer 如何融入我们一直在研究的 Redux 更广泛的画面中。
图 11.1。Reducer 只是帮助确定应该对状态进行哪些更改的函数。你可以把它们看作是进入应用程序状态的某种类型的网关,它紧密控制着传入的更改。

但什么是 reducer 呢?如果你到目前为止一直喜欢 Redux 的简单直接,那么你不会对 reducer 感到失望:它们只是具有单一目的的更简单的函数。Reducer 是纯函数,它们接收先前的状态和一个动作作为参数,并返回下一个状态。根据 Redux 文档,它们被称为 reducer,因为它们的函数签名看起来就像你会传递给 Array.prototype.reduce 的内容(例如,[1,2,3].reduce((a, b) => a + b, 0))。
Reducers 必须是纯函数,这意味着给定一个输入,它们将每次都产生相同的关联输出。这与产生副作用或进行 API 调用的动作或中间件形成对比。在 reducer 中进行任何异步或不纯的操作(如调用Date.now或Math.random())是一种反模式,可能会降低应用程序的性能或可靠性。Redux 文档强调了这一点:“给定相同的参数,它应该计算下一个状态并返回它。没有惊喜。没有副作用。没有 API 调用。没有突变。只是一个计算。”关于这一点,请参阅redux.js.org/basics/reducers。
11.1.1。状态形状和初始状态
Reducers 将开始修改单个 Redux 存储,因此这是一个讨论该存储将采取什么形状的好时机。设计任何应用程序的状态形状将既会影响又会受到应用程序 UI 工作方式的影响,但通常一个好的做法是尽可能地将“原始”数据与 UI 数据分开。做到这一点的一种方法是将像 ID 这样的东西与其对应物分开存储,并使用 ID 来查找数据。
你将创建一个初始状态文件,这将帮助你确定状态形状和结构。在 constants 文件夹中,创建一个名为 initialState.js 的文件。这将是在任何动作被分发或任何更改之前你的 Redux 应用的状态。你将包括错误和加载状态的信息,以及有关帖子、评论和用户的一些信息。你将把评论和帖子的 ID 存储在数组中,并将这些对象的主要信息存储在可以轻松引用的对象中。以下列表显示了设置初始状态的示例。
列表 11.1. 初始状态和状态形状(src/constants/initialState.js)
export default { *1*
error: null,
loading: false,
postIds: [], *2*
posts: {}, *2*
commentIds: [], *2*
comments: {}, *2*
pagination: { *3*
first: `${process.env
.ENDPOINT}/posts?_page=1&_sort=date&_order=DESC&
_embed=comments&_expand=user&_embed=likes`, *3*
next: null,
prev: null,
last: null
},
user: { *4*
authenticated: false,
profilePicture: null,
id: null,
name: null,
token: null
}
};
-
1 Redux 将用于其初始状态的对象
-
2 将存储评论和帖子的 ID 与实际数据分开。
-
3 存储分页链接(通过 HTTP 头接收)——这只是分页的一种方法。
-
4 存储有关用户认证状态的信息
11.1.2. 设置 reducer 以响应传入的动作
在设置好初始状态后,你应该创建一些 reducer 来处理传入的动作,以便你的 store 能够更新。Reducer 通常使用switch语句来匹配传入的动作类型,以便更新状态。它们返回状态的新副本(而不是带有更改的同一版本),然后将被用来更新 store。Reducer 还执行捕获所有行为的操作,以确保未知动作只返回现有状态。我们之前已经提到过,但重要的是再次强调,reducer 正在执行计算,并且应该根据给定的输入每次都返回相同的输出;不应启动任何副作用或不纯过程。
Reducer 负责计算 store 应该如何变化。在大多数应用中,你将有许多 reducer,每个 reducer 将负责 store 的一部分。这有助于保持文件整洁并保持专注。你最终将使用 Redux 提供的combineReducers方法,将你的 reducer 合并为一个。大多数 reducer 使用switch语句,其中包含不同动作类型的案例,并在底部有一个默认的捕获所有行为的案例,以确保未知动作类型(可能是意外创建的)不会对状态产生任何意外的效果。
Reducer 还会复制状态,并且不会直接修改现有的存储状态。如果你回顾一下 图 11.1,你会看到 reducer 在执行其任务时使用状态。这种方法类似于不可变数据结构通常工作的方式;修改的是副本而不是直接修改。列表 11.2 展示了如何设置加载 reducer。注意,在这种情况下,你只处理一个“扁平”的状态切片——布尔 loading 属性——所以你只需为新的状态返回 true 或 false。你将经常处理具有许多键或嵌套属性的 state 对象,并且你的 reducer 需要做的不仅仅是返回 true 或 false。
列表 11.2. 设置加载 reducer(src/reducers/loading.js)
import initialState from '../constants/initialState';
import * as types from '../constants/types';
export function loading(state = initialState.loading, action) { *1*
switch (action.type) { *2*
case types.app.LOADING: *3*
return true; *3*
case types.app.LOADED: *4*
return false;
default: *5*
return state;
}}
-
1 函数接受两个参数,状态和动作
-
2 通常,你会使用 switch 语句显式处理每种类型的动作并返回状态。
-
3 如果动作具有加载类型,则返回新状态值为 true
-
4 处理已加载情况并返回适当的 false 情况
-
5 默认返回现有状态
现在当加载相关动作被分发时,Redux 存储将能够对此做出响应。当一个动作到来并且通过了任何现有的中间件,Redux 将调用 reducer 来确定应该基于该动作创建什么新状态。在你设置任何 reducer 之前,你的存储无法知道动作中包含的更改信息。为了可视化这一点,图 11.2 切除了 reducer 从流程中;看看是否有办法让动作达到存储?
图 11.2. 当 reducer 设置到位后,Redux 将知道在动作分发时如何更改存储。在一个中等复杂的应用中,你通常会有许多不同的 reducer,每个 reducer 负责存储状态的“切片”。

接下来,你将创建另一个 reducer 来运用你的 Redux 技能。毕竟,许多 reducer 不会只是返回一个true或false值,或者至少如果它们这样做,计算那个true或false值的过程中可能还有更多。Letters Social 应用的关键部分之一是显示和创建帖子,你需要将其迁移到 Redux。就像你可能将一个真实的 React 应用迁移到使用 Redux 一样,你应该能够保留应用使用的现有逻辑的大部分,并将其转换为 Redux 友好的形式。你将创建两个 reducer 来处理帖子本身,以及一个用于跟踪帖子 ID 的 reducer。在一个更大的应用中,你可能会将这些组合在另一个键下,但保持它们分开现在是可以的。这也作为了如何设置多个 reducer 来处理单个操作的示例。列表 11.3 显示了如何创建注释的 reducer。你将在这里创建相当多的 reducer,但一旦完成,你的应用不仅将有一个关于可能发生的操作的全面描述,还将有关于状态如何改变的方法。
列表 11.3. 创建注释 reducer(src/reducers/comments)
import initialState from '../constants/initialState'; *1*
import * as types from '../constants/types';
export function comments(state = initialState.comments, action) { *2*
switch (action.type) { *3*
case types.comments.GET: { *4*
const { comments } = action; *4*
let nextState = Object.assign({}, state); *4*
for (let comment of comments) {
if (!nextState[comment.id]) {
nextState[comment.id] = comment;
}
}
return nextState; *5*
}
case types.comments.CREATE: { *6*
const { comment } = action;
let nextState = Object.assign({}, state);
nextState[comment.id] = comment;
return nextState;
}
default: *7*
return state;
}
}
export function commentIds(state = initialState.commentIds, action) {
switch (action.type) {
case types.comments.GET: {
const nextCommentIds = action.comments.map(comment =>
comment.id); *8*
let nextState = Array.from(state); *9*
for (let commentId of nextCommentIds) {
if (!state.includes(commentId)) {
nextState.push(commentId);
}
}
return nextState;
}
case types.comments.CREATE: { *10*
const { comment } = action;
let nextState = Array.from(state);
nextState.push(comment.id);
return nextState;
}
default:
return state;
}
}
-
1 拉取初始状态
-
2 Reducer 是接受状态对象和操作的函数。
-
3 使用 switch 语句确定如何响应传入的操作
-
4 对于 GET,复制状态并添加你尚未拥有的注释
-
5 返回新状态
-
6 向状态添加新注释
-
7 默认返回相同状态
-
8 你只在这里想要 ID,因为你会将它们与主要对象分开存储。
-
9 创建前一个状态副本
-
10 推入新 ID
现在你分发与注释相关的操作时,你的存储状态将相应更新。你注意到你能够响应那些并非严格相同类型的操作吗?即使它们不是同一类型的,reducer 也可以响应其范围内的操作。这是因为尽管“帖子”状态片段管理帖子,但还有其他可能影响它的操作。这里的要点是,reducer 负责决定状态的一个特定方面应该如何改变,无论哪个操作或哪种类型的操作正在通过。一些 reducer 可能需要了解许多不同类型的操作,而这些操作并非特别与它们所模拟的资源(帖子)相关。
现在你已经创建了注释 reducer,你可以创建处理帖子的 reducer。因为它将使用相同的策略来存储它们,即分别存储 ID 和对象,所以它将与注释 reducer 非常相似。它还需要知道如何处理点赞和取消点赞帖子(你已经在第十章中创建了这些功能的操作)。下面的列表显示了如何创建这些 reducer。
列表 11.4. 创建帖子 reducers(src/reducers/posts.js)
import initialState from '../constants/initialState';
import * as types from '../constants/types';
export function posts(state = initialState.posts, action) {
switch (action.type) {
case types.posts.GET: { *1*
const { posts } = action;
let nextState = Object.assign({}, state);
for (let post of posts) {
if (!nextState[post.id]) {
nextState[post.id] = post;
}
}
return nextState;
}
case types.posts.CREATE: {
const { post } = action;
let nextState = Object.assign({}, state);
if (!nextState[post.id]) {
nextState[post.id] = post;
}
return nextState;
}
case types.comments.SHOW: { *2*
let nextState = Object.assign({}, state);
nextState[action.postId].showComments = true;
return nextState;
}
case types.comments.TOGGLE: { *2*
let nextState = Object.assign({}, state);
nextState[action.postId].showComments =
!nextState[action.postId].showComments;
return nextState;
}
case types.posts.LIKE: { *3*
let nextState = Object.assign({}, state);
const oldPost = nextState[action.post.id];
nextState[action.post.id] = Object.assign({}, oldPost, action.post);
return nextState;
}
case types.posts.UNLIKE: { *3*
let nextState = Object.assign({}, state);
const oldPost = nextState[action.post.id];
nextState[action.post.id] = Object.assign({}, oldPost, action.post);
return nextState;
}
case types.comments.CREATE: {
const { comment } = action;
let nextState = Object.assign({}, state);
nextState[comment.postId].comments.push(comment);
return state;
}
default:
return state;
}
}
export function postIds(state = initialState.postIds, action) { *4*
switch (action.type) {
case types.posts.GET: {
const nextPostIds = action.posts.map(post => post.id);
let nextState = Array.from(state);
for (let post of nextPostIds) {
if (!state.includes(post)) {
nextState.push(post);
}
}
return nextState;
}
case types.posts.CREATE: {
const { post } = action;
let nextState = Array.from(state);
if (!state.includes(post.id)) {
nextState.push(post.id);
}
return nextState;
}
default:
return state;
}
}
-
1 处理获取新帖子
-
2 显示或切换帖子的评论
-
3 点赞/取消点赞帖子涉及使用 API 的新数据更新状态中的特定帖子
-
4 以处理评论相同的方式处理新 ID
我在这几个文件中包含了两个 reducer,因为它们非常相关,并且都作用于相同的基本数据(帖子与评论),但你可能会发现,大多数情况下你希望每个文件有一个 reducer 以保持事情简单。大多数情况下,你的 reducer 设置将与你的 store 结构相似或至少遵循其结构。你可能已经注意到一个细微之处,即你如何设计你的 store 状态形状(参见本章前面设置的初始状态)将极大地影响你的 reducers 以及,在一定程度上,你的 actions 的定义。从这个角度来看,通常花更多的时间来设计状态形状比草率地处理它要好。设计时间过少可能会导致大量返工以改进状态形状,而稳健的设计加上 Redux 提供的模式可以使添加新功能比不添加它更容易。
迁移到 Redux:值得吗?
在本章中,我提到过几次 Redux 的初始设置可能是一项大量工作(也许你现在就有这种感觉!)但最终通常是有价值的。显然,这并不适用于所有可能的情况,但我发现我在工作的项目中以及我所知道的那些做过同样事情的工程师那里都是这样。我参与的一个项目涉及将应用从 Flux 迁移到 Redux 架构的完整迁移。整个团队可能花了大约一个月的时间,但我们能够以最小的不稳定性和错误创建来发布应用的重新编写版本。
然而,更大的整体成果是,由于 Redux 帮助我们建立的模式,我们可以更快地对产品进行迭代。在 Redux 迁移几个月后,我们最终进行了一系列应用的重设计。尽管我们最终重建了应用 React 部分的很大一部分,但 Redux 架构意味着我们只需要对应用的状态管理和业务逻辑部分进行相对较少的更改。更重要的是,Redux 为我们提供的模式使得在必要时向应用状态中添加内容变得非常简单。集成 Redux 值得最初的工作来设置它并将应用迁移到它,而且它继续在长期内带来回报。
在处理了一些更复杂的 reducers 之后,你可以通过创建错误、分页和用户的 reducers 来完成我们的 Redux 工作。以下列表以错误 reducer 开始。
列表 11.5. 创建错误 reducer(src/reducers/error.js)
import initialState from '../constants/initialState';
import * as types from '../constants/types';
export function error(state = initialState.error, action) {
switch (action.type) {
case types.app.ERROR: *1*
return action.error;
default:
return state;
}
}
- 1 这个状态片段并不复杂;在动作中传递错误
接下来,你需要确保你的分页状态可以更新。目前,分页仅与帖子相关,但在更大的应用程序中,你可能需要为应用程序的许多不同部分设置分页(例如,当你有一个包含太多评论而无法一次性合理显示的帖子时)。对于你的示例应用程序,你只需要处理简单的分页,因此请创建以下列表中的分页 reducer。
列表 11.6. 创建分页 reducer(src/reducers/pagination.js)
import initialState from '../constants/initialState';
import * as types from '../constants/types';
export function pagination(state = initialState.pagination, action) {
switch (action.type) {
case types.posts.UPDATE_LINKS: *1*
const nextState = Object.assign({}, state); *2*
for (let k in action.links) { *2*
if (action.links.hasOwnProperty(k)) {
if (process.env.NODE_ENV === 'production') { *3*
nextState[k] =
action.links[k].url.replace(/http:\/\//, 'https://'); *3*
} else {
nextState[k] = action.links[k].url; *4*
}
}
}
return nextState;
default:
return state;
}
}
-
1 使用新的分页信息更新那些链接 URL
-
2 创建前一个状态的新副本,并将动作的有效负载中的 URL 合并进去
-
3 由于 Letters Social 在部署到 Zeit (
zeit.co/now)时终止 SSL 的怪异行为——如果你没有自己部署应用程序,请忽略 -
4 更新每种链接类型的 URL
现在,你需要创建一个 reducer,它将允许你响应用户相关的事件,如登录和登出。在这个 reducer 中,你还将处理在浏览器上存储一些 cookie,以便你可以在第十二章(kindle_split_024_split_000.xhtml#ch12)中进行服务器端渲染时使用它们。Cookies 是服务器可以发送到用户 Web 浏览器的小数据块。你可能因为每天使用计算机而熟悉 cookie(由于法律原因,在某些网站上你会收到关于它们的通知),但也许你以前从未以编程方式处理过它们。没关系。你将使用js-cookie库与 cookie 交互,而你将做的所有事情就是在用户的身份验证状态改变时设置和取消设置一个特定的 cookie。以下列表显示了创建用户 reducer 以执行此操作。
列表 11.7. 创建用户 reducer(src/reducers/user.js)
import Cookies from 'js-cookie'; *1*
import initialState from '../constants/initialState';
import * as types from '../constants/types';
export function user(state = initialState.user, action) {
switch (action.type) {
case types.auth.LOGIN_SUCCESS:
const { user, token } = action; *2*
Cookies.set('letters-token', token); *3*
return Object.assign({}, state.user, { *4*
authenticated: true,
name: user.name,
id: user.id,
profilePicture: user.profilePicture ||
'/static/assets/users/4.jpeg',
token
});
case types.auth.LOGOUT_SUCCESS: *5*
Cookies.remove('letters-token');
return initialState.user;
default:
return state;
}
}
-
1 导入 js-cookie 库以使用
-
2 从动作中提取用户和令牌
-
3 使用 js-cookie 在浏览器中存储令牌作为 cookie
-
4 返回包含新用户数据和令牌的状态副本
-
5 在登出时,将用户状态重置为初始状态并擦除 cookie
11.1.3. 在我们的存储中组合 reducers
最后,你需要确保你的 reducer 已与你的 Redux 存储集成。尽管你已经创建了它们,但它们目前没有任何连接。让我们回顾一下你在第十章中创建的根 reducer,看看如何向其中添加新的 reducer。列表 11.8 展示了如何将你创建的 reducer 添加到根 reducer。这里需要注意的是,combineReducers 将根据你传入的 reducer 在你的存储中创建键的方式。对于列表 11.8 中的情况,你的存储状态将具有 loading 和 posts 键,每个键分别由相应的 reducer 管理。我在这里使用的是 ES2015 属性简写,但如果我想的话,也可以将最终的键命名为不同的名称。这一点很重要,以免你觉得你的函数名必须直接与存储上的键相关联。
列表 11.8. 向现有根 reducer 添加新 reducer(src/reducers/root.js)
import { combineReducers } from 'redux';
import { error } from './error'; *1*
import { loading } from './loading';
import { pagination } from './pagination'; *1*
import { posts, postIds } from './posts';
import { user } from './user';
import { comments, commentIds } from './comments'; *1*
const rootReducer = combineReducers({
commentIds, *2*
comments,
error,
loading,
pagination, *2*
postIds,
posts,
user *2*
});
export default rootReducer;
-
1 导入 reducer 以将其添加到根 reducer
-
2
combineReducers将在每个对应的键上挂载每个 reducer,但如果你希望的话,可以更改名称
11.1.4. 测试 reducer
由于 Redux reducer 的纯函数和松耦合特性,测试 Redux reducer 是直接的——毕竟,它们只是函数。为了测试你的 reducer,你需要断言给定一定的输入,它们应该产生一定的状态。下一个列表展示了如何测试你为状态切片的 posts 和 post ID 创建的 reducer。与其他 Redux 部分一样,由于 reducer 也是函数,这使得它们易于隔离和测试。
列表 11.9. 测试 reducer(src/reducers/posts.test.js)
jest.mock('js-cookie'); *1*
import Cookies from 'js-cookie';
import { user } from '../../src/reducers/user'; *2*
import initialState from '../../src/constants/initialState'; *2*
import * as types from '../../src/constants/types'; *2*
describe('user', () => {
test('should return the initial state', () => {
expect(user(initialState.user, {})).toEqual(initialState.user); *3*
});
test(`${types.auth.LOGIN_SUCCESS}`, () => {
const mockUser = { *4*
name: 'name',
id: 'id',
profilePicture: 'pic'
};
const mockToken = 'token'; *4*
const expectedState = { *4*
name: 'name',
id: 'id',
profilePicture: 'pic',
token: mockToken,
authenticated: true
};
expect( *5*
user(initialState.user, {
type: types.auth.LOGIN_SUCCESS,
user: mockUser,
token: mockToken
})
).toEqual(expectedState);
expect(Cookies).toHaveBeenCalled(); *6*
});
test(`${types.auth.LOGOUT_SUCCESS}, browser`, () => {
expect( *7*
user(initialState.user, {
type: types.auth.LOGOUT_SUCCESS
})
).toEqual(initialState.user);
expect(Cookies).toHaveBeenCalled();
});
});
-
1 模拟 js-cookie 库
-
2 导入测试所需的 reducer 和类型
-
3 断言默认将返回初始状态
-
4 创建模拟用户、令牌和预期的状态以进行断言
-
5 给定一个登录动作,断言状态按预期改变
-
6 断言你的 cookies 模拟被调用
-
7 对 LOGOUT_SUCCESS 动作执行类似的断言
通过这些,我们已经涵盖了 Redux 应用程序的大部分基础知识:store、reducers、actions 和 middleware!Redux 生态系统强大,还有更多你可以自己探索的领域。我们省略了一些 API 和/或 Redux 生态系统的一部分,比如高级 middleware 使用、selectors(与 store 状态交互的优化方式)等。我们还特别省略了广泛介绍 store API(例如,使用 store.subscribe() 与更新事件交互)。这是因为与 Redux 这一部分工作的细节将通过 react-redux 库进行抽象。如果你对这些领域有更深入的兴趣,并想了解更多关于 Redux 的信息,请参阅 redux.js.org。我还在我的博客 ifelse.io/react-ecosystem 上整理了一份关于 React 生态系统的指南,其中也包括了 Redux。
真或假
Redux 对于它所做的事情来说是一个相对较小的库,但它对 store、reducer、actions 和 middleware 中的数据流工作方式有一些“强烈”的观点。花点时间评估以下陈述,以检查你的理解:
-
T | F Reducers 应该直接修改现有的状态。
-
T | F Redux 默认包含一种执行异步工作(例如网络请求)的方式。
-
T | F 默认为每个 reducer 包含一个初始状态是一个好主意。
-
T | F Reducers 可以组合,这使得分离状态片段更容易。
11.2. 将 React 和 Redux 结合起来
你已经在 Redux 上取得了进展,但你的 React 组件目前对此一无所知。你需要以某种方式将它们结合起来。现在你已经完成了 Redux 设置过程,通过构建出 reducers、actions 和一个 store 来使用,你可以开始将你的新架构与 React 集成。你可能已经注意到,你不需要做太多 React 的工作就能让 Redux 运行起来。这是因为 Redux 可以在不考虑特定框架——或者任何框架的情况下实现。当然,Redux 的工作方式与 React 应用程序特别契合,这也是它成为 React 应用程序架构中最受欢迎的选择之一的原因。但请记住,即使你开始将 React 和 Redux 集成,你也可以将其与 Angular、Vue、Preact 或 Ember 集成。
11.2.1. 容器组件与展示组件
当将 Redux 集成到 React 应用程序中时,你几乎肯定会使用react-redux库。这个库作为抽象层,涵盖了 Redux 存储和动作与 React 组件的集成。我将介绍一些你可以使用react-redux的方法,包括如何将动作引入你的组件,并讨论一些新的组件类型:表现性组件和容器组件。你不再需要在你的许多组件之间分配状态,因为 Redux 通过动作、reducer 和存储负责管理应用程序状态。再次提醒,创建不使用 Redux 的 React 应用程序并没有什么固有的错误;你仍然会得到使用 React 带来的所有其他好处。Redux 的可预测性和附加结构使得设计和维护大型、复杂的 React 应用程序更容易,这也是为什么许多团队会选择使用它而不是“纯”React。
这两种新的组件类别(表现性和容器)实际上只是对组件已经执行的功能的两种更专注的表达。普通组件与表现性或容器组件之间的区别在于它们的功能。而不是允许任何组件处理样式、UI 数据和以及应用程序数据,表现性组件处理 UI 和 UI 相关数据,而容器组件处理应用程序数据(类似于 Redux)。
理解容器组件和表现性组件之间的区别很重要,但你的应用程序仍在做同样的事情,只是关注点分离得更好。你并没有在应用程序中引入任何根本性的新内容;你的 React 组件仍然会接收属性、维护状态、响应用件,并以与之前相同的生命周期渲染。react-redux提供的关键区别在于将你的存储、reducer 和动作与组件集成。表现性组件和容器组件之间的新划分只是一个可以使你的生活更轻松的模式。
让我们来看看在具有 Redux 架构的 React 应用程序中使用的这两种一般类型的组件。正如所提到的,表现性组件是“仅 UI”组件。这意味着它们通常不会与确定应用程序数据如何更改、更新或发出的方式有很大关系。
下面是一些关于表现性组件的基本知识:
-
它们处理的是事物的外观,而不是数据流或确定的方式。
-
如果有必要,它们只有自己的状态(它们是带有后端实例的 React 类);大多数时候,它们应该是无状态的函数组件,通过
react-redux绑定从 Redux 接收属性。 -
当它们确实有自己的状态时,它应该是 UI 相关数据,而不是应用程序数据。例如:一个打开/关闭的下拉菜单项及其状态。
-
它们不决定数据如何加载或更改——这应该在容器中主要发生。
-
它们通常是通过“手动”创建的,而不是通过
react-redux库。 -
它们可能包含样式信息,例如 CSS 类、其他与样式相关的组件以及任何其他与 UI 相关的数据。
如果你正在探索 React/Redux 生态系统,你可能会偶尔看到对智能(容器)和愚笨(展示)组件的引用。这种称呼方式已经不再流行,因为它被发现是不有帮助的,并且带有贬义倾向,但如果你看到这种术语被使用,你将能够将其映射到展示/容器二分法。考虑到这一点,容器组件通常执行以下操作:
-
作为数据源,可以是状态化的;状态通常来自你的 Redux 存储。
-
向展示组件提供数据和行为信息(如动作)。
-
可以包含其他展示组件或容器组件;容器作为父组件拥有许多展示子组件是很常见的。
-
通常使用
react-redux的connect方法(稍后将详细介绍)创建,通常是高阶组件(从其他组件创建新组件的组件)。 -
通常不需要与应用数据无关的样式信息。例如,Redux 存储中的用户配置文件状态切片可能会记录用户的“最喜欢的颜色”为“红色”,但容器不会使用这些数据来进行任何样式设计——它只会将其传递给一个展示组件。
在本章中,我们将采取一种中间方法来将你的组件分解为展示组件和连接或容器组件。对于你想连接到 Redux 存储的每个组件,你将执行以下操作:
-
通过导出一个连接组件以及常规组件来修改它。
-
将任何属性和状态移动到
react-redux可以使用(稍后将详细介绍)的特殊函数中。 -
引入你需要的任何动作,并将这些动作绑定到组件将拥有的
actions属性。 -
在适当的地方用映射到 Redux 存储状态的属性替换本地状态。
图 11.3 应该能帮助你更好地理解一个连接组件通常是如何工作的;相同的 Redux 方面存在,但基本上是围绕 React 组件“重新排列”的,以便将存储的更新传递给组件。
图 11.3. 将 Redux 与 React 集成。react-redux提供了帮助你生成组件(高阶组件;生成其他组件的组件)的实用工具。

本章没有足够的空间涵盖将本书中接触到的每个组件都转换为容器组件,但容器组件和展示组件之间的区别以及你如何将 Redux 与 React 集成应该为你提供一些良好的起点实践,指引你走向正确的方向。
11.2.2. 使用将组件连接到 Redux 存储
将你的 Redux 设置集成到你的 React 应用中的第一步是将整个应用包裹在 react-redux 提供的 Provider 组件中。该组件接受一个 Redux store 作为属性,并将该 store 提供给你的“连接”组件——这是连接到 Redux 的组件的另一种描述。在几乎所有情况下,这是你的 React 组件和 Redux 之间的集成中心点。store 必须可用于你的容器,否则你的应用可能无法正常工作(或者可能根本无法工作)。以下列表显示了如何使用 Provider 组件并更新身份验证监听器以处理你的 Redux actions。
列表 11.10. 使用 react-redux 的
import React from 'react';
import { render } from 'react-dom';
import { Provider } from 'react-redux';
import Firebase from 'firebase';
import * as API from './shared/http';
import { history } from './history';
import configureStore from './store/configureStore'; *1*
import initialReduxState from './constants/initialState'; *1*
import Route from './components/router/Route';
import Router from './components/router/Router';
import App from './app';
import Home from './pages/home';
import SinglePost from './pages/post';
import Login from './pages/login';
import NotFound from './pages/404';
import { createError } from './actions/error'; *1*
import { loginSuccess } from './actions/auth'; *1*
import { loaded, loading } from './actions/loading'; *1*
import { getFirebaseUser, getFirebaseToken } from './backend/auth';
import './shared/crash';
import './shared/service-worker';
import './shared/vendor';
import './styles/styles.scss';
const store = configureStore(initialReduxState); *2*
const renderApp = (state, callback = () => {}) => {
render(
<Provider store={store}> *3*
<Router {...state}>
<Route path="" component={App}>
<Route path="/" component={Home} />
<Route path="/posts/:postId" component={SinglePost} />
<Route path="/login" component={Login} />
<Route path="*" component={NotFound} />
</Route>
</Router>
</Provider>,
document.getElementById('app'),
callback
);
};
const initialState = {
location: window.location.pathname
};
// Render the app initially
renderApp(initialState);
history.listen(location => { *4*
const user = Firebase.auth().currentUser;
const newState = Object.assign(initialState, { location: user ?
location.pathname : '/login' });
renderApp(newState);
});
getFirebaseUser() *5*
.then(async user => {
if (!user) {
return history.push('/login');
}
store.dispatch(loading()); *5*
const token = await getFirebaseToken();
const res = await API.loadUser(user.uid);
if (res.status === 404) { *6*
const userPayload = {
name: user.displayName,
profilePicture: user.photoURL,
id: user.uid
};
const newUser = await API.createUser(userPayload).then(res =>
res.json());
store.dispatch(loginSuccess(newUser, token)); *6*
store.dispatch(loaded());
history.push('/');
return newUser;
}
const existingUser = await res.json();
store.dispatch(loginSuccess(existingUser, token)); *7*
store.dispatch(loaded());
history.push('/');
return existingUser;
})
.catch(err => createError(err));
//...
-
1 导入你在这里需要的与 redux 相关的模块
-
2 使用初始状态创建 Redux store
-
3 使用来自 react-redux 的 Provider 包装你的路由器,并传递它 store
-
4 历史监听器保持不变
-
5 从 Firebase 获取用户并分发加载操作
-
6 如果还没有,创建新用户并分发用户/token
-
7 加载现有用户并分发
现在 store 将可用于你的组件,你可以将它们连接到你的 store。你会记得从图 11.3 中,react-redux 将将 store 状态注入到你的组件作为 props,并在 store 更新时更改这些 props。如果你没有使用 react-redux,你需要手动在每个组件的基础上订阅 store 的更新。
要实现这一点,你需要使用来自 react-redux 的 connect 工具。它将生成一个连接到 Redux store 的容器组件(因此得名),并在 store 更改时应用更新。connect 方法只有几个参数,但它的内容比最初看起来要多;你可以在github.com/reactjs/react-redux 上了解更多。对于你的目的,你将使用订阅 store 的能力以及注入 store 的 dispatch 函数,以便为你的组件创建 actions。
要注入状态,你需要传递一个函数(mapStateToProps),该函数将接收 state 作为参数,并将返回一个将被合并到组件 props 中的对象;react-redux 将在组件接收新 props 时重新调用此函数。一旦你使用 connect 来包裹你的组件,你将需要调整组件中 props 的使用方式(我将在下一部分介绍 actions);state 不应使用,除非它与 UI 特定的数据相关。记住,尽管这被认为是最佳实践,但这并不意味着没有模糊表现性和容器组件之间界限的有效案例;它们确实存在,即使它们很罕见;为你的团队和特定情况做出最佳工程决策。
列表 11.11 展示了如何使用 connect 以及如何调整你在我们的 Home 组件中访问 props 的方式,并将其转换为无状态函数组件。你将使用传递给 connect 的两个参数中的第一个:mapStateToProps。这个函数将接收状态(存储状态)并可以有一个额外的参数,ownProps,它将传递给容器组件的任何额外的 props。你现在不会使用这个参数,但 API 提供了它以防你需要它。
列表 11.11. mapStateToProps(src/pages/Home.js)
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import orderBy from 'lodash/orderBy'; *1*
import Ad from '../components/ad/Ad'; *2*
import CreatePost from '../components/post/Create'; *2*
import Post from '../components/post/Post'; *2*
import Welcome from '../components/welcome/Welcome'; *2*
export class Home extends Component {
render() {
return (
<div className="home">
<Welcome />
<div>
<CreatePost />
{this.props.posts && ( *3*
<div className="posts">
{this.props.posts.map(post => (
<Post
key={post.id} *4*
post={post} *4*
/>
))}
</div>
)}
<button className="block">
Load more posts
</button>
</div>
<div>
<Ad url="https://ifelse.io/book" imageUrl="/static/
assets/ads/ria.png" />
<Ad url="https://ifelse.io/book" imageUrl="/static/
assets/ads/orly.jpg" />
</div>
</div>
);
}
}
//...
export const mapStateToProps = state => {
const posts = orderBy(state.postIds.map(postId => state.posts[postId]),
'date', 'desc'); *5*
return { posts }; *6*
};
export default connect(mapStateToProps)(Home); *7*
-
1 使用 Lodash 的 orderBy 函数对帖子进行排序
-
2 导入显示在主页上的组件
-
3 遍历帖子
-
4 传递帖子及其 ID(
mapStateToProps将进一步处理) -
5 使用 orderBy 对帖子进行映射和排序
-
6
mapStateToProps函数返回连接组件的 props -
7 导出连接组件
当你现在运行应用时(使用 npm run dev),你不应该遇到任何运行时错误,但你也应该看不到任何帖子,因为没有任何操作在执行。但是,如果你打开 React 开发者工具,你应该能够看到 react-redux 正在创建你的连接组件。注意 connect 是如何创建另一个组件来包裹你传递给它的组件,并给它一组新的 props。在幕后,它还将订阅来自 Redux 存储的更新,并将它们作为新的 props 传递给你的容器。图 11.4 展示了当你同时打开开发者工具和你的应用时你应该看到的内容。
图 11.4. 如果你打开 React 开发者工具,你将能够通过 connect 挑选出新连接的组件以及它通过 connect 传递给它的 props。注意 connect 函数是如何创建一个新的组件来包裹你传递给它的组件的。

11.2.3. 将操作绑定到组件事件处理器
你需要让你的应用再次响应用户操作。你将使用第二个函数来完成这个任务:mapDispatchToProps。这个函数正是其名字所暗示的——它有一个 dispatch 参数,这将作为存储的 dispatch 方法注入到你的组件中。你可能已经注意到,在 第十章 的 图 10.3 或者在你的 React 开发者工具中,容器已经有一个注入到其 props 中的 dispatch 方法;你可以直接使用这个函数,因为它会自动注入,如果你没有提供 mapDispatchToProps 函数。但使用 mapDispatchToProps 的优点是,你可以用它将组件特定的操作逻辑与组件本身分离出来,并且它使得测试变得更加容易。
源代码作业
react-redux 库提供了一些经过许多公司和个人在 React 中使用 Redux 进行战斗测试的抽象。但您不必使用这个库来让 React 和 Redux 一起工作。作为一个练习,花些时间阅读 React-Redux 的源代码,网址为 github.com/reactjs/react-redux/tree/master/src。不建议您创建自己的方式来连接 React 和 Redux,但您应该能够看到这并不是“魔法”。
mapDispatchToProps 函数将由 react-redux 调用,并生成的对象将合并到您的组件属性中。您将使用它来设置您的动作创建器并使它们可用于您的组件。您还将利用 Redux 的 bindActionCreators 辅助实用工具。bindActionCreators 实用工具将值是动作创建器的对象转换为一个具有相同键的对象——区别在于每个动作创建器都被包装在一个调度调用中,因此可以直接调用。
您可能已经注意到在 列表 11.11 中,您使用了一个 React 类而不是无状态函数组件。创建无状态函数组件很常见,但在这个例子中,您需要一种方法来最初加载帖子,因此您需要可以在组件挂载时调度动作的生命周期方法。一种解决方案是将初始化事件卸载到路由层,并在进入或退出某些路由时协调加载数据。您当前的路由器没有考虑到生命周期钩子,但其他路由器,如 React-router,确实具有这个功能。我们将在下一章中探讨切换到 React Router,您将利用这个功能。
然后,剩下的就是使用 mapDispatchToProps 来获取您的动作并将它们绑定到您的组件上。您还可以创建一个对象,将函数分配给您喜欢的任何键。这种模式可以使直接引用您的动作更容易,如果 mapDispatchToProps 对象上的函数之间没有额外的逻辑,那么它们之间。
列表 11.12. 使用 mapDispatchToProps (src/containers/Home.js)
// ...
import { createError } from '../actions/error'; *1*
import { createNewPost, getPostsForPage } from '../actions/posts'; *1*
import { showComments } from '../actions/comments'; *1*
import Ad from '../components/ad/Ad';
import CreatePost from '../components/post/Create';
import Post from '../components/post/Post';
import Welcome from '../components/welcome/Welcome';
export class Home extends Component {
componentDidMount() { *2*
this.props.actions.getPostsForPage(); *2*
}
componentDidCatch(err, info) { *3*
this.props.actions.createError(err, info); *3*
}
render() {
return (
<div className="home">
<Welcome />
<div>
<CreatePost onSubmit={this.props.actions.createNewPost} />*4*
{this.props.posts && (
<div className="posts">
{this.props.posts.map(post => (
<Post
key={post.id}
post={post}
openCommentsDrawer=
{this.props.actions.showComments} *5*
/>
))}
</div>
)}
<button className="block"
onClick={this.props.actions.getNextPageOfPosts}> *6*
Load more posts
</button>
</div>
<div>
<Ad url="https://ifelse.io/book" imageUrl="/static/
assets/ads/ria.png" />
<Ad url="https://ifelse.io/book" imageUrl="/static/
assets/ads/orly.jpg" />
</div>
</div>
);
}
}
//...
export const mapDispatchToProps = dispatch => {
return {
actions: bindActionCreators( *7*
{
createNewPost,
getPostsForPage,
showComments, *7*
createError,
getNextPageOfPosts: getPostsForPage.bind(this, 'next') *8*
},
dispatch *7*
)
};
};
export default connect(mapStateToProps, mapDispatchToProps)(Home);
-
1 导入您将需要为此组件使用的动作
-
2 组件挂载时加载帖子
-
3 如果组件中发生错误,请使用 componentDidCatch 来处理它,将错误调度到存储
-
4 将创建帖子动作传递给 CreatePost 组件
-
5 通过 props 传递 showComments 动作
-
6 传递加载更多帖子的动作
-
7 使用 bindAction-Creators 将您的动作包装在调度调用中
-
8 使用 .bind() 确保每次调用 getPostsForPage 动作时都带有 ‘next’ 参数
有了这些,你已经将你的组件连接到 Redux!正如我之前提到的,没有足够的空间来涵盖将你应用中的每个组件都转换为使用 Redux。好消息是它们都遵循相同的模式(创建 mapStateToProps 和 mapDispatchToProps,使用 connect 导出),你应该能够将它们转换为以与这里对主页相同的方式与 Redux 交互。以下是你在应用程序源中连接到 Redux 存储的其他组件:
-
应用—src/app.js
-
评论—src/components/comment/Comments.js
-
错误—src/components/error/Error.js
-
导航—src/components/nav/navbar.js
-
帖子操作部分—src/components/post/PostActionSection.js
-
帖子—src/components/post/Posts.js
-
登录—src/pages/login.js
-
单帖子—src/pages/post.js
所有这些组件集成后,你的应用程序将过渡到使用 Redux!现在你知道了如何添加 Redux “循环”(动作创建者、处理动作的减少器以及连接任何组件),那么你将如何添加新功能,比如用户资料?你还可以向 Letters Social 添加哪些其他功能?幸运的是,Letters Social 应用程序有许多扩展区域和你可以尝试 Redux 的新方法。
11.2.4. 更新你的测试
当你将你的主页组件转换为 React 时,你破坏了你之前为其编写的测试。你现在将修复它。幸运的是,大部分的测试逻辑现在应该存在于其他地方,所以如果有什么的话,这些测试应该比之前简单。以下列表显示了主页组件更新的测试文件。
列表 11.13. 更新主页组件测试(src/containers/Home.test.js)
jest.mock('mapbox'); *1*
import React from 'react';
import renderer from 'react-test-renderer'; *1*
import { Provider } from 'react-redux';
import { Home, mapStateToProps, mapDispatchToProps } from
'../../src/pages/home'; *2*
import configureStore from '../../src/store/configureStore';
import initialState from '../../src/constants/initialState';
const now = new Date().getTime(); *2*
describe('Single post page', () => {
const state = Object.assign({}, initialState, { *2*
posts: {
2: { content: 'stuff', likes: [], date: now },
1: { content: 'stuff', likes: [], date: now }
},
postIds: [1, 2]
});
const store = configureStore(state); *3*
test('mapStateToProps', () => { *4*
expect(mapStateToProps(state)).toEqual({
posts: [
{ content: 'stuff', likes: [], date: now },
{ content: 'stuff', likes: [], date: now }
]
});
});
test('mapDispatchToProps', () => { *5*
const dispatchStub = jest.fn();
const mappedDispatch = mapDispatchToProps(dispatchStub);
expect(mappedDispatch.actions.createNewPost).toBeDefined();
expect(mappedDispatch.actions.getPostsForPage).toBeDefined();
expect(mappedDispatch.actions.showComments).toBeDefined();
expect(mappedDispatch.actions.createError).toBeDefined();
expect(mappedDispatch.actions.getNextPageOfPosts).toBeDefined();
});
test('should render posts', function() { *6*
const props = {
posts: [
{ id: 1, content: 'stuff', likes: [], date: now },
{ id: 2, content: 'stuff', likes: [], date: now }
],
actions: {
getPostsForPage: jest.fn(),
createNewPost: jest.fn(),
createError: jest.fn(),
showComments: jest.fn()
}
};
const component = renderer.create(
<Provider store={store}>
<Home {...props} />
</Provider>
);
let tree = component.toJSON();
expect(tree).toMatchSnapshot(); *7*
});
});
-
1 模拟 Mapbox,因为 CreateComment 组件将尝试使用它,引入测试渲染器从 react-test-renderer
-
2 使用一些帖子创建初始状态
-
3 使用初始状态创建存储
-
4 为了测试 mapState-ToProps,断言特定的状态将导致正确的属性
-
5 断言 mapDispatchToProps 函数具有所有正确的属性
-
6 执行快照测试以断言组件的输出没有改变
-
7 执行快照测试以断言组件的输出没有改变
11.3. 总结
本章你学到的主要内容:
-
减少器是 Redux 用来根据给定的动作计算状态变化的函数。
-
Redux 在许多方面与 Flux 类似,但引入了减少器的概念,有一个单一存储,并且其动作创建者不会直接派发动作。
-
动作包含有关发生的事情的信息。它们必须有一个类型,但可以包含任何其他信息,你的存储和减少器将需要这些信息来确定如何更新。在 Redux 中,整个应用程序有一个单一的状态树;状态都生活在同一个区域,并且只能通过特定的 API 进行更新。
-
Action creators 是返回可以由 store 分发的 actions 的函数。在有某些中间件(见下一条项目符号)的情况下,你可以创建异步 action creators,这对于执行诸如调用远程 API 等操作非常有用。
-
Redux 允许你编写中间件,这是一个将自定义行为注入 Redux 状态管理过程的地方。中间件在触发 reducers 之前执行,并允许你执行副作用或为你的应用实现全局解决方案。
-
react-redux为 React 组件提供了绑定,使你能够将你的组件连接到你的 store,处理新属性的传递,并检查 Redux 的更新(当 store 发生变化时)。 -
容器组件是只处理数据而不涉及 UI 相关内容的组件(想想“仅应用数据”)。
-
呈现组件只关注你可以看到的内容或 UI 特定的数据,例如下拉菜单是否打开(想想“你所看到的”)。
-
Redux 强制执行单向数据流模式,其中数据更改由响应 actions 的 reducers 计算并应用到 store 中。
在下一章中,你将探索现代网络应用中服务器端渲染的可能性,并开始使用 React 在服务器上。
第十二章. 服务器上的 React 和集成 React Router
本章涵盖
-
使用 React 进行服务器端渲染
-
何时以及何时不要将服务器端渲染添加到你的应用中
-
将你的路由设置过渡到 React Router
-
使用 React Router 处理认证路由
-
在服务器端渲染期间获取数据
-
在服务器端渲染过程中使用 Redux
你知道你可以在浏览器外使用 React 吗?这是因为 react-dom 库的一些部分不需要浏览器环境即可工作,并且可以在 node.js 运行时(或几乎任何具有足够语言支持的 JavaScript 运行时)上运行。公平地说,大多数非平台特定的 JavaScript 都可以在浏览器或服务器上运行;这会排除 node.js 平台相关的 IO 功能,如读取文件或加密,以及浏览器平台相关的用户相关事件或 DOM 相关方面。但是,随着 node.js 平台的稳健性和普及,越来越多的框架开始考虑服务器和浏览器支持。
这对 React 也是如此;它通过 React DOM 的服务器 API 支持服务器端渲染(SSR)。这意味着什么?SSR 通常是指生成可以发送到浏览器通过 HTTP 或其他协议的静态 HTML 标记;它仍然是“渲染”,但在服务器环境中。在某些情况下,在应用程序中集成 SSR 可能是有用的,而在其他情况下则是不必要的。在本章中,我们将探讨一些服务器端渲染的历史背景,看看何时可能需要实现它,将其集成到 Letters Social 应用程序中,并替换你在 第七章 和 第八章 中创建的路由,以更好地支持 SSR 并允许未来的改进。你将使用 React 实现一个简单的服务器端渲染版本,以便熟悉基本概念。
我如何获取本章的代码?
就像每一章一样,你可以通过访问 GitHub 仓库 github.com/react-in-action/letters-social 来查看本章的源代码。如果你想从这个章节开始一个全新的环境并跟随,你可以使用你从 第十章 和 第十一章(如果你跟随并自己构建了示例)中现有的代码,或者检出特定章节的分支(chapter-12)。
记住,每个分支都对应于章节末尾的代码(例如,chapter-12 分支对应于本章末尾的代码)。你可以在你选择的目录中执行以下终端命令之一来获取当前章节的代码。
如果你根本就没有仓库,请输入以下命令:
git clone git@github.com:react-in-action/letters-social.git
如果你已经克隆了仓库:
git checkout chapter-12
你可能是从另一个章节来到这里的,所以始终确保你已经安装了所有正确的依赖项:
npm install
12.1. 服务器端渲染是什么?
在我们探索在服务器上使用 React 之前,让我们简要地回顾一下网络应用中渲染的历史背景。如果你已经熟悉了 SSR 的工作原理(也许你之前使用过像 Ruby on Rails 或 Laravel 这样的框架,或者已经理解了其机制),那么请随意跳到第 12.1.4 节,在那里你开始为你的应用程序实现 SSR。
在过去(并且至今对许多应用程序而言),只有服务器渲染视图的应用程序是普遍的标准。通常,这些应用程序会创建包含用户相关或其他数据的 HTML 字符串,并通过 HTTP 将其发送到浏览器。事情最终会得到改善,但最初即使是服务器端的功能也很原始。创建了简单的服务器端脚本,这些脚本会手动将 HTML 字符串的部分拼接在一起,然后将其作为响应发送下去。这虽然可行,但使得事情比必要的更加复杂,因为手动创建拼接视图既耗时又难以更改。随着时间的推移,框架甚至语言被开发或创建出来,以更好地帮助开发者构建主要在服务器上渲染的用户界面。
图 12.1 展示了这个过程的粗略概述。基本思想是服务器对浏览器的请求做出响应,返回动态生成的 HTML,例如,以某种方式包含请求用户的具体信息。示例 ERB 模板展示了工程师在创建 HTML 标记时可能工作的内容。如果你之前在 node.js 社区工作过,你可能熟悉 Pug(原名 Jade)模板语言。
图 12.1。服务器端渲染的简化概述

像 Ruby on Rails、WordPress(一个基于 PHP 的内容管理系统框架)等框架的开发和成长是为了满足以这种方式构建应用程序的需求。这种以服务器为中心的方法效果良好,并且至今仍然如此。但随着客户端 JavaScript 变得更加健壮,浏览器变得更加强大,开发者最终开始使用 JavaScript 来做的不仅仅是为他们的应用程序添加基本交互性。他们开始使用它来生成和更新带有动态数据的界面。这意味着服务器在模板化方面的使用减少,更多地作为数据源。今天你会发现许多应用程序(如你的)使用强大的客户端应用程序来管理 UI,并使用远程(通常是 REST)API 来提供动态数据。这种范式是你迄今为止在书中一直在使用的。但本章开始稍微改变这一点,因为你开始混合服务器端渲染和客户端渲染的模式。下一节将展示一些关于服务器端渲染的具体示例。图 12.2 展示了这种设置与图 12.1 中的设置相比的例子。
图 12.2。随着浏览器和 JavaScript(有时进展缓慢)的演变,客户端 JavaScript 承担了更多的责任。在图 12.1 和这张图中,都在完成相同的基本任务(获取或计算数据;向用户展示),但客户端和服务器承担了不同的责任。

12.1.1. 深入研究服务器端渲染
在开始实现 SSR 之前,我们将从非 React 环境中探讨其更多方面,以便当你开始将其构建到你的应用中时,你的任务将更有意义。让我们看看一个使用 ERB(嵌入式 Ruby)的 SSR 示例。我们在图 12.1 中看到了 ERB 的引用。ERB 是 Ruby 编程语言的一个特性,可以用来创建 HTML(或其他类型的文本,如用于 RSS 订阅源生成的 XML)模板。如果你感兴趣,可以在guides.rubyonrails.org/layouts_and_rendering.html了解更多关于 ERB 和 Ruby on Rails 的信息。
许多 Ruby on Rails 应用将包含使用 ERB 模板生成的视图。框架将读取开发者创建的.erb 模板文件,并使用来自服务器或其他地方的数据填充它们。填充了数据后,生成的文本将被发送到用户的浏览器。模板化 HTML 视图的能力类似于 JSX,尽管语法和语义不同。React 创建并管理 UI,而像 ERB 这样的模板化方法仅覆盖“创建”这一半。列表 12.1 展示了 ERB 文件的一个简单示例,以展示在服务器端渲染应用中经常使用的模板化类型。除了语法差异外,它与其他模板语言(如 Handlebars、Jade、EJS,甚至在 React 中)所熟悉的内容不应有太大差异。许多这些模板语言允许你使用编程语言中许多基本结构,如循环、变量访问等;React 的 JSX 也不例外。
列表 12.1. ERB 模板化
<h1>Listing Books</h1>
<table>
<tr>
<th>Title</th>
<th>Summary</th>
<th></th>
<th></th>
<th></th>
</tr>
<% @books.each do |book| %> #A
<tr>
<td><%= book.title %></td>
<td><%= book.content %></td>
<td><%= link_to "Show", book %></td>
<td><%= link_to "Edit", edit_book_path(book) %></td>
<td><%= link_to "Remove", book, method: :delete, data: { confirm: "Are
you sure?" } %></td>
</tr>
<% end %>
</table>
<br>
<%= link_to "New book", new_book_path %>
快速查看服务器端渲染过程中发送到浏览器的内容,可能会有助于你对构建过程的机制有一个直观的了解。在服务器处理类似于列表 12.1 中的模板之后,它将发送一个文本响应到浏览器。结果将类似于列表 12.2,它展示了 HTTP(版本 1/1.1)响应的文本表示。这类似于你在服务器上渲染 Letters Social 应用时发送到浏览器的内容。
我使用了一个常见的命令行工具 cURL 来获取 http://example.com 的网页,以便我们可以看到原始 HTTP 请求。您可能已经在您的机器上安装了 cURL,但如果您没有,请访问github.com/curl/ curl 并按照那里的说明进行安装。列表 12.2 显示了运行curl -v https://example.com的“原始”HTTP 响应样本输出。为了简洁起见,我省略了一些内容,并保留了 cURL 中的>和<符号来指示出站(>)和入站(<)消息。如果您不想使用 cURL,您也可以在浏览器中导航到 http://example.com 并打开开发者工具。Chrome、Firefox 和 Edge 都拥有网络部分,允许您检查 HTTP 请求。
列表 12.2. 示例 HTTP 请求
> GET / HTTP/1.1 *1*
> Host: example.com *1*
> User-Agent: curl/7.51.0 *1*
> Accept: */* *1*
< HTTP/1.1 200 OK *2*
< Cache-Control: max-age=604800
< Content-Type: text/html
< Date: Mon, 01 May 2017 16:34:13 GMT
< Etag: "359670651+gzip+ident"
< Expires: Mon, 08 May 2017 16:34:13 GMT
< Last-Modified: Fri, 09 Aug 2013 23:54:35 GMT *3*
< Server: ECS (rhv/81A7)
< Vary: Accept-Encoding
< X-Cache: HIT
< Content-Length: 1270 *3*
<
<!doctype html> *4*
<html>
<head>
<title>Example Domain</title>
<meta charset="utf-8" />
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<div>
<h1>Example Domain</h1>
<p>This domain is established to be used for illustrative examples in
documents. You may use this
domain in examples without prior coordination or asking for
permission.</p>
<p><a href="http://www.iana.org/domains/example">More
information...</a></p>
</div>
</body>
</html>
-
1 您使用 cURL 发送到服务器的请求
-
2 响应头提供了诸如响应状态和其他有用信息(如 Cache-Control、Expires 等)
-
3 响应头提供了诸如响应状态和其他有用信息(如 Cache-Control、Expires 等)
-
4 响应体——您将使用 React 生成的内容
到本章结束时,您希望应用的服务器部分能够创建与列表 12.2 中相同类型的输出(但当然要针对您的 app)。希望到现在为止,服务器渲染的一般概念已经变得有意义。在接下来的两个部分中,我们将探讨何时以及何时不应将此功能构建到您的应用中。
12.2. 为什么要在服务器上渲染?
为什么您想进行 SSR?这取决于您的用例,可能会有一些非常有力的理由。例如,有一些轶事证据表明,当涉及到被搜索引擎索引和抓取时,服务器渲染的应用表现更好。尽管大型搜索引擎如 Google 似乎可以在服务器上执行或至少模拟 JavaScript 和 DOM,但似乎渲染动态内容而不需要 DOM 的网站表现更好。由于 Google 和其他公司的网站排名算法是保密的,因此很难确定 SSR 与非 SSR 应用对搜索引擎优化(SEO)的确切影响,但至少有来自行业中的个人和团队的轶事证据表明它可能产生积极影响。如果您有一个高度公开的应用,并且高度依赖在搜索引擎结果中显示,您可能需要考虑 SSR 来增加爬虫友好性,以及您所有的其他 SEO 优化。
在这本书中,您一直在构建一个需要交互性和允许用户动态创建内容的 app,但并非每个 app 都有这些需求。如果您只想使用 React 的静态方面,您可以使用 React-DOM 的静态渲染能力轻松创建一个静态页面生成器或模板库。
你可能希望在服务器上渲染的另一个原因是优化用户的体验。如果你的应用需要尽快向用户展示内容,那么在服务器上渲染可能允许你比等待客户端渲染更快地向他们展示内容。这可能适用于你的应用依赖于向人们展示广告或其他静态付费内容,并且负载大小不是特别大的情况。在你想快速展示内容而不需要交互的情况下,你通常更关心的是首次绘制,这是用户第一次能够在他们的浏览器中看到内容的时候。
首次绘制是你可以用来确定浏览器如何渲染应用的许多指标之一。另一个是感知速度索引(通常简称为速度索引或SpeedIndex)。这是通过记录页面随时间完成渲染的部分来计算的。浏览器会在页面加载时记录一个视频,并确定在给定的时间间隔内页面加载了多少百分比。这个指标可以用来理解在总体层面上,给定页面对于用户来说加载得多快。SSR 可以通过允许在加载过程的早期阶段有更多的网站内容可以被浏览器渲染,从而潜在地贡献到一个更快的速度索引。更多关于速度索引的信息,请参阅sites.google.com/a/webpagetest.org/docs/using-webpagetest/metrics/speed-index。
大多数应用都将从更快的速度索引和快速首次绘制中受益。但在其他情况下,你可能不会像关心尽快向用户展示内容那样关心,因为你更关心他们使用你的应用的速度。用户能够与你的应用程序或页面交互所需的时间,称为交互时间(TTI),如果您的应用是一个高度交互、功能丰富的应用,如 Basecamp 或 Asana,那么这个时间可能更为重要。对于这些应用,SSR 可能没有意义,因为它们不是面向公众的,并且比快速向用户展示内容更依赖于交互性。
让我们看看几个应用,并看看 TTI 如何可能影响:
-
Basecamp(项目管理应用)—**用户希望能够搜索问题、更新待办事项和检查项目状态。在这种情况下,你希望优化你的应用,尽可能快地加载 JavaScript,而不是试图尽快向用户展示内容。
-
Medium(博客/写作应用)—**用户希望尽可能快地阅读和浏览文章。他们这样做的能力并不依赖于应用交互性,因此在这种情况下,你可能希望优化首次绘制。
当考虑 SSR 时,你还需要权衡在服务器和客户端渲染之间的资源使用权衡。如果你正在渲染大量数据(比如在线电子表格中的数千行),在服务器上执行这一操作可能需要你向浏览器发送更大的初始负载。这反过来可能意味着更长的 TTI(首次内容绘制时间),这可能会对你的用户造成不利影响,并可能使用更多的服务器资源。例如,在应用加载后以 JSON 格式获取相同数量的数据,可能会产生更小的负载大小,并可能带来更好的用户体验。
企业级和消费级应用的服务器渲染
你可能会觉得我们本章关于服务器渲染的讨论是某种理论性的内容,你永远不会需要处理。但我想告诉你,服务器渲染比你想象的要普遍得多,并且是许多团队会积极考虑的一个选项。我在自己的经历以及我所遇到的工程师的经历中看到了这一点。我参与过面向公众的消费产品以及封闭的企业应用的开发,有机会看到服务器渲染在多种商业场景中被考虑。在这两种情况下,我们都希望为用户提供最好的服务,并考虑了服务器端渲染作为一个选项。
在企业应用中,我们面对的是希望应用能够快速交互的用户,而不仅仅是快速渲染。我们还必须提供可能包含数百行甚至数千行财务数据的页面(这可能会抵消服务器渲染带来的收益)。该应用由几个较小的应用组成,我们根据特定时间使用哪些应用来提供不同的 JavaScript 包。更复杂的是,数据完整性和安全性对我们来说是最重要的考虑因素,因此服务器渲染可能会引入一个新的安全领域,需要从安全角度进行评估和保障。
这些因素使得服务器渲染成为“锦上添花”的东西,可以在未来某个可以重新评估的时间保存。我们发现我们可以做其他事情来帮助我们的用户,比如提高我们的服务器性能,优化我们提供应用资源的方式,以及在必要时才在客户端延迟数据获取。有趣的是,人们对不同类型的应用也有不同的期望。像 Facebook、Twitter 和 Amazon 这样的消费应用都在争夺用户,他们有各种各样的选择,因此直接在其他许多方面与其他人竞争。在我的经验中,企业用户对他们用于工作的应用有一套不同的期望。速度当然非常重要,但稳定性、可靠性、清晰度以及商业应用的其他重要方面也同样重要。对于工程团队来说,在这些维度上优化可能比花同样时间在影响较小的指标上优化更有意义。这并不总是情况,但对我来说,我在一些项目上就是这样做的。
我参与的其他项目有着非常不同的需求。另一个应用是在电子商务领域。页面服务器渲染是有意义的,因为首次绘制时间和 SEO 考虑因素非常重要。我们努力最小化捆绑资源的尺寸,并尽可能快地向用户展示内容。任何迟缓的表现都可能阻止用户继续他们的购物体验。这些应用也与营销活动紧密集成,因此保证稳定的 SEO 性能是一个优先事项。
仍然有其他类型的案例,其中服务器渲染可以应用,但我希望这两个更简单的例子能帮助稍微阐明我们在本章讨论的一些实际问题的细节。
在你的 SSR 实现中,不一定非得全有或全无。如果你必须在电子表格中渲染成千上万的行,可能让客户端处理渲染的这一方面是有意义的,但登录和注册页面可以在服务器上渲染,因为它们较小,并且更多地依赖于首次绘制而不是交互时间。你也可以选择在网页上渲染某些部分,但允许客户端处理所有进一步的数据获取和渲染。如果你对深入了解不同方面的 Web 性能有更多兴趣,一个好的起点是 Google 的 Web 基础指南:developers.google.com/web/fundamentals/performance/.
12.3. 你可能不需要 SSR
尽管 SSR 有一些潜在的好处,但你只有在真正需要时才应该将其构建到你的应用程序中。这是因为,根据其深度集成程度,它可能会引入显著复杂性。在本章中,我们将实现一个基本的、甚至可以说是简化的 SSR(服务器端渲染)版本,以便熟悉这些概念,但构建一个强大、专门设计的实现,以处理所有不同的 SSR 细微差别,可能需要重大的技术投入。
至少有几个原因说明为什么集成服务器端渲染会增加复杂性。以下是一些原因:
-
你需要以某种方式同步服务器和客户端,使得客户端能够理解何时接管。这可能包括设置标记、事件处理程序等客户端可能需要的更多内容。你的认证实现还需要考虑到来自服务器或客户端的请求,这可能需要做出改变。
-
客户端和服务器在不同的范式下运行,这些范式并不总是容易相互映射(例如,没有 DOM,没有文件系统等)。你必须协调交接和渲染,并确保你不使用,或者正确处理依赖于浏览器环境的组件。
-
尽管有一些例外,但 React(以及任何 JavaScript)最可靠地运行在 Node.js 运行时。这可能会使你的客户端和渲染它的服务器耦合在一起,因为它们现在都需要支持 JavaScript。这可能是一件好事,但也意味着你比以前更多地把自己绑定在 JavaScript 语言/平台上。
-
微调 SSR 可能需要对客户端和服务器进行特殊的调整。性能提升通常是通过关注特定功能的小幅、增量胜利来实现的,这几乎总是涉及权衡。这有时可能意味着在快速做出改变时灵活性降低,以及更复杂的维护过程。服务器端渲染给这个过程增加了另一个方面。
总体而言,这里谨慎的主要原因是“只使用你需要的东西”这一理念。我不想让你产生这样的想法,即你的 React 应用程序不完整,或者以某种方式“不够 React 化”,除非它使用了 SSR。最好的工程决策过程涉及对所涉及权衡的彻底考虑(而不仅仅是其他人使用什么或什么受欢迎!),这也适用于这里。一个例子可能是你正在编写一个简单的博客应用程序作为个人副项目。现实是,如果你不是 Netflix,你不需要 Netflix 的基础设施和编排技术。即便如此,并非所有大型公司都在做 SSR。例如,在撰写本文时,甚至 Instagram 似乎也没有使用 React 进行 SSR,而该公司在 React 上投入了大量资金。使用你所需要的。
12.4. 服务器上渲染组件
现在我们简要地探讨了服务器端渲染的一些权衡,我们可以开始深入了解它是如何与 React 一起工作的。让我们从您将使用的 React API 开始。ReactDOMServer(通过 require('react-dom/server') 或 import ReactDOM from 'react-dom/server' 访问)公开了四个重要的方法,您可以使用这些方法为您组件生成初始 HTML:
-
renderToString -
renderToStaticMarkup -
renderToNodeStream -
renderToStaticNodeStream
让我们逐一查看每个方法。
首先,我们有 ReactDOMServer.renderToString。renderToString 做的事情正如其名:它接受一个 React 元素,并根据方法调用时存在的初始状态和属性(默认或传递的)从组件生成相应的 HTML 标记。如您在前面章节中记住的那样,React 元素是 React 应用程序的最小构建块。它们通过 React.createElement(或更常见的是 JSX)创建,并且可以从字符串类型或 React 组件类创建。该方法看起来像这样:
ReactDOMServer.renderToString(element) string
当您在服务器上渲染时,您会像往常一样使用组件并传递属性。您迄今为止所习惯的与在服务器上使用 React 的主要区别是缺少 DOM 和浏览器环境。这意味着 React 不会运行生命周期方法,如 componentWillMount,也不会持久化状态或利用其他 DOM 特定功能。
服务器端渲染可能涉及相当多的复杂性,不应被视为所有应用程序的标准或“必备”功能。花点时间思考一下您可能如何实现(或选择不实现)以下类型应用程序的服务器端渲染:
-
没有面向公众部分的企业应用程序
-
严重依赖广告的社交媒体网站
-
电子商务应用程序
-
视频托管平台
ReactDOM.renderToStaticMarkup 将与 renderToString 做同样的事情,但不会附加任何额外的 DOM 属性供 React 在客户端“接管”时使用。这在您只想进行基本的模板化或静态站点生成且不需要任何额外属性的情况下非常有用。renderToStaticMarkup 几乎与 renderToString 相同:
ReactDOMServer.renderToStaticMarkup(element) string
您将不会在此之后使用 renderToStaticMarkup,但一旦您学会了如何使用 React 实现服务器端渲染,在适当的项目中将其用于未来项目应该会很简单。
你可能已经注意到前两种方法在 renderToNodeStream 和 renderToStaticNodeStream 中有明显的补充。如果是这样,你猜对了。这些方法与其他方法相同,只是它们使用了 Node 的 Streams API,并在 React 16 中随着 fiber reconciler 和许多其他变化一起引入。Streams 在 node.js 中被广泛使用,如果你与 Node 有过任何工作,你可能已经听说过它们。如果你没有,那也无所谓,你可以在 nodejs.org/api/stream.html 上了解更多信息。对我们来说,这些基于流的方法的要点是它们是异步的。这使它们在它们的同步对应物之上具有显著的优势。在一段时间内,使用 React 进行服务器渲染的一个小缺点是这些方法是同步的。这对必须渲染包含许多组件的复杂页面的应用程序提出了挑战。我们将在本章后面探讨这些方法,当我们查看服务器渲染中的数据获取时。
现在你对可用的 API 方法了解得更多了,我们可以关注 renderToString。renderToString 将生成 React 可以在客户端使用和工作的代码。React-DOM 另有一个方法 hydrate,它几乎与您非常熟悉的常规 render 方法完全一样。主要区别在于 hydrate 专门处理由服务器端渲染生成的标记。
如果你在一个已经由 React-DOM 在服务器上创建了标记的节点上调用 ReactDOM.hydrate(),React 将保留现有的 HTML 并比其他情况下做更少的工作。这通常意味着在初始启动时 React 需要做的工怍会更少(这取决于你发送的数据量以及服务器负载、网络、天气等因素)。我不会再次提及这一点,但请记住,SSR 并非魔法,如果你像加载大型的 JavaScript 文件、不拆分代码或违反其他最佳实践这样的行为,你很容易就会抵消任何性能提升。
到目前为止,你还没有接触过任何服务器文件。除了本章的有限范围,服务器编程通常不在此书的范围之内,所以我们不会过多地介绍 node.js 运行时或网络服务器编程范式。如果你对 Node 和服务器端编程感兴趣,可以查看 Alex Young 等人所著的 Node.js in Action 第二版(Manning Publications,2017):www.manning.com/books/node-js-in-action。
你将通过关注需要进行的服务器更改来开始构建 SSR。列表 12.3 显示了在您对它进行任何操作以使其与 React 一起工作之前,主应用服务器代码的状态。我包括了所有内容,以便您可以了解它在做什么。大部分代码是简单的 Express 应用程序可能使用的样板中间件,但其中大部分与 SSR 没有直接关系。图 12.3 将列表 12.3 中的代码置于本章中我们讨论过的渲染方法背景中。
图 12.3. 截至列表 12.3,这是服务器代码的基本功能。它设置了您的服务器,添加了一些样板中间件,然后提供了一个简化版的 HTML 文件,该文件反过来下载您的应用。

列表 12.3 显示了您的应用(基本)服务器设置。当您将其置于本章中我们一直在查看的 SSR 方法背景中时,它与以客户端为中心的范例相匹配。在这种方法中,服务器通常会仅发送一个不包含预渲染内容的 HTML 文件。您的构建工具目前正在处理生成和提供 HTML 文件。该文件包含对将下载并执行以进行渲染和管理应用程序的脚本的引用,但在服务器上(尚未!)没有进行渲染。
列表 12.3. 从服务器开始(server/server.js)
import { __PRODUCTION__ } from 'environs'; *1*
import { resolve } from 'path';
import bodyParser from 'body-parser';
import compression from 'compression';
import cors from 'cors';
import express from 'express';
import helmet from 'helmet';
import favicon from 'serve-favicon';
import hpp from 'hpp';
import logger from 'morgan';
import cookieParser from 'cookie-parser';
import responseTime from 'response-time';
import * as firebase from 'firebase-admin';
import config from 'config';
import DB from '../db/DB';
const app = express(); *2*
const backend = DB(); *2*
app.use(logger(__PRODUCTION__ ? 'combined' : 'dev'));
app.use(helmet.xssFilter({ setOnOldIE: true }));
app.use(responseTime());
app.use(helmet.frameguard());
app.use(helmet.ieNoOpen());
app.use(helmet.noSniff());
app.use(helmet.hidePoweredBy({ setTo: 'react' }));
app.use(compression());
app.use(cookieParser());
app.use(bodyParser.json());
app.use(hpp());
app.use(cors({ origin: config.get('ORIGINS') }));
app.use('/api', backend); *3*
app.use(favicon(resolve(__dirname, '..', 'static', 'assets', 'meta', *3*
'favicon.ico')));
app.use((req, res, next) => { *4*
const err = new Error('Not Found');
err.status = 404;
next(err);
});
app.use((err, req, res) => {
console.error(err);
return res.status(err.status || 500).json({
message: err.message
});
});
module.exports = app;
-
1 使用 ES 模块语法,在 node 8.5 及以上版本中可用通过 ESM
-
2 设置将应用于所有传入请求的中间件;处理日志记录、一些基本的安全保护、解析传入请求。
-
3 响应请求,您将集成 React DOM
-
4 错误处理代码,用于捕获来自其他路由的转发错误并发送给客户端
您想要采取的第一步是引入 React-DOM 并尝试渲染一个简单的组件。在您开始集成您的应用之前,您将首先渲染一个包含一些文本的简单div。您将使用React.createElement进行这个小示例,这样您就不必处理服务器文件的转译,但您可以在稍后拉入组件以供使用时在其他文件中使用 JSX。这是因为您将使用babel-register,这是一个用于开发的 Babel 库,它即时转译您的代码。您可以在 index.js 中看到我们正在引入babel-register。在生产环境中,您不会这样做。相反,您将使用类似 Webpack 和 Babel 的工具将您的代码编译成一个包。在这里,我无法深入介绍工具,但您可以在webpack.js.org和babeljs.io了解更多信息。
在这次第一次遍历中,你只需插入一条简单的消息作为div的子内容,并将其发送到客户端。一旦你设置了这一切,你将运行服务器并检查你得到什么反馈。图 12.4 显示了列表 12.4 中的代码做了什么。
列表 12.4. 尝试服务器端渲染
//...
app.use('/api', backend);
app.use(favicon(resolve(__dirname, '..', 'static', 'assets', 'meta',
'favicon.ico')));
app.use('*', (req, res, next) => { *1*
const componentResponse = ReactDOMServer.renderToString( *2*
React.createElement( *3*
'div', *3*
null, *3*
`Rendered on the server at ${new Date()}` *4*
)
);
res.send(componentResponse).end(); *5*
});
//...
-
1 在请求处理器中,创建 HTML 字符串并发送下去
-
2 使用
renderToString并传入裸骨 React 元素 -
3 创建一个没有属性的 div 类型的元素
-
4 传入带有时间戳的简单字符串作为子内容
-
5 将响应发送到客户端
图 12.4. 你现在正在使用 React-DOM 来渲染一个简单的 HTML 字符串并将其发送到客户端。从某种意义上说,这就是所有 SSR(创建静态标记,发送到客户端)的全部。我提到的复杂性通常来自于,除了其他事情之外,获取创建文本所需的所有数据,与客户端协调过程,然后进行优化。

如果你修改了列表 12.4,只需在终端中运行node server/run.js来仅运行服务器,并使用另一个会话通过 cURL 发送请求,然后你应该会看到服务器返回的响应。在此之前,你每次都发送相同的 HTML 字符串,然后该文档会在之后加载你的应用程序脚本。React 随后会运行并将你的应用程序渲染到 DOM 中(创建 DOM 节点、分配事件监听器等)。使用这种新的方法,你可以将第一次渲染委托给服务器,并让 React 接管。列表 12.5 显示了如何运行服务器和使用 cURL 检查从服务器返回的响应。
列表 12.5. 检查你的第一个服务器端渲染响应
$ npm run server:dev
// ... in a different terminal session
$ curl -v http://localhost:3000 *1*
> GET / HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.51.0
> Accept: */*
>
< HTTP/1.1 200 OK *2*
< X-Powered-By: react
< X-XSS-Protection: 1; mode=block
< X-Frame-Options: SAMEORIGIN
< X-Download-Options: noopen
< X-Content-Type-Options: nosniff
< Access-Control-Allow-Origin: *
< Content-Type: text/html; charset=utf-8
< Content-Length: 144
< ETag: W/"90-gXhNJUy73fc2MSrpr7eaKDZ7OV8"
< Vary: Accept-Encoding
< X-Response-Time: 0.795ms
< Date: Mon, 08 May 2017 10:26:55 GMT
< Connection: keep-alive
<
* Curl_http_done: called premature == 0
* Connection #0 to host localhost left intact
<div data-reactroot="">Rendered on the server at Mon May 08 2017 03:26:55
GMT-0700 (PDT)</div> *3*
-
1 向运行中的服务器发送请求,检查你得到什么反馈
-
2 你应该在请求中收到头部信息,但你最关心的是响应体。
-
3 最外层 HTML 元素上的特殊
react-root和react-checksum属性
通过这样,你已经完成了你的第一次服务器渲染。你使用了 React 来创建 React 组件的字符串表示形式并将其发送到客户端。目前,React 还没有被加载,所以它不能从服务器停止的地方继续,但一旦它被包含进来,它将能够接管。尝试运行相同的命令,但选择使用renderToStaticMarkup而不是它,看看你的服务器 HTTP 响应如何不同。
12.5. 切换到 React Router
你在前面章节中构建的路由器是针对处理浏览器中的路由进行优化的,但它并没有考虑到服务器端渲染。有机会深入了解 React 的潜力是构建它自己的很大一部分,而不仅仅是安装第三方库,我希望这给了你看到组件可以以不同方式使用的机会。
对于相对简单的示例应用需求来说,它可能已经足够有用,但你的路由器在几个方面还不足。它有一个相当基础的 API,如果它能支持诸如路由钩子(路由之间的转换)、中间件(可以应用于多个路由的逻辑)等功能,那就更好了。随着你深入到 React 的服务器端渲染,你将需要更多的功能,比如根据请求 URL 生成组件树进行渲染的能力。这就是为什么你会转向使用 React Router V3。
React Router (github.com/ReactTraining/react-router) 似乎是 React 单一最常用且最发达的路由解决方案。它在 GitHub 上拥有强大的追随者和贡献者社区,并且已经经历了多次重大修订。
在撰写本文时,React Router 的最新主要版本是 4。它目前处于变动中,在你阅读本文时可能已经被新的主要版本所取代。你会使用版本 3,因为它的 API 与你创建的路由相似,你应该能够几乎不需要更改就能使用它。你还会使用它,因为它是由 React 开源社区开发的一个健壮的技术。它能够做比你更简单的路由更多的事情,甚至超过了你在这里的需求。
选择第三方库还是内部构建
你选择转向 React Router 而不是坚持使用自家的解决方案的另一个原因是,它更可能是你或你的团队将面临任何商业情况下的候选者。你可能会更倾向于选择像 React Router 这样的开源解决方案,而不是自己编写。这是因为,根据你的需求,构建和维护一个强大解决方案所需的时间可能或可能不值得。在处理外部依赖时,导航构建或购买决策也可能很棘手。我的观点是,记住两点:1) 你不必因为别人都在使用而使用某物,2) 建立自己的解决方案通常比初始工作要付出更多努力——维护通常是最大的时间消耗。开源贡献者的大社区通常会在你遇到之前捕捉到许多错误。
值得注意的是,React Router 是一项重要的技术,我们在这里只会浅尝辄止。该项目已经包含了针对多种情况的广泛路由功能。在撰写本文时,最新的主要版本(4)甚至为 React Native 平台提供了路由解决方案。使用并参与 React Router 开发的开发者数量使得该项目极其有用,但这也带来了一个缺点,那就是在主要版本之间有时会有很大的变化。正因为如此,并且与您从头开始构建的路由相似,您不会使用 React Router 的最新版本。如果您想使用 React Router 的最新版本,我在我的博客上有一篇关于使用 React Router v4 和 React 16 的文章:ifelse.io/2017/09/07/server-rendering-with-react-router-and-react-16-fiber。我还会指出,尽管 React Router 的版本之间 API 已经发生变化,但大多数相同的概念仍然适用——您只需在过渡时将功能重新映射到新的 API 上即可。
12.5.1. 设置 React Router
我们已经决定使用 React Router 作为您自建路由的生产级替代品,因此让我们看看如何设置它。第一步是确保您已经安装了 React Router,并将其替换为当前的路由。尽管技术不同,但您将使用的 API 应该是相似的。
React Router 应该已经作为项目依赖项安装。现在您需要开始将项目过渡到 React Router 和一个允许您进行 SSR 的设置。从当前的 src/index.js 文件开始。这是一个入口文件,您在这里设置了应用的主要部分,包括监听浏览器历史记录、渲染路由组件和激活您的身份验证事件监听器。
这对于您的 SSR 设置是不适用的,因为那里的代码很大一部分依赖于浏览器环境,而且您不需要 React Router 的所有功能来使应用工作。您真正需要保留的是您的身份验证监听器。在添加任何内容之前,创建一个辅助工具以备后用。列表 12.6 展示了如何创建一个简单的实用工具来检查您是否处于浏览器环境中。一些工具技术,如 Webpack,可以帮助您捆绑出环境的代码,但就我们的目的而言,坚持使用这种方法。
列表 12.6. 检查浏览器环境(src/utils/environment.js)
export function isServer() {
return typeof window === 'undefined';
}
现在,你可以使用这个助手来确定你处于什么环境,并根据你的需求有条件地执行代码。它不会进行详尽的检查以确保你处于浏览器环境,但应该足以满足你的需求。考虑到你的代码运行的环境是构建具有 SSR 功能的应用程序或客户端和服务器之间共享代码的应用程序(有时称为 通用 或 同构)的一个相当常见的方面。在我的经验中,这也可能是难以追踪的常见错误来源,特别是如果你安装了没有考虑环境意识的第三方依赖项。
到现在为止,React 社区中现有的许多技术通常要么支持 SSR,要么指出可能引起问题的位置。这并不总是如此。几年前使用 React 的早期版本时,我遇到了 React 本身的一些错误,导致某些库的某些方面无法预测地失败。不过,现在情况要好得多,SSR 不仅受到 React 社区的关注,也受到核心团队的关注。
在继续之前,你需要对你的一个还原器进行微调,以考虑服务器环境。用户还原器将使用 js-cookie 在浏览器上设置一个 cookie。服务器通常不允许你存储 cookie(尽管有一些库可以模拟这种行为,如 tough-cookie (github.com/salesforce/tough_cookie)),因此你需要使用你的环境助手来调整这段代码。以下列表显示了你需要进行的修改
列表 12.7. 修改用户还原器
export function user(state = initialState.user, action) {
switch (action.type) {
case types.auth.LOGIN_SUCCESS:
const { user, token } = action;
if (!isServer()) { *1*
Cookies.set('letters-token', token); *1*
} *1*
return Object.assign({}, state.user, {
authenticated: true,
name: user.name,
id: user.id,
profilePicture: user.profilePicture ||
'/static/assets/users/4.jpeg',
token
});
case types.auth.LOGOUT_SUCCESS:
Cookies.remove('letters-token');
return initialState.user;
default:
return state;
}
}
- 1 只有在你处于浏览器环境时才尝试使用浏览器 cookie。
回到手头的任务。你需要设置 React Router。与你的路由器类似,React Router(版本 3)允许你使用嵌套的
创建一个新的文件,src/routes.js,用于你的路由。你将路由拆分到自己的文件中,因为它们需要被你的服务器和客户端访问。这对于客户端代码与服务器代码并存的 应用程序来说很方便,但如果你将它们托管在其他地方(通过 npm、Git 子模块等),你可能需要找到另一种方法将你的路由引入到你的服务器中。你的路由文件应该看起来像你创建的路由器,但有几点细微差别。你添加了在同一个
图 12.5。与你自己构建的路由器一样,React Router 的路由配置将 URL 映射到组件。你可以嵌套组件,以便在页面或子部分(如导航栏或其他共享组件)之间共享某些 UI 部分。

列表 12.8。为 React Router 创建路由(src/routes.js)
import React from 'react';
import App from './pages/app';
import Home from './pages/index';
import SinglePost from './pages/post';
import Login from './pages/login';
import NotFound from './pages/404;
import { Route, IndexRoute } from 'react-router';
export const routes = (
<Route path="/" component={App}> *1*
<IndexRoute component={Home} /> *2*
<Route path="posts/:post" component={SinglePost} /> *3*
<Route path="login" component={Login} /> *3*
<Route path="*" component={NotFound} /> *3*
</Route>
);
-
1 使用 App 来包裹整个应用。
-
2 使用 React Router 的 IndexRoute 组件确保你可以在索引 (/) 路径上显示组件。
-
3 按照你自己的路由器的方式匹配组件与路径。
现在你已经设置了一些路由,你可以将它们导入到你的主应用文件中,以便与 React Router 一起使用。相同的路由将在客户端和服务器上使用,这就是你可能听说过的 SSR 的 通用 或 同构 方面发挥作用的地方。在客户端和服务器上重用代码可能是一个很大的优势,但你可能不会在这里看到它的更多显著好处,因为这是一个非常有限的案例。你在这里获得的优势是,可以轻松地将你的客户端组件以“正常”的 React 方式暴露给服务器。
现在将您的路由导入到您的服务器中。列表 12.9 展示了如何将您的路由引入服务器并在渲染过程中使用它们。您的服务器将如何获取正确的组件(s)进行渲染?因为路由只是将 URL 映射到操作(在这种情况下是 HTTP 响应),您需要能够查找与路径关联的正确组件。在您自己的路由器中,您使用了一个基本的 URL-regex-matching 库来确定 URL 是否映射到您的路由器中的组件。它完成了根据 URL 确定哪个组件(如果有的话)应该被渲染的工作(参考图 12.5)。React Router 将允许您在服务器上做同样的事情。这样,您可以使用来自服务器的 HTTP 请求的传入 URL 来匹配要渲染到静态标记中的组件(s)。这是 React Router 和您进行 SSR 目标之间的关键连接点。React Router 使用 URL 来渲染组件或组件树,就像它通常所做的那样,但在服务器上。下一个列表展示了如何使用 React Router 设置您的 SSR 能力的初始服务器部分。
列表 12.9. 在服务器上使用 React Router(server/server.js)
//...
import { renderToString } from 'react-dom/server'; *1*
import React from 'react'; *1*
import { match, RouterContext } from 'react-router'; *1*
import { Provider } from 'react-redux'; *1*
*1*
import configureStore from '../src/store/configureStore'; *1*
import initialReduxState from '../src/constants/initialState'; *1*
import { routes } from '../src/routes'; *1*
//...
app.use('*', (req, res) => {
match({ routes: routes, location: req.originalUrl }, *2*
(err, redirectLocation, props) => { *3*
if (redirectLocation && req.originalUrl !== '/login') {
return res.redirect(302, redirectLocation.pathname +
redirectLocation.search); *3*
}
const store = configureStore(initialReduxState); *4*
const appHtml = renderToString( *4*
<Provider store={store}>
<RouterContext {...props} />
</Provider>
);
const html = ` *5*
<!doctype html>
<html>
<head> *5*
<link rel="stylesheet"
href="http://localhost:3100/static/styles.css" />
<meta charset=utf-8/>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Letters Social | React In Action by Mark
Thomas</title>
<meta name="viewport" content="width=device-
width,initial-scale=1">
</head>
<body>
<div id="app"> *5*
${appHtml} *5*
</div>
<script src="http://localhost:3000/bundle.js"
type='text/javascript'></script> *5*
</body>
</html>
`.trim();
res.setHeader('Content-type', 'text/html'); *6*
res.send(html).end(); *6*
});
});
//... Error handling
export default app;
-
1 从 React Router 导入一些实用工具,renderToString 从 React DOM,Redux Provider 组件,您的 store 和您的路由
-
2 将 URL 传递给匹配函数以及路由
-
3 匹配会出错,重定向(如果有),并传递 props;将用于渲染自定义错误页面或重定向
-
4 将您从 React Router 导入的 RouterContext 组件传递进去,并用常规的 Redux Provider 组件包裹它
-
5 使用字符串模板字面量创建包含您的应用程序 HTML 的 HTML 文档
-
6 在响应中设置头部并发送回浏览器
12.6. 使用 React 路由处理受保护的路线
现在您已经设置了服务器,您可以稍微清理一下您的应用程序的客户端部分。您需要确保您正在使用您的新路由设置。您还需要移动一些与身份验证相关的逻辑,以便更好地利用 React Router。为此,您将使用 React Router 提供的一系列功能:钩子。类似于生命周期方法在组件挂载、更新和卸载时工作的方式,React Router 提供了一些钩子用于路由之间的转换。您可以使用这些钩子的多种方式,包括以下内容:
-
您可以在允许用户完成 URL 转换之前触发页面数据获取或检查用户是否已登录。
-
您可以在用户离开页面时处理任何清理操作或结束分析会话——您不仅限于与进入相关的事件。
-
使用 React Router 的钩子,您甚至可以进行同步 或 异步工作,因此您不受限制于其中之一。
-
向分析平台(如 Google Analytics)发送页面浏览事件。
图 12.6 展示了 React Router v3 中你可以使用的钩子基本流程。React Router 在底层与 History API (developer.mozilla.org/en-US/docs/Web/API/History_API) 交互,但将这些钩子暴露出来,以便在应用程序中更容易进行路由。如果你想了解更多关于 React Router V3 API 的信息,并探索社区编写的一些其他有用的指南,请查看 GitHub 上的文档 github.com/ReactTraining/react-router/blob/v3/docs/API.md。
图 12.6. React Router 在 Route 组件上暴露了一些事件处理器。你可以使用这些处理器来挂钩到用户或你的代码导致转换时发生的路由转换。请注意,“重定向”不是一个带有 3XX 状态码的 HTTP 重定向。

你将使用 onEnter 钩子来检查某些路由的登录用户,并在没有认证用户的情况下将他们重定向到登录页面。在实践中,你希望从安全的角度思考你的应用程序,并投入大量时间来防止用户转换到他们不应能够转换到的页面。你还需要确保你的安全策略也扩展到你的服务器。但就目前而言,Firebase 和路由钩子应该足以保护一些你的路由。下一个列表显示了如何为受保护页面设置 onEnter 钩子。你可能从上一章中认出了认证逻辑,其中你在登录操作中使用它。图 12.6 展示了此过程的工作方式。
列表 12.10. 设置 onEnter 钩子(src/routes.js)
import React from 'react';
import { Route, IndexRoute } from 'react-router';
import App from './pages/app';
import Home from './pages/index';
import SinglePost from './pages/post';
import Login from './pages/login';
import Profile from './pages/profile';
import NotFound from './pages/error';
import { firebase } from './backend'; *1*
import { isServer } from './utils/environment'; *1*
import { getFirebaseUser, getFirebaseToken } from './backend/auth'; *1*
async function requireUser(nextState, replace, callback) { *2*
if (isServer()) { *3*
return callback();
}
try {
const isOnLoginPage = nextState.location.pathname === '/login'; *4*
const firebaseUser = await getFirebaseUser(); *5*
const fireBaseToken = await getFirebaseToken(); *5*
const noUser = !firebaseUser || !fireBaseToken; *6*
if (noUser && !isOnLoginPage && !isServer()) { *6*
replace({
pathname: '/login'
});
return callback();
}
if (noUser && isOnLoginPage) { *7*
return callback();
}
return callback();
} catch (err) {
return callback(err); *8*
}
}
export const routes = (
<Route path="/" component={App}>
<IndexRoute component={Home} onEnter={requireUser} /> *9*
<Route path="/posts/:postId" component={SinglePost}
onEnter={requireUser} />
<Route path="/login" component={Login} />
<Route path="*" component={NotFound} />
</Route>
);
-
1 导入 Firebase 和 isServer 实用工具。
-
2 React Router 钩子需要三个参数:nextState、一个替换函数和一个回调函数。
-
3 如果你处于服务器,继续
-
4 你需要知道你是否在登录页面,这样你才不会无限重定向
-
5 使用样本仓库中包含的 Firebase 实用函数来获取 Firebase 用户和令牌
-
6 如果没有令牌或用户且不在登录页面,重定向用户
-
7 如果没有用户但他们在登录页面,允许他们继续
-
8 如果出错,则使用回调函数返回它
-
9 使用属性将钩子添加到适当的组件中
在继续之前,你需要做的最后一点设置是清理主应用文件并替换你的链接组件。以下列表显示了简化后的主客户端文件版本。
列表 12.11. 清理你的应用索引(src/index.js)
import React from 'react';
import { hydrate } from 'react-dom';
import { Provider } from 'react-redux'; *1*
import { Router, browserHistory } from 'react-router'; *2*
import configureStore from './store/configureStore';
import initialReduxState from './constants/initialState';
import { routes } from './routes'; *3*
import './shared/crash';
import './shared/service-worker';
import './shared/vendor';
// NOTE: this isn't ES*-compliant/possible, but works because we use
Webpack as a build tool
import './styles/styles.scss';
// Create the Redux store
const store = configureStore(initialReduxState);
hydrate( *1*
<Provider store={store}> *4*
<Router history={browserHistory} routes={routes} /> *5*
</Provider>,
document.getElementById('app')
);
-
1 从 React-DOM 中导入和使用 hydrate 方法,以便它可以与服务器端渲染的标记一起工作
-
2 导入路由和 browserHistory
-
3 导入你的路由。
-
4 将你的应用包裹在 Redux Provider 中
-
5 将你的路由和 browser-History 传递给 Router 组件
你已经使用browserHistory设置了 React Router,但你也可以使用基于哈希或内存的历史记录来设置它。这些与你的浏览器历史记录略有不同,因为它们不使用相同的浏览器历史 API。基于哈希的历史记录通过改变 URL 中的哈希片段来实现,但不会改变用户的浏览器历史记录。内存历史记录 API 完全不操作 URL,更适合像本地开发或 React Native(下一章将介绍)这样的应用。有关不同历史实现的信息,请参阅github.com/ReactTraining/react-router/blob/v3/docs/guides/Histories.md。
如果你在本地上运行应用程序,你应该能看到所有内容在服务器上渲染并发送到客户端。React 应该接管,一切应该像你预期的那样交互式。不过,你可能注意到一个问题:使用链接的路由似乎出了问题。这是因为你构建了自己的链接组件,这些组件与你的旧路由器集成。幸运的是,要解决这个问题,你只需要将你一直在使用的 history 模块替换为 React Router 使用的模块。这里的转换应该很简单,但值得注意的是,当你选择或构建一个路由器时,它可能会影响应用程序的很大一部分。链接、页面间的切换、如何访问 props——所有这些都可能受到路由的影响,你应该考虑这一点。
你需要做的主要更改是替换链接使用的 history。React Router 仍然使用浏览器历史 API,但你可以通过使用 React Router 提供的而不是之前使用的来与你的路由器同步。由于你集中了导航包装器,任何需要将用户路由到其他位置的操作都应该在新设置中正常工作。下一个列表显示了你需要更改的行。除此之外,你不需要更改其他任何内容。
列表 12.12. 替换历史记录(src/history/history.js)
import { browserHistory } from 'react-router'; *1*
const history = typeof window !== 'undefined'
? browserHistory *1*
: { push: () => {} };
const navigate = to => history.push(to);
export { history, navigate };
- 1 只需更改的行;让 React Router 知道你的转换
在这些更改到位后,你应该在服务器上使用 React Router 进行渲染!让我们在结束前回顾一下:
-
当请求到来时,你将请求的 URL 传递给 React Router 的
match实用工具,以获取你想要渲染的组件。 -
使用
match的结果,你可以使用 React DOM 的renderToString方法构建一个 HTML 响应并将其发送回客户端。 -
如果你使用 cURL 或开发者工具检查你的开发服务器(使用
npm run server:dev运行),你应该能在响应中看到你的组件的 HTML(见图 12.7)。
图 12.7. 检查你的服务器端渲染应用。使用 React-DOM,你可以创建你的应用 HTML,然后将其发送到客户端。注意,因为你没有进行任何服务器端的数据获取,所以你不会期望看到任何动态数据填充你的应用(如帖子)。

12.7. 使用数据获取的服务器端渲染
你已经将服务器端渲染集成到你的应用中。这可能在应用参与度和性能方面带来潜在的好处。然而,仍有改进的空间。你目前没有做任何事情来在发送之前渲染应用的全状态。你发送的负载在用户是否登录的情况下都是相同的。目前这取决于浏览器去做诸如启动认证流程和加载帖子之类的事情。你的服务器端渲染也是同步的,因为你还没有使用renderToNodeStream。在本节中,你将改进你的服务器端渲染以利用这个 API,并在服务器上集成 Firebase,这样你就可以进行了解认证状态的渲染。图 12.8 展示了集成数据获取的服务器端渲染的概述。
图 12.8. 使用数据获取的服务器端渲染。总体上与你的渲染方式相似,主要区别在于你需要在渲染过程中进行一些数据获取。渲染输出将根据用户是否登录、用户的数据看起来如何以及他们何时登录而变化。

Firebase 提供了一种从服务器以类似浏览器的方式与他们的 API 进行交互的方法。这将使你即使在服务器上也能继续将 Firebase 视为你的数据库。在其他情况下,你可能需要做一些类似的事情,比如向一个微服务或数据库发起 HTTP 调用,以确定用户是否存在以及他们是否处于当前认证状态。由于你专注于 React,你将坚持使用 Firebase,但请注意,这是你可能在不同情况下替换这些系统的一个地方。
如果你还没有创建 Firebase 账户,这是一个很好的时机。我已经将应用程序源代码与账户的公共令牌一起分发,但为了使用 Firebase 用户管理 API,你需要有一个真实账户(你可以用它来访问用户信息,这是我不希望人们做的事情)。要设置 Firebase 账户,请访问firebase.google.com并注册一个账户(你应该能够使用现有的 Google 账户)。从那里,创建一个你喜欢的项目名称。
之后,您需要完成 Firebase 管理 SDK 的设置。这个过程可能会随时间而变化,所以在这里我不会具体说明。设置和安装说明可以在firebase.google.com/docs/admin/setup找到,应该相对容易遵循。我们最感兴趣的是用户管理 API。您不需要在项目中安装任何其他东西,因为 node.js Firebase SDK 已经包含在您的项目依赖中。
作为最后的设置步骤,您需要替换应用程序中包含的 Firebase 密钥,因为它们与 Letters Social 项目相关,并且可能会与您自己的冲突。您可以在源代码中通过查看 config 目录找到它们。两个文件,development.json 和 production.json,分别包含开发和生产环境下的配置变量。请随意编辑这些或其他变量(也许您想自定义应用程序并在网站上部署它!)显示了 Firebase 控制台和服务账户页面。生成一个新的私有密钥,并将下载的文件移动到主应用程序仓库中——您很快就会用到它。
图 12.9. 创建一个新的 Firebase 项目并生成一个新的私有密钥。这将允许您验证到 Firebase 平台并使用 SDK 在服务器上管理用户。

现在您已经处理完这些后勤问题,可以回到编码了。您想要通过 Firebase 平台验证您的服务器应用程序,以便您可以验证和检索用于渲染完整应用程序状态的 Firebase 用户。您可能已经在 Firebase 页面上看到了如何做到这一点的示例片段,但列表 12.13 展示了如何在您的服务器上配置 Firebase Admin SDK。
列表 12.13. 在服务器上集成 Firebase (server/server.js)
// ...
import * as firebase from 'firebase-admin'; *1*
import config from 'config';
// Initialize Firebase
firebase.initializeApp({
credential: firebase.credential.cert(JSON.parse(process.env.LETTERS_
FIREBASE_ADMIN_KEY) ), *2*
databaseURL: 'https://letters-social.firebaseio.com'
});
// const serviceAccount = require("path/to/serviceAccountKey.json"); *3*
// admin.initializeApp({
// credential: firebase.credential.cert(serviceAccount),
// databaseURL: "https://test-8d685.firebaseio.com"
// });
// Our dummy database backend
import DB from '../db/DB';
//...
-
1 导入 Firebase 管理 SDK
-
2 将 JSON 文件的字符串化版本设置为环境变量;解析它以便 Firebase 可以工作
-
3 使用 Firebase 进行验证的另一种方式
现在当服务器运行时,它将自动连接到 Firebase 并允许您使用 Admin SDK 与用户交互。这样,您就可以在服务器上以了解请求用户的方式执行数据检索。这有什么意义?您可能还记得,在本章早期我说过服务器端路由可能很复杂,因为它可能涉及同步您的客户端和服务器。您不会做任何特别复杂的事情,但这就是我所指的。服务器端渲染可能会迅速变得极其复杂。
幸运的是,你不会做任何如此令人畏惧的事情。你将要做的就是以一种你可能之前没有使用过的方式使用 Redux。因为 Redux 没有任何限制它只能在浏览器中运行的特性,所以你也可以在服务器上用它进行状态管理。以下是你将要做的事情的简要概述,以实现允许数据获取的渲染:
-
从之前章节中存储的 cookie 中获取用户的令牌。
-
使用 Firebase 验证令牌,如果存在则获取用户。
-
如果他们没有有效的令牌(可能已过期),清除 cookie 并将他们发送到登录页面。
-
如果他们是有效用户,从你的服务器获取他们的信息并向存储库发送动作。
-
根据存储的状态渲染适当的路由组件。
-
使用
JSON.stringify将当前存储状态序列化,并将其嵌入你需要发送到浏览器的 HTML 中。
如果这听起来很复杂,不要担心。你只是在之前做的服务器渲染流程中添加了一个小步骤。你不再每次都渲染相同的内容,而是从 Firebase 获取数据,并使用这些信息进行渲染。记住,这里的优势是你可以“完全”渲染应用程序,这样用户就可以立即看到内容。
你在服务器上使用 Redux 是“通用”JavaScript 付诸实践的绝佳例子。如果 Redux 严重依赖于浏览器 API,那么在服务器上集成它可能很困难或不可能,你可能需要采取完全不同的方法。然而,现在,你可以按需重新创建一个存储库,根据你的 API 和 Firebase 的响应来更新它,然后就像在浏览器中一样使用存储库来渲染你的应用程序。图 12.10 展示了在本章中我们一直在研究的服务器渲染的上下文中的此过程。
图 12.10. 作为渲染过程一部分的数据获取的服务器渲染

在这个流程中,您使用来自浏览器的 cookie 来验证用户的令牌是否有效。然后您从 Firebase 获取用户,并将动作分发到在服务器端创建的 Redux 存储。您仍然渲染为静态 HTML,但这次您使用更新的状态进行渲染,以便应用程序可以使用新数据渲染。您还将状态嵌入到 HTML 响应中,以便浏览器可以从服务器停止的地方继续。在执行此操作时,需要注意的一个问题是您的 Redux 存储在服务器上没有被重新创建或持久保存在内存中。我在一些项目中遇到过这种情况,在本地开发期间会短暂发生,很难追踪。除了令人烦恼之外,这意味着服务器将为所有请求的用户渲染相同的数据,因为存储的状态没有被清除。在生产环境中,这将是一个不可接受的严重安全漏洞。我提到这一点是为了强调协调浏览器和客户端可能很复杂,并且必须谨慎操作,以避免棘手的错误或安全漏洞。
让我们看看您需要执行数据获取和渲染过程的代码。列表 12.14 展示了获取数据的初始步骤以及可能由过期或无效令牌引起的一些基本错误处理。在下一步中,您将集成异步服务器渲染与 React-DOM 的 renderToNodeStream,并进一步提高您的服务器渲染性能。
列表 12.14. 为服务器渲染获取数据(server/server.js)
// ...
const store = configureStore(initialReduxState); *1*
try {
const token = req.cookies['letters-token']; *2*
if (token) {
const firebaseUser = await firebase.auth()
.verifyIdToken(token); *3*
const userResponse = await fetch(
`${config.get('ENDPOINT')}/users/${firebaseUser.uid}` *3*
);
if (userResponse.status !== 404) { *4*
const user = await userResponse.json(); *4*
await store.dispatch(loginSuccess(user)); *5*
await store.dispatch(getPostsForPage());. *5*
}
}
} catch (err) {
if (err.errorInfo.code === 'auth/argument-error') { *6*
res.clearCookie('letters-token'); *6*
}
// dispatch the error
store.dispatch(createError(err)); *6*
}
//...
-
1 创建 Redux 存储实例
-
2 从请求的 cookie 中获取用户令牌
-
3 使用 Firebase 验证令牌,并使用响应从您的 JSON API 获取用户
-
4 如果用户存在,从 API 解包 JSON 响应(这里您使用 isomorphic-fetch 库和 async/await 语法)
-
5 感谢 Redux-thunk,您可以在登录时分发异步动作创建器,并在它们完成之前继续操作。
-
6 如果出现错误,例如令牌过期,将错误分发到存储
这就是您需要完成的大部分工作,以使用用户上下文完全渲染应用程序!这种方法的缺点是,如果您有多个页面,每个页面都有不同的数据获取需求,那么将它们整合起来会变得困难。您没有一种方法可以说,“啊,我们正在请求页面 X,页面 X 需要 Y 数据。”尽管如此,还是有方法可以做到这一点,我在我的博客上简要介绍了这些方法,请访问 ifelse.io/2017/09/07/server-rendering-with-react-router-and-react-16-fiber(如果您想了解更多关于这一点以及一些较新的 React Router 版本的信息)。
要完成您的渲染改进,您还需要做几件事。首先,您需要找到一种方法来注入 React-DOM 将返回给我们的 HTML 字符串。因为它与流一起工作,所以您之前使用的字符串模板方法需要改变。您不会直接注入生成的 HTML,而是使用两个函数来为您的应用程序编写 HTML。一个将包含您的应用程序需要的标题信息(关于应用程序的元数据、Open Graph 数据、CSS 链接等)。另一个将 Redux 商店状态嵌入到 HTML 响应中。您希望嵌入状态,这样当浏览器接管时,它不会重做服务器已经完成的工作。您希望减少渲染,而不是增加!下一个列表显示了您将传递组件和 Redux 商店状态的 HTML 包装组件。
列表 12.15. 嵌入 Redux 状态
const ogProps = {
updated_time: new Date(),
type: 'website',
url: 'https://social.react.sh',
title: 'Letters Social | React in Action by Mark Thomas from Manning
Publications',
description:
'Letters Social is a sample application for the React.js book React in
Action by Mark Thomas from Manning Publications. Get it today at
https://ifelse.io/book'
}; *1*
export const start = () => { *2*
return `<!DOCTYPE html><html lang="en-us">
<head>
<link rel="stylesheet" href="/static/styles.css" type="text/css" />
<link rel="stylesheet" href="https://api.mapbox.com/map-
box.js/v3.1.1/mapbox.css" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
Letters Social | React in Action by Mark Thomas from Manning
Publications
</title>
<link rel="manifest" href="/static/manifest.json" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="ROBOTS" content="INDEX, FOLLOW" />
<meta property="og:title" content="${ogProps.title}" />
<meta property="og:description" content="${ogProps.description}" />
<meta property="og:type" content="${ogProps.type}" />
<meta property="og:url" content="${ogProps.url}" />
<meta property="og:updated_time" content="${ogProps.updated_time}" />
<meta itemProp="description" content="${ogProps.description}" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="${ogProps.title}" />
<meta name="twitter:description" content="${ogProps.description}" />
<meta property="book:author" content="Mark Tielens Thomas" />
<meta property="book:tag" content="react" />
<meta property="book:tag" content="reactjs" />
<meta property="book:tag" content="React in Action" />
<meta property="book:tag" content="javascript" />
<meta property="book:tag" content="single page application" />
<meta property="book:tag" content="Manning publications" />
<meta property="book:tag" content="Mark Thomas" />
<meta name="HandheldFriendly" content="True" />
<meta name="MobileOptimized" content="320" />
<meta name="theme-color" content="#4469af" />
<link
href="https://fonts.googleapis.com/css?fam-
ily=Open+Sans:400,700,800"
rel="stylesheet"
/>
</head>
<body>
<div id="app">
`;
};
export const end = reduxState => { *3*
return `</div>
<script id="initialState">
window.__INITIAL_STATE__ = ${JSON.stringify(reduxState)};
</script> *3*
<script src="https://cdn.ravenjs.com/3.17.0/raven.min.js"
type="text/javascript"></script>
<script src="https://api.mapbox.com/mapbox.js/v3.1.1/mapbox.js"
type="text/javascript"></script>
<script src="/static/bundle.js" type="text/javascript"></script>
</body>
</html>`;
};
-
1 关于应用程序的基本元数据——一些与当前讨论不相关的样板代码被省略
-
2 将您的应用程序注入到主 div 中,这样当 React-DOM 在浏览器中接管时,它就不需要重做服务器已经完成的工作
-
3 浏览器中的 Redux 商店应该能够从服务器停止的地方接管,因此以 JSON-stringified 格式嵌入商店;
有了这些,您需要修改 Redux 商店,以便它能够接管。在这个列表中,您将做两件事:确保每次在服务器上创建 Redux 商店都是从头开始的(以防止前面提到的潜在错误),并教会它从 DOM 中读取初始状态。以下列表显示了您将对您的生产商店(开发版本不会被服务器渲染,因此没有初始状态可拾取)进行的这些小修改。
列表 12.16. 修改用于 SSR 的 Redux 商店(src/store/configureStore.prod.js)
//...
let store; *1*
export default function configureStore(initialState) {
if (store && !isServer()) {
return store; *1*
}
const hydratedState =
!isServer() && process.env.NODE_ENV === 'production' *2*
? window.__INITIAL_STATE__ *2*
: initialState;
store = createStore(
rootReducer,
hydratedState,
compose(applyMiddleware(thunk, crashReporting))
);
return store;
}
-
1 如果您在服务器上,您希望每次都返回新的商店
-
2 如果您不在服务器上且应用程序处于生产模式,检查 DOM 以获取状态并尽可能使用
现在,您的商店将能够从您的服务器嵌入的数据中读取初始状态,而无需做重复工作。接下来是什么?您可能还记得章节开头提到的,当在服务器上渲染时,您有异步选项可供使用。您目前使用的是 React-DOM 的 renderToString 方法,但它是同步的,如果许多用户同时访问应用程序,这可能会成为您服务器的瓶颈。在 React 16 中,引入了服务器渲染的异步选项,您将在这里使用它。使用方式相同,只是可以使用 node.js 流代替同步方法。
开源库
你已经将服务器端渲染集成到 Letters Social 应用中。你使用 Redux 使其工作,但要将应用扩展到非常大的规模或引入新的数据获取需求(例如其他页面的数据)可能需要一些重构和重新考虑你的服务器端渲染方法。有一些开源库可以帮助使用 React 进行服务器端渲染,这些库有助于统一允许组件在服务器上渲染。作为一个提高你对 React 服务器端渲染可能性的理解的练习,花些时间查看它们及其源代码。你可能会对服务器端渲染(在 react-server github.com/redfin/react-server的情况下是优化渲染)所能实现的事情感到惊喜,以及抽象化如何使实现服务器端渲染变得更加容易(在 Next.js 的情况下:github.com/zeit/next.js/)。
如果你之前使用过 node.js,你可能熟悉流。如果不熟悉,那也无所谓。node.js 中的流是一个用于处理流数据的抽象接口。这可能包括读取或写入文件、转换和压缩图像,或处理 HTTP 请求和响应等。你可以在nodejs.org/api/stream.html了解更多关于 node.js 中流的信息。下面的列表展示了如何利用 React-DOM 中的新renderToNodeStream API。
列表 12.17. 异步服务器端渲染(server/server.js)
res.setHeader('Content-type', 'text/html'); *1*
res.write(HTML.start()); *2*
const renderStream = renderToNodeStream( *3*
<Provider store={store}>
<RouterContext {...props} />
</Provider>
);
renderStream.pipe(res, { end: false }); *4*
renderStream.on('end', () => { *5*
res.write(HTML.end(store.getState()));
res.end();
});
-
1 写入 Content-type 头,以便浏览器知道期望的内容类型
-
2 浏览器应该尽可能快地开始加载页面,因此发送应用程序的第一部分
-
3 为你的应用程序创建一个渲染流
-
4 将渲染的应用程序管道发送到浏览器,但不要结束流
-
5 当流发出结束事件并且渲染完成后,发送剩余的 HTML 并结束响应
有了这些,Letters Social 现在正被完全渲染给用户。如果你使用开发者工具检查文档加载过程并查看服务器发送下来的内容,可以直接观察到这一点(图 12.11 显示了你应该看到的内容)。如果你在生产模式下运行应用程序,可能会看到速度上的差异,但查看 Chrome 或 Firefox 的开发者工具将允许你逐帧检查应用程序的加载。你会发现服务器正在发送完整的网页,而不仅仅是应用程序加载后进行渲染。
图 12.11. 如果我们使用 Chrome 开发者工具检查 social.react.sh 的性能标签,你会看到服务器正在发送完全渲染的 HTML,而不是等待应用程序包加载完成后再渲染应用程序。

12.8. 摘要
在本章中,我们探讨了如何将服务器端渲染功能构建到你的应用程序中。正如我们所看到的,它可能涉及到你应用程序的许多方面,包括路由、数据获取和状态管理(Redux):
-
服务器端渲染(SSR)是在服务器上为 UI 生成静态标记,并将其发送到客户端。使用 React 进行 SSR 涉及使用 React-DOM 来渲染一个 React 可以在客户端运行时重用的 HTML 字符串,或者静态标记(
ReactDOM.renderToString()),它旨在在浏览器上保持静态(ReactDOM.renderToStaticMarkUp())。 -
并非所有 JS 框架或库都是为处理 SSR 而构建的;React 是,并且可以“接管”在服务器上生成的标记,而无需最初在浏览器上重新渲染现有元素。
-
使用像 React Router 这样的路由解决方案可以允许你在客户端和服务器之间共享路由,从而让你能够在平台上共享一些代码。
-
SSR 的实现可能很复杂,并且仅在特定情况下才有意义。一些可能合理的情况包括当你特别关注 SEO 时,当你有一个关键路径需要快速首次绘制的应用程序时,或者如果你使用 React 作为静态标记生成器。
-
SSR 可以提供的性能提升通常只有在服务器发送的页面负载不是过大(这样就不会比之前加载得更慢)的情况下才能实现。更长的响应时间和更多数据可能会抵消你原本期望的快速首次绘制。
-
SSR 要求你考虑你的应用程序的哪些部分可以在服务器上运行,哪些不行。那些需要浏览器环境的特性需要修补才能工作,或者应该以不运行在服务器上的方式处理。
-
你可以通过在服务器上同步客户端和服务器之间的身份验证状态以及执行任何必要的服务器端数据获取来实现服务器上的“完整”渲染。
-
尽管存在其他 JS 平台实现,但 SSR 实际上要求你运行一个 node.js 服务器或至少调用一个服务器来生成发送给客户端的 HTML。
在下一章中,我们将简要介绍 React Native 并完成你对 React 基础知识的学习之旅。
第十三章。React Native 简介
本章涵盖
-
React Native 概述
-
React 和 React Native 之间的区别
-
了解更多关于 React Native 的方法
到目前为止,你已经掌握了使用 React 的基础知识,实现了路由器,探索了 Redux,研究了服务器端渲染,甚至过渡到了使用 React Router。接下来是什么?React 生态系统和社区中仍有大量内容可以学习和探索。本章从高层次的角度审视 React Native,这是 Facebook 在 React 生态系统内开发的另一个项目。使用 React Native,你可以编写在 iOS 和 Android 等移动平台上运行的 React 应用程序。这意味着你可以编写在智能手机和其他 React Native 现在或未来目标平台上的应用程序。React Native 以类似 React 的方式构建这些移动应用程序时提供了卓越的开发者体验,这也是它为什么在 React 社区中越来越重要和受欢迎的原因之一。
由于 React Native 和移动开发入门涵盖了相当大的领域,因此我将保持我们对 React Native 的讨论简明扼要,主要关注高级概念。到本章结束时,你应该对 React Native 是什么以及为什么你可能想使用它有一个概念,并且你会知道如何开始学习更多关于它的内容。
13.1. 介绍 React Native
在 React Native 出现之前,在创建移动应用程序方面你有几种选择。你可以使用可用的 iOS 和 Android 平台和语言,或者你可以选择可用的混合方法之一。这些方法在实现方式上有所不同,但它们通常会使用一个网页视图(想想“移动浏览器”)并向原生 SDK 公开一些接口。这种方法的缺点是,尽管你可以编写允许你使用许多熟悉的 Web API 和惯用方法的原生应用程序,但应用程序并不是“真正的原生”,有时在性能和整体感觉上会有明显的差异。好处是,没有移动开发专业知识的团队或开发者可以转移他们的 Web 相关技能,并能够创建一个移动应用程序。
移动开发的主题以及在这个世界中平台、语言和硬件如何扮演各自的不同角色超出了本书的范围。但混合和全原生方法之间的选择与我们对 React Native 的讨论相关,因为 React Native 提供了一个新的替代方案。使用 React Native,你可以构建“真正的原生”应用程序,同时可以使用 JavaScript 和平台特定代码(如 Swift 或 Java)的组合。
React Native 旨在将构建用户界面的 React 惯用和概念带到移动应用程序开发中,并将移动和浏览器开发的最佳方面结合起来。它鼓励跨平台代码共享(存在针对 iOS 和 Android 设备的组件),允许你在适当的地方编写原生代码,并编译成原生应用程序——所有这些都在使用许多 React 熟悉的惯用方法的同时完成。
让我们快速浏览一下 React Native 的几个顶级特性:
-
使用 React Native,你可以编写可以同时使用原生代码(Swift 或 Java)并编译为在 iOS 或 Android 上运行的本地应用程序的 JavaScript 应用程序。
-
React Native 可以在 Android 和 iOS 上处理创建相同的 UI 元素,这可能会简化移动应用程序的开发。
-
当你需要时,你可以添加自己的原生代码,这样你就不受限于仅使用 JavaScript。
-
React Native 应用程序与 React 共享语法,提供相同的以组件驱动、声明式概念,在某些情况下甚至提供相同的 API,以便在设计你的 UI 时使用。
-
构建 React Native 应用程序的开发者工具允许你在不需要等待漫长的编译周期的情况下重新加载你的应用程序。这通常可以节省开发者的时间,并使体验更加愉快。
-
能够共享代码并针对多个平台可以有时减少专门用于构建特定应用程序或项目的工程师数量。它可以导致维护的代码库减少,工程师可以更容易地在 Web 和原生平台之间移动。
-
你可以将 React 网页应用程序的逻辑和其他方面(如业务逻辑和某些情况下的样式)与 React Native 应用程序共享。
React Native 是如何工作的?它可能看起来是一个神秘的、黑盒的过程,将你的 JavaScript 转换为编译后的、本地应用程序。你不需要了解 React Native 的每个部分是如何工作的,才能与之合作,就像你不需要了解 React-DOM 的内部和外部才能编写出色的 React 应用程序一样。但至少对所使用的技术有一个工作理解通常是很有帮助的。
使用 React Native,你可以创建混合了 JavaScript 和原生代码的应用程序。React Native 通过在应用程序和底层移动平台之间创建某种桥梁来实现这一点。大多数移动设备都可以执行 JavaScript,React Native 就利用这一点来运行你的 JavaScript。当你的 JavaScript 与任何原生代码一起执行时,React Native 的桥接系统使用 React 核心库等工具,将组件层次结构(包括事件处理程序、状态、属性和样式)转换为移动设备上的视图。
当发生更新时(例如,用户按下按钮),React Native 将原生事件(一个点击、一个摇晃、一个地理位置事件,或任何其他事件)转换为你的 JavaScript 或原生代码可以处理的事件。它还会根据状态或属性的变化渲染适当的 UI。React Native 还会捆绑所有你的代码并执行任何必要的编译,以便你可以将你的应用程序发布到 Apple App Store 或 Google Play Store。
这些过程以及 React Native 的工作方式还有很多,但将设备上运行的 JavaScript 与原生平台 API 和事件之间进行转换的基本过程是 React Native“魔法”发生的地方。结果是您可以与之工作的平台,但在性能方面也不会妥协。它是在之前混合移动应用方法的问题和传统移动开发的一些痛点之间的一个折中方案。图 13.1 展示了它的工作的整体视图。
图 13.1. React Native 通过在您的 JavaScript 和底层原生平台之间创建桥梁来工作。大多数原生平台都实现了 JavaScript 虚拟机或其他运行 JavaScript 的本地方式。该桥梁使您的应用程序的 JavaScript 能够执行。React Native 桥接系统将在底层平台和您的 JavaScript 之间传递消息,以便将原生事件转换为 React 组件可以理解和响应的事件。

如果这听起来与您在这本书中学到的 React 有所不同,那么在许多方面确实是这样的。但更重要的是相似之处。我将在下一节中更详细地介绍这些相似之处,但您可以通过查看列表 13.1 中的代码来了解 React Native 组件与您迄今为止所使用的组件是多么相似。
即使我在本章中不介绍如何设置 React Native 项目,您仍然可以看到列表 13.1 中的代码做了什么。如果您想查看代码执行情况并尝试使用 React Native,请访问repl.it/KOAE/3。Repl.it 是一个在线平台,可以以交互式方式运行和共享代码,并且支持 React Native。您可以使用手机扫描 QR 码来查看您的 React Native playground 应用程序。这是一种无需进行任何设置或配置即可实验 React Native 的绝佳方式。
您可能会注意到的一个重要事情是,组件的元素(View,Text)与您之前章节中的组件中的div和span元素类似。这是一个 React 概念在各个平台之间持续存在的例子。组件的各个元素是什么并不那么重要,重要的是您可以重用和组合它们,如本列表所示。
列表 13.1. React Native 示例组件
import React, { Component } from 'react'; *1*
import { Text, View } from 'react-native'; *2*
export default class WhyReactNativeIsSoGreat extends Component {
render() {
return (
<View> *3*
<Text> *4*
If you like React on the web, you'll like React Native.
</Text>
<Text>
You just use native components like 'View' and 'Text',
instead of web components like 'div' and 'span'.
</Text>
</View>
);
}
}
-
1 您仍然可以使用常规的 React.Component,即使在原生应用中
-
2 React Native 提供了构建移动应用程序的基本元素。
-
3 您可以使用 React Native 组合组件;这里的视图组件就像浏览器中的 div 元素(常见布局组件)
-
4 文本在浏览器中更像是 span 元素
还有其他一些项目,如 React VR,其重点甚至更偏离您一直在工作的 Web UI,但它们使用相同的模式和概念。这是 React 平台最强大的方面之一,当您跨平台看到它时尤为明显。更多关于 React VR 的信息请访问facebook.github.io/react-vr。
13.2. React 和 React Native
React 和 React Native 有多相似?除了共享一个名字,它们都使用React核心库,但针对不同的平台(浏览器和移动设备)。本节将简要探讨它们的一些差异和相似之处。让我们比较一下 React 和 React Native 的一些重要方面:
-
运行时— React 和 React Native 针对不同的平台。React 针对浏览器,因此大量使用浏览器特定的 API。您可以在每个 API 中看到一些结果。例如,class、ID 等属性在基于 Web 的 React 组件中很常见。原生平台使用不同的布局和样式语义,因此在 React Native 组件上您不会看到很多这些属性。基于浏览器和移动应用程序也运行在不同的设备上,因此在考虑 React 和 React Native 时,不应忽视底层技术(如线程、CPU 利用率等)的差异。
-
核心 APIs— 许多 React 特定的 API(如用于组件生命周期、状态、属性等)在 React 和 React Native 中是相似的。但每个平台在联网、布局、地理位置、资源管理、持久化、事件和其他重要领域实现了不同的 API。React Native 旨在从面向浏览器的世界中导入一些熟悉的 API,例如用于联网的 Fetch API (
developer.mozilla.org/en-US/docs/Web/API/Fetch_API) 和用于布局的 Flexbox API (developer.mozilla.org/en-US/docs/Web/CSS/flex)。React Native 也暴露了事件,但它们更适用于移动平台(例如onPress)。这些差异可能是一个小障碍,但幸运的是,有一些库可以帮助消除 Web 和本地 API 之间的差异,如react-primitives(github.com/lelandrichardson/react-primitives)。 -
组件— 基于 Web 的 React 项目没有“内置”组件(例如,用于图像、文本布局或其他 UI 元素)。您需要自己创建这些组件。另一方面,React Native 确实包括文本、视图、图像等组件。这些是您需要为移动应用程序创建 UI 的原生元素,类似于浏览器环境中的 DOM 元素。
-
React核心库的使用—**React 和 React Native 都使用React核心库进行组件定义。每个项目都利用不同的渲染系统将一切连接起来并与设备(浏览器或移动设备)交互。React Web 使用react-dom库,而 React Native 实现了自己的系统。这种方法使你能够在不同平台上以类似的方式编写组件。 -
生命周期方法—**React Native 组件也有生命周期方法,因为它们继承自相同的
React基类,并且这些方法也由特定于平台的系统(React-DOM 或 React Native)处理。 -
事件类型—**与 React-DOM 实现了一个合成事件系统,允许你的组件以标准方式与浏览器事件一起工作不同,移动应用程序公开其他事件。一个例子是手势。你可以在触摸设备上平移、缩放、拖动等。用 React Native 组件编写的组件允许你响应这些事件。
-
样式—**由于 React Native 不针对浏览器,你需要以略微不同的方式对组件进行样式设计。常规移动开发中没有 CSS API,但你几乎可以使用所有 CSS 属性与 React Native 一起使用。React Native 提供了一个特定的 API,其中属性之间没有 1:1 的对应关系。以 CSS 动画为例。CSS 规范以及浏览器实现它的方式与 iOS 和 Android 启用和实现动画的方式不同,因此你需要以不同的方式动画化并使用每个平台正确的 API。学习新的样式 API 可能需要时间,并可能阻止直接在 Web 和原生项目中共享 CSS 样式。幸运的是,尽管如此,还有一些与 React 和 React Native 一起工作的库,如
styled-components(www.styled-components.com)。随着 React Native 的日益流行,你应该期待看到更多这些跨平台库的开发。 -
第三方依赖—**与 React 一样,你仍然可以使用第三方组件库为 React Native 开发。许多流行的库,如
React Router和styled-components,甚至包括针对 React Native 的变体(如前所述)。React Native 最吸引人的方面之一是它仍然可以利用 JavaScript 模块生态系统。 -
分发—**虽然你可以将 React 应用程序部署到几乎任何现代浏览器,但 React Native 应用程序需要特定于平台的分发工具,用于开发和最终发布(例如 Xcode)。你通常需要使用 React Native 构建过程来编译你的应用程序以供最终上传。iOS 和 Android 工具的“围栏花园”性质是开发移动应用程序的一个众所周知的权衡。
-
开发工具— React 用于网页在浏览器中运行,因此你可以利用任何特定浏览器的工具来帮助调试和开发。对于 React Native,你不需要拥有特定平台的工具,但它仍然可能很有用。项目之间的一个关键区别是 React Native 专注于热重载,而这是 React 默认不包含的。热重载可以加快移动开发速度,因为你不需要等待你的应用程序编译。图 13.2 展示了当你与 React Native 一起工作时可以访问的一些开发者工具。
图 13.2. React Native 随带一些额外的开发者工具,这些工具有助于性能、调试和其他功能。这些工具还意味着你不太严格依赖于 Xcode 等工具进行开发,尽管你当然可以使用你平台特定的工具进行开发。尽管有许多原因,但 React Native 提供的优秀开发者体验似乎是其特别受欢迎的技术之一原因。

13.3. 何时使用 React Native
并非每个开发者和每个团队都需要 React Native。让我们设想一些你可能遇到的场景,看看 React Native 是否是你应该考虑的:
-
独立开发者— 如果你第一次学习 React 或只是将其用于副项目,你可能会为了乐趣或如果你在处理任何移动项目时学习 React Native。如果你不熟悉原生开发但想逐步进入或拥有更简单的应用程序,React Native 也是可以考虑的。如果你已经了解 React,那么利用一些熟悉的概念来使用 React Native 进行移动开发是有意义的。
-
小型跨职能团队— 小型初创公司通常处于工程师将在整个堆栈上工作的位置,从服务器到客户端应用程序(网页、移动或其他)。在这种情况下,React Native 有时可以为那些在组织内扮演多个角色的工程师提供一种方式,让他们在没有深入移动经验的情况下开发移动应用程序,并且可以将他们的 React 动力延续到移动应用中。这也适用于希望轻松在不同应用程序或项目之间移动工程师的大型组织。
-
对原生开发经验有限到适中的团队— 如果你或你的团队对移动开发只有有限到适中的经验,但熟悉 React 和 JavaScript,React Native 可能会更容易让你快速组装产品。经验是无法替代的,但不必完全投入 Swift(iOS)或 Java(Android)可能潜在地节省你时间。
-
深入原生专业知识— 有些团队选择 React Native 并不是因为它在某些方面降低了移动开发的门槛,而是因为它有助于标准化业务(移动和桌面)应用程序的各种实现中的惯用和模式。但如果这不是问题,并且你已经对移动开发投入了大量的专业知识和时间,React Native 可能需要更仔细的评估,看看你的团队能否从可用的抽象和模式中受益。
除了你在考虑 React Native 时可能做出的团队和专业知识方面的考虑之外,你还应该意识到一些今天存在的技术的固有局限性:
-
使用 JavaScript— 如果你的团队或组织没有专注于 JavaScript 的开发者,或者已经在移动开发方面经验丰富,那么将工程师过渡到 JavaScript 和以 JavaScript 为中心的生态系统可能没有意义,这是完全可以接受的。就像 Web 上的 React 一样,React Native 并非万能的银弹,而应该基于权衡来评估,而不是围绕它的炒作。
-
特定的性能需求— React Native 性能良好,但作为一个抽象层,它可能会成为实现你或你的团队可能有的特定性能目标的另一个障碍。例如,如果渲染 3D 场景是应用程序的主要目标,React Native 可能不是最佳选择。其他框架(如 Unity)可能更适合。这与我刚才提到的“React 不是万能的银弹”的观点一致,并且我在前面的章节中一直试图保持这一观点。
-
高度专业化的应用程序— 有些应用程序类型不适合 React 模型。增强现实(AR)、图形密集型或其他高度专业化的应用程序通常需要特殊的库和技能,而大多数 Web 工程师并不具备这些技能。这并不是说不能做到,但截至目前,React Native 并没有专注于解决这些需求。
-
内部应用程序— 有时大公司会开发用于内部使用的应用程序,以帮助员工以各种方式更好地完成工作。React Native 非常适合这类应用程序,因为这类应用程序通常涉及相对简单的用户界面,并且可以由不专注于移动开发的工程师快速迭代。
当然,最终是否使用这项技术取决于你和你的团队来评估,但希望你现在对何时使用 React Native 可能或可能不合适有了更好的认识。
13.4. 最简单的“Hello World”
尽管我不会介绍如何将 React Native 与 Letters Social 集成,但本节将花一些时间通过一个基本的“Hello World”示例来演示其功能。你将在 Letters Social 存储库之外工作,所以请随意将应用程序代码放在你喜欢的位置以跟踪计算机上的代码。运行以下列表中的命令以开始。
列表 13.2. 安装 create-react-native-app
cd ./path-to-your-react-native-sample-folder
npm install -g create-react-native-app
create-react-native-app .
运行这些命令后,你应该能在你想要的目录中看到创建的多个文件和一些说明。这些命令与 Create React App 中可用的命令类似,这是一个仅关注 Web 平台的 React.js 的类似项目。你可以在 github.com/facebookincubator/create-react-app 上了解更多关于 Create React App 的信息。图 13.3 展示了当你开始使用 Create React Native App 库时应该看到的内容。
图 13.3. 当你在开发模式下启动应用程序时,你应该会看到 React Native 打包器启动,并看到这里所示的消息。按照说明确保你在本地机器上设置了 Expo XDE。根据你想要针对的环境,打开 Android 或 iOS 模拟器。

Create React Native App 工具安装了依赖项,创建了一些样板文件,设置了构建过程,并将 Expo React Native 工具包集成到项目中。Expo SDK 扩展了 React Native 的功能,使得与硬件技术的工作更加容易,以及其他方面。Expo XDE 开发环境使得管理多个 React Native 项目以及构建和部署它们变得容易。
你不会构建出任何实质性的东西,但你可以尝试修改并感受使用 React Native 开始构建应用程序的容易程度。一旦你使用 yarn start 启动了 React Native 打包器,打开一个模拟器(Android 或 iOS),这样你就可以看到正在运行的应用程序。替换一些样板代码,看看热重载是如何发生的。列表 13.3 展示了一个简单的组件,当它挂载时会从《星球大战》API 获取一些数据。注意,React Native 已经使用了现代的 Web API,如 Flexbox 和 Fetch(你之前章节中使用 polyfill 实现)。
列表 13.3. 简单的 React Native 示例(App.js)
import React from 'react';
import { StyleSheet, Text, View } from 'react-native'; *1*
export default class App extends React.Component {
constructor(props) { *2*
super(props);
this.state = {
people: []
};
}
async componentDidMount() { *3*
const res = await fetch('https://swapi.co/api/people');
const { results } = await res.json();
this.setState(() => {
return {
people: results
};
});
}
render() {
return (
<View style={styles.container}> *4*
<Text style={{ color: '#fcd433', fontSize: 40, padding: 10 }}>
A long time ago, in a Galaxy far, far away...
</Text>
<Text>Here are some cool people:</Text>
{this.state.people.map(p => { *5*
return (
<Text style={{ color: '#fcd433' }} key={p.name}>
{p.name}
</Text>
);
})}
</View>
);
}
}
const styles = StyleSheet.create({ *6*
container: {
flex: 1,
backgroundColor: '#000',
alignItems: 'center',
justifyContent: 'center'
}
});
-
1 与 React 不同,React Native 为你的 UI 提供了原始组件。
-
2 构造函数、状态初始化和生命周期方法在 React 和 React Native 中是相同的
-
3 你也可以在 React Native 应用中使用现代 JavaScript 特性,如 async/await。
-
4 即使在 React Native 中样式看起来相似,你并没有使用 CSS。
-
5 JSX 表达式在 React Native 和 React 中是相同的
-
6 在 React Native 中创建样式表需要使用其 Stylesheet API 来样式化你的组件。
如果你修改了应用程序,你应该看到打包器实时响应并更新你的运行中的应用程序,如图 13.4 所示 figure 13.4。我希望这能让你感受到在 React Native 中构建应用程序是多么容易。你可能已经习惯了网页上的热重载,但对于移动开发来说,编译-检查-重新编译的周期可能会占用相当多的时间。
图 13.4. 你应该能够看到在运行你的应用程序代码的模拟器中即时反映出的变化。

有了这个,你已经创建了你的第一个 React Native 组件和代码,这应该让你对这项技术的工作原理以及与之合作的便捷性有一个初步的了解。
13.5. 接下来去哪里
你在 React 文档、库生态系统和社区中会看到的一个短语是 一次学习,到处编写。这某种程度上是对 Java 社区中流行的 一次编写,到处运行 短语的致敬,这也是 React 范式的一个标志。正如我们在本章中看到的,你可以学习 React 概念并将它们应用到从网页到移动到 VR 的各种平台上。无论何时你学习如何在新的平台上使用 React,都会有一些平台特定的差异和细微差别,但你的大部分 React 知识将很容易迁移。这就是为什么与 React 合作可以如此愉快的原因之一。
如果你想要继续学习 React Native,有许多资源你可以查阅。一个是 Nader Dabit 编著的《React Native in Action》(Manning Publications,2018),如图 13.5 所示 figure 13.5,它与这本书配合得很好,因为它允许你在学习 React 的过程中无缝继续学习,并且是 React Native 的一个优秀入门。你将应用这本书到目前为止的工作中的知识,并利用这种势头深入构建使用 React Native 的移动应用程序。如果你的团队正在考虑为即将到来的项目使用 React Native,这也是一个值得查阅的好资源。
图 13.5. 《React Native in Action》由 Nader Dabit 编著,为 iOS、Android 和网页开发者提供了构建健壮、复杂 React Native 应用程序所需的技能。如果你对 React 仍然好奇,这是一本过渡到下一阶段的完美书籍。更多信息请访问 www.manning.com/books/react-native-in-action。

另一个帮助你开始使用 React Native 的绝佳资源是 Create React Native App 项目。Create React Native App 为新的 React Native 项目提供了一个出色的起点,对于那些刚开始使用它的开发者来说,也是一个出色的示例应用程序。它包含了一些预设的库和工具,用于构建 React Native 应用程序,但允许你“退出”并重置到默认设置。如果你对 Create React App 或 Create React Native App 感兴趣,可以在网上查看:
-
创建 React Native 应用—
github.com/react-community/create-react-native-app -
创建 React App—
github.com/facebook/create-react-app -
React Native 文档—
facebook.github.io/react-native
13.6. 概述
下面是本章所学内容的总结:
-
React Native 是 React 生态系统中的技术,开发者可以使用它来编写在移动 iOS 和 Android 设备上运行的 React 应用程序。
-
React Native 使用
React核心库进行组件创建,但使用不同的库来处理在本地平台上渲染应用程序以及与底层平台(触摸事件、地理位置、相机访问等)的交互。 -
React Native 处理 JavaScript 和底层移动平台之间的桥接。
-
React Native 使用许多与 Web API 相同或相似的 API。它使用 Flexbox 进行布局,使用 Fetch 进行网络请求,以及其他熟悉的 API。
-
在构建 React Native 应用程序时,你可以混合使用 JavaScript 和原生代码。
-
React Native 为开发和编译你的应用程序提供了一套强大的工具。
-
React Native 的热重载开发者工具通过每次不需要等待应用程序重新编译来节省你的时间。
-
使用 React Native 可以帮助你或你的团队降低移动开发的门槛。
-
你可能不会希望为所有类型的移动应用程序都使用 React Native,但它应该足够用于大多数典型移动应用程序。
-
Nader Dabit 著的 React Native in Action(Manning,2018)是你在 React 之旅中可以考虑的绝佳资源——请访问 www.manning.com/books/react-native-in-action 了解详情。



浙公网安备 33010602011771号