React16-模具-全-

React16 模具(全)

原文:zh.annas-archive.org/md5/649B7A05B5FE7684E1D753EE428FF41C

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

任何技术都取决于支持它的工具。React 也不例外。尽管 React 只是一个用于创建用户界面的库,但围绕它产生的生态系统意味着典型的 React 项目有许多组成部分。如果没有适当的工具支持,您最终会花费大量时间手动执行最好由工具自动化的任务。

React 工具有很多形式。有些已经存在一段时间,而其他一些是全新的。有些在浏览器中找到,而其他一些严格在命令行中。React 开发人员可以使用很多工具——我试图专注于对我所在项目产生直接影响的最强大的工具。

本书的每一章都专注于一个 React 工具。从基本开发工具开始,进入有助于完善 React 组件设计的工具,最后是用于在生产中部署 React 应用程序的工具。

这本书是为谁准备的

这本书适用于不断寻找更好工具和技术来提升自己水平的 React 开发人员。虽然阅读本书并不严格要求具有 React 经验,但如果您事先了解一些 React 的基础知识,您将获得最大的价值。

充分利用这本书

  • 学习 React 的基础知识。

  • 如果您已经在项目中使用 React,请确定缺少的工具。

下载示例代码文件

您可以从您在www.packtpub.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便文件直接通过电子邮件发送给您。

您可以按照以下步骤下载代码文件:

  1. www.packtpub.com登录或注册。

  2. 选择“支持”选项卡。

  3. 单击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用最新版本解压缩或提取文件夹:

  • WinRAR/7-Zip 适用于 Windows

  • Zipeg/iZip/UnRarX 适用于 Mac

  • 7-Zip/PeaZip 适用于 Linux

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/React-16-Tooling。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有来自我们丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。去看看吧!

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/React16Tooling_ColorImages.pdf

使用的约定

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

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。这是一个例子:“接下来,让我们看看由Create React App创建的package.json文件。”

代码块设置如下:

import React from 'react'; 

const Heading = ({ children }) => ( 
  <h1>{children}</h1> 
); 

export default Heading;

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

import React from 'react'; 

const Heading = ({ children }) => ( 
  <h1>{children}</h1> 
); 

export default Heading;

任何命令行输入或输出都是这样写的:

$ npm install -g create-react-app

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示为这样。这是一个例子:“一旦您点击“添加扩展”按钮,该扩展将被标记为已安装。”

警告或重要提示会出现在这样。提示和技巧会出现在这样。

第一章:创建个性化的 React 开发生态系统

当人们听到 React 时,他们会想到一个专注于高效渲染用户界面的库。当人们听到框架时,他们会想到一个庞大的系统,其中可能有一些有用的工具,但其他方面都是臃肿的混乱。在大多数情况下,他们对框架是正确的,但说 React 不是框架有点误导人。

如果你拿出 React 并尝试进行任何有意义的开发,你很快就会遇到障碍。这是因为 React 不是作为一个单一的框架分发的,而是更好地描述为一个核心库,周围有一系列工具的生态系统。

框架的优势在于你可以一次性安装核心库以及支持的工具。缺点是每个项目都不同,你无法确定你需要哪些工具,哪些不需要。另一个优势是拥有一系列工具的生态系统可以独立演进;你不必等待整个框架的新版本来增强你的项目所使用的工具之一。

本书的目的是向你展示如何最好地利用围绕 React 的工具生态系统。在本章中,你将通过学习以下内容来介绍 React 工具的概念:

  • 没有工具的 React

  • 工具介绍

  • 本书涵盖的工具

  • 决定项目所需的工具

React 包含了什么

在我们深入讨论工具之前,让我们确保我们对 React 是什么,以及在安装时实际包含了哪些内容有相同的理解。运行 React web 应用程序需要两个核心 React 包。我们现在来看一下这些,为你提供一些关于思考 React 工具的背景知识。

比较渲染树的组件

React 核心的第一部分是名为react的包。这个包是我们在编写 React 组件时直接接触的。它是一个小型 API——我们真正使用它的唯一时机是在创建带有状态并且需要扩展Component类的组件时。

react包的内部有很多工作。这就是渲染树所在的地方,负责高效地渲染 UI 元素。渲染树的另一个名称是虚拟 DOM。其思想是你只需要编写描述要渲染的 UI 元素的 JSX 标记,而渲染树会处理其他一切:

在这个图表中,你看到的是你的代码直接与之交互的组件,以及处理由改变状态的组件导致的呈现变化的渲染树。渲染树及其为你做的一切是 React 的关键价值主张。

DOM 渲染目标

React 核心的第二部分是文档对象模型DOM)本身。事实上,虚拟 DOM 的名称根植于 React 在实际与 DOM API 交互之前在 JavaScript 中创建 DOM 表示。然而,渲染树是一个更好的名称,因为 React 基于 React 组件及其状态创建了一个AST(抽象语法树)。这就是为什么相同的 React 库能够与 React Native 等项目一起工作。

react-dom包用于通过直接与浏览器 DOM API 通信,将渲染树实际转换为 DOM 元素。以下是包括react-dom的先前图表的样子:

这是一个很好的架构——这意味着你可以轻松地用另一个渲染目标替换react-dom。正如你所看到的,React 的核心层是最小的。难怪它如此受欢迎——我们可以使用声明性代码创建易于维护且高效的用户界面,而我们的工作量很少。有了这个想法,让我们把注意力转向使所有这些成为可能的工具。

介绍工具?

工具并不是 React 独有的。每个项目都有自己的一套工具,处理与核心技术相关的任务,这样你就不必自己去处理。对于框架,工具大部分都已经内置到项目中。对于像 React 这样的库,你可以选择你需要的工具,而不需要那些在你的项目中没有作用的工具。

现在你知道了 React 核心是什么,那么 React 生态系统的其余部分是什么呢?

React 之外的辅助任务

框架膨胀是许多人的主要抵触因素。之所以感觉膨胀,是因为它们有许多你可能永远不会使用的功能。React 处理这一点很好,因为它清楚地区分了核心库和其他任何东西,包括对 React 开发至关重要的东西。

关于 React 及其在周围生态系统中的定位,我做出了两点观察:

  • 依赖于简单库而不是包含所有功能的框架的应用程序更容易部署

  • 当你有工具大部分时间都不会妨碍你的时候,就更容易思考应用程序开发了。

换句话说,你不必使用大部分 React 工具,但其中一些工具非常有帮助。

任何给定的工具都是外部的,与你正在使用的库是分开的;这一点很重要。工具的存在是为了自动化一些本来会占用我们更多开发时间的事情。生命太短暂,没有时间手动做可以由软件代替的事情。我重申一遍,生命太短暂,没有时间做软件可以比我们做得更好的任务。如果你是一个 React 开发者,可以放心,有工具可以帮你完成所有重要的事情,而你自己没有时间去做。

建筑工地的类比

也许,认真对待工具的最终动机是想象一下,如果没有我们作为专业人士所依赖的工具,生活会是什么样子。建筑行业比软件更成熟,并且是一个很好的例子。

想象一下,你是一个负责建造房屋的团队的一部分,这是一个非常复杂的任务,有许多组成部分。现在,想想你要使用的所有东西。让我们从材料本身开始。任何不必在现场组装的东西都不会在现场组装。当你建造房屋时,许多部件会部分组装好。例如,屋顶框架的部分或混凝土在需要时出现。

然后是建筑工人在组装房屋时使用的实际工具——简单的螺丝刀、锤子和卷尺被视为理所当然。如果没有能力在现场制造部件或使用日常建筑材料的工具,建筑生活会是什么样子呢?建造房屋会变得不可能吗?不会。建造过程会变得非常昂贵和缓慢,以至于很可能在完成之前就会被取消吗?会。

不幸的是,在软件世界中,我们才刚刚开始意识到工具的重要性。如果我们没有正确的工具,就算拥有建造未来之屋所需的所有材料和知识也没有用。

JSX 需要被编译成 JavaScript

React 使用一种类似 HTML 的特殊语法来声明组件。这种标记语言叫做 JSX,它嵌入在组件的 JavaScript 中,在可被浏览器使用之前需要被编译成 JavaScript。

最常见的方法是使用 Babel——一个 JavaScript 编译器——以及一个 JSX 插件:

关键是找到一种使这个编译步骤尽可能无缝的方法。作为开发人员,你不应该需要关心 Babel 产生的 JavaScript 输出。

新的 JavaScript 语言特性需要被转译

与将 JSX 编译成 JavaScript 类似,新的 JavaScript 语言特性需要被编译成广泛支持的浏览器版本。事实上,一旦你弄清楚了如何将 JSX 编译成 JavaScript,同样的过程也可以用来在不同版本的 JavaScript 之间进行转译:

你不应该担心你的 JSX 或 JavaScript 编译的转换输出。这些活动更适合由工具来处理,这样你就可以专注于应用程序开发。

热模块加载以实现应用程序开发

Web 应用程序开发的独特之处在于,它主要是静态内容,加载到浏览器中。浏览器加载 HTML,然后加载任何脚本,然后运行完成。有一个长时间运行的过程,根据应用程序的状态不断刷新页面——一切都是通过网络进行的。

正如你所想象的那样,在开发过程中这是特别令人恼火的,当你想要看到代码更改的结果时。你不想每次做一些事情都要手动刷新页面。这就是热模块替换发挥作用的地方。基本上,HMR 是一个监听代码更改的工具,当它检测到更改时,它会向浏览器发送模块的新版本:

即使使用了像 Webpack 及其 HMR 组件这样的工具,为了使这个设置正确工作,即使对于简单的 React 项目也是耗时且容易出错的。幸运的是,今天有工具可以隐藏这些设置细节。

自动运行单元测试

你知道你需要为你的组件编写测试。并不是你不想编写实际的测试;而是设置它们能够运行可能会很麻烦。Jest 单元测试工具简化了这一点,因为它知道在哪里找到测试并且可以运行它们:

使用 Jest,我们有一个地方可以放置所有的单元测试,每个测试都依赖于它们所测试的组件。这个工具知道在哪里找到这些测试以及如何运行它们。结果是,当我们需要时,我们可以得到很好的单元测试和代码覆盖率输出。除了实际编写测试之外,没有额外的开销。

考虑类型安全性

JavaScript 不是一种类型安全的语言。类型安全性可以通过消除运行时错误的可能性大大提高应用程序的质量。我们可以再次使用工具来创建类型安全的 React 应用程序。Flow 工具可以检查你的代码,查找类型注释,并在发现错误时通知你。

代码质量检查

拥有一个能够工作的应用程序是一回事;拥有一个既能工作又具有可维护代码的应用程序是另一回事。实现可衡量的代码质量的最佳方法是采用标准,比如 Airbnb 的(github.com/airbnb/javascript)。强制执行编码标准的最佳方法是使用一个代码检查工具。对于 React 应用程序,首选的代码检查工具是 ESLint(eslint.org/)。

隔离组件开发环境

也许 React 开发者最容易忽视的工具是 Storybook,它用于隔离组件开发。在开发组件时,你可能意识不到,但应用程序可能会妨碍你。有时,你只想看看组件是什么样子,以及它是如何行为的。

使用类似 Storybook 这样的工具,为组件提供一个与其他组件无关的隔离环境是微不足道的。

提供基于浏览器的调试环境

有时,查看单元测试输出和源代码并不足以解决您正在经历的问题。相反,您需要查看与应用程序本身的交互情况。在浏览器中,您可以安装 React 工具,以便轻松检查与呈现的 HTML 内容相关的 React 组件。

React 还具有一些内置的性能监控功能,可以扩展浏览器开发人员工具的功能。您可以使用它们来检查和分析您的组件的低级别情况。

部署 React 应用程序

当您准备部署 React 应用程序时,它并不像简单地生成构建并分发那样简单。实际上,如果您正在构建托管服务,您甚至可能根本不会分发它。无论您的应用程序的最终用例是什么,除了 React 前端之外,可能还会有几个移动部分。越来越多地,将构成应用程序堆栈的主要进程容器化是首选方法:

为了创建和部署像这样的 React 应用程序堆栈,您将依赖于诸如 Docker 之类的工具,特别是在自动化项目的各种部署场景时。

选择正确的工具

如果上一节中的工具对于单个项目来说似乎有点过多,不要担心。试图同时利用每个可能的 React 工具总是一个错误。从基本工具开始,逐个解决问题。随着项目的推进,逐渐添加可选工具以扩展您的工具集。

基本工具

有一些 React 工具是您简直无法离开的。例如,浏览器无法理解 JSX 语法,因此需要将其编译为 JavaScript。在编写代码时,您会希望对其进行 lint 处理,以确保不会错过基本错误,并且您会希望运行单元测试。如果努力尝试,您可能可以在没有这些工具的情况下完成。但问题是,您将花费更多的精力来不使用给定的工具,而不是简单地接受它。

作为起点,找到一组最小的 React 工具,使您能够取得进展。一旦您的进展明显放缓,就是时候考虑引入其他工具了。

可选工具

可选工具是你可能不会从中获得任何真正价值的东西。例如,你可能不会在项目开始阶段就使用 Flow 来检查类型安全性或 Storybook 来隔离组件开发而获得巨大的好处。

要记住的关键是任何 React 工具都是可选的,没有永久的决定。你可以随时引入 Flow,如果隔离组件开发不是你的菜,你也可以随时放弃 Storybook。

总结

本章介绍了 React 生态系统中工具的概念。你了解到 React 本质上是一个简单的库,它依赖于使用多种工具才能在现实世界中产生任何价值。框架试图为你的项目提供所有你需要的工具。虽然方便,但框架用户的需求很难预测,可能会分散注意力,而不是专注于核心功能。

接下来,你了解到 React 中的工具可能是一个挑战,因为作为 React 开发者,你需要负责选择合适的工具并管理它们的配置。然后,你对本书剩余部分将更详细学习的工具进行了概述。最后,你了解到一些工具对于 React 开发是至关重要的,你需要立即设置它们。其他工具是可选的,你可能直到项目后期真正需要时才开始使用它们。

在下一章中,你将使用Create React App工具来启动一个 React 项目。

第二章:使用 Create React App 高效引导 React 应用程序

本书中您将学习的第一个 React 工具是Create React App。它是一个命令行实用程序,帮助您惊人地创建一个 React 应用程序。这可能听起来像是您不需要太多帮助的事情,但当您使用这个工具时,您不再需要考虑很多配置。在本章中,您将学习:

  • 在系统上安装Create React App工具

  • 引导创建您的 React 应用程序

  • 创建新应用程序时安装了哪些包

  • 应用程序的目录组织和文件

安装 Create React App

第一步是安装Create React App,这是一个 npm 包:create-react-app。这个包应该全局安装,因为它在您的系统上安装了一个用于创建 React 项目的命令。换句话说,create-react-app实际上并不是您的 React 项目的一部分,它用于初始化您的 React 项目。

以下是您可以全局安装Create React App的方法:

$ npm install -g create-react-app

注意命令中的-g标志—这确保create-react-app命令被全局安装。安装完成后,您可以通过运行以下命令来确保该命令可以正常运行:

$ create-react-app -V

> 1.4.1 

现在,您已经准备好使用这个工具来创建您的第一个 React 应用程序了!

创建您的第一个应用程序

我们将在本章的剩余部分使用Create React App创建您的第一个 React 应用程序。别担心,这很容易做到,所以这将是一个简短的章节。Create React App的目标是尽快开始为您的应用程序构建功能。如果您花费时间配置系统,就无法做到这一点。

Create React App提供了所谓的零配置应用程序。这意味着我们提供应用程序的名称,然后它将安装我们需要的依赖项,并为我们创建样板目录结构和文件。让我们开始吧。

指定项目名称

您需要向Create React App提供的唯一配置值是名称,以便它可以引导您的项目。这作为参数传递给create-react-app命令:

$ create-react-app my-react-app

如果当前目录中不存在my-react-app目录,它将在其中创建一个,如果已经存在,则将使用该目录。这是你将找到与你的应用程序有关的一切。一旦目录创建完成,它将安装包依赖项并创建项目目录和文件。这是create-react-app命令输出的缩短版本可能看起来像:

Creating a new React app in 02/my-react-app.

Installing packages. This might take a couple of minutes.
Installing react, react-dom, and react-scripts...

+ react-dom@16.0.0
+ react@16.0.0
+ react-scripts@1.0.14
added 1272 packages in 57.831s

Success! Created my-react-app at 02/my-react-app
Inside that directory, you can run several commands:

  npm start
    Starts the development server.

  npm run build
    Bundles the app into static files for production.

  npm test
    Starts the test runner.

  npm run eject
    Removes this tool and copies build dependencies,
    configuration files and scripts into the app directory.
    If you do this, you can't go back!

We suggest that you begin by typing:

  cd my-react-app
  npm start

Happy hacking!

这个输出向你展示了一些有趣的东西。首先,它显示了安装了哪些东西。其次,它向你展示了在你的项目中可以运行的命令。你将在本书的后续章节中学习如何使用这些命令。现在,让我们看看你刚刚创建的项目,并看看它包含了什么。

自动依赖处理

接下来,让我们看看在引导过程中安装的依赖项。你可以通过运行npm ls --depth=0来列出你的项目包。--depth=0选项意味着你只想看到顶层依赖项:

├── react@16.0.0 
├── react-dom@16.0.0 
└── react-scripts@1.0.14 

这里没有太多东西,只有你需要的两个核心 React 库,还有一个叫做react-scripts的东西。后者包含了你想要在这个项目中运行的脚本,比如启动开发服务器和生成生产版本。

接下来,让我们看看Create React App创建的package.json文件:

{ 
  "name": "my-react-app", 
  "version": "0.1.0", 
  "private": true, 
  "dependencies": { 
    "react": "¹⁶.0.0", 
    "react-dom": "¹⁶.0.0", 
    "react-scripts": "1.0.14" 
  }, 
  "scripts": { 
    "start": "react-scripts start", 
    "build": "react-scripts build", 
    "test": "react-scripts test --env=jsdom", 
    "eject": "react-scripts eject" 
  } 
} 

这里是跟踪依赖关系的地方,这样你就可以在没有Create React App的不同机器上安装你的应用程序。你可以看到dependencies部分与npm ls --depth=0命令的输出相匹配。scripts部分指定了在这个项目中可用的命令。这些都是react-scripts命令——react-scripts被安装为一个依赖项。

Create React App的一个更强大的方面是,它为你简化了package.json的配置。你不再需要维护几十个依赖项,而是只有少数几个依赖项。react-scripts包为你处理了动态配置方面。

例如,当您运行 React 开发服务器时,通常需要花费大量时间来处理 Webpack 配置,并确保适当的 Babel 插件已安装。由于react-scripts会动态创建这些内容的标准配置,您就不必担心了。相反,您可以立即开始编写应用程序代码。

react-scripts包还处理了许多通常需要自己处理的依赖关系。您可以使用npm ls --depth=1来了解这个包为您处理了哪些依赖关系:

└─┬ react-scripts@1.0.14 
     ├── autoprefixer@7.1.2 
     ├── babel-core@6.25.0 
     ├── babel-eslint@7.2.3 
     ├── babel-jest@20.0.3 
     ├── babel-loader@7.1.1 
     ├── babel-preset-react-app@3.0.3 
     ├── babel-runtime@6.26.0 
     ├── case-sensitive-paths-webpack-plugin@2.1.1 
     ├── chalk@1.1.3 
     ├── css-loader@0.28.4 
     ├── dotenv@4.0.0 
     ├── eslint@4.4.1 
     ├── eslint-config-react-app@2.0.1 
     ├── eslint-loader@1.9.0 
     ├── eslint-plugin-flowtype@2.35.0 
     ├── eslint-plugin-import@2.7.0 
     ├── eslint-plugin-jsx-a11y@5.1.1 
     ├── eslint-plugin-react@7.1.0 
     ├── extract-text-webpack-plugin@3.0.0 
     ├── file-loader@0.11.2 
     ├── fs-extra@3.0.1 
     ├── fsevents@1.1.2 
     ├── html-webpack-plugin@2.29.0 
     ├── jest@20.0.4 
     ├── object-assign@4.1.1 deduped 
     ├── postcss-flexbugs-fixes@3.2.0 
     ├── postcss-loader@2.0.6 
     ├── promise@8.0.1 
     ├── react-dev-utils@4.1.0 
     ├── style-loader@0.18.2
 ├── sw-precache-webpack-plugin@0.11.4 
     ├── url-loader@0.5.9 
     ├── webpack@3.5.1 
     ├── webpack-dev-server@2.8.2 
     ├── webpack-manifest-plugin@1.2.1 
     └── whatwg-fetch@2.0.3 

通常,您不会在应用程序代码中与大多数这些包进行交互。当您不得不积极管理自己没有直接使用的依赖关系时,会感觉像是在浪费大量时间。Create React App有助于消除这种感觉。

目录结构

到目前为止,您已经了解了在使用Create React App创建项目时作为其一部分安装的依赖关系。除了依赖关系外,Create React App还设置了一些其他样板文件和目录。让我们快速地过一遍这些,这样您就可以在下一章开始编码了。

顶层文件

在您的应用程序的顶层只创建了两个文件,您需要关注:

  • README.md:这个 Markdown 文件用于描述项目。如果您计划将您的应用程序作为 GitHub 项目,这是一个很好的地方来解释您的项目存在的原因以及人们如何开始使用它。

  • package.json:这个文件用于配置分发您的应用程序作为 npm 包的所有方面。例如,这是您可以添加新依赖项或删除过时依赖项的地方。如果您计划将您的应用程序发布到主 npm 注册表,这个文件就非常重要。

静态资产

Create React App为您创建了一个 public 目录,并在其中放置了一些文件。这是静态应用程序资产的存放位置。默认情况下,它包含以下内容:

  • favion.ico:这是在浏览器标签中显示的 React 标志。在发布之前,您会希望用代表您的应用程序的东西替换它。

  • index.html:这是提供给浏览器的 HTML 文件,也是您的 React 应用程序的入口点。

  • manifest.json:当应用程序添加到主屏幕时,一些移动操作系统会使用这个文件。

源代码

src目录是由create-react-app创建的应用程序中最重要的部分。这是你创建的任何 React 组件的所在地。默认情况下,这个目录中有一些源文件,可以让你开始,尽管随着你的进展,你显然会替换大部分文件。以下是默认情况下你会找到的内容:

  • App.css:这定义了一些简单的 CSS 来为App组件设置样式

  • App.js:这是渲染应用程序 HTML 的默认组件

  • App.test.js:这是App组件的基本测试

  • index.css:这定义了应用程序范围的样式

  • index.js:这是你的应用程序的入口点—渲染App组件

  • logo.svg:一个由App组件渲染的动画 React 标志

  • registerServiceWorker.js:在生产构建中,这将使组件从离线缓存中加载

有这些默认源文件为你创建有两个好处。首先,你可以快速启动应用程序,确保一切正常运行,而且你没有犯任何基本错误。其次,它为你的组件设定了一个基本模式。在本书中,你将看到如何将模式应用到组件实际上有助于工具化。

概要

在本章中,你学会了如何在你的系统上安装Create React App工具。Create React App是启动现代 React 应用程序的首选工具。Create React App的目标是让开发人员在最短的时间内从零开始创建 React 组件。

安装了这个工具后,你使用它创建了你的第一个 React 应用程序。你需要提供的唯一配置是应用程序名称。一旦工具完成安装依赖项并创建样板文件和目录,你就可以开始编写代码了。

然后,我们看了react-scripts和这个包所处理的依赖项。然后,你被带领快速浏览了为你创建的应用程序的整体结构。

在接下来的章节中,我们将开始开发一些 React 组件。为此,我们将启动开发服务器。你还将学习如何使用create-react-app开发环境快速上手。

第三章:开发模式和精通热重载

在上一章中,你学会了如何使用create-react-app。这只是我们React 工具链旅程的开始。通过使用create-react-app来引导你的应用程序,你安装了许多其他用于开发的工具。这些工具是react-scripts包的一部分。本章的重点将是react-scripts附带的开发服务器,我们将涵盖:

  • 启动开发服务器

  • 自动 Webpack 配置

  • 利用热组件重新加载

启动开发服务器

如果你在上一章中使用create-react-app工具创建了一个 React 应用程序,那么你已经拥有了启动开发服务器所需的一切。不需要进行任何配置!让我们立即启动它。首先确保你在项目目录中:

cd my-react-app/ 

现在你可以启动开发服务器了:

npm start 

这将使用react-scripts包中的start脚本启动开发服务器。你应该会看到类似于这样的控制台输出:

Compiled successfully!

You can now view my-react-app in the browser.

  Local:            http://localhost:3000/
  On Your Network:  http://192.168.86.101:3000/

Note that the development build is not optimized.
To create a production build, use npm run build. 

你会注意到,除了在控制台中打印这个输出之外,这个脚本还会在浏览器中打开一个新的标签页,地址为http://localhost:3000/。显示的页面看起来像这样:

到目前为止,在仅仅几章中我们已经取得了很多成就。让我们暂停一下,回顾一下我们所做的事情:

  1. 你使用create-react-app包创建了一个新的 React 应用程序。

  2. 你已经有了基本的项目结构和一个占位符App组件来渲染。

  3. 你启动了开发服务器,现在你准备构建 React 组件了。

在没有create-react-appreact-scripts的情况下,要达到这一点通常需要花费数小时。你可能没有数小时来处理元开发工作。很多工作已经为你自动化了!

Webpack 配置

Webpack 是构建现代 Web 应用程序的首选工具。它强大到足以将从 JSX 语法到静态图像的所有内容编译成准备部署的捆绑包。它还带有一个开发服务器。它的主要缺点是复杂性。有很多需要配置的移动部分才能让 Webpack 起步,但你不需要触及其中任何部分。这是因为大多数为 React 应用程序设置的 Webpack 配置值对于大多数 React 应用程序都是相同的。

有两个独立的开发服务器配置。首先是 Webpack 开发服务器本身。然后是主要的 Webpack 配置,即使你没有使用 Webpack 开发服务器,你也需要它。那么这些配置文件在哪里?它们是react-scripts包的一部分,这意味着你不必去瞎折腾它们!

现在让我们浏览一些这些配置值,让你更好地了解你可以避免的不必要的头痛。

入口点

入口点用于告诉 Webpack 从哪里开始查找用于构建应用程序的模块。对于一个简单的应用程序,你不需要更多的东西,只需要一个文件作为入口点。例如,这可以是用于渲染你的根 React 组件的index.js文件。从其他编程语言借来的术语来看,这个入口点也可以被称为主程序。

当你运行start脚本时,react-scripts包会在你的源文件夹中寻找一个index.js文件。它还添加了一些其他入口点:

  • Promisefetch()Object.assign()的填充。只有在目标浏览器中不存在时才会使用它们。

  • 一个用于热模块重载的客户端。

这最后两个入口点对于 React 开发非常有价值,但当你试图启动一个项目时,它们并不是你想要考虑的事情。

构建输出

Webpack 的工作是打包你的应用程序资源,以便它们可以轻松地从网络中提供。这意味着你必须配置与包输出相关的各种事物,从输出路径和文件开始。Webpack 开发服务器实际上并不会将捆绑文件写入磁盘,因为假定构建会频繁发生。生成的捆绑文件保存在内存中。即使有这个想法,你仍然需要配置主要输出路径,因为 Webpack 开发服务器仍然需要将其作为真实文件提供给浏览器。

除了主要的输出位置,你还可以配置块文件名和用于提供文件的公共路径。块是被分割成更小的片段以避免创建一个太大并可能导致性能问题的单个捆绑文件。等等,什么?在你甚至为你的应用程序实现一个组件之前就考虑性能和用于提供资源的路径?在项目的这一阶段完全是不必要的。别担心,react-scripts已经为你提供了配置,你可能永远不需要改变。

解析输入文件

Webpack 的一个关键优势是你不需要提供一个需要捆绑的模块列表。一旦在 Webpack 配置中提供了一个入口点,它就可以找出你的应用程序需要哪些模块,并相应地捆绑它们。不用说,这是 Webpack 为你执行的一个复杂的任务,它需要尽可能多的帮助。

例如,resolve配置的一部分是告诉 Webpack 要考虑哪些文件扩展名,例如.js.jsx。你还想告诉 Webpack 在哪里查找包模块。这些是你没有编写的模块,也不是你应用程序的一部分。这些通常可以在项目的node_modules目录中找到的 npm 包。

还有更高级的选项,比如为模块创建别名并使用解析器插件。再次强调,在编写任何 React 代码之前,这些都与你无关,但你需要配置它们以便开发你的组件,除非你正在使用react-scripts来处理这个配置。

加载和编译文件

加载和编译文件对于你的捆绑来说可能是 Webpack 最重要的功能。有趣的是,Webpack 在加载文件后并不直接处理它们。相反,它通过 Webpack 加载器插件协调 I/O。例如,react-scripts使用以下加载器插件的 Webpack 配置:

  • Babel:Babel 加载器将你应用程序的源文件中的 JavaScript 转译成所有浏览器都能理解的 JavaScript。Babel 还会处理将你的 JSX 语法编译成普通的 JavaScript。

  • CSSreact-scripts使用了一些加载程序来生成 CSS 输出:

  • style-loader:使用import语法像导入 JavaScript 模块一样导入 CSS 模块。

  • postcss-loader:增强的 CSS 功能,如模块、函数和自定义属性。

  • 图片:通过 JavaScript 或 CSS 导入的图片使用url-loader进行捆绑。

随着你的应用程序成熟,你可能会发现自己需要加载和捆绑不在默认react-scripts配置范围内的不同类型的资产。由于你在项目开始时不需要担心这一点,所以没有必要浪费时间配置 Webpack 加载器。

配置插件

似乎有一个无穷无尽的插件列表可以添加到你的 Webpack 配置中。其中一些对开发非常有用,所以你希望这些插件在前期就配置好。其他一些可能在项目成熟后才会有用。react-scripts默认使用的插件有助于无缝的 React 开发体验。

热重载

热模块重载机制需要在主 Webpack 捆绑配置文件和开发服务器配置中进行配置。这是另一个你在开始开发组件时想要的东西的例子,但不想花时间去做。react-scriptsstart命令启动了一个已经配置好了热重载的 Webpack 开发服务器。

热组件重载正在进行中

在本章的前面,你学会了如何启动react-scripts开发服务器。这个开发服务器已经配置好了热模块重载,可以直接使用。你只需要开始编写组件代码。

让我们从实现以下标题组件开始:

import React from 'react'; 

const Heading = ({ children }) => ( 
  <h1>{children}</h1> 
); 

export default Heading; 

这个组件将任何子文本呈现为<h1>标签。简单吗?现在,让我们改变App组件来使用Heading

import React, { Component } from 'react'; 
import './App.css'; 
import Heading from './Heading';

class App extends Component { 
  render() { 
    return ( 
      <div className="App"> 
        <Heading> 
          My App 
        </Heading> 
      </div> 
    ); 
  } 
} 

export default App; 

然后,你可以看到这是什么样子的:

Heading组件按预期渲染。现在你已经在浏览器中初始化加载了你的应用程序,是时候让热重载机制开始工作了。假设你决定改变这个标题的标题:

<Heading> 
  My App Heading 
</Heading> 

当你在代码编辑器中保存时,Webpack 开发服务器会检测到发生了变化,新代码应该被编译、捆绑并发送到浏览器。由于react-scripts已经配置好了 Webpack,你可以直接进入浏览器,观察变化的发生:

这应该有助于加快开发速度!事实上,它已经做到了,你刚刚见证了。你修改了一个 React 元素的文本,并立即看到了结果。你本可以花几个小时来设置 Webpack 配置,但你不必这样做,因为你只需重用react-scripts提供的配置,因为几乎所有的 React 开发配置看起来都应该差不多。随着时间的推移,它们会分歧,但没有任何组件的项目看起来都非常相似。关键是要快速上手。

现在让我们尝试一些不同的东西。让我们添加一个带有state的组件,并看看当我们改变它时会发生什么。这是一个简单的按钮组件,它会跟踪自己的点击次数:

import React, { Component } from 'react'; 

class Button extends Component { 
  style = {} 

  state = { 
    count: 0 
  } 

  onClick = () => this.setState(state => ({ 
    count: state.count + 1 
  })); 

  render() { 
    const { count } = this.state; 
    const { 
      onClick, 
      style 
    } = this; 

    return ( 
      <button {...{ onClick, style }}> 
        Clicks: {count} 
      </button> 
    ); 
  } 
} 

export default Button;

让我们分解一下这个组件的运行情况:

  1. 它有一个style对象,但没有任何属性,所以这没有任何效果。

  2. 它有一个count状态,每次点击按钮时都会增加。

  3. onClick()处理程序设置了新的count状态,将旧的count状态增加1

  4. render()方法渲染了一个带有onClick处理程序和style属性的<button>元素。

一旦你点击这个按钮,它就会有一个新的状态。当我们使用热模块加载时会发生什么?让我们试一试。我们将在我们的App组件中渲染这个Button组件,如下所示:

import React, { Component } from 'react'; 
import './App.css'; 
import Heading from './Heading'; 
import Button from './Button'; 

class App extends Component { 
  render() { 
    return ( 
      <div className="App"> 
        <Heading> 
          My App Heading 
        </Heading> 
        <Button/> 
      </div> 
    ); 
  } 
} 

export default App; 

当你加载 UI 时,你应该看到类似这样的东西:

点击按钮应该将count状态增加1。确实,点击几次会导致渲染的按钮标签发生变化,反映出新的状态:

现在,假设你想改变按钮的样式。我们将使文本加粗:

class Button extends Component { 
  style = { fontWeight: 'bold' } 

  ... 

  render() { 
    const { count } = this.state; 
    const { 
      onClick, 
      style 
    } = this; 

    return ( 
      <button {...{ onClick, style }}> 
        Clicks: {count} 
      </button> 
    ); 
  } 
} 

export default Button; 

热模块机制的工作正常,但有一个重要的区别:Button组件的状态已经恢复到初始状态:

这是因为当Button.js模块被替换时,现有的组件实例在被新实例替换之前会被卸载。组件的状态和组件本身都会被清除。

解决这个问题的方法是使用React Hot Loader工具。这个工具将保持你的组件在其实现更新时挂载。这意味着状态会保持不变。在某些情况下,这可能非常有帮助。当你刚开始时是否需要这个?可能不需要——不保持状态的热模块重载已经足够让你开始。

从 Create React App 中弹出

create-react-appreact-scripts的目标是零配置的 React 开发。你花在配置开发样板的时间越少,你就能花更多时间开发组件。你应该尽可能地避免担心为你的应用程序进行配置。但是在某个时候,你将不得不放弃create-react-app并维护自己的配置。

提供零配置环境之所以可能,是因为create-react-app假定了许多默认值和许多限制。这是一种权衡。通过为大多数 React 开发人员必须做但不想做的事情提供合理的默认值,你正在为开发人员做出选择。这是一件好事——在应用程序开发的早期阶段能够推迟决策会让你更加高效。

React 组件热加载是create-react-app的一个限制的很好的例子。它不是create-react-app提供的配置的一部分,因为在项目初期你可能不需要它。但随着事情变得更加复杂,能够在不中断当前状态的情况下对组件进行故障排除是至关重要的。在项目的这一阶段,create-react-app已经完成了它的使命,现在是时候弹出了。

