React-项目-全-

React 项目(全)

原文:zh.annas-archive.org/md5/67d21690ff58712c68c8d6f205c8e0a0

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书将帮助您将 React 知识提升到下一个水平,教您如何应用基本和高级的 React 模式来创建跨平台应用程序。React 的概念以一种既适合新手又适合有经验的开发人员理解的方式进行描述;虽然不需要有 React 的先前经验,但这将有所帮助。

在本书的 12 章中,您将使用 React、React Native 或 React 360 创建一个项目。这些章节中创建的项目实现了流行的 React 功能,如用于重用逻辑的高阶组件(HOCs)、用于状态管理的上下文 API 和用于生命周期的 Hooks。用于路由的流行库,如 React Router 和 React Navigation,以及用于编写应用程序的单元测试的 JavaScript 测试框架 Jest。此外,一些更高级的章节涉及 GraphQL 服务器,并且 Expo 用于帮助您创建 React Native 应用程序。

本书适合对象

本书适用于希望探索用于构建跨平台应用程序的 React 工具和框架的 JavaScript 开发人员。对 Web 开发、ECMAScript 和 React 的基本知识将有助于理解本书涵盖的关键概念。

本书支持的 React 版本为:

  • React - v16.10.2

  • React Native - v0.59

  • React 360 - v1.1.0

本书涵盖的内容

第一章,“在 React 中创建电影列表应用程序”,将探讨构建可扩展的 React 项目的基础。将讨论和实践如何组织文件、使用的包和工具的最佳实践。通过构建电影列表来展示构建 React 项目的最佳方法。此外,使用 webpack 和 Babel 来编译代码。

第二章,“使用可重用的 React 组件创建渐进式 Web 应用程序”,将解释如何在整个应用程序中设置和重用 React 组件中的样式。我们将构建一个 GitHub 卡片应用程序,以了解如何在 JavaScript 中使用 CSS 并在应用程序中重用组件和样式。

第三章,“使用 React 和 Suspense 构建动态项目管理看板”,将介绍如何创建确定其他组件之间数据流的组件,即所谓的 HOCs。我们将构建一个项目管理看板,以了解数据在整个应用程序中的流动。

第四章,使用 React Router 构建基于 SSR 的社区动态,将讨论路由设置,从设置基本路由、动态路由处理,到如何为服务器端渲染设置路由。

第五章,使用 Context API 和 Hooks 构建个人购物清单应用程序,将向您展示如何使用 React 上下文 API 和 Hooks 处理整个应用程序中的数据流。我们将创建一个个人购物清单,以了解如何使用 Hooks 和上下文 API 从父组件到子组件以及反之访问和更改数据。

第六章,使用 Jest 和 Enzyme 构建探索 TDD 的应用程序,将专注于使用断言和快照进行单元测试。还将讨论测试覆盖率。我们将构建一个酒店评论应用程序,以了解如何测试组件和数据流。

第七章,使用 React Native 和 GraphQL 构建全栈电子商务应用程序,将使用 GraphQL 为应用程序提供后端。本章将向您展示如何设置基本的 GraphQL 服务器并访问该服务器上的数据。我们将构建一个电子商务应用程序,以了解如何创建服务器并向其发送请求。

第八章,使用 React Native 和 Expo 构建房屋列表应用程序,将涵盖 React Native 应用程序的扩展和结构,这与使用 React 创建的 Web 应用程序略有不同。本章将概述开发环境和工具(如 Expo)的差异。我们将构建一个房屋列表应用程序,以检验最佳实践。

第九章,使用 React Native 和 Expo 构建动画游戏,将讨论动画和手势,这正是移动应用程序与 Web 应用程序的真正区别。本章将解释如何实现它们。此外,通过构建一个具有动画并响应手势的纸牌游戏应用程序,将展示 iOS 和 Android 之间手势的差异。

第十章 使用 React Native 和 Expo 创建实时消息应用程序,将涵盖通知,这对于让应用程序的用户保持最新状态非常重要。本章将展示如何通过 Expo 从 GraphQL 服务器添加通知并发送通知。我们将通过构建消息应用程序来学习如何实现所有这些。

第十一章 使用 React Native 和 GraphQL 构建全栈社交媒体应用程序,将介绍如何使用 React Native 和 GraphQL 构建全栈应用程序。演示服务器和应用程序之间的数据流动,以及如何从 GraphQL 服务器获取数据。

第十二章 使用 React 360 创建虚拟现实应用程序,将讨论如何通过创建全景查看器来开始使用 React 360,使用户能够在虚拟世界中四处张望并在其中创建组件。

为了充分利用本书

本书中的所有项目都是使用 React、React Native 或 React 360 创建的,需要您具备 JavaScript 的基础知识。虽然本书中描述了 React 和相关技术的所有概念,但我们建议您在想要了解更多功能时参考 React 文档。在接下来的部分,您可以找到关于为本书设置您的计算机以及如何下载每一章的代码的一些信息。

设置您的计算机

对于本书中创建的应用程序,您需要至少在您的计算机上安装 Node.js v10.16.3,以便您可以运行 npm 命令。如果您尚未在计算机上安装 Node.js,请访问nodejs.org/en/download/,在那里您可以找到 macOS、Windows 和 Linux 的下载说明。

安装 Node.js 后,在命令行中运行以下命令以检查已安装的版本:

  • 对于 Node.js(应为 v10.16.3 或更高版本):
node -v
  • 对于 npm(应为 v6.9.0 或更高版本):
npm -v

此外,您还应该安装React Developer Tools插件(适用于 Chrome 和 Firefox)并将其添加到您的浏览器中。可以从Chrome Web Store(chrome.google.com/webstore)或 Firefox Addons(addons.mozilla.org)安装此插件。

下载示例代码文件

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

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

  1. www.packt.com上登录或注册。

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

  3. 单击“代码下载”。

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

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

  • Windows 的 WinRAR/7-Zip

  • Mac 的 Zipeg/iZip/UnRarX

  • Linux 的 7-Zip/PeaZip

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

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

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载:static.packt-cdn.com/downloads/9781789954937_ColorImages.pdf

使用的约定

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

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这里有一个例子:“由于您将在本章中构建一个电影列表应用程序,因此将此目录命名为movieList。”

代码块设置如下:

{
    "name": "movieList",
    "version": "1.0.0",
    "description": "",
    "main": "index.js",
    "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1"
    },
    "keywords": [],
    "author": "",
    "license": "ISC"
}

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

import React from 'react';
import ReactDOM from 'react-dom';
+ import List from './containers/List';

const App = () => {
-   return <h1>movieList</h1>;
+   return <List />;
};

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

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

npm init -y

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这里有一个例子:“当用户单击关闭 X 按钮时,组件的显示样式规则将设置为 none。”

警告或重要说明是这样显示的。提示和技巧是这样显示的。

第一章:在 React 中创建电影列表应用程序

当您购买这本书时,您可能之前已经听说过 React,甚至可能尝试过一些在线找到的代码示例。这本书的构建方式是,每一章的代码示例逐渐增加复杂性,因此即使您对 React 的经验有限,每一章也应该是可以理解的,如果您已经阅读了前一章。当您阅读完本书时,您将了解如何使用 React 及其稳定功能,直到 16.11 版本,并且您还将有使用 React Native 和 React 360 的经验。

本章首先学习如何构建一个简单的电影列表应用程序,并为您提供我们将从外部来源获取的热门电影的概述。入门 React 的核心概念将应用于这个项目,如果您之前有一些使用 React 构建应用程序的经验,这应该是可以理解的。如果您之前没有使用过 React,也没有问题;本书将沿途描述代码示例中使用的 React 功能。

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

  • 使用 webpack 和 React 设置新项目

  • 构建 React 项目结构

让我们开始吧!

项目概述

在本章中,我们将在 React 中创建一个电影列表应用程序,该应用程序从本地 JSON 文件中检索数据,并在浏览器中使用 webpack 和 Babel 运行。样式将使用 Bootstrap 完成。您将构建的应用程序将返回截至 2019 年的最卖座电影列表,以及一些更多的细节和每部电影的海报。

构建时间为 1 小时。

入门

本章的应用程序将从头开始构建,并使用可以在 GitHub 上找到的资产:github.com/PacktPublishing/React-Projects/tree/ch1-assets。这些资产应下载到您的计算机上,以便您稍后在本章中使用。本章的完整代码也可以在 GitHub 上找到:github.com/PacktPublishing/React-Projects/tree/ch1

对于本书中创建的应用程序,您需要在计算机上安装至少 Node.js v10.16.3,以便可以运行npm命令。如果您尚未在计算机上安装 Node.js,请转到nodejs.org/en/download/,在那里您可以找到 macOS、Windows 和 Linux 的下载说明。

安装 Node.js 后,在命令行中运行以下命令以检查已安装的版本:

  • 对于 Node.js(应为 v10.16.3 或更高版本):
node -v
  • 对于npm(应为 v6.9.0 或更高版本):
npm -v

此外,您应该已安装了React Developer Tools插件(适用于 Chrome 和 Firefox),并将其添加到浏览器中。可以从Chrome Web Storechrome.google.com/webstore)或 Firefox Addons(addons.mozilla.org)安装此插件。

创建电影列表应用程序

在本节中,我们将从头开始创建一个新的 React 应用程序,首先设置一个带有 webpack 和 Babel 的新项目。从头开始设置一个 React 项目将帮助您了解项目的基本需求,这对您创建的任何项目都是至关重要的。

设置项目

每次创建新的 React 项目时,第一步是在本地计算机上创建一个新目录。由于您将在本章中构建一个电影列表应用程序,因此将此目录命名为movieList

在这个新目录中,从命令行执行以下操作:

npm init -y

运行此命令将创建一个package.json文件,其中包含npm对该项目的基本信息的最低要求。通过在命令中添加-y标志,我们可以自动跳过设置nameversiondescription等信息的步骤。运行此命令后,将创建以下package.json文件:

{
    "name": "movieList",
    "version": "1.0.0",
    "description": "",
    "main": "index.js",
    "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1"
    },
    "keywords": [],
    "author": "",
    "license": "ISC"
}

如您所见,由于我们尚未安装任何依赖项,因此npm包没有依赖项。我们将在本节的下一部分中安装和配置的第一个包是 webpack。

设置 webpack

要运行 React 应用程序,我们需要安装 webpack 4(在撰写本书时,webpack 的当前稳定版本为版本 4)和 webpack CLI 作为devDependencies。让我们开始吧:

  1. 使用以下命令从npm安装这些包:
npm install --save-dev webpack webpack-cli
  1. 下一步是在package.json文件中包含这些包,并在我们的启动和构建脚本中运行它们。为此,将startbuild脚本添加到我们的package.json文件中:
{
    "name": "movieList",
    "version": "1.0.0",
    "description": "",
    "main": "index.js",
    "scripts": {
_       "start": "webpack --mode development",
+       "build": "webpack --mode production",
        "test": "echo \"Error: no test specified\" && exit 1"
    },
    "keywords": [],
    "author": "",
    "license": "ISC"
}

"+"符号用于添加的行,"-"符号用于删除的行在代码中。

上述配置将使用 webpack 为我们的应用程序添加startbuild脚本。正如您所看到的,npm start将在开发模式下运行 webpack,而npm build将在生产模式下运行 webpack。最大的区别在于,在生产模式下运行 webpack 将最小化我们的代码,以减小项目捆绑的大小。

  1. 在我们的项目内创建一个名为src的新目录,并在这个目录内创建一个名为index.js的新文件。稍后,我们将配置 webpack,使这个文件成为我们应用程序的起点。将以下代码放入这个新创建的文件中:
console.log("movieList")

如果我们现在在命令行中运行npm startnpm build命令,webpack 将启动并创建一个名为dist的新目录。在这个目录里,将会有一个名为main.js的文件,其中包含我们的项目代码。根据我们是在开发模式还是生产模式下运行 webpack,这个文件中的代码将被最小化。您可以通过运行以下命令来检查您的代码是否工作:

node dist/main.js

这个命令运行我们应用程序的捆绑版本,并应该在命令行中返回movieList字符串作为输出。现在,我们可以从命令行运行 JavaScript 代码。在本节的下一部分中,我们将学习如何配置 webpack,使其与 React 一起工作。

配置 webpack 以与 React 一起工作

现在我们已经为 JavaScript 应用程序设置了一个基本的开发环境,可以开始安装我们运行任何 React 应用程序所需的包。这些包括reactreact-dom,前者是 React 的通用核心包,后者提供了浏览器 DOM 的入口点,并渲染 React。让我们开始吧:

  1. 通过在命令行中执行以下命令来安装这些包:
npm install react react-dom

仅仅安装 React 的依赖是不足以运行它的,因为默认情况下,并非每个浏览器都能读取您的 JavaScript 代码所写的格式(如 ES2015+或 React)。因此,我们需要将 JavaScript 代码编译成每个浏览器都能读取的格式。

  1. 为此,我们将使用 Babel 及其相关包,可以通过运行以下命令将其安装为devDependencies
npm install --save-dev @babel/core @babel/preset-env @babel/preset-react babel-loader

除了 Babel 核心之外,我们还将安装babel-loader,这是一个辅助工具,使得 Babel 可以与 webpack 一起运行,并安装两个预设包。这些预设包有助于确定将用于将我们的 JavaScript 代码编译为浏览器可读格式的插件(@babel/preset-env)以及编译 React 特定代码(@babel/preset-react)。

安装了 React 和正确的编译器包后,下一步是使它们与 webpack 配合工作,以便在运行应用程序时使用它们。

  1. 要做到这一点,在项目的根目录中创建一个名为webpack.config.js的文件。在这个文件中,添加以下代码:
module.exports = {
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /node_modules/,
                use: {
                    loader:'"babel-loader',
                },
            },
        ],
    },
}

这个文件中的配置告诉 webpack 对具有.js扩展名的每个文件使用babel-loader,并排除 Babel 编译器中node_modules目录中的.js文件。babel-loader的实际设置放在一个名为.babelrc的单独文件中。

  1. 我们还可以在项目的根目录中创建.babelrc文件,并在其中放置以下代码,该代码配置babel-loader在编译我们的代码时使用@babel/preset-env@babel/preset-react预设:
{
    "presets": [
        [
            "@babel/preset-env", 
            {
                "targets": {
                    "node": "current"
                }
            }
        ],
        "@babel/react"
    ]
}

我们还可以直接在webpack.config.js文件中声明babel-loader的配置,但为了更好的可读性,我们应该将其放在一个单独的.babelrc文件中。此外,Babel 的配置现在可以被与 webpack 无关的其他工具使用。

@babel/preset-env预设中定义了选项,确保编译器使用最新版本的 Node.js,因此诸如async/await等功能的 polyfill 仍然可用。现在我们已经设置了 webpack 和 Babel,我们可以从命令行运行 JavaScript 和 React。在本节的下一部分中,我们将创建我们的第一个 React 代码,并使其在浏览器中运行。

渲染 React 项目

现在我们已经设置了 React,使其可以与 Babel 和 webpack 一起工作,我们需要创建一个实际的 React 组件,以便进行编译和运行。创建一个新的 React 项目涉及向项目添加一些新文件,并对 webpack 的设置进行更改。让我们开始吧:

  1. 让我们编辑src目录中已经存在的index.js文件,以便我们可以使用reactreact-dom
import React from 'react';
import ReactDOM from 'react-dom';

const App = () => {
    return <h1>movieList</h1>;
};

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

正如你所看到的,这个文件导入了reactreact-dom包,定义了一个简单的组件,返回一个包含你的应用程序名称的h1元素,并使用react-dom渲染了这个组件。代码的最后一行将App组件挂载到文档中rootID 的元素上,这是应用程序的入口点。

  1. 我们可以通过在src目录中添加一个名为index.html的新文件并在其中添加以下代码来创建此文件:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>movieList</title>
</head>
<body>
    <section id="root"></section>
</body>
</html>

这将添加一个 HTML 标题和主体。在head标签中是我们应用程序的标题,在body标签中是一个带有id属性root的部分。这与我们在src/index.js文件中将App组件挂载到的元素相匹配。

  1. 渲染我们的 React 组件的最后一步是扩展 webpack,以便在运行时将压缩的捆绑代码添加到body标签作为scripts。因此,我们应该将html-webpack-plugin包安装为 devDependency:
npm install --save-dev html-webpack-plugin

将这个新包添加到webpack.config.js文件中的 webpack 配置中:

const HtmlWebPackPlugin = require('html-webpack-plugin');

const htmlPlugin = new HtmlWebPackPlugin({
 template: './src/index.html',
 filename: './index.html',
});

module.exports = {
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /node_modules/,
                use: {
                    loader: 'babel-loader',
                },
            },
        ],
    },
    plugins: [htmlPlugin],
};

html-webpack-plugin的配置中,我们将应用程序的入口点设置为index.html文件。这样,webpack 就知道在body标签中添加捆绑包的位置。

我们还可以通过在导出的 webpack 配置中直接添加插件的配置来将这个新包添加到 webpack 配置中,以替换导出配置中的htmlPlugin常量。随着我们的应用程序规模的增长,这可能会使 webpack 配置变得不太可读,这取决于我们的偏好。

现在,如果我们再次运行npm start,webpack 将以开发模式启动,并将index.html文件添加到dist目录中。在这个文件中,我们会看到,在你的body标签中,一个新的scripts标签已经被插入,指向我们的应用程序捆绑包,也就是dist/main.js文件。如果我们在浏览器中打开这个文件,或者从命令行运行open dist/index.html,它将直接在浏览器中返回movieList的结果。当运行npm build命令以启动生产模式下的 Webpack 时,我们也可以做同样的操作;唯一的区别是我们的代码将被压缩。

通过使用 webpack 设置开发服务器,可以加快这个过程。我们将在本节的最后部分进行这个操作。

创建开发服务器

在开发模式下工作时,每次对应用程序中的文件进行更改时,我们需要重新运行npm start命令。由于这有点繁琐,我们将安装另一个名为webpack-dev-server的包。该包添加了选项,强制 webpack 在我们对项目文件进行更改时重新启动,并将我们的应用程序文件管理在内存中,而不是构建dist目录。webpack-dev-server包也可以使用npm安装:

npm install --save-dev webpack-dev-server

此外,我们需要编辑package.json文件中的start脚本,以便在运行start脚本时直接使用webpack-dev-server而不是 webpack:

{
    "name": "movieList",
    "version": "1.0.0",
    "description": "",
    "main": "index.js",
    "scripts": {
-       "start": "webpack --mode development",
+       "start": "webpack-dev-server --mode development --open",        
        "build": "webpack --mode production"
    },
    "keywords": [],
    "author": "",
    "license": "ISC"

    ...
}

上述配置将在启动脚本中用webpack-dev-server替换 webpack,以开发模式运行 webpack。这将创建一个本地服务器,使用--open标志运行应用程序,确保每次更新项目文件时 webpack 都会重新启动。

要启用热重载,将--open标志替换为--hot标志。这将仅重新加载已更改的文件,而不是整个项目。

现在,我们已经为 React 应用程序创建了基本的开发环境,在本章的下一部分中,您将进一步开发和构建它。

项目结构

设置开发环境后,是时候开始创建电影列表应用程序了。首先让我们看一下项目的当前结构,在项目根目录中有两个重要的目录:

  • 第一个目录称为dist,其中包含 webpack 打包版本的应用程序输出

  • 第二个称为src,包括我们应用程序的源代码:

movieList
|-- dist
    |-- index.html
    |-- main.js
|-- node_modules
|-- src
    |-- index.js
    |-- index.html
.babelrc
package.json
webpack.config.js

在我们项目的根目录中还可以找到另一个目录,名为node_modules。这是我们使用npm安装的每个包的源文件所在的地方。建议您不要手动更改此目录中的文件。

在接下来的小节中,我们将学习如何构建 React 项目。这种结构将在本书的其余章节中使用。

创建新组件

React 的官方文档并未说明如何构建 React 项目的首选方法。尽管社区中有两种常见的方法:按功能或路由结构化文件,或按文件类型结构化文件。

电影列表应用程序将采用混合方法,首先按文件类型结构化,其次按功能结构化。实际上,这意味着将有两种类型的组件:顶层组件,称为容器,和与这些顶层组件相关的低级组件。创建这些组件需要添加以下文件和代码更改:

  1. 实现这种结构的第一步是在src目录下创建一个名为containers的新子目录。在此目录中,创建一个名为List.js的文件。这将是包含电影列表的容器,其中包含以下内容:
import React, { Component } from 'react';

class List extends Component {
    render() {
        return <h1>movieList</h1>;
    }
};

export default List;
  1. 应该在应用程序的入口点中包含此容器,以便它可见。因此,我们需要在src目录内的index.js文件中包含它,并引用它:
import React from 'react';
import ReactDOM from 'react-dom';
+ import List from './containers/List';

const App = () => {
-   return <h1>movieList</h1>;
+   return <List />;
};

ReactDOM.render(<App />, document.getElementById('root'));
  1. 如果我们仍在运行开发服务器(如果没有,请再次执行npm start命令),我们将看到我们的应用程序仍然返回相同的结果。我们的应用程序应该具有以下文件结构:
movieList
|-- dist
    |-- index.html
    |-- main.js
|-- src
 |-- containers
 |-- List.js
    |-- index.js
    |-- index.html
.babelrc
package.json
webpack.config.js
  1. 下一步是向List容器添加一个组件,稍后我们将使用它来显示有关电影的信息。此组件将被称为Card,应位于名为components的新src子目录中,该子目录将放置在与组件相同名称的目录中。我们需要在src目录内创建一个名为components的新目录,然后在其中创建一个名为Card的新目录。在此目录中,创建一个名为Card.js的文件,并将以下代码块添加到空的Card组件中:
import React from 'react';

const Card = () => {
    return <h2>movie #1</h2>;
};

export default Card;
  1. 现在,将Card组件导入List容器中,并用以下代码替换return函数,返回此组件而不是h1元素:
import React, { Component } from 'react';
+ import Card from '../components/Card/Card';

class List extends Component {
    render() {
-       return <h1>movieList</h1>;
+       return <Card />;
    }
};

export default List;

现在我们已经添加了这些目录和Card.js文件,我们的应用程序文件结构将如下所示:

movieList
|-- dist
    |-- index.html
    |-- main.js
|-- src
 |-- components
 |-- Card
 |-- Card.js
    |-- containers
        |-- List.js
    |-- index.js
    |-- index.html
.babelrc
package.json
webpack.config.js

如果我们再次在浏览器中访问我们的应用程序,将不会有可见的变化,因为我们的应用程序仍然返回相同的结果。但是,如果我们在浏览器中打开 React Developer Tools 插件,我们会注意到应用程序当前由多个堆叠的组件组成:

<App>
    <List>
        <Card>
            <h1>movieList</h1>
        </Card>
    </List>
</App>

在本节的下一部分,您将利用对 React 项目进行结构化的知识,并创建新组件来获取有关我们想要在此应用程序中显示的电影的数据。

检索数据

随着开发服务器和项目结构的设置完成,现在是时候最终向其中添加一些数据了。如果您还没有从入门部分的 GitHub 存储库中下载资产,现在应该这样做。这些资产是此应用程序所需的,包含有关五部票房最高的电影及其相关图像文件的 JSON 文件。

data.json文件由一个包含有关电影信息的对象数组组成。该对象具有titledistributoryearamountimgranking字段,其中img字段是一个具有srcalt字段的对象。src字段指的是也包含在内的图像文件。

我们需要将下载的文件添加到此项目的根目录中的不同子目录中,data.json文件应放在名为assets的子目录中,图像文件应放在名为media的子目录中。添加了这些新目录和文件后,我们的应用程序结构将如下所示:

movieList
|-- dist
    |-- index.html
    |-- main.js
|-- src
 |-- assets
 |-- data.json
    |-- components
        |-- Card
            |-- Card.js
    |-- containers
        |-- List.js
 |-- media
 |-- avatar.jpg
 |-- avengers_infinity_war.jpg
 |-- jurassic_world.jpg
 |-- star_wars_the_force_awakens.jpg
 |-- titanic.jpg
    |-- index.js
    |-- index.html
.babelrc
package.json
webpack.config.js

此数据将仅在顶层组件中检索,这意味着我们应该在List容器中添加一个fetch函数,该函数更新此容器的状态并将其作为 props 传递给低级组件。state对象可以存储变量;每当这些变量发生变化时,我们的组件将重新渲染。让我们开始吧:

  1. 在检索电影数据之前,Card组件需要准备好接收这些信息。为了显示有关电影的信息,我们需要用以下代码替换Card组件的内容:
import React from 'react';

const Card = ({ movie }) => {
     return (
        <div>
            <h2>{`#${movie.ranking} - ${movie.title} (${movie.year})`}</h2>
            <img src={movie.img.src} alt={movie.img.alt} width='200' />
            <p>{`Distributor: ${movie.distributor}`}</p>
            <p>{`Amount: ${movie.amount}`}</p>
        </div>
    );
};

export default Card;
  1. 现在,可以通过向List组件添加一个constructor函数来实现检索数据的逻辑,该函数将包含一个空数组作为电影的占位符以及一个指示数据是否仍在加载的变量:
...

class List extends Component {+
+   constructor() {
+       super()
+       this.state = {
+           data: [],
+           loading: true,
+       };
+   }

    return (
      ...

  1. 在设置constructor函数之后,我们应该设置一个componentDidMount函数,在此函数中,我们将在List组件挂载后获取数据。在这里,我们应该使用async/await函数,因为fetch API 返回一个 promise。获取数据后,应通过用电影信息替换空数组来更新state,并将loading变量设置为false
...

class List extends Component {

    ...

 +    async componentDidMount() {
 +        const movies = await fetch('../../assets/data.json');
 +        const moviesJSON = await movies.json();

 +        if (moviesJSON) {
 +            this.setState({
 +                data: moviesJSON,
 +                loading: false,
 +            });
 +        }
 +    }

    return (
      ...

我们以前使用的从 JSON 文件中使用fetch检索信息的方法并没有考虑到对该文件的请求可能会失败。如果请求失败,loading状态将保持为true,这意味着用户将继续看到加载指示器。如果您希望在请求失败时显示错误消息,您需要将fetch方法包装在try...catch块中,这将在本书的后面部分中介绍。

  1. 将此状态传递给Card组件,最终可以在第一步中更改的Card组件中显示。此组件还将获得一个key属性,这是在迭代中呈现的每个组件都需要的。由于这个值需要是唯一的,所以使用电影的id,如下所示:
class List extends Component {

    ...

    render() {
 _     return <Card />
 +     const { data, loading } = this.state;

+      if (loading) {
+         return <div>Loading...</div>
+      }

+      return data.map(movie => <Card key={ movie.id } movie={ movie } />);
    }
}

export default List;

如果我们再次在浏览器中访问我们的应用程序,我们会看到它现在显示了一系列电影,包括一些基本信息和一张图片。此时,我们的应用程序将看起来类似于以下的屏幕截图:

如您所见,应用程序已经应用了有限的样式,并且只呈现了从 JSON 文件中获取的信息。在本节的下一部分中,将使用一个名为Bootstrap的包来添加样式。

添加样式

仅显示电影信息是不够的。我们还需要对项目应用一些基本样式。使用 Bootstrap 包可以为我们的组件添加样式,这些样式是基于类名的。Bootstrap 可以从npm中安装,并需要进行以下更改才能使用:

  1. 要使用 Bootstrap,我们需要从npm中安装它并将其放在这个项目中:
npm install --save-dev bootstrap
  1. 还要将此文件导入到我们的 React 应用程序的入口点index.js中,以便我们可以在整个应用程序中使用样式:
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import List from './containers/List';
+ import 'bootstrap/dist/css/bootstrap.min.css';

const App = () => {
    return <List />;
}

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

如果我们再次尝试运行开发服务器,我们将收到一个错误,显示“您可能需要一个适当的加载程序来处理此文件类型。”。因为 Webpack 无法编译 CSS 文件,我们需要添加适当的加载程序来实现这一点。我们可以通过运行以下命令来安装这些加载程序:

npm install --save-dev css-loader style-loader
  1. 我们需要将这些包添加为 webpack 配置的规则:
const HtmlWebPackPlugin = require('html-webpack-plugin');

const htmlPlugin = new HtmlWebPackPlugin({
    template: './src/index.html',
    filename: './index.html',
});

module.exports = {
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /node_modules/,
                use: {
                    loader: "babel-loader"
                }
            },
+           {
+               test: /\.css$/,
+               use: ['style-loader', 'css-loader']
+           }
        ]
    },
    plugins: [htmlPlugin]
};

加载程序的添加顺序很重要,因为css-loader处理 CSS 文件的编译,而style-loader将编译后的 CSS 文件添加到 React DOM 中。Webpack 从右到左读取这些设置,CSS 需要在附加到 DOM 之前进行编译。

  1. 应用程序现在应该在浏览器中正确运行,并且应该已经从默认的 Bootstrap 样式表中接收到一些小的样式更改。让我们首先对index.js文件进行一些更改,并将其样式化为整个应用程序的容器。我们需要更改渲染到 DOM 的App组件,并用div容器包装List组件:
...

const App = () => {
    return (
+        <div className='container-fluid'>
            <List />
 </div>
    );
};

ReactDOM.render(<App />, document.getElementById('root'));
  1. List组件内部,我们需要设置网格以显示显示电影信息的Card组件。使用以下代码包装map函数和Card组件:
...

class List extends Component {

    ...

    render() {
        const { data, loading } = this.state;

        if (loading) {
            return <div>Loading...</div>;
        }

         return (
 +         <div class='row'>
                {data.map(movie =>
 +                 <div class='col-sm-2'>
                        <Card key={ movie.id } movie={ movie } />
 +                 </div>
                )}
 +          </div>
        );
    }
}

export default List;
  1. Card组件的代码如下。这将使用 Bootstrap 为Card组件添加样式:
import React from 'react';

const Card = ({ movie }) => {
    return (
        <div className='card'>
            <img src={movie.img.src} className='card-img-top' alt={movie.img.alt} />
            <div className='card-body'>
                <h2 className='card-title'>{`#${movie.ranking} - ${movie.title} (${movie.year})` }</h2>
            </div>
            <ul className='list-group list-group-flush'>
                <li className='list-group-item'>{`Distributor: ${movie.distributor}`}</li>
                <li className='list-group-item'>{`Amount: ${movie.amount}`}</li>
            </ul>
        </div>
    );
};

export default Card;
  1. 为了添加最后的修饰,打开index.js文件并插入以下代码,以添加一个标题,将放置在应用程序中电影列表的上方:
...

const App = () => {
    return (
        <div className='container-fluid'>
_            <h1>movieList</h1>
+            <nav className='navbar sticky-top navbar-light bg-dark'>
+               <h1 className='navbar-brand text-light'>movieList</h1>
+           </nav>

            <List />
        </div>
    );
};

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

如果我们再次访问浏览器,我们会看到应用程序已经通过 Bootstrap 应用了样式,使其看起来如下:

Bootstrap 的样式规则已应用到我们的应用程序中,使其看起来比以前更完整。在本节的最后部分,我们将向项目添加 ESLint 包,这将通过在整个项目中同步模式来使维护我们的代码更容易。

添加 ESLint

最后,我们将添加 ESLint 到项目中,以确保我们的代码符合某些标准,例如,我们的代码遵循正确的 JavaScript 模式。添加 ESLint 需要以下更改:

  1. 通过运行以下命令从npm安装 ESLint:
npm install --save-dev eslint eslint-loader eslint-plugin-react

第一个包叫做eslint,是核心包,帮助我们识别 JavaScript 代码中的潜在问题模式。eslint-loader是一个由 Webpack 使用的包,每次更新代码时都会运行 ESLint。最后,eslint-plugin-react为 React 应用程序向 ESLint 添加特定规则。

  1. 要配置 ESLint,我们需要在项目的根目录中创建一个名为.eslintrc.js的文件,并将以下代码添加到其中:
module.exports = {
    "env": {
        "browser": true,
        "es6": true
    },
    "parserOptions": {
        "ecmaFeatures": {
            "jsx": true
        },
        "ecmaVersion": 2018,
        "sourceType": "module"
    },
    "plugins": [
        "react"
    ],
    "extends": ["eslint:recommended", "plugin:react/recommended"]
};       

env字段设置了我们的代码将运行的实际环境,并将在其中使用es6函数,而parserOptions字段为使用jsx和现代 JavaScript 添加了额外的配置。然而,有趣的地方在于plugins字段,这是我们指定我们的代码使用react作为框架的地方。extends字段是使用eslintrecommended设置以及 React 的特定设置的地方。

我们可以运行eslint --init命令来创建自定义设置,但建议使用前面的设置,以确保我们的 React 代码的稳定性。

  1. 如果我们查看命令行或浏览器,我们将看不到错误。但是,我们必须将eslint-loader包添加到 webpack 配置中。在webpack.config.js文件中,将eslint-loader添加到babel-loader旁边:
...

module.exports = {
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /node_modules/,
+               use: ['babel-loader', 'eslint-loader'] 
            },
            {
                test: /\.css$/,
                use: ['style-loader', 'css-loader']
            }
        ]
    },
    plugins: [htmlPlugin]
};

通过重新启动开发服务器,webpack 现在将使用 ESLint 来检查我们的 JavaScript 代码是否符合 ESLint 的配置。在我们的命令行(或浏览器中的控制台选项卡)中,应该可以看到以下错误:

movieList/src/components/Card/Card.js
 3:17  error 'movie' is missing in props validation  react/prop-types

在使用 React 时,建议我们验证发送到组件的任何 props,因为 JavaScript 的动态类型系统可能会导致变量未定义或类型不正确的情况。我们的代码将在不验证 props 的情况下工作,但为了修复此错误,我们必须安装prop-types包,这曾经是 React 的一个功能,但后来被弃用了。让我们开始吧:

  1. 我们用于检查 prop 类型的包可以从npm安装:
npm install --save prop-types
  1. 现在,我们可以通过将该包导入Card组件并将验证添加到该文件的底部来验证组件中的propTypes
import React from 'react';
+ import PropTypes from 'prop-types';

const Card = ({ movie }) => {
    ...
};

+ Card.propTypes = {
+    movie: PropTypes.shape({}),
+ };

export default Card;
  1. 如果我们再次查看命令行,我们会发现缺少的propTypes验证错误已经消失了。但是,我们的 props 的验证仍然不是很具体。我们可以通过还指定movie prop 的所有字段的propTypes来使其更具体:
...

Card.propTypes = {
_   movie: PropTypes.shape({}),
+    movie: PropTypes.shape({
+    title: PropTypes.string,
+    distributor: PropTypes.string,
+     year: PropTypes.number,
+     amount: PropTypes.string,
+     img: PropTypes.shape({
+       src: PropTypes.string,
+       alt: PropTypes.string
+     }),
+     ranking: PropTypes.number
+   }).isRequired  
};  

我们还可以通过将isRequired添加到propTypes验证中来指示React渲染组件所需的 props。

恭喜!您已经使用 React、ReactDom、webpack、Babel 和 ESLint 从头开始创建了一个基本的 React 应用程序。

总结

在本章中,您从头开始为 React 创建了一个电影列表应用程序,并了解了核心 React 概念。本章以您使用 webpack 和 Babel 创建一个新项目开始。这些库可以帮助您以最小的设置编译和在浏览器中运行 JavaScript 和 React 代码。然后,我们描述了如何构建 React 应用程序的结构。这种结构将贯穿本书始终。应用的原则为您提供了从零开始创建 React 应用程序并以可扩展的方式构建它们的基础。

如果您之前已经使用过 React,那么这些概念可能不难理解。如果没有,那么如果某些概念对您来说感觉奇怪,也不用担心。接下来的章节将建立在本章中使用的功能之上,让您有足够的时间充分理解它们。

下一章中您将构建的项目将专注于使用更高级的样式创建可重用的 React 组件。由于它将被设置为渐进式 Web 应用程序PWA),因此将可以离线使用。

进一步阅读

第二章:使用可重用的 React 组件创建渐进式 Web 应用程序

在完成第一章后,您是否已经对 React 的核心概念感到熟悉?太好了!这一章对您来说将不成问题!如果没有,不要担心-您在上一章中遇到的大多数概念将被重复。但是,如果您想获得更多关于 webpack 和 Babel 的经验,建议您再次尝试在第一章中创建项目,在 React 中创建电影列表应用程序,因为本章不会涵盖这些主题。

在这一章中,您将使用 Create React App,这是一个由 React 核心团队创建的入门套件,可以快速开始使用 React,并且可以用作渐进式 Web 应用程序PWA)-一种行为类似移动应用程序的 Web 应用程序。这将使模块捆绑器和编译器(如 webpack 和 Babel)的配置变得不必要,因为这将在 Create React App 包中处理。这意味着您可以专注于构建您的 GitHub 作品集应用程序,将其作为一个 PWA,重用 React 组件和样式。

除了设置 Create React App 之外,本章还将涵盖以下主题:

  • 创建渐进式 Web 应用程序

  • 构建可重用的 React 组件

  • 使用styled-components在 React 中进行样式设置

迫不及待?让我们继续吧!

项目概述

在这一章中,我们将使用 Create React App 和styled-components创建具有可重用 React 组件和样式的 PWA。该应用程序将使用从公共 GitHub API 获取的数据。

建立时间为 1.5-2 小时。

入门

在本章中,您将创建的项目将使用 GitHub 的公共 API,您可以在developer.github.com/v3/找到。要使用此 API,您需要拥有 GitHub 帐户,因为您将希望从 GitHub 用户帐户中检索信息。如果您还没有 GitHub 帐户,可以通过在其网站上注册来创建一个。此外,您需要从这里下载 GitHub 标志包:github-media-downloads.s3.amazonaws.com/GitHub-Mark.zip。此应用程序的完整源代码也可以在 GitHub 上找到:github.com/PacktPublishing/React-Projects/tree/ch2

GitHub 作品集应用程序

在这一部分,我们将学习如何使用 Create React App 创建一个新的 React 项目,并将其设置为一个可以重用 React 组件和使用styled-components进行样式设置的 PWA。

使用 Create React App 创建 PWA

每次创建新的 React 项目都需要配置 webpack 和 Babel 可能会非常耗时。此外,每个项目的设置可能会发生变化,当我们想要为我们的项目添加新功能时,管理所有这些配置变得困难。

因此,React 核心团队推出了一个名为 Create React App 的起始工具包,并在 2018 年发布了稳定版本 2.0。通过使用 Create React App,我们不再需要担心管理编译和构建配置,即使 React 的新版本发布了,这意味着我们可以专注于编码而不是配置。此外,它还具有我们可以使用的功能,可以轻松创建 PWA。

PWA 通常比普通的 Web 应用程序更快、更可靠,因为它专注于离线/缓存优先的方法。这使得用户在没有或者网络连接缓慢的情况下仍然可以打开我们的应用程序,因为它专注于缓存。此外,用户可以将我们的应用程序添加到他们的智能手机或平板电脑的主屏幕,并像本地应用程序一样打开它。

这一部分将向我们展示如何创建一个具有 PWA 功能的 React 应用程序,从设置一个新的应用程序开始,使用 Create React App。

安装 Create React App

Create React App 可以通过命令行安装,我们应该全局安装它,这样该包就可以在我们本地计算机的任何地方使用,而不仅仅是在特定项目中:

npm install -g create-react-app

现在create-react-app包已经安装完成,我们准备创建我们的第一个 Create React App 项目。有多种设置新项目的方法,但由于我们已经熟悉了npm,我们只需要学习两种方法。让我们开始吧:

  1. 第一种方法是使用npm创建一个新项目,运行以下命令:
npm init react-app github-portfolio

您可以将github-portfolio替换为您想要为此项目使用的任何其他名称。

  1. 另外,我们也可以使用npx,这是一个与npm(v5.2.0 或更高版本)预装的工具,简化了我们执行npm包的方式:
npx create-react-app github-portfolio

这两种方法都将启动 Create React App 的安装过程,这可能需要几分钟,具体取决于您的硬件。虽然我们只执行一个命令,但 Create React App 的安装程序将安装我们运行 React 应用程序所需的软件包。因此,它将安装reactreact-domreact-scripts,其中最后一个软件包包含了编译、运行和构建 React 应用程序的所有配置。

如果我们进入项目的根目录,该目录以我们的项目名称命名,我们会看到它具有以下结构:

github-portfolio
|-- node_modules
|-- public
    |-- favicon.ico
    |-- index.html
    |-- manifest.json
|-- src
    |-- App.css
    |-- App.js
    |-- App.test.js
    |-- index.css
    |-- index.js
    |-- logo.svg
    |-- serviceWorker.js
.gitignore
package.json

这个结构看起来很像我们在第一章设置的结构,尽管有一些细微的差异。public目录包括所有不应包含在编译和构建过程中的文件,而该目录中的文件是唯一可以直接在index.html文件中使用的文件。manifest.json文件包含 PWA 的默认配置,这是我们将在本章后面学到更多的内容。

在另一个名为src的目录中,我们将找到在执行package.json文件中的任何脚本时将被编译和构建的所有文件。有一个名为App的组件,它由App.jsApp.test.jsApp.css文件定义,以及一个名为index.js的文件,它是 Create React App 的入口点。serviceWorker.js文件是设置 PWA 所需的,这也是本节下一部分将讨论的内容。

如果我们打开package.json文件,我们会看到定义了三个脚本:startbuildtest。由于测试是目前尚未处理的事情,我们现在可以忽略这个脚本。为了能够在浏览器中打开项目,我们只需在命令行中输入以下命令,即以开发模式运行package react-scripts

npm start

如果我们访问http://localhost:3000/,默认的 Create React App 页面将如下所示:

由于react-scripts默认支持热重载,我们对代码所做的任何更改都将导致页面重新加载。如果我们运行构建脚本,将在项目的根目录中创建一个名为build的新目录,其中可以找到我们应用程序的缩小捆绑包。

使用基本的 Create React App 安装完成后,本节的下一部分将向我们展示如何启用功能,将该应用程序转变为 PWA。

创建 PWA

Create React App 自带了一个支持 PWA 的配置,在我们初始化构建脚本时生成。我们可以通过访问src/index.js文件并修改最后一行来将我们的 Create React App 项目设置为 PWA,这将注册serviceWorker

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';

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

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: http://bit.ly/CRA-PWA
- //serviceWorker.register();
+ serviceWorker.register();

现在,当我们运行构建脚本时,我们的应用程序的压缩包将使用离线/缓存优先的方法。在幕后,react-scripts使用一个名为workbox-webpack-plugin的包,它与 webpack 4 一起工作,将我们的应用程序作为 PWA 提供。它不仅缓存放在public目录中的本地资产;它还缓存导航请求,以便我们的应用程序在不稳定的移动网络上更可靠地运行。

另一个在使用 Create React App 设置 PWA 中起作用的文件是manifest.json。我们的 PWA 的大部分配置都放在这里,如果我们打开public/manifest.json文件就可以看到。在这个配置 JSON 文件中,我们会找到操作系统和浏览器的最重要的部分。让我们来分解一下:

  1. 这个文件包含了short_namename字段,描述了我们的应用程序应该如何被用户识别:
{
  "short_name": "React App",
  "name": "Create React App Sample",

...

short_name字段的长度不应超过 12 个字符,并将显示在用户主屏幕上应用程序图标的下方。对于name字段,我们最多可以使用 45 个字符。这是我们应用程序的主要标识符,并且可以在将应用程序添加到主屏幕的过程中看到。

  1. 当用户将我们的应用程序添加到主屏幕时,他们看到的特定图标可以在icons字段中配置:
  "icons": [
    {
      "src": "favicon.ico",
      "sizes": "64x64 32x32 24x24 16x16",
      "type": "image/x-icon"
    }
  ],

正如我们之前提到的,favicon.ico文件被用作唯一的图标,并以image/x-icon格式以多种尺寸提供。对于manifest.json,同样的规则适用于index.html。只有放在 public 目录中的文件才能从这个文件中引用。

  1. 最后,使用theme_colorbackground_color字段,我们可以为打开我们的应用程序时在移动设备主屏幕上设置顶部栏的颜色(以十六进制格式):
  ...
  "theme_color": "#000000",
  "background_color": "#ffffff"
}

默认的工具栏和 URL 框不会显示;相反,会显示一个顶部栏。这种行为类似于原生移动应用程序。

配置文件还可以处理的另一件事是国际化,当我们的应用程序以不同的语言提供内容时,这将非常有用。如果我们的应用程序有多个版本在生产中,我们还可以在这个文件中添加版本控制。

我们在这里所做的更改配置了应用程序,使其作为 PWA 运行,但目前还不向用户提供这些功能。在本节的下一部分,我们将学习如何提供这个 PWA 并在浏览器中显示出来。

提供 PWA

PWA 的配置已经就绪,现在是时候看看这将如何影响应用程序了。如果您仍在运行 Create React App(如果没有,请再次执行npm start命令),请访问项目http://localhost:3000/。我们会发现目前还没有任何变化。正如我们之前提到的,只有当我们的应用程序的构建版本打开时,PWA 才会可见。为了做到这一点,请在项目的根目录中执行以下命令:

npm run build 

这将启动构建过程,将我们的应用程序最小化为存储在build目录中的捆绑包。我们可以从本地机器上提供这个构建版本的应用程序。如果我们在命令行上查看构建过程的输出,我们会看到 Create React App 建议我们如何提供这个构建版本。

npm install -g serve
serve -s build

npm install命令安装了serve包,用于提供构建的静态站点或者 JavaScript 应用程序。安装完这个包后,我们可以使用它在服务器或本地机器上部署build目录,方法如下:

serve -s build

-s标志用于将任何未找到的导航请求重定向回我们的index.js文件。

如果我们在浏览器中访问我们的项目http://localhost:5000/,我们会发现一切看起来和我们在http://localhost:3000/上运行的版本完全一样。然而,有一个很大的不同:构建版本是作为 PWA 运行的。这意味着如果我们的互联网连接失败,应用程序仍然会显示。我们可以通过断开互联网连接或从命令行停止serve包来尝试这一点。如果我们在http://localhost:5000/上刷新浏览器,我们会看到完全相同的应用程序。

这是如何工作的?如果我们在浏览器(Chrome 或 Firefox)中打开开发者工具并访问应用程序选项卡,我们将看到侧边栏中的项目。我们首先应该打开的是 Service Workers。如果您使用 Chrome 作为浏览器,结果将类似于以下截图所示:

如果我们点击 Service Worker 侧边栏项目,我们将看到正在运行的所有 service worker 的列表。对于localhost,有一个活动的 service worker,其源为service-worker.js - 这与我们项目中的文件相同。该文件确保在没有或者网络连接缓慢的情况下提供我们应用程序的缓存版本。

当我们使用npm start在本地运行应用程序时,service worker 不应处于活动状态。由于 service worker 将缓存我们的应用程序,我们将无法看到我们所做的任何更改,因为缓存版本将是一个服务器。

这些缓存文件存储在浏览器缓存中,也可以在工具栏的缓存存储下找到。在这里,我们可能会看到多个缓存位置,这些位置是在构建应用程序时由workbox-webpack-plugin包创建的。

与我们应用程序相关的一个是workbox-precache-v2-http://localhost:5000/,其中包含我们应用程序的所有缓存文件:

在上述截图中,我们可以看到浏览器为我们的应用程序缓存了哪些文件,其中index.html文件是应用程序的入口点,以static/开头的文件是在构建过程中创建的,并代表我们应用程序的缩小捆绑包。正如我们所看到的,它包括缩小的.js.css.svg文件,这些文件存储在浏览器缓存中。每当用户加载我们的应用程序时,它都会尝试首先提供这些文件,然后再寻找网络连接。

创建了我们的第一个 PWA 并安装了 Create React App 后,我们将开始着手创建项目的组件并为它们设置样式。

构建可重用的 React 组件

在上一章中简要讨论了使用 JSX 创建 React 组件,但在本章中,我们将通过创建可以在整个应用程序中重用的组件来进一步探讨这个主题。首先,让我们看看如何构建我们的应用程序,这是基于上一章的内容的。

构建我们的应用程序

首先,我们需要以与第一章相同的方式构建我们的应用程序。这意味着我们需要在src目录内创建两个新目录,分别为componentscontainersApp组件的文件可以移动到container目录,App.test.js文件可以删除,因为测试还没有涉及到。

创建完目录并移动文件后,我们的应用程序结构将如下所示:

github-portfolio
|-- node_modules
|-- public
    |-- favicon.ico
    |-- index.html
    |-- manifest.json
|-- src
 |-- components
 |-- containers
 |-- App.css
 |-- App.js
    |-- index.css
    |-- index.js
    |-- serviceWorker.js
.gitignore
package.json

不要忘记在src/index.js中更改对App组件的导入位置:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
- import App from './App';
+ import App from './containers/App';
import * as serviceWorker from './serviceWorker';

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

...

src/containers/App.js中的 React logo的位置也做同样的事情:

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

class App extends Component {

...

如果我们再次运行npm start并在浏览器中访问项目,将不会有可见的变化,因为我们只是改变了项目的结构,而没有改变其内容。

我们的项目仍然只包含一个组件,这并不使它非常可重用。下一步将是将我们的App组件也分成Components。如果我们查看App.js中这个组件的源代码,我们会看到返回函数中已经有一个 CSS header元素。让我们将header元素改成一个 React 组件:

  1. 首先,在components目录内创建一个名为Header的新目录,并将classNamesApp-headerApp-logoApp-link的样式复制到一个名为Header.css的新文件中:
.App-logo {
  height: 40vmin;
  pointer-events: none;
}

.App-header {
  background-color: #282c34;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-size: calc(10px + 2vmin);
  color: white;
}

.App-link {
  color: #61dafb;
}

@keyframes App-logo-spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}
  1. 现在,在这个目录内创建一个名为Header.js的文件。这个文件应该返回与<header>元素相同的内容。
import React from 'react';
import './Header.css';

const Header = () => (
 <header className='App-header'>
     <img src={logo} className='App-logo' alt='logo' />
     <p>
       Edit <code>src/App.js</code> and save to reload.
     </p>
     <a
       className='App-link'
       href='https://reactjs.org'
       target='_blank'
       rel='noopener noreferrer'
     >
       Learn React
     </a>
 </header>
);

export default Header;
  1. App组件内导入这个Header组件,并将其添加到return函数中:
import React, { Component } from 'react';
+ import Header from '../components/App/Header';
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'  /> -        <p>Edit <code>src/App.js</code> and save to reload.</p> -        <a -          className='App-link' -          href='https://reactjs.org' -          target='_blank' -          rel='noopener noreferrer' -        >
-          Learn React
-        </a> -      </header>
+      <Header />
     </div>
   );
 }
}

export default App;

当我们再次在浏览器中访问我们的项目时,会看到一个错误,说 logo 的值是未定义的。这是因为新的Header组件无法访问在App组件内定义的logo常量。根据我们在第一章学到的知识,我们知道这个 logo 常量应该作为 prop 添加到Header组件中,以便显示出来。让我们现在来做这个:

  1. logo常量作为 prop 发送到src/container/App.js中的Header组件:
...
class App extends Component {
 render() {
   return (
     <div className='App'>
-      <Header />
+      <Header logo={logo} />
     </div>
   );
 }
}

export default App;
  1. 获取logo属性,以便它可以被img元素作为src属性在src/components/App/Header.js中使用:
import React from 'react';

- const Header = () => (
+ const Header = ({ logo }) => (
 <header className='App-header'>
   <img src={logo} className='App-logo' alt='logo' />

   ...

在上一章中,演示了prop-types包的使用,但在本章中没有使用。如果您想在本章中也使用prop-types,可以使用npm install prop-typesnpm安装该包,并在要使用它的文件中导入它。

在这里,当我们在浏览器中打开项目时,我们看不到任何可见的变化。但是,如果我们打开 React 开发者工具,我们将看到项目现在被分成了一个App组件和一个Header组件。该组件以.svg文件的形式接收logo属性,如下截图所示:

Header组件仍然被分成多个可以拆分为单独组件的元素。看看imgp元素,它们看起来已经很简单了。但是,a元素看起来更复杂,需要接受诸如urltitleclassName等属性。为了将这个a元素改为可重用的组件,它需要被移动到我们项目中的不同位置。

为此,在components目录中创建一个名为Link的新目录。在该目录中,创建一个名为Link.js的新文件。该文件应返回与我们已经在Header组件中拥有的相同的a元素。此外,我们可以将urltitle作为属性发送到该组件。现在让我们这样做:

  1. src/components/Header/Header.css中删除App-link类的样式,并将其放置在名为Link.css的文件中:
.App-link {
    color: #61dafb;
}
  1. 创建一个名为Link的新组件,该组件接受urltitle属性。该组件将这些属性添加为<a>元素的属性,放在src/components/Link/Link.js中:
import React from 'react';
import './Link.css';

const Link = ({ url, title }) => (
  <a
    className='App-link'
    href={url}
    target='_blank'
    rel='noopener noreferrer'
  >
    {title}
  </a>
);

export default Link;
  1. 导入这个Link组件,并将其放置在src/components/Header/Header.js中的Header组件中:
import React from 'react';
+ import Link from '../Link/Link';

const Header = ({ logo }) => (
 <header className='App-header'>
   <img src={logo} className='App-logo' alt='logo' />
   <p>Edit <code>src/App.js</code> and save to reload.</p>
-  <a -    className='App-link' -    href='https://reactjs.org' -    target='_blank' -    rel='noopener noreferrer' -  > -    Learn React
-  </a>
+  <Link url='https://reactjs.org' title='Learn React' />
 </header>
);

export default Header;

我们的代码现在应该如下所示,这意味着我们已成功将目录分成了containerscomponents,其中组件被放置在以组件命名的单独子目录中:

github-portfolio
|-- node_modules
|-- public
    |-- favicon.ico
    |-- index.html
    |-- manifest.json
|-- src
    |-- components
        |-- Header
            |-- Header.js
            |-- Header.css
        |-- Link
            |-- Link.js
            |-- Link.css
    |-- containers
        |-- App.css
        |-- App.js
    |-- index.css
    |-- index.js
    |-- serviceWorker.js
.gitignore
package.json

然而,如果我们在浏览器中查看项目,就看不到任何可见的变化。然而,在 React 开发者工具中,我们的应用程序结构已经形成。App组件显示为组件树中的父组件,而Header组件是一个具有Link作为子组件的子组件。

在本节的下一部分,我们将向该应用程序的组件树中添加更多组件,并使这些组件在整个应用程序中可重用。

在 React 中重用组件

我们在本章中构建的项目是一个 GitHub 作品集页面;它将显示我们的公共信息和公共存储库的列表。因此,我们需要获取官方的 GitHub REST API(v3)并从两个端点拉取信息。获取数据是我们在第一章中做过的事情,但这次信息不会来自本地 JSON 文件。检索信息的方法几乎是相同的。我们将使用 fetch API 来做这件事。

我们可以通过执行以下命令从 GitHub 检索我们的公共 GitHub 信息。将代码的粗体部分末尾的username替换为您自己的username

curl 'https://api.github.com/users/username'

如果您没有 GitHub 个人资料或者没有填写所有必要的信息,您也可以使用octocat用户名。这是 GitHub 吉祥物的用户名,已经填充了示例数据。

这个请求将返回以下输出:

{
  "login": "octocat",
  "id": 1,
  "node_id": "MDQ6VXNlcjE=",
  "avatar_url": "https://github.com/images/error/octocat_happy.gif",
  "gravatar_id": "",
  "url": "https://api.github.com/users/octocat",
  "html_url": "https://github.com/octocat",
  "followers_url": "https://api.github.com/users/octocat/followers",
  "following_url": "https://api.github.com/users/octocat/following{/other_user}",
  "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
  "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
  "subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
  "organizations_url": "https://api.github.com/users/octocat/orgs",
  "repos_url": "https://api.github.com/users/octocat/repos",
  "events_url": "https://api.github.com/users/octocat/events{/privacy}",
  "received_events_url": "https://api.github.com/users/octocat/received_events",
  "type": "User",
  "site_admin": false,
  "name": "monalisa octocat",
  "company": "GitHub",
  "blog": "https://github.com/blog",
  "location": "San Francisco",
  "email": "octocat@github.com",
  "hireable": false,
  "bio": "There once was...",
  "public_repos": 2,
  "public_gists": 1,
  "followers": 20,
  "following": 0,
  "created_at": "2008-01-14T04:33:35Z",
  "updated_at": "2008-01-14T04:33:35Z"
}

JSON 输出中的多个字段都被突出显示,因为这些是我们在应用程序中将使用的字段。这些字段是avatar_urlhtml_urlrepos_urlnamecompanylocationemailbio,其中repos_url字段的值实际上是另一个我们需要调用以检索该用户所有存储库的 API 端点。这是我们将在本章稍后要做的事情。

由于我们想在应用程序中显示这个结果,我们需要做以下事情:

  1. 要从 GitHub 检索这些公共信息,请创建一个名为Profile的新容器,并将以下代码添加到src/containers/Profile.js中:
import React, { Component } from 'react';

class Profile extends Component {
  constructor() {
    super();
    this.state = {
      data: {},
      loading: true,
    }
  }

  async componentDidMount() {
    const profile = await fetch('https://api.github.com/users/octocat');
    const profileJSON = await profile.json();

    if (profileJSON) {
      this.setState({
        data: profileJSON,
        loading: false,
      })
    }
  }

  render() {
    return (
      <div></div>
    );
  }
}

export default Profile;

这个新组件包含一个constructor,其中设置了state的初始值,以及一个componentDidMount生命周期方法,该方法在异步使用时,当获取的 API 返回结果时,为state设置一个新值。由于我们仍然需要创建新组件来显示数据,因此尚未呈现任何结果。

现在,将这个新组件导入到App组件中:

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

class App extends Component {
  render() {
    return (
      <div className='App'>
        <Header logo={logo} />
+       <Profile />
      </div>
    );
  }
}

export default App;
  1. 快速查看我们项目运行的浏览器,我们会发现这个新的Profile组件还没有显示。这是因为Header.css文件具有height属性,其view-height100,这意味着组件将占据整个页面的高度。要更改此设置,请打开scr/components/App/Header.css文件并更改以下突出显示的行:
.App-logo {
- height: 40vmin;
+ height: 64px;
  pointer-events: none;
}

.App-header {
  background-color: #282c34;
- min-height: 100vh;
+ height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-size: calc(10px + 2vmin);
  color: white;
}

...
  1. 我们的页面上应该有足够的空间来显示Profile组件,因此我们可以再次打开scr/containers/Profile.js文件,并显示 GitHub API 返回的avatar_urlhtml_urlrepos_urlnamecompanylocationemailbio字段:
...

render() {
+   const { data, loading } = this.state;

+   if (loading) {
+       return <div>Loading...</div>;
+   }

    return (
      <div>
+       <ul>
+         <li>avatar_url: {data.avatar_url}</li>
+         <li>html_url: {data.html_url}</li>
+         <li>repos_url: {data.repos_url}</li>
+         <li>name: {data.name}</li>
+         <li>company: {data.company}</li>
+         <li>location: {data.location}</li>
+         <li>email: {data.email}</li>
+         <li>bio: {data.bio}</li>
+       </ul>
      </div>
    );
  }
}

export default Profile;

保存此文件并在浏览器中访问我们的项目后,我们将看到显示 GitHub 信息的项目列表,如下截图所示:

由于这看起来不太好看,页眉与页面内容不匹配,让我们对这两个组件的样式文件进行一些更改:

  1. 更改Header组件的代码,删除 React 标志,并用 GitHub 标志替换它。我们不再需要从App组件中获取logo作为属性。此外,Link组件可以从这里删除,因为我们将在稍后在Profile组件中使用它:
import React from 'react';
- import logo from '../logo.svg';
+ import logo from '../../GitHub-Mark-Light-64px.png';
- import Link from '../components/Link';
import './Header.css';

- const Header = ({ logo }) => (
+ const Header = () => (
  <header className='App-header'>
    <img src={logo} className='App-logo' alt='logo' />
-   <p>
+   <h1>
-     Edit <code>src/App.js</code> and save to reload.
+     My Github Portfolio
-   </p>
+   </h1> -   <Link url='https://reactjs.org' title='Learn React' />
  </header>
);

export default Header;
  1. 更改scr/containers/Profile.js中的突出显示的行,我们将把头像图像与项目列表分开,并在字段名称周围添加strong元素。还记得我们之前创建的Link组件吗?这将用于在 GitHub 网站上创建指向我们个人资料的链接:
import React, { Component } from 'react';
+ import Link from '../components/Link/Link';
+ import './Profile.css';

class Profile extends Component {

  ...

      return (
-       <div>
+       <div className='Profile-container'>
+         <img className='Profile-avatar' src={data.avatar_url} alt='avatar' />
-         <ul>
-           ...
-         </ul>
+         <ul>
+           <li><strong>html_url:</strong> <Link url={data.html_url} title='Github URL' /></li>
+           <li><strong>repos_url:</strong> {data.repos_url}</li>
+           <li><strong>name:</strong> {data.name}</li>
+           <li><strong>company:</strong> {data.company}</li>
+           <li><strong>location:</strong> {data.location}</li>
+           <li><strong>email:</strong> {data.email}</li>
+           <li><strong>bio:</strong> {data.bio}</li>
+         </ul>
+      </div>
    );
  }
}

export default Profile;
  1. 不要忘记创建src/containers/Profile.css文件,并将以下代码粘贴到其中。这定义了Profile组件的样式:
.Profile-container {
  width: 50%;
  margin: 10px auto;
}

.Profile-avatar {
  width: 150px;
}

.Profile-container > ul {
 list-style: none;
 padding: 0;
 text-align: left;
}

.Profile-container > ul > li {
 display: flex;
 justify-content: space-between;
}

最后,我们可以看到应用程序开始看起来像一个 GitHub 作品集页面,其中有一个显示 GitHub 标志图标和标题的页眉,接着是我们的 GitHub 头像和我们的公共信息列表。这导致应用程序看起来类似于以下截图中显示的内容:

如果我们查看Profile组件中的代码,我们会发现有很多重复的代码,因此我们需要将显示我们公共信息的列表转换为一个单独的组件。让我们开始吧:

  1. 在新的src/components/List目录中创建一个名为List.js的新文件:
import React from 'react';

const List = () => (
  <ul></ul>
);

export default List;
  1. Profile组件中,可以在src/containers/Profile.js文件中找到,我们可以导入这个新的List组件,构建一个包含我们想要在此列表中显示的所有项目的新数组,并将其作为一个 prop 发送。对于html_url字段,我们将发送Link组件作为值,而不是从 GitHub API 返回的值:
import React, { Component } from 'react';
import Link from '../components/Link/Link';
+ import List from '../components/List/List';

class Profile extends Component {

...

render() {
  const { data, loading } = this.state;

  if (loading) {
    return <div>Loading...</div>;
  }

+ const items = [
+   { label: 'html_url', value: <Link url={data.html_url} title='Github URL' /> },
+   { label: 'repos_url', value: data.repos_url },
+   { label: 'name', value: data.name},
+   { label: 'company', value: data.company },
+   { label: 'location', value: data.location },
+   { label: 'email', value: data.email },
+   { label: 'bio', value: data.bio }
+ ]

  return (
    <div className='Profile-container'>
      <img className='Profile-avatar' src={data.avatar_url} alt='avatar' />
-     <ul>
-       <li><strong>html_url:</strong> <Link url={data.html_url} title='Github URL' /></li>
-       <li><strong>repos_url:</strong> {data.repos_url}</li>
-       <li><strong>name:</strong> {data.name}</li>
-       <li><strong>company:</strong> {data.company}</li>
-       <li><strong>location:</strong> {data.location}</li>
-       <li><strong>email:</strong> {data.email}</li>
-       <li><strong>bio:</strong> {data.bio}</li>
-     </ul>
+     <List items={items} />
    </div>
   );
  }
}

export default Profile;
  1. List组件中,我们现在可以映射items属性并返回带有样式的列表项:
import React from 'react';

- const List = () => (
+ const List = ({ items }) => (
  <ul>
+   {items.map(item =>
+     <li key={item.label}>
+       <strong>{item.label}</strong>{item.value}
+     </li>
+   )}
  </ul>
);

export default List;

假设我们正确执行了前面的步骤,你的应用在美学上不应该有任何变化。然而,如果我们查看 React 开发者工具,我们会发现组件树已经发生了一些变化。

在下一节中,我们将使用styled-components而不是 CSS 来为这些组件添加样式,并添加链接到我们 GitHub 账户的存储库。

使用styled-components在 React 中添加样式

到目前为止,我们一直在使用 CSS 文件为我们的 React 组件添加样式。然而,这迫使我们在不同的组件之间导入这些文件,这使得我们的代码不够可重用。因此,我们将把styled-components包添加到项目中,这允许我们在 JavaScript 中编写 CSS(所谓的CSS-in-JS)并创建组件。

通过这样做,我们将更灵活地为我们的组件添加样式,可以防止由于classNames而产生样式重复或重叠,并且可以轻松地为组件添加动态样式。所有这些都可以使用我们用于 CSS 的相同语法来完成,就在我们的 React 组件内部。

第一步是使用npm安装styled-components

npm install styled-components

如果你查看styled-components的官方文档,你会注意到他们强烈建议你也使用这个包的 Babel 插件。但是,由于你使用 Create React App 来初始化你的项目,你不需要添加这个插件,因为所有编译你的应用程序需要的工作已经被react-scripts处理了。

安装styled-components后,让我们尝试从其中一个组件中删除 CSS 文件。一个很好的开始是Link组件,因为这是一个非常小的组件,功能有限:

  1. 首先导入styled-components包并创建一个名为InnerLink的新样式化组件。这个组件扩展了一个a元素,并采用了我们已经为className App-link得到的 CSS 规则:
import React from 'react';
+ import styled from 'styled-components'; import './Link.css';

+ const InnerLink = styled.a`
+  color: #61dafb;
+ `;

const Link = ({ url, title }) => (
  <a className='App-link'
    href={url}
    target='_blank'
    rel='noopener noreferrer'
  >
    {title}
  </a>
);

export default Link;
  1. 添加了这个组件后,我们可以用这个 styled component 替换现有的<a>元素。此外,我们也不再需要导入Link.css文件,因为所有的样式现在都在这个 JavaScript 文件中进行了设置。
import React from 'react';
import styled from 'styled-components';
- import './Link.css';

const InnerLink = styled.a`
 color: #61dafb;
`;

const Link = ({ url, title }) => (
- <a className='App-link'
+ <InnerLink
    href={url}
    target='_blank'
    rel='noopener noreferrer'
  >
    {title}
- </a>
+ </InnerLink>
);

export default Link;

如果我们再次运行npm start并在浏览器中访问我们的项目,我们会看到删除 CSS 文件后,我们的应用程序仍然看起来一样。下一步是替换所有导入 CSS 文件进行样式设置的其他组件:

  1. src/components/Header/Header.js中的Header组件添加styled-components并删除 CSS 文件:
import React from 'react';
+ import styled from 'styled-components';
import logo from '../../GitHub-Mark-Light-64px.png';
- import './Header.css'

+ const HeaderWrapper = styled.div`
+  background-color: #282c34;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  font-size: calc(10px + 2vmin);
+  color: white;
+ `;

+ const Logo = styled.img`
+  height: 64px;
+  pointer-events: none;
+ `;

const Header = ({ logo }) => (
- <header className='App-header'>
+ <HeaderWrapper>
    <Logo src={logo} alt='logo' />
    <h1>My Github Portfolio</h1>
- </header>
+ </HeaderWrapper>
);

export default Header;
  1. src/containers/App.js中的App组件添加styled-components并删除 CSS 文件:
import React, { Component } from 'react';
+ import styled from 'styled-components';
import Profile from './Profile';
import Header from '../components/App/Header';
- import './App.css'; 
+ const AppWrapper = styled.div`
+  text-align: center;
+ `;

class App extends Component {
 render() {
   return (
-    <div className="App">
+    <AppWrapper>
       <Header />
       <Profile />
-    </div>
+    </AppWrapper>
   );
  }
}

export default App;
  1. List组件中的ullistrong元素添加一些 styled components:
import React from 'react';
+ import styled from 'styled-components';

+ const ListWrapper = styled.ul`
+  list-style: none;
+  text-align: left;
+  padding: 0;
+ `;

+ const ListItem = styled.li`
+  display: flex;
+  justify-content: space-between;
+ `;

+ const Label = styled.span`
+  font-weight: strong;
+ `;

const List = ({ items }) => (
- <ul>
+ <ListWrapper>
    {items.map(item =>
-     <li key={item.label}>
+     <ListItem key={item.label}>
-       <strong>{item.label}</strong>{item.value}
+       <Label>{item.label}</Label>{item.value}
-     </li>
+     </ListItem>
    )}
-  </ul>
+  </ListWrapper>
);

export default List;
  1. 最后,通过将Profile组件中的最后两个元素转换为 styled components,删除Profile.css文件:
import React, { Component } from 'react';
+ import styled from 'styled-components';
import Link from '../components/Link/Link';
import List from '../components/List/List';
- import './Profile.css';

+ const ProfileWrapper = styled.div`
+  width: 50%;
+  margin: 10px auto;
+ `;

+ const Avatar = styled.img`
+  width: 150px;
+ `;

class Profile extends Component {

...

  return (
-   <div className='Profile-container'>
+   <ProfileWrapper>
-     <img className='Profile-avatar' src={data.avatar_url} alt='avatar' />
+     <Avatar src={data.avatar_url} alt='avatar' />
 <List items={items} />
-   </div>
+   </ProfileWrapper>
  );
 }
}

export default Profile;

现在再次在浏览器中打开项目;我们的应用程序应该看起来仍然一样。我们所有的组件都已经转换为使用styled-components,不再使用 CSS 文件和classNames进行样式设置。不要忘记删除containerscomponents目录及子目录中的.css文件。

然而,在项目中仍然有一个 CSS 文件直接位于src目录内。这个 CSS 文件包含了<body>元素的样式,该元素存在于public/index.html文件中,并已被导入到src/index.js文件中。为了删除这个 CSS 文件,我们可以使用styled-components中的createGlobalStyle函数来为我们的应用程序添加<body>元素的样式。

我们可以为App组件内的全局样式创建一个 styled component,并将<body>元素的 CSS 样式粘贴到其中。由于这个组件应该与我们的AppWrapper组件在组件树中处于相同的层次结构,我们需要使用React Fragments,因为 JSX 组件应该被封装在一个封闭标签内。

import React, { Component } from 'react';
- import styled from 'styled-components';
+ import styled, { createGlobalStyle } from 'styled-components';
import Profile from './Profile';
import Header from '../components/App/Header';

+ const GlobalStyle = createGlobalStyle`
+  body {
+    margin: 0;
+    padding: 0;
+    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
+    "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
+    sans-serif;
+    -webkit-font-smoothing: antialiased;
+    -moz-osx-font-smoothing: grayscale;
+  }
+ `;

...

class App extends Component {
 render() {
   return (
+   <> 
+    <GlobalStyle />
     <AppWrapper>
       <Header />
       <Profile />
     </AppWrapper>
+   </>
  );
 }
}

export default App;

<>标签是<React.Fragment>的简写。这些 React Fragments 用于在单个封闭标签内列出子组件,而无需向 DOM 添加额外的节点。

现在,我们应该能够删除项目中的最后一个 CSS 文件,即src/index.css。我们可以通过在浏览器中查看项目来确认这一点。我们将看不到由src/index.css文件设置的body字体的任何更改。

最后一步是在 Github 作品集页面上显示我们 Github 个人资料中的存储库。检索这些存储库的 API 端点也是由检索我们用户信息的端点返回的。要显示这些存储库,我们可以重用之前创建的List组件:

  1. 从 API 端点加载存储库列表并将其添加到src/containers/Profile.js中的state中:
...

class Profile extends Component {
  constructor() {
    super();
    this.state = {
      data: {},
+     repositories: [],
      loading: true,
    }
  }

  async componentDidMount() {
    const profile = await fetch('https://api.github.com/users/octocat');
    const profileJSON = await profile.json();

    if (profileJSON) {
+     const repositories = await fetch(profileJSON.repos_url);
+     const repositoriesJSON = await repositories.json();

      this.setState({
        data: profileJSON,
+       repositories: repositoriesJSON,
        loading: false,
      })
    }
  }

  render() {
-   const { data, loading } = this.state; 
+   const { data, loading, repositories } = this.state;

    if (loading) {
      return <div>Loading...</div>
    }

    const items = [
      ...
    ];

 +  const projects = repositories.map(repository => ({
 +    label: repository.name,
 +    value: <Link url={repository.html_url} title='Github URL' />
 +  }));

...
  1. 接下来,为存储库返回一个List组件,并向该列表发送一个名为title的 prop。我们这样做是因为我们想显示两个列表之间的区别:
...

  render() {

  ...

    const projects = repositories.map(repository => ({
      label: repository.name,
      value: <Link url={repository.html_url} title='Github URL' />
    }));

    return (
      <ProfileWrapper>
         <Avatar src={data.avatar_url} alt='avatar' />
-       <List items={items} />
+       <List title='Profile' items={items} />
+       <List title='Projects' items={projects} />
      </ProfileWrapper>
    );
  }
}

export default Profile;
  1. src/components/List/List.js中的List组件进行更改,并在每个列表的顶部显示标题。在这种情况下,我们将使用 React Fragments 来防止不必要的节点被添加到 DOM 中:
import React from 'react';
import styled from 'styled-components';

+ const Title = styled.h2`
+  padding: 10px 0;
+  border-bottom: 1px solid lightGrey;
+ `;

...

- const List = ({ items }) => (
+ const List = ({ items, title }) => (
+  <>
+    <Title>{title}</Title>
     <ListWrapper>
       {items.map(item =>
         <ListItem key={item.label}>
           <Label>{item.label}</Label>{item.value}
         </ListItem>
       )}
     </ListWrapper>
+  </>
);

export default List;

现在,如果我们再次在浏览器中访问该项目,我们将看到我们在本章中创建的 GitHub 作品集页面。该应用程序将看起来像以下截图所示,其中使用了上一节中的默认 GitHub 用户来获取数据:

现在,我们已经使用了 Create React App 并启用了项目作为 PWA 的设置,当我们访问项目的build版本时,应该能够看到一个缓存版本。要构建项目,请运行以下命令:

npm run build

然后,通过运行以下命令来提供build版本:

serve -s build

我们可以通过访问http://localhost:5000/来查看我们应用程序的build版本。但是,我们可能会看到我们应用程序的第一个版本。这是因为该项目已创建为 PWA,因此将显示应用程序的缓存版本。我们可以通过转到浏览器的开发者工具中的Application选项卡来重新启动 Service Worker 并缓存我们应用程序的新版本:

在此页面中,选择侧边栏中的 Service Workers。从这里,我们可以通过按下Update按钮来更新localhost的 service worker。service-worker.js文件将被再次调用,并且当前缓存的版本将被新版本替换。我们还可以通过检查Offline复选框来测试我们的应用程序在互联网连接失败时的响应方式。

正如我们所看到的,Header组件已经被正确缓存,但是没有来自 GitHub 的信息被显示出来。相反,Profile组件显示了一个Loading...消息,因为没有从 API 请求中返回任何信息。如果我们在浏览器中打开开发者工具并查看控制台,我们会看到一个错误消息。我们可以捕获这个错误来显示为什么我们的应用程序不包含任何内容的原因:

  1. 为了做到这一点,我们需要改变src/containers/Profile.js文件,并向state添加一个名为error的变量:
...

class Profile extends Component {
  constructor() {
    super();
    this.state = {
      data: {},
      repositories: [],
      loading: false,
+     error: '',
    }
  }

  async componentDidMount() {
     ...
  1. 这个变量要么是一个空字符串,要么包含try...catch方法返回的错误消息:
...

  async componentDidMount() {
+   try {
      const profile = await fetch('https://api.github.com/users/octocat');
      const profileJSON = await profile.json();

      if (profileJSON) {
        const repositories = await fetch(profileJSON.repos_url);
        const repositoriesJSON = await repositories.json();

       this.setState({
         data: profileJSON,
         repositories: repositoriesJSON,
         loading: false,
       });
     }
   }
+  catch(error) {
+    this.setState({
+      loading: false,
+      error: error.message,
+    });
+  }
+ } ...
  1. 当组件被渲染时,如果发生错误,错误状态也应该从状态中获取并显示,而不是显示加载状态。
...

render() {
-  const { data, loading, repositories } = this.state;
+  const { data, loading, repositories, error } = this.state;

-  if (loading) {
-    return <div>Loading...</div>;
+  if (loading || error) {
+    return <div>{loading ? 'Loading...' : error}</div>;
  }

...

export default Profile;

通过这些更改,状态现在具有加载状态的初始值,在应用程序首次挂载时显示Loading...消息。GitHub 端点被包裹在try...catch语句中,这意味着当fetch函数失败时,我们可以捕获错误消息。如果发生这种情况,loading的值将被错误消息替换。

我们可以通过再次构建我们的应用程序并在本地运行它来检查这些更改是否起作用,就像这样:

npm run build
serve -s build

当我们访问项目http://localhost:5000并在浏览器的开发者工具中的Application选项卡中将应用程序设置为离线模式时,我们将看到一个Failed to fetch消息被显示出来。现在,我们知道如果用户在没有活动互联网连接的情况下使用我们的应用程序,他们将看到这条消息。

总结

在本章中,您使用 Create React App 创建了 React 应用程序的起始项目,该项目具有用于库(如 Babel 和 webpack)的初始配置。通过这样做,您不必自己配置这些库,也不必担心您的 React 代码将如何在浏览器中运行。此外,Create React App 还提供了 PWA 的默认设置,您可以通过注册服务工作程序来使用。这使得您的应用程序在没有互联网连接或在移动设备上运行时可以平稳运行。还记得以前如何使用 CSS 来为应用程序添加样式吗?本章向您展示了如何使用styled-components包来创建可重用且无需导入任何 CSS 文件的样式化组件,因为它使用了 CSS-in-JS 原则。

即将到来的章节将全部使用 Create React App 创建的项目,这意味着这些项目不需要您对 webpack 或 Babel 进行更改。您在本章中喜欢使用styled-components吗?那么您将喜欢这本书中大多数项目都是使用这个包进行样式设计,包括下一章。

在下一章中,我们将在本章的基础上创建一个使用 React 的动态项目管理板,其中使用了Suspense等功能。

进一步阅读

第三章:使用 React 和 Suspense 构建动态项目管理面板

在这本书的前两章中,你已经自己创建了两个 React 项目,现在你应该对 React 的核心概念有了扎实的理解。到目前为止,你已经使用的概念也将在本章中用于创建你的第三个 React 项目,其中包括一些新的和更高级的概念,这将展示出使用 React 的强大之处。如果你觉得自己可能缺乏完成本章内容所需的一些知识,你可以随时重复你到目前为止所建立的内容。

本章将再次使用 Create React App,这是你在上一章中使用过的。在开发本章的项目管理面板应用程序时,你将使用使用styled-components创建的可重用组件。之后,你将使用更高级的 React 技术来控制组件中的数据流。此外,将使用 HTML5 Web API 来动态拖放作为高阶组件HOC)的组件。

本章将涵盖以下主题:

  • React Suspense 和代码拆分

  • 使用 HOC

  • 动态数据流

项目概述

在本章中,我们将使用 Create React App 和styled-components创建一个可重用的 React 组件和样式的渐进式 Web 应用程序PWA)。该应用程序将具有使用 HTML5 拖放 API 的动态拖放界面。

构建时间为 1.5-2 小时。

入门

在本章中,我们将创建一个基于 GitHub 上初始版本的项目:github.com/PacktPublishing/React-Projects/tree/ch3-initial。完整的源代码也可以在 GitHub 上找到:github.com/PacktPublishing/React-Projects/tree/ch3

从 GitHub 下载初始应用程序后,我们可以进入其根目录并运行npm install命令。这将安装来自 Create React App 的核心包(reactreact-domreact-scripts),以及我们在上一章中使用的styled-components包。安装完成后,我们可以通过执行npm start命令启动应用程序,并通过访问http://localhost:3000在浏览器中访问项目。

我们还可以通过执行npm run build,然后serve -s build来构建应用程序。现在可以访问应用程序的缩小版本http://localhost:5000。由于它被设置为 PWA,即使没有任何互联网连接,它也可以工作。

如果您之前构建并提供了 Create React App PWA,可能会看到与在本地运行项目时不同的应用程序。这是由于 PWA 的 service worker 在浏览器中存储了该应用程序的缓存版本。您可以通过打开devTools并打开Application选项卡,在Clear storage部分中单击Clear site data按钮来从浏览器缓存中删除任何先前的应用程序。

如下截图所示,该应用程序具有一个基本的标题和分为四列。这些列是项目管理看板的车道,一旦我们将项目连接到数据文件,它们将包含各个票证:

正如我们在第二章中提到的,使用可重用的 React 组件创建渐进式 Web 应用程序,我们可以通过访问Application选项卡的Service Workers部分来检查当没有互联网连接时我们的应用程序是否正在运行。在此页面上,我们可以选中Offline复选框,然后尝试刷新浏览器。

如果我们查看项目的结构,我们会发现它的结构与前几章的项目相同。应用程序的入口点是src/index.js文件,它渲染了一个名为App的组件,该组件包含两个其他组件,分别是HeaderBoard。第一个是应用程序的实际标题,而Board组件包含我们在应用程序中看到的四个列。这些列由Lane组件表示。

此外,在assets目录中,我们会看到一个名为data.json的文件,其中包含我们可以在项目管理看板上显示的数据:

project-management-board
|-- assets
    |-- data.json
|-- node_modules
|-- public
    |-- favicon.ico
    |-- index.html
    |-- manifest.json
|-- src
    |-- components
        |-- Header
            |-- Header.js
        |-- Lane
            |-- Lane.js
    |-- containers
        |-- App.js
        |-- Board.js
    |-- index.js
    |-- serviceWorker.js
.gitignore
package.json

创建项目管理看板应用

在本节中,我们将创建一个使用 React API(如 Suspense 和 HTML5 拖放 API)的项目管理看板 PWA。我们将使用 Create React App,可以在本章的 GitHub 存储库中找到。

处理数据流

在放置初始版本的应用程序之后,下一步是从数据文件中获取数据并通过组件处理其流程。为此,我们将使用 React Suspense 和 memo。使用 Suspense,我们可以访问 React 懒加载 API 来动态加载组件,并且使用 memo,我们可以控制哪些组件在其 props 更改时应该重新渲染。

本节的第一部分将向我们展示如何使用 React 生命周期方法从数据源加载数据并在 React 组件中显示。

加载和显示数据

加载和显示从数据源检索的数据是我们在上一章中做过的事情。本节将进一步探讨这一点。按照以下步骤开始:

  1. 我们将从数据文件中获取项目数据开始。为此,我们需要向Board组件添加必要的函数。我们需要这些函数来访问 React 生命周期。这些是constructor,在其中设置初始状态,以及componentDidMount,在其中将获取数据:
...
class Board extends Component {
+ constructor() {
+   super();
+   this.state = {
+     data: [],
+     loading: true,
+     error: '',
+   }
+ }

+ async componentDidMount() {
+   try {
+     const tickets = await fetch('../../assets/data.json');
+     const ticketsJSON = await tickets.json();

+     if (ticketsJSON) {
+       this.setState({
+         data: ticketsJSON,
+         loading: false,
+       });
+     }
+   } catch(error) {
+     this.setState({
+      loading: false,
+      error: error.message,
+    });
+   }
+ }

  render() {
    ...
  }
}

export default Board;

componentDidMount生命周期函数中,在try..catch语句内获取数据。此语句捕获从数据获取过程返回的任何错误,并用此消息替换错误状态。

  1. 现在,我们可以将票务分发到相应的车道上:
...
class Board extends Component {
  ...
  render() {
+   const { data, loading, error } = this.state;

    const lanes = [
      { id: 1, title: 'To Do' },
      { id: 2, title: 'In Progress' },
      { id: 3, title: 'Review' },
      { id: 4, title: 'Done' },
    ];

    return (
      <BoardWrapper>
        {lanes.map(lane =>
          <Lane
            key={lane.id}
            title={lane.title}
+           loading={loading}
+           error={error}
+           tickets={data.filter(ticket => ticket.lane === 
            lane.id)}
          />
        )}
      </BoardWrapper>
    );
  }
}

export default Board;

在上述代码中,我们可以看到,在render内部,dataloadingerror常量已经从状态对象中解构出来。在迭代lanes常量的函数内部,这些值应该作为 props 传递给Lane组件。对于数据状态,有一些特殊的情况,因为filter函数被用来仅返回与车道 ID 匹配的data状态的票。

  1. 接下来,我们需要对Lane组件进行一些更改:
import React from 'react';
import styled from 'styled-components';
+ import Ticket from '../Ticket/Ticket';

...

+ const TicketsWrapper = styled.div`
+  padding: 5%;
+ `;

+ const Alert = styled.div`
+  text-align: center;
+ `;

- const Lane = ({ title }) => (
+ const Lane = ({ tickets, loading, error, title }) => (
    <LaneWrapper>
      <Title>{title}</Title>
+     {(loading || error) && <Alert>{loading ? 'Loading...' : 
       error}</Alert>}
+     <TicketsWrapper>
+       {tickets.map(ticket => <Ticket key={ticket.id} 
         ticket={ticket} />)}
+     </TicketsWrapper>
    </LaneWrapper>
);

export default Lane;
  1. Lane组件现在需要三个其他 props,即ticketsloadingerror,其中tickets包含来自data状态的票数组,loading表示是否应显示加载消息,error包含错误消息(如果有的话)。我们可以看到已经创建了一个包装器,并且在map函数内部,将呈现显示票务信息的Ticket组件。这个Ticket组件也是我们需要在src/components目录中创建的:
import React from 'react';
import styled from 'styled-components';

const TicketWrapper = styled.div`
  background: darkGray;
  padding: 20px;
  border-radius: 20px;

  &:not(:last-child) {
    margin-bottom: 5%;
  }
`;

const Title = styled.h3`
  width: 100%;
  margin: 0px;
`;

const Body = styled.p`
  width: 100%;
`;

const Ticket = ({ ticket }) => (
  <TicketWrapper>
    <Title>{ticket.title}</Title>
    <Body>{ticket.body}</Body>
  </TicketWrapper>
);

export default Ticket;

如果我们在网页浏览器中访问http://localhost:3000,我们会看到以下内容:

由于此应用程序已设置为 PWA,我们可以重新构建项目并重新启动服务工作程序。在离线模式下,项目应该仍然显示标题和四列,并在这些列内显示一个消息,显示“无法获取*”。

要构建和提供 PWA,我们需要在构建过程完成后运行npm runserve -s build。现在,我们可以访问项目http://localhost:5000。我们可能需要重新启动服务工作程序,在devTools中的“应用程序”选项卡上可以执行此操作,并选择“服务工作程序”部分。在此部分的右侧,紧挨服务工作程序,按“更新”。要在离线模式下查看应用程序,我们需要选中“离线”复选框。

从数据源获取数据是可以在整个应用程序中重复使用的逻辑。在下一节中,我们将探讨如何使用 HOC 在多个组件之间重用此逻辑。

开始使用 HOC

HOC 是 React 中的高级功能,专注于组件的可重用性。它们不是官方的 React API 的一部分,但引入了一种在核心团队和许多库中流行的模式。

在本节的第一部分中,我们将创建我们的第一个 HOC,该 HOC 使用逻辑从我们在上一节中创建的数据源中检索数据。

创建 HOC

正如我们之前提到的,HOC 专注于重用组件。因此,它可以最好地描述如下:

“HOC 是一个接受组件并返回一个新组件的函数。”

为了解释这在实践中意味着什么,让我们创建一个示例。我们的项目有一个Board组件,它获取并呈现所有的车道。在这个组件中有逻辑,以constructorcomponentDidMount的形式,以及关于如何呈现每个Lane组件的信息。我们如何处理只想显示一个没有车道,只有票的情况?我们只是向Board组件发送不同的 props 吗?当然,这是可能的,但在 React 中,这就是 HOC 的用途。

一个没有lanesBoard组件将不会映射所有的lanes并将相应的lane作为 props 渲染。相反,它将映射所有的tickets并直接渲染它们。尽管渲染的组件不同,但设置初始状态、获取数据和渲染组件的逻辑可以被重用。HOC 应该能够通过将这个组件发送给它以及一些额外的 props,为Board组件添加生命周期。

要创建 HOC,将一个名为withDataFetching.js的新文件放在src目录中。现在,按照以下步骤进行操作:

  1. 首先,我们需要导入 React 并创建一个新的 HOC 函数,它成为默认导出。由于这个 HOC 将为数据获取添加生命周期,让我们称这个 HOC 为withDataFetching,并让它以组件作为参数。这个函数应该返回另一个组件。
+ import React from 'react';

+ export default function withDataFetching(WrappedComponent) {
+   return class extends React.Component {

+ }
  1. 在返回的组件内部,添加constructor组件,它的结构几乎与Board组件相同。
...

export default function withDataFetching(WrappedComponent) {
  return class extends React.Component {
+   constructor(props) {
+     super(props);
+     this.state = {
+       data: [],
+       loading: true,
+       error: '',
+     };
+   }
...
  1. 接下来,我们需要创建componentDidMount函数,这是数据获取的地方。dataSource属性被用作获取数据的位置。另外,请注意,常量名称现在更加通用,不再指定单一用途。
export default function withDataFetching(WrappedComponent) {
  return class extends React.Component {

  ...

+ async componentDidMount() {
+   try {
+     const data = await fetch(this.props.dataSource);
+     const dataJSON = await data.json();

+     if (dataJSON) {
+       this.setState({
+         data: dataJSON,
+         loading: false,
+       });
+     }
+   } catch(error) {
+     this.setState({
+       loading: false,
+       error: error.message,
+     });
+   }
+ }

 ...
  1. render函数中,我们可以返回插入到函数中的WrappedComponent,并将dataloadingerror状态作为 props 传递。重要的是要理解,它还接受任何通过{...this.props}扩展的额外 props。
export default function withDataFetching(WrappedComponent) {
  return class extends React.Component {

    ...

+   render() {
+     const { data, loading, error } = this.state;

+     return (
+       <WrappedComponent 
+         data={data} 
+         loading={loading} 
+         error={error}
+         {...this.props} 
+       />
+     );
+   }
  };
}

恭喜!你已经创建了你的第一个 HOC!但是,它需要一个组件来返回一个支持数据获取的组件。因此,我们需要将我们的Board组件重构为一个函数组件。让我们开始吧:

  1. src/withDataFetching.js文件中导入 HOC:
import React, { Component } from 'react';
import styled from 'styled-components';
+ import withDataFetching from '../withDataFetching';
import Lane from '../components/Lane/Lane';

const BoardWrapper = styled.div`
  display: flex;
  justify-content: space-between;
  flex-direction: row;
  margin: 5%;

  @media (max-width: 768px) {
    flex-direction: column;
  }
`;

...
  1. 随后,我们可以从这个文件中删除整个类组件Board,并创建一个新的函数组件,返回我们在重构后的类组件的return函数中声明的 JSX。这个函数组件将以lanesloadingerrordata作为 props。
import React, { Component } from 'react';
import styled from 'styled-components';
import withDataFetching from '../withDataFetching';
import Lane from '../components/Lane/Lane';

const BoardWrapper = ...;

+ const Board = ({ lanes, loading, error, data }) => (
+  <BoardWrapper>
+    {lanes.map(lane =>
+      <Lane
+        key={lane.id}
+        title={lane.title}
+        loading={loading}
+        error={error}
+        tickets={data.filter(ticket => ticket.lane === lane.id)}
+      />
+    )}
+  </BoardWrapper>
+ ); export default Board;
  1. 最后,导出函数组件以及 HOC 函数:
...
const Board = ({ lanes, loading, error, data }) => (
  <BoardWrapper>
    {boards.map(lane =>
      <Lane
        key={lane.id}
        title={lane.title}
        loading={loading}
        error={error}
        tickets={data.filter(ticket => ticket.lane === lane.id)}
      />
    )}
  </BoardWrapper>
);

- export default Board;
+ export default withDataFetching(Board);

但这些 props 是从哪里来的呢?如果我们打开应用程序并打开浏览器,我们会看到以下错误:

TypeError: Cannot read property 'map' of undefined

这是因为我们的Board组件尝试对lanesprop 进行映射,但是在 HOC 中,WrappedComponent接收到dataloadingerror prop。幸运的是,我们还添加了通过组件发送的任何其他 props 的扩展选项。如果我们打开App组件,在那里Board组件被打开,我们可以使用之前在Board组件中声明的lane常量传递lanesprop:

...

class App extends Component {
  render() {
+   const lanes = [
+     { id: 1, title: 'To Do' },
+     { id: 2, title: 'In Progress' },
+     { id: 3, title: 'Review' },
+     { id: 4, title: 'Done' },
+   ]

    return (
        <>
          <GlobalStyle />
            <AppWrapper>
            <Header />
-           <Board />
+           <Board lanes={lanes} />
          </AppWrapper>
        </>
    );
  }
}

export default App;

现在,如果我们在浏览器中查看我们的项目,我们会看到应用程序再次被渲染。然而,它显示了 HOC 中try...catch语句的错误消息。这个 HOC 需要dataSource0 prop,我们也需要将其传递给Board组件:

...
class App extends Component {
  render() {

    ...

    return (
        <>
          <GlobalStyle />
            <AppWrapper>
            <Header />
-           <Board lanes={lanes} />
+           <Board lanes={lanes} dataSource={'../../assets/data.json'} />
          </AppWrapper>
        </>
    );
  }
}

export default App;

最后,我们可以看到Board组件在浏览器中由 HOC 渲染。然而,正如我们之前提到的,HOC 应该重用逻辑。在下一节中,我们将学习如何通过将 HOC 添加到不同的组件来实现这一点。

使用 HOC

在第一个 HOC 就位的情况下,现在是时候考虑使用这个 HOC 创建其他组件,比如只显示票的组件。创建这个组件的过程包括两个步骤:创建实际的组件并导入组件并向其传递所需的 props。让我们开始吧:

  1. 在 containers 目录中,我们需要创建一个名为Tickets.js的新文件,并将以下代码放入其中。在我们导入 HOC 的地方,使用styled-components设置一些基本样式,并创建一个可以导出的函数组件:
import React from 'react';
import styled from 'styled-components';
import withDataFetching from '../withDataFetching';
import Ticket from '../components/Ticket/Ticket';

const TicketsWrapper = styled.div`
  display: flex;
  justify-content: space-between;
  flex-direction: row;
  margin: 5%;

  @media (max-width: 768px) {
    flex-direction: column;
  }
`;

const Alert = styled.div`
    text-align: center;
`;

const Tickets = ({ loading, data, error }) => (
  <TicketsWrapper>
    {(loading || error) && <Alert>{loading ? 'Loading... : 
     error}</Alert>}
    {data.map(ticket => <Ticket key={ticket.id} ticket={ticket} />)}
  </TicketsWrapper>
);

export default withDataFetching(Tickets);
  1. App组件中,我们可以导入这个组件并向其传递一个dataSource prop:
import React, { Component } from 'react';
import styled, { createGlobalStyle } from 'styled-components';
import Board from './Board';
+ import Tickets from './Tickets';
import Header from '../components/Header/Header';

...

class App extends Component {
  render() {
    ...
    return (
        <>
          <GlobalStyle />
            <AppWrapper>
            <Header />
            <Board boards={boards} 
             dataSource={'../../assets/data.json'} />
+           <Tickets dataSource={'../../assets/data.json'} />                    
            </AppWrapper>
       </>
    );
  }
}

export default App;

有点不对劲的是,票据显示在一起而没有任何边距。我们可以在实际的Ticket组件中更改这一点,但这也会改变在车道中显示的票据的边距。为了解决这个问题,我们可以传递一个被styled-components用于这个组件的 prop。为了做到这一点,我们需要对渲染票据的Tickets组件和定义样式的Ticket组件进行更改。让我们开始吧:

  1. map函数内部向Ticket组件传递一个名为marginRight的新 prop。这个 prop 只是一个布尔值,不需要值:
...

const Tickets = ({ loading, data, error }) => (
  <TicketsWrapper>
    {(loading || error) && <Alert>{loading ? 'Loading...' : 
      error}</Alert>}
-   {data.map(ticket => <Ticket key={ticket.id} ticket={ticket} />)}
+   {data.map(ticket => <Ticket key={ticket.id} marginRight ticket={ticket} />)}
  </TicketsWrapper>
);

export default withDataFetching(Tickets);
  1. Ticket组件中,我们需要解构这个 prop 并将它传递给我们用styled-components创建的TicketWrapper
import React from 'react';
import styled from 'styled-components';

const TicketWrapper = styled.div`
  background: darkGray;
  padding: 20px;
  border-radius: 20px;

  &:not(:last-child) {
    margin-bottom: 5%;
+   margin-right: ${props => !!props.marginRight ? '1%' : '0'};
  }
`;

...

- const Ticket = ({ ticket }) => (
+ const Ticket = ({ marginRight, ticket }) => (
-   <TicketWrapper>
+   <TicketWrapper marginRight={marginRight}>
      <Title>{ticket.title}</Title>
      <Body>{ticket.body}</Body>
    </TicketWrapper>
);

export default Ticket;

现在,我们可以通过向Ticket组件发送 props 来控制TicketWrappermargin-right属性。如果我们在浏览器中查看我们的应用程序,我们会看到,在具有四个车道的Board组件正下方,另一个组件正在呈现一个Ticket组件。

我们可以自定义的另一件事是,HOC 返回的组件在 React 开发者工具中的命名方式。在浏览器中打开应用程序并查看组件树。在这里,我们可以看到我们创建的没有 HOC 的组件具有可读的命名约定,如AppHeader。由 HOC 创建的组件被命名为<_class />。为了使这个组件树更清晰,我们可以让我们的 HOC 轻松地将这种命名约定添加到它创建的组件中。通常,我们会使用 HOC 创建的组件的名称。然而,在我们的情况下,HOC 被称为withDataFetching,当我们插入一个名为Board的组件时,在 React 开发者工具中显示的名称将是withDataFetching(Board)。为了设置这一点,我们需要对withDataFetching.js文件进行一些更改。让我们开始吧:

  1. 在声明类组件之前删除return,并给类组件命名。为此,使用 HOC 的名称,并将第一个字符改为大写字母。这将得到WithDataFetching
import React from 'react';

export default function withDataFetching(WrappedComponent) {
- return class extends React.Component {
+ class WithDataFetching extends React.Component {
  ...
  1. 在文件的最后几行,我们可以获取已插入 HOC 的WrappedComponent的名称,并将其用于通过设置返回组件的displayName来命名 HOC。不要忘记在文件末尾返回WithDataFetching类组件:
import React from 'react';

export default function withDataFetching(WrappedComponent) {
  class WithDataFetching extends React.Component {

    ...

    render() {
      const { data, loading, error } = this.state;

      return (
        <WrappedComponent 
          data={data} 
          loading={loading} 
          error={error} 
          {...this.props} 
        />
      );
    }
  };

+ WithDataFetching.displayName = `WithDataFetching(${WrappedComponent.name})`;

+ return WithDataFetching;
}

再次查看 React 开发者工具,我们可以看到这些更改导致了 HOC 创建的组件具有更可读的命名约定。

在我们的应用程序中,显示在车道中的所有票据只在一个部分,因为我们希望能够将这些票据拖放到不同的车道中。我们将在下一节中学习如何做到这一点,我们将为板块添加动态功能。

让板块变得动态起来

通常给项目管理板提供良好用户交互的一件事是能够将票务从一个车道拖放到另一个车道。这是可以很容易地通过 HTML5 拖放 API 来实现的,该 API 在包括 IE11 在内的每个现代浏览器中都可用。

HTML5 拖放 API 使我们能够在项目管理板中拖放元素。为了实现这一点,它使用拖动事件。onDragStartonDragOveronDrop将用于此应用程序。这些事件应放置在LaneTicket组件上。让我们开始吧:

  1. 首先,我们需要将Board组件从函数组件更改为类组件。我们这样做是因为票务数据需要添加到状态中,而Board组件是最合适的地方,因为我们可能希望Lane组件在其他地方被重用。我们可以通过更改Board常量的定义来实现这一点,如下所示:
...
 - const Board = ({ lanes, loading, data, error }) => (
+ class Board extends React.Component {
+   render() {
+     const { lanes, loading, data, error } = this.props;

+     return (
        <BoardWrapper>
          {lanes.map(lane =>
            <Lane
              key={lane.id}
              title={lane.title}
              loading={loading}
              error={error}
              tickets={data.filter(ticket => ticket.lane ===  
              lane.id)}
            />
          )}
        </BoardWrapper>
      );
+   }
+ }

export default withDataFetching(Board);
  1. 现在,我们可以将票务的初始值添加到状态中。我们这样做是因为我们希望更改应该放置在的车道的键。通过将这些数据添加到状态中,我们可以使用setState函数动态地改变它。
...
class Board extends React.Component {
+ constructor() {
+   super();
+   this.state = {
+     tickets: [],
+   };
+ } 
  render() {
  ...
  1. 由于数据需要从源加载,并且在应用程序首次挂载时不可用,我们需要检查这些组件的 props 是否已更改。如果是,我们需要将票务数据添加到状态中。为此,使用componentDidUpdate生命周期方法,该方法可以将先前的 props 作为参数:
...

class Board extends React.Component {
  constructor() {
    super()
    this.state = {
      tickets: [],
    };
  }

+ componentDidUpdate(prevProps) {
+   if (prevProps.data !== this.props.data) {
+     this.setState({ tickets: this.props.data });
+   }
+ } 
  render() {
  ...
  1. 最后,显示来自状态的票务:
...  
render() {
-   const { lanes, data, loading, error } = this.props; 
+   const { lanes, loading, error } = this.props;

    return (
      <BoardWrapper>
        {lanes.map(lane =>
          <Lane
            key={lane.id}
            title={lane.title}
            loading={loading}
            error={error}
-           tickets={data.filter(ticket => ticket.lane === 
            lane.id)}
+           tickets={this.state.tickets.filter(ticket => 
            ticket.lane === lane.id)}
          />
        )}
      </BoardWrapper>
    );
  }
}

export default withDataFetching(Board);

如果我们现在在浏览器中查看项目,应该没有可见的变化。唯一的区别是票务的数据现在是从状态中加载,而不是从 props 中加载。

在同一个文件中,让我们添加响应拖放事件的函数,这些函数需要发送到LaneTicket组件:

  1. 首先,添加onDragStart事件的事件处理程序函数,该函数在开始拖动操作时触发,添加到Board组件。这个函数需要传递给Lane组件,然后可以传递给Ticket组件。这个函数为被拖动的票务设置一个 ID,该 ID 被用于浏览器识别拖动元素的dataTransfer对象:
...
class Board extends React.Component {
  constructor() {
    super();
    this.state = {
      tickets: [],
    };
  }

  componentDidUpdate(prevProps) {
    if (prevProps.data !== this.props.data) {
        this.setState({ tickets: this.props.data });
    }
  }

+ onDragStart = (e, id) => {
+   e.dataTransfer.setData('id', id);
+ }; 
  render() {
    const { lanes, loading, error } = this.props;

    return (
      <BoardWrapper>
        {lanes.map(lane =>
          <Lane
            key={lane.id}
            title={lane.title}
            loading={loading}
            error={error}
+           onDragStart={this.onDragStart}
            tickets={this.state.tickets.filter(ticket => 
            ticket.lane === lane.id)}
          />
        )}
      </BoardWrapper>
    );
  }
}

export default withDataFetching(Board);
  1. Lane组件中,我们需要将此事件处理程序函数传递给Ticket组件:
...
- const Lane = ({ tickets, loading, error, title }) => (
+ const Lane = ({ tickets, loading, error, onDragStart, title }) => (
  <LaneWrapper>
    <Title>{title}</Title>
    {(loading || error) && <Alert>{loading ? 'Loading...' : 
     error}</Alert>}
    <TicketsWrapper>
-     {tickets.map(ticket => <Ticket key={ticket.id} 
       ticket={ticket} />)}
+     {tickets.map(ticket => <Ticket key={ticket.id} 
       onDragStart={onDragStart} ticket={ticket} />)}
    </TicketsWrapper>
  </LaneWrapper>
);

export default Lane;
  1. 现在,我们可以在Ticket组件中调用这个函数,我们还需要在TicketWrapper中添加draggable属性。在这里,我们将元素和票据 ID 作为参数发送到事件处理程序:
...
- const Ticket = ({ marginRight, ticket }) => (
+ const Ticket = ({ marginRight, onDragStart, ticket }) => (
  <TicketWrapper
+   draggable
+   onDragStart={e => onDragStart(e, ticket.id)}
    marginRight={marginRight}
  >
    <Title>{ticket.title}</Title>
    <Body>{ticket.body}</Body>
  </TicketWrapper>
);

export default Ticket;

做出这些更改后,我们应该能够看到每个票据都可以被拖动。但是现在不要把它们放在任何地方——其他放置事件和更新状态的事件处理程序也应该被添加。可以通过点击票据而不释放鼠标并将其拖动到另一个车道来将票据从一个车道拖动到另一个车道,如下面的截图所示:

实现了onDragStart事件后,onDragOveronDrop事件也可以实现。让我们开始吧:

  1. 默认情况下,不可能将元素放入另一个元素中;例如,将Ticket组件放入Lane组件中。这可以通过在onDragOver事件中调用preventDefault方法来防止:
...
 +  onDragOver = e => {
+   e.preventDefault();
+ };

 render() {
    const { lanes, loading, error } = this.props;

    return (
      <BoardWrapper>
        {lanes.map(lane =>
          <Lane
            key={lane.id}
            title={lane.title}
            loading={loading}
            error={error}
            onDragStart={this.onDragStart}
+           onDragOver={this.onDragOver}
            tickets={this.state.tickets.filter(ticket => 
            ticket.lane === lane.id)}
          />
        )}
      </BoardWrapper>
    );
  }
}
  1. 这个事件处理程序需要放在Lane组件上:
...
- const Lane = ({ tickets, loading, error, title }) => (
+ const Lane = ({ tickets, loading, error, onDragOver, title }) => (
-   <LaneWrapper>
+   <LaneWrapper
+     onDragOver={onDragOver}
+   >
      <Title>{title}</Title>
      {(loading || error) && <Alert>{loading ? 'Loading...' : 
       error}</Alert>}
      <TicketsWrapper>
        {tickets.map(ticket => <Ticket onDragStart={onDragStart}   
         ticket={ticket} />)}
      </TicketsWrapper>
    </LaneWrapper>
);

export default Lane;

onDrop事件是让事情变得有趣的地方,因为这个事件使我们能够在完成拖动操作后改变状态。

这个事件处理程序的函数应该放在Ticket组件上,但在Board组件中定义,因为setState函数只能在与状态的初始值相同的文件中调用。

...  
+  onDrop = (e, laneId) => {
+   const id = e.dataTransfer.getData('id');
+
+   const tickets = this.state.tickets.filter(ticket => {
+     if (ticket.id === id) {
+       ticket.board = boardId;
+     }
+     return ticket;
+   });
+
+   this.setState({
+     ...this.state,
+     tickets,
+   });
+ }; 
  render() {
    const { lanes, loading, error } = this.props;

    return (
      <BoardWrapper>
        {lanes.map(lane =>
          <Lane
            key={lane.id}
+           laneId={lane.id}
            title={lane.title}
            loading={loading}
            error={error}
            onDragStart={this.onDragStart}
            onDragOver={this.onDragOver}
+           onDrop={this.onDrop}
            tickets={this.state.tickets.filter(ticket => ticket.lane === 
            lane.id)}
          />
        )}
      </BoardWrapper>
    );
  }
}

export default withDataFetching(Board);

这个onDrop事件处理函数接受一个元素和车道的 ID 作为参数,因为它需要被拖动元素的 ID 和它应该放置在的新车道的 ID。有了这些信息,函数使用filter函数来找到需要移动的票,并改变车道的 ID。这些新信息将用setState函数替换状态中票的当前对象。由于onDrop事件是从Lane组件触发的,它作为一个 prop 传递给这个组件。此外,车道的 ID 也作为一个 prop 添加,因为这需要从Lane组件传递给onDrop事件处理函数:

...
- const Lane = ({ tickets, loading, error, onDragStart, onDragOver, title }) => (
+ const Lane = ({ laneId, tickets, loading, error, onDragStart, onDragOver, onDrop, title }) => (
  <LaneWrapper
    onDragOver={onDragOver}
+   onDrop={e => onDrop(e, laneId)}
  >
    <Title>{title}</Title>
    {(loading || error) && <Alert>{loading ? 'Loading...' : error}</Alert>}
    <TicketsWrapper>
      { tickets.map(ticket => <Ticket onDragStart={onDragStart} 
        ticket={ticket} />)}
    </TicketsWrapper>
  </LaneWrapper>
);

export default Lane;

有了这个,我们就能在我们的看板上将票据拖放到其他车道上了。

总结

在本章中,您创建了一个项目管理面板,可以使用 React Suspense 和 HTML5 拖放 API 将票据从一个车道移动到另一个车道。该应用程序的数据流使用本地状态和生命周期来处理,并确定在不同车道中显示哪些票据。本章还介绍了高阶组件(HOCs)的高级 React 模式。使用 HOCs,您可以在应用程序中跨类组件重用状态逻辑。

这种高级模式还将在下一章中使用,该章将处理 React 应用程序中的路由和服务器端渲染(SSR)。您有没有尝试过使用 Stack Overflow 来找到您曾经遇到的编程问题的解决方案?我有!

在下一章中,我们将构建一个使用 Stack Overflow 作为数据源并使用 React 来渲染应用程序的社区动态。

进一步阅读

第四章:使用 React Router 构建基于 SSR 的社区动态

到目前为止,您已经了解到 React 应用程序通常是单页应用程序SPA),可以用作渐进式 Web 应用程序PWA)。这意味着应用程序是在客户端渲染的,当用户访问您的应用程序时,它会在浏览器中加载。但您是否知道 React 还支持服务器端渲染SSR),就像您可能还记得从以前代码只能从服务器渲染的时代一样?

在这一章中,您将使用react-router为 Create React App 添加声明式路由,并使组件动态加载到服务器而不是浏览器。为了启用 SSR,将使用 React 特性 Suspense 与ReactDOMServer。如果您对搜索引擎优化SEO)感兴趣,本章将使用 React Helmet 为页面添加元数据,以便您的应用程序可以更好地被搜索引擎索引。

本章将涵盖以下主题:

  • 声明式路由

  • 服务器端渲染

  • React 中的 SEO

项目概述

在本章中,我们将使用react-router创建一个支持 SSR 的 PWA,因此从服务器而不是浏览器加载。此外,该应用程序使用 React Helmet 进行搜索引擎优化。

构建时间为 2 小时。

入门

在本章中,我们将创建的项目是在初始版本的基础上构建的,您可以在 GitHub 上找到:github.com/PacktPublishing/React-Projects/tree/ch4-initial。完整的源代码也可以在 GitHub 上找到:github.com/PacktPublishing/React-Projects/tree/ch4。此外,该项目使用公开可用的 Stack Overflow API 来填充应用程序的数据。这是通过获取发布到 Stack Overflow 的问题来完成的。有关此 API 的更多信息,请访问:api.stackexchange.com/docs/questions#order=desc&sort=hot&tagged=reactjs&filter=default&site=stackoverflow&run=true

从 GitHub 下载初始项目后,您需要进入该项目的根目录并运行npm install。由于该项目是基于 Create React App 构建的,运行此命令将安装reactreact-domreact-scripts。此外,styled-components用于处理应用程序中所有组件的样式。安装过程完成后,您可以执行npm命令start,以便在浏览器中访问项目,网址为http://localhost:3000

由于该项目设置为 PWA,服务工作者已注册,使得即使没有互联网连接也可以访问该应用。您可以通过首先运行npm run build,然后在构建过程完成后运行serve -s build来检查这一点。现在可以访问该应用的构建版本,网址为http://localhost:5000。如前一章所述,您可以通过访问浏览器的开发者工具中的“应用程序”选项卡来检查在没有互联网连接时应用程序是否仍然可用。在该选项卡中,您可以在左侧菜单中找到“服务工作者”;点击此链接后,您可以在出现的页面上选择“离线”复选框。

如果您之前构建并提供过 Create React App PWA,则可能看到与在本地运行项目时不同的应用程序。您可以通过打开浏览器的开发者工具并打开“应用程序”选项卡,在其中可以点击“清除站点数据”按钮来删除浏览器缓存中的任何先前应用程序。

初始应用程序位于http://localhost:3000,包括一个简单的标题和一系列卡片,如下面的屏幕截图所示。这些卡片有标题和元信息,如查看次数、回答次数以及提出此问题的用户的信息:

如果您查看项目的结构,它使用与之前创建的项目相同的结构。该应用程序的入口点是一个名为src/index.js的文件,它渲染一个名为App的容器组件,其中包含HeaderFeed组件。Header组件仅显示项目的标题,而Feed是一个具有生命周期方法的类组件,调用 Stack Overflow API,并渲染包含 Stack Overflow 问题的Card组件:

community-feed
|-- node_modules
|-- public
    |-- favicon.ico
    |-- index.html
    |-- manifest.json
|-- src
    |-- components
        |-- Header
            |-- Header.js
        |-- Card
            |-- Card.js
        |-- Owner
            |-- Owner.js
    |-- containers
        |-- App.js
        |-- Feed.js
    |-- index.js
    |-- serviceWorker.js
.gitignore
package.json

社区动态应用

在本节中,您将使用启用了 SSR 的声明式路由构建一个社区动态应用程序。为了 SEO,将使用一个名为 React Helmet 的软件包。在这个社区动态中,您可以看到 Stack Overflow 上具有reactjs标签的最新问题的概述,并单击它们以查看更多信息和答案。起点将是使用 Create React App 创建的项目。

声明式路由

使用react-router软件包,您可以通过添加组件来为 React 应用程序添加声明式路由。这些组件可以分为三种类型:路由器组件、路由匹配组件和导航组件。

使用react-router设置路由包括多个步骤:

  1. 要使用这些组件,您需要通过执行以下命令来安装react-router的 web 软件包,称为react-router-dom
npm install react-router-dom
  1. 安装完react-router-dom后,下一步是在您的应用程序入口点组件中从该软件包中导入路由和路由匹配组件。在这种情况下,这是App组件,它位于src/containers目录中:
import React, { Component } from 'react';
import styled, { createGlobalStyle } from 'styled-components';
+ import { BrowserRouter as Router, Route } from 'react-router-dom';
import Header from '../components/Header/Header';
import Feed from './Feed';

const GlobalStyle = createGlobalStyle`...`;

const AppWrapper = styled.div`...`;

class App extends Component {
    ...
  1. 实际的路由必须添加到该组件的return函数中,在那里所有的路由匹配组件(Route)必须包裹在一个路由组件Router中。当您的 URL 与Route的任何迭代中定义的路由匹配时,该组件将呈现添加为component属性的 JSX 组件:
...
class App extends Component {
  render() {
    return (
        <>
          <GlobalStyle />
          <AppWrapper>
            <Header />
+           <Router>
+             <Route path='/' component={Feed} />
+           </Router>
          </AppWrapper>
        </>
    );
  }
}

export default App;
  1. 如果您现在在浏览器中再次访问项目,地址为http://localhost:3000,将呈现显示所有问题的Feed组件。此外,如果您在浏览器中输入http://localhost:3000/feedFeed组件仍将被呈现。这是因为/路由匹配每个可能的 URL,因为您没有定义应该进行精确匹配。因此,添加exact属性到Route
...
class App extends Component {
  render() {
    return (
        <>
          <GlobalStyle />
          <AppWrapper>
            <Header />
            <Router>
-             <Route path='/' component={Feed} />
+             <Route exact path='/' component={Feed} />
            </Router>
          </AppWrapper>
        </>
    );
  }
}

export default App;

现在,如果您访问除/之外的任何路由,不应该看到Feed组件被呈现。

如果您希望显示这些路由,例如,显示特定的问题,您需要向路由发送参数。如何做到这一点将在本节的下一部分中展示。

带参数的路由

有了第一个路由之后,其他路由可以添加到路由器组件中。一个合理的路由是为单独的问题添加一个路由,该路由具有指定要显示的问题的额外参数。因此,必须创建一个名为Question的新容器组件,其中包含从 Stack Overflow API 获取问题的逻辑。当路径匹配/question/:id时,将呈现此组件,其中id代表从 feed 中点击的问题的 ID:

  1. src/containers目录中创建一个名为Question的新类组件,并向该文件添加一个constructor和一个render方法:
import React, { Component } from 'react';
import styled from 'styled-components';

const QuestionWrapper = styled.div`
  display: flex;
  justify-content: space-between;
  flex-direction: column;
  margin: 5%;
`;

const Alert = styled.div`
  text-align: center;
`;

class Question extends Component {
  constructor() {
    super();
    this.state = {
      data: [],
      loading: true,
      error: '',
    };
  }

  render() {
    const { data, loading, error } = this.state;

    if (loading || error) {
      return <Alert>{loading ? 'Loading...' : error}</Alert>;
    }

    return (
      <QuestionWrapper></QuestionWrapper>
    );
  }
}

export default Question;
  1. 要使此路由可用,您需要在App组件内导入此组件并为其定义一个路由:
import React, { Component } from 'react';
import styled, { createGlobalStyle } from 'styled-components';
import { BrowserRouter as Router, Route } from 'react-router-dom';
import Header from '../components/Header/Header';
import Feed from './Feed';
+ import Question from './Question';
...
class App extends Component {
  render() {
    return (
        <>
          <GlobalStyle />
          <AppWrapper>
            <Header />
            <Router>
              <Route exact path='/' component={Feed} />
+             <Route path='/questions/:id' component={Question} />
            </Router>
          </AppWrapper>
        </>
    );
  }
}

export default App;

如果您现在访问http://localhost:3000/questions/55366474,由于尚未实现数据获取,将显示Loading...消息。Route组件将 props 传递给它渲染的组件,在本例中是Question;这些 props 是matchlocationhistory。您可以通过打开 React 开发者工具并搜索Question组件来查看这一点,将返回以下结果:

match属性是最有趣的,因为它包含了id参数的值。locationhistory属性包含了有关应用程序当前位置和过去位置的信息。

您还可以通过使用withRouterHigher-Order Component (HOC)访问react-router props,该组件在每次渲染时将matchlocationhistory props 传递给包装组件。这样,您可以在应用程序的任何位置使用history.goBackhistory.push等方法。在第三章中,使用 React 和 Suspense 构建动态项目管理面板,您已经看到了使用 HOC 的示例;withRouter HOC 以相同的方式实现。

Question组件上实现数据获取,您需要检查id参数并从 Stack Overflow API 中获取相应的问题:

  1. 因此,应向Question添加一个componentDidMount方法,该方法使用此参数获取 API:
...

+ const ROOT_API = 'https://api.stackexchange.com/2.2/';

class Question extends Component {
  constructor(props) { ... }

+ async componentDidMount() {
+   const { match } = this.props;
+   try {
+     const data = await fetch(
+       `${ROOT_API}questions/${match.params.id}?site=stackoverflow`,
+     );
+     const dataJSON = await data.json();

+     if (dataJSON) {
+       this.setState({
+         data: dataJSON,
+         loading: false,
+       });
+     }
+   } catch(error) {
+     this.setState({
+       loading: true,
+       error: error.message,
+     });
+   }
+ }

  render() {
    ...
  1. 然后,获取的数据可以显示在Card组件内。请记住,当进行此请求时,Stack Overflow API 返回的是一个数组而不是单个对象:
import React, { Component } from 'react';
import styled from 'styled-components';
+ import Card from '../components/Card/Card';

...

class Question extends Component {
  ...
  render() {
    const { data, loading, error } = this.state;

    if (loading || error) {
      return <Alert>{loading ? 'Loading...' : error}</Alert>;
    }

    return (
      <QuestionWrapper>
+       <Card key={data.items[0].question_id} data={data.items[0]} />
      </QuestionWrapper>
    );
  }
}

export default Question;
  1. 如果你现在刷新http://localhost:3000/questions/55366474,将显示一个显示有关这个特定问题信息的Card组件。为了能够从Feed组件导航到这个页面,应该添加一个Link导航来包裹Card
import React, { Component } from 'react';
import styled from 'styled-components';
+ import { Link } from 'react-router-dom';
import Card from '../components/Card/Card';

...

class Feed extends Component {
  ...
  render() {
    const { data, loading, error } = this.state;

    if (loading || error) {
      return <Alert>{loading ? 'Loading...' : error}</Alert>;
    }

    return (
      <FeedWrapper>   
        {data.items.map(item =>
+         <Link key={item.question_id} to={`/questions/${item.question_id}`}>
-            <Card key={item.question_id} data={item} />
+            <Card data={item} />
+          </Link>
+ )}
       </FeedWrapper>
     );
   }
}

export default Feed;
  1. 当你访问http://localhost:3000/时,你可能会注意到Card组件现在是可点击的,并链接到一个新页面,显示你刚刚点击的问题。Card组件的样式也发生了变化,因为Link导航组件是一个a元素;它会添加下划线并改变填充。你需要做以下更改来修复这些样式变化:
...
+ const CardLink = styled(Link)`
+  text-decoration: none;
+  color: inherit;
+ `; 
const  ROOT_API  =  'https://api.stackexchange.com/2.2/'; 
class Feed extends Component {
  ...
  render() {
    const { data, loading, error } = this.state;

    if (loading || error) {
      return <Alert>{loading ? 'Loading...' : error}</Alert>;
    }

    return (
      <FeedWrapper>
        {data.items.map(item => (
-         <Link key={item.question_id} to={`/questions/${item.question_id}`}>
+         <CardLink key={item.question_id} to={`/questions/${item.question_id}`}>
            <Card data={item} />
-         </Link>
+         </CardLink>
        ))}
      </FeedWrapper>
    );
  }
}

export default Feed;

现在,样式应该恢复了,你可以导航到问题路由以查看单个问题。但除了参数之外,还有其他方法可以使用路由进行过滤或向其传递数据,即查询字符串。这些将在本章的下一部分中进行探讨。

处理查询字符串

当你想要为项目添加路由时,能够导航到单个问题只是其中的一部分,分页可能是另一个部分。为此,将所有问题的概述移动到另一个名为/questions的路由可能是一个好主意。为此,你需要在App组件中的Router中添加另一个引用Feed组件的Route

...
class App extends Component {
  render() {
    return (
       <>
         <GlobalStyle />
         <AppWrapper>
           <Header />
           <Router>
             <Route exact path='/' component={Feed} />
+            <Route path='/questions' component={Feed} />
             <Route path='/questions/:id' component={Question} />
           </Router>
          </AppWrapper>
        </>
     );
   }
 }

 export default App;

然而,如果你现在访问该项目并尝试点击任何一个问题,你会发现渲染的组件和 URL 都没有改变。由于react-router的设置方式,它会导航到与当前 URL 匹配的任何路由。为了解决这个问题,你需要添加一个Switch路由匹配组件,它的工作原理类似于 switch 语句,并且会渲染与当前位置匹配的第一个Route

  1. 你可以在scr/containers/App.js文件中从react-router-dom包中导入Switch
import React, { Component } from 'react';
import styled, { createGlobalStyle } from 'styled-components';
- import { BrowserRouter as Router, Route } from 'react-router-dom';
+ import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; 
...
  1. 并将这个Switch放在Router中,路由的顺序必须改变,以确保每当有一个id参数时,这个路由将首先被渲染。
...
class App extends Component {
  render() {
    return (
      <>
        <GlobalStyle />
        <AppWrapper>
          <Header />
          <Router>
+         <Switch>
            <Route exact path='/' component={Feed} />
-           <Route path='/questions' component={Feed} />
            <Route path='/questions/:id' component={Question} />
+           <Route path='/questions' component={Feed} />
+         </Switch>
          </Router>
        </AppWrapper>
       </>
     );
   }
 }

 export default App;

现在/questions/questions/:id路由将返回正确的组件,即FeedQuestion组件。有了这个设置,下一步是添加分页。如果你查看 API 响应,返回的对象有一个叫做has_more的字段。如果这个字段的值是true,就意味着你可以通过在 API 请求中添加page查询字符串来请求更多问题。

你可以尝试将这个查询字符串添加到浏览器中的 URL 中,访问http://localhost:3000/questions?page=2。这个查询字符串现在作为Feed组件的一个 prop 出现在location对象的search字段下,你可以在 React Developer Tools 的输出中看到它:

不幸的是,react-router没有一个标准的解决方案来轻松地获取location.search的值。因此,你需要使用npm安装query-string包:

npm install query-string

这个包被创建用来解析查询字符串,比如location.search,将其转换为你可以在应用程序中使用的对象:

  1. 你可以通过在Feed组件中导入包来实现这一点:
import React, { Component } from 'react';
import styled from 'styled-components';
+ import queryString from 'query-string';

...
  1. 现在,你可以在constructor方法中解析page查询字符串的值,并将这个解析后的值添加到state中。确保使用 JavaScript 的parseInt函数,这样页面将成为一个整数而不是一个字符串。如果没有可用的页面查询字符串,就假定你正在访问第一页:
...
class Feed extends Component {
- constructor() {
-   super();
+ constructor(props) {
+   super(props);
+   const query = queryString.parse(props.location.search);
    this.state = {
      data: [],
+     page: (query.page) ? parseInt(query.page) : 1,
      loading: true,
      error: '',
    };
}
...
  1. 如果state中有page查询字符串的值,你可以将其发送到 API,以获取你指定的页面号的问题:
...
async componentDidMount() {
+ const { page } = this.state;
  try {
-   const data = await fetch(
-     `${ROOT_API}questions/${match.params.id}?site=stackoverflow`,
-   );
+   const data = await fetch(
+     `${ROOT_API}questions?order=desc&sort=activity&tagged=reactjs&site=stackoverflow${(page) ? `&page=${page}` : ''}`,
+   );
    const dataJSON = await data.json();

    if (dataJSON) {
      this.setState({
        data: dataJSON,
        loading: false,
      });
    }
  } catch(error) {
    this.setState({
      loading: false,
      error: error.message,
    });
  }
}
...

你可以通过更改page的查询字符串来测试它是否有效,比如http://localhost:3000/questions?page=1http://localhost:3000/questions?page=3。为了使应用程序更加用户友好,让我们在页面底部添加分页按钮。

  1. 创建PaginationBar组件,其中包含两个Button组件,它们是来自react-router的样式化的Link组件:
...
 + const PaginationBar = styled.div`
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+ `;

+ const PaginationLink = styled(Link)`
+  padding: 1%;
+  background: lightBlue;
+  color: white;
+  text-decoration: none
+  border-radius: 5px;
+ `;

const  ROOT_API  =  'https://api.stackexchange.com/2.2/'; class Feed extends Component {
  ...
  1. 现在你可以将这些添加到FeedWrapper的底部。
...
render() {
  const { data, loading, error } = this.state;

    if (loading || error) {
      return <Alert>{loading ? 'Loading...' : error}</Alert>;
    }

    return (
      <FeedWrapper>
        {data.items.map(item => (
          <CardLink key={item.question_id} to={`/questions/${item.question_id}`}>
            <Card data={item} />
          </CardLink>
        ))} +       <PaginationBar>
+         <PaginationLink>Previous</PaginationLink>
+         <PaginationLink>Next</PaginationLink>
+       </PaginationBar>
      </FeedWrapper>
    );
  }
}

export default Feed;
  1. 这些PaginationLink组件应该链接到某个地方,以便用户能够导航到不同的页面。为此,可以从match属性中获取当前 URL,并且当前页码在state中可用。请注意,只有当页码大于 1 时,才应显示上一页按钮,而只有当 API 响应表明返回的结果比返回的结果更多时,才应显示下一页按钮:
...

render() {
- const { data, loading } = this.state; 
+ const { data, page, loading } = this.state;
+ const { match } = this.props;

  if (loading || error) {
    return <Alert>{loading ? 'Loading...' : error}</Alert>;
  }

  return (
    <FeedWrapper>
      {data.items.map(item => (
        <CardLink key={item.question_id} to={`/questions/${item.question_id}`}>
          <Card data={item} />
        </CardLink>
      ))}
      <PaginationBar>
-       <PaginationLink>Previous</PaginationLink>
-       <PaginationLink>Next</PaginationLink>
+       {page > 1 && <PaginationLink to={`${match.url}?page=${page - 1}`}>Previous</PaginationLink>}
+       {data.has_more && <PaginationLink to={`${match.url}?page=${page + 1}`}>Next</PaginationLink>}
      </PaginationBar>
     </FeedWrapper>
    );
  }
}

export default Feed;

然而,如果您现在尝试单击下一个(或上一个)按钮,URL 将更改,显示的问题不会更改。通过使用componentDidMount方法,API 将仅在应用程序首次挂载后调用。要在应用程序已经挂载时监视propsstate的任何更改,您需要使用另一个称为componentDidUpdate的生命周期方法。该方法可以监视propsstate的更改,因为它可以访问更新之前的propsstate的值。它们在componendDidUpdate方法中作用域内,作为prevPropsprevState参数,您可以比较它们以检查在任何propsstate更改时是否需要再次获取 API。

  1. 实现这一点的第一步是创建一个获取 API 的函数,该函数还可以在componentDidMount方法之外使用。此函数应将page号作为参数,以便可以获取正确的页面:
...
+ async fetchAPI(page) {
+   try {
+     const data = await fetch(`${ROOT_API}questions?order=desc&sort=activity&tagged=reactjs&site=stackoverflow${(page) ? `&page=${page}` : ''}`);
+     const dataJSON = await data.json();
+
+     if (dataJSON) {
+       this.setState({
+         data: dataJSON,
+         loading: false,
+       });
+     }
+   } catch(error) {
+     this.setState({
+      loading: false,
+      error: error.message,
+    });
+  }
+ }

async componentDidMount() {
  ...
  1. 创建此函数后,可以在componentDidMount方法中调用它,因为这不再需要是一个异步函数,因为这已经由新的fetchAPI函数处理。因此,该方法可以被删除并替换为以下内容:
...
 - async componentDidMount() { ... }

+ componentDidMount() {
+  const { page } = this.state;
+  this.fetchAPI(page);
+ } render() {
  ...
  1. componentDidMount方法之后,您需要添加新的componentDidUpdate生命周期方法。如前所述,这可以将prevPropsprevState作为参数,但是由于导航到新 URL 只会更改props,因此只使用前者。在这里,您需要检查查询字符串是否已更改。如果它们已更改,则需要使用page查询字符串的新解析值更新state,并调用fetchAPI函数以获取此页面的结果:
...  
componentDidMount() {
  const { page } = this.state;
  this.fetchAPI(page);
}

+ componentDidUpdate(prevProps) {
+  if (prevProps.location.search !== this.props.location.search) {
+    const query = queryString.parse(this.props.location.search);
+    this.setState({ page: parseInt(query.page) }, () => 
+      this.fetchAPI(this.state.page),
+    );
+  }
+ }

render() {
...

在使用componentDidUpdate生命周期方法时,您应始终确保将prevPropsprevState与当前的propsstate进行比较。componentDidUpdate方法会不断调用,当您不比较任何值时,可能会导致应用程序崩溃的无限循环。

您现在已经实现了解析查询字符串以动态更改应用程序路由的功能。在下一节中,您将探索 React 的另一项功能,即 SRR,它使您能够从服务器上提供应用程序,而不是在运行时进行渲染。

启用 SSR

使用 SSR 可以帮助您构建需要快速渲染的应用程序,或者当您希望在网页可见之前加载某些信息时。尽管大多数搜索引擎现在能够渲染 SPA,但如果您希望用户在社交媒体上分享您的页面,这仍然可以是一个改进。

使用 react-router 创建 express 服务器

没有标准模式可以为您的 React 应用程序启用 SSR,但起点是创建一个 Node.js 服务器,该服务器为应用程序的构建版本提供服务。为此,您将使用一个名为express的 Node.js 的最小 API 框架。此外,您已经使用的包,如react-routerstyled-components,也可以与 SSR 一起使用:

  1. 您可以通过运行以下命令来安装express
npm install express
  1. 现在,您必须在项目的根目录中创建一个名为server的新目录,并在其中放置一个名为server.js的新文件。在此文件中,您可以放置以下代码块来导入您需要运行 Node.js 服务器、reactreact-dom/server的软件包,后者用于从服务器渲染您的应用程序:
import path from 'path';
import fs from 'fs';
import express from 'express';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
  1. 在这些导入的正下方,您需要导入应用程序的入口点,该入口点应该由服务器进行渲染:
import path from 'path';
import fs from 'fs';
import express from 'express';
import React from 'react';
import ReactDOMServer from 'react-dom/server';

+ import App from '../src/containers/App';
  1. 在定义了入口点之后,可以添加用express设置 Node.js 服务器并使其监听服务器上的所有端点的代码。首先,您需要设置express将运行的端口,之后,您定义所有与/*通配符匹配的路由应返回由ReactDOMServer呈现为字符串的应用程序的静态版本。这是通过获取index.html构建文件的内容并用包含App组件的服务器渲染版本的新标记替换<div id="root"></div>标记来完成的:
...
const PORT = 8080;
const app = express();

app.get('/*', (req, res) => {
  const context = {};
  const app = ReactDOMServer.renderToString(<App />);

  const indexFile = path.resolve('./build/index.html');
  fs.readFile(indexFile, 'utf8', (err, data) => {
    if (err) {
      console.error('Something went wrong:', err);
      return res.status(500).send('Oops, better luck next time!');
    }

    data = data.replace('<div id="root"></div>', `<div id="root">${app}</div>`);

    return res.send(data);
  });
});
  1. 并且通过将以下代码块添加到此文件的底部,使此express服务器监听您定义的8080端口:
...
app.listen(PORT, () => {
  console.log(`Server-Side Rendered application running on port ${PORT}`);
});
  1. 最后,您需要更改src/index.js中应用程序的入口点的方式。在这个文件中,ReactDOM.render需要被ReactDOM.hydrate替换,因为 Node.js 服务器试图通过注入服务器渲染版本来更改index.html构建文件的标记:
import React from 'react';
import ReactDOM from 'react-dom';
import App from './containers/App';
import * as serviceWorker from './serviceWorker';

+ ReactDOM.hydrate(<App />, document.getElementById('root'));

...

然而,这个 Node.js 服务器无法使用 React 应用程序使用的任何 webpack 配置,因为其代码不在src目录中。为了能够运行这个 Node.js 服务器,您需要为server目录配置 Babel 并安装一些 Babel 包。这是您在第一章中做过的事情:

  1. 应该安装的 Babel 包是@babel/polyfill,它编译诸如async/await之类的函数;@babel/register告诉 Babel 它应该转换扩展名为.js的文件;@babel/preset-env@babel/preset-react用于配置 Babel 以与 React 一起工作:
npm install @babel/polyfill @babel/register @babel/preset-env @babel/preset-react
  1. server目录内的一个名为index.js的新文件中,您现在可以要求这些包,并使此文件作为server.js文件的入口点:
require('@babel/polyfill');

require('@babel/register')({
 presets: ['@babel/preset-env', '@babel/preset-react'],
});

require('./server');
  1. 您应该能够通过执行node server/index.js命令来运行server/index.js文件。因此,在package.json中的 scripts 字段中为此命令创建一个快捷方式:
...  
"scripts": {
  "start": "react-scripts start",
  "build": "react-scripts build",
  "test": "react-scripts test",
  "eject": "react-scripts eject",
+  "ssr": "node server/index.js"
},

在运行npm run ssr命令之前,您应该始终在 Node.js 服务器使用构建版本之前执行npm run build。如果您现在运行npm run ssr命令,您将收到一个错误,提示“BrowserRouter 需要 DOM 来渲染”。由于react-router的设置方式,您需要在使用 SSR 时使用StaticRouter组件,而不是BrowserRouter

  1. 当应用程序在客户端运行时(使用npm start),它仍然需要使用BrowserRouter,因此Route组件的包装应该从App移到src/index.js文件中:
import React from 'react';
import ReactDOM from 'react-dom';
+ import { BrowserRouter as Router } from 'react-router-dom';
import App from './containers/App';
import * as serviceWorker from './serviceWorker';

ReactDOM.hydrate(
+  <Router>
     <App />
+  </Router>,
  document.getElementById('root'),
);
  1. 当然,它从App组件中删除:
import React, { Component } from 'react';
import styled, { createGlobalStyle } from 'styled-components';
- import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
+ import { Route, Switch } from 'react-router-dom';
import Header from '../components/Header/Header';
import Feed from './Feed';
import Question from './Question';

...

class App extends Component {
  render() {
    return (
       <>
        <GlobalStyle />
        <AppWrapper>
          <Header />
-         <Router>
          <Switch>
            <Route exact path='/' component={Feed} />
            <Route path='/questions/:id' component={Question} />
            <Route path='/questions' component={Feed} />
          </Switch>
-         </Router>
        </AppWrapper>
      </>
    );
  }
}

export default App;
  1. 要使 Node.js 服务器现在使用react-router中的StaticRouter组件,您需要在server/index.js中添加此内容,并使用StaticRouter包装由ReactDOMServer呈现的App组件。对于react-router来知道加载哪个路由,您必须将当前 URL 作为location属性传递,并且(在本例中)将空的context属性作为StaticRouter应该始终具有此属性以处理重定向:
import path from 'path';
import fs from 'fs';
import express from 'express';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
+ import { StaticRouter } from 'react-router-dom';

import App from '../src/containers/App';

const PORT = 8080;
const app = express();

app.get('/*', (req, res) => {
  const context = {};
  const app = ReactDOMServer.renderToString(
-   <Router>
+   <Router location={req.url} context={context}>
      <App />
    </Router>,
  );

  ...

完成了最后一步,您可以再次执行npm run build。构建完成后,您可以通过运行npm run ssr启动 Node.js 服务器,以在http://localhost:8080上查看您的服务器渲染的 React 应用程序。这个应用程序看起来一样,因为 SSR 不会改变应用程序的外观。

SSR 的另一个优点是,您的应用程序可以更有效地被搜索引擎发现。在本节的下一部分,您将添加标记,使您的应用程序可以被这些引擎发现。

使用 React Helmet 添加头标签

假设您希望您的应用程序被搜索引擎索引,您需要为爬虫设置头标签,以识别页面上的内容。对于每个路由,您都希望动态执行此操作,因为每个路由都将具有不同的内容。在 React 应用程序中设置这些头标签的流行包是 React Helmet,它支持 SSR。您可以使用npm安装 React Helmet:

npm install react-helmet

React Helmet 可以在应用程序中呈现的任何组件中定义头标签,并且如果嵌套,则组件树中Helmet组件的最低定义将被使用。这就是为什么您可以在Header组件中为所有路由创建一个Helmet组件,并且在每个在路由上呈现的组件中,您可以覆盖这些标签:

  1. src/components/App/Header.js文件中导入react-helmet包,并创建一个Helmet组件,设置title和 metadescription
import React from 'react';
import styled from 'styled-components';
+ import Helmet from 'react-helmet';

...

const Header = () => (
+  <>
+    <Helmet>
+      <title>Q&A Feed</title>
+      <meta name='description' content='This is a Community Feed project build with React' />
+    </Helmet>
    <HeaderWrapper>
      <Title>Q&A Feed</Title>
    </HeaderWrapper>
+  </>
);

export default Header;
  1. 此外,在 src/containers/Feed.js 中创建一个 Helmet 组件,该组件仅为此路由设置标题,因此它将使用 Headerdescription 元标签。此组件放置在 Alert 组件之前的 Fragment 中,因为这在应用程序首次渲染时可用。
import React, { Component } from 'react';
import styled from 'styled-components';
import queryString from 'query-string'
import { Link } from 'react-router-dom';
+ import Helmet from 'react-helmet';
import Card from '../components/Card/Card';

  ...

  render() {
    const { data, page, loading, error } = this.state;
    const { match } = this.props;

    if (loading || error) {
      return 
+       <>
+         <Helmet>
+           <title>Q&A Feed - Questions</title>
+         </Helmet>
          <Alert>{loading ? 'Loading...' : error}</Alert>
+       </>
    }
    ...
  1. 对于 src/containers/Question.js 文件也要做同样的操作,您还可以从 match props 中获取问题的 ID,使页面标题更加动态:
import React, { Component } from 'react';
import styled from 'styled-components';
+ import Helmet from 'react-helmet';
import Card from '../components/Card/Card';

  ...

  render() {
+   const { match } = this.props;
    const { data, loading, error } = this.state;

    if (loading || error) {
      return 
+       <>
+         <Helmet>
+           <title>{`Q&A Feed - Question #${match.params.id}`}</title>
+         </Helmet>
          <Alert>{loading ? 'Loading...' : error}</Alert>
+       </>
    }

    ...
  1. 当您执行 npm start 命令在客户端运行应用程序时,这些头标签将被使用。但是为了支持 SSR,React Helmet 也应该在 Node.js 服务器上进行配置。为此,您可以使用 Helmet.renderStatic 方法,该方法会将您代码中的 Helmet 组件转换为其他组件的 ReactDOMserver.renderToString 所做的方式一样。打开 server/server.js 文件并添加以下代码:
import path from 'path';
import fs from 'fs';
import express from 'express';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import { StaticRouter as Router } from 'react-router-dom';
+ import Helmet from 'react-helmet';

...

app.get('/*', (req, res) => {
  const context = {};
  const app = ReactDOMServer.renderToString(
    <Router location={req.url} context={context}>
      <App />
    </Router>,
  );
+  const helmet = Helmet.renderStatic();

  const indexFile = path.resolve('./build/index.html');
  fs.readFile(indexFile, 'utf8', (err, data) => {
    if (err) {
      console.error('Something went wrong:', err);
      return res.status(500).send('Oops, better luck next time!');
    }

    data = data.replace('<div id="root"></div>', `<div id="root">${app}</div>`);
+   data = data.replace('<meta name="helmet"/>', `${helmet.title.toString()}${helmet.meta.toString()}`);

    return res.send(data);
  });
});

...
  1. 在此文件的最后一行中,您现在已经定义了 <meta name="helmet" /> 元素应该被 React Helmet 创建的 titlemeta 标签替换。为了能够用这些标签替换这个元素,将此元素添加到 public 目录中的 index.html 中。此外,您还必须删除 React Helmet 现在已经创建的 title 元素:
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, shrink-to-fit=no"
    />
    <meta name="theme-color" content="#000000" />
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
+   <meta name="helmet" />
-   <title>React App</title>
  </head>
...

完成了这些最后的更改后,您现在可以再次运行 npm run build 来创建应用程序的新构建版本。完成此过程后,执行 npm run ssr 命令来启动 Node.js 服务器,并在浏览器上访问您的 React SSR 应用程序,网址为 http://localhost:8080

摘要

在本章中,您使用 react-router 为 Create React App 添加了动态路由,使用户可以在特定页面上打开您的应用程序。通过使用 React 的 Suspense 特性,组件在客户端动态加载。这样,您可以减少用户首次接触应用程序之前的时间。在本章中创建的项目还支持 SSR,并且使用 React Helmet 为应用程序添加动态头标签以用于 SEO 目的。

完成本章后,您应该已经感觉像是 React 的专家了!下一章肯定会将您的技能提升到更高的水平,因为您将学习如何使用上下文 API 处理状态管理。使用上下文 API,您可以在应用程序中的多个组件之间共享状态和数据,无论它们是父组件的直接子组件还是其他组件。

进一步阅读

第五章:使用上下文 API 和 Hooks 构建个人购物清单应用程序

状态管理是现代 Web 和移动应用程序的一个非常重要的部分,也是 React 擅长的领域。在 React 应用程序中处理状态管理可能会相当令人困惑,因为有多种方式可以处理应用程序的当前状态。本书前四章创建的项目并没有过多关注状态管理,这一点将在本章中更加深入地探讨。

本章将展示如何在 React 中处理状态管理,通过为应用程序创建一个全局状态,可以从每个组件中访问。在 React v16.3 之前,您需要第三方包来处理 React 中的全局状态,但是随着上下文 API 的更新版本,这不再是必需的。此外,随着 React Hooks 的发布,引入了更多改变此上下文的方法。使用一个示例应用程序,演示了处理应用程序全局状态管理的方法。

本章将涵盖以下主题:

  • 使用上下文 API 进行状态管理

  • 高阶组件HOC)和上下文

  • 使用 Hooks 改变上下文

项目概述

在本章中,我们将使用react-router创建一个渐进式 Web 应用程序PWA),它使用上下文和 React Hooks 进行全局状态管理。此外,HOC 用于在整个应用程序中访问数据。

构建时间为 2.5 小时。

入门

本章将创建的项目是在 GitHub 上找到的初始版本的基础上构建的:github.com/PacktPublishing/React-Projects/tree/ch5-initial。完整的源代码也可以在 GitHub 上找到:github.com/PacktPublishing/React-Projects/tree/ch5

下载初始应用程序后,请确保从项目的根目录运行npm install。该项目是使用 Create React App 创建的,并安装了reactreact-domreact-scriptsstyled-componentsreact-router等包,这些包在前几章中已经见过。安装完成后,您可以在终端的同一个标签页中运行npm start,并在浏览器中查看项目(http://localhost:3000)。

由于项目是使用 Create React App 创建的,因此已注册服务工作者以使应用程序作为 PWA 运行。您可以通过首先运行npm run build,然后在构建过程完成后运行serve -s build来检查此功能。现在可以访问应用程序的构建版本http://localhost:5000。如果您访问此 URL 上的应用程序并看到不同的 URL,可能是您在任何先前章节中创建的应用程序的构建版本仍在提供。这可能是由服务工作者创建的浏览器缓存造成的。您可以通过在浏览器上打开开发者工具并打开“应用程序”选项卡,在那里您可以单击“清除站点数据”部分上的“清除存储”按钮来清除浏览器缓存中的任何先前的应用程序。

检查应用程序在没有互联网连接时是否仍然可用,您可以让浏览器模拟离线情况。启用此选项可以在浏览器的开发者工具中的“应用程序”选项卡中找到。在此选项卡中,您可以在左侧菜单中找到“服务工作者”,单击此链接后,可以在出现的页面上选择“离线”复选框。

本节的初始应用程序位于http://localhost:3000,比以往任何一章都要先进一些。打开应用程序时,将呈现显示标题、副标题和两个列表的屏幕。例如,如果您单击此处显示的第一个列表,将打开一个新页面,显示此列表的项目。在此页面上,您可以单击右上角的“添加列表”按钮打开一个新页面,该页面具有添加新列表的表单,并且看起来像这样:

此表单由Form组件呈现,但尚无功能,因为稍后将添加此功能。当您单击左侧按钮时,它将使用react-router中的history.goBack方法将您重定向到先前访问的页面。

当您尝试提交表单以添加新列表或向列表中添加新项目时,什么也不会发生。这些表单的功能将稍后在本节中添加,您将使用上下文 API 和 React Hooks。

该项目的结构与您之前创建的应用程序的结构相同。在components目录中区分了可重用的函数组件和containers目录中的类组件。类组件被包装在一个名为withDataFetching的 HOC 中,该 HOC 为这些组件添加了数据获取和生命周期(componentDidMount)。

withDataFetching HOC 是在第二章中创建的 HOC 的略微修改版本,即使用可重用的 React 组件创建渐进式 Web 应用程序,该版本也被称为withDataFetching.js。这个修改后的版本是一个柯里化组件,意味着它一次接受多个参数。在 HOC 的情况下,这意味着您不仅可以将组件用作参数,还需要将此组件的 props 用作参数。

以下是项目的完整结构概述:

shopping-list
|-- node_modules
|-- public
    |-- favicon.ico
    |-- index.html
    |-- manifest.json
|-- src
    |-- components
        |-- Button
            |-- Button.js
        |-- FormItem
            |-- FormItem.js
        |-- Header
            |-- Header.js
            |-- Subheader.js
         |-- ListItem
             |-- ListItem.js
 |-- containers
    |-- App.js
    |-- Form.js
    |-- List.js
    |-- Lists.js
 |-- index.js
 |-- serviceWorker.js
.gitignore
db.json
package.json

这个应用程序的入口点是src/index.js文件,它在react-routerRouter组件中渲染App类组件。App组件包含一个Header组件和一个Switch路由组件,定义了四个路由。这些路由如下:

  • /:渲染Lists,显示所有列表的概述

  • /list/:id:渲染List,显示特定列表中所有项目的概述

  • /list/:id/new:渲染Form,显示向特定列表添加新项目的表单

数据是从一个使用免费服务创建的模拟服务器中获取的,该服务是 My JSON Server,它从 GitHub 项目的根目录中的db.json文件创建服务器。该文件包含一个具有两个字段itemslists的 JSON 对象,它在模拟服务器上创建了多个端点。在本章中,您将使用的端点如下:

  • https://my-json-server.typicode.com/<your-username>/<your-repo>/items

  • https://my-json-server.typicode.com/<your-username>/<your-repo>/lists

db.json文件必须存在于您的 GitHub 存储库的主分支(或默认分支)中,以使 My JSON Server 正常工作。否则,在尝试请求 API 端点时,您将收到 404 Not Found 的消息。

个人购物清单

在本节中,您将构建一个个人购物清单应用程序,该应用程序使用 Context 和 React Hooks 进行全局状态管理。通过这个应用程序,您可以创建购物清单,并添加商品、数量和价格。本节的起点是一个已启用路由和本地状态管理的初始应用程序。

使用上下文 API 进行状态管理

状态管理非常重要,因为应用程序的当前状态包含对用户有价值的数据。在之前的章节中,您已经通过在constructor中设置初始状态并使用this.setState方法进行更新来使用本地状态管理。当状态中的数据只对设置状态的组件重要时,这种模式非常有用。由于通过多个组件传递状态作为 props 可能会变得混乱,您需要一种方法来在整个应用程序中访问 props,即使您没有专门将它们作为 props 传递。为此,您可以使用 React 的上下文 API,这也是您在之前章节中已经使用的包(如styled-componentsreact-router)所使用的。

在多个组件之间共享状态,将探讨一个名为 Context 的 React 功能,从本节的第一部分开始。

创建 Context

当您想要将 Context 添加到 React 应用程序中时,可以通过使用 React 的createContext方法创建一个新的 Context 来实现。这将创建一个由两个 React 组件组成的 Context 对象,称为ProviderConsumer。Provider 是 Context 的初始(以及随后的当前)值所在的地方,可以被存在于 Consumer 中的组件访问。

这是在src/containers/App.js中的App组件中完成的,因为您希望列表的上下文在由Route渲染的每个组件中都可用。

  1. 让我们首先为列表创建一个 Context,并将其导出,以便列表数据可以在任何地方使用。为此,您可以在一个新目录src/Context中创建一个名为ListsContextProvider.js的新文件。在这个文件中,您可以添加以下代码:
import React from 'react';
import withDataFetching from '../withDataFetching';

export const ListsContext = React.createContext();
const ListsContextProvider = ({ children, data }) => (
  <ListsContext.Provider value={{ lists: data }}>
    {children}
  </ListsContext.Provider>
);

export default withDataFetching({
  dataSource: 'https://my-json-server.typicode.com/PacktPublishing/React-Projects/lists',
})(ListsContextProvider);

先前的代码基于传递为 prop 的 Context 组件创建了一个 Provider,并根据从获取所有列表的withDataFetching HOC 的返回设置了一个值。使用children prop,所有将包装在ListsContextProvider组件内的组件都可以从 Consumer 中检索值的数据。

  1. 这个ListsContextProvider组件和上下文可以在src/containers/App.js中的App组件中导入,随后应该放在Switch组件周围。ListsContext对象也被导入,因为之后无法创建 Consumer:
import React from 'react';
import styled, { createGlobalStyle } from 'styled-components';
import { Route, Switch } from 'react-router-dom';
+ import ListsContextProvider, { ListsContext } from '../Context/ListsContextProvider';

...

const App = () => (
 <>
   <GlobalStyle />
   <AppWrapper>
     <Header />
+    <ListsContextProvider>
       <Switch>
         <Route exact path='/' component={Lists} />
         <Route path='/list/:id/new' component={Form} />
         <Route path='/list/:id' component={List} />
       </Switch>
+    </ListsContextProvider>
 </AppWrapper>
 </>
);

export default App;
  1. 这样,您现在可以为ListsContext添加一个 Consumer,它嵌套在包含ListsContext的 Provider 的ListsContextProvider组件中。这个 Consumer 返回 Provider 中包含的值,其中包含之前获取的列表数据:
...

const App = () => (
  <>
    <GlobalStyle />
      <AppWrapper>
      <Header />
        <ListsContextProvider>
+         <ListsContext.Consumer>
+           {({ lists }) => (
              <Switch>
                <Route exact path='/' component={Lists} />
                <Route path='/list/:id/new' component={Form} />
                <Route path='/list/:id' component={List} />
              </Switch>
+           )}
+         </ListsContext.Consumer>
        </ListsContextProvider>
    </AppWrapper>
  </>
);

export default App;
  1. 要将此列表数据实际传递给Route渲染的任何组件,您应该更改将组件传递给Route组件的方式。您可以使用 React 的RenderProps模式,而不是告诉Route要渲染哪个组件。这种模式是指一种在 React 组件之间共享代码的技术,使用一个值为返回组件的函数的 prop。在这种情况下,您希望Route组件渲染一个组件,不仅将react-router的 props 添加到其中,还要添加来自ListsContext的列表数据:
...
<ListsContextProvider>                       
  <ListsContext.Consumer>
    {({ lists }) => (
      <Switch>
-       <Route exact path='/' component={Lists} />
+       <Route exact path='/' render={props => lists && <Lists lists={lists} {...props} /> } />
        <Route path='/list/:id/new' component={Form} />
        <Route path='/list/:id' component={List} />
      </Switch>
    )}
  </ListsContext.Consumer>
</ListsContextProvider>
...
  1. 如果您现在查看浏览器的开发者工具中的网络选项卡,您会看到 API 被获取了两次。由于现在ListsContextProvider也在获取列表,因此Lists组件本身不再需要获取 API,因为它现在作为 prop 发送。因此,您可以对src/containers/Lists.js进行以下更改:
import React from 'react';
import styled from 'styled-components';
import { Link } from 'react-router-dom';
- import withDataFetching from '../withDataFetching';
import SubHeader from '../components/SubHeader/SubHeader';

...

- const Lists = ({ data, loading, error, match, history }) => (
+ const Lists = ({ lists, loading = false, error = false, match, history }) => (
  <>
    {history && <SubHeader title='Your Lists' openForm={() => history.push('/new')} /> }
    <ListWrapper>
      {(loading || error) && <Alert>{loading ? 'Loading...' : error}</Alert>}
-     {data.lists && data.lists.map(list => (
+     {lists && lists.map(list => (
        <ListLink key={list.id} to={`list/${list.id}`}>
          <Title>{ list.title }</Title>
        </ListLink>
      ))}
    </ListWrapper>
  </>
);

- export default withDataFetching({
-   dataSource: 'https://github.com/PacktPublishing/React-Projects/lists',
})(Lists); + export default Lists;

现在您已经从Lists中删除了withDataFetching HOC,不再发送重复的 API 请求。列表的数据是从ListsContextProvider中获取的,并由ListsContext.Consumer传递给Lists。如果通过转到http://localhost:3000/在浏览器中打开应用程序,您会看到列表像以前一样被渲染。

您还可以将列表数据发送到List组件中,这样,例如,当您从主页点击列表时,可以显示所选列表的名称:

  1. 为此,您再次使用RenderProps模式,这次是为Route渲染List。这确保了lists是可用的,并在之后渲染List组件,该组件还接受所有的react-router props:
...
<ListsContextProvider>                       
  <ListsContext.Consumer>
    {({ lists }) => (
      <Switch>
        <Route exact path='/' render={props => lists && <Lists lists={lists} {...props} /> } />
        <Route path='/list/:id/new' component={Form} />
-       <Route path='/list/:id' component={List} />
+       <Route path='/list/:id' render={props => lists && <List lists={lists} {...props} />} />
      </Switch>
    )}
  </ListsContext.Consumer>
</ListsContextProvider>
...
  1. src/containers/List.js文件中的List组件中,您可以从 props 中检索列表。这个数组需要被过滤以获取正确的list,找到的对象包含title,可以添加到SubHeader组件中,这样它就会显示在页面上:
- const List = ({ data, loading, error, match, history }) => {
+ const List = ({ data, loading, error, lists, match, history }) => {
    const items = data && data.filter(item => item.listId === parseInt(match.params.id))
+   const list = lists && lists.find(list => list.id === parseInt(match.params.id));

  return (
    <>
-     {history && <SubHeader goBack={() => history.goBack()} openForm={() => history.push(`${match.url}/new`)} />}
+     {history && list && <SubHeader goBack={() => history.goBack()} title={list.title} openForm={() => history.push(`${match.url}/new`)} />}
      <ListItemWrapper>
        {items && items.map(item => <ListItem key={item.id} data={item} />)}
      </ListItemWrapper>
    </>
  )
};

export default withDataFetching({
  dataSource: 'https://my-json-server.typicode.com/PacktPublishing/React-Projects/items',
})(List);

通过这些添加,如果您访问http://localhost:3000/list/1,当前列表的title现在将显示。在SubHeader组件中,标题"Daily groceries"现在可见,看起来类似于以下截图:

在下一节中,您还将为项目添加一个 Context 对象,这样项目也可以在react-routerSwitch组件内的所有组件中使用。

嵌套上下文

就像对于列表数据一样,项目数据也可以存储在 Context 中,并传递给需要这些数据的组件。这样,数据不再从任何渲染的组件中获取,而是从src/Providers目录中的ContextProvider组件中获取:

  1. 再次,首先创建一个新的组件,其中创建了一个 Context 和 Provider。这次,它被称为ItemsContextProvider,也可以添加到src/Context目录中,文件名为ItemsContextProvider.js
import React from 'react';
import withDataFetching from '../withDataFetching';

export const ItemsContext = React.createContext();

const ItemsContextProvider = ({ children, data }) => (
  <ItemsContext.Provider value={{ items: data }}>
    { children }
  </ItemsContext.Provider>
);

export default withDataFetching({
  dataSource: 'https://my-json-server.typicode.com/PacktPublishing/React-Projects/items', 
})(ItemsContextProvider);
  1. 接下来,在src/containers/App.js中导入这个新的 Context 和ContextProvider,您可以将其嵌套在ListsContextProvider组件内:
import React from 'react';
import styled, { createGlobalStyle } from 'styled-components';
import { Route, Switch } from 'react-router-dom';
import ListsContextProvider, { ListsContext } from '../Context/ListsContextProvider';
+ import ItemsContextProvider, { ItemsContext } from '../Context/ItemsContextProvider';

...

const App = () => (
  <>
    <GlobalStyle />
    <AppWrapper>
     <Header />
     <ListsContextProvider>
+    <ItemsContextProvider>
     <ListsContext.Consumer>
        ...
  1. ItemsContextProvider现在嵌套在ListsContextProvider下面,这意味着ItemsContextConsumer也可以嵌套在ListsContextConsumer下面。这使得来自ItemsContextProvider的值可以被使用RenderProps模式的List组件使用:
<ListsContextProvider>
  <ItemsContextProvider>
    <ListsContext.Consumer>
      {({ lists }) => (
+       <ItemsContext.Consumer>
+         {({ items }) => (
            <Switch>
              <Route exact path='/' render={props => lists && <Lists lists={lists} {...props} />} />
              <Route path='/new' component={Form} />
              <Route path='/list/:id/new' component={Form} />
-             <Route path='/list/:id' render={props => lists && <List lists={lists} {...props} />
+             <Route path='/list/:id' render={props => lists && items && <List lists={lists} listItems={items} {...props} />}/>
             </Switch>
+          )}
+        </ItemsContext.Consumer>
       )}
     </ListsContext.Consumer>
   </ItemsContextProvider>
 </ListsContextProvider>
  1. 在将项目数据作为 prop 传递给List之后,现在可以使用withDataFetching HOC 替换已经存在的数据获取。为了实现这一点,您需要对src/containers/List.js进行以下更改:
import React from 'react';
import styled from 'styled-components';
- import withDataFetching from '../withDataFetching';
import SubHeader from '../components/SubHeader/SubHeader';
import ListItem from '../components/ListItem/ListItem';

...

- const List = ({ data, lists, loading, error, match, history }) => {
+ const List = ({ lists, listItems, loading = false, error = false, match, history }) => {
-   const items = data && data.filter(item => item.listId === parseInt(match.params.id))
+   const items = listItems && listItems.filter(item => item.listId === parseInt(match.params.id))

    const list = lists && lists.find(list => list.id === parseInt(match.params.id));
    return (
      <>
        {history && <SubHeader goBack={() => history.goBack()} title={list.title} openForm={() => history.push(`${match.url}/new`)} />}
        <ListItemWrapper>
          {items && items.map(item => <ListItem key={item.id} data={ item } />) }
        </ListItemWrapper>
      </>
    )
};

- export default withDataFetching({
    dataSource: 'https://my-json-server.typicode.com/PacktPublishing/React-Projects/items',
  })(List);
+ export default List;

现在所有的数据获取都不再由ListLists组件进行。通过嵌套这些 Context Providers,返回值可以被多个组件消耗。但这仍然不是理想的,因为现在在启动应用程序时加载了所有的列表和所有的项目。

在下一节中,您将看到如何通过将上下文与 Hooks 结合来获取所需的数据。

使用 Hooks 改变上下文

有多种方式可以有条件地从上下文中获取数据;其中一种是将上下文中的数据放入本地状态。这可能是一个较小应用的解决方案,但对于较大的应用来说效率不高,因为您仍然需要将这个状态传递到组件树中。另一个解决方案是使用 React Hooks 创建一个函数,将其添加到上下文的值中,并可以从嵌套在此上下文中的任何组件中调用。此外,这种获取数据的方法可以防止您有效地加载只需要的数据。

如何将其与 React 生命周期和使用 Hooks 进行状态管理结合使用的示例在本节的第一部分中进行了演示。

在函数组件中使用生命周期

Hooks 带来的许多伟大的增强之一是在函数组件中使用生命周期。在 Hooks 之前,只有类组件支持生命周期,使用容器组件模式和到目前为止您使用的withDataFetching HOC。按照以下步骤:

  1. 实现这一点的第一步是将数据获取功能从withDataFetching HOC 移动到列表的 Provider 中,在src/Context/ListsContextProvider.js文件中。这个函数将接受dataSource(可以是文件或 API)并使用fetch从这个源中检索数据:
import React from 'react';

export const ListsContext = React.createContext();

async function fetchData(dataSource) {
 try {
 const data = await fetch(dataSource);
 const dataJSON = await data.json();

 if (dataJSON) {
 return await ({ data: dataJSON, error: false });
 }
 } catch(error) {
 return ({ data: false, error: error.message });
 }
};

....
  1. 有了这个函数,下一步将是使用dataSource调用它并将数据添加到 Provider 中。但是,您应该将dataSource返回的数据存储在哪里?以前,您使用componentDidMount生命周期方法来实现这一点,并将来自源的结果添加到本地状态中。使用 Hooks,您可以在函数组件中使用useState Hook 进行本地状态管理。您可以将状态的初始值作为参数传递给这个 Hook,这个初始值是您之前在constructor中设置的。返回的值将是一个数组,包含此状态的当前值和一个更新此状态的函数。此外,Hooks 应该始终在使用它的组件内部创建——在这种情况下,应该在ListsContextProvider内部创建。
...
async function fetchData(dataSource) {
  try {
    const data = await fetch(dataSource);
    const dataJSON = await data.json();

    if (dataJSON) {
      return await ({ data: dataJSON, error: false });
    }
  } catch(error) {
      return ({ data: false, error: error.message });
  }
};

- const ListsContextProvider = ({ children, data }) => ( + const ListsContextProvider = ({ children }) => {
+    const [lists, setLists] = React.useState([]);
+    return (
-       <ListsContext.Provider value={{ lists: data }}>
+       <ListsContext.Provider value={{ lists }}>
          {children}
        </ListsContext.Provider>
      ) + };

- export default withDataFetching({
    dataSource: 'https://my-json-server.typicode.com/PacktPublishing/React-Projects/items', 
  })(ListsContextProvider);
+ export default ListsContextProvider; 
  1. 在前面的代码块中,您可以看到状态的初始值是一个空数组,它被传递给ListsContext的 Provider。要用来自dataSource的数据填充此状态,您需要实际调用fetchData函数。通常情况下,这将在componentDidMountcomponentDidUpdate生命周期方法内完成,但由于组件是一个函数组件,您将使用一个 Hook。这个 Hook 被称为useEffect,用于处理副作用,无论是应用程序挂载时还是状态或 prop 更新时。这个 Hook 接受两个参数,第一个是回调函数,第二个是包含此 Hook 依赖的所有变量的数组。当其中任何一个发生变化时,将调用此 Hook 的回调函数。当此数组中没有值时,Hook 将仅在第一次挂载时调用。从源中获取数据后,状态将被更新为结果:
...
const ListsContextProvider = ({ children }) => {
const [lists, setLists] = React.useState([]); React.useEffect(() => {
    const asyncFetchData = async dataSource => {
      const result = await fetchData(dataSource);

      setLists([...result.data]);
    };

    asyncFetchData('https://my-json-server.typicode.com/PacktPublishing/React-Projects/lists');

  }, [fetchData, setLists]);  return (    <ListsContext.Provider value={{ lists }}>
      {children}
    </ListsContext.Provider>
  )
};

export default ListsContextProvider;

您可以看到fetchData函数并不是直接调用的,而是包裹在一个名为asyncFetchData的函数中。由于fetchData函数中的async/await将返回Promise,您需要另一个async/await来检索值并解决Promise。但是,您不能直接在useEffect Hook 中使用async/await。在useEffect Hook 的回调之后的数组块被称为依赖数组,在这里定义了在 Hook 中使用的值。fetchDatasetLists函数是在此组件的第一次挂载时创建的,这意味着useEffect Hook 模拟了一个类似于componentDidMount的生命周期。如果要将此 Hook 用作componentDidUpdate生命周期方法,数组将包含应该被监视更新的所有状态变量和 props。

通过使用其他 Hooks,您还可以直接将数据传递给 Provider,而无需使用本地状态管理。这将在本节的下一部分中进行演示。

使用 Flux 模式更新 Provider

另一种使用动作将数据添加到 Provider 的方法是使用类似 Flux 的模式,这是由 Facebook 引入的。Flux 模式描述了一个数据流,其中派发动作从存储中检索数据并将其返回给视图。这意味着动作需要在某个地方描述;应该有一个全局的地方存储数据,视图可以读取这些数据。为了使用上下文 API 实现这种模式,可以使用另一个名为useReducer的 Hook。这个 Hook 可以用来从任何数据变量中返回数据,而不是从本地状态中返回数据。

  1. useState Hook 一样,使用useReducer Hook 的组件也需要添加到其中。useReducer将接受一个初始值和一个确定应返回哪些数据的函数。这个初始值需要在src/Context/ListsContextProvider.js文件中添加,然后再添加 Hook。
import React from 'react';

export const ListsContext = React.createContext();

const initialValue = {
 lists: [],
 loading: true,
  error: '',
},

... 
  1. initialValue旁边,useReducer Hook 还接受一个名为reducer的函数。这个reducer函数也应该被创建,它是一个更新initialValue的函数,根据发送给它的动作返回当前值。如果派发的动作不匹配reducer中定义的任何动作,reducer将只是返回当前值而没有任何改变。
import React from 'react';

export const ListsContext = React.createContext();

const initialValue = {
  lists: [],
  loading: true,
  error: '',
};

const reducer = (value, action) => {
 switch (action.type) {
 case 'GET_LISTS_SUCCESS':
 return {
 ...value,
 lists: action.payload,
 loading: false,
 };
 case 'GET_LISTS_ERROR':
 return {
        ...value,
 lists: [],
        loading: false,
 error: action.payload,
 };
 default:
 return value;
 }
};

...
  1. 现在将useReducer Hook 的两个参数添加到文件中,因此需要添加实际的 Hook 并将initialValuereducer传递给它。
...

const ListsContextProvider = ({ children }) => { 
-    const [lists, setLists] = React.useState([]);
+    const [value, dispatch] = React.useReducer(reducer, initialValue);

...
  1. 正如你所看到的,当GET_LISTS_SUCCESSGET_LISTS_ERROR动作发送到reducer时,reducer会改变它返回的值。在之前提到过,可以使用useReducer Hook 返回的dispatch函数来调用这个reducer。然而,由于你还需要处理数据的异步获取,所以不能直接调用这个函数。相反,你需要创建一个async/await函数,调用fetchData函数,然后派发正确的动作。
...
const ListsContextProvider = ({ children }) => {
  const [value, dispatch] = React.useReducer(reducer, initialValue);

 const getListsRequest = async () => {
    const result = await fetchData('https://my-json-server.typicode.com/PacktPublishing/React-Projects/lists');

    if (result.data && result.data.length) {
      dispatch({ type: 'GET_LISTS_SUCCESS', payload: result.data });
    } else {
      dispatch({ type: 'GET_LISTS_ERROR', payload: result.error });
    }
  } ...

使用前面的getListsRequest函数时,当调用这个函数时,会对fetchData函数进行async/await调用。如果dataSource返回的数据不是空数组,将使用useReducer Hook 中的dispatch函数向 reducer 派发GET_LISTS_SUCCESS动作。如果不是,将派发GET_LISTS_ERROR动作,返回错误消息。

  1. 当您的应用程序挂载时,现在可以从useEffect Hook 中调用getListsRequest函数,以便应用程序将填充列表数据。这应该是从视图中完成的,因此您需要创建一个操作,可以将其添加到Provider中,以便从Consumer中获取此值的任何组件都可以使用它:
...  

-  React.useEffect(() => {
-    const asyncFetchData = async (dataSource) => {
-      const result = await fetchData(dataSource);
-
-      setLists([...result.data]);
-    }
-
-    asyncFetchData('https://my-json-server.typicode.com/PacktPublishing/React-Projects/lists');
-  }, [setLists]);

  return (
-   <ListsContext.Provider value={{ lists: state }}>               
+   <ListsContext.Provider value={{ ...value, getListsRequest }}>
      {children}
    </ListsContext.Provider>
  );
};

export default ListsContextProvider;
  1. 在显示列表的组件Lists中,您可以使用getListsRequest函数检索列表的数据。因此,您需要从src/containers/App.js文件中的RenderProps中将其传递给此组件。此外,当尚未检索到列表数据或发生错误时,您可以添加一个加载指示器或错误消息:
...
const App = () => (
  <>
    <GlobalStyle />
      <AppWrapper>
      <Header />
        <ListsContextProvider>
          <ItemsContextProvider>
            <ListsContext.Consumer>
-             {({ lists }) => (
+             {({ lists, loading: listsLoading, error: listsError, getListsRequest }) => (
                <ItemsContext.Consumer>
                  {({ items }) => (
                    <Switch>
-                     <Route exact path='/' render={props => lists && <Lists lists={lists} {...props} />} />
+                     <Route exact path='/' render={props => lists && <Lists lists={lists} loading={listsLoading} error={listsError} getListsRequest={getListsRequest} {...props} />} />
...
  1. 最后,在Lists组件中挂载时,从Lists组件调用getListsRequest函数,并添加加载指示器或错误消息。只有在尚无可用列表时才应检索列表:
- const Lists = ({lists, loading = false, error = '', match, history}) => !loading && !error ? (
+ const Lists = ({lists, loading, error, getListsRequest, match, history}) => {
+  React.useEffect(() => {
+    if (!lists.length) {
+      getListsRequest();
+    }
+  }, [lists, getListsRequest]);

+ return !loading && !error ? (
  <>
    {history && <SubHeader title='Your Lists' openForm={() => history.push('/new')} /> }
    <ListWrapper>
      {lists && lists.map(list => (
        <ListLink key={list.id} to={`list/${list.id}`}>
          <Title>{list.title}</Title>
        </ListLink>
      ))}
    </ListWrapper>
  </>
- );
+  ) : <Alert>{loading ? 'Loading...' : error}</Alert>;
+ } export default Lists;

如果您现在再次在浏览器中访问项目,您会发现列表中的数据与以前一样加载。最大的区别是数据是使用 Flux 模式获取的,这意味着这可以扩展到在其他情况下获取数据。同样,也可以在src/Context/ItemsContextProvider.js文件中对ItemsContextProvider执行相同操作:

  1. 首先添加项目的初始值,这将与useReducer Hook 一起使用:
import React from 'react';
- import withDataFetching from '../withDataFetching';

+ const initialValue = {
+  items: [],
+  loading: true,
+  error: '',
+ }

export const ItemsContext = React.createContext();

- const ItemsContextProvider = ({ children, data }) => (
+ const ItemsContextProvider = ({ children }) => {
    + const [value, dispatch] = React.useReducer(reducer, initialValue);

+ return (
  <ItemsContext.Provider value={{ items: data }}>
    {children}
  </ItemsContext.Provider>
);
+ };

...
  1. 之后,您可以添加 reducer,它有两个操作,与列表 reducer 的操作非常相似。唯一的区别是它们将向 Provider 添加有关项目的信息。还要添加与您添加到ListsContextProviderfetchData函数相同的函数:
import React from 'react';
import withDataFetching from '../withDataFetching';

export const ItemsContext = React.createContext();

const initialValue = {
  items: [],
  loading: true,
  error: '',
}

+ const reducer = (value, action) => {
+  switch (action.type) {
+    case 'GET_ITEMS_SUCCESS':
+      return {
+        ...value,
+        items: action.payload,
+        loading: false,
+      };
+    case 'GET_ITEMS_ERROR':
+      return {
+        ...value,
+        items: [],
+        loading: false,
+        error: action.payload,
+      };
+    default:
+      return value;
+  }
+ };

+ async function fetchData(dataSource) {
+  try {
+    const data = await fetch(dataSource);
+    const dataJSON = await data.json();
+
+    if (dataJSON) {
+      return await ({ data: dataJSON, error: false })
+    }
+  } catch(error) {
+      return ({ data: false, error: error.message })
+  }
+ };

const ItemsContextProvider = ({ children }) => {
    ...
  1. 现在,您可以创建async/await函数,用于获取项目的dataSource。此函数还将获取所选列表的id变量,以避免数据的过度获取。withDataFetching HOC 可以被移除,因为不再需要检索数据:
...
const ItemsContextProvider = ({ children }) => {
  const [value, dispatch] = React.useReducer(reducer, initialValue);

+  const getItemsRequest = async (id) => {
+    const result = await fetchData(`
+      https://my-json-server.typicode.com/PacktPublishing/React-Projects/items/${id}/items
+    `);

+    if (result.data && result.data.length) {
+      dispatch({ type: 'GET_ITEMS_SUCCESS', payload: result.data });
+    } else {
+      dispatch({ type: 'GET_ITEMS_ERROR', payload: result.error });
+    }
+  }

  return (
-    <ItemsContext.Provider value={{ items: data }}>            
+    <ItemsContext.Provider value={{ ...value, getItemsRequest }}>
      {children}
    </ItemsContext.Provider>
  );
}

- export default withDataFetching({
    dataSource: 'https://my-json-server.typicode.com/PacktPublishing/React-Projects/items', 
  })(ItemsContextProvider);
+ export default ItemsContextProvider;
  1. 由于现在已将检索项目的函数添加到项目的 Provider 中,因此 Consumer 是src/containers/App.js,可以将此函数传递给显示项目的List组件:
...
const App = () => (
  <>
    <GlobalStyle />
      <AppWrapper>
      <Header />
        <ListsContextProvider>
          <ItemsContextProvider>
            <ListsContext.Consumer>
              {({ lists, loading: listsLoading, error: listsError, getListsRequest }) => (
                <ItemsContext.Consumer>
-                 {({ items }) => (
+                 ({ items, loading: itemsLoading, error: itemsError, getItemsRequest }) => (
                    <Switch>
                      <Route exact path='/' render={props => lists && <Lists lists={lists} loading={listsLoading} error={listsError} getListsRequest={getListsRequest} {...props} />} />
                      <Route path='/list/:id/new' component={Form} />
-                     <Route path='list/:id' render={props => lists && items && <List lists={lists} listItems={items} {...props} /> 
+                     <Route path='/list/:id' render={props => lists && items && <List lists={lists} items={items} loading={itemsLoading} error={itemsError} getItemsRequest={getItemsRequest} {...props} /> } />
                    </Switch>
                  )}
                </ItemsContext.Consumer>
              )}
           </ListsContext.Consumer>
         </ItemsContextProvider>
       </ListsContextProvider>
    </AppWrapper>
  </>
);

export default App;
  1. 最后,在src/containers/List.js中的List组件中调用getItemsRequest函数。此函数将使用match属性从当前路由中获取您正在显示的列表的id变量。重要的是要提到,只有在items的值为空时才应调用此函数,以防止不必要的数据获取。
...
- const List = ({ listItems, loading = false, error = '', lists, match, history }) => {
+ const List = ({ items, loading, error, lists, getItemsRequest, match, history }) => {
-  const items = listItems && listItems.filter(item => item.listId === parseInt(match.params.id));
  const list = lists && lists.find(list => list.id === parseInt(match.params.id));

+  React.useEffect(() => {
+   if (!items.length > 0) {
+     getItemsRequest(match.params.id);
+   };
+ }, [items, match.params.id, getItemsRequest]);

  return !loading && !error ? (
    <>
      {(history && list) && <SubHeader goBack={() => history.goBack()} title={list.title} openForm={() => history.push(`${match.url}/new`)} />}
      <ListItemWrapper>
        {items && items.map(item => <ListItem key={item.id} data={ item } />)}
      </ListItemWrapper>
    </>
) : <Alert>{loading ? 'Loading... : error}</Alert>
};

export default List;

您可能会注意到,当您刷新页面时,列表的标题将不再显示。只有在Lists组件挂载时才会获取列表的信息,因此您需要创建一个新函数,始终获取List组件中当前显示的列表的信息:

  1. src/Context/ListsContextProvider.js文件中,您需要扩展initialValue,还要添加一个名为list的字段:
import React from 'react';

export const ListsContext = React.createContext();

const initialValue = {
  lists: [],
+ list: {},
  loading: true,
  erorr: '',
}

const reducer = (value, action) => {
...
  1. reducer中,现在还必须检查两个新操作,其中一个是将列表数据添加到上下文中,另一个是添加错误消息:
...

const reducer = (value, action) => {
  switch (action.type) {
    case 'GET_LISTS_SUCCESS':
      return {
        ...value,
        lists: action.payload,
        loading: false,
      };
    case 'GET_LISTS_ERROR':
      return {
        ...value,
        lists: [],
        loading: false,
        error: action.payload,
      };
+   case 'GET_LIST_SUCCESS':
+     return {
+       ...value,
+       list: action.payload,
+       loading: false,
+     };
+   case 'GET_LIST_ERROR':
+     return {
+       ...value,
+       list: {},
+       loading: false,
+       error: action.payload,
+     };
    default:
      return value;
  }
};

async function fetchData(dataSource) {
...
  1. 这些操作将从一个使用特定id调用dataSourceasync/await函数中分派。如果成功,将分派GET_LIST_SUCCESS操作;否则,将分派GET_LIST_ERROR操作。还要将该函数传递给 Provider,以便可以从List组件中使用:
...
const ListsContextProvider = ({ children }) => {
  const [value, dispatch] = React.useReducer(reducer, initialValue);

  const getListsRequest = async () => {
    const result = await fetchData('https://my-json-server.typicode.com/PacktPublishing/React-Projects/lists');

    if (result.data && result.data.length) {
      dispatch({ type: 'GET_LISTS_SUCCESS', payload: result.data });
    } else {
      dispatch({ type: 'GET_LISTS_ERROR', payload: result.error });
    }
  }

+  const getListRequest = async id => {
+    const result = await fetchData(`https://my-json-server.typicode.com/PacktPublishing/React-Projects/lists/${id}`);

+    if (result.data && result.data.hasOwnProperty('id')) {
+      dispatch({ type: 'GET_LIST_SUCCESS', payload: result.data });
+    } else {
+      dispatch({ type: 'GET_LIST_ERROR', payload: result.error });
+    }
+  }

  return (
-   <ListsContext.Provider value={{ ...value, getListsRequest }}>
+   <ListsContext.Provider value={{ ...value, getListsRequest, getListRequest }}>
        ...
  1. 并将其传递给List组件,通过从ListsContext Consumer 中解构它。还要从此 Consumer 中获取列表数据,并将其传递给List组件。lists属性现在可以从此组件中删除,因为现在列表数据的过滤是由ListsContextProvider完成的:
<ListsContext.Consumer>
-  {({ lists, loading: listsLoading, error: listsError, getListsRequest }) => (
+  {({ list, lists, loading: listsLoading, error: listsError, getListsRequest, getListRequest }) => (
     <ItemsContext.Consumer>
       {({ items, loading: itemsLoading, error: itemsError, getItemsRequest }) => (
         <Switch>
           <Route exact path='/' render={props => lists && <Lists lists={lists} loading={listsLoading} error={listsError} getListsRequest={getListsRequest} {...props} />} />
           <Route path='/list/:id/new' component={Form} />
-          <Route path='/list/:id' render={props => lists && items && <List lists={lists} items={items} loading={itemsLoading} error={itemsError} getItemsRequest={getItemsRequest} {...props} /> } />
+          <Route path='/list/:id' render={props => list && items && <List list={list} items={items} loading={itemsLoading} error={itemsError} getListRequest={getListRequest} getItemsRequest={getItemsRequest} {...props} /> } />
         </Switch>
       )}
     </ItemsContext.Consumer>
   )}
</ListsContext.Consumer>

...
  1. 最后,您可以调用getListRequest函数,从List组件中获取列表数据。只有在此数据尚不可用时,您才希望检索列表信息;因此不再需要对lists属性进行过滤:
...
- const List = ({ items, loading, error, lists, getItemsRequest, match, history }) => {
+ const List = ({ items, loading, error, list, getListRequest, getItemsRequest, match, history }) => {
-   const list = lists && lists.find(list => list.id === parseInt(match.params.id));

  React.useEffect(() => {
+   if (!list.id) {
+     getListRequest(match.params.id);
+   }

    if (!items.length > 0) {
      getItemsRequest(match.params.id);
    }
- }, [items, match.params.id, getItemsRequest]);
+ }, [items, list, match.params.id, getItemsRequest, getListRequest]);

  return !loading && !error ? (
    ...

现在,您的应用程序中的所有数据都是使用 Providers 加载的,这意味着它现在与视图分离。此外,withDataFetching HOC 已完全删除,使您的应用程序结构更易读。

不仅可以使用此模式的上下文 API 使数据可用于许多组件,还可以改变数据。如何改变这些数据将在下一节中展示。

在 Provider 中改变数据

不仅可以使用这种 Flux 模式来检索数据,还可以用它来更新数据。模式仍然是一样的:您派发一个动作,触发对服务器的请求,根据结果,reducer 将使用这个结果改变数据。根据是否成功,您可以显示成功消息或错误消息。

该代码已经有一个用于向列表添加新项目的表单,但目前还没有工作。让我们通过更新items的 Provider 来创建添加项目的机制:

  1. 第一步是创建一个新的函数,可以处理POST请求,因为这个函数在处理fetch请求时还应该设置方法和主体。您可以在src/Context/ItemsContextProvider.js文件中创建这个函数:
...
async function fetchData(dataSource) {
  try {
    const data = await fetch(dataSource);
    const dataJSON = await data.json();

    if (dataJSON) {
      return await ({ data: dataJSON, error: false });
    }
  } catch(error) {
      return ({ data: false, error: error.message });
  }
};

async function postData(dataSource, content) {
 try {
 const data = await fetch(dataSource, {
 method: 'POST',
 body: JSON.stringify(content),
 });
 const dataJSON = await data.json();

 if (dataJSON) {
 return await ({ data: dataJSON, error: false });
 }
 } catch(error) {
 return ({ data: false, error: error.message });
 }
};

const ItemsContextProvider = ({ children }) => {
    ...
  1. 这个函数不仅需要dataSource,还需要将要发布到这个源的信息。就像检索项目一样,在reducerswitch语句中可以添加一个情况。这一次,它将寻找一个名为ADD_ITEM_REQUEST的动作,它的载荷由dataSource和应该添加到值中的content组成。这些动作会改变loading和/或error的值,并在返回时也会传播实际的当前值。如果不这样做,所有关于列表的已有信息都将被清除:
...
const reducer = (value, action) => {
  switch (action.type) {
    case 'GET_ITEMS_SUCCESS':
      return {
        ...value,
        items: action.payload,
        loading: false,
      };
    case 'GET_ITEMS_ERROR':
      return {
        ...value,
        items: [],
        loading: action.payload,
      };
+   case 'ADD_ITEM_SUCCESS':
+     return {
+       ...value,
+       items: [
+         ...value.items,
+         action.payload,
+       ],
+       loading: false,
+     };
+   case 'ADD_ITEM_ERROR':
+     return {
+       ...value,
+       loading: false,
+       error: 'Something went wrong...',
+     };
    default:
      return value;
  }
};

async function fetchData(dataSource) {
...

来自 My JSON Server 的模拟 API 一旦添加、更新或删除请求,数据就不会持久保存。但是,您可以通过在浏览器的开发者工具的 Network 选项卡中检查请求来查看请求是否成功。这就是为什么输入内容分布在items的值上,所以这些数据可以从 Consumer 中获取。

  1. 还要创建一个处理POST请求的async/await函数。如果这个请求成功,返回的数据将有一个名为id的字段。因此,在这种情况下,可以派发ADD_ITEM_SUCCESS动作。否则,会派发一个ADD_ITEM_ERROR动作。这些动作将从reducer改变这个 Provider 的值:
...
const ItemsContextProvider = ({ children }) => {
  const [value, dispatch] = React.useReducer(reducer, initialValue);

  const getItemsRequest = async (id) => {
    const result = await fetchData(`
      https://my-json-server.typicode.com/PacktPublishing/React-Projects/items/${id}/items
    `);

    if (result.data && result.data.length) {
      dispatch({ type: 'GET_ITEMS_SUCCESS', payload: result.data });
    } else {
      dispatch({ type: 'GET_ITEMS_ERROR', payload: result.error });
    }
  }

+  const addItemRequest = async (content) => {
+    const result = await postData('https://my-json-server.typicode.com/PacktPublishing/React-Projects/items', content);

+    if (result.data && result.data.hasOwnProperty('id')) {
+      dispatch({ type: 'ADD_ITEM_SUCCESS', payload: content });
+    } else {
+      dispatch({ type: 'ADD_ITEM_ERROR' });
+    }
+  }

  return (
-   <ItemsContext.Provider value={{ ...value, getItemsRequest }}>
+   <ItemsContext.Provider value={{ ...value, getItemsRequest, addItemRequest }}>
    ...
  1. 就像检索列表一样,用于添加列表的actionDispatch函数可以包装在一个辅助函数中。这个函数将在稍后从表单返回的内容。还要将这个函数传递给 Provider,以便它可以在任何使用这个 Provider 的组件中使用:
...
  const getListsRequest = () => {
    actionDispatch({ 
      type: 'GET_LISTS_REQUEST', 
      payload: 'https://my-json-server.typicode.com/PacktPublishing/React-Projects/items',
    });
  };

+  const addListRequest = (content) => {
+    actionDispatch({
+      type: 'ADD_LIST_REQUEST',
+      payload: { 
+        dataSource: 'https://my-json-server.typicode.com/PacktPublishing/React-Projects/items', 
+        content, 
+       } 
+     });
+  };

  return (
-    <ListsContext.Provider value={{ ...value, getListsRequest }}>
+    <ListsContext.Provider value={{ ...value, getListsRequest, addListRequest }}>
      {children}
    </ListsContext.Provider>
  )
};

export default ListsContextProvider;
  1. 由于现在可以从提供者中使用添加列表的函数,你可以通过使用RouteRenderProps将其传递给Form组件。这可以在src/containers/App.js文件中完成。确保不要忘记发送matchhistory属性,因为这些被Form组件使用:
...
<ListsContext.Consumer>
  {({ list, lists, loading: listsLoading, error: listsError, getListsRequest, getListRequest }) => (
    <ItemsContext.Consumer>
-     {({ items, loading: itemsLoading, error: itemsError, getItemsRequest }) => (
+     {({ items, loading: itemsLoading, error: itemsError, getItemsRequest, addItemRequest }) => (
        <Switch>
          <Route exact path='/' render={props => lists && <Lists lists={lists} loading={listsLoading} error={listsError} getListsRequest={getListsRequest} {...props} />} />
-         <Route path='/list/:id/new' component={Form} />
+         <Route path='/list/:id/new' render={props => <Form addItemRequest={addItemRequest} {...props} />} />
          <Route path='/list/:id' render={props => list && items && <List list={list} items={items} loading={itemsLoading} error={itemsError} getListRequest={getListRequest} getItemsRequest={getItemsRequest} {...props} /> } />
        </Switch>
      )}
    </ItemsContext.Consumer>
  )}
</ListsContext.Consumer>

...

Form组件现在可以使用addListRequest函数,该函数将触发POST请求的动作,将项目添加到dataSource中。当用户提交表单时,需要触发这个函数。

然而,表单中输入字段的值需要首先确定。因此,输入字段需要成为受控组件,这意味着它们的值由封装值的本地状态控制:

  1. 为此,你可以使用useState Hook,并为你想要创建的每个state值调用它。这个 Hook 将返回这个state值的当前值和一个更新这个值的函数,必须添加在src/containers/Form.js中:
...
- const Form = ({ match, history }) => (
+ const Form = ({ addItemRequest, match, history }) => {  
+  const [title, setTitle] = React.useState('');
+  const [quantity, setQuantity] = React.useState('');
+  const [price, setPrice] = React.useState('');

+  return (
  <>
    {history && <SubHeader goBack={() => history.goBack()} title='Add Item' />}
    <FormWrapper>
      <form>
        <FormItem id='title' label='Title' placeholder='Insert title' />
        <FormItem id='quantity' label='Quantity' type='number' placeholder='0' />
        <FormItem id='price' label='Price' type='number' placeholder='0.00' />
        <SubmitButton>Add Item</SubmitButton>
      </form>
    </FormWrapper>
  </>
);
+ }

export default Form;
  1. 本地状态值和触发本地state值更新的函数必须作为FormItem组件的属性进行设置:
...

  return (
    <>
      {history && <SubHeader goBack={() => history.goBack()} title='Add item' /> }
      <FormWrapper>
        <form>
-         <FormItem id='title' label='Title' placeholder='Insert title' />
+         <FormItem id='title' label='Title' placeholder='Insert title' value={title} handleOnChange={setTitle} />
-         <FormItem id='quantity' label='Quantity' type='number' placeholder='0' />
+         <FormItem id='quantity' label='Quantity' type='number' placeholder='0' value={quantity} handleOnChange={setQuantity} />
-         <FormItem id='price' label='Price' type='number' placeholder='0.00' />
+         <FormItem id='price' label='Price' type='number' placeholder='0.00' value={price} handleOnChange={setPrice} />
          <SubmitButton>Add Item</SubmitButton>
        </form>
      </FormWrapper>
    </>
  )
};

export default Form;

  1. FormItem组件在src/components/FormItem.js文件中可以接受这些属性,并使输入字段调用handleOnChange函数。元素的当前target值必须作为此函数的参数使用:
...
- const FormItem = ({ id, label, type = 'text', placeholder = '' }) => (
+ const FormItem = ({ id, label, type = 'text', placeholder = '', value, handleOnChange }) => (
  <FormItemWrapper>
    <Label htmlFor={id}>{label}</Label>
-    <Input type={type} name={id} id={id} placeholder={placeholder} />
+    <Input type={type} name={id} id={id} placeholder={placeholder} value={value} onChange={e => handleOnChange(e.target.value)} />
  </FormItemWrapper>
);

export default FormItem;
  1. 现在你需要做的最后一件事是添加一个函数,当点击提交按钮时将被调度。这个函数接受本地状态的value,添加关于列表的信息和一个随机生成的id,然后使用这些来调用addItemRequest函数。在调用了这个函数之后,将调用history属性中的goBack函数:
...
const Form = ({ addItemRequest, match, history }) => {
  ...

+ const handleOnSubmit = e => {
+    e.preventDefault();
+    addItemRequest({
+      title, 
+      quantity,
+      price,
+      id: Math.floor(Math.random() * 100), 
+      listId: parseInt(match.params.id) 
+    });
+    history.goBack();
+  };

  return (
    <>
      {history && <SubHeader goBack={() => history.goBack()} title={title} />}
      <FormWrapper>
-        <form>
+        <form onSubmit={handleOnSubmit}>

...

现在当你提交表单时,将发送一个POST请求到模拟服务器。你将被发送回到之前的页面,你可以在那里看到结果。如果成功,将会触发GET_LIST_SUCCESS动作,并且你插入的项目将被添加到列表中。

到目前为止,上下文中的信息仅通过使用提供者分开使用,但这也可以合并为一个全局上下文,如下一节所示。

创建全局上下文

如果你看一下你的App组件中路由的当前结构,你可以想象如果你在应用程序中添加更多的 Providers 和 Consumers,这将变得混乱。状态管理包如 Redux 倾向于有一个全局状态,其中存储了应用程序的所有数据。当使用 Context 时,可以创建一个全局 Context,可以使用useContext Hook 访问。这个 Hook 充当 Consumer,可以从传递给它的 Context 的 Provider 中检索值。让我们重构当前的应用程序以拥有一个全局 Context:

  1. 首先,在src/Context目录中创建一个名为GlobalContext.js的文件。这个文件将导入ListsContextProviderItemsContextProvider,将它们嵌套,并让它们包装任何作为children属性传递给它的组件:
import React from 'react';
import ListsContextProvider from './ListsContextProvider';
import ItemsContextProvider from './ItemsContextProvider';

const GlobalContext = ({ children }) => {
  return (
    <ListsContextProvider>
      <ItemsContextProvider>
        {children}
      </ItemsContextProvider>
    </ListsContextProvider>
  );
};

export default GlobalContext;
  1. src/containers/App.js文件中,你现在可以导入GlobalContext文件,而不是导入列表和项目的 Providers:
import React from 'react';
import styled, { createGlobalStyle } from 'styled-components';
import { Route, Switch } from 'react-router-dom';
- import ListsContextProvider, { ListsContext } from '../Context/ListsContextProvider';
- import ItemsContextProvider, { ItemsContext } from '../Context/ItemsContextProvider';
+ import GlobalContext from '../Context/GlobalContext';
...
  1. 你可以用GlobalContext替换ListsContextProviderItemsContextProvider。如果你仍然导入它们,Consumer 仍然可以从ListsContextItemsContext中检索数据:
const App = () => (
  <>
    <GlobalStyle />
      <AppWrapper>
      <Header />
+      <GlobalContext>
-      <ListsContextProvider>
-        <ItemsContextProvider>
          <ListsContext.Consumer>
            {({ list, lists, loading: listsLoading, error: listsErorr, getListsRequest, getListRequest }) => (
              <ItemsContext.Consumer>
                {({ items, loading: itemsLoading, error: itemsError, getItemsRequest, addItemRequest }) => (
                  <Switch>
                    <Route exact path='/' render={props => lists && <Lists lists={lists} loading={listsLoading} error={listsError} getListsRequest={getListsRequest} {...props} />} />
                    <Route path='/list/:id/new' render={props => <Form addItemRequest={addItemRequest} {...props} />} />
                    <Route path='/list/:id' render={props => list && items && <List list={list} items={items} loading={itemsLoading} error={itemsError} getListRequest={getListRequest} getItemsRequest={getItemsRequest} {...props} /> } />
                  </Switch>
                )}
              </ItemsContext.Consumer>
            )}
          </ListsContext.Consumer>
-       </ItemsContextProvider>
-     </ListsContextProvider>
+     </GlobalContext>
    </AppWrapper>
  </>
);

export default App;
  1. 接下来,你可以删除路由中的 Consumers 和RenderProps模式。上下文中的值将不再从两个 Consumers 中传递,而是将使用useContext Hook 在每个路由中检索:
...
        <GlobalContext>
-         <ListsContext.Consumer>
-           {({ list, lists, loading: listsLoading, error: listsError, getListsRequest, getListRequest }) => (
-             <ItemsContext.Consumer>
-               {({ items, loading: itemsLoading, error: itemsError, getItemsRequest, addItemRequest }) => (
                  <Switch>
-                   <Route exact path='/' render={props => lists && <Lists lists={lists} loading={listsLoading} error={listsError} getListsRequest={getListsRequest} {...props} />} />
+                   <Route exact path='/' component={Lists} />
-                   <Route path='/list/:id/new' render={props => <Form addItemRequest={addItemRequest} {...props} />} />
+                   <Route path='/list/:id/new' component={Form} />
-                   <Route path='/list/:id' render={props => list && items && <List list={list} items={items} loading={itemsLoading} error={itemsError} getListRequest={getListRequest} getItemsRequest={getItemsRequest} {...props} /> } />
+                   <Route path='/list/:id' component={List} />
                  </Switch>
-               )}
-             </ItemsContext.Consumer>
-           )}
-        </ListsContext.Consumer>
       </GlobalContext>
...
  1. 在每个由Route渲染的组件中,你想要使用的上下文都应该被导入。然后,useContext Hook 可以从这个上下文中检索值。你可以从src/containers/Lists.js组件开始添加这个 Hook:
import React from 'react';
import styled from 'styled-components';
import { Link } from 'react-router-dom';
+ import { ListsContext } from '../Context/ListsContextProvider';
import SubHeader from '../components/Header/SubHeader';

...

- const Lists = ({lists, loading, error, getListsRequest, match, history}) => {
+ const Lists = ({ match, history }) => {
+  const { lists, loading, error, getListsRequest } =    React.useContext(ListsContext);
  React.useEffect(() => {
    if (!lists.length) {
      getListsRequest();
    }
  }, [lists, getListsRequest]);

  return !loading && !error ? (
    <>
      {history && <SubHeader title='Your Lists' />}
      <ListWrapper>
        {lists && lists.map((list) => (
          <ListLink key={list.id} to={`list/${list.id}`}>
            <Title>{list.title}</Title>
          </ListLink>
        ))}
      </ListWrapper>
    </>
  ) : <Alert>{loading ? 'Loading...' : error}</Alert>;
}
export default Lists;
  1. 正如你所看到的,useContext只需要将要使用的上下文作为参数。要在List组件中实现这一点,你需要在src/containers/List.js文件中导入ListsContextItemsContext
import React from 'react';
import styled from 'styled-components';
import { ListsContext } from '../Context/ListsContextProvider';
import { ItemsContext } from '../Context/ItemsContextProvider';
import SubHeader from '../components/Header/SubHeader';
import ListItem from '../components/ListItem/ListItem';

...

- const List = ({ items, loading, error, list, getListRequest, getItemsRequest, match, history }) => {
+ const List = ({ match, history }) => {
+  const { list, getListRequest } = React.useContext(ListsContext);
+  const { loading, error, items, getItemsRequest } = React.useContext(ItemsContext);

  React.useEffect(() => {    ...
  1. 对于Form组件在src/containers/Form.js文件中也是一样,你只使用ItemsContext
import React from 'react';
import styled from 'styled-components';
+ import { ItemsContext } from '../Context/ItemsContextProvider';
import SubHeader from '../components/Header/SubHeader';
import FormItem from '../components/FormItem/FormItem';
import Button from '../components/Button/Button';

...

- const Form = ({ addItemRequest, match, history }) => {
+ const Form = ({ match, history }) => {
+  const { addItemRequest } = React.useContext(ItemsContext);

...

现在你可以看到你的应用程序有一个更清晰的结构,同时数据仍然是通过 Providers 检索的。

总结

在这一章中,您已经创建了一个购物清单应用程序,该应用程序使用上下文 API 和 Hooks 来传递和检索数据,而不是使用 HOC。上下文用于存储数据,Hooks 用于检索和改变数据。使用上下文 API,您可以使用useReducer Hook 创建更高级的状态管理场景。此外,您已经重新创建了一个情况,其中所有数据都存储在全局,并且可以通过创建共享上下文从任何组件访问。

在下一章中,上下文 API 也将被使用,该章节将向您展示如何使用诸如 Jest 和 Enzyme 等库构建具有自动化测试的酒店评论应用程序。它将向您介绍使用 React 创建 UI 组件的多种测试方法,并向您展示如何使用上下文 API 测试应用程序中的状态管理。

进一步阅读

消耗多个上下文对象:reactjs.org/docs/Context.html#consuming-multiple-Contexts

第六章:使用 Jest 和 Enzyme 构建探索 TDD 的应用程序

为了保持应用的可维护性,最好为项目设置测试。一些开发人员讨厌编写测试,因此试图避免编写测试,而其他开发人员则喜欢将测试作为其开发过程的核心,实施测试驱动开发TDD)策略。关于测试应用程序以及如何进行测试有很多不同的观点。幸运的是,在使用 React 构建应用程序时,许多出色的库可以帮助您进行测试。

在本章中,您将使用两个库来对 React 应用程序进行单元测试。第一个是 Jest,由 Facebook 自己维护,并随 Create React App 一起发布。另一个工具叫做 Enzyme,它比 Jest 具有更多的功能,并且可以用来测试组件内的整个生命周期。它们一起非常适合测试大多数 React 应用程序,如果您想要测试函数或组件在给定特定输入时是否表现如预期。

本章将涵盖以下主题:

  • 使用 Jest 进行单元测试

  • 为测试渲染 React 组件

  • 使用 Enzyme 进行测试

项目概述

在本章中,我们将创建一个酒店评论应用程序,并使用 Jest 和 Enzyme 进行单元和集成测试。该应用程序已经预先构建,并使用了我们在前几章中看到的相同模式。

构建时间为 2 小时。

入门

本章的应用程序是基于初始版本构建的,可以在github.com/PacktPublishing/React-Projects/tree/ch6-initial找到。本章的完整代码可以在 GitHub 上找到:github.com/PacktPublishing/React-Projects/tree/ch6

从 GitHub 下载初始项目,并进入该项目的根目录,然后运行npm install命令。由于该项目是基于 Create React App 构建的,运行此命令将安装reactreact-domreact-scripts。此外,还将安装styled-componentsreact-router-dom,以便它们可以处理应用程序的样式和路由。安装过程完成后,可以执行npm start命令来运行应用程序,然后在浏览器中访问http://localhost:3000来查看项目。就像你在之前章节中构建的应用程序一样,这个应用程序也是一个 PWA。

初始应用程序包括一个简单的标题和酒店列表。这些酒店有标题和缩略图等元信息。该页面将如下所示。如果你点击列表中的任何酒店,将会打开一个新页面,显示该酒店的评论列表。通过点击页面左上角的按钮,你可以返回到上一个页面;通过点击右上角的按钮,将打开一个包含表单的页面,你可以在其中添加评论。如果你添加了新的评论,这些数据将被存储在全局上下文中,并发送到一个模拟 API 服务器。

如果你查看项目的结构,你会发现它使用了与我们之前创建的项目相同的结构。这个应用程序的入口点是一个名为src/index.js的文件,它渲染了一个名为App的组件。在这个App组件中,所有的路由都被声明并包装在一个路由组件中。此外,这里还声明了持有全局上下文和提供者的组件。与之前创建的应用程序相比,这个应用程序中没有使用容器组件模式。相反,所有的数据获取都是通过上下文组件完成的。生命周期是使用 Hooks 来访问的:

hotel-review
|-- node_modules
|-- public
    |-- assets
        |-- beachfront-hotel.jpg
        |-- forest-apartments.jpg
        |-- favicon.ico
        |-- index.html
        |-- manifest.json
|-- src
    |-- components
        |-- Button
            |-- Button.js
        |-- Detail
            |-- Detail.js
            |-- ReviewItem.js
        |-- Form
            |-- Form.js
            |-- FormItem.js
        |-- Header
            |-- Header.js
            |-- SubHeader.js
        |-- Hotels
            |-- Hotels.js
            |-- HotelItem.js
        |-- App.js
    |-- Context
        |-- GlobalContext.js
        |-- HotelsContextProvider.js
        |-- ReviewsContextProvider.js
    |-- api.js
    |-- index.js
    |-- serviceWorker.js
.gitignore
package.json

在上述项目结构中,你可以看到public/assets目录中还有两个文件,这些文件是酒店的缩略图。为了在渲染的应用程序中使用它们,你可以将它们放在public目录中。此外,在src中还有一个名为api.js的文件,它导出了函数,以便可以向 API 发送GETPOST请求。

酒店评论应用程序

在本节中,我们将为在 Create React App 中创建的酒店评论应用程序添加单元测试和集成测试。这个应用程序允许你向酒店列表中添加评论,并从全局上下文中控制这些数据。Jest 和 Enzyme 将用于在没有 DOM 的情况下渲染 React 组件,并对这些组件进行测试断言。

使用 Jest 进行单元测试

单元测试是应用程序的重要部分,因为你希望知道你的函数和组件在进行代码更改时是否按预期行为。为此,你将使用 Jest,这是一个由 Facebook 创建的用于 JavaScript 应用程序的开源测试包。使用 Jest,你可以测试断言,例如,如果函数的输出与你预期的值匹配。

要开始使用 Jest,你无需安装任何东西;它是 Create React App 的一部分。如果你查看package.json文件,你会看到已经有一个用于运行测试的脚本。

让我们看看如果你从终端执行以下命令会发生什么:

npm run test 

这将返回一条消息,说No tests found related to files changed since last commit.,这意味着 Jest 正在观察模式下运行,并且只对已更改的文件运行测试。通过按下a键,你可以运行所有测试,即使你没有修改任何文件。如果按下这个键,将显示以下消息:

No tests found
 26 files checked.
 testMatch: /hotel-review/src/**/__tests__/**/*.{js,jsx,ts,tsx},/hotel-review/src/**/?(*.)(spec|test).{js,jsx,ts,tsx} - 0 matches
 testPathIgnorePatterns: /node_modules/ - 26 matches
Pattern: - 0 matches

这条消息说明已经调查了26个文件,但没有找到测试。它还说明正在寻找项目中名为__tests__的目录中的 JavaScript 或 JSX 文件,以及具有spectest后缀的文件。node_modules目录,即所有npm包安装的地方,将被忽略。从这条消息中,你可能已经注意到 Jest 会自动检测包含测试的文件。

可以使用 Jest 来创建这些测试,这将在本节的第一部分进行演示。

创建一个单元测试

由于 Jest 可以以多种方式检测哪个文件包含测试,让我们选择每个组件都有一个单独的测试文件的结构。这个测试文件将与包含组件的文件同名,后缀为.test。如果我们选择SubHeader组件,我们可以在src/components/Header目录中创建一个名为SubHeader.test.js的新文件。将以下代码添加到这个文件中:

describe('the <SubHeader /> component', () => {
  it('should render', () => {

  });
});

这里使用了 Jest 的两个全局函数:

  • describe:用于定义一组相关的测试

  • it:用于定义测试

在测试的定义中,您可以添加假设,比如toEqualtoBe,分别检查值是否完全等于某些内容,或者只是类型匹配。假设可以在it函数的回调中添加:

describe('the <SubHeader /> component', () => {
  it('should render', () => {
+   expect(1+2).toBe(3);
  });
});

如果您的终端仍在运行测试脚本,您将看到 Jest 已检测到您的测试。测试成功,因为1+2确实是3。让我们继续并将假设更改为以下内容:

describe('the <SubHeader /> component', () => {
  it('should render', () => {
-    expect(1+2).toBe(3);
+    expect(1+2).toBe('3');
  });
});

现在,测试将失败,因为第二个假设不匹配。虽然1+2仍然等于3,但假设返回了一个值为3的字符串类型,而实际上返回的是一个数字类型。这在编写代码时可以帮助您,因为您可以确保应用程序不会更改其值的类型。

然而,这个假设实际上没有用,因为它并没有测试您的组件。要测试您的组件,您需要渲染它。在本节的下一部分将处理渲染组件以便测试它们。

渲染 React 组件进行测试

Jest 基于 Node.js,这意味着它无法使用 DOM 来渲染您的组件并测试其功能。因此,您需要向项目添加一个 React 核心软件包,它可以帮助您在没有 DOM 的情况下渲染组件。让我们在这里看一下:

  1. 从您的终端执行以下命令,它将在您的项目中安装react-test-renderer。它可以作为 devDependency 安装,因为您不需要在应用程序的构建版本上运行测试:
npm install react-test-renderer --save-dev
  1. 安装了react-test-renderer后,您现在可以将此软件包导入到src/components/Header/SubHeader.test.js文件中。此软件包返回一个名为ShallowRenderer的方法,让您可以渲染组件。使用浅渲染,您只在其第一级渲染组件,从而排除任何可能的子组件。您还需要导入 React 和您想要测试的实际组件,因为这些是react-test-renderer使用的:
+ import React from 'react';
+ import ShallowRenderer from 'react-test-renderer/shallow';
+ import SubHeader from './SubHeader';

describe('the <SubHeader /> component', () => {
 ....
  1. 在您的测试中,您现在可以使用ShallowRenderer来渲染组件,并获得此组件的输出。使用 Jest 的toMatchSnapshot假设,您可以测试组件的结构。ShallowRenderer将渲染组件,toMatchSnapshot将从此渲染创建快照,并在每次运行此测试时将其与实际组件进行比较:
import React from 'react';
import ShallowRenderer from 'react-test-renderer/shallow';
import SubHeader from './SubHeader';

describe('the <SubHeader /> component', () => {
  it('should render', () => {
-   expect(1+2).toBe('3');
+    const renderer = new ShallowRenderer();
+    renderer.render(<SubHeader />);
+    const component = renderer.getRenderOutput();

+    expect(component).toMatchSnapshot();
  });
});
  1. src/components/Header目录中,Jest 现在创建了一个名为__snapshots__的新目录。在这个目录中有一个名为SubHeader.test.js.snap的文件,其中包含了快照。如果您打开这个文件,您会看到SubHeader组件的渲染版本存储在这里:
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`the <SubHeader /> component should render 1`] = `
<ForwardRef>
  <ForwardRef />
</ForwardRef>
`;

使用styled-components创建的组件无法被react-test-renderer渲染,因为它们是由styled-components导出的方式。如果您查看SubHeader组件的代码,您会看到ForwardRef组件代表SubHeaderWrapperTitle。在本章的后面,我们将使用 Enzyme 进行测试,它可以更好地处理这种测试场景。

  1. 由于未向SubHeader组件传递任何 props,因此react-test-renderer不会呈现任何实际值。您可以通过向SubHeader组件传递title prop 来检查快照的工作方式。为此,创建一个新的测试场景,应该呈现带有标题的SubHeader。此外,将renderer常量的创建移动到describe函数中,以便它可以被所有的测试场景使用:
import React from 'react';
import ShallowRenderer from 'react-test-renderer/shallow';
import SubHeader from './SubHeader';

describe('the <SubHeader /> component', () => {
+  const renderer = new ShallowRenderer();

  it('should render', () => {
-   const renderer = new ShallowRenderer(); 
    renderer.render(<SubHeader />);
    const component = renderer.getRenderOutput();

    expect(component).toMatchSnapshot();
  });

+  it('should render with a dynamic title', () => {
+    renderer.render(<SubHeader title='Test Application' />);
+    const component = renderer.getRenderOutput();

+    expect(component).toMatchSnapshot();
+  }); });
  1. 下次运行测试时,将会在src/components/Header/__snapshots__/SubHeader.test.js.snap文件中添加一个新的快照。这个快照为title prop 呈现了一个值。如果您在测试文件中更改了SubHeader组件显示的title prop 的值,渲染的组件将不再与快照匹配。您可以通过更改测试场景中title prop 的值来尝试这一点:
import React from 'react';
import ShallowRenderer from 'react-test-renderer/shallow';
import SubHeader from './SubHeader';

describe('the <SubHeader /> component', () => {
  const renderer = new ShallowRenderer();

  ...

  it('should render with a dynamic title', () => {
-   renderer.render(<SubHeader title='Test Application' />);
+   renderer.render(<SubHeader title='Test Application Test' />);
    const component = renderer.getRenderOutput();

    expect(component).toMatchSnapshot();
  });
});

Jest 将在终端中返回以下消息,其中指定了与快照相比发生了哪些变化的行。在这种情况下,显示的标题不再是Test Application,而是Test Application Test,这与快照中的标题不匹配:

 • the <SubHeader /> component › should render

 expect(value).toMatchSnapshot()

 Received value does not match stored snapshot "the <SubHeader /> component should render 1".

 - Snapshot
 + Received

 <ForwardRef>
 <ForwardRef>
 - Test Application
 + Test Application Title
 </ForwardRef>
 </ForwardRef>
...

通过按下u键,您可以更新快照以处理这个新的测试场景。这是测试组件结构的一种简单方法,可以看到标题是否已经被渲染。通过前面的测试,最初创建的快照仍然与第一个测试的渲染组件匹配。此外,还为第二个测试创建了另一个快照,其中向SubHeader组件添加了title prop。

  1. 你可以对传递给SubHeader组件的其他属性做同样的操作,如果你传递或不传递某些属性,它会以不同的方式呈现。除了title之外,这个组件还接受goBackopenForm作为属性,其中openForm属性的默认值为 false。

就像我们为title属性所做的那样,我们也可以为另外两个属性创建测试场景。当goBack有值时,会创建一个按钮,让我们返回到上一页,而当openForm有值时,会创建一个按钮,让我们可以继续到下一页,这样我们就可以添加新的评论。你需要将这两个新的测试场景添加到src/components/Header/SubHeader.test.js文件中:

import React from 'react';
import ShallowRenderer from 'react-test-renderer/shallow';
import SubHeader from './SubHeader';

describe('the <SubHeader /> component', () => {
  const renderer = new ShallowRenderer();

  ...

+  it('should render with a goback button', () => {
+   renderer.render(<SubHeader goBack={() => {}} />);
+    const component = renderer.getRenderOutput();
+
+    expect(component).toMatchSnapshot();
+  });

+  it('should render with a form button', () => {
+   renderer.render(<SubHeader openForm={() => {}} />);
+    const result = renderer.getRenderOutput();
+
+    expect(component).toMatchSnapshot();
+  });
});

你现在为SubHeader组件创建了另外两个快照,总共有四个快照。Jest 还会显示你的测试覆盖了多少行代码。你的测试覆盖率越高,就越有理由认为你的代码是稳定的。你可以通过执行带有--coverage标志的test脚本命令来检查你的代码的测试覆盖率,或者在终端中使用以下命令:

npm run test --coverage

这个命令将运行你的测试并生成一个报告,其中包含有关每个文件的代码测试覆盖信息。在为SubHeader添加测试之后,这个报告将如下所示:

 PASS src/components/Header/SubHeader.test.js
----------------------------|----------|----------|----------|----------|-------------------|
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s |
----------------------------|----------|----------|----------|----------|-------------------|
All files | 5 | 6.74 | 4.26 | 5.21 | |
 src | 0 | 0 | 0 | 0 | |
 api.js | 0 | 0 | 0 | 0 |... 20,22,23,26,30 |
 index.js | 0 | 100 | 100 | 0 | 1,2,3,4,5,17 |
 serviceWorker.js | 0 | 0 | 0 | 0 |... 23,130,131,132 |
 src/components | 0 | 100 | 0 | 0 | |
 App.js | 0 | 100 | 0 | 0 |... ,8,10,22,26,27 |
 src/components/Button | 0 | 100 | 0 | 0 | |
 Button.js | 0 | 100 | 0 | 0 | 20 |
 src/components/Detail | 0 | 0 | 0 | 0 | |
 Detail.js | 0 | 0 | 0 | 0 |... 26,27,31,33,35 |
 ReviewItem.js | 0 | 100 | 0 | 0 |... 15,21,26,30,31 |
 src/components/Form | 0 | 0 | 0 | 0 | |
 Form.js | 0 | 0 | 0 | 0 |... 29,30,31,34,36 |
 FormInput.js | 0 | 0 | 0 | 0 |... 17,26,35,40,41 |
 src/components/Header | 100 | 100 | 100 | 100 | |
 Header.js | 100 | 100 | 100 | 100 | |
 SubHeader.js | 100 | 100 | 100 | 100 | |
...

测试覆盖只告诉我们关于已经测试过的代码行和函数的信息,而不是它们的实际实现。拥有 100%的测试覆盖并不意味着你的代码中没有任何错误,因为总会有边缘情况。此外,达到 100%的测试覆盖意味着你可能会花更多的时间编写测试而不是实际的代码。通常,80%以上的测试覆盖被认为是良好的实践。

正如你所看到的,组件的测试覆盖率为 100%,这意味着你的测试覆盖了所有的代码行。然而,使用快照测试的这种方法会创建大量新文件和代码行。我们将在本节的下一部分中看看我们可以用其他方法来测试我们的组件。

使用断言测试组件

理论上,快照测试并不一定是坏的实践;然而,随着时间的推移,你的文件可能会变得非常庞大。此外,由于你没有明确告诉 Jest 你想测试组件的哪一部分,你可能需要定期更新你的代码。

幸运的是,使用快照并不是我们测试组件是否渲染正确属性的唯一方法。相反,您还可以直接比较组件渲染的属性的值并进行断言。使用断言进行测试的重要优势是,您可以进行大量测试,而无需深入了解正在测试的组件的逻辑。

例如,您可以查看正在渲染的子元素的样子。让我们看看如何做到这一点:

  1. 首先,让我们为 Button 组件创建一个快照测试,以比较测试覆盖率的影响。创建一个名为 src/components/Button/Button.test.js 的新文件。在这个文件中,您需要插入一个创建快照的测试:
import React from 'react';
import ShallowRenderer from 'react-test-renderer/shallow';
import Button from './Button';

describe('the <Button /> component', () => {
  const renderer = new ShallowRenderer();

  it('should render', () => {
    const children = 'This is a button';
    renderer.render(<Button>{children</Button>);
    const result = renderer.getRenderOutput();

    expect(result).toMatchSnapshot();
  });
});
  1. 如果您使用 --coverage 标志运行测试,将创建一个新的测试覆盖报告:
npm run test --coverage

此报告生成以下报告,显示了 Button 组件的覆盖率,为 100%:

 PASS src/components/Header/SubHeader.test.js
 PASS src/components/Button/Button.test.js
 › 1 snapshot written.
 PASS src/components/Header/Header.test.js
----------------------------|----------|----------|----------|----------|-------------------|
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s |
----------------------------|----------|----------|----------|----------|-------------------|
All files | 5.45 | 6.74 | 6.38 | 5.69 | |
 src | 0 | 0 | 0 | 0 | |
 api.js | 0 | 0 | 0 | 0 |... 20,22,23,26,30 |
 index.js | 0 | 100 | 100 | 0 | 1,2,3,4,5,17 |
 serviceWorker.js | 0 | 0 | 0 | 0 |... 23,130,131,132 |
 src/components | 0 | 100 | 0 | 0 | |
 App.js | 0 | 100 | 0 | 0 |... ,8,10,22,26,27 |
 src/components/Button | 100 | 100 | 100 | 100 | |
 Button.js | 100 | 100 | 100 | 100 | |

如果您打开 src/components/Button/__snapshots__/Button.test.js.snap 文件中 Button 组件的快照,您将看到按钮内部渲染的唯一内容(由 ForwardRef 表示)是 children 属性:

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`the <Button /> component should render 1`] = `
<ForwardRef>
  This is a button
</ForwardRef>
`;
  1. 尽管测试覆盖率达到了 100%,但还有其他方法可以测试正确的子元素是否已被渲染。为此,我们可以创建一个新的测试,也使用 ShallowRenderer 并尝试使用子元素渲染 Button 组件。这个测试断言渲染的 children 属性是否等于 Button 渲染的实际 children 属性。您可以删除快照测试,因为您只想通过断言测试子元素:
import React from 'react';
import ShallowRenderer from 'react-test-renderer/shallow';
import Button from './Button';

describe('the <Button /> component', () => {
  const renderer = new ShallowRenderer();

-  it('should render', () => {
-    const children = 'This is a button';
-    renderer.render(<Button>{children}</Button>);
-    const result = renderer.getRenderOutput();

-    expect(result).toMatchSnapshot();
-  })

+  it('should render the correct children', () => {
+    const children = 'This is a button';
+    renderer.render(<Button>{children}</Button>);
+    const component = renderer.getRenderOutput();

+    expect(component.props.children).toEqual(children);
+  });
});
  1. 从您的终端运行 npm run test --coverage 再次检查这种测试方法对测试覆盖率的影响:
 PASS src/components/Header/Header.test.js
 PASS src/components/Header/SubHeader.test.js
 PASS src/components/Button/Button.test.js
 › 1 snapshot obsolete.
 • the <Button /> component should render 1
----------------------------|----------|----------|----------|----------|-------------------|
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s |
----------------------------|----------|----------|----------|----------|-------------------|
All files | 5.45 | 6.74 | 6.38 | 5.69 | |
 src | 0 | 0 | 0 | 0 | |
 api.js | 0 | 0 | 0 | 0 |... 20,22,23,26,30 |
 index.js | 0 | 100 | 100 | 0 | 1,2,3,4,5,17 |
 serviceWorker.js | 0 | 0 | 0 | 0 |... 23,130,131,132 |
 src/components | 0 | 100 | 0 | 0 | |
 App.js | 0 | 100 | 0 | 0 |... ,8,10,22,26,27 |
 src/components/Button | 100 | 100 | 100 | 100 | |
 Button.js | 100 | 100 | 100 | 100 | |
...

在上述报告中,您可以看到测试覆盖率仍然为 100%,这意味着这种测试方法具有相同的结果。但这次,您特别测试子元素是否等于该值。好处是,您无需在每次进行代码更改时更新快照。

  1. 还显示了一个消息,指出 1 个快照已过时。通过使用 -u 标志运行 npm run testButton 组件的快照将被 Jest 删除:
npm run test -u

这为我们提供了以下输出,显示快照已被移除:

 PASS src/components/Button/Button.test.js
 › snapshot file removed.

Snapshot Summary
 › 1 snapshot file removed from 1 test suite.

然而,Button组件不仅接受children属性,还接受onClick属性。如果您想测试当单击按钮时是否触发了此onClick属性,您需要以不同的方式渲染组件。这可以通过使用react-test-renderer来完成,但 React 文档还指出您也可以使用 Enzyme 来实现这一点。

在下一节中,我们将使用 Enzyme 的浅渲染函数,该函数比ShallowRenderer有更多选项。

使用 Enzyme 进行 React 测试

react-test-rendererShallowRenderer允许我们渲染组件的结构,但不会显示组件在某些场景下的交互方式,例如当触发onClick事件时。为了模拟这一点,我们将使用一个更复杂的工具,称为 Enzyme。

使用 Enzyme 进行浅渲染

Enzyme 是由 Airbnb 创建的开源 JavaScript 测试库,可以与几乎所有 JavaScript 库或框架一起使用。使用 Enzyme,您还可以浅渲染组件以测试组件的第一级,以及渲染嵌套组件,并模拟集成测试的生命周期。Enzyme 库可以使用npm安装,并且还需要一个适配器来模拟 React 功能。让我们开始吧:

  1. 安装 Enzyme,您需要从终端运行以下命令,该命令安装 Enzyme 和您正在使用的 React 版本的特定适配器:
npm install enzyme enzyme-adapter-react-16 --save-dev
  1. 安装 Enzyme 后,您需要创建一个设置文件,告诉 Enzyme 应该使用哪个适配器来运行测试。通常,您需要在package.json文件中指定保存此配置的文件,但是,当您使用 Create React App 时,这已经为您完成。自动用作测试库配置文件的文件名为setupTests.js,应该创建在src目录中。创建文件后,将以下代码粘贴到其中:
import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

configure({ adapter: new Adapter() });

安装 Enzyme 后,您将无法再使用使用react-test-renderer的测试场景。因此,您需要更改SubHeaderButton组件的测试。正如我们之前提到的,Enzyme 有一个方法允许我们浅渲染组件。让我们先尝试对SubHeader组件进行这样的操作:

  1. 您需要从 Enzyme 导入shallow,而不是导入react-test-rendererShallowRender方法不应再添加到renderer常量中,因此您可以删除此行:
import React from 'react';
- import ShallowRenderer from 'react-test-renderer/shallow';
+ import { shallow } from 'enzyme';
import SubHeader from './SubHeader';

describe('the <SubHeader /> component', () => {
-  const renderer = new ShallowRenderer();
  it('should render', () => {
    ...
  1. 每个测试方案都应更改为使用 Enzyme 的浅渲染函数。我们可以通过用shallow替换renderer.render来实现这一点。我们用于获取此渲染输出的函数也可以删除。Enzyme 的shallow渲染将立即创建一个可以由 Jest 测试的结果:
import React from 'react';
import { shallow } from 'enzyme';
import SubHeader from './SubHeader';

describe('the <SubHeader /> component', () => {
  it('should render', () => {
-    renderer.render(<SubHeader />);
-    const component = renderer.getRenderOutput();
+    const component = shallow(<SubHeader />);

    expect(component).toMatchSnapshot();
  });

  ...
  1. 就像我们在第一个测试方案中所做的那样,我们必须替换其他测试方案;否则,测试将无法运行。这是因为我们已经删除了react-test-renderer的设置:
import React from 'react';
import { shallow } from 'enzyme';
import SubHeader from './SubHeader';

describe('the <SubHeader /> component', () => {
  ...

  it('should render with a dynamic title', () => {
-    renderer.render(<SubHeader title='Test Application' />);
-    const component = renderer.getRenderOutput();
+    const component = shallow(<SubHeader title='Test Application' />);

    expect(component).toMatchSnapshot();
  });

  it('should render with a goback button', () => {
-    renderer.render(<SubHeader goBack={() => {}} />);
-    const component = renderer.getRenderOutput();
+    const component = shallow(<SubHeader goBack={() => {}} />);

    expect(component).toMatchSnapshot();
  });

  it('should render with a form button', () => {
-    renderer.render(<SubHeader openForm={() => {}} />);
-    const component = renderer.getRenderOutput();
+    const component = shallow(<SubHeader openForm={() => {}} />);

    expect(component).toMatchSnapshot();
  });
});
  1. 在终端中,您现在可以通过运行npm run test再次运行测试。由于测试正在观察模式下运行,Button组件的测试可能也会开始运行。您可以通过按下p键然后在终端中输入SubHeader来指定应该运行哪些测试。现在,Jest 将仅运行SubHeader组件的测试。

由于您的快照不再是由react-test-renderer创建的快照,测试将失败。Enzyme 的浅渲染对来自styled-components的导出有更好的理解,不再将这些组件呈现为ForwardRef组件。相反,它返回,例如,名为styled.divstyled.h2的组件:

 FAIL src/components/Header/SubHeader.test.js
 the <SubHeader /> component
 Χ should render (27ms)
 Χ should render with a dynamic title (4ms)
 Χ should render with a goback button (4ms)
 Χ should render with a form button (4ms)

 • the <SubHeader /> component › should render

 expect(value).toMatchSnapshot()

 Received value does not match stored snapshot "the <SubHeader /> component should render 1".

 - Snapshot
 + Received

 - <ForwardRef>
 - <ForwardRef />
 - </ForwardRef>
 + <styled.div>
 + <styled.h2 />
 + </styled.div>

通过按下u键,所有由react-test-renderer创建的快照将被 Enzyme 的新快照替换。

对于Button组件,也可以进行相同的操作,不使用快照进行测试。而是使用断言。在您的测试方案中,在src/components/Button/Button.test.js文件中,用 Enzyme 的浅渲染替换ShallowRenderer。此外,由于 Enzyme 呈现组件的方式,component.props.children的值不再存在。相反,您需要使用props方法,该方法可用于浅渲染的组件上,以获取children属性:

import React from 'react';
- import ShallowRenderer from 'react-test-renderer/shallow';
+ import { shallow } from 'enzyme';
import Button from './Button';

describe('the <Button /> component', () => {
-  const renderer = new ShallowRenderer();

  it('should render the correct children', () => {
    const children = 'This is a button';
-   renderer.render(<Button>{children}</Button>);
-   const component = renderer.getRenderOutput();
+   const component = shallow(<Button>{children}</Button>)

-   expect(component.props.children).toEqual(children)
+   expect(component.props().children).toEqual(children)
  })
})

现在当您运行测试时,所有测试都应该成功,并且测试覆盖率不应受影响,因为您仍在测试组件上的属性是否被渲染。然而,使用 Enzyme 的快照,您可以获得有关正在呈现的组件结构的更多信息。现在,您甚至可以测试更多内容,并找出例如onClick事件是如何处理的。

然而,快照并不是测试 React 组件的唯一方式,正如我们将在本节的下一部分中看到的那样。

使用浅渲染进行断言测试

除了react-test-renderer之外,Enzyme 可以处理浅渲染组件上的onClick事件。为了测试这一点,您必须创建一个模拟版本的函数,该函数应在组件被点击时触发。之后,Jest 可以检查该函数是否被执行。

您之前测试过的Button组件不仅接受children作为属性 - 它还接受onClick函数。让我们尝试看看是否可以使用 Jest 和 Enzyme 来测试这一点,通过在Button组件的文件中创建一个新的测试场景:

import React from 'react';
import { shallow } from 'enzyme';
import Button from './Button';

describe('the <Button /> component', () => {
  ...

+  it('should handle the onClick event', () => {
+    const mockOnClick = jest.fn();
+    const component = shallow(<Button onClick={mockOnClick} />);

+    component.simulate('click');

+    expect(mockOnClick).toHaveBeenCalled();
+  });
});

在前面的测试场景中,使用 Jest 创建了一个模拟的onClick函数,该函数作为属性传递给了浅渲染的Button组件。然后,在该组件上调用了一个带有点击事件处理程序的simulate方法。模拟点击Button组件应该执行模拟的onClick函数,您可以通过检查该测试场景的测试结果来确认这一点。

SubHeader组件的测试也可以更新,因为它渲染了两个带有onClick事件的按钮。让我们开始吧:

  1. 首先,您需要对src/components/Header/SubHeader.js中的SubHeader组件的文件进行一些更改,因为您需要导出使用styled-components创建的组件。通过这样做,它们可以在SubHeader的测试场景中用于测试:
import React from 'react';
import styled from 'styled-components';
import Button from '../Button/Button';

const SubHeaderWrapper = styled.div`
  width: 100%;
  display: flex;
  justify-content: space-between;
  background: cornflowerBlue;
`;

- const Title = styled.h2`
+ export const Title = styled.h2`
  text-align: center;
  flex-basis: 60%;

  &:first-child {
    margin-left: 20%;
  }

  &:last-child {
    margin-right: 20%;
  }
`;

- const SubHeaderButton = styled(Button)`
+ export const SubHeaderButton = styled(Button)`
  margin: 10px 5%;
`;

...
  1. 一旦它们被导出,我们就可以将这些组件导入到我们的SubHeader测试文件中:
import React from 'react';
import { shallow } from 'enzyme';
- import SubHeader from './SubHeader';
+ import SubHeader, { Title, SubHeaderButton } from './SubHeader';

describe('the <SubHeader /> component', () => {
    ...
  1. 这样可以在任何测试中找到这些组件。在这种情况下,使用快照测试了title属性的渲染,但您也可以直接测试SubHeader中的Title组件是否正在渲染title属性。要测试这一点,请更改以下代码行:
import React from 'react';
import { shallow } from 'enzyme';
import SubHeader, { Title, SubHeaderButton } from './SubHeader';

describe('the <SubHeader /> component', () => {
  it('should render with a dynamic title', () => {
+    const title = 'Test Application';
-    const component = shallow(<SubHeader title='Test Application' />);
+    const component = shallow(<SubHeader title={title} />);

-    expect(component).toMatchSnapshot();

+    expect(component.find(Title).text()).toEqual(title);
  });

  ...

在这里创建了一个新的常量用于title属性,并将其传递给SubHeader组件。不再使用快照作为断言,而是创建一个新的快照,尝试找到Title组件,并检查该组件内的文本是否等于title属性。

  1. 除了title prop 之外,您还可以测试goBack(或openForm)prop。如果存在这个 prop,将渲染一个具有goBack prop 作为onClick事件的按钮。这个按钮被渲染为SubHeaderButton组件。在这里,我们需要改变第二个测试场景,使其具有goBack prop 的模拟函数,然后创建一个断言来检查渲染组件中SubHeaderButton的存在:
import React from 'react';
import { shallow } from 'enzyme';
import SubHeader, { Title, SubHeaderButton } from './SubHeader';

describe('the <SubHeader /> component', () => {
  ...

  it('should render with a goback button and handle the onClick event', () => {
+    const mockGoBack = jest.fn();
-    const component = shallow(<SubHeader goBack={() => {}} />);
+    const component = shallow(<SubHeader goBack={mockGoBack} />);

-    expect(component).toMatchSnapshot();

+    const goBackButton = component.find(SubHeaderButton);
+    expect(goBackButton.exists()).toBe(true);
  });
  ...
  1. 我们不仅要测试带有goBack prop 的按钮是否被渲染,还要测试一旦我们点击按钮,这个函数是否被调用。就像我们为Button组件测试所做的那样,我们可以模拟点击事件并检查模拟的goBack函数是否被调用:
import React from 'react';
import { shallow } from 'enzyme';
import SubHeader, { Title, SubHeaderButton } from './SubHeader';

describe('the <SubHeader /> component', () => {
  ...

  it('should render with a goback button and handle the onClick event', () => {
    const mockGoBack = jest.fn();
    const component = shallow(<SubHeader goBack={mockGoBack} />);

    const goBackButton = component.find(SubHeaderButton);
    expect(goBackButton.exists()).toBe(true);

+    goBackButton.simulate('click');
+    expect(mockGoBack).toHaveBeenCalled();
  })
  ...
  1. 如果我们用两个断言替换测试快照的断言,测试按钮的存在以及它是否触发了模拟的openForm函数,那么对于openForm prop 也可以做同样的事情。我们可以将这个添加到现有的测试场景中,也可以扩展goBack按钮的测试场景:
import React from 'react';
import { shallow } from 'enzyme';
import SubHeader, { Title, SubHeaderButton } from './SubHeader';

describe('the <SubHeader /> component', () => {
  ...

-   it('should render with a goback button and handle the onClick event', () => {
+   it('should render with a buttons and handle the onClick events', () => {
    const mockGoBack = jest.fn();
+    const mockOpenForm = jest.fn();
-    //const component = shallow(<SubHeader goBack={mockGoBack} />);
+    const component = shallow(<SubHeader goBack={mockGoBack} openForm={mockOpenForm} />);

    ...
  });

-  it('should render with a form button', () => {
-    const component = shallow(<SubHeader openForm={() => {}} />);

-    expect(component).toMatchSnapshot();
-  });
});
  1. 现在为SubHeader渲染的组件应该同时具有一个按钮返回到上一页和一个按钮打开表单。然而,它们都使用SubHeaderButton组件进行渲染。返回按钮首先在组件树中进行渲染,因为它位于SubHeader的左侧。因此,我们需要指定哪个渲染的SubHeaderButton是哪个按钮:
import React from 'react';
import { shallow } from 'enzyme';
import SubHeader, { Title, SubHeaderButton } from './SubHeader';

describe('the <SubHeader /> component', () => {
  ...

  it('should render with buttons and handle the onClick events', () => {
    const mockGoBack = jest.fn();
    const mockOpenForm = jest.fn();
    const component = shallow(<SubHeader goBack={mockGoBack} openForm={mockOpenForm} />);

-   const goBackButton = component.find(SubHeaderButton);
+   const goBackButton = component.find(SubHeaderButton).at(0);
    expect(goBackButton.exists()).toBe(true);

+   const openFormButton = component.find(SubHeaderButton).at(1);
+   expect(openFormButton.exists()).toBe(true)

    goBackButton.simulate('click');
    expect(mockGoBack).toHaveBeenCalled();

+    openFormButton.simulate('click');
+    expect(mockOpenForm).toHaveBeenCalled();
  });
  ...

在这些更改之后,所有使用快照的测试场景都被移除,并替换为更具体的测试,一旦我们改变了任何代码,它们就会变得不太脆弱。除了快照,这些测试将在我们改变任何使重构更容易的 props 时继续工作。

在这一部分,我们已经创建了单元测试,用于测试我们代码的特定部分。然而,测试不同部分的代码如何一起工作可能会很有趣。为此,我们将向我们的项目添加集成测试。

使用 Enzyme 进行集成测试

我们创建的所有测试都使用浅渲染来渲染组件,但是在 Enzyme 中,我们也有选项来挂载组件。使用这个选项时,我们可以启用生命周期并测试比第一级更深的更大的组件。当我们想一次测试多个组件时,这被称为集成测试。在我们的应用程序中,由路由直接渲染的组件也会渲染其他组件。Hotels组件就是一个很好的例子,它渲染了上下文返回的酒店列表。让我们开始吧:

  1. 和往常一样,起点是在与要测试的组件位于同一目录中创建一个带有.test后缀的新文件。在这里,我们需要在src/components/Hotels目录中创建Hotels.test.js文件。在这个文件中,我们需要从 Enzyme 中导入mount,导入我们要测试的组件,并创建一个新的测试场景:
import React from 'react';
import { mount } from 'enzyme';
import Hotels from './Hotels';

describe('the <Hotels /> component', () => {

});
  1. Hotels组件使用useContext Hook 来获取显示酒店所需的数据。然而,由于这是针对特定组件的测试,该数据需要被模拟。在我们可以模拟这些数据之前,我们需要为useContext Hook 创建一个模拟函数。如果我们有多个使用此模拟的测试场景,我们还需要使用beforeEachafterEach方法为每个场景创建和重置这个模拟函数。
import React from 'react';
import { mount } from 'enzyme';
import Hotels from './Hotels';

+ let useContextMock;

+ beforeEach(() => {
+  useContextMock = React.useContext = jest.fn();
+ });

+ afterEach(() => {
+  useContextMock.mockReset();
+ });

describe('the <Hotels /> component', () => {
    ...
  1. 现在我们可以使用模拟的useContextMock函数来生成将用作上下文的模拟数据,该数据将由Hotels组件使用。将返回的数据也应该是模拟的,可以通过调用可用于模拟函数的mockReturnValue函数来实现。如果我们看一下Hotels组件的实际代码,我们会发现它从上下文中获取了四个值:loadingerrorhotelsgetHotelsRequest。这些值应该在我们将创建的第一个测试场景中被模拟和返回,以检查上下文在加载酒店数据时的行为:
import React from 'react';
import { mount } from 'enzyme';
import Hotels from './Hotels';

...

describe('the <Hotels /> component', () => {
  it('should handle the first mount', () => {
+    const mockContext = { 
+      loading: true,
+      error: '', 
+      hotels: [], 
+      getHotelsRequest: jest.fn(),
+    }
+    useContextMock.mockReturnValue(mockContext);
+    const wrapper = mount(<Hotels />);
+
+    expect(mockContext.getHotelsRequest).toHaveBeenCalled();
  });
});

这个第一个测试场景检查了Hotels组件在首次挂载时是否会调用上下文中的getHotelsRequest函数。这意味着在Hotels中使用的useEffect Hook 已经经过了测试。

  1. 由于数据仍在加载中,我们还可以测试Alert组件是否从上下文中渲染了loading值并显示了加载消息。在这里,我们需要从src/components/Hotels/Hotels.js中导出这个组件:
...

- const Alert = styled.span`
+ export const Alert = styled.span`
  width: 100%;
  text-align: center;
`;

const Hotels = ({ match, history }) => {
    ...

现在,我们可以在测试文件中导入这个组件,并编写断言来检查它是否显示了来自上下文的值:

import React from 'react';
import { mount } from 'enzyme';
- import Hotels from './Hotels';
+ import Hotels, { Alert } from './Hotels';

...

describe('the <Hotels /> component', () => {
  it('should handle the first mount', () => {
    const mockContext = { 
      loading: true,
      error: '',
      hotels: [], 
      getHotelsRequest: jest.fn(), 
    }
    useContextMock.mockReturnValue(mockContext);
    const wrapper = mount(<Hotels />);

    expect(mockContext.getHotelsRequest).toHaveBeenCalled();
+   expect(wrapper.find(Alert).text()).toBe('Loading...');
  });
  1. Hotels组件挂载并且数据被获取后,上下文中的loadingerrorhotels的值将被更新。当loadingerror的值为false时,HotelItemsWrapper组件将被Hotels渲染。为了测试这一点,我们需要从Hotels中导出HotelItemsWrapper
import React from 'react';
import styled from 'styled-components';
import { Link } from 'react-router-dom';
import { HotelsContext } from '../../Context/HotelsContextProvider';
import SubHeader from '../Header/SubHeader';
import HotelItem from './HotelItem';

- const HotelItemsWrapper = styled.div`
+ export const HotelItemsWrapper = styled.div`
  display: flex;
  justify-content: space-between;
  flex-direction: column;
  margin: 2% 5%;
`;

...

在测试文件中,现在可以导入这个组件,这意味着我们可以添加新的测试场景,检查这个组件是否被渲染:

import React from 'react';
import { mount } from 'enzyme';
- import Hotels, { Alert } from './Hotels';
+ import Hotels, { Alert, HotelItemsWrapper } from './Hotels';

describe('the <Hotels /> component', () => {
  ...

+  it('should render the list of hotels', () => {
+    const mockContext = {
+      loading: false,
+      error: '',
+      hotels: [{
+        id: 123,
+        title: 'Test Hotel',
+        thumbnail: 'test.jpg',
+      }],
+      getHotelsRequest: jest.fn(),
+    }
+    useContextMock.mockReturnValue(mockContext);
+    const wrapper = mount(<Hotels />);

+    expect(wrapper.find(HotelItemsWrapper).exists()).toBe(true);
+  });
});

现在,当我们运行测试时,会出现错误,显示“不变式失败:您不应该在之外使用”,因为 Enzyme 无法渲染Link组件,这是我们点击酒店时用来导航的。因此,我们需要将Hotels组件包装在react-router的路由器组件中:

import React from 'react';
import { mount } from 'enzyme';
+ import { BrowserRouter as Router } from 'react-router-dom';
import Hotels, { Alert, HotelItemsWrapper } from './Hotels';

...

describe('the <Hotels /> component', () => {
  ...

  it('should render the list of hotels', () => {
    const mockContext = {
      loading: false,
      alert: '',
      hotels: [{
        id: 123,
        title: 'Test Hotel',
        thumbnail: 'test.jpg',
      }],
      getHotelsRequest: jest.fn(),
    }
    useContextMock.mockReturnValue(mockContext);
-    const wrapper = mount(<Hotels />);
+    const wrapper = mount(<Router><Hotels /></Router>);

    expect(wrapper.find(HotelItemsWrapper).exists()).toBe(true);
  });
});

这个测试现在会通过,因为 Enzyme 可以渲染组件,包括Link来导航到酒店。

  1. HotelItemsWrapper组件内部是一个map函数,它遍历来自上下文的酒店数据。对于每次迭代,都会渲染一个HotelItem组件。在这些HotelItem组件中,数据将以某种方式显示,例如一个Title组件。我们可以测试这些组件中将显示的数据是否等于模拟的上下文数据。显示酒店标题的组件应该从src/components/Hotels/HotelItem.js中导出。
- const Title = styled.h3`
+ export const Title = styled.h3`
  margin-left: 2%;
`

除了HotelItem组件,这应该被导入到Hotels的测试中。在测试场景中,我们现在可以检查<HotelItem组件是否存在,并检查这个组件是否有Title组件。这个组件显示的值应该等于数组hotels中第一行的标题的模拟上下文值:

import React from 'react';
import { mount } from 'enzyme';
import { BrowserRouter as Router } from 'react-router-dom';
import Hotels, { Alert, HotelItemsWrapper } from './Hotels';
+ import HotelItem, { Title } from './HotelItem';

...

describe('the <Hotels /> component', () => {
  ...

  it('should render the list of hotels', () => {
    const mockContext = {
      loading: false,
      alert: '',
      hotels: [{
        id: 123,
        title: 'Test Hotel',
        thumbnail: 'test.jpg',
      }],
      getHotelsRequest: jest.fn(),
    }
    useContextMock.mockReturnValue(mockContext);
    const wrapper = mount(<Router><Hotels /></Router>);

    expect(wrapper.find(HotelItemsWrapper).exists()).toBe(true);

+   expect(wrapper.find(HotelItem).exists()).toBe(true);
+ expect(wrapper.find(HotelItem).at(0).find(Title).text()).toBe(mockContext.hotels[0].title);
  });
});

在使用--coverage标志再次运行测试之后,我们将能够看到编写此集成测试对我们的覆盖率产生了什么影响。由于集成测试不仅测试一个特定的组件,而是一次测试多个组件,因此Hotels的测试覆盖率将得到更新。此测试还涵盖了HotelItem组件,我们将能够在运行npm run test --coverage后的覆盖率报告中看到这一点:

 PASS src/components/Button/Button.test.js
 PASS src/components/Header/SubHeader.test.js
 PASS src/components/Hotels/Hotels.test.js
----------------------------|----------|----------|----------|----------|-------------------|
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s |
----------------------------|----------|----------|----------|----------|-------------------|
All files | 13.27 | 11.24 | 12.77 | 13.73 | |
 ...
 src/components/Hotels | 100 | 83.33 | 100 | 100 | |
 HotelItem.js | 100 | 100 | 100 | 100 | |
 Hotels.js | 100 | 83.33 | 100 | 100 | 33 |

Hotels的覆盖率接近 100%。HotelItems的测试覆盖率也达到了 100%。这意味着我们可以跳过为HotelItem编写单元测试,假设我们只在Hotels组件中使用此组件。

相对于单元测试,集成测试的唯一缺点是它们更难编写,因为它们通常包含更复杂的逻辑。此外,由于集成测试具有更多的逻辑并将多个组件组合在一起,因此这些集成测试将运行得更慢。

摘要

在本章中,我们介绍了使用 Jest 结合react-test-renderer或 Enzyme 进行 React 应用程序测试。这两个软件包对于希望为其应用程序添加测试脚本的每个开发人员都是很好的资源,它们也与 React 很好地配合。本章讨论了为应用程序编写测试的优势,希望现在您知道如何为任何项目添加测试脚本。还展示了单元测试和集成测试之间的区别。

由于本章中测试的应用程序与前几章的应用程序具有相同的结构,因此可以将相同的测试原则应用于本书中构建的任何应用程序。

下一章将结合本书中已经使用过的许多模式和库,因为我们将使用 React、GraphQL 和 Apollo 创建一个全栈电子商务商店。

进一步阅读

第七章:使用 React Native 和 GraphQL 构建全栈电子商务应用程序

如果您正在阅读本文,这意味着您已经到达了本书的最后部分,该部分使用 React 构建 Web 应用程序。在前面的章节中,您已经使用了 React 的核心功能,如渲染组件、使用 Context 进行状态管理和 Hooks。您已经学会了如何创建 PWA 和 SSR 应用程序,以及如何将路由添加到您的 React 应用程序中。此外,您还知道如何使用 Jest 和 Enzyme 向 React 应用程序添加测试。让我们将 GraphQL 添加到您迄今为止学到的东西列表中。

在本章中,您不仅将构建应用程序的前端,还将构建后端。为此,将使用 GraphQL,它最好被定义为 API 的查询语言。使用模拟数据和 Apollo Server,您将扩展一个 GraphQL 服务器,为您的 React 应用程序公开一个单一的端点。在前端方面,将使用 Apollo Client 来消耗此端点,它将帮助您处理向服务器发送请求以及此数据的状态管理。

本章将涵盖以下主题:

  • 使用 GraphQL 查询和变异数据

  • 使用 Apollo Client 消耗 GraphQL

  • 使用 GraphQL 处理状态管理

项目概述

在本章中,我们将创建一个全栈电子商务应用程序,后端使用 GraphQL 服务器,并在 React 中使用 Apollo Client 消耗此服务器。对于后端和前端,都有一个初始应用程序可供您快速开始。

构建时间为 3 小时。

入门

在本章中,我们将创建的项目是基于 GitHub 上可以找到的初始版本构建的:github.com/PacktPublishing/React-Projects/tree/ch7-initial。完整的源代码也可以在 GitHub 上找到:github.com/PacktPublishing/React-Projects/tree/ch7

初始项目包括一个基于 Create React App 的样板应用程序,可以让您快速开始,并且一个 GraphQL 服务器,您可以在本地运行。您可以在client目录中找到应用程序,server目录中可以找到 GraphQL 服务器。初始应用程序和 GraphQL 服务器都需要安装依赖项,并且在开发过程中需要始终运行,您可以通过在clientserver目录中运行以下命令来实现:

npm install && npm start

该命令将安装运行 React 应用程序和 GraphQL 服务器所需的所有依赖项,包括reactreact-scriptsgraphqlapollo-server。如果您想了解安装的所有依赖项,请查看clientserver目录中的package.json文件。

安装过程完成后,将启动 GraphQL 服务器和 React 应用程序。

开始使用初始 React 应用程序

由于 React 应用程序是由 Create React App 创建的,它将自动在浏览器中启动,网址是http://localhost:3000/。这个初始应用程序不显示任何数据,因为它仍然需要连接到 GraphQL 服务器,这将在本章后面进行。因此,此时应用程序将仅呈现一个标题为 Ecommerce Store 的标题和一个子标题,看起来像这样:

这个初始 React 应用程序的结构如下:

ecommerce-store
|-- client
    |-- node_modules
    |-- public
        |-- favicon.ico
        |-- index.html
        |-- manifest.json
    |-- src
        |-- components
            |-- Button
                |-- Button.js
            |-- Cart
                |-- Cart.js
                |-- CartButton.js
                |-- Totals.js
            |-- Header
                |-- Header.js
                |-- SubHeader.js
            |-- Products
                |-- ProductItem.js
                |-- Products.js
            |-- App.js
        |-- index.js
        |-- serviceWorker.js
    |-- package.json

client/src目录中,您将找到应用程序的入口点,即index.js。该文件将引用App.js中的App组件。App组件具有一个Router组件,根据用户访问的 URL,它将呈现ProductsCart组件。当未指定特定路由时,将呈现Products组件,其中包括SubHeader组件,带有指向Cart组件的Button,以及返回显示产品信息的ProductItem组件列表的map函数。/cart路由将呈现Cart组件,该组件还具有SubHeader,这次带有返回到上一页的Button。同样,将返回产品列表,并且Totals组件将显示购物车中产品的总数。

开始使用 GraphQL 服务器

虽然您不会对 GraphQL 服务器进行任何代码更改,但了解服务器的运行方式和 GraphQL 的基本概念是很重要的。

GraphQL 最好被描述为 API 的查询语言,并被定义为从 API 检索数据的约定。通常,GraphQL API 被比作 RESTful API,后者是发送 HTTP 请求的众所周知的约定,这些请求依赖于多个端点,这些端点将返回单独的数据集。与众所周知的 RESTful API 相反,GraphQL API 将提供一个单一的端点,让您查询和/或改变数据源,比如数据库。您可以通过向 GraphQL 服务器发送包含查询或变异操作的文档来查询或改变数据。无论可用的数据是什么,都可以在 GraphQL 服务器的模式中找到,该模式由定义可以查询或改变的数据的类型组成。

GraphQL 服务器可以在 server 目录中找到,并为您在本章中构建的前端 React 应用程序提供后端支持。该服务器使用 Express 和 Apollo Server 创建,其中 Express 是一个使用 JavaScript 创建 API 的框架,而 Apollo Server 是一个开源包,可以帮助您使用有限的代码创建 GraphQL 服务器。确保您已在 server 目录中运行了 npm installnpm start 命令后,GraphQL API 就可以在 http://localhost:4000/graphql 上使用。Apollo Server 默认会在端口 4000 上运行您的 GraphQL 服务器。在浏览器的这个页面上,将显示 GraphQL Playground,您可以在其中使用和探索 GraphQL 服务器。以下是该 Playground 的示例截图:

通过这个 Playground,您可以向 GraphQL 服务器发送查询和变异,您可以在本页面的左侧输入。您可以在此 GraphQL 服务器的 SCHEMA 中找到可以发送的查询和变异,点击标有 SCHEMA 的绿色按钮即可找到。该按钮将打开 SCHEMA 的概述,显示 GraphQL 服务器的所有可能返回值:

每当您在此页面的左侧描述查询或突变时,服务器返回的输出将显示在播放器的右侧。构造 GraphQL 查询的方式将决定返回数据的结构,因为 GraphQL 遵循“请求所需内容,获得确切内容”的原则。由于 GraphQL 查询始终返回可预测的结果,这意味着我们可以有这样的查询:

query {
  products {
    id
    title
    thumbnail
  }
}

这将返回一个输出,其结构将遵循您发送到 GraphQL 服务器的文档中定义的查询的相同结构,并具有以下格式:

{
  "data": {
    "products": [
      {
        "id": 16608,
        "title": "Awesome Rubber Shoes",
        "thumbnail": "http://lorempixel.com/400/400/technics"
      },
      {
        "id": 20684,
        "title": "Refined Soft Table",
        "thumbnail": "http://lorempixel.com/400/400/fashion"
      }
    ]
  }
}

使用 GraphQL 的应用程序通常快速且稳定,因为它们控制获取的数据,而不是服务器。

在下一节中,您将使用 Apollo 将 GraphQL 服务器连接到 React Web 应用程序,并从应用程序向服务器发送文档。

使用 React、Apollo 和 GraphQL 构建全栈电子商务应用程序

在本节中,您将连接 React Web 应用程序到 GraphQL 服务器。Apollo Server 用于创建一个使用动态模拟数据作为源的单个 GraphQL 端点。React 使用 Apollo Client 来消耗此端点并处理应用程序的状态管理。

将 GraphQL 添加到 React 应用程序

GraphQL 服务器已经就位,让我们继续进行从 React 应用程序向该服务器发出请求的部分。为此,您将使用 Apollo 软件包,该软件包可帮助您在应用程序和服务器之间添加一个抽象层。这样,您就不必担心自己通过例如fetch发送文档到 GraphQL 端点,而是可以直接从组件发送文档。

如前所述,您可以使用 Apollo 连接到 GraphQL 服务器;为此,将使用 Apollo Client。使用 Apollo Client,您可以建立与服务器的连接,处理查询和突变,并为从 GraphQL 服务器检索的数据启用缓存,等等。通过以下步骤将 Apollo Client 添加到您的应用程序:

  1. 要安装 Apollo Client 及其相关软件包,您需要在初始化 React 应用程序的client目录中运行以下命令:
npm install apollo-client apollo-link-http react-apollo graphql graphql-tag

这将安装不仅 Apollo Client,还将安装您在 React 应用程序中使用 Apollo Client 和 GraphQL 所需的其他依赖项:

  • apollo-link-http将与 GraphQL 服务器连接

  • react-apollo将提供您发送查询和突变以及处理数据流所需的组件。

  • graphqlgraphql-tag将处理 GraphQL 并编写查询语言

  1. 这些包应该被导入到您想要创建 Apollo Client 的文件中,在这种情况下,将是client/src/App.js
import React from 'react';
import styled, { createGlobalStyle } from 'styled-components';
import { Route, Switch } from 'react-router-dom';
import Header from './Header/Header';
import Products from './Products/Products';
import Cart from './Cart/Cart';

import ApolloClient from 'apollo-client';
import { HttpLink } from 'apollo-link-http';
import { ApolloProvider } from 'react-apollo';

const GlobalStyle = createGlobalStyle`
    ...
  1. 现在,您可以使用ApolloClient类定义client常量,并使用HttpLink与 GraphQL 服务器建立连接;因此,可以创建如下的client常量:
import React from 'react';
import styled, { createGlobalStyle } from 'styled-components';
import { Route, Switch } from 'react-router-dom';
import Header from './Header/Header';
import Products from './Products/Products';
import Cart from './Cart/Cart';

import ApolloClient from 'apollo-client';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { HttpLink } from 'apollo-link-http';
import { ApolloProvider } from 'react-apollo';

const client = () => new ApolloClient({
 link: new HttpLink({
 uri: 'http://localhost:6000',
 }),
});

const GlobalStyle = createGlobalStyle`
    ...
  1. App组件的return函数中,您需要添加ApolloProvider并将刚刚创建的client作为属性传递:
...
const App = () => (
-  <>
+  <ApolloProvider client={client}>
     <GlobalStyle />
       <AppWrapper>
       <Header />
       <Switch>
         <Route exact path='/' component={Products} />
         <Route path='/cart' component={Cart} />
       </Switch>
     </AppWrapper>
-  </>
+  </ApolloProvider>
);

export default App;

经过这些步骤,所有嵌套在ApolloProvider中的组件都可以访问此client并发送带有查询和/或突变的文档到 GraphQL 服务器。从ApolloProvider获取数据的方法类似于上下文 API 与上下文值的交互,并将在本节的下一部分中进行演示。

使用 React 发送 GraphQL 查询

react-apollo包不仅导出 Provider,还导出了从此 Provider 中消耗值的方法。这样,您可以使用添加到 Provider 的客户端轻松获取任何值。其中之一是Query,它可以帮助您发送包含查询的文档到 GraphQL 服务器,而无需使用fetch函数,例如。

由于Query组件应始终嵌套在ApolloProvider组件内,它们可以放置在已在App中呈现的任何组件中。其中之一是client/src/components/Product/Products.js中的Products组件。该组件被呈现为/路由,并应显示电子商务商店中可用的产品。

要从Products组件发送文档,请按照以下步骤进行操作,这些步骤将指导您使用react-apollo发送文档的过程:

  1. 可以使用播放器中的内省方法或server/typeDefs.js文件找到从 GraphQL 服务器获取产品的查询,并且如下所示:
query {
  products {
    id
    title
    thumbnail
  }
}

使用查询将此文档发送到 GraphQL 服务器将返回一个由产品信息对象组成的数组,默认情况下每次返回 10 个产品。结果将以 JSON 格式返回,并且每次发送请求时都会包含不同的产品,因为数据是由 GraphQL 服务器模拟的。

  1. Products组件中,您可以从react-apollo导入Query组件并为命名为getProducts的查询定义一个常量。此外,您需要从graphql-tag导入gql,以在 React 文件中使用 GraphQL 查询语言,如下所示:
import React from 'react';
import styled from 'styled-components';
import { Query } from 'react-apollo';
import gql from 'graphql-tag';
import SubHeader from '../Header/SubHeader';
import ProductItem from './ProductItem';

const GET_PRODUCTS = gql`
 query getProducts {
 products {
 id
 title
 thumbnail
 }
 }
`;

export const ProductItemsWrapper = styled.div`
    ...
  1. 导入的Query组件可以从Products返回,并根据您作为 prop 传递给它的查询处理数据获取过程。与上下文 API 一样,Query可以通过返回data变量来消耗 Provider 中的数据。您可以遍历此对象中的products字段,并通过添加Query组件返回ProductItem组件的列表:
...
const Products = ({ match, history, loading, error, products }) => {
-  const isEmpty = products.length === 0 ? 'No products available' : false;

  return (
    <>
      {history && (
        <SubHeader title='Available products' goToCart={() => history.push('/cart')} />
      )} -      {!loading && !error && !isEmpty ? (
+      <Query query={GET_PRODUCTS}>
+        {({ data }) => {
+          return (             <ProductItemsWrapper>
               {data.products && data.products.map(product => (
                 <ProductItem key={product.id} data={product} />
               ))}
             </ProductItemsWrapper> +          );
+        }}
+      </Query>
-      ) : (
-        <Alert>{loading ? 'Loading...' : error || isEmpty}</Alert>
-      )}
    </>
  );
};
...
  1. Query组件不仅会返回一个data对象,还会返回loadingerror变量。因此,您可以使用这个值而不是为loading prop 设置默认值,并在其值为true时返回加载消息。对于error变量,您也可以采用相同的方法。此外,Products prop 的默认值不再使用,可以删除:
- const Products = ({ match, history, loading, error, products }) => {
-   return (
+ const Products = ({ match, history }) => (
  <>
    {history && (
      <SubHeader title='Available products' goToCart={() => history.push('/cart')} />
    )}
    <Query query={GET_PRODUCTS}>
-       {({ data }) => {
+       {({ loading, error, data }) => {
+         if (loading || error) {
+           return <Alert>{loading ? 'Loading...' : error}</Alert>;
+         }
          return (
            <ProductItemsWrapper>
              {data.products && data.products.map(product => (
                <ProductItem key={product.id} data={product} />
              ))}
            </ProductItemsWrapper>
          );
        }}
      </Query>
  </>
);
- };

- Products.defaultProps = {
-   loading: false,
-   error: '',
-   products: [],
- }

当您的应用程序挂载并随后在ProductItem组件的列表中显示产品信息时,将向 GraphQL 服务器发送带有GET_PRODUCTS查询的文档。在添加逻辑以从 GraphQL 服务器检索产品信息之后,您的应用程序将类似于以下内容:

由于/cart路由上的Cart组件还需要从 GraphQL 服务器查询数据,因此还应该对src/components/Cart/Cart.js文件进行更改。就像我们为Products所做的那样,应该添加一个Query组件来从服务器检索数据,并且可以通过以下步骤完成:

  1. 首先导入发送查询到 GraphQL 服务器所需的依赖项,即react-apollo以获取Query组件和graphql-tag以使用 GraphQL 查询语言来定义要发送到 GraphQL 的查询。
import React from 'react';
import styled from 'styled-components';
+ import { Query } from 'react-apollo';
+ import gql from 'graphql-tag';
import SubHeader from '../Header/SubHeader';
import ProductItem from '../Products/ProductItem';
import Totals from './Totals';

const CartWrapper = styled.div`
    ...
  1. 完成后,您可以定义query,应该在文档中发送。这将检索cart的信息,包括可能在cart中的任何products
import React from 'react';
import styled from 'styled-components';
import { Query } from 'react-apollo';
import gql from 'graphql-tag';
import SubHeader from '../Header/SubHeader';
import ProductItem from '../Products/ProductItem';
import Totals from './Totals';

+ const GET_CART = gql`
+  query getCart {
+    cart {
+      total
+      products {
+        id
+        title
+        thumbnail
+      }
+    }
+  } + `; const CartWrapper = styled.div`
    ...
  1. 用以下内容替换Cart组件的现有代码,其中实现了Query组件,而Cart组件仅接收matchhistory props。因此,您需要用以下内容替换此组件的代码:
...

- const Cart = ...

+ const Cart = ({ match, history }) => (
+  <>
+    {history && (
+      <SubHeader goBack={() => history.goBack()} title='Cart' />
+    )}
+    <Query query={GET_CART}>
+      {({ loading, error, data }) => {
+        if (loading || error) {
+          return <Alert>{loading ? 'Loading...' : error}</Alert>;
+        }
+        return (
+          <CartWrapper>
+            <CartItemsWrapper>
+              {data.cart && data.cart.products.map(product => (
+                <ProductItem key={product.id} data={product} />
+              ))}
+            </CartItemsWrapper>
+            <Totals count={data.cart.total} />
+          </CartWrapper>
+        );
+      }}
+    </Query>
+  </>
+ );

export default Cart;

...
  1. 由于购物车是空的,所以现在不会显示任何产品;购物车将在下一节中填满产品。然而,让我们继续在SubHeader/路由中为购物车的按钮添加一个Query组件,以及一个占位符计数。因此,在client/src/components/Cart目录中可以创建一个名为CartButton.js的新文件。在这个文件中,一个Query组件将从一个查询中返回购物车中产品的总数。此外,我们可以通过在这个文件中添加以下代码来为Button组件添加一个值:
import React from 'react'
import { Query } from 'react-apollo';
import gql from 'graphql-tag';
import Button from '../Button/Button';

const GET_CART_TOTAL = gql`
  query getCartTotal {
    cart {
      total
    }
  }
`;

const CartButton = ({ onClick }) => (
  <Query query={GET_CART_TOTAL}>
    {({ data, loading, error }) => (
      <Button onClick={onClick}>
        {`Cart (${(loading || error) ? 0 : data && data.cart.total})`}
      </Button>
    )}
  </Query>
);

export default CartButton
  1. 这个CartButton组件替换了Button,现在在client/src/components/Header/SubHeader.js文件中显示为购物车中产品数量的占位符计数:
import React from 'react';
import styled from 'styled-components';
import Button from '../Button/Button';
+ import CartButton from '../Cart/CartButton'; ...

const SubHeader = ({ goBack, title, goToCart = false }) => (
  <SubHeaderWrapper>
    {goBack && <SubHeaderButton onClick={goBack}>{`< Go Back`}</SubHeaderButton>}
    <Title>{ title }</Title>
-    {goToCart && <SubHeaderButton onClick={goToCart}>{`Cart (0)`}</SubHeaderButton>}
+    {goToCart && <CartButton onClick={goToCart} />}
  </SubHeaderWrapper>
);

export default SubHeader;

所有显示产品或购物车信息的组件都连接到 GraphQL Client,你可以继续添加将产品添加到购物车的变异。如何将变异添加到应用程序并将文档容器变异发送到 GraphQL 服务器将在本节的最后部分中展示。

使用 Apollo Client 处理变异

数据的变异使得使用 GraphQL 更加有趣,因为当数据发生变异时,一些副作用应该被执行。例如,当用户将产品添加到购物车时,购物车的数据也应该在整个组件中更新。当你使用 Apollo Client 时,这是相当容易的,因为 Provider 以与上下文 API 相同的方式处理这个问题。

在编写第一个变异之前,应该将购物车的可执行查询的定义移动到一个常量文件中。这样,你就可以轻松地将它们导入到其他组件中以便重用,并将它们作为副作用执行。创建新的常量文件并将所有的 GraphQL 查询和变异移动到其中需要我们做出以下更改:

  1. client/src目录中,你应该创建一个名为constants.js的新文件,并将两个已经定义的查询放在这里,这些查询可以在CartCartButton组件中找到。此外,你需要导入graphql-tag,以便在新创建的文件中添加以下代码块来使用 GraphQL 查询语言:
import gql from 'graphql-tag';

export const GET_CART_TOTAL = gql`
  query getCartTotal {
    cart {
      total
    }
  }
`;

const GET_CART = gql`
  query getCart {
    cart {
      total
      products {
        id
        title
        thumbnail
      }
    }
  }
`;

export default GET_CART
  1. Cart组件中,你可以删除对GET_CART的定义,并在client/src/components/Cart/Cart.js文件中从client/src/constants.js导入该定义:
import React from 'react';
import styled from 'styled-components';
import { Query } from 'react-apollo';
- import gql from 'graphql-tag';
import SubHeader from '../Header/SubHeader';
import ProductItem from '../Products/ProductItem';
import Totals from './Totals';
+ import { GET_CART } from '../../constants';

- const GET_CART = gql`
-  query getCart {
-    cart {
-      total
-      products {
-        id
-        title
-        thumbnail
-      }
-    }
-  }
- `;

const CartWrapper = styled.div`
  ...
  1. 对于CartButton.js中的CartButton组件,您应该应用相同的更改,但这次是针对GET_CART_TOTAL查询,它也可以从constants文件中导入,并从CartButton.js文件中删除:
import React from 'react'
import { Query } from 'react-apollo';
- import gql from 'graphql-tag';
import Button from '../Button/Button';
+ import { GET_CART_TOTAL } from '../../constants';

- const GET_CART_TOTAL = gql`
-   query getCartTotal {
-    cart {
-      total
-    }
-  }
- `;

const CartButton = ({ onClick }) => (
  ...

任何与目录中的组件相关的查询或变异的新定义都应从现在开始放在这个文件中。

由于您希望用户能够将产品添加到购物车,因此可以在此文件中添加一个变异的定义。添加产品到购物车的变异如下,它需要productId参数来将产品添加到购物车。以下变异可以返回购物车的字段,就像查询一样:

mutation addToCart($productId: Int!) {
    addToCart(input: { productId: $productId }) {
        total
    }
  }

您可以通过在http://localhost:4000/graphql上可用的 GraphQL Playground 上尝试此变异来测试此变异。在这里,您需要在此页面的左上角框中添加变异。您想要包含在此变异中的productId变量必须放在此页面的左下角框中,称为查询变量。这将导致以下输出:

为了能够从您的 React 应用程序中使用此变异,您需要对一些文件进行以下更改:

  1. client/src/constants.js文件中创建一个新的导出常量,并将变异添加到其中:
import gql from 'graphql-tag';

+ export const ADD_TO_CART = gql`
+  mutation addToCart($productId: Int!) {
+    addToCart(input: { productId: $productId }) {
+        total
+    }
+  }
+ `;

export const GET_CART_TOTAL = gql`
    ...
  1. 目前,还没有按钮可以将产品添加到购物车,因此您可以在Cart目录中创建一个新文件,并将其命名为AddToCartButton.js。在这个文件中,您可以添加以下代码:
import React from 'react'
import { Mutation } from 'react-apollo';
import Button from '../Button/Button';
import { ADD_TO_CART } from '../../constants';

const AddToCartButton = ({ productId }) => (
  <Mutation mutation={ADD_TO_CART}>
    {addToCart => (
      <Button onClick={() => addToCart({ variables: { productId }})}>
        {`+ Add to cart`}
      </Button>
    )}
  </Mutation>
);

export default AddToCartButton;

这个新的AddToCartButtonproductId作为 prop,并且具有来自react-apolloMutation组件,该组件使用您在client/src/constants.js中创建的MutationMutation的输出是调用此变异的实际函数,它以包含输入的对象作为参数。单击Button组件将执行变异。

  1. 此按钮应显示在Products组件中列表中的产品旁边,其中每个产品都显示在ProductItem组件中。这意味着,您需要在'src/components/Products/ProductItem.js'中导入AddCartButton并通过以下代码传递productId prop 给它:
import React from 'react';
import styled from 'styled-components';
+ import AddToCartButton from '../Cart/AddToCartButton';

...

const ProductItem = ({ data }) => (
  <ProductItemWrapper>
    <Thumbnail src={data.thumbnail} width={200} />
    <Title>{data.title}</Title>
+   <AddToCartButton productId={data.id} />
  </ProductItemWrapper>
);

export default ProductItem;

现在,当您在浏览器中打开 React 应用程序时,将会在产品标题旁边显示一个按钮。如果您点击此按钮,变更将被发送到 GraphQL 服务器,并且产品将被添加到购物车中。但是,您不会看到显示购物车(0)的按钮在SubHeader组件中的任何变化。

  1. 要更新CartButton,您需要指定当购物车发生变更时,其他查询也应该再次执行。这可以通过在client/src/components/Cart/AddToCartButton.js中的Mutation组件上设置refetchQueries属性来完成。该属性接受一个包含有关应该请求的查询信息的对象数组。这些查询是由CartButton执行的GET_CART_TOTAL查询,以及Cart组件中的GET_CART查询。要做到这一点,请进行以下更改:
import React from 'react'
import { Mutation } from 'react-apollo';
import Button from '../Button/Button';
- import { ADD_TO_CART, GET_CART_TOTAL } from '../../constants';
+ import { GET_CART, ADD_TO_CART, GET_CART_TOTAL } from '../../constants';

const AddToCartButton = ({ productId }) => (
-  <Mutation mutation={ADD_TO_CART}>
+  <Mutation mutation={ADD_TO_CART} refetchQueries={[{ query: GET_CART }, { query: GET_CART_TOTAL }]}>
    {addToCart => (
      <Button onClick={() => addToCart({ variables: { productId }})}>
        {`+ Add to cart`}
      </Button>
    )}
  </Mutation>
);

export default AddToCartButton;

现在,每当您从此组件向 GraphQL 服务器发送文档中的变更时,GET_CARTGET_CART_TOTAL查询也将被发送。如果结果发生了变化,CartButtonCart组件将以新的输出进行渲染。

在这一部分,您已经添加了一些逻辑,通过使用 Apollo 的 GraphQL 客户端向 GraphQL 服务器发送查询和变更。这个客户端还有其他功能,比如本地状态管理,您将在下一部分学习到。

管理本地状态

您不仅可以使用 Apollo Client 来管理从 GraphQL 服务器获取的数据,还可以用它来管理本地状态。使用 Apollo,很容易将本地状态与从 GraphQL 服务器获取的数据结合起来,因为您还可以使用查询和变更来处理本地状态。

您可能希望将信息放入本地状态以便在这个电子商务商店中使用,比如应该从 GraphQL 服务器请求多少产品的数量。在本章的第一部分,您已经创建了一个带有名为limit的参数的查询,该参数定义了将返回多少产品。

要向应用程序添加本地状态,需要对 Apollo Client 的设置进行一些更改,之后还需要进行以下更改:

  1. client/src/App.js文件中,您需要分离cache常量;这样,您就可以使用writeData方法向cache添加新值。此外,您还需要向client添加本地resolverstypeDefs,这将在下一个resolverstypeDefs之后使用 GraphQL 服务器。要做到这一点,更改以下代码:
+ const cache = new InMemoryCache();

const client = new ApolloClient({
   link: new HttpLink({
     uri: 'http://localhost:4000/',
   }),
-  cache,
+  resolvers: {},
+  typeDefs: `
+    extend type Query {
+        limit: Int!
+    }
+  `,
});

+ cache.writeData({
+  data: {
+      limit: 5,
+  },
+ });

在上述代码块中,模式通过具有limit字段的Query类型进行了扩展,这意味着您可以查询client获取此值。此外,limit的初始值被写入了cache。这意味着当应用程序首次挂载时,limit的值将始终为5

  1. 让我们还将与产品相关的所有查询添加到client/src/constants.js文件中。这可以通过将以下代码添加到client/src/components/Products目录中的文件中来实现:
import gql from 'graphql-tag';

...

+ export const GET_LIMIT = gql`
+  query getLimit {
+    limit @client
+  }
+ `;

+ export const GET_PRODUCTS = gql`
+  query getProducts {
+    products {
+      id
+      title
+      thumbnail
+    }
+  }
+ `;
  1. 为了让products查询使用本地状态中的limit,必须对GET_PRODUCTS查询进行一些小改动:
...

const GET_PRODUCTS = gql`
- query getProducts { + query getProducts($limit: Int) { -   products {
+   products(limit: $limit) {
      id
      title
      thumbnail
    }
  }
`;

export default GET_PRODUCTS;

这个查询现在将使用limit变量来请求产品的数量,而不是在 GraphQL 服务器中预定义的10值。通过添加@client,Apollo Client 将知道从cache获取这个值,意味着本地状态。

  1. Products组件中,这些查询应该从constants.js文件中导入,并且应该使用react-apollo中的Query组件请求limit的值。此外,通过Query返回的limit值应在请求GET_PRODUCTS查询时发送到variables属性。因此,进行以下更改以使用更新后的查询并将变量传递给它:
import React from 'react';
import styled from 'styled-components';
import {Query} from 'react-apollo';
- import gql from 'graphql-tag';
import SubHeader from '../Header/SubHeader';
import ProductItem from './ProductItem';
+ import { GET_PRODUCTS, GET_LIMIT } from '../../constants';

- const GET_PRODUCTS = gql`
- query getProducts {
-    products {
- id
- title
-       thumbnail
-    }
- }
- `;

...

const Products = ({ match, history }) => (
  <>
    {history && (
      <SubHeader title='Available products' goToCart={() => history.push('/cart')} />
    )}
    <Query query={GET_LIMIT}>
      {({ loading, error, data }) => (
-       <Query query={GET_PRODUCTS}>
+       <Query query={GET_PRODUCTS} variables={{ limit: parseInt(data.limit) }}>
          {({ loading, error, data }) => {
            if (loading || error) {
              return <Alert>{loading ? 'Loading...' : error}</Alert>;
            }
            return (
              <ProductItemsWrapper>
                {data.products && data.products.map(product => (
                  <ProductItem key={product.id} data={product} />
                ))}
              </ProductItemsWrapper>
            );
          }}
        </Query>
      )}
    </Query>
  </>
);

export default Products;

通过之前的更改,从GET_LIMIT查询返回的值将作为变量发送到GET_PRODUCTS查询,您需要确保使用parseInt将该值转换为整数。如果您现在在浏览器中查看应用程序,将显示 5 个产品。

  1. 接下来,为了给limit设置一个初始值,这个值也可以动态设置。因此,您可以再次使用writeData方法来更新缓存。这应该从可以访问客户端的不同组件中完成。为了实现这一点,您需要在client/src/components/Products目录中的新的Filter.js文件中创建一个组件。在这个文件中,您可以放置以下代码:
import React from 'react';
import { ApolloConsumer } from 'react-apollo';

const Filters = ({ limit }) => (
  <ApolloConsumer>
      {client => (
        <>
        <label for='limit'>Number of products: </label>
        <select id='limit' value={limit} onChange={e => client.writeData({ data: { limit: e.target.value } })}>
          <option value={5}>5</option>
          <option value={10}>10</option>
          <option value={20}>20</option>
        </select>
        </>
      )}
    </ApolloConsumer>
);

export default Filters;

这个Filter组件使用ApolloConsumerApolloProvider获取客户端的值,这类似于 React 上下文 API 的工作原理。从任何嵌套在ApolloProvider中的组件中,您都可以使用react-apollo中的 Consumer 来获取客户端值。客户端将用于向缓存写入数据,并且这些数据是从选择下拉菜单的值中检索出来的。

  1. Filter组件还应该添加到Products组件中,以便实际上可以用它来更改limit的值:
import React from 'react';
import styled from 'styled-components';
import { Query } from 'react-apollo';
import SubHeader from '../Header/SubHeader';
import ProductItem from './ProductItem';
+ import Filters from './Filters';
import { GET_PRODUCTS, GET_LIMIT } from '../../constants';

...

const Products = ({ match, history }) => (
  <>
    {history && (
      <SubHeader title='Available products' goToCart={() => history.push('/cart')} />
    )}
    <Query query={GET_LIMIT}>
      {({ loading, error, data }) => (
+       <>
+         <Filters limit={parseInt(data.limit)} />
          <Query query={GET_PRODUCTS} variables={{ limit: parseInt(data.limit) }}>
            {({ loading, error, data }) => {
              if (loading || error) {
                return <Alert>{loading ? 'Loading...' : error}</Alert>;
              }
              return (
                <ProductItemsWrapper>
                  {data.products && data.products.map(product => (
                    <ProductItem key={product.id} data={product} />
                  ))}
                </ProductItemsWrapper>
              );
            }}
          </Query>
+       </>
      )}
    </Query>
  </>
);

export default Products;

由于GET_PRODUCTSQuery组件嵌套在GET_LIMITQuery组件中,每当发送GET_LIMIT查询时,此查询也将被发送。因此,当您使用选择下拉菜单更改limit时,将发送GET_PRODUCTS查询,并且显示的产品数量将发生变化。

随着这些变化,您的应用程序将使用 Apollo Client 从 GraphQL 服务器获取数据并处理本地状态管理。此外,用户现在可以过滤在您的应用程序中看到的产品数量,这将使您的应用程序看起来类似于以下内容:

在上一节中添加了将产品添加到购物车的按钮,而购物车的功能将在下一节中处理,当您向项目添加身份验证时。

使用 React 和 GraphQL 进行身份验证

当用户将产品添加到购物车时,您希望他们能够结账,但在此之前,用户应该经过身份验证,因为您想知道谁在购买产品。在 React 中处理身份验证还需要与后端进行交互,因为您需要将用户信息存储在某个地方或检查用户是否存在。

在前端应用程序中进行身份验证时,大多数情况下会使用JSON Web TokensJWTs),这是加密令牌,可以轻松地用于与后端共享用户信息。当用户成功经过身份验证时,后端将返回 JWT,并且通常,此令牌将具有到期日期。用户应经过身份验证的每个请求都应发送令牌,以便后端服务器可以确定用户是否经过身份验证并且被允许执行此操作。尽管 JWT 可以用于身份验证,因为它们是加密的,但不应向其中添加私人信息,因为令牌只应用于对用户进行身份验证。只有在发送具有正确 JWT 的文档时,才可以从服务器发送私人信息。

React Router 和身份验证

此项目的 GraphQL 服务器已经设置好处理身份验证,并且将在向其发送正确的用户信息时返回 JWT 令牌。当用户想要查看购物车时,应用程序将在本地或会话存储中查找 JWT 令牌,并将用户重定向到结账页面或登录页面。为此,应该在react-router中添加私人路由,只有在用户经过身份验证时才可用。

添加私人路由需要我们进行以下更改:

  1. client/src/components/App.js文件的Router组件中必须添加新的结账和登录页面路由,用户可以在其中进行结账或登录。为此,您必须从react-router-dom中导入已经创建的CheckoutLogin组件以及Redirect组件:
import  React  from 'react'; import  styled, { createGlobalStyle } from 'styled-components'; - import { Route, Switch } from 'react-router-dom'**;**
**+ import { Route, Switch, Redirect } from 'react-router-dom';** import  Header  from './Header/Header'; import  Products  from './Products/Products'; import  Cart  from './Cart/Cart'; + import  Login  from './Checkout/Login'; + import  Checkout  from '**./Checkout/Checkout';**

...
  1. 导入这些后,必须将路由添加到Router中的Switch,使其对用户可用:
const  App  = () => (  <ApolloProvider  client={client}>
 <GlobalStyle  />
 <AppWrapper>
 <Header  />
 <Switch>
 <Route  exact  path='/'  component={Products}  /> <Route  path='/cart'  component={Cart}  /> +       <Route  path='/checkout'  component={Checkout}  /> +       <Route  path='/login/  component={Login} **/>** </Switch> </AppWrapper>
 </ApolloProvider> ); export  default  App;
  1. 在当前情况下,用户可以在未经身份验证的情况下导航到logincheckout页面。要检查用户是否经过身份验证,可以使用Route组件的渲染属性方法。在这种方法中,您必须检查该用户的会话存储中是否存储了 JWT。目前,会话存储中没有存储令牌,因为这将在以后添加。但是您仍然可以通过添加以下函数来创建检查它的功能:
...

**+ const** isAuthenticated  =  sessionStorage.getItem('token'**);** const  cache  =  new  InMemoryCache(); const  client  =  new  ApolloClient({

  ...

有许多存储 JWT 的方法,例如使用本地存储、会话存储、cookies 或者 apollo-link-state 包中的本地状态。只要遵循 JWT 的协议,在令牌中不加密私人信息,并为其添加到期日期,所有这些地方都可以被视为存储令牌的安全位置。

  1. 之后,使用渲染 props 方法来检查结帐路由中用户是否经过身份验证。如果没有经过身份验证,用户将使用 Redirect 组件被重定向到登录页面。否则,用户将看到 Checkout 组件,该组件将接收由渲染 props 方法返回的路由 props。要实现这一点,请进行以下更改:
const  App  = () => (  <ApolloProvider  client={client}>
 <GlobalStyle  />
 <AppWrapper>
 <Header  />
 <Switch>
 <Route  exact  path='/'  component={Products}  /> <Route  path='/cart'  component={Cart}  /> -       <Route  path='/checkout'  component={Checkout}  />
+       <Route 
+         path='/checkout' 
+         render={props => 
+           isAuthenticated() 
+             ? <Checkout /> 
+             : <Redirect to='/login' />
+         } 
+       />  <Route  path='/login'  component={Login}  /> </Switch>
    <AppWrapper>
 </ApolloProvider> ); export  default  App;

当您尝试访问浏览器中的 http://localhost:3000/checkout 路由时,您将始终被重定向到 /login 路由,因为会话存储中尚未存储 JWT。在本节的下一部分中,您将添加逻辑,通过发送带有登录信息的 mutation 来从 GraphQL 服务器检索 JWT。

从 GraphQL 服务器接收 JWT

GraphQL 服务器已经设置好处理身份验证,因为我们向其发送了包含带有我们的登录信息的 mutation 的文档。当您发送正确的用户名和密码时,服务器将返回一个包含您的用户名和到期日期的 JWT。可以通过使用 react-apollo 中的 Mutation 组件或使用提供更多灵活性的 React Apollo Hooks 来向 GraphQL 服务器发送查询。登录可以从 Login 组件中完成,您可以在 client/src/components/Checkout/Login.js 文件中找到该组件,在那里需要进行以下更改以对用户进行身份验证:

  1. 用于 mutation 的 React Apollo Hook 需要一个将发送到 GraphQL 服务器的文档。这个 mutation 也可以在 client/src/constants.js 文件中定义,那里您也定义了所有其他查询和 mutation:
import gql from 'graphql-tag';

... + export  const  LOGIN_USER  =  gql`
+   mutation loginUser($userName: String!, $password: String!) {
+     loginUser(userName: $userName, password: $password) {
+       userName
+       token
+     }
+   }
+ `;
  1. client/src/components/Checkout/Login.js中的Login组件已经在使用useState Hooks 来控制userNamepassword的输入字段的值。可以从react-apollo中导入useMutation Hook,并可以使用此 Hook 来替换Mutation组件并仍具有相同的功能。此 Hook 还可以从ApolloProvider中的任何位置使用,并返回一个登录函数,该函数将文档发送到 GraphQL 服务器。通过导入 Hook 并将client/src/constants.js中的LOGIN_USER mutation 传递给它来添加此操作:
import  React  from 'react'; import  styled  from 'styled-components'; + import { useMutation } from 'react-apollo'; import  Button  from '../Button/Button'; + import { LOGIN_USER } from **'../../constants';**

... const  Login  = () => { + const [loginUser] =  useMutation(LOGIN_USER);   const [userName, setUserName] =  React.useState('');
  const [password, setPassword] =  React.useState('');

  return (

    ...

可以从react-apollo包中使用 React Apollo Hooks,但如果只想使用 Hooks,可以通过执行npm install @apollo/react-hooks安装@apollo/react-hooks来代替。GraphQL 组件,如QueryMutation,在react-apollo@apollo/react-components包中都可用。使用这些包将减少捆绑包的大小,因为您只导入所需的功能。

  1. 创建loginUser函数后,可以将其添加到ButtononClick事件中,并将userNamepassword的值作为变量传递给此函数:
return ( <LoginWrapper>
 <TextInput
 onChange={e  =>  setUserName(e.target.value)} value={userName} placeholder='Your username' /> <TextInput onChange={e  =>  setPassword(e.target.value)} value={password} placeholder='Your password' />
**-   <Button color='royalBlue'>**
**+** <Button
+ color='royalBlue'
+ onClick={() =>  loginUser({ variables: { userName, password } })}
+ **>**
 Login </Button>
 </LoginWrapper> );
  1. 单击Button将发送包含userNamepassword值的文档到 GraphQL 服务器,如果成功,它将返回此用户的 JWT。但是,此令牌还应存储在会话存储中,并且由于loginUser函数返回一个 promise,onClick事件应该成为一个异步函数。这样,您可以等待loginUser函数解析并在之后存储令牌,或者如果没有返回令牌,则发送错误消息:
...  <Button
 color='royalBlue'
**-** onClick={() =>  loginUser({ variables: { userName, password } })} + onClick={async () => { +   const { data } = await  loginUser({ +     variables: { userName, password } +   });
+ +   if (data.loginUser && data.loginUser.token) { +     sessionStorage.setItem('token', data.loginUser.token); +   } else { +     alert('Please provide (valid) authentication details'); +   } + }**}** >
 Login </Button> ...
  1. 最后,如果身份验证成功,用户应该被重定向到“结账”页面。由于“登录”组件是通过渲染 props 方法由结账路由渲染的,它从react-router接收了 props。要将用户重定向回去,可以使用来自react-routerhistoryprops 将用户推到“结账”页面:
...

- const Login = () => {
**+ const Login = ({ history }) => {**

  ...

  return (

    ...
 <Button
 color='royalBlue'
 onClick={async () => { ...        if (data.loginUser && data.loginUser.token) {
 sessionStorage.setItem('token', data.loginUser.token); +         return history.push('/checkout');        } else {
          alert('Please provide (valid) authentication details');
        }         
     ...

现在,只要会话存储中存储有令牌的用户就能访问“结账”页面。您可以通过转到浏览器的开发者工具中的应用程序选项卡,在那里,您会找到另一个名为会话存储的选项卡来从会话存储中删除令牌。

由于您希望用户能够从cart页面导航到checkout页面,您应该在Cart组件中添加一个Button,让用户可以使用react-router-dom中的Link组件进行导航。如果用户尚未经过身份验证,这将重定向用户到登录页面;否则,它将重定向他们到结账页面。此外,只有在购物车中有产品时才应显示该按钮。要添加此Button,需要在client/src/components/Cart/Cart.js中进行以下更改:

import  React  from 'react'; import  styled  from 'styled-components'; import { Query } from 'react-apollo'; + import { Link } from 'react-router-dom'; import  SubHeader  from '../Header/SubHeader'; import  ProductItem  from '../Products/ProductItem'; + import  Button  from '../Button/Button'; import  Totals  from './Totals'; import { GET_CART } from '../../constants';

... const  Cart  = ({ history }) => (

  ... return (    <CartWrapper>
      <CartItemsWrapper>
        {data.cart && data.cart.products.map(product  => (          <ProductItem  key={product.id}  data={product}  />
        ))}
      </CartItemsWrapper>
      <Totals  count={data.cart.total}  />
**+** {data.cart && data.cart.products.length > 0 && (  +       <Link  to='/checkout'> +         <Button  color='royalBlue'>Checkout</Button> +       </Link**>
+     )}**
    </CartWrapper>
  );

  ...

您现在已经添加了继续应用程序的最终结账页面的功能,这使得在向其添加产品后,您的应用程序中的/cart路由如下所示:

在本节的最后部分,您将向发送到 GraphQL 服务器的文档中添加这个令牌,该令牌将被验证以确保用户对某个操作进行了身份验证。

将 JWT 传递给 GraphQL 服务器

用户的身份验证细节以 JWT 的形式现在存储在会话存储中,结账页面的路由现在是私有的。但是为了让用户结账,这个令牌也应该被发送到 GraphQL 服务器,以及每个发送到服务器的文档,以验证用户是否真的被认证,或者令牌是否已经过期。因此,您需要扩展 Apollo Client 的设置,以便在向服务器发出请求时也发送令牌,并在前面加上Bearer,因为这是 JWT 的识别方式。

按照以下步骤将 JWT 传递给 GraphQL 服务器:

  1. 您需要安装一个 Apollo 包来处理向上下文添加值,因为您需要setContext方法来做到这一点。这个方法可以从apollo-link-Context包中获得,您可以从npm安装:
npm install apollo-link-Context
  1. Apollo Client 是在client/src/components/App.js文件中创建的,您可以从apollo-link-Context中导入setContext方法。此外,与 GraphQL 服务器的链接的创建必须解耦,因为这也应该带有身份验证细节,即token
...

import { ApolloClient } from 'apollo-client'; import { InMemoryCache } from 'apollo-cache-inmemory'; import { HttpLink } from 'apollo-link-http'; import { ApolloProvider } from 'react-apollo';
**+ import { setContext } from 'apollo-link-Context';** const  isAuthenticated  =  sessionStorage.getItem('token');

**+ const httpLink = new HttpLink({**
**+   uri: 'http://localhost:4000/graphql',**
**+ });** const  cache  =  new  InMemoryCache(); const  client  =  new  ApolloClient({
 link:  new  HttpLink({
 uri: 'http://localhost:4000/graphql',
 }), cache,
 resolvers: {

    ... 
  1. 现在,您可以使用setContext方法来扩展发送到 GraphQL 服务器的请求头,以便它也包括可以从会话存储中检索到的令牌。您从会话存储中检索到的令牌必须以Bearer为前缀,因为 GraphQL 服务器期望以这种格式接收 JWT 令牌:
... const  httpLink  =  new  HttpLink({
 uri: 'http://localhost:4000/graphql', }) + const  authLink  =  setContext((_, { headers }) => { +   const  token  =  isAuthenticated; +
+   return { +     headers: { +       ...headers, +       authorization:  token  ?  `Bearer ${token}`  : '',  +     }, +   }; **+ });** const  cache  =  new  InMemoryCache(); const  client  =  new  ApolloClient({

  ...
  1. HttpLink方法一起,必须在设置 Apollo Client 时使用authLink常量;这将确保从authLink添加到由httpLink发送的标头的上下文值:
...

const  client  =  new  ApolloClient({ - link:  new  HttpLink({ -   uri: 'http://localhost:4000/graphql', - }),
**+ l**ink:  authLink.concat(httpLink),  cache,
 resolvers: {

    ...

如果您再次在浏览器中访问应用程序,并确保已登录,方法是转到checkoutlogin页面,您会看到请求仍然发送到 GraphQL 服务器。当您打开浏览器的开发者工具并转到网络选项卡时,可以看到请求到服务器的标头信息不同。因为还发送了一个名为authorization的字段,其值看起来像 Bearer eyAABBB....

当用户转到结账页面时,应该有一个按钮来完成订单。此按钮将调用一个完成购物车的函数。由于用户必须经过身份验证才能创建订单,因此必须将令牌与发送completeCart变异的请求一起发送。此变异完成购物车并清除其内容,之后结账页面的内容会发生变化。

将此功能添加到checkout页面需要进行以下更改:

  1. completeCart变异具有以下形状,并且可以在client/constants.js中找到:
export  const  COMPLETE_CART  =  gql`
 mutation completeCart { completeCart { complete } } `;

必须导入到client/src/components/Checkout/Checkout.js文件中:

import  React  from 'react'; import  styled  from 'styled-components'; import  Button  from '../Button/Button'; + import { COMPLETE_CART } from '**../../constants';** 
... const  Checkout  = () => {
  ...
  1. 可以使用从react-apollo导入的useMutation Hook 将变异发送到 GraphQL 服务器。在Checkout组件的开头,可以使用COMPLETE_CART变异作为参数添加 Hook。 Hook 返回发送变异的函数和从变异返回的数据:
import  React  from 'react'; import  styled  from 'styled-components';
**+ import { useMutation } from 'react-apollo';** import  Button  from '../Button/Button'; import { COMPLETE_CART } from '../../constants';

... const  Checkout  = () => {
**+ [completeCart, { data }] = useMutation(COMPLETE_CART);**

  ...
  1. 必须将completeCart函数添加到Button组件作为onClick属性,以便单击按钮时将调用该函数。此外,您必须检查COMPLETE_CART变异是否返回complete字段的值,该字段指示购物车是否已完成。如果是,则结账已完成,并且可以向用户显示不同的消息:
...

const  Checkout  = () => {  const [completeCart, { data }] =  useMutation(COMPLETE_CART);
 return ( <CheckoutWrapper> +     {data && data.completeCart.complete ? ( +       <p>Completed checkout!</p> +     ) : ( **+       <>**
 <p>This is the checkout, press the button below to complete:</p> -         <Button  color='royalBlue'**>**
**+         <Button color='royalBlue' onClick={completeCart}>**
 Complete checkout </Button> +       </> +     )**}**
 </CheckoutWrapper>
 ); };

...

这结束了用户的结账流程和本章,您已经使用 React 和 GraphQL 创建了一个电子商务应用程序。

总结

在本章中,您已经创建了一个使用 GraphQL 作为后端的全栈 React 应用程序。使用 Apollo 服务器和模拟数据,创建了 GraphQL 服务器,该服务器接受查询和变异以提供数据。这个 GraphQL 服务器被一个使用 Apollo Client 的 React 应用程序使用,用于向服务器发送和接收数据以及处理本地状态管理。身份验证由 GraphQL 服务器使用 JWT 处理,在前端由 React 和react-router处理。

就是这样!您已经完成了本书的第七章,并且已经使用 React 创建了七个 Web 应用程序。到目前为止,您应该对 React 及其功能感到满意,并准备学习更多。在下一章中,您将介绍 React Native,并学习如何使用 React 技能来创建一个移动应用程序,通过使用 React Native 和 Expo 创建一个房源列表应用程序。

进一步阅读

第八章:使用 React Native 和 Expo 构建房屋列表应用程序

React 开发的一个标语是学一次,随处编写,这是由于 React Native 的存在。使用 React Native,您可以使用 JavaScript 和 React 编写原生移动应用程序,同时使用 React 的相同功能,例如状态管理。在本书中已经获取的 React 知识的基础上,您将从本章开始探索 React Native。由于 React 和 React Native 有很多相似之处,建议您在对 React 知识感到不安时再次查看一些以前的章节。

在本章中,您将使用 React Native 创建一个移动应用程序,该应用程序使用了您在之前章节中看到的相同语法和模式。您将设置基本路由,探索 iOS 和 Android 开发之间的差异,并学习如何使用styled-components对 React Native 组件进行样式设置。此外,将使用名为Expo的工具链来运行和部署您的 React Native 应用程序。

本章将涵盖以下主题:

  • 创建 React Native 项目

  • 移动应用程序的路由

  • React Native 中的生命周期

  • 在 React Native 中设置组件样式

项目概述

在本章中,我们将创建一个房屋列表应用程序,显示可用房屋的概述,并使用styled-components进行样式设置和React Navigation进行路由。数据是从模拟 API 中获取的。

构建时间为 1.5 小时。

入门

确保您已在 iOS 或 Android 设备上安装了 Expo 客户端应用程序,以便能够运行您在本章中创建的应用程序。Expo 客户端可在 Apple 应用商店和 Google Play 商店中下载。

一旦您下载了应用程序,您需要创建一个 Expo 账户,以使开发过程更加顺利。确保将您的账户详细信息存储在安全的地方,因为您稍后在本章中会需要这些信息。不要忘记通过点击您收到的电子邮件中的链接来验证您的电子邮件地址。

本章的完整代码可以在 GitHub 上找到:github.com/PacktPublishing/React-Projects/tree/ch8.

此应用程序是使用 Expo SDK 版本 33.0.0 创建的,因此您需要确保您在本地计算机上使用的 Expo 版本相似。由于 React Native 和 Expo 经常更新,请确保您使用此版本,以确保本章描述的模式表现如预期。如果您的应用程序无法启动或收到错误消息,请务必查看 Expo 文档,以了解有关更新 Expo SDK 的更多信息。

使用 React Native 和 Expo 构建房源列表应用程序

在本节中,您将使用 React Native 和 Expo 构建一个房源列表应用程序,这使您可以使用与 React 相同的语法和模式,因为它使用了 React 库。此外,Expo 使得无需安装和配置 Xcode(用于 iOS)或 Android Studio 即可开始在您的计算机上创建原生应用程序成为可能。因此,您可以从任何计算机上为 iOS 和 Android 平台编写应用程序。

您还可以使用 Expo web 在浏览器中运行 React Native 应用程序,以创建渐进式 Web 应用程序(PWA)。但是,同时为 iOS、Android 和 Web 开发仍处于实验阶段,可能需要大量性能和架构修复。此外,并非所有在移动设备上的 React Native 中工作的包也会在 Expo web 上工作。

Expo 将 React API 和 JavaScript API 与 React Native 开发流程结合在一起,以便允许诸如 JSX 组件、Hooks 和原生功能(如相机访问)等功能。大致上,Expo 工具链由多个工具组成,这些工具可以帮助您进行 React Native 开发,例如 Expo CLI,它允许您从终端创建 React Native 项目,并提供运行 React Native 所需的所有依赖项。使用 Expo 客户端,您可以从连接到本地网络的 iOS 和 Android 移动设备上打开这些项目。Expo SDK 是一个包,其中包含了使您的应用能够在多个设备和平台上运行的所有库。

创建 React Native 项目

在本书中,每个新的 React 项目的起点都是使用 Create React App 为您的应用程序创建一个样板。对于 React Native,有一个类似的样板可用,它是 Expo CLI 的一部分,并且可以像这样轻松设置:

您需要使用以下命令使用npm全局安装 Expo CLI:

npm install -g expo-cli

这将启动安装过程,这可能需要一些时间,因为它将安装帮助您开发移动应用程序的所有依赖项的 Expo CLI。之后,您可以使用 Expo CLI 的init命令创建新项目:

expo init house-listing

Expo 现在将为您创建项目,但首先会要求您回答以下问题:

  1. 它会询问您是否要创建一个空白模板,带有 TypeScript 配置的空白模板,或者带有一些示例屏幕设置的示例模板。在本章中,您需要选择第一个选项:空白(expo-template-blank)。

  2. 选择模板后,您需要输入应用程序的名称,在这种情况下是房源列表。此名称将添加到app.json文件中,其中包含有关您的应用程序的配置信息。

  3. Expo 会自动检测您的计算机上是否安装了 Yarn。如果安装了 Yarn,它将要求您使用 Yarn 安装其他必要的依赖项来设置您的计算机。如果安装了 Yarn,请选择“是”;否则,默认情况下将使用 npm。在本章中,建议使用 npm 而不是 Yarn,以便与之前的章节保持一致。

现在,您的应用程序将使用您选择的设置创建。可以通过以下命令进入 Expo 刚刚创建的目录来启动此应用程序:

cd house-listing
npm start

这将启动 Expo,并使您能够从终端或浏览器启动项目,从而可以在移动设备上运行应用程序,或者使用 iOS 或 Android 模拟器。在终端中,有多种方法可以打开应用程序:

  • 使用 Android 或 iOS 上 Expo Client 的用户名登录。您的项目将自动显示在移动设备的“项目”选项卡中。

  • 使用运行在 Android 或 iOS 上的移动设备扫描显示的 QR 码。如果您使用的是 Android 设备,可以直接从 Expo Client 应用程序扫描 QR 码。在 iOS 上,您需要使用相机扫描该代码,然后会要求您打开 Expo Client。

  • 按下a键打开 Android 模拟器,或按下i键打开 iOS 模拟器。请记住,您需要安装 Xcode 和/或 Android Studio 才能使用其中一个模拟器。

  • 通过按下e键将链接发送到您的电子邮件,这个链接可以从安装有 Expo Client 应用程序的移动设备上打开。

另外,运行npm start命令会在http://localhost:19002/URL 上打开你的浏览器,显示 Expo 开发者工具。这个页面看起来会像这样,假设你安装了在入门部分提到的 Expo SDK 的版本:

在这个页面上,你可以看到左边有一个侧边栏,右边是你的 React Native 应用的日志。这个侧边栏包含按钮,让你可以启动 iOS 或 Android 模拟器,你需要安装 Xcode 或 Android Studio。另外,你也可以找到一个按钮,通过邮件发送一个链接或者使用之前安装的 Expo 应用在你的移动设备上生成一个 QR 码来打开应用。

在这一点上,你的应用应该看起来如下。这个截图是从一个 iOS 设备上拍摄的。无论你是使用 iOS 或 Android 模拟器打开应用,还是从 iOS 或 Android 设备上打开应用,都不应该有影响:

这个应用是使用Expo SDK 版本 33.0.0创建的,所以你需要确保你本地机器上使用的 Expo 版本是相似的。由于 React Native 和 Expo 经常更新,确保你使用这个版本来确保本章描述的模式表现如预期。如果你的应用无法启动或者收到错误,确保查看 Expo 文档以了解更多关于更新 Expo SDK 的信息。

这个 React Native 应用的项目结构与我们之前在前几章创建的 React 项目非常相似,我们是用 Expo 创建的。它看起来如下:

house-listing
|-- .expo
|-- assets
    |-- icon.png
    |-- splash.png
|-- node_modules
.gitignore
App.js
app.json
babel.config.js
package.json

assets目录中,你可以找到用于应用主屏幕图标的图片,一旦你在移动设备上安装了这个应用,以及用作启动画面的图片,当你启动应用时会显示。App.js文件是你应用的实际入口点,在这里你会返回当应用挂载时将被渲染的组件。应用的配置,例如 App Store 的配置,被放置在app.json中,而babel.config.js包含特定的 Babel 配置。

在 React Native 中设置路由

正如我们之前提到的,App.js文件是您的应用程序的入口点,由 Expo 定义。如果您打开这个文件,您会看到它由组件组成,并且StyleSheet直接从react-native导入。在 React Native 中编写样式的语法与在浏览器中使用的 React 不同,因此您将不得不在本章后面安装styled-components

使用 React Navigation 创建路由

让我们继续安装 React Navigation。在 React Native 中有许多可用的包来帮助您处理路由,但这是 Expo 推荐使用的最受欢迎的包之一。除了 React Navigation,您还必须安装相关的包,称为react-navigation-stackreact-navigation-tabs,这些包需要为您的应用程序创建导航器。可以通过运行以下命令来安装 React Navigation 及其依赖项:

npm install react-navigation react-navigation-stack react-navigation-tabs

要向您的 React Native 应用程序添加路由,您需要了解在浏览器和移动应用程序中的路由之间的区别。在 React Native 中,历史记录的行为方式与在浏览器中不同,在浏览器中,用户可以通过更改浏览器中的 URL 导航到不同的页面,并且先前访问的 URL 将被添加到浏览器历史记录中。相反,您需要自己跟踪页面之间的转换并在应用程序中存储本地历史记录。

使用 React Navigation,您可以使用多个不同的导航器来帮助您实现这一点,包括堆栈导航器和选项卡导航器。堆栈导航器的行为方式与浏览器非常相似,因为它在页面之间进行转换后堆叠页面,并允许您使用 iOS 和 Android 的本机手势和动画进行导航:

  1. 您可以通过将包含路由配置的对象传递给createStackNavigator方法来设置堆栈导航器,该方法可以从react-navigation-stackApp.js文件中导入。此外,您还需要从react-navigation导入createAppContainer,它可以帮助您返回一个包装所有路由的组件:
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
+ import { createAppContainer } from 'react-navigation';
+ import { createStackNavigator } from 'react-navigation-stack';

export default function App() {
    ...
  1. 您需要返回使用createStackNavigator创建的组件,而不是返回一个名为App的组件,该组件保存了应用程序的所有路由。这个StackNavigator组件需要使用createAppContainer导出,如下所示:
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { createAppContainer } from 'react-navigation';
import { createStackNavigator } from 'react-navigation-stack';

- export default function App() {
- return (
+ const Home = () => (
    <View style={styles.container}>
        <Text>Open up App.js to start working on your app!</Text>
    </View>
  );
- } const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
}); + const StackNavigator = createStackNavigator({
+  Home: {
+    screen: Home,
+  },
+ });

+ export default createAppContainer(StackNavigator);
  1. 您的应用程序现在有一个名为Home的路由,并呈现Home组件。您还可以通过在传递给createStackNavigator的对象中设置navigationOptions字段来为此屏幕添加title,如下所示:
...

const AppNavigator = createStackNavigator({
  Home: {
    screen: Home,
+   navigationOptions: { title: 'Home' },
  },
});

export default createAppContainer(AppNavigator);
  1. 要创建另一个路由,您可以通过添加Detail组件并添加呈现此组件的路由来复制此过程:
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { createAppContainer } from 'react-navigation';
import { createStackNavigator } from 'react-navigation-stack';

const Home = () => (
  <View style={styles.container}>
    <Text>Open up App.js to start working on your app!</Text>
  </View>
);

+ const Detail = () => (
+  <View style={styles.container}>
+    <Text>Open up App.js to start working on your app!</Text>
+  </View>
+ );

...

const AppNavigator = createStackNavigator({
  Home: {
    screen: Home,
    navigationOptions: { title: 'Home' },
  },
+ Detail: {
+   screen: Detail,
+   navigationOptions: { title: 'Detail' },
+ },
});

export default createAppContainer(AppNavigator);
  1. 现在您的应用程序中有两个屏幕,您还需要设置一个默认路由,该路由在应用程序首次挂载时将呈现。您可以通过使用以下代码扩展传递给createStackNavigator的路由配置对象来执行此操作:
...

const AppNavigator = createStackNavigator({
  Home: {
    screen: Home,
    navigationOptions: { title: 'Home' },
  },
  Detail: {
    screen: Detail,
    navigationOptions: { title: 'Detail' },
  },
+ }, { initialRouteName: 'Home' });
- });

export default createAppContainer(AppNavigator);

您可以通过将initialRouteName的值更改为Detail,并检查应用程序中呈现的屏幕是否具有标题Detail,来看到Detail路由也正在呈现。

在本节的下一部分中,您将学习如何在此导航器创建的不同屏幕之间进行过渡。

在屏幕之间过渡

在 React Native 中,在屏幕之间过渡也与在浏览器中有些不同,因为再次,没有 URL。相反,您需要使用navigation属性,该属性可从堆栈导航器呈现的组件中获取。navigation属性可用于通过进行以下更改来处理路由:

  1. 您可以从HomeDetail组件中访问此示例中的navigation属性:
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { createAppContainer } from 'react-navigation';
import { createStackNavigator } from 'react-navigation-stack';

- const Home = () => (
+ const Home = ({ navigation }) => (
  <View style={styles.container}>
    <Text>Open up App.js to start working on your app!</Text>
  </View>
);

...
  1. navigation属性包含多个值,包括navigate函数,该函数以路由名称作为参数。您可以将此函数用作事件,例如,您可以从react-native导入的Button组件上调用onPress事件处理程序来单击按钮。与您在 React 中习惯的方式相比,您可以通过调用onPress事件处理程序而不是onClick来单击按钮。此外,Button组件不接受子元素作为属性,而是接受title属性。要做到这一点,请更改以下代码:
import React from 'react';
- import { StyleSheet, Text, View } from 'react-native';
+ import { Button, StyleSheet, Text, View } from 'react-native';
import { createAppContainer } from 'react-navigation';
import { createStackNavigator } from 'react-navigation-stack';

const Home = ({ navigation }) => (
  <View style={styles.container}>
    <Text>Open up App.js to start working on your app!</Text>
+   <Button onPress={() => navigation.navigate('Detail')} title='Go to Detail' />
  </View>
);

...
  1. 当您按下标题为转到详细信息的按钮时,您将转到Detail屏幕。此屏幕的标题栏还将呈现一个返回按钮,当您按下它时,将返回到Home屏幕。您还可以使用navigation属性中的goBack函数创建自定义返回按钮,如下所示:
...

- const Detail = () => (
+ const Detail = ({ navigation }) => (
  <View style={styles.container}>
    <Text>Open up App.js to start working on your app!</Text>
+    <Button onPress={() => navigation.goBack()} title='Go to back to Home' />
  </View>
);

...

通常,将这些组件存储在不同的目录中,并且只使用App.js文件可以使您的应用程序更易读。为了实现这一点,您需要在应用程序的根目录中创建一个名为Screens的新目录,在其中需要为您刚刚创建的两个屏幕中的每一个添加一个文件。让我们学习如何做到这一点:

  1. Screens目录中创建一个名为Home.js的文件,并将Home组件添加到该文件中,包括所使用模块的导入。Home组件的代码如下:
import React from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';

const Home = ({ navigation }) => (
  <View style={styles.container}>
    <Text>Open up App.js to start working on your app!</Text>
    <Button onPress={() => navigation.navigate('Detail')} title='Go to Detail' />
  </View>
);

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
});

export default Home;
  1. 您需要为Detail屏幕做同样的事情,方法是创建Screens/Detail.js文件,并将Detail组件和所使用的模块的代码添加到该文件中。您可以通过向该新文件添加以下代码块来实现这一点:
import React from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';

const Detail = ({ navigation }) => (
  <View style={styles.container}>
    <Text>Open up App.js to start working on your app!</Text>
    <Button onPress={() => navigation.goBack()} title='Go to back to Home' />
  </View>
);

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
});

export default Detail;
  1. App.js文件中,您需要导入HomeDetail组件,并删除先前创建这两个组件的代码块,如下所示:
import React from 'react';
- import { Button, StyleSheet, Text, View } from 'react-native';
import { createAppContainer } from 'react-navigation';
import { createStackNavigator } from 'react-navigation-stack'; + import Home from './Screens/Home';
+ import Detail from './Screens/Detail';

- const Home = ({ navigation }) => (
-   <View style={styles.container}>
-     <Text>Open up App.js to start working on your app!</Text>
-     <Button onPress={() => navigation.navigate('Detail')} title='Go to Detail' />
-   </View>
- );

- const Detail = ({ navigation }) => (
-   <View style={styles.container}>
-     <Text>Open up App.js to start working on your app!</Text>
-     <Button onPress={() => navigation.goBack()} title='Go to back to Home' />
-   </View>
- );

- const styles = StyleSheet.create({
-  container: {
-   flex: 1,
-   backgroundColor: '#fff',
-   alignItems: 'center',
-   justifyContent: 'center',
-  },
- });

const AppNavigator = createStackNavigator({
  Home: {
    screen: Home,
    navigationOptions: { title: 'Home' },
  },
  Detail: {
    screen: Detail,
    navigationOptions: { title: 'Detail' },
  },
}, { initialRouteName: 'Home' });

export default createAppContainer(AppNavigator);

您的应用程序只使用App.js文件来创建路由并设置堆栈导航器。许多应用程序在彼此旁边使用多种类型的导航器,这将在本节的下一部分中展示。

将多个导航器一起使用

对于更复杂的应用程序,您不希望所有的路由都堆叠在一起;您只希望为彼此相关的路由创建这些堆栈。幸运的是,您可以在 React Navigation 中同时使用不同类型的导航器。可以通过以下方式使用多个导航器来完成应用程序:

  1. 在移动应用程序中导航的最常见方式之一是使用选项卡;React Navigation 也可以为您创建选项卡导航器。因此,您需要将一个路由对象传递给createBottomTabNavigator方法,您可以使用以下代码从react-navigation-tabs导入它:
import React from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import { createAppContainer } from 'react-navigation';
import { createStackNavigator } from 'react-navigation-stack';
+ import { createBottomTabNavigator } from 'react-navigation-tabs'; 
import Home from './Screens/Home';
import Detail from './Screens/Detail';

...
  1. 假设您希望“主页”屏幕和相邻的“详细”屏幕在同一个选项卡上可用-您需要为这些屏幕重命名堆栈导航器。这个堆栈导航器应该被添加到传递给createBottomTabNavigator的路由对象中,该对象创建了选项卡导航器。加载的初始路由声明现在也与选项卡导航器相关联:
import React from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import { createAppContainer } from 'react-navigation'; 
import { createStackNavigator } from 'react-navigation-stack';
import { createBottomTabNavigator } from 'react-navigation-tabs';
import Home from './Screens/Home';
import Detail from './Screens/Detail';

- const AppNavigator = createStackNavigator({
+ const HomeStack = createStackNavigator({
    Home: {
      screen: Home,
      navigationOptions: { title: 'Home' },
    },
    Detail: {
      screen: Detail,
      navigationOptions: { title: 'Detail' },
    },
-  }, { initialRouteName: 'Home' });
+ });

+ const AppNavigator = createBottomTabNavigator({
+  Home: HomeStack
+ }, { initialRouteName: 'Home' });

export default createAppContainer(AppNavigator);

您应用程序的主要导航现在是选项卡导航器,只有一个名为Home的选项卡。此选项卡将呈现包含HomeDetail路由的堆栈导航器,这意味着您仍然可以在不离开Home选项卡的情况下导航到Detail屏幕。

  1. 您可以轻松地向选项卡导航器添加另一个选项卡,该选项卡将呈现组件或另一个堆栈导航器。让我们创建一个名为Settings的新屏幕,首先需要在Screens/Settings.js文件中创建一个新组件:
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';

const Settings = ({ navigation }) => (
  <View style={styles.container}>
    <Text>Open up App.js to start working on your app!</Text>
  </View>
);

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
});

export default Settings;
  1. App.js中导入此组件,以将新的Screens路由添加到选项卡导航器。在您进行这些更改后,此屏幕将呈现Settings组件:
import React from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import { createAppContainer } from 'react-navigation';
import { createStackNavigator } from 'react-navigation-stack';
import { createBottomTabNavigator } from 'react-navigation-tabs';
import Home from './Screens/Home';
import Detail from './Screens/Detail';
+ import Settings from './Screens/Settings';

...

const AppNavigator = createBottomTabNavigator({
   Home: HomeStack,
+  Settings,
}, { initialRouteName: 'Home' });

export default createAppContainer(AppNavigator);
  1. 您的应用程序现在有一个名为Settings的选项卡,它将呈现Settings组件。但是,例如此屏幕的title是不可能自定义的。因此,您需要使用以下代码创建另一个只有Settings路由的堆栈导航器:
...

+ const SettingsStack = createStackNavigator({
+  Settings: {
+    screen: Settings,
+    navigationOptions: { title: 'Settings' },
+  },
+ });

const AppNavigator = createBottomTabNavigator({
   Home: HomeStack,
-  Settings,
+  Settings: SettingsStack,
}, { initialRouteName: 'Home' });

export default createAppContainer(AppNavigator);

您现在已经在应用程序中添加了堆栈导航器和选项卡导航器,这使您可以同时在屏幕和选项卡之间导航。如果您正在使用 iOS 模拟器或运行 iOS 的设备上运行应用程序,它将看起来完全像以下屏幕截图。对于 Android,在这一点上,应用程序应该看起来非常相似:

在下一节中,您将从模拟 API 加载数据,并使用 React 生命周期在不同的屏幕中加载这些数据。

在 React Native 中使用生命周期

在开始为 React Native 组件添加样式之前,您需要在应用程序中获取一些数据,这些数据将由这些组件显示。因此,您需要使用生命周期来检索这些数据并将其添加到应用程序的本地状态中。

要获取数据,您将再次使用fetch API,并结合useStateuseEffect Hooks 在生命周期内检索这些数据。一旦从模拟 API 中获取了数据,它可以在 React Native 的FlatList组件中显示。可以通过以下方式使用 Hooks 向 React Native 应用程序添加生命周期方法:

  1. 您将使用useState Hook 来设置加载指示器、错误消息和显示数据的常量,其中loading常量应最初为 true,error常量应为空,data常量应为空数组:
...

- const Home = ({ navigation }) => (
+ const Home = ({ navigation }) => {
+  const [loading, setLoading] = React.useState(true);
+  const [error, setError] = React.useState('');
+  const [data, setData] = React.useState([]);

+  return (
    <View style={styles.container}>
      <Text>Open up App.js to start working on your app!</Text>
      <Button onPress={() => navigation.navigate('Detail')} title='Go to Detail' />
    </View>
   )
+ };
  1. 接下来,您需要创建一个异步函数,从模拟 API 中检索数据,并从应用程序挂载时调用useEffect Hook。当 API 请求成功时,fetchAPI函数将更改loadingerrordata的两个常量。如果不成功,错误消息将被添加到error常量中。
...
const Home = ({ navigation }) => {
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState('');
  const [data, setData] = React.useState([]);

+  const fetchAPI = async () => {
+    try {
+      const data = await fetch('https://my-json-server.typicode.com/PacktPublishing/React-Projects/listings');
+      const dataJSON = await data.json();

+      if (dataJSON) {
+        setData(dataJSON);
+        setLoading(false);
+      }
+    } catch(error) {
+      setLoading(false);
+      setError(error.message);
+    }
+  };

+  React.useEffect(() => {
+    fetchAPI();
+  }, []);

  return (
    ...
  1. 现在,这个数据常量可以作为FlatList组件的一个 prop 添加,它会遍历数据并渲染显示这些数据的组件。FlatList返回一个包含名为item的字段的对象,其中包含每次迭代的数据,如下所示:
import React from 'react';
- import { Button, StyleSheet, Text, View } from 'react-native';
+ import { FlatList, StyleSheet, Text, View } from 'react-native';

const Home = ({ navigation }) => {

  ...

  return (
    <View style={styles.container}>
-     <Text>Open up App.js to start working on your app!</Text>
-     <Button onPress={() => navigation.navigate('Detail')} title='Go to Detail' />
+     {!loading && !error && <FlatList
+       data={data}
+       renderItem={({item}) => <Text>{item.title}</Text>}
+     />}
    </View>
  )
};

...
  1. 就像我们在 React 中可以做的那样,当使用mapforEach函数时,您需要在每个迭代的组件上指定一个key属性。FlatList会自动查找data对象中的key字段,但如果您没有特定的key字段,您需要使用keyExtractor属性来设置它。重要的是要知道,用于键的值应该是一个字符串,因此您需要将模拟 API 返回的id字段转换为字符串:
  ...

  return (
    <View style={styles.container}>
     {!loading && !error && <FlatList
       data={data}
+      keyExtractor={item => String(item.id)}
       renderItem={({item}) => <Text>{item.title}</Text>}
     />}
    </View>
  );
};

...

现在,您的应用程序将显示来自模拟 API 的房源标题列表,而无需路由到特定的列表或样式。这将使您的应用程序看起来如下,Android 和 iOS 之间的差异应该是有限的,因为我们尚未向应用程序添加任何重要的样式:

要再次将导航添加到Detail路由,您需要从FlatList返回一个支持onPress事件的组件。例如,您之前使用的Button组件和TouchableOpacity组件。这个最后一个组件可以用作View组件的替代品,它不支持onPress事件。在这里创建导航是通过进行以下更改完成的:

  1. 您需要从react-native中导入TouchableOpacity组件,并用这个组件包装FlatList返回的Text组件。onPress事件将从navigation属性调用navigate函数,并导航到Detail路由,如果我们更改以下代码:
import React from 'react';
- import { FlatList, View, Text } from 'react-native';
+ import { FlatList, View, Text, TouchableOpacity } from 'react-native';

const Home = ({ navigation }) => {
  ...

  return (
    <View style={styles.container>
      {!loading && !error && <FlatList
        data={data}
        keyExtractor={item => String(item.id)}
-       renderItem={({item}) => <Text>{item.text}</Text>}
+       renderItem={({item}) => (
+         <TouchableOpacity onPress={() => navigation.navigate('Detail')}>
+           <Text>{item.title}</Text>
+         </TouchableOpacity>
+       )}
      />}
    </View>
  );
};

...
  1. 当您单击应用程序中显示的任何标题时,您将导航到“详细”路由。但是,您希望此屏幕显示您刚刚按下的项目。因此,一旦按下TouchableOpacity组件,您将需要向此路由传递参数。为此,您需要将这些参数作为对象传递给navigate函数:
  ...

  return (
    <View style={styles.container>
      {!loading && !error && <FlatList
        data={data}
        keyExtractor={item => String(item.id)}
        renderItem={({item}) => (
-         <TouchableOpacity onPress={() => navigation.navigate('Detail')}>
+         <TouchableOpacity onPress={() => navigation.navigate('Detail', { item })}>
           <Text>{item.title}</Text>
         </TouchableOpacity>
       )}
      />}
    </View>
  );
};

...
  1. 从由“详细”路由呈现的组件中,您可以从navigation属性中获取此参数对象,并使用它来显示该项目。要从navigation属性获取参数,您可以使用getParam函数,其中您需要指定要获取的参数的名称和此参数的回退值。就像我们为“主页”路由所做的那样,您可以显示列表的title,在这种情况下应该是来自item参数的title
import React from 'react';
- import { Button, StyleSheet, Text, View } from 'react-native';
+ import { StyleSheet, Text, View } from 'react-native';

- const Detail = ({ navigation }) => (
+ const Detail = ({ navigation }) => {
+   const item = navigation.getParam('item', {})

+   return (
      <View style={styles.container}>
-       <Text>Open up - App.js to start working on your app!</Text>
-       <Button onPress={() => navigation.goBack()} title='Go to back to Home' />
+       <Text>{item.title}</Text>
      </View>
    );
+ };

...

export default Detail;

不要传递包含所点击项目数据的整个对象,而是只需发送项目的 ID。这样,您可以获取模拟 API 以获取此列表的数据,并在“详细”路由上显示它。要获取单个列表,您需要发送请求到'listings/:id'路由。

您现在可以查看来自模拟 API 的所有列表和来自此 API 的特定列表。下一节将使用styled-components添加样式。

样式化 React Native 应用程序

到目前为止,在此应用程序中用于样式化 React Native 组件的语法看起来与您已经使用的有些不同。因此,您可以安装styled-components以使用您已经熟悉的样式编写语法。要安装此内容,您需要运行以下命令:

npm install styled-components

这将安装styled-components包,之后您可以继续为应用程序中已经存在的组件创建样式:

  1. 让我们从将Screens/Home.js文件中的ViewFlatList组件转换为styled-components开始。为此,您需要从styled-components/native中导入styled,因为您只想导入包的特定本机部分:
import React from 'react';
- import { FlatList, StyleSheet, Text, View, TouchableOpacity } from 'react-native';
+ import { FlatList, Text, View, TouchableOpacity } from 'react-native';
+ import styled from 'styled-components/native'; 
const Home = ({ navigation }) => {
  ...
  1. 文件底部的StyleSheet创建了View组件的样式,应该将其转换为使用styled-components样式的组件。正如我们在前几章中看到的那样,您也可以扩展现有组件的样式。大多数样式规则可以复制并更改为styled-components的语法,如下代码块所示:
... + const ListingsWrapper = styled(View)`
+  flex: 1;
+  background-color: #fff;
+  align-items: center;
+  justify-content: center;
+ `

- const styles = StyleSheet.create({
-   container: {
-     flex: 1,
-     backgroundColor: '#fff',
-     alignItems: 'center',
-     justifyContent: 'center',
-   },
- }); 
const Home = ({ navigation }) => {
  ...
  return (
-    <View style={styles.container}>
+    <ListingsWrapper>
      {!loading && !error && <FlatList
        data={data}
        keyExtractor={item => String(item.id)}
        renderItem={({item}) => (
          <TouchableOpacity onPress={() => navigation.navigate('Detail', { item })}>
            <Text>{item.title}</Text>
          </TouchableOpacity>
        )}
      />}
+    </ListingsWrapper>
-    </View>
  );
};

export default Home;
  1. FlatList组件也可以做同样的事情,即通过使用styled-components中的styled来扩展此组件的样式,并设置自定义样式规则,如下所示:
...

const ListingsWrapper = styled(View)`
  flex: 1;
  background-color: #fff;
  align-items: center;
  justify-content: center;
`

+ const Listings = styled(FlatList)`
+  width: 100%;
+  padding: 5%;
+ `; 
const Home = ({ navigation }) => {
  ...
  return (
    <ListingsWrapper>
-     {!loading && !error && <FlatList
+     {!loading && !error && <Listings
        data={data}
        keyExtractor={item => String(item.id)}
        renderItem={({item}) => (
          <TouchableOpacity onPress={() => navigation.navigate('Detail', { item })}>
            <Text>{item.title}</Text>
          </TouchableOpacity>
        )}
      />}
    </ListingsWrapper>
  );
};

export default Home;
  1. FlatList目前只返回一个带有titleText组件,而可以显示更多数据。为了做到这一点,您需要创建一个新的组件,该组件返回包含来自模拟 API 的列表数据的多个组件。您可以在一个名为Components的新目录中完成这个操作,该目录包含另一个名为Listing的目录。在这个目录中,您需要创建ListingItem.js文件,并将以下代码块放入其中:
import React from 'react';
import styled from 'styled-components/native';
import { Image, Text, View, TouchableOpacity } from 'react-native';

const ListingItemWrapper = styled(TouchableOpacity)`
 display: flex;
 flex-direction: row;
 padding: 2%;
 background-color: #eee;
 border-radius: 5px;
 margin-bottom: 5%;
`;

export const Title = styled(Text)`
 flex-wrap: wrap;
 width: 99%;
 font-size: 20px;
`

export const Price = styled(Text)`
 font-weight: bold;
 font-size: 20px;
 color: blue;
`

const Thumbnail = styled(Image)`
 border-radius: 5px;
 margin-right: 4%;
 height: 200px;
 width: 200px;
`

const ListingItem = ({ item, navigation }) => (
 <ListingItemWrapper onPress={() => navigation.navigate('Detail', { item })}>
   <Thumbnail
     source={{uri: item.thumbnail}}
   />
   <View>
     <Title>{item.title}</Title>
     <Price>{item.price}</Price>
   </View>
 </ListingItemWrapper>
);

export default ListingItem;

在这个代码块中,您从styled-components/native中导入styled和您想要样式化的 React Native 组件。文件底部导出的ListingItem组件接受一个item和一个navigation属性,以在创建的组件中显示这些数据并处理导航。就像我们在样式化的Image组件中看到的那样,source属性被赋予一个对象,以显示来自模拟 API 的缩略图。

  1. 现在,应该将这个ListingItem组件导入到Screens/Home.js中,FlatList将使用它来显示列表。这个组件接受itemnavigation作为属性,如下所示:
import React from 'react';
- import { FlatList, View, Text, TouchableOpacity } from 'react-native';
+ import { FlatList, View } from 'react-native';
import styled from 'styled-components/native';
+ import ListingItem from '../Components/Listing/ListingItem'

...
const Home = ({ navigation }) => {
  ...

  return (
    <ListingsWrapper>
      {!loading && !error && <Listings
        data={data}
        keyExtractor={item => String(item.id)}
-       renderItem={({item}) => (
-         <TouchableOpacity onPress={() => navigation.navigate('Detail', { item })}>
-           <Text>{item.title}</Text>
-         </TouchableOpacity>
-       )}
+       renderItem={({item}) => <ListingItem item={item} />}
      />}
    </ListingsWrapper>
  );
};

export default Home;

在 React Native 中,样式规则是针对组件的,这意味着Text组件只能接受由 React Native 为该组件指定的样式规则。当您尝试添加不受支持的样式规则时,您将收到一个错误和该组件的所有可能的样式规则的列表。请注意,styled-components会自动为您重命名样式规则,以匹配 React Native 中的样式语法。

经过这些更改,您将向应用程序添加了第一个styled-components。当您使用 iOS 模拟器或运行 iOS 的设备时,您的应用程序应该如下所示:

到目前为止,由于我们尚未向应用程序添加任何特定于平台的样式,因此 iOS 和 Android 上的样式应该看起来相似。这将在本节的下一部分中完成,在该部分中,您将探索根据应用程序运行的平台而不同的多种添加样式的方法。

iOS 和 Android 的样式差异

在设计应用程序时,您可能希望为 iOS 和 Android 设置不同的样式规则,例如,以更好地匹配 Android 操作系统的样式。有多种方法可以将不同的样式规则应用于不同的平台;其中一种方法是使用Platform模块,该模块可以从 React Native 中导入。

让我们尝试通过向navigator选项卡中的选项卡添加图标,并为 iOS 和 Android 设置不同的图标。

  1. 首先,从 Expo 中将图标导入到App.js文件中。Expo 提供了许多图标集。对于此应用程序,您将导入Ionicons图标集:
import React from 'react';
+ import { Ionicons } from '@expo/vector-icons';
import { createAppContainer } from 'react-navigation';
import { createStackNavigator } from 'react-navigation-stack';
import { createBottomTabNavigator } from 'react-navigation-tabs';
import Home from './Screens/Home';
import Detail from './Screens/Detail';
import Settings from './Screens/Settings';

const HomeStack = createStackNavigator({
  ...
  1. 创建选项卡导航器时,您可以定义应该添加到每个路由选项卡的图标。因此,您需要在路由对象中创建一个defaultNavigationOptions字段,该字段应包含一个tabBarIcon字段。在此字段中,您需要从navigation属性中获取当前路由,并返回此路由的图标:
...

const AppNavigator = createBottomTabNavigator({
  Home: HomeStack,
  Settings: SettingsStack,
- }, { initialRouteName: 'Home' });
+ }, {
+  initialRouteName: 'Home',
+  defaultNavigationOptions: ({ navigation }) => ({
+    tabBarIcon: () => {
+      const { routeName } = navigation.state;

+      let iconName;
+      if (routeName === 'Home') {
+        iconName = `ios-home`;
+      } else if (routeName === 'Settings') {
+        iconName = `ios-settings`;
+      }

+      return <Ionicons name={iconName} size={20} />;
+    }
+  })
});

export default createAppContainer(AppNavigator);
  1. 要区分 iOS 和 Android,您需要从react-native中导入Platform模块。使用此模块,您可以通过检查Platform.OS的值是否为iosandroid来检查您的移动设备是运行 iOS 还是 Android。必须将该模块导入以下代码块中:
import React from 'react';
+ import { Platform } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { createAppContainer } from 'react-navigation';
import { createStackNavigator } from 'react-navigation-stack';
import { createBottomTabNavigator } from 'react-navigation-tabs';
import Home from './Screens/Home';
import Detail from './Screens/Detail';
import Settings from './Screens/Settings';

const HomeStack = createStackNavigator({
  ...
  1. 使用Platform模块,您可以更改导航器中每个选项卡呈现的图标。除了为 iOS 设计的图标外,Ionicons还具有基于 Material Design 的 Android 设计图标,可以像这样使用:
...

const AppNavigator = createBottomTabNavigator({
  Home: HomeStack,
  Settings: SettingsStack,
}, {
  initialRouteName: 'Home',
  defaultNavigationOptions: ({ navigation }) => ({
    tabBarIcon: () => {
      const { routeName } = navigation.state;

      let iconName;
      if (routeName === 'Home') {
-       iconName = `ios-home`;
+       iconName = `${Platform.OS === 'ios' ? 'ios' : 'md'}-home`;
      } else if (routeName === 'Settings') {
-       iconName = `ios-settings`;
+       iconName = `${Platform.OS === 'ios' ? 'ios' : 'md'}-settings`;
      }

      return <Ionicons name={iconName} size={20} />;
    }
  }),
});

export default createAppContainer(AppNavigator);

当您在 Android 移动设备上运行应用程序时,navigator选项卡将显示基于 Material Design 的图标。如果您使用的是苹果设备,它将显示不同的图标;您可以将Platform.OS === 'ios'条件更改为Platform.OS === 'android',以将 Material Design 图标添加到 iOS 中。

  1. 显示的图标是黑色的,而活动和非活动标签的标签具有不同的颜色。您可以通过更改配置对象来指定图标和标签在活动和非活动状态下的颜色。在tabBarIcon字段之后,您可以创建一个名为tabBarOptions的新字段,并将activeTintColorinActiveTintColor字段添加到其中,如下所示:
...
const AppNavigator = createBottomTabNavigator({
  Home: HomeStack,
  Settings: SettingsStack,
}, {
  initialRouteName: 'Home',
  defaultNavigationOptions: ({ navigation }) => ({
    tabBarIcon: () => {
      const { routeName } = navigation.state;

      let iconName;
      if (routeName === 'Home') {
        iconName = `${Platform.OS === 'ios' ? 'ios' : 'md'}-home`;
      } else if (routeName === 'Settings') {
        iconName = `${Platform.OS === 'ios' ? 'ios' : 'md'}-settings`;
      }

      return <Ionicons name={iconName} size={20} />;
    },
+   tabBarOptions: {
+      activeTintColor: 'blue',
+      inactiveTintColor: '#556',
+   },
  })
});

export default createAppContainer(AppNavigator);
  1. 这只改变了标签的值,但活动和非活动的色调颜色值也可以在tabBarIcon字段上使用tintColor属性。这个值可以传递给Ionicons来改变图标的颜色:
...

const AppNavigator = createBottomTabNavigator({
  Home: HomeStack,
  Settings: SettingsStack,
}, {
  initialRouteName: 'Home',
  defaultNavigationOptions: ({ navigation }) => ({
-   tabBarIcon: () => {
+   tabBarIcon: ({ tintColor }) => {
      const { routeName } = navigation.state;

      let iconName;
      if (routeName === 'Home') {
        iconName = `${Platform.OS === 'ios' ? 'ios' : 'md'}-home`;
      } else if (routeName === 'Settings') {
        iconName = `${Platform.OS === 'ios' ? 'ios' : 'md'}-settings`;
      }

-     return <Ionicons name={iconName} size={20} />;
+     return <Ionicons name={iconName} size={20} color={tintColor} />;
    },
    tabBarOptions: {
      activeTintColor: 'blue',
      inactiveTintColor: '#556',
    },
  }),
});

export default createAppContainer(AppNavigator);

现在,当您查看主屏幕时,选项卡图标和标签都会呈蓝色,而设置选项卡将呈灰色。此外,无论您是在模拟器上还是在移动设备上运行应用程序,显示的图标都会有所不同。如果您使用 iOS,应用程序应该如下所示:

另一个可以进行样式设置的页面是“详情”屏幕。对于这个屏幕,您也可以选择在 iOS 和 Android 之间进行样式上的差异。如前所述,有多种方法可以做到这一点;除了使用Platform模块之外,您还可以使用特定于平台的文件扩展名。任何具有*.ios.js*.android.js扩展名的文件都只会在扩展名指定的平台上呈现。您不仅可以应用不同的样式规则,还可以在不同平台上进行功能上的变化:

  1. 为了在运行 Android 的移动设备上创建一个特定的“详情”屏幕,您需要创建一个名为Components/Listing/ListingDetail.android.js的新文件。这个文件里面将包含以下代码:
import React from 'react';
import styled from 'styled-components/native';
import { Image, Text, View, Dimensions } from 'react-native';

const ListingDetailWrapper = styled(View)`
  display: flex;
`;

const Details = styled(View)`
  padding: 5%;
`

export const Title = styled(Text)`
  flex-wrap: wrap;
  width: 99%;
  font-size: 30px;
`

export const Price = styled(Text)`
  font-weight: bold;
  font-size: 20px;
  color: blue;
`

const Thumbnail = styled(Image)`
  width: 100%;
  height: ${Dimensions.get('window').width};
`

const ListingDetail = ({ item }) => (
  <ListingDetailWrapper>
    <Thumbnail
      source={{uri: item.thumbnail}}
    />
    <Details>
      <Title>{item.title}</Title>
      <Price>{item.price}</Price>
    </Details>
  </ListingDetailWrapper>
);

export default ListingDetail;

正如您所看到的,一些组件将由ListingDetail组件呈现。还从react-native中导入了Dimensions模块。这个模块可以帮助您获取应用程序正在运行的设备的屏幕尺寸。通过获取宽度,您可以在用户屏幕的整个宽度上显示图像。

  1. 对于运行 iOS 的设备,您也可以做同样的事情,但这次您需要创建一个名为Components/Listing/ListingDetail.ios.js的新文件。这个文件将包含在 Android 上运行的代码的变体,其中图像将使用Dimensions模块在整个屏幕高度上显示。iOS 的ListingDetail组件可以通过将以下代码块粘贴到该文件中来创建:
import React from 'react';
import styled from 'styled-components/native';
import { Image, Text, View, Dimensions } from 'react-native';

const ListingDetailWrapper = styled(View)`
  display: flex;
`;

const Details = styled(View)`
  position: absolute;
  top: 0;
  padding: 5%;
  width: 100%;
  background: rgba(0, 0, 255, 0.1);
`

export const Title = styled(Text)`
  flex-wrap: wrap;
  width: 99%;
  font-size: 30px;
`

export const Price = styled(Text)`
  font-weight: bold;
  font-size: 20px;
  color: blue;
`

const Thumbnail = styled(Image)`
  width: 100%;
  height: ${Dimensions.get('window').height};
`

const ListingDetail = ({ item }) => (
  <ListingDetailWrapper>
    <Thumbnail
      source={{uri: item.thumbnail}}
    />
    <Details>
      <Title>{item.title}</Title>
      <Price>{item.price}</Price>
    </Details>
  </ListingDetailWrapper>
);

export default ListingDetail;
  1. 要在应用程序中显示这些组件中的一个,需要对Screens/Detail.js文件进行一些更改。ListingDetail组件应该被导入到这个文件中,并使用item属性返回:
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
+ import ListingDetail from '../Components/Listing/ListingDetail';

const Detail = ({ navigation }) => {
  const item = navigation.getParam('item', {});

  return (
-  <View style={styles.container}>
+  <ListingDetail item={item} />
-  </View>
  )
};

- const styles = StyleSheet.create({
-  container: {
-    flex: 1,
-    backgroundColor: '#fff',
-    alignItems: 'center',
-    justifyContent: 'center',
-  },
- });

export default Detail;

您的应用程序现在在 iOS 和 Android 上有两个不同版本的详细屏幕,React Native 将确保具有正确扩展名的文件在该操作系统上运行。您可以通过比较在 Android 模拟器或移动设备上运行的应用程序与以下截图来检查这一点,该截图是从 iOS 设备上获取的:

通过这些最后的更改,您已经创建了您的第一个 React Native 应用程序,该应用程序将在 Android 和 iOS 设备上运行,并实现了基本的路由和样式。

摘要

在本章中,您使用 React Native 为 iOS 和 Android 移动设备创建了一个房源应用程序。Expo 用于创建应用程序的第一个版本,并提供了许多功能以平滑开发人员的体验。react-navigation包用于处理移动应用程序的不同类型的路由,而styled-components用于处理这个 React Native 应用程序的样式。

由于这可能是您对 React Native 的第一次介绍,如果一开始并不清楚一切,您不必感到难过。本章中学到的基础知识应该提供了一个合适的基线,以便我们可以继续您的移动应用开发之旅。在下一章中,您将创建的项目将进一步建立在这些原则之上,并处理诸如动画之类的功能,同时我们将创建一个井字棋游戏。

进一步阅读

第九章:使用 React Native 和 Expo 构建动画游戏

在本书中创建的大多数项目都侧重于显示数据并使其可以在页面之间进行导航。在上一章中,您探索了创建 Web 和移动应用程序之间的一些差异。构建移动应用程序时的另一个区别是,用户期望动画和手势,因为它们使应用程序的使用变得简单和熟悉。这是本章的重点。

在本章中,您将使用 React Native 的 Animated API、一个名为 Lottie 的包以及 Expo 的GestureHandler为 React Native 应用程序添加动画和手势。它们共同使我们能够创建最充分利用移动交互方法的应用程序,这对于像Tic-Tac-Toe这样的游戏非常理想。此外,该应用程序将在游戏界面旁边显示此游戏的最高分排行榜。

创建这个游戏时,将涵盖以下主题:

  • 使用 React Native Animated API

  • 使用 Lottie 进行高级动画

  • 使用 Expo 处理原生手势

项目概述

在本章中,我们将使用 React Native 和 Expo 创建一个带有基本动画的Tic-Tac-Toe游戏,使用 Animated API 添加基本动画,使用 Lottie 添加高级动画,并使用 Expo 的 Gesture Handler 处理原生手势。起点将是创建一个具有基本路由实现的 Expo CLI 应用程序,以便我们的用户可以在游戏界面和此游戏的最高分概述之间切换。

构建时间为 1.5 小时。

入门

我们将在本章中创建的项目是基于 GitHub 上的初始版本构建的:github.com/PacktPublishing/React-Projects/tree/ch9-initial。完整的源代码也可以在 GitHub 上找到:github.com/PacktPublishing/React-Projects/tree/ch10-initial

您需要在移动 iOS 或 Android 设备上安装 Expo Client 应用程序,以在物理设备上运行该项目。或者,您可以在计算机上安装 Xcode 或 Android Studio,以在虚拟设备上运行该应用程序:

export ANDROID_SDK=**ANDROID_SDK_LOCATION** export PATH=**ANDROID_SDK_LOCATION**/platform-tools:$PATH export PATH=**ANDROID_SDK_LOCATION**/tools:$PATH

ANDROID_SDK_LOCATION的值是本地机器上 Android SDK 的路径,可以通过打开 Android Studio 并转到首选项|外观和行为|系统设置|Android SDK来找到。路径在声明 Android SDK 位置的框中列出,看起来像这样:/Users/myuser/Library/Android/sdk

本应用程序是使用Expo SDK 版本 33.0.0创建的,因此,您需要确保您在本地机器上使用的 Expo 版本是相似的。由于 React Native 和 Expo 经常更新,请确保您使用此版本,以便本章描述的模式表现如预期般。如果您的应用程序无法启动或遇到错误,请参考 Expo 文档,了解有关更新 Expo SDK 的更多信息。

检查初始项目

在本章中,您将要处理的应用程序已经为您构建,但我们需要通过添加诸如动画和过渡之类的功能来完成它。下载或克隆项目后,您需要进入项目的根目录,在那里您可以运行以下命令来安装依赖项并启动应用程序:

npm install && npm start

这将启动 Expo 并使您能够从终端或浏览器启动项目。在终端中,您可以使用 QR 码在移动设备上打开应用程序,或选择在模拟器中打开应用程序。

无论您是在虚拟设备还是物理设备上打开应用程序,在这一点上,应用程序应该看起来像这样:

该应用程序由三个屏幕组成:StartGameLeaderBoard。第一个屏幕是Start,在那里可以通过点击绿色按钮开始游戏。这将导致Game屏幕,该屏幕设置为模态。Start屏幕使用选项卡导航,您还可以访问LeaderBoard屏幕,该屏幕将显示玩家的分数。

此 React Native 应用程序的项目结构如下。此结构类似于本书中已创建的项目:

tic-tac-toe
|-- .expo
|-- assets
    |-- icon.png
    |-- splash.png
    |-- winner.json
|-- Components
    |-- // ...
|-- context
    |-- AppContext.js
|-- node_modules
|-- Screens
    |-- Game.js
    |-- LeaderBoard.js
    |-- Start.js
|-- utils
    |-- arrayContainsArray.js
    |-- checkSlots.js
.gitignore
App.js
AppContainer.js
app.json
babel.config.js
package.json

assets目录中,您将找到两个图像:一个将用作应用程序的图标,一旦您在移动设备上安装了该应用程序,它将显示在主屏幕上,另一个将用作启动应用程序时显示的启动画面。还在这里放置了一个 Lottie 动画文件,您将在本章后面使用。应用程序的配置,例如 App Store,放在app.json中,而babel.config.js保存特定的 Babel 配置。

App.js文件是您的应用程序的实际入口点,其中导入并返回AppContainer.js文件,该文件在context/AppContext.js文件中创建的上下文提供程序中。在AppContainer中,定义了此应用程序的所有路由,而AppContext将包含应该在整个应用程序中可用的信息。在utils目录中,您可以找到游戏的逻辑,即填充Tic-Tac-Toe棋盘的函数以及确定哪个玩家赢得了比赛。

此游戏的所有组件都位于ScreensComponents目录中,前者包含由StartGameLeaderBoard路由呈现的组件。这些屏幕的子组件可以在Components目录中找到,该目录具有以下结构:

|-- Components
    |-- Actions
        |-- Actions.js
    |-- Board
        |-- Board.js
    |-- Button
        |-- Button.js
    |-- Player
        |-- Player.js
    |-- Slot
        |-- Slot.js
        |-- Filled.js

在前面结构中最重要的组件是BoardSlotFilled,因为它们构成了大部分游戏。BoardGame屏幕呈现,并包含一些游戏逻辑,而SlotFilled是在此棋盘上呈现的组件。Actions组件返回两个Button组件,以便我们可以从Game屏幕导航离开或重新开始游戏。Player显示了轮到哪个玩家或赢得比赛的玩家的名称。

使用 React Native 和 Expo 创建动画的井字棋游戏应用程序

手机游戏通常具有引人注目的动画,使用户想要继续玩游戏并使游戏更具互动性。已经运行的 Tic-Tac-Toe 游戏到目前为止没有使用任何动画,只是使用了内置的一些过渡效果,这些过渡效果是使用 React Navigation 构建的。在本节中,您将向应用程序添加动画和手势,这将改善游戏界面,并使用户在玩游戏时感到更舒适。

使用 React Native Animated API

在 React Native 中使用动画的多种方法之一是使用 Animated API,该 API 可在 React Native 的核心中找到。使用 Animated API,您可以默认为 react-native 中的 ViewTextImageScrollView 组件创建动画。或者,您可以使用 createAnimatedComponent 方法创建自己的组件。

创建基本动画

您可以添加的最简单的动画之一是通过更改元素的不透明度来使元素淡入或淡出。在您之前创建的 Tic-Tac-Toe 游戏中,插槽填充了绿色或蓝色,具体取决于哪个玩家填充了该插槽。由于您使用 TouchableOpacity 元素创建插槽,这些颜色已经显示了一个小的过渡效果。但是,可以通过使用 Animated API 为其添加自定义过渡效果。要添加动画,必须更改以下代码块:

  1. 首先,在 src/Components/Slot 目录中创建一个新文件,并将其命名为 Filled.js。该文件将包含以下代码,用于构建 Filled 组件。在此文件中,添加以下代码:
import  React  from  'react'; import { View } from 'react-native'; const  Filled  = ({ filled }) => { return ( <View style={{ position:  'absolute',
            display: filled ? 'block' : 'none', width:  '100%',
  height:  '100%', backgroundColor: filled === 1 ? 'blue' : 'green',  }}
    />
 ); } export  default  Filled; 

该组件显示一个 View 元素,并使用使用 JSS 语法的样式对象进行样式设置,这是 React Native 的默认语法。由于其位置是绝对的,宽度和高度均为 100%,因此该元素可以用于填充另一个元素。它还接受 filled 属性,以便我们可以设置 backgroundColor 并确定组件是否显示。

  1. 您可以将此组件导入到 Slot 组件中,并在任何玩家填充插槽后显示它。而不是为 SlotWrapper 组件设置背景颜色,您可以将属于玩家一或玩家二的颜色传递给 Filled 组件:
import  React  from  'react'; import { TouchableOpacity, Dimensions } from  'react-native'; import  styled  from  'styled-components/native'; + import  Filled  from  './Filled'**;** const  SlotWrapper  =  styled(TouchableOpacity)` width: ${Dimensions.get('window').width * 0.3}; height: ${Dimensions.get('window').width * 0.3}; -   background-color: ${({ filled }) => filled ? (filled === 1 ? 'blue' : 'green') : 'grey'}; + **background-color: grey;**
 border: 1px solid #fff;
`;  const Slot = ({ index, filled, handleOnPress }) => ( - <SlotWrapper filled={filled} onPress={() => !filled && handleOnPress(index)} />
+ <SlotWrapper  onPress={() => !filled && handleOnPress(index)}> + <Filled filled={filled}  />  + </SlotWrapper**>** );  export  default  Slot;
  1. 现在,每当您单击插槽时,由于您需要先将可点击元素从TouchableOpacity元素更改为TouchableWithoutFeedback元素,因此不会发生任何可见变化。这样,默认的带不透明度的过渡就会消失,因此您可以用自己的过渡替换它。可以从react-native导入TouchableWithoutFeedback元素,并将其放置在一个View元素周围,该元素将保存插槽的默认样式:
import  React  from  'react'; - import { TouchableOpacity, Dimensions } from  'react-native'; + import { TouchableWithoutFeedback, View, Dimensions } from  'react-native'; import  styled  from  'styled-components/native'; import  Filled  from  './Filled'; - const  SlotWrapper  =  styled(TouchableOpacity)` + const  SlotWrapper  =  styled(View)`  width: ${Dimensions.get('window').width * 0.3}; height: ${Dimensions.get('window').width * 0.3};  background-color: grey;
 border: 1px solid #fff;
`;   const Slot = ({ index, filled, handleOnPress }) => ( - <SlotWrapper  onPress={() => !filled && handleOnPress(index)}> + <TouchableWithoutFeedback onPress={() => !filled && handleOnPress(index)}>  +   <SlotWrapper**>**
  <Filled filled={filled} />  </SlotWrapper>
**+ <TouchableWithoutFeedback>** );  export  default  Slot;

现在,您刚刚按下的插槽将立即填充为您在Filled组件的backgroundColor字段中指定的颜色,而无需任何过渡。

  1. 要重新创建此过渡,可以使用 Animated API,您将使用它来从插槽渲染时更改Filled组件的不透明度。因此,您需要在src/Components/Slot/Filled.js中从react-native导入Animated
import  React  from  'react';
**- import { View } from 'react-native';** **+ import { Animated, View } from 'react-native';** const  Filled  = ({ filled }) => { return (
    ... 
  1. 使用 Animated API 的新实例是通过指定应在使用 Animated API 创建的动画期间更改的值来开始的。此值应该可以由整个组件的 Animated API 更改,因此您可以将此值添加到组件的顶部。由于您希望稍后可以更改此值,因此应使用useState Hook 创建此值:
import  React  from  'react'; import { Animated, View } from 'react-native'; const  Filled  = ({ filled }) => {
**+ const [opacityValue] = React.useState(new Animated.Value(0));** return (
    ...
  1. 现在,可以使用内置的三种动画类型之一(即decayspringtiming)通过 Animated API 更改此值,其中您将使用 Animated API 的timing方法在指定的时间范围内更改动画值。可以从任何函数触发 Animated API,例如与onPress事件链接或从生命周期方法触发。由于Filled组件应仅在插槽填充时显示,因此可以使用在filled属性组件更改时触发的生命周期方法,即具有filled属性作为依赖项的useEffect Hook。可以删除显示的样式规则,因为当filled属性为false时,组件的opacity将为0
import  React  from  'react'; import { Animated, View } from 'react-native'; const  Filled  = ({ filled }) => {
  const [opacityValue] = React.useState(new Animated.Value(0)); **+** **R**eact.useEffect(() => {
+    filled && Animated.timing(
+        opacityValue, 
+ {
+ toValue:  1,
+ duration:  500, +        }
+ ).start();
+ **}, [filled]);** return ( <View style={{ position:  'absolute',
 **-          display: filled ? 'block' : 'none',** width:  '100%',
  height:  '100%', backgroundColor: filled === 1 ? 'blue' : 'green',  }}
    />
 ); } export  default  Filled;

timing方法使用您在组件顶部指定的opacityValue和包含 Animated API 配置的对象。其中一个字段是toValue,当动画结束时,它将成为opacityValue的值。另一个字段是字段的持续时间,它指定动画应持续多长时间。

timing旁边的其他内置动画类型是decayspringtiming方法随着时间逐渐改变,decay类型的动画在开始时变化很快,然后逐渐减慢直到动画结束。使用spring,您可以创建动画,使其在动画结束时稍微超出其边缘。

  1. 最后,您只需要将View元素更改为Animated.View元素,并将opacity字段和opacityValue值添加到style对象中:
import  React  from  'react';
**- import { Animated, View } from 'react-native';** + import { Animated } from 'react-native';  const  Filled  = ({ filled }) => {

... return (    
**-** **<View**
**+   <Animated.View** style={{ position:  'absolute', width:  '100%',
  height:  '100%', backgroundColor: filled === 1 ? 'blue : 'green',
**+           opacity: opacityValue,**  }}
    />
 ); } export  default  Filled;

现在,当您按下任何一个插槽时,Filled组件将淡入,因为不透明度值在 500 毫秒内过渡。当您在 iOS 模拟器或运行 iOS 的设备上运行应用程序时,这将使填充的插槽看起来如下。在 Android 上,应用程序应该看起来类似,因为没有添加特定于平台的样式:

为了使动画看起来更加流畅,您还可以向Animated对象添加一个easing字段。这个字段的值来自Easing模块,可以从react-native中导入。Easing模块有三个标准函数:linearquadcubic。在这里,linear函数可以用于更平滑的时间动画:

import  React  from  'react'; **- import { Animated } from 'react-native';**
**+ import { Animated, Easing } from 'react-native';** const  Filled  = ({ filled }) => {
  const [opacityValue] = React.useState(new Animated.Value(0));  React.useEffect(() => {
    filled && Animated.timing(
        opacityValue, { toValue:  1, duration: 1000, **+           easing: Easing.linear(),**
        } ).start(); }, [filled]);

  return (
    ...

通过最后这个改变,动画已经完成,游戏界面已经感觉更加流畅,因为插槽正在使用您自己的自定义动画进行填充。在本节的下一部分中,我们将结合其中一些动画,使这个游戏的用户体验更加先进。

使用 Animated API 结合动画

通过改变Filled组件的不透明度来进行过渡已经改善了游戏界面。但是我们可以创建更多的动画来使游戏的交互更具吸引力。

我们可以做的一件事是为Filled组件的大小添加淡入动画。为了使这个动画与我们刚刚创建的淡入动画配合得很好,我们可以使用 Animated API 中的parallel方法。这个方法将在同一时刻开始指定的动画。为了创建这种效果,我们需要做出以下改变:

  1. 对于这第二个动画,您希望Filled组件不仅具有淡入的颜色,还具有淡入的大小。为了为不透明度设置初始值,您必须为该组件的大小设置初始值:
import  React  from  'react'; import { Animated, Easing } from 'react-native'; const  Filled  = ({ filled }) => {
  const [opacityValue] = React.useState(new Animated.Value(0));
**+ const [scaleValue] = React.useState(new Animated.Value(0));**  React.useEffect(() => {
    ...
  1. 您在useEffect Hook 中创建的Animated.timing方法需要包装在Animated.parallel函数中。这样,您可以在以后添加另一个改变Filled组件大小的动画。Animated.parallel函数将Animated方法的数组作为参数添加,必须像这样添加:
import  React  from  'react'; import { Animated, Easing } from  'react-native'; const  Filled  = ({ filled }) => { const [opacityValue]  = React.useState(new  Animated.Value(0)); const [scaleValue]  = React.useState(new  Animated.Value(0));  React.useEffect(() => {
**+ filled &&** Animated.parallel**([
- filled && Animated.timing(**
**+** Animated.timing**(** opacityValue, { toValue:  1, duration:  1000, easing:  Easing.linear(),
 } **-   ).start();
+  ),**
**+** ]).start**();** }, [filled]); return (
 ... 

除了parallel函数之外,还有三个函数可以帮助您进行动画组合。这些函数是delaysequencestagger,它们也可以结合使用。delay函数在预定义的延迟之后开始任何动画,sequence函数按照您指定的顺序开始动画,并在动画解决之前等待,然后开始另一个动画,stagger函数可以按顺序和指定的延迟同时开始动画。

  1. parallel函数中,您需要添加 Animated API 的spring方法,该方法可以动画化Filled组件的大小。这次,您不会使用timing方法,而是使用spring方法,它会在动画结束时添加一点弹跳效果。还添加了一个Easing函数,使动画看起来更加流畅。
...
const  Filled  = ({ filled }) => { const [opacityValue]  = React.useState(new  Animated.Value(0)); const [scaleValue]  = React.useState(new  Animated.Value(0)); React.useEffect(() => {
      filled && Animated.parallel([ Animated.timing( opacityValue, { toValue:  1, duration:  1000, easing:  Easing.linear(),
 } ),
**+       Animated.spring(**
**+         scaleValue,**
**+         {**
**+           toValue: 1,**
**+           easing: Easing.cubic(),**
**+         },**
**+       ),** ]).start(); }, [filled]); return (
        ...
  1. 这个spring动画将会把scaleValue的值从0改变到1,并在动画结束时创建一个小的弹跳效果。scaleValue也必须添加到style对象中的Animated.View组件中,以使动画生效。scaleValue将被添加到transform字段中的scale字段中,这将改变Filled组件的大小:
... return (    <Animated.View style={{ position:  'absolute', width:  '100%',
  height:  '100%', backgroundColor: filled === 1 ? 'blue' : 'green',            opacity: opacityValue,
**+           transform: [**
**+             {**
**+               scale: scaleValue,**
**+             }**
**+           ],**  }}
    />
 ); } export  default  Filled

当您点击任何一个插槽时,Filled组件不仅通过改变不透明度而淡入,还会通过改变大小来淡入。动画结束时的弹跳效果为淡入效果增添了一丝美感。

  1. 然而,当您点击描绘游戏获胜者的插槽时,动画没有足够的时间结束,而获胜状态由组件渲染。因此,您还需要在设置游戏获胜者的函数中添加一个超时。这个函数可以在src/Screens/Game.js中找到,您可以添加一个常量来设置动画持续的毫秒数:
import  React  from 'react'; import { View } from  'react-native'; import  styled  from  'styled-components/native'; import  Board  from  '../Components/Board/Board'; import  Actions  from  '../Components/Actions/Actions'; import  Player  from  '../Components/Player/Player'; import  checkSlots  from  '../utils/checkSlots'; import { AppContext } from  '../context/AppContext'; + export  const  ANIMATION_DURATION  =  1000**;**

...

这也将包装设置获胜者的函数在一个setTimeout函数中,这会延迟这些函数的执行时间,延迟时间与动画持续时间相同:

...
const  checkWinner  = (player) => { const  slots  =  state[`player${player}`]; if (slots.length  >=  3) { if (checkSlots(slots)) { + setTimeout(() => { setWinner(player);
 setPlayerWins(player); +     }, ANIMATION_DURATION**);**
 } } return  false;
}

...
  1. 由于ANIMATION_DURATION常量被导出,您可以在src/Components/Slot/Filled.js文件中导入这个常量,并在实际动画中使用相同的常量。这样,如果您在某个时候更改了动画的持续时间,您不必对其他组件进行任何更改,这些更改就会可见:
import  React  from  'react'; import { Animated, Easing } from  'react-native'; + import { ANIMATION_DURATION } from  '../../Screens/Game';  const  Filled  = ({ filled }) => { const [opacityValue]  = React.useState(new  Animated.Value(0)); const [scaleValue]  = React.useState(new  Animated.Value(0)); React.useEffect(() => {
      filled && Animated.parallel( Animated.timing( opacityValue, { toValue:  1,
**-** duration:  1000**,**
**+           duration: ANIMATION_DURATION,** easing:  Easing.linear(),
 }

除了插槽现在填充了一个执行两个并行动画的动画Filled组件之外,当您点击其中任何一个时,设置游戏获胜者的函数将等到插槽填充后再触发。

下一节将展示如何处理更高级的动画,比如在任何两个玩家中有一个获胜时显示动画图形。为此,我们将使用 Lottie 包,因为它支持的功能比内置的 Animated API 更多。

使用 Lottie 进行高级动画

React Native 动画 API 非常适合构建简单的动画,但构建更高级的动画可能更难。幸运的是,Lottie 通过在 iOS、Android 和 React Native 中实时渲染 After Effects 动画,为我们提供了在 React Native 中创建高级动画的解决方案。Lottie 可以作为一个独立的包使用npm安装,但也可以从 Expo 获取。由于 Lottie 仍然是 Expo 的实验性功能的一部分,您可以通过从DangerZone命名空间中检索它来使用它。因此,目前最好是从npm安装 Lottie,并在要使用它的文件中导入它。

在使用 Lottie 时,您不必自己创建这些 After Effects 动画;有一个完整的资源库,您可以在项目中自定义和使用。这个库叫做LottieFiles,可以在https://lottiefiles.com/上找到。

由于您已经将动画添加到了棋盘游戏的插槽中,一个很好的地方来添加更高级的动画将是在任何一名玩家赢得比赛时显示的屏幕上。在这个屏幕上,可以显示一个奖杯,而不是棋盘,因为游戏已经结束了。现在让我们来做这个:

  1. 要开始使用 Lottie,请运行以下命令,这将安装 Lottie 及其依赖项,并将其添加到您的package.json文件中:
npm install lottie-react-native
  1. 安装过程完成后,你可以继续创建一个组件,用来渲染已下载为 Lottie 文件的 After Effects 动画。这个组件可以在新的src/Components/Winner/Winner.js文件中创建。在这个文件中,你需要导入 React 和当然是从lottie-react-native中导入的 Lottie,这是你刚刚安装的:
import React from 'react';
import Lottie from 'lottie-react-native';

const Winner = () => ();

export default Winner;
  1. 导入的Lottie组件可以渲染你自己创建的或者从LottieFiles库下载的任何 Lottie 文件。在assets目录中,你会找到一个可以在这个项目中使用的 Lottie 文件,名为winner.json。当你将它添加到源中时,Lottie组件可以渲染这个文件,并且可以通过传递一个样式对象来设置动画的宽度和高度。此外,你应该添加autoPlay属性来在组件渲染时启动动画:
import React from 'react';
import Lottie from 'lottie-react-native';

const Winner = () => (
+    <Lottie
+        autoPlay
+        style={{
+            width: '100%',
+            height: '100%',
+        }}
+        source={require('../../assets/winner.json')}
+    />
);

export default Winner;
  1. 该组件现在将开始在包含此组件的任何屏幕中渲染奖杯动画。由于这个动画应该在任一玩家赢得比赛时显示出来,所以Board组件是一个很好的地方来添加这个组件,因为你可以使用包装样式来包裹棋盘。Board组件可以在src/Components/Board/Board.js文件中找到,你可以在这里导入Winner组件:
import  React  from 'react'; import { View, Dimensions } from  'react-native'; import  styled  from  'styled-components/native'; import  Slot  from  '../Slot/Slot'; + import  Winner  from  '../Winner/Winner'**;**

... const  Board  = ({ slots, winner, setSlot }) => (
    ... 

在这个组件的return函数中,你可以检查winner属性是true还是false,并根据结果显示Winner组件或者遍历slots

const Board = ({ slots, winner, setSlot }) => (
 <BoardWrapper>
    <SlotsWrapper>
-    {slots.map((slot, index) =>
+    {
+      winner
+      ? <Winner />
+      : slots.map((slot, index) =>
            <Slot
              key={index}
              index={index}
              handleOnPress={!winner ? setSlot : () => { }}
              filled={slot.filled}
            />
        )
    }
    </SlotsWrapper>
  </BoardWrapper>
);

Board组件接收到值为truewinner属性时,用户将看到渲染的奖杯动画,而不是棋盘。当你在 iOS 模拟器上或者 iOS 设备上运行应用程序时,可以看到这将是什么样子的例子:

![

如果你觉得这个动画的速度太快,可以通过将 Animated API 与 Lottie 结合来进行调整。Lottie组件可以接受一个progress属性,用来确定动画的速度。通过传递由 Animated API 创建的值,你可以调整动画的速度以满足自己的需求。将这个添加到 Lottie 动画中可以这样做:

  1. 首先,你需要导入AnimatedEasing(稍后会用到),并在组件顶部使用AnimateduseState Hook 创建一个新值:
import  React  from  'react'; + import { Animated, Easing } from  'react-native'; import  Lottie  from  'lottie-react-native'; - const  Winner  = () => ( + const Winner = () => {
+   const [progressValue]  = React.useState(new  Animated.Value(0**));**
**+   return (** <Lottie autoPlay
        style={{ width:  '100%', height:  '100%' , }} source={  require('../../assets/winner.json') } progress={progressValue} />
  );
+ };

export  default  Winner;
  1. useEffect Hook 中,您可以创建Animated.timing方法,它将在您使用duration字段指定的时间范围内设置progressValue。动画应该在组件渲染时立即开始,因此 Hook 的依赖数组应为空。您还可以将Easing.linear函数添加到easing字段中,以使动画运行更顺畅:
...
const  Winner  = () => { const [progressValue]  = React.useState(new  Animated.Value(0));

**+** React.useEffect(() => { +    Animated.timing(progressValue, { +      toValue:  1, +      duration:  4000, +      easing:  Easing.linear,
+ }).start(); **+ }, []);** return (
  ... 
  1. 现在,progressValue值可以传递给Lottie组件,这将导致动画的不同行为:
...

const Winner = () => {
 const [progressValue]  = React.useState(new  Animated.Value(0));

  ...

  return ( <Lottie autoPlay
      style={{ width:  '100%', height:  '100%' , }} source={  require('../../assets/winner.json') }
**+** progress={progressValue**}** />
  );
};

export  default  Winner;

现在,动画正在减速。动画将花费 4,000 毫秒而不是默认的 3,000 毫秒来从头到尾播放。在下一节中,您将通过处理移动设备上可用的手势,为该应用程序的用户体验增加更多复杂性。

使用 Expo 处理手势

手势是移动应用程序的重要特性,因为它们将决定平庸和优秀移动应用程序之间的差异。在您创建的Tic-Tac-Toe游戏中,可以添加几种手势以使游戏更具吸引力。

以前,您使用了TouchableOpacity元素,用户按下该元素后会通过更改元素来获得反馈。您还可以使用TouchableHighlight元素来实现这一点。与TouchableOpacity一样,它可以被用户按下,但是它会突出显示元素,而不是改变不透明度。这些反馈或突出显示手势让用户对在应用程序中做出决定时会发生什么有所印象,从而提高用户体验。这些手势也可以自定义并添加到其他元素中,使得可以创建自定义的可触摸元素。

为此,您可以使用一个名为react-native-gesture-handler的软件包,它可以帮助您在每个平台上访问原生手势。所有这些手势都将在原生线程中运行,这意味着您可以添加复杂的手势逻辑,而无需处理 React Native 手势响应系统的性能限制。它支持的一些手势包括轻触、旋转、拖动和平移手势。使用 Expo CLI 创建的任何项目都可以在不必手动安装软件包的情况下使用react-native-gesture-handler中的GestureHandler

您还可以直接从 React Native 中使用手势,而无需使用额外的包。然而,React Native 目前使用的手势响应系统并不在原生线程中运行。这不仅限制了创建和自定义手势的可能性,还可能遇到跨平台或性能问题。因此,建议您使用react-native-gesture-handler包,但在 React Native 中使用手势并不需要这个包。

处理轻击手势

我们将实现的第一个手势是轻击手势,它将被添加到Slot组件中,以便为用户的操作提供更多反馈。用户轻击时不会填充插槽,而是在轻击事件开始时就会收到一些反馈,并在事件完成时收到反馈。在这里,我们将使用react-native-gesture-handler中的TouchableWithoutFeedback元素,它在原生线程中运行,而不是使用手势响应系统的react-native中的TouchableWithoutFeedback元素。可以通过以下步骤将react-native组件替换为react-native-gesture-handler中的组件:

  1. TouchableWithoutFeedback可以从react-native-gesture-handler中导入到src/components/Slot.js文件的顶部:
import  React  from  'react'; - import { TouchableWithoutFeedback, View, Dimensions } from  'react-native';
+ import {  View, Dimensions } from  'react-native';  + import **{ Tou**chableWithoutFeedback } from  'react-native-gesture-handler'; import  styled  from  'styled-components/native'; import  Filled  from  './Filled';

... const  Slot  = ({ index, filled, handleOnPress }) => ( ...

您不必在返回函数中做任何更改,因为TouchableWithoutFeedback使用与react-native相同的 props。当您轻击插槽时,什么都不会改变。这是因为插槽将由Filled组件填充,一旦出现就会显示动画。

  1. 当您轻击任何插槽并将手指放在上面时,handleOnPress函数还不会被调用。只有当您通过移开手指完成轻击手势时,手势才会结束,并且handleOnPress函数将被调用。当您触摸插槽开始轻击手势时,可以使用TouchableWithoutFeedback中的onPressIn回调来启动动画。一旦轻击事件开始,就需要向Filled组件传递一个值,该值指示它应该开始动画。这个值可以使用useState Hook 创建,因此您已经有一个可以调用以更改此值的函数。当通过从元素上移开手指结束轻击事件时,应调用handleOnPress函数。您可以使用onPressOut回调来实现这一点:
import  React  from  'react'; import { View, Dimensions } from  'react-native'; import { TapGestureHandler, State } from  'react-native-gesture-handler';  import  styled  from  'styled-components/native'; import  Filled  from  './Filled';

... - const  Slot  = ({ index, filled, handleOnPress }) => ( + const  Slot  = ({ index, filled, handleOnPress }) => {  +  const [start, setStart] = React.useState(false);

+  return ( -    <TouchableWithoutFeedback onPress={() => !filled && handleOnPress(index)}> +    <TouchableWithoutFeedback onPressIn={() => setStart()} onPressOut={() => !filled && handleOnPress(index)}>
 <SlotWrapper> - <Filled filled={filled}  /> + <Filled filled={filled} start={start}  />  </SlotWrapper>
 </TouchableWithoutFeedback>  );
};

export default Slot;
  1. src/Components/Slot/Filled.js文件中的Filled组件中,您需要检查start属性,并在此属性的值为true时开始动画。由于您不希望在start的值为true时启动整个动画,只有改变opacityValue的动画会开始:
import  React  from  'react'; import { Animated, Easing } from  'react-native'; import { ANIMATION_DURATION } from  '../../utils/constants'; - const  Filled  = ({ filled }) => { + const  Filled  = ({ filled, start }) => **{**  const [opacityValue] =  React.useState(new  Animated.Value(0));
**-** const [scaleValue] =  React.useState(new  Animated.Value(0)); + const [scaleValue] =  React.useState(new  Animated.Value(.8**));** + React.useEffect(() => { + start  &&  Animated.timing( + opacityValue, +     { + toValue:  1, + duration:  ANIMATION_DURATION, + easing:  Easing.linear(),
+     } +   ).start(); + }, [start**]);**

  React.useEffect(() => {    ...
  1. 此外,可以从检查filled属性的useEffect Hook 中删除改变不透明度的动画。此useEffect Hook 仅处理改变比例的动画。应该更改初始的scaleValue,否则组件的大小将等于0
+ const  Filled  = ({ filled, start }) => **{**  const [opacityValue] =  React.useState(new  Animated.Value(0));
**-** const [scaleValue] =  React.useState(new  Animated.Value(0)); + const [scaleValue] =  React.useState(new  Animated.Value(.8**));** React.useEffect(() => {

... React.useEffect(() => { - filled && Animated.parallel([ -   Animated.timing( -     opacityValue, -     { - toValue:  1, - duration:  ANIMATION_DURATION, - easing:  Easing.linear(),
- } -   ),
-   Animated.spring(
+   filled && Animated.spring**(** scaleValue,
      {
  toValue:  1,
  easing:  Easing.cubic(),
      }
**-    )**
**-  ]).start()**
**+**  ).start();  }, [filled]);

...

当您在进行这些更改后轻击任何插槽时,将启动timing动画,并且一个正方形将出现在插槽中,这表示正在轻击插槽。一旦您从该插槽释放手指,正方形将改变大小,并且在spring动画开始时填充插槽的其余部分,这发生在onPress函数改变filled的值时。

自定义轻击手势

现在,插槽具有不同的动画,取决于轻击事件的状态,这可能对用户在选择哪个插槽时犹豫不决很有用。用户可能会从所选插槽上移开手指,此时轻击事件将遵循不同的状态流。您甚至可以确定用户是否应该长时间点击插槽以使选择变得明确,或者像在某些社交媒体应用程序上喜欢图片一样双击插槽。

要创建更复杂的轻击手势,您需要知道轻击事件经历不同的状态。TouchableWithoutFeedback在底层使用TapGestureHandler,并且可以经历以下状态:UNDETERMINEDFAILEDBEGANCANCELLEDACTIVEEND。这些状态的命名非常直观,通常情况下,处理程序将具有以下流程:UNDETERMINED > BEGAN > ACTIVE > END > UNDETERMINED。当您在TouchableWithoutFeedback元素的onPressIn回调中添加函数时,此函数在轻击事件处于BEGAN状态时被调用。当状态为END时,将调用onPressOut回调,而默认的onPress回调则响应于ACTIVE状态。

要创建这些复杂的手势,您可以使用react-native-gesture-handler包,通过自己处理事件状态,而不是使用可触摸元素的声明方式:

  1. TapGestureHandler可以从react-native-gesture-handler中导入,并允许您创建自定义的可触摸元素,您可以自己定义手势。您需要从react-native-gesture-handler中导入State对象,其中包含您需要用来检查轻触事件状态的常量:
import  React  from  'react'; - import { TouchableWithoutFeedback } from  'react-native-gesture-handler'; + import { TapGestureHandler, State } from  'react-native-gesture-handler';import  styled  from  'styled-components/native'; import  Filled  from  './Filled';

... const  Slot  = ({ index, filled, handleOnPress }) => (   ...
  1. 不要像onPress那样使用事件处理程序,TouchableWithoutFeedback元素有一个名为onHandlerStateChange的回调。每当TapGestureHandler的状态发生变化时,例如当元素被点击时,都会调用这个函数。通过使用TapGestureHandler来创建可触摸元素,您就不再需要TouchableWithoutFeedback元素。这个元素的功能可以移动到您将要创建的新元素中:
... const  Slot  = ({ index, filled, handleOnPress }) => {
...

return ( - <TouchableWithoutFeedback onPressIn={() => setStart()} onPressOut={() => !filled && handleOnPress(index)}>  + <TapGestureHandler onHandlerStateChange={onTap}**>**
  <SlotWrapper>
  <Filled filled={filled} start={start}  />  </SlotWrapper> - </TouchableWithoutFeedback>
+ </TapGestureHandler**>**
 );
}; ...
  1. onHandlerStateChange接受onTap函数,您仍然需要创建,并检查轻触事件的当前状态。当轻触事件处于BEGAN状态时,类似于onPressIn处理程序,应该开始Filled的动画。轻触事件的完成状态为END,类似于onPressOut处理程序,在这里您将调用handleOnPress函数,该函数会更改有关点击插槽的玩家的属性值。将调用setStart函数来重置启动动画的状态。
import  React  from  'react'; import { View, Dimensions } from  'react-native'; import { TapGestureHandler, State } from  'react-native-gesture-handler';  import  styled  from  'styled-components/native'; import  Filled  from  './Filled';

... const  Slot  = ({ index, filled, handleOnPress }) => {
    const [start, setStart] = React.useState(false);   + const  onTap  = event => { +    if (event.nativeEvent.state === State.BEGAN) {
+       setStart(true);
+    }  + if (event.nativeEvent.state  ===  State.END) {  +       !filled && handleOnPress(index);
+       setStart(false);
+    }
+ }

  return (
    ...

当您点击任何一个插槽并将手指放在上面时,handleOnPress函数不会被调用。只有当您完成轻触手势并移开手指时,手势才会结束,并调用handleOnPress函数。

这些手势甚至可以进行更多的自定义,因为您可以使用组合来拥有多个相互响应的轻触事件。通过创建所谓的跨处理程序交互,您可以创建一个支持双击手势和长按手势的可触摸元素。通过设置并传递使用 React useRef Hook 创建的引用,您可以让来自react-native-gesture-handler的手势处理程序监听其他处理程序的状态生命周期。这样,您可以按顺序响应事件和手势,比如双击事件:

  1. 要创建引用,您需要将useRef Hook 放在组件顶部,并将此引用传递给TapGestureHandler
import  React  from  'react'; import { View, Dimensions } from  'react-native'; import { TapGestureHandler, State } from  'react-native-gesture-handler'; import  styled  from  'styled-components/native'; import  Filled  from  './Filled';

... const  Slot  = ({ index, filled, handleOnPress }) => { const [start, setStart] =  React.useState(false); +  const  doubleTapRef  =  React.useRef(null);

   ...  return ( -    <TapGestureHandler onHandlerStateChange={onTap}> + <TapGestureHandler + ref={doubleTapRef} + onHandlerStateChange={onTap} **+    >**
  <SlotWrapper>
  <Filled  filled={filled}  start={start}  />
  </SlotWrapper>
  </TapGestureHandler>
 ); }; export default Slot;
  1. 现在,您需要设置开始和完成轻击手势所需的轻击次数。由于第一次轻击元素时,不必对onTap函数进行任何更改,轻击事件的状态将为BEGAN。只有在您连续两次轻击元素后,轻击事件状态才会变为END
... return (
 <TapGestureHandler
 ref={doubleTapRef}
 onHandlerStateChange={onTap}
**+   numberOfTaps={2}**
 >  <SlotWrapper>
  <Filled  filled={filled}  start={start}  />
  </SlotWrapper>
  </TapGestureHandler> );  ...
  1. 要填充一个插槽,用户必须轻击TapGestureHandler两次才能完成轻击事件。但是,您还可以在轻击一次TapGestureHandler时调用一个函数,方法是添加另一个以现有的一个为其子元素的TapGestureHandler。这个新的TapGestureHandler应该等待另一个处理程序进行双击手势,它可以使用doubleTapRef来检查。onTap函数应该重命名为onDoubleTap,这样您就有了一个新的onTap函数来处理单击:
...

const  Slot  = ({ index, filled, handleOnPress }) => {  const [start, setStart] =  React.useState(false);
  const  doubleTapRef  =  React.useRef(null); + const  onTap  =  event  => {**};** - const  onTap  =  event  => { + const  onDoubleTap  =  event  => **{** ... }  return ( + <TapGestureHandler + onHandlerStateChange={onTap} + waitFor={doubleTapRef} + **>**
  <TapGestureHandler
  ref={doubleTapRef} - onHandlerStateChange={onTap} + onHandlerStateChange={onDoubleTap**}**
  numberOfTaps={2}
  > 
 <SlotWrapper>
           <Filled  filled={filled}  start={start}  /> </SlotWrapper>
      </TapGestureHandler>
**+** </TapGestureHandler> ); }

...
  1. 当您仅单击插槽时,动画将开始,因为TapGestureHandler将处于BEGAN状态。双击手势上的动画应该只在状态为ACTIVE而不是BEGAN时开始,这样动画就不会在单击时开始。此外,通过向轻击手势结束时调用的函数添加setTimeout,动画看起来会更加流畅,因为否则两个动画会在彼此之后太快地发生:
...

const  Slot  = ({ index, filled, handleOnPress }) => {  const [start, setStart] =  React.useState(false);
  const  doubleTapRef  =  React.useRef(null);

  const  onTap  =  event  => {};   const  onDoubleTap  =  event  => { - if (event.nativeEvent.state  ===  State.BEGAN) { +    if (event.nativeEvent.state  ===  State.ACTIVE) {        setStart(true);
 }     
     if (event.nativeEvent.state  ===  State.END) {
**+**  setTimeout(() => **{** !filled  &&  handleOnPress(index);
         setStart(false);
**+**  }, 100**);**
     }  }

...

除了具有双击手势来填充插槽之外,具有长按手势也可以改善用户的交互。您可以通过以下步骤添加长按手势:

  1. react-native-gesture-handler导入LongPressGestureHandler
import  React  from  'react'; import { View, Dimensions } from  'react-native'; - import { TapGestureHandler, State } from 'react-native-gesture-handler'; + import { LongPressGestureHandler, TapGestureHandler, State } from  'react-native-gesture-handler'**;** import  styled  from  'styled-components/native'; import  Filled  from  './Filled';

...
  1. 在此处理程序上,您可以设置长按手势的最短持续时间,并设置在此时间段过去后应调用的函数。LongPressGestureHandler处理程序具有状态生命周期,您可以与onDoubleTap函数一起使用:
... const  Slot  = ({ index, filled, handleOnPress }) => {
 ... return ( +  <LongPressGestureHandler + onHandlerStateChange={onDoubleTap} + minDurationMs={500**}**
**+  >** <TapGestureHandler
  onHandlerStateChange={onTap}
  waitFor={doubleTapRef}
  >
 ...  </TapGestureHandler>  +   </LongPressGestureHandler>  ) };

export default Slot;

如果您只想创建一个长按手势,可以使用react-nativereact-native-gesture-handler中可用的可触摸元素上的onLongPress事件处理程序。建议您使用react-native-gesture-handler中的可触摸元素,因为它们将在本机线程中运行,而不是使用 React Native 手势响应系统。

  1. 也许并非所有用户都会理解他们需要使用长按手势来填充一个插槽。因此,您可以使用onTap函数,在单击时调用,向用户提醒此功能。为此,您可以使用适用于 iOS 和 Android 的Alert API,并使用这些平台中的任何一个的本机警报消息。在此警报中,您可以为用户添加一条小消息:
import  React  from  'react'; - import { View, Dimensions } from 'react-native'; + import { Alert, View, Dimensions } from  'react-native'**;** import { LongPressGestureHandler, TapGestureHandler, State } from  'react-native-gesture-handler'; import  styled  from  'styled-components/native'; import  Filled  from  './Filled';

... const  Slot  = ({ index, filled, handleOnPress }) => {  const [start, setStart] =  React.useState(false);
  const  doubleTapRef  =  React.useRef(null);

  const  onTap  =  event  => { + if (event.nativeEvent.state  ===  State.ACTIVE) { +     Alert.alert( + 'Hint', + 'You either need to press the slot longer to make your move', +     ); **+   }**
 }

  ... 

当用户在棋盘上没有使用长按来移动时,将显示警报,从而使用户更容易理解。通过这些最终的添加,游戏界面得到了进一步改进。用户不仅会看到基于其操作的动画,还将被通知他们可以使用哪些手势。

总结

在本章中,我们为使用 React Native 和 Expo 构建的简单井字棋游戏添加了动画和手势。动画是使用 React Native Animated API 和 Expo CLI 以及作为单独包的 Lottie 创建的。我们还为游戏添加了基本和更复杂的手势,这得益于react-native-gesture-handler包在本地线程中运行。

动画和手势为您的移动应用程序的用户界面提供了明显的改进,我们还可以做更多。但是,我们的应用程序还需要向用户请求和显示数据。

之前,我们在 React 中使用了 GraphQL。我们将在下一章中继续构建。在下一章中,您将创建的项目将使用 WebSockets 和 Apollo 在 React Native 应用程序中处理实时数据。

进一步阅读

第十章:使用 React Native 和 Expo 创建实时消息应用程序

与服务器建立实时连接在开发实时消息应用程序时至关重要,因为您希望用户在发送消息后尽快收到消息。您可能在前两章中经历过的是,移动应用程序比 Web 应用程序更直观。当您希望用户来回发送消息时,最好的方法是构建一个移动应用程序,这就是本章将要做的事情。

在这一章中,您将使用 React Native 和 Expo 创建一个实时移动消息应用程序,该应用程序与 GraphQL 服务器连接。通过使用 WebSockets,您可以为 Web 和移动应用程序与服务器创建实时连接,并在应用程序和 GraphQL 服务器之间实现双向数据流。这种连接也可以用于身份验证,使用 OAuth 和 JWT 令牌,这就是您在第七章中所做的事情,使用 React Native 和 GraphQL 构建全栈电子商务应用程序

本章将涵盖以下主题:

  • 使用 Apollo 的 React Native 中的 GraphQL

  • React Native 中的身份验证流程

  • GraphQL 订阅

项目概述

在本章中,我们将使用 React Native 和 Expo 创建一个移动消息应用程序,该应用程序使用 GraphQL 服务器进行身份验证并发送和接收消息。通过使用 Apollo 创建的 WebSocket,可以实时接收消息,因为使用了 GraphQL 订阅。用户需要登录才能通过应用程序发送消息,为此使用了 React Navigation 和 AsyncStorage 构建了身份验证流程,以将身份验证详细信息存储在持久存储中。

构建时间为 2 小时。

入门

我们将在本章中创建的项目是在初始版本的基础上构建的,您可以在 GitHub 上找到:github.com/PacktPublishing/React-Projects/tree/ch10-initial。完整的源代码也可以在 GitHub 上找到:github.com/PacktPublishing/React-Projects/tree/ch10

您需要在移动 iOS 或 Android 设备上安装应用程序 Expo Client,以在物理设备上运行该项目。或者,您可以在计算机上安装 Xcode 或 Android Studio,以在虚拟设备上运行应用程序:

export ANDROID_SDK=**ANDROID_SDK_LOCATION**export PATH=**ANDROID_SDK_LOCATION**/platform-tools:$PATH export PATH=**ANDROID_SDK_LOCATION**/tools:$PATH

ANDROID_SDK_LOCATION的值是您本地计算机上 Android SDK 的路径,可以通过打开 Android Studio 并转到首选项|外观和行为|系统设置|Android SDK来找到。路径在声明 Android SDK 位置的框中列出,看起来像这样:/Users/myuser/Library/Android/sdk

该应用程序是使用 Expo SDK 版本 33.0.0 创建的,因此,您需要确保您在本地机器上使用的 Expo 版本是相似的。由于 React Native 和 Expo 经常更新,请确保您使用此版本,以便本章描述的模式表现如预期。如果您的应用程序无法启动或遇到错误,请参考 Expo 文档以了解有关更新 Expo SDK 的更多信息。

检查初始项目

该项目由两部分组成:一个样板 React Native 应用程序和一个 GraphQL 服务器。React Native 应用程序可以在client目录中找到,而 GraphQL 服务器可以在server目录中找到。对于本章,您需要始终同时运行应用程序和服务器,您只会对client目录中的应用程序进行代码更改。

要开始本章,您需要在clientserver目录中运行以下命令,以安装所有依赖项并启动服务器和应用程序:

npm install && npm start

对于移动应用程序,此命令将在安装依赖项后启动 Expo,并使您能够从终端或浏览器启动项目。在终端中,您可以使用 QR 码在移动设备上打开应用程序,也可以在虚拟设备上打开应用程序。

无论您是使用物理设备还是虚拟 iOS 或 Android 设备打开应用程序,应用程序应该看起来像这样:

初始应用程序包括五个屏幕:AuthLoadingConversationsConversationLoginSettingsConversations屏幕将是初始屏幕,并显示加载消息,而Settings屏幕包含一个不起作用的注销按钮。目前,AuthLoadingConversationLogin屏幕尚不可见,因为您将在本章后面为这些屏幕添加路由。

client目录中,此 React Native 应用程序的项目结构如下,结构类似于您在本书中之前创建的项目:

messaging
|-- client
    |-- .expo
    |-- assets
        |-- icon.png
        |-- splash.png
    |-- Components
        |-- // ...
    |-- node_modules
    |-- Screens
        |-- AuthLoading.js
        |-- Conversation.js
        |-- Conversations.js
        |-- Login.js
        |-- Settings.js
    |-- .watchmanconfig
    |-- App.js
    |-- AppContainer.js
    |-- app.json
    |-- babel.config.js
    |-- package.json

assets目录中,您可以找到用于主屏幕应用程序图标的图像。一旦您在移动设备上安装了此应用程序,启动应用程序时将显示用作启动画面的图像。有关应用程序的详细信息,如名称、描述和版本,都放在app.json中,而babel.config.js包含特定的 Babel 配置。

App.js文件是您的应用程序的实际入口点,其中导入并返回AppContainer.js文件。在AppContainer中,定义了此应用程序的所有路由,并且AppContext将包含应该在整个应用程序中可用的信息。

此应用程序的所有组件都位于ScreensComponents目录中,其中第一个包含由屏幕呈现的组件。这些屏幕的子组件可以在Components目录中找到,其结构如下:

|-- Components
    |-- Button
        |-- Button.js
    |-- Conversation
        |-- ConversationActions.js
        |-- ConversationItem.js
    |-- Message
        |-- Message.js
    |-- TextInput
        |-- TextInput.js

GraphQL 服务器位于:http://localhost:4000/graphql,GraphQL Playground 将在此处可见。通过这个 Playground,您可以查看 GraphQL 服务器的模式,并审查所有可用的查询、变异和订阅。虽然您不会对服务器进行任何代码更改,但了解模式及其工作原理是很重要的。

服务器有两个查询,一个是通过使用userName参数作为标识符来检索对话列表,另一个是检索单个对话。这些查询将返回Conversation类型,其中包括iduserNameMessage类型的消息列表。

在这个 GraphQL 服务器上,可以找到两个变异,一个是登录用户,另一个是发送消息。用户可以通过以下方式登录:

  • 用户名test

  • 密码test

最后,有一个订阅将检索添加到对话中的消息。这个订阅将增强查询,并可以发送到一个文档中以检索单个对话。

使用 React Native 和 Expo 创建实时消息应用程序

移动应用程序受欢迎的原因之一是它们通常提供实时数据,例如更新和通知。使用 React Native 和 Expo,您可以创建能够使用 WebSockets 处理实时数据的移动应用程序,例如与 GraphQL 服务器同步。在本章中,您将向 React Native 应用程序添加 GraphQL,并为该应用程序添加额外功能,使其能够处理实时数据。

使用 Apollo 在 React Native 中使用 GraphQL

第七章中,使用 React Native 和 GraphQL 构建全栈电子商务应用程序,您已经为 Web 应用程序建立了与 GraphQL 服务器的连接;同样,在本章中,您将为移动应用程序中的数据使用 GraphQL 服务器。要在 React Native 应用程序中使用 GraphQL,您可以使用 Apollo 来使开发人员的体验更加顺畅。

在 React Native 中设置 Apollo

react-apollo包,你已经在 React web 应用程序中使用过 Apollo,也可以在 React Native 移动应用程序中使用。这与 React 和 React Native 的标语“学一次,随处编写”完美契合。但在将 Apollo 添加到应用程序之前,重要的是要知道,当你在移动设备上使用 Expo 应用程序运行应用程序时,不支持本地主机请求。该项目的本地 GraphQL 服务器正在运行在http://localhost:4000/graphql,但为了能够在 React Native 应用程序中使用这个端点,你需要找到你的机器的本地 IP 地址。

要找到你的本地 IP 地址,你需要根据你的操作系统做以下操作:

  • 对于 Windows:打开终端(或命令提示符)并运行这个命令:
ipconfig

这将返回一个列表,如下所示,其中包含来自本地机器的数据。在这个列表中,你需要查找IPv4 Address字段:

  • 对于 macOS:打开终端并运行这个命令:
ipconfig getifaddr en0

运行这个命令后,你的机器的本地Ipv4 Address将被返回,看起来像这样:

192.168.1.107

获取本地 IP 地址后,你可以使用这个地址来为 React Native 应用程序设置 Apollo 客户端。为了能够使用 Apollo 和 GraphQL,你需要使用以下命令从npm安装npm中的几个包。你需要在一个单独的终端标签中从client目录中执行这个命令:

cd client && npm install graphql apollo-client apollo-link-http apollo-cache-inmemory react-apollo

App.js文件中,你现在可以使用apollo-client来创建你的 GraphQL 客户端,使用apollo-link-http来设置与本地 GraphQL 服务器的连接,并使用apollo-cache-inmemory来缓存你的 GraphQL 请求。此外,ApolloProvider组件将使用你创建的客户端,使 GraphQL 服务器对所有嵌套在此提供程序中的组件可用。必须使用本地 IP 地址来创建API_URL的值,前缀为http://,后缀为:4000/graphql,指向正确的端口和端点,使其看起来像http://192.168.1.107:4000/graphql

为了做到这一点,将以下行添加到App.js中:

import React from 'react';
import AppContainer from './AppContainer';
+ import { ApolloClient } from 'apollo-client';
+ import { InMemoryCache } from 'apollo-cache-inmemory';
+ import { HttpLink } from 'apollo-link-http';
+ import { ApolloProvider } from 'react-apollo';

+ const API_URL = 'http://192.168.1.107:4000/graphql';

+ const cache = new InMemoryCache();
+ const client = new ApolloClient({
+   link: new HttpLink({
+     uri: API_URL,
+   }),
+   cache
+ });

- const App = () => <AppContainer />;

+ const App = () => (
+  <ApolloProvider client={client}>
+     <AppContainer />
+  </ApolloProvider>
+ );

export default App;

现在,您可以从ApolloProvider中的任何嵌套组件发送带有查询和变异的文档,但是您还不能在文档中发送订阅。订阅的支持并不是开箱即用的,需要为客户端 React Native 应用程序和 GraphQL 服务器之间的实时双向连接设置 WebSocket。这将在本章后面完成,之后您将为应用程序添加认证。

在本节的下一部分中,您将使用 Apollo 从 GraphQL 服务器获取数据,您刚刚在本节中将其链接到 Apollo Client。

在 React Native 中使用 Apollo

如果您查看应用程序,您会看到有两个选项卡;一个显示Conversations屏幕,另一个显示Settings屏幕。Conversations屏幕现在显示文本Loading...,应该显示从 GraphQL 服务器返回的对话。用于显示对话的组件已经创建,可以在client/Components/Conversation目录中找到,而请求对话的逻辑仍需要创建。

要添加 Apollo,请按照以下步骤:

  1. 第一步是从react-apollo中导入Query组件到client/Screens/Conversations.js文件中,您将使用它向 GraphQL 服务器发送文档。这个Query组件将使用GET_CONVERSATIONS查询,ConversationItem组件也必须被导入:
import  React  from 'react'; import { FlatList, Text, View } from 'react-native'; import  styled  from 'styled-components/native'; + import { Query } from 'react-apollo';  + import { GET_CONVERSATIONS } from '../constants'; + import  ConversationItem  from '../Components/Conversations/ConversationItem'; ... const  Conversations  = () => (
 ...
  1. Conversations屏幕现在应该使用Query组件请求GET_CONVERSATIONS查询。当请求未解决时,将显示加载消息。当向 GraphQL 服务器的请求解决时,样式化的Flatlist将返回导入的ConversationItem组件列表。样式化的Flatlist已经创建,可以在该文件底部的ConversationsList组件中找到:
...

const  Conversations  = () => (  <ConversationsWrapper> - <ConversationsText>Loading...</ConversationsText> +   <Query query={GET_CONVERSATIONS}> +     {({ loading, data }) => { +       if (loading) { +         return <ConversationsText>Loading...</ConversationsText> +       } +       return ( +         <ConversationsList +           data={data.conversations} +           keyExtractor={item => item.userName} +           renderItem={({ item }) => <ConversationItem item={item} /> } +         /> +       ); +     }} +   </Query>  </ConversationsWrapper> ); export default Conversations;

Conversations屏幕最初显示加载消息,当发送带有查询的文档时;在查询返回数据后,将显示ConversationsList组件。该组件呈现显示查询数据的ConversationItem组件。

  1. 当您尝试点击任何对话时,除了看到一个改变不透明度的小动画之外,什么也不会发生。这是因为ConversationItem组件是一个样式化的TouchableOpacity,当您点击它时可以作为一个被调用的函数传递。用于导航到对话的函数可以从Conversations屏幕中可用的navigation属性中创建。这个属性应该作为一个属性传递给ConversationItem
...

- const  Conversations  = () => ( + const  Conversations  = ({ navigation ) => **(** <ConversationsWrapper>
  <ConversationsText>Loading...</ConversationsText>
 <Query query={GET_CONVERSATIONS}> {({ loading, data }) => { if (loading) { return <ConversationsText>Loading...</ConversationsText> } return ( <ConversationsList data={data.conversations} keyExtractor={item => item.userName} -             renderItem={({ item }) => <ConversationItem item={item} /> }
+ renderItem={({ item }) => <ConversationItem item={item} navigation={navigation} />}  /> ); }} </Query>  </ConversationsWrapper> ); export default Conversations;
  1. ConversationItem组件现在可以在点击TouchableOpacity时导航到Conversation屏幕;这个组件可以在client/Components/Conversation/ConversationItem.js文件中找到,其中应该解构并使用navigation属性来调用onPress处理程序上的navigate函数。这个项目被传递给navigate函数,以便这些数据可以在Conversation屏幕中使用:
import  React  from 'react'; import { Platform, Text, View, TouchableOpacity } from 'react-native'; import { Ionicons }  from '@expo/vector-icons'; import  styled  from 'styled-components/native';

... - const ConversationItem = ({ item }) => ( + const  ConversationItem  = ({ item, navigation }) => ( -   <ConversationItemWrapper> +   <ConversationItemWrapper +     onPress={() =>  navigation.navigate('Conversation', { item })} **+   >**
      <ThumbnailWrapper>
        ... 
  1. 这将从client/Screens/Conversation.js文件中导航到Conversation屏幕,其中应该显示完整的对话。要显示对话,您可以使用刚刚传递到此屏幕的项目数据,或者发送另一个包含检索对话的查询的文档到 GraphQL 服务器。为了确保显示最新的数据,Query组件可以用来发送一个查询,使用从navigation属性中的userName字段来检索对话。为了做到这一点,您需要导入Query组件、Query使用的GET_CONVERSATION查询,以及用于显示对话中消息的Message组件:
import  React  from 'react'; import { Dimensions, ScrollView, Text, FlatList, View } from 'react-native'; + import { Query } from 'react-apollo'; import  styled  from 'styled-components/native'; + import  Message  from '../Components/Message/Message'; + import { GET_CONVERSATION } from '../constants'**;**

... const  Conversation  = () => (  ...
  1. 在此之后,您可以将Query组件添加到Conversation屏幕,并让它使用从navigation属性中检索到的userNameGET_CONVERSATION查询。一旦查询解析,Query组件将返回一个带有名为messages的字段的data对象。这个值可以传递给FlatList组件。在这个组件中,您可以遍历这个值并返回显示对话中所有消息的Message组件。FlatList已经被样式化,并且可以在文件底部找到,命名为MessagesList
... - const  Conversation  = () => { + const  Conversation  = ({ navigation }) => { +   const  userName  =  navigation.getParam('userName', '');  + return **(** <ConversationWrapper>  -       <ConversationBodyText>Loading...</ConversationBodyText> +       <Query query={GET_CONVERSATION} variables={{ userName }}>        <ConversationBody> +         {({ loading, data }) => { +           if (loading) { +             return <ConversationBodyText>Loading...</ConversationBodyText>; +           } +           const { messages } = data.conversation;
  +           <MessagesList
+ data={messages}
+ keyExtractor={item  =>  String(item.id)}
+ renderItem={({ item }) => (
+ <Message  align={item.userName === 'me' ? 'left' : 'right'}>
+ {item.text}
+ </Message>
+ )}
+ />  +         }}        </ConversationBody>**+     </Query>**  <ConversationActions userName={userName}  />
 </ConversationWrapper>
 ); + }; export default Conversation;

现在正在显示来自这次对话的所有接收到的消息,并且可以使用屏幕底部的表单向对话中添加新消息。

根据您运行应用程序的设备,运行 iOS 设备的ConversationConversation屏幕应该看起来像这样:

然而,要发送消息,必须向 GraphQL 服务器发送带有突变的文档,并且用户必须经过身份验证。如何处理此突变的身份验证将在下一节中讨论,身份验证流程将被添加。

React Native 中的身份验证

通常,移动应用程序的身份验证类似于在 Web 应用程序中处理身份验证,尽管存在一些细微差异。在移动应用程序上对用户进行身份验证的流程如下:

  1. 用户打开您的应用程序

  2. 显示检查持久存储中的任何身份验证信息的加载屏幕

  3. 如果经过身份验证,用户将被转发到应用程序的主屏幕;否则,他们将被转发到登录屏幕,用户可以在那里登录

  4. 每当用户退出登录时,身份验证详细信息将从持久存储中删除

这种流程的最大缺点之一是移动设备不支持本地存储或会话存储,因为这些持久存储解决方案与浏览器绑定。相反,您需要使用 React Native 中的AsyncStorage库在 iOS 和 Android 上实现持久存储。在 iOS 上,它将使用本机代码块为您提供AsyncStorage提供的全局持久存储,而在运行 Android 的设备上,将使用基于 RockDB 或 SQLite 的存储。

对于更复杂的用法,建议在AsyncStorage的顶层使用抽象层,因为AsyncStorage不支持加密。此外,如果要使用AsyncStorage为应用程序存储大量信息,键值系统的使用可能会导致性能问题。iOS 和 Android 都会对每个应用程序可以使用的存储量设置限制。

使用 React Navigation 进行身份验证

要设置我们之前描述的身份验证流程,你将再次使用 React Navigation 包。之前,你使用了 React Navigation 中的不同类型的导航器,但没有使用SwitchNavigator。使用这种导航器类型,你只能一次显示一个屏幕,并且可以使用navigation属性导航到其他屏幕。SwitchNavigator应该是你的应用程序的主要导航器,其他导航器如StackNavigator可以嵌套在其中。

向 React Native 应用程序添加身份验证涉及执行以下步骤:

  1. 使用这种导航器类型的第一步是从react-navigation导入createSwitchNavigator,就像你将其他导航器导入到client/AppContainer.js文件中一样。还要导入登录屏幕的屏幕组件,可以在client/Screens/Login.js中找到:
import  React  from 'react'; import { Platform } from 'react-native'; import { Ionicons }  from '@expo/vector-icons'; import {  + createSwitchContainer,    createAppContainer  } from 'react-navigation'; import { createStackNavigator } from 'react-navigation-stack';
import { createBottomTabNavigator } from 'react-navigation-tabs';
import  Conversations  from './Screens/Conversations'; import  Conversation  from './Screens/Conversation'; import  Settings  from './Screens/Settings'; + import  Login  from './Screens/Login'**;** const  ConversationsStack  =  createStackNavigator({
  ... 
  1. 不要在此文件底部用createAppContainer包装TabNavigator,而是需要返回SwitchNavigator。要创建这个,你需要使用在上一步中导入的createSwitchNavigator。这个导航器包含Login屏幕和TabNavigator,后者是这个应用程序的主屏幕。为了让用户只在经过身份验证时看到主屏幕,Login屏幕需要成为初始屏幕:
...

+ const SwitchNavigator = createSwitchNavigator( +   { +     Main: TabNavigator, +     Auth: Login +   }, +   { +     initialRouteName: 'Auth', +   } + ); - export default createAppContainer(TabNavigator); + export default createAppContainer(SwitchNavigator);

现在在应用程序中显示的Login屏幕只有在填写正确的身份验证详细信息时才会切换到TabNavigator

  1. 但是,此表单首先需要连接到 GraphQL 服务器,以接收身份验证所需的 JWT 令牌。Login屏幕的组件已经有一个表单,但是提交此表单尚未调用任何函数来对用户进行身份验证。因此,你需要使用react-apollo中的Mutation组件,并让该组件向 GraphQL 服务器发送包含正确变异的文档。需要添加到此组件的变异可以在constants.js文件中找到,称为LOGIN_USER。要提交表单,应该在用户按下Button时调用Mutation组件返回的loginUser函数:
import React from 'react';
import { View, TextInput } from 'react-native';
import styled from 'styled-components/native';
+ import { Mutation } from 'react-apollo';
import Button from '../Components/Button/Button';
+ import { LOGIN_USER } from '../constants';

... const Login = () => {
 const [userName, setUserName] = React.useState('');
 const [password, setPassword] = React.useState('');

 return (
+  <Mutation mutation={LOGIN_USER}>
+    {loginUser => (
       <LoginWrapper>
          <StyledTextInput
            onChangeText={setUserName}
            value={userName}
            placeholder='Your username'
            textContentType='username'
          />
          <StyledTextInput
            onChangeText={setPassword}
            value={password}
            placeholder='Your password'
            textContentType='password'
          />
          <Button
            title='Login'
+           onPress={() => loginUser({ variables: { userName, password } })}
          />
        </LoginWrapper>
+    )}
+  </Mutation>
 );
};

export default Login;

两个TextInput组件都是受控组件,并使用useState钩子来控制它们的值。用于此变异的userNamepassword常量都使用两个变量进行身份验证,这两个变量也是userNamepassword

... export  const  LOGIN_USER  =  gql`
 mutation loginUser($userName: String!, $password: String!) {
   loginUser(userName: $userName, password: $password) {
     userName
     token
   }
 }
`;
...
  1. 除了loginUser函数之外,该函数发送了一个文档中的变化,Mutation组件还会返回由 GraphQL 服务器返回的loadingerrordata变量。loading变量可用于向用户传达文档已发送到服务器,而当 GraphQL 服务器对此文档做出响应时,将返回dataerror变量:
import React from 'react';
import { View, TextInput } from 'react-native';
import styled from 'styled-components/native';
import { Mutation } from 'react-apollo';
import Button from '../Components/Button/Button';
import { LOGIN_USER } from '../constants'; ... const Login = () => {
 const [userName, setUserName] = React.useState('');
 const [password, setPassword] = React.useState('');

 return (
  <Mutation mutation={LOGIN_USER}>
-    {loginUser => (
+    {(loginUser, { loading }) => (  <LoginWrapper>
          <StyledTextInput
            onChangeText={setUserName}
            value={userName}
            placeholder='Your username'
            textContentType='username'
          />
          <StyledTextInput
            onChangeText={setPassword}
            value={password}
            placeholder='Your password'
            textContentType='password'
          />
          <Button
-           title='Login'
+           title={loading ? 'Loading...' : 'Login'}
            onPress={() => loginUser({ variables: { userName, password } })}
          />
       </LoginWrapper>
    }}
   </Mutation>
 );
};

export default Login;

当文档发送到 GraphQL 服务器并且尚未返回响应时,这将会改变表单底部按钮的文本为Loading...

  1. 要使用error变量在填写错误凭据时显示错误消息,您不会从Mutation组件的输出中解构该变量。相反,错误变量将从loginUser函数返回的Promise中检索。为了显示错误,您将使用error变量中可用的graphQLErrors方法,该方法返回一个数组(因为可能存在多个错误),并在 React Native 的Alert组件中呈现错误:
import React from 'react';
- import { View, TextInput } from 'react-native';
+ import { Alert, View, TextInput } from 'react-native';
import styled from 'styled-components/native';
import { Mutation } from 'react-apollo';
import Button from '../Components/Button/Button';
import { LOGIN_USER } from '../constants';

...

 <Button
   title={loading ? 'Loading...' : 'Login'}
   onPress={() => {     loginUser({ variables: { userName, password } })
**+** .catch(error  => {
+ Alert.alert(
+         'Error',
+         error.graphQLErrors.map(({ message }) =>  message)[0] +        );
+    });
   }}
 />

...
  1. 当使用正确的用户名和密码组合时,应使用data变量来存储由 GraphQL 服务器返回的 JWT 令牌。就像从loginUser函数中检索的error变量一样,data变量也可以从这个Promise中检索。这个令牌可用于data变量,并且应该被安全地存储,可以使用AsyncStorage库来实现:
import  React  from 'react';  - import { Alert, View, TextInput } from 'react-native';
+ import { AsyncStorage, Alert, View, TextInput } from 'react-native';  import  styled  from 'styled-components/native';  import { Mutation } from 'react-apollo';  import  Button  from '../Components/Button/Button';  import { LOGIN_USER } from '../constants'; ... const  Login  = ({ navigation }) => {
  ... 
  <Button
    title={loading ? 'Loading...' : 'Login'}
    onPress={() => {      loginUser({ variables: { userName, password } }) +       .then(({data}) => { +         const { token } = data.loginUser; +         AsyncStorage.setItem('token', token);  +       })
        .catch(error  => {         if (error) {
            Alert.alert(
              'Error',
              error.graphQLErrors.map(({ message }) =>  message)[0], );
          }
        });
      }}
    /> 
    ...
  1. 存储令牌后,用户应被重定向到主应用程序,该应用程序可以在Main路由中找到,并表示与TabNavigator相关联的屏幕。要重定向用户,您可以使用SwitchNavigator通过传递给Login组件的navigation属性。由于使用AsyncStorage存储东西应该是异步的,因此应该从AsyncStorage返回的Promise的回调中调用导航函数:
import  React  from 'react';  import { AsyncStorage, Alert, View, TextInput } from 'react-native';  import  styled  from 'styled-components/native';  import { Mutation } from 'react-apollo';  import  Button  from '../Components/Button/Button';  import { LOGIN_USER } from '../constants'; ... - const  Login  = () => { + const  Login  = ({ navigation }) => { ... 
<Button
 title={loading ? 'Loading...' : 'Login'}
 onPress={() => { loginUser({ variables: { userName, password } })  .then(({data}) => {    const { token } = data.loginUser;
**-** AsyncStorage.setItem('token', token) +   AsyncStorage.setItem('token', token).then(value  => { +     navigation.navigate('Main'); +   });    })
  .catch(error  => { if (error) { Alert.alert( 'Error', error.graphQLErrors.map(({ message }) =>  message)[0], );
    }
  });
 }} />

...

然而,这只完成了认证流程的一部分,因为当应用程序首次渲染时,Login屏幕将始终显示。这样,用户始终必须使用他们的认证详细信息登录,即使他们的 JWT 令牌存储在持久存储中。

要检查用户以前是否已登录,必须向SwitchNavigator中添加第三个屏幕。这个屏幕将确定用户是否在持久存储中存储了令牌,如果有,用户将立即重定向到Main路由。如果用户以前没有登录,则会重定向到你刚刚创建的Login屏幕:

  1. 确定是否在持久存储中存储了身份验证令牌的中间屏幕,即AuthLoading屏幕,应该在App.js中添加到SwitchNavigator中。这个屏幕也应该成为导航器提供的初始路由:
import  React  from 'react';  import { Platform } from 'react-native';  import { Ionicons }  from '@expo/vector-icons';  import {   createSwitchNavigator,
  createAppContainer } from 'react-navigation';
import { createStackNavigator } from 'react-navigation-stack';
import { createBottomTabNavigator } from 'react-navigation-tabs';  import  Conversations  from './Screens/Conversations';  import  Conversation  from './Screens/Conversation';  import  Settings  from './Screens/Settings';  import  Login  from './Screens/Login';  + import  AuthLoading  from './Screens/AuthLoading'; const  ConversationsStack  =  createStackNavigator({

  ...   const  SwitchNavigator  =  createSwitchNavigator(
  {
    Main:  TabNavigator,    Login,
**+   AuthLoading,**
  },
  {
-   initialRouteName: 'Login',
+   initialRouteName: 'AuthLoading',
  }
);export default createAppContainer(SwitchNavigator);
  1. 在这个AuthLoading屏幕中,应该从持久存储中检索身份验证令牌,然后处理导航到LoginMain屏幕。这个屏幕可以在client/Screens/AuthLoading.js文件中找到,那里只添加了一个简单的界面。可以使用AsyncStorage库中的getItem方法来检索令牌,并且应该从useEffect Hook 中调用,以便在首次加载AuthLoading屏幕时检索它。从callbackPromise返回的getItem中,使用navigation属性的navigate函数来实际导航到这些屏幕中的任何一个:
import  React  from 'react';  - import { Text, View } from 'react-native'; + import { AsyncStorage, Text, View } from 'react-native'; import  styled  from 'styled-components/native'; ... - const AuthLoading = () => ( + const  AuthLoading  = ({ navigation }) => { + React.useEffect(() => { + AsyncStorage.getItem('token').then(value  => { +       navigation.navigate(value  ? 'Main'  : 'Auth'); +     }); +   }, [navigation]); +   return **(** <AuthLoadingWrapper> <AuthLoadingText>Loading...</AuthLoadingText> </AuthLoadingWrapper>
 ); **+ };**

export default AuthLoading;
  1. 完成身份验证流程的最后一步是通过从持久存储中删除令牌来为用户添加注销应用的可能性。这是在client/Screens/Settings.js文件中完成的。这会呈现TabNavigator中的Settings屏幕。Settings屏幕上有一个绿色按钮,你可以在上面设置onPress事件。

AsyncStorageremoveItem方法可用于从持久存储中删除令牌,并返回Promise。在这个Promise的回调中,你可以再次处理导航,以返回到Login屏幕,因为你不希望未经身份验证的用户在你的应用中。

import  React  from 'react';  - import { Text, View } from 'react-native'; + import { AsyncStorage, Text, View } from 'react-native';  import  styled  from 'styled-components/native';  import  Button  from '../Components/Button/Button'; ... - const Settings = () => ( + const  Settings  = ({ navigation }) => **(**
      <SettingsWrapper> - <Button title='Log out' /> +       <Button +         title='Log out' +         onPress={() => { +           AsyncStorage.removeItem('token').then(() =>  navigation.navigate('AuthLoading')); +         }} **+       />**
 </SettingsWrapper>
 );

export default Settings;

通过添加注销功能,您已经完成了使用 GraphQL 服务器返回的 JWT 令牌的身份验证流程。这可以通过在“登录”屏幕上填写表单来请求。如果身份验证成功,用户将被重定向到“主”屏幕,并且通过“设置”屏幕上的“注销”按钮,用户可以注销并将被重定向回“登录”屏幕。最终的身份验证流程现在看起来可能是这样的,具体取决于您在哪个操作系统上运行此应用程序。以下屏幕截图是从运行 iOS 的设备上获取的:

然而,为了 GraphQL 服务器知道这个用户是否经过身份验证,您需要向其发送一个验证令牌。在本节的下一部分,您将学习如何通过使用 JSON Web Token(JWT)来实现这一点。

向 GraphQL 服务器发送身份验证详细信息

现在存储在持久存储中的身份验证详细信息也应该添加到 Apollo Client 中,以便在每个文档中与 GraphQL 服务器一起发送。这可以通过扩展 Apollo Client 的设置与令牌信息来完成。由于令牌是 JWT,因此应该以Bearer为前缀:

  1. 您需要安装一个 Apollo 包来处理向“上下文”添加值。setContext方法来自apollo-link-context包,您可以从npm安装该包:
npm install apollo-link-context
  1. 应该将apollo-link-context包导入到client/App.js文件中,其中创建了 Apollo 客户端。您需要分开为客户端创建HttpLink对象的构造,因为这个对象需要与创建的上下文结合使用:
import  React  from 'react';  import { ApolloClient } from 'apollo-client';  import { InMemoryCache } from 'apollo-cache-inmemory'; **+ import { setContext }  from 'apollo-link-context';** import { HttpLink } from 'apollo-link-http';  import { ApolloProvider } from 'react-apollo';  import  AppContainer  from './AppContainer'; const API_URL = '..'; + const  httpLink  =  new  HttpLink({ + uri: API_URL,**+ });** const  cache  =  new  InMemoryCache(); const  client  =  new  ApolloClient({ - link: new HttpLink({ -   uri: API_URL, - }), + link:  httpLink**,**
 cache, }); const  App  = () => (
 ...
  1. 之后,您可以使用setContext()方法来扩展发送到 GraphQL 服务器的标头,以便还可以包括可以从持久存储中检索的令牌。由于从AsyncStorage获取项目也是异步的,因此应该异步使用此方法。将返回的令牌必须以Bearer为前缀,因为 GraphQL 服务器期望以该格式接收 JWT 令牌:
import React from 'react';
+ import { AsyncStorage } from 'react-native';
import AppContainer from './AppContainer';
import { ApolloClient } from 'apollo-client';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { setContext } from 'apollo-link-context';
import { HttpLink } from 'apollo-link-http';
import { ApolloProvider } from 'react-apollo';

const API_URL = '...';

const  httpLink  =  new  HttpLink({
  uri:  API_URL,  }); 
+ const  authLink  =  setContext(async (_, { headers }) => { +   const  token  =  await  AsyncStorage.getItem('token'); +   return { +     headers: { +       ...headers, +       authorization:  token  ?  `Bearer ${token}`  : '',  +     }
+   };
+ });  ...
  1. 在创建 Apollo Client 时用于link字段的httpLink现在应该与authLink结合,以便从AsyncStorage检索到的令牌在发送请求到 GraphQL 服务器时被添加到标头中:
...

const  cache  =  new  InMemoryCache(); const  client  =  new  ApolloClient({ - link: httpLink,
+ link:  authLink.concat(httpLink),  cache }); const  App  = () => (
  ...

现在,任何传递给 GraphQL 服务器的文档都可以使用通过应用程序登录表单检索到的令牌,这是在下一节中使用变异发送消息时所需的内容。

使用 Apollo 在 React Native 中处理订阅

在您可以继续并发送包含变异的文档到 GraphQL 服务器之前,我们需要设置 Apollo 以便处理订阅。为了处理订阅,需要为您的应用程序设置一个 WebSocket,这样可以在 GraphQL 服务器和您的应用程序之间建立实时的双向连接。这样,当您使用这个移动应用程序发送或接收消息时,您将收到即时反馈。

为 GraphQL 订阅设置 Apollo 客户端

要在您的 React Native 应用程序中使用订阅,您需要添加更多的软件包到项目中,例如,使其可能添加 WebSocket。这些软件包如下:

npm install apollo-link-ws subscriptions-transport-ws apollo-utilities

apollo-link-ws软件包帮助您创建到运行订阅的 GraphQL 服务器的链接,就像apollo-link-http为查询和变异所做的那样。subscriptions-transport-ws是运行apollo-link-ws所需的软件包,而apollo-utilities被添加以使用这些软件包上可用的方法,以便您可以将有关订阅的请求与查询或变异的请求分开。

安装这些软件包后,您需要按照以下步骤在应用程序中使用订阅:

  1. 您可以使用apollo-link-ws来添加链接到 GraphQL 服务器的创建。GraphQL 服务器的 URL 应该以ws://开头,而不是http://,因为它涉及与 WebSocket 的连接。在您的机器上运行的 GraphQL 服务器的 URL 看起来像ws://192.168.1.107/graphql,而不是http://192.168.1.107/graphql,必须添加到SOCKET_URL常量中:
import  React  from 'react'; import { AsyncStorage } from 'react-native'; import { ApolloClient } from 'apollo-client';  import { InMemoryCache } from 'apollo-cache-inmemory'; import { setContext } from 'apollo-link-context'; import { HttpLink } from 'apollo-link-http';  + import { split } from 'apollo-link';  import { ApolloProvider } from 'react-apollo';  import  AppContainer  from './AppContainer'; const API_URL = '...';
**+ const SOCKET_URL = 'ws://192.168.1.107/graphql';** ...

+ const  wsLink  =  new  WebSocketLink({ +   uri: SOCKET_URL,  +   options: { +     reconnect:  true, +   },
+ });

...
  1. 使用splitgetMainDefinition方法,可以通过将查询和变异与订阅分开来区分对 GraphQL 服务器的不同请求。这样,只有包含订阅的文档才会使用 WebSocket 发送,而查询和变异将使用默认流程:
import  React  from 'react'; import { AsyncStorage } from 'react-native'; import { ApolloClient } from 'apollo-client';  import { InMemoryCache } from 'apollo-cache-inmemory'; import { setContext } from 'apollo-link-context'; import { HttpLink } from 'apollo-link-http';  import { split } from 'apollo-link'; + import { WebSocketLink } from 'apollo-link-ws';  + import { getMainDefinition } from 'apollo-utilities';  import { ApolloProvider } from 'react-apollo';  import  AppContainer  from './AppContainer'; ... + const  link  =  split( +   ({ query }) => { +     const  definition  =  getMainDefinition(query);
+ +     return ( +       definition.kind  === 'OperationDefinition'  && definition.operation  === 'subscription' +     );
+   },
+   wsLink, +   httpLink,
+ );

const  cache  =  new  InMemoryCache(); const  client  =  new  ApolloClient({ - link: authLink.concat(httpLink),
+ link: authLink.concat(link),
 cache,
});

const  App  = () => (
 ...

现在 Apollo 的设置也支持订阅,您将在本节的下一部分中添加,其中Conversations屏幕将填充实时数据。

将订阅添加到 React Native

在您的本地 GraphQL 服务器上运行的服务器支持查询和订阅,以便您可以从特定用户返回对话。查询将返回完整的对话,而订阅将返回可能已发送或接收到的对话中的任何新消息。目前,Conversation屏幕只会发送一个带有查询的文档,如果您点击Conversations屏幕上显示的任何对话,它将返回与用户的对话。

订阅可以以多种方式添加到您的应用程序中;使用react-apollo中的Subscription组件是最简单的方法。但由于您已经使用client/Screens/Conversation.js中的Query组件检索对话,因此可以扩展Query组件以支持订阅:

  1. Conversation屏幕添加订阅的第一步是将屏幕拆分为多个组件。您可以通过在client/Components/Conversation目录中创建一个名为ConversationBody的新组件来实现这一点。该文件应该被命名为ConversationBody.js,并包含以下代码:
import  React  from 'react';  import  styled  from 'styled-components/native';  import { Dimensions, ScrollView, FlatList } from 'react-native';  import  Message  from '../Message/Message';  const  ConversationBodyWrapper  =  styled(ScrollView)`
 width: 100%; padding: 2%;
 display: flex; height: ${Dimensions.get('window').height * 0.6}; `; const  MessagesList  =  styled(FlatList)`
 width: 100%; `; const  ConversationBody  = ({  userName, messages }) => {  return ( <ConversationBodyWrapper> <MessagesList data={messages} keyExtractor={item  =>  String(item.id)} renderItem={({ item }) => ( <Message  align={item.userName === 'me' ? 'left' : 'right'}> {item.text} </Message> )} /> </ConversationBodyWrapper>
 ); };  export  default  ConversationBody;
  1. 创建了这个新组件之后,应该将其导入到client/Screens/Conversation.js文件中的Conversation屏幕中,以取代该文件中已经存在的ContainerBody组件。这也意味着一些导入变得过时,ContainerBody样式组件也可以被删除:
import  React  from 'react';  - import { Dimensions, ScrollView, Text, FlatList, View } from 'react-native';  + import { Text, View } from 'react-native';  import { Query } from 'react-apollo';  import  styled  from 'styled-components/native';  - import  Message  from '../Components/Message/Message'; + import ConversationBody from '../Components/Conversation/ConversationBody'; import { GET_CONVERSATION } from '../constants';   ... const  Conversation  = ({ navigation }) => { const  userName  =  navigation.getParam('userName', ''); return ( <ConversationWrapper> <Query query={GET_CONVERSATION} variables={{ userName }}> -       <ConversationBody>   {({ loading, data }) => { if (loading) { return <ConversationBodyText>Loading...</ConversationBodyText>; } const { messages } = data.conversation;  -           return ( -             <MessagesList
- data={messages}
- keyExtractor={item  =>  String(item.id)}
- renderItem={({ item }) => (
- <Message  align={item.userName === 'me' ? 'left' : 'right'}>
- {item.text}
- </Message>
- )}
- /> -           ); -         }} +         return <ConversationBody messages={messages} userName={userName} /> }} -     </ConversationBody>   </Query>  <ConversationActions userName={userName}  />
 </ConversationWrapper>
 ); };

export default Conversation;
  1. 现在,可以将检索订阅的逻辑添加到Query组件中,通过从中获取subscribeToMore方法。这个方法应该传递给ConversationBody组件,在那里它将被调用,从而检索发送或接收到的任何新消息:
 ...

  return ( <ConversationWrapper> <Query query={GET_CONVERSATION} variables={{ userName }}> -       {({ loading, data }) => {
+       {({ subscribeToMore, loading, data }) => {
 if (loading) { return <ConversationBodyText>Loading...</ConversationBodyText>; } const { messages } = data.conversation;  -         return <ConversationBody messages={messages} userName={userName} />
+         return (
+           <ConversationBody
+             messages={messages}
+             userName={userName}
+             subscribeToMore={subscribeToMore}
+           /> }} </Query>  <ConversationActions userName={userName}  />
 </ConversationWrapper>
 ); };
  1. ConversationBody组件中,现在可以使用subscribeToMore方法通过订阅来检索添加到对话中的任何新消息。要使用的订阅称为MESSAGES_ADDED,可以在client/constants.js文件中找到。它以userName作为变量:
import  React  from 'react';  import  styled  from 'styled-components/native';  import { Dimensions, ScrollView, FlatList } from 'react-native';  import  Message  from '../Message/Message';  + import { MESSAGE_ADDED } from '../../constants'; ... - const  ConversationBody  = ({  userName, messages }) => { + const  ConversationBody  = ({ subscribeToMore, userName, messages }) => **{**  return ( <ConversationBodyWrapper> <MessagesList data={messages} keyExtractor={item  =>  String(item.id)} renderItem={({ item }) => ( <Message  align={item.userName === 'me' ? 'left' : 'right'}> {item.text} </Message> )} /> </ConversationBodyWrapper>
 ); };

export default ConversationBody;
  1. 在导入订阅并从 props 中解构subscribeToMore方法之后,可以添加检索订阅的逻辑。应该从useEffect Hook 中调用subscribeToMore,并且仅当ConversationBody组件首次挂载时。任何新添加的消息都将导致Query组件重新渲染,这将使ConversationBody组件重新渲染,因此在useEffect Hook 中不需要检查任何更新:
... const  ConversationBody  = ({ subscribeToMore, userName, messages }) => { +  React.useEffect(() => { +    subscribeToMore({ +      document:  MESSAGE_ADDED, +      variables: { userName }, +      updateQuery: (previous, { subscriptionData }) => { +        if (!subscriptionData.data) { +          return  previous; +        }
+        const  messageAdded  =  subscriptionData.data.messageAdded;
+ +        return  Object.assign({}, previous, { +          conversation: { +            ...previous.conversation, +            messages: [...previous.conversation.messages, messageAdded] +          }
+        });
+     }
+   });
+ }, []);
   return ( <ConversationBodyWrapper>
 ...

subscribeToMore方法现在将使用MESSAGES_ADDED订阅来检查任何新消息,并将该订阅的结果添加到名为previous的对象上的Query组件中。本地 GraphQL 服务器将每隔几秒钟返回一条新消息,因此您可以通过打开对话并等待新消息出现在该对话中来查看订阅是否起作用。

除了查询,您还希望能够发送实时订阅。这将在本节的最后部分进行讨论。

使用订阅与突变

除了使用订阅来接收对话中的消息,它们还可以用于显示您自己发送的消息。以前,您可以在Mutation组件上使用refetchQueries属性来重新发送受到您执行的突变影响的任何查询的文档。通过使用订阅,您不再需要重新获取,例如,对话查询,因为订阅将获取您刚刚发送的新消息并将其添加到查询中。

在上一节中,您使用了来自react-apolloQuery组件向 GraphQL 服务器发送文档,而在本节中,将使用新的 React Apollo Hooks。

React Apollo Hooks 可以从react-apollo包中使用,但如果您只想使用 Hooks,可以通过执行npm install @apollo/react-hooks来安装@apollo/react-hooks。GraphQL 组件,如QueryMutation,在react-apollo@apollo/react-components包中都可用。使用这些包将减少捆绑包的大小,因为您只导入所需的功能。

这个包中的 Hooks 必须在ConversationActions组件中使用。这在Conversation屏幕组件中使用,该组件将包括输入消息的输入字段和发送消息的按钮。当您按下此按钮时,什么也不会发生,因为按钮未连接到变异。让我们连接这个按钮,看看订阅如何显示您发送的消息:

  1. useMutation Hook 应该被导入到client/Components/Conversation/ConversationActions.js文件中,该文件将用于将输入字段中的消息发送到 GraphQL 服务器。还必须导入将包含在您发送的文档中的变异,名为SEND_MESSAGE;这可以在client/constants.js文件中找到:
import  React  from 'react';  import { Platform, Text, View } from 'react-native';  import  styled  from 'styled-components/native';  import { Ionicons }  from '@expo/vector-icons';  + import { useMutation } from 'react-apollo'; import  TextInput  from '../TextInput/TextInput';  import  Button  from '../Button/Button';  + import { SEND_MESSAGE } from '../../constants'; ... const  ConversationActions  = ({ userName }) => {
  ...
  1. 这个useMutation Hook 现在可以用来包裹TextInputButton组件,来自 Hook 的sendMessage属性可以用来向 GraphQL 服务器发送带有消息的文档。TextInput的值由useState Hook 创建的setMessage函数控制,这个函数可以在发送变异后用来清除TextInput
...
const  ConversationActions  = ({ userName }) => { + const [sendMessage] = useMutation(SEND_MESSAGE);   const [message, setMessage] =  React.useState('');
 return ( <ConversationActionsWrapper> + **<>** <TextInput width={75} marginBottom={0} onChangeText={setMessage} placeholder='Your message' value={message} /> <Button width={20} padding={10}
**+** onPress={() => {
+ sendMessage({ variables: { to:  userName, text:  message } });
+ setMessage(''); +         }**}**
 title={ <Ionicons name={`${Platform.OS === 'ios' ? 'ios' : 'md'}-send`} size={42} color='white' /> } /> +     </>  +   </ConversationActionsWrapper**>**
 ); };

通过在文本字段中输入值并在之后按下发送按钮来发送消息,现在会更新对话,显示您刚刚发送的消息。但是,您可能会注意到,这个组件会在移动设备屏幕的大小上被键盘遮挡。通过使用react-native中的KeyboardAvoidingView组件,可以轻松避免这种行为。这个组件将确保输入字段显示在键盘区域之外。

  1. KeyboardAvoidingView组件可以从react-native中导入,并用于替换当前正在样式化为ConversationsActionsWrapper组件的View组件:
import  React  from 'react';  - import { Platform, Text, View } from 'react-native';  + import { Platform, Text, KeyboardAvoidingView } from 'react-native';  import  styled  from 'styled-components/native';  import { Ionicons }  from '@expo/vector-icons';  import { useMutation } from 'react-apollo';  import  TextInput  from '../TextInput/TextInput';  import  Button  from '../Button/Button';  import { SEND_MESSAGE } from '../../constants';  - const  ConversationActionsWrapper  =  styled(View)` + const  ConversationActionsWrapper  =  styled(KeyboardAvoidingView)**`**
    width: 100%;
    background-color: #ccc;
    padding: 2%;
    display: flex;
    flex-direction: row;
    align-items: center;
    justify-content: space-around;
`; const  ConversationActions  = ({ userName }) => {

 ... 
  1. 根据您的移动设备运行的平台,KeyboardAvoidingView组件可能仍然无法在键盘区域之外显示输入字段。但是,KeyboardAvoidingView组件可以使用keyboardVerticalOffsetbehavior属性进行自定义。对于 iOS 和 Android,这些属性的值应该不同;一般来说,Android 需要比 iOS 更小的偏移量。在这种情况下,keyboardVerticalOffset必须设置为190behavior必须设置为padding
...

const  ConversationActions  = ({ userName }) => { const [sendMessage] = useMutation(SEND_MESSAGE);
  const [message, setMessage] =  React.useState('');
 return ( -   <ConversationActionsWrapper +   <ConversationActionsWrapper +     keyboardVerticalOffset={Platform.OS === 'ios' ? 190 : 140} +     behavior=;padding' **+   >**
 <Mutation  mutation={SEND_MESSAGE}> ... 

KeyboardAvoidingView在 Android Studio 模拟器或运行 Android 的设备上可能无法按预期工作,因为可以运行 Android 操作系统的设备有许多不同的可能屏幕尺寸。

当您按下输入字段时,键盘将不再隐藏在键盘后面,您应该能够输入并发送一条消息,该消息将发送一个包含对 GraphQL 服务器的突变的文档。您的消息还将出现在先前显示的对话中。

摘要

在本章中,您构建了一个移动消息应用程序,可以用于与 GraphQL 服务器发送和接收消息。通过使用 GraphQL 订阅,消息可以实时接收,通过 WebSocket 接收消息。此外,还添加了移动身份验证流程,这意味着用户必须登录才能发送和接收消息。为此,使用AsyncStorage将 GraphQL 服务器返回的 JWT 令牌存储在持久存储中。

您在本章中构建的项目非常具有挑战性,但您将在下一章中创建的项目将更加先进。到目前为止,您已经处理了大多数 React Native 移动应用程序的核心功能,但还有更多内容。下一章将探讨如何使用 React Native 和 GraphQL 构建全栈应用程序,您将向社交媒体应用程序添加通知等功能。

进一步阅读

有关本章涵盖的更多信息,请查看以下资源:

第十一章:使用 React Native 和 GraphQL 构建全栈社交媒体应用程序

到目前为止,你几乎可以称自己是 React Native 的专家了,因为你即将开始在 React Native 部分中工作最复杂的应用程序。移动应用程序的一个巨大优势是,你可以直接向安装了你的应用程序的人发送通知。这样,你可以在应用程序中发生重要事件或有人很久没有使用应用程序时,针对用户。此外,移动应用程序可以直接使用设备的相机拍照和录像。

在上一章中,你创建了一个移动消息应用程序,具有身份验证流程和实时数据,并使用 React Native 的 GraphQL。这些模式和技术也将在本章中使用,以创建一个移动社交媒体应用程序,让你将图片发布到社交动态,并允许你对这些帖子进行点赞和评论。在本章中,使用相机不仅是一个重要的部分,还将添加使用 Expo 向用户发送通知的可能性。

本章将涵盖以下主题:

  • 使用 React Native 和 Expo 的相机

  • 使用 React Native 和 GraphQL 刷新数据

  • 使用 Expo 发送移动通知

项目概述

一个移动社交媒体应用程序,使用本地 GraphQL 服务器请求和添加帖子到社交动态,包括使用移动设备上的相机。使用本地 GraphQL 服务器和 React Navigation 添加基本身份验证,同时使用 Expo 访问相机(滚动)并在添加新评论时发送通知。

构建时间为 2 小时。

入门

我们将在本章中创建的项目基于 GitHub 上的初始版本:github.com/PacktPublishing/React-Projects/tree/ch11-initial。完整的源代码也可以在 GitHub 上找到:github.com/PacktPublishing/React-Projects/tree/ch11

你需要在移动 iOS 或 Android 设备上安装 Expo Client 应用程序,才能在物理设备上运行项目。

强烈建议使用 Expo Client 应用程序在物理设备上运行本章的项目。目前,仅支持在物理设备上接收通知,并且在 iOS 模拟器或 Android Studio 模拟器上运行项目将导致错误消息。

或者,您可以在计算机上安装 Xcode 或 Android Studio 来在虚拟设备上运行应用程序:

export ANDROID_SDK=**ANDROID_SDK_LOCATION** export PATH=**ANDROID_SDK_LOCATION**/platform-tools:$PATH export PATH=**ANDROID_SDK_LOCATION**/tools:$PATH

ANDROID_SDK_LOCATION的值是本地机器上 Android SDK 的路径,可以通过打开 Android Studio 并转到首选项|外观和行为|系统设置|Android SDK来找到。路径在声明 Android SDK 位置的框中列出,看起来像这样:/Users/myuser/Library/Android/sdk

该应用程序是使用Expo SDK 版本 33.0.0创建的,因此,您需要确保您在本地机器上使用的 Expo 版本类似。由于 React Native 和 Expo 经常更新,请确保您使用此版本,以便本章中描述的模式表现如预期。如果您的应用程序无法启动或遇到错误,请参考 Expo 文档,了解有关更新 Expo SDK 的更多信息。

检出初始项目

该项目由两部分组成,一个是样板 React Native 应用程序,另一个是 GraphQL 服务器。 React Native 应用程序位于client目录中,而 GraphQL 服务器放置在server目录中。在本章中,您需要始终同时运行应用程序和服务器,而只对client目录中的应用程序进行代码更改。

要开始,您需要在clientserver目录中运行以下命令,以安装所有依赖项并启动服务器和应用程序:

npm install && npm start

对于移动应用程序,此命令将在安装依赖项后启动 Expo,并使您能够从终端或浏览器启动项目。在终端中,您现在可以使用 QR 码在移动设备上打开应用程序,或者在模拟器中打开应用程序。

此项目的本地 GraphQL 服务器正在运行http://localhost:4000/graphql/,但为了能够在 React Native 应用程序中使用此端点,您需要找到您机器的本地 IP 地址。

要查找本地 IP 地址,您需要根据您的操作系统执行以下操作:

  • 对于 Windows:打开终端(或命令提示符)并运行此命令:
ipconfig

这将返回一个类似下面所见的列表,其中包含来自您本地机器的数据。在此列表中,您需要查找字段IPv4 地址

  • 对于 macOS:打开终端并运行此命令:
ipconfig getifaddr en0

运行此命令后,将返回您机器的本地Ipv4 地址,看起来像这样:

192.168.1.107

必须使用本地 IP 地址来创建文件client/App.js中的API_URL的值,前缀为http://,后缀为/graphql,使其看起来像http://192.168.1.107/graphql

...

**- const API_URL = '';**
**+ const API_URL = 'http://192.168.1.107/graphql';**

const  httpLink  =  new  HttpLink({
 uri: API_URL,  }); const  authLink  =  setContext(async (_, { headers }) => {

  ...

无论您是从虚拟设备还是物理设备打开应用程序,此时应用程序应该看起来像这样:

此应用程序是使用Expo SDK 版本 33.0.0创建的,因此您需要确保您本地机器上使用的 Expo 版本类似。由于 React Native 和 Expo 经常更新,请确保您使用此版本,以确保本章中描述的模式表现如预期。如果您的应用程序无法启动或收到错误消息,请务必查看 Expo 文档,以了解有关更新 Expo SDK 的更多信息。

初始应用程序由七个屏幕组成:AddPostAuthLoadingLoginNotificationsPostPostsSettings。当首次启动应用程序时,您将看到Login屏幕,您可以使用以下凭据登录:

  • 用户名test

  • 密码test

Posts 屏幕将是登录后的初始屏幕,显示一个帖子列表,您可以点击继续到Post屏幕,而Settings屏幕显示一个无效的注销按钮。目前,AddPostNotification屏幕尚不可见,因为您将在本章后面添加到这些屏幕的路由。

React Native 应用程序中的项目结构在directory client 中如下,结构类似于您在本书中之前创建的项目:

messaging
|-- client
    |-- .expo
    |-- assets
        |-- icon.png
        |-- splash.png
    |-- Components
        |-- // ...
    |-- node_modules
    |-- Screens
        |-- AddPost.js
        |-- AuthLoading.js
        |-- Login.js
        |-- Notifications.js
        |-- Post.js
        |-- Posts.js
        |-- Settings.js
    |-- .watchmanconfig
    |-- App.js
    |-- AppContainer.js
    |-- app.json
    |-- babel.config.js
    |-- package.json

assets目录中,您可以找到用作应用程序图标的图像,一旦您在移动设备上安装了该应用程序,它将显示在主屏幕上,以及作为启动画面的图像,当您启动应用程序时显示。例如,应用程序名称的 App Store 配置放在app.json中,而babel.config.js包含特定的 Babel 配置。

App.js文件是您的应用程序的实际入口点,其中导入并返回AppContainer.js文件。在AppContainer中,定义了该应用程序的所有路由,AppContext将包含应该在整个应用程序中可用的信息。

该应用程序的所有组件都位于ScreensComponents目录中,其中第一个包含由屏幕呈现的组件。这些屏幕的子组件可以在Components目录中找到,其结构如下:

|-- Components
    |-- Button
        |-- Button.js
    |-- Comment
        |-- Comment.js
        |-- CommentForm.js
    |-- Notification
        |-- Notification.js
    |-- Post
        |-- PostContent.js
        |-- PostCount.js
        |-- PostItem.js
    |-- TextInput
        |-- TextInput.js

GraphQL 服务器可以在http://localhost:4000/graphql URL 找到,GraphQL Playground 将可见。从这个 playground,您可以查看 GraphQL 服务器的模式,并检查所有可用的查询、变异和订阅。虽然您不会对服务器进行任何代码更改,但了解模式及其工作原理是很重要的。

服务器有两个查询,通过使用userName参数作为标识符来检索帖子列表或单个帖子。这些查询将返回具有iduserNameimagestarscomments计数值的Post类型,stars类型的星星列表,以及具有Comment类型的comments列表。检索单个帖子的查询将如下所示:

export  const  GET_POST  =  gql`
 query getPost($userName: String!) { post(userName: $userName) { id userName image stars { userName } comments { id userName text } } } `;

之后,可以在 GraphQL 服务器中找到三个变异,用于登录用户、存储来自 Expo 的推送令牌,或添加帖子。

如果收到错误消息“请提供(有效的)身份验证详细信息”,则需要重新登录应用程序。可能,上一个应用程序的 JWT 仍然存储在 Expo 的AsyncStorage中,并且这将无法在本章的 GraphQL 服务器上验证。

使用 React Native、Apollo 和 GraphQL 构建全栈社交媒体应用程序

在本章中要构建的应用程序将使用本地 GraphQL 服务器来检索和改变应用程序中可用的数据。该应用程序将显示来自社交媒体动态的数据,并允许您对这些社交媒体帖子进行回复。

使用 React Native 和 Expo 的相机

除了显示由 GraphQL 服务器创建的帖子之外,您还可以使用 GraphQL mutation 自己添加帖子,并将文本和图像作为变量发送。将图像上传到您的 React Native 应用程序可以通过使用相机拍摄图像或从相机滚动中选择图像来完成。对于这两种用例,React Native 和 Expo 都提供了 API,或者可以从npm安装许多包。对于此项目,您将使用 Expo 的 ImagePicker API,它将这些功能合并到一个组件中。

要向您的社交媒体应用程序添加创建新帖子的功能,需要进行以下更改以创建新的添加帖子屏幕:

  1. 可以使用的 GraphQL mutation 用于向您在Main屏幕中看到的动态中添加帖子,它将图像变量发送到 GraphQL 服务器。此 mutation 具有以下形式:
mutation {
  addPost(image: String!) {
    image
  }
}

image变量是String,是此帖子的图像的绝对路径的 URL。此 GraphQL mutation 需要添加到client/constants.js文件的底部,以便稍后可以从useMutation Hook 中使用:

export  const  GET_POSTS  =  gql`
 ... `; + export  const  ADD_POST  =  gql` +   mutation addPost($image: String!) { +     addPost(image: $image) { +       image  +     } +   } + `;
  1. 有了Mutation,必须将添加帖子的屏幕添加到client/AppContainer.js文件中的SwitchNavigatorAddPost屏幕组件可以在client/Screens/AddPost.js文件中找到,并应作为导航器中的模态添加:
import  React  from 'react';  import { Platform } from 'react-native';  import { Ionicons }  from '@expo/vector-icons';  import {  createSwitchNavigator,
 createAppContainer } from 'react-navigation';
import { createStackNavigator } from 'react-navigation-stack';
import { createBottomTabNavigator } from 'react-navigation-tabs';  import  Posts  from './Screens/Posts';  import  Post  from './Screens/Post';  import  Settings  from './Screens/Settings';  import  Login  from './Screens/Login';  import  AuthLoading  from './Screens/AuthLoading';  + import  AddPost  from './Screens/AddPost'; ... 
const  SwitchNavigator  =  createSwitchNavigator(
  {
    Main:  TabNavigator, Login, AuthLoading,
**+** **AddPost,**
  },
  {
+   mode: 'modal'**,**
    initialRouteName: 'AuthLoading',
  },
);

export  default  createAppContainer(SwitchNavigator);
  1. 当然,用户必须能够从应用程序的某个位置打开这个模态框,例如,从屏幕底部的选项卡导航器或标题栏。对于这种情况,您可以在client/Screens/Posts.js文件中设置navigationOptions来在标题栏中添加导航链接到AddPost屏幕:
...

**+ Posts**.navigationOptions  = ({ navigation}) => ({ +   headerRight: ( +     <Button  onPress={() =>  navigation.navigate('AddPost')}  title='Add Post'  /> +   ), **+ });** export  default  Posts;

通过在navigationOptions中设置headerRight字段,只会更改标题的右侧部分,而导航器设置的标题将保持不变。现在点击Add Post链接将导航到AddPost屏幕,显示标题和关闭模态框的按钮。

现在您已经添加了AddPost屏幕,Expo 的 ImagePicker API 应该被添加到这个屏幕上。要将ImagePicker添加到AddPost屏幕上,请按照以下步骤在client/Screens/AddPost.js文件中启用从相机滚动中选择照片:

  1. 在用户可以从相机滚动中选择照片之前,当用户使用 iOS 设备时,应该为应用程序设置正确的权限。要请求权限,您可以使用 Expo 的权限 API,它应该请求CAMERA_ROLL权限。权限 API 曾经直接从 Expo 可用,但现在已经移动到一个名为expo-permissions的单独包中,可以通过 Expo CLI 安装,方法是运行以下命令:
expo install expo-permissions
  1. 之后,您可以导入权限 API 并创建函数来检查是否已经为相机滚动授予了正确的权限:
import  React  from 'react';  import { Dimensions, TouchableOpacity, Text, View } from 'react-native';  + import { Dimensions, Platform, TouchableOpacity, Text, View } from 'react-native'; import  styled  from 'styled-components/native';  import  Button  from '../Components/Button/Button';  + import * as Permissions from 'expo-permissions'; ...

const AddPost = ({ navigation }) => { +  const  getPermissionAsync  =  async () => { +    if (Platform.OS  === 'ios') { +      const { status } =  await  Permissions.askAsync(Permissions.CAMERA_ROLL);
+ +      if (status  !== 'granted') { +        alert('Sorry, you need camera roll permissions! Go to 'Settings > Expo' to enable these.'); +      } +    } **+ };**   ...
  1. 这个getPermissionAsync函数是异步的,可以从ButtonTouchable元素中调用。在文件底部可以找到UploadImage组件,它是一个带有onPress函数的样式化TouchableOpacity元素。这个组件必须添加到AddPost的返回函数中,并在点击时调用getPermissionAsync函数:
...

const  AddPost  = ({ navigation }) => { const  getPermissionAsync  =  async () => { if (Platform.OS  === 'ios') {
 const { status } =  await  Permissions.askAsync(Permissions.CAMERA_ROLL);

 if (status  !== 'granted') {
 alert('Sorry, you need camera roll permissions! Go to 'Settings > Expo' to enable these.');
 } } };  return ( <AddPostWrapper>
 <AddPostText>Add Post</AddPostText> +     <UploadImage  onPress={() =>  getPermissionAsync()}> +       <AddPostText>Upload image</AddPostText> +     </UploadImage**>**
 <Button  onPress={() =>  navigation.navigate('Main')}  title='Cancel'  />
  </AddPostWrapper>
 ); };

...

在 iOS 设备上点击时,将打开一个请求访问相机滚动权限的弹出窗口。如果您不接受请求,就无法从相机滚动中选择照片。

您不能再次要求用户授予权限;相反,您需要手动授予对摄像机滚动的权限。要再次设置这个权限,您应该从 iOS 的设置屏幕进入,并选择 Expo 应用程序。在下一个屏幕上,您可以添加访问摄像机的权限。

  1. 当用户已经授予访问摄像机滚动的权限时,您可以调用 Expo 的 ImagePicker API 来打开摄像机滚动。就像权限 API 一样,这曾经是 Expo 核心的一部分,但现在已经移动到一个单独的包中,您可以使用 Expo CLI 安装:
expo install expo-image-picker

这是一个再次使用异步函数,它接受一些配置字段,比如宽高比。如果用户选择了一张图片,ImagePicker API 将返回一个包含字段 URI 的对象,该字段是用户设备上图片的 URL,可以在Image组件中使用。可以通过使用useState Hook 创建一个本地状态来存储这个结果,以便稍后将其发送到 GraphQL 服务器:

import  React  from 'react';  import { Dimensions, Platform, TouchableOpacity, Text, View } from 'react-native';  import  styled  from 'styled-components/native';  import  Button  from '../Components/Button/Button'; **+ import * as ImagePicker from 'expo-image-picker';** import * as Permissions from 'expo-permissions';  ...

const  AddPost  = ({ navigation }) => { +  const [imageUrl, setImageUrl] = React.useState(false); 
+  const  pickImageAsync  =  async () => { +    const  result  =  await  ImagePicker.launchImageLibraryAsync({ +      mediaTypes:  ImagePicker.MediaTypeOptions.All, +      allowsEditing:  true, +      aspect: [4, 4], +    });
+    if (!result.cancelled) { +      setImageUrl(result.uri); +    }
+  };

 return (
     ... 

然后可以从函数中调用pickImageAsync函数,以获取用户在摄像机滚动时授予的权限:

...

const  AddPost  = ({ navigation }) => { ...

  const  getPermissionAsync  =  async () => { if (Platform.OS  === 'ios') {
 const { status } =  await  Permissions.askAsync(Permissions.CAMERA_ROLL);

 if (status  !== 'granted') {
 alert('Sorry, you need camera roll permissions! Go to 'Settings > Expo' to enable these.');
**+     } else {**
**+       pickImageAsync();**
 } } };  return (
  1. 现在,由于图片的 URL 已经存储在本地状态中的imageUrl常量中,您可以在Image组件中显示这个 URL。这个Image组件以imageUrl作为源的值,并且已经设置为使用 100%的widthheight
...

  return ( <AddPostWrapper>
 <AddPostText>Add Post</AddPostText>

 <UploadImage  onPress={() =>  getPermissionAsync()}>
**+       {imageUrl ? (**
**+** <Image +           source={{ uri:  imageUrl }} +           style={{ width: '100%', height: '100%' }} +         />
+       ) : (
          <AddPostText>Upload image</AddPostText>
**+       )}**
 </UploadImage>
 <Button  onPress={() =>  navigation.navigate('Main')}  title='Cancel'  />
  </AddPostWrapper>
 ); };

...

通过这些更改,AddPost屏幕应该看起来像下面的截图,这是从运行 iOS 的设备上获取的。如果您使用 Android Studio 模拟器或运行 Android 的设备,这个屏幕的外观可能会有轻微的差异:

这些更改将使从摄像机滚动中选择照片成为可能,但您的用户还应该能够通过使用他们的摄像机上传全新的照片。使用 Expo 的 ImagePicker,您可以处理这两种情况,因为这个组件还有一个launchCameraAsync方法。这个异步函数将启动摄像机,并以与从摄像机滚动中返回图片的 URL 相同的方式返回它。

要添加直接使用用户设备上的摄像机上传图片的功能,可以进行以下更改:

  1. 由于用户需要授予您的应用程序访问相机滚动条的权限,因此用户需要做同样的事情来使用相机。可以通过使用Permissions.askAsync方法发送Permissions.CAMERA来请求使用相机的权限。必须扩展对相机滚动条的授予权限的检查,以便还检查相机权限:
...

  const  getPermissionAsync  =  async () => {  if (Platform.OS  === 'ios') { -   const { status } = await Permissions.askAsync(Permissions.CAMERA_ROLL);
-   if (status !== 'granted') {
+     const { status: statusCamera } =  await  Permissions.askAsync(Permissions.CAMERA); +     const { status: statusCameraRoll } =  await  Permissions.askAsync(Permissions.CAMERA_ROLL); +     if (statusCamera  !== 'granted'  ||  statusCameraRoll  !== 'granted'**) {**
        alert(
          `Sorry, you need camera roll permissions! Go to 'Settings > Expo' to enable these.`
        );
      } else {        pickImageAsync();
      }
    }
  };

  return (
    ... 

这将在 iOS 上要求用户允许使用相机,也可以通过转到设置| Expo 手动授予权限。

  1. 在获得权限后,您可以通过调用ImagePicker中的launchCameraAsync函数来创建启动相机的功能。该功能与您创建的用于打开相机滚动条的launchCameraAsync函数相同;因此,pickImageAsync函数也可以编辑为能够启动相机:
const  AddPost  = ({ navigation }) => { const [imageUrl, setImageUrl] =  React.useState(false);
 **-  const** pickImageAsync  =  async () => {  +  const addImageAsync  =  async (camera = false) => { -    const  result  =  await  ImagePicker.launchCameraAsync({ -      mediaTypes:  ImagePicker.MediaTypeOptions.All, -      allowsEditing:  true, -      aspect: [4, 4]
-    }); +    const  result  = !camera 
+      ? await  ImagePicker.launchImageLibraryAsync({ +          mediaTypes:  ImagePicker.MediaTypeOptions.All, +          allowsEditing:  true, +          aspect: [4, 4] +        })
+      : await  ImagePicker.launchCameraAsync({  +          allowsEditing:  true, +          aspect: [4, 4] **+        })**
 if (!result.cancelled) { setImageUrl(result.uri);
 } };

如果现在向addImageAsync函数发送参数,将调用launchCameraAsync。否则,用户将被引导到其设备上的相机滚动条。

  1. 当用户点击图像占位符时,默认情况下将打开图像滚动条。但您还希望给用户选择使用他们的相机的选项。因此,必须在使用相机或相机滚动条上传图像之间进行选择,这是实现ActionSheet组件的完美用例。React Native 和 Expo 都有一个ActionSheet组件;建议使用 Expo 中的组件,因为它将在 iOS 上使用本机的UIActionSheet组件,在 Android 上使用 JavaScript 实现。ActionSheet组件可从 Expo 的react-native-action-sheet软件包中获得,您可以从npm安装。
npm install @expo/react-native-action-sheet

之后,您需要在client/App.js文件中使用来自该软件包的Provider将顶级组件包装起来,这类似于添加ApolloProvider

import React from 'react';
import { AsyncStorage } from 'react-native';
import { ApolloClient } from 'apollo-client';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { setContext } from 'apollo-link-context';
import { HttpLink } from 'apollo-link-http';
import { ApolloProvider } from '@apollo/react-hooks';
+ import { ActionSheetProvider } from '@expo/react-native-action-sheet';
import AppContainer from './AppContainer';

...

const  App  = () => (  <ApolloProvider  client={client}> +   <ActionSheetProvider>       <AppContainer  /> +   </ActionSheetProvider**>**
  </ApolloProvider> );

export  default  App;

client/Screens/AddPost.js中通过从react-native-action-sheet导入connectActionSheet函数来创建ActionSheet,在导出之前需要将AddPost组件包装起来。使用connectActionSheet()AddPost组件包装起来,将showActionSheetWithOptions属性添加到组件中,你将在下一步中使用它来创建ActionSheet

import  React  from 'react';  import { Dimensions,
 Image,
 Platform,
  TouchableOpacity,
  Text,
  View } from 'react-native';  import  styled  from 'styled-components/native';  import  *  as  ImagePicker  from 'expo-image-picker';  import  *  as  Permissions  from 'expo-permissions';  + import { connectActionSheet } from  '@expo/react-native-action-sheet'; import  Button  from '../Components/Button/Button'; ... - const  AddPost  = ({ navigation }) => { + const  AddPost  = ({ navigation, showActionSheetWithOptions }) => **{**

    ... 
- export default AddPost;
+ const  ConnectedApp  =  connectActionSheet(AddPost); + export  default  ConnectedApp;
  1. 要添加ActionSheet,必须添加一个打开ActionSheet的函数,并使用showActionSheetWithOptions属性和选项来构造ActionSheet。选项包括相机相机相册取消,选择第一个选项应该调用带有参数的addImageAsync函数,第二个选项应该调用不带参数的函数,最后一个选项是关闭ActionSheet。打开ActionSheet的函数必须添加到getPermissionsAsync函数中,并在相机相机相册的权限都被授予时调用:
...

+  const openActionSheet = () => { +    const  options  = ['Camera', 'Camera roll', 'Cancel']; +    const  cancelButtonIndex  =  2; + 
+    showActionSheetWithOptions( +      {
+        options, +        cancelButtonIndex
+      },
+      buttonIndex  => { +        if (buttonIndex  ===  0  ||  buttonIndex  ===  1) { +          addImageAsync(buttonIndex  ===  0); +        }
+      },
+    );
+   };

  const  getPermissionAsync  =  async () => {    if (Platform.OS  === 'ios') {
      const { status: statusCamera } =  await  Permissions.askAsync(Permissions.CAMERA);
      const { status: statusCameraRoll } =  await  Permissions.askAsync(Permissions.CAMERA_ROLL);

      if (statusCamera  !== 'granted'  ||  statusCameraRoll  !== 'granted') {
        alert(
          `Sorry, you need camera roll permissions! Go to 'Settings > Expo' to enable these.`
        );
      } else { -       pickImageAsync**();**
**+       openActionSheet();**
      }
    }
  };

  return (
    ...

点击图像占位符将给用户选择使用相机相机相册AddPost组件添加图像的选项。这可以通过ActionSheet来实现,在 iOS 和 Android 上看起来会有所不同。在下面的截图中,您可以看到在使用 iOS 模拟器或运行 iOS 的设备时的效果:

  1. 然而,这还不是全部,因为图像仍然必须发送到服务器才能出现在应用程序的动态中,通过从@apollo/react-hooks中添加useMutation Hook,并使用返回的addPost函数将imageUrl变量发送到 GraphQL 服务器的文档中。在本节的开头已经提到了添加帖子的变异,并可以从client/constants.js文件中导入:
import  React  from 'react';  import { Dimensions,
 Image,
 Platform,
  TouchableOpacity,
  Text,
  View } from 'react-native';  import  styled  from 'styled-components/native';  import  *  as  ImagePicker  from 'expo-image-picker';  import  *  as  Permissions  from 'expo-permissions';  import { connectActionSheet } from '@expo/react-native-action-sheet';
**+ import { useMutation } from '@apollo/react-hooks';** **+ import { ADD_POST } from '../constants';** import  Button  from '../Components/Button/Button';  
...

const  AddPost  = ({ navigation, showActionSheetWithOptions }) => { + const [addPost] = useMutation(ADD_POST);
  const [imageUrl, setImageUrl] =  React.useState(false); ... 
  return (    <AddPostWrapper>
      <AddPostText>Add Post</AddPostText>
        <UploadImage  onPress={() =>  getPermissionAsync()}> {imageUrl ? ( <Image source={{ uri:  imageUrl }} style={{ width: '100%', height: '100%' }} />
          ) : (
            <AddPostText>Upload image</AddPostText> )} </UploadImage> +       {imageUrl && ( +         <Button +           onPress={() => { +             addPost({ variables: { image:  imageUrl } }).then(() => 
+ navigation.navigate('Main') +             );
+           }} +           title='Submit' +         />
+       )}  <Button  onPress={() =>  navigation.navigate('Main')}  title='Cancel'  /> </AddPostWrapper>
   );
 };

export default AddPost;

点击提交按钮后,图像将作为帖子添加,并且用户将被重定向到Main屏幕。

  1. 通过将refetchQueries变量上的查询设置为useMutation Hook,可以重新加载Main屏幕上的帖子,并在此列表中显示您刚刚添加的帖子。可以通过从client/constants.js中获取GET_POSTS查询来检索帖子:
import  React  from 'react';  import { Dimensions,
 Image,
 Platform,
  TouchableOpacity,
  Text,
  View } from 'react-native';  import  styled  from 'styled-components/native';  import  *  as  ImagePicker  from 'expo-image-picker';  import  *  as  Permissions  from 'expo-permissions';  import { connectActionSheet } from '@expo/react-native-action-sheet';
import { useMutation } from '@apollo/react-hooks'; **- import { ADD_POST } from '../constants';** **+ import { ADD_POST, GET_POSTS } from '../constants';** import  Button  from '../Components/Button/Button';  
...

const  AddPost  = ({ navigation, showActionSheetWithOptions }) => { - const [addPost] = useMutation(ADD_POST);
+ const [addPost] =  useMutation(ADD_POST, { +   refetchQueries: [{ query:  GET_POSTS }] + });
  const [imageUrl, setImageUrl] =  React.useState(false);
 ... 
 return (   <AddPostWrapper>
     ...

您的帖子现在将显示在Main屏幕的顶部,这意味着您已成功添加了帖子,其他用户可以查看、点赞和评论。由于用户可能在应用程序打开时发送帖子,您希望他们能够接收这些帖子。因此,接下来的部分将探讨如何从 GraphQL 实现近实时数据。

使用 GraphQL 检索近实时数据

除了消息应用程序之外,您不希望每当您的网络中的任何人发布新帖子时,就重新加载带有帖子的信息流。除了订阅之外,还有其他方法可以使用 GraphQL 和 Apollo 实现(近乎)实时数据流,即轮询。通过轮询,您可以每隔n毫秒从useQuery Hook 中检索一个查询,而无需设置订阅的复杂性。

轮询可以添加到client/Screens/Posts.js中的useQuery Hook 中,就像这样。通过在useQuery Hook 的对象参数上设置pollInterval值,您可以指定多久应该由 Hook 重新发送带有GET_POSTS查询的文档:

...

const  Posts  = ({ navigation }) => {
**- const { loading, data } = useQuery(GET_POSTS);**
**+ const { loading, data } = useQuery(GET_POSTS, { pollInterval: 2000 });**

  return ( <PostsWrapper> {loading ? (  <PostsText>Loading...</PostsText>;
      ) : ( ...

这会导致您的Posts组件每 2 秒(2,000 毫秒)发送一个带有GET_POSTS查询的文档,由于 GraphQL 服务器返回的是模拟数据,显示的帖子在每次重新获取时都会有所不同。与订阅相比,轮询会重新发送文档以检索帖子,即使没有新数据,这对于显示模拟数据或经常更改的数据的应用程序并不是很有用。

除了在useQuery Hook 上设置pollInterval变量之外,您还可以手动调用refetch函数,该函数会发送一个带有查询的文档。社交媒体信息流的常见交互是能够下拉显示的组件以刷新屏幕上的数据。

通过对Posts屏幕组件进行以下更改,也可以将此模式添加到您的应用程序中:

  1. pollInterval属性可以设置为0,这样就暂时禁用了轮询。除了loadingdata变量之外,还可以从useQuery Hook 中检索更多变量。其中一个变量是refetch函数,您可以使用它手动将文档发送到服务器:
...

const  Posts  = ({ navigation }) => {
**- const { loading, data } = useQuery(GET_POSTS, { pollInterval: 2000 });**
**+ const { loading, data, refetch } = useQuery(GET_POSTS, { pollInterval: 0 });**
  return ( <PostsWrapper> {loading ? (  <PostsText>Loading...</PostsText>;
      ) : ( ...
  1. 有一个 React Native 组件用于创建下拉刷新交互,称为RefreshControl,您应该从react-native中导入它。此外,您还应该导入一个ScrollView组件,因为RefreshControl组件只能与ScrollViewListView组件一起使用:
import  React  from 'react';  import { useQuery } from '@apollo/react-hooks';  - import { FlatList, Text, View } from 'react-native';  + import { FlatList, Text, View, ScrollView, RefreshControl } from 'react-native';  import  styled  from 'styled-components/native';  import { GET_POSTS } from '../constants';  import  PostItem  from '../Components/Post/PostItem'; ... const  Posts  = ({ navigation }) => {  ...
  1. 这个ScrollView组件应该包裹在PostsList组件周围,它是一个经过 GraphQL 服务器创建的帖子进行迭代的样式化FlatList组件。作为refreshControl属性的值,必须将RefreshControl组件传递给这个ScrollView,并且必须设置一个style属性,将宽度锁定为 100%,以确保只能垂直滚动:
const Posts = ({ navigation }) => {
  const { loading, data, refetch } = useQuery(GET_POSTS, { pollInterval: 0 });
  return (
    <PostsWrapper>
      {loading ? (
        <PostsText>Loading...</PostsText>;
      ) : (
+       <ScrollView
+         style={{ width: '100%' }}
+         refreshControl={
+           <RefreshControl />
+         }
+       >
         <PostsList
           data={data.posts}
           keyExtractor={item => String(item.id)}
           renderItem={({ item }) => (
             <PostItem item={item} navigation={navigation} />
           )}
         />
+       </ScrollView>
      )}
    </PostsWrapper>
  );
};
  1. 如果您现在下拉Posts屏幕,屏幕顶部将显示一个不断旋转的加载指示器。通过refreshing属性,您可以通过传递由useState Hook 创建的值来控制是否应该显示加载指示器。除了refreshing属性,还可以将应该在刷新开始时调用的函数传递给onRefresh属性。您应该将refetch函数传递给此函数,该函数应将refreshing状态变量设置为true并调用useQuery Hook 返回的refetch函数。在refetch函数解析后,回调可以用于再次将refreshing状态设置为false
...
const Posts = ({ navigation }) => {
  const { loading, data, refetch } = useQuery(GET_POSTS, { pollInterval: 0 });
+ const [refreshing, setRefreshing] = React.useState(false);

+ const handleRefresh = (refetch) => {
+   setRefreshing(true);
+
+   refetch().then(() => setRefreshing(false));
+ }

  return(
    <PostsWrapper>
    {loading ? (
      <PostsText>Loading...</PostsText>;
    ) : (
      <ScrollView
        style={{ width: '100%' }}
        refreshControl={
-         <RefreshControl />
+         <RefreshControl
+           refreshing={refreshing}
+           onRefresh={() => handleRefresh(refetch)}
+         />
        }
      >
        <PostsList
          ...
  1. 最后,当您下拉Posts屏幕时,从useQuery Hook 返回的加载消息会干扰RefreshControl的加载指示器。通过在 if-else 语句中还检查refreshing的值,可以防止这种行为:
...
const Posts = ({ navigation }) => {
  const { loading, data, refetch } = useQuery(GET_POSTS, { pollInterval: 0 });
  const [refreshing, setRefreshing] = React.useState(false);

  const handleRefresh = (refetch) => {
    setRefreshing(true);

    refetch().then(() => setRefreshing(false));
  }

  return(
    <PostsWrapper>
-     {loading ? (
+     {loading && !refreshing ? (
        <PostsText>Loading...</PostsText>      ) : (

        ...

在最后这些更改之后,下拉刷新Posts屏幕的交互已经实现,使您的用户可以通过下拉屏幕来检索最新数据。当您将 iOS 作为运行应用程序的虚拟或物理设备的操作系统时,它将看起来像这样的截图:

在接下来的部分中,您将使用 Expo 和 GraphQL 服务器向这个社交媒体应用程序添加通知。

使用 Expo 发送通知

移动社交媒体应用程序的另一个重要功能是向用户发送重要事件的通知,例如,当他们的帖子被点赞或朋友上传了新帖子。使用 Expo 可以发送通知,并且需要添加服务器端和客户端代码,因为通知是从服务器发送的。客户端需要检索用户设备的本地标识符,称为 Expo 推送代码。这个代码是需要的,以确定哪个设备属于用户,以及通知应该如何发送到 iOS 或 Android。

测试通知只能通过在您的移动设备上使用 Expo 应用程序来完成。iOS 和 Android 模拟器无法接收推送通知,因为它们不在实际设备上运行。

检索推送代码是向用户发送通知的第一步,包括以下步骤:

  1. 为了能够发送通知,用户应该允许您的应用程序推送这些通知。要请求此权限,应该使用相同的权限 API 来获取相机的权限。请求此权限的函数可以添加到一个名为registerForPushNotificationsAsync.js的新文件中。这个文件必须创建在新的client/utils目录中,您可以在其中粘贴以下代码,该代码还使用通知 API 检索推送代码:
import { Notifications } from 'expo';  import  *  as  Permissions  from 'expo-permissions';  async  function  registerForPushNotificationsAsync() {
 const { status: existingStatus } =  await  Permissions.getAsync(
 Permissions.NOTIFICATIONS
 ); let  finalStatus  =  existingStatus;
   if (existingStatus  !== 'granted') {
  const { status } =  await  Permissions.askAsync(Permissions.NOTIFICATIONS);
 finalStatus  =  status;
 }  if (finalStatus  !== 'granted') {
 return;
 } const  token  =  await  Notifications.getExpoPushTokenAsync();
 return  token; }

export default registerForPushNotificationsAsync;
  1. 当您使用 iOS 设备时,应该在应用程序打开时调用registerForPushNotificationAsync函数,因为您应该请求权限。在 Android 设备上,用户是否希望您发送通知的请求是在安装过程中发送的。因此,当用户打开应用程序时,应该触发此函数,之后此函数将在 Android 上返回 Expo 推送令牌,或在 iOS 上启动弹出窗口以请求权限。由于您只想要向注册用户请求他们的令牌,因此在client/Screens/Posts.js文件中使用useEffect Hook 来完成。
import React from 'react';
import { useQuery } from '@apollo/react-hooks';
import {
  Button,
  FlatList,
  Text,
  View,
  ScrollView,
  RefreshControl
} from 'react-native';
import styled from 'styled-components/native';
import { GET_POSTS } from '../constants';
import PostItem from '../Components/Post/PostItem';
+ import registerForPushNotificationsAsync from '../utils/registerForPushNotificationsAsync';

... const Posts = ({ navigation }) => {
  const { loading, data, refetch } = useQuery(GET_POSTS, { pollInterval: 0 });
  const [refreshing, setRefreshing] = React.useState(false);
+ React.useEffect(() => {
+   registerForPushNotificationsAsync();
+ });

...

如果您看到此错误,“错误:Expo 推送通知服务仅支持 Expo 项目。请确保您已登录到从中加载项目的计算机上的 Expo 开发人员帐户。”,这意味着您需要确保已登录到 Expo 开发人员帐户。通过在终端中运行expo login,您可以检查是否已登录,否则它将提示您重新登录。

  1. 在终端中,现在将显示此用户的 Expo 推送令牌,看起来像ExponentPushToken[AABBCC123]。这个令牌对于这个设备是唯一的,可以用来发送通知。要测试通知的外观,您可以在浏览器中转到https://expo.io/dashboard/notifications的 URL 以找到 Expo 仪表板。在这里,您可以输入 Expo 推送令牌以及通知的消息和标题;根据移动操作系统的不同,您可以选择不同的选项,例如以下选项:

这将向您的设备发送一个标题为Test,正文为This is a test的通知,并在发送通知时尝试播放声音。

然而,当应用程序在 iOS 设备上运行并处于前台时,此通知不会显示。因此,当您在苹果设备上使用 Expo 应用程序时,请确保 Expo 应用程序在后台运行。

本节的下一部分将展示如何在应用程序在前台运行时也可以接收通知。

处理前台通知

当应用程序处于前台时处理通知更加复杂,需要我们添加一个监听器来检查新通知,然后这些通知应该被存储在某个地方。Expo 的通知 API 提供了一个可用的监听器,可以帮助您检查新通知,而通知可以使用 Apollo 来存储,通过使用本地状态。这个本地状态通过添加监听器发现的任何新通知来扩展 GraphQL 服务器返回的数据。

当通知存储在本地状态中时,可以查询这些数据并在应用程序的组件或屏幕中显示。让我们创建一个通知屏幕,显示这些在应用程序在前台加载时发送的通知。

添加对前台通知的支持需要您进行以下更改:

  1. client/App.js中 Apollo Client 的设置应该被扩展,以便您可以查询通知,并在监听器发现新通知时添加新通知。应该创建一个名为notifications的新类型Query,返回Notification类型的列表。此外,必须在cache中添加一个空数组的形式作为这个Query的初始值:
...

 const  client  =  new  ApolloClient({
  link:  authLink.concat(link),
 cache, +  typeDefs:  ` +    type Notification { +      id: Number! +      title: String! +      body: String! +    } +    extend type Query { +      notifications: [Notification]! +    } +  `
 }); + cache.writeData({ +  data: { +    notifications: [] +  } **+ });** const  App  = () => {

  ...
  1. 现在,您可以发送一个带有查询的文档,以检索包括idtitlebody字段的通知列表。这个查询也必须在client/constants.js文件中定义,以便在下一步中从useQuery Hook 中使用。
...

export  const  ADD_POST  =  gql`
 mutation addPost($image: String!) { addPost(image: $image) { image } } `; + export  const  GET_NOTIFICATIONS  =  gql` +   query getNotifications { +     notifications { +       id @client +       title @client +       body @client +     } +   } + `;
  1. client/Screens目录中,可以找到Notifications.js文件,必须将其用作用户显示通知的屏幕。此屏幕组件应该在client/AppContainer.js文件中导入,其中必须创建一个新的StackNavigator对象:
import  React  from 'react';  import { Platform } from 'react-native';  import { Ionicons }  from '@expo/vector-icons';  import {   createSwitchNavigator,
 createAppContainer } from 'react-navigation';
import { createStackNavigator } from 'react-navigation-stack';
import { createBottomTabNavigator } from 'react-navigation-tabs';  import  Posts  from './Screens/Posts';  import  Post  from './Screens/Post';  import  Settings  from './Screens/Settings';  import  Login  from './Screens/Login';  import  AuthLoading  from './Screens/AuthLoading';  import  AddPost  from './Screens/AddPost';  + import  Notifications  from './Screens/Notifications';  ...

+ const  NotificationsStack  =  createStackNavigator({ +   Notifications: { +     screen:  Notifications, +     navigationOptions: { title: 'Notifications' }, +   } **+ });**

创建Notifications屏幕的StackNavigator之后,需要将其添加到TabNavigator中,以便它将显示在PostsSettings屏幕旁边:

...

const  TabNavigator  =  createBottomTabNavigator(
 { Posts:  PostsStack, +   Notifications:  NotificationsStack,  Settings }, { initialRouteName: 'Posts',
 defaultNavigationOptions: ({ navigation }) => ({ tabBarIcon: ({ tintColor }) => { const { routeName } =  navigation.state;
  let  iconName;
  if (routeName  === 'Posts') { iconName  =  `${Platform.OS === 'ios' ? 'ios' : 'md'}-home`; } else  if (routeName  === 'Settings') {
 iconName  =  `${Platform.OS === 'ios' ? 'ios' : 'md'}-settings`; +     } else  if (routeName  === 'Notifications') { +       iconName  =  `${Platform.OS === 'ios' ? 'ios' : 'md'}-notifications`; **+     }** return  <Ionicons  name={iconName}  size={20}  color={tintColor}  />;
 },  ...
  1. Notifications屏幕现在显示在TabNavigator中,并显示文本 Empty!因为没有任何通知可显示。要添加已发送给用户的任何通知,需要为 GraphQL 客户端创建本地解析器。此本地解析器将用于创建Mutation,用于将任何新通知添加到本地状态。您可以通过将以下代码添加到client/App.js来创建本地解析器:
...

import AppContainer from './AppContainer';
**+ import { GET_NOTIFICATIONS } from './constants';**

...

const  client  =  new  ApolloClient({
 link:  authLink.concat(link),
 cache, + resolvers: { +   Mutation: { +     addNotification:  async (_, { id, title, body }) => { +       const { data } =  await  client.query({ query:  GET_NOTIFICATIONS })
+ +       cache.writeData({ +         data: { +           notifications: [ +             ...data.notifications, +             { id, title, body, __typename: 'notifications' }, +           ], +         }, +       }); +     } +   } **+ },**
 typeDefs:  `
 type Notification { id: Number! title: String! body: String! } extend type Query { notifications: [Notification]! } ` });

...

这将创建addNotification变异,该变异接受idtitlebody变量,并将这些值添加到Notification类型的数据中。当前在本地状态中的通知是使用之前创建的GET_NOTIFICATIONS查询来请求的。通过在 GraphQL client常量上调用query函数,您将向服务器发送包含此查询的文档。连同与变异一起发送的通知以及包含变异的文档,这些将通过cache.writeData写入本地状态。

  1. 这个变异必须添加到client/constants.js文件中,其他 GraphQL 查询和变异也放在那里。同样重要的是要添加client应该使用@client标签来解决这个变异:
...

export  const  GET_NOTIFICATIONS  =  gql`
 query getNotifications { notifications { id @client title @client body @client } } `; + export  const  ADD_NOTIFICATION  =  gql`
+ mutation { +     addNotification(id: $id, title: $title, body: $body) @client +   } + `;
  1. 最后,从Notifications API 中添加的监听器被添加到client/App.js文件中,当应用程序处于前台时,它将寻找新的通知。新的通知将使用client/constants.js中的前述变异添加到本地状态。在客户端上调用的mutate函数将使用来自 Expo 通知的信息并将其添加到变异;变异将确保通过将此信息写入cache将其添加到本地状态:
...

import { ActionSheetProvider } from '@expo/react-native-action-sheet';  + import { Notifications } from 'expo'; import AppContainer from './AppContainer';
- import { GET_NOTIFICATIONS } from './constants'; + import { ADD_NOTIFICATIONS, GET_NOTIFICATIONS } from './constants'; 
...

const  App  = () => { + React.useEffect(() => { +   Notifications.addListener(handleNotification); + });

+ const  handleNotification  = ({ data }) => { +   client.mutate({ +     mutation:  ADD_NOTIFICATION, +     variables: { +       id:  Math.floor(Math.random() *  500) +  1, +       title:  data.title, +       body:  data.body, +     },
+   });
+ };

  return (

    ...

在上一个代码块中,您不能使用useMutation Hook 来发送ADD_NOTIFICATION变异,因为 React Apollo Hooks 只能从嵌套在ApolloProvider中的组件中使用。因此,使用了client对象上的mutate函数,该函数还提供了发送带有查询和变异的文档的功能,而无需使用QueryMutation组件。

  1. 通过从 Expo 导入Notifications API,handleNotification函数可以访问发送的通知中的数据对象。该数据对象与您使用 Expo 仪表板发送的消息标题和消息正文不同,因此在从https://expo.io/dashboard/notifications发送通知时,您还需要添加 JSON 数据。可以通过在表单中添加正文来发送测试通知:

通过提交表单,当应用程序处于前台运行时,将向用户发送标题为Test,正文为This is a test的通知,但也会在应用程序在后台运行时发送。

在生产中运行的移动应用程序中,您期望通知是从 GraphQL 服务器而不是 Expo 仪表板发送的。处理此应用程序的数据流的本地 GraphQL 服务器已配置为向用户发送通知,但需要用户的 Expo 推送令牌才能发送。该令牌应存储在服务器上并与当前用户关联,因为该令牌对于此设备是唯一的。该令牌应在文档中从变异发送到 GraphQL 服务器,该变异将获取关于用户的信息并从变异的标头中获取:

  1. 首先,在client/constants.js文件中创建将在 GraphQL 服务器上存储 Expo 推送令牌的变异,以及其他查询和变异。此变异所需的唯一变量是推送令牌,因为发送到 GraphQL 服务器的每个文档的 OAuth 令牌用于标识用户:
import  gql  from 'graphql-tag';  export  const  LOGIN_USER  =  gql`
 mutation loginUser($userName: String!, $password: String!) { loginUser(userName: $userName, password: $password) { userName token } } `; + export  const  STORE_EXPO_TOKEN  =  gql` +   mutation storeExpoToken($expoToken: String!) { +     storeExpoToken(expoToken: $expoToken) { +       expoToken +     } +   } + `**;**

...
  1. 必须从client/Posts.js文件中发送带有 Expo 推送令牌的此变异,该文件通过调用registerForPushNotificationsAsync函数检索令牌。此函数将返回推送令牌,您可以将其与变异文档一起发送。要发送此文档,可以使用@apollo/react-hooks中的useMutation Hook,您必须与STORE_EXPO_TOKEN常量一起导入:
import  React  from 'react';  - import { useQuery } from '@apollo/react-hooks'; **+ import { useQuery, useMutation } from '@apollo/react-hooks';**  ... - import { GET_POSTS } from '../constants';  + import { GET_POSTS, STORE_EXPO_TOKEN } from '../constants';  import  PostItem  from '../Components/Post/PostItem';  import  registerForPushNotificationsAsync  from '../utils/registerForPushNotificationsAsync';  ...

在 React Apollo Hooks 可用之前,使用变异是很复杂的,因为只能从client对象或Mutation组件发送变异。通过导入ApolloConsumer组件,可以从 React 组件中访问client对象,该组件可以从包装应用程序的ApolloProvider中读取客户端值。

  1. 现在可以使用useMutation Hook 调用STORE_EXPO_TOKEN变异,并将registerForPushNotificationsAsync中的expoToken作为参数,该参数返回一个用于存储令牌的函数称为storeExpoToken。可以从异步registerForPushNotificationsAsync函数的回调中调用此函数,并将令牌作为变量传递:
...

const  Posts  = ({ client, navigation }) => {
**+ const [storeExpoToken] = useMutation(STORE_EXPO_TOKEN);** const [refreshing, setRefreshing] =  React.useState(false);

 React.useEffect(() => { -   registerForPushNotificationsAsync(); +   registerForPushNotificationsAsync().then(expoToken  => { +     return storeExpoToken({ variables: { expoToken } }); +   });  }, []);

...

每当“帖子”屏幕被挂载时,Expo 推送令牌将被发送到 GraphQL 服务器,您可以通过在“添加帖子”和“帖子”屏幕之间切换来强制执行此操作。当从 GraphQL 服务器请求“帖子”屏幕的内容时,服务器将向您的应用程序发送一个随机通知,您可以从“通知”屏幕中查看该通知。此外,您仍然可以在 Expo 仪表板上发送任何通知,无论应用程序是在前台还是后台运行。

总结

在本章中,您使用 React Native 和 Expo 创建了一个移动社交媒体应用程序,该应用程序使用 GraphQL 服务器发送和接收数据以及进行身份验证。使用 Expo,您学会了如何让应用程序请求访问设备的相机或相机滚动条,以添加新照片到帖子中。此外,Expo 还用于从 Expo 仪表板或 GraphQL 服务器接收通知。这些通知将被用户接收,无论应用程序是在后台还是前台运行。

完成了这个社交媒体应用程序,您已经完成了本书的最后一个 React Native 章节,现在准备开始最后一个章节。在这最后一个章节中,您将探索 React 的另一个用例,即 React 360。使用 React 360,您可以通过编写 React 组件创建 360 度的 2D 和 3D 体验。

进一步阅读

第十二章:使用 React 360 创建虚拟现实应用程序

您已经接近成功了——只剩下最后一个章节,然后您就可以称自己为一个在每个平台上都有 React 经验的 React 专家了。在本书中,您已经使用 React 和 React Native 构建了 11 个应用程序,而对于最后的大结局,您将使用 React 360。React 和 React Native 的“一次学习,随处编写”策略的最终部分将在本章中得到最好的展示。使用 React 360,您可以使用 React 和 React Native 的原则创建动态的 3D 和虚拟现实(VR)体验,更具体地说,使用 React Native 类似的生命周期和 UI 组件。虽然虚拟现实仍然是新兴技术,但虚拟现实的最佳用例是,例如,希望顾客体验他们的商店或在线游戏的零售商店。

在本章中,您将探索 React 360 的基础知识以及它与 React 和 React Native 的关系。您将构建的应用程序将能够渲染 360 度全景图像,并使用状态管理在屏幕之间进行渲染。使用 React 360 构建的场景中还将显示动画 3D 对象。

本章将涵盖以下主题:

  • 使用 React 360 入门

  • 使用 React 360 创建全景查看器

  • 构建可点击元素

项目概述

在本章中,您将使用 React 360 构建一个应用程序,该应用程序使用了来自 React 和 React Native 的原则。这个应用程序将添加 2D 全景图像和 3D 对象,并且可以使用 Metro 捆绑器在浏览器中运行项目。

构建时间为 1.5 小时。

入门

本章的应用程序将从头开始构建,并使用可以在 GitHub 上找到的资产。这些资产应该下载到您的计算机上,以便您稍后在本章中使用。本章的完整代码可以在 GitHub 上找到。

React 360 需要与 React 和 React Native 项目相同版本的 Node.js 和npm。如果您尚未在计算机上安装 Node.js,请转到https://nodejs.org/en/download/,在那里您可以找到 macOS、Windows 和 Linux 的下载说明。

安装 Node.js 后,您可以在命令行中运行以下命令来检查已安装的版本:

  • 对于 Node.js(应为 v10.16.3 或更高版本),请使用以下命令:
node -v
  • 对于npm(应为 v6.9.0 或更高版本),请使用以下命令:
npm -v

使用 React 360 创建 VR 应用程序

React 360 使用了来自 React 的原则,并且在很大程度上基于 React Native。React 360 允许您创建应用程序,使用 UI 组件而无需处理移动设备或 VR 设备的复杂设置,这与 React Native 的工作方式类似。

开始使用 React 360

无论您是使用 React、React Native 还是 React 360 创建项目,都有工具可以轻松帮助您开始使用这些技术。在本书中,您已经使用 Create React App 作为 React web 应用程序的起点,并使用 Expo CLI 创建 React Native 项目。此 React 360 项目将使用 React 360 CLI 启动,该 CLI 将帮助您创建和管理 React 360 应用程序。

设置 React 360

可以通过运行以下命令从npm安装 React 360 CLI:

npm install -g react-360-cli

这将从npm软件包注册表全局安装 React 360 CLI。安装过程完成后,您可以使用它通过执行以下命令来创建您的第一个 React 360 项目:

react-360 init virtual-reality

通过执行此命令,将创建一个名为virtual-reality的新 React 360 项目。将安装运行 React 360 应用程序所需的所有软件包,例如reactreact-nativereact-360react-360-webthreethree软件包安装了three.js,这是一个轻量级且易于使用的 JavaScript 3D 库,带有默认的 WebGL 渲染器。React 360 使用此渲染器来渲染 3D 图形,它通过添加一个允许您创建声明式 UI 组件的层来实现。

此外,将在具有相同名称的目录中创建构建项目所需的所有文件。该目录具有以下结构,其中以下文件很重要:

virtual-reality
|-- __tests__
    |-- index-test.js
|-- node_modules
|-- static_assets
    |-- 360_world.jpg
.babelrc
client.js
index.html
index.js
package.json

__tests__目录是您可以使用react-test-renderer包创建测试文件的地方。node_modules目录是您安装包的位置,而static_assets目录包含在开发模式中静态使用的文件,以后可能会转移到 CND。要在浏览器(或移动设备)中使用 React 360,您需要使用 Babel 来转译您的代码。其配置可以在.babelrc文件中找到。由react-360-cli创建的最重要的文件是client.jsindex.htmlindex.js,因为这些文件是您开发和提供应用程序的地方。client.js文件包含您用于执行应用程序的代码,而index.js包含实际的代码,该代码被挂载到index.html中的 DOM 中。

与 webpack 不同,React 360 使用了另一个 JavaScript 捆绑器Metro。这是由 Facebook 创建的,就像 React 一样。Metro 是 React Native 项目的捆绑器,由于 React 360 也从 React Native 中借鉴了很多原则来在 VR 设备上运行,因此 Metro 是 React 360 应用程序的首选捆绑器。与 webpack 一样,所有源代码都被捆绑成一个可供 Web 浏览器阅读的大文件。在开发应用程序时,Metro 捆绑器将运行一个本地开发服务器,允许您在浏览器中查看应用程序。文件在请求时被编译或处理,当应用程序完成时,它可以用于创建一个生产就绪的构建。您可以使用以下命令启动捆绑器来启动开发服务器:

npm start 

这将启动 Metro 捆绑器并编译您的源代码,该代码将被挂载到index.html文件中,并在http://localhost:8081/index.html上提供。

当您首次在浏览器中访问项目时,捆绑器可能需要更长的时间来加载,因为它需要读取您的文件系统以获取有关如何呈现的更多信息。如果您对项目的源代码进行更改,这些更改将更快地变得可见,以增加您的开发速度。由 React 360 CLI 创建的初始应用程序现在在http://localhost:8081/index.html上可见,显示了一个 360 度查看器,可以探索static_assets/360_world.jpg文件中的黑暗景观。它看起来如下:

React 360 应用程序可以显示 360 度(或 3D)图像或视频作为背景,并在此背景上渲染 2D 和 3D UI 组件。在client.js文件中,来自static_assets目录的图像被用作 360 度 2D 背景图像,代码如下:

function  init(bundle, parent, options  = {}) { ... **// Load the initial environment** r360.compositor.setBackground(r360.getAssetURL('360_world.jpg'**))** } window.React360  = {init};

getAssetUrl函数指向static_assets目录,并且在应用程序处于生产状态时,可以稍后用于指向 CDN 或其他 URL,其中托管了背景图像。

如果您有 3D 眼镜,可以用 3D 360 图像替换初始的 360 度 2D 图像,以创建 3D 效果。例如,NASA 的网站是寻找来自任何火星任务的 360 度 3D 图像的好来源。可以在该任务的图像网址中找到这些图像,并将下载的文件放在static_assets中。这应该在client.js文件中使用,而不是360_world.jpg文件。

通过react-360 init创建的应用程序也显示一些 UI 组件;在下一节中,我们将更详细地探讨如何在 React 360 中使用 UI 组件。

React 360 UI 组件

之前,我们提到 React 360 使用了许多 React Native 的概念之一是可以渲染的 UI 组件。React 360 默认提供了四个 UI 组件,即ViewTextEntityVrButton。首先,ViewText组件是 2D 的,并且在index.js文件中用于创建面板和问候消息,您可以在应用程序中看到。另外两个组件更复杂,可以用于渲染 3D 对象(Entity组件)或响应用户操作,例如按下a键(VrButton组件)。

client.js文件中,这些组件可以放置在index.js文件中的圆柱面上,因为这些组件是由client.js中的renderToSurface渲染的。在这里,声明的默认表面指的是显示来自index.js的 UI 组件的 2D 圆柱面:

function  init(bundle, parent, options  = {}) { ...  ** // Render your app content to the default cylinder surface** r360.renderToSurface(
 r360.createRoot('virtual_reality', { /* initial props */ }), r360.getDefaultSurface() **);** ... } window.React360  = {init};

index.js文件中,我们有ViewText组件,用于渲染默认表面,显示应用程序启动时看到的欢迎消息。index.js的默认导出称为virtual_reality,它指的是项目名称,并且与client.js中的createRoot函数使用的名称相同。

随着应用程序的增长,初始结构和命名可能会变得有点混乱。为了解决这个问题,您可以拆分组件,并在index.js中区分应用程序的入口和实际的 UI 组件。需要进行以下更改:

  1. index.js文件移动到一个名为Components的新目录中,并将该文件命名为Panel.js。在这里,您需要将此类组件的名称从virtual_reality更改为Panel

不幸的是,当前版本的 React 360 与 React 16.8+不兼容,因此您需要使用类组件来使用生命周期。

import  React  from  'react'; import { - AppRegistry,  StyleSheet,
 Text,
 View, } from  'react-360'; - export  default  class  virtual_reality  extends  React.Component {
+ export default class Panel extends React.Component {  render() {
 return ( <View  style={styles.panel}> <View  style={styles.greetingBox}> <Text  style={styles.greeting}>Welcome to React 360</Text> </View> </View> );
   }
 }; const  styles  =  StyleSheet.create({
  ... }); - AppRegistry.registerComponent('virtual_reality', () =>  virtual_reality); 
  1. 这个新创建的Panel组件可以被导入到index.js文件中,您需要删除其中已经存在的所有代码,并用以下代码块替换它:
import { AppRegistry, } from  'react-360'; import  Panel  from  './Components/Panel';  AppRegistry.registerComponent('virtual_reality', () =>  Panel);
  1. 要查看您所做的更改,您需要在http://localhost:8081/index.html处刷新浏览器,之后 Metro bundler 将重新编译代码。由于您没有进行可见的更改,您需要查看终端中的输出来查看是否成功。要直接在浏览器中查看这些更改,您可以通过更改Panel组件中Text组件内的值来更改显示的文本:
import  React  from  'react'; import { StyleSheet,
 Text,
 View, } from  'react-360'; export default class Panel extends React.Component {
 render() { return ( <View  style={styles.panel}> <View  style={styles.greetingBox}> -         <Text  style={styles.greeting}>Welcome to React 360</Text> +         <Text  style={styles.greeting}>Welcome to this world!</Text**>** </View> </View> );
  }; }; ...

在进行此更改后刷新浏览器,将显示文本“欢迎来到这个世界!”而不是初始消息。

这些ViewText组件是简单的 2D 元素,可以使用StyleSheet进行样式设置,您在 React Native 中也使用过。通过使用这种方法来为您的 React 360 组件设置样式,React 360 的学习曲线变得不那么陡峭,并且应用了“一次学习,随处编写”的原则。ViewText组件的样式放在scr/Panel.js文件的底部。可以用于ViewText组件的样式规则是有限的,因为并非每个样式规则都适用于这些组件中的每一个。您可以对这些样式进行一些小的更改,就像我们在以下代码块中所做的那样:

...

const  styles  =  StyleSheet.create({
 panel: { // Fill the entire surface width:  1000,
 height:  600,
 backgroundColor: 'rgba(255, 255, 255, 0.4)',  justifyContent: 'center',  alignItems: 'center',
 }, greetingBox: { -   padding:  20, -   backgroundColor: '#000000',  -   borderColor: '#639dda', **+   padding: 25,**
**+   backgroundColor: 'black',**
**+   borderColor: 'green',** borderWidth:  2,
 }, greeting: { fontSize:  30,
 } });

以下截图显示了在进行这些更改后您的应用程序将会是什么样子,面板内显示欢迎消息的框已经有了一些变化:

此外,使用panel样式的第一个视图是在client.js中创建的,默认表面是圆柱形,宽度为1000px,高度为600px。还可以更改此表面的形状和大小,我们将在接下来的部分中进行。

在这一部分,您学习了如何开始使用 React 360 的基础知识。现在,我们将学习如何与 React 360 进行交互。

在 React 360 中的交互

在上一节中,您设置了 React 360 的基础知识,并对显示欢迎消息的初始表面进行了一些更改。使用 React 360,可以创建其他甚至与用户进行一些交互的表面。这些表面可以具有不同的形状和大小,例如平面或圆形,这使得可以在这些表面上添加可操作的按钮。

使用本地状态和 VrButton

在这一部分,您将在表面上添加一些按钮,以便用户可以关闭欢迎消息或切换背景图像场景。首先,让我们从创建一个按钮开始,让我们关闭欢迎消息表面:

  1. Panel组件是一个类组件,它让您可以访问生命周期和本地状态管理。由于您希望能够关闭欢迎消息,因此可以使用本地状态。在Panel组件的声明顶部,您必须添加一个constructor,其中将有初始状态:
import  React  from  'react'; import { StyleSheet,
 Text,
 View, } from  'react-360'; export default class Panel extends React.Component {
**+ constructor() {**
**+   super();**
**+   this.state = {**
**+     open: true**
**+   }**
**+ }**

 render() { return (        ...

如果您对使用类组件进行生命周期不太熟悉,可以回顾一下本书的前几章。在这些章节中,类组件用于生命周期,而不是 Hooks,您在最近的几章中主要使用了 Hooks。

  1. 现在已经设置了初始状态,您可以使用它来修改面板的样式,方法是使用一个styles数组而不是单个对象。除了在这个数组中传递一个style对象之外,您还可以通过使用条件展开直接插入样式规则。如果打开状态不为 true,则会将display: 'none'样式规则添加到面板的样式中。否则,一个空数组将被展开到style数组中:
...

export default class Panel extends React.Component {
  constructor() {
    super();
    this.state = {
      open: true,
    };
  }

 render() {
**+   const { open } = this.state;** return ( -     <View  style={styles.panel}> +     <View  style={[styles.panel, ...(!open ? [{ display: 'none' }] : [])]}>  <View  style={styles.greetingBox}> <Text  style={styles.greeting}>Welcome to this world!</Text> </View> </View> );
  }; };
  1. 在将此state变量添加到面板的样式属性之后,您可以创建将更改打开状态的按钮。您可能还记得,React 360 具有四个默认 UI 组件之一称为VrButton。该组件类似于 React Native 中的TouchableOpacity,默认情况下没有任何样式。可以从react-360中导入VrButton,并将其放置在Text(或View)组件内。单击此VrButton将更改打开状态,因为它使用setState方法:
import  React  from  'react'; import { StyleSheet,
 Text,
 View,
**+ VrButton,** } from  'react-360'; export default class Panel extends React.Component {

  ...

 render() { return (      <View  style={[styles.panel, ...(!open ? [{ display: 'none' }] : [])]}>  <View  style={styles.greetingBox}> <Text  style={styles.greeting}>Welcome to this world!</Text> </View> +       <VrButton +         onClick={() =>  this.setState({ open:  false })} +       >
+         <Text>Close X</Text> +       </VrButton>  </View> );
  }; };
  1. 我们还可以为VrButtonText添加一些样式。这些组件的样式可以放在与此文件中其他组件的样式相同的StyleSheet中:
 ... render() { return (      <View  style={[styles.panel, ...(!open ? [{ display: 'none' }] : [])]}>  <View  style={styles.greetingBox}> <Text  style={styles.greeting}>Welcome to this world!</Text> </View>
        <VrButton          onClick={() =>  this.setState({ open:  false })}
**+         style={styles.closeButton}**
        >
-         <Text>Close X</Text>
+         <Text style={styles.close}>Close X</Text>         </VrButton>  </View> );
  }; }; 
const  styles  =  StyleSheet.create({ 
  ... 

+   closeButton: { +     position: 'absolute',  +     top:  20, +     right:  20, +   },
+   close: { +     fontSize:  40, +     color: 'black', +   },
});

现在,当您在浏览器中刷新应用程序时,面板的右上角将有一个按钮,上面写着Close X。单击此按钮,面板将关闭,您可以自由探索整个背景表面。除了关闭面板,您还可以更改整个应用程序的风景,这将在本节的最后部分中添加。

动态更改场景

该应用程序使用默认背景显示在表面上,但也可以动态更改此背景图像。初始应用程序带有默认的 360 度背景图像。要更改此设置,您需要制作自己的 360 度全景图像,或者从互联网上下载一些图像。可以使用特殊相机或在移动设备上下载应用程序来创建自己的 360 度图像。在线图像可以在许多库存照片网站上找到。在本书的 GitHub 存储库中,在ch12-assets分支中,您可以找到一些 360 度全景图像的选择。

目前,您的应用程序只有一个默认表面,这是一个圆形表面,显示了Panel组件的欢迎面板。还可以添加平面组件,以便用户可以使用按钮更改风景。这需要您进行以下更改:

  • 创建一个显示指定按钮的组件

  • index.js导入并注册组件

  • client.js中设置新的表面

在进行这些更改之前,您必须从 GitHub 存储库下载图像,并将它们放在static_assets目录中,以便可以从应用程序内部使用它们。现在,进行以下更改以改变风景:

  1. Components目录中创建一个名为Navigation的新组件,并将以下代码块放入其中。这将返回一个具有表面基本样式的组件,稍后将在其中放置按钮:
import  React  from 'react';  import { StyleSheet, View } from 'react-360';  export  default  class  Navigation  extends  React.Component { render() {
 return  <View  style={styles.navigation} />;
 } } const  styles  =  StyleSheet.create({
 navigation: { width:  800, height:  100, backgroundColor: 'blue', justifyContent: 'space-between', alignItems: 'center',
    flexDirection: 'row',
 } });
  1. index.js文件中,您必须导入Navigation组件,并使用AppRegistry方法注册它。这将确保该组件可以呈现到一个表面上:
import { AppRegistry } from 'react-360';  import  Panel  from './Components/Panel';  + import  Navigation  from './Components/Navigation';  AppRegistry.registerComponent('Panel', () =>  Panel); + AppRegistry.registerComponent('Navigation', () =>  Navigation);
  1. client.js文件中,必须将此Navigation组件添加到一个表面上;在这种情况下,这是一个平面表面。可以使用react-360Surface方法创建一个新表面,并且必须指定组件的形状和大小。您还可以设置一个角度来定位组件:
function  init(bundle, parent, options  = {}) {  const  r360  =  new  ReactInstance(bundle, parent, {
    // Add custom options here
    fullScreen:  true,
    ...options
  });

+ const  navigationPanel  =  new  Surface(1000, 100, Surface.SurfaceShape.Flat); + navigationPanel.setAngle(0, -0.3); + r360.renderToSurface(r360.createRoot('Navigation'), navigationPanel**);**

 // Render your app content to the default cylinder surface r360.renderToSurface(
 r360.createRoot('virtual_reality', { /* initial props */ }), r360.getDefaultSurface(),
 ); ... } window.React360  = {init};

通过刷新浏览器中的项目,您将看到一个蓝色块被渲染在屏幕底部。要向此块添加按钮,您可以使用VrButton组件,并将当前选择的背景放在本地状态中。现在让我们来做这个:

  1. Components/Navigation.js文件中,您可以向Navigation组件添加必要的按钮。为此,您需要从react-360中导入VrButtonText组件,并将它们放在正在呈现的View组件中。它们将获得样式属性,因为您希望按钮在左侧或右侧都有边距:
import  React  from 'react';  - import { StyleSheet, View } from 'react-360'; **+ import {**
**+   StyleSheet,**
**+   Text,**
**+   View,**
**+   VrButton,**
**+ } from 'react-360';** export  default  class  Navigation  extends  React.Component { render() { -   return  <View  style={styles.navigation} />;
+   return (
+     <View style={styles.navigation}>
+       <VrButton style={[styles.button, styles.buttonLeft]}>
+         <Text style={styles.buttonText}>{'< Prev'}</Text>
+       </VrButton> +       <VrButton style={[styles.button, styles.buttonRight]}>
+         <Text style={styles.buttonText}>{'Next >'}</Text>
+       </VrButton> **+   );**
 } } ...
  1. 这些样式对象可以添加到此文件底部的StyleSheet方法中,就在navigation的样式下面:
... const  styles  =  StyleSheet.create({
 navigation: { width:  800, height:  100, backgroundColor: 'blue',  justifyContent: 'space-between',  alignItems: 'center',
    flexDirection: 'row', }, + button: { +   padding:  20, +   backgroundColor: 'white',  +   borderColor: 'black',  +   borderWidth:  2, + alignItems: 'center',  + width:  200, + }, + buttonLeft: { + marginLeft:  10, + }, + buttonRight: { +   marginRight:  10, + }, + buttonText: { +   fontSize:  40, +   fontWeight: 'bold',  +   color: '**blue',** **+ },** });
  1. 稍后可以使用react-360assets方法将从 GitHub 存储库下载并放置在static_assets中的不同 360 度全景背景图像导入到此文件中。为此,您需要创建一个常量,其中包含这些图像的所有文件名的数组,包括由react-360-cli添加的初始图像。此外,必须在此处导入assetsEnvironment方法,因为您需要这些来更改背景图像:
import  React  from 'react';  import {
**+ assets,**
**+ Environment,**
  StyleSheet,
  Text,
  View,
  VrButton,
} from 'react-360';

**+ const backgrounds = [** +  '360_world.jpg', +  'beach.jpg', +  'landscape.jpg', +  'mountain.jpg', +  'winter.jpg',
+ ];  export  default  class  Navigation  extends  React.Component {

  ...
  1. 就像我们为Panel组件所做的那样,我们需要创建一个初始状态,定义显示哪个背景。这将是背景数组的第一个背景,即0。此外,必须创建一个函数,可以使用setState方法来改变currentBackground。当currentBackground的状态已经改变时,将使用Environment方法更新背景图像,该方法使用assets方法从static_assets目录中选择一个背景:
...

export  default  class  Navigation  extends  React.Component {
**+ constructor() {**
**+  super();**
**+  this.state = {**
**+    currentBackground: 0**
**+  };**
**+ }** 
+ changeBackground(change) { +  const { currentBackground } =  this.state; 
+  this.setState( +    {
+      currentBackground: currentBackground  +  change +    },
+    () => { +      Environment.setBackgroundImage( +        asset(backgrounds[this.state.currentBackground], { format: '2D' }) +      );
+    }
+  );
+ }
 ...
  1. 新创建的changeBackground函数可以在Navigation组件挂载时调用,并使用第一个背景图像,但是当用户点击按钮时,也必须调用changeBackground函数。这可以通过在按钮上添加componentDidMount生命周期并使用onClick事件调用函数来实现:
...

export  default  class  Navigation  extends  React.Component {

  ...

**+ componentDidMount() {**
**+   this.changeBackground(0);**
**+ }** render() {
    return (      <View  style={styles.navigation}> +  <VrButton  style={[styles.button, styles.buttonLeft]}> +       <VrButton +         onClick={() =>  this.changeBackground(-1)} +         style={[styles.button, styles.buttonLeft]} +       >
          <Text  style={styles.buttonText}>{`< Prev`}</Text> </VrButton> +  <VrButton  style={[styles.button, styles.buttonRight]}> +       <VrButton +         onClick={() =>  this.changeBackground(1)} +         style={[styles.button, styles.buttonRight]} +       >
          <Text  style={styles.buttonText}>{`Next >`}</Text> </VrButton> </View> );
  }
}

... 
  1. 当您在浏览器中刷新项目时,您可能会注意到当您按一次左按钮或多次按右按钮时会出现错误。为了防止发生此错误,您需要限定currentBackground状态的最大和最小值。该值不能低于零或高于backgrounds数组的长度。您可以通过对changeBackground函数进行以下更改来实现这一点:
...

export  default  class  Navigation  extends  React.Component {

  ... 
  changeBackground(change) {
   const { currentBackground } =  this.state; 
   this.setState(
     {
-      currentBackground: currentBackground  +  change
+ currentBackground:
+        currentBackground  +  change  >  backgrounds.length  -  1 +          ?  0 +          :  currentBackground  +  change  <  0 +          ?  backgrounds.length  -  1 +          :  currentBackground  +  change      },
     () => {       Environment.setBackgroundImage(
         asset(backgrounds[this.state.currentBackground], { format: '2D' })       );
     }
   );
  }
 ...

currentBackground状态的值将始终是可以在backgrounds数组长度内找到的值,这使您可以在不同的背景图像之间来回导航。点击 Prev 或 Next 按钮几次后,您的应用程序将如下所示:

使用 React 360,您可以做的另一件事是添加动画组件,就像我们在学习 React Native 时所做的那样。您将在下一节学习如何添加这些动画。

动画和 3D

到目前为止,在本章中添加的所有组件都是 2D 的,并且没有动画;但是,您也可以使用 React 360 对组件进行动画处理,甚至添加 3D 对象。这些 3D 对象必须在特殊的 3D 建模软件中预先构建,或者从互联网上下载,并可以添加到应用程序的表面上。对于动画,必须导入 Animated API,这类似于我们用于 React Native 的 Animated API。

动画

在进入 React 360 中使用 3D 对象之前,让我们学习如何使用 React 360 中的 Animated API 创建动画。Animated API 使用 React Native 中的 Animated API,可用于为 UI 组件创建简单和高级动画。使用 Animated API,您可以轻松创建淡入淡出或旋转的动画,只需使用受本地状态影响的值即可。

Panel组件是可以进行动画处理的一个组件,它显示一个欢迎消息,因为这个组件有一个元素,用户可以点击关闭表面。当用户点击关闭按钮时,组件的显示样式规则将被设置为none,使组件突然消失。与此相反,您可以通过以下方式将其改变为平滑的动画:

  1. panel组件是在Components/Panel.js文件中创建的,这是必须从react-360导入AnimatedAPI 的地方:
import  React  from 'react';  - import { StyleSheet, Text, View, VrButton } from 'react-360'; **+ import {**
**+   Animated,**
**+   StyleSheet,**
**+   Text,**
**+   View,**
**+   VrButton,**
**+ } from 'react-360';** export  default  class  Panel  extends  React.Component {
  1. constructor()中,应该设置Animated值的初始值。在这种情况下称为opacity,因为您希望Panel组件的opacity值变为零以使其消失。最初,opacity应为 1,因为用户打开应用程序时必须显示欢迎消息:
...

export  default  class  Panel  extends  React.Component { constructor() {
 super();
 this.state  = { open:  true, +     opacity:  new  Animated.Value(1),  }; }

  render() {
    ... 
  1. 当用户在Panel组件中点击VrButton时,open状态将被更改,之后动画应该开始。因此,必须创建一个componentDidUpdate()生命周期方法,在其中可以检查open状态的变化并在之后开始动画。当open的值从true变为false时,动画应该开始将opacity的值从1变为0,使其消失。
export  default  class  Panel  extends  React.Component { constructor() {
 super();
 this.state  = { open:  true,
 opacity:  new  Animated.Value(1),
 }; } + componentDidUpdate() { +   const { open, opacity } =  this.state; +   Animated.timing(opacity, { +     toValue:  open  ?  1  :  0, +     duration:  800, +   }).start(); **+ }**

 render() {

    ...
  1. 最后,这个值应该传递给Animated组件的style属性,这意味着您需要将View组件更改为可以处理动画的Animated.View组件。style属性中的display样式规则可以被删除,并替换为opacity,因为这控制着组件对用户是否可见:
render() { - const { open, opacity } =  this.state**;**
**+ const { opacity } this.state;**
 return ( -   <View  style={[styles.panel, ...(!open ? [{ display: 'none' }] : [])]}> +   <Animated.View  style={[styles.panel, { opacity }]}**>**
 <View  style={styles.welcomeBox}>
 <Text  style={styles.welcome}>Welcome to this world!</Text>
 </View>
 <VrButton
 onClick={() =>  this.setState({ open:  false })}
 style={styles.closeButton}
 > <Text  style={styles.close}>Close X</Text> </VrButton> -   </View>
+   </Animated.View**>**
 ); }

现在,当您点击关闭带有欢迎消息的Panel组件的VrButton时,该组件将慢慢溶解到背景中并消失。同样的动画效果也可以添加到Navigation组件中,因为您希望确保我们的用户知道他们可以浏览不同的背景。您可以通过使其重复淡入淡出来突出显示点击选项,例如Next按钮。其中很多逻辑与Panel组件相同:

  1. Components/Navigation.js文件的顶部导入AnimatedAPI,并创建opacity状态的初始值:
import  React  from 'react';  import { + Animated,  asset,
 Environment,
  StyleSheet,
 Text,
 View,
 VrButton, } from 'react-360'; ... export  default  class  Navigation  extends  React.Component { constructor() {
 super();     this.state  = { currentBackground:  0, +     opacity:  new  Animated.Value(0),  }; } changeBackground(change) {
    ...
  1. 动画应该在组件挂载后立即开始,因此Animated.timing方法,用于改变opacity的值,必须放在componentDidMount()生命周期方法中。这将启动opacity01的动画,使按钮内的文本闪烁:
...

componentDidMount() { + const { opacity } =  this.state;  this.changeBackground(0); + Animated.timing(opacity, { +  toValue:  1, +  duration:  800 + }).start**()** } render() {

  ...
  1. VrButton中的Text组件用于按钮,以便用户可以导航到下一个背景图像,现在可以更改为Animated.Text组件,并且必须将opacity样式规则添加到style属性中。这将向该组件添加动画,使文本在应用程序挂载时闪烁一次。
render() {
**+ const { opacity } = this.state;**
  return (    <View  style={styles.navigation}>
      <VrButton
        onClick={() =>  this.changeBackground(-1)}
        style={[styles.button, styles.buttonLeft]}
      >
        <Text  style={styles.buttonText}>{`< Prev`}</Text> </VrButton>
 <VrButton
        onClick={() =>  this.changeBackground(1)}
        style={[styles.button, styles.buttonRight]}
      >
-       <Text  style={styles.buttonText}>{`Next >`}</Text> +       <Animated.Text  style={[styles.buttonText, { opacity }]}>{`Next >`}</Animated.Text> </VrButton> </View> );
}

... 
  1. 您不希望按钮文本只闪烁一次。为了使其重复闪烁,您可以使用Animatedloopsequence方法来获得此动画的多次迭代。为了使其更加平滑,我们可以给动画添加一个小延迟。这将迭代 10 次,之后按钮将停止闪烁:
...

componentDidMount() {
  const { opacity } =  this.state;
 this.changeBackground(0);

**+ Animated.loop(**
**+  Animated.sequence([**
**+    Animated.delay(400),**
 Animated.timing(opacity, {
 toValue:  1,
 duration:  800 -    }).start**()**
**+    })**
**+  ]),**
**+  {**
**+    iterations: 10**
**+  }**
**+ ).start();** } render() {

  ...

现在,当应用程序挂载时,下一个按钮将闪烁 10 次,从而强调用户可以在背景场景之间进行导航。然而,这些动画并不是您可以添加的唯一动画特性。在下一节中,您将学习如何添加动画的 3D 对象。

渲染 3D 对象

要在 React 360 中使用 3D 对象,您需要预先构建的 3D 对象,可以使用特殊的 3D 建模软件创建,也可以从互联网上下载。在本节中,我们将使用 GitHub 存储库中的 3D 对象,您可以在该章节中找到一个.obj文件,它受到 React 360 的支持。除了 OBJ,GLTF 模型也受到 React 360 支持作为 3D 对象。

OBJ 文件是 3D 模型文件的标准格式,可以被许多 3D 工具导出和导入。请记住,React 360 不支持照明,您需要包含更高级的软件包来渲染 3D 模型中的复杂纹理。因此,这个例子中使用的 3D 模型只是一个颜色,即白色。

在 React 360 中添加 3D 对象可以很容易地使用Entity对象,同时使用存储在static_assets目录中的 3D 模型。通过使用Entity,3D 模型可以转换为一个组件,您需要在index.js中注册它,以便在client.js中使用并添加到应用程序中。

添加 3D 对象,进行以下更改:

  1. 首先,确保你已经将本章的 GitHub 存储库中的helicopter.obj文件复制到static_assets目录中,并在Components目录中创建一个名为Helicoper.js的新文件。在这个文件中,可以使用asset方法导入 3D 模型,并将其添加为Entity对象的源。为此,请使用以下代码:
import  React  from 'react';  import { asset } from 'react-360';  import  Entity  from 'Entity';  export  default  class  Helicopter  extends  React.Component { render() { return ( <Entity source={{ obj:  asset('helicopter.obj'), }} style={{ transform: [
            { rotate: 90 },
            { scaleX:  0.02 }, 
            { scaleY:  0.02 }, 
            { scaleZ:  0.02 },
          ] }} /> ); } }

Entity对象在style属性中的缩放将减小 3D 模型的大小;否则,它将会太大而无法正确显示。此外,rotateY的值将在y轴上将直升机旋转 90 度。

  1. 这个Helicopter组件应该在你的应用程序中显示,但只有在index.js文件中将其注册到AppRegistry中才能实现:
import { AppRegistry } from 'react-360';
import Panel from './Components/Panel';
import Navigation from './Components/Navigation';
+ import Helicopter from './Components/Helicopter';

AppRegistry.registerComponent('Panel', () => Panel);
AppRegistry.registerComponent('Navigation', () => Navigation);
+ AppRegistry.registerComponent('Helicopter', () => Helicopter);
  1. 这个组件可以在client.js文件中使用renderToLocation方法挂载到应用程序中。之前,你使用renderToSurface方法来挂载PanelNavigation组件,但是对于 3D 对象,这种方法行不通。除了组件本身,renderToLocation方法还需要指定对象放置的位置。
- import { ReactInstance, Surface } from 'react-360-web'; + import { ReactInstance, Surface, Location } from 'react-360-web';  function  init(bundle, parent, options  = {}) {

  ... + const  location  =  new  Location([-100, 10, -2]);
+ r360.renderToLocation(r360.createRoot('Helicopter'), location**);**

 // Render your app content to the default cylinder surface r360.renderToSurface(

    ... 

现在,当你打开应用程序时,当你向左转 90 度时,将会看到一个白色的直升机。在上述代码中,Location用于在应用程序中创建一个位置,3D 模型将被挂载在这个位置上。这是通过new Location([-100, 10, -2])来实现的。这将把对象放置在用户启动应用程序时的初始位置的左侧 100 米,上方 10 米,前方 2 米处。这可以在以下截图中看到,这是在应用程序的不同场景之一中拍摄的:

然而,React 360 不仅仅局限于导入和渲染 3D 对象:你也可以像任何其他组件一样对它们进行动画处理。为此,可以再次使用 Animated API。你可以使用这个 API 与本地状态一起为 3D 直升机添加任何动画。Entitystyle属性已经具有一些样式,用于确定比例,这是可以通过使用Animated值动态地实现的。通过进一步减小直升机的比例,它看起来就像在飞行,并且会消失在远处。通过改变rotateY的值,可以添加更多效果,使直升机看起来正在转向。

要创建一个动画的 3D 对象,请对Components/Helicopter.js进行以下更改:

  1. react-360中导入Animated并创建EntityAnimated版本。由于这不是预定义的Animated组件,我们不能通过输入Animated.Entity来实现。相反,我们需要使用createAnimatedComponent方法创建一个自定义的Animated组件:
import  React  from 'react';  - import {  asset } from 'react-360';  + import { Animated, asset } from 'react-360'; import  Entity  from 'Entity';  + const  AnimatedEntity  =  Animated.createAnimatedComponent(Entity**);** export  default  class  Helicopter  extends  React.Component {

  ...
  1. 必须在Helicopter组件中添加一个constructor,在其中将scalerotateY的初始Animated值设置为本地状态值。scale的初始值为0.02,与直升机的当前比例相同,而rotateY将获得与当前值相同的值:
...

export  default  class  Helicopter  extends  React.Component { + constructor() { +   super(); +   this.state  = { +     scale:  new  Animated.Value(0.02), +     rotateY:  new  Animated.Value(90) +   }; **+ }**

  render() {

    ...
  1. 我们可以在componentDidMount()生命周期方法中创建动画序列,因为我们希望直升机转向并飞走。动画的第一部分是一个小延迟,所以动画不会在应用程序挂载后立即开始。1 秒后(1,000 毫秒),直升机将开始转向约 8 秒,并在另一个小延迟后飞走:
... + componentDidMount() { +   const { scale, rotateY } =  this.state;
+ +   Animated.sequence([ +     Animated.delay(1000), +     Animated.timing(rotateY, { +       toValue:  0, +       duration:  8000 +     }), +     Animated.delay(800), +     Animated.timing(scale, { +       toValue:  0, +       duration:  8000 +     }) +   ]).start(); **+ }** render() {

  ...
  1. Entity组件必须被AnimatedEntity组件替换,后者处理来自Animated API 的值。这些值可以从本地状态中获取,以便将它们添加到AnimatedEntity组件的style属性中:
  render() { +   const { scale, rotateY } =  this.state;  return ( -     <Entity
+     <**AnimatedEntity** source={{ obj:  asset('helicopter.obj') }} style={{ transform: [
**-           { rotateY: 90 },**
**-           { scaleX: 0.02 },**
**-           { scaleY: 0.02 },**
**-           { scaleZ: 0.02 },**
**+** { rotateY }, +           { scaleX:  scale }, +           { scaleY:  scale }, +           { scaleZ:  scale **},** ] }} /> );
 } }

现在,直升机将从 90 度转向 0 度,经过一段时间,它将飞向远处并消失。

总结

在本章的最后,您已经结合了本书中收集到的所有知识,开始使用 React 360。虽然 React 360 使用了来自 React 和 React Native 的实践,但它的用途与其他 React 技术不同且更为特定。在撰写本文时,已经使用了诸如生命周期方法和 Animated API 之类的众所周知的原则,以创建一个允许用户探索 2D 全景图像的 VR 应用程序。它具有基本的动画,以及一个飞向远处的 3D 直升机对象。

通过这最后一章,您已完成本书的所有 12 章,并使用 React、React Native 和 React 360 创建了 12 个项目。现在,您对 React 的所有可能性以及如何在不同平台上使用它有了扎实的理解。虽然 React 和 React Native 已经是成熟的库,但不断添加新功能。即使您完成了本书的阅读,可能还会有新功能可以查看,首先是并发模式。我的建议是永远不要停止学习,并在宣布新功能时密切关注文档。

进一步阅读

posted @ 2024-05-16 14:50  绝不原创的飞龙  阅读(17)  评论(0编辑  收藏  举报