要从create-react-app中弹出,运行eject脚本:

npm run eject

你将被要求确认此操作,因为没有回头的余地。在这一点上,值得强调的是,在create-react-app不再适用之前,你不应该弹出。记住,一旦你从create-react-app中弹出,你现在要承担维护所有曾经隐藏在视图之外的脚本和配置的责任。

好消息是,弹出过程的一部分涉及为项目设置脚本和配置值。基本上,这与react-scripts在内部使用的是相同的东西,只是现在这些脚本和配置文件被复制到你的项目目录中供你维护。例如,弹出后,你会看到一个包含以下文件的scripts目录:

  • build.js

  • start.js

  • test.js

现在,如果您查看package.json,您会发现您使用npm调用的脚本现在引用您的本地脚本,而不是引用react-scripts包。反过来,这些脚本使用在您运行弹出时为您创建的config目录中找到的文件。以下是在此处找到的相关 Webpack 配置文件:

  • webpack.config.dev.js

  • webpack.config.prod.js

  • webpackDevServer.config.js

请记住,这些文件是从react-scripts包中复制过来的。弹出只是意味着您现在控制了曾经隐藏的一切。它的设置方式仍然完全相同,并且在您更改它之前将保持不变。

例如,假设您已经决定需要 React 的热模块替换,以一种可以保持组件状态的方式。现在您已经从create-react-app中弹出,可以配置启用react-hot-loader工具所需的部分。让我们从安装依赖开始:

npm install react-hot-loader --save-dev

接下来,让我们更新webpack.config.dev.js文件,以便它使用react-hot-loader。这是在我们弹出之前不可能配置的东西。有两个部分需要更新:

  1. 首先,在entry部分找到以下行:
      require.resolve('react-dev-utils/webpackHotDevClient'), 
  1. 用以下两行替换它:
      require.resolve('webpack-dev-server/client') + '?/', 
      require.resolve('webpack/hot/dev-server'), 
  1. 接下来,您需要将react-hot-loader添加到 Webpack 配置的module部分。找到以下对象:
      { 
        test: /\.(js|jsx|mjs)$/, 
        include: paths.appSrc, 
        loader: require.resolve('babel-loader'), 
        options: { 
          cacheDirectory: true, 
        }, 
      }
  1. 将其替换为以下内容:
      { 
        test: /\.(js|jsx|mjs)$/, 
        include: paths.appSrc, 
        use: [ 
          require.resolve('react-hot-loader/webpack'), 
          { 
            loader: require.resolve('babel-loader'), 
            options: { 
              cacheDirectory: true, 
            }, 
          } 
        ] 
      }, 

在这里所做的只是将loader选项更改为use选项,以便您可以传递一系列的加载器。您之前使用的babel-loader保持不变。但现在您还添加了react-hot-loader/webpack加载器。现在这个工具可以在源代码更改时检测何时需要热替换 React 组件。

这就是您需要更改开发 Webpack 配置的全部内容。接下来,您需要更改根 React 组件的渲染方式。以下是index.js以前的样子:

import React from 'react'; 
import ReactDOM from 'react-dom'; 
import './index.css'; 
import App from './App'; 
import registerServiceWorker from './registerServiceWorker'; 

ReactDOM.render(<App />, document.getElementById('root')); 
registerServiceWorker(); 

为了启用热组件替换,您可以更改index.js,使其看起来像这样:

import 'react-hot-loader/patch'; 
import React from 'react'; 
import ReactDOM from 'react-dom'; 
import { AppContainer } from 'react-hot-loader'; 

import './index.css'; 
import App from './App'; 
import registerServiceWorker from './registerServiceWorker'; 

const render = Component => { 
  ReactDOM.render( 
    <AppContainer> 
      <Component /> 
    </AppContainer>, 
    document.getElementById('root') 
  ) 
};
render(App); 

if (module.hot) { 
  module.hot.accept('./App', () => { 
    render(App); 
  }); 
} 

registerServiceWorker(); 

让我们分解一下您刚刚添加的内容:

  1. import 'react-hot-loader/patch'语句是必要的,用于引导react-hot-loader机制。

  2. 您创建了一个接受要渲染的组件的render()函数。该组件被react-hot-loaderAppContainer组件包装,该组件处理了一些与热加载相关的簿记工作。

  3. render(App)的第一次调用渲染了应用程序。

  4. module.hot.accept() 的调用设置了一个回调函数,当组件的新版本到达时渲染 App 组件。

现在您的应用程序已准备好接收热更新的 React 组件。当您的源代码发生更改时,它总是能够接收更新,但正如本章前面讨论的那样,这些更新将在组件重新渲染之前清除组件中的任何状态。现在 react-hot-loader 已经就位,您可以保留组件中的任何状态。让我们试一试。

加载 UI 后,点击按钮几次以更改其状态。然后,更改 style 常量以使字体加粗:

const style = { 
  fontWeight: 'bold' 
}; 

保存此文件后,您会注意到按钮组件已更新。更重要的是,状态没有改变!如果您点击按钮两次,现在应该是这样的:

这只涉及一个按钮的简单示例。但是,通过从 create-react-app 中弹出,调整开发 Webpack 配置,并改变 App 组件渲染方式所创建的设置可以支持未来创建的每个组件的热加载。

react-hot-loader 包添加到您的项目中只是需要从 create-react-app 中弹出以便您可以调整配置的一个例子。我建议不要更改绝对必要的内容。确保在更改 create-react-app 给您的配置时有一个具体的目标。换句话说,不要撤消 create-react-app 为您所做的所有工作。

总结

在本章中,您学会了如何为使用 create-react-app 创建的项目启动开发服务器。然后您了解到 react-scripts 包在为您启动开发服务器时使用自己的 Webpack 配置。我们讨论了在尝试编写应用程序时不一定需要考虑的配置的关键领域。

最后,您看到了热模块重新加载的实际操作。react-scripts默认情况下在您进行源代码更改时重新加载应用程序。这会导致页面刷新,这已经足够好用了。然后我们看了一下使用这种方法开发组件可能面临的挑战,因为它会清除组件在更新之前的任何状态。因此,您从create-react-app中退出,并自定义了项目的 Webpack 配置,以支持保留状态的热组件重新加载。

在接下来的章节中,您将使用工具来支持在您的 React 应用程序中进行单元测试。

第四章:优化测试驱动的 React 开发

也许,React 生态系统中最重要的工具之一是 Jest——用于测试 React 组件的测试运行器和单元测试库。Jest 旨在克服其他测试框架(如 Jasmine)面临的挑战,并且是针对 React 开发而创建的。有了像 Jest 这样强大的测试工具,您更有能力让您的单元测试影响 React 组件的设计。在本章中,您将学到:

  • Jest 的总体设计理念及其对 React 开发者的意义

  • create-react-app环境和独立的 React 环境中运行 Jest 单元测试

  • 使用 Jest API 编写有效的单元测试和测试套件

  • 在您的代码编辑器中运行 Jest 单元测试并将测试集成到您的开发服务器中

Jest 的驱动理念

在上一章中,您了解到create-react-app工具是为了使开发 React 应用程序更容易而创建的。它通过消除前期配置来实现这一目的——您直接开始构建组件。Jest 也是出于同样的目的而创建的,它消除了您通常需要创建的前期样板,以便开始编写测试。除了消除初始单元测试配置因素之外,Jest 还有一些其他技巧。让我们来看看使用 Jest 进行测试的一些驱动原则。

模拟除应用程序代码之外的所有内容

你最不想花时间测试别人的代码。然而,有时你被迫这样做。例如,假设您想测试一个调用某个 HTTP API 的fetch()函数。另一个例子:您的 React 组件使用某个库来帮助设置和操作其状态。

在这两个例子中,有一些您没有实现的代码在运行您的单元测试时被执行。您绝对不希望通过 HTTP 与外部系统联系。您绝对不希望确保您的组件状态是根据另一个库的函数输出正确设置的。对于我们不想测试的代码,Jest 提供了一个强大的模拟系统。但是您需要在某个地方划清界限——您不能模拟每一个小事物。

这是一个组件及其依赖项的示例:

这个组件需要三个库才能正常运行。你可能不想按原样对这个组件进行单元测试,因为这样你也会测试其他三个库的功能。你不想在单元测试期间运行的库可以使用 Jest 进行模拟。你不必对每个库进行模拟,对一些库来说,模拟它们可能会带来更多麻烦。

举个例子,假设在这种情况下Lib C是一个日期库。你真的需要对它进行模拟吗,还是你实际上可以在组件测试中使用它产生的值?日期库是相当低级的,所以它可能是稳定的,对你的单元测试的功能可能造成非常小的风险。另一方面,库的级别越高,它所做的工作越多,对你的单元测试就越有问题。让我们看看如果你决定使用 Jest 来模拟Lib ALib B会是什么样子:

如果你告诉 Jest 你想要模拟Lib ALib B的实现,它可以使用实际的模块并自动创建一个对象供你的测试使用。因此,几乎不费吹灰之力,你就可以模拟那些对测试你的代码构成挑战的依赖关系。

隔离测试并并行运行

Jest 使得在一个沙盒环境中隔离你的单元测试变得容易。换句话说,运行一个测试的副作用不能影响其他测试的结果。每次测试运行完成后,全局环境会自动重置为下一个测试。由于测试是独立的,它们的执行顺序并不重要,Jest 会并行运行测试。这意味着即使你有数百个单元测试,你也可以频繁地运行它们,而不必担心等待的问题。

这是 Jest 如何在它们自己的隔离环境中并行运行测试的示例:

最好的部分是 Jest 会为你处理扩展进程的问题。例如,如果你刚刚开始,你的项目只有少数几个单元测试,Jest 不会生成八个并行进程。它只会在一个进程中运行它们。你需要记住的关键是,单元测试是它们自己的宇宙,不受其他宇宙的干扰。

测试应该感觉自然

Jest 让你很容易开始运行你的测试,但是写测试呢?Jest 提供的 API 使得编写没有太多复杂部分的测试变得容易。API 文档(facebook.github.io/jest/docs/en/api.html)被组织成易于查找所需内容的部分。例如,如果你正在编写一个测试并且需要验证一个期望值,你可以在 API 文档的Expect部分找到你需要的函数。或者,你可能需要帮助配置一个模拟函数——API 文档的Mock Functions部分包含了你在这个主题上需要的一切。

Jest 真正脱颖而出的另一个领域是当你需要测试异步代码时。这通常涉及使用 promise。Jest API 使得在不必写大量异步样板的情况下,轻松期望解析或拒绝的 promise 返回特定值变得容易。正是这些小细节使得为 Jest 编写单元测试感觉像是实际应用代码的自然延伸。

运行测试

Jest 命令行工具是运行单元测试所需的全部。工具有多种使用方式。首先,你将学习如何在create-react-app环境中调用测试运行器以及如何使用交互式观察模式选项。然后,你将学习如何在没有create-react-app帮助的情况下在独立环境中运行 Jest。

使用 react-scripts 运行测试

当你使用create-react-app创建你的 React 应用时,你可以立即运行测试。实际上,在为你创建的样板代码中,已经为App组件创建了一个单元测试。这个测试被添加以便 Jest 能够找到一个可以运行的测试。它实际上并没有测试你的应用中的任何有意义的东西,所以一旦添加更多测试,你可能会删除它。

另外,create-react-app会在你的package.json文件中添加适当的脚本来运行你的测试。你可以在终端中运行以下命令:

npm test

这实际上会调用react-scripts中的test脚本。这将调用 Jest,运行它找到的任何测试。在这种情况下,因为你正在使用一个新项目,它只会找到create-react-app创建的一个测试。运行这个测试的输出如下:

PASS  src/App.test.js
 ![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/react16-tl/img/a021b99a-9dbe-4033-9351-6670f4a36ba6.png) renders without crashing (3ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.043s, estimated 1s

运行的测试位于App.test.js模块中——所有的 Jest 测试文件名中都应该包含test。一个好的约定是ComponentName.test.js。然后,你可以看到在这个模块中运行的测试列表,它们花费了多长时间,以及它们是否通过或失败。

在底部,Jest 打印出了运行的摘要信息。这通常是一个很好的起点,因为如果你的所有测试都通过了,你可能不会关心任何其他输出。另一方面,当一个测试失败时,信息越多越好。

react-scripts中的test脚本以观察模式调用 Jest。这意味着当文件发生更改时,你可以选择实际运行哪些测试。在命令行中,菜单看起来像这样:

Watch Usage
 > Press a to run all tests.
 > Press p to filter by a filename regex pattern.
 > Press t to filter by a test name regex pattern.
 > Press q to quit watch mode.
 > Press Enter to trigger a test run. 

当 Jest 以观察模式运行时,进程不会在所有测试完成后立即退出。相反,它会监视你的测试和组件文件的更改,并在检测到更改时运行测试。这些选项允许你在发生更改时微调运行哪些测试。pt选项只有在你有成千上万个测试并且其中许多测试失败时才有用。这些选项对于深入了解并找到正在开发的有问题的组件非常有用。

默认情况下,当 Jest 检测到更改时,只有相关的测试会被运行。例如,更改测试或组件将导致测试再次运行。在你的终端中运行npm test,让我们打开App.test.js并对测试进行小小的更改:

it('renders without crashing', () => { 
  const div = document.createElement('div'); 
  ReactDOM.render(<App />, div); 
}); 

你可以只需更改测试的名称,使其看起来像下面这样,然后保存文件:

it('renders the App component', () => { 
  const div = document.createElement('div'); 
  ReactDOM.render(<App />, div); 
}); 

现在,看一下你的终端,你在那里让 Jest 以观察模式运行:

PASS  src/App.test.js
 ![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/react16-tl/img/a021b99a-9dbe-4033-9351-6670f4a36ba6.png) renders the App component (4ms)

Jest 检测到了你的单元测试的更改,并运行它,生成了更新的控制台输出。现在让我们引入一个新的组件和一个新的测试,看看会发生什么。首先,你将实现一个Repeat组件,看起来像下面这样:

export default ({ times, value }) => 
  new Array(parseInt(times, 10))
    .fill(value)
    .join(' ');

这个组件接受一个times属性,用于确定重复value属性的次数。下面是Repeat组件被App组件使用的方式:

import React, { Component } from 'react'; 
import logo from './logo.svg'; 
import './App.css'; 
import Repeat from './Repeat'; 

class App extends Component { 
  render() { 
    return ( 
      <div className="App"> 
        <header className="App-header"> 
          <img src={logo} className="App-logo" alt="logo" /> 
          <h1 className="App-title">Welcome to React</h1> 
        </header> 
        <p className="App-intro"> 
          <Repeat times="5" value="React!" /> 
        </p> 
      </div> 
    ); 
  } 
} 

export default App; 

如果你查看这个应用程序,你会在页面上看到字符串React!被渲染了五次。你的组件按预期工作,但在提交新组件之前,让我们确保添加一个单元测试。创建一个名为Repeat.test.js的文件,内容如下:

import React from 'react'; 
import ReactDOM from 'react-dom'; 
import Repeat from './Repeat'; 

it('renders the Repeat component', () => { 
  const div = document.createElement('div'); 
  ReactDOM.render(<Repeat times="5" value="test" />, div); 
}); 

实际上,这是用于App组件的相同单元测试。它除了组件可以渲染而不触发某种错误之外,没有太多测试内容。现在 Jest 有两个组件测试要运行:一个是App,另一个是Repeat。如果你查看 Jest 的控制台输出,你会看到两个测试都被运行了:

PASS  src/App.test.js
PASS  src/Repeat.test.js

Test Suites: 2 passed, 2 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        0.174s, estimated 1s
Ran all test suites related to changed files.

注意输出中的最后一行。Jest 的默认监视模式是查找尚未提交到源代码控制的文件,并已保存的文件。通过忽略已提交的组件和测试,你知道它们没有改变,因此运行这些测试是没有意义的。让我们尝试更改Repeat组件,看看会发生什么(实际上你不需要更改任何内容,只需保存文件就足以触发 Jest):

 PASS  src/App.test.js 
 PASS  src/Repeat.test.js 

为什么App测试会运行?它已经提交并且没有改变。问题在于,由于App依赖于Repeat,对Repeat组件的更改可能会导致App测试失败。

让我们引入另一个组件和测试,不过这次我们不会引入任何依赖导入新组件。创建一个Text.js文件,并保存以下组件实现:

export default ({ children }) => children; 

这个Text组件只会渲染传递给它的任何子元素或文本。这是一个人为的组件,但这并不重要。现在让我们编写一个测试,验证组件返回预期的值:

import Text from './text'; 

it('returns the correct text', () => {
  const children = 'test';
  expect(Text({ children })).toEqual(children);
});

toEqual()断言在Text()返回的值等于children值时通过。当你保存这个测试时,看一下 Jest 控制台输出:

PASS  src/Text.test.js
 ![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/react16-tl/img/a021b99a-9dbe-4033-9351-6670f4a36ba6.png) returns the correct text (1ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total

现在你有一个没有任何依赖的测试,Jest 会自行运行它。其他两个测试已经提交到 Git,所以它知道这些测试不需要运行。你永远不会提交不能通过单元测试的东西,对吧?

现在让我们让这个测试失败,看看会发生什么。将Test组件更改为以下内容:

export default ({ children }) => 1;

这将导致测试失败,因为它期望组件函数返回传递给children属性的值。现在如果你回到 Jest 控制台,输出应该是这样的:

FAIL  src/Text.test.js
 ● returns the correct text

   expect(received).toEqual(expected)

   Expected value to equal:
     "test"
   Received:
     1

   Difference:

    Comparing two different types of values. Expected string but 
     received number.

测试失败了,正如你所知道的。有趣的是,这又是唯一运行的测试,因为根据 Git,没有其他东西发生变化。对你有利的是,一旦你有了数百个测试,你就不需要等待所有测试都运行完毕,才能运行当前正在工作的组件的失败测试。

使用独立的 Jest 运行测试

在前一节中你刚刚了解到的react-scripts中的test脚本是一个很好的工具,可以在你构建应用程序时在后台运行。它在你实现组件和单元测试时给出了即时的反馈。

其他时候,你只想运行所有的测试,并在打印结果输出后立即退出进程。例如,如果你正在将 Jest 输出集成到持续集成流程中,或者如果你只想看一次测试结果,你可以直接运行 Jest。

让我们尝试单独运行 Jest。确保你仍然在项目目录中,并且已经停止了npm test脚本的运行。现在只需运行:

jest

与在观察模式下运行 Jest 不同,这个命令只是尝试运行所有的测试,打印结果输出,然后退出。然而,这种方法似乎存在问题。像这样运行 Jest 会导致错误:

FAIL  src/Repeat.test.js
 ● Test suite failed to run

   04/my-react-app/src/Repeat.test.js: Unexpected token (7:18)
        5 | it('renders the Repeat component', () => {
        6 |   const div = document.createElement('div');
      > 7 |   ReactDOM.render(<Repeat times="5" value="test"...
          |                   ^
        8 | });

这是因为react-scripts中的test脚本为我们设置了很多东西,包括解析和执行 JSX 所需的所有 Jest 配置。鉴于我们有这个工具可用,让我们使用它,而不是试图从头开始配置 Jest。记住,你的目标是只运行一次 Jest,而不是在观察模式下运行。

事实证明,react-scripts中的test脚本已经准备好处理持续集成环境。如果它发现CI环境变量,它就不会在观察模式下运行 Jest。让我们尝试通过导出这个变量来验证一下:

export CI=1

现在当你运行npm test时,一切都按预期进行。当一切都完成时,进程退出:

PASS  src/Text.test.js
PASS  src/App.test.js
PASS  src/Repeat.test.js

Test Suites: 3 passed, 3 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        1.089s
Ran all test suites.

当你完成后,可以取消这个环境变量:

unset CI 

大多数情况下,你可能只会在观察模式下使用 Jest。但是,如果你需要在短暂的进程中快速运行测试,你可以暂时进入持续集成模式。

编写 Jest 测试

现在你知道如何运行 Jest 了,让我们写一些单元测试。我们将涵盖 Jest 可用于测试 React 应用的基础知识以及更高级的功能。我们将开始将你的测试组织成套件,并介绍 Jest 中的基本断言。然后,你将创建你的第一个模拟模块并处理异步代码。最后,我们将使用 Jest 的快照机制来帮助测试 React 组件的输出。

使用套件组织测试

套件是你的测试的主要组织单元。套件不是 Jest 的要求——create-react-app创建的测试不包括套件:

it('renders without crashing', () => { 
  ... 
}); 

it()函数声明了一个通过或失败的单元测试。当你刚开始项目并且只有少数测试时,不需要套件。一旦你有了多个测试,就是时候开始考虑组织了。把套件看作是一个容器,你可以把你的测试放进去。你可以有几个这样的容器,以你认为合适的方式组织你的测试。通常,一个套件对应一个源模块。以下是如何声明套件:

describe('BasicSuite', () => { 
  it('passes the first test', () => { 
    // Assertions... 
  }); 

  it('passes the second test', () => { 
    // Assertions... 
  }); 
}); 

这里使用describe()函数声明了一个名为BasicSuite的测试套件。在套件内部,我们声明了几个单元测试。使用describe(),你可以组织你的测试,使相关的测试在测试结果输出中被分组在一起。

然而,如果套件是唯一可用于组织测试的机制,你的测试将很快变得难以管理。原因是通常一个类、方法或函数位于一个模块中会有多个测试。因此,你需要一种方法来说明测试实际上属于代码的哪一部分。好消息是你可以嵌套调用describe()来为你的套件提供必要的组织:

describe('NestedSuite', () => { 
  describe('state', () => { 
    it('handles the first state', () => { 

    }); 

    it('handles the second state', () => { 

    }); 
  }); 

  describe('props', () => { 
    it('handles the first prop', () => { 

    });
 it('handles the second prop', () => { 

    }); 
  });

 describe('render()', () => { 
    it('renders with state', () => { 

    }); 

    it('renders with props', () => { 

    }); 
  }); 
}); 

最外层的describe()调用声明了测试套件,对应于一些顶层的代码单元,比如一个模块。对describe()的内部调用对应于更小的代码单元,比如方法和函数。这样,你可以轻松地为给定的代码片段编写多个单元测试,同时避免对实际被测试的内容产生困惑。

让我们来看一下你刚刚创建的测试套件的详细输出。为此,请运行以下命令:

npm test -- --verbose

第一组双破折号告诉npm将后面的任何参数传递给test脚本。以下是你将看到的内容:

PASS  src/NestedSuite.test.js
 NestedSuite
   state
     ![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/react16-tl/img/05ee9296-041a-4087-b809-ef2d86a9a6bb.png) handles the first state (1ms)
     ![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/react16-tl/img/05ee9296-041a-4087-b809-ef2d86a9a6bb.png) handles the second state
   props
     ![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/react16-tl/img/05ee9296-041a-4087-b809-ef2d86a9a6bb.png) handles the first prop
     ![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/react16-tl/img/05ee9296-041a-4087-b809-ef2d86a9a6bb.png) handles the second prop
   render()
     ![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/react16-tl/img/05ee9296-041a-4087-b809-ef2d86a9a6bb.png) renders with state
     ![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/react16-tl/img/05ee9296-041a-4087-b809-ef2d86a9a6bb.png) renders with props (1ms)

PASS  src/BasicSuite.test.js
 BasicSuite
   ![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/react16-tl/img/05ee9296-041a-4087-b809-ef2d86a9a6bb.png) passes the first test
   ![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/react16-tl/img/05ee9296-041a-4087-b809-ef2d86a9a6bb.png) passes the second test

NestedSuite下,你可以看到state是被测试的代码,并且有两个测试通过了。propsrender()也是一样的情况。

基本断言

在单元测试中,使用 Jest 的期望 API 创建断言。当代码的期望未达到时,这些函数会触发单元测试失败。使用此 API 时,测试失败的输出会显示您期望发生的事情以及实际发生的事情。这严重减少了您追踪值所花费的时间。

基本相等

您可以使用 toBe() 期望方法来断言两个值相同:

describe('basic equality', () => { 
  it('true is true', () => { 
    expect(true).toBe(true); 
    expect(true).not.toBe(false); 
  }); 

  it('false is false', () => { 
    expect(false).toBe(false); 
    expect(false).not.toBe(true); 
  }); 
}); 

在第一个测试中,您期望 true 等于 true。然后,在下一行使用 .not 属性否定这个期望。如果这是一个真正的单元测试,您不必像这样证明您刚刚做出的断言的相反情况——我这样做是为了说明您的一些选择。

在第二个测试中,我们执行相同的断言,但期望值为 falsetoBe() 方法使用严格相等来比较其值。

近似相等

有时,在代码中检查某些东西的确切值并没有什么区别,而且可能比值得的工作更多。例如,您可能只需要确保某个值存在。您可能还需要执行相反的操作——确保没有值。在 JavaScript 术语中,某物与无物是“真值”与“假值”。

要在 Jest 单元测试中检查真值或假值,您将分别使用 isTruthy()isFalsy() 方法:

describe('approximate equality', () => { 
  it('1 is truthy', () => { 
    expect(1).toBeTruthy(); 
    expect(1).not.toBeFalsy(); 
  }); 

  it('\'\' is falsy', () => { 
    expect('').toBeFalsy(); 
    expect('').not.toBeTruthy(); 
  }); 
});

1 不是 true,但在布尔比较的上下文中使用时,它会计算为 true。同样,空字符串计算为 false,因此被视为假值。

值相等

在处理对象和数组时,检查相等可能很痛苦。通常您不能使用严格相等,因为您在比较引用,而引用总是不同的。如果您要比较的是值,您需要逐个迭代对象或集合并比较值、键和索引。

由于没有人在理智的头脑中想要做所有这些工作来执行简单的测试。Jest 提供了 toEqual() 方法,它可以为您比较对象属性和数组值:

describe('value equality', () => { 
  it('objects are the same', () => { 
    expect({ 
      one: 1, 
      two: 2 
    }).toEqual({ 
      one: 1, 
      two: 2, 
    });

    expect({ 
      one: 1, 
      two: 2 
    }).not.toBe({ 
      one: 1, 
      two: 2
 }); 
  }); 

  it('arrays are the same', () => { 
    expect([1, 2]).toEqual([1, 2]); 
    expect([1, 2]).not.toBe([1, 2]); 
  }); 
}); 

这个例子中的每个对象和数组都是唯一的引用。然而,这两个对象和两个数组在其属性和值方面是相等的。toEqual() 方法检查值的相等性。之后,我要展示 toBe() 不是你想要的——这会返回 false,因为它在比较引用。

集合中的值

Jest 中有比我在这本书中介绍的断言方法更多。我鼓励你查看 Jest API 文档中的 Expect 部分:facebook.github.io/jest/docs/en/expect.html

我想要和你讨论的最后两个断言方法是 toHaveProperty()toContain()。前者测试对象是否具有给定属性,而后者检查数组是否包含给定值:

describe('object properties and array values', () => { 
  it('object has property value', () => { 
    expect({ 
      one: 1, 
      two: 2 
    }).toHaveProperty('two', 2); 

    expect({ 
      one: 1, 
      two: 2 
    }).not.toHaveProperty('two', 3); 
  });
  it('array contains value', () => { 
    expect([1, 2]).toContain(1); 
    expect([1, 2]).not.toContain(3); 
  }); 
}); 

当你需要检查对象是否具有特定属性值时,toHaveProperty() 方法非常有用。当你需要检查数组是否具有特定值时,toContain() 方法非常有用。

使用模拟

当你编写单元测试时,你是在测试自己的代码。至少这是理论上的想法。实际上,这比听起来更困难,因为你的代码不可避免地会使用某种库。这是你不想测试的代码。编写调用其他库的单元测试的问题在于它们通常需要访问网络或文件系统。你绝对不希望由于其他库的副作用而产生误报。

Jest 提供了一个强大的模拟机制,使用起来很容易。你给 Jest 提供要模拟的模块的路径,它会处理剩下的事情。在某些情况下,你不需要提供模拟实现。在其他情况下,你需要以与原始模块相同的方式处理参数和返回值。

假设你创建了一个如下所示的 readFile() 函数:

import fs from 'fs'; 

const readFile = path => new Promise((resolve, reject) => { 
  fs.readFile(path, (err, data) => { 
    if (err) { 
      reject(err); 
    } else { 
      resolve(data); 
    } 
  }); 
}); 

export default readFile; 

这个函数需要来自 fs 模块的 readFile() 函数。它返回一个 promise,在传递给 readFile() 的回调函数被调用时解析,除非出现错误。

现在你想为这个函数编写一个单元测试。你想做出如下断言:

  • 它是否调用了 fs.readFile()

  • 返回的 promise 是否以正确的值解析?

  • 当传递给 fs.readFile() 的回调接收到错误时,返回的 promise 是否被拒绝?

您可以通过使用 Jest 对其进行模拟来执行所有这些断言,而不必依赖于fs.readFile()的实际实现。您不必对外部因素做任何假设;您只关心您的代码是否按照您的预期工作。

因此,让我们尝试为使用模拟的fs.readFile()实现的此函数实施一些测试:

import fs from 'fs'; 
import readFile from './readFile'; 

jest.mock('fs'); 

describe('readFile', () => { 
  it('calls fs.readFile', (done) => { 
    fs.readFile.mockReset(); 
    fs.readFile.mockImplementation((path, cb) => { 
      cb(false); 
    }); 

    readFile('file.txt') 
      .then(() => { 
        expect(fs.readFile).toHaveBeenCalled(); 
        done(); 
      }); 
  }); 

  it('resolves a value', (done) => { 
    fs.readFile.mockReset(); 
    fs.readFile.mockImplementation((path, cb) => { 
      cb(false, 'test'); 
    }); 

    readFile('file.txt') 
      .then((data) => { 
        expect(data).toBe('test'); 
        done(); 
      }); 
  }); 

  it('rejects on error', (done) => { 
    fs.readFile.mockReset(); 
    fs.readFile.mockImplementation((path, cb) => { 
      cb('failed'); 
    }); 

    readFile() 
      .catch((err) => { 
        expect(err).toBe('failed'); 
        done(); 
      }); 
  }); 
}); 

通过调用jest.mock('fs')来创建fs模块的模拟版本。请注意,在模拟之前实际导入了真实的fs模块,并且在任何测试实际使用它之前就已经模拟了它。在每个测试中,我们都在创建fs.readFile()的自定义实现。默认情况下,Jest 模拟的函数实际上不会执行任何操作。这很少足以测试大多数事情。模拟的美妙之处在于您可以控制代码使用的库的结果,并且您的测试断言确保您的代码相应地处理一切。

通过将其作为函数传递给mockImplementation()方法来提供实现。但在这样做之前,一定要确保调用mockReset()来清除有关模拟的任何存储信息,比如它被调用的次数。例如,第一个测试有断言expect(fs.readFile).toHaveBeenCalled()。您可以将模拟函数传递给expect(),Jest 提供了知道如何与它们一起工作的方法。

对于类似的功能,可以遵循相同的模式。这是readFile()的对应函数:

import fs from 'fs'; 

const writeFile = (path, data) => new Promise((resolve, reject) => { 
  fs.writeFile(path, data, (err) => { 
    if (err) { 
      reject(err); 
    } else { 
      resolve(); 
    } 
  }); 
}); 

export default writeFile; 

readFile()writeFile()之间有两个重要的区别:

  • writeFile()函数接受第二个参数,用于写入文件的数据。这个参数也传递给fs.writeFile()

  • writeFile()函数不会解析值,而readFile()会解析已读取的文件数据。

这两个差异对您创建的模拟实现有影响。现在让我们来看看它们:

import fs from 'fs'; 
import writeFile from './writeFile'; 

jest.mock('fs'); 

describe('writeFile', () => { 
  it('calls fs.writeFile', (done) => { 
    fs.writeFile.mockReset(); 
    fs.writeFile.mockImplementation((path, data, cb) => { 
      cb(false); 
    }); 

    writeFile('file.txt') 
      .then(() => { 
        expect(fs.writeFile).toHaveBeenCalled(); 
        done(); 
      }); 
  }); 

  it('resolves without a value', (done) => { 
    fs.writeFile.mockReset(); 
    fs.writeFile.mockImplementation((path, data, cb) => { 
      cb(false, 'test'); 
    }); 

    writeFile('file.txt', test) 
      .then(() => { 
        done(); 
      }); 
  }); 

  it('rejects on error', (done) => { 
    fs.writeFile.mockReset(); 
    fs.writeFile.mockImplementation((path, data, cb) => { 
      cb('failed'); 
    });
 writeFile() 
      .catch((err) => { 
        expect(err).toBe('failed'); 
        done(); 
      }); 
  }); 
}); 

现在data参数需要成为模拟实现的一部分;否则,将无法访问cb参数并调用回调函数。

readFile()writeFile()测试中,您必须处理异步性。这就是为什么我们在then()回调中执行断言的原因。从it()传入的done()函数在测试完成时被调用。如果您忘记调用done(),测试将挂起并最终超时和失败。

单元测试覆盖率

Jest 自带对测试覆盖报告的支持。将这包含在测试框架中是很好的,因为并非所有测试框架都支持这一点。如果你想看看你的测试覆盖率是什么样子,只需在启动 Jest 时传递 --coverage 选项即可:

npm test -- --coverage 

当你这样做时,测试会像平常一样运行。然后,Jest 内部的覆盖工具会计算你的测试覆盖源代码的程度,并生成一个报告,看起来像这样:

----------|--------|----------|---------|---------|----------------|
File      |% Stmts | % Branch | % Funcs | % Lines |Uncovered Lines |
----------|--------|----------|---------|---------|----------------|
All files |   2.17 |        0 |    6.25 |    4.55 |                |
 App.js   |    100 |      100 |     100 |     100 |                |
 index.js |      0 |        0 |       0 |       0 |  1,2,3,4,5,7,8 |
----------|--------|----------|---------|---------|----------------|

如果你想提高你的覆盖率,看看报告中的 Uncovered Lines 列。其他列告诉你测试覆盖的代码类型:语句、分支和函数。

异步断言

Jest 预期你会有异步代码需要测试。这就是为什么它提供了 API 来使编写单元测试中的这一方面感觉自然。在前一节中,我们编写了在 then() 回调中执行断言并在所有异步测试完成时调用 done() 的测试。在本节中,我们将看另一种方法。

Jest 允许你从单元测试函数中返回 promise 期望,并会相应地处理它们。让我们重构一下你在前一节中编写的 readFile() 测试:

import fs from 'fs'; 
import readFile from './readFile'; 

jest.mock('fs'); 

describe('readFile', () => { 
  it('calls fs.readFile', () => { 
    fs.readFile.mockReset(); 
    fs.readFile.mockImplementation((path, cb) => { 
      cb(false); 
    });
return readFile('file.txt') 
      .then(() => { 
        expect(fs.readFile).toHaveBeenCalled(); 
      }); 
  }); 

  it('resolves a value', () => { 
    fs.readFile.mockReset(); 
    fs.readFile.mockImplementation((path, cb) => { 
      cb(false, 'test'); 
    }); 

    return expect(readFile('file.txt')) 
      .resolves 
      .toBe('test'); 
  }); 

  it('rejects on error', () => { 
    fs.readFile.mockReset(); 
    fs.readFile.mockImplementation((path, cb) => { 
      cb('failed'); 
    }); 

    return expect(readFile()) 
      .rejects 
      .toBe('failed'); 
  }); 
}); 

现在测试返回的是 promises。当返回一个 promise 时,Jest 会等待它解析完成,然后才捕获测试结果。你也可以传递一个 promise 给 expect(),并使用 resolvesrejects 对象来执行断言。这样,你就不必依赖 done() 函数来指示测试的异步部分已经完成了。

rejects 对象在这里特别有价值。确保函数按预期拒绝是很重要的。但如果没有 rejects,这是不可能做到的。在这个测试的先前版本中,如果你的代码因某种原因解析了,而本应拒绝,那就无法检测到这一点。现在,如果发生这种情况,使用 rejects 会导致测试失败。

React 组件快照

React 组件会渲染输出。自然地,你希望组件单元测试的一部分是确保正确的输出被创建。一种方法是将组件渲染到基于 JS 的 DOM 中,然后对渲染输出执行单独的断言。至少可以说,这将是一个痛苦的测试编写体验。

快照测试允许你生成渲染组件输出的快照。然后,每次运行测试时,输出会与快照进行比较。如果有什么看起来不同,测试就会失败。

让我们修改create-react-app为你添加的App组件的默认测试,使其使用快照测试。这是原始测试的样子:

import React from 'react'; 
import ReactDOM from 'react-dom'; 
import App from './App'; 

it('renders without crashing', () => { 
  const div = document.createElement('div'); 
  ReactDOM.render(<App />, div); 
}); 

这个测试实际上并没有验证渲染的内容——只是没有抛出错误。如果你做出了导致意外结果的更改,你将永远不会知道。这是相同测试的快照版本:

import React from 'react'; 
import renderer from 'react-test-renderer'; 
import App from './App'; 

it('renders without crashing', () => { 
  const tree = renderer 
    .create(<App />) 
    .toJSON(); 

  expect(tree).toMatchSnapshot(); 
}); 

在运行这个测试之前,我必须安装react-test-renderer包:

npm install react-test-renderer --save-dev

也许有一天这将被添加到create-react-app中。与此同时,你需要记得安装它。然后,你的测试可以导入测试渲染器并使用它来创建一个 JSON 树。这是渲染组件内容的表示。接下来,你期望这个树与第一次运行此测试时创建的快照匹配,使用toMatchSnapshot()断言。

这意味着第一次运行测试时,它总是会通过,因为这是第一次创建快照。快照文件是应该提交到项目的源代码控制系统的工件,就像单元测试源代码本身一样。这样,项目中的其他人在运行你的测试时就会有一个快照文件可供使用。

关于快照测试的误解在于它给人的印象是你实际上不能改变组件以产生不同的输出。事实上,这是真的——改变组件产生的输出会导致快照测试失败。不过,这并不是一件坏事,因为它迫使你在每次更改时查看你的组件渲染的内容。

让我们修改App组件,使其对单词started添加强调。

<p className="App-intro"> 
  To get <em>started</em>, edit <code>src/App.js</code> and save to  
  reload. 
</p> 

现在如果你运行你的测试,你会得到一个类似这样的失败:

Received value does not match stored snapshot 1\. 

- Snapshot 
+ Received 

 @@ -16,11 +16,15 @@ 
    </h1> 
    </header> 
    <p 
       className="App-intro" 
    > 
-    To get started, edit  
+    To get  
+    <em> 
+      started 
+    </em> 
+    , edit  

哇!这很有用。统一的差异显示了组件输出的确切变化。你可以查看这个输出,并决定这正是你期望看到的变化,或者你犯了一个错误,需要去修复它。一旦你对新的输出满意,你可以通过向test脚本传递参数来更新存储的快照:

npm test -- --updateSnapshot

这将在运行测试之前更新存储的快照,任何失败的快照测试现在都将通过,因为它们符合其输出期望:

PASS  src/App.test.js
 ![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/react16-tl/img/a3323890-90b8-4d05-a479-d8046e057b2d.png) renders without crashing (12ms)

Snapshot Summary
 > 1 snapshot updated in 1 test suite.

 Test Suites: 1 passed, 1 total
 Tests:       1 passed, 1 total
 Snapshots:   1 updated, 1 total
 Time:        0.631s, estimated 1s 

Jest 告诉您在运行任何测试之前快照已更新,通过传递--updateSnapshot参数来实现。

总结

在本章中,您了解了 Jest。您了解到 Jest 的关键驱动原则是创建有效的模拟、测试隔离和并行执行,以及易用性。然后,您了解到react-scripts通过提供一些基本配置使运行单元测试变得更加容易。

在运行 Jest 时,您会发现通过react-scripts运行 Jest 时,观察模式是默认模式。观察模式在有许多不需要在每次源代码更改时运行的测试时特别有用,只有相关的测试会被执行。

接下来,您在单元测试中执行了一些基本断言。然后,您为fs模块创建了一个模拟,并对模拟函数进行断言,以确保它们被预期使用。然后,您进一步发展了这些测试,以利用 Jest 的固有异步能力。单元测试覆盖报告内置在 Jest 中,您学会了如何通过传递额外的参数来查看此报告。

在下一章中,您将学习如何使用 Flow 创建类型安全的组件。

第五章:使用类型安全简化开发和重构 React 组件

本章重点介绍的工具是 Flow,它是 JavaScript 应用程序的静态类型检查器。Flow 的范围和你可以用它做的事情是巨大的,所以我将在引入 Flow 的上下文中介绍它,这是一个用于改进 React 组件的工具。在本章中,你将学到以下内容:

  • 通过引入类型安全解决的问题

  • 在你的 React 项目中启用 Flow

  • 使用 Flow 验证你的 React 组件

  • 使用类型安全增强 React 开发的其他方法

类型安全解决了什么问题?

类型安全并非万能药。例如,我完全有能力编写一个充满错误的类型安全应用程序。有趣的是,只是在引入类型检查器后,那种停止发生的错误。那么在引入 Flow 这样的工具后,你可以期待什么类型的事情?我将分享我在学习 Flow 时经历的三个因素。Flow 文档中的类型系统部分对这个主题进行了更详细的介绍,可在flow.org/en/docs/lang/上找到。

用保证替换猜测

JavaScript 这样的动态类型语言的一个很好的特性是,你可以编写代码而不必考虑类型。类型是好的,它们确实解决了很多问题——你可能不相信,但有时你需要能够只是编写代码而不必正式验证正确性。换句话说,有时候猜测恰恰是你需要的。

如果我正在编写一个我知道接受一个对象作为参数的函数,我可以假设传递给我的函数的任何对象都将具有预期的属性。这使我能够实现我需要的东西,而不必确保正确的类型作为参数传递。然而,这种方法只能持续那么长时间。因为不可避免地,你的代码将会得到一些意外的输入。一旦你有了一个由许多组成部分组成的复杂应用程序,类型安全可以消除猜测。

Flow 采取了一种有趣的方法。它不是基于类型编译新的 JavaScript 代码,而是简单地根据类型注释检查源代码是否正确。然后将这些注释从源代码中移除,以便可以运行。通过使用 Flow 这样的类型检查器,你可以明确地指定每个组件愿意接受的输入,并通过使用类型注释来说明它与应用程序的其他部分是如何交互的。

移除运行时检查

在诸如 JavaScript 之类的动态语言中处理未知类型的数据的解决方案是在运行时检查值。根据值的类型,你可能需要执行一些替代操作来获取你的代码所期望的值。例如,在 JavaScript 中的一个常见习惯是确保一个值既不是 undefined 也不是 null。如果是,那么我们要么抛出一个错误,要么提供一个默认值。

当你执行运行时检查时,它会改变你对代码的思考方式。一旦你开始执行这些检查,它们不可避免地会演变成更复杂的检查和更多的检查。这种思维方式实际上意味着不相信自己或他人能够使用正确的数据调用代码。你会认为,由于很可能你的函数会被用垃圾参数调用,你需要准备好处理任何被传递给你的函数的东西。

另一方面,拥抱类型安全意味着你不必依赖于实现自定义解决方案来防御错误数据。让类型系统来代替你处理这个问题。你只需要考虑你的代码需要处理什么类型的数据,然后从那里开始。思考我的代码需要什么,而不是如何获得我的代码需要的东西。

明显的低严重性错误

如果你可以使用诸如 Flow 之类的类型检查器来消除由于错误类型而产生的隐匿错误,那么你将只剩下高级别的应用程序错误。当这些错误发生时,它们是显而易见的,因为应用程序是错误的。它产生了错误的输出,计算出了错误的数字,其中一个屏幕无法加载,等等。你可以更容易地看到并与这些类型的错误进行交互。这使它们变得显而易见,而当错误显而易见时,它们更容易被追踪和修复。

另一方面,您可能会遇到微妙错误的错误。这些可能是由于错误的类型。这些类型的错误特别可怕的原因是您甚至不知道出了什么问题。您的应用程序可能有些微妙的问题。或者它可能完全崩溃,因为您的代码的一部分期望一个数组,但它在某些地方可以工作,因为它得到了另一种可迭代的东西,但在其他地方却不行。

如果您只是使用类型注释并使用 Flow 检查了您的源代码,它会告诉您正在传递的不是数组。当类型静态检查时,这些类型的错误就没有了容身之地。原来,这些通常是更难解决的错误。

安装和初始化 Flow

在您开始实现类型安全的 React 组件之前,您需要安装和初始化 Flow。我将向您展示如何在create-react-app环境中完成此操作,但几乎可以为几乎任何 React 环境遵循相同的步骤。

您可以全局安装 Flow,但我建议将其与项目依赖的所有其他软件包一起本地安装。除非有充分的理由全局安装某些东西,否则请将其本地安装。这样,安装您的应用程序的任何人都可以通过运行npm install来获取每个依赖项。

在本地安装 Flow,请运行以下命令:

npm install flow-bin --save-dev

这将在本地安装 Flow 可执行文件到您的项目,并将更新您的package.json,以便 Flow 作为项目的依赖项安装。现在让我们向package.json添加一个新的命令,以便您可以针对您的源代码运行 Flow 类型检查器。使scripts部分看起来像这样:

"scripts": { 
  "start": "react-scripts start",
  "build": "react-scripts build", 
  "test": "react-scripts test --env=jsdom", 
  "eject": "react-scripts eject", 
  "flow": "flow" 
}, 

现在,您可以通过在终端中执行以下命令来运行 Flow:

npm run flow

这将按预期运行flow脚本,但 Flow 将抱怨找不到 Flow 配置文件:

Could not find a .flowconfig in . or any of its parent directories. 

解决此问题的最简单方法是使用flow init命令:

npm run flow init 

这将在您的项目目录中创建一个.flowconfig文件。您现在不需要担心更改此文件中的任何内容;只是 Flow 希望它存在。现在当您运行npm run flow时,您应该会收到一条指示没有错误的消息:

Launching Flow server for 05/installing-and-initializing-flow
Spawned flow server (pid=46516)
No errors!  

原来,实际上没有检查您的任何源文件。这是因为默认情况下,Flow 只检查具有// @flow指令作为其第一行的文件。让我们继续在App.js的顶部添加这一行:

// @flow 
import React, { Component } from 'react'; 
import logo from './logo.svg'; 
import './App.css'; 

class App extends Component { 
  render() { 
    return ( 
      <div className="App"> 
        <header className="App-header"> 
          <img src={logo} className="App-logo" alt="logo" /> 
          <h1 className="App-title">Welcome to React</h1> 
        </header> 
        <p className="App-intro"> 
          To get started... 
        </p> 
      </div> 
    ); 
  } 
} 

export default App; 

现在 Flow 正在检查这个模块,我们得到了一个错误:

      6: class App extends Component {
                           ^^^^^^^^^ Component. Too few type arguments. Expected at least 1

这是什么意思?Flow 试图在错误输出的下一行提供解释:

Component<Props, State = void> { 
          ^^^^^^^^^^^^ See type parameters of definition here. 

Flow 抱怨你正在用App扩展的Component类。这意味着你需要为Component提供至少一个type参数来表示 props。由于App实际上并没有使用任何 props,现在可以暂时使用一个空类型:

// @flow 
import React, { Component } from 'react'; 
import logo from './logo.svg'; 
import './App.css'; 

type Props = {}; 

class App extends Component<Props> { 
  render() { 
    return ( 
      <div className="App"> 
        <header className="App-header"> 
          <img src={logo} className="App-logo" alt="logo" /> 
          <h1 className="App-title">Welcome to React</h1> 
        </header> 
        <p className="App-intro"> 
          To get started... 
        </p> 
      </div> 
    ); 
  } 
}
export default App; 

现在当你再次运行 Flow 时,在App.js中就没有任何错误了!这意味着你已经成功地用类型信息注释了你的模块,Flow 用它来静态分析你的源代码,确保一切都是正确的。

那么 Flow 是如何知道 React 的Component类在泛型方面期望什么的呢?事实证明,React 本身是 Flow 类型注释的,这就是当 Flow 检测到问题时你会得到具体错误消息的原因。

接下来,让我们在index.js的顶部添加// @flow指令:

// @flow 
import React from 'react'; 
import ReactDOM from 'react-dom'; 
import './index.css'; 
import App from './App'; 
import registerServiceWorker from './registerServiceWorker'; 

const root = document.getElementById('root'); 

ReactDOM.render( 
  <App />, 
  root 
); 

registerServiceWorker(); 

如果你再次运行npm run flow,你会看到以下错误:

    Error: src/index.js:12
     12:   root
    ^^^^ null. This type is incompatible with the expected param 
                type of Element  

这是因为root的值来自document.getElementById('root')。由于这个方法没有返回元素的 DOM,Flow 检测到一个null值并抱怨。由于这是一个合理的担忧(root元素可能不存在),我们需要在没有元素时为 Flow 提供路径,你可以添加一些逻辑来处理这种情况:

// @flow 
import React from 'react'; 
import ReactDOM from 'react-dom'; 
import './index.css'; 
import App from './App';
import registerServiceWorker from './registerServiceWorker'; 

const root = document.getElementById('root'); 

if (!(root instanceof Element)) { 
  throw 'Invalid root'; 
} 

ReactDOM.render( 
  <App />, 
  root 
); 

registerServiceWorker(); 

在调用ReactDOM.render()之前,你可以手动检查root的类型,以确保它是 Flow 期望看到的类型。现在当你运行npm run flow时,就不会有错误了。

你已经准备好了!你已经在本地安装和配置了 Flow,并且create-react-app的初始源已经通过了类型检查。现在你可以继续开发类型安全的 React 组件了。

验证组件属性和状态

React 设计时考虑了 Flow 静态类型检查。在 React 应用程序中,Flow 最常见的用途是验证组件属性和状态是否被正确使用。你还可以强制执行作为另一个组件子元素的组件的类型。

在 Flow 之前,React 依赖于 prop-types 机制来验证传递给组件的值。现在这是 React 的一个单独包,你仍然可以使用它。Flow 比 prop-types 更优秀,因为它执行静态检查,而 prop-types 执行运行时验证。这意味着你的应用程序在运行时不需要运行多余的代码。

原始属性值

通过 props 传递给组件的最常见的值类型是原始值——例如字符串、数字和布尔值。使用 Flow,您可以声明自己的类型,指定给定属性允许哪些原始值。

让我们看一个例子:

// @flow 
import React from 'react'; 

type Props = { 
  name: string, 
  version: number 
}; 

const Intro = ({ name, version }: Props) => ( 
  <p className="App-intro"> 
    <strong>{name}:</strong>{version} 
  </p> 
); 

export default Intro; 

这个组件渲染了一些应用程序的名称和版本。这些值是通过属性值传递的。对于这个组件,让我们说您只想要name属性的字符串值和version属性的数字值。这个模块使用type关键字声明了一个新的Props类型:

type Props = { 
  name: string, 
  version: number 
}; 

这个 Flow 语法允许您创建新类型,然后可以用来对函数参数进行类型化。在这种情况下,您有一个功能性的 React 组件,其中 props 作为第一个参数传递。这是告诉 Flow,props 对象应该具有特定类型的地方:

({ name, version }: Props) => (...) 

有了这个,Flow 可以找出我们传递无效的属性类型到这个组件的任何地方!更好的是,这是在静态地完成的,在浏览器中运行任何东西之前。在 Flow 之前,您必须使用prop-types包在运行时验证组件属性。

让我们使用这个组件,然后我们将运行 Flow。这是App.js使用Intro组件:

// @flow 
import React, { Component } from 'react'; 
import logo from './logo.svg'; 
import './App.css'; 
import Intro from './Intro';

type Props = {}; 

class App extends Component<Props> { 
  render() { 
    return ( 
      <div className="App"> 
        <header className="App-header"> 
          <img src={logo} className="App-logo" alt="logo" /> 
          <h1 className="App-title">Welcome to React</h1> 
        </header> 
        <Intro name="React" version={16} /> 
      </div> 
    ); 
  } 
} 

export default App; 

传递给Intro的属性值符合Props类型的期望:

<Intro name="React" version={16} /> 

您可以通过运行npm run flow来验证这一点。您应该会看到没有错误!作为输出。让我们看看如果我们改变这些属性的类型会发生什么:

<Intro version="React" name={16} /> 

现在我们正在传递一个字符串,而期望的是一个数字,以及一个数字,而期望的是一个字符串。如果您再次运行npm run flow,您应该会看到以下错误:

    Error: src/App.js:17
     17:         <Intro version="React" name={16} />
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ props of React element `Intro`. This type is incompatible with
      9: const Intro = ({ name, version }: Props) => (
                                           ^^^^^ object type. See: src/Intro.js:9
      Property `name` is incompatible:
         17:         <Intro version="React" name={16} />
                                                  ^^ number. This type is incompatible with
          5:   name: string,
                     ^^^^^^ string. See: src/Intro.js:5

    Error: src/App.js:17
     17:         <Intro version="React" name={16} />
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ props of React element `Intro`. This type is incompatible with
      9: const Intro = ({ name, version }: Props) => (
                                           ^^^^^ object type. See: src/Intro.js:9
      Property `version` is incompatible:
         17:         <Intro version="React" name={16} />
                                    ^^^^^^^ string. This type is incompatible with
          6:   version: number
                        ^^^^^^ number. See: src/Intro.js:6

这两个错误都非常详细地向您展示了问题所在。它首先向您展示了组件属性值被传递的地方:

    <Intro version="React" name={16} />
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ props of React element `Intro`. 

然后,它向您展示了Props类型被用来声明属性参数的类型:

    This type is incompatible with
      9: const Intro = ({ name, version }: Props) => (
                                           ^^^^^ object type. See: src/Intro.js:9

最后,它向您展示了类型的确切问题是什么:

    Property `name` is incompatible:
         17:         <Intro version="React" name={16} />
                                                  ^^ number. This type is incompatible with
          5:   name: string,
                     ^^^^^^ string. See: src/Intro.js:5

流错误消息试图为您提供尽可能多的信息,这意味着您花费的时间更少,寻找文件。

对象属性值

在前面的部分,您学会了如何检查原始属性类型。React 组件也可以接受具有原始值和其他对象的对象。如果您的组件期望一个对象作为属性值,您可以使用与原始值相同的方法。不同之处在于您如何构造Props类型声明:

// @flow 
import React from 'react'; 

type Props = { 
  person: { 
    name: string, 
    age: number 
  } 
}; 

const Person = ({ person }: Props) => ( 
  <section> 
    <h3>Person</h3> 
    <p><strong>Name: </strong>{person.name}</p> 
    <p><strong>Age: </strong>{person.age}</p> 
  </section> 
); 

export default Person; 

此组件期望一个person属性,它是一个对象。此外,它期望此对象具有一个name字符串属性和一个数字age属性。实际上,如果您有其他需要person属性的组件,您可以将此类型分解为可重用的部分:

type Person = { 
  name: string, 
  age: number 
}; 

type Props = { 
  person: Person 
}; 

现在让我们看看作为属性传递给此组件的值:

// @flow 
import React, { Component } from 'react'; 
import logo from './logo.svg'; 
import './App.css'; 
import Person from './Person'; 

class App extends Component<{}> { 
  render() { 
    return ( 
      <div className="App"> 
        <header className="App-header"> 
          <img src={logo} className="App-logo" alt="logo" /> 
          <h1 className="App-title">Welcome to React</h1> 
        </header> 
        <Person person={{ name: 'Roger', age: 20 }} /> 
      </div> 
    ); 
  } 
} 

export default App; 

而不是将Person组件传递给几个属性值,它被传递了一个单一的属性值,一个符合Props类型期望的对象。如果不符合,Flow 会抱怨。让我们试着从这个对象中删除一个属性:

<Person person={{ name: 'Roger' }} /> 

现在当您运行npm run flow时,它会抱怨传递给person的对象的缺少属性:

    15:         <Person person={{ name: 'Roger' }} />
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ props of React element `Person`. This type is incompatible with
     11: const Person = ({ person }: Props) => (
                                     ^^^^^ object type. See: src/Person.js:11
      Property `person` is incompatible:
         15:         <Person person={{ name: 'Roger' }} />
                                     ^^^^^^^^^^^^^^^^^ object literal. This type is incompatible with
                       v
          5:   person: {
          6:     name: string,
          7:     age: number
          8:   }
               ^ object type. See: src/Person.js:5
          Property `age` is incompatible:
                           v
              5:   person: {
              6:     name: string,
              7:     age: number
              8:   }
                   ^ property `age`. Property not found in. See: src/Person.js:5
             15:         <Person person={{ name: 'Roger' }} />
                                         ^^^^^^^^^^^^^^^^^ object literal

无论您如何奇特地使用属性值,Flow 都可以弄清楚您是否在错误使用它们。尝试在运行时使用诸如prop-types之类的东西来实现相同的功能最多是麻烦的。

验证组件状态

您可以通过对传递给组件的 props 参数进行类型化来验证功能性 React 组件的属性。您的一些组件将具有状态,您可以验证组件的状态与属性的方式大致相同。您可以创建一个表示组件状态的类型,并将其作为类型参数传递给Component

让我们看一个包含由子组件使用和操作的状态的容器组件:

// @flow 
import React, { Component } from 'react'; 
import Child from './Child'; 

type State = { 
  on: boolean 
}; 

class Container extends Component<{}, State> { 
  state = { 
    on: false 
  } 

  toggle = () => { 
    this.setState(state => ({ 
      on: !state.on 
    }));
  } 

  render() { 
    return ( 
      <Child 
        on={this.state.on} 
        toggle={this.toggle} 
      />); 
  } 
} 

export default Container; 

Container渲染的Child组件需要一个on布尔属性和一个toggle函数。Child传递给的“toggle()”方法将改变Container的状态。这意味着Child可以调用此函数以改变其父级的状态。在模块顶部,在组件类的上方,有一个State类型,用于指定允许设置为状态的值。在这种情况下,状态只是一个简单的on布尔值:

type State = { 
  on: boolean 
}; 

然后在扩展时将此类型作为类型参数传递给Component

class Container extends Component<{}, State> { 
  ... 
} 

通过将此类型参数传递给Component,您可以随意设置组件状态。例如,Child组件调用“toggle()”方法来改变Container组件的状态。如果此调用设置状态不正确,Flow 将检测到并抱怨。让我们更改“toggle()”实现,使其通过将状态设置为与 Flow 不一致的内容而失败:

toggle = () => { 
  this.setState(state => ({ 
    on: !state.on + 1 
  })); 
} 

您将收到以下错误:

    Error: src/Container.js:16
     16:       on: !state.on + 1
                   ^^^^^^^^^^^^^ number. This type is incompatible with
      6:   on: boolean
               ^^^^^^^ boolean

在开发过程中错误地设置组件状态是很容易的,因此让 Flow 告诉您您做错了什么是真正的时间节省器。

函数属性值

将函数从一个组件传递到另一个组件作为属性是完全正常的。您可以使用 Flow 来确保不仅将函数传递给组件,而且还传递了正确类型的函数。

让我们通过查看 React 应用程序中的常见模式来检验这个想法。假设您有以下渲染Article组件的Articles组件:

// @flow 
import React, { Component } from 'react'; 
import Article from './Article'; 

type Props = {}; 
type State = { 
  summary: string, 
  selected: number | null, 
  articles: Array<{ title: string, summary: string}> 
}; 

class Articles extends Component<Props, State> { 
  state = { 
    summary: '', 
    selected: null, 
    articles: [ 
      { title: 'First Title', summary: 'First article summary' }, 
      { title: 'Second Title', summary: 'Second article summary' }, 
      { title: 'Third Title', summary: 'Third article summary' } 
    ] 
  }
  onClick = (selected: number) => () => { 
    this.setState(prevState => ({ 
      selected, 
      summary: prevState.articles[selected].summary 
    })); 
  } 

  render() { 
    const { 
      summary, 
      selected, 
      articles 
    } = this.state; 

    return ( 
      <div> 
        <strong>{summary}</strong> 
        <ul> 
          {articles.map((article, index) => ( 
            <li key={index}> 
              <Article 
                index={index} 
                title={article.title} 
                selected={selected === index} 
                onClick={this.onClick} 
              /> 
            </li> 
          ))} 
        </ul> 
      </div> 
    ); 
  } 
} 

export default Articles; 

Articles组件是一个容器组件,因为它具有状态,并且使用此状态来渲染子Article组件。它还定义了一个onClick()方法,用于更改summary状态和selected状态。其想法是Article组件需要访问此方法,以便触发状态更改。如果您仔细观察onClick()方法,您会注意到它实际上返回了一个新的事件处理程序函数。这样,当单击事件实际调用返回的函数时,它将具有对选定参数的作用域访问权限。

现在让我们看看Article组件,看看 Flow 如何帮助您确保您得到了您期望传递给组件的函数:

// @flow 
import React from 'react'; 

type Props = { 
  title: string, 
  index: number, 
  selected: boolean, 
  onClick: (index: number) => Function 
}; 

const Article = ({ 
  title, 
  index, 
  selected, 
  onClick 
}: Props) => ( 
  <a href="#" 
    onClick={onClick(index)} 
    style={{ fontWeight: selected ? 'bold' : 'normal' }} 
  > 
    {title} 
  </a> 
); 

export default Article; 

此组件渲染的<a>元素的onClick处理程序调用了作为属性传递的onClick()函数,并期望返回一个新函数。如果您查看Props类型声明,您会发现onClick属性期望特定类型的函数:

type Props = { 
  onClick: (index: number) => Function, 
  ... 
}; 

这告诉 Flow,这个属性必须是一个接受数字参数并返回一个新函数的函数。将此组件传递给一个事件处理程序函数,而不是返回事件处理程序函数的函数是一个容易犯的错误。Flow 可以轻松发现这一点,并让您轻松进行更正。

强制子组件类型

除了验证状态和属性值的类型之外,Flow 还可以验证您的组件是否获得了正确的子组件。接下来的部分将向您展示 Flow 可以在哪些常见情况下告诉您,当您通过传递错误的子组件来误用组件时。

具有特定子类型的父级

您可以告诉 Flow 组件只能与特定类型的子组件一起使用。假设您有一个Child组件,并且这是唯一允许作为正在处理的组件的子组件的类型。以下是如何告诉 Flow 这个约束的方法:

// @flow 
import * as React from 'react'; 
import Child from './Child'; 

type Props = { 
  children: React.ChildrenArray<React.Element<Child>>, 
}; 

const Parent = ({ children }: Props) => ( 
  <section> 
    <h2>Parent</h2> 
    {children} 
  </section> 
); 

export default Parent; 

让我们从第一个import语句开始:

 import * as React from 'react'; 

您希望将星号导入为React的原因是因为这将引入 React 中可用的所有 Flow 类型声明。在此示例中,您使用ChildrenArray类型来指定该值实际上是组件的子组件,并使用Element来指定您需要一个 React 元素。在此示例中使用的类型参数告诉 Flow,Child组件是此处可接受的唯一组件类型。

给定子组件约束,此 JSX 将通过 flow 验证:

<Parent> 
  <Child /> 
  <Child /> 
</Parent> 

对于作为Parent子组件渲染的Child组件的数量没有限制,只要至少有一个即可。

只有一个子组件的父组件

对于某些组件,拥有多个子组件是没有意义的。对于这些情况,您将使用React.Element类型而不是React.ChildrenArray类型:

// @flow
import * as React from 'react';
import Child from './Child';

type Props = {
  children: React.Element<Child>,
};

const ParentWithOneChild = ({ children }: Props) => (
  <section>
    <h2>Parent With One Child</h2>
    {children}
  </section>
);

export default ParentWithOneChild; 

与之前的示例一样,您仍然可以指定允许的子组件类型。在这种情况下,子组件称为Child,从'./Child'导入。以下是如何将此组件传递给子组件的方法:

<ParentWithOneChild> 
  <Child /> 
</ParentWithOneChild> 

如果您传递多个Child组件,Flow 会抱怨:

    Property `children` is incompatible:
         24:         <ParentWithOneChild>
                     ^^^^^^^^^^^^^^^^^^^^ React children array. Inexact type is incompatible with exact type
          6:   children: React.Element<Child>,
                         ^^^^^^^^^^^^^^^^^^^^ object type. See: src/ParentWithOneChild.js:6

再次,Flow 错误消息会准确显示代码的问题所在。

具有可选子组件的父组件

始终需要一个子组件并不是必要的,实际上可能会引起麻烦。例如,如果没有要渲染的内容,因为 API 没有返回任何内容怎么办?以下是如何使用 Flow 语法指定子组件是可选的示例:

// @flow
import * as React from 'react';
import Child from './Child';

type Props = {
  children?: React.Element<Child>,
};

const ParentWithOptionalChild = ({ children }: Props) => (
  <section>
    <h2>Parent With Optional Child</h2>
    {children}
  </section>
);

export default ParentWithOptionalChild;

这看起来很像需要特定类型元素的 React 组件。不同之处在于有一个问号:children?。这意味着可以传递Child类型的子组件,也可以不传递任何子组件。

具有原始子值的父组件

渲染接受原始值作为子组件的 React 组件是很常见的。在某些情况下,您可能希望接受字符串或布尔类型。以下是您可以这样做的方法:

// @flow
import * as React from 'react';

type Props = {
  children?: React.ChildrenArray<string|boolean>,
};

const ParentWithStringOrNumberChild = ({ children }: Props) => (
  <section>
    <h2>Parent With String or Number Child</h2>
    {children}
  </section>
);

export default ParentWithStringOrNumberChild;

再次,您可以使用React.ChildrenArray类型来指定允许多个子元素。要指定特定的子类型,您将其传递给React.ChildrenArray作为类型参数—在这种情况下是字符串和布尔联合。现在您可以使用字符串渲染此组件:

<ParentWithStringOrNumberChild>
  Child String
</ParentWithStringOrNumberChild>

或者使用布尔值:

<ParentWithStringOrNumberChild> 
  {true} 
</ParentWithStringOrNumberChild> 

或者两者都使用:

<ParentWithStringOrNumberChild> 
  Child String 
  {false} 
</ParentWithStringOrNumberChild> 

验证事件处理程序函数

React 组件使用函数来响应事件。这些被称为事件处理程序函数,当 React 事件系统调用它们时,它们会被传递一个事件对象作为参数。使用 Flow 明确地为这些事件参数类型化可能是有用的,以确保您的事件处理程序获得它所期望的元素类型。

例如,假设您正在开发一个组件,该组件响应来自<a>元素的点击。您的事件处理程序函数还需要与被点击的元素交互,以获取href属性。使用 React 公开的 Flow 类型,您可以确保正确的元素类型确实触发了导致函数运行的事件:

// @flow
import * as React from 'react';
import { Component } from 'react';

class EventHandler extends Component<{}> {
  clickHandler = (e: SyntheticEvent<HTMLAnchorElement>): void => {
    e.preventDefault();
    console.log('clicked', e.currentTarget.href);
  }

  render() {
    return (
      <section>
        <a href="#page1" onClick={this.clickHandler}>
          First Link
        </a>
      </section>
    );
  }
}

export default EventHandler;

在这个例子中,clickHandler()函数被分配为<a>元素的onClick处理程序。注意事件参数的类型:SyntheticEvent<HTMLAnchorElement>。Flow 将使用此来确保您的代码只访问事件的适当属性和事件的currentTarget

currentTarget是触发事件的元素,在这个例子中,您已指定它应该是HTMLAnchorElement。如果您使用了其他类型,Flow 会抱怨您引用href属性,因为其他 HTML 元素中不存在该属性。

将 Flow 引入开发服务器

如果您希望在项目中为此功能,您需要从create-react-app中退出。

这种方法的目标是在检测到更改时让开发服务器为您运行 Flow。然后,您可以在开发服务器控制台输出和浏览器控制台中看到 Flow 输出。

一旦您通过运行npm ejectcreate-react-app中退出,您需要安装以下 Webpack 插件:

npm install flow-babel-webpack-plugin --save-dev

然后,您需要通过编辑config/webpack.config.dev.js来启用插件。首先,您需要包含插件:

const FlowBabelWebpackPlugin = require('flow-babel-webpack-plugin');

然后,你需要将插件添加到plugins选项中的数组中。之后,这个数组应该看起来像这样:

plugins: [ 
  new InterpolateHtmlPlugin(env.raw), 
  new HtmlWebpackPlugin({ 
    inject: true, 
    template: paths.appHtml, 
  }), 
  new webpack.NamedModulesPlugin(), 
  new webpack.DefinePlugin(env.stringified), 
  new webpack.HotModuleReplacementPlugin(), 
  new CaseSensitivePathsPlugin(), 
  new WatchMissingNodeModulesPlugin(paths.appNodeModules), 
  new webpack.IgnorePlugin(/^./locale$/, /moment$/), 
  new FlowBabelWebpackPlugin() 
], 

就是这样。现在当你启动开发服务器时,Flow 将自动运行并在 Webpack 构建过程中对你的代码进行类型检查。让我们在App.js的顶部添加@flow指令,然后运行npm start。由于App组件不会作为Component的子类进行验证,你应该会在开发服务器控制台输出中得到一个错误:

    Failed to compile.

    Flow: Type Error
    Error: src/App.js:6
      6: class App extends Component {
                           ^^^^^^^^^ Component. Too few type arguments. Expected at least 1
     26: declare class React$Component<Props, State = void> {
                                       ^^^^^^^^^^^^ See type parameters of definition here.

    Found 1 error

我真的很喜欢这种方法,即使有 Flow 错误,开发服务器仍然会启动。如果你在浏览器中查看应用程序,你会看到以下内容:

这意味着在开发过程中,你甚至不需要查看开发服务器控制台来捕捉类型错误!而且由于它是开发服务器的一部分,每次你进行更改时,Flow 都会重新检查你的代码。所以让我们通过传递一个属性类型参数(<{}>)来修复App.js中的当前错误。

class App extends Component<{}> { 
  ... 
} 

一旦进行了这个改变,保存文件。就像这样,错误就消失了,你又可以继续工作了。

将 Flow 整合到你的编辑器中

我们将看一下最后一个选项,用于使用 Flow 验证你的 React 代码,那就是将这个过程整合到你的代码编辑器中。我正在使用流行的 Atom 编辑器,所以我会以此为例,但很可能也有其他编辑器可以与 Flow 整合。

要在 Atom 编辑器中启用 Flow 功能,你需要安装linter-flow包:

安装完成后,你需要改变linter-flow的可执行路径设置。默认情况下,插件假设你已经全局安装了 Flow,但实际上你可能没有。你需要告诉插件在本地的node_modules目录中查找 Flow 可执行文件:

你已经准备好了。为了验证这是否按预期工作,请打开一个新的create-react-app安装中的App.js,并在文件顶部添加@flow指令。这应该会触发 Flow 的错误,并应该在 Atom 中显示出来:

Linter 还会突出显示导致 Flow 抱怨的有问题的代码:

通过在编辑器中使用 Flow 的方法,您甚至不需要保存,更不用说切换窗口来进行代码类型检查——您只需要编写代码。

总结

在本章中,您了解了为什么对 React 代码进行类型检查很重要。您还了解了 Flow——用于对 React 代码进行类型检查的工具。对于 React 应用程序来说,类型检查很重要,因为它消除了在大多数情况下执行值的运行时检查的需要。这是因为 Flow 能够静态地跟踪代码路径,并确定是否一切都被按照预期使用。

然后,您在本地安装了 Flow 到一个 React 应用程序,并学会了如何运行它。接下来,您学会了验证 React 组件的属性和状态值的基础知识。然后,您学会了验证函数类型以及如何强制执行子 React 组件类型。

Flow 可以在create-react-app开发服务器中使用,但您必须先进行弹出。在未来的create-react-app版本中,可能会有更好的集成支持,可以作为开发服务器的一部分运行 Flow。另一个选择是在诸如 Atom 之类的代码编辑器中安装 Flow 插件,并在编写代码时直接在眼前显示错误。

在接下来的章节中,您将学习如何借助工具来强制执行 React 代码的高质量水平。

第六章:强制执行代码质量以提高可维护性

如果一个项目的代码是一致的且易于阅读,那不是很好吗?之所以通常情况下不是这样,是因为强制执行这种程度的代码质量是繁重的。当手动完成某事是一种负担时,您就引入了一个工具。

本章的重点是使用工具来确保您的 React 代码质量达到标准。以下是本章的学习内容:

  • 安装和配置 ESLint

  • 在 React 源代码上运行 ESLint

  • 从 Airbnb 获取配置帮助

  • 对 JSX 和 React 组件进行 Linting

  • 将 ESLint 与您的代码编辑器集成

  • 自定义 ESLint 错误和警告

  • 使用 Prettier 自动格式化代码

安装和配置 ESLint

自动化 React 源代码质量的第一步是安装和配置用于自动化的工具—ESLint。当安装了 ESLint 时,它会在您的系统上安装一个eslint命令。与安装全局命令的其他软件包一样,最好将它们作为项目的一部分安装在本地,这样您就不必依赖系统上全局可用的命令。

要在项目中安装 ESLint,请运行以下npm命令:

npm install eslint --save-dev

现在您已经安装了 ESLint,您可以创建一个新的 npm 脚本来运行 ESLint。将以下内容添加到您的package.json文件的scripts部分:

"scripts": { 
  ... 
  "lint": "eslint" 
}, 

现在您有了一个可以在项目中运行的eslint命令。试一试吧:

npm run lint

而不是对任何源文件进行 Linting,您应该在控制台中看到一个使用消息:

eslint [options] file.js [file.js] [dir]

Basic configuration:
  -c, --config path::String      Use configuration from this file or shareable config
  --no-eslintrc                  Disable use of configuration from .eslintrc
  --env [String]                 Specify environments
  --ext [String]                 Specify JavaScript file extensions - default: .js
  --global [String]              Define global variables
  --parser String                Specify the parser to be used
  --parser-options Object        Specify parser options 
...

如您所见,您必须告诉eslint命令您想要进行 Lint 的文件或目录。为了保持简单,让我们假设我们所有的代码都在与package.json相同的目录中。您可以修改您的package.json文件如下,以便 ESLint 知道在哪里查找文件:

"scripts": { 
  ... 
  "lint": "eslint ." 
}, 

您注意到在eslint后面添加了点(.)吗?这意味着在大多数系统上是当前目录。继续运行npm run lint。这一次,您将看到不同的输出,因为 ESLint 实际上正在尝试查找要进行 Lint 的源文件:

Oops! Something went wrong! :(ESLint: 4.15.0.
ESLint couldn't find a configuration file. To set up a configuration file for this project, please run:
 eslint --init

好的,让我们按照它告诉我们的去做。我们将运行npm run lint -- --init来创建一个配置文件。当您这样做时,您将看到一系列选项供您选择:

? How would you like to configure ESLint? 
› Answer questions about your style 
 Use a popular style guide 
 Inspect your JavaScript file(s) 

现在让我们选择第一个选项,并回答一些关于您计划编写的代码的基本问题。选择选项后,按下Enter键将带您到第一个问题:

? Are you using ECMAScript 6 features? (y/N)  

是的,你是。

? Are you using ES6 modules? (y/N)

是的,你是。

? Where will your code run? (Press <space> to select, <a> to toggle all, <i> to inverse selection)
›(*) Browser
 ( ) Node

选择 Browser

? Do you use CommonJS? (y/N)  

不。

? Do you use JSX? (y/N)  

不。我们稍后会介绍 JSX。

? What style of indentation do you use? (Use arrow keys)
› Tabs 
  Spaces

在这里使用任何你喜欢的,因为我最终肯定会错。

? What quotes do you use for strings? (Use arrow keys)
› Double 
  Single 

单个。你是什么,一个动物吗?

? What line endings do you use? (Use arrow keys)
› Unix
  Windows

Unix 在这里是一个安全的选择。

? Do you require semicolons? (Y/n)  

这是一个棘手的问题。在 JavaScript 源代码中分号不是必需的。有时它们可以帮助,而其他时候它们只是为了一些 JavaScript 解释器已经理解的东西而添加的语法。如果你不确定,要求使用分号;你总是可以稍后更改你的 ESLint 配置:

? What format do you want your config file to be in? (Use arrow keys)
› JavaScript 
  YAML 
  JSON 

使用你最舒适阅读和编辑的任何东西。我将坚持使用 JavaScript 的默认选项:

Successfully created .eslintrc.js file

万岁!让我们再试一次运行这个:

npm run lint

这次没有输出。这意味着 ESLint 没有发现任何错误。部分原因是项目中还没有代码,但是现在你有了一个已知的工作起点。让我们快速看一下为你创建的 .eslintrc.js 文件:

module.exports = { 
    "env": { 
        "browser": true, 
        "es6": true 
    }, 
    "extends": "eslint:recommended", 
    "parserOptions": { 
        "sourceType": "module"
    }, 
    "rules": { 
        "indent": [ 
            "error", 
           4 
        ], 
        "linebreak-style": [ 
            "error", 
            "unix" 
        ], 
        "quotes": [ 
            "error", 
            "single" 
        ], 
        "semi": [ 
            "error", 
            "always" 
        ] 
    } 
}; 

既然你已经回答了创建这个文件所需的问题,你现在不需要改变任何东西。当你需要时,这就是要编辑的文件。当你只是学习 ESLint 时,像这样打出一个配置文件可能会让人望而却步。但是,当你决定你的代码质量标准需要调整时,ESLint 规则参考(eslint.org/docs/rules/)是一个很好的资源。

作为为项目设置和配置 ESLint 的最后一步,让我们引入一些源代码进行 lint。如果还没有,创建一个 index.js 文件,并添加以下函数:

export const myFunc = () => 'myFunc';

不要担心运行这个函数,linting 不像测试或类型检查那样。相反,linting 为开发人员提供了关于他们从代码质量角度做错了什么的易于忽视的提示。正确性与代码质量是不同的。这意味着你有许多可调整的选项与 ESLint,告诉它如何评估你的代码。

现在,回到你刚刚添加的函数。你可以通过再次运行 npm run lint 来验证这个函数是否正确。果然,根据你在 .eslintrc.js 中配置的规则,这个函数是好的。现在,尝试从函数中删除分号,使其看起来像这样:

export const myFunc = () => 'myFunc' 

这次,你会从 ESLint 得到一个错误:

index.js 
  1:37  error  Missing semicolon  semi 
Χ 1 problem (1 error, 0 warnings)

这是您需要的确切输出类型。它为您提供了源文件的名称,文件中错误/警告的位置,并描述了找到的实际问题。

让我们再试一次。请恢复您删除的分号。现在,删除 export 语句,使您的函数定义如下:

const myFunc = () => 'myFunc'; 

当对此代码进行检查时,您会得到不同的错误:

index.js 
  1:7  error  'myFunc' is assigned a value but never used  no-unused-vars Χ 1 problem (1 error, 0 warnings)

因为您删除了 export 关键字,所以模块只是一个分配给 myFunc 的函数。它从未被使用,ESLint 能够告诉您这一点。

建立在 Airbnb 标准的基础上

拥有大型 JavaScript 代码库的组织已经在代码质量工具上进行了大量投资。这包括在配置诸如 ESLint 之类的工具方面的投资。使用一组标准的配置值来强制执行代码质量的伟大之处在于,由于轻微的配置差异,开发人员之间不会有任何差异。

ESLint 允许您安装和使用 npm 包作为配置设置来使用和扩展。一个受欢迎的选择是 Airbnb 标准。让我们再次使用 ESLint init 工具来开始使用 Airbnb JavaScript 代码质量标准。首先,再次运行 init 工具:

npm run lint -- --init

第一个问题问您如何配置 ESLint。您可以选择一个指南而不是回答问题:

? How would you like to configure ESLint? 
  Answer questions about your style 
› Use a popular style guide 
  Inspect your JavaScript file(s) 

下一个问题让您选择要遵循的指南。您想要遵循 Airbnb 的指南:

? Which style guide do you want to follow? 
  Google  
›  Airbnb 
  Standard 

现在,ESLint 将安装必要的 npm 包以使用 Airbnb 的 ESLint 配置设置:

Checking peerDependencies of eslint-config-airbnb-base@latest 
Installing eslint-config-airbnb-base@latest, eslint-plugin-import@².7.0 

+ eslint-plugin-import@2.8.0 
+ eslint-config-airbnb-base@12.1.0 

让我们看看 ESLint 创建的 .eslintrc.js 文件是什么样子的:

module.exports = { 
  "extends": "airbnb-base" 
}; 

正如您所看到的,现在这个文件非常简单,因为一切都由 airbnb-base npm 包处理。您的 .eslintrc.js 只是在扩展它。让我们看看这些 Airbnb 规则是如何起作用的。将以下代码添加到 index.js 中:

const maybe = v => v ? v : 'default';

console.log(maybe('yes'));
// -> yes
console.log(maybe());
// -> default

maybe() 函数如果参数为真,则返回该参数;否则返回字符串 default。然后,使用字符串值和没有值来调用 maybe()。注释指示了这两个函数调用的输出。随时运行此代码以确保它按照广告中的方式工作。

在您这样做之后,让我们看看 Airbnb 对您的代码有何看法:

npm run lint

这是输出:

index.js 
  1:15  error    Arrow function used ambiguously with a conditional expression     no-confusing-arrow
 1:24  error    Unnecessary use of conditional expression for default assignment  no-unneeded-ternary 
  3:1   warning  Unexpected console statement                                      no-console 
  5:1   warning  Unexpected console statement                                      no-console 
Χ 4 problems (2 errors, 2 warnings)

四个问题!哎呀。让我们逐个解决每个问题,看看能做些什么。第一个错误是no-confusing-arrow,它表示箭头函数与比较运算符模糊地使用了。您可以查看每个错误的具体内容(eslint.org/docs/rules/),在那里您将找到详细的解释和示例。

接下来的错误no-unneeded-ternary与第一个错误密切相关。它指出我们可以使用比三元表达式更简单的表达式,这应该有助于提高代码的可读性。所以让我们试一试。maybe()函数应该返回参数或者如果参数为假的话返回一些默认值。除了三元运算符,让我们尝试使用逻辑或(||):

const maybe = (v = 'default') => v; 

这里的可读性稍有改善,明显减少了语法。关于这个微小改进本身更重要的是,每个在这个代码库上工作的开发人员都会做出相同的微小改进。让我们看看现在npm run lint会说些什么:

index.js 
  6:1  warning  Unexpected console statement  no-console 
  8:1  warning  Unexpected console statement  no-console 
Χ 2 problems (0 errors, 2 warnings)

太棒了!您只剩下两个警告。但这些警告只是在抱怨您的console.log()调用。显然,Airbnb 的 ESLint 规则不喜欢这样做,但您喜欢。由于您只是通过扩展它们来使用 Airbnb 规则设置作为起点,您也可以关闭它们。在您的情况下,no-console规则没有任何作用,因为您显然依赖它。为此,编辑您的.eslintrc.js文件,使其如下所示:

module.exports = { 
  "extends": "airbnb-base", 
  "rules": { 
    "no-console": 0 
  } 
}; 

在 ESLint 配置的extends部分之后,您可以添加一个rules部分,您可以在其中关闭由airbnb-base定义的特定规则。在这个例子中,将no-console设置为0告诉 ESLint 不应报告这些警告。让我们再次运行npm run lint,看看是否已经修复了所有问题。

果然,没有更多的错误要报告了!

向 ESLint 添加 React 插件

假设您想在尝试并喜欢了之后使用 Airbnb 的 ESLint 规则集。假设您还想对 React 组件代码进行 lint。在 ESLint init过程中,您已经回答了一个问题,该问题询问您的项目是否使用 React。这次,让我们回答“是”。所以,再次运行 ESLint init过程:

npm run lint -- --init

再次,您想使用 Airbnb 的 lint 规则:

? Which style guide do you want to follow? 
  Google 
›  Airbnb 
  Standard 

当它询问您是否使用 React 时,回答“是”:

? Do you use React? (y/N) y

您会注意到安装了一些额外的包:

+ eslint-plugin-react@7.5.1
+ eslint-plugin-jsx-a11y@6.0.3  

现在让我们编写一些 React 代码,以便我们可以对其进行 lint。将以下组件添加到MyComponent.js中:

import React, { Component } from 'react'; 

class MyComponent extends Component { 
  render() { 
    return ( 
      <section> 
        <h1>My Component</h1> 
      </section> 
    );
  } 
} 

export default MyComponent; 

这是组件的渲染方式:

import React from 'react'; 
import ReactDOM from 'react-dom'; 
import MyComponent from './MyComponent'; 

const root = document.getElementById('root'); 

ReactDOM.render( 
  <MyComponent />, 
  root 
); 

您不需要担心在浏览器中运行此 React 应用程序;这只是为了确保 ESLint 能够解析 JSX 并对其进行 lint。现在让我们尝试运行 ESLint:

npm run lint

在对源代码进行 lint 时,这里是生成的错误:

index.js 
  5:14  error  'document' is not defined                      no-undef 
  8:3   error  JSX not allowed in files with extension '.js'  react/jsx-filename-extension 
  9:7   error  Missing trailing comma                         comma-dangle 

MyComponent.js 
  3:1  error  Component should be written as a pure function  react/prefer-stateless-function 
  6:7  error  JSX not allowed in files with extension '.js'   react/jsx-filename-extension 

您需要处理两个源文件中的错误。现在让我们逐个讨论这些错误。

来自index.js的第一个错误是no-undef,它指的是一个不存在的document标识符。问题是,您知道document是在浏览器环境中全局存在的标识符。ESLint 不知道这个全局标识符被定义了,所以我们必须在.eslintrc.js中告诉它这个值:

module.exports = { 
  "extends": "airbnb",
  "globals": {
    "document": true 
  } 
}; 

在 ESLint 配置的globals部分,您可以列出 ESLint 应该识别的全局标识符的名称。如果标识符实际上在引用它的源代码中是全局可用的,则值应为true。这样,ESLint 就知道不会抱怨在浏览器环境中识别为全局标识符的东西。

为特定环境中存在的标识符(如 Web 浏览器)添加全局标识符的问题在于它们有很多。您不希望维护这样一个列表,以便 ESLint 通过您的源代码。幸运的是,ESLint 对此有解决方案。您可以指定代码将在的环境,而不是指定globals

module.exports = { 
  "extends": "airbnb", 
  "env": { 
    "browser": true 
  } 
}; 

通过将browser环境指定为true,ESLint 知道所有浏览器全局变量,并且在代码中找到它们时不会抱怨。此外,您可以指定多个环境,因为通常会有在浏览器和 Node.js 中运行的代码。或者即使您不在不同环境之间共享代码,也可能希望对同时具有客户端和服务器代码的项目进行 lint。在任何一种情况下,这是多个 ESLint 环境的示例:

module.exports = { 
  "extends": "airbnb", 
  "env": { 
    "browser": true, 
    "node": true 
  } 
}; 

要修复的下一个错误是react/jsx-filename-extension。这个规则来自于你初始化 ESLint 配置时安装的eslint-plugin-react包。该规则希望你使用不同的扩展名来命名包含 JSX 语法的文件。假设你不想麻烦这个(我不会责怪你,为几乎相同类型的文件内容维护两个文件扩展名太费劲了)。让我们暂时禁用这个规则。

这是更新后的 ESLint 配置:

module.exports = {
  "extends": "airbnb", 
  "env": { 
    "browser": true, 
    "node": true 
  }, 
  "rules": { 
    "react/jsx-filename-extension": 0 
  } 
}; 

react/jsx-filename-extension规则被设置为0,在配置的rules部分中被忽略。继续运行npm run lint。现在只剩下两个错误了。

comma-dangle规则确实有自己的见解,但这是一个有趣的想法。让我们聚焦于触发这个错误的有问题的代码:

ReactDOM.render( 
  <MyComponent />, 
  root 
); 

ESLint 抱怨在root参数后没有尾随逗号。添加尾随逗号的想法是:

  • 后面添加项目更容易,因为逗号已经在那里

  • 当你提交代码时,它会导致更清晰的差异,因为添加或删除项目只需要更改一行而不是两行

假设这是有道理的,你决定保留这个规则(我喜欢它),这是修复后的代码:

ReactDOM.render( 
  <MyComponent />, 
  root, 
); 

现在让我们再次运行npm run lint。只剩下一个错误!这是另一个 React 特定的错误:react/prefer-stateless-function。让我们再看看触发这个错误的 React 组件:

import React, { Component } from 'react'; 

class MyComponent extends Component {

  render() { 
    return (
      <section> 
        <h1>My Component</h1> 
      </section> 
    ); 
  } 
} 

export default MyComponent; 

ESLint 通过eslint-plugin-react的帮助,告诉你这个组件应该被实现为一个函数而不是一个类。它这么说是因为它能够检测到MyComponent没有任何状态,也没有任何生命周期方法。所以如果它被实现为一个函数,它:

  • 不再依赖Component

  • 将是一个简单的函数,比类的语法要少得多

  • 将明显地表明这个组件没有副作用

考虑到这些好处,让我们按照 ESLint 的建议,将MyComponent重构为一个纯函数:

import React, { Component } from 'react';

const MyComponent = () => (
  <section>
    <h1>My Component</h1>
  </section>
);

export default MyComponent;

当你运行npm run lint时,你会得到:

MyComponent.js 
  1:17  error  'Component' is defined but never used  no-unused-vars 

哎呀,在修复另一个错误的过程中,你引入了一个新的错误。没关系,这就是为什么要对代码进行检查,以找出容易忽略的问题。在这种情况下,是因为我们忘记了去掉Component导入,所以出现了no-unused-vars错误。这是修复后的版本:

import React from 'react';
const MyComponent = () => ( 
  <section>
    <h1>My Component</h1> 
  </section> 
); 

export default MyComponent; 

然后你就完成了,不再有错误!借助eslint-config-airbnbeslint-plugin-react的帮助,你能够生成任何其他 React 开发人员都能轻松阅读的代码,因为很可能他们正在使用完全相同的代码质量标准。

使用 ESLint 与 create-react-app

到目前为止,在本章中你所看到的一切,你都必须自己设置和配置。并不是说让 ESLint 运行起来特别困难,但create-react-app完全抽象了这一点。记住,create-react-app的理念是尽快开始编写组件代码,而不必考虑配置诸如 linters 之类的东西。

为了看到这一点的实际效果,让我们使用create-react-app创建一个新的应用程序:

create-react-app my-new-app

然后,一旦创建,立即启动应用程序:

npm start

现在让我们让 ESLint 抱怨一些事情。在你的编辑器中打开App.js,它应该看起来像这样:

import React, { Component } from 'react'; 
import logo from './logo.svg'; 
import './App.css'; 

class App extends Component { 
  render() { 
    return ( 
      <div className="App"> 
        <header className="App-header"> 
          <img src={logo} className="App-logo" alt="logo" /> 
          <h1 className="App-title">Welcome to React</h1> 
        </header>
        <p className="App-intro"> 
          To get started, edit <code>src/App.js</code> and save to reload. 
        </p> 
      </div>
    ); 
  } 
} 

export default App; 

ESLint 认为这是可以的,所以让我们删除Component导入,这样App.js现在看起来像这样:

import React from 'react'; 
import logo from './logo.svg'; 
import './App.css'; 

class App extends Component { 
  render() { 
    return ( 
      <div className="App"> 
        <header className="App-header"> 
          <img src={logo} className="App-logo" alt="logo" /> 
          <h1 className="App-title">Welcome to React</h1> 
        </header> 
        <p className="App-intro"> 
          To get started, edit <code>src/App.js</code> and save to reload. 
        </p> 
      </div> 
    ); 
  } 
} 

export default App; 

你的App类现在试图扩展Component,但Component并不存在。一旦你保存文件,ESLint 将被调用,因为它作为 Webpack 插件集成到开发服务器中。在开发服务器控制台中,你应该看到以下内容:

Failed to compile.

./src/App.js
Line 5:  'Component' is not defined  no-undef  

正如预期的那样,ESLint 会为你检测到问题。将 ESLint 集成到开发服务器中的好处是你不必记得调用npm run lint命令。如果 ESLint 不通过,整个构建将失败。

你不仅会在开发服务器控制台中收到构建失败的通知,而且还会直接在浏览器中收到通知:

这意味着即使你忘记查看服务器控制台,也很难错过替换整个 UI 的通知。如果你撤消了故意破坏 ESLint 的更改(重新添加Component导入),一旦你保存App.js,你的 UI 会再次显示出来。

在代码编辑器中使用 ESLint

如果你想要进一步对create-react-app的代码进行 linting,你可以这样做。如果你正在编写组件代码,你最不想做的事情就是不得不切换到控制台或浏览器窗口,只是为了查看你写的东西是否足够好。对于一些人来说,更好的开发体验是在他们的编辑器中看到 lint 错误发生。

让我们看看如何在 Atom 中实现这一点。首先,你需要安装linter-eslint插件:

现在当你在 Atom 中打开 JavaScript 源文件时,这个插件会为你进行 lint,并在行内显示错误和警告。唯一的挑战是create-react-app实际上并没有为你创建一个.eslintrc.js文件。这是因为create-react-app的性质是默认情况下隐藏所有配置。

然而,ESLint 仍然由create-react-app配置。这就是在启动开发服务器时对你的源代码进行 lint 的方式。问题在于你可能希望在编辑器 linter 中使用这个配置。create-react-app安装了一个名为eslint-config-react-app的包,其中包含开发服务器使用的 ESLint 配置。你可以在自己的项目中使用这个配置,这样你的编辑器 linter 就配置与浏览器或控制台中输出的内容相同。这非常重要,你最不希望的就是编辑器告诉你代码的一些问题,而你在浏览器中却看不到任何问题。

如果你在 Atom 中打开App.js,你不应该看到任何 lint 错误,因为:

  • 没有任何

  • linter-eslint Atom 插件没有运行,因为它没有找到任何配置

当没有错误时,文件看起来像这样:

你所要做的就是添加扩展eslint-config-react-app配置的 ESLint 配置。在你的项目根目录中,创建以下.eslintrc.js文件:

module.exports = { 
  "extends": "eslint-config-react-app" 
}; 

现在 Atom 的linter-eslint插件将尝试实时对你的开源文件进行 lint。此外,它将使用与你的create-react-app开发服务器完全相同的配置。让我们再试着删除Component导入。现在你的编辑器看起来有点不同:

正如你所看到的,Component标识符被用红色下划线标出,以便突出显示代码的这一部分。在你的源代码下面,有一个窗格显示了找到的每个 linter 错误的列表,以及有关每个错误的更多细节。如果你运行npm start,你会在开发服务器控制台和浏览器中看到完全相同的错误,因为 Atom 使用与create-react-app相同的 ESLint 配置。

现在让我们消除这个错误。转到以下代码行:

import React from 'react';

将其改回:

import React, { Component } from 'react'; 

在你的编辑器中不应该再显示任何 linter 错误。

使用 Prettier 自动化代码格式化

ESLint 可以用来改进代码的任何方面,包括格式。使用 ESLint 的问题在于它只告诉你它发现的格式问题。你仍然需要去修复它们。

这就是为什么 create-react-app 的 ESLint 配置没有指定任何代码格式规则。这就是 Prettier 这样的工具发挥作用的地方。它是一个针对你的 JavaScript 代码的有主见的代码格式化工具。它可以直接理解 JSX,因此非常适合格式化你的 React 组件。

create-react-app 用户指南中有一个完整的部分介绍了如何设置 Git 提交钩子,以在提交之前触发 Prettier 格式化任何代码:github.com/facebookincubator/create-react-app#user-guide

我不会在这里重复这个指南,但基本思想是,设置好 Git 钩子,以便在提交任何 JavaScript 源代码时调用 Prettier 来确保一切都格式化得很好。只依赖 Git 提交钩子的缺点是,作为开发人员,你不一定在编写代码时看到格式化后的代码。

除了设置 Prettier 在每次提交时格式化 JavaScript 源代码之外,添加代码编辑器插件可以大大改善开发体验。再次,你可以安装适当的 Atom 包(或类似的东西;Atom 很受欢迎,所以我在这里使用它作为示例编辑器):

安装了 prettier-atom 包后,你可以使用 Atom 来格式化你的 React 代码。默认情况下,这个包使用快捷键 Ctrl + Alt + F 来调用 Prettier 格式化当前的源文件。另一个选项是在保存时启用格式化。

现在,每次保存 JavaScript 源代码时,Prettier 都会对其进行格式化。让我们来测试一下。首先,打开 App.js,完全破坏格式,让它看起来像这样:

恶心!让我们保存文件,看看会发生什么:

这样好多了。想象一下,如果你不得不手动修复那个混乱的代码。Prettier 可以让你的代码清晰,几乎不需要你费心思。

总结

本章重点介绍了使用工具来强制执行 React 项目的代码质量水平。您学习了第一个工具是 ESLint。您学会了如何安装和配置它。您很少需要手动配置 ESLint。您学会了如何使用 ESLint 初始化工具,该工具会引导您完成配置 ESLint 规则的各种选项。

接下来,您了解了不同的标准 ESLint 配置,可以在您的 React 应用程序中使用。Airbnb 是一个流行的标准,您可以在 ESLint 中使用,并且可以逐条自定义规则以适应您团队的特定风格。您还可以告诉 ESLint 初始化工具,您打算使用 React,并让它为您安装适当的软件包。

最后,您了解了create-react-app如何使用 ESLint。它使用一个 Webpack 插件在运行开发服务器时对您的代码进行 lint。您学会了create-react-app如何为此配置 ESLint,以及如何在代码编辑器中使用此配置。Prettier 是一个工具,它将自动格式化您的代码,这样您就不必花时间手动处理大量的 ESLint 样式警告。

在下一章中,您将学习如何使用 Storybook 在它们自己的环境中隔离 React 组件开发。

第七章:使用 Storybook 隔离组件

React 组件是较大用户界面的较小部分。自然而然,您希望与应用程序的其余部分一起开发 UI 组件。另一方面,如果您唯一的环境是在较大的 UI 内部,那么尝试组件更改可能会变得棘手。本章的重点是向您展示如何利用 Storybook 工具提供一个隔离的沙盒来开发 React 组件。您将学到:

  • 隔离组件开发的重要性

  • 安装 Storybook 并进行设置

  • 使用故事开发组件

  • 将组件引入应用程序

隔离组件开发的需求

在开发过程中隔离 React 组件可能会很困难。开发人员和他们正在制作的 React 组件所拥有的唯一上下文通常只有应用程序本身。在组件开发过程中很少会按计划进行。调试 React 组件的一部分是,嗯,与之互动。

我经常发现自己在应用程序代码中做一些奇怪的事情,以适应我们对组件进行临时更改时出现的问题。例如,我会更改容器元素的类型,看看这是否导致了我看到的布局问题;或者,我会更改组件内部的标记;或者,我会完全捏造一些组件使用的状态或属性。

重点是,在开发组件的过程中,您将想要进行一些随机实验。在您构建的应用程序中尝试这样做可能会很麻烦。这主要是因为您被迫接受组件周围的一切,当您只关心看看您的组件做了什么时,这可能会分散注意力。

有时,我最终会创建一个全新的页面,或者一个全新的应用程序,只是为了看看我的组件单独做了什么。这是一个痛苦的过程,其他人也有同样的感受,这就是为什么Storybook存在的原因。React 工具存在是为了为 React 开发人员自动化某些事情。使用 Storybook,您正在自动化一个沙盒环境供您使用。它还为您处理所有构建步骤,因此您只需为组件编写一个故事并查看结果。

最好的方式是将 Storybook 视为类似 JSFiddle(jsfiddle.net/)或 JSBin(jsbin.com/)这样的网站。它们让你可以在不设置和维护环境的情况下尝试小段代码。Storybook 就像 React 的 JSFiddle,作为你项目的一个组成部分存在。

安装和配置 Storybook

使用 Storybook 的第一步是安装全局命令行工具。它被安装为全局工具,因为它可以同时用于许多项目,并且可以用来引导新项目。让我们从这第一步开始:

npm install @storybook/cli -g

安装完成后,你将拥有用于修改package.json依赖项和生成样板 Storybook 文件的命令行工具。假设你已经使用create-react-app创建了一个新应用程序。进入你的应用程序目录,并使用 Storybook 命令行工具将 Storybook 添加到你当前的项目中:

getstorybook

当你运行getstorybook命令时,它会为你做很多事情。当你运行这个命令时,以下是你应该看到的输出:

getstorybook - the simplest way to add a storybook to your project. 
![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/react16-tl/img/4f10e203-ce18-4c2c-ae60-5c08079192da.jpg) Detecting project type. ![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/react16-tl/img/86ac4257-00f3-4fcb-92c0-73bde8dd7af4.png)
![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/react16-tl/img/4f10e203-ce18-4c2c-ae60-5c08079192da.jpg) Adding storybook support to your "Create React App" based project. ![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/react16-tl/img/86ac4257-00f3-4fcb-92c0-73bde8dd7af4.png)![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/react16-tl/img/4f10e203-ce18-4c2c-ae60-5c08079192da.jpg) Preparing to install dependencies. ![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/react16-tl/img/86ac4257-00f3-4fcb-92c0-73bde8dd7af4.png)

它会在添加任何内容之前尝试弄清楚你的项目类型,因为不同类型的项目会有不同的组织要求。getstorybook会考虑到这一点。然后,它会安装依赖项,样板文件,并向你的package.json添加脚本:

 ![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/react16-tl/img/4f10e203-ce18-4c2c-ae60-5c08079192da.jpg) Installing dependencies.
To run your storybook, type:
 npm run storybook 

输出告诉你如何在项目中运行 Storybook 服务器。此时,你的package.jsonscripts部分应该如下所示:

"scripts": { 
  "start": "react-scripts start", 
  "build": "react-scripts build", 
  "test": "react-scripts test --env=jsdom", 
  "eject": "react-scripts eject", 
  "storybook": "start-storybook -p 9009 -s public", 
  "build-storybook": "build-storybook -s public" 
} 

我们将在本章后面看一下build-storybook脚本;你会更经常使用storybook脚本。

接下来,让我们来看看getstorybook为你创建的样板文件。首先,你会注意到在项目的顶层目录中有一个新的.storybook目录:

.storybook/
├── addons.js
└── config.js

添加的两个文件如下:

  • addons.js:这个文件导入了 Storybook 的插件模块。默认情况下,会使用 actions 和 links 插件,但如果不需要可以移除。

  • config.js:这个文件导入了这个项目的故事,并配置 Storybook 来使用它们。

你还会在你的src目录中找到一个名为stories的新目录:

src/
├── App.css
├── App.js
├── App.test.js
├── index.css
├── index.js
├── logo.svg
├── registerServiceWorker.js
└── stories
    └── index.js

记得getstorybook是如何发现你正在使用create-react-app来开发你的项目的吗?这就是它知道要把stories目录放在src下的方式。这里有两个演示故事,可以帮助你入门:

import React from 'react'; 

import { storiesOf } from '@storybook/react'; 
import { action } from '@storybook/addon-actions'; 
import { linkTo } from '@storybook/addon-links'; 

import { Button, Welcome } from '@storybook/react/demo'; 

storiesOf('Welcome', module).add('to Storybook', () => ( 
  <Welcome showApp={linkTo('Button')} /> 
)); 

storiesOf('Button', module) 
  .add('with text', () => ( 
    <Button onClick={action('clicked')}>Hello Button</Button> 
  )) 
  .add('with some emoji', () => ( 
    <Button onClick={action('clicked')}></Button> 
  )); 

现在先不要担心这个文件里发生了什么,我们会搞清楚的。这些默认故事将被你为组件想出的故事所替代。将这些默认故事放在那里也很有帮助,这样当你第一次启动 Storybook 服务器时,你就有东西可以看。现在让我们来做吧:

npm run storybook

几秒钟后,你应该会看到控制台输出,告诉你服务器运行的位置,这样你就可以在浏览器中打开它:

Storybook started on => http://localhost:9009/

当你在浏览器中查看 Storybook 应用程序时,你应该看到的是:

以下是你所看到的大致情况:

  • 左窗格是你找到所有故事的地方。这是显示两个默认 Storybook 故事的地方。

  • 主窗格是你将看到所选故事的渲染内容的地方。

  • 底部操作窗格是你将看到触发的操作被记录的地方。

让我们尝试在左窗格中选择一个不同的故事:

一旦你在左窗格中更改故事选择,你就会在主窗格中看到渲染的组件输出。在这种情况下,它是一个基本的按钮。

使用故事开发组件

Storybook 的价值在于,你不必设置应用程序就可以开始开发组件。或者,如果你已经在开发中有一个应用程序,你也不必想办法将正在进行中的组件集成到你的应用程序中。Storybook 是一个可以进行实验的工具。通过使用插件,你可以在担心将其集成到应用程序之前测试组件的几乎任何方面。

尝试使用 props 进行实验

也许,开始在 Storybook 中开发组件最直接的方法是开始尝试不同的属性值。为此,你只需要创建组件的不同故事,每个故事都有不同的属性值。

首先,让我们来看看你正在开发的组件:

import React from 'react'; 

const MyComponent = ({ title, content, titleStyle, contentStyle }) => ( 
  <section> 
    <heading> 
      <h2 style={titleStyle}>{title}</h2> 
    </heading> 
    <article style={contentStyle}>{content}</article> 
  </section> 
); 

export default MyComponent; 

这个组件并不复杂。它接受四个属性并呈现一些 HTML 标记。titlecontent属性的值都是简单的字符串。titleStylecontentStyle属性是分配给相应 HTML 元素的style属性的对象。

让我们开始为这个组件编写故事。假设使用了与前一节相同的方法:

  1. create-react-app用于创建 React 应用程序结构并安装依赖项

  2. getstorybook用于检查当前项目并添加适当的样板和依赖项

您可以打开src/stories/index.js并开始使用storiesOf()函数:

storiesOf('MyComponent Properties', module) 

这是启动 Storybook UI 时将出现在左窗格中的顶级主题。在此函数下方是您添加单独故事的位置。由于您目前对测试不同的属性值感兴趣,您添加的故事将用于反映不同的属性值:

.add('No Props', () => <MyComponent />) 

这将在 Storybook 的左窗格中添加一个名为No Props的故事。当您点击它时,您将看到在没有任何属性的情况下MyComponent在主窗格中的外观:

这里没有什么可看的,因为titlecontent属性都缺失。由于这两个值是唯一可见的呈现内容,所以没有内容可显示。让我们切换到下一个故事:

这次,选择了"Just "title" story",您可以看到不同的 React 组件输出呈现。正如故事标题所示,只有title属性被传递给了MyComponent。以下是此故事的代码:

.add('Just "title"', () => <MyComponent title="The Title" />) 

下一个故事只传递了content属性。以下是结果:

以下是仅传递content属性的代码:

.add('Just "Content"', () => <MyComponent content="The Content" />) 

下一个故事将titlecontent属性都传递给MyComponent

以下是在故事中呈现这两个属性的代码:

.add('Both "title" and "content"', () => ( 
  <MyComponent title="The Title" content="The Content" /> 
)) 

此时,您的组件有三个故事,并且它们已经被证明是有用的。例如,您已经看到了MyComponent在没有内容或没有标题时的外观。根据结果,您可能决定将这两个属性都设为必填,或者提供默认值。

接下来让我们移动到样式属性。首先,您将只传递titleStyle属性,就像这样:

.add('Just "titleStyle"', () => ( 
  <MyComponent 
    title="The Title" 
    content="The Content" 
    titleStyle={{ fontWeight: 'normal' }} 
  /> 
)) 

请注意,您还传递了titlecontent属性。这样,您就可以看到样式实际上如何影响MyComponent渲染的内容。这是结果:

接下来,您将只传递contentStyle属性:

.add('Just "contentStyle"', () => (
  <MyComponent 
    title="The Title" 
    content="The Content" 
    contentStyle={{ fontFamily: 'arial', fontSize: '1.2em' }} 
  /> 
)) 

这是它的样子:

最后,让我们将每个可能的属性传递给MyComponent

.add('Both "titleStyle" and "contentStyle"', () => ( 
  <MyComponent 
    title="The Title" 
    content="The Content"
    titleStyle={{ fontWeight: 'normal' }} 
    contentStyle={{ fontFamily: 'arial', fontSize: '1.2em' }} 
  /> 
)); 

这是MyComponent传递给它的每个属性的样子:

您刚刚为一个简单的组件创建了七个故事。使用 Storybook 开发服务器和 Storybook 用户界面,很容易在您为组件创建的不同故事之间切换,以便您可以轻松地看到它们之间的差异。这对于只处理属性的功能组件特别有效,就像您刚刚看到的那样。

这是您刚刚实现的所有故事,这样您就可以看到它们一起是什么样子的:

import React from 'react'; 
import { storiesOf } from '@storybook/react'; 
import MyComponent from '../MyComponent'; 

storiesOf('MyComponent Properties', module) 
  .add('No Props', () => <MyComponent />) 
  .add('Just "title"', () => <MyComponent title="The Title" />) 
  .add('Just "Content"', () => <MyComponent content="The Content" />) 
  .add('Both "title" and "content"', () => ( 
    <MyComponent title="The Title" content="The Content" /> 
  )) 
  .add('Just "titleStyle"', () => ( 
    <MyComponent 
      title="The Title" 
      content="The Content" 
      titleStyle={{ fontWeight: 'normal' }} 
    /> 
  )) 
  .add('Just "contentStyle"', () => ( 
    <MyComponent 
      title="The Title" 
      content="The Content" 
      contentStyle={{ fontFamily: 'arial', fontSize: '1.2em' }} 
    /> 
  )) 
  .add('Both "titleStyle" and "contentStyle"', () => ( 
    <MyComponent 
      title="The Title" 
      content="The Content" 
      titleStyle={{ fontWeight: 'normal' }} 
      contentStyle={{ fontFamily: 'arial', fontSize: '1.2em' }} 
    /> 
  )); 

为您的组件添加每个故事都有不同的属性配置的好处是,这就像为您的组件拍摄静态快照。然后,一旦您为组件有了几个故事,您可以在这些快照之间切换。另一方面,您可能还没有准备好以这种方式开始实现几个故事。如果您只是想玩弄属性值,有一个名为Knobs的 Storybook 插件。

旋钮插件允许您通过 Storybook UI 中的表单控件玩转 React 组件属性值。现在让我们试用一下这个插件。第一步是在您的项目中安装它:

npm install @storybook/addon-knobs --save-dev

然后,您必须告诉您的 Storybook 配置,您想要使用这个插件。将以下行添加到.storybook/addons.js

import '@storybook/addon-knobs/register'; 

现在,您可以将withKnobs装饰器导入到您的stories/index.js文件中,该装饰器用于告诉 Storybook 接下来的故事将使用控件来玩转属性值。您还需要导入各种类型的旋钮控件。这些都是简单的函数,当 Storybook UI 中的值发生变化时,它们将值传递给您的组件。

作为示例,让我们复制刚刚为MyComponent实现的相同故事情节。这一次,不再构建一堆静态故事,每个故事都设置特定的属性值,而是只添加一个使用 Knobs 附加组件来控制属性值的故事。以下是需要添加的导入内容:

import { withKnobs, text, object } from '@storybook/addon-knobs/react';

以下是故事的新上下文,以及一个使用旋钮控件来设置和更改 React 组件属性值的默认故事:

storiesOf('MyComponent Prop Knobs', module) 
  .addDecorator(withKnobs) 
  .add('default', () => ( 
    <MyComponent 
      title={text('Title', 'The Title')} 
      content={text('Content', 'The Content')} 
      titleStyle={object('Title Style', { fontWeight: 'normal' })} 
      contentStyle={object('Content Style', { 
        fontFamily: 'arial', 
        fontSize: '1.2em' 
      })} 
    />
  )); 

从 Knobs 附加组件中导入的两个函数text()object()用于设置旋钮控件的标签和默认值。例如,title使用text()函数并带有默认字符串值,而contentStyle使用object()函数并带有默认样式对象。

在 Storybook 用户界面中的效果如下:

如果你看底部窗格,你会看到一个 KNOBS 标签,旁边是一个 ACTION LOGGER 标签。根据你用来声明故事的 Knobs 附加组件中的函数,这些表单控件被创建。现在你可以继续玩弄组件属性值,并观察呈现的内容实时变化:

如果你在尝试旋钮字段时找到了喜欢的属性值,你可以将这些值硬编码到一个故事中。这就像是将一个组件配置标记为有效,以便以后可以返回到它。

尝试使用 actions

让我们将注意力转移到另一个附加组件——Actions。这个附加组件在你的 Storybook 中默认启用。Actions 的理念是,一旦你选择了一个故事,你就可以与主窗格中呈现的页面元素进行交互。Actions 为你提供了一种记录用户在 Storybook UI 中交互的机制。此外,Actions 还可以作为一个通用工具,帮助你监视数据在组件中的流动。

让我们从一个简单的按钮组件开始:

import React from 'react'; 

const MyButton = ({ onClick }) => ( 
  <button onClick={onClick}>My Button</button> 
); 

export default MyButton; 

MyButton组件

渲染一个<button>元素并为其分配一个onClick事件处理程序。实际上,处理程序是由MyComponent定义的;它作为一个 prop 传递进来。因此,让我们为这个组件创建一个故事,并传递一个onClick处理程序函数:

import React from 'react'; 
import { storiesOf } from '@storybook/react'; 
import { action } from '@storybook/addon-actions'; 
import MyButton from '../MyButton'; 

storiesOf('MyButton', module).add('clicks', () => ( 
  <MyButton onClick={action('my component clicked')} /> 
)); 

你看到了从@storybook/addon-actions导入的action()函数吗?这是一个高阶函数——一个返回另一个函数的函数。当你调用action('my component clicked')时,你会得到一个新的函数作为返回。这个新函数的行为有点像console.log(),你可以给它分配一个标签并记录任意值。不同之处在于,Storybook action() 插件函数创建的函数的输出会直接在 Storybook UI 的动作面板中呈现:

像往常一样,<button>元素被渲染在主面板中。你在动作面板中看到的内容是点击按钮三次的结果。每次点击的输出都是完全相同的,所以输出都被分组在你分配给处理函数的my component clicked标签下。

在上面的例子中,action()创建的事件处理函数对于作为你传递给组件的实际事件处理函数的替代是有用的。其他时候,你实际上需要事件处理行为来运行。例如,你有一个维护自己状态的受控表单字段,并且你想看看状态改变时会发生什么。

对于这样的情况,我发现最简单和最有效的方法是添加事件处理程序属性,即使你没有用它们做其他事情。让我们来看一个例子:

import React, { Component } from 'react'; 

class MyRangeInput extends Component { 
  static defaultProps = { 
    onChange() {}, 
    onRender() {} 
  }; 

  state = { value: 25 }; 

  onChange = ({ target: { value } }) => { 
    this.setState({ value }); 
    this.props.onChange(value); 
  }; 

  render() { 
    const { value } = this.state; 
    this.props.onRender(value); 
    return ( 
      <input 
        type="range" 
        min="1" 
        max="100" 
        value={value} 
        onChange={this.onChange} 
      /> 
    ); 
  } 
}
export default MyRangeInput; 

让我们首先看一下这个组件的defaultProps。默认情况下,这个组件有两个onChangeonRender的默认处理函数,它们什么也不做,所以如果它们没有设置,仍然可以被调用而不会发生任何事情。正如你可能已经猜到的,现在我们可以将action()处理程序传递给MyRangeInput组件。让我们试一试。现在你的stories/index.js看起来是这样的:

import React from 'react'; 
import { storiesOf } from '@storybook/react'; 
import { action } from '@storybook/addon-actions'; 
import MyButton from '../MyButton'; 
import MyRangeInput from '../MyRangeInput'; 

storiesOf('MyButton', module).add('clicks', () => ( 
  <MyButton onClick={action('my component clicked')} /> 
)); 

storiesOf('MyRangeInput', module).add('slides', () => ( 
  <MyRangeInput 
    onChange={action('range input changed')} 
    onRender={action('range input rendered')} 
  /> 
)); 

现在当你在 Storybook UI 中查看这个故事时,你应该会看到在滑动范围输入滑块时记录了很多动作。

当滑块移动时,你可以看到传递给组件的两个事件处理函数在组件渲染生命周期的不同阶段记录了值。最近的操作被记录在面板顶部,不像浏览器开发工具会在底部记录最近的值。

让我们再次回顾一下MyRangeInput代码。滑块移动时调用的第一个函数是更改处理程序:

onChange = ({ target: { value } }) => { 
  this.setState({ value }); 
  this.props.onChange(value); 
}; 

这个onChange()方法是MyRangeInput内部的。它是必需的,因为它渲染的<input>元素使用组件状态作为唯一的真相来源。在 React 术语中,这些被称为受控组件。首先,它使用事件参数的target.value属性设置值的状态。然后,它调用this.props.onChange(),将相同的值传递给它。这就是您可以在 Storybook UI 中看到事件值的方式。

请注意,这不是记录组件的更新状态的正确位置。当您调用setState()时,您必须假设您在函数中已经处理完状态,因为它并不总是同步更新。调用setState()只安排了状态更新和随后的重新渲染组件。

这里有一个可能会引起问题的例子。假设您不是记录事件参数中的值,而是在设置后记录值状态:

现在出现了一点问题。onChange处理程序记录了旧状态,而onRender处理程序记录了更新后的状态。如果您试图追踪事件值到呈现的输出,这种记录输出会非常令人困惑-事情不会对齐!永远不要在调用setState()后记录状态值。

如果调用空操作函数的想法让您感到不舒服,那么在 Storybook 中显示操作的这种方法可能不适合您。另一方面,您可能会发现,无需在组件内部编写大量调试代码,就可以在组件的生命周期的任何时刻记录基本上任何内容的实用程序。对于这种情况,操作是一种方法。

链接故事在一起

链接故事书附加组件允许您以与链接常规网页相同的方式将故事链接在一起。故事书有一个导航窗格,允许您从一个故事切换到另一个故事。这就像一个目录一样有用。但是当您在网上阅读内容时,通常会在一个段落中找到几个链接。想象一下,如果在网上移动的唯一方法是查看每个文档中的目录中的链接,那将是痛苦的。

在网页内容中嵌入链接有价值的原因,同样在 Storybook 输出中嵌入链接也是有价值的:它们提供了上下文。让我们看一个链接实际应用的例子。与 Actions 一样,当您在项目中运行getstorybook命令时,链接插件默认启用。这是您将为其编写故事的组件:

import React from 'react'; 

const MyComponent = ({ headingText, children }) => ( 
  <section> 
    <header> 
      <h1>{headingText}</h1> 
    </header> 
    <article>{children}</article> 
  </section> 
); 

MyComponent.defaultProps = { 
  headingText: 'Heading Text' 
}; 

export default MyComponent;

这个组件接受headingTextchildren属性。现在让我们编写一些相互关联的 Storybook 故事。以下是三个故事,它们在输出窗格中都相互关联:

import React from 'react'; 
import { storiesOf } from '@storybook/react'; 
import { linkTo } from '@storybook/addon-links'; 
import LinkTo from '@storybook/addon-links/react'; 
import MyComponent from '../MyComponent'; 

storiesOf('MyComponent', module) 
  .add('default', () => ( 
    <section> 
      <MyComponent /> 
      <p> 
        This is the default. You can also change the{' '} 
        <LinkTo story="heading text">heading text</LinkTo>. 
      </p> 
    </section> 
  )) 
  .add('heading text', () => ( 
    <section> 
      <MyComponent headingText="Changed Heading!" /> 
      <p> 
        This time, a custom <code>headingText</code> prop 
        changes the heading text. You can also pass{' '} 
        <LinkTo story="children">child elements</LinkTo> to{' '} 
        <code>MyComponent</code>. 
      </p> 
      <button onClick={linkTo('default')}>Default</button> 
    </section> 
  )) 
  .add('children', () => ( 
    <section> 
      <MyComponent> 
        <strong>Child Element</strong> 
      </MyComponent> 
      <p> 
        Passing a child component. You can also change the{' '} 
        <LinkTo story="headingText">heading text</LinkTo> of{' '} 
        <code>MyComponent</code>. 
      </p> 
      <button onClick={linkTo('default')}>Default</button> 
    </section> 
  )); 

让我们逐个讲解这些故事,这样您就可以看到它们是如何相互关联的。首先是默认故事:

您可以看到MyComponent的渲染内容,其中只包含标题文本,因为您没有传递任何子元素。此外,这只是默认的标题文本,因为在组件下方呈现的内容解释了这一点。这个内容方便地链接到一个呈现不同标题文本的故事:

再次,您可以看到使用自定义headingText prop 值呈现的组件,并在组件下方有一个链接到另一个故事的注释。在这种情况下,链接将用户带到一个将子元素传递给MyComponent的故事:

<LinkTo story="children">child elements</LinkTo>

还有一个按钮,它使用linkTo()函数构建一个回调函数,该函数将用户带到链接的故事,而不是渲染链接的<LinkTo>组件:

<button onClick={linkTo('default')}>Default</button>

这两种方法都需要一个 kind 参数,但在这里被省略了,因为我们是从MyComponent kind 内部进行链接。像这样链接故事的能力使您更接近将 Storybook 作为记录 React 组件的工具。

故事作为文档

Storybook 不仅仅是一个方便的地方,可以在开发过程中隔离您的组件。通过插件,它也是一个有效的记录组件的工具。随着应用程序的增长,拥有类似 Storybook 这样的工具变得更加具有吸引力。其他开发人员可能需要使用您创建的组件。如果他们可以查看 Storybook 故事来了解组件的各种用法,那不是很好吗?

这一章我们将看一下的最后一个插件叫做 Info。它以一个漂亮的格式提供关于组件的使用信息,除了标准的渲染组件输出之外。

让我们创建一些我们想要记录的组件。与其像本章节一直以来那样在stories/index.js中编写每个故事,不如把你的故事分开成更易消化的内容:

  • stories/MyButton.story.js

  • stories/MyList.story.js

你即将要实现的两个组件的故事将分别在它们自己的模块中,这样以后维护起来会更容易一些。为了支持这种新的文件布局,你还需要在.storybook/config.js中做一些改变。在这里,你需要分别引入你的两个故事模块:

import { configure } from '@storybook/react'; 

function loadStories() { 
  require('../src/stories/MyButton.story'); 
  require('../src/stories/MyList.story'); 
}
configure(loadStories, module); 

现在让我们来看看这些组件。首先是MyButton

import React from 'react'; 
import PropTypes from 'prop-types'; 

const MyButton = ({ onClick }) => ( 
  <button onClick={onClick}>My Button</button> 
); 

MyButton.propATypes = { 
  onClick: PropTypes.func 
}; 

export default MyButton; 

你可以看到MyButton定义了一个propTypes属性;很快你就会明白为什么这对于 Info Storybook 插件很重要。接下来,让我们看看MyList组件:

import React from 'react'; 
import PropTypes from 'prop-types'; 

const Empty = ({ items, children }) => 
  items.length === 0 ? children : null; 

const MyList = ({ items }) => ( 
  <section> 
    <Empty items={items}>No items found</Empty> 
    <ul>{items.map((v, i) => <li key={i}>{v}</li>)}</ul> 
  </section> 
); 

MyList.propTypes = { 
  items: PropTypes.array 
}; 

MyList.defaultProps = { 
  items: [] 
}; 
export default MyList; 

这个组件还定义了一个propTypes属性。它也定义了一个defaultProps属性,这样当items属性没有提供时,默认情况下它是一个空数组,这样调用map()仍然有效。

现在你已经准备好为这两个组件编写故事了。记住你还希望这些故事作为组件的主要文档来源,你将使用 Storybook 的 Info 插件为任何给定的故事提供更多的使用信息。让我们从MyButton.story.js开始:

import React from 'react'; 
import { storiesOf } from '@storybook/react'; 
import { withInfo } from '@storybook/addon-info'; 
import { action } from '@storybook/addon-actions'; 
import MyButton from '../MyButton'; 

storiesOf('MyButton', module) 
  .add( 
    'basic usage', 
    withInfo(' 
      Without passing any properties 
    ')(() => <MyButton />) 
  ) 
  .add( 
    'click handler', 
    withInfo(' 
      Passing an event handler function that's called when 
      the button is clicked 
    ')(() => <MyButton onClick={action('button clicked')} />) 
  ); 

在这里,你使用两个故事来记录MyButton,每个故事展示了组件的不同使用方式。第一个故事展示了基本用法,第二个故事展示了如何传递一个点击处理程序属性。这些故事的新添加是调用withInfo()。这个函数来自 Info Storybook 插件,你可以传递一些文本(支持 markdown),更详细地说明故事。换句话说,这是你记录组件特定用法的地方。

现在让我们先看看MyList.story.js,然后再看看 Info 插件在 Storybook UI 中的输出是什么样子的:

import React from 'react'; 
import { storiesOf } from '@storybook/react'; 
import { withInfo } from '@storybook/addon-info'; 
import MyList from '../MyList'; 

storiesOf('MyList', module) 
  .add( 
    'basic usage', 
    withInfo(' 
      Without passing any properties
    ')(() => <MyList />) 
  ) 
  .add( 
    'passing an array of items', 
    withInfo(' 
      Passing an array to the items property 
    ')(() => <MyList items={['first', 'second', 'third']} />) 
  ); 

这看起来很像为MyButton定义的故事——不同的文档和组件,相同的整体结构和方法。

让我们来看看MyButton的默认使用故事:

如预期的那样,按钮会在输出窗格中呈现,以便用户可以看到他们正在使用的内容。在输出窗格的右上角,有一个信息按钮。当您点击它时,您会看到通过在故事中调用withInfo()提供的所有额外信息:

这会显示有关故事和您正在记录的组件的各种信息。从上到下,这是它显示的内容:

  • 组件名称

  • 故事名称

  • 用法文档(作为withInfo()的参数提供)

  • 用于呈现组件的源

  • 组件可用的属性(从propTypes中读取)

Info 插件的好处在于它显示了用于呈现用户正在查看的输出的源,并且如果您将其提供为属性类型,则显示可用属性。这意味着试图理解和使用您的组件的人可以在您作为组件作者不费吹灰之力的情况下获得他们所需的信息。

让我们看看当MyList组件传递一个项目数组时的情况:

它呈现了通过属性获取的项目列表。现在让我们看看这个故事的信息:

通过查看有关此故事的信息,您可以一目了然地看到此组件接受的属性、它们的默认值以及用于生成示例的代码,所有这些都在一个地方。我还喜欢信息窗格默认情况下是隐藏的这一事实,这意味着您可以浏览故事并寻找所需的最终结果,然后再担心细节。

构建静态 Storybook 应用程序

如果您正在构建组件库,并希望将其作为开源项目或与组织内的各个团队共享的内容,您可以使用 Storybook 作为记录如何使用您的组件的工具。也就是说,您可能不希望运行 Storybook 服务器,或者只想托管 Storybook 文档。

在任何一种情况下,您都需要组件库的故事的静态构建。当您运行getstorybook命令时,Storybook 会为您提供此实用程序。

让我们继续使用前一节的示例,您在其中使用 Storybook 来记录两个组件的使用场景。要构建您的静态 Storybook 文档,您只需在项目目录中运行以下命令:

npm run build-storybook

你应该看到类似以下的输出:

info @storybook/react v3.3.13
info 
info => Loading custom addons config.
info => Using default webpack setup based on "Create React App".
info => Copying static files from: public
info Building storybook ...  

构建完成后,您将在项目文件夹中看到一个新的storybook-static目录。在其中,您将看到几个文件,包括由 Webpack 创建的静态 JavaScript 捆绑包和一个index.html文件,您可以从任何 Web 服务器提供,或者直接在 Web 浏览器中打开。

总结

本章是一个名为 Storybook 的工具的重点。Storybook 为 React 开发人员提供了一个沙盒环境,使他们可以轻松地独立开发 React 组件。当您唯一的环境是您正在工作的应用程序时,这可能会很困难。Storybook 提供了一定程度的开发隔离。

首先,您学会了如何安装全局 Storybook 命令行实用程序,以及如何使用此实用程序在您的create-react-app项目中设置 Storybook。接下来,您学会了如何编写展示组件不同视角的故事。

然后,您了解到 Storybook 功能的很大一部分来自于插件。您了解到 Actions 可以帮助记录日志,链接提供了超出默认范围的导航机制。您还学会了如何使用 Storybook 为 React 组件编写文档。我们在本章结束时看了一下构建静态 Storybook 内容。

在下一章中,您将探索 Web 浏览器中可用的 React 工具。

第八章:在浏览器中调试组件

如果您正在开发 React Web 应用程序,您需要基于浏览器的工具来帮助您从 React 开发人员的角度查看页面上发生了什么。当今的 Web 浏览器默认安装了令人惊叹的开发人员工具。如果您进行任何类型的 Web 开发,这些工具是必不可少的,因为它们公开了 DOM、样式、性能、网络请求等方面的真实情况。

使用 React,您仍然需要所有这些工具,但您需要的不仅仅是这些。React 的核心原则是在 JavaScript 组件中使用声明性标记。如果这种抽象在开发人员为其他所有事情依赖的 Web 浏览器工具中不存在,生活会比必要的更加困难。

在本章中,您将学到:

  • 安装 React Developer Tools 浏览器插件

  • 定位和选择 React 组件

  • 操作组件的 props 和 state

  • 分析组件性能

安装 React Developer Tools 插件

开始使用 React 工具的第一步是安装 React Developer Tools 浏览器扩展。在本章的示例中,我将使用 Chrome,因为这是一个流行的选择。React Developer Tools 也可以作为 Firefox 的扩展使用(addons.mozilla.org/en-US/firefox/addon/react-devtools/)。

要在 Chrome 中安装扩展,请访问chrome.google.com/webstore/category/extensions并搜索react developer tools

第一个结果应该是您想要的扩展。点击“添加到 Chrome”按钮进行安装:

Chrome 可能会警告您,它可以更改您访问的网站上的数据。别担心,该扩展仅在您访问 React 应用程序时才会激活:

点击“添加扩展”按钮后,扩展将被标记为已安装:

您已经准备好了!安装并启用 React Developer Tools Chrome 扩展后,您就可以开始检查页面上的 React 组件,就像您检查常规 DOM 元素一样。

在 React Developer Tools 中使用 React 元素

安装了 Chrome 中的 React 开发者工具后,你会在浏览器地址栏右侧看到一个按钮。我的按钮是这样的:

我这里有几个浏览器扩展的按钮。你可以看到最右边的是 React 开发者工具按钮,上面有 React 的标志。当按钮变灰时,意味着当前页面没有运行 React 应用。试着在其他页面点击一下这个按钮:

现在让我们使用create-react-app来创建一个新的应用程序,就像你在整本书中一直在做的那样:

create-react-app finding-and-selecting-components

现在启动开发服务器:

npm start

这应该会直接将你带到浏览器页面,你的 React 应用程序已经加载到一个新的标签页中。现在 React 开发者工具按钮应该看起来不一样了:

就是这样。因为你在运行 React 应用的页面上,React 开发者工具按钮会变亮,告诉你它已经可用。现在试着点击一下它:

太棒了!React 开发者工具可以检测到这是 React 库的开发版本。如果你不小心将 React 的开发版本部署到生产环境中,这可能会派上用场。诚然,如今使用诸如create-react-app之类的工具构建生产版本是更加困难的,因为你已经具备了构建生产版本的工具。

好的,现在你已经安装了 React 浏览器工具,除了检测应用程序使用的 React 构建类型,它还能为你做些什么呢?让我们在 Chrome 中打开开发者工具面板看看:

你可以看到开发者工具面板中通常的部分:元素、控制台等等。但是没有关于 React 的内容?我把开发者工具面板停靠在了浏览器窗口的右侧,所以你看不到每个部分。如果你看到的也是一样的情况,你只需要点击性能旁边的箭头按钮:

从菜单中选择 React,你将进入开发者工具面板的 React 部分。加载完成后,你应该会看到根 React 组件显示出来:

如果你在任何浏览器中使用过 DOM 检查工具,这个界面应该会让你感到熟悉。在左侧的主要部分,你有你的 React 元素树。这应该与你的 JSX 源代码非常相似。在这个树的右侧,你有当前选中元素的详细信息,在这种情况下是App,它没有定义任何属性。

如果你展开App,你会看到它的子 HTML 标记和其他 React 元素:

这是运行create-react-app后的默认源代码,因此在App元素下没有太多有趣的内容。要进一步探索 React 开发者工具,你需要引入一些更多的组件并在页面上渲染更多的 React 元素。

选择 React 元素

实际上有两种方法可以使用 React 开发者工具选择 React 元素。当你打开开发者工具窗格的 React 部分时,React 应用的根元素会自动被选中在元素树中。然而,你可以展开此元素以显示子元素并选择它们。

让我们组合一个简单的应用程序,帮助你使用 React 开发者工具探索页面上渲染的 React 元素。从顶层开始,这是App组件:

import React from 'react'; 
import MyContainer from './MyContainer'; 
import MyChild from './MyChild'; 

const App = () => ( 
  <MyContainer>
    <MyChild>child text</MyChild> 
  </MyContainer> 
); 

export default App; 

通过查看这个源代码,你可以一览在页面上渲染 React 元素的整体结构。接下来,让我们看看MyContainer组件:

import React from 'react'; 
import './MyContainer.css'; 

const MyContainer = ({ children }) => ( 
  <section className="MyContainer"> 
    <header> 
      <h1>Container</h1> 
    </header> 
    <article>{children}</article> 
  </section> 
); 

export default MyContainer; 

该组件渲染一些标题文本和传递给它的任何子元素。在这个应用程序中,你传递给它一个MyChild元素,所以让我们接下来看看这个组件:

import React from 'react'; 

const MyChild = ({ children }) => <p>{children}</p>; 

export default MyChild; 

现在当你运行npm start时,你应该会看到以下内容被渲染出来:

看起来不起眼,但你知道一切都按预期工作。该应用程序足够小,以至于你可以在 React 开发者工具窗格的树视图中看到每个 JSX 元素:

React 元素和其他元素类型之间有视觉区别,因此它们在树视图中更容易识别。例如,<MyContainer>元素是一种颜色,而<section>元素是另一种颜色。让我们选择<MyContainer>元素,看看会发生什么:

直到这一点,你只选择了<App>元素,所以关于这个元素没有什么可显示的——它没有 props 或状态。另一方面,<MyContainer>元素确实有要显示的属性。在这种情况下,它有一个children属性,因为<MyChild>元素被呈现为<MyContainer>的子元素。暂时不要担心所选元素右侧显示的具体内容——我们将在下一节详细介绍。

接下来,让我们激活选择工具。它是元素树上方的按钮,上面有一个目标图标。当你点击图标时,它会变成蓝色,让你知道它是激活的:

这个工具的想法是允许你点击页面上的元素,并在开发者工具窗格中选择相应的 React 组件。当工具激活时,当你移动到元素上时,元素会被突出显示,让你知道它们是什么:

在这里,鼠标指针位于页面上的<p>元素上,如小框所示。如果你点击元素,选择工具将在开发者工具窗格中选择适当的元素,然后停用自身。当选择时,<p>元素的样子如下:

即使这里选择了<p>元素,你看到的是由 React 元素渲染的 props——<MyChild>。如果你正在处理页面元素,而不确定哪个 React 元素呈现了它们,使用 React 开发者工具中的选择工具是快速找出的方法。

搜索 React 元素

当你的应用程序变得更大时,在 React 开发者工具面板中遍历页面或元素树上的元素效果不佳。你需要一种搜索 React 元素的方法。幸运的是,元素树上方有一个搜索框:

当你在搜索框中输入时,元素在下面的元素树中被过滤。正如你所看到的,匹配的文本也被高亮显示。搜索只匹配元素的名称,这意味着如果你需要从 100 个相同类型的元素中进行过滤,搜索将无法帮助你。然而,即使在这些情况下,搜索也可以删除应用中的其他所有内容,这样你就可以手动浏览一个较小的列表。

如果你选择了高亮搜索复选框,搜索将在主浏览器窗口中高亮显示 React 元素:

此页面上的两个 React 元素(<MyContainer><MyChild>)都被高亮显示,因为它们都符合搜索条件my。让我们看看当你搜索child时会发生什么:

这一次,你可以看到唯一匹配你搜索的 React 元素。它在主浏览器窗口和元素树中都被高亮显示。通过这样搜索,你可以确切地知道在屏幕上选择的元素是什么,当你在元素树中选择它时。

检查组件属性和状态

React 遵循声明式范式,因此有助于在浏览器中使用 React 开发者工具等工具,让你看到你的 JSX 标记。这只是你的 React 应用的静态方面——你声明 UI 的元素,让数据控制其余部分。使用相同的工具,你可以观察 props 和 state 在你的应用中流动。为了演示这一点,让我们创建一个简单的列表,一旦挂载就填满自己:

import React, { Component } from 'react'; 
import MyItem from './MyItem'; 

class MyList extends Component { 
  timer = null; 
  state = { items: [] };
  componentDidMount() { 
    this.timer = setInterval(() => { 
      if (this.state.items.length === 10) { 
        clearInterval(this.timer); 
        return; 
      } 

      this.setState(state => ({ 
        ...state, 
        items: [ 
          ...state.items, 
          { 
            label: 'Item ${state.items.length + 1}', 
            strikethrough: false 
          } 
        ] 
      })); 
    }, 3000); 
  } 

  componentWillUnmount() { 
    clearInterval(this.timer); 
  } 

  onItemClick = index => () => { 
    this.setState(state => ({ 
      ...state, 
      items: state.items.map( 
        (v, i) => 
          index === i 
            ? { 
                ...v, 
                strikethrough: !v.strikethrough 
              } 
            : v 
      ) 
    })); 
  }; 

  render() { 
    return ( 
      <ul> 
        {this.state.items.map((v, i) => ( 
          <MyItem 
            key={i} 
            label={v.label} 
            strikethrough={v.strikethrough} 
            onClick={this.onItemClick(i)}

          /> 
        ))} 
      </ul> 
    ); 
  } 
} 

export default MyList; 

以下是这个组件所做的一切的大致分解:

  • timerstate: 这些属性被初始化。这个组件的主要状态是一个items数组。

  • componentDidMount(): 设置一个间隔计时器,每三秒向items数组添加一个新值。一旦有十个项目,间隔就会被清除。

  • componentWillUnmount(): 确保timer属性被强制清除。

  • onItemClick(): 接受一个index参数,并返回一个索引的事件处理程序。当调用处理程序时,strikethrough状态将被切换。

  • render(): 渲染一个<ul>列表,包含<MyItem>元素,传递相关的 props。

这里的想法是慢慢地建立列表,这样你就可以在浏览器工具中观察状态变化发生。然后,通过MyList元素,你可以观察传递给它的 props。这个组件看起来是这样的:

import React from 'react'; 

const MyItem = ({ label, strikethrough, onClick }) => ( 
  <li 
    style={{ 
      cursor: 'pointer', 
      textDecoration: strikethrough ? 'line-through' : 'none' 
    }} 
    onClick={onClick} 
  > 
    {label} 
  </li> 
); 

export default MyItem; 

这是一个简单的列表项。textDecoration样式根据strikethrough prop 的值而改变。当这个值为 true 时,文本将显示为被划掉的样子。

让我们在浏览器中加载这个应用程序,并观察MyList的状态随着间隔处理程序的调用而改变。应用程序加载后,请确保您已经打开并准备好使用 React Developer Tools 窗格。然后,展开<App>元素并选择<MyList>。您将在右侧看到元素的状态:

左侧呈现的内容与所选<MyList>元素的右侧显示的状态相匹配。有一个包含 5 个项目的数组,并且页面上呈现了 5 个项目的列表。这个例子使用间隔计时器随着时间更新状态(直到达到 10 个项目)。如果您仔细观察,您会发现右侧的状态值随着新的列表项的添加而与呈现的内容同步变化。您还可以展开状态中的单个项目以查看它们的值:

如果您展开<MyList>元素,您将看到所有<MyItem>元素作为items数组状态添加到结果中呈现的结果。从那里,您可以选择<MyItem>元素来查看其 props 和状态。在这个例子中,<MyItem>元素只有 props,没有状态:

您可以在左侧的树视图中看到传递给给定元素的 props。与您可以在右侧看到的值相比,这有点难以阅读,右侧显示了所选元素的 prop 值。以下 props 被传递给<MyItem>

  • label:要呈现的文本

  • onClick:当点击项目时调用的函数

  • strikethrough:如果为true,则文本将以strikethrough样式呈现

您可以观察属性值随着元素重新呈现而改变。在这个应用程序的情况下,当您点击列表项时,处理函数将更改<MyList>元素中项目列表的状态。具体来说,被点击的项目的索引将切换其strikethrough值。这将导致<MyItem>元素重新呈现自身以新的 prop 值。如果您在开发者工具窗格中选择要点击的元素,您可以随时关注 prop 的变化:

第一项的文本以strikethrough样式呈现。这是因为strikethrough属性为true。如果你仔细看开发者工具窗格中元素树右侧的属性值,你会看到当它们改变时会闪烁黄色,这是一个方便调试组件的视觉提示。

操作元素状态值

React 开发者工具允许你检查所选元素的当前状态。你也可以监视状态的变化,就像前面演示的那样,你可以设置一个间隔定时器来随时间改变元素的状态。元素的状态也可以以有限的方式进行操作。

对于下一个示例,让我们修改MyList组件,移除间隔定时器并在构造时简单地填充状态:

import React, { Component } from 'react'; 
import MyItem from './MyItem';
class MyList extends Component { 
  timer = null; 
  state = { 
    items: new Array(10).fill(null).map((v, i) => ({ 
      label: 'Item ${i + 1}', 
      strikethrough: false
    })) 
  }; 

  onItemClick = index => () => { 
    this.setState(state => ({ 
      ...state, 
      items: state.items.map( 
        (v, i) => 
          index === i 
            ? { 
                ...v, 
                strikethrough: !v.strikethrough 
              } 
            : v 
      ) 
    })); 
  }; 

  render() { 
    return ( 
      <ul> 
        {this.state.items.map((v, i) => ( 
          <MyItem 
            key={i} 
            label={v.label} 
            strikethrough={v.strikethrough} 
            onClick={this.onItemClick(i)} 
          /> 
        ))} 
      </ul> 
    ); 
  } 
} 

export default MyList; 

现在当你运行这个应用时,你会立即看到 10 个项目被渲染出来。除此之外,没有其他改变。你仍然可以点击单个项目来切换它们的strikethrough状态。一旦你运行了这个应用,请确保 React 开发者工具浏览器窗格是打开的,这样你就可以选择<MyList>元素:

在右侧,你可以看到所选元素的状态。你实际上可以展开items数组中的一个对象并改变它的属性值:

items数组状态中第一个对象的labelstrikethrough属性被改变。这导致了<MyList>和第一个<MyItem>元素被重新渲染。如预期的那样,改变的状态在左侧的渲染输出中反映出来。当你需要排除组件没有按照预期更新渲染内容时,这是很方便的。你不需要在组件内部编写测试代码,只需直接进入浏览器中渲染元素的状态并在其中进行操作。

使用 React 开发者工具编辑状态的一个注意事项是,你不能向集合中添加或删除项目。例如,我不能向items数组中添加新项目,也不能向数组中的对象添加新属性。对此,你需要在代码中编排你的状态,就像在之前的示例中所做的那样。

组件性能分析

通过 React 开发者工具,更容易地对 React 组件的性能进行分析。它更容易发现导致元素重新渲染的更新,当实际上不需要重新渲染时。它还更容易收集给定组件在其生命周期内花费的 CPU 时间以及花费在哪里。

尽管 React 开发者工具不包括任何内存分析工具,但我们将看看如何使用现有的内存开发者工具来专门为 React 元素进行分析。

删除协调工作

当渲染 React 元素时,会发生协调。它首先计算将呈现元素的当前状态和 props 的虚拟 DOM 树。然后,将该树与元素的现有树进行比较,假设该树已经至少渲染过一次。React 这样做的原因是因为在与 DOM 交互之前,在 JavaScript 中协调这样的更改更具性能。与简单的 JavaScript 代码相比,DOM 交互相对昂贵。此外,React 协调器还有一些常见情况的启发式方法。

React 为您处理所有这些-您只需要考虑编写声明性的 React 组件。这并不意味着您永远不会遇到性能问题。仅仅因为 JavaScript 中的协调通常比直接操作 DOM 表现更好,并不意味着它是廉价的。因此,让我们组合一个应用程序,突出显示协调的一些潜在问题,然后让我们借助 React 开发者工具来解决这些问题。

我们将创建一个应用程序,用于呈现每个组的组和成员。它将具有更改组数和每个组成员数的控件。最后,每个呈现的组将有一个添加新组的按钮。让我们从index.js开始:

import React from 'react'; 
import ReactDOM from 'react-dom'; 
import './index.css'; 
import App from './App'; 
import registerServiceWorker from './registerServiceWorker'; 

const update = () => { 
  ReactDOM.render(<App />, document.getElementById('root')); 
}; 

setInterval(update, 5000); 
update(); 

registerServiceWorker(); 

这几乎就像你从create-react-app看到的任何index.js。不同之处在于有一个使用setInterval()调用的update()函数。你不会随机地在你的应用程序中抛出一个每五秒重新渲染一次应用程序的间隔计时器。我在这里添加这个只是为了简单地说明重复重新渲染以及这样做的协调后果。在真实的应用程序中,你可能会发现类似的行为,其中你更新组件以保持它们的状态新鲜-这是这种行为的近似。

接下来是主要的App组件。这是应用程序状态的所在地,也是大部分功能所在地。让我们先看一下整个文件,然后我会为你解释:

import React, { Component } from 'react'; 
import './App.css'; 
import Group from './Group';

class App extends Component { 
  state = { 
    groupCount: 10, 
    memberCount: 20, 
    groups: [] 
  }; 

  refreshGroups = (groups, members) => { 
    this.setState(state => { 
      const groupCount = 
        groups === undefined ? state.groupCount : groups; 
      const memberCount = 
        members === undefined ? state.memberCount : members; 
      return { 
        ...state, 
        groupCount, 
        memberCount, 
        groups: new Array(groupCount).fill(null).map((g, gi) => ({ 
          name: 'Group ${gi + 1}', 
          members: new Array(memberCount) 
            .fill(null) 
            .map((m, mi) => ({ name: 'Member ${mi + 1}' })) 
        })) 
      }; 
    }); 
  }; 

  onGroupCountChange = ({ target: { value } }) => { 
    // The + makes value a number. 
    this.refreshGroups(+value); 
  }; 

  onMemberCountChange = ({ target: { value } }) => { 
    this.refreshGroups(undefined, +value); 
  }; 

  onAddMemberClick = i => () => { 
    this.setState(state => ({ 
      ...state, 
      groups: state.groups.map( 
        (v, gi) => 
          i === gi 
            ? { 
                ...v, 
                members: v.members.concat({ 
                  name: 'Member ${v.members.length + 1}' 
                }) 
              }
            : v 
      ) 
    })); 
  }; 

  componentWillMount() { 
    this.refreshGroups(); 
  } 

  render() { 
    return ( 
      <section className="App"> 
        <div className="Field"> 
          <label htmlFor="groups">Groups</label> 
          <input 
            id="groups" 
            type="range" 
            value={this.state.groupCount} 
            min="1" 
            max="20" 
            onChange={this.onGroupCountChange} 
          /> 
        </div> 
        <div className="Field"> 
          <label htmlFor="members">Members</label> 
          <input 
            id="members" 
            type="range" 
            value={this.state.memberCount} 
            min="1" 
            max="20" 
            onChange={this.onMemberCountChange} 
          /> 
        </div> 
        {this.state.groups.map((g, i) => ( 
          <Group 
            key={i} 
            name={g.name} 
            members={g.members} 
            onAddMemberClick={this.onAddMemberClick(i)} 
          /> 
        ))} 
      </section> 
    ); 
  } 
} 

export default App; 

让我们从初始状态开始:

state = { 
  groupCount: 10, 
  memberCount: 20, 
  groups: [] 
}; 

这个组件管理的状态如下:

  • groupCount: 要渲染的组数

  • memberCount: 每个组中要渲染的成员数量

  • groups: 一个组对象数组

这些值都存储为状态,因为它们可以被改变。接下来,让我们看一下refreshGroups()函数:

refreshGroups = (groups, members) => { 
  this.setState(state => { 
    const groupCount = 
      groups === undefined ? state.groupCount : groups; 
    const memberCount = 
      members === undefined ? state.memberCount : members; 
    return { 
      ...state, 
      groupCount, 
      memberCount, 
      groups: new Array(groupCount).fill(null).map((g, gi) => ({ 
        name: 'Group ${gi + 1}', 
        members: new Array(memberCount) 
          .fill(null) 
          .map((m, mi) => ({ name: 'Member ${mi + 1}' })) 
      })) 
    }; 
  }); 
}; 

在这里不要太担心具体的实现细节。这个函数的目的是在组数和组成员数改变时填充状态。例如,一旦调用,你会有类似以下的状态:

{ 
  groupCount: 10, 
  memberCount: 20, 
  groups: [ 
    {
      Name: 'Group 1', 
      Members: [ { name: 'Member 1' }, { name: 'Member 2' } ] 
    }, 
    { 
      Name: 'Group 2', 
      Members: [ { name: 'Member 1' }, { name: 'Member 2' } ] 
    } 
  ] 
} 

之所以将这个定义为自己的函数,是因为你将在几个地方调用它。例如,在componentWillMount()中调用它,以便组件在首次渲染之前具有初始状态。接下来,让我们看一下事件处理程序函数:

onGroupCountChange = ({ target: { value } }) => { 
  this.refreshGroups(+value); 
}; 

onMemberCountChange = ({ target: { value } }) => { 
  this.refreshGroups(undefined, +value); 
}; 

onAddMemberClick = i => () => { 
  this.setState(state => ({ 
    ...state, 
    groups: state.groups.map( 
      (v, gi) => 
        i === gi 
          ? { 
              ...v, 
              members: v.members.concat({ 
                name: 'Member ${v.members.length + 1}' 
              }) 
            } 
          : v 
    ) 
  })); 
}; 

这些做以下事情:

  • onGroupCountChange(): 通过使用新的组数调用refreshGroups()来更新组状态

  • onMemberCountChange(): 使用新的成员数量更新组状态中的每个成员对象。

  • onAddMemberClick(): 通过在给定索引处添加新成员对象来更新组状态

最后,让我们看一下这个组件渲染的 JSX:

render() { 
  return ( 
    <section className="App"> 
      <div className="Field"> 
        <label htmlFor="groups">Groups</label> 
        <input 
          id="groups" 
          type="range" 
          value={this.state.groupCount} 
          min="1" 
          max="20" 
          onChange={this.onGroupCountChange} 
        /> 
      </div> 
      <div className="Field"> 
        <label htmlFor="members">Members</label> 
        <input 
          id="members" 
          type="range" 
          value={this.state.memberCount} 
          min="1" 
          max="20" 
          onChange={this.onMemberCountChange} 
        /> 
      </div> 
      {this.state.groups.map((g, i) => ( 
        <Group 
          key={i} 
          name={g.name} 
          members={g.members} 
          onAddMemberClick={this.onAddMemberClick(i)} 
        /> 
      ))} 
    </section> 
  ); 
} 

这个组件渲染两个滑块控件:一个控制组数,一个控制每个组中的成员数。接下来,渲染组列表。为此,有一个Group组件,看起来像这样:

import React from 'react';
const Group = ({ name, members, onAddMemberClick }) => ( 
  <section> 
    <h4>{name}</h4> 
    <button onClick={onAddMemberClick}>Add Member</button> 
    <ul>{members.map((m, i) => <li key={i}>{m.name}</li>)}</ul> 
  </section> 
); 

export default Group; 

这将渲染组的名称,然后是一个添加新成员的按钮,然后是成员列表。当你首次加载页面时,你会看到以下输出:

这里只显示了部分输出——在第 1 组中有更多成员,后面还有更多组,使用相同的模式渲染。在使用页面上的任何控件之前,打开 React 开发者工具。然后,查找“高亮更新”复选框:

一旦您勾选了这个框,当它们的状态更新时,您渲染的元素将在视觉上得到增强。请记住,您设置了App组件每五秒重新渲染一次。每次调用setState()时,输出看起来像这样:

蓝色边框会在刚刚更新的元素周围闪烁一下。虽然您在这个截图中看不到<App>渲染的所有内容,但蓝色边框围绕所有<Group>元素,因为它表示<App>组件刚刚更新。如果您观察一会儿屏幕,您会注意到蓝色边框每 5 秒出现一次。这表明即使您的元素状态没有改变,它仍在执行协调。它正在遍历可能有数百或数千个树节点,查找任何差异并进行适当的 DOM 更新。

虽然您在这个应用程序中看不到差异,但更复杂的 React 应用程序的累积效果可能会成为问题。在这种特定情况下,由于更新频率,这是一个潜在的问题。

让我们对App进行一个补充,看看是否有一种快捷方式可以执行完全的协调:

shouldComponentUpdate(props, state) { 
  return ( 
    this.state.groupCount !== state.groupCount || 
    this.state.memberCount !== state.memberCount 
  ); 
} 

如果一个 React 组件类有shouldComponentUpdate()方法并且返回 false,就会完全避免协调,不会进行重新渲染。通过确保勾选了高亮更新复选框,您可以立即在浏览器中看到变化。如果您坐下来观察一会儿,您会发现没有更多的蓝色边框出现。

更新边框有不同的颜色。您看到的蓝色代表不经常的更新。这取决于更新的频率,可以一直到红色。例如,如果您来回快速滑动组或成员滑块,您应该能够产生红色边框。

然而,请注意,您并不总是能够避免协调。重要的是要对此进行宏观优化。例如,您刚刚添加到App组件的解决方案解决了在明显不必要的情况下重新渲染具有大量子元素的巨大组件。与微观优化Group组件相比,这是有价值的——它足够小,以至于在这里避免协调并不能节省太多。

你的目标应该是保持高水平,并保持shouldComponentUpdate()简单。这是 bug 进入组件的入口点。事实上,您已经引入了一个 bug。尝试点击一个组的“添加成员”按钮,它们不再起作用。这是因为您在shouldComponentUpdate()中使用的标准只考虑了groupCountmemberCount状态。它没有考虑将新成员添加到组中。

要解决这个问题,您必须使用与shouldComponentUpdate()中的groupCountmemberState状态相同的方法。如果所有组的成员总数发生变化,那么您就知道您的应用程序需要重新渲染。让我们在shouldComponentUpdate()中进行这个更改:

shouldComponentUpdate(props, state) { 
  const totalMembers = ({ groups }) => 
    groups 
      .map(group => group.members.length) 
      .reduce((result, m) => result + m); 

  return ( 
    this.state.groupCount !== state.groupCount || 
    this.state.memberCount !== state.memberCount || 
    totalMembers(this.state) !== totalMembers(state) 
  ); 
} 

totalMembers()函数以组件状态作为参数,并返回组成员的总数。使用这个函数,你可以添加另一个条件,使用这个函数来比较当前状态中的成员数量和新状态中的成员数量:

totalMembers(this.state) !== totalMembers(state) 

现在,如果您再次尝试点击“添加成员”按钮,它将如预期般添加成员,因为组件可以检测到状态变化。再次,您需要权衡计算成员数组长度并比较两者的成本,以及在 React DOM 树中执行协调的成本。

查找 CPU 密集型组件

shouldComponentUpdate()生命周期方法可以实现组件性能的宏观优化。如果明显不需要重新渲染元素,那么让我们完全绕过协调过程。其他时候,协调是无法避免的——元素状态经常发生变化,这些变化需要在 DOM 中反映出来供用户看到。

React 16 的开发版本内置了一些方便的性能工具。它调用相关的浏览器开发工具 API,以记录相关指标,同时记录性能概要。请注意,这与您之前安装的 React 开发者工具浏览器扩展无关;这只是 React 在开发模式下与浏览器交互。

目标是生成 React 特定的时间数据,这样您就不必将其他 20 个浏览器性能指标心算一遍,然后弄清楚它们的含义。一切都为您准备好了。

为了演示这个功能,您可以使用上一节中的相同代码,只需进行一些小的调整。首先,让我们在每个组中提供更多成员:

state = { 
  groupCount: 1, 
  memberCount: 200, 
  groups: [] 
}; 

我们增加这个数字的原因是,当您操作控件时,应用的性能会下降——您希望使用性能开发工具来捕获这种性能下降。接下来,让我们增加成员字段的最大滑块值:

<div className="Field"> 
  <label htmlFor="members">Members</label> 
  <input 
    id="members" 
    type="range" 
    value={this.state.memberCount}
    min="1" 
    max="200" 
    onChange={this.onMemberCountChange} 
  /> 
</div> 

就是这样。现在当您在浏览器中查看此应用时,它应该是这样的:

在更改任何这些滑块数值之前,请确保您的开发者工具窗格已打开,并且已选择“性能”选项卡:

接下来,点击左侧的圆圈图标开始记录性能概要。按钮将变为红色,您会看到一个状态对话框出现,表示已开始分析:

现在您正在记录,将“组”滑块滑动到最右边。当您接近右边时,您可能会注意到 UI 有些延迟,这是件好事,因为这正是您想要设计的。一旦滑块滑到最右边,点击开始录制时点击的红色圆圈来停止录制。您应该会看到类似以下的内容:

我扩大了左侧的用户定时标签,因为这里显示了所有 React 特定的时间。在这个图表中,时间从左到右流动。某件事情越宽,它花费的时间就越长。您可能会注意到,当您接近滑块的右侧时,性能会变差(这也可能与您在滑块控制中注意到的延迟相吻合)。

因此,让我们探索一下这些数据的含义。我们将查看最右边的数据,因为这里性能真的下降了:

这个标签告诉您,React 树协调需要 78 毫秒来执行。并不是非常慢,但足够慢以至于对用户体验产生了实质性影响。当您逐个查看这些标签时,您应该能更好地了解为什么协调过程需要这么长时间。让我们看下一个:

这很有趣:App [update] 标签告诉你,在 App 组件中的状态更新花费了 78 毫秒。在这一点上,你知道 App 中的状态更新导致了 React 协调过程花费了 78 毫秒。让我们跳到下一个级别。在这个级别,有两种颜色。让我们看看黄色代表什么:

通过悬停在黄色的片段上,你可以看到 Group [update] 花费了 7.7 毫秒来更新一个 Group 组件。这可能是一个微不足道的时间,可能无法以任何有意义的方式改进。然而,看一下代表 Group 更新的黄色片段的数量。所有这些单位数时间片段加起来占据了整体协调时间的相当大一部分。最后,让我们看看棕色:

这个标签,Group [mount],表示安装一个新的 Group 组件花费了 6.5 毫秒。再一次,这是一个小数字,但有几个片段。

在这一点上,你已经一直深入到组件层次结构的底部,以检查是什么导致了你的性能问题。这里的要点是什么?你确定了 React 执行协调所花费的大部分时间发生在 Group 组件中。每次渲染 Group 组件时,只需要几毫秒的时间来完成,但有很多组。

感谢浏览器开发者工具中的性能图表,现在你知道改变你的代码并不会有所收获——你不会以任何有意义的方式改善单位数毫秒的时间。在这个应用程序中,解决你在将滑块向右移动时感到的延迟的唯一方法是以某种方式减少在页面上呈现的元素数量。另一方面,你可能会注意到一些 React 性能指标有 50 毫秒,或在某些情况下有数百毫秒。你可以轻松修复你的代码以提供更好的用户体验。关键是,如果没有像你在本节中使用过的性能开发工具,你将永远不知道实际上有什么会产生差异。

当您作为用户与应用程序交互时,通常会感觉到性能问题。但验证组件是否存在性能问题的另一种方法是查看显示在 React 指标上方的帧速率,呈绿色。它显示了在相应的 React 代码下渲染帧所花费的时间。您刚刚构建的示例在滑块位于左侧时以每秒 40 帧开始,但当滑块移至最右侧时以每秒 10 帧结束。

摘要

在本章中,您了解了可以直接通过 Web 浏览器使用的 React 工具。这里的首选工具是一个名为 React Developer Tools 的 Chrome/Firefox 扩展程序。该扩展程序为浏览器的原生开发者工具添加了特定于 React 的功能。安装了该扩展程序后,您学会了如何选择 React 元素以及如何按标签名称搜索 React 元素。

接下来,您查看了 React Developer Tools 中所选 React 组件的属性和状态值。这些值会自动更新,因为它们被应用程序更改。然后,您学会了如何在浏览器中直接操作元素状态。这里的限制是您无法向集合中添加或删除值。

最后,您学会了如何在浏览器中对 React 组件的性能进行分析。这不是 React Developer Tools 的功能,而是 React 16 的开发版本自动执行的。使用这样的分析可以确保在遇到性能问题时您正在解决正确的问题。本章中您查看的示例表明,代码实际上并没有问题,问题在于一次在屏幕上渲染了太多的元素。

在下一章中,您将构建一个基于 Redux 的 React 应用程序,并使用 Redux DevTools 来监视应用程序的状态。

第九章:使用 Redux 对应用程序状态进行仪器化

Redux 是在 React 应用程序中管理状态的事实标准库。单独使用 React 应用程序可以使用setState()来管理其组件的状态。这种方法的挑战在于没有控制状态更改的顺序(考虑异步调用,如 HTTP 请求)。

本章的目的不是向您介绍 Redux——有很多资源可以做到这一点,包括 Packt 图书和官方 Redux 文档。因此,如果您对 Redux 还不熟悉,您可能希望在继续之前花 30 分钟熟悉 Redux 的基础知识。本章的重点是您可以在 Web 浏览器中启用的工具。我认为 Redux 的重要价值之一来自 Redux DevTools 浏览器扩展。

在本章中,您将学到:

  • 如何构建一个基本的 Redux 应用程序(而不深入研究 Redux 概念)

  • 安装 Redux DevTools Chrome 扩展

  • 选择 Redux 操作并检查其内容

  • 如何使用时光旅行调试技术

  • 手动触发操作以更改状态

  • 导出应用程序状态并稍后导入

构建 Redux 应用程序

本章中您将使用的示例应用程序是一个基本的图书管理器。目标是拥有足够的功能来演示不同的 Redux 操作,但又足够简单,以便您可以学习 Redux DevTools 而不感到不知所措。

此应用程序的高级功能如下:

  • 呈现您想要跟踪的书籍列表。每本书显示书籍的标题、作者和封面图片。

  • 允许用户通过在文本输入中键入来筛选列表。

  • 用户可以创建新书籍。

  • 用户可以选择一本书查看更多详情。

  • 书籍可以被删除。

在您深入研究 Redux DevTools 扩展之前,让我们花几分钟来了解这个应用程序的实现方式。

App 组件和状态

App组件是图书管理应用程序的外壳。您可以将App视为呈现的每个其他组件的容器。它负责呈现左侧导航,并定义应用程序的路由,以便在用户移动时挂载和卸载适当的组件。以下是App的实现方式:

import React, { Component } from 'react';
import { connect } from 'react-redux';
import {
  BrowserRouter as Router,
  Route,
  NavLink
} from 'react-router-dom';
import logo from './logo.svg';
import './App.css';
import Home from './Home';
import NewBook from './NewBook';
import BookDetails from './BookDetails';

class App extends Component {
  render() {
    const { title } = this.props;

    return (
      <Router>
        <div className="App">
          <header className="App-header">
            <img src={logo} className="App-logo" alt="logo" />
            <h1 className="App-title">{title}</h1>
          </header>
          <section className="Layout">
            <nav>
              <NavLink
                exact
                to="/"
                activeStyle={{ fontWeight: 'bold' }}
              >
                Home
              </NavLink>
              <NavLink to="/new" activeStyle={{ fontWeight: 'bold' }}>
                New Book
              </NavLink>
            </nav>
            <section>
              <Route exact path="/" component={Home} />
              <Route exact path="/new" component={NewBook} />
              <Route
                exact
                path="/book/:title"
                component={BookDetails}
              />
            </section>
          </section>
        </div>
      </Router>
    );
  }
}

const mapState = state => state.app;
const mapDispatch = dispatch => ({});
export default connect(mapState, mapDispatch)(App);

react-redux包中的connect()函数用于将App组件连接到 Redux 存储(应用程序状态所在的地方)。mapState()mapDispatch()函数分别向App组件添加 props——状态值和动作分发函数。到目前为止,App组件只有一个状态值和没有动作分发函数。

要深入了解如何将 React 组件连接到 Redux 存储,请查看此页面:redux.js.org/basics/usage-with-react

接下来让我们来看一下app()reducer 函数:

const initialState = {
  title: 'Book Manager'
};

const app = (state = initialState, action) => {
  switch (action.type) {
    default:
      return state;
  }
};

export default app;

App使用的状态并不多,只有一个title。实际上,这个title永远不会改变。reducer 函数只是简单地返回传递给它的状态。在这里实际上不需要switch语句,因为没有需要处理的动作。然而,title状态很可能会根据动作而改变——只是您还不知道。设置这样的 reducer 函数从来不是坏主意,这样您就可以将组件连接到 Redux 存储,一旦确定应该引起状态改变的动作,就有一个准备好处理它的 reducer 函数。

主页组件和状态

Home组件是作为App的子组件首先呈现的组件。Home的路由是/,这是过滤文本输入和书籍列表呈现的地方。当用户首次加载应用程序时,用户将看到以下内容:

在左边,您有由App组件呈现的两个导航链接。在这些链接的右侧,您有过滤文本输入,然后是书籍列表——React 书籍。现在,让我们来看一下Home组件的实现:

import React, { Component } from 'react';
import { connect } from 'react-redux';

import { fetchBooks } from '../api';
import Book from './Book';
import Loading from './Loading';
import './Home.css';

class Home extends Component {
  componentWillMount() {
    this.props.fetchBooks();
  }

  render() {
    const {
      loading,
      books,
      filterValue,
      onFilterChange
    } = this.props;
    return (
      <Loading loading={loading}>
        <section>
          <input
            placeholder="Filter"
            onChange={onFilterChange}
            value={filterValue}
          />
        </section>
        <section className="Books">
          {books
            .filter(
              book =>
                filterValue.length === 0 ||
                new RegExp(filterValue, 'gi').test(book.title)
            )
            .map(book => (
              <Book
                key={book.title}
                title={book.title}
                author={book.author}
                imgURL={book.imgURL}
              />
            ))}
        </section>
      </Loading>
    );
  }
}

const mapState = state => state.home;
const mapDispatch = dispatch => ({
  fetchBooks() {
    dispatch({ type: 'FETCHING_BOOKS' });
    fetchBooks().then(books => {
      dispatch({
        type: 'FETCHED_BOOKS',
        books
      });
    });
  },

  onFilterChange({ target: { value } }) {
    dispatch({ type: 'SET_FILTER_VALUE', filterValue: value });
  }
});

export default connect(mapState, mapDispatch)(Home);

这里需要注意的关键事项:

  • componentWillMount()调用fetchBooks()从 API 加载书籍数据

  • Loading组件用于在获取书籍时显示加载文本

  • Home组件定义了分发动作的函数,这是您希望使用 Redux DevTools 查看的内容

  • 书籍和过滤数据来自 Redux 存储

这是处理动作并维护与该组件相关状态的 reducer 函数:

const initialState = {
  loading: false,
  books: [],
  filterValue: ''
};

const home = (state = initialState, action) => {
  switch (action.type) {
    case 'FETCHING_BOOKS':
      return {
        ...state,
        loading: true
      };
    case 'FETCHED_BOOKS':
      return {
        ...state,
        loading: false,
        books: action.books
      };

    case 'SET_FILTER_VALUE':
      return {
        ...state,
        filterValue: action.filterValue
      };

    default:
      return state;
  }
};

export default home;

如果你看initialState对象,你会看到Home依赖于一个books数组,一个filterValue字符串和一个loading布尔值。switch语句中的每个动作情况都会改变这个状态的一部分。虽然通过查看这个 reducer 代码可能有点棘手,但结合 Redux 浏览器工具,情况变得清晰起来,因为你可以将在应用程序中看到的内容映射回这段代码。

NewBook 组件和状态

在左侧导航栏的主页链接下面,有一个 NewBook 链接。点击这个链接将带你到一个允许你创建新书的表单。现在让我们来看一下NewBook组件的源码:

import React, { Component } from 'react';
import { connect } from 'react-redux';

import { createBook } from '../api';
import './NewBook.css';

class NewBook extends Component {
  render() {
    const {
      title,
      author,
      imgURL,
      controlsDisabled,
      onTitleChange,
      onAuthorChange,
      onImageURLChange,
      onCreateBook
    } = this.props;

    return (
      <section className="NewBook">
        <label>
          Title:
          <input
            autoFocus
            onChange={onTitleChange}
            value={title}
            disabled={controlsDisabled}
          />
        </label>
        <label>
          Author:
          <input
            onChange={onAuthorChange}
            value={author}
            disabled={controlsDisabled}
          />
        </label>
        <label>
          Image URL:
          <input
            onChange={onImageURLChange}
            value={imgURL}
            disabled={controlsDisabled}
          />
        </label>
        <button
          onClick={() => {
            onCreateBook(title, author, imgURL);
          }}
          disabled={controlsDisabled}
        >
          Create
        </button>
      </section>
    );
  }
}
const mapState = state => state.newBook;
const mapDispatch = dispatch => ({
  onTitleChange({ target: { value } }) {
    dispatch({ type: 'SET_NEW_BOOK_TITLE', title: value });
  },

  onAuthorChange({ target: { value } }) {
    dispatch({ type: 'SET_NEW_BOOK_AUTHOR', author: value });
  },

  onImageURLChange({ target: { value } }) {
    dispatch({ type: 'SET_NEW_BOOK_IMAGE_URL', imgURL: value });
  },

  onCreateBook(title, author, imgURL) {
    dispatch({ type: 'CREATING_BOOK' });
    createBook(title, author, imgURL).then(() => {
      dispatch({ type: 'CREATED_BOOK' });
    });
  }
});

export default connect(mapState, mapDispatch)(NewBook);

如果你看一下用于渲染这个组件的标记,你会看到有三个输入字段。这些字段的值作为 props 传递。与 Redux 存储的连接实际上就是这些 props 的来源。随着它们的状态改变,NewBook组件会重新渲染。

映射到这个组件的调度函数负责调度维护这个组件状态的动作。它们的责任如下:

  • onTitleChange(): 调度SET_NEW_BOOK_TITLE动作以及新的title状态

  • onAuthorChange(): 调度SET_NEW_BOOK_AUTHOR动作以及新的author状态

  • onImageURLChange(): 调度SET_NEW_BOOK_IMAGE_URL动作以及新的imgURL状态

  • onCreateBook(): 调度CREATING_BOOK动作,然后在createBook() API 调用返回时调度CREATED_BOOK动作

如果你不清楚所有这些动作是如何导致高级应用程序行为的,不要担心。这就是为什么你马上要安装 Redux DevTools,这样你就可以理解应用程序状态的变化情况。

这是处理这些动作的 reducer 函数:

const initialState = {
  title: '',
  author: '',
  imgURL: '',
  controlsDisabled: false
};

const newBook = (state = initialState, action) => {
  switch (action.type) {
    case 'SET_NEW_BOOK_TITLE':
      return {
        ...state,
        title: action.title
      };
    case 'SET_NEW_BOOK_AUTHOR':
      return {
        ...state,
        author: action.author
      };
    case 'SET_NEW_BOOK_IMAGE_URL':
      return {
        ...state,
        imgURL: action.imgURL
      };
    case 'CREATING_BOOK':
      return {
        ...state,
        controlsDisabled: true
      };
    case 'CREATED_BOOK':
      return initialState;
    default:
      return state;
  }
};

export default newBook;

最后,这就是渲染时新书表单的样子:

当你填写这些字段并点击创建按钮时,新书将由模拟 API 创建,并且你将被带回到主页,新书应该会被列出。

API 抽象

对于这个应用程序,我正在使用一个简单的 API 抽象。在 Redux 应用程序中,您应该能够将您的异步功能(API 或其他)封装在自己的模块或包中。以下是api.js模块的样子,其中一些模拟数据已被省略以保持简洁:

const LATENCY = 1000;

const BOOKS = [
  {
    title: 'React 16 Essentials',
    author: 'Artemij Fedosejev',
    imgURL: 'big long url...'
  },
  ...
];

export const fetchBooks = () =>
  new Promise(resolve => {
    setTimeout(() => {
      resolve(BOOKS);
    }, LATENCY);
  });

export const createBook = (title, author, imgURL) =>
  new Promise(resolve => {
    setTimeout(() => {
      BOOKS.push({ title, author, imgURL });
      resolve();
    }, LATENCY);
  });

export const fetchBook = title =>
  new Promise(resolve => {
    setTimeout(() => {
      resolve(BOOKS.find(book => book.title === title));
    }, LATENCY);
  });

export const deleteBook = title =>
  new Promise(resolve => {
    setTimeout(() => {
      BOOKS.splice(BOOKS.findIndex(b => b.title === title), 1);
      resolve();
    }, LATENCY);
  });

要开始构建您的 Redux 应用程序,这就是您所需要的。这里需要注意的重要一点是,这些 API 函数中的每一个都返回一个Promise对象。为了更贴近真实 API,我添加了一些模拟的延迟。您不希望 API 抽象返回常规值,比如对象或数组。如果它们在与真实 API 交互时会是异步的,请确保初始模拟也是异步的。否则,这将非常难以纠正。

把所有东西放在一起

让我们快速看一下将所有内容整合在一起的源文件,以便让您感受到完整性。让我们从index.js开始:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import Root from './components/Root';
import registerServiceWorker from './registerServiceWorker';

ReactDOM.render(<Root />, document.getElementById('root'));
registerServiceWorker();

这看起来就像这本书中到目前为止您所使用的create-react-app中的大多数index.js文件。它不是渲染一个App组件,而是渲染一个Root组件。让我们接着看:

import React from 'react';
import { Provider } from 'react-redux';
import App from './App';
import store from '../store';

const Root = () => (
  <Provider store={store}>
    <App />
  </Provider>
);

export default Root;

Root的工作是用react-redux中的Provider组件包装App组件。这个组件接受一个store属性,这样您就能确保连接的组件可以访问 Redux store 数据。

接下来让我们看一下store属性:

import { createStore } from 'redux';
import reducers from './reducers';

export default createStore(
  reducers,
  window.__REDUX_DEVTOOLS_EXTENSION__ &&
    window.__REDUX_DEVTOOLS_EXTENSION__()
);

Redux 有一个createStore()函数,用于为您的 React 应用程序构建一个 store。第一个参数是处理操作并返回 store 新状态的 reducer 函数。第二个参数是一个增强器函数,可以响应 store 状态的变化。在这种情况下,您需要检查 Redux DevTools 浏览器扩展是否安装,如果安装了,就将其连接到您的 store。如果没有这一步,您将无法使用浏览器工具与您的 Redux 应用程序一起使用。

我们快要完成了。让我们看一下reducers/index.js文件,它将您的 reducer 函数组合成一个函数:

import { combineReducers } from 'redux';
import app from './app';
import home from './home';
import newBook from './newBook';
import bookDetails from './bookDetails';

const reducers = combineReducers({
  app,
  home,
  newBook,
  bookDetails
});

export default reducers;

Redux 只有一个 store。为了将您的 store 细分为映射到应用程序概念的状态片段,您需要命名处理各种状态片段的个体 reducer 函数,并将它们传递给combineReducers()。对于这个应用程序,您的 store 有以下状态片段,可以映射到组件:

  • app

  • home

  • newBook

  • bookDetails

现在您已经看到了这个应用程序是如何组合和工作的,现在是时候开始使用 Redux DevTools 浏览器扩展对其进行调试了。

安装 Redux DevTools

安装 Redux DevTools 浏览器扩展的过程与安装 React Developer Tools 扩展的过程类似。第一步是打开 Chrome Web Store 并搜索redux

您要寻找的扩展很可能是第一个结果:

点击“添加到 Chrome”按钮。然后,您将看到一个对话框,询问您是否同意安装该扩展,并在向您展示它可以更改的内容后安装该扩展:

单击“添加扩展”按钮后,您将看到一个通知,指出已安装了该扩展:

就像 React Developer Tools 扩展一样,Redux DevTools 图标在打开运行 Redux 并添加了对该工具的支持的页面之前都会保持禁用状态。请记住,您在图书管理应用程序中明确添加了对该工具的支持,使用了以下代码:

export default createStore(
  reducers,
  window.__REDUX_DEVTOOLS_EXTENSION__ &&
    window.__REDUX_DEVTOOLS_EXTENSION__()
);

现在让我们启动图书管理应用程序,并确保您可以使用该扩展。运行npm start并等待 UI 在浏览器选项卡中打开和加载后,React 和 Redux 开发人员工具图标应该都是启用状态:

接下来,打开开发人员工具浏览器窗格。您可以以与访问 React Developer Tools 相同的方式访问 Redux DevTools:

当您选择 Redux 工具时,您应该看到类似于这样的东西:

Redux DevTools 中的左侧窗格包含最重要的数据——应用程序中的操作。正如在这里反映的,您的图书管理应用程序已经分派了三个操作,因此您知道一切都在运作!

选择和检查操作

Redux DevTools 左侧窗格上显示的操作是按时间顺序列出的,根据它们的分派时间。可以选择任何操作,并通过这样做,您可以使用右侧窗格来检查应用程序状态和操作本身的不同方面。在本节中,您将学习如何深入了解 Redux 操作如何驱动您的应用程序。

操作数据

通过选择一个动作,你可以查看作为动作一部分分发的数据。但首先,让我们生成一些动作。一旦应用程序加载,就会分发FETCHING_BOOKSFETCHED_BOOKS动作。点击 React Native Blueprints 链接,加载书籍数据并转到书籍详情页面。这将导致分发两个新动作:FETCHING_BOOKFETCHED_BOOK。渲染的 React 内容应该是这样的:

Redux DevTools 中的动作列表应该是这样的:

@@INIT动作是由 Redux 自动分发的,并且始终是第一个动作。通常情况下,你不需要担心这个动作,除非你需要知道在分发动作之前应用程序的状态是什么样子的——我们将在接下来的部分中介绍这个。

现在,让我们选择FETCHING_BOOKS动作。然后,在右侧窗格中,选择动作切换按钮以查看动作数据。你应该看到类似这样的东西:

默认情况下选择了动作的树视图。你可以在这里看到动作数据有一个名为type的属性,其值是动作的名称。这告诉你 reducer 应该知道如何处理这个动作,而且它不需要任何额外的数据。

现在让我们选择FETCHED_BOOKS动作,看看动作数据是什么样子的:

再次,你有一个带有动作名称的type属性。这次,你还有一个带有书籍数组的books属性。这个动作是作为对 API 数据解析的响应而分发的,以及书籍数据如何进入存储——它是通过动作携带进来的。

通过查看动作数据,你可以比较实际分发的内容与应用程序状态中所看到的内容。改变应用程序状态的唯一方法是通过分发具有新状态的动作。接下来,让我们看看单个动作如何改变应用程序的状态。

动作状态树和图表

在前面的部分中,你看到了如何使用 Redux DevTools 来选择特定的动作以查看它们的数据。动作及其携带的数据导致应用程序状态的变化。当你选择一个动作时,你可以查看该动作对整个应用程序状态的影响。

让我们选择FETCHING_BOOK操作,然后选择右侧窗格中的状态切换按钮:

此树视图显示了在分派FETCHING_BOOK操作后应用程序的整个状态。在这里,bookDetails状态被展开,以便您可以看到该操作对状态的影响。在这种情况下,它是loading的值——现在是true

现在让我们选择此操作的图表视图:

我偏好图表视图而不是树视图,用于可视化应用程序的整个状态。在图表的最左边,您有根状态。在其右侧,您有应用程序状态的主要部分——apphomenewBookbookDetails。随着您向右移动,您会深入到应用程序中组件的具体状态。正如您在这里看到的,最深层次是home状态中books数组中的个别书籍。

FETCHING_BOOK操作仍然被选中,这意味着该图表反映了 reducers 响应该操作后的应用程序状态。此操作改变了bookDetails中的loading状态。如果您将鼠标指针移动到状态标签上,您将看到它的值:

现在让我们选择FETCHED_BOOK操作。当书籍详细数据从调用 API 获取解析时,将分派此操作:

如果您在切换到不同的操作时保持图表视图处于激活状态,您会注意到图表实际上会动画显示状态的变化。这看起来很酷,毫无疑问,但它也会吸引您注意实际发生变化的值,以便更容易看到。在这个例子中,如果您查看bookDetails下的book对象,您会发现它现在有了新的属性。您可以将鼠标指针移动到每个属性上以显示其值。您还可以检查loading的值——它应该恢复为false

操作状态差异

在 Redux DevTools 中查看操作数据的另一种方法是查看从分派操作中产生的状态差异。这个视图不是试图通过查看整个状态树来推断状态的变化,而是只向您展示了发生了什么变化。

让我们尝试添加一本新书来生成一些动作。我要添加你现在正在阅读的这本书。首先,我会粘贴生成输入元素上的更改事件的书名,然后触发SET_NEW_BOOK_TITLE动作。如果你选择该动作,你应该会看到以下内容:

newBook状态的title值从空字符串变为了粘贴到标题文本输入框中的值。您无需寻找此更改,它已清晰标记,所有不相关的状态数据都被隐藏起来。

接下来,让我们粘贴作者并选择SET_NEW_BOOK_AUTHOR动作:

再次,这里只显示了author值,因为它是由于分派SET_NEW_BOOK_AUTHOR而发生变化的唯一值。这是最终的表单字段-图像 URL:

通过使用动作的差异视图,您只会看到由于动作而发生变化的数据。如果这不能给您足够的视角,您可以随时跳转到状态视图,以便查看整个应用程序的状态。

让我们通过点击“创建”按钮来创建新书。这将分派两个动作:CREATING_BOOKCREATED_BOOK。首先,让我们看看CREATING_BOOK

此动作在进行 API 调用创建书籍之前分派。这使得您的 React 组件有机会处理用户交互的异步性质。在这种情况下,您不希望用户在请求挂起时能够与任何表单控件进行交互。通过查看此差异,您可以看到controlsDisabled值现在为false,React 组件可以使用它来禁用任何表单控件。

最后,让我们看一下CREATED_BOOK动作:

titleauthorimgURL的值都被设置为空字符串,这将重置表单字段的值。通过将controlsDisabled设置为false,表单字段也被重新启用。

时间旅行调试

Redux 中 reducer 函数的一个要求是它们必须是纯函数;也就是说,它们只返回新数据,而不是改变现有数据。这样做的一个结果是它可以实现时间旅行调试。因为没有任何改变,你可以将应用程序的状态向前、向后或者到任意时间点。Redux DevTools 使这变得很容易。

为了看到时间旅行调试的效果,让我们在过滤输入框中输入一些过滤文本:

在 Redux DevTools 中查看动作,你应该看到类似以下的内容:

我选择了最后一个被分发的SET_FILTER_VALUE动作。filterValue的值应该是native b,这反映了当前显示的标题。现在,让我们回到两个动作之前。为了做到这一点,将鼠标指针移动到当前选定动作的两个位置之前的动作上。点击 Jump 按钮,应用程序的状态将被更改为分发SET_FILTER_VALUE时的状态:

你可以看到filterValue已经从native b变成了native。你已经成功地撤销了最后两次按键,相应地更新了状态和 UI:

要将应用程序状态恢复到当前时间,按照相同的过程但是反向操作。点击最近状态上的 Jump。

手动触发动作

在开发 Redux 应用程序时手动触发动作的能力是很有帮助的。例如,你可能已经准备好了组件,但是不确定用户交互会如何工作,或者你只是需要排除一些本应该工作但是却没有的问题。你可以使用 Redux DevTools 通过点击面板底部附近带有键盘图标的按钮来手动触发动作:

这将显示一个文本输入框,你可以在其中输入动作的载荷。例如,我已经导航到了《React Native By Example》的书籍详情页面:

我不想点击删除按钮,我只想看看应用程序的状态会发生什么变化,而不触发 DOM 事件或 API 调用。为了做到这一点,我可以点击 Redux DevTools 中的键盘按钮,这样我就可以手动输入一个动作并分派它。例如,这是我如何分派DELETING_BOOK动作的方式:

这导致动作被分派,因此 UI 被更新。这是DELETING_BOOK动作:

要将controlsDisabled设置回false,您可以分派DELETED_BOOK动作:

导出和导入状态

随着 Redux 应用程序的规模和复杂性的增长,状态树的大小和复杂性也会同步增长。因此,有时玩弄单个动作并使应用程序进入特定状态可能会太繁琐,无法手动一遍又一遍地执行。

使用 Redux DevTools,您可以导出应用程序的当前状态。然后,当您以后进行故障排除并需要特定状态作为起点时,您可以直接加载它,而不是手动重新创建它。

让我们尝试导出应用程序状态。首先,导航到 React 16 Essentials 的详细信息页面:

要使用 Redux DevTools 导出当前状态,请单击带有向下箭头的按钮:

然后,您可以使用向上箭头导入状态。但在这之前,导航到不同的书名,比如《使用 React VR 入门》:

现在,您可以在 Redux DevTools 窗格中使用上传按钮:

由于您已经在书籍详细信息页面上,加载此状态将替换由此页面上的组件呈现的状态值:

现在您知道如何将 Redux 存储的状态恢复到您导出并本地保存的任何给定点。这样做的想法是避免记住并按照正确的顺序执行校正操作以达到特定状态。这是容易出错的,导出所需的确切状态可以避免整个过程。

摘要

在本章中,你组合了一个简单的图书管理 Redux 应用程序。有了这个应用程序,然后你学会了如何在 Chrome 中安装 Redux DevTools 浏览器扩展。然后,你学会了如何查看和选择动作。

一旦选择了一个动作,就有许多方法可以查看有关应用程序的信息。你可以查看动作的载荷数据。你可以查看整个应用程序状态。你可以查看应用程序状态和上次分发的动作之间的差异。这些都是你可以用来调试 Redux 应用程序的不同方法。

然后,你学会了如何在 Redux DevTools 中进行时间旅行调试。因为在 Redux 中状态变化是不可变的,你可以使用 Redux DevTools 从一个动作跳转到另一个动作。这可以极大地简化调试周期。最后,你学会了如何手动分发动作以及导入/导出应用程序的状态。

在下一章中,你将学习如何使用 Gatsby 从 React 组件生成静态内容。

第十章:使用 Gatsby 构建和部署静态 React 站点

Gatsby 是 React 开发人员的静态网站生成工具。本质上,这个工具让你构建 React 组件并捕获它们的渲染输出,以用作静态站点内容。然而,Gatsby 将静态站点生成提升到了一个新的水平。特别是,它提供了将网站数据作为 GraphQL 源并将其转换为更容易被 React 组件消耗的机制。Gatsby 可以处理从单页宣传册站点到跨越数百页的站点的任何内容。

在本章中,您将学到以下内容:

  • 为什么要使用 React 组件构建静态站点?

  • 使用入门者构建简单的 Gatsby 站点

  • 使用来自本地文件系统的数据

  • 使用来自 Hacker News 的远程数据

为什么要静态 React 站点?

在使用 Gatsby 构建静态网站之前,让我们通过简要讨论为什么要这样做来设定背景。这里有三个关键因素——我们现在将逐个讨论每一个。

React 应用程序的类型

React 与非常互动和生动变化的数据相关联。这可能对一些应用程序是真实的,甚至可能对大多数应用程序是真实的,但仍然存在用户查看静态数据的情况——即不会改变或很少改变的信息。

考虑一个博客。典型的流程是作者发布一些内容,然后该内容被提供给访问网站的任何人,然后他们可以查看内容。通常情况是,一旦内容发布,它就保持不变,或者保持静态。不寻常的情况是作者更新他们的帖子,但即使是这样,这也是一个不经常的行为。现在,想想你典型的博客发布平台。每当读者访问博客上的页面时,都会执行数据库查询,必须组装内容等。问问自己,如果结果每次都一样,那么发出所有这些查询真的有意义吗?

让我们看另一个例子。您有一个企业级应用程序,一个大型应用程序,有大量数据和大量功能。应用程序的一部分专注于用户交互——添加/更改数据和与几乎实时数据交互。应用程序的另一部分生成报告——基于数据库查询的报告和基于历史数据快照的图表。这个企业应用程序的后半部分似乎不与频繁更改的数据交互,或者根本不交互。也许,将应用程序拆分为两个应用程序会有所好处:一个处理用户与活跃数据的交互,另一个生成几乎不频繁更改或根本不更改的静态内容。

您可能正在构建一个应用程序或较大应用程序的一部分,其中大部分数据都是静态的。如果是这样,您可能可以使用类似 Gatsby 的工具来生成静态渲染的内容。但是为什么要这样做?有什么好处呢?

更好的用户体验

构建 React 组件的静态版本最具说服力的原因是为用户提供更好的体验。关键指标在于整体性能的改进。不必触及各种 API 端点并处理提供数据给 React 组件的所有异步方面,而是一切都是预先加载的。

使用静态构建的 React 内容还有一个不太明显的用户体验改进是,由于移动部件较少,网站出现故障的可能性较小,从而减少了用户的挫败感。例如,如果您的 React 组件不必通过网络获取数据,那么这种故障可能性就完全从您的网站中消除了。

高效的资源使用

由 Gatsby 静态编译的组件知道如何有效地使用它们消耗的 GraphQL 资源。GraphQL 的一个很棒的地方是,工具在编译时可以轻松解析和生成高效的代码。如果您在继续使用 Gatsby 之前想要更深入地了解 GraphQL,可以在这里找到一个很好的介绍:graphql.org/learn/

静态 Gatsby React 应用程序帮助减少资源消耗的另一个地方是后端。这些应用程序不会不断地命中返回相同响应的 API 端点。这段时间可以用来为实际需要动态数据或正在生成新数据的请求提供服务。

构建您的第一个 Gatsby 网站

使用 Gatsby 的第一步是全局安装命令行工具:

npm install gatsby-cli -g  

现在,您可以运行命令行工具来生成您的 Gatsby 项目,就像create-react-app的工作方式一样。gatsby命令接受两个参数:

  • 新项目的名称

  • Gatsby starter 存储库的 URL

项目名称基本上是创建以保存所有项目文件的文件夹的名称。Gatsby starter 有点像模板,使您更容易上手,特别是如果您正在学习。如果您不传递一个 starter,将使用默认的 starter:

gatsby new your-first-gatsby-site

运行上述命令将与运行以下命令相同:

gatsby new your-first-gatsby-site https://github.com/gatsbyjs/gatsby-starter-default

在这两种情况下,starter 存储库都会克隆到your-first-gatsby-site目录中,然后为您安装依赖项。如果一切顺利,您应该看到类似于这样的控制台输出:

info Creating new site from git: https://github.com/gatsbyjs/gatsby-starter-default.git
Cloning into 'your-first-gatsby-site'...
success Created starter directory layout
info Installing packages...
added 1540 packages from 888 contributors in 29.528s  

现在,您可以切换到your-first-gatsby-site目录并启动开发服务器:

cd your-first-gatsby-site
gatsby develop

这将在您的项目中启动 Gatsby 开发服务器。再次强调,这与create-react-app的工作方式类似——没有任何配置要处理,Webpack 已经设置好了。启动开发服务器后,您应该在控制台上看到类似于这样的输出:

success delete html and css files from previous builds - 0.007 s
success open and validate gatsby-config.js - 0.004 s
success copy gatsby files - 0.014 s
success onPreBootstrap - 0.011 s
success source and transform nodes - 0.022 s
success building schema - 0.070 s
success createLayouts - 0.020 s
success createPages - 0.000 s
success createPagesStatefully - 0.014 s
success onPreExtractQueries - 0.000 s
success update schema - 0.044 s
success extract queries from components - 0.042 s
success run graphql queries - 0.024 s
success write out page data - 0.003 s
success write out redirect data - 0.001 s
success onPostBootstrap - 0.001 s

info bootstrap finished - 1.901 s

DONE  Compiled successfully in 3307ms                                          

您现在可以通过导航到http://localhost:8000/在浏览器中查看gatsby-starter-default

查看 GraphiQL,一个在浏览器中探索站点数据和模式的 IDE

http://localhost:8000/___graphql

请注意,开发构建未经优化。要创建生产构建,请使用gatsby build

WAIT  Compiling... 

DONE  Compiled successfully in 94ms 

如果您在 Web 浏览器中访问http://localhost:8000/,您应该看到默认内容:

默认的 starter 创建了多个页面,这样您就可以看到如何将页面链接在一起。如果您点击“转到第 2 页”链接,您将被带到站点的下一页,看起来像这样:

这是您的默认 Gatsby starter 项目的结构:

├── LICENSE
├── README.md
├── gatsby-browser.js
├── gatsby-config.js
├── gatsby-node.js
├── gatsby-ssr.js
├── package-lock.json
├── package.json
├── public
│   ├── index.html
│   ├── render-page.js.map
│   └── static
└── src
 ├── components
 │   └── Header
 │       └── index.js
 ├── layouts
 │   ├── index.css
 │   └── index.js
 └── pages
 ├── 404.js
 ├── index.js
 └── page-2.js  

对于基本的站点设计和编辑,您主要关注src目录下的文件和目录。让我们看看您要处理的内容,从Header组件开始:

import React from 'react' 
import Link from 'gatsby-link' 

const Header = () => ( 
  <div 
    style={{ 
      background: 'rebeccapurple', 
      marginBottom: '1.45rem', 
    }} 
  > 
    <div 
      style={{ 
        margin: '0 auto', 
        maxWidth: 960, 
        padding: '1.45rem 1.0875rem', 
      }} 
    > 
      <h1 style={{ margin: 0 }}> 
        <Link 
          to="/" 
          style={{ 
            color: 'white', 
            textDecoration: 'none', 
          }} 
        > 
          Gatsby 
        </Link> 
      </h1> 
    </div> 
  </div> 
) 

export default Header 

该组件定义了紫色的页眉部分。标题目前是静态的,它链接到主页,并定义了一些内联样式。接下来,让我们看一下layouts/index.js文件:

import React from 'react' 
import PropTypes from 'prop-types' 
import Helmet from 'react-helmet' 

import Header from '../components/Header' 
import './index.css' 

const TemplateWrapper = ({ children }) => ( 
  <div> 
    <Helmet 
      title="Gatsby Default Starter" 
      meta={[ 
        { name: 'description', content: 'Sample' }, 
        { name: 'keywords', content: 'sample, something' }, 
      ]} 
    /> 
    <Header /> 
    <div 
      style={{ 
        margin: '0 auto', 
        maxWidth: 960, 
        padding: '0px 1.0875rem 1.45rem', 
        paddingTop: 0, 
      }} 
    > 
      {children()} 
    </div> 
  </div> 
) 

TemplateWrapper.propTypes = { 
  children: PropTypes.func, 
} 

export default TemplateWrapper 

这个模块导出了一个TemplateWrapper组件。这个组件的作用是定义网站的布局。就像你可能已经实现的其他容器组件一样,这个组件在网站的每个页面上都会被渲染。这类似于你在react-router中所做的事情,只不过在 Gatsby 中,路由已经为你处理好了。例如,处理指向page-2的链接的路由是由 Gatsby 自动创建的。同样地,Gatsby 通过确保它在网站的每个页面上都被渲染来自动处理这个布局模块。你所需要做的就是确保它看起来符合你的要求,并且children()函数被渲染。现在,你可以将它保持原样。

你也会注意到,布局模块还导入了一个包含与网站布局相关的样式的样式表。

让我们现在来看一下页面组件,从index.js开始:

import React from 'react' 
import Link from 'gatsby-link' 

const IndexPage = () => ( 
  <div> 
    <h1>Hi people</h1> 
    <p>Welcome to your new Gatsby site.</p> 
    <p>Now go build something great.</p> 
    <Link to="/page-2/">Go to page 2</Link> 
  </div> 
) 

export default IndexPage 

就像普通的 HTML 网站有一个index.html文件一样,静态的 Gatsby 网站也有一个index.js页面,它将内容导出到主页上进行渲染。在这里定义的IndexPage组件渲染了一些基本的 HTML,包括指向page-2的链接。接下来让我们来看一下page-2.js

import React from 'react' 
import Link from 'gatsby-link' 

const SecondPage = () => ( 
  <div> 
    <h1>Hi from the second page</h1> 
    <p>Welcome to page 2</p> 
    <Link to="/">Go back to the homepage</Link> 
  </div> 
)
export default SecondPage 

这个页面看起来与主页非常相似。在这里渲染的链接将用户带回到主页。

这只是一个基本的介绍,让你开始使用 Gatsby。你没有使用任何数据源来生成内容;你将在接下来的部分中做到这一点。

添加本地文件系统数据

在前面的部分中,你看到了如何启动并运行一个基本的 Gatsby 网站。这个网站并不是很有趣,因为没有数据来驱动它。例如,驱动博客的数据是存储在数据库中的博客条目内容,博客框架使用这些数据来渲染文章列表和文章本身的标记。

你可以用 Gatsby 做同样的事情,但以一种更复杂的方式。首先,标记(或在这种情况下,React 组件)是静态构建和捆绑一次的。然后,这些构建被提供给用户,而无需查询数据库或 API。其次,Gatsby 使用的插件架构意味着你不仅限于一个数据源,不同的数据源经常被结合在一起。最后,GraphQL 是一个查询抽象层,位于所有这些东西的顶部,并将数据传递给你的 React 组件。

要开始,你需要一个数据源来驱动你网站的内容。现在我们将保持简单,使用本地 JSON 文件作为数据源。为此,你需要安装gatsby-source-filesystem插件:

npm install --save gatsby-source-filesystem

安装了这个包之后,你可以通过编辑gatsby-config.js文件将其添加到你的项目中:

plugins: [ 
  // Other plugins... 
  { 
    resolve: 'gatsby-source-filesystem', 
    options: { 
      name: 'data', 
      path: '${__dirname}/src/data/', 
    }, 
  }, 
] 

name选项告诉 GraphQL 后端如何组织查询结果。在这种情况下,所有内容都将在data属性下。路径选项限制了可读取的文件。在这个例子中使用的路径是src/data—随意将文件放入该目录,以便进行查询。

此时,你可以启动 Gatsby 开发服务器。GraphiQL 实用程序可在http://localhost:8000/___graphql访问。在开发 Gatsby 网站时,你会经常使用这个工具,因为它允许你创建临时的 GraphQL 查询并立即执行它们。当你首次加载这个界面时,你会看到类似这样的东西:

左侧面板是你编写 GraphQL 查询的地方,点击上面的播放按钮执行查询,右侧面板显示查询结果。右上角的文档链接是一个探索 Gatsby 为你创建的可用 GraphQL 类型的有用方式。此外,右侧的查询编辑器窗格将在你输入时自动完成,以帮助更轻松地构建查询。

让我们执行你的第一个查询,列出文件系统中关于文件的信息。请记住,你需要至少在src/data中有一个文件,才能使你的查询返回任何结果。以下是如何查询数据目录中文件的名称、扩展名和大小:

如你所见,查询中指定了特定的节点字段。右侧面板中的结果显示你得到了你要求的确切字段。GraphQL 的吸引力之一在于你可以创建任意嵌套和复杂的查询,涵盖多个后端数据源。然而,深入研究 GraphQL 的细节远远超出了本书的范围。Gatsby 首页(www.gatsbyjs.org/)上有一些关于 GraphQL 的很好的资源,包括其他 GraphQL 教程和文档的链接。

这里的要点是,gatsby-source-filesystem数据源插件为您完成了所有繁重的 GraphQL 工作。它为您生成了整个模式,这意味着一旦您安装了插件,您就可以启动开发服务器并立即开始使用自动完成和文档。

继续使用这个例子,您可能不需要在 UI 中呈现本地文件数据。所以让我们创建一个带有一些 JSON 内容的articles.json文件:

[ 
  { "topic": "global", "title": "Global Article 1" }, 
  { "topic": "global", "title": "Global Article 2" }, 
  { "topic": "local", "title": "Local Article 1" }, 
  { "topic": "local", "title": "Local Article 2" }, 
  { "topic": "sports", "title": "Sports Article 1" }, 
  { "topic": "sports", "title": "Sports Article 2" } 
]

这个 JSON 结构是一组带有topictitle属性的文章对象。这是您想要用 GraphQL 查询的数据。为了做到这一点,您需要安装另一个 Gatsby 插件:

npm install --save gatsby-transformer-json

gatsby-transformer-json插件来自 Gatsby 插件的另一类别——转换器。源插件负责向 Gatsby 提供数据,而转换器负责使数据可通过 GraphQL 查询。就像您想要使用的任何插件一样,您需要将它添加到您的项目配置中:

plugins: [ 
  // Other plugins... 
  'gatsby-transformer-json', 
], 

现在,您在数据目录中有一个带有 JSON 内容的文件,并且安装并启用了gatsby-transformer-json插件,您可以回到 GraphiQL 并查询 JSON 内容:

gatsby-transformer-json插件使allArticlesJson查询成为可能,因为它根据数据源中的 JSON 数据为您定义了 GraphQL 模式。在node下,您可以请求特定属性,就像您对任何其他 GraphQL 查询一样。在结果中,您会得到您查询的所有 JSON 数据。

在这个例子中,假设您想要为按主题组织的文章列出三个单独的页面。您需要一种方法来过滤查询返回的节点。您可以直接将过滤器添加到您的 GraphQL 语法中。例如,要仅查找全球文章,您可以执行以下查询:

这次,一个过滤参数被传递给allArticlesJson查询。在这里,查询是要求具有全局主题值的节点。果然,具有全局主题的文章在结果中返回。

GraphiQL 实用程序允许您设计一个 GraphQL 查询,然后可以被您的 React 组件使用。一旦您有一个返回正确结果的查询,您可以简单地将其复制到您的组件中。这个最后的查询返回全球文章,所以您可以将它与用于pages/global.js页面的组件一起使用:

import React from 'react' 
import Link from 'gatsby-link' 

export default ({ data: { allArticlesJson: { edges } } }) => ( 
  <div>
    <h1>Global Articles</h1> 
    <Link to="/">Home</Link> 
    <ul> 
      {edges.map(({ node: { title } }) => ( 
        <li key={title}>{title}</li> 
      ))} 
    </ul> 
  </div> 
) 

export const query = graphql' 
  query GlobalArticles { 
    allArticlesJson(filter: { topic: { eq: "global" } }) { 
      edges { 
        node { 
          topic 
          title 
        } 
      } 
    } 
  } 
'

在这个模块中有两件事需要注意。首先,看一下传递给组件的参数,并注意它是如何与您在 GraphiQL 中看到的结果数据匹配的。然后,注意query导出字符串。在构建时,Gatsby 将找到此字符串并执行适当的 GraphQL 查询,以便您的组件具有结果的静态快照。

鉴于您现在知道如何筛选全局文章,您现在可以更新pages/local.js页面的筛选器:

import React from 'react' 
import Link from 'gatsby-link' 

export default ({ data: { allArticlesJson: { edges } } }) => ( 
  <div> 
    <h1>Local Articles</h1> 
    <Link to="/">Home</Link> 
    <ul> 
      {edges.map(({ node: { title } }) => ( 
        <li key={title}>{title}</li> 
      ))} 
    </ul> 
  </div> 
)
export const query = graphql' 
  query LocalArticles { 
    allArticlesJson(filter: { topic: { eq: "local" } }) { 
      edges { 
        node { 
          topic 
          title 
        } 
      } 
    } 
  } 
' 

这是pages/sports.js页面的样子:

import React from 'react' 
import Link from 'gatsby-link' 

export default ({ data: { allArticlesJson: { edges } } }) => ( 
  <div> 
    <h1>Sports Articles</h1> 
    <Link to="/">Home</Link> 
    <ul> 
      {edges.map(({ node: { title } }) => ( 
        <li key={title}>{title}</li> 
      ))} 
    </ul> 
  </div> 
) 

export const query = graphql' 
  query SportsArticles { 
    allArticlesJson(filter: { topic: { eq: "sports" } }) { 
      edges { 
        node { 
          topic 
          title 
        } 
      } 
    } 
  } 
' 

您可能已经注意到这三个组件看起来非常相似。这是因为它们都使用相同的数据。它们唯一的不同之处在于它们的标题。为了减少一些冗余,您可以创建一个接受name参数并返回在每个页面上使用的相同基础组件的高阶组件:

import React from 'react' 
import Link from 'gatsby-link' 

export default title => ({ data: { allArticlesJson: { edges } } }) => ( 
  <div> 
    <h1>{title}</h1> 
    <Link to="/">Home</Link> 
    <ul> 
      {edges.map(({ node: { title } }) => ( 
        <li key={title}>{title}</li> 
      ))} 
    </ul> 
  </div> 
) 

然后,您可以像这样使用它:

import React from 'react' 
Import ArticleList from '../components/ArticleList' 

export default ArticleList('Global Articles') 

export const query = graphql' 
  query GlobalArticles { 
    allArticlesJson(filter: { topic: { eq: "global" } }) { 
      edges { 
        node { 
          topic 
          title 
        } 
      } 
    } 
  } 
'

为了查看所有这些页面,您需要一个链接到每个页面的索引页面:

import React from 'react' 
import Link from 'gatsby-link' 

const IndexPage = () => ( 
  <div> 
    <h1>Home</h1> 
    <p>Choose an article category</p> 
    <ul> 
      <li> 
        <Link to="/global/">Global</Link> 
      </li>
      <li> 
        <Link to="/local/">Local</Link> 
      </li>

      <li> 
        <Link to="/sports/">Sports</Link> 
      </li> 
    </ul> 
  </div> 
) 

export default IndexPage 

这是主页的样子:

如果您点击其中一个主题链接,比如全局,您将进入文章列表页面:

获取远程数据

Gatsby 拥有丰富的数据源插件生态系统 - 我们没有时间去了解它们所有。Gatsby 源插件通常会在构建时从另一个系统获取数据并通过网络获取数据。gatsby-source-hacker-news插件是一个很好的插件,可以让您了解 Gatsby 如何处理这个获取过程。

与其使用 Gatsby 构建自己的 Hacker News 网站,我们将使用github.com/ajayns创建的演示。要开始,您可以克隆他的存储库,如下所示:

git clone https://github.com/ajayns/gatsby-hacker-news.git
cd gatsby-hacker-news

然后,您可以安装依赖项,包括gatsby-source-hacker-news插件:

npm install

不需要编辑项目配置来启用任何功能,因为这已经是一个 Gatsby 项目。只需像在本章中一样启动开发服务器:

gatsby develop

与本章中您所工作的其他网站相比,这次构建需要更长的时间才能完成。这是因为 Gatsby 必须通过网络获取数据。还有更多资源需要获取。如果您查看开发服务器的控制台输出,您应该会看到以下内容:

success onPreBootstrap - 0.011 s
![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/react16-tl/img/91b4b10e-2f9a-4b0b-8223-8f057f3c9f05.jpg) starting to fetch data from the Hacker News GraphQL API. Warning, this can take a long time e.g. 10-20 seconds
![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/react16-tl/img/91b4b10e-2f9a-4b0b-8223-8f057f3c9f05.jpg) source and transform nodesfetch HN data: 10138.119ms

这表明由于需要加载 Hacker News 数据而导致构建时间较长。一旦此过程完成,您可以在浏览器中加载站点。您应该看到类似以下内容:

让我们来看一下加载用于呈现此内容的数据的 GraphQL 查询。在index.js页面中,您会找到以下查询:

query PageQuery { 
  allHnStory(sort: { fields: [order] }, limit: 10) { 
    edges { 
      node { 
        ...Story 
      } 
    } 
  } 
} 

不是指定单个节点字段,而是...Story。这被称为片段,它在StoryItem组件中定义:

fragment Story on HNStory { 
  id 
  title 
  score 
  order 
  domain 
  url 
  by 
  descendants 
  timeISO(fromNow: true) 
} 

StoryItem组件定义了这个 GraphQL 片段,因为它使用了这些数据。现在,让我们转到 GraphiQL,组合并执行这个查询:

这就是站点首页如何加载从 Hack News API 获取的数据。以下是首页组件的外观:

import React from 'react' 

import StoryItem from '../components/story-item' 

const IndexPage = ({ data, active }) => ( 
  <div> 
    <div> 
      {data.allHnStory.edges.map(({ node }) => ( 
        <StoryItem key={node.id} story={node} active={false} /> 
      ))} 
    </div> 
  </div> 
) 

export default IndexPage 

返回的数据的边缘被映射到StoryItem组件,传入数据节点。以下是StoryItem组件的外观:

import React, { Component } from 'react'; 
import Link from 'gatsby-link'; 

import './story-item.css'; 

const StoryItem = ({ story, active }) => ( 
  <div 
    className="story" 
    style={active ? { borderLeft: '6px solid #ff6600' } : {}} 
  > 
    <div className="header"> 
      <a href={story.url}> 
        <h4>{story.title}</h4> 
      </a> 
      <span className="story-domain"> 
        {' '}({story.domain}) 
      </span> 
    </div> 
    <div className="info"> 
      <h4 className="score">▴ {story.score}</h4> 
      {' '} 
      by <span className="author">{story.by}</span> 
      {' '} 
      <span className="time">{story.timeISO}</span> 
      {' '} 
      {active ? ( 
        '' 
      ) : ( 
        <Link to={'/item/${story.id}'} className="comments"> 
          {story.descendants} comments 
        </Link> 
      )} 
    </div> 
  </div> 
); 

export default StoryItem; 

在这里,您可以看到这个组件如何使用由传递给更大查询的 GraphQL 片段定义的数据。

现在让我们点击一个故事的评论链接,这将带您到故事的详细页面。新的 URL 应该看起来像http://localhost:8000/item/16691203,页面应该看起来像这样:

你可能想知道这个页面是从哪里来的,因为它有一个 URL 参数(故事的 ID)。当使用 Gatsby 构建具有动态 URL 组件的静态页面时,您必须编写一些代码,其工作是告诉 Gatsby 如何根据 GraphQL 查询结果创建页面。这段代码放在gatsby-node.js模块中。这是 Hacker News 网站中页面创建的方式:

const path = require('path') 

exports.createPages = ({ graphql, boundActionCreators }) => { 
  const { createPage } = boundActionCreators 
  return new Promise((resolve, reject) => { 
    graphql(' 
      { 
        allHnStory(sort: { fields: [order] }, limit: 10) { 
          edges { 
            node { 
              id 
            } 
          } 
        } 
      } 
    ').then(result => { 
      if (result.errors) {
        reject(result.errors) 
      } 

      const template = path.resolve('./src/templates/story.js') 

      result.data.allHnStory.edges.forEach(({ node }) => { 
        createPage({ 
          path: '/item/${node.id}', 
          component: template, 
          context: { 
            id: node.id, 
          }, 
        }) 
      }) 

      resolve() 
    })
  }) 
} 

这个模块导出了一个createPages()函数,Gatsby 将在构建时使用它来创建静态的 Hacker News 文章页面。它首先使用grapghql()函数执行查询,以找到您需要为其创建页面的所有文章节点:

graphql(' 
  { 
    allHnStory(sort: { fields: [order] }, limit: 10) { 
      edges { 
        node { 
          id 
        } 
      } 
    } 
  } 
') 

接下来,对每个节点调用createPage()函数:

const template = path.resolve('./src/templates/story.js') 

result.data.allHnStory.edges.forEach(({ node }) => { 
  createPage({ 
    path: '/item/${node.id}', 
    component: template, 
    context: { 
      id: node.id, 
    },
  }) 
}) 

传递给createPage()的属性是:

  • path:这是访问时将呈现页面的 URL。

  • component:这是呈现页面内容的 React 组件的文件系统路径。

  • context:这是传递给 React 组件的数据。在这种情况下,组件知道文章 ID 非常重要。

这是您在使用 Gatsby 时可能会采取的一般方法,每当您有大量基于动态数据生成页面时,但是相同的 React 组件可以用于呈现内容。换句话说,您可能更愿意在 React 组件中编写此代码,而不是为每篇文章单独编写组件。

让我们来看一下用于呈现文章详细信息页面的组件:

import React from 'react' 

import StoryItem from '../components/story-item' 
import Comment from '../components/comment' 

const Story = ({ data }) => ( 
  <div> 
    <StoryItem story={data.hnStory} active={true} /> 
    <ul> 
      {data.hnStory.children.map(comment => ( 
        <Comment key={comment.id} data={comment} /> 
      ))} 
    </ul> 
  </div> 
) 

export default Story 

export const pageQuery = graphql' 
  query StoryQuery($id: String!) { 
    hnStory(id: { eq: $id }) { 
      ...Story 
      children { 
        ...Comment 
      } 
    } 
  } 
' 

再次,该组件依赖于 Gatsby 执行pageQuery常量中的 GraphQL 查询。上下文被传递给gatsby-node.js中的createPage()。这就是您能够将$id参数传递到查询中,以便您可以查询特定的故事数据的方式。

总结

在本章中,您了解了 Gatsby,这是一个基于 React 组件生成静态网站的工具。我们在本章开始时讨论了为什么您可能希望考虑构建静态站点,以及为什么 React 非常适合这项工作。静态站点会带来更好的用户体验,因为它们不像常规的 React 应用程序那样利用相同类型的资源。

接下来,您构建了自己的第一个 Gatsby 网站。您了解了 Gatsby 起始模板创建的基本文件布局以及如何将页面链接在一起。然后,您了解到 Gatsby 数据是由插件架构驱动的。Gatsby 能够通过插件支持各种数据源。您开始使用本地文件系统数据。接下来,您了解了转换器插件。这些类型的 Gatsby 插件使特定类型的数据源能够通过 GraphQL 进行查询。

最后,您看了一个使用 Gatsby 构建的 Hacker News 示例。这使您能够获取远程 API 数据作为数据源,并根据 GraphQL 查询结果动态生成页面。

在下一章,也是最后一章中,您将了解有关工具的内容,以便将您的 React 应用程序与其消耗的服务一起进行容器化和部署。

第十一章:使用 Docker 容器构建和部署 React 应用程序

在本书的这一部分,你一直在使用各种工具以开发模式运行你的 React 应用程序。在本章中,我们将把重点转向生产环境工具。总体目标是能够将你的 React 应用程序部署到生产环境中。幸运的是,有很多工具可以帮助你完成这项工作,在本章中你将熟悉这些工具。本章的目标是:

  • 构建一个基本的消息 React 应用,利用 API

  • 使用 Node 容器来运行你的 React 应用

  • 将您的应用程序拆分为可部署的容器中运行的服务

  • 在生产环境中使用静态 React 构建

构建一个消息应用

在没有任何上下文的情况下讨论用于部署 React 应用程序的工具是困难的。为此,你将组合一个基本的消息应用。在本节中,你将看到应用程序的工作原理和构建方式。然后,你将准备好进行剩余章节的学习,学习如何将你的应用程序部署为一组容器。

这个应用的基本思想是能够登录并向你的联系人发送消息,同时也能接收消息。我们会保持它非常简单。在功能上,它几乎可以匹配短信的功能。事实上,这可以是应用的标题——Barely SMS。这个想法是有一个 React 应用程序,有足够多的活动部分可以在生产环境中测试,以及一个稍后可以部署在容器中的服务器。

为了视觉效果,我们将使用 Material-UI(material-ui-next.com/)组件库。然而,UI 组件的选择不应影响本章的教训。

启动 Barely SMS

为了熟悉Barely SMS,让我们在终端中以与本书中一直以来一样的方式启动它。一旦你切换到本书附带的源代码包中的building-a-messaging-app目录中,你可以像任何其他create-react-app项目一样启动开发服务器:

npm start

在另一个终端窗口或选项卡中,你可以通过在同一目录中运行以下命令来启动Barely SMS的 API 服务器:

npm run api

这将启动一个基本的 Express(expressjs.com/)应用。一旦服务器启动并监听请求,你应该看到以下输出:

API server listening on port 3001!  

现在你已经准备好登录了。

登录

当您首次加载 UI 时,您应该看到这样的登录屏幕:

以下模拟用户作为 API 的一部分存在:

  • user1

  • user2

  • user3

  • user4

  • user5

实际上,密码并没有被验证,所以留空或输入胡言乱语都应该验证之前的任何用户。让我们来看一下呈现此页面的“登录”组件:

import React, { Component } from 'react';

import { withStyles } from 'material-ui/styles';
import TextField from 'material-ui/TextField';
import Button from 'material-ui/Button';

import { login } from './api';

const styles = theme => ({
  container: {
    display: 'flex',
    flexWrap: 'wrap'
  },
  textField: {
    marginLeft: theme.spacing.unit,
    marginRight: theme.spacing.unit,
    width: 200
  },
  button: {
    margin: theme.spacing.unit
  }
});

class Login extends Component {
  state = {
    user: '',
    password: ''
  };

  onInputChange = name => event => {
    this.setState({
      [name]: event.target.value
    });
  };

  onLoginClick = () => {
    login(this.state).then(resp => {
      if (resp.status === 200) {
        this.props.history.push('/');
      }
    });
  };

  componentWillMount() {
    this.props.setTitle('Login');
  }

  render() {
    const { classes } = this.props;
    return (
      <div className={classes.container}>
        <TextField
          id="user"
          label="User"
          className={classes.textField}
          value={this.state.user}
          onChange={this.onInputChange('user')}
          margin="normal"
        />
        <TextField
          id="password"
          label="Password"
          className={classes.textField}
          value={this.state.password}
          onChange={this.onInputChange('password')}
          type="password"
          autoComplete="current-password"
          margin="normal"
        />
        <Button
          variant="raised"
          color="primary"
          className={classes.button}
          onClick={this.onLoginClick}
        >
          Login
        </Button>
      </div>
    );
  }
}
export default withStyles(styles)(Login);

这里有很多 Material-UI,但可以忽略大部分。重要的是从api模块导入的login()函数。这用于调用/api/login端点。从生产 React 部署的角度来看,这是相关的,因为这是与可能部署为自己的容器的服务进行交互。

主页

如果您能成功登录,您将被带到应用程序的主页。您应该看到一个看起来像这样的页面:

Barely SMS的主页显示了当前在线的用户联系人。在这种情况下,显然还没有其他用户在线。现在让我们来看一下“主页”组件的源代码:

import React, { Component } from 'react';

import { withStyles } from 'material-ui/styles';
import Paper from 'material-ui/Paper';
import Avatar from 'material-ui/Avatar';
import IconButton from 'material-ui/IconButton';

import ContactMail from 'material-ui-icons/ContactMail';
import Message from 'material-ui-icons/Message';

import List, {
  ListItem,
  ListItemAvatar,
  ListItemText,
  ListItemSecondaryAction
} from 'material-ui/List';

import EmptyMessage from './EmptyMessage';
import { getContacts } from './api';

const styles = theme => ({
  root: {
    margin: '10px',
    width: '100%',
    maxWidth: 500,
    backgroundColor: theme.palette.background.paper
  }
});

class Home extends Component {
  state = {
    contacts: []
  };

  onMessageClick = id => () => {
    this.props.history.push(`/newmessage/${id}`);
  };

  componentWillMount() {
    const { setTitle, history } = this.props;

    setTitle('Barely SMS');

    const refresh = () =>
      getContacts().then(resp => {
        if (resp.status === 403) {
          history.push('/login');
        } else {
          resp.json().then(contacts => {
            this.setState({
              contacts: contacts.filter(contact => contact.online)
            });
          });
        }
      });

    this.refreshInterval = setInterval(refresh, 5000);
    refresh();
  }

  componentWillUnmount() {
    clearInterval(this.refreshInterval);
  }

  render() {
    const { classes } = this.props;
    const { contacts } = this.state;
    const { onMessageClick } = this;

    return (
      <Paper className={classes.root}>
        <EmptyMessage coll={contacts}>
          No contacts online
        </EmptyMessage>
        <List component="nav">
          {contacts.map(contact => (
            <ListItem key={contact.id}>
              <ListItemAvatar>
                <Avatar>
                  <ContactMail />
                </Avatar>
              </ListItemAvatar>
              <ListItemText primary={contact.name} />
              <ListItemSecondaryAction>
                <IconButton onClick={onMessageClick(contact.id)}>
                  <Message />
                </IconButton>
              </ListItemSecondaryAction>
            </ListItem>
          ))}
        </List>
      </Paper>
    );
  }
}

export default withStyles(styles)(Home);

componentWillMount()生命周期方法中,使用getContacts()函数获取联系人 API 端点。然后使用间隔重复此操作,以便当您的联系人登录时,它们会显示在这里。当组件被卸载时,间隔被清除。

为了测试这一点,我将打开 Firefox(实际上使用哪个浏览器并不重要,只要它与您登录为user1的地方不同)。从这里,我可以登录为user2,这是user1的联系人,反之亦然:

当我在这里第一次登录时,我看到用户 1 在另一个浏览器上线了:

现在,如果我回到在 Chrome 中登录为用户 1 的地方,我应该看到我的用户 2 联系人已经登录:

这个应用程序将在其他页面上遵循类似的刷新模式——使用间隔从 API 服务端点获取数据。

联系人页面

如果您想查看所有联系人,而不仅仅是当前在线的联系人,您必须转到联系人页面。要到达那里,您必须通过单击标题左侧的汉堡按钮展开导航菜单:

当您点击联系人链接时,您将进入看起来像这样的联系人页面:

这个页面与主页非常相似,只是显示了所有联系人。您可以向任何用户发送消息,而不仅仅是当前在线的用户。让我们来看看Contacts组件:

import React, { Component } from 'react';

import { withStyles } from 'material-ui/styles';
import Paper from 'material-ui/Paper';
import Avatar from 'material-ui/Avatar';
import IconButton from 'material-ui/IconButton';

import ContactMail from 'material-ui-icons/ContactMail';
import Message from 'material-ui-icons/Message';

import List, {
  ListItem,
  ListItemAvatar,
  ListItemText,
  ListItemSecondaryAction
} from 'material-ui/List';

import EmptyMessage from './EmptyMessage';
import { getContacts } from './api';

const styles = theme => ({
  root: {
    margin: '10px',
    width: '100%',
    maxWidth: 500,
    backgroundColor: theme.palette.background.paper
  }
});

class Contacts extends Component {
  state = {
    contacts: []
  };

  onMessageClick = id => () => {
    this.props.history.push(`/newmessage/${id}`);
  };

  componentWillMount() {
    const { setTitle, history } = this.props;

    setTitle('Contacts');

    const refresh = () =>
      getContacts().then(resp => {
        if (resp.status === 403) {
          history.push('/login');
        } else {
          resp.json().then(contacts => {
            this.setState({ contacts });
          });
        }
      });

    this.refreshInterval = setInterval(refresh, 5000);
    refresh();
  }

  componentWillUnmount() {
    clearInterval(this.refreshInterval);
  }

  render() {
    const { classes } = this.props;
    const { contacts } = this.state;
    const { onMessageClick } = this;

    return (
      <Paper className={classes.root}>
        <EmptyMessage coll={contacts}>No contacts</EmptyMessage>
        <List component="nav">
          {contacts.map(contact => (
            <ListItem key={contact.id}>
              <ListItemAvatar>
                <Avatar>
                  <ContactMail />
                </Avatar>
              </ListItemAvatar>
              <ListItemText primary={contact.name} />
              <ListItemSecondaryAction>
                <IconButton onClick={onMessageClick(contact.id)}>
                  <Message />
                </IconButton>
              </ListItemSecondaryAction>
            </ListItem>
          ))}
        </List>
      </Paper>
    );
  }
}

export default withStyles(styles)(Contacts);

像“主页”组件一样,“联系人”使用间隔模式来刷新联系人。例如,将来如果您想要在此页面上添加一个增强功能,以直观地指示哪些用户在线,您将需要从服务中获取最新数据。

消息页面

如果您展开导航菜单并访问消息页面,您会看到类似于这样的内容:

还没有消息。在发送消息之前,让我们看看Messages组件:

import React, { Component } from 'react';
import moment from 'moment';
import { Link } from 'react-router-dom';

import { withStyles } from 'material-ui/styles';
import Paper from 'material-ui/Paper';
import Avatar from 'material-ui/Avatar';
import List, {
  ListItem,
  ListItemAvatar,
  ListItemText
} from 'material-ui/List';

import Message from 'material-ui-icons/Message';

import EmptyMessage from './EmptyMessage';
import { getMessages } from './api';

const styles = theme => ({
  root: {
    margin: '10px',
    width: '100%',
    maxWidth: 500,
    backgroundColor: theme.palette.background.paper
  }
});

class Messages extends Component {
  state = {
    messages: []
  };

  componentWillMount() {
    const { setTitle, history } = this.props;

    setTitle('Messages');

    const refresh = () =>
      getMessages().then(resp => {
        if (resp.status === 403) {
          history.push('/login');
        } else {
          resp.json().then(messages => {
            this.setState({
              messages: messages.map(message => ({
                ...message,
                duration: moment
                  .duration(new Date() - new Date(message.timestamp))
                  .humanize()
              }))
            });
          });
        }
      });

    this.refreshInterval = setInterval(refresh, 5000);
    refresh();
  }

  componentWillUnmount() {
    clearInterval(this.refreshInterval);
  }

  render() {
    const { classes } = this.props;
    const { messages } = this.state;

    return (
      <Paper className={classes.root}>
        <EmptyMessage coll={messages}>No messages</EmptyMessage>
        <List component="nav">
          {messages.map(message => (
            <ListItem
              key={message.id}
              component={Link}
              to={`/messages/${message.id}`}
            >
              <ListItemAvatar>
                <Avatar>
                  <Message />
                </Avatar>
              </ListItemAvatar>
              <ListItemText
                primary={message.fromName}
                secondary={`${message.duration} ago`}
              />
            </ListItem>
          ))}
        </List>
      </Paper>
    );
  }
}

export default withStyles(styles)(Messages);

同样,这里也使用了刷新数据的间隔模式。当用户点击其中一条消息时,他们将被带到消息详情页面,可以阅读消息内容。

发送消息

让我们回到另一个浏览器(在我这里是 Firefox),您以 User 2 身份登录。点击 User 1 旁边的小消息图标:

这将带您到新消息页面:

继续输入消息,然后点击发送。然后,回到 Chrome,您以 User 1 身份登录。您应该会在消息页面上看到来自 User 2 的新消息:

如果您点击消息,您应该能够阅读消息内容:

在这里,您可以点击“回复”按钮,带您到新消息页面,该页面将发送给 User 2,或者您可以删除消息。在我们查看 API 代码之前,让我们看看NewMessage组件:

import React, { Component } from 'react';

import { withStyles } from 'material-ui/styles';
import Paper from 'material-ui/Paper';
import TextField from 'material-ui/TextField';
import Button from 'material-ui/Button';

import Send from 'material-ui-icons/Send';

import { getUser, postMessage } from './api';

const styles = theme => ({
  root: {
    display: 'flex',
    flexWrap: 'wrap',
    flexDirection: 'column'
  },
  textField: {
    marginLeft: theme.spacing.unit,
    marginRight: theme.spacing.unit,
    width: 500
  },
  button: {
    width: 500,
    margin: theme.spacing.unit
  },
  rightIcon: {
    marginLeft: theme.spacing.unit
  }
});

class NewMessage extends Component {
  state = {
    message: ''
  };

  onMessageChange = event => {
    this.setState({
      message: event.target.value
    });
  };

  onSendClick = () => {
    const { match: { params: { id } }, history } = this.props;
    const { message } = this.state;

    postMessage({ to: id, message }).then(() => {
      this.setState({ message: '' });
      history.push('/');
    });
  };

  componentWillMount() {
    const {
      match: { params: { id } },
      setTitle,
      history
    } = this.props;

    getUser(id).then(resp => {
      if (resp.status === 403) {
        history.push('/login');
      } else {
        resp.json().then(user => {
          setTitle(`New message for ${user.name}`);
        });
      }
    });
  }

  render() {
    const { classes } = this.props;
    const { message } = this.state;
    const { onMessageChange, onSendClick } = this;

    return (
      <Paper className={classes.root}>
        <TextField
          id="multiline-static"
          label="Message"
          multiline
          rows="4"
          className={classes.textField}
          margin="normal"
          value={message}
          onChange={onMessageChange}
        />
        <Button
          variant="raised"
          color="primary"
          className={classes.button}
          onClick={onSendClick}
        >
          Send
          <Send className={classes.rightIcon} />
        </Button>
      </Paper>
    );
  }
}

export default withStyles(styles)(NewMessage);

在这里,使用postMessage() API 函数来使用 API 服务发送消息。现在让我们看看MessageDetails组件:

import React, { Component } from 'react'; 
import { Link } from 'react-router-dom'; 

import { withStyles } from 'material-ui/styles'; 
import Paper from 'material-ui/Paper'; 
import Button from 'material-ui/Button'; 
import Typography from 'material-ui/Typography'; 

import Delete from 'material-ui-icons/Delete'; 
import Reply from 'material-ui-icons/Reply'; 

import { getMessage, deleteMessage } from './api'; 

const styles = theme => ({ 
  root: { 
    display: 'flex', 
    flexWrap: 'wrap', 
    flexDirection: 'column' 
  }, 
  message: { 
    width: 500, 
    margin: theme.spacing.unit 
  }, 
  button: { 
    width: 500, 
    margin: theme.spacing.unit 
  }, 
  rightIcon: { 
    marginLeft: theme.spacing.unit 
  } 
}); 

class NewMessage extends Component { 
  state = { 
    message: {} 
  }; 

  onDeleteClick = () => { 
    const { history, match: { params: { id } } } = this.props; 

    deleteMessage(id).then(() => { 
      history.push('/messages'); 
    }); 
  }; 

  componentWillMount() { 
    const { 
      match: { params: { id } }, 
      setTitle, 
      history 
    } = this.props; 

    getMessage(id).then(resp => { 
      if (resp.status === 403) { 
        history.push('/login'); 
      } else { 
        resp.json().then(message => { 
          setTitle(`Message from ${message.fromName}`); 
          this.setState({ message }); 
        }); 
      } 
    }); 
  } 

  render() { 
    const { classes } = this.props; 
    const { message } = this.state; 
    const { onDeleteClick } = this; 

    return ( 
      <Paper className={classes.root}> 
        <Typography className={classes.message}> 
          {message.message} 
        </Typography> 
        <Button 
          variant="raised" 
          color="primary" 
          className={classes.button} 
          component={Link} 
          to={`/newmessage/${message.from}`} 
        > 
          Reply 
          <Reply className={classes.rightIcon} /> 
        </Button> 
        <Button 
          variant="raised" 
          color="primary" 
          className={classes.button} 
          onClick={onDeleteClick} 
        > 
          Delete 
          <Delete className={classes.rightIcon} /> 
        </Button> 
      </Paper> 
    ); 
  } 
} 

export default withStyles(styles)(NewMessage); 

在这里,使用getMessage() API 函数来加载消息内容。请注意,这两个组件都没有使用其他组件一直在使用的刷新模式,因为信息从不改变。

API

API 是您的 React 应用与之交互以检索和操作数据的服务。在考虑部署生产 React 应用程序时,重要的是使用 API 作为抽象,它不仅代表一个服务,还可能代表应用程序与之交互的多个微服务。

说到这里,让我们来看看您的 React 组件使用的 API 函数,这些组件组成了Barely SMS

export const login = body => 
  fetch('/api/login', { 
    method: 'post', 
    headers: { 'Content-Type': 'application/json' }, 
    body: JSON.stringify(body), 
    credentials: 'same-origin' 
  }); 

export const logout = user => 
  fetch('/api/logout', { 
    method: 'post', 
    credentials: 'same-origin' 
  }); 

export const getUser = id => 
  fetch(`/api/user/${id}`, { credentials: 'same-origin' }); 

export const getContacts = () => 
  fetch('/api/contacts', { credentials: 'same-origin' }); 

export const getMessages = () => 
  fetch('/api/messages', { credentials: 'same-origin' }); 

export const getMessage = id => 
  fetch(`/api/message/${id}`, { credentials: 'same-origin' }); 

export const postMessage = body => 
  fetch('/api/messages', { 
    method: 'post', 
    headers: { 'Content-Type': 'application/json' }, 
    body: JSON.stringify(body), 
    credentials: 'same-origin' 
  });

export const deleteMessage = id => 
  fetch(`/api/message/${id}`, { 
    method: 'delete', 
    credentials: 'same-origin' 
  }); 

这些简单的抽象使用fetch()来向 API 服务发出 HTTP 请求。目前,只有一个 API 服务作为单个进程运行,其中包含模拟用户数据,并且所有更改仅在内存中发生,不会持久保存:

const express = require('express'); 
const bodyParser = require('body-parser'); 
const cookieParser = require('cookie-parser'); 

const sessions = []; 
const messages = []; 
const users = { 
  user1: { 
    name: 'User 1', 
    contacts: ['user2', 'user3', 'user4', 'user5'], 
    online: false 
  }, 
  user2: { 
    name: 'User 2', 
    contacts: ['user1', 'user3', 'user4', 'user5'], 
    online: false 
  }, 
  user3: { 
    name: 'User 3', 
    contacts: ['user1', 'user2', 'user4', 'user5'], 
    online: false 
  }, 
  user4: { 
    name: 'User 4', 
    contacts: ['user1', 'user2', 'user3', 'user5'], 
    online: false 
  }, 
  user5: { 
    name: 'User 5', 
    contacts: ['user1', 'user2', 'user3', 'user4'] 
  } 
}; 

const authenticate = (req, res, next) => { 
  if (!sessions.includes(req.cookies.session)) { 
    res.status(403).end(); 
  } else { 
    next(); 
  } 
}; 

const app = express(); 
app.use(cookieParser()); 
app.use(bodyParser.json()); 
app.use(bodyParser.urlencoded({ extended: true })); 

app.post('/api/login', (req, res) => { 
  const { user } = req.body; 

  if (users.hasOwnProperty(user)) { 
    sessions.push(user); 
    users[user].online = true; 
    res.cookie('session', user); 
    res.end(); 
  } else { 
    res.status(403).end(); 
  } 
}); 

app.post('/api/logout', (req, res) => { 
  const { session } = req.cookies; 
  const index = sessions.indexOf(session); 

  sessions.splice(index, 1); 
  users[session].online = false; 

  res.clearCookie('session'); 
  res.status(200).end(); 
}); 

app.get('/api/user/:id', authenticate, (req, res) => { 
  res.json(users[req.params.id]); 
}); 

app.get('/api/contacts', authenticate, (req, res) => { 
  res.json( 
    users[req.cookies.session].contacts.map(id => ({ 
      id, 
      name: users[id].name, 
      online: users[id].online 
    })) 
  ); 
}); 

app.post('/api/messages', authenticate, (req, res) => { 
  messages.push({ 
    from: req.cookies.session, 
    fromName: users[req.cookies.session].name, 
    to: req.body.to, 
    message: req.body.message, 
    timestamp: new Date() 
  }); 

  res.status(201).end(); 
}); 

app.get('/api/messages', authenticate, (req, res) => { 
  res.json( 
    messages 
      .map((message, id) => ({ ...message, id })) 
      .filter(message => message.to === req.cookies.session) 
  ); 
}); 

app.get('/api/message/:id', authenticate, (req, res) => { 
  const { params: { id } } = req; 
  res.json({ ...messages[id], id }); 
}); 

app.delete('/api/message/:id', authenticate, (req, res) => { 
  messages.splice(req.params.id, 1); 
  res.status(200).end(); 
}); 

app.listen(3001, () => 
  console.log('API server listening on port 3001!') 
);

这是一个 Express 应用程序,它将应用程序数据保存在简单的 JavaScript 对象和数组中。虽然现在所有事情都发生在这一个服务中,但情况可能并非总是如此。其中一些 API 调用可能存在于不同的服务中。这就是将部署到容器如此强大的原因——您可以在高级别上抽象复杂的部署。

开始使用 Node 容器

让我们首先通过在 Node.js Docker 镜像中运行Barely SMS React 开发服务器来开始。请注意,这不是生产部署的一部分。这只是一个起点,让您熟悉部署 Docker 容器。随着本章剩余部分的进行,您将逐渐向生产级部署迈进。

将 React 应用程序放入容器的第一步是创建一个Dockerfile。如果您的系统尚未安装 Docker,请在此处找到安装说明:www.docker.com/community-edition。如果您打开终端并切换到getting-started-with-containers目录,您将看到一个名为Dockerfile的文件。它看起来是这样的:

FROM node:alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD [ "npm", "start" ]

这是用于构建镜像的文件。镜像就像是运行 React 应用程序的容器进程的模板。基本上,这些行执行以下操作:

  • FROM node:alpine:这个镜像使用的基础镜像是什么。这是一个带有 Node.js 的小型 Linux 版本。

  • WORKDIR /usr/src/app:更改容器上的工作目录。

  • COPY package*.json ./:将package.jsonpackage-lock.json复制到容器中。

  • RUN npm install:在容器上安装 npm 包依赖项。

  • COPY . .:将您的应用程序的源代码复制到容器中。

  • EXPOSE 3000:在容器运行时暴露端口3000

  • CMD [ "npm", "start" ]:容器启动时运行npm start

接下来要添加的文件是.dockerignore文件。此文件列出了您不希望通过COPY命令包含在镜像中的所有内容。它看起来像这样:

node_modules
npm-debug.log

重要的是,您不要复制您在系统上安装的npm_modules,因为npm install命令将再次安装它们,您将拥有两份库的副本。

在构建可以部署的 Docker 镜像之前,有一些小的更改需要进行。首先,您需要弄清楚您的 IP 地址,以便您可以用它与 API 服务器进行通信。您可以通过在终端中运行ifconfig来找到它。一旦您找到了它,您可以更新package.json中的proxy值。以前是这样的:

http://localhost:3001

现在它应该有一个 IP 地址,以便您的 Docker 容器在运行时可以访问它。这是我的现在的样子:

http://192.168.86.237:3001

接下来,您需要将您的 IP 作为参数传递给server.js中的listen()方法。以前是这样的:

app.listen(3001, () => 
  console.log('API server listening on port 3001!') 
); 

这是我的现在的样子:

app.listen(3001, '192.168.86.237', () => 
  console.log('API server listening on port 3001!') 
); 

现在您可以通过运行以下命令来构建 Docker 镜像:

docker build -t barely-sms-ui . 

这将使用当前目录中找到的Dockerfile构建一个 ID 为barely-sms-ui的镜像。构建完成后,您可以通过运行docker images来查看镜像。输出应该类似于这样:

REPOSITORY       TAG      IMAGE ID       CREATED       SIZE
barely-sms-ui    latest   b1526915598d   7 hours ago   267MB

现在您可以使用以下命令部署容器:

docker run -p 3000:3000 barely-sms-ui

要清理旧的未使用的容器,您可以运行以下命令:

docker system prune

-p 3000:3000参数确保容器上的暴露端口3000映射到您系统上的端口3000。您可以通过打开http://localhost:3000/来测试这一点。但是,您可能会看到类似于这样的错误:

如果您查看容器控制台输出,您将看到类似以下的内容:

    Proxy error: Could not proxy request /api/contacts from localhost:3000 to http://192.168.86.237:3001.
    See https://nodejs.org/api/errors.html#errors_common_system_errors for more information (ECONNREFUSED).

这是因为您还没有启动 API 服务器。如果您将无效的 IP 地址作为代理地址,您实际上会看到类似的错误。如果您需要更改代理值,您将需要重新构建镜像,然后重新启动容器。如果您在另一个终端中运行npm run api来启动 API,然后重新加载 UI,一切应该按预期工作。

使用服务构建 React 应用

前一部分的主要挑战是,你有一个作为运行容器的用户界面服务。另一方面,API 服务正在做自己的事情。你将学习如何使用的下一个工具是docker-compose。顾名思义,docker-compose是用来将较小的服务组合成较大应用程序的工具。Barely SMS的下一个自然步骤是使用这个 Docker 工具来制作 API 服务,并将两个服务作为一个应用程序进行控制。

这一次,我们需要两个Dockerfile文件。你可以重用前面部分的Dockerfile,只需将其重命名为Dockerfile.ui。然后,创建另一个几乎相同的Dockerfile,将其命名为Dockerfile.api并给它以下内容:

FROM node:alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3001
CMD [ "npm", "run", "api" ]

两个不同之处是EXPOSE端口值和运行的CMD。这个命令启动 API 服务器而不是 React 开发服务器。

在构建镜像之前,server.jspackage.js文件需要进行轻微调整。在package.json中,代理可以简单地指向http://api:3001。在server.js中,确保你不再向listen()传递特定的 IP 地址。

app.listen(3001, () => 
  console.log('API server listening on port 3001!') 
); 

构建这两个镜像也需要进行轻微修改,因为你不再使用标准的Dockerfile名称。以下是构建 UI 镜像的方法:

docker build -f Dockerfile.ui -t barely-sms-ui . 

然后,构建 API 镜像:

docker build -f Dockerfile.api -t barely-sms-api .

在这一点上,你已经准备好创建一个docker-compose.yml。这是你在调用时声明docker-compose工具应该做什么的方式。它看起来像这样:

api:
  image: barely-sms-api
  expose:
    - 3001
  ports:
    - "3001:3001"

ui:
  image: barely-sms-ui
  expose:
    - 3000
  links:
    - api
  ports:
    - "3000:3000"

正如你所看到的,这个 YAML 标记分为两个服务。首先是api服务,它指向barely-sms-api镜像并相应地映射端口。然后是ui服务,它做同样的事情,只是它指向barely-sms-ui镜像并映射到不同的端口。它还链接到 API 服务,因为你希望在任何浏览器中加载 UI 之前确保 API 服务可用。

要启动服务,你可以运行以下命令:

docker-compose up

然后,您应该在控制台中看到来自两个服务的日志。然后,如果您访问http://localhost:3000/,您应该能够像往常一样使用Barely SMS,只是这一次,一切都是自包含的。从这一点开始,您将更有可能根据需求发展您的应用程序。必要时,您可以添加新的服务,并让您的 React 组件与它们通信,就像它们都在与同一个应用程序交谈一样,同时保持服务的模块化和自包含性。

生产环境的静态 React 构建

使Barely SMS准备好进行生产部署的最后一步是从 UI 服务中删除 React 开发服务器。开发服务器从未被用于生产环境,因为它有许多部分可以帮助开发人员,但最终会减慢整体用户体验,并且在生产环境中没有位置。

您可以使用一个简单的 NGINX HTTP 服务器来代替基于 Node.js 的镜像,该服务器提供静态内容。由于这是一个生产环境,您不需要一个能够即时构建 UI 资产的开发服务器,您可以只使用create-react-app构建脚本来构建 NGINX 要提供的静态构件:

npm run build

然后,您可以更改Dockerfile.ui文件,使其看起来像这样:

FROM nginx:alpine 
EXPOSE 3000 
COPY nginx.conf /etc/nginx/nginx.conf 
COPY build /data/www 
CMD ["nginx", "-g", "daemon off;"] 

这次,镜像是基于一个提供静态内容的 NGINX 服务器,并且我们传递了一个nginx.conf文件。这是它的样子:

worker_processes 2; 

events { 
  worker_connections 2048; 
} 

http { 
  upstream service_api { 
    server api:3001; 
  } 

  server { 
    location / { 
      root /data/www; 
      try_files $uri /index.html; 
    } 

    location /api { 
      proxy_pass http://service_api; 
    } 
  } 
} 

在这里,您可以对 HTTP 请求发送的位置进行精细级别的控制。例如,如果/api/login/api/logout端点被移动到它们自己的服务中,您可以在这里控制这个变化,而不必重新构建 UI 图像。

需要做的最后一个变化是docker-compose.yml

api: 
  image: barely-sms-api 
  expose: 
    - 3001 
  ports: 
    - "3001:3001" 

ui: 
  image: barely-sms-ui 
  expose: 
    - 80 
  links: 
    - api 
  ports: 
    - "3000:80" 

您是否注意到端口3000现在映射到ui服务中的端口80?这是因为 NGINX 在端口80上提供服务。如果您运行docker-compose up,您应该能够访问http://localhost:3000/并与您的静态构建进行交互。

恭喜!没有了 React 开发服务器,您几乎可以从构建工具的角度准备好进行生产。

总结

在这一章中,您构建了一个名为“Barely SMS”的简单消息应用程序。然后,您学习了如何将此应用程序部署为 Docker 容器。接着,您学习了如何将服务打包在一起,包括 UI 服务,这样在部署具有许多移动部分的应用程序时,您就有了更高级的抽象层来处理。最后,您学习了如何构建生产就绪的静态资产,并使用工业级的 HTTP 服务器 NGINX 来提供它们。

我希望这是一次启发性的阅读。写作既是挑战,也是快乐。在过去的十年里,Web 开发中的工具应该不应该像它一样困难。像 React 这样的项目和 Chrome 等浏览器供应商开始改变这一趋势。我相信任何技术都取决于其工具。现在您对 React 生态系统中可用的工具有了牢固的掌握,将其充分利用,并让它为您做艰苦的工作。

posted @ 2024-05-16 14:49  绝不原创的飞龙  阅读(4)  评论(0编辑  收藏  举报