React-项目第二版-全-

React 项目第二版(全)

原文:zh.annas-archive.org/md5/54872467feccdd4d547352263ad84982

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书将通过展示如何将基本和高级 React 模式应用于创建跨平台应用,帮助你将 React 知识提升到新的水平。React 的概念将以新开发者和老开发者都能理解的方式描述;虽然不需要有 React 的先验经验,但这会有所帮助。

在本书的 10 个章节中,你将使用 React 或 React Native 创建一个项目。这些章节中创建的项目实现了流行的 React 功能,如用于复用逻辑的 Hooks、用于状态管理的 context API 和 Suspense。对于路由,使用了流行的库,如 React Router 和 React Navigation;而 JavaScript 测试框架 React Testing Library 和 Cypress 用于编写应用的单元和集成测试。一些更高级的章节还涉及 GraphQL 服务器,并使用 Expo 帮助你创建 React Native 应用。

本书面向对象

本书面向希望探索 React 工具和框架以构建跨平台应用的 JavaScript 开发者。对 Web 开发、ECMAScript 和 React 的基本了解将有助于理解本书中涵盖的关键概念。

本书支持的 React 版本包括:

  • React - v18.0

  • React Native - v0.64

本书涵盖内容

第一章**,在 React 中创建单页应用,将探讨构建可扩展的 React 项目的基石。将讨论如何组织文件、使用的包和工具的最佳实践,并在实践中学习。在构建单页应用的过程中,你将了解 React 架构。此外,webpack 和 Babel 用于编译代码。

第二章**,使用可复用组件和路由在 React 中创建投资组合,将解释如何在整个应用程序中设置和复用 React 组件的样式。我们将构建一个 GitHub 卡应用来展示如何在 JavaScript 中使用 CSS 并复用组件和样式。此外,你还将学习如何使用 React Router v6 实现导航。

第三章**,构建动态项目管理板,将介绍如何通过使用 Hooks 复用组件中的应用状态逻辑。你将学习如何构建自定义 Hooks 并与 Web API 交互以制作可拖拽组件。引入了 Styled Components,以便以可扩展的方式为 React 组件设置样式。

第四章**,使用 Next.js 构建服务器端渲染的社区动态,将讨论路由,从设置基本路由、动态路由处理到如何为服务器端渲染设置路由。因此,在基于 Stack Overflow 构建应用程序的过程中,将使用 React 网络框架 Next.js。

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

第六章**,使用 React Testing Library 和 Cypress 构建探索 TDD 的应用程序,将重点关注使用断言和快照进行单元测试。你将学习如何管理测试覆盖率,并使用 Cypress 框架实现视觉集成测试。我们将构建一个酒店评论应用程序,以了解如何测试组件和数据流。

第七章**,使用 Next.js 和 GraphQL 构建全栈电子商务应用程序,将使用 GraphQL 为应用程序提供后端。本章将向您展示如何使用 Next.js 设置全栈 React 应用程序,包括一个基本的 GraphQL 服务器。我们将构建一个电子商务应用程序,以了解如何创建服务器、向其发送请求以及处理身份验证。

第八章**,使用 React Native 和 Expo 构建动画游戏,将讨论动画和手势,这是真正区分移动应用程序和 Web 应用程序的因素。本章将解释如何实现它们。此外,将通过构建一个具有动画和手势响应功能的卡片游戏应用程序来展示 iOS 和 Android 之间手势的差异。

第九章**,使用 React Native 和 Expo 构建全栈社交媒体应用程序,将涵盖扩展和构建 React Native 应用程序,这与使用 React 创建的 Web 应用程序略有不同。本章将概述如何在构建全栈社交媒体应用程序的同时使用移动设备的原生 API,例如使用相机。我们将通过构建全栈社交媒体应用程序来检查 React Native 的最佳实践。

第十章**,使用 React 和 Three.js 创建虚拟现实应用程序,将讨论如何通过创建一个允许用户在虚拟世界中四处张望并在其中创建组件的全景查看器来开始使用 React 和 Three.js。你将构建的应用程序将看起来像可以在虚拟现实(VR)中玩的游戏。

为了充分利用这本书

本书中的所有项目都是使用 React 或 React Native 创建的。本书的大部分章节需要具备 JavaScript 的先验知识。尽管本书描述了 React 和相关技术的所有概念,但我们建议您在需要了解更多关于某个功能时参考 React 文档。在下一节中,您可以找到有关为本书设置机器以及如何下载每章代码的一些信息。

图片

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

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

  • 对于 Node.js(应该是 v14.19.1 或更高版本):

    node -v
    
  • 对于 npm(应该是 v6.14.14 或更高版本):

    npm -v
    

如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码复制和粘贴相关的任何潜在错误。

本书的内容使用的是截至 2022 年 4 月本书完成时的最新可用版本。在此日期之后的任何更新可能无法与本书中描述的功能兼容。建议您遵循官方的 React 和 React Native 文档以获取有关本书发布后发布的功能的更多信息。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/React-Projects-Second-Edition。如果代码有更新,它将在 GitHub 仓库中更新。

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

下载彩色图像

我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781801070638_ColorImages.pdf

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“如果我们查看App.js中此组件的源代码,我们会看到return函数中已经有一个 CSS header元素。”

代码块设置如下:

.App-logo {
  height: 40vmin;
  pointer-events: none;
}

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

  import './Header.css';
- function Header() {
+ function Header({ logo }) {
    return (
      <header className='App-header'>

任何命令行输入或输出都应如下编写:

npx create-react-app chapter-2

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“从管理面板中选择系统信息。”

小贴士或重要注意事项

看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata并填写表格。

盗版:如果您在互联网上发现我们作品的任何非法副本,我们将不胜感激,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packt.com 与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

分享您的想法

一旦您阅读了《React Projects》,我们很乐意听到您的想法!请点击此处直接访问此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区都至关重要,并将帮助我们确保我们提供高质量的内容。

第一章:使用 React 创建单页应用程序

当您购买这本书时,您可能已经听说过 React,甚至可能尝试过一些在线可找到的代码示例。这本书的构建方式使得每个章节中的代码示例的复杂度逐渐增加,因此即使您的 React 经验有限,如果您已经阅读了前一章节,每个章节都应该容易理解。到本书结束时,您将了解如何使用 React 及其稳定功能,直到版本 18,并且您还将拥有使用 GraphQL 和 React Native 的经验。

本章首先介绍如何基于流行的电视剧 Rick and Morty 构建单页应用程序;该应用程序将向我们提供从外部来源获取的角色信息。我们将应用 React 入门的核心概念,如果您有使用 React 构建应用程序的先前经验,这将容易理解。如果您之前没有使用过 React,也没有问题;本书描述了代码示例中使用的 React 功能。

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

  • 设置新的 React 项目

  • 项目结构

让我们开始吧!

项目概述

在本章中,我们将使用 React 创建一个单页应用程序,从 API 获取数据,并在浏览器中使用 Webpack 和 Babel 运行。样式将使用 Bootstrap 完成。您将构建的应用程序将展示关于流行电视剧 Rick and Morty 的信息,以及图片。

构建时间为 1 小时。

开始

本章的完整代码可以在 GitHub 上找到:github.com/PacktPublishing/React-Projects-Second-Edition/tree/main/Chapter01

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

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

  • 对于 Node.js(应至少为 v14.17.0 或更高版本),请使用以下命令:

    node -v
    
  • 对于 npm(应至少为 v6.14.3 或更高版本),请使用以下命令:

    npm -v
    

此外,您还应该已经安装了 React 开发者工具插件(适用于 Chrome 和 Firefox)并将其添加到您的浏览器中。此插件可以从 Chrome 网上应用店(chrome.google.com/webstore)或 Firefox 插件(addons.mozilla.org)安装。

创建单页应用程序

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

设置项目

每次创建一个新的 React 项目时,第一步是在您的本地机器上创建一个新的目录。由于这是您将要构建的单页应用程序的第一章,因此将此目录命名为chapter-1

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

npm init -y

运行此命令将创建一个全新的package.json文件,其中包含运行 JavaScript/React 项目所需的最基本信息。通过在命令中添加-y标志,我们可以自动跳过设置名称、版本和描述等信息的过程。

运行此命令后,将为项目创建以下package.json文件:

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

注意

要了解更多关于package.json的工作原理,请确保阅读 npm 的文档:docs.npmjs.com/cli/v6/configuring-npm/package-json

在本节创建package.json后,我们就可以添加 Webpack 了,我们将在下一节中这样做。

设置 Webpack

要运行 React 应用程序,我们需要安装 Webpack 5(在撰写本文时,Webpack 的当前稳定版本是版本 5)和 Webpack CLI 作为devDependencies。Webpack 是一个库,它允许我们创建一个由 JavaScript/React 代码组成的包,可以在浏览器中使用。以下步骤将帮助您设置 Webpack:

  1. 使用以下命令从 npm 安装所需的包:

    npm install --save-dev webpack webpack-cli
    
  2. 安装后,这些包将包含在package.json文件中,我们可以在startbuild脚本中运行它们。但首先,我们需要将一些文件添加到项目中:

    chapter-1
      |- node_modules
      |- package.json
    + |- src
    +    |- index.js
    

这将把index.js文件添加到一个名为src的新目录中。稍后,我们将配置 Webpack,使此文件成为我们应用程序的起点。

  1. 首先,必须将以下代码块添加到该文件中:

    console.log('Rick and Morty');
    
  2. 要运行前面的代码,我们将使用 Webpack 将startbuild脚本添加到我们的应用程序中。在本章中不需要测试脚本,因此可以将其删除。此外,main字段可以更改为private,使用true值,因为我们正在构建的是本地项目:

      {
        "name": "chapter-1",
        "version": "1.0.0",
        "description": "",
        "main": "index.js",
        "scripts": {
    -     "test": "echo \"Error: no test specified\" && 
                   exit 1"
    +     "start": "webpack --mode development",
    +     "build": "webpack --mode production"
        },
        "keywords": [],
        "author": "",
        "license": "ISC"
      }
    

npm start命令将以开发模式运行 Webpack,而npm run build将使用 Webpack 创建一个生产包。最大的区别是,以生产模式运行 Webpack 将最小化我们的代码并减小项目包的大小。

  1. 现在,我们从命令行运行startbuild命令;Webpack 将启动并创建一个名为dist的新目录:

    chapter-1
      |- node_modules
      |- package.json
    + |- dist
    +    |- main.js 
      |- src
         |- index.js
    
  2. 在此目录中,将有一个名为 main.js 的文件,它包含我们的项目代码,也被称为我们的包。如果成功,以下输出将可见:

    asset main.js 794 bytes [compared for emit] (name: main)
    ./src/index.js 31 bytes [built] [code generated]
    webpack compiled successfully in 67 ms
    

根据我们是否以开发或生产模式运行 Webpack,此文件中的代码将被最小化。

  1. 你可以通过从命令行运行你的包中的 main.js 文件来检查你的代码是否工作:

    node dist/main.js
    

此命令运行我们应用程序的打包版本,并应返回以下输出:

> node dist/main.js
Rick and Morty

现在,我们能够从命令行运行 JavaScript 代码。在本节下一部分,我们将学习如何配置 Webpack 以使其与 React 一起工作。

配置 Webpack 以与 React 一起工作

现在我们已经为 JavaScript 应用程序设置了一个基本的开发环境,我们可以开始安装运行任何 React 应用程序所需的包。

这些包是 reactreact-dom,前者是 React 的通用核心包,后者提供了浏览器 DOM 的入口点并渲染 React。通过在命令行中执行以下命令来安装这些包:

npm install react react-dom

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

为此,我们将使用 Babel 及其相关包创建一个工具链,以便在浏览器中使用 Webpack 运行 React。这些包可以通过运行以下命令作为 devDependencies 安装:

npm install --save-dev @babel/core babel-loader @babel/preset-env @babel/preset-react

除了 Babel 核心包之外,我们还将安装 babel-loader,这是一个辅助工具,使得 Babel 可以与 Webpack 一起运行,并安装两个预设包。这些预设包有助于确定哪些插件将被用来将我们的 JavaScript 代码编译成浏览器可读的格式(@babel/preset-env)以及编译 React 特定的代码(@babel/preset-react)。安装了 React 的包和正确的编译器后,下一步是让它们与 Webpack 一起工作,以便在运行我们的应用程序时使用它们。

要做到这一点,需要在项目的 src 目录中创建 Webpack 和 Babel 的配置文件:

chapter-1
  |- node_modules
  |- package.json
+ |- babel.config.json
+ |- webpack.config.js
  |- dist
     |- main.js 
  |- src
     |- index.js

将 Webpack 的配置添加到 webpack.config.js 文件中,以使用 babel-loader

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

此文件中的配置告诉 Webpack 使用 babel-loader 处理所有具有 .js 扩展名的文件,并排除 node_modules 目录中的文件供 Babel 编译器使用。

要使用 Babel 预设,必须在 babel.config.json 文件中添加以下配置:

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "esmodules": true
        }
      }
    ],
    [
      "@babel/preset-react",
      {
        "runtime": "automatic"
      }
    ]
  ]
}

@babel/preset-env 必须设置为 esmodules 以使用最新的 Node 模块。此外,定义 JSX 运行时为 automatic 也是必要的,因为 React 18 已经采用了新的 JSX 转换功能:reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html

注意

babel-loader 的配置也可以放在 webpack.config.json 内部的配置中。但通过为这个创建一个单独的 Babel 配置文件,这些设置也可以被 JavaScript/React 生态系统中的其他工具使用。

现在我们已经设置了 Webpack 和 Babel,我们可以从命令行运行 JavaScript 和 React。在本节的下一部分,我们将创建我们的第一个 React 代码,并在浏览器中运行它。

渲染 React 项目

使用我们在前面的部分中安装和配置的包来设置 Babel 和 Webpack,我们需要创建一个实际的 React 组件,该组件可以被编译和运行。创建一个新的 React 项目涉及到向项目中添加一些新文件,并修改 Webpack 的设置:

  1. 让我们编辑 src 目录中已经存在的 index.js 文件,以便我们可以使用 reactreact-dom。此文件的 内容可以替换为以下内容:

    import ReactDOM from 'react-dom/client';
    function App() {
      return <h1>Rick and Morty</h1>;
    }
    const container = document.getElementById('app');
    const root = ReactDOM.createRoot(container);
    root.render(<App />);
    

如您所见,此文件导入了 reactreact-dom 包,定义了一个简单的组件,该组件返回一个包含您应用程序名称的 h1 元素,并且使用 react-dom 在浏览器中渲染此组件。代码的最后一行将 App 组件挂载到文档中具有 root ID 选择器的元素上,这是应用程序的入口点。

  1. 我们可以在一个名为 public 的新目录中创建一个包含此元素的文件,并将其命名为 index.html

    chapter-1
      |- node_modules
      |- package.json
      |- babel.config.json
      |- webpack.config.js
      |- dist
         |- main.js
    + |- public
    +    |- index.html
      |- src
         |- index.js
    
  2. 在此目录中添加一个名为 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>Rick and Morty</title>
      </head>
      <body>
        <section id="root"></section>
      </body>
    </html>
    

这添加了一个 HTML 标题和正文。在 head 标签中是应用程序的标题,在 body 标签中是一个具有 "root" ID 选择器的部分。这与我们在 src/index.js 文件中挂载 App 组件的元素相匹配。

  1. 渲染我们的 React 组件的最终步骤是扩展 Webpack,以便在运行时将压缩后的包代码作为脚本添加到 body 标签中。因此,我们应该将 html-webpack-plugin 包安装到我们的 devDependencies 中:

    npm install --save-dev html-webpack-plugin
    

要使用这个新包用 React 渲染我们的文件,webpack.config.js 文件中的 Webpack 配置必须被扩展:

+ const HtmlWebpackPlugin = 
    require('html-webpack-plugin');
  module.exports = {
    module: {
      rules: [
        {
          test: /\.js$/,
          exclude: /node_modules/,
          use: {
            loader: 'babel-loader',
          },
        },
      ],
    },
+   plugins: [
+     new HtmlWebpackPlugin({
+       template: './public/index.html',
+       filename: './index.html',
+     }),
+   ],
};

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

图 1.1 – 在浏览器中渲染 React

Figure 1.1 – Rendering React in the browser

图 1.1 – 在浏览器中渲染 React

通过设置带有 Webpack 的开发服务器,可以加快此过程。我们将在本节的最后部分完成此操作。

创建开发服务器

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

可以使用 npm 安装 webpack-dev-server 包:

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

此外,我们还需要编辑 package.json 文件中的 dev 脚本,使其使用 webpack-dev-server 而不是 Webpack。这样,每次代码更改后,你就不必重新编译并在浏览器中重新打开捆绑包:

{
    "name": "chapter-1",
    "version": "1.0.0",
    "description": "",
    "private": true,
    "scripts": {
-       "start": "webpack –mode development",
+       "start": "webpack serve –mode development",        
        "build": "webpack –mode production"
    },
    "keywords": [],
    "author": "",
    "license": "ISC"
    …
}

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

从命令行运行以下命令:

npm start

这将使本地开发服务器在 http://localhost:8080/ 上变得活跃,并且每次我们更新项目中的任何文件时,它都会刷新。

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

结构化项目

在设置好开发环境后,是时候开始创建单页应用程序了。在前面的章节中,我们已经在项目中添加了新的目录。但让我们回顾一下项目的当前结构,其中项目根目录下的两个目录很重要:

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

  • 第二个目录称为 src,包含我们应用程序的源代码。

    注意

    在我们项目的根目录中还可以找到一个名为 node_modules 的目录。这是放置我们使用 npm 安装的每个包的源文件的目录。建议您不要对此目录中的文件进行任何手动更改。

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

创建新组件

尽管 React 的官方文档没有说明关于如何结构化我们的 React 项目的首选方法,尽管在社区中有两种常见的方法:要么按功能/页面结构化文件,要么按文件类型结构化。

本章中的单页应用程序将采用混合方法,首先按文件类型结构化文件,其次按功能结构化。在实践中,这意味着将有两种类型的组件:顶层组件,有时也称为容器,以及与这些顶层组件相关的低级组件。创建这些组件需要我们添加以下文件和代码更改:

  1. 实现这种结构的第一步是创建一个名为 components 的新子目录,位于 src 目录下。在这个目录内,创建一个名为 List.js 的文件:

    chapter-1
      |- node_modules
      |- package.json
      |- babel.config.json
      |- webpack.config.js
      |- dist
         |- main.js
         |- index.html
      |- public
         |- index.html
      |- src
    +    |- components
    +       |- List.js 
         |- index.js
    

此文件将返回列出有关 Rick and Morty 所有信息的组件:

function List() {
  return <h2>Characters</h2>;
}
export default List;
  1. 此组件应包含在我们的应用程序入口点中,以便它可见。因此,我们需要在 src 目录下的 index.js 文件中包含它,并引用它:

      import ReactDOM fr'm 'react-dom/client';
    + import List from './components/List';
      function App() {
    -   return <h1>Rick and Morty</h1>;
    +   return (
    +     <div>
    +       <h1>Rick and Morty</h1>
    +       <List />
    +     </div>
    +   );
        }; 
    
      // ...
    
    

如果我们仍然在运行开发服务器(如果不是,请再次执行 npm start 命令),我们会看到我们的应用程序现在在标题下方返回了 Characters 标题。

  1. 下一步是将组件添加到 List 组件中,使其成为一个所谓的组合组件,即由多个组件组成的组件。这个组件将被命名为 Character,并且也应该位于名为 componentssrc 子目录中。在这个目录内,创建一个名为 Character.js 的文件,并将以下代码块添加到其中:

    function Character() {
      return <h3>Character</h3>;
    };
    export default Character;
    

如您从该组件的名称中可能猜到的,它将被用于稍后返回有关 Rick and Morty 中角色的信息。

  1. 现在,将此 Character 组件导入到 List 组件中,并在 h2 元素之后返回此组件,用以下代码替换 return 函数:

    + import Character from './Character';
      function List() {
    - return <h2>Characters</h2>;
    + return (
    +   <div>
    +     <h2>Characters</h2>
    +     <Character />
    +     <Character />
    +   </div>
    + );
    }
    export default List;
    

如果我们再次在浏览器中访问我们的应用程序 http://localhost:8080/,标题和标题下方将显示 Character 字样。

图 1.2 – 向 React 添加组件

图 1.2 – 向 React 添加组件

图 1.2 – 向 React 添加组件

从这个角度来看,我们无法看到浏览器中正在渲染哪些组件。但幸运的是,我们可以在浏览器中打开 React 开发者工具插件;我们会注意到,应用程序目前由多个堆叠的组件组成:

<App>
    <List>
        <Character>

在本节的下一部分,我们将利用我们对构建 React 项目的知识,创建新的组件来检索我们想要在这个单页应用程序中显示的 Rick and Morty 的数据。

检索数据

随着我们的开发服务器和项目结构都已设置,现在是时候向其中添加一些数据了。为此,我们将使用 Rick and Morty REST API (rickandmortyapi.com/documentation/#rest),该 API 提供了关于这部热门电视剧的信息。

可以使用 JavaScript 中的 fetch 方法检索来自 API 的信息,例如,我们的浏览器已经支持此方法。这些数据只会在顶层组件中检索,这意味着我们应该在 List 容器中添加一个 fetch 函数来检索和存储这些信息。

为了存储信息,我们将使用 React 中的内置状态管理 (reactjs.org/docs/state-and-lifecycle.html)。存储在状态中的任何内容都可以传递给低级组件,之后它们被称为 props。在 React 中使用状态的简单示例是通过使用 useState Hook,它可以用来存储和更新变量。每次使用 useState Hook 返回的 update 方法更改这些变量时,我们的组件都会重新渲染。

注意

自从 16.8.0 版本发布以来,React 采用了 Hooks 的概念,这些是 React 提供的方法,允许您在不使用类组件的情况下使用其核心功能。有关 Hooks 的更多信息,请参阅文档:reactjs.org/docs/hooks-intro.html

在添加从 Rick and Morty REST API 检索数据的逻辑之前,让我们检查该 API 以查看将返回哪些字段。API 的基本 URL 是 rickandmortyapi.com/api

此 URL 返回一个包含此 API 所有可能端点的 JSON 输出,这些端点都是 GET 请求,意味着只读,并且通过 https 工作。从此基本 URL 开始,我们将使用 /character 端点来获取 Rick and Morty 中角色的信息。此端点返回的所有信息都不会被使用;以下是我们实际上将使用的字段:

  • id (int): 角色的唯一标识符

  • name (string): 角色的名字

  • origin (object): 包含角色起源名称和链接的对象

  • image (string): 角色图像的链接,尺寸为 300 x 300 像素

在检索 Rick and Morty 的数据之前,需要准备 Character 组件以接收这些信息。为了显示关于 Rick and Morty 的信息,我们需要在 Character 组件中添加以下行:

- function Character() {
- return <h3>Character</h3>;
+ function Character(character) {
+ return (
+   <div>
+     <h3>{character.name}</h3>
+     <img src={character.image} alt={character.name} 
        width='300' />
+     <p>{'Origin: ${character.origin && 
        character.origin.name}'}</p>
+   </div>
+ );
};
export default Character;

现在,可以通过从 React 中导入 useState 并将此 Hook 添加到 List 组件中来实现检索数据的逻辑,该组件将包含一个空数组作为字符的占位符:

+ import { useState } from 'react';
  import Character from './Character';
  function List() {
+   const [characters, setCharacters] = useState([]);
    return (
      // ...

要进行实际的数据检索,还需要导入另一个 Hook,即 useEffect Hook。这个 Hook 可以用来处理副作用,无论是当应用程序挂载时还是当状态或 prop 被更新时。这个 Hook 有两个参数,第一个是一个回调函数,第二个是一个包含此 Hook 所依赖的所有变量的数组——所谓的依赖数组。当这些依赖中的任何一个发生变化时,此 Hook 的回调函数将被调用。当这个数组中没有值时,Hook 将不断被调用。在从源获取数据后,状态将使用结果进行更新。

在我们的应用程序中,我们需要添加此 Hook 并从 API 检索数据,我们应该使用 async/await 函数,因为 fetch API 返回一个承诺。在检索数据后,state 应通过用字符信息替换数据中的空数组来更新:

- import { useState } from 'react';
+ import { useEffect, useState } from 'react';
  import Character from './Character';
  function List() {
    const [characters, setCharacters] = useState([]);
+   useEffect(() => {
+     async function fetchData() {
+       const data = await fetch(
          'https://rickandmortyapi.com/api/character');
+       const { results } = await data.json();
+       setCharacters(results);
+     }
+     fetchData();
+   }, [characters.length]);
  return (
    // ...

useEffect Hook 内部,将调用新的 fetchData 函数,因为建议不要直接使用 async/await 函数。当 characters 状态的长度发生变化时,Hook 仅调用从 API 检索数据的逻辑。你可以通过向应用程序添加一个 loading 状态来扩展此逻辑,这样用户就会知道数据仍在被检索:

  function List() {
+   const [loading, setLoading] = useState(true);
    const [characters, setCharacters] = useState([]);
    useEffect(() => {
      async function fetchData() {
        const data = await fetch(
          'https://rickandmortyapi.com/api/character');
        const { results } = await data.json();
        setCharacters(results);
+       setLoading(false);
      }
      fetchData();
    }, [characters.length]);
  return (
    // ...

注意

我们之前使用 fetch 从 JSON 文件中检索信息的方法没有考虑到对这个文件的请求可能会失败。如果请求失败,loading 状态将保持为 true,这意味着用户将一直看到加载指示器。如果你想在请求不成功时显示错误消息,你需要将 fetch 方法包裹在一个 try...catch 块中,这将在本书的后面部分展示。

为了在应用程序中显示字符信息,我们需要将其传递给 Character 组件,它最终可以在我们第一步中更改的 Character 组件中显示。

当从 API 检索数据时,loading 状态为 true,所以我们还不能显示 Character 组件。当数据检索完成后,加载将变为 false,我们可以遍历 character 状态,返回 Character 组件,并将字符信息作为 props 传递。此组件还将获得一个 key prop,这是在迭代中渲染的每个组件所必需的。由于此值需要是唯一的,因此使用字符的 id,如下所示:

  // ...
  return (
    <div>
      <h2>Characters</h2>
-     <Character />
-     <Character />
+     {loading ? (
+       <div>Loading...</div>
+     ) : (
+       characters.map((character) => (
+         <Character
+           key={character.id}
+           name={character.name}
+           origin={character.origin}
+           image={character.image}
+         />
+       ))
+     )}
    </div>
  );
}
export default List;

如果我们再次在浏览器中访问我们的应用程序,我们会看到它现在显示了一个字符列表,包括一些基本信息和一张图片。在这个时候,我们的应用程序将类似于以下截图:

图 1.3 – 从本地状态渲染组件列表

图 1.3 – 从本地状态渲染组件列表

如您所见,已经对应用程序应用了有限的样式,并且它只渲染从 API 获取的信息。在下一部分中,我们将使用名为 Bootstrap 的包添加样式。

添加样式

只显示角色信息是不够的。我们还需要对项目应用一些基本的样式。通过 Bootstrap 包添加样式到项目,该包根据类名对我们的组件添加样式。

可以使用以下命令从 npm 安装 Bootstrap 并将其添加到devDependencies中:

npm install --save-dev bootstrap

还需要将此文件导入我们的 React 应用程序的入口点src/index.js,这样我们就可以在整个应用程序中使用这些样式:

  import ReactDOM from 'react-dom/client';
  import List from './containers/List';
+ import 'bootstrap/dist/css/bootstrap.min.css';
  function App() {
    // ...    

Webpack 无法自行编译 CSS 文件;我们需要添加适当的加载器来实现这一点。我们可以通过运行以下命令来安装这些加载器:

npm install --save-dev css-loader style-loader

我们需要将这些包作为规则添加到 Webpack 配置中:

  const HtmlWebpackPlugin = require('html-webpack-plugin');
  module.exports = {
    module: {
      rules: [
        {
          test: /\.js$/,
          exclude: /node_modules/,
          use: {
            loader: 'babel-loader',
          },
        },
+       {
+         test: /\.css$/,
+         use: ['style-loader', 'css-loader'],
+       },
      ],
    },
    plugins: [
      new HtmlWebpackPlugin({
        template: './public/index.html',
        filename: './index.html',
      }),
    ],
  };

注意

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

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

  // ...
  function App() {
    return (
-     <div>
+     <div className='container'>
        <h1>Rick and Morty</h1>
        <List />
      </div>
    );
  };
  const root = ReactDOM.createRoot(
    document.getElementById('root'));
  root.render(<App />);

List组件内部,我们需要设置网格以显示Characters组件,这些组件用于显示角色信息。将map函数包裹在一个div元素中,将其视为 Bootstrap 的行容器:

  // ...
  return (
    <div>
      <h2>Characters</h2>
+     <div className='row'>
        {loading ? (
          <div>Loading...</div>
        ) : (
          // ...
          ))
        )}
+     </div>
    </div>
  );
}
export default List;

Character组件的代码也必须进行更改,以使用 Bootstrap 添加样式;您可以将该文件的当前内容替换为以下内容:

function Character(character) {
  return (
    <div className='col-3'>
      <div className='card'>
        <img
          src={character.image}
          alt={character.name}
          className='card-img-top'
        />
        <div className='card-body'>
          <h3 className='card-title'>{character.name}</h3>
          <p>{'Origin: ${character.origin && 
            character.origin.name}'}</p>
        </div>
      </div>
    </div>
  );
};
export default Character;

这使我们能够使用 Bootstrap 容器布局,列大小为 3(getbootstrap.com/docs/5.0/layout/columns/),并将Character组件样式化为 Bootstrap 卡片组件(getbootstrap.com/docs/5.0/components/card/))。

为了添加最后的修饰,打开index.js文件并插入以下代码以添加一个标题,该标题将放置在我们的应用程序中瑞克和莫蒂角色列表的上方:

  // ...
  function App() {
    return (
      <div className='container'>
-       <h1>Rick and Morty</h1>
+       <nav className='navbar sticky-top navbar-light
          bg-dark'>
+         <h1 className='navbar-brand text-light'>
            Rick and Morty</h1>
+       </nav>
        <List />
      </div>
        );
  // ...

确保开发服务器正在运行后,我们会看到应用程序已经通过 Bootstrap 应用了样式,这在浏览器中看起来如下:

图 1.4 – 使用 Bootstrap 样式化的我们的应用程序

图片

图 1.4 – 使用 Bootstrap 样式化的我们的应用程序

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

添加 ESLint

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

通过运行以下命令从 npm 安装 ESLint:

npm install --save-dev eslint eslint-webpack-plugin eslint-plugin-react

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

要配置 ESLint,我们需要在项目的根目录中创建一个名为 .eslintrc 的文件,并将以下代码添加到其中:

{
  "env": {
    "browser": true,
    "node": true,
    "es6": true
  },
  "parserOptions": {
    "ecmaVersion": 2020,
    "sourceType": "module"
  },
  "plugins": ["react"],
  "extends": ["eslint:recommended",
              "plugin:react/recommended"],
  "rules": {
    "react/react-in-jsx-scope": "off"
  }
}  

env 字段设置了代码实际运行的环境,并将使用其中的 es6 函数,而 parserOptions 字段为使用 jsx 和现代 JavaScript 添加了额外的配置。然而,有趣的地方在于 plugins 字段,这是我们指定代码使用 react 作为框架的地方。extends 字段用于使用 eslint 的推荐设置,以及 React 的特定框架设置。此外,rules 字段包含一个规则来禁用关于 React 未导入的通知,因为在 React 18 中这不再需要。

注意

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

如果我们查看命令行或浏览器,我们将看到没有错误。然而,我们必须将 eslint-webpack-plugin 包添加到 Webpack 配置中。在 webpack.config.js 文件中,您需要导入此包并将其添加到配置中:

  const HtmlWebpackPlugin = require('html-webpack-plugin');
+ const ESLintPlugin = require('eslint-webpack-plugin');
  module.exports = {
    // ...
    plugins: [
      new HtmlWebpackPlugin({
        template: './public/index.html',
        filename: './index.html',
      }),
+     new ESLintPlugin(),
    ],
  };

通过重启开发服务器,Webpack 现在将使用 ESLint 检查我们的 JavaScript 代码是否遵守 ESLint 的配置。在我们的命令行(或浏览器中的 控制台 选项卡),任何 React(或 JavaScript)功能的误用都将显示出来。

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

摘要

在本章中,您从零开始创建了一个 React 单页应用程序,并学习了核心的 React 概念。本章从您使用 Webpack 和 Babel 创建新项目开始。这些库帮助您在浏览器中以最小的设置编译和运行您的 JavaScript 和 React 代码。然后,我们描述了如何构建一个 React 应用程序,这个结构将在整本书中使用。此外,您还学习了使用 React Hooks 进行状态管理和数据获取,以及使用 Bootstrap 进行基本样式设计。应用的原则为您提供了从零开始创建 React 应用程序并按可扩展方式构建它们的基础。

如果您之前使用过 React,那么这些概念可能并不难理解。如果您没有,那么请不要担心,如果某些概念让您感到陌生。接下来的章节将基于本章使用的功能,给您足够的时间完全理解它们。

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

进一步阅读

第二章: 使用可重用组件和路由创建 React 个人作品集

完成第一章后,你是否已经熟悉了 React 的核心概念?太好了!本章对你来说将不成问题!如果不熟悉,别担心——你在上一章中遇到的大多数概念都会被重复。然而,如果你想更多地了解 Webpack 和 Babel,建议你再次尝试在 第一章 中创建项目,即 创建 React 单页应用程序,因为本章不会涉及这些主题。

在本章中,你将使用 Create React App,这是 React 核心团队创建的一个启动工具,可以帮助你快速开始使用 React。它将使模块打包器和编译器(如 Webpack 和 Babel)的配置变得不必要,因为 Create React App 包会处理这些。这意味着你可以专注于构建你的个人作品集应用程序,该应用程序重用 React 组件并具有路由。除此之外,我们还将使用 react-router v6 添加路由,这是 React 中路由的领先库。

在设置 Create React App 的同时,本章将涵盖以下主题:

  • 使用 Create React App 创建新项目

  • 构建可重用 React 组件

  • 使用 react-router 进行路由

等不及了?让我们开始吧!

项目概述

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

构建时间约为 1.5-2 小时。

入门

本章中你将创建的项目将使用来自 GitHub 的公共 API,你可以在 docs.github.com/en/rest 找到这些 API。要使用此 API,你需要有一个 GitHub 账户,因为你将需要从 GitHub 用户账户中检索信息。如果你还没有 GitHub 账户,你可以在 GitHub 网站上创建一个。此应用程序的完整源代码也可以在 GitHub 上找到:github.com/PacktPublishing/React-Projects-Second-Edition/tree/main/Chapter02

使用 React 创建个人作品集

在本节中,我们将学习如何使用 Create React App 创建一个新的 React 项目,并使用 react-router 添加可重用的 React 组件和路由。

使用 Create React App 创建个人作品集

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

因此,React 核心团队引入了一个名为 Create React App 的启动工具包,目前版本为 5。通过使用 Create React App,我们不再需要担心管理编译和构建配置,即使 React 发布了新版本也是如此,这意味着我们可以专注于编码而不是配置。

本节将向我们展示如何使用 Create React App 创建一个 React 应用程序。

在做任何事情之前,让我们看看如何安装 Create React App。

安装 Create React App

Create React App 不必全局安装。相反,我们可以使用 npx,这是一个与 npm(v5.2.0 或更高版本)预安装的工具,它简化了我们执行 npm 包的方式:

npx create-react-app chapter-2

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

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

chapter-2
  |- node_modules
  |- package.json
  |- public
     |- index.html
  |- src
     |- App.css
     |- App.test.js
     |- App.js
     |- index.css
     |- index.js

注意

并非所有由 Create React App 创建的文件都被列出;相反,只有本章中使用的文件被列出。

这种结构看起来与我们第一章节中设置的结构非常相似,尽管有一些细微的差别。public 目录包含了所有不应该包含在编译和构建过程中的文件,而这个目录内的文件是唯一可以直接在 index.html 文件中使用的文件。

在另一个名为 src 的目录中,我们将找到在执行 package.json 文件中任何脚本时将被编译和构建的所有文件。有一个名为 App 的组件,它由 App.jsApp.test.jsApp.css 文件定义,还有一个名为 index.js 的文件,它是 Create React App 的入口点。

如果我们打开 package.json 文件,我们会看到已经定义了四个脚本:startbuildtesteject。由于最后两个在此阶段尚未处理,我们可以暂时忽略这两个脚本。为了能够在浏览器中打开项目,我们只需在命令行中输入以下命令,这将以开发模式运行 package react-scripts

npm start

注意

除了 npm start,我们还可以运行 yarn start,因为 Create React App 推荐使用 Yarn。

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

图 2.1 – 默认的 Create React App 模板

图 2.1 – 默认的 Create React App 模板

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

在 Create React App 的基本安装完成后,我们将开始创建我们项目的组件并对它们进行样式设计。

构建可重用的 React 组件

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

构建应用程序结构

我们的项目仍然只包含一个组件,这并不使其非常可重用。为了开始,我们需要以与第一章相同的方式构建我们的应用程序。这意味着我们需要将App组件拆分成多个更小的组件。如果我们查看App.js中此组件的源代码,我们会看到在return函数中已经有一个 CSS header元素。让我们将这个header元素改为 React 组件:

  1. 首先,在src目录下的components新目录中创建一个名为Header.css的新文件,并将classNamesApp-headerApp-logoApp-link的样式复制到其中:

    .App-logo {
      height: 40vmin;
      pointer-events: none;
    }
    @media (prefers-reduced-motion: no-preference) {
      .App-logo {
        animation: App-logo-spin infinite 20s linear;
      }
    }
    .App-header {
      background-color: #282c34;
      min-height: 100vh;
      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);
      }
    }
    
  2. 现在,在这个目录中创建一个名为Header.js的文件。此文件应返回与<header>元素相同的内容:

    import './Header.css';
    function Header() {
      return (
        <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;
    
  3. 在你的App组件中导入此Header组件并将其添加到return函数中:

    + import Header from './components/Header';
      import './App.css';
      import logo from './logo.svg';
      function App() {
        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;
    

需要从App.css中删除标题的样式。此文件应只包含以下样式定义:

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

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

  1. logo常量作为属性发送到src/App.js中的Header组件:

    // ...
    function App() {
       return (
         <div className='App'>
    -      <Header />
    +      <Header logo={logo} />
         </div>
       );
     }
    }
    export default App;
    
  2. 获取logo属性,以便可以在src/components/Header.js中的img元素作为src属性使用:

      import './Header.css';
    - function Header() {
    + function Header({ logo }) {
        return (
          <header className='App-header'>
            // ...
    

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

![Figure 2.2 – The React Developer Tools]

![img/Figure_2.2_B17390.jpg]

![Figure 2.2 – The React Developer Tools]

Header组件仍然被分割成多个元素,这些元素可以被分割成单独的组件。观察imgp元素,它们看起来已经很简单了。然而,a元素看起来更复杂,并具有诸如urltitleclassName等属性。为了将这个a元素转换成一个可重用的组件,我们需要将其移动到我们项目的不同位置。

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

  1. src/components/Header.css中删除App-link类的样式,并将其放置在一个名为Link.css的文件中:

    .App-link {
        color: #61dafb;
    }
    
  2. 创建一个名为Link的新组件,该组件接受urltitle属性。此组件将这些属性作为属性添加到src/components/Link.js中的<a>元素:

    import './Link.css';
    function Link({ url, title }) {
      return (
        <a
          className='App-link'
          href={url}
          target='_blank'
          rel='noopener noreferrer'
        >
          {title}
        </a>
      );
    };
    export default Link;
    
  3. src/components/Header.js中导入此Link组件并将其放置在Header组件内部:

    + import Link from './Link.js';
      import './Header.css';
      function Header({ logo }) {
        return (
          <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;
    
  4. 我们现在的代码应该看起来像以下这样,这意味着我们已经成功将App组件分割成components目录中的不同文件。此外,logo.svg文件可以被移动到一个名为assets的新目录:

    chapter-2
      |- node_modules
      |- package.json
      |- public
         |- index.html
      |- src
         |- assets
            |- logo.svg
         |- components
            |- Header.css
            |- Header.js
            |- Link.css
            |- Link.js
         |- App.css
         |- App.js 
         |- index.css
         |- index.js
    
  5. 不要忘记也更改src/App.js文件中的import语句,其中logo.svg文件被导入为一个组件:

      import Header from './components/Header';
      import './App.css';
    - import logo from './logo.svg';
    + import logo from './assets/logo.svg';
      function App() {
        return (
          // ...
    

然而,如果我们查看浏览器中的项目,则没有明显的更改。然而,在 React 开发者工具中,我们应用程序的结构已经形成。App组件在组件树中显示为父组件,而Header组件是一个子组件,它将Link作为子组件。

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

在 React 中重用组件

本章中我们正在构建的项目是一个个人作品集页面;它将展示我们的公开信息和公开仓库列表。因此,我们需要获取官方 GitHub REST API(v3)并从两个端点获取信息。在第一章中,我们已经执行了数据获取操作,但这次信息不会来自本地 JSON 文件。获取信息的方法几乎相同。我们将使用fetch API 来完成此操作。

我们可以通过执行以下命令从 GitHub 获取我们的公开 GitHub 信息(将代码加粗部分的末尾的username替换为你的用户名):

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

注意

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

此请求将返回以下输出:

{
  "login": "octocat",
  "id": 583231,
  "node_id": "MDQ6VXNlcjU4MzIzMQ==",
"avatar_url": 
    "https://avatars.githubusercontent.com/u/583231?v=4",
  "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",
  "type": "User",
  "site_admin": false,
  "name": "The Octocat",
  "company": "@github",
  "blog": "https://github.blog",
  "location": "San Francisco",
  "email": null,
  "hireable": null,
  "bio": null,
  "twitter_username": null,
  "public_repos": 8,
  "public_gists": 8,
  "followers": 3555,
  "following": 9
}

JSON 输出中的多个字段被突出显示,因为这些是我们将在应用程序中使用的字段。这些是avatar_urlhtml_urlrepos_urlnamecompanylocationemailbio,其中repos_url字段的值实际上是我们需要调用的另一个 API 端点,以检索此用户的全部仓库。这是我们将在本章后面完成的事情。

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

  1. 要从 GitHub 检索这些公开信息,请在新的目录pages中创建一个名为Profile的新组件。这个目录将持有代表我们应用程序中页面的所有组件。在这个文件中,将以下代码添加到src/pages/Profile.js

    import { useState, useEffect } from 'react';
    function Profile({ userName }) {
      const [loading, setLoading] = useState(false);
      const [profile, setProfile] = useState({});
      useEffect(() => {
        async function fetchData() {
          const profile = await fetch(
            'https://api.github.com/users/${userName}');
          const result = await profile.json();
          if (result) {
            setProfile(result);
            setLoading(false);
          }
        }
        fetchData();
      }, [userName]);
      return (
        <div>
          <h2>About me</h2>
          {loading ? (
            <span>Loading...</span>
          ) : (
            <ul></ul>
          )}
        </div>
       );
    }
    export default Profile;
    

这个新组件从 React 中导入了两个 Hooks,用于处理状态管理和生命周期。我们在上一章中已经使用了useState Hook,用于创建loadingprofile的状态。在第二个 Hook 中,即useEffect Hook 中,我们从 GitHub API 进行异步数据获取。由于我们还需要创建新的组件来显示数据,因此还没有渲染任何结果。

  1. 现在,将这个新组件导入到App组件中,并将userName属性传递给它。如果您没有 GitHub 账户,可以使用用户名octocat

      import Header from './Header';
    + import Profile from './pages/Profile';
      import './App.css';
      function App() {
        return (
          <div className='App'>
            <Header logo={logo} />
    +       <Profile userName="octocat" />
          </div>
        );
      }
    }
    export default App;
    
  2. 快速查看运行我们项目的浏览器,我们可以看到这个新的Profile组件目前还不可见。这是因为Header.css文件中有一个height属性,其view-height值为100,这意味着该组件将占用页面的整个高度。要更改这一点,请打开src/components/Header.css文件,并更改以下突出显示的行:

      .App-logo {
    -   height: 40vmin;
    +   height: 60px;
        pointer-events: none;
      }
      // ... 
      .App-header {
        background-color: #282c34;
    -   min-height: 100vh;
    +   min-height: 100%;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        font-size: calc(10px + 2vmin);
        color: white;
      }
    
  3. 页面上应该有足够的空间来显示Profile组件,因此我们可以再次打开scr/pages/Profile.js文件,并显示 GitHub API 返回的avatar_urlhtml_urlrepos_urlnamecompanylocationemailbio字段:

      // ...
      return (
        <div>
          <h2>About me</h2> 
          {loading ? (
            <span>Loading...</span>
          ) : (
            <ul>
    +         <li><span>avatar_url: </span>
                {profile.avatar_url}</li>
    +         <li><span>html_url: </span> 
                {profile.html_url}</li>
    +         <li><span>repos_url: </span> 
                {profile.repos_url}</li>
    +         <li><span>name: </span> {profile.name}</li>
    +         <li><span>company: </span>
                {profile.company}</li>
    +         <li><span>location: </span>
                {profile.location}</li>
    +         <li><span>email: </span>
                {profile.email}</li>
    +         <li><span>bio: </span> {profile.bio}</li>
            </ul>
          )}
        </div>
      );
    }
    export default Profile;
    

保存此文件并访问浏览器中的项目后,我们将看到 GitHub 信息的子弹列表正在显示。

由于这看起来不太美观,并且标题与页面内容不匹配,让我们对这两个组件的styling文件做一些更改:

  1. 更改Header组件的代码,使其显示页面不同的标题。此外,可以删除此处的Link组件,因为我们将在后面的Profile组件中使用它:

      import './Header.css';
    - import Link from './Link';
      function Header({ logo }) {
        return (
          <header className='App-header'>
            <img src={logo} className='App-logo'
             alt='logo' />
    -       <p>
    -         Edit <code>src/App.js</code> and save to
                reload.
    -       </p>
    -       <Link url='https://reactjs.org' 
              title='Learn React' />
    +       <h1>My Portfolio</h1>
          </header>
        );
      }
      export default Header;
    
  2. 在更改Profile组件的样式之前,我们首先需要创建一个 CSS 文件,该文件将包含组件的样式规则。为此,在pages目录中创建Profile.css文件,并添加以下内容:

    .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;
    }
    .Profile-container > ul > li > span {
      font-weight: 600;
    }
    
  3. src/pages/Profile.js 中,我们需要导入此文件以应用样式。还记得我们之前创建的 Link 组件吗?我们也导入此文件,因为它将被用于创建链接到我们的个人资料和 GitHub 网站上的仓库列表:

      import { useState, useEffect } from 'react';
    + import Link from '../components/Link';
    + import './Profile.css';
      function Profile({ userName }) {
    
        // ..
    
  4. return 语句中,我们将添加我们在样式定义中定义的 classNames 函数,并将头像图像与项目符号列表分开。通过这样做,我们还需要将项目符号列表包裹在一个额外的 div 中:

      // ...
      return (
    -   <div>
    +   <div className='Profile-container'>    
          <h2>About me</h2>
          {loading ? (
            <span>Loading...</span>
          ) : (
    +       <div>
    +         <img
    +           className='Profile-avatar'
    +           src={profile.avatar_url}
    +           alt={profile.name}
    +         />
              <ul>
    -           <li><span>avatar_url: </span>
                  {profile.avatar_url}</li>
    -           <li><span>html_url: </span>
                  {profile.html_url}</li>
    -           <li><span>repos_url: </span>
                  {profile.repos_url}</li>
    +           <li>
    +             <span>html_url: </span>
    +             <Link url={profile.html_url}
                   title={profile.html_url} />
    +           </li>
    +           <li>
    +             <span>repos_url: </span>
    +             <Link url={profile.repos_url} 
                    title={profile.repos_url} />
    +           </li>
                <li><span>name: </span>
                  {profile.name}</li>
                <li><span>company: </span>
                  {profile.company}</li>
                <li><span>location: </span>
                  {profile.location}</li>
                <li><span>email: </span>
                  {profile.email}</li>
                <li><span>bio: </span> {profile.bio}</li>
             </ul>
    +      </div>
         );
       }
       // ..
    

最后,我们可以看到应用程序开始看起来像一个加载你的 GitHub 信息的投资组合页面,包括你的头像和公共信息列表。这导致了一个看起来类似于以下截图的应用程序:

图 2.3 – 我们定制的投资组合应用程序

图 2.3 – 我们定制的投资组合应用程序

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

  1. components 目录内创建一个名为 List.js 的新文件,它将接受一个名为 items 的属性:

    function List({ items }) {
      return (
        <ul></ul>
      );
    }
    export default List;
    
  2. Profile 组件中,我们可以导入这个新的 List 组件。应该创建一个名为 items 的新变量,它是一个包含我们想要在此列表中显示的所有项目的数组:

      import { useState, useEffect } from 'react';
    + import List from '../components/List';
      import Link from '../components/Link';
      import './Profile.css';
      function Profile({ userName }) {
        // …
    +   const items = [
    +     {
    +       field: 'html_url',
    +       value: <Link url={profile.html_url} 
              title={profile.html_url} />,
    +     },
    +     {
    +       field: 'repos_url',
    +       value: <Link url={profile.repos_url}
              title={profile.repos_url} />,
    +     },
    +     { field: 'name', value: profile.name },
    +     { field: 'company', value: profile.company },
    +     { field: 'location', value: profile.location },
    +     { field: 'email', value: profile.email },
    +     { field: 'bio', value: profile.bio },
    +   ];
        // ...
    
  3. 这将作为属性发送到 List 组件,因此这些项目可以从该组件中渲染。这意味着你可以删除 ul 元素以及其中所有的 li 元素:

        // ...  
        return (
          <div className='Profile-container'>
            <h2>About me</h2>
            {loading ? (
              <span>Loading...</span>
            ) : (
              <div>
                <img
                  className='Profile-avatar'
                  src={profile.avatar_url}
                  alt={profile.name}
                />
    -           <ul>
    -             // ...
    -           </ul>
    +           <List items={items} />
              </div>
            )}
          </div>
        );
      }
    export default Profile;
    

你可以看到,对于具有 html_urlrepos_url 字段的列表项,我们将发送 Link 组件作为值,而不是从 GitHub API 返回的值。在 React 中,你还可以将完整的组件作为属性发送给不同的组件,因为属性可以是任何东西。

  1. List 组件中,我们现在可以遍历 items 属性并返回列表项:

      // ...
      function List({ items }) {
        return (
          <ul>
    +       {items.map((item) => (
    +         <li key={item.field}>
    +           <span>{item.field}: </span>
    +           {item.value}
    +         </li>
    +       ))}
          </ul>
        );
      }
      export default List;
    

样式是从 Profile 组件继承的,因为 List 组件是一个子组件。为了更好地结构化你的应用程序,你可以将信息列表的样式移动到一个单独的 List.css 文件中,并在 List 组件内导入它。

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

在下一节中,我们将添加使用 react-router 的路由,并显示与我们的 GitHub 账户链接的仓库。

使用 react-router 进行路由

react-router v6 是 React 中最受欢迎的路由库,它支持许多功能,可以帮助你充分利用它。使用这个库,你只需添加组件即可向 React 应用程序添加声明式路由。这些组件可以分为三种类型:路由组件、路由匹配组件和导航组件。

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

  1. 要使用这些组件,你需要通过执行以下命令安装 react-router 网络包,称为 react-router-dom

    npm install react-router-dom
    
  2. 在安装 react-router-dom 之后,下一步是将此包中的路由和路由匹配组件导入到应用程序的容器组件中。在这种情况下,是 App 组件,它位于 src 目录中:

      import React from 'react';
    + import { BrowserRouter, Routes, Route } 
        from 'react-router-dom';
      import logo from './assets/logo.svg';
      import './App.css';
      import Header from './components/Header';
      import Profile from './pages/Profile';
      function App() {
        // …
    
  3. 实际的路由必须添加到此组件的 return 语句中,其中所有路由匹配组件(Route)都必须包裹在一个名为 Router 的路由组件中。当你的 URL 与 Route 的任何迭代定义的路由匹配时,此组件将渲染作为子组件传递的 React 组件:

      // ...
      function App() {
        return (
          <div className='App'>
    +       <BrowserRouter>
            <Header logo={logo} />
    -         <Profile userName='octocat' />
    +         <Routes>
    +           <Route 
    +             path='/'
    +             element={<Profile userName='octocat' />}
    +           />
    +         </Routes>
    +       </BrowserRouter>
          </div>
        );
      }
      export default App;
    

如果你现在再次在浏览器中访问 http://localhost:3000 上的项目,将渲染 Profile 组件。

除了我们的 GitHub 个人资料外,我们还想展示我们一直在工作的项目。让我们向应用程序添加一个新的路由,该路由将渲染我们 GitHub 账户的所有仓库:

  1. 这个新组件将使用端点来获取所有你的仓库,你可以通过执行以下命令来尝试(将粗体部分代码末尾的 username 替换为你自己的用户名):

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

调用此端点的输出将类似于以下内容:

[
  {
    "id": 132935648,
    "node_id": "MDEwOlJlcG9zaXRvcnkxMzI5MzU2NDg=",
    "name": "boysenberry-repo-1",
    "full_name": "octocat/boysenberry-repo-1",
    "private": false,
    "html_url":
      "https://github.com/octocat/boysenberry-repo-1",
    "description": "Testing",
    "fork": true,
    "created_at": "2018-05-10T17:51:29Z",
    "updated_at": "2021-01-13T19:56:01Z",
    "pushed_at": "2018-05-10T17:52:17Z",
    "stargazers_count": 9,
    "watchers_count": 9,
    "forks": 6,
    "open_issues": 0,
    "watchers": 9,
    "default_branch": "master"
  },
  // ...
]

如前一个示例响应所示,仓库数据是一个包含对象的数组。我们将使用前面突出显示的字段来在 /projects 路由上显示我们的仓库。

  1. 首先,我们需要在 pages 目录中创建一个新的组件,命名为 Projects。这个组件将具有与 Profile 组件几乎相同的逻辑用于状态管理和数据获取,但它将调用不同的端点来获取仓库:

    import { useState, useEffect } from 'react';
    import Link from '../components/Link';
    import List from '../components/List;
    function Projects({ userName }) {
      const [loading, setLoading] = useState(true);
      const [projects, setProjects] = useState({});
      useEffect(() => {
        async function fetchData() {
          const data = await fetch(
           'https://api.github.com/users/${
            userName}/repos',
          );
          const result = await data.json();
          if (result) {
            setProjects(result);
            setLoading(false);
          }
        }
        fetchData();
      }, [userName]);
    
      // ...
    
  2. 在将端点的信息放入本地状态变量 projects 之后,我们将使用相同的 List 组件来渲染关于仓库的信息:

      // ...
      return (
        <div className='Projects-container'>
          <h2>Projects</h2>
          {loading ? (
            <span>Loading...</span>
          ) : (
            <div>
              <List items={projects.map((project) => ({
                field: project.name,
                value: <Link url={project.html_url}
                title={project.html_url} />,
      }))} />
            </div>
          )}
        </div>
      );
    }
    export default Projects;
    
  3. 要在访问 /profile 路由时渲染此组件,我们需要使用 Route 组件将其添加到 App 组件中:

      import React from 'react';
      import { BrowserRouter, Routes, Route } 
        from 'react-router-dom';
      import logo from './assets/logo.svg';
      import './App.css';
      import Header from './components/Header';
      import Profile from './pages/Profile';
    + import Projects from './pages/Projects';
      function App() {
        return (
          <div className='App'>
            <Header logo={logo} />
            <BrowserRouter>
              <Routes>
                <Route path='/' element={  <Profile
                   userName='octocat' />} />
    +           <Route path='/projects' element=
                     {<Projects userName='octocat' />} />
                </Routes>
              </BrowserRouter>
            // ...
    

现在,Profile 组件只有在访问 / 路由时才会渲染,而 Projects 组件在访问 /projects 路由时渲染。如果你访问任何其他路由,除了 Header 组件外,不会渲染任何组件。

注意

你可以通过将 * 作为路径传递给 Route 组件来设置一个组件,当没有路由可以匹配时将显示该组件。

虽然我们已经设置了两个路由,但访问这些路由的唯一方法是通过在浏览器中更改 URL。使用 react-router,我们还可以创建动态链接,从任何组件访问这些路由。在我们的 Header 组件中,我们可以添加一个导航栏,渲染指向这些路由的链接:

  import './Header.css';
+ import { Link as RouterLink } from 'react-router-dom';
  function Header({ logo }) {
    return (
      <header className='App-header'>
        <img src={logo} className='App-logo' alt='logo' />
        <h1>My Portfolio</h1>
+       <nav>
+         <RouterLink to='/' className='App-link'>
+           About me
+         </RouterLink>
+         <RouterLink to='/projects' className='App-link'>
+           Projects
+         </RouterLink>
+       </nav>
      </header>
    );
  }
  export default Header;

由于我们已自行定义了 Link 组件,因此我们将从 react-router-dom 中导入 Link 组件作为 RouterLink。这将防止您在以后进行更改时或在使用 IDE 中的自动完成功能时产生混淆。

最后,我们可以在 Header.css 中添加一些样式,以便我们的路由链接显示得更好:

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

如果您现在在浏览器中访问应用程序的 http://localhost:3000/projects,它应该看起来像以下截图。点击页眉中的链接将在两个不同的路由之间导航:

图 2.4 – 应用程序中的项目路由

图 2.4 – 应用程序中的项目路由

在这些路由设置到位后,还可以向 router 组件添加更多路由。一个合理的做法是为单个项目添加一个路由,该路由有一个额外的参数,用于指定应显示哪些项目。因此,我们有一个名为 ProjectDetailpages 的新组件目录,其中包含从 GitHub API 获取单个仓库的逻辑。当路径匹配 /projects/:name 时,该组件会被渲染,其中 name 代表在项目页面点击的项目名称:

  1. 此路由使用一个名为 ProjectDetail.js 的文件中的新组件,该组件与 Projects 组件类似。您也可以在 pages 目录中创建此文件,但将是从 api.github.com/repos/userName/repo 端点获取数据,其中 userNamerepo 应替换为您自己的用户名和您想要显示的仓库名称:

    import { useState, useEffect } from 'react';
    Import { useParams } from 'react-router-dom';
    function Project({ userName }) {
      const [loading, setLoading] = useState(false);
      const [project, setProject] = useState([]);
      const { name } = useParams();
      useEffect(() => {
        async function fetchData() {
          const data = await fetch(
            'https://api.github.com/repos/${
             userName}/${name}',
          );
          const result = await data.json();
          if (result) {
            setProject(result);
            setLoading(false);
          }
        }
        if (userName && name) {
          fetchData();
        }
      }, [userName, name]);
      // ...
    

在前面的章节中,您可以看到如何使用您的用户名和仓库名称从 GitHub API 获取数据。仓库名称来自 react-router-domuseParams 钩子,该钩子为您从 URL 获取 name 变量。

  1. 通过从 GitHub 获取的仓库数据,您可以使用我们在之前的路由中也使用过的 List 组件创建 items 变量,以渲染有关此项目的信息。添加到 items 中的字段来自 GitHub,也可以在我们之前检查的 https://api.github.com/users/username/repos 端点的响应中看到。此外,仓库名称之前也已列出:

     // ...
      return (
        <div className='Project-container'>
          <h2>Project: {project.name}</h2>
          {loading ? (
            <span>Loading...</span>
          ) : (
            <div></div>
          )}
        </div>
      );
    }
    export default Project;
    
  2. 要在 /projects/:name 路由上渲染此组件,我们需要在 App.js 中的 Router 组件内添加此组件:

      // ...
    + import ProjectDetail from './pages/ProjectDetail';
      function App() {
        return (
          <div className='App'>
            <BrowserRouter>
              <Header logo={logo} />
              <Routes>
                <Route exact path='/' element=
                  {<Profile userName='octocat' />} />
                <Route path='/projects' elements=
                  {<Projects userName='octocat' />} />
    +           <Route path='/projects/:name' element=
    {<ProjectDetail userName='octocat' />} 
                />
            </Routes>
          </BrowserRouter>
        );
      }
    
  3. 您可以通过更改浏览器中的 URL 来导航到此路由,但您还希望在 Projects 组件中添加到该页面的链接。因此,您需要做出更改,以导入 RouterLinkreact-router-dom 并使用它来代替您自己的 Link 组件:

      import { useState, useEffect } from 'react';
    + import { Link as RouterLink } from 
        'react-router-dom'
      import List from '../components/List';
    - import Link from '../components/Link';
      // ...
      return (
        <div className='Projects-container'>
          <h2>Projects</h2>
          {loading ? (
            <span>Loading...</span>
          ) : (
            <div>
              <List items={projects.map((project) => ({
                field: project.name,
    -           value: <Link url={project.html_url}
                title={project.html_url} />,
                  }))items} />
    +           value: <RouterLink url={project.html_url}
                title={project.html_url} />,
                  }))items} />
            </div>
          )}
        </div>
      );
    }
    export default Projects;
    

如果你现在在浏览器中访问http://localhost:3000/projects页面,你可以点击项目,进入一个新页面,该页面显示了特定项目的所有信息。

通过这些最后的更改,你已经创建了一个使用react-router进行动态路由的资料库应用。

摘要

在本章中,你使用了 Create React App 来创建你的 React 应用的起始项目,该应用附带了对 Babel 和 Webpack 等库的初始配置。通过这样做,你不必自己配置这些库,也不必担心你的 React 代码如何在浏览器中运行。我们已经探讨了如何构建可重用组件,并学习了如何使用react-router添加动态路由。使用这个库,你可以创建具有大量路由的应用程序,并且能够使用 URL 的变化来更改应用程序内部显示的内容。

接下来的章节都将展示使用 Create React App 或其他零配置库创建的项目,这意味着这些项目不需要你对 Webpack 或 Babel 进行任何更改。

在下一章中,我们将通过使用 React 创建一个动态的项目管理板来构建本章内容,该板使用 styled-components 进行样式设计,并通过自定义 Hooks 重用逻辑。

进一步阅读

第三章: 构建动态项目管理板

在本书的前两章中,你独立创建了两个 React 项目,到现在你应该已经对 React 的核心概念有了扎实的理解。你到目前为止所使用的概念也将在本章中用于创建你的第三个 React 项目,包括一些新的更高级的概念,这些概念将展示使用 React 的优势。再次提醒,如果你觉得你缺少完成本章内容所需的一些知识,你总是可以重复你迄今为止所构建的内容。

本章将再次使用 Create React App,这是你在上一章中使用过的。在本章的项目管理板应用开发过程中,我们将创建并使用一个自定义钩子进行数据获取。将使用 HTML5 网络 API 动态拖放组件,这些组件被设置为可重用组件,并使用styled-components进行样式设置。随后,你将使用更高级的 React 技术来控制组件间的数据流,例如通过创建自定义钩子。

本章将涵盖以下主题:

  • 处理数据流

  • 使用自定义钩子

  • 使用 HTML5 网络 API

  • 使用styled-components为 React 添加样式

项目概述

在本章中,我们将创建一个动态项目管理板,它使用 Create React App 和styled-components具有可重用 React 组件和样式。该应用将具有一个使用 HTML5 拖放 API 的动态拖放界面。

构建时间约为 1.5-2 小时。

开始

本章我们将创建的项目基于 GitHub 上可找到的初始版本:github.com/PacktPublishing/React-Projects-Second-Edition/tree/main/Chapter03-initial。该应用的完整源代码也可以在 GitHub 上找到:github.com/PacktPublishing/React-Projects-Second-Edition/tree/main/Chapter03

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

如以下截图所示,应用有一个带有标题的基本页眉,并分为四个列。这些列是项目管理板的通道,一旦我们将项目连接到数据文件,它们将包含个别任务。

![图 3.1 – 初始应用

![图 3.1]

图 3.1 – 初始应用

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

在这个应用程序中,你可以看到我们已经将组件进一步拆分到单独的目录中。现在,componentspages目录中的每个组件都有自己的子目录:

chapter-3-initial
  |- /node_modules
  |- /public
  |- /src
    |- /components
       |- /Header
          |- Header.css
          |- Header.js
       |- /Lane
          |- Lane.css
          |- Lane.js
    |- /pages
       |- /Board
          |- Board.js
          |- Board.css
    |- App.js
    |- App.css
    |- index.js
    |- index.css
package.json

创建项目管理板应用程序

在本节中,我们将创建一个使用自定义钩子进行数据获取和 HTML5 拖放 API 使其动态化的项目管理板 PWA。我们将使用一个由 Create React App 设置的样板应用程序,我们可以在本章的 GitHub 仓库中找到它。

处理数据流

在应用程序的初始版本就绪后,下一步是获取项目管理板初始数据并处理其通过组件的流动。为此,我们将创建一个用于数据获取的自定义钩子,该钩子可以在其他组件中重用。

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

加载和显示数据

加载和显示从数据源检索到的数据是我们上一章所做的事情。本章使用的数据来自一个模拟的 REST API,由 Typicode 的 My JSON Server 创建。使用名为db.json的文件,该文件位于本书的仓库中,我们可以自动创建 REST 端点。

图 3.2 – 使用 My JSON Server

图 3.2 – 使用 My JSON Server

使用 My JSON Server,my-json-server.typicode.com/PacktPublishing/React-Projects-Second-Edition/tasks端点返回一个任务列表,在本节中我们将将其加载到我们的项目管理板中。响应是一个包含 id、标题、正文和通道字段中定义的任务信息的对象数组。

本节将进一步探讨这个问题。按照以下步骤开始:

  1. 我们将首先从数据文件中获取项目数据。为此,我们需要向Board组件添加必要的函数。我们需要这些函数来使用 Hooks 访问 React 生命周期,这我们在前面的章节中已经做了:

    + import { useState, useEffect } from 'react';
      import Lane from '../../components/Lane/Lane';
      import './Board.css';
      // ...
      function Board() {
    +   const [loading, setLoading] = useState(false);
    +   const [tasks, setTasks] = useState([]);
    +   const [error, setError] = useState('');
    +   useEffect(() => {
    +     async function fetchData() {
    +       try {
    +         const tasks = await fetch(
                  `https://my-json-server.typicode.com/
                  PacktPublishing/React-Projects-Second-
                  Edition/tasks`,
            );
    +         const result = await tasks.json();
    +         if (result) {
    +           setTasks(result);
    +           setLoading(false);
    +         }
    +       } catch (e) {
    +         setLoading(false);
    +         setError(e.message);
    +       }
    +     }
    +     fetchData();
    +   }, []);
       return (
         // ...
    

useEffect钩子中,数据是在try..catch语句中获取的。这个语句捕获数据获取过程中返回的任何错误,并用此消息替换错误状态。

  1. 现在,我们可以将任务分配到相应的通道上:

        // ...
        return (
          <div className='Board-wrapper'>
            {lanes.map((lane) => (
              <Lane
                key={lane.id}
                title={lane.title}
    +           loading={loading}
    +           error={error}
    +           tasks={tasks.filter((task) => 
                  task.lane === lane.id)}
              />
            ))}
          </div>
        );
      }
    
      export default Board;
    

return语句中,你可以看到一个迭代lanes常量的函数,并将这些值作为 props 传递给Lane组件。另外,当我们向组件传递任务时,有一些特别的事情正在发生,因为filter函数正在被用来只返回与 lane ID 匹配的任务状态。

  1. 接下来,我们需要对Lane组件做一些修改,以便它能够使用我们从 REST API 获取的数据来显示任务:

    + import Task from '../Task/Task';
      import './Lane.css';
    - function Lane({ title }) {
    + function Lane({ title, loading, error, tasks }) {
        return (
          <div className='Lane-wrapper'>
            <h2>{title}</h2>
    +       {loading || error ? (
    +         <span>{error || 'Loading...'}</span>
    +       ) : (
    +         tasks.map((task) => (
    +            <Task
    +              key={task.id}
    +              id={task.id}
    +              title={task.title}
    +              body={task.body}
    +            />
    +         ))
    +       )}
          </div>
        );
      }
      export default Lane;
    

Lane组件现在接受另外三个 props,分别是tasksloadingerror,其中tasks包含从 REST API 获取的任务数组,loading指示是否应显示加载消息,而error包含当存在错误时的错误消息。我们可以看到,在map函数中迭代任务时,将渲染显示信息的Task组件。

  1. 要创建此任务,我们需要在components目录中创建一个名为Task的目录,并在其中放置一个名为Task.js的新文件,用于Task组件:

    import './Task.css';
    function Task({ title, body }) {
      return (
        <div className='Task-wrapper'>
          <h3>{title}</h3>
          <p>{body}</p>
        </div>
      );
    }
    export default Task;
    
  2. 此组件从我们需要在Task目录内创建的另一个文件中获取样式,该文件名为Task.css,内容如下:

    .Task-wrapper {
      background: darkGray;
      padding: 20px;
      border-radius: 20px;
      margin: 0% 5% 5% 5%;
    }
    .Task-wrapper h3 {
      width: 100%;
      margin: 0;
    }
    

如果我们在网页浏览器中访问我们的应用程序http://localhost:3000,我们将看到以下内容:

![图 3.3 – 我们的应用程序,使用模拟 REST API 的数据图片

图 3.3 – 我们的应用程序,使用模拟 REST API 的数据

从数据源获取数据是可以在我们的应用程序中复用的逻辑。在下一节中,我们将探讨如何通过创建自定义 Hook 来跨多个组件复用这种逻辑。

使用自定义 Hooks

Hooks 是一种使用 React 功能来创建本地状态或使用生命周期来监视该状态更新的方法。但 Hooks 也是复用你为 React 应用程序创建的逻辑的一种方式。这是许多为 React 创建功能库中流行的一种模式,例如react-router

注意

在 React 引入 Hooks 之前,创建高阶组件HOCs)以复用逻辑是一种流行的模式。HOCs 是 React 的高级特性,专注于组件的可复用性。React 文档这样描述它们:“一个高阶组件是一个函数,它接受一个组件并返回一个新的组件。”

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

创建自定义 Hooks

我们已经看到我们可以在 React 中重用组件,但下一步是重用这些组件内部的逻辑。为了解释这在实践中意味着什么,让我们创建一个示例。我们的项目有一个 Board 组件,它获取 REST API 并渲染所有通道和任务。这个组件中有逻辑,以 useState Hook 创建的本地状态、useEffect Hook 内部的数据获取和关于每个 Lane 组件如何渲染的信息的形式存在。如果我们只想显示没有通道且只有任务的板怎么办?我们只是从 Board 组件发送不同的属性吗?当然,这是可能的,但在 React 中,这就是自定义 Hook 的用途。

没有通道的 Board 组件不会遍历所有通道并渲染带有任务的相应通道作为属性。相反,它会遍历所有任务并直接渲染它们。尽管渲染的组件不同,但设置初始状态、获取数据和渲染组件的逻辑可以重用。自定义 Hook 应该能够使用本地状态并在任何使用它的组件中执行数据获取。

要创建自定义 Hook,在 src 目录下创建一个名为 hooks 的新目录,并在其中创建一个名为 useDataFetching.js 的新文件。现在,按照以下步骤操作:

  1. 从 React 中导入 useStateuseEffect Hook 并为 Hook 创建一个新的函数,该函数成为默认导出。这个函数将接受一个名为 dataSource 的参数。由于这个 Hook 将使用数据获取的生命周期,让我们将这个自定义 Hook 命名为 useDataFetching 并使其返回一个空数组:

    import { useState, useEffect } from 'react';
    function useDataFetching(dataSource) {
      return [];
    }
    export default useDataFetching;
    
  2. 在这个函数内部,添加 useState Hook 来创建 loadingerrordata 的本地状态,其结构与 Board 组件内部的本地状态几乎相同:

      import { useState, useEffect } from 'react';
      function useDataFetching(dataSource) {
    +   const [loading, setLoading] = useState(false);
    +   const [data, setData] = useState([]);
    +   const [error, setError] = useState('');
        return [];
      }
    
      export default useDataFetching;
    
  3. 接下来,我们需要使用 useEffect Hook,这是数据获取将发生的地方。dataSource 参数用作获取的位置。注意,常量名称现在更加通用,不再指定单一用途:

      import { useState, useEffect } from 'react';
      function useDataFetching(dataSource) {
        // ...
    -   return [];
    +   useEffect(() => {
    +     async function fetchData() {
    +       try {
    +         const data = await fetch(dataSource);
    +         const result = await data.json();
    +         if (result) {
    +           setData(result);
    +           setLoading(false);
    +         }
    +       } catch (e) {
    +         setLoading(false);
    +         setError(e.message);
    +       }
    +     }
    +     fetchData();
    +   }, [dataSource]);
    +   return [loading, error, data];
      }
      export default useDataFetching;
    

这添加了进行数据获取的方法,并且在 return 语句中,我们返回 dataloadingerror 状态。

恭喜!你已经创建了你的第一个 Hook!然而,它仍然需要添加到一个支持数据获取的组件中。因此,我们需要重构我们的 Board 组件以使用这个自定义 Hook 进行数据获取:

  1. src/hooks/useDataFetching.js 文件中导入自定义 Hook 并删除 React Hook 的导入:

    - import { useState, useEffect } from 'react';
    + import useDataFetching from 
        '../../hooks/useDataFetching';
      import Lane from '../../components/Lane/Lane';
      import './Board.css';
      // ...
    
  2. 随后,我们可以删除 Board 组件中 useStateuseEffect Hook 的使用:

      // ...
      function Board() {
    -   const [loading, setLoading] = useState(false);
    -   const [tasks, setTasks] = useState([]);
    -   const [error, setError] = useState('');
    -   useEffect(() => {
    -     async function fetchData() {
    -       try {
    -         const tasks = await fetch(
              `https://my-json-server.typicode.com/
               PacktPublishing/React-Projects-Second-
               Edition/tasks`,
            );
    -         const result = await tasks.json();
    -         if (result) {
    -           setTasks(result);
    -           setLoading(false);
    -         }
    -       } catch (e) {
    -         setLoading(false);
    -         setError(e.message);
    -       }
    -     }
    -     fetchData();
    -   }, []);
       return (
         // ...
    
  3. 相反,使用导入的自定义 Hook 来处理我们的数据获取。该 Hook 返回与之前相同的 loadingerrortasks 状态,但数据状态被重命名为 tasks 以适应我们组件的需求:

      import useDataFetching 
        from '../../hooks/useDataFetching';
      import Lane from '../../components/Lane/Lane';
      import './Board.css';
      function Board() {
    +   const [loading, error, tasks] =
          useDataFetching(`https://my-json-server.
          typicode.com/PacktPublishing/React-Projects-
          Second-Edition/tasks`);
       return (
         // ...
    

在下一节中,我们将学习如何通过从不同的组件导入来重复使用自定义钩子。

重复使用自定义钩子

在放置了第一个自定义钩子之后,是时候考虑其他可以进行数据获取的组件了,例如仅显示任务的组件。创建此组件的过程包括两个步骤:创建实际组件和使用自定义钩子进行数据获取。让我们开始吧:

  1. pages目录中,我们需要在名为Backlog的新目录中创建一个名为Backlog.js的新文件。在此文件中,我们可以放置以下代码以创建组件,导入自定义钩子,并导入 CSS 进行样式设置:

    import Task from '../../components/Task/Task';
    import useDataFetching from 
      '../../hooks/useDataFetching';
    import './Backlog.css';
    function Backlog() {
      const [loading, error, tasks] = useDataFetching(
        'https://my-json-server.typicode.com/
         PacktPublishing/React-Projects-Second-Edition/
         tasks',
      );
      return (
        <div></div>
      );
    }
    export default Backlog;
    
  2. 现在的return语句正在返回一个空的div元素,因此我们需要在此处添加代码以渲染任务:

      // ...
      return (
    -   <div>
    +   <div className='Backlog-wrapper'>
    +     <h2>Backlog</h2>
    +     <div className='Tasks-wrapper'>
    +       {loading || error ? (
    +         <span>{error || 'Loading...'}</span>
    +       ) : (
    +         tasks.map((task) => (
    +           <Task
    +             key={task.id}
    +             title={task.title}
    +             body={task.body}
    +           />
    +         ))
    +       )}
    +     </div>
        </div>
      );
    }
    export default Backlog;
    
  3. 此组件导入Backlog.css文件进行样式设置,我们还在此文件中的元素上添加了类。但我们也需要在Backlog.css中创建并添加一些基本的样式规则:

    .Backlog-wrapper {
      display: flex;
      flex-direction: column;
      margin: 5%;
    }
    .Backlog-wrapper h2 {
      width: 100%;
      padding-bottom: 10px;
      text-align: center;
      border-bottom: 1px solid darkGray;
    }
    .Tasks-wrapper {
      display: flex;
      justify-content: space-between;
      flex-direction: row;
      flex-wrap: wrap;
      margin: 5%;
    }
    
  4. App组件中,我们可以导入这个组件,在Board组件下方渲染它:

      import './App.css';
      import Board from './pages/Board/Board';
      import Header from './components/Header/Header';
    + import Backlog from './pages/Backlog/Backlog';
      function App() {
        return (
          <div className='App'>
            <Header />
            <Board />
    +       <Backlog />
          </div>
        );
      }
      export default App;
    

这将在我们的板子下方渲染新的Backlog组件,其中包含所有不同的任务。这些任务与Board组件中的任务相同,因为我们使用了相同的 REST API 端点。此外,您还可以为这个项目设置react-router,以便在另一个页面上渲染Backlog组件。

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

使板子动态化

项目管理板通常具有很好的用户交互能力之一是能够将任务从一个车道拖放到另一个车道。这是可以使用 HTML5 拖放 API 轻松实现的事情,该 API 在所有现代浏览器中都是可用的,包括 IE11。

HTML5 拖放 API 使我们能够在项目管理板之间拖放元素。为了实现这一点,它使用拖放事件。onDragStartonDragOveronDrop将用于此应用程序。这些事件应该放置在LaneTask组件上。

Board组件的文件中,让我们添加响应拖放事件的函数,这些事件需要发送到LaneTask组件。让我们开始吧:

  1. 首先为onDragStart事件添加事件处理函数,该事件在拖动操作开始时触发,并将其添加到Board组件中。这个函数需要传递给Lane组件,然后可以传递给Task组件。此函数为正在拖动的任务设置一个 ID,并将其放置在元素的dataTransfer对象中,浏览器使用该对象来识别拖放元素:

      // ...
    + function onDragStart(e, id) {
    +   e.dataTransfer.setData('id', id);
    + }
      function Board() {
        const [loading, error, tasks] = useDataFetching(
          'https://my-json-server.typicode.com/
           PacktPublishing/React-Projects-Second-Edition/
           tasks',
        );
        return (
          <div className='Board-wrapper'>
            {lanes.map((lane) => (
              <Lane
                key={lane.id}
                title={lane.title}
                loading={loading}
                error={error}
                tasks={tasks.filter((task) => 
                       task.lane === lane.id)}
    +           onDragStart={onDragStart}
              />
            ))}
          </div>
        );
      }
      export default Board;
    
  2. Lane组件中,我们需要将这个事件处理函数传递给Task组件:

      // ...
    - function Lane({ title, loading, error, tasks }) {
    + function Lane({ title, loading, error, tasks, 
        onDragStart }) {
        return (
          <div className='Lane-wrapper'>
            <h2>{title}</h2>
            {loading || error ? (
              <span>{error || 'Loading...'}</span>
            ) : (
              tasks.map((task) => (
                <Task
                  key={task.id}
                  title={task.title}
                  body={task.body}
    +             onDragStart={onDragStart}
                />
              ))
            )}
          </div>
        );
      }
      export default Lane;
    
  3. 现在,我们可以在 Task 组件中调用此函数,在那里我们还需要将可拖动属性添加到具有 Task-wrapper 类名的 div 元素上。在这里,我们将元素和任务 ID 作为参数传递给事件处理程序:

      import './Task.css';
    - function Task({ title, body }) { 
    + function Task({ id, title, body, onDragStart }) {
        return (
          <div
            className='Task-wrapper'
    +       draggable
    +       onDragStart={(e) => onDragStart(e, id)}
          >
            <h3>{title}</h3>
            <p>{body}</p>
          </div>
        );
      }
      export default Task;
    

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

图 3.4 – 交互式项目管理板

图 3.4 – 交互式项目管理板

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

  1. 默认情况下,无法将元素拖放到另一个元素中——例如,将 Task 组件拖放到 Lane 组件中。这可以通过调用 onDragOver 事件的 preventDefault 方法来防止:

      // ...
      function onDragStart(e, id) {
        e.dataTransfer.setData('id', id);
      }
    + function onDragOver(e) {
    +   e.preventDefault();
    + };
      function Board() {
        const [loading, error, tasks] = useDataFetching(
          'https://my-json-server.typicode.com/
           PacktPublishing/React-Projects-Second-Edition/
           tasks',
        );
        return (
          <div className='Board-wrapper'>
            {lanes.map((lane) => (
              <Lane
                key={lane.id}
                title={lane.title}
                loading={loading}
                error={error}
                tasks={tasks.filter((task) => 
                       task.lane === lane.id)}
                onDragStart={onDragStart}
    +           onDragOver={onDragOver}
              />
            ))}
          </div>
        );
      }
      export default Board;
    
  2. 此函数需要被导入,并放置在 Lane 组件中具有 Lane-wrapper 类名的 div 元素上作为事件处理程序:

      // ...
    - function Lane({ title, loading, error, tasks, 
        onDragStart }) {
    + function Lane({ title, loading, error, tasks, 
        onDragStart, onDragOver }) {
        return (
    -     <div className='Lane-wrapper'>
    +     <div className='Lane-wrapper' 
           onDragOver={onDragOver}>
            <h2>{title}</h2>
            // ...
    
  3. onDrop 事件是事情变得有趣的地方,因为这个事件使得我们在完成拖动操作后能够修改状态成为可能。在我们能够添加此事件处理程序之前,我们需要在 Board 组件中创建一个新的局部状态变量 tasks。当从 useDataFetching 钩子获取数据时,此状态变量会被覆盖,并用于显示来自 Lane 组件的任务:

    + import { useEffect, useState } from 'react';
      import Lane from '../../components/Lane/Lane';
      import useDataFetching from
        '../../hooks/useDataFetching';
      import './Board.css';
      // ...
      function Board() {
        const [
          loading, 
          error, 
    -     tasks
    +     data] = useDataFetching(
            'https://my-json-server.typicode.com/
             PacktPublishing/React-Projects-Second-Edition/
             tasks',
      );
    +   const [tasks, setTasks] = useState([]);
    +   useEffect(() => {
    +     setTasks(data);
    +   }, [data]);
      // ...
      return (
        // ...
    
  4. 现在可以创建新的事件处理函数,当它被调用时,我们可以从 useState 钩子中的任务状态调用 setTasks 函数:

      // ...
      function Board() {
        // ...
    +   function onDrop(e, laneId) {
    +     const id = e.dataTransfer.getData('id');
    +     const updatedTasks = tasks.filter((task) => {
    +       if (task.id.toString() === id) {
    +         task.lane = laneId;
    +       }
    +       return task;
    +     });
    +     setTasks(updatedTasks);
    +   }
        return (
          // ...
    
  5. 此外,此事件处理函数应作为属性传递给 Task 组件:

        // ...
        Return (
          <div className='Board-wrapper'>
            {lanes.map((lane) => (
              <Lane
                key={lane.id}
    +           laneId={lane.id}
                title={lane.title}
                loading={loading}
                error={error}
                tasks={tasks.filter((task) => 
                       task.lane === lane.id)}
                onDragStart={onDragStart}
                onDragOver={onDragOver}
    +           onDrop={onDrop}
              />
            ))}
          </div>
        );
      }
      export default Board;
    

onDrop 事件处理函数接受一个元素和车道 ID 作为参数,因为它需要拖动元素的 ID 和它应该放置的新车道。有了这些信息,函数使用 filter 函数找到需要移动的任务,并更改车道的 ID。这个新信息将用 setState 函数替换状态中当前的任务对象。

  1. 由于 onDrop 事件是从 Lane 组件触发的,因此它作为属性传递给此组件。此外,还添加了车道 ID 作为属性,因为需要将其传递给来自 Lane 组件的 onDrop 事件处理函数:

      import Task from '../Task/Task';
      import './Lane.css';
      function Lane({
    +   laneId,
        title,
        loading,
        error,
        tasks,
        onDragStart,
        onDragOver,
    +   onDrop,
      }) {
        return (
          <div
            className='Lane-wrapper'
            onDragOver={onDragOver}
    +       onDrop={(e) => onDrop(e, laneId)}
          >
          // ...
    

通过这种方式,我们能够将任务拖放到我们板上的其他车道上——你也可以为 Backlog 组件做同样的事情——或者甚至通过另一个自定义钩子使此逻辑可重用。但相反,我们将探讨如何通过使用 styled-components 库来使我们的组件样式更加灵活和可重用,在下一节中。

使用 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 文件。一个好的开始是 Task 组件,因为这个组件非常小,功能有限:

  1. 首先,导入 styled-components 包并创建一个新的样式组件,命名为 TaskWrapper。该组件扩展了一个 div 元素,并采用了我们已经在 Task.css 中的 Task-wrapper 类名所拥有的 CSS 规则。此外,我们不再需要导入此文件,因为所有的样式现在都在这个 JavaScript 文件内部完成:

    + import styled from 'styled-components';
    - import './Task.css';
    + const TaskWrapper = styled.div`
    +   background: darkGray;
    +   padding: 20px;
    +   border-radius: 20px;
    +   margin: 0% 5% 5% 5%;
    +   h3 {
    +     width: 100%;
    +     margin: 0;
    +   }
    + `;
      function Task({ id, title, body, onDragStart }) {
        return (
    -     <div className="Task-wrapper"
    +     <TaskWrapper 
            draggable 
            onDragStart={(e) => onDragStart(e, id)}
          >
            <h3>{title}</h3>
            <p>{body}</p>
    -     </div>
    +     </TaskWrapper>
      );
    }
    export default Task;
    
  2. 在前面的代码块中,我们在 TaskWrapper 样式组件中添加了 h3 元素的样式,但我们也可以在特定的样式组件内部做同样的事情:

      import styled from 'styled-components';
    
      // ...
    -   h3 {
    -     width: 100%;
    -     margin: 0;
    -   }
    - `;
    + const Title = styled.h3`
    +   width: 100%;
    +   margin: 0;
    + `;
      function Task({ id, title, body, onDragStart }) {
        return (
          <TaskWrapper 
            draggable 
            onDragStart={(e) => onDragStart(e, id)}
          >
    -       <h3>{title}</h3>
    +       <Title>{title}</Title>
            <p>{body}</p>
          </TaskWrapper>
      );
    }
    export default Task;
    
  3. 我们也可以为项目中的其他组件做同样的事情,从 Lane 组件开始,我们需要首先创建使用与 Lane.css 文件中相同的样式的样式组件:

    + import styled from 'styled-components';
      import Task from '../Task/Task';
    - import './Lane.css';
    + const LaneWrapper = styled.div`
    +   text-align: left;
    +   padding: 0;
    +   background: lightGray;
    +   border-radius: 20px;
    +   min-height: 50vh;
    +   width: 20vw;
    +   @media (max-width: 768px) {
    +     margin-bottom: 5%;
    +   }
    + `;
    + const Title = styled.h2`
    +   width: 100%;
    +   padding-bottom: 10px;
    +   text-align: center;
    +   border-bottom: 1px solid darkGray;
    + `;
      function Lane({
        // ...
    
  4. 将现有的 divh3 元素替换为这些新组件:

      // ...
      function Lane({
        laneId,
        title,
        loading,
        error,
        tasks,
        onDragStart,
        onDragOver,
        onDrop,
      }) {
        return (
    -     <div className="Lane-wrapper"    
    +     <LaneWrapper 
            onDragOver={onDragOver} 
            onDrop={(e) => onDrop(e, laneId)}
          >
    -       <h3>{title}</h3>
    +       <Title>{title}</Title>
            {loading || error ? (
              <span>{error || 'Loading...'}</span>
            ) : (
              // ...
            )}
    -     </div>
    +     </LaneWrapper>
        );
      }
      export default Lane;
    

如果我们在浏览器中再次运行 npm start 后访问我们的项目,我们会看到在删除了 Ticket 和 Lane 组件的 CSS 文件后,我们的应用程序仍然看起来一样。当然,您也可以为项目中的其他组件做同样的事情。

让我们继续将另一个组件转换为使用 styled-components 而不是 CSS,例如 src/App.js 文件中的组件。这个组件正在使用 src/App.css 文件来为包含我们应用程序中所有组件的 div 元素添加样式:

- import './App.css';
+ import styled from 'styled-components';
  import Board from './pages/Board/Board';
  import Header from './components/Header/Header';
  import Backlog from './pages/Backlog/Backlog';
+ const AppWrapper = styled.div`
+   text-align: center;
+ `;
  function App() {
    return (
-     <div className='App'>
+     <AppWrapper>
        <Header />
        <Board />
        <Backlog />
-     </div>
+     </AppWrapper>
    );
  }
  export default App;

在进行这些更改后,您可以删除 src/App.css 文件,因为我们不再使用它来为 App 组件添加样式。

使用styled-components的另一种可能性是为我们的应用程序创建一个全局样式,这目前在src/index.css中完成。此文件在src/index.js中导入,因此被加载到应用程序的每一页,因为它是我们 React 应用程序的入口。但是src/App.js中的App组件也包装了所有我们的组件,我们可以在其中复制src/index.css中的样式规则,并使用它们来创建一个GlobalStyle组件:

- import styled from 'styled-components'; 
+ import styled, { createGlobalStyle } from 
    'styled-components';
  import Board from './pages/Board/Board';
  import Header from './components/Header/Header';
  import Backlog from './pages/Backlog/Backlog';
+ const GlobalStyle = createGlobalStyle`
+   body {
+     margin: 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;
+   }
+ `;
  const AppWrapper = styled.div`
    // ...

我们刚刚创建的全局样式必须添加到App组件的返回语句中,在AppWrapper组件之上。因为我们只能从return语句返回一个元素或组件,所以我们需要将内容包装到另一个元素中。如果我们想给这个元素应用样式,我们可以使用一个div元素。但由于在这个场景中我们不希望这样做,我们将使用 React 片段。使用片段,我们可以包装元素和组件,而不会在浏览器中渲染任何内容:

  // ...
  function App() {
    return (
+     <>
+       <GlobalStyle />
        <AppWrapper>
          <Header />
          <Board />
          <Backlog />
        </AppWrapper>
+     </>
    );
  }
  export default App;

注意

<>符号是<React.Fragment>的简写;你可以在 React 中使用这两种符号。对于<React.Fragment>符号,你也可以从 React 中导入Fragment来编写<Fragment>

最后,你可以删除src/index.css文件和src/index.js中导入此文件的行:

  import React from 'react'; 
  import ReactDOM from 'react-dom/client';
- import './index.css';
  import App from './App';
  import reportWebVitals from './reportWebVitals';
  const root = ReactDOM.createRoot(
    document.getElementById('root'));
    // ...

通过这些最终添加,我们已使用styled-components而不是 CSS 来设计了我们应用程序的大部分样式。通过直接在组件中编写样式规则,我们可以减少项目中的文件数量,并使查找应用于我们元素的风格变得更加容易。

摘要

在本章中,你创建了一个项目管理板,允许你使用 HTML5 拖放 API 将任务从一个通道拖放到另一个通道。此应用程序的数据流由本地状态和生命周期处理,并确定哪些任务显示在不同的通道中。本章还介绍了 React 的高级模式自定义 Hooks。使用自定义 Hooks,你可以在你的应用程序中跨功能组件重用状态逻辑。

这种高级模式也将在下一章中使用,下一章将处理 React 应用程序中使用 Next.js 的服务器端渲染SSR)和路由。你有没有尝试过使用 Stack Overflow 来寻找你曾经遇到过的编程问题的解决方案?我有!在下一章中,我们将构建一个使用 Stack Overflow 作为数据源并使用 React 来渲染应用程序的社区动态。

进一步阅读

第四章:使用 Next.js 构建服务器端渲染的社区信息流

到目前为止,你已经学习了 React 应用程序通常是单页应用程序SPAs),并且可以使用 Create React App 启动。这意味着应用程序在客户端渲染,当用户访问你的应用程序时在浏览器中加载。但你是否知道 React 也支持服务器端渲染SSR),就像你可能记得的那样,在代码仅从服务器渲染的旧日子里?

在本章中,你将学习如何创建一个从服务器动态加载组件的应用程序,而不是从浏览器加载。为了启用 SSR(服务器端渲染),我们将使用 Next.js 而不是 Create React App。Next.js 是一个用于 React 应用程序的框架,并为 React 添加了额外的功能。如果你对搜索引擎优化SEO)感兴趣,SSR 具有以下优势:我们可以向应用程序添加元数据,使其能够被搜索引擎更好地索引。

本章将涵盖以下主题:

  • 设置 Next.js

  • 服务器端渲染

  • React 中的 SEO

项目概述

在本章中,我们将使用 Next.js 创建一个支持 SSR 的社区信息流应用程序,因此它从服务器加载而不是从浏览器加载。此外,该应用程序针对搜索引擎进行了优化。

构建时间为 2 小时。

开始

完整的源代码也可以在 GitHub 上找到:github.com/PacktPublishing/React-Projects-Second-Edition/tree/main/Chapter04。此外,该项目使用公开可用的 Stack Overflow API 来填充应用程序的数据。这是通过获取发布到 Stack Overflow 的问题来完成的。有关此 API 的更多信息,请参阅:api.stackexchange.com/docs/

社区信息流应用程序

在本节中,你将使用 Next.js 构建一个支持 SSR 的社区信息流应用程序。Next.js 超越了 Create React App 的功能,提供了一个快速构建 React 应用程序的框架。它内置了路由、SEO、SSR 等功能,你将在本章中了解到。在这个社区信息流中,你可以看到带有reactjs标签的最近问题的概述,你可以点击它们来查看更多信息以及答案。

设置 Next.js

在前面的章节中,我们使用了 Create React App 来运行 React 应用程序。虽然 Create React App 是大多数 React 应用程序的一个很好的起点,但它不支持 SSR。幸运的是,Next.js 确实提供了这个功能,因为它被认为是一个 React 框架。在本章中,我们将使用 Next.js 的最新稳定版本,即版本 12。

安装 Next.js

要设置 Next.js,我们运行以下命令:

npx create-next-app chapter-4

您将被要求选择一个模板,这可以是默认的启动应用程序或由社区创建的模板。在选择默认模板后,将安装 reactreact-domnext 库等。

安装完成后,我们可以进入新的 chapter-4 目录并运行以下命令:

npm run dev

这将启动一个新的 Next.js 应用程序,该应用程序将在 http://localhost:3000 上可用。默认的启动应用程序将类似于以下应用程序:

图 4.1 – 初始 Next.js 应用程序

图 4.1 – 初始 Next.js 应用程序

在这个应用程序中,您不仅可以看到 Next.js 应用程序的外观,还可以找到更多有用的链接,以了解 Next.js 以及如何部署它的示例。

Next.js 项目的应用程序结构与我们前面章节中创建的 Create React App 的结构略有不同:

chapter-4
  |- package.json
  |- node_modules
  |- public
  |- pages
     |- api
        |- hello.js
     |- _app.js
     |- index.js
  |- styles
     |- globals.css
     |- Home.module.css

在前面的结构中,您可以再次看到一个名为 pages 的目录,该目录将包含作为页面的 React 组件。但与 Create React App 不同,我们不需要设置 react-router 来服务页面。相反,Next.js 自动将此目录中的每个组件渲染为页面。在这个目录中,我们还在 api 目录中找到了 hello.js 文件。Next.js 还可以用来创建 API 端点,我们将在 第七章 中进一步探讨,即使用 Next.js 和 GraphQL 构建全栈电子商务应用程序。此外,我们的组件的 CSS 文件放在 styles 目录中,您将找到具有全局样式的 globals.css 文件以及具有特定组件样式的 Home.module.css

添加 styled-components

在我们设置路由之前,让我们将 styled-components 添加到项目中,我们也在前面的章节中使用过它。为此,我们需要通过运行以下命令来安装 styled-components

npm install styled-components

这将添加包到我们的项目中,我们可以使用它们来创建和样式化可重用组件。

注意

Next.js 使用 styles 目录来存储全局和组件特定的 CSS 文件以进行样式化。由于我们使用 styled-components 进行样式化,因此我们不需要向此目录添加任何新的 CSS 文件。如果您不使用 CSS-in-JS 的库进行样式化,则可以将全局和组件级别的样式文件放置在 styles 目录中。

在 Next.js 中设置 styled-components 与 Create React App 相比略有不同:

  1. 在 Next.js 中,一个 Document 组件包裹 htmlbodyhead 标签,并通过所谓的 renderPage 方法运行它们,以允许进行 SSR。我们需要在 pages 文件夹中的新文件 _document.js 中重写这个 Document 组件:

    import Document from 'next/document';
    import { ServerStyleSheet } from 'styled-components';
    export default class MyDocument extends Document {
      static async getInitialProps(ctx) {
        const sheet = new ServerStyleSheet();
        const originalRenderPage = ctx.renderPage;
        try {
          ctx.renderPage = () =>
            originalRenderPage({
              enhanceApp: (App) => (props) =>
                sheet.collectStyles(<App {...props} />),
            });
          const initialProps = 
            await Document.getInitialProps(ctx);
          return {
            ...initialProps,
            styles: (
              <>
                {initialProps.styles}
                {sheet.getStyleElement()}
              </>
            ),
          };
        } finally {
          sheet.seal();
        }
      }
    }
    

上述代码创建了一个 ServerStyleSheet 实例,styled-components 使用它来检索我们应用程序中所有组件中找到的任何样式。这需要创建一个可以稍后注入到我们的服务器端渲染应用程序中的样式表。sheets.collectStyles 方法收集我们组件中的所有样式,而 sheets.getElement() 生成 style 标签,我们需要将其作为名为 styles 的属性返回。

  1. 为了支持 styled-components 的 SSR,我们还需要在项目根目录中配置 next.config.json 文件。此文件需要包含以下配置:

    const nextConfig = {
        reactStrictMode: true,
    +   compiler: {
    +     styledComponents: true
    +   }
    }
    module.exports = nextConfig
    
  2. 与 Next.js 一样,我们没有为应用程序设置全局入口文件。我们需要一个不同的地方来添加我们想要在所有页面上显示的全局样式和组件,例如标题。这些样式和组件必须添加到 pages 目录中的 _app.js 中,它返回我们当前访问的页面组件以及我们返回的任何其他组件或样式:

    - import '../styles/globals.css';
    + import { createGlobalStyle } from 'styled-components';
    + 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; 
    +   } 
    + ';
      function MyApp({ Component, pageProps }) {
    -   return <Component {...pageProps} />;
    +   return (
    +     <>
    +       <GlobalStyle />
    +       <Component {...pageProps} />
    +     </>
    +   );
      }
      export default MyApp;
    

在前面的文件中,我们删除了导入 styles/globals.css 文件的行,并用 styled-components 中的样式替换了它。这意味着你也可以安全地从 styles 目录中删除 globals.css 文件。

  1. 这种全局样式现在将出现在我们将在 Next.js 应用程序中创建的所有页面上。但首先,让我们通过在名为 components 的新目录中添加一个名为 Header.js 的文件来创建一个 Header 组件:

    import styled from 'styled-components';
    const HeaderWrapper = styled.div'
      background-color: orange;
      height: 100%;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      font-size: calc(10px + 2vmin);
      color: white;
    ';
    const Title = styled.h1'
      height: 64px;
      pointer-events: none;
    ';
    function Header() {
      return (
        <HeaderWrapper>
          <Title>Community Feed</Title>
        </HeaderWrapper>
      );
    }
    export default Header;
    
  2. 此组件将返回一个将在每个页面上使用的标题,但我们还需要将其添加到 _app.js 中才能生效:

      import { createGlobalStyle } from 
        'styled-components';
    + import Header from '../components/Header';
      // ...
      function MyApp({ Component, pageProps }) {
        return (
          <>
            <GlobalStyle />
    +       <Header />
            <Component {...pageProps} />
          </>
        );
      }
      export default MyApp;
    

当我们在应用程序中添加第一个路由时,你将在下一节中看到标题的样式。

我们添加了多个新的文件,这些文件使用 styled-components 具有全局样式,支持 SSR,并且还有一个包含可重用组件的目录。

在 Next.js 和 styled-components 设置完成后,我们就可以开始在本章下一节中使用 Next.js 进行开发了。

使用 Next.js 进行路由

使用 react-router 包,我们可以为任何 React 应用程序添加声明式路由,但你需要设置定义你想要添加的路由的组件。使用 Next.js,路由是通过文件系统实现的,从 pages 目录开始。pages 目录中的每个文件和目录都可以代表一个路由。你可以通过访问项目在 http://localhost:3000 来检查这一点,其中 index.js 的内容正在被渲染。

例如,如果我们想将新的 /questions 路由添加到应用程序中,我们需要创建一个名为 questions.js 的新文件或一个名为 questions 的目录,并在 pages 中包含一个 index.js 文件。让我们选择第二种方案,并将以下代码添加到该文件中:

import styled from 'styled-components';
const QuestionsContainer = styled.div'
  display: flex;
  justify-content: space-between;
  flex-direction: column;
  margin: 5%;
';
function Questions() {
  return (
    <QuestionsContainer>
      <h2>Questions</h2>
    </QuestionsContainer>
  );
}
export default Questions;

这条新路由现在已在 http://localhost:3000/questions 上可用,此处只渲染标题。正如本章引言中提到的,我们将使用 Stack Overflow API 来获取此应用程序的数据。

在从端点检索数据之前,我们还需要创建一个组件来渲染这些数据。为此,我们需要创建一个新的组件,该组件将用于显示问题。这个新组件可以创建在 components 目录下的 Card.js 文件中,内容如下:

import styled from 'styled-components';
const CardWrapper = styled.div'
  text-align: left;
  padding: 1%;
  background: lightGray;
  border-radius: 5px;
  margin-bottom: 2%;
';
const Title = styled.h2'
  width: 100%;
  padding-bottom: 10px;
  text-align: center;
  border-bottom: 1px solid darkGray;
  color: black;
';
const Count = styled.span'
  color: darkGray;
';
function Card({ title, views, answers }) {
  return (
    <CardWrapper>
      <Title>{title}</Title>
      <Count>{
        'Views: ${views} | Answers: ${answers}'}
      </Count>
    </CardWrapper>
  );
}
export default Card;

在此组件就位后,让我们从 Stack Overflow API 获取数据。从这个 API 中,我们想要检索带有 reactjs 标签的所有问题,使用以下端点:

https://api.stackexchange.com/2.2/questions?order=desc&sort=hot&tagged=reactjs&site=stackoverflow

你可以在 api.stackexchange.com/docs/questions#order=desc&sort=hot&tagged=reactjs&filter=default&site=stackoverflow&run=true 找到更多相关信息。

这返回一个在 items 字段下的对象数组,并且从每个对象中,我们可以获取有关问题的信息,例如标题和答案数量。

我们可以通过对 pages 目录中 questions 目录下的 index.js 文件进行一些修改,从该端点检索数据并在 /questions 路由上显示它:

  1. 首先,我们需要在 Questions 组件中添加本地状态变量,并添加一个 useEffect Hook 来从 Stack Overflow API 获取数据:

    + import { useState, useEffect } from 'react';
      import styled from 'styled-components';
    + import Card from '../ ../ components/Card';
      function Questions() {
    +   const [loading, setLoading] = useState(false);
    +   const [questions, setQuestions] = useState([]);
    +   useEffect(() => {
    +     async function fetchData() {
    +       const data = await fetch(
             'https://api.stackexchange.com/2.2/questions?
              order=desc&sort=hot&tagged=reactjs&
              site=stackoverflow');
    +       const result = await data.json();
    +       if (result) {
    +         setQuestions(result.items);
    +         setLoading(false);
    +       }
    +     }
    +     fetchData();
    +   }, []);
      return (
        // ...
    
  2. 在添加数据获取逻辑后,我们还需要添加一些代码来在页面上显示来自 API 的字段。我们将此数据传递给我们的 Card 组件以在页面上渲染它:

      // ...
        return (
          <QuestionsContainer>
            <h2>Questions</h2>
    +       {loading ? (
    +         <span>Loading...</span>
    +        ) : (
    +         <div>
    +             {questions.map((question) => (
    +               <Card
    +                 key={question.question_id}
    +                 title={question.title}
    +                 views={question.view_count}
    +                 answers={question.answer_count}
    +               />
    +             ))}
    +         </div>
    +       )}
        </QuestionsContainer>
      );
    }
    export default Questions;
    

如果你现在访问 http://localhost:3000/questions 上的 /questions 路由,你可以看到问题列表正在与 Header 组件一起渲染,如下面的截图所示:

图 4.2 – 我们的带有 Stack Overflow 数据的应用程序

图 4.2 – 我们的带有 Stack Overflow 数据的应用程序

我们将在稍后处理 SSR,但在那之前,我们需要添加支持参数的路由。要创建一个支持参数的路由,我们需要以与 Questions 组件相同的方式创建一个文件。新路由将显示特定问题,这些信息我们也可以从 Stack Overflow API 获取。同样,文件系统将在创建路由时起主导作用:

  1. 要创建一个带有参数的新路由,我们需要在 questions 目录内创建一个名为 [id].js 的文件。此文件包含参数名称,方括号内,并且在此文件中,我们可以使用 Next.js 路由库中的 Hook 来获取此参数值:

    import { useRouter } from 'next/router';
    import styled from 'styled-components';
    const QuestionDetailContainer = styled.div'
      display: flex;
      justify-content: space-between;
      flex-direction: column;
      margin: 5%;
    ';
    function QuestionDetail() {
      const router = useRouter();
      const { id } = router.query;
      return (
        <QuestionDetailContainer>
          <h2>Question: {id}</h2>
        </QuestionDetailContainer>
      );
    }
    export default QuestionDetail;
    

通过访问 http://localhost:3000/questions/123,你可以看到我们添加的参数已经显示在屏幕上。

  1. QuestionDetail 组件中,我们可以导入 Card 组件,并可以使用 Stack Overflow API 获取特定问题的数据。为此,我们需要在之前步骤中创建的 questions 目录下的 [id].js 文件中添加数据获取逻辑和渲染数据的元素:

    + import { useState, useEffect } from 'react';
      import { useRouter } from 'next/router';
      import styled from 'styled-components';
    + import Card from '../../components/Card';
      // ...
      function QuestionDetail() {
        const router = useRouter();
        const { id } = router.query;
    +   const [loading, setLoading] = useState(false);
    +   const [question, setQuestion] = useState({});
    +   useEffect(() => {
    +     async function fetchData() {
    +       const data = await fetch(
             'https://api.stackexchange.com/2.2/questions
             /${id}?site=stackoverflow');
    +       const result = await data.json();
    +       if (result) {
    +         setQuestion(result.items[0]);
    +         setLoading(false);
    +       }
    +     }
    +     id && fetchData();
    +   }, [id]);
        // ...
    
  2. 在添加数据获取逻辑后,我们可以将 Card 组件返回,并将作为 props 传递给它的问题信息:

        // ...
        return (
          <QuestionDetailContainer>
    -       <h2>Question: {id}</h2>
    +       {loading ? (
    +         <span>Loading...</span>
    +       ) : (
    +         <Card
    +           title={question.title}
    +           views={question.view_count}
    +           answers={question.answers_count}
    +         />
    +       )}
        </QuestionDetailContainer>
      );
    }
    export default QuestionDetail;
    

通过其标识符获取问题的 API 端点返回一个数组,因为它期望一次性提供多个 ID。因此,我们需要获取端点返回的第一个项目,因为我们只提供了一个 ID。

  1. 要访问特定问题,你需要有该问题的 ID。这最好从 Questions 组件中完成,我们可以从 Next.js 的路由库中导入一个 Link 组件。每个 Link 组件都需要包裹一个能够进行路由的组件,因此我们将在其中添加一个样式化的 a 元素。此外,用于显示问题的元素将被我们之前创建的 Card 组件所替换:

      import { useState, useEffect } from 'react';
      import styled from 'styled-components';
    + import Link from 'next/link';  
      import Card from '../../components/Card';
      // ...
    + const CardLink = styled.a'
    +   text-decoration: none;
    + ';
      function Questions() {
      // ...
        return (
          <QuestionsContainer>
            // ...
            {questions.map((question) => (
    +         <Link
    +          key={question.question_id}
    +          href={'/questions/${question.question_id}'}
    +          passHref
    +         >
    +           <CardLink>
                  <Card 
    -               key={question.question_id}
                    title={question.title} 
                    views={question.view_count}
                    answers={question.answer_count} 
                  />
    +           </CardLink>
    +         </Link>
            ))}
            // ...
    

当你访问 http://localhost:3000/questions 时,你可能会注意到 Card 组件现在是可点击的,并且会链接到一个新页面,显示你刚刚点击的问题。

  1. 最后,我们希望基本的 / 路由也能显示 Questions 组件。我们可以通过在 /pages/index.js 中导入这个组件并从那里返回它来实现这一点:

    import Questions from './questions';
    export default function Home() {
      return <Questions />;
    }
    

应用程序现在将在 //questions 路由上返回问题列表,并且能够在你点击这些路由中的任何问题时代码显示特定问题:

图 4.3 – 带有基本样式和动态路由的应用程序

图 4.3 – 带有基本样式和动态路由的应用程序

除了使用参数进行路由外,我们还可以添加使用查询字符串的路由,例如分页功能。这将在本节下一部分中展示,关于使用 Next.js 的路由。

处理查询字符串

当你想要在项目中添加路由时,能够导航到单个问题只是其中的一部分,分页可能是另一部分。

Stack Overflow API 已经支持分页,如果你查看 API 响应,就可以看到这一点。当你调用描述在 https://api.stackexchange.com/docs/questions#order=desc&sort=hot&tagged=reactjs&filter=default&site=stackoverflow&run=true 的端点时返回的对象有一个名为 has_more 的字段。如果这个字段有 true 值,你可以在 API 请求中添加 page 查询字符串来请求更多问题。

就像我们从 Next.js 的 useRouter 钩子中获取 URL 参数一样,我们也可以使用这个钩子来获取查询字符串。为了给 Questions 组件添加分页功能,我们需要进行以下修改:

  1. pages/questions/index.js中的Questions页面组件中,我们需要导入useRouter钩子并从 URL 中获取查询字符串:

      import { useState, useEffect } from 'react';
      import styled from 'styled-components';
      import Link from 'next/link';
    + import { useRouter } from 'next/router';
      import Card from '../components/Card';
      // ...
      function Questions() {
        const [loading, setLoading] = useState(false);
        const [questions, setQuestions] = useState([]);
    +   const router = useRouter();
    +   const { page } = router.query;
        useEffect(() => {
          // ...
    
  2. 然后,可以将page常量附加到端点以从 Stack Overflow API 获取问题:

      // ...
      useEffect(() => {
        async function fetchData() {
    page to the dependency array of the useEffect Hook to do the data fetching. When the application first renders, the value for page is not set as the query string should still be retrieved from the API. This is causing the API to be called twice, something that we won't optimize now but will do later once we add SSR to the application.
    

你可以通过更改page查询字符串为不同的数字来测试这是否正常工作,例如http://localhost:3000/questions?page=1http://localhost:3000/questions?page=3。为了使应用程序更友好,让我们在页面底部添加分页按钮。

  1. components目录内创建Pagination组件,该目录包含两个来自 Next.js 的Link组件。该组件将在当前页码大于 1 时显示上一页的链接,如果还有更多页面,也会显示下一页的链接:

    import styled from 'styled-components';
    import Link from 'next/link';
    const PaginationContainer = styled.div'
      display: flex;
      justify-content: center;
    ';
    const PaginationLink = styled.a'
      padding: 2%;
      margin: 1%;
      background: orange;
      cursor: pointer;
      color: white;
      text-decoration: none;
      border-radius: 5px;
    ';
    function Pagination({ currentPage, hasMore }) {
      return (
        <PaginationContainer>
          <Link 
            href={'?page=${parseInt(currentPage) - 1}'}>
            <PaginationLink>Previous</PaginationLink>
          </Link>
          <Link 
            href={'?page=${parseInt(currentPage) + 1}'}>
            <PaginationLink>Next</PaginationLink>
          </Link>
        </PaginationContainer>
      );
    }
    export default Pagination;
    
  2. 我们需要在Questions页面组件中导入这个新的Pagination组件,同时还需要从 Stack Overflow API 获取hasMore的值:

      import { useState, useEffect } from 'react';
      import Link from 'next/link';
      import { useRouter } from 'next/router';
      import styled from 'styled-components';
      import Card from '../../components/Card';
    + import Pagination from
        '../../components/Pagination';
      // ...
      function Questions() {
        const [loading, setLoading] = useState(false);
        const [questions, setQuestions] = useState([]);
    +   const [hasMore, setHasMore] = useState(false);
        const router = useRouter();
        const { page } = router.query;
        useEffect(() => {
          async function fetchData() {
            const data = await fetch(
              'https://api.stackexchange.com/2.2/questions
               ?${page ? 'page=${page}&' : ''}order=
               desc&sort=hot&tagged=reactjs&site=
               stackoverflow');
          const result = await data.json();
            if (result) {
              setQuestions(result.items);
    +         setHasMore(result.has_more);
              setLoading(false);
            }
          }
          fetchData();
        }, [page]);
        // ...
    
  3. 此外,Pagination组件必须在Questions组件的末尾渲染。请确保也将currentPagehasMore属性传递给组件:

      // ...
        return (
          <QuestionsContainer>
            <h2>Questions</h2>
            {loading ? (
              <span>Loading...</span>
            ) : (
    +         <>
                <div>
                  {questions.map((question) => (
                    // ...
                  ))}
                </div>
    +          <Pagination currentPage={parseInt(page) ||
                 1} hasMore={hasMore} />
    +        </>
            )}
          </QuestionsContainer>
        );
      }
      export default Questions;
    
  4. 最后,我们希望用户无法导航到不存在的页面。因此,在Pagination组件中,如果没有页面可用,请禁用上一页或下一页按钮:

      // ...
      const PaginationLink = styled.a'
        padding: 2%;
        margin: 1%;
    +   background: ${(props) => 
          (!props.disabled ? 'orange' : 'lightGrey')};
    +   pointer-events: ${(props) => 
          (!props.disabled ? 'all' : 'none')};
    +   cursor: ${(props) => 
          (!props.disabled ? 'pointer' : 'not-allowed')};
        color: white;
        text-decoration: none;
        border-radius: 5px;
      ';  
      function Pagination({ currentPage, hasMore }) {
        return (
          <PaginationContainer>
          <Link href={'?page=${parseInt(currentPage) - 1}'}>
    -       <PaginationLink>Previous</PaginationLink>
    +       <PaginationLink disabled={currentPage <= 1}>
              Previous
             </PaginationLink>
          </Link>
          <Link href={'?page=${parseInt(currentPage) + 1}'}>
    -       <PaginationLink>Next</PaginationLink>
    +       <PaginationLink disabled={!hasMore}>
              Next
            </PaginationLink>
          </Link>
        </PaginationContainer>
      );
    }
    export default Pagination;
    

你现在已经实现了查询字符串的解析,以便动态更改应用程序的路由。随着Pagination组件的添加,//questions路由将看起来像这样:

图 4.4 – 添加分页后的应用程序

图 4.4 – 添加分页后的应用程序

在下一节中,你将探索你可以使用 React 结合 Next.js 和 SRR(服务器端渲染)一起做的另一件事,这使你能够从服务器而不是在运行时渲染你的应用程序。

启用 SSR

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

使用 Next.js 在服务器端获取数据

为你的 React 应用程序启用 SSR 没有标准模式,但幸运的是,Next.js 支持多种数据获取方式,例如从客户端动态获取,在每个请求的服务器端获取,或在构建时静态获取。我们到目前为止已经使用第一种方式,在本节中,我们将请求在每个请求的服务器端获取数据。为此,将使用 Next.js 的getServerSideProps方法。

注意

Next.js 还提供了 getStaticPropsgetStaticPaths 方法,在构建时生成应用程序的内容。如果你的内容不经常更改,并且你希望尽可能快地提供你的网站,这特别有用。

在本章的开头,我们已经以支持 SSR 的方式设置了 styled-components,所以我们只需要改变我们进行数据获取的方式,以便在整个应用程序中启用它。因此,我们需要重构我们的 Questions 组件,使其能够在服务器端从 Stack Overflow API 获取数据,而不是从客户端动态获取:

  1. Questions 页面组件中,我们不再需要导入 useStateuseEffect 钩子来设置状态管理和数据获取,因此这些可以删除。Next.js 的 useRouter 钩子也可以删除:

    - import { useState, useEffect } from 'react';
      import styled from 'styled-components';
      import Link from 'next/link';
    - import { useRouter } from 'next/router';
      import Card from '../../components/Card';
      import Pagination from '../../components/Pagination';
      // ...
      function Questions() {
    -   const [loading, setLoading] = useState(false);
    -   const [questions, setQuestions] = useState([]);
    -   const [hasMore, setHasMore] = useState(false);
    -   const router = useRouter();
    -   const { page } = router.query;
    -   useEffect(() => {
    -     async function fetchData() {
    -       const data = await fetch(
              'https://api.stackexchange.com/2.2/questions
               ?${page ? 'page=${page}&' : ''}order=
               desc&sort=hot&tagged=reactjs&site
               =stackoverflow');
    -       const result = await data.json();
    -       if (result) {
    -         setQuestions(result.items);
    -         setHasMore(result.has_more);
    -         setLoading(false);
    -       }
    -     }
    -     fetchData();
    -   }, [page]);
      return (
        // ...
    
  2. 相反,需要使用 getServerSideProps 方法在服务器端进行数据获取。由于数据不是由客户端检索,我们不再需要设置本地状态变量或生命周期来跟踪数据的变化。一旦我们在浏览器中加载我们的 React 应用程序,数据就已经准备好了:

      // ...
    + export async function getServerSideProps(context) {
    +   const { page } = context.query;
    +   const data = await fetch(
    +     'https://api.stackexchange.com/2.2/questions?${
    +       page ? 'page=${page}&' : ''
    +       }order=desc&sort=hot&tagged=reactjs&site=
            stackoverflow',
    +   );
    +   const result = await data.json();
    +   return { 
    +     props: { 
    +       questions: result.items, 
    +       hasMore: result.has_more, 
    +       page: page || 1, 
    +     } 
    +   };
    + }
    export default Questions;
    

在这个方法中,page 的值是从一个名为 context 的常量中检索的,这个常量是通过 Next.js 传递给 getServerSideProps 的,并从路由器中获取页面。使用这个值,我们可以像之前在生命周期中做的那样进行数据获取。现在,我们不是将数据存储在本地状态变量中,而是通过从我们创建的方法返回它,将其作为属性传递给 Questions 组件。

  1. 我们的 Questions 页面组件可以使用这些属性在浏览器中渲染我们的问题列表。由于数据是在将应用程序传递给客户端之前从服务器端检索的,我们不再需要设置一个加载指示器来等待数据被检索:

      // ...
    - function Questions() {
    + function Questions({ questions, hasMore, page }) {
      return (
        <QuestionsContainer>
          <h2>Questions</h2>
    -     {loading ? (
    -       <span>Loading...</span>
    -     ) : (
    -     <> 
          <div>
            {questions &&
              questions.map((question) => (
                // ...
              ))}
          </div>
          <Pagination currentPage={parseInt(page) || 1} 
            hasMore={hasMore} />
    -    </>
    -    )}
        </QuestionsContainer>
      );
    }
    // ...
    

你可以通过访问 http://localhost:3000/questions 来检查这一点,你会看到问题不再是在客户端检索,而是在服务器端检索。在 网络 选项卡中,没有对 Stack Overflow API 的请求,而问题正在浏览器中渲染。你还可以通过检查页面源代码来验证这一点:

![图 4.5 – 使用 Next.js 的 SSR 应用程序图片

图 4.5 – 使用 Next.js 的 SSR 应用程序

然而,Questions 页面组件也被导入到 pages/index.js 中,并在那里返回。但是,在 http://localhost:3000/ 打开我们的主要 / 路由时,并没有显示任何问题。这是因为这个文件没有 getServerSideProps 来获取数据。因此,我们还需要在 pages/index.js 中创建这个方法,并让它返回我们可以从 pages/questions/index.js 导入的方法,这样我们就不必重复数据获取逻辑。这个文件中的 Home 组件可以从此处获取数据,并将其传递给 Questions 组件:

- import Questions from './questions';
+ import Questions, {
+  getServerSideProps as getServerSidePropsQuestions,
+ } from './questions';
+ export function getServerSideProps(context) {
+  return getServerSidePropsQuestions(context);
+ }
- export default function Home() {
-   return <Questions />;
+ export default function Home(props) {
+   return <Questions {...props} />;
}

进行此更改后,//questions 路由都将启用 SSR。SSR 的另一个优点是您的应用程序可以更有效地被搜索引擎发现。在本节的下一部分,您将添加使您的应用程序可以被这些引擎发现的标签。

添加标题标签以进行 SEO

假设您希望您的应用程序被搜索引擎索引,您需要为爬虫设置标题标签以识别您页面上的内容。这是您希望为每个路由动态执行的事情,因为每个路由将具有不同的内容。

Next.js 可以通过从next/head导入Head来定义任何由您的应用程序渲染的组件中的标题标签。如果嵌套,组件树中Head组件的最低定义将被使用。这就是为什么我们可以在Header组件中为所有路由以及在每个路由上被渲染的组件创建一个Head组件:

  1. components/Header.js文件中导入Head组件,并创建一个设置title和元描述的Head组件:

      import styled from 'styled-components';
    + import Head from 'next/head';
    // ...
      const Header = () => (
    +  <>
    +    <Head>
    +      <title>Community Feed</title>
    +      <meta name='description' content='This is a
             Community Feed project build with React' />
    +    </Head>
        <HeaderWrapper>
          <Title>Community Feed</Title>
        </HeaderWrapper>
    +  </>
      );
    export default Header;
    
  2. 还在pages/questions/index.js中创建一个Head组件,只为该路由设置标题,因此它将使用Header组件的元描述:

      import styled from 'styled-components';
      import Link from 'next/link';
    + import Head from 'next/head';
      import Card from '../../components/Card';
      import Pagination from
        '../../components/Pagination';
      // ...
      function Questions({ questions, hasMore, page }) {
        return (
    +     <>
    +       <Head>
    +         <title>Questions</title>
    +       </Head>
            <QuestionsContainer> 
              // ... 
            </QuestionsContainer>
    +     </>
        );
      }
      // ...
    
  3. 对于pages/questions/[id].js文件,您也可以使用问题的标题来使页面标题更加动态:

      import { useState, useEffect } from 'react';
      import { useRouter } from 'next/router';
    + import Head from 'next/head';
      import styled from 'styled-components';
      import Card from '../../components/Card';
      // ...
      function QuestionDetail() {
        // ...
        return (
          <QuestionDetailContainer>
            {loading ? (
              <span>Loading...</span>
            ) : (
    +         <>
    +           <Head>
    +             <title>{question.title}</title>
    +           </Head>
                <Card
                  title={question.title}
                  views={question.view_count}
                  answers={question.answer_count}
                />
    +         </>
            )}
          </QuestionDetailContainer>
        );
      }
      export default QuestionDetail;
    

这些标题标签现在将在您在服务器和客户端上运行应用程序时使用,使您的应用程序更适合被搜索引擎索引,从而提高您的 SEO。

摘要

在本章中,您已经学习了如何将 Next.js 作为 Create React App 的替代品使用。Next.js 是一个用于创建 React 应用程序的框架,无需添加编译和构建代码的配置,也无需处理诸如路由和数据获取等功能。您在本章中创建的项目支持 SSR,因为这是 Next.js 内置的。此外,我们还为应用程序添加了动态标题标签,以提高 SEO。

完成本章后,您应该已经感觉自己像一名 React 专家!下一章将把您的技能提升到新的水平,因为您将学习如何使用上下文 API 来处理状态管理。使用上下文 API,您可以在您的应用程序中共享状态和数据,无论这些组件是否是父组件的直接子组件。

进一步阅读

关于 Next.js 的更多信息,您可以参考nextjs.org/docs/

第五章:使用 Context 和 Hooks 构建个人购物清单应用程序

状态管理是现代 Web 和移动应用程序的重要组成部分,React 在此方面表现优秀。在 React 应用程序中处理状态管理可能会相当复杂,因为您有多种方式可以处理应用程序的当前状态。本书的前四章中创建的项目并没有过多关注状态管理,而这一点将在本章中深入探讨。

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

本章将涵盖以下主题:

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

  • 使用 Hooks 修改 Context

  • 使用 React Suspense 进行代码拆分

项目概述

在本章中,我们将通过使用 Context 和 Hooks 来添加状态管理,构建一个个人购物清单。我们将基于使用 Create React App 创建的样板应用程序进行扩展,该应用程序使用 react-router 进行声明式路由。此外,还添加了 React Suspense 的代码拆分功能。

构建时间为 2.5 小时。

入门

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

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

本节初始应用程序使用 Create React App 创建,并已实现路由和数据获取。当您打开应用程序时,一个显示标题、导航栏和两个列表的屏幕正在渲染。例如,如果您点击此处显示的第一个列表,将打开一个新页面,显示该列表的项目。在此页面上,您可以在右上角点击 添加项目 按钮打开一个新页面,该页面有一个用于添加新列表的表单,看起来如下:

![图 5.1 – 初始应用程序]

](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/rct-proj-2e/img/Figure_5.1_B17390.jpg)

图 5.1 – 初始应用程序

此表单由 ListForm 组件渲染,目前没有功能,因为您将在稍后添加此功能。当您点击左侧按钮时,它将使用 react-router-dom 中的 navigate 方法重定向到之前访问的页面。

注意

当您尝试提交表单以添加新列表或向列表添加新项目时,目前没有任何反应。这些表单的功能将在本节稍后添加,您将使用 Context API 和 React Hooks 来实现。

该项目结构与您之前创建的应用程序相同。然而,在 components 目录中的可重用函数组件和在 pages 目录中表示路由的组件之间有所区分。页面组件使用了我们在 第三章 中见过的 useDataFetching 钩子,即 构建动态项目管理板,它增加了数据获取功能。

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

chapter-5-initial
  |- /node_modules
  |- /public
  |- /src
    |- /components
       |- /Button
          |- Button.js
       |- /FormItem
          |- FormItem.js
       |- /Header
          |- Header.js
       |- /NavBar
          |- NavBar.js
       |- /ListItem
          |- ListItem.js
    |- /hooks
       |- useDataFetching.js
    |- /pages
       |- ListDetail.js
       |- ListForm.js
       |- Lists.js
    |- App.js
    |- index.js
  package.json

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

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

  • /list/:listId: 渲染 ListDetail 组件,显示特定列表中所有项目的概述

  • /list/:listId/new: 渲染 ListForm 组件,包含用于向特定列表添加新项目的表单

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

个人购物清单

在本节中,你将构建一个使用 Context 和 Hooks 进行状态管理的个人购物清单应用程序。使用这个应用程序,你可以创建可以添加项目、数量和价格的购物清单。本节的起点是一个已经启用了路由和本地状态管理的初始应用程序。

使用 Context API 进行状态管理

状态管理非常重要,因为应用程序的当前状态包含对用户有价值的数据。在之前的章节中,你已经通过使用 useStateuseEffect 钩子来使用本地状态管理。当状态中的数据只对设置状态的组件重要时,这种模式非常有用。因为通过多个组件向下传递状态可能会变得混乱,你需要一种方法来访问你的应用程序中的 props,即使你并没有明确地将它们作为 props 传递。为此,你可以使用 React 的 Context API,这个 API 也被你在之前章节中使用的包如 styled-componentsreact-router-dom 所使用。

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

创建 Context

当你想给你的 React 应用程序添加上下文时,你可以通过使用 React 的 createContext 方法创建一个新的上下文来实现。这会创建一个包含两个 React 组件的上下文对象,称为 Provider 和 Consumer。Provider 是放置上下文初始(以及随后的当前)值的地方,这些值可以通过 Consumer 内部的组件访问。

这是在 src/App.js 中的 App 组件中完成的,因为你希望列表的 Context 在由 Route 渲染的每个组件中都是可用的:

  1. 让我们先为列表创建一个 Context,并使其可导出,以便列表数据可以在任何地方使用。为此,你可以在一个新目录 src/context 中创建一个名为 ListsContext.js 的新文件。在这个文件中,你可以添加以下代码:

    import { createContext } from 'react';
    import useDataFetching from 
      '../hooks/useDataFetching';
    export const ListsContext = createContext();
    export const ListsContextProvider = ({ children }) => {
      const [loading, error, data] = useDataFetching(
        'https://my-json-server.typicode.com/
         PacktPublishing/React-Projects-Second-Edition/
         lists',
      );
    
      return (
        <ListsContext.Provider value=
          {{ lists: data, loading, error }}>
          {children}
        </ListsContext.Provider>
      );
    };
    export default ListsContext;
    

之前的代码创建了一个基于传递为 prop 的 Context 对象的 Provider,并根据从 useDataFetching 钩子返回的值设置了一个值,该钩子正在获取所有列表。使用 children prop,所有将被包裹在 ListsContextProvider 组件内部的组件都可以从 Consumer 中检索值的 data。

  1. 你可以将 ListsContextProvider 组件导入到你的 App 组件中,以便包裹所有路由的 Router 组件:

    import styled, { createGlobalStyle } from 
        'styled-components';
    import { Route, Routes, BrowserRouter } from 
        'react-router-dom';
    + import { ListsContextProvider } from 
        './context/ListsContext';
      // ...
    function App() { 
        return (
          <>
           <GlobalStyle />
           <AppWrapper>
             <BrowserRouter>
               <Header />
    +          <ListsContextProvider>
                 <Routes>
                   // ...
                 </Routes>
    +          </ListsContextProvider>
             </BrowserRouter>
           </AppWrapper>
         </>
       );
      }
      export default App;
    
  2. 这样,你现在可以消费 ListsContext 提供者的值,从所有被 ListsContextProvider 包裹的组件中。在 Lists 组件中,可以使用 React 的 useContext Hook 通过传递 ListsContext 对象来检索这些数据。然后可以使用这些数据来渲染列表,并且可以从 src/pages/Lists.js 中移除 useDataFetching Hook:

    + import { useContext } from 'react';
      import styled from 'styled-components';
      import { Link, useNavigate } from 
        'react-router-dom';
    - import useDataFetching from
        '../hooks/useDataFetching';
      import NavBar from '../components/NavBar/NavBar';
    + import ListsContext from '../context/ListsContext';
      // ...
      const Lists = () => {
        let navigate = useNavigate();
    -   const [loading, error, data] =
          useDataFetching('https://my-json-server.
          typicode.com/PacktPublishing/React-Projects-
          Second-Edition/lists');
    +   const { loading, error, lists } = 
          useContext(ListsContext);
      return (
              <>
            {navigate && <NavBar title='Your Lists' />}
            <ListWrapper>
              {loading || error ? (
                <span>{error || 'Loading...'}</span>
              ) : (
    -           data.map((list) => (
    +           lists.map((list) => (
                  <ListLink key={list.id} 
                   to={`list/${list.id}`}>
                    // ...
    

现在你已经从 Lists 中移除了 useDataFetching Hook,这个组件不再直接向 API 发送请求。列表的数据现在是从 ListsContextProvider 中获取的,并通过 ListsContext 传递,该上下文被 Lists 中的 useContext Hook 使用。如果你通过访问 http://localhost:3000/ 在浏览器中打开应用程序,你可以看到列表的渲染与之前一样。

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

嵌套上下文

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

  1. 再次,首先创建一个新文件,其中创建了一个上下文和一个提供者。这次,它被命名为 ItemsContext.js,也可以添加到 src/context 目录中:

    import { createContext } from 'react';
    import useDataFetching from 
      '../hooks/useDataFetching';
    export const ItemsContext = createContext();
    export const ItemsContextProvider = ({ children }) => {
      const [loading, error, data] = useDataFetching(
        'https://my-json-server.typicode.com/
         PacktPublishing/React-Projects-Second-
         Edition/items',
      );
      return (
        <ItemsContext.Provider value=
          {{ items: data, loading, error }}>
          {children}
        </ItemsContext.Provider>
      );
    };
    export default ItemsContext;
    
  2. 接下来,在 src/App.js 中导入这个新的 Context 和 Provider,你可以在 ListsContextProvider 组件下面嵌套它:

      // ...
      import { ListsContextProvider } from 
        './context/ListsContext';
    + import { ItemsContextProvider } from 
        './context/ItemsContext';
      // ...
    
      function App() { 
        return (
          <>
            <GlobalStyle />
            <AppWrapper>
              <BrowserRouter>
                <Header />
                <ListsContextProvider>
    +             <ItemsContextProvider>
                    <Routes>
                      // ...
                    </Routes>
    +             </ItemsContextProvider>
                </ListsContextProvider>
              </BrowserRouter>
            </AppWrapper>
          </>
        );
      }
      export default App;
    
  3. ListDetail 组件现在可以从 ItemsContext 中获取项目,这意味着我们不再需要在组件中使用 useDataFetching Hook。为了实现这一点,你需要对 src/pages/ListDetail.js 进行以下更改:

    - import { useState, useEffect } from 'react';
    + import { useState, useEffect, useContext } from 
        'react';
      import styled from 'styled-components';
      import { useNavigate, useParams } from 
        'react-router-dom';
    - import useDataFetching from 
        '../hooks/useDataFetching';
      import NavBar from '../components/NavBar/NavBar';
      import ListItem from
        '../components/ListItem/ListItem';
    + import ItemsContext from '../context/ItemsContext';
      // ...
      function ListDetail() {
        let navigate = useNavigate();
        const { listId } = useParams();
    -   const [loading, error, data] =
          useDataFetching('https://my-json-server.
          typicode.com/PacktPublishing/React-Projects-
          Second-Edition/items/');
    +   const { loading, error, items: data } = 
          useContext(ItemsContext);
        // ...
    

现在所有数据获取都不是由 ListLists 组件完成的。通过嵌套这些 Context 提供者,返回值可以被多个组件消费。但这还不是最佳方案,因为你现在在启动应用程序时加载了所有列表和所有项目。

这种方法的缺点是,一旦我们打开列表的详细页面,它将检索所有项目,即使它们不是属于这个列表的。在下一节中,你将看到如何通过结合 Context 和自定义 Hooks 来获取所需的数据。

使用 Hooks 修改上下文

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

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

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

之前,我们使用useDataFetching钩子来帮我们进行数据获取,但这并不让我们能够精确控制数据何时被获取。从消耗我们上下文数据的组件中,我们希望能够启动数据获取。因此,我们需要为它们添加生命周期,这些生命周期会在我们的上下文组件内部调用一个函数来执行数据获取。按照以下步骤来实现这一功能:

  1. 实现这一功能的第一步是在src/context/ItemsContext.js文件中添加进行数据获取的逻辑。这个逻辑将替换掉useDataFetching钩子的使用,从添加用于数据获取状态的本地区域变量开始:

    - import { createContext } from 'react';
    - import useDataFetching from 
        '../hooks/useDataFetching';
    + import { createContext, useCallback, useState } from
        'react';
      export const ItemsContext = createContext();
      export const ItemsContextProvider = ({ children }) 
        => {
    -   const [loading, error, data] =
          useDataFetching('https://my-json-server.
          typicode.com/PacktPublishing/React-Projects-
          Second-Edition/items);
    +   const [loading, setLoading] = useState(true);
    +   const [items, setItems] = useState([]);
    +   const [error, setError] = useState('');
        // ...
    
  2. 在此之后,我们可以添加一个名为fetchItems的函数,并将其传递给ItemsContextProvider,这意味着它将被添加到上下文中。这个函数被useCallback钩子包装,以防止组件不必要的(重新)渲染:

        // ...
        const [error, setError] = useState('');
    +   const fetchItems = useCallback(async (listId) => {
    +     try {
    +       const data = await fetch(`https://my-json-
              server.typicode.com/PacktPublishing/
              React-Projects-Second-Edition/lists/
              ${listId}/items`);
    +       const result = await data.json();
    +       if (result) {
    +         setItems(result);
    +         setLoading(false);
    +       }
    +     } catch (e) {
    +       setLoading(false);
    +       setError(e.message);
    +     }
    +   }, [])
        return (
    -     <ItemsContext.Provider value={{ data: items,
            loading, error }}>
    +     <ItemsContext.Provider value={{ items, loading,
            error, fetchItems }}>
          // ...
    
  3. 在这个函数就位后,下一步就是从ListDetail组件中调用它,并传递一个listId的值。这意味着一旦加载了这个组件,我们就不再检索所有项目,而是使用 URL 中的参数来确定应该获取并添加到上下文中的数据:

    - import { useState, useEffect, useContext } from
       'react';
    + import { useEffect, useContext } from 'react';
      import styled from 'styled-components';
      // ...
      function ListDetail() {
        let navigate = useNavigate();
        const { listId } = useParams();
    -   const { loading, error, items: data } =
          useContext(ItemsContext);
    +   const { loading, error, items, fetchItems } = 
          useContext(ItemsContext);
    -   const [items, setItems] = useState([]);
    -   useEffect(() => {
    -     data && listId && setItems(data.filter((item) =>
            item.listId === parseInt(listId)));
    -   }, [data, listId]);
    +   useEffect(() => {
    +     listId && !items.length && fetchItems(listId);
    +   }, [fetchItems, items, listId]);
        return (
          // ...
    

之前的useEffect钩子会在页面 URL 中存在listId并且items的值为空数组时调用fetchItems函数。这防止我们在ItemsContext中已经存在项目时再次获取项目。

通过在我们的上下文中创建一个进行数据获取的函数,我们现在可以控制数据何时被获取,因此将不再有对 API 的不必要请求。但其他钩子也可以直接将数据传递给 Provider,而无需重复useState钩子。这将在本节的下一部分进行演示。

使用useReducer进行高级状态管理

另一种向 Provider 添加数据的方法是使用类似于 Facebook 引入的 Flux 的模式。Flux 模式描述了一种数据流,其中正在分发动作以从存储中检索数据并将其返回给视图。这意味着动作需要在某处进行描述;应该有一个中央位置来存储数据,并且视图可以读取这些数据。为了使用上下文 API 实现此模式,你可以使用另一个名为useReducer的钩子。这个钩子可以用来返回数据,而不是从本地状态,而是从任何数据变量:

  1. 正如与useState钩子一样,useReducer钩子需要添加到使用它的组件中。useReducer将接受一个初始状态和一个函数,该函数确定应该返回哪些数据。这个初始值需要在添加钩子之前添加到src/context/ListsContext.js文件中:

    - import { createContext } from 'react';
    + import { createContext, useCallback, useReducer }
        from 'react';
      const ListsContext = createContext();
    + const initialState = {
    +   lists: [],
    +   loading: true,
    +   error: '',
    + };
      // ...
    
  2. 除了初始值之外,useReducer Hook 还接受一个名为reducer的函数。这个reducer函数也应该被创建,它是一个根据发送给它的操作更新initialState(已传递)并返回当前值的函数。如果发送给 reducer 的操作不匹配reducer中定义的任何操作,reducer 将只返回当前值而不做任何更改:

      import { createContext, useReducer } from 'react';
      const ListsContext = createContext();
      // ...
    + const reducer = (state, action) => {
    +   switch (action.type) {
    +     case 'GET_LISTS_SUCCESS':
    +       return {
    +         ...state,
    +         lists: action.payload,
    +         loading: false,
    +       };
    +     case 'GET_LISTS_ERROR':
    +       return {
    +         ...state,
    +         lists: [],
    +         loading: false,
    +         error: action.payload,
    +       };
    +     default:
    +       return state;
    +   }
    + };
    
      export const ListsContextProvider = ({ children }) => {
        // ...
    
  3. useReducer Hook 的两个参数现在已添加到文件中,因此你需要添加实际的 Hook 并将initialStatereducer传递给它。useDataFetching Hook 可以被移除,因为这将被一个新的具有数据获取逻辑的函数所取代:

      // ...
    const ListsContextProvider = ({ children }) => { 
    -   const [loading, error, data] =
          useDataFetching('https://my-json-server.
          typicode.com/PacktPublishing/React-Projects-
          Second-Edition/lists');
    +   const [state, dispatch] = 
          useReducer(reducer, initialState);
      // ...
    
  4. 如你所见,reducer在接收到GET_LISTS_SUCCESSGET_LISTS_ERROR操作时改变了它返回的值。在之前提到之前,你可以通过使用由useReducer Hook 返回的dispatch函数来调用这个reducer。然而,因为你还要处理数据的异步获取,你不能直接调用这个函数。相反,你需要创建一个调用fetchData函数并在之后发送正确操作的async/await函数:

      // ...
      export const ListsContextProvider = ({ children })
        => {
        const [state, dispatch] = 
          useReducer(reducer, initialState);
    +   const fetchLists = useCallback(async () => {
    +     try {
    +       const data = await fetch(`https://my-json-
              server.typicode.com/PacktPublishing/React-
              Projects-Second-Edition/lists`);
    +       const result = await data.json();
    +       if (result) {
    +         dispatch({ type: 'GET_LISTS_SUCCESS', 
                         payload: result });
    +       }
    +     } catch (e) {
    +       dispatch({ type: 'GET_LISTS_ERROR', 
              payload: e.message });
    +     }
    +   }, [])
      return (
        // ...
    
  5. 在前面的fetchLists函数中,它调用 API,如果有结果,将使用来自useReducer Hook 的dispatch函数将GET_LISTS_SUCCESS操作发送到 reducer。如果没有,将发送GET_LISTS_ERROR操作,它返回一个错误消息。

  6. 必须将状态和fetchLists函数的值添加到 Provider 中,这样我们就可以从其他组件通过 Context 访问它们:

        // ...
        return (
    -     <ListsContext.Provider value=
            {{ loading, error, data: lists }}>
    +     <ListsContext.Provider value=
            {{ ...state, fetchLists }}>
            {children}
          </ListsContext.Provider>
        );
      };
      export default ListsContext;
    
  7. 现在,这个getLists函数可以从显示列表的组件中的useEffect Hook 调用,即Lists组件,在第一次渲染时。列表应该在还没有任何列表可用时才检索:

    - import { useContext } from 'react';
    + import { useContext, useEffect } from 'react';
      import styled from 'styled-components';
      import { Link, useNavigate } from 
        'react-router-dom';
      import NavBar from '../components/NavBar/NavBar';
      import ListsContext from '../context/ListsContext';
      // ...
      function Lists() {
        let navigate = useNavigate();
    
    -   const { loading, error, lists } =
          useContext(ListsContext);
    +   const { loading, error, lists, fetchLists } = 
          useContext(ListsContext);
    +   useEffect(() => {
    +     !lists.length && fetchLists()
    +   }, [fetchLists, lists])
        return (
          // ...
    

如果你现在再次在浏览器中访问项目,你可以看到列表的数据就像之前一样被加载。最大的不同是数据是使用 Flux 模式获取的,这意味着这可以扩展到在其他实例中获取数据。同样,对于ItemsContext也可以在src/context/ItemsContext.js文件中这样做:

  1. 首先,导入useReducer Hook,并为项目添加初始值和我们将与这个 Hook 一起使用的reducer函数:

    - import { createContext, useState } from 'react';
    + import { createContext, useReducer } from 'react';
      export const ItemsContext = createContext();
    + const initialState = {
    +   items: [],
    +   loading: true,
    +   error: '',
    + };
    + const reducer = (state, action) => {
    +   switch (action.type) {
    +     case 'GET_ITEMS_SUCCESS':
    +       return {
    +         ...state,
    +         items: action.payload,
    +         loading: false,
    +       };
    +     case 'GET_ITEMS_ERROR':
    +       return {
    +         ...state,
    +         items: [],
    +         loading: false,
    +         error: action.payload,
    +       };
    +     default:
    +       return state;
    +   }
    + };
      export const ItemsContextProvider = 
        ({ children }) => {
        // ...
    
  2. 在此之后,你可以将初始状态和 reducer 添加到useReducer Hook 中。该文件中已经存在的fetchItems函数必须进行更改,以便它将使用来自useReducerdispatch函数,而不是来自useState Hook 的update函数:

      // ...
      export const ItemsContextProvider =
        ({ children }) => {
    -   const [loading, setLoading] = useState(true);
    -   const [items, setItems] = useState([]);
    -   const [error, setError] = useState('');
    +   const [state, dispatch] =
          useReducer(reducer, initialState);
      const fetchItems = useCallback(async (listId) => {
        try {
          const data = await fetch(`https://my-json-
            server.typicode.com/PacktPublishing/React-
            Projects-Second-Edition/lists/${listId}/
            items`);
          const result = await data.json();
          if (result) {
    -       setItems(result);
    -       setLoading(false);
    +       dispatch({ type: 'GET_ITEMS_SUCCESS', 
               payload: result });
          }
        } catch (e) {
    -     setLoading(false);
    -     setError(e.message);
    +     dispatch({ type: 'GET_ITEMS_ERROR', 
            payload: e.message });
        }
      }, [])
      return (
        // ...
    
  3. 此外,将状态和fetchItems函数添加到ListsContextProvider

        // ...
        return (
    -     <ItemsContext.Provider value={{ items, loading, 
            error, fetchItems }}>
    +     <ItemsContext.Provider value=
            {{ ...state, fetchItems }}>
            {children}
          </ItemsContext.Provider>
        );
      };
      export default ItemsContext;
    

如果你现在打开特定的列表,例如/lists/:listId路由上的列表,例如http://localhost:3000/list/1,你会看到没有任何变化,列表的项目仍然被显示。

你可能会注意到列表的标题在这里没有显示。列表的信息只有在Lists组件首次渲染时才会被检索,所以你需要创建一个新的函数来始终检索你目前在List组件中显示的列表信息:

  1. src/context/ListsContextProvider.js文件中,你需要扩展initialState以包含一个名为list的字段:

      import { createContext, useReducer } from 'react';
      export const ListsContext = createContext();
      const initialState = {
        lists: [],
    +   list: {},
        loading: true,
        error: '',
      };
      const reducer = (state, action) => {
        // ...
    
  2. reducer中,你现在还必须检查两个新的动作,这两个动作要么将关于列表的数据添加到上下文中,要么添加一个错误消息:

      // ...
      const reducer = (state, action) => {
        switch (action.type) {
          case 'GET_LISTS_SUCCESS':
             // ...
          case 'GET_LISTS_ERROR':
            // ...
    +     case 'GET_LIST_SUCCESS':
    +       return {
    +         ...state,
    +         list: action.payload,
    +         loading: false,
    +       };
    +     case 'GET_LIST_ERROR':
    +       return {
    +         ...state,
    +         list: {},
    +         loading: false,
    +         error: action.payload,
    +       };
          default:
            return state;
        }
      };
      export const ListsContextProvider = 
        ({ children }) => {
        // ...
    
  3. 这些动作将从一个新的fetchList函数中分发,该函数接受一个特定列表的 ID 来调用 API。如果成功,将分发GET_LIST_SUCCESS动作;否则,将分发GET_LIST_ERROR动作。同时,将函数传递给 Provider,以便它可以在其他组件中使用:

      // ...
    +   const fetchList = useCallback(async (listId) => {
    +     try {
    +       const data = await fetch(`https://my-json-
              server.typicode.com/PacktPublishing/React-
              Projects-Second-Edition/lists/${listId}`);
    +       const result = await data.json();
    +       if (result) {
    +         dispatch({ type: 'GET_LIST_SUCCESS',
                payload: result });
    +       }
    +     } catch (e) {
    +       dispatch({ type: 'GET_LIST_ERROR', 
              payload: e.message });
    +     }
    +   }, [])
        return (
    -     <ListsContext.Provider value=
            {{ ...state, fetchLists }}>
    +     <ListsContext.Provider value=
            {{ ...state, fetchLists, fetchList }}>
            {children}
          </ListsContext.Provider>
        );
      };
      export default ListsContext;
    
  4. ListDetail组件中,我们可以通过在useEffect Hook 中调用fetchList函数从ListsContext获取列表数据。同时,将其作为 prop 传递给NavBar组件,以便它将被显示:

      import { useEffect, useCallback, useContext } from
        'react';
      import styled from 'styled-components';
      import { useNavigate, useParams } from 
        'react-router-dom';
      import NavBar from '../components/NavBar/NavBar';
      import ListItem from
        '../components/ListItem/ListItem';
      import ItemsContext from '../context/ItemsContext';
    + import ListsContext from '../context/ListsContext';
      // ...
      function ListDetail() {
        let navigate = useNavigate();
        const { listId } = useParams();
        const { loading, error, items, fetchItems } = 
          useContext(ItemsContext);
    +   const { list, fetchList } = 
          useContext(ListsContext);
        useEffect(() => {
          listId && !items.length && fetchItems(listId);
        }, [fetchItems, items, listId]);
    +   useEffect(() => {
    +     listId && fetchList(listId);
    +   }, [fetchList, listId]);
        return (
          <>
            {navigate && (
              <NavBar
                goBack={() => navigate(-1)}
                openForm={() =>
                  navigate(`/list/${listId}/new`)}
    +           title={list && list.title}
              />
            )}
            // ...
    

在前面的代码块中,我们是从一个与fetchItems函数不同的useEffect Hook 中调用fetchList函数。这是一种很好的分离关注点的方法,以保持我们的代码干净和简洁。

你应用中的所有数据现在都是通过 Provider 加载的,这意味着它现在与视图分离。此外,useDataFetching Hook 被完全移除,使你的应用程序结构更易于阅读。

你不仅可以使用这个上下文 API 与这个模式来使数据对许多组件可用,还可以修改数据。如何修改这些数据将在下一节中展示。

在 Provider 中修改数据

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

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

  1. 第一步是在ItemsContext中添加新的动作,一旦我们尝试添加一个新项目,这个动作就会被分发:

      // ... 
      const reducer = (state, action) => {
        switch (action.type) {
          // ...   
    +     case 'ADD_ITEM_SUCCESS':
    +       return {
    +         ...state,
    +         items: [...state.items, action.payload],
    +         loading: false,
    +       };
          default:
            return state;
        }
      };
      export const ItemsContextProvider = 
        ({ children }) => {
        // ...
    
  2. 我们还需要添加一个新的函数来处理POST请求,因为这个函数在处理fetch请求时也应该设置方法和一个主体。你可以在前面的文件中创建这个函数,并将其传递给 Provider:

      // ...  
    items, so this data is available from the Consumer.
    
  3. 由于现在可以从提供者处获取添加新项目到列表的函数,src/pages/ListForm.js中的ListForm组件现在能够使用addItem函数,这将触发调用 API 并将项目添加到状态的行动。然而,表单中输入字段的值需要首先确定。因此,输入字段需要是受控组件,意味着它们的值由封装值的本地状态控制。因此我们需要导入useState钩子和一个useContext钩子,我们将在稍后使用它来从传递给它的上下文的提供者中获取值:

    + import { useState, useContext } from 'react';
      import styled from 'styled-components';
      import { useNavigate, useParams } from 
       'react-router-dom';
      import NavBar from '../components/NavBar/NavBar';
      import FormItem from 
        '../components/FormItem/FormItem';
      import Button from '../components/Button/Button';
    + import ItemsContext from '../context/ItemsContext';
      // ...
      function ListForm() {
        let navigate = useNavigate();
        const { listId } = useParams();
    +   const [title, setTitle] = useState('');
    +   const [quantity, setQuantity] = useState('');
    +   const [price, setPrice] = useState('');
        return (
          // ...   
    

为了做到这一点,我们为每个创建的state值使用了useState钩子。

  1. 本地状态值和触发本地状态值更新的函数必须作为属性设置在FormItem组件上:

         // ...
         return (
           <>
             {navigate && <NavBar goBack={() => 
               navigate(-1)} title={`Add Item`} />}
             <FormWrapper>
               <form>
                 <FormItem
                   id='title'
                   label='Title'
                   placeholder='Insert title'
    +              value={title}
    +              handleOnChange={(e) =>
                     setTitle(e.currentTarget.value)}
                 />
                 <FormItem
                   id='quantity'
                   label='Quantity'
                   type='number'
                   placeholder='0'
    +              value={quantity}
    +              handleOnChange={(e) =>
                     setQuantity(e.currentTarget.value)}
                 />
                 <FormItem
                   id='price'
                   label='Price'
                   type='number'
                   placeholder='0.00'
    +              value={price}
    +              handleOnChange={(e) => 
                     setPrice(e.currentTarget.value)}
                 />
                 <SubmitButton>Add Item</SubmitButton>
               </form>
             </FormWrapper>
           </>
         );
      };
      export default Form;
    
  2. 现在你需要做的最后一件事是添加一个函数,当点击提交按钮提交表单时将被触发。这个函数接受value作为本地状态,添加关于项的信息,并使用这个信息来调用addItem函数。在这个函数被调用后,useNavigate中的导航函数被调用,以便返回到该列表的概览页面:

      // ...
    + const { addItem } = useContext(ItemsContext);
    + function onSubmit(e) {
    +   e.preventDefault();
    +   if (title && quantity && price) {
    +     addItem({
    +       title,
    +       quantity,
    +       price,
    +       listId,
    +     });
    +   }
    +   navigate(`/list/${listId}`);
    + }
      return (
        <>
          {navigate && <NavBar goBack={() =>
            navigate(-1)} title={`Add Item`} />}
          <FormWrapper>
    -       <form>
    +       <form onSubmit={onSubmit}>
              // ...
    

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

到目前为止,来自上下文的信息仅通过使用提供者单独使用,但也可以将其组合成一个应用程序上下文,如下一节所示。

创建应用程序上下文

如果你查看App组件中当前的路由结构,你可以想象,如果你向应用程序中添加更多的提供者和消费者,这将会变得混乱。像 Redux 这样的状态管理包通常有一个应用程序状态,其中存储了应用程序的所有数据。当使用上下文时,可以创建一个应用程序上下文,可以使用useContext钩子访问它。这个钩子充当消费者,可以从传递给它的上下文的提供者中检索值。让我们重构当前的应用程序,使其具有应用程序上下文:

  1. 首先,在src/context目录中创建一个名为AppContext.js的文件。这个文件将导入ListsContextProviderItemsContextProvider,将它们嵌套,并将任何将被传递给它的组件作为children属性包裹:

    import { ListsContextProvider } from './ListsContext';
    import { ItemsContextProvider } from './ItemsContext';
    const AppContext = ({ children }) => {
      return (
        <ListsContextProvider>
          <ItemsContextProvider>{children}
          </ItemsContextProvider>
        </ListsContextProvider>
      );
    };
    export default AppContext;
    
  2. src/App.js文件中,我们现在可以导入这个AppContext文件,以替代列表和项的提供者,并将ListsContextProviderItemsContextProvider替换为AppContext

      import styled, { createGlobalStyle } from 
        'styled-components';
      import { Route, Routes, BrowserRouter } from 
        'react-router-dom';
      import Header from './components/Header/Header';
      import Lists from './pages/Lists';
      import ListDetail from './pages/ListDetail';
      import ListForm from './pages/ListForm';
    - import { ListsContextProvider } from 
        './context/ListsContext';
    - import { ItemsContextProvider } from
        './context/ItemsContext';
    + import AppContext from './context/AppContext';
      // ...
      function App() { 
        return (
          <>
            <GlobalStyle />
            <AppWrapper>
              <BrowserRouter>
                <Header />
    +           <AppContext>
    -           <ListsContextProvider>
    -             <ItemsContextProvider>
                    <Routes>
                      // ...
                    </Routes>
    -             </ItemsContextProvider>
    -           </ListsContextProvider>
    +           </AppContext>
              </BrowserRouter>
            </AppWrapper>
          </>
        );
      }
      export default App;
    

AppContext组件可以扩展到所有你可能希望在将来添加的上下文对象。我们的应用程序现在有一个更干净的结构,而数据仍然由提供者检索。

使用 React Suspense 进行代码拆分

到目前为止,我们主要关注添加新功能,如路由或状态管理,到我们的应用程序中。但并没有太多关注于使我们的应用程序更高效,我们可以通过代码拆分来实现这一点。React 的一个名为 Suspense 的功能可以用于代码拆分,这意味着你将编译后的代码(你的包)拆分成更小的块。这将防止浏览器一次性下载包含编译代码的整个包,而是根据浏览器渲染的组件分块加载你的包。

注意

在上一章中,我们使用 Next.js 而不是 Create React App 来创建我们的 React 应用程序,它默认启用了代码拆分。

Suspense 允许你的组件等待直到你导入的组件准备好显示。在 React 18 之前,它只能用于代码拆分,但自从最新版本的 React 以来,它有更多的用途。当你从使用 Suspense 导入的组件获取数据时,React 也会等待直到该组件的数据完全获取。

悬念必须与懒加载方法结合使用,这涉及到使用 JavaScript 动态导入来仅在请求时加载组件。这两种方法都可以从 React 的src/App.js中导入,其中懒加载方法用于导入我们页面的组件:

+ import { Suspense, lazy } from 'react';
  import styled, { createGlobalStyle } from 
    'styled-components';
  import { Route, Routes, BrowserRouter} from 
    'react-router-dom';
  import Header from './components/Header/Header';
- import Lists from './pages/Lists';
- import ListDetail from './pages/ListDetail';
- import ListForm from './pages/ListForm';
  import AppContext from './context/AppContext';
+ const Lists = lazy(() => import('./pages/Lists'));
+ const ListDetail = lazy(() =>
    import('./pages/ListDetail'));
+ const ListForm = lazy(() => import('./pages/ListForm'));
  // ...

  function App() {
    // ...

App组件的return语句中,Suspense 必须与一个回退一起使用,当动态导入的组件正在加载时,将显示该回退:

  // ...
  function App() {
    return (
      <>
        <GlobalStyle />
        <AppWrapper>
          <BrowserRouter>
            <Header />
+           <Suspense fallback={<div>Loading...</div>}>
              <AppContext>
                // ...
              </AppContext>
+           </Suspense>
          </BrowserRouter>
        </AppWrapper>
      </>
    );
  }
  export default App;

当你在浏览器中查看应用程序时,除非你有慢速的互联网连接,否则你不会看到任何变化。在这种情况下,Suspense 的回退将在组件加载时显示。然而,当你打开bundle.jsmain.chunk.js时。然而,在应用代码拆分后,块组件也会被加载,例如,src_pages_ListDetail_js.js

![图 5.2 – 代码拆分后我们应用程序的网络请求

![图 5.2_B17390.jpg]

图 5.2 – 代码拆分后我们应用程序的网络请求

查看主路由/,我们可以看到名为3.chunk.js的块被加载。这不是一个非常有用的文件名,我们可以通过在行内注释中使用webpackChunkName来更改它。通过这个添加,我们可以指导 webpack 将文件命名为更用户友好的名称:

  // ...
- const Lists = lazy(() => import('./pages/Lists'));
- const ListDetail = lazy(() =>
    import'./pages/ListDetail'));
- const ListForm = lazy(() => import('./pages/ListForm'));
+ const Lists = lazy(() => import(/* webpackChunkName:
    "Lists" */ './pages/Lists'));
+ const ListDetail = lazy(() => import(/* webpackChunkName:
    "ListDetail" */ './pages/ListDetail'));
+ const ListForm = lazy(() => import(/* webpackChunkName: 
    "ListForm" */ './pages/ListForm'));
  function App() {
    // ...

这个最新的功能使得识别我们应用程序中加载了哪些块(或组件)变得容易得多,正如你通过在浏览器中重新加载应用程序并再次检查网络标签所看到的那样。

摘要

在本章中,您创建了一个使用 Context API 和 Hooks 传递和检索数据的购物清单应用程序。Context 用于存储数据,Hooks 用于检索和修改数据。使用 Context API,您可以使用 useReducer Hook 创建更高级的状态管理场景。Context 还用于创建一个所有数据都存储在应用程序范围内,并且可以通过创建共享 Context 从任何组件访问的情况。最后,我们使用了 React Suspense 来对我们的包应用代码拆分,以改善性能。

在下一章中也将使用 Context API,这将向您展示如何使用 Jest 和 Enzyme 等库进行自动化测试来构建一个酒店评论应用程序。它将向您介绍多种测试您使用 React 创建的 UI 组件的方法,并展示如何使用 Context API 测试您应用程序中的状态管理。

进一步阅读

更多信息,请参阅以下链接:

第六章:使用 React Testing Library 和 Cypress 探索 TDD 构建应用

为了保持您的应用可维护,为项目设置测试是一个好的实践。虽然一些开发者讨厌编写测试并因此试图避免编写它们,但其他开发者喜欢将测试作为他们开发过程的核心,通过实施测试驱动开发TDD)策略。关于测试应用和如何进行测试有许多不同的观点。幸运的是,当使用 React 构建应用时,许多优秀的库可以帮助您进行测试。

在本章中,您将使用React Testing Library工具对 React 应用进行单元测试。这个库由 React 社区本身维护,并随 Create React App 一起提供。它具有许多针对测试组件和其他 React 功能整个生命周期的功能。因此,如果您想测试函数或组件在给定特定输入时是否按预期行为,React Testing Library 是测试大多数 React 应用的绝佳选择。此外,我们还将探索另一个名为Cypress的工具,它非常适合对我们的 React 应用进行端到端测试。

本章将涵盖以下主题:

  • 单元测试组件

  • 测试 React 状态和 Hooks

  • 使用 Cypress 进行端到端测试

项目概述

在本章中,我们将创建一个使用 React 构建的酒店评论应用,该应用使用 Context API 进行状态管理。我们将添加 React Testing Library 来对这个应用进行单元和集成测试,同时使用 Cypress 进行端到端测试。该应用已经预构建,并使用我们在前几章中查看过的相同模式。

构建时间为 2 小时。

开始

本章的应用基于一个初始版本,您可以在github.com/PacktPublishing/React-Projects-Second-Edition/tree/main/Chapter06-initial找到。本章的完整代码可以在 GitHub 上找到:github.com/PacktPublishing/React-Projects-Second-Edition/tree/main/Chapter06

首先,从 GitHub 下载初始项目,然后进入此项目的根目录,在那里你必须运行npm install命令。由于此项目基于 Create React App 构建,运行此命令将安装reactreact-domreact-scripts。此外,还将安装styled-componentsreact-router-dom,以便它们可以处理应用程序的样式和路由。还将安装的是 React Test Library,你可以通过@testing-library/*前缀来识别它。完成安装过程后,你可以执行npm start命令来运行应用程序,以便你可以在浏览器中访问http://localhost:3000上的项目。初始应用程序包括一个简单的页眉和酒店列表。这些酒店有标题和元信息,例如缩略图。此页面将如下所示:

图 6.1 – 初始应用程序

图 6.1 – 初始应用程序

如果你点击列表中的任何酒店,将打开一个新页面,显示该酒店的评论列表。通过点击页面左上角的按钮,你可以返回上一页,而通过点击右上角的按钮,将打开一个表单页面,你可以在此添加评论。如果你添加了新的评论,这些数据将被存储在全局上下文中,并发送到模拟 API 服务器:

如果你查看项目的结构,你会看到它与我们之前创建的项目使用相同的结构:

chapter-6-initial 
  |- node_modules 
  |- public 
  |- package.json 
  |- src 
     |- components 
        |- Button 
           |- Button.js 
        |- FormItem 
           |- FormItem.js 
        |- Header 
           |- Header.js 
        |- HotelItem 
           |- HotelItem.js 
        |- NavBar 
           |- NavBar.js 
        |- ReviewItem 
           |- ReviewItem.js 
     |- context 
        |- AppContext.js 
        |- HotelsContext.js 
        |- ReviewsContext.js 
     |- pages 
        |- HotelDetail.js 
        |- Hotels.js 
        |- ReviewForm.js 
     |- App.js 
     |- index.js 
     |- setupTests.js

本章中非常重要的文件是setupTests.js,该文件用于为此项目配置 React Testing Library。此应用的入口点是一个名为src/index.js的文件,它渲染了一个名为App的组件。在这个App组件中,所有路由都被声明并包裹在一个Router组件中。这些路由如下所示:

  • /:此页面渲染Hotels,显示所有酒店的概述。

  • /hotel/:hotelId:此页面渲染HotelDetail,显示特定酒店的评论概述。

  • /hotel/:hotelId/new:此页面渲染ReviewForm,包含一个表单,可以添加特定酒店的评论。

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

db.json 文件必须存在于你的 GitHub 仓库的 master 分支(或默认分支)中,以便 My JSON Server 能够正常工作。否则,当你尝试请求 API 端点时,你会收到一个 404 Not Found 的消息。

酒店评论应用程序

在本节中,我们将向在 Create React App 中创建的酒店评论应用程序添加单元测试和集成测试。这个应用程序允许你向酒店列表添加评论,并从应用程序上下文中控制这些数据。我们将使用 React Testing Library 来渲染 React 组件,以测试这些组件的断言。

组件的单元测试

单元测试是应用程序的重要组成部分,因为你希望知道你的函数和组件在代码更改时仍然按预期行为。为此,我们将使用 React Testing Library,这是一个由 React 社区创建的开源测试包,用于 React 应用程序。使用 React Testing Library,你可以测试断言——例如,函数的输出是否与预期的值匹配。

要开始,我们不需要安装任何东西;它是 Create React App 的一部分。如果你查看 package.json 文件,你会看到已经有一个用于运行测试的脚本。让我们看看如果你从终端执行以下命令会发生什么:

npm run test 

这将返回一条消息,说明 No tests found related to files changed since last commit.,这意味着我们的测试正在监视模式下运行,并且只针对已更改的文件运行测试。在底层,我们使用 Jest 测试运行器来运行我们的测试。按下 A 键,你可以运行所有测试,即使你没有修改任何文件。如果你按下这个键,将会显示以下消息:

No tests found related to files changed since last commit. 

Jest 将自动检查 src 目录下的所有文件,并寻找测试文件。在本节的第一部分,我们将展示如何使用 React Test Library 创建可以运行的测试。

创建单元测试

由于 Jest 有多种方法可以检测哪个文件包含测试,让我们选择一个结构,其中每个组件都有一个单独的测试文件。这个测试文件将与包含组件的文件具有相同的名称,并带有 .test 后缀。如果我们选择 NavBar 组件,我们可以在 src/components/NavBar 目录中创建一个名为 NavBar.test.js 的新文件。将以下代码添加到该文件中:

test('The NavBar component should render', () => {

});

这里使用 Jest 的全局 test 函数来定义一个测试;测试断言可以放置在大括号内。或者,你也可以使用 describeit 函数来声明一个(块)测试。

如果我们现在再次运行 npm run test 命令,Jest 运行器将找到我们的第一个测试并显示以下输出:

PASS  src/components/NavBar/NavBar.test.js
  ✓ The NavBar component should render (1 ms)
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        3.105 s
Ran all test suites related to changed files.

在测试的定义中,你可以添加如 toEqualtoBe 这样的假设,这些假设检查值是否与某物完全相等,或者类型是否仅匹配。这些假设可以添加到 test 函数的回调中:

  test('The NavBar component should render', () => {
+   expect(1 + 2).toBe(3);
  });

如果你还在终端中运行测试脚本,你会看到 Jest 已经检测到你的测试。测试成功,因为 1+2 确实等于 3。让我们继续将假设更改为以下内容:

  test('The NavBar component should render', () => {
-   expect(1 + 2).toBe(3);
+   expect(1 + 2).toBe('3');
  });

现在,测试将失败,因为第二个假设不匹配。尽管 1+2 仍然等于 3,但假设返回的是一个值为 3 的字符串类型,而实际上返回的是一个数字类型。如果你还在终端中运行 npm run test 命令,你还可以在那里看到这个解释。

然而,这个假设实际上没有使用,因为它没有测试你的组件。要测试你的组件,你需要渲染它。渲染组件以便测试它们将在本节的下一部分处理。

渲染 React 组件进行测试

Jest 基于 Node.js,这意味着它不能使用浏览器或(虚拟)DOM 来渲染你的组件并测试其功能。因此,我们将使用 React 测试库来帮助我们渲染这些组件。Create React App 默认包含这个库,它使用的包可以在 package.json 文件中找到:

  • @testing-library/jest-dom:提供用于测试 DOM 的自定义元素

  • @testing-library/react:React 测试库的核心包

  • @testing-library/user-events:提供用于测试用户交互的方法

React 测试库可以为我们渲染 React 组件,这样我们就可以为它们编写测试。前面的包将用于创建我们的测试:

  1. 在我们的 NavBar 组件的测试文件中,我们可以使用 @testing-library/reactrender 方法来渲染组件,并获取该组件的输出。通过 Jest 的 toMatchSnapshot 假设,我们可以通过从这个渲染创建快照并与每次运行测试时实际组件进行比较来测试组件的结构:

    + import { render } from '@testing-library/react';
    + import NavBar from './NavBar';
      test('The NavBar component should render', () => {
    -   expect(1 + 2).toBe('3');
    +   const view = render(<NavBar />);
    +   expect(view).toMatchSnapshot();
      });
    
  2. src/components/NavBar 目录中,Jest 已经创建了一个名为 __snapshots__ 的新目录。在这个目录中有一个名为 NavBar.test.js.snap 的文件,其中包含快照。如果你打开这个文件,你会看到存储在这里的 NavBar 组件的渲染版本:

    // Jest Snapshot v1, https://goo.gl/fbAQLP
    exports[`The NavBar component should render 1`] = `
    Object {
      "asFragment": [Function],
      "baseElement": <body>
        <div>
          <div
            class="sc-gsDJrp PAvEv"
          >
            <h2
              class="sc-dkPtyc jFfuUr"
            />
          </div>
        </div>
      </body>,
      // ...
    

使用 styled-components 创建的组件将被渲染为带有 sc-* 前缀的类名的 HTML 元素。

  1. 由于没有将属性传递给 NavBar 组件,React 测试库实际上没有渲染任何值。你可以通过传递,例如,一个 title 属性给组件来检查快照是如何工作的:

      import { render } from '@testing-library/react';
      import NavBar from './NavBar';
      // ...
    + test('The NavBar component should render with a title',
        () => {
    +   const view = render(<NavBar title='Test application' 
                            />);
    +   expect(view).toMatchSnapshot();
    + });
    
  2. 下次运行测试时,将在 src/components/NavBar/__snapshots__/NavBar.test.js.snap 文件中添加一个新的快照。此快照具有 title 属性的渲染值。如果您更改测试文件中 NavBar 组件显示的 title 属性,渲染的组件将不再匹配快照。您可以通过更改测试场景中的 title 属性的值来尝试此操作:

      import { render } from '@testing-library/react';
      import NavBar from './NavBar';
      // ...
      test('The NavBar component should render with a
        title', () => {
    -   const view = 
          render(<NavBar title='Test application' />);
    +   const view = 
          render(<NavBar title='Test application #2' />);
        expect(view).toMatchSnapshot();
      });
    

Jest 将在终端返回以下消息,其中指定了与快照相比哪些行已更改。在这种情况下,正在显示的标题不再是 Test Application,而是 Test Application #2,这与快照中的标题不匹配:

FAIL  src/components/NavBar/NavBar.test.js
  ✓ The NavBar component should render (29 ms)
  ✕ The NavBar component should render with a title
    (10 ms)
  ● The NavBar component should render with a title
    expect(received).toMatchSnapshot()
    Snapshot name: `The NavBar component should render
    with a title 1`
    - Snapshot  - 3
    + Received  + 3
    @@ -6,23 +6,23 @@
              class="sc-gsDJrp PAvEv"
            >
              <h2
                class="sc-dkPtyc jFfuUr"
              >
    -           Test application
    +           Test application #2
    // ...

通过按 U 键,您可以更新快照以处理此新的测试场景。这是一种测试组件结构和查看标题是否已渲染的简单方法。在先前的测试中,最初创建的快照仍然与第一个测试的渲染组件匹配。此外,为第二个测试还创建了一个快照,其中为 NavBar 组件添加了 title 属性。

注意

您可以对传递给 NavBar 组件的其他属性做同样的事情,如果传递或不传递某些属性,该组件的渲染方式将不同。除 title 外,此组件还接受 goBackopenForm 作为属性,这些属性也可以进行测试。

我们现在为我们的 NavBar 组件创建了两个测试,这是一个良好的开始。但 Jest 还会向您展示您的测试覆盖了多少行代码。测试覆盖率越高,就越有理由假设您的代码是稳定的。您可以通过使用带有 --coverage 标志和中间额外一个 --test 脚本命令来检查代码的测试覆盖率,或者在您的终端中使用以下命令:

npm run test -- --coverage

此命令将运行您的测试并生成一个报告,其中包含有关您代码每个文件的所有测试覆盖率信息。在为 NavBar 添加测试之后,此报告将如下所示:

---------------------------|---------|----------|---------|---------|
File                       | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
---------------------------|---------|----------|---------|---------|
All files                  |       5 |     4.68 |    3.12 |       5 |                   
 src                       |       0 |      100 |       0 |       0 |                   
  App.js                   |       0 |      100 |       0 |       0 | 
  index.js                 |       0 |      100 |     100 |       0 |              
 src/components/Button     |     100 |      100 |     100 |     100 |
  Button.js                |     100 |      100 |     100 |     100 |                   
 src/components/FormItem   |       0 |        0 |       0 |       0 |                   
  FormItem.js              |       0 |        0 |       0 |       0 | 
 src/components/Header     |       0 |      100 |       0 |       0 |                   
  Header.js                |       0 |      100 |       0 |       0 | 
 src/components/HotelItem  |       0 |      100 |       0 |       0 |                   
  HotelItem.js             |       0 |      100 |       0 |       0 | 
 src/components/NavBar     |     100 |       60 |     100 |     100 |                   
  NavBar.js                |     100 |       60 |     100 |     100 | 
  // ...

注意

测试覆盖率仅告诉我们有关您代码中已测试的行和函数的信息,而不是它们的实际实现。测试覆盖率为 100%并不意味着您的代码中没有错误,因为总会存在边缘情况。此外,达到 100%的测试覆盖率意味着您可能花费更多的时间在编写测试上,而不是实际代码上。通常,测试覆盖率超过 80%被认为是良好的实践。

如您所见,组件的测试覆盖率为 60%,这意味着您的测试覆盖了大多数行。要达到 100%的覆盖率,我们还需要为在 NavBar 组件中用于渲染返回上一阶段按钮或 form 组件的其他属性添加测试。此外,Button 组件的覆盖率为 100%,因为没有实际元素在那里渲染。

然而,使用快照进行测试的方法将会创建大量的新文件和代码行。我们将在本节下一部分探讨其他测试组件的方法。

使用断言测试组件

理论上,快照测试并不一定是不良实践;然而,随着时间的推移,你的文件可能会变得相当大。另外,由于你没有明确告诉 Jest 你想测试组件的哪个部分,你可能需要定期更新你的代码。

幸运的是,使用快照并不是测试组件是否正确渲染属性的唯一方法。相反,我们还可以直接比较渲染的属性,通过检查组件的值并做出断言。使用断言测试的优势在于,你可以在不深入测试组件逻辑的情况下测试很多内容。例如,你可以看到正在渲染的子组件的外观。

让我们将NavBar组件的第二张快照测试更改为比较对测试覆盖率的影响。我们需要从 React Testing Library 导入screen方法,该方法用于扫描渲染的组件。我们不会对整个组件创建快照并在其中找到标题,而是会寻找任何标题组件(如h2),并检查它们的值是否等于我们在NavBar上设置的属性:

- import { render } from '@testing-library/react';
+ import { render, screen } from '@testing-library/react';
  import NavBar from './NavBar';
  // ...
+ test('The NavBar component should render with a title', 
    () => {
-   const view = 
      render(<NavBar title='Test application #2' />);
-   expect(view).toMatchSnapshot();
+   const title = 'Test application';
+   render(<NavBar title={title} />);
+   expect(screen.getByRole('heading')).
           toHaveTextContent(title);
  });

我们已经使用了 React Testing Library 的getByRole方法在NavBar组件中找到Title组件,并使用toHaveTextContent方法检查Title内部的文本是否与我们设置的属性相等。测试仍然通过,并且允许我们删除快照,因为我们现在正在使用假设来测试组件的这一部分:

PASS  src/components/NavBar/NavBar.test.js
  ✓ The NavBar component should render (13 ms)
  ✓ The NavBar component should render with a title (54 ms)
 › 1 snapshot obsolete.
   • The NavBar component should render with a title 1
Snapshot Summary
 › 1 snapshot obsolete from 1 test suite. To remove it, press `u`.
   ↳ src/components/NavBar/NavBar.test.js
       • The NavBar component should render with a title 1

通过按U键或使用带有-u标志的npm run test命令,Jest 将移除NavBar组件的快照:

Snapshot Summary
 › 1 snapshot removed from 1 test suite.
   ↳ src/components/NavBar/NavBar.test.js
       • The NavBar component should render with a title 1 

NavBar组件的测试覆盖率仍应为 60%,因为我们继续测试title属性是否被呈现和渲染,你可以通过再次运行来检查:

---------------------------|---------|----------|---------|---------|
File                       | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
---------------------------|---------|----------|---------|---------|
All files                  |       5 |     4.84 |    3.33 |       5 |                   
 src                       |       0 |      100 |       0 |       0 |                   
  App.js                   |       0 |      100 |       0 |       0 | 
  index.js                 |       0 |      100 |     100 |       0 |               
src/components/NavBar      |     100 |       60 |     100 |     100 |                   
  NavBar.js                |     100 |       60 |     100 |     100 |
  // ...

然而,NavBar组件不仅接受title属性,还接受goBackopenForm函数作为属性。你还需要测试在点击任何按钮时这些函数是否被触发。

为了测试这些属性,我们需要创建一个模拟函数,并将其作为属性传递给NavBar,以模拟用户点击事件来测试该函数是否被调用。React Testing Library 中的fireEvent方法可以用来模拟用户事件,并且使用 Jest,我们可以模拟一个函数并检查该函数是否被调用:

- import { render, screen } from '@testing-library/react';
+ import { render, screen, fireEvent } from 
    '@testing-library/react';
  import NavBar from './NavBar';

  // ...
+ test('The NavBar component should respond to button
    clicks', () => {
+   const mockFunction = jest.fn();
+   render(<NavBar goBack={mockFunction} />);
+   fireEvent.click(screen.getByText('< Go Back'));
+   expect(mockFunction).toHaveBeenCalled();
+ });

通过运行前面的测试,将在NavBar中模拟点击后退按钮,Jest 将检查模拟函数是否被调用。同样,也可以对openForm属性进行操作:

  // ...
  test('The NavBar component should respond to button
    clicks', () => {
    const mockFunction = jest.fn();
-   render(<NavBar goBack={mockFunction} />);
+   render(<NavBar goBack={mockFunction} openForm=
      {mockFunction} />);
    fireEvent.click(screen.getByText('< Go Back'));
    expect(mockFunction).toHaveBeenCalled();
+   fireEvent.click(screen.getByText('+ Add Review'));
+   expect(mockFunction).toHaveBeenCalledTimes(2);
  });

goBackopenForm 属性的模拟函数是相同的,因此我们需要检查在点击打开表单按钮后这个函数是否被调用两次。通过测试这两个按钮上的用户事件,我们已经测试了 NavBar 组件的 100%,正如你可以在覆盖率报告中看到的那样:

PASS  src/components/NavBar/NavBar.test.js
  ✓ The NavBar component should render (27 ms)
  ✓ The NavBar component should render with a title (45 ms)
  ✓ The NavBar component should respond to button clicks (13 ms)
---------------------------|---------|----------|---------|---------|
File                       | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
---------------------------|---------|----------|---------|---------|
All files                  |       5 |     8.06 |    3.33 |       5 |                   
 src                       |       0 |      100 |       0 |       0 |                   
  App.js                   |       0 |      100 |       0 |       0 | 
  index.js                 |       0 |      100 |     100 |       0 |               
 src/components/NavBar     |     100 |      100 |     100 |     100 |
  NavBar.js                |     100 |      100 |     100 |     100 |
  // ...

在本节中,我们创建了单元测试来测试我们代码的特定部分。然而,测试我们代码的不同部分如何协同工作可能很有趣。为此,我们将添加集成测试来测试我们的状态管理和 Hooks。

测试 React 状态和 Hooks

我们创建的所有测试都渲染了没有状态管理的组件,但使用 React Testing Library,我们也有测试状态和 Hooks 的选项。在我们的设置中,由我们的路由器渲染的页面被包裹在一个应用程序上下文组件中。如果我们想要测试页面组件,我们需要确保这些页面的数据被模拟或存根,以便可以测试该组件与状态集成。

一个很好的例子是我们可以测试的 Hotels 组件,它渲染了上下文返回的酒店列表:

  1. 和往常一样,起点是在我们想要测试的组件所在的同一目录下创建一个以 .test 后缀的新文件。在这里,我们需要在 src/pages 目录中创建 Hotels.test.js 文件。在这个文件中,我们需要添加以下代码:

    import { render, screen } from 
      '@testing-library/react';
    import Hotels from './Hotels';
    import HotelsContext from '../context/HotelsContext';
    test('The Hotels component should render', async () => {
      const wrapper = ({ children }) => (
        <HotelsContext.Provider
          value={{
            loading: true,
            error: '',
            hotels: [],
            fetchHotels: jest.fn(),
          }}
        >
          {children}
        </HotelsContext.Provider>
      );
      render(<Hotels />, { wrapper });
      expect(await screen.findByText(
        'Loading...')).toBeVisible();
    });
    

前面的测试导入 Hotels 组件用于渲染页面的上下文对象,并创建一个包装函数,该函数在 HotelsContext 上创建一个提供者。我们向这个 Provider 添加了 Hotels 组件使用的上下文的模拟值。我们的测试断言尝试查找具有 Loading… 文本值的元素,并检查它是否存在。由于我们上下文中 loading 的值为 true,该元素确实可以找到。

注意

要运行测试的一部分,你可以在运行 npm run test 命令后按 P 键;你现在可以在终端中输入一个字符串,该字符串将用于模式匹配测试文件。

  1. 要测试当上下文中存在数据时酒店是否被渲染,我们需要在 Hotels.test.js 文件中创建一个新的测试来模拟这些数据:

      import { render, screen } from 
        '@testing-library/react';
      import Hotels from './Hotels';
      import HotelsContext from 
        '../context/HotelsContext';
    + import { BrowserRouter } from 'react-router-dom';
     // ...
    + test('The Hotels component should render a list of 
        hotels', async () => {
    +   const wrapper = ({ children }) => (
    +     <BrowserRouter>
    +       <HotelsContext.Provider
    +         value={{
    +           loading: false,
    +           error: '',
    +           hotels: [
    +             { id: 1, title: 'Test hotel 1', 
                    thumbnail: '' },
    +             { id: 2, title: 'Test hotel 2', 
                    thumbnail: '' },
    +           ],
    +           fetchHotels: jest.fn(),
    +         }}
    +       >
    +         {children}
    +       </HotelsContext.Provider>
    +     </BrowserRouter>
    +   );
    + });
    

在前面的模拟上下文值中,loading 的值被设置为 false,并添加了模拟的酒店。请注意,我们还用 React Router 的 BrowserRouter 包装了 Provider,因为 Hotels 组件使用 Link 组件使酒店可点击。

  1. 要测试酒店是否被渲染,我们需要添加一个测试断言来检查 loading 消息是否消失以及渲染了正确数量的酒店链接:

      // ...
      test('The Hotels component should render a list of
        hotels', async () => {
        const wrapper = ({ children }) => (
          // ...
        );
    +   render(<Hotels />, { wrapper });
    +   expect(screen.queryByText('Loading...')
          ).toBeNull();
    +   expect(screen.getAllByRole('link'
          ).length).toBe(2);
      });
    

我们之前使用的 getBy 方法在找不到元素时会抛出错误;为了测试某些内容没有被渲染,我们需要使用 queryBy 方法。此外,我们需要通过查找 link 角色并计数来检查是否存在两个 Link 组件。

  1. Hotels 组件中的 useEffect 钩子也可以被测试,以检查在上下文中没有酒店时是否调用了 fetchHotels 函数。因此,我们可以通过从 React Testing Library 导入 waitFor 方法并稍微调整上下文值来编辑第一个测试:

    - import { render, screen } from 
        '@testing-library/react';
    + import { render, screen, waitFor } from 
        '@testing-library/react';
      import Hotels from './Hotels';
      import HotelsContext from
        '../context/HotelsContext';
      import { BrowserRouter } from 'react-router-dom';
      test('The Hotels component should render', async ()
        => {
    +   const mockFunction = jest.fn()
        const wrapper = ({ children }) => (
          <HotelsContext.Provider
            value={{
              loading: true,
              error: '',
              hotels: [],
    -         fetchHotels: jest.fn(),
    +         fetchHotels: mockFunction,
            }}
          >
            {children}
          </HotelsContext.Provider>
        );
        // ...
    
  2. 我们还添加了测试断言以等待模拟函数被调用。在这里,我们特别声明模拟的函数,即来自 HotelsContextfetchHotels,只被调用一次。在我们的 Hotels 组件中,useEffect 有一个检查,只有在没有数据时才会获取酒店:

        // ...
        render(<Hotels />, { wrapper });
        expect(await screen.findByText('Loading...')
          ).toBeVisible();
    +   await waitFor(() => 
          expect(mockFunction).toHaveBeenCalledTimes(1));
      });
      // ...
    

通过这个测试,我们已经测试了 Hotels 组件中的上下文以及在该函数中获取酒店数据的 useEffect 钩子。

再次运行带有 --coverage 标志的测试后,我们将能够看到编写这个集成测试对我们覆盖率的影响。由于集成测试不仅测试一个特定的组件,而是同时测试多个组件,因此 Hotels 的测试覆盖率将得到更新。这个测试还覆盖了 HotelItem 组件,我们将在运行 npm run test --coverage 后在覆盖率报告中看到这一点:

---------------------------|---------|----------|---------|---------|
File                       | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
---------------------------|---------|----------|---------|---------|
All files                  |   19.83 |    29.03 |   16.67 |   19.83 |                   
 src                       |       0 |      100 |       0 |       0 |                   
  App.js                   |       0 |      100 |       0 |       0 | 
  index.js                 |       0 |      100 |     100 |       0 |             
 src/components/HotelItem  |     100 |      100 |     100 |     100 |
  HotelItem.js             |     100 |      100 |     100 |     100 |                   
 src/components/NavBar     |     100 |      100 |     100 |     100 |                   
  NavBar.js                |     100 |      100 |     100 |     100 |                   
 src/context               |    8.16 |        0 |       0 |    8.16 |                   
  AppContext.js            |       0 |      100 |       0 |       0 | 
HotelsContext.js         |   16.67 |        0 |       0 |   16.67 | 
  ReviewsContext.js        |       0 |        0 |       0 |       0 | 
 src/pages                 |   21.95 |    34.21 |      20 |   21.95 |
Hotels.js                |     100 |      100 |     100 |     100 | 

  // ...  

根据 Jest,Hotels 的覆盖率达到了 100%。HotelItems 的测试覆盖率也达到了 100%。这意味着如果我们只在这个 Hotels 组件中使用这个组件,我们可以跳过为 HotelItem 编写单元测试。我们的 HotelsContext 组件已经通过从 Hotels 组件测试它而获得了一小部分覆盖率。为了获得更高的覆盖率,我们可以测试更多,例如测试上下文本身或上下文中的 useReducer 钩子的行为。

在集成测试相对于单元测试的唯一缺点是,它们更难编写,因为它们通常包含更复杂的逻辑。此外,由于它们包含更多的逻辑并集成了多个组件,这些集成测试的运行速度会比单元测试慢。在本章的最后部分,我们将添加一个端到端测试,该测试将从用户的角度使用 Cypress 测试整个应用程序。

使用 Cypress 进行端到端测试

到目前为止,我们已经使用 React Testing Library 覆盖了单元测试和集成测试,该库在隔离环境中测试我们的代码。但为了确保我们的应用程序作为一个整体被测试,我们还可以编写端到端测试来覆盖应用程序的某些方面从开始到结束。端到端测试被认为比单元测试或集成测试编写和运行更耗时。建议您的项目中单元测试和集成测试的数量要多于端到端测试,并考虑您真正希望测试应用程序的哪些方面。

要添加端到端测试,我们将安装开源测试工具 Cypress,它可以用来编写和运行此类测试。将 Cypress 添加到项目需要几个步骤:

  1. 通过在终端中运行以下命令,在我们的项目中从 npm 安装库:

    npm install cypress --save-dev
    
  2. 完成安装后,需要将运行 Cypress 的脚本添加到 package.json 文件中,以便可以通过单个命令启动:

      // ... 
      "scripts": {
        "start": "react-scripts start",
        "build": "react-scripts build",
        "test": "react-scripts test",
    +   "cypress": "cypress open"
        "eject": "react-scripts eject"
      },
      // ...
    
  3. 您现在可以运行 npm run cypress 命令来启动 Cypress。请确保在终端的新标签页中这样做,因为您需要同时运行应用程序和 Cypress。如果您这是第一次运行 Cypress,它将验证是否能在您的系统上运行。一切顺利的话,Cypress 将打开并在我们的项目中创建一个名为 cypress 的新目录,如下面的截图所示:

图 6.2 – Cypress 首次运行

图 6.2 – Cypress 首次运行

  1. Cypress 在 cypress/integration/examples 目录中创建了示例测试,您可以使用这些测试来了解库的工作方式。否则,您可以删除这些测试,因为当我们添加新测试时,它们会 clutter Cypress 运行器。在 cypress/integration 目录中,我们可以为我们的项目添加一个名为 hotels.spec.js 的新端到端测试,其内容如下:

    describe('Cypress', () => {   
        it('opens the app', () => {   
            cy.visit('http://localhost:3000') 
        })
    })
    
  2. 您可以通过在 Cypress 运行器中点击它来运行此测试,之后测试将在浏览器中运行。它使用的浏览器取决于您在 Cypress 右上角选择的选项。此测试将在浏览器中打开应用程序并使用 Cypress 进行测试,输出如下:

图 6.3 – Cypress 在测试中渲染我们的应用程序

图 6.3 – Cypress 在测试中渲染我们的应用程序

图 6.3 – Cypress 在测试中渲染我们的应用程序

注意

您需要确保在您的终端中同时运行 Cypress 和应用程序。这意味着一个终端标签必须运行 npm run cypress,另一个运行 npm start。如果您没有运行应用程序,您将收到一个错误,表明网页不可用。

前面的测试将仅渲染我们的应用程序,而不进行任何断言。为了使用 Cypress 测试应用程序,我们需要使用库提供的任何断言。使用这些断言,我们将编写一个端到端测试,检查从访问应用程序到为酒店添加评论的整个流程。这样,我们就测试了应用程序中最关键的过程,即酒店评论。

首先,我们需要更改hotels.spec.js文件,使其打开应用程序并导航到酒店页面,然后在那个页面上点击按钮以打开表单。填写此表单后,我们希望测试提交表单并检查我们的评论是否已添加。按照以下步骤进行这些更改:

  1. 所有在hotels.spec.js文件中的测试都将从在浏览器中访问应用程序开始,以便它可以点击此页面上列出的任何酒店。点击酒店后,我们需要使用cy.location方法验证浏览器中的位置是否已更改:

      describe('Cypress', () => {
    -   it('opens the app', () => {   
    +   it('opens the app and clicks on a hotel', () => {
          cy.visit('http://localhost:3000');
    +     cy.get('a').first().click();
    +     cy.location('pathname').should('include',
            'hotel');
        });  
      });
    

运行此测试将验证您是否可以点击酒店并导航到正确的页面,您可以在 Cypress 运行器中检查。

注意

当您需要在不同的开发环境中访问多个 URL 时,您还可以在cypress.json文件中定义baseUrl

{

"id": "http://localhost:3000",

}

  1. 在第二个测试中,我们告诉 Cypress 找到带有+ Add Review文本的按钮并点击它,这将改变浏览器的位置到添加评论的页面。此页面位于/hotel/:hotelId/new路由,并包含new字符串。请注意,我们不再需要导航到应用程序,因为这个测试基于前面的测试,因此已经位于正确的页面:

      describe('Cypress', () => {
        // ...
    +   it('navigates to the form to add a review', () =>
          {
    +     cy.get('button').contains('+ Add 
            Review').click();
    +     cy.location('pathname').should('include',
            'new');
    +   });
    });
    
  2. 在前面的测试中,Cypress 将寻找包含特定单词的按钮,如果有人更改了按钮的内容,这并不是一个未来兼容的解决方案。在编写测试时决定使用哪个选择器(idclass或内容)非常重要。为了防止测试失败,您还可以向您的元素添加data-cydata-testdata-testid属性。因此,我们需要更改src/components/NavBarButton组件的渲染方式:

      // ...
      function NavBar({ goBack, title, openForm = false })
        {
        return (
          <NavBarWrapper>
            {goBack && <NavBarButton onClick={goBack}>{`< 
              Go Back`} </NavBarButton>}
            <Title>{title}</Title>
            {openForm && 
              <NavBarButton 
                onClick={openForm}
    +           data-cy='addReview'        
              >
               {`+ Add Review`}
              </NavBarButton>
            }
          </NavBarWrapper>
        );
      }
      export default NavBar;
    
  3. cypress/integrations/hotels.spec.js Cypress 测试文件中,我们可以寻找data-cy属性而不是使用按钮的内容作为选择器:

      describe('Cypress', () => {
        // ...
        it('navigates to the form to add a review', () => 
          {
    -     cy.get('button').contains('+ Add 
            Review').click();
    +     cy.get('[data-cy=addReview]').click();
          cy.location('pathname').should('include', 
            'new');
        });
      });
    
  4. 可以将一个用于填写表单以添加评论并提交的第三个测试也添加到该文件中。使用cy.get命令,Cypress 可以找到此页面上的form元素,并使用within方法在表单内查找input元素。它将搜索input字段的名称,向其中添加一个值,并最终提交表单内的button

      describe('Cypress', () => {
        // ...
    +   it('fills in and submits the form', () => {
    +     cy.get('form').within(() => {
    +       cy.get('input[name=title]').type('Test 
              review');
    +       cy.get('input[name=description]').type('Is a 
              test review by Cypress');
            cy.get('input[name=rating]').type(4);
            cy.get('button').click();
    +     });
    +   });
      });
    
  5. 最后,我们需要编写一个测试,再次检查酒店详情页面,并尝试找到我们添加的新评论。为了找到这个评论,我们需要在页面上搜索 Cypress 刚刚添加的评论内容;同时,我们还需要添加一个wait命令以确保评论已经被处理并显示在屏幕上:

      describe('Cypress', () => {
        // ...
    +   it('and verifies if the review is added', () => {
    +     cy.wait(600);
    +     cy.get('h3').contains('Test review');
    +     cy.get('div').contains('Is a test review by 
            Cypress');
    +   });
      });
    

通过向 Cypress 添加这个最后的测试,我们已经测试了我们应用程序最重要的场景,您可以通过添加测试边缘情况(如错误消息)来进一步扩展。

小贴士

我们没有为显示评论的元素添加data-cy属性,这是您可以自己添加的。由于我们了解我们刚刚添加的内容,因此可以安全地假设我们不需要一个复杂的选择器。

您可以添加更多功能,例如模拟 API 请求和响应,因为前面的测试正在使用与应用程序本身相同的 API。在这种情况下,这并没有什么问题,因为我们使用的 API 已经是一个模拟 API。如果您在生产环境中工作,您将希望用 Cypress 可以生成的模拟响应来替换它。

对于这一点,我们需要在我们的测试文件中添加一个beforeEach钩子,以拦截 API 调用并将响应替换为模拟值。这个模拟值的格式应该与实际 API 的格式相同。幸运的是,我们的 API 正在从您可以在本书仓库中找到的db.json文件中填充。从该文件的内容中,您可以提取酒店字段的数据,并将其粘贴到cypress/fixtures目录内的两个单独的文件中。让我们看看步骤:

  1. 第一个可以命名为hotels.json,需要有一个包含酒店详细信息的对象数组:

    [
      {
        "id": 1,
        "title": "Downtown Hotel (***)",
        "thumbnail": 
          "https://picsum.photos/id/369/400/400"
      }
    ]
    
  2. 第二个固定值需要有一个单独的对象,用于替换单个酒店的 API 请求,文件名为hotel.json

    {
      "id": 1,
      "title": "Downtown Hotel (***)",
      "thumbnail": "https://picsum.photos/id/369/400/400"
    }
    
  3. 通过在cypress/integrations/hotels.spec.js测试文件中添加一个beforeEach钩子和cy.intercept方法,可以拦截对实际 API 的调用。对于hotelshotels/*端点,它可以返回固定值,而对于reviews端点,可以返回一个空数组,因为 Cypress 将自行添加评论:

      describe('Cypress', () => {
    +   beforeEach(() => {
    +     cy.intercept('GET', 'PacktPublishing/
    React-Projects-Second-Edition/hotels', 
            { fixture: 'hotels.json' });
    +     cy.intercept('GET', 'PacktPublishing/
    React-Projects-Second-Edition/hotels/*', 
            { fixture: 'hotel.json' });
    +     cy.intercept('GET', 'PacktPublishing/
    React-Projects-Second-Edition/hotels/*/reviews', 
            []);
    +   })
        it('opens the app and clicks on a hotel', () => {
          // ...
    

通过打开 Cypress 运行器,您可以看到我们的测试现在正在使用固定值中的数据执行,因为 API 调用正在被拦截。

我们在本节中创建的测试为您编写 React 应用程序的端到端测试提供了一个良好的起点。此外,Cypress 还可以用于对您的应用程序进行视觉回归测试或测试 API 响应。

摘要

在本章中,我们介绍了使用 React Testing Library 和 Jest 对 React 应用程序进行测试。这两个包都是任何希望向其应用程序添加测试脚本的开发人员的绝佳资源,并且它们与 React 的工作非常出色。本章讨论了为您的应用程序编写测试的优势,并希望您现在知道如何向任何项目添加测试脚本。本章展示了单元测试和集成测试之间的区别,并且您还学习了如何使用 Cypress 编写端到端测试。

由于本章测试的应用程序结构与之前章节中的应用程序相同,因此相同的测试原则可以应用于本书中我们构建的任何应用程序。

下一章将结合本书中我们已经使用过的许多模式和库,因为我们将会使用 React、GraphQL 和 Apollo 创建一个全栈电子商务商店。

进一步阅读

更多信息,请参考以下链接:

第七章:使用 Next.js 和 GraphQL 构建全栈电商应用

如果你正在阅读此内容,这意味着你已经到达了本书的最后一章,该章节专注于使用 React 构建网络应用。在前面的章节中,你已经使用了 React 的核心功能,例如渲染组件、使用 Context 和 Hooks 进行状态管理。你已经学习了如何为你的 React 应用添加路由或使用 Next.js 进行 SSR。此外,你还知道如何使用 Jest 和 Enzyme 为 React 应用添加测试。让我们通过添加 GraphQL 到你迄今为止所学的内容列表中,使这次体验成为全栈的。

在本章中,你不仅将构建应用的前端,还将构建后端。为此,我们将使用 GraphQL,它最好被定义为一个针对 API 的查询语言。使用模拟数据,你将在 Next.js 中创建一个 GraphQL 服务器,该服务器为你的 React 应用提供了一个端点。在前端方面,这个端点将通过 Apollo Client 进行消费,它帮助你处理向服务器发送请求以及对此数据的状态管理。

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

  • 使用 Next.js 创建 GraphQL 服务器

  • 使用 Apollo Client 消费 GraphQL

  • 在 GraphQL 中处理身份验证

项目概述

在本章中,我们将使用 Next.js 创建一个全栈电商应用,该应用的后端是一个 GraphQL 服务器,并通过 Apollo Client 在 React 中消费这个服务器。对于前端,有一个初始应用可供快速入门。

构建时间为 3 小时。

入门

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

初始项目基于 Next.js 的样板应用,旨在快速入门。此应用需要安装几个依赖项,你可以通过运行以下命令来完成:

npm install && npm run dev

此命令将安装运行 Next.js 上的 React 应用所需的全部依赖,例如 reactnextstyled-components。一旦安装过程完成,GraphQL 服务器和 React 应用都将启动。

使用初始 React 应用入门

由于该 React 应用程序是用 Next.js 创建的,因此可以使用 npm run dev 启动,并在 http://localhost:3000/ 上可用。这个初始应用程序不显示任何数据,因为它还需要连接到 GraphQL 服务器,您将在本章的后面完成这项工作。因此,此时应用程序将仅渲染一个标题为 E-Commerce Store 的页眉以及一个副标题,看起来大致如下:

:

图 7.1 – 初始应用程序

图 7.1 – 初始应用程序

使用 Next.js 构建的此初始 React 应用程序的结构如下:

chapter-7-initial 
  |- /node_modules 
  |- /public 
  |- /pages 
     |- /api 
        |- /hello.js 
     |- /products 
        |- /index.js 
     |- /cart 
        |- /index.js 
     |- /login 
        |- /index.js 
     |- _app.js 
     |- index.js 
  |- /utils 
     |- hooks.js 
     |- authentication.js
  package.json 

pages 目录中,您可以找到此应用程序的所有路由。路由 /pages/index.js 渲染,而路由 /cart/login/products 由相应目录中的 .js 文件渲染。所有路由都将包含在 pages/_app.js 中。在这个文件中,构建了所有页面的页眉。所有路由也将包含一个 SubHeader 组件,以及一个 Button 用于返回上一页或一个 Button 用于 Cart 组件。utils 目录包含两个文件,其中包含您在本章后面需要使用的方法。此外,此应用程序将在 http://localhost:3000/api/hello 下提供一个 REST 端点,该端点来自 pages/api/hello.js 文件。

使用 React、Apollo 和 GraphQL 构建全栈电子商务应用程序

在本节中,您将连接 React 网络应用程序到 GraphQL 服务器。Next.js API 路由上的 GraphQL 服务器用于创建一个使用动态模拟数据作为源的单一 GraphQL 端点。React 使用 Apollo Client 消费此端点并处理应用程序的状态管理。

使用 Next.js 创建 GraphQL 服务器

第三章 中,构建动态项目管理板,我们已经使用 Next.js 创建了一个 React 应用程序,其中已经提到您也可以用它来创建 API 端点。通过查看本章目录中的文件,您可以看到 pages 目录中有一个名为 api 的目录,其中包含一个名为 hello.js 的文件。您在 pages 目录中创建的所有目录和文件都将作为浏览器中的路由可用,但如果您在 pages 目录下的 api 目录中创建它们,它们被称为 API 路由。hello.js 文件就是这样一条 API 路由,它位于 http://localhost:3000/api/hello 下。此端点返回一个包含以下内容的 JSON 块:

{"name":"John Doe"}

这是一个 REST 端点,我们也在本书的前几章中进行了探索。在本章中,我们将使用 GraphQL 端点,因为 GraphQL 是 Web 和移动应用程序使用的 API 的流行格式。

GraphQL 最好描述为 API 的查询语言,它被定义为从 API 检索数据的一种约定。通常,GraphQL API 与 RESTful API 相比较,后者是发送依赖于多个端点的 HTTP 请求的一种知名约定,这些端点将返回各自的数据集合。与知名的 RESTful API 相反,GraphQL API 将提供一个单一端点,允许您查询和/或突变数据源,如数据库。您可以通过向 GraphQL 服务器发送包含查询或突变操作的文档来查询或突变数据。无论什么数据可用,都可以在 GraphQL 服务器的模式中找到,该模式由定义可以查询或突变的数据的类型组成。

在创建 GraphQL 端点之前,我们需要在 Next.js 中设置服务器。因此,我们需要安装以下依赖项,这些依赖项是设置所必需的:

npm install graphql @graphql-tools/schema @graphql-tools/mock express-graphql

在我们的应用程序中使用 GraphQL 需要graphql库,而express-graphql是 Node.js 的 GraphQL 服务器的一个小型实现。@graphql-tools/schema@graphql-tools/mock都是开源库,可以帮助您创建 GraphQL 服务器。我们还可以删除pages/api/hello.js文件,因为我们不会使用这个 API 路由。

要设置 GraphQL 服务器,我们必须创建一个新的文件,pages/api/graphql/index.js,它将包含我们应用程序的单个 GraphQL 端点。我们需要导入graphqlHTTP来创建服务器。GraphQL 服务器的模式是在名为typeDefs的变量下编写的:

import { graphqlHTTP } from 'express-graphql'; 
import { makeExecutableSchema } from '@graphql-tools/schema'; 
import { addMocksToSchema } from '@graphql-tools/mock';
const typeDefs = /* GraphQL */ ` 
  type Product { 
    id: Int! 
    title: String! 
    thumbnail: String! 
    price: Float 
  } 
  type Query {
    product: Product
    products(limit: Int): [Product]
  }
`;

在模式下方,我们可以使用graphqlHTTP实例启动 GraphQL 服务器,并将模式传递给它。我们还配置服务器为我们的模式中的所有值创建模拟。在文件底部,我们返回handler,这是 Next.js 用来在路由http://localhost:3000/api/graphql上使 GraphQL 服务器可用的:

// ...
const executableSchema = addMocksToSchema({ 
  schema: makeExecutableSchema({ typeDefs, }), 
}); 
function runMiddleware(req, res, fn) { 
  return new Promise((resolve, reject) => { 
    fn(req, res, (result) => { 
      if (result instanceof Error) { 
        return reject(result); 
      } 
      return resolve(result); 
    }); 
  }); 
} 
async function handler(req, res) { 
  const result = await runMiddleware( 
    req, 
    res, 
    graphqlHTTP({ 
      schema: executableSchema, 
      graphiql: true, 
    }), 
  ); 
  res.json(result); 
} 
export default handler;

确保再次运行应用程序后,GraphQL API 在http://localhost:3000/api/graphql上可用。在浏览器页面上,GraphiQL 游乐场将显示,这里您可以使用和探索 GraphQL 服务器。

使用这个游乐场,您可以向 GraphQL 服务器发送查询和突变,这些可以在页面的左侧键入。您可以发送的查询和突变可以在该 GraphQL 服务器的文档中找到,您可以通过点击标记为文档的绿色按钮来查找。此按钮将打开一个概述,其中包含 GraphQL 服务器所有可能的返回值。

图 7.2 – 使用 GraphiQL 游乐场

图 7.2 – 使用 GraphiQL 游乐场

当您在此页面的左侧描述查询或突变时,服务器返回的输出将在演示场的右侧显示。GraphQL 查询的构建方式将决定返回数据的结构,因为 GraphQL 遵循 需要什么,就得到什么 的原则。由于 GraphQL 查询总是返回可预测的结果,我们可以有一个如下所示的查询:

query {
  products {
    id
    title
    price
  }
}

这将返回一个输出,其结构将与您发送到 GraphQL 服务器的文档中定义的查询结构相同。将此文档与查询一起发送到 GraphQL 服务器将返回一个对象数组,其中包含产品信息,默认情况下限制为 10 个产品。结果将以 JSON 格式返回,并且每次发送请求时都会包含不同的产品,因为数据是由 GraphQL 服务器模拟的。响应格式如下:

{
  "data": {
    "products": [
      {
        "id": 85,
        "title": "Hello World",
        "price": 35.610056991945214
      },
      {
        "id": 24,
        "title": "Hello World",
        "price": 89.47561381959673
      }
    ]
  }
}

使用 GraphQL 的应用程序通常快速且稳定,因为它们控制着获取的数据,而不是服务器。使用 GraphQL,我们还可以在我们的数据中创建某些字段之间的关系,例如,通过在我们的产品中添加一个类别字段。这是通过在 pages/api/graphql/index.js 中的 GraphQL 模式中添加以下内容来完成的:

// ...
  const typeDefs = `
    type Product {
      id: Int!
      title: String!
      thumbnail: String!
      price: Float
+     category: Category
    }
+   type Category {
+     id: Int!
+     title: String!
+   }
    type Query {
      product: Product
      products(limit: Int): [Product]
    }
  `;
  // ...

我们还可以通过将其添加到模式中来添加对 type Category 的查询:

// ...
  const typeDefs = `
    // ...
    type Category {
      id: Int!
      title: String!
    }
    type Query {
      product: Product
      products(limit: Int): [Product]
+     categories: [Category]
    }
  `;
  // ...

产品现在将有一个名为 category 的新字段,但您也可以单独查询类别列表。由于 GraphQL 服务器的所有数据目前都是模拟的,您不需要连接一个提供类别信息的数据库。但我们可以指定某些字段应该如何模拟,例如,通过为我们的产品添加缩略图。因此,我们需要创建一个名为 mocks 的变量,将 Product 类型的字段缩略图设置为指向 picsum.photos 的 URL。这是一个用于实时生成模拟图像的免费服务器:

  // ...
+ const mocks = {
+     Product: () => ({
+       thumbnail: () => 'https://picsum.photos/400/400'
+     }),
+   };
   const executableSchema = addMocksToSchema({ 
schema: makeExecutableSchema({ typeDefs, }), 
+   mocks, 
  });
  // ...

除了在 Product 类型上模拟 thumbnail 字段外,我们还想模拟所有具有 IntFloat 类型的字段值。这两个字段现在通常是负值,这对其用作标识符或价格是不正确的。Int 类型用于定义标识符,而 Float 类型用于价格。我们也可以通过添加以下内容来模拟这些字段:

  // ...
  const mocks = {
+   Int: () => Math.floor(Math.random() * 99) + 1,
+   Float: () => (Math.random() * 99.0 + 1.0).toFixed(2),
    Product: () => ({
      thumbnail: () => 'https://picsum.photos/400/400'
    }),
  };

    // ...

您可以通过尝试以下查询来检查此操作,该查询还请求产品的类别和缩略图:

query {
  products {
    id
    title
    price
    thumbnail
    category {
      id
      title
    }
  }
}

您可以将前面的查询插入到 GraphQL 演示场中,以获取响应,其外观将类似于以下截图:

图 7.3 – 向 GraphQL 服务器发送查询

图 7.3 – 向 GraphQL 服务器发送查询

由于 GraphQL 服务器模拟了数据,因此每次您使用此查询发送新的请求时,值都会发生变化。但您可以通过在 HTTP 请求的正文发送查询来获得相同的响应,无论是从命令行还是从使用fetch的 React 应用程序。

您还可以使用像 Apollo Client 这样的库来使这个过程更加直观。这将在本章的下一节中解释,您将使用 Apollo 将 GraphQL 服务器连接到 React Web 应用程序,并从您的应用程序向服务器发送文档。

使用 Apollo Client 消费 GraphQL

在设置好 GraphQL 服务器后,让我们继续到从 React 应用程序向该服务器发送请求的部分。为此,您将使用 Apollo 包,这些包可以帮助您在应用程序和服务器之间添加一个抽象层。这样,您就不必担心自己使用例如fetch这样的方法将文档发送到 GraphQL 端点,可以直接从组件中发送文档。

设置 Apollo Client

如我们之前提到的,您可以使用 Apollo 连接到 GraphQL 服务器;为此,将使用 Apollo Client。使用 Apollo Client,您可以设置与服务器的连接,处理查询和突变,并为从 GraphQL 服务器检索的数据启用缓存,以及其他功能。您可以通过以下步骤将 Apollo Client 添加到应用程序中:

  1. 要安装 Apollo Client 及其相关包,您需要从 React 应用程序初始化的client目录中运行以下命令:

    npm install @apollo/client
    

这将安装 Apollo Client 以及您在 React 应用程序中使用 Apollo Client 和 GraphQL 所需的其他依赖项。

注意

通常,在安装 Apollo Client 时,我们还需要安装graphql,但这个库已经存在于我们的应用程序中。

  1. 这些包应该导入到您想要创建包含与 GraphQL 服务器连接的 Apollo Provider 的pages/_app.js文件中:

      import { createGlobalStyle } from 
        'styled-components';
    + import {
    +   ApolloClient,
    +   InMemoryCache,
    +   ApolloProvider,
    + } from "@apollo/client";
      import Header from '../components/Header';
      const GlobalStyle = createGlobalStyle` 
        // ...
    
  2. 现在,您可以使用ApolloClient类来定义client常量,并将本地 GraphQL 服务器的位置传递给它:

      // ...
    + const client = new ApolloClient({
    +   uri: 'http://localhost:3000/api/graphql/',
    +   cache: new InMemoryCache()
    + });
      function MyApp({ Component, pageProps }) {
        return (
          // ...
    
  3. MyApp组件的return函数中,您需要添加ApolloProvider并将您刚刚创建的client作为属性传递:

      // ...
      function MyApp({ Component, pageProps }) {
        return (
    -     <>
    +     <ApolloProvider client={client}>
            <GlobalStyle />
            <Header />
            <Component {...pageProps} />
    +     </ApolloProvider>
    -     </>
        );
      }
      export default MyApp;
    

在这些步骤之后,所有嵌套在ApolloProvider内的组件都可以访问此client,并通过查询和/或突变将文档发送到 GraphQL 服务器。在 Next.js 中,所有页面组件都是基于路由在Component下渲染的。从ApolloProvider获取数据的方法与我们之前使用的上下文 API 类似。

使用 React 发送 GraphQL 查询

Apollo Client 不仅导出了一个 Provider,还导出了从该 Provider 中消耗值的方法。这样,你可以轻松地使用添加到 Provider 中的客户端获取任何值。其中一种方法是Query,它可以帮助你发送一个包含查询的文档到 GraphQL 服务器,而无需使用fetch函数,例如。

由于Query组件应该始终嵌套在ApolloProvider组件内部,它们可以放置在App中渲染的任何组件中。其中之一是pages/product/index.js中的Products组件。该组件正在为/路由渲染,应显示电子商务店中可用的产品。

要从Products组件发送文档,请按照以下步骤操作,这将指导你使用react-apollo发送文档的过程:

  1. Products页面组件中,你可以从@apollo/client导入useQuery钩子,并为命名查询getProducts定义一个常量。此外,你需要导入gql,以便在你的 React 文件中使用 GraphQL 查询语言,如下所示:

      import styled from 'styled-components';
    + import { useQuery, gql } from '@apollo/client';
      import SubHeader from '../../components/SubHeader';
      import ProductItem from 
        '../../components/ProductItem';
      // ...
    + const GET_PRODUCTS = gql`
    +   query getProducts {
    +     products {
    +       id
    +       title
    +       price
    +       thumbnail
    +     }
    +   }
    + `;
      function Products() {
        // ...
    
  2. Products组件中导入的useQuery钩子可以调用并处理基于传递给它的查询的数据获取过程。与上下文 API 类似,useQuery可以通过返回一个data变量来从 Provider 中消耗数据。你可以遍历此对象中的products字段,并返回已导入此文件的ProductItem组件列表。此外,还会返回一个loading变量,当 GraphQL 服务器尚未返回数据时,该变量将为true

      // ...
      function Products() {
    +   const { loading, data } = useQuery(GET_PRODUCTS);
        return (
          <>
            <SubHeader title='Available products' goToCart />
    +       {loading ? (
    +         <span>Loading...</span>
    +       ) : (
              <ProductItemsWrapper>
    +           {data && data.products && 
                 data.products.map((product) => (
    +               <ProductItem key={product.id} 
                      data={product} />
    +           ))}
              </ProductItemsWrapper>
    +       )}
                </>
        );
      };
      export default Products;
    

这将在你的应用程序挂载时发送一个包含GET_PRODUCTS查询的文档到 GraphQL 服务器,并随后在ProductItem组件列表中显示产品信息。在添加从 GraphQL 服务器检索产品信息的逻辑后,你的应用程序将类似于以下所示:

![图 7.4 – 从 GraphQL 渲染产品图 7.4_B17390.jpg

图 7.4 – 从 GraphQL 渲染产品

通过点击此页面的右上角按钮,你将导航到/cart路由,该路由也需要从 GraphQL 服务器查询数据。由于我们还没有检索购物车的查询,我们需要将其添加到pages/api/graphql/index.js中的 GraphQL 服务器。

  1. 由于 GraphQL 服务器没有连接的数据源,我们可以使用let创建一个可变变量。这是一个我们希望稍后更新的对象,例如,当我们向购物车添加产品时:

      import { graphqlHTTP } from 'express-graphql';
      import { makeExecutableSchema } 
        from '@graphql-tools/schema'; 
      import { addMocksToSchema } from '@graphql-tools/mock';
    + let cart = {
    +   count: 0,
    +   products: [],
    +   complete: false,
    + };
      const typeDefs = `
        // ... 
    
  2. 在模式中,我们需要为Cart定义一个类型,并将此类型添加到我们 GraphQL 服务器的查询列表中:

      // ...
      const typeDefs = `
        // ...
    +   type Cart {
    +     count: Int
    +     products: [Product]
    +     complete: Boolean
    +   }
        type Query {
          product: Product
          products(limit: Int): [Product]
          categories: [Category]
    +     cart: Cart
        }
      `;
      const mocks = {
        // ...
    
  3. pages/cart/index.js文件中,已经导入了用于在购物车中渲染产品的组件。我们确实需要从@apollo/client导入useQuery钩子和gql,并创建查询常量:

      import styled from 'styled-components';
    + import { useQuery, gql } from '@apollo/client';
      import { usePrice } from '../../utils/hooks';
      import SubHeader from '../../components/SubHeader';
      import ProductItem from 
        '../../components/ProductItem';
      import Button from '../../components/Button';
      // ...
    + const GET_CART = gql`
    +   query getCart {
    +     cart {
    +       products {
    +         id
    +         title
    +         price
    +         thumbnail
    +       }
    +     }
    +   }
    + `;
      function Cart() {
        // ...
    
  4. Cart 组件中,我们需要使用 useQuery 钩子获取我们想要显示的数据。在获取数据后,我们可以返回一个列表,其中包含添加到购物车中的产品以及结账按钮:

      // ...
      function Cart() {
    +   const { loading, data } = useQuery(GET_CART);
        return (
          <>
            <SubHeader title='Cart' />
    +       {loading ? (
    +         <span>Loading...</span>
    +       ) : (
              <CartWrapper>
                <CartItemsWrapper>
    +             {data && data.cart.products &&
                   data.cart.products.map((product) => (
    +                <ProductItem key={product.id} 
                       data={product} />
    +              ))}
                </CartItemsWrapper>
    +           {data && data.cart.products.length > 
                  0 && (
    +             <Button backgroundColor='royalBlue'>
                    Checkout
                  </Button>
    +           )}
              </CartWrapper>
    +       )}
          </>
        );
      };
    export default Cart;
    
  5. 由于购物车为空,这不会显示任何产品;在下一节中,购物车将被产品填充。然而,让我们通过将 useQuery 钩子添加到导航到 SubHeader 中按钮中,继续操作,SubHeader 在除 /cart 本身以外的路由上渲染。可以在 components 目录中创建一个名为 CartButton.js 的新文件。在这个文件中,useQuery 钩子将返回一个查询的数据,该查询请求购物车中产品的总数。此外,我们还可以通过向此文件添加以下代码来向 Button 组件添加一个值:

    import { useQuery, gql } from '@apollo/client';
    import Button from './Button';
    export const GET_CART_TOTAL = gql`
      query getCart {
        cart {
          count
        }
      }
    `;
    function CartButton({ ...props }) {
      const { loading, data } = useQuery(GET_CART_TOTAL);
      return (
        <Button {...props}>
          {loading ? 'Cart' : `Cart (${data.cart.count})`}
        </Button>
      );
    }
    export default CartButton;
    
  6. 这个 CartButton 组件替换了 Button,现在在 components/SubHeader.js 文件中以购物车中产品数量的占位符形式显示:

      import styled from 'styled-components';
      import { useRouter } from 'next/router';
    - import Button from './Button';
    + import CartButton from './CartButton';
      // ...
      function SubHeader({ title, goToCart = false }) {
        const router = useRouter();
        return (
          <SubHeaderWrapper>
            // ...
            {goToCart && (
    -         <Button onClick={() => 
                router.push('/cart')}>
    -           Cart (0)
    -         </Button>
    +           <CartButton onClick={() => 
                  router.push('/cart')} />
            )}
          </SubHeaderWrapper>
        );
      }
      export default SubHeader;
    

在将显示产品或购物车信息的组件连接到 GraphQL 服务器后,你可以通过添加将产品添加到购物车的变更来继续操作。如何在应用程序中添加变更以及如何将文档容器变更发送到 GraphQL 服务器将在本节下一部分中展示。

处理 GraphQL 中的变更

变更数据使得使用 GraphQL 更有趣,因为当数据被变更时,应该执行一些副作用。例如,当用户将产品添加到他们的购物车时,购物车的数据应该在整个组件中更新。当你使用 Apollo Client 时,这相当简单,因为 Provider 以与上下文 API 相同的方式处理这一点。

现在的 GraphQL 服务器只有查询,还没有操作。添加变更与之前我们添加查询到模式的方式类似,但对于变更,我们还需要添加解析器。解析器是 GraphQL 中的魔法所在,也是模式与获取数据逻辑(可能来自数据源)链接的地方。变更的添加是在 pages/api/graphql/index.js 文件中完成的。

  1. 第一步是将添加产品到购物车的变更添加到模式中。此变更以 productId 作为参数。此外,我们还需要在稍后模拟一个类型列表:

       // ...
      const typeDefs =`
      // ...
      const typeDefs = gql`
       // ...
        type Cart {
          total: Float
          count: Int
          products: [Product]
          complete: Boolean
        }
        type Query {
          product: Product
          products(limit: Int): [Product]
          categories: [Category]
          cart: Cart
        }
    +   type Mutation {
    +     addToCart(productId: Int!): Cart
    +   }
      `;
      const mocks = {
        // ...
    
  2. 到目前为止,我们模式中的所有值都是由 GraphQLServer 模拟的,但通常你会在模式中的每个类型上添加解析器。这些解析器将包含从数据源获取数据的逻辑。由于我们希望将 Cart 类型的值存储在此文件顶部创建的 cart 对象中,因此我们需要为 addToCart 变更添加一个解析器:

      // ...
    + const resolvers = {
    +   Mutation: {
    +     addToCart: (_, { productId }) => {
    +       cart = {
    +         ...cart,
    +         count: cart.count + 1,
    +         products: [ 
    +           ...cart.products, 
    +           { 
    +             productId, 
    +             title: 'My product', 
    +             thumbnail: 'https://picsum.photos/400/400', 
    +             price: (Math.random() * 99.0 + 1.0).
    toFixed(2), 
    +             category: null, 
    +           }, 
    +         ],
    +       };
    +       return cart;
    +     },
    +   },
    + };
      const executableSchema = addMocksToSchema({
        // ...
    
  3. 在创建 graphqlHTTP 实例时,我们需要传递我们为其创建的解析器,以便我们的更改生效:

      // ...
        const executableSchema = addMocksToSchema({ 
        schema: makeExecutableSchema({ typeDefs, }),
        mocks, 
    +   resolvers,
      });
      // ...
      export default handler;
    

您可以通过在http://localhost:3000/api/graphql可用的 GraphQL playground 中尝试此突变来测试它。在这里,您需要在页面左上角的框中添加突变。您想要包含在此突变中的productId变量必须放置在页面左下角的框中,称为查询变量。这将产生以下输出:

图 7.5 – 在 GraphiQL playground 中使用突变

图 7.5 – 在 GraphiQL playground 中使用突变

每次您使用此突变将文档发送到 GraphQL 服务器时,列表中都会添加一个新的产品。此外,count字段将增加1。但是,当您想要使用Cart类型的查询检索此信息时,值仍然将由 GraphQL 服务器模拟。为了返回cart对象,我们还需要为获取购物车信息的查询添加一个解析器:

  // ...
  const resolvers = {
+   Query: {
+     cart: () => cart,
+   },
    Mutation: {
      // ...
    },
  };
    const executableSchema = addMocksToSchema({
    // ...

使用addToCart突变返回的响应将反映您可以使用购物车查询检索的内容。

为了能够从我们的 React 应用程序中使用此突变,我们需要进行以下更改:

  1. 目前,还没有按钮可以将产品添加到购物车中,因此您可以在components目录中创建一个新文件,并命名为AddToCartButton.js。在这个文件中,您可以添加以下代码:

    import { useMutation, gql } from '@apollo/client';
    import Button from './Button';
    const ADD_TO_CART = gql`
      mutation addToCart($productId: Int!) {
        addToCart(productId: $productId) {
          count
          products {
            id
            title
            price
          }
        }
      }
    `;
    function AddToCartButton({ productId }) {
      const [addToCart, { data }] = 
        useMutation(ADD_TO_CART);
      return (
        <Button
          onClick={() =>
            !data && addToCart({ variables: { productId } })
          }
        >
          {data ? 'Added to cart!' : 'Add to cart'}
        </Button>
      );
    }
    export default AddToCartButton;
    

这个新的AddToCartButtonproductId作为属性,并使用来自@apollo/clientuseMutation Hook,该 Hook 使用我们之前创建的突变。Mutation的输出是调用此突变的实际函数,该函数接受一个包含输入参数的对象作为参数。点击Button组件将执行突变并将productId传递给它。

  1. 此按钮应显示在//products路由上的产品列表旁边,其中每个产品都通过ProductItem组件显示。这意味着您需要在components/ProductItem.js中导入AddCartButton,并通过以下代码向其传递一个productId属性:

      import styled from 'styled-components';
      import { usePrice } from '../utils/hooks';
    + import AddToCartButton from './AddToCartButton';
      // ...
      function ProductItem({ data }) {
        const price = usePrice(data.price);
        return (
          <ProductItemWrapper>
            {data.thumbnail && <Thumbnail 
              src={data.thumbnail} width={200} />}
            <Title>{data.title}</Title>
            <Price>{price}</Price>
    +       <AddToCartButton productId={data.id} />
          </ProductItemWrapper>
        );
      }
      export default ProductItem;
    

现在,当您在浏览器中打开 React 应用程序时,将在产品标题旁边显示一个按钮。如果您点击此按钮,突变将被发送到 GraphQL 服务器,产品将被添加到购物车中。然而,您不会看到显示SubHeader组件的按钮有任何变化。

  1. 在发送突变后执行此查询可以通过在components/AddToCartButton.js中的useMutation Hook 的refetchQueries选项中设置值来完成。此选项接受一个包含应请求的查询信息的对象数组。在这种情况下,它仅是GET_CART_TOTAL查询,由CartButton执行。为此,进行以下更改:

      import { useMutation, gql } from '@apollo/client';
      import Button from './Button';
    + import { GET_CART_TOTAL } from './CartButton';
      // ...
      function AddToCartButton({ productId }) {
        const [addToCart, { data }] = 
          useMutation(ADD_TO_CART);
        return (
          <Button
            onClick={() =>
              !data && addToCart({
                variables: { productId },
    +           refetchQueries: 
                  [{ query: GET_CART_TOTAL }],
              })
            }
          >
            {data ? 'Added to cart!' : 'Add to cart'}
          </Button>
        );
      }
    export default AddToCartButton;
    
  2. 当你点击 CartButton 时,我们将导航到 /cart 路由,在这里显示我们购物车中的产品。在这里,AddToCartButton 也会被渲染,因为这是在 ProductItem 组件中定义的。让我们通过访问 components/ProductItem.js 文件并添加以下代码行来更改这一点,这将条件性地渲染此按钮:

      // ...
    - function ProductItem({ data }) {
    + function ProductItem({ data, addToCart = false }) {
        const price = usePrice(data.price);
        return (
          <ProductItemWrapper>
            {data.thumbnail && <Thumbnail 
              src={data.thumbnail} width={200} />}
            <Title>{data.title}</Title>
            <Price>{price}</Price>
    -       <AddToCartButton productId={data.id} />
    +       {addToCart && <AddToCartButton 
              productId={data.id} />}
          </ProductItemWrapper>
        );
      }
      export default ProductItem;
    
  3. Products 页面组件中,我们需要传递 addToCart 属性来渲染此页面上的按钮:

        // ...
        return (
          <>
            <SubHeader title='Available products' goToCart 
            />
            {loading ? (
              <span>Loading...</span>
            ) : (
              <ProductItemsWrapper>
                {data && data.products && 
                  data.products.map((product) => (
                  <ProductItem 
                    key={product.id} 
                    data={product} 
    +               addToCart
                  />
                ))}
              </ProductItemsWrapper>
            )}
          </>
        );
      };
      export default Products;
    

现在,每次你从这个组件向 GraphQL 服务器发送文档突变时,都会发送 GET_CART_TOTAL 查询。如果结果已更改,CartButtonCart 组件将使用这个新的输出进行渲染。因此,CartButton 组件将被更新以显示 AddToCartButton 组件:

![图 7.6 – 更新购物车中的产品图 7.6 – 更新购物车中的产品

图 7.6 – 更新购物车中的产品

在本节中,我们学习了如何设置 Apollo 客户端并使用它向 GraphQL 服务器发送文档。在本书的下一节中,我们将通过处理身份验证来扩展这一点。

在 GraphQL 中处理身份验证

到目前为止,我们已经创建了一个可以被使用 Next.js 和 React 构建的应用程序消费的 GraphQL 服务器。通过查询和突变,我们可以查看产品列表并将它们添加到购物车中。但我们还未添加检查购物车的逻辑,这将在本节中完成。

当用户将产品添加到购物车后,你希望他们能够进行结账;但在那之前,用户应该进行身份验证,因为你想要知道谁在购买该产品。

对于前端应用程序中的身份验证,大多数情况下使用 JSON Web Tokens(JWTs),这些是加密的令牌,可以轻松地用于与后端共享用户信息。JWT 将在用户成功认证后由后端返回,并且通常,此令牌将有一个过期日期。对于用户应该进行身份验证的每个请求,都应该发送此令牌,以便后端服务器可以确定用户是否已认证并且允许执行此操作。尽管 JWT 可以用于身份验证,因为它们是加密的,但不应将任何私人信息添加到其中,因为令牌仅应用于认证用户。只有当发送了包含正确 JWT 的文档时,服务器才能发送私人信息。

在我们能够将结账过程添加到 React 应用程序之前,我们需要使客户能够进行身份验证。这包括多个步骤:

  1. 我们需要在模式中创建一个新的类型,该类型定义了用户和用于登录用户的突变,我们可以在 pages/api/graphql/index.js 中完成:

      // ...
      const typeDefs = `
        // ... 
    +   type User {
    +     username: String!
    +     token: String!
    +   }
        type Query {
          product: Product
          products(limit: Int): [Product]
          categories: [Category]
          cart: Cart
        }
        type Mutation {
          addToCart(productId: Int!): Cart 
    +     loginUser(username: String!, password: String!):
            User
        }
      `;
      // ...
    
  2. 在模式中定义突变后,可以将其添加到解析器中。在 utils/authentication.js 文件中,已经存在一个用于检查 usernamepassword 组合的方法。如果这个组合是正确的,该方法将返回一个有效的令牌以及用户名。从这个文件中,我们还导入了一个用于检查令牌是否有效的方法:

      import { graphqlHTTP } from 'express-graphql'; 
      import { makeExecutableSchema } 
        from '@graphql-tools/schema'; 
      import { addMocksToSchema } from '@graphql-tools/mock';
    + import { loginUser, isTokenValid } 
        from '../../../utils/authentication';
    
      // ...
      const resolvers = {
        Query: {
          cart: () => cart,
        },
        Mutation: {
    +     loginUser: async (_, { username, password }) => 
          {
    +       const user = loginUser(username, password);
    +       if (user) {
    +         return user;
    +       }
    +     },
          // ...
    

从 GraphiQL 游乐场,我们现在可以通过输入用户名 test 和密码 test 来检查这个突变是否工作:

图 7.7 – 使用 GraphQL 创建 JWT

图 7.7 – 使用 GraphQL 创建 JWT

  1. pages/login/index.js 文件中,我们可以添加逻辑来使用表单的输入来发送包含 loginUser 变化的文档到 GraphQL 服务器。Login 页面组件已经使用 useState 钩子来控制 usernamepassword 输入字段的值。可以从 @apollo/client 导入 useMutation 钩子:

      import { useState } from 'react';
    + import { useMutation, gql } from '@apollo/client';
      // ...
    + const LOGIN_USER = gql`
    +   mutation loginUser($username: String!, $password:
          String!) {
    +     loginUser(username: $username, 
            password: $password) {
    +       username
    +       token
    +     }
    +   }
    + `;
      function Login() {
        const [username, setUsername] = useState('');
        const [password, setPassword] = useState('');
    +   const [loginUser, { data }] = 
          useMutation(LOGIN_USER);
        return (
          // ...
    
  2. 在创建 loginUser 函数后,我们可以将其添加到 form 元素的 onSubmit 事件中,并将 usernamepassword 的值作为变量传递给此函数:

      // ...
      function Login() {
        // ...
        return (
          <>
            <SubHeader title='Login' />
            <FormWrapper>
              <form
    +           onSubmit={(e) => {
    +             e.preventDefault();
    +             loginUser({ variables: { username, 
                    password } });
    +           }}
              >
                // ...
    
  3. 点击 Button 将会发送包含 usernamepassword 值的文档到 GraphQL 服务器,如果成功,它将返回该用户的 JWT。此令牌也应存储在会话存储中,以便以后使用。此外,我们希望在用户登录后将其重定向回主页。为此,我们需要从 React 导入一个 useEffect 钩子来监视数据的变化。当令牌存在时,我们可以使用从 useRouter 钩子获得的 router 对象,我们需要从 Next.js 导入这个钩子:

    - import { useState } from 'react';
    + import { useState, useEffect } from 'react';
      import { useMutation, gql } from '@apollo/client';
    + import { useRouter } from 'next/router';
      // ...
      function Login() {
        const [username, setUsername] = useState('');
        const [password, setPassword] = useState('');
        const [loginUser, { data }] = 
          useMutation(LOGIN_USER);
    +   const router = useRouter();
    +   useEffect(() => {
    +     if (data && data.loginUser && 
            data.loginUser.token) {
    +       sessionStorage.setItem('token',
              data.loginUser.token);
    +       router.push('/');
    +     }
    +   }, [data]);
      return (
        // ...
    
  4. 每次客户通过 /login 路由登录时,令牌都会存储在浏览器中的会话存储中。您可以通过访问 Bearer 来从会话存储中删除令牌,因为这就是 JWT 被识别的方式。这需要我们对 pages/_app.js 进行多次修改:

      import { createGlobalStyle } from 
        'styled-components';
      import {
        ApolloClient,
        InMemoryCache,
        ApolloProvider,
    +   createHttpLink,
      } from '@apollo/client';
    + import { setContext } from 
        '@apollo/client/link/context';
      import Header from '../components/Header';
      // ...
    + const httpLink = createHttpLink({
    +   uri: 'http://localhost:3000/api/graphql/',
    + });
    + const authLink = setContext((_, { headers }) => {
    +   const token = sessionStorage.getItem('token');
    +   return {
    +     headers: {
    +       ...headers,
    +       authorization: token ? `Bearer ${token}` : '',
    +     },
    +   };
    + });
      const client = new ApolloClient({
    -   uri: 'http://localhost:3000/api/graphql/',
    +   link: authLink.concat(httpLink),
        cache: new InMemoryCache(),
      });
      function MyApp({ Component, pageProps }) {
        // ...
    

在对 GraphQL 服务器的每个请求中,现在都会将令牌添加到 HTTP 请求的头部。

  1. GraphQL 服务器现在可以从 HTTP 请求头部获取令牌并将它们存储在上下文中。上下文是一个对象,您可以使用它来存储您想在解析器中使用的数据,例如 JWT。这可以在 pages/api/graphql/index.js 中完成:

      // ...
        const executableSchema = addMocksToSchema({ 
        schema: makeExecutableSchema({ typeDefs, }), 
        mocks, 
        resolvers,
    +   context: ({ req }) => {
    +     const token = req.headers.authorization || '';
    
    +     return { token }
    +   },
      });
      // ...
    

最后,我们还可以创建一个用于检查项目的突变。这个突变应该清空卡片,在生产环境中,将客户重定向到支付服务提供商。在这种情况下,我们只需清空卡片并显示订单已成功创建的消息。为了帮助检查过程,我们需要进行以下修改:

  1. 我们需要在我们的 GraphQL 服务器 pages/api/graphql/index.js 的模式中添加一个新的突变:

        // ...
        type Mutation {
          addToCart(productId: Int!): Cart
          loginUser(username: String!, password: String!):
            User
    +     completeCart: Cart
        }
      `;
      const mocks = {
        // ...
    
  2. 在模式中定义的突变可以被添加到解析器中。这个突变需要清除购物车中的产品,将 count 字段设置为 0,并将 complete 字段设置为 true。此外,它应该检查用户是否在上下文中存储了一个令牌,以及这个令牌是否有效。为了检查令牌,我们可以使用之前导入的 isTokenValid 方法:

      // ...
      const resolvers = {
        Query: {
          cart: () => cart,
        },
        Mutation: {
          // ...
    +     completeCart: (_, {}, { token }) => {
    +       if (token && isTokenValid(token)) {
    +         cart = {
    +           count: 0,
    +           products: [],
    +           complete: true,
    +         };
    
    +         return cart;
    +       }
    +     },
        },
      };
      // ...
    
  3. pages/cart/index.js 文件中,我们需要从 @apollo/client 导入这个 Hook,并从 Next.js 导入 useRouter 以将未认证的用户重定向到 /login 页面。此外,可以在这里添加完成购物车的突变:

      import styled from 'styled-components';
      import { 
        useQuery, 
    +   useMutation, 
        gql 
      } from '@apollo/client';
    + import { useRouter } from 'next/router';
      // ...
    + const COMPLETE_CART = gql`
    +   mutation completeCart {
    +     completeCart {
    +       complete
    +     }
    +   }
    + `;
      function Cart() {
        // ...
    

Cart 组件的返回语句中,有一个用于结账的按钮。这个按钮需要调用由 useMutation Hook 创建的函数,该函数接受这个新的突变。这个突变完成购物车并清除其内容。如果用户未认证,它应该将用户重定向到 /login 页面:

  // ...
  function Cart() {
    const { loading, data } = useQuery(GET_CART);
+   const [completeCard] = useMutation(COMPLETE_CART);
    return (
      <>
        <SubHeader title='Cart' />
        {loading ? (
          <span>Loading...</span>
        ) : (
          <CartWrapper>
            // ...            
            {data &&
              data.cart.products.length > 0 && 
+             sessionStorage.getItem('token') && (
                <Button
                  backgroundColor='royalBlue'
+                 onClick={() => {
+                   const isAuthenticated = 
                      sessionStorage.getItem(
                        'token');
+                   if (isAuthenticated) {
+                     completeCard();
+                   }
+                 }}
                >
                  Checkout
                </Button>
              )}
          </CartWrapper>
        )}
      </>
    );
  }
  export default Cart;

这完成了应用程序的结账过程,从而结束了这一章,在这一章中,你使用了 React 和 GraphQL 来创建一个电子商务应用程序。

摘要

在这一章中,你创建了一个全栈 React 应用程序,该应用程序使用 GraphQL 作为其后端。使用 GraphQL 服务器和模拟数据,在 Next.js 中使用 API 路由创建了 GraphQL 服务器。这个 GraphQL 服务器接收查询和突变,为你提供数据,并允许你突变这些数据。这个 GraphQL 服务器被一个使用 Apollo 客户端的 React 应用程序使用,以从服务器发送和接收数据。

就这样!你已经完成了这本书的第七章,并且已经用 React 创建了七个网络应用程序。到现在为止,你应该对 React 和其特性感到很舒适,并且准备好学习更多。在下一章中,你将介绍 React Native,并学习如何通过使用 React Native 和 Expo 创建一个动画游戏来利用你的 React 技能构建一个移动应用程序。

进一步阅读

第八章:使用 React Native 和 Expo 构建动画游戏

React 开发的口号之一是*"一次学习,到处编写","这得益于 React Native 的存在。使用 React Native,您可以使用 JavaScript 和 React 编写原生移动应用程序,并使用名为Expo的工具链轻松运行和部署这些应用程序。本书中创建的先前应用程序都是 Web 应用程序,这意味着它们将在浏览器中运行。在浏览器中运行应用程序的缺点是在您点击按钮或导航到不同页面时缺乏交互。当构建直接在手机上运行的移动应用程序时,您的用户期望有使应用程序使用起来容易且熟悉的动画和手势。这就是您在本章中要关注的内容。

在本章中,您将创建一个 React Native 应用程序,使用 React Native 的 Animated API 和一个名为GestureHandler的包添加动画和手势。它们一起使我们能够创建充分利用移动设备交互方法的应用程序,这对于像高/低这样的游戏来说非常完美。

要创建这个游戏,以下主题将被涵盖:

  • 使用 Expo 设置 React Native

  • 向 React Native 添加手势和动画

  • 使用 Lottie 的高级动画

项目概述

在本章中,我们将使用 React Native 和 Expo 创建一个动画高/低游戏,它使用 Animated API 添加基本动画,Lottie 进行高级动画,以及来自 Expo 的GestureHandler来处理原生手势。

构建时间为 1.5 小时。

注意

本章使用 React Native 版本 0.64.3 和 Expo SDK 版本 44。由于 React Native 和 Expo 更新频繁,请确保您使用的是这个版本,以确保本章中描述的模式按预期运行。

入门

本章中我们构建的项目完整源代码可以在 GitHub 上找到:github.com/PacktPublishing/React-Projects-Second-Edition/tree/main/Chapter08。此外,本章最后部分所需的winner.json文件可以在github.com/PacktPublishing/React-Projects-Second-Edition/tree/main/Chapter08-assets找到。

您需要在 iOS 或 Android 移动设备上安装 Expo Go 应用程序,以便在物理设备上运行项目。一旦您下载了应用程序,您需要创建一个 Expo 账户以使开发过程更加顺畅。请确保将您的账户详细信息保存在安全的地方,因为您在本章的后续部分需要使用这些信息。

或者,您可以在计算机上安装 Xcode 或 Android Studio 来在虚拟设备上运行应用程序:

  • 对于 iOS:有关如何设置本地机器以运行 iOS 模拟器的信息,请在此处查看:docs.expo.io/workflow/ios-simulator/.

  • 对于 Android:有关如何设置本地机器以从 Android Studio 运行模拟器的信息,请在此处查看:docs.expo.io/workflow/android-studio-emulator/.

    注意

    强烈推荐使用 Expo 客户端应用程序从本章开始在一个物理设备上运行项目。目前,仅支持在物理设备上接收通知,在 iOS 模拟器或 Android Studio 模拟器上运行项目将导致错误信息。

使用 React Native 和 Expo 创建一个动画游戏应用程序

在本节中,您将使用 React Native 和 Expo 构建一个在移动设备上直接运行的动画游戏。React Native 允许您使用您已经从 React 熟悉的相同语法和模式,因为它使用核心 React 库。此外,Expo 使得您无需安装和配置 Xcode(对于 iOS)或 Android Studio 即可开始在您的机器上创建原生应用程序。因此,您可以从任何机器上为 iOS 和 Android 平台编写应用程序。

Expo 将 React API 和 JavaScript API 结合到 React Native 开发过程中,例如 JSX 组件、Hooks 以及如相机访问等原生功能。简而言之,Expo 工具链由多个工具组成,这些工具可以帮助您使用 React Native,例如 Expo CLI,它允许您从终端创建 React Native 项目,并包含运行 React Native 所需的所有依赖项。使用 Expo 客户端,您可以从连接到本地网络的 iOS 和 Android 移动设备上打开这些项目,而 Expo SDK 是一个包含所有库的包,使得您的应用程序能够在多个设备和平台上运行。

使用 Expo 设置 React Native

我们在这本书中之前创建的应用程序使用了 Create React App 或 Next.js 来设置起始应用程序。对于 React Native,有一个类似的样板代码可用,它是 Expo CLI 的一部分,可以同样轻松地设置。

您需要使用以下命令全局安装 Expo CLI,使用 Yarn

yarn global add expo-cli

或者,您可以使用 npm

npm install -g expo-cli

注意

Expo 使用 Yarn 作为其默认的包管理器,但您仍然可以使用 npm,就像我们在之前的 React 章节中所做的那样。

这将启动安装过程,这可能需要一些时间,因为它将安装带有所有依赖项的 Expo CLI,以帮助您开发移动应用程序。之后,您将能够使用 Expo CLI 的 init 命令创建一个新项目:

expo init chapter-8

现在,Expo 将为您创建项目,但在那之前,它会询问您是想创建一个空白模板、带有 TypeScript 配置的空白模板,还是带有一些示例屏幕的样本模板。对于本章,您需要选择第一个选项。Expo 会自动检测您的机器上是否已安装 Yarn;如果是,它将使用 Yarn 安装设置计算机所需的其它依赖项。

您的应用程序现在将根据您之前选择的设置创建。您现在可以通过进入 Expo 刚刚创建的目录,使用以下命令启动此应用程序:

cd chapter-8
yarn start

这将启动 Expo,并允许您从终端和浏览器启动您的项目。在终端中,您现在将看到一个 QR 码,您可以使用移动设备上的 Expo 应用程序扫描,或者如果您已安装 Xcode 或 Android Studio,您还可以启动 iOS 或 Android 模拟器。此外,在运行start命令后,Expo DevTools将在您的浏览器中打开:

图 8.1 – 运行 Expo 时的 Expo DevTools 截图

图 8.01 – Expo DevTools 运行时的截图

图 8.1 – 运行 Expo 时的 Expo DevTools

在此页面上,您将在左侧看到一个侧边栏,以及您的 React Native 应用程序的日志在右侧。如果您使用的是 Android 设备,您可以直接从 Expo Go 应用程序扫描 QR 码。在 iOS 上,您需要使用相机扫描代码,这将要求您打开 Expo 客户端。或者,Expo DevTools 中的侧边栏有按钮可以启动 iOS 或 Android 模拟器,您需要安装 Xcode 或 Android Studio。否则,您还可以找到按钮通过电子邮件发送应用程序链接。

无论您是使用 iOS 或 Android 模拟器打开的应用程序,还是从 iOS 或 Android 设备打开,此时应用程序应该是一个显示打开 App.js 以开始您的应用程序开发的白色屏幕。

注意

如果您没有看到应用程序,而是显示错误信息的红色屏幕,您应该确保您在本地机器和移动设备上运行的是正确的 React Native 和 Expo 版本。这些版本应该是 React Native 版本 0.64.3 和 Expo 版本 44。使用任何其他版本都可能导致错误,因为 React Native 和 Expo 的版本应该保持同步。

使用 Expo 创建的此 React Native 应用程序的项目结构与您在前几章中创建的 React 项目非常相似:

chapter-8 
  |- node_modules 
  |- assets
  |- package.json 
  |- App.js
  |- app.json 
  |- babel.config.js  

assets目录中,一旦你在移动设备上安装了此应用程序,你就可以找到用作主屏幕应用程序图标的图像,以及将作为启动屏幕使用的图像,该屏幕在启动应用程序时显示。App.js文件是应用程序的实际入口点,你将在这里放置在应用程序挂载时将被渲染的代码。应用程序的配置(例如,应用商店)放置在app.json中,而babel.config.js包含特定的 Babel 配置。

添加基本路由

对于使用 React 创建的 Web 应用程序,我们使用了 React Router 进行导航,而对于 Next.js,路由已经通过文件系统内置。对于 React Native,我们需要一个支持 iOS 和 Android 的不同路由库。这个最受欢迎的库是react-navigation,我们可以从Yarn安装它:

yarn add @react-navigation/native

这将安装核心库,但我们需要通过运行以下命令来扩展我们当前的 Expo 安装,以包含react-navigation所需的依赖项:

expo install react-native-screens react-native-safe-area-context

要将路由添加到你的 React Native 应用程序中,你需要了解浏览器和移动应用程序中路由的区别。React Native 中的历史记录在行为上与浏览器不同,在浏览器中,用户可以通过更改浏览器中的 URL 来导航到不同的页面,并且之前访问过的 URL 会被添加到浏览器历史记录中。相反,你需要自己跟踪页面之间的转换并存储应用程序中的本地历史记录。

使用 React Navigation,你可以使用多个不同的导航器来帮助你完成这项工作,包括栈导航器和标签导航器。栈导航器的行为非常类似于浏览器,因为它在过渡后将页面堆叠在一起,并允许你使用 iOS 和 Android 的原生手势和动画进行导航。让我们开始吧:

  1. 首先,我们需要安装这个库来使用栈导航以及来自react-navigation的带有导航元素的附加库:

    yarn add @react-navigation/native-stack@react-navigation/elements
    
  2. 从这个库和react-navigation的核心库中,我们需要在App.js中导入以下内容来创建栈导航器:

      import { StatusBar } from 'expo-status-bar';
      import React from 'react';
      import { StyleSheet, Text, View } from 
        'react-native';
    + import { NavigationContainer } 
        from '@react-navigation/native';
    + import { createNativeStackNavigator } 
        from '@react-navigation/native-stack';
    + const Stack = createNativeStackNavigator();
      export default function App() {
        // ...
    
  3. App组件中,我们需要返回这个栈导航器,它还需要一个组件来返回主屏幕。因此,我们需要在名为screens的新目录中创建一个Home组件。这个组件可以在名为Home.js的文件中创建,内容如下:

    import React from 'react';
    import { StyleSheet, Text, View } from 'react-native';
    export default function Home() {
      return (
        <View style={styles.container}>
          <Text>Home screen</Text>
        </View>
      );
    }
    const styles = StyleSheet.create({
      container: {
        flex: 1,
        backgroundColor: '#fff',
        alignItems: 'center',
        justifyContent: 'center',
      },
    });
    
  4. App.js中,我们需要导入这个Home组件,并通过从App组件返回一个NavigationContainer组件来设置栈导航器。在这个组件内部,栈导航器是通过Stack组件中的Navigator组件创建的,主屏幕在Stack.Screen组件中描述。此外,移动设备的状态栏也在这里定义:

      import { StatusBar } from 'expo-status-bar';
      import React from 'react';
    - import { StyleSheet, Text, View } from 
        'react-native';
    + import { StyleSheet } from 'react-native';
      import { NavigationContainer } 
        from '@react-navigation/native';
      import { createNativeStackNavigator } 
        from '@react-navigation/native-stack';
    + import Home from './screens/Home';
    const Stack = createNativeStackNavigator();
    export default function App() {
      export default function App() {
        return (
    -     <View style={styles.container}>
    -       <Text>Open up App.js to start working on your 
              app!</Text>
    +     <NavigationContainer>
                  <StatusBar style='auto' />
    +       <Stack.Navigator>
    +         <Stack.Screen name='Home' component={Home} 
              />
    +       </Stack.Navigator>
    +     </NavigationContainer>
    -     </View>
        );
      }
      // ...
    

确保您仍在终端中运行 Expo;否则,请使用yarn start命令重新启动。现在,您的移动设备或模拟器上的应用程序应该看起来像这样:

图 8.2 – 带有堆栈导航器的应用程序

图 8.2 – 带有堆栈导航器的应用程序

注意

在 Expo Go 中重新加载应用程序时,您可以使用 iOS 或 Android 手机摇晃设备。通过摇晃设备,将出现一个菜单,其中包含重新加载应用程序的选项。在此菜单中,您还必须选择启用快速刷新,以便在您对代码进行更改时自动刷新应用程序。

我们已经设置了堆栈导航器的第一个页面,所以让我们在下一部分添加更多页面,并创建按钮在它们之间导航。

在屏幕之间导航

在 React Native 中在屏幕之间导航的工作方式与在浏览器中略有不同,因为再次没有 URL。相反,您需要使用堆栈导航器渲染的组件可用的导航对象,或者通过从react-navigation调用useNavigation钩子。

在学习如何在屏幕之间导航之前,我们需要添加另一个屏幕进行导航:

  1. 您可以通过在screens目录下名为Game.js的文件中创建一个新的组件来添加此屏幕,代码如下:

    import React from 'react';
    import { StyleSheet, Text, View } from 'react-native';
    export default function Game() {
      return (
        <View style={styles.container}>
          <Text>Game screen</Text>
        </View>
      );
    }
    const styles = StyleSheet.create({
      container: {
        flex: 1,
        backgroundColor: '#fff',
        alignItems: 'center',
        justifyContent: 'center',
      },
    });
    
  2. 此组件必须在App.js中导入,并作为新屏幕添加到堆栈导航器中。此外,在导航器上,我们需要设置initialRouteName属性来设置必须显示的默认屏幕:

      import { StatusBar } from 'expo-status-bar';
      import React from 'react';
      import { StyleSheet } from 'react-native';
      import { NavigationContainer } 
        from '@react-navigation/native';
      import { createNativeStackNavigator } 
        from '@react-navigation/native-stack';
      import Home from './screens/Home';
    + import Game from './screens/Game';
      const Stack = createNativeStackNavigator();
      export default function App() {
        return (
          <NavigationContainer>
            <StatusBar style='auto' />
    -       <Stack.Navigator>
    +       <Stack.Navigator initialRouteName='Home'>
              <Stack.Screen name='Home' component={Home} 
              />
    +         <Stack.Screen name='Game' component={Game} 
              />
            </Stack.Navigator>
          </NavigationContainer>
        );
      }
      // ...
    
  3. screens/Home.js中的Home组件,我们可以从useNavigation钩子中获取导航对象,并创建一个按钮,当按下时将导航到Game屏幕。这是通过使用navigation对象的navigate方法并将其传递给 React Native 的Button组件的onPress属性来完成的:

      import React from 'react';
    - import { StyleSheet, Text, View } from 
        'react-native';
    + import { StyleSheet, View, Button } from 
        'react-native';
    + import { useNavigation } from 
        '@react-navigation/native';
      export default function Home() {
    +   const navigation = useNavigation();
        return (
          <View style={styles.container}>
    -       <Text>Home screen</Text>
    +       <Button onPress={() => navigation.navigate(
              'Game')} title='Start game!' />
          </View>
        );
      }
      // ...
    

现在,您可以通过使用我们刚刚创建的按钮或使用页眉中的按钮在主页游戏屏幕之间进行切换。此页眉是由 react-navigation 自动生成的,但您也可以自定义它,我们将在第九章中这样做,使用 React Native 和 Expo 构建全栈社交媒体应用程序

图 8.3 – 带有基本路由的我们的应用程序

图 8.3 – 带有基本路由的我们的应用程序

到目前为止,我们已经向应用程序添加了基本路由,但我们还没有游戏。在screens/Game.js文件中,可以通过使用本地状态管理(使用useStateuseEffect钩子)来添加高/低游戏的逻辑。这些钩子在 React Native 中的工作方式与在 React 网络应用程序中相同。让我们添加游戏逻辑:

  1. Game 组件中,从 React 导入这些钩子,在 ButtonAlert 组件旁边。导入它们之后,我们需要创建一个本地状态变量来存储用户的选择,并为游戏创建随机数和分数。还要从 react-navigation 中导入 useNavigation 钩子:

    - import React from 'react';
    - import { StyleSheet, Text, View } from 
        'react-native';
    + import React, { useEffect, useState } from 'react';
    + import { Button, StyleSheet, Text, View, Alert }
        from 'react-native';
    + import { useNavigation } from 
        '@react-navigation/native';
      export default function Game() {
    +   const baseNumber = Math.floor(Math.random() *
          100);
    +   const score = Math.floor(Math.random() * 100);
    +   const [choice, setChoice] = useState('');
    
        return (
          <View style={styles.container}>
          // ...
    

baseNumber 的值是游戏开始时带有 1 到 100 之间的初始随机值的数字,使用 JavaScript 中的 Math 方法创建。分数值也有一个随机数作为值,这个值用于与 baseNumber 进行比较。choice 本地状态变量用于存储用户的选项,如果分数高于或低于 baseNumber

  1. 要能够做出选择,我们需要添加两个 Button 组件,根据你按下的哪个按钮设置选择值是更高或更低:

        // ...
        return (
          <View style={styles.container}>
    -       <Text>Game screen</Text>
    +       <Text>Starting: {baseNumber}</Text>
    +       <Button onPress={() => setChoice('higher')} 
              title='Higher' />
    +       <Button onPress={() => setChoice('lower')}
              title='Lower' />
          </View>
        );
      }
      const styles = StyleSheet.create({
        // ...
    
  2. useEffect 钩子中,我们可以比较 baseNumberscore 的值,并根据值选择显示一个警告。根据选择,用户会看到一个显示消息说明他们是否获胜以及得分的 Alert 组件。在显示警告的同时,将使用 baseNumberscorechoice 的值来导航回上一页。这将重置 Game 组件:

      // ...
    + const navigation = useNavigation();
    + useEffect(() => {
    +   if (choice) {
    +     const winner =
    +       (choice === 'higher' && score > baseNumber) ||
    +       (choice === 'lower' && baseNumber > score);
    +     Alert.alert(`You've ${winner ? 'won' : 'lost'}`,
            `You scored: ${score}`);
    +     navigation.goBack();
    +   }
    + }, [baseNumber, score, choice]);
      return (
        <View style={styles.container}>
        // ...
    

现在,你能够玩游戏并选择你认为分数是否会高于或低于显示的 baseNumber。但我们还没有添加任何样式,这将在本节下一部分完成。

React Native 中的样式

你可能在前面的组件中看到,我们更改或添加到项目中使用了名为 StyleSheet 的变量。使用这个变量从 React Native 中,我们可以创建一个样式对象,我们可以通过传递一个名为 style 的属性将其附加到 React Native 组件上。我们已经使用它来使用名为 container 的样式来设置组件样式,但让我们做一些更改,也为其他组件添加样式:

  1. screens/Home.js 中,我们需要将 Button 组件替换为 TouchableHighlight 组件,因为 React Native 中的 Button 组件难以设置样式。这个 TouchableHighlight 组件是一个可按的元素,当按下时会高亮显示,为用户提供反馈。在这个组件内部,必须添加一个 Text 组件来显示按钮的标签:

      import React from 'react';
    - import { StyleSheet, View, Button } from 
        'react-native';
    + import { StyleSheet, Text, View, TouchableHighlight
        } from 'react-native';
      import { useNavigation } from 
        '@react-navigation/native';
      export default function Home() {
        const navigation = useNavigation();
    
        return (
          <View style={styles.container}>
    -       <Button onPress={() => navigation.navigate(
              'Game')} title='Start game!' />
    +       <TouchableHighlight
    +         onPress={() => navigation.navigate('Game')}
    +         style={styles.button}
    +       >
    +         <Text style={styles.buttonText}>
                Start game!</Text>
    +       </TouchableHighlight>
          </View>
        );
      }
      // ...
    
  2. TouchableHighlightText 组件使用 styles 对象中的 buttonbuttonText 样式,我们需要将其添加到文件底部的 StyleSheetcreate 方法中:

      // ...
      const styles = StyleSheet.create({
        container: {
          flex: 1,
          backgroundColor: '#fff',
          alignItems: 'center',
          justifyContent: 'center',
        },
    +   button: {
    +     width: 300,
    +     height: 300,
    +     display: 'flex',
    +     alignItems: 'center',
    +     justifyContent: 'space-around',
    +     borderRadius: 150,
    +     backgroundColor: 'purple',
    +   },
    +   buttonText: {
    +     color: 'white',
    +     fontSize: 48,
    +   },
      });
    

使用 React Native 创建样式意味着你需要使用 camelCase 表示法,而不是我们习惯的 CSS 中的 kebab-case – 例如,background-color 变为 backgroundColor

  1. 我们还需要通过打开 screens/Game.js 文件来为 Game 屏幕上的按钮添加样式修改。在这个文件中,我们再次需要用带有内部 TextTouchableHighlight 组件替换 React Native 的 Button 组件:

      import React, { useEffect, useState } from 'react';
      import {
    -   Button,
        StyleSheet,
        Text,
        View,
        Alert,
    +   TouchableHighlight,
      } from 'react-native';
        import { useNavigation } from 
          '@react-navigation/native';
      export default function Game() {
        // ...
        return (
          <View style={styles.container}>
    -       <Text>Starting: {baseNumber}</Text>
    -       <Button onPress={() => setChoice('higher')}
    title='Higher' /> 
    -       <Button onPress={() => setChoice('lower')}
              title='Lower' />
    +       <Text style={styles.baseNumber}>
              Starting: {baseNumber}</Text>
    +       <TouchableHighlight onPress={() =>
              setChoice('higher')} style={styles.button}>
    +         <Text style={styles.buttonText}>Higher
              </Text>
    +       </TouchableHighlight>
    +       <TouchableHighlight onPress={() =>
                setChoice('lower')} style={styles.button}>
    +         <Text style={styles.buttonText}>Lower</Text>
    +       </TouchableHighlight>
          </View>
        );
      }
      // ...
    
  2. styles 对象必须包含新的 baseNumberbuttonbuttonText 样式,我们可以在文件底部添加这些样式:

      // ...
      const styles = StyleSheet.create({
        container: {
          flex: 1,
          backgroundColor: '#fff',
          alignItems: 'center',
          justifyContent: 'center',
        },
    +   baseNumber: {
    +     fontSize: 48,
    +     marginBottom: 30,
    +   },
    +   button: {
    +     display: 'flex',
    +     alignItems: 'center',
    +     justifyContent: 'space-around',
    +     borderRadius: 15,
    +     padding: 30,
    +     marginVertical: 15,
    +   },
    +   buttonText: {
    +     color: 'white',
    +     fontSize: 24,
    +   },
      });
    
  3. 然而,现在两个按钮都将拥有相同的白色背景。我们可以通过为它们添加额外的样式来改变这一点。React Native 组件上的 style 属性也可以接受一个样式对象的数组,而不仅仅是单个对象:

      // ...
      return (
        <View style={styles.container}>
          <Text style={styles.baseNumber}>
            Starting: {baseNumber}</Text>
          <TouchableHighlight
            onPress={() => setChoice('higher')}
    -       style={styles.button}
    +       style={[styles.button, styles.buttonGreen]}
          >
            <Text style={styles.buttonText}>Higher</Text>
          </TouchableHighlight>
          <TouchableHighlight
            onPress={() => setChoice('lower')}
    -       style={styles.button}
    +       style={[styles.button, styles.buttonRed]}
          >
            <Text style={styles.buttonText}>Lower</Text>
          </TouchableHighlight>
        </View>
      );
      // ...
    
  4. 这些 buttonGreenbuttonRed 对象也必须添加到样式对象中:

      // ...
      const styles = StyleSheet.create({
        // ...
    +   buttonRed: {
    +     backgroundColor: 'red',
    +   },
    +   buttonGreen: {
    +     backgroundColor: 'green',
    +   },
        buttonText: {
          color: 'white',
          fontSize: 24,
        },
      });
    

通过这些添加,应用程序现在已经被样式化,这使得它更具吸引力。我们使用了 React Native 的 StyleSheet 对象来应用这种样式,使你的应用程序看起来像这样:

图 8.4 – 样式化的 React Native 应用程序

图 8.4 – 样式化的 React Native 应用程序

移动游戏通常有令人眼花缭乱的动画,这些动画会让用户想要继续玩游戏,并使游戏更具互动性。目前功能正常的 Higher/Lower 游戏还没有使用动画,只是内置了一些 React Navigation 创建的过渡效果。在下一节中,你将为应用程序添加动画和手势,这将改善游戏界面,并让用户在玩游戏时感到更加舒适。

在 React Native 中添加手势和动画

在 React Native 中使用动画有多种方式,其中之一是使用 Animated API,这是 React Native 的核心。使用 Animated API,你可以为 React Native 的 ViewTextImageScrollView 组件创建默认的动画。或者,你也可以使用 createAnimatedComponent 方法来创建自己的组件。

创建基本动画

你可以添加的最简单的动画之一是通过改变元素的透明度值来使元素淡入或淡出。在之前创建的 Higher/Lower 游戏中,按钮已经被样式化了。这些颜色已经显示出微小的过渡,因为你在创建按钮时使用了 TouchableHighlight 元素。然而,你可以通过使用 Animated API 来添加一个自定义的过渡效果。要添加动画,必须更改以下代码块:

  1. 首先,创建一个名为 components 的新目录,它将包含我们所有的可重用 components。在这个目录中,创建一个名为 AnimatedButton.js 的文件,它将包含以下代码来构建新的组件:

    import React from 'react';
    import { StyleSheet, Text, TouchableHighlight } 
      from 'react-native';
    export default function AnimatedButton({ action, 
      onPress }) {
            return (
        <TouchableHighlight
          onPress={onPress}
          style={[
            styles.button,
            action === 'higher' ? styles.buttonGreen :
              styles.buttonRed,
          ]}
        >
          <Text style={styles.buttonText}>{action}</Text>
        </TouchableHighlight>
      );
    }
    
  2. 将以下样式添加到文件底部:

    // ...
    const styles = StyleSheet.create({
      button: {
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'space-around',
        borderRadius: 15,
        padding: 30,
        marginVertical: 15,
      },
      buttonRed: {
        backgroundColor: 'red',
      },
      buttonGreen: {
        backgroundColor: 'green',
      },
      buttonText: {
        color: 'white',
        fontSize: 24,
        textTransform: 'capitalize',
      },
    });
    
  3. 如你所见,这个组件与我们在screens/Game.js中拥有的按钮相似。因此,我们可以从该文件中删除TouchableHighlight按钮,并用AnimatedButton组件替换它们。确保将正确的actiononPress值作为属性传递给此组件:

      import React, { useEffect, useState } from 'react';
      import {
        StyleSheet,
        Text,
        View,
        Alert,
    -   TouchableHighlight,
      } from 'react-native';
      import { useNavigation } from 
        '@react-navigation/native';
    + import AnimatedButton from 
        '../components/AnimatedButton';
    
      export default function Game() {
        // ...
        return (
          <View style={styles.container}>
            <Text style={styles.baseNumber}>
              Starting: {baseNumber}</Text>
    -       <TouchableHighlight onPress={() =>
              setChoice('higher')} style={[styles.button,
                styles.buttonGreen]}>
    -         <Text style={styles.buttonText}>Higher
              </Text>
    -       </TouchableHighlight>
    -       <TouchableHighlight onPress={() =>
              setChoice('lower')} style={[styles.button,
                styles.buttonRed]}>
    -         <Text style={styles.buttonText}>Lower</Text>
    -       </TouchableHighlight>
    +       <AnimatedButton action='higher' onPress={() =>
              setChoice('higher')} />
    +       <AnimatedButton action='lower' onPress={() =>
              setChoice('lower')} />
          </View>
        );
      }
      // ...
    
  4. 如果你在移动设备上的应用程序或电脑上的模拟器中查看,则不会看到任何可见的变化,因为我们首先需要将可点击元素从TouchableHighlight元素更改为TouchableWithoutFeedback元素。这样,带有高亮的默认过渡就会消失,我们可以用我们自己的效果来替换它。TouchableWithoutFeedback元素可以从 React Native 的components/AnimatedButton.js中导入,并且应该放在一个View元素周围,该元素将保留按钮的默认样式:

      import React from 'react';
      import {
        StyleSheet,
        Text,
    -   TouchableHighlight,
    +   TouchableWithoutFeedback,
    +   View
      } from 'react-native';
        export default function AnimatedButton({ action, 
          onPress }) {
        return (
    -     <TouchableHighlight onPress={onPress} style={[ 
    styles.button, action === 'higher' ? 
            styles.buttonGreen : styles.buttonRed ]}>
    +     <TouchableWithoutFeedback onPress={onPress}>
    +       <View style={[ styles.button, action === 'higher' 
              ? styles.buttonGreen : styles.buttonRed ]}>
              <Text style={styles.buttonText}>{action}</Text>
    -     </TouchableHighlight>
    +       </View>
    +     </TouchableWithoutFeedback>
        );
      }
      // ...
    
  5. 要在点击按钮时创建过渡效果,我们可以使用 Animated API。我们将使用它来改变AnimatedButton组件在被按下时的不透明度。Animated API 的新实例通过指定在动画过程中应该改变值的值来开始。这个值应该在组件的整个范围内可改变,因此你可以将这个值添加到组件的顶部。这个值应该使用useRef钩子创建,因为你希望这个值以后可以改变。此外,我们还需要从 React Native 中导入Animated

    - import React from 'react';
    + import React, { useRef } from 'react';
      import {
        StyleSheet,
        Text,
        TouchableWithoutFeedback,
    -   View,
    +   Animated,
      } from 'react-native';
      export default function AnimatedButton({ action,
        onPress }) {
    +   const opacity = useRef(new Animated.Value(1));
    
        return (
          //  ...
    
  6. 现在可以使用内置的任何三种动画类型来改变这个值。这些是decayspringtiming,其中你将使用 Animated API 的timing方法在指定的时间范围内改变动画值。Animated API 可以从TouchableWithoutFeedback上的onPress事件触发,并在动画完成后调用onPress属性:

      // ...
      export default function AnimatedButton({ action,
        onPress }) {
        const opacity = useRef(new Animated.Value(1));
    
        return (
          <TouchableWithoutFeedback
    -       onPress={onPress}   
    +       onPress={() => {
    +         Animated.timing(opacity.current, {
    +           toValue: 0.2,
    +           duration: 800,
    +           useNativeDriver: true,
    +         }).start(() => onPress());
    +       }}
          >
          // ...
    

timing方法接受你在组件顶部指定的opacity以及一个包含 Animated API 配置的对象。我们需要获取当前的不透明度值,因为这个是一个ref值。其中一个字段是toValue,当动画结束时,它将成为opacity的值。另一个字段是用于指定动画持续时间的字段。

注意

timing并列的内置动画类型还有decayspring。而timing方法在一段时间内逐渐改变,decay类型具有在开始时快速改变并在动画结束时逐渐减慢的动画。使用spring,你可以在动画结束时创建稍微超出其边缘的动画。

  1. 可以用Animated.View组件替换View组件。该组件使用由useRef钩子创建的opacity变量来设置其不透明度:

            // ...
    -       <View
    +       <Animated.View
              style={[
                styles.button,
                action === 'higher' ? styles.buttonGreen :
                  styles.buttonRed,
    +           { opacity: opacity.current },
              ]}
            >
              <Text style={styles.buttonText}>{action}
              </Text>
    -       </View>
    +       </Animated.View>
          </TouchableWithoutFeedback>
        );
      }
      // ...
    

现在,当你按下Game屏幕上的任何按钮时,它们将会淡出,因为不透明度从1过渡到0.2需要 400 毫秒。

为了使动画看起来更平滑,你可以向Animated对象添加一个easing字段。这个字段的值来自Easing模块,可以从 React Native 导入。Easing模块有三个标准函数:linearquadcubic。在这里,linear函数可以用于更平滑的时间动画:

  import React, { useRef } from 'react';
  import {
    StyleSheet,
    Text,
    TouchableWithoutFeedback,
    Animated,
+   Easing,
  } from 'react-native';
export default function AnimatedButton({ action, onPress }) {
  const opacity = useRef(new Animated.Value(1));
  return (
    <TouchableWithoutFeedback
      onPress={() => {
        Animated.timing(opacity.current, {
          toValue: 0.2,
          duration: 400,
          useNativeDriver: true,
+         easing: Easing.linear(),      
        }).start(() => onPress());
      }}
    >
    // ...

经过这个最后的修改,动画就完成了,游戏界面已经感觉更平滑了,因为按钮是通过我们自己的自定义动画来高亮的。在本节的下一部分,我们将结合一些这些动画,使游戏的用户体验更加先进。

注意

你还可以结合动画——例如,使用parallel方法——从 Animated API。这个方法将启动在同一时刻指定的动画,并将一个动画数组作为其值。在parallel函数旁边,还有三个其他函数可以帮助你进行动画组合。这些函数是delaysequencestagger,它们也可以组合使用。delay函数在预定延迟后开始任何动画,sequence函数按照你指定的顺序开始动画,并在一个动画解决之前等待,然后开始另一个动画,而stagger函数可以在指定延迟之间按顺序和并行地开始动画。

使用 Expo 处理手势

手势是移动应用程序的一个重要特性,因为它们区分了平庸和优秀的移动应用程序。在你创建的高低游戏中,可以添加几个手势来使游戏更具吸引力。

之前,你使用了TouchableHighlight元素,它在用户按下后会通过改变它来提供用户反馈。另一种你可以用来实现这个功能的元素是TouchableOpacity元素。这些手势给用户一种印象,即当他们在你的应用程序中做出决策时会发生什么,从而改善了用户体验。这些手势可以自定义并添加到其他元素中,使得可以拥有自定义的可触摸元素。

为了实现这一点,你可以使用一个名为react-native-gesture-handler的包,它可以帮助你在每个平台上访问原生手势。所有这些手势都将运行在原生线程上,这意味着你可以在不处理 React Native 手势响应系统性能限制的情况下添加复杂的手势逻辑。它支持的一些手势包括点击旋转拖动平移长按。在前一节中,我们已经安装了这个包,因为它是react-navigation的要求。

注意

你也可以直接从 React Native 使用手势,而无需使用额外的包。然而,React Native 当前使用的响应者系统不在原生线程中运行。这不仅限制了创建和自定义手势的可能性,还可能导致跨平台或性能问题。因此,建议使用react-native-gesture-handler包,但这对于在 React Native 中使用手势并不是必需的。

我们将实现的动作是长按手势,它将被添加到我们的主页屏幕上的开始按钮中,位于screens/Home.js。在这里,我们将使用来自react-native-gesture-handlerTapGestureHandler元素,它在原生线程中运行,而不是使用 React Native 的TouchableWithoutFeedback元素,后者使用手势响应系统。为了实现这一点,我们需要做以下操作,请确保其余的数字都相应更新:

  1. 使用 Expo 进行安装:

    expo install react-native-gesture-handler
    
  2. react-native-gesture-handler导入TapGestureHandlerState,紧挨着从 React Native 导入的ViewAlert。可以移除TouchableHighlight的导入,因为这将被替换:

      import React from 'react';
      import {
        StyleSheet,
            Text,  View,
    +   Alert,
    -   TouchableHighlight,
      } from 'react-native';
      import { useNavigation } from 
        '@react-navigation/native';
    + import { TapGestureHandler, State } from 
        'react-native-gesture-handler';
      export default function Home() {
        // ...
    
  3. 我们可以将TouchableHighlight组件替换为TapGestureHandler,并在其中放置一个View组件,然后对其应用样式。TapGestureHandler不接收onPress属性,而是接收onHandlerStateChange属性,我们将新的on Tap函数传递给它。在这个函数中,我们需要检查触摸事件的状态是否为活动状态。为此,你需要知道触摸事件会经过不同的状态:UNDETERMINEDFAILEDBEGANCANCELLEDACTIVEEND。这些状态的名字相当直观,通常处理器的流程如下:UNDETERMINED > BEGAN > ACTIVE > END > UNDETERMINED

      // ...
      export default function Home() {
        const navigation = useNavigation();
    +   function onTap(e) {
    +     if (e.nativeEvent.state === State.ACTIVE) {
    +       Alert.alert('Long press to start the game');
    +     }
    +   }
        return (
          <View style={styles.container}>
    -       <TouchableHighlight
    -         onPress={() => navigation.navigate('Game')}
    -         style={styles.button}
    -       >
    +      <TapGestureHandler onHandlerStateChange={onTap}>
    +        <View style={styles.button}> <Text 
               style={styles.buttonText}>Start game!</Text>
    +        </View>
    -      </TouchableHighlight>
    +      </TapGestureHandler>
          </View>
        );
      }
      // ...
    
  4. 如果你现在在“主页”屏幕上按下开始按钮,你会收到需要长按按钮以开始游戏的消息。为了添加这个长按手势,我们需要在TapGestureHandler组件内部添加一个LongPressGestureHandler组件。此外,我们还需要创建一个可以被LongPressGestureHandler组件调用的函数,该函数将带我们进入游戏屏幕:

      import React  from 'react';
    import { StyleSheet, Text, View, Alert } 
    from 'react-native'; 
    import { useNavigation } 
    from '@react-navigation/native'; 
    import { 
    +   LongPressGestureHandler, 
    TapGestureHandler, 
    State, 
      } from 'react-native-gesture-handler';
      export default function Home() {
        const navigation = useNavigation();
    +   function onLongPress(e) {
    +     if (e.nativeEvent.state === State.ACTIVE) {
    +       navigation.navigate('Game');
    +     }
    +   }
        // ...
    
  5. TapGestureHandler内部应放置新导入的LongPressGestureHandler组件。该组件接收导航到游戏的函数,以及一个设置长按最小持续时间的属性。如果你不设置此属性,默认的最小持续时间将是 500ms:

      // ...  
      export default function Home() {
        // ...
        return (
          <View style={styles.container}>
            <TapGestureHandler 
              onHandlerStateChange={onSingleTap} 
            >
    +         <LongPressGestureHandler+ 
                onHandlerStateChange={onLongPress}
    +           minDurationMs={600}
    +         >
                <View style={styles.button}>
                  <Text style={styles.buttonText}>
                    Start game!</Text>
                </View>
    +         </LongPressGestureHandler>
            </TapGestureHandler>
          </View>
        );
      }
      // ...
    

通过这个最新的更改,您只能通过在 主页 屏幕上长按 开始 按钮来启动游戏。这些手势可以进一步自定义,因为您可以使用组合来拥有多个相互响应的点击事件。通过创建所谓的 交叉处理程序交互,您可以创建一个支持 双击长按 手势的可触摸元素。

下一节将向您展示如何处理更高级的动画,例如在任意两位玩家获胜时显示动画图形。为此,我们将使用 Lottie 包,因为它比内置的 Animated API 支持更多的功能。

使用 Lottie 的高级动画

React Native Animated API 对于构建简单的动画来说很棒,但构建更高级的动画可能更困难。幸运的是,Lottie 通过使我们能够在 iOS、Android 和 React Native 中实时渲染 After Effects 动画,为 React Native 提供了创建高级动画的解决方案。

注意

当使用 Lottie 时,您不必自己创建这些 After Effects 动画;有一个完整的资源库,您可以在项目中自定义并使用。这个库叫做 LottieFiles,可在 lottiefiles.com/ 获取。

由于我们已经为游戏按钮添加了动画,添加更多高级动画的好地方是在显示您赢或输游戏的消息上。这个消息可以显示在屏幕上而不是弹窗中,如果用户赢了,可以显示奖杯。让我们现在就做这个:

  1. 要开始使用 Lottie,运行以下命令,它将安装 Lottie 到我们的项目中:

    yarn add lottie-react-native
    
  2. 安装完成后,我们可以创建一个新的屏幕组件,名为 screens/Result.js,其内容如下:

    import React from 'react';
    import { StyleSheet, Text, View } from 'react-native';
    export default function Result() {
      return (
        <View style={styles.container}>
          <Text></Text>
        </View>
      );
    }
    const styles = StyleSheet.create({
      container: {
        flex: 1,
        backgroundColor: '#fff',
        alignItems: 'center',
        justifyContent: 'center',
      },
    });
    
  3. 将此屏幕添加到堆栈导航器中,以便可以通过在 App.js 中导入它来在移动应用的导航中使用。此外,还应该导入导航元素 HeaderBackButton

      import { StatusBar } from 'expo-status-bar';
      import React from 'react';
      import { StyleSheet } from 'react-native';
      import { NavigationContainer } 
        from '@react-navigation/native';
      import { createNativeStackNavigator } 
        from '@react-navigation/native-stack';
    + import { HeaderBackButton } 
        from '@react-navigation/elements';
      import Home from './screens/Home';
      import Game from './screens/Game';
    + import Result from './screens/Result';
      // ...
    
  4. 当添加 Result 屏幕时,我们也从 React Navigation 导入了 HeaderBackButton 组件,因为我们还想要更改 主页 屏幕而不是 游戏 屏幕,以便用户在完成游戏后可以开始新游戏:

      // ...
      export default function App() {
        return (
          <NavigationContainer>
            <StatusBar style='auto' />
            <Stack.Navigator initialRouteName='Home'>
              <Stack.Screen name='Home' component={Home} />
              <Stack.Screen name='Game' component={Game} />
    +         <Stack.Screen
    +           name='Result'
    +           component={Result}
    +           options={({ navigation }) => ({
    +             headerLeft: (props) => (
    +               <HeaderBackButton
    +                 {...props}
    +                 label='Home'
    +                 onPress={() =>
                        navigation.navigate('Home')}
    +               />
    +             ),
    +           })}
    +         />
          </Stack.Navigator>
        </NavigationContainer>
      );
      // ...
    
  5. screens/Game.js 中的 Game 屏幕中,我们可以在游戏后引导用户到 Result 屏幕并传递一个参数给这个屏幕。使用此参数,可以显示游戏的结果消息:

      // ...
      export default function Game() {
        // ...
        useEffect(() => {
          if (choice.length) {
            const winner = (choice === 'higher' && score >
              baseNumber) || (choice === 'lower' && 
                baseNumber > score);
    -       Alert.alert(`You've ${winner ? 'won' : 
              'lost'}`, `You scored: ${score}`);
    -       navigation.goBack();
    +       navigation.navigate('Result', { winner })
        }
      }, [baseNumber, score, choice]);
      return (
      // ...
    
  6. screens/Result.js 文件中的 Result 屏幕中,我们可以从 lottie-react-native 导入 LottieView,并使用 React Navigation 的 useRoute 钩子从 route 对象中获取参数。使用此参数,如果用户赢了或输了,我们可以返回一条消息:

      import React from 'react';
      import { StyleSheet, Text, View } from 
        'react-native';
    + import LottieView from 'lottie-react-native';
    + import { useRoute } from '@react-navigation/native';
      export default function Result() {
    +   const route = useRoute();
    +   const { winner } = route.params;
        return (
          <View style={styles.container}>
    +       <Text>You've {winner ? 'won' : 'lost'}</Text>
          // ...
    
  7. 导入的Lottie组件可以渲染你自行创建或从LottieFiles库下载的任何 Lottie 文件。在本章的 GitHub 仓库中,你可以找到一个名为winner.json的 Lottie 文件,该文件可用于本项目。此文件必须放置在assets目录中,并且可以通过将组件添加到源代码中由LottieView组件渲染。动画的widthheight值可以通过传递一个style对象来设置。此外,你应该添加autoPlay属性以在组件渲染后开始动画:

      // ...
      export default function Result() {
        const route = useRoute();
        const { winner } = route.params;
        return (
          <View style={styles.container}>
            <Text>You've {winner ? 'won' : 'lost'}</Text>
    +       {winner && (
    +         <LottieView
    +           autoPlay
    +           style={{
    +             width: 300,
    +             height: 300,
    +           }}
    +           source={require('../assets/winner.json')}
    +         />
    +       )}
          </View>
        );
      }
      // ...
    
  8. 作为最后的润色,我们可以在屏幕上显示的消息中添加一些样式,并使其更大:

        // ...  
        return (
          <View style={styles.container}>
    -       <Text>You've {winner ? 'won' : 'lost'}</Text>
    +       <Text style={styles.message}>
              You've {winner ? 'won' : 'lost'}</Text>
            // ...
      const styles = StyleSheet.create({
        // ...
    +   message: {
    +     fontSize: 48,
    +   },
      });
    

Result屏幕组件接收到带有true值的winner参数时,用户将看到渲染的奖杯动画,而不是游戏板。当你使用 iOS 模拟器或 iOS 设备运行应用程序时,这个效果的样子可以在这里看到:

![图 8.5 – 游戏获胜后的 Lottie 动画

![img/Figure_8.05_B17390.jpg]

图 8.5 – 游戏获胜后的 Lottie 动画

注意

如果你觉得这个动画的速度太快,你可以通过结合 Animated API 和 Lottie 来降低速度。LottieView组件可以接受一个progress属性,该属性决定了动画的速度。当你传递由 Animated API 创建的值时,你可以根据你的偏好调整动画的速度。

通过使用 Lottie 添加此动画,我们创建了一个可以玩数小时的动画游戏移动应用程序。

摘要

在本章中,我们使用 Expo 创建了一个 React Native 应用程序。React Native 使用与 React 相同的原理,可以用来创建移动应用程序。我们基于堆栈导航添加了基本的路由,即 React Navigation。我们还向游戏中添加了基本和更复杂的手势,这些手势通过react-native-gesture-handler包在本地线程中运行。最后,我们使用 React Native Animated API 和 Lottie 创建了动画,这些 API 可通过 Expo CLI 获取。

在下一章中,我们将创建一个探索在 React Native 中处理数据的工程项目。我们还将了解 iOS 和 Android 之间在样式上的差异。

进一步阅读

第九章:使用 React Native 和 Expo 构建全栈社交媒体应用程序

在本书中您创建的大多数项目都集中在显示数据和在页面之间导航。当我们使用 React Native 创建第一个移动应用程序时,动画是其中一个重点,这在创建移动应用程序时是必不可少的。在本章中,我们将探讨移动应用程序的一个大优势,即能够使用手机上的相机(或相册)。

本章中我们将创建的应用程序将遵循与之前章节相同的数据密集型应用程序模式。使用 React 技术,如 Context 和 Hooks,从支持身份验证的本地 API 获取数据,同时再次使用 React Navigation 创建更高级的路由设置。此外,使用运行应用程序的移动设备的相机通过 Expo 将图片发布到社交动态。

本章将涵盖以下主题:

  • 具有身份验证的高级路由

  • 在 React Native 和 Expo 中使用相机

  • iOS 和 Android 的样式差异

项目概述

在本章中,我们将构建一个使用本地 API 请求并添加帖子到社交动态的应用程序,包括使用移动设备上的相机。通过本地 API 和 React Navigation 添加了具有身份验证的高级路由,同时使用 Expo 来访问相机(滚动)。

构建时间为 2 小时。

注意

本章使用 React Native 版本 0.64.3 和 Expo SDK 版本 44。由于 React Native 和 Expo 经常更新,请确保您使用这些版本以确保本章中描述的模式按预期运行。

开始

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

您需要在 iOS 或 Android 移动设备上安装 Expo Go 应用程序,以便在物理设备上运行项目。一旦您下载了应用程序,您需要创建一个 Expo 账户以使开发过程更加顺畅。请确保将您的账户详细信息保存在安全的地方,因为您在本章的后续部分需要这些信息。

或者,您可以在计算机上安装 Xcode 或 Android Studio 来在虚拟设备上运行应用程序:

  • 对于 iOS:有关如何设置本地机器以运行 iOS 模拟器的信息,请参阅此处:docs.expo.io/workflow/ios-simulator/

  • 对于 Android:有关如何设置你的本地机器以从 Android Studio 运行模拟器的信息,可以在此处找到:docs.expo.io/workflow/android-studio-emulator/

    注意

    强烈建议使用 Expo 客户端应用程序在物理设备上运行本章中的项目。目前仅支持在物理设备上接收通知,在 iOS 模拟器或 Android Studio 模拟器上运行项目将导致错误信息。

检查初始项目

对于本章,已经使用 Expo 的 CLI 创建了一个初始应用,正如你在上一章所学。要开始,你需要在本章目录下运行以下命令来安装所有依赖项并启动服务器和应用程序:

yarn && yarn start

在安装依赖项后,此命令将启动 Expo,并给你从终端或浏览器启动项目的权限。在终端中,你现在可以使用二维码在你的移动设备上打开应用程序,或者在模拟器中打开应用程序。在浏览器中,将打开 Expo DevTools,这也允许你使用手机摄像头或 Expo Go 应用程序扫描二维码。

为我们的应用程序获取数据的本地 API 是使用 JSON Server 创建的。我们之前已经使用过这个库,因为我们在这个存储库中使用了 db.json 文件。对于这个项目,我们在本章的目录中有一个单独的 db.json 文件,它由 server.js 文件加载以创建本地 API。可以通过在单独的终端标签或窗口中运行以下命令来启动本地 API:

yarn start-server

这将在 http://localhost:3000/api/ 上启动一个服务器,例如,http://localhost:3000/api/posts 端点,它返回一系列帖子。然而,在构建移动应用程序时,出于安全原因,你不能使用 localhost 地址(或任何没有 HTTPS 的其他地址)。为了能够在 React Native 应用程序中使用此端点,你需要找到你机器的本地 IP 地址。

要找到你的本地 IP 地址,你需要根据你的操作系统执行以下操作:

  • 对于 Windows:打开终端(或命令提示符)并运行以下命令:

    Ipconfig
    

这将返回一个类似于以下截图中的列表,其中包含你本地机器的数据。在这个列表中,你需要查找 IPv4 地址 字段:

图 9.1 – 在 Windows 中查找本地 IP 地址

图 9.1 – 在 Windows 中查找本地 IP 地址

  • 对于 macOS:打开终端并运行以下命令:

    ipconfig getifaddr en0
    

运行此命令后,你的机器的本地 IPv4 地址将被返回,看起来像这样:

192.168.1.107

本地 IP 地址可以用作 localhost 的替代品,您可以通过访问以下页面来尝试:http://192.168.1.107/api/posts。请确保将 IP 地址替换为您自己的。

本章的应用程序已经设置好,需要知道用于本地 API 的 URL。在 Expo 中的配置可以存储在 app.json 中,但如果您想存储特定的配置环境变量,也可以存储在 app.config.js 中。在此文件中,您可以添加以下配置:

export default {
    extra: {
      apiUrl: 'http://LOCAL_IP_ADDRESS:3000',
    },
  };

在前面的 app.config.js 文件中,您需要将 LOCAL_IP_ADDRESS 替换为您从您的机器上获取的自己的 IP 地址。

要在我们的代码中使用此环境变量,我们使用 expo-constants 库。这已经在本章的初始应用程序中安装,如何从 app.config.js 获取 apiUrl 的示例可以在 context/PostsContext.js 文件中看到:

import React from 'react';
import { createContext, useReducer } from 'react';
import Constants from 'expo-constants';
const { apiUrl } = Constants.manifest.extra;
export const PostsContext = createContext();
  // ...

apiUrl 常量现在用于获取以下本地 API。无论您是从虚拟设备还是物理设备打开的应用程序,此时初始应用程序应该看起来像这样:

图 9.2 – 初始应用程序

图 9.2 – 初始应用程序

初始应用程序的 screens 目录包含五个屏幕,分别是 PostsPostDetailPostFormProfileLoginPosts 屏幕将是加载的初始屏幕,显示您可以点击以继续到 PostDetail 屏幕的帖子列表。目前,PostFormProfileLogin 屏幕尚未可见,因为我们将在本章后面添加高级路由和身份验证。

从这个 React Native 应用程序的项目结构如下,其中结构与您在这本书中之前创建的项目类似:

chapter-9-initial  
  |- /.expo
  |- /.expo-shared   
  |- /node_modules 
  |- /assets
  |- /components 
     |- Button.js
     |- FormItem.js
     |- PostItem.js 
  |- /context 
     |- AppContext.js 
     |- PostsContext.js 
     |- UserContext.js 
  |- /screens 
     |- Login.js
     |- PostDetail.js 
     |- PostForm.js
     |- Posts.js
     |- Profile.js
  app.config.js
  app.json  
  App.js
  babel.config.js
  db.json
  server.js

assets 目录中,您可以找到在您将此应用程序安装到移动设备上后用作主屏幕应用程序图标的图像,以及将作为启动屏幕显示的图像。App.js 文件是您应用程序的实际入口点,所有此应用程序的组件都位于 screenscomponents 目录中。您还可以找到一个名为 context 的目录。此目录包含此应用程序的所有状态管理组件。

注意

如果在您的本地设备或模拟器上加载应用程序时出现错误,显示 app.config.js。此外,服务器必须在单独的终端标签页中运行。

您的应用程序的配置,例如 App Store,放置在 app.json 中,而 babel.config.js 包含特定的 Babel 配置。如前所述,app.config.js 文件包含本地 API 的 URL 配置。还需要两个文件来创建本地 API。这些是前面在本节中描述的 db.jsonserver.js 文件。

使用 React Native 和 Expo 构建全栈社交媒体应用程序

本章中将要构建的应用程序将使用本地 API 检索和修改应用程序中可用的数据。此应用程序将显示社交媒体源的数据,允许您添加包含图片的新帖子,并允许您对这些社交媒体帖子做出回应。

带有身份验证的高级路由

我们已经学习了如何使用 React Navigation 向 React Native 应用程序添加路由。我们添加的路由是使用堆栈导航器,它没有显示所有路由的某种菜单或导航栏的方式。在本节中,我们将使用 React Navigation 添加标签导航器以在应用程序底部显示标签栏。稍后,我们还将添加身份验证流程。

添加底部标签

底部标签在 iOS 应用程序中很常见,但在 Android 应用程序中则不太受欢迎。在本章的最后部分,我们将了解 iOS 和 Android 之间在样式上的差异。但首先,我们将专注于向我们的应用程序添加底部标签。

要添加标签导航器,我们需要完成以下操作:

  1. React Navigation 有一个用于创建标签导航器的单独库,我们需要从 npm 安装它:

    yarn add @react-navigation/bottom-tabs
    

@react-navigation/bottom-tabs的安装完成时,请确保使用npm start命令重新启动 Expo。

  1. App.js文件中,列出此应用程序的所有路由,我们需要导入创建标签的方法:

      import { StatusBar } from 'expo-status-bar';
      import React from 'react';
      import { NavigationContainer } from 
        '@react-navigation/native';
      import { createStackNavigator } from 
        '@react-navigation/stack';
    + import { createBottomTabNavigator } from 
        '@react-navigation/bottom-tabs';
      // ...
    
  2. 可以使用createBottomTabNavigator方法创建标签导航器。这些导航器屏幕必须在App.js文件中的单独组件内创建,其中PostsPostFormProfile屏幕将被添加到其中。这些屏幕将后来在底部标签中可用。重要的是要传递不显示标题的选项,因为屏幕标题将由父导航器渲染:

      // ...
    + const Tab = createBottomTabNavigator();
    + function Home() {
    +   return (
    +     <Tab.Navigator>
    +       <Stack.Screen 
    +         name='Posts' 
    +         component={Posts} 
    +         options={{ headerShown: false }}
    +       />
    +       <Stack.Screen 
    +         name='Profile' 
    +         component={Profile} 
    +         options={{ headerShown: false }}
    +         />
    +       <Stack.Screen 
    +         name='PostForm' 
    +         component={PostForm} 
    +         options={{ headerShown: false }}
    +       />
    +     </Tab.Navigator>
    +   );
    + }
      export default function App() {
        // ...
    
  3. 要在应用程序中渲染导航器,我们需要将其添加到App组件内的return语句中:

      export default function App() {
        return (
          <AppContext>
            <NavigationContainer>
              <StatusBar style='auto' />
    -         <Stack.Navigator initialRouteName='Posts'>
    -           <Stack.Screen name='Posts' 
                  component={Posts} />
    -           <Stack.Screen name='Profile'
                  component={Profile} />
    -           <Stack.Screen name='PostForm' 
                  component={PostForm} />
    +         <Stack.Navigator initialRouteName='Home'>
    +           <Stack.Screen name='Home' component={Home} />
                <Stack.Screen name='PostDetail'
                  component={PostDetail} />
                <Stack.Screen name='Login' 
                  component={Login} />
              </Stack.Navigator>
            </NavigationContainer>
          </AppContext>
        );
      }
    
  4. 当你现在使用标签导航器导航到任何屏幕时,你会看到标题栏中的标题始终是Home组件,它本身渲染不同的屏幕。我们可以通过使用 React Navigation 中的getFocusedRouteNameFromRoute在主页面的options属性中强制标题为活动标签的标题:

      import { StatusBar } from 'expo-status-bar';
      import React from 'react';
    - import { NavigationContainer } from 
       '@react-navigation/native';
    + import { NavigationContainer,
        getFocusedRouteNameFromRoute }
          from '@react-navigation/native';
    
      // ...
    
      export default function App() {
        return (
          <AppContext>
            <NavigationContainer>
              <StatusBar style='auto' />
              <Stack.Navigator>
                <Stack.Screen
                  name='Home'
                  component={Home}
    +             options={({ route }) => ({
    +               headerTitle:
                      getFocusedRouteNameFromRoute(route),
    +             })}
                />
                <Stack.Screen name='PostDetail'
                 component={PostDetail} />
                <Stack.Screen name='Login'
                 component={Login} />
              </Stack.Navigator>
            </NavigationContainer>
          </AppContext>
        );
      }
    
  5. 底部标签也可以在激活时拥有一个图标和自定义颜色。为此,我们可以修改标签导航器的screenOptions。标签的图标可以从@expo/vector-icons导入,该图标已经包含在 Expo 中:

      import { StatusBar } from 'expo-status-bar';
      + import { FontAwesome } from '@expo/vector-icons';
      import React from 'react';
    
      // ...
    
      function Home() {
        return (
          <Tab.Navigator
    +       screenOptions={({ route }) => ({
    +         tabBarActiveTintColor: 'blue',
    +         tabBarInactiveTintColor: 'gray',
    +         tabBarIcon: ({ color, size }) => {
    +           const iconName =
    +             (route.name === 'Posts' && 'feed') ||
    +             (route.name === 'PostForm' && 
                    'plus-square') ||
    +             (route.name === 'Profile' && 'user');
    +            return <FontAwesome name={iconName} 
                   size={size} color={color} />;
              },
    +       })}
          >
            // ...
          </Tab.Navigator>
        );
      }
      // ...
    
  6. 最后,我们还可以更改标签的标签,例如,对于显示添加新帖子表单的PostForm屏幕:

      // ...
      function Home() {
        return (
          <Tab.Navigator
            // ...
          >
              <Stack.Screen
              name='PostForm'
              component={PostForm}
              options={{
                headerShown: false,
    +           tabBarLabel: 'Add post',
              }}
            />
            <Stack.Screen name='Profile' component={Profile} 
            />
          </Tab.Navigator>
        );
      }
      // ...
    

通过这些更改,应用程序现在具有具有堆栈导航器和标签导航器的路由,看起来应该像这样:

图 9.3 – 带有底部标签的应用程序

图 9.3 – 带有底部标签的应用程序

现在,我们几乎可以到达所有屏幕,只有Login屏幕仍然隐藏。这个屏幕被添加到堆栈导航器中,并且当用户未认证时应显示。在本节的下一部分,我们将添加认证流程来处理这个问题。

认证流程

在前端应用程序中进行身份验证时,大多数情况下使用的是JSON Web Tokens(JWTs),这是一种加密的令牌,可以轻松地与后端共享用户信息。当用户成功认证后,后端会返回 JWT,通常这个令牌会有一个过期日期。对于用户需要认证的每个请求,都应该发送这个令牌,以便后端服务器可以确定用户是否已认证并且允许执行此操作。尽管 JWT 可以用于认证,因为它们是加密的,但不应向其中添加任何私人信息,因为令牌仅应用于认证用户。只有当发送了包含正确 JWT 的文档时,服务器才能发送私人信息。

本章中我们正在构建的移动应用程序仅使用GET请求检索帖子,但本地 API 也支持POST请求。但为了发送POST请求,我们需要进行认证,这意味着我们需要检索一个可以与我们的 API 请求一起发送的令牌。为此,我们可以使用 API 的api/login端点:

  1. Login组件可以用于登录,但目前没有显示。要显示此组件,我们需要更改App.js中堆栈导航器的逻辑。我们需要在这个文件中创建一个新的组件,称为Navigator,而不是让App组件返回堆栈导航器:

      // ...
    + function Navigator() {
    +   return (
    +     <NavigationContainer>
    +       <StatusBar style='auto' />
    +       <Stack.Navigator>
    +         <Stack.Screen name='Login' component={Login}
              />
    +         <Stack.Screen
    +           name='Home'
    +           component={Home}
    +           options={({ route }) => ({
    +             headerTitle:  
                    getFocusedRouteNameFromRoute(route),
    +           })}
    +         />
    +         <Stack.Screen name='PostDetail'
                component={PostDetail} />
    +       </Stack.Navigator>
    +     </NavigationContainer>
    +   );
    + }
      export default function App() {
        // ...
    
  2. 上述代码块可以从App中删除,并用这个新的Navigator组件替换:

      // ...
      export default function App() {
        return (
          <AppContext>
    -       // ...
    +       <Navigator />
          </AppContext>
        );
      }
    
  3. 我们还需要检查Navigator组件中令牌的值,因为我们不希望在未提供令牌时包含主页。登录的逻辑已经存在于context/UserContext.js文件中的UserContext中,并且可以从Navigator组件中获取此上下文中的user对象:

      import { StatusBar } from 'expo-status-bar';
      import { FontAwesome } from '@expo/vector-icons';
    - import React from 'react';
    + import React, { useContext } from 'react';
      // ...
      import AppContext from './context/AppContext';
    + import UserContext from './context/UserContext';
      const Stack = createStackNavigator();
      const Tab = createBottomTabNavigator();
      function Home() {
        // ...
    
  4. 现在,我们可以从上下文中获取user对象,并添加逻辑以在不存在令牌时仅返回Login屏幕:

      // ...
      function Navigator() {
    +   const { user } = useContext(UserContext);
        return (
          <NavigationContainer>
            <StatusBar style='auto' />
    -       <Stack.Navigator>
    +       <Stack.Navigator initialRouteName=
              {user.token.length ? 'Home' : 'Login'}>
              <Stack.Screen
                   name='Home'
                   // ...
                  />
                  <Stack.Screen name='PostDetail'
                   component={PostDetail} />
    <Stack.Screen name='Login' 
                   component={Login} />
                )}
              </Stack.Navigator>
            </NavigationContainer>
          );
        }
        export default function App() {
        // ...
    
  5. 如果你现在刷新应用程序,你可以看到正在显示的Login组件。你可以使用用户名和密码组合登录,这两个值都是test。登录后,我们希望导航到主页,为此我们需要在screens/Login.js中做出更改:

    + import { useNavigation } from 
       '@react-navigation/core';
    + import React, { useContext, useState } from 'react';
    - import React, { useContext, useEffect, useState } 
        from 'react';
      // ...
      export default function Login() {
        const [username, setUsername] = useState('');
        const [password, setPassword] = useState('');
    -    const { error, loginUser } = 
           useContext(UserContext);
    +    const { user, error, loginUser } = 
           useContext(UserContext);
    +   const navigation = useNavigation();
    +   useEffect(() => {
    +     if (user.token) {
    +       navigation.navigate('Home');
    +     }
    +   }, [user.token]);
        return (
         // ...
    

当上下文中user对象的token值发生变化时,用户现在将被导航到主页。这可以通过使用用户名和密码组合登录来显示,这两个值都是test。如果你输入了错误值,你会看到错误信息,就像这里所显示的:

图 9.4 – 处理认证

图 9.4 – 处理认证

然而,由于在重新加载应用程序时上下文会被恢复,令牌并没有被持久化。对于 Web 应用程序,我们可以使用localStoragesessionStorage。但对于移动应用程序,你需要使用 React Native 的AsyncStorage库来在 iOS 和 Android 上实现持久化存储。在 iOS 上,它将使用原生代码块为你提供AsyncStorage提供的全局持久化存储,而在运行 Android 的设备上,将使用基于 RocksDB 或 SQLite 的存储。

注意

对于更复杂的用法,建议在AsyncStorage之上使用抽象层,因为默认情况下不支持加密。此外,如果你想要使用AsyncStorage存储大量信息,键值系统可能会给你带来性能问题。iOS 和 Android 都会对每个应用程序可以使用的存储量设置限制。

要添加用户令牌的持久性,我们需要从 Expo 安装正确的库并对上下文进行修改:

  1. 我们可以通过运行以下命令从 Expo 安装AsyncStorage

    expo install @react-native-async-storage/async-storage
    
  2. 为了持久化,可以在context/UserContext.js文件中的UserContext中导入AsyncStorage令牌:

      import React, { createContext, useReducer } 
        from 'react';
    + import AsyncStorage from 
       '@react-native-community/async-storage';
      import Constants from 'expo-constants';
      // ...
    
  3. 在同一文件中,在将其添加到上下文后,可以使用AsyncStorage来存储令牌:

      // ...
      export const UserContextProvider = ({ children }) => {
        const [state, dispatch] = useReducer(reducer,
          initialState);
        async function loginUser(username, password) {
          try {
            // ...
            if (result) {
              dispatch({ type: 'SET_USER_TOKEN',
                         payload: result.token });
    +         AsyncStorage.setItem('token', result.token);
            }
          } catch (e) {
            dispatch({ type: 'SET_USER_ERROR',
                       payload: e.message });
          }
        }
        // ...
    
  4. 现在令牌从本地 API 检索后已持久化,也可以从AsyncStorage中检索。因此,我们需要创建一个新的函数来检索令牌并将其添加到上下文中:

        // ...  
    +   async function getToken() {
    +     try {
    +       const token = 
              await AsyncStorage.getItem('token');
    +       if (token !== null) {
    +         dispatch({ type: 'SET_USER_TOKEN', 
                        payload: token });
    +       }
    +     } catch (e) {}
    +   }
        return (
    -     <UserContext.Provider value={{ ...state, 
            loginUser, logoutUser }}>
    +     <UserContext.Provider value={{ ...state, 
            loginUser, logoutUser, getToken }}>
            {children}
          </UserContext.Provider>
        );
      };
      export default UserContext;
    
  5. 最后,当应用程序首次渲染时,需要从App.js调用此函数。这样,当应用程序启动或刷新时,你将获得令牌,并且认证会被持久化:

      import { StatusBar } from 'expo-status-bar';
      import { FontAwesome } from '@expo/vector-icons';
    - import React, { useContext } from 'react';
    + import React, { useContext, useEffect } from 'react';
      // ...
      function Navigator() {
    -   const { user } = useContext(UserContext);
    +   const { user, getToken } =
          useContext(UserContext);
    +   useEffect(() => {
    +     getToken();
    +   }, []);
        return (
          // ...
    
  6. 登录一次后,令牌现在已持久化,当应用程序加载时将跳过Login屏幕,并且AsyncStorage中存在令牌。然而,由于令牌已持久化,我们还需要一种注销并删除令牌的方法。在context/UserContext.js文件中,必须修改logoutUser函数:

      // ...
      async function logoutUser() {
    +   try {
    +     await AsyncStorage.removeItem('token');
          dispatch({ type: 'REMOVE_USER_TOKEN' });
    +   } catch (e) { }
      }
      async function getToken() {
        // ...
    

当你现在转到Profile屏幕并点击AsyncStorage和应用程序状态时,我们需要将用户导航回Login屏幕。在不同嵌套导航器之间导航的演示将在本节的下一部分进行。

注意

在使用 iOS 或 Android 手机时,要重新加载 Expo Go 中的应用程序,你可以摇晃设备。通过摇晃设备,会出现一个菜单,其中包含重新加载应用程序的选项。在这个菜单中,你还必须选择启用 快速刷新,以便在修改代码时自动刷新应用程序。

在嵌套路由之间导航

在 React Navigation 中,我们可以嵌套不同的导航器,例如在应用程序启动时渲染的堆栈导航器,显示 Login 屏幕或标签导航器。从嵌套导航器中,无法直接导航到父导航器,因为无法访问父导航器的 navigation 对象。但幸运的是,我们可以使用一个 ref 来创建对“最高”导航器的引用。从这个引用,我们可以访问 navigation 对象,否则我们会使用 useNavigation 钩子来访问。为了在我们的应用程序中实现这一点,我们需要更改以下内容:

  1. 创建一个名为 routing.js 的新文件,并包含以下内容:

    import React, { createRef } from 'react';
    export const navigationRef = createRef(); 
    
  2. 这个 navigationRef 可以在 App.js 中导入,并将其附加到 App 组件中的 NavigationContainer

      // ...
      import AppContext from './context/AppContext';
      import UserContext from './context/UserContext';
    + import { navigationRef } from './routing';
    
       // ...
      function Navigator() {
        const { user, getToken } = 
          useContext(UserContext);
        // ...
        return (
    -     <NavigationContainer>
    +     <NavigationContainer ref={navigationRef}>
            // ...
    
  3. 包含 Login 屏幕的堆栈导航器的 navigation 对象现在可以通过 screens/Profile.js 中的 Profile 屏幕的此 ref 访问。使用 reset 方法,我们可以重置整个 navigation 对象并导航到 Login 屏幕:

      // ...
    + import { navigationRef } from '../routing';
      export default function Profile() {
        const { logoutUser } = useContext(UserContext);
        return (
          <View style={styles.container}>
            <Button
              onPress={() => {
                logoutUser();
    +           navigationRef.current.reset({
    +             index: 0,
    +             routes: [{ name: 'Login' }],
    +           });
              }}
              label='Logout'
            />
          </View>
        );
      }
      // ...
    

用户认证处理完毕后,我们可以在下一节继续添加创建带有图片的新帖子的功能。

使用 React Native 和 Expo 的相机

除了显示已添加到本地 API 的帖子外,您还可以使用 POST 请求添加帖子,并发送文本和图片作为变量。将图片上传到您的 React Native 应用程序可以通过使用相机拍照或从相册中选择图片来实现。对于这两种用例,React Native 和 Expo 都提供了 API,或者可以从 npm 安装大量可安装的包。对于这个项目,您将使用来自 Expo 的 ImagePicker API,它将这些功能合并到一个组件中。

要将创建新帖子功能添加到您的社交媒体应用程序中,需要对创建添加帖子的新屏幕进行以下更改:

  1. 我们需要从 Expo 安装一个库,以便我们可以在任何设备上访问相册:

    expo install expo-image-picker
    
  2. 要使用相册,我们需要使用在 screens/PostForm.js 文件中导入的 ImagePicker 库请求 CAMERA_ROLL 权限:

      import React, { useContext, useState } from 'react';
      import { StyleSheet, TouchableOpacity, View, Text,
        KeyboardAvoidingView, Platform, Alert, Image } from 
          'react-native';
    + import * as ImagePicker from 'expo-image-picker';
      // ...
      export default function PostForm() {
        // ...
    +   async function uploadImage() {
    +     const { status } = await ImagePicker
            .requestMediaLibraryPermissionsAsync();
    +     if (status !== 'granted') {
    +       Alert.alert('Sorry', 'We need camera roll 
              permissions to make this work!');
    +     }
    +   }
        return (
          // ...
    
  3. 然后,需要将这个 uploadImage 函数添加到同一文件中的 TouchableOpacity 组件:

      // ...
      return (
        <KeyboardAvoidingView
          behavior={Platform.OS == 'ios' ? 'padding' :
         'height'}
          style={styles.container}
        >
          <View style={styles.form}>
            <TouchableOpacity
    +         onPress={() => uploadImage()}
              style={styles.imageButton}
            >
              <Text style={styles.imageButtonText}>+
              </Text>
            </TouchableOpacity>
            // ...
    
  4. 当您现在按下此屏幕上添加帖子的按钮时,将显示一个弹出窗口,要求给予 Expo Go 访问相册的权限。此外,请注意,在此页面上,我们不是使用 View 组件来包装屏幕,而是使用 KeyboardAvoidingView 组件。这确保了当您在输入时,此屏幕上的组件不会被键盘隐藏。

    注意

    您不能再次请求用户权限;相反,您需要手动授予相册权限。要再次设置此权限,您应该在 iOS 的设置屏幕上选择 Expo 应用程序。在下一屏幕上,您能够添加访问相册的权限。

  5. 当用户已授予访问相册的权限时,您可以使用来自 Expo 的 ImagePicker API 打开相册。这同样是一个异步函数,它接受一些配置字段,例如宽高比。如果用户已选择了一张图片,ImagePicker API 将返回一个包含字段 URI 的对象,这是用户设备上图片的 URL:

      // ...
      async function uploadImage() {
        const { status } = await ImagePicker
          .requestMediaLibraryPermissionsAsync();
        if (status !== 'granted') {
          Alert.alert(
            'Sorry',
            'We need camera roll permissions to make this 
             work!',
          );
    +   } else {
    +     const result = 
            await ImagePicker.launchImageLibraryAsync({
    +       mediaTypes: ImagePicker.MediaTypeOptions.All,
    +       allowsEditing: true,
    +       aspect: [4, 3],
    +       quality: 1,
    +     });
    +     if (!result.cancelled) {
    +       setImageUrl(result.uri);
    +     }
        }
      }
      return (
        // ...
    
  6. 由于图像的 URL 现在存储在本地状态中的 imageUrl 常量中,您可以在 Image 组件中显示此 URL。此 Image 组件将 imageUrl 作为源值,并已设置为使用 100% 的 widthheight

      // ...
      return (
        <KeyboardAvoidingView
          behavior={Platform.OS == 'ios' ? 'padding' :
            'height'}
          style={styles.container}
        >
          <View style={styles.form}>
            <TouchableOpacity onPress={() =>
              uploadImage()} style={styles.imageButton}>
    +         {imageUrl.length ? (
    +           <Image
    +             source={{ uri: imageUrl }}
    +             style={{ width: '100%', height: '100%' 
                        }}
    +           />
    +         ) : (
                <Text style={styles.imageButtonText}>+</Text>
    +         )}
            </TouchableOpacity>
            // ...
    

通过这些更改,AddPost 屏幕应该看起来像以下截图,这些截图是从运行 iOS 的设备上拍摄的。如果您使用的是 Android Studio 模拟器或运行 Android 的设备,此屏幕的外观可能会有细微差异:

![Figure 9.5 – 使用相册

![img/Figure_9.05_B17390.jpg]

图 9.5 – 使用相册

这些更改将使您能够从相册中选择照片,但您的用户也应该能够通过使用他们的相机上传全新的照片。使用来自 Expo 的 ImagePicker API,您可以处理这两种情况,因为该组件还有一个 launchCameraAsync 方法。这个异步函数将启动相机,并以相同的方式返回相机相册中的图片的 URL。

要添加直接使用用户设备上的相机上传图片的功能,您可以进行以下更改:

  1. 当用户点击图像占位符时,默认情况下将打开图片库。但您也希望给用户选择使用相机的选项。因此,必须在使用相机或相册上传图片之间做出选择,这是一个实现 ActionSheet 组件的完美用例。React Native 和 Expo 都有一个 ActionSheet 组件;建议使用来自 Expo 的组件,因为它将在 iOS 上使用本地的 UIActionSheet 组件,在 Android 上使用 JavaScript 实现:

    yarn add @expo/react-native-action-sheet
    
  2. 在此之后,我们需要在 App.js 文件中导入 ActionSheetProvider@expo/react-native-action-sheet

      import { StatusBar } from 'expo-status-bar';
      import { FontAwesome } from '@expo/vector-icons';
      import React, { useContext, useEffect } from 'react';
      import { NavigationContainer, 
        getFocusedRouteNameFromRoute } 
        from '@react-navigation/native';
      import { createNativeStackNavigator } 
        from '@react-navigation/native-stack';
      import { createBottomTabNavigator } 
        from '@react-navigation/bottom-tabs';
    + import { ActionSheetProvider } 
        from '@expo/react-native-action-sheet';
      // ...
    
  3. 我们将包含PostForm屏幕的导航器包裹在这个相同的文件中,这样我们就可以在该屏幕组件中使用 Hook 创建操作表:

      function Home() {
        return (
    +     <ActionSheetProvider>
            // ...
    +     </ActionSheetProvider>
        );
      }
      function Navigator() {
        // ...
    
  4. screens/PostForm.js文件中,我们现在可以导入 Hook 来从@expo/react-native-action-sheet创建操作表:

      // ...
      import * as ImagePicker from 'expo-image-picker';
    + import { useActionSheet } from 
       '@expo/react-native-action-sheet';
      import { useNavigation } from '@react-navigation/core';
      import Button from '../components/Button';
      import FormInput from '../components/FormInput';
      import PostsContext from '../context/PostsContext';
      export default function PostForm() {
        // ...
    
  5. 要添加操作表,必须添加一个打开此ActionSheet的函数,并使用showActionSheetWithOptions属性和选项来构建ActionSheet。选项是相机相册取消,根据按下的按钮的索引,应调用不同的函数:

      // ...
      export default function PostForm() {
        // ...
        const { addPost } = useContext(PostsContext);
        const navigation = useNavigation();
    +   const { showActionSheetWithOptions } = 
          useActionSheet();
        // ...
    +   function openActionSheet() {
    +     const options = ['Camera roll', 'Camera',
            'Cancel'];
    +     const cancelButtonIndex = 2;
    +     showActionSheetWithOptions(
    +       { options, cancelButtonIndex },
    +       (buttonIndex) => {
    +         if (buttonIndex === 0) {
    +           uploadImage()
    +         }
    +       },
    +     );
    +   }
        return (
          // ...
    
  6. buttonIndex为 0 时,将调用请求访问相册并从中选择图片的函数,但我们还需要一个请求相机权限并使用相机的函数:

      // ...
    + async function takePicture() {
    +   const { status } = await 
          ImagePicker.requestCameraPermissionsAsync();
    +   if (status !== 'granted') {
    +     Alert.alert('Sorry', 'We need camera permissions 
            to make this work!');
    +   } else {
    +     const result = 
            await ImagePicker. launchCameraAsync ({
    +       mediaTypes: ImagePicker.MediaTypeOptions.All,
    +       aspect: [4, 3],
    +       quality: 1,
    +     });
    +     if (!result.cancelled) {
    +       setImageUrl(result.uri);
    +     }
    +   }
    + }
      function openActionSheet() {
        // ...
    
  7. 最后,必须将打开操作表的openActionSheet函数附加到TouchableOpacity组件:

      // ...
      return (
        <KeyboardAvoidingView
          behavior={Platform.OS == 'ios' ? 'padding' : 
            'height'}
          style={styles.container}
        >
          <View style={styles.form}>
            <TouchableOpacity
    -         onPress={() => uploadImage()}
    +         onPress={() => openActionSheet()}
              style={styles.imageButton}
            >
            // ...
    

按压图像占位符现在将打开操作表以选择您是想使用相册还是相机来选择图像:

图 9.6 – iOS 上的操作表

图 9.6 – iOS 上的操作表

您的帖子及图片现在将显示在帖子屏幕的顶部,这意味着您已成功添加了帖子。在本章的最后部分,我们将探讨该应用在 iOS 和 Android 之间在样式上的差异。

iOS 和 Android 的样式差异

在设计您的应用时,您可能希望为 iOS 和 Android 设置不同的样式规则,例如,以更好地匹配 Android 操作系统的样式。有多种方法可以将不同的样式规则应用于不同的平台;其中之一是通过使用Platform模块,该模块可以从 React Native 导入。

此模块已经在本应用的某些部分中使用,但让我们通过在导航器的标签中根据设备的操作系统添加不同的图标来更详细地了解其工作原理:

  1. App.js中,我们已经从 Expo 导入了FontAwesome图标,但对于 Android,我们希望导入MaterialIcons以便它们可以显示。此外,我们还需要从 React Native 导入Platform

      import { StatusBar } from 'expo-status-bar';
      import { 
        FontAwesome,
    +   MaterialIcons, 
      } from '@expo/vector-icons';
      import React, { useContext, useEffect } 
        from 'react';
    + import { Platform } from 'react-native';
      // ...
    
  2. 使用Platform模块,您可以通过检查Platform.OS的值是否为iosandroid来检查您的移动设备是否正在运行 iOS 或 Android。该模块必须在标签导航器中使用,这样我们就可以在这两个平台之间做出区分:

      // ...
      function Home() {
        return (
          <ActionSheetProvider>
            <Tab.Navigator
              // ...
              screenOptions={({ route }) => ({
                tabBarIcon: ({ color, size }) => {
                  // ...
    -             return <FontAwesome name={iconName} 
                    size={size} color={color} />;
    +             return Platform.OS === 'ios' ? (
    +               <FontAwesome name={iconName} 
                     size={size} color={color} />
    +             ) : (
    +               <MaterialIcons name={iconName} 
                      size={size} color={color} />
    +           );
              },
            })}
          >
          // ...
    
  3. 这将用MaterialIcons替换 Android 上的FontAwesome图标。此图标库为图标使用不同的名称,因此我们还需要进行以下更改:

      // ...
      function Home() {
        return (
          <ActionSheetProvider>
            <Tab.Navigator
              // ...
              screenOptions={({ route }) => ({
                tabBarIcon: ({ color, size }) => {
                  const iconName =
    -               (route.name === 'Posts' && 'feed') ||
    -               (route.name === 'PostForm' && 
                     'plus-square') ||
    -               (route.name === 'Profile' && 'user');
    +               (route.name === 'Posts' &&
    +                 (Platform.OS === 'ios' ? 'feed' :
                       'rss-feed')) ||
    +               (route.name === 'PostForm' &&
    +                 (Platform.OS === 'ios' ? 
                       'plus-square' : 'add-box')) ||
    +               (route.name === 'Profile' &&
                      (Platform.OS === 'ios' ? 'user' :
                       'person'));
                  return Platform.OS === 'ios' ? (
                    // ...
    

当您在 Android 移动设备上运行应用程序时,导航标签将显示基于 Material Design 的图标。如果您使用的是苹果设备,它将显示不同的图标;您可以将 Platform.OS === 'ios' 条件更改为 Platform.OS === 'android',以将 Material Design 图标添加到 iOS。如果您还没有看到任何变化,请尝试在您的设备上重新加载应用程序。

  1. 我们还可以直接在 StyleSheet 中使用 Platform 模块,例如,更改我们应用程序中 Button 组件的颜色。默认情况下,我们的 Button 组件具有蓝色背景,但让我们将其在 Android 上更改为紫色。在 components/Button.js 中,我们需要导入 Platform 模块:

      import React from 'react';
      import {
        StyleSheet,
        TouchableOpacity,
        View,
        Text,
    +   Platform,
      } from 'react-native';
      export default function Button({ onPress, label }) {
        // ...
    
  2. 我们在创建 StyleSheet 的过程中使用 select 方法:

      // ... 
      const styles = StyleSheet.create({
        button: {
          width: '100%',
          padding: 20,
          borderRadius: 5,
    -     backgroundColor: 'blue',
    +     ...Platform.select({
    +       ios: {
    +         backgroundColor: 'blue',
    +       },
    +       android: {
    +         backgroundColor: 'purple',
    +       },
          }),
        },
        // ...
    

另一个可以在 iOS 和 Android 之间以不同方式样式的组件是 PostItem 组件。如前所述,有多种方法可以做到这一点;除了使用 Platform 模块外,您还可以使用平台特定的文件扩展名。任何具有 *.ios.js*.android.js 扩展名的文件都只会在扩展名指定的平台上渲染。您不仅可以应用不同的样式规则,还可以在不同的平台上进行功能上的更改:

  1. 将当前的 components/PostItem.js 文件重命名为 components/PostItem.android.js,并创建一个名为 components/PostItem.ios.js 的新文件,其内容如下:

    import React from 'react';
    import { StyleSheet, Text, Dimensions, Image, View }
      from 'react-native';
    const PostItem = ({ data }) => (
      <View style={styles.container}>
        <View style={styles.details}>
          <Text>{data.description}</Text>
        </View>
        <Image source={{ uri: data.imageUrl }} 
         style={styles.thumbnail} />
      </View>
    );
    
  2. 这将改变 iOS 上帖子标题和图片的顺序,显示标题在图片上方。此外,我们还需要在文件末尾添加以下样式:

    // ...
    const styles = StyleSheet.create({
      container: {
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'flex-start',
        backgroundColor: 'white',
        borderWidth: 1,
        borderColor: '#ccc',
        marginBottom: '2%',
      },
      thumbnail: {
        width: Dimensions.get('window').width * 0.98,
        height: Dimensions.get('window').width * 0.98,
        margin: Dimensions.get('window').width * 0.01,
      },
      details: {
        width: '95%',
        margin: '2%',
      },
    });
    export default PostItem;
    
  3. 在 iOS 上,我们希望显示一个阴影而不是围绕此组件的边框。为了添加此阴影,我们需要更改组件的样式:

    // ...
    const styles = StyleSheet.create({
      container: {
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'flex-start',
        backgroundColor: 'white',
    shadowRadius rule, while Android uses the elevation rule. 
    
  4. 最后,我们还需要更改图像的尺寸,因为我们已经向 container 样式添加了边距:

    // ...
    const styles = StyleSheet.create({
      // ...
      thumbnail: {
    -   width: Dimensions.get('window').width * 0.98,
    -   height: Dimensions.get('window').width * 0.98,
    +   width: Dimensions.get('window').width * 0.94,
    +   height: Dimensions.get('window').width * 0.94,
        margin: Dimensions.get('window').width * 0.01,
      },
    

这将在 iOS 和 Android 上产生以下结果,其中边框已被阴影取代:

图 9.7 – iOS 和 Android 上的样式差异

图 9.7 – iOS 和 Android 上的样式差异

根据您的手机类型,您也可以将此文件从 components/PostItem.ios.js 重命名为 components/PostItem.android.js,以在 Android 上看到相同的更改。

就这样。通过这些最终更改,您已经创建了一个将在 Android 和 iOS 设备上运行的 React Native 应用程序,并且这两个平台之间存在样式差异。

摘要

在本章中,您已经使用 React Native 和 Expo 创建了一个移动社交媒体应用程序,该应用程序使用本地 API 发送和接收数据,同时也用于身份验证。为了处理身份验证,结合了多种类型的导航器。在获得使用权限后,我们学习了如何使用移动设备的相机和相册。同时,还解释了 iOS 和 Android 之间在样式上的差异。

在完成这个社交媒体应用后,你已经完成了本书的最后一章 React Native 章节,现在可以开始阅读最后一章了。在最后一章中,你将探索 React 的另一个用例,即 VR。通过将 React 与 Three.js 结合,你可以通过编写 React 组件来创建 360 度的 2D 和 3D 体验。

进一步阅读

第十章:使用 React 和 Three.js 创建虚拟现实应用程序

您几乎完成了——只需再有一章,您就可以自称是一个在所有平台上都经历过 React 的 React 专家。在整个本书中,您已经使用 React 和 React Native 构建了九个应用程序。在本章的最后一章中,我们不会创建一个 Web 或移动应用程序,而是一个 虚拟现实VR)应用程序,使用 React 和 three.js。使用 three.js,您可以使用 JavaScript 创建动态的 2D、3D 和 VR 体验,并通过使用另一个库将其应用于 React。尽管 VR 仍然是一种新兴技术,但 VR 的最佳用例,例如,零售店希望让客户体验他们的商店或在线游戏。

在本章中,您将探索使用 React 和 three.js 可以实现的基本功能,以及它与 React 的关系。您将构建的应用程序将能够渲染 360 度全景图像,并使用状态管理在屏幕之间进行渲染。通过结合 React 和 three.js 以及其他库,还将显示动画 3D 对象。

本章将涵盖以下主题:

  • 开始使用 three.js

  • 使用 React 和 three.js 创建全景查看器

  • 动画 3D 对象

项目概述

在本章中,您将使用 React 和 three.js 构建一个 VR 应用程序,该应用程序结合了 JavaScript 和 React 的原则。该应用程序将添加 2D 全景图像和 3D 对象,并且可以使用 Create React App 在浏览器中运行该项目。

构建时间为 1.5 小时。

开始

本章的应用程序将从头开始构建,并使用可在 GitHub 上找到的资产 github.com/PacktPublishing/React-Projects-Second-Edition/tree/main/Chapter10-assets。应将这些资产下载到您的计算机上,以便您可以在本章的后续部分使用它们。本章的完整代码可在 GitHub 上找到 github.com/PacktPublishing/React-Projects-Second-Edition/tree/main/Chapter10

使用 React 和 Three.js 创建 VR 应用程序

您可以通过与其他库结合使用 React 来编写 2D 和 3D VR 应用程序。以前,您可以直接使用 React 360 在 React 中编写 VR 应用程序。但由于其他流行库的出现,例如基于 JavaScript 的 three.js,其开发已被停止。Three.js 允许您创建具有 2D 和 3D UI 组件的应用程序,而无需处理移动或 VR 设备的复杂设置,这与 React 的工作方式类似。

为了在浏览器中渲染 2D 和 3D,three.js 使用 WebGL,这是一个在浏览器中直接运行的 JavaScript API。它被所有流行的浏览器最新版本所支持,例如 Chrome、Firefox 和 Microsoft Edge。

开始使用 Three.js

Three.js 是基于 JavaScript 开发的,并且可以使用名为 @react-three/fiber 的不同库与 React 一起使用,这是一个为 three.js 设计的 React 渲染器,它建立了两者之间的联系。

正如我们在之前的浏览器中渲染的 React 应用程序中所做的那样,我们可以使用 Create React App 作为此应用的起点。要开始使用 three.js 在 React 中构建 2D 和 3D 应用程序,我们首先需要使用 Create React App 创建一个新的项目:

npx create-react-app chapter-10

其次,我们需要从 npm 安装 three.js 和 @react-three/fiber

npm install three @react-three/fiber

我们不需要任何额外的依赖项或配置,因为 Create React App 已经提供了正确的配置。如果我们进入项目根目录,该目录以我们的项目名称命名,我们会看到它具有以下结构:

chapter-10
  |- /node_modules
  |- package.json
  |- /public
     |- index.html
  |- /src
    |- App.css
    |- App.js
    |- index.css
    |- index.js

注意

并非所有由 Create React App 创建的文件都在上面提到;相反,这里只列出了本章中使用的文件。

使用 Three.js 创建 3D 对象

使用 Create React App 创建的应用程序基础已经建立,并且我们已经安装了 three.js 以及 @react-three/fiber。这个最后的库允许我们在 React 中将 three.js 元素作为组件渲染,并提供了多个 Hooks 来进行更改。这样,我们就可以以我们已从学习 React 中熟悉的声明性和预测性方式使用 three.js。与直接使用 three.js 相比,使用此库不会产生额外的开销,因为组件是在 React 的渲染循环之外渲染的。

要在 React 中使用 three.js 创建 3D 对象,我们需要采取以下步骤:

  1. src/App.js 的内容替换为以下内容,以便它从 @react-three/fiber 返回一个 Canvas 组件:

    import { Canvas } from '@react-three/fiber';
    import './App.css';
    export default function App() {
      return (
        <div id="Canvas-container">
          <Canvas><Canvas />
        </div>
      )
    }
    

此代码添加了一个 Canvas 组件,这在我们要在 React 中渲染 three.js 元素时非常重要。有了 Canvas 组件,它将渲染我们的 three.js 组件和元素,并且位于 DOM 之外,并自动处理调整大小。

  1. Canvas 组件将被调整大小以适应其渲染的 div 元素,因此您可以通过更改 CSS 中 #canvas-container 的宽度和高度来控制其大小。这可以通过用以下内容替换 src/App.css 中的内容来完成:

    #Canvas-container {
      height: 100vh;
    }
    
  2. 要在 Canvas 上渲染内容,我们需要向此文件添加一个网格元素,我们不需要导入任何内容。就像我们可以在 React 中添加 div 或其他任何元素一样,当 three.js 元素放置在 @react-three/fiberCanvas 中时,它们将被自动视为 JSX 元素:

      import { Canvas } from '@react-three/fiber'
      export default function App() {
        return (
          <div id="Canvas-container">
            <Canvas>
    mesh element inside a Canvas component from @react-three/fiber, under the hood, it will create a THREE.Mesh object.
    
  3. 这将使用 three.js 的 boxGeometry 元素渲染一个小灰色方块,但目前还没有其他功能。此外,我们现在拥有的方块相当小。通过向 mesh 元素添加 scale 属性,我们可以增加此元素的大小:

      // ...
      export default function App() {
        return (
          <div id="Canvas-container">
            <Canvas>
    -         <mesh>
    +         <mesh scale={2}>
                <boxGeometry />
                // ...
    
  4. 为了给元素添加一些颜色,我们首先需要在 mesh 元素内添加一个带有color prop 的meshStandardMaterial,并添加另一个名为ambientLight的元素。此元素将为组件添加光照,使boxGeometry的颜色可见。在这个ambientLight元素上,我们可以使用intensity prop 来配置光必须有多亮:

      // ...
      export default function App() {
        return (
          <div id='canvas-container'>
            <Canvas>
              <mesh scale={2}>
                <boxGeometry />
    +           <meshStandardMaterial color='blue' />
    +           <ambientLight intensity={0.5} />
              </mesh>
            </Canvas>
          </div>
        );
      }
    

在我们的应用程序中,我们现在可以看到一个蓝色方块被渲染,而不是灰色方块。你可以通过改变ambientLight元素的强度来看到效果,根据强度的值,方块会变亮或变暗。

有一个 2D 方块很酷,但使用 three.js,我们还可以构建 3D 组件。为此,我们需要对组件进行一些修改,以便直接在 React 之外与 three.js 交互,以防止性能问题。为了使元素成为 3D,需要进行以下更改:

  1. 让我们为boxGeometry元素创建一个单独的组件,这样我们就可以分离关注点并使其可重用。我们可以在src目录下的一个名为components的新目录中创建一个名为Box.js的新文件来完成此操作:

    export default function Box() {
      return (
        <mesh scale={2}>
          <boxGeometry />
          <meshStandardMaterial color='blue' />
        </mesh>
      );
    }
    
  2. 我们需要给mesh添加一个ref,这样我们就可以在 React 的作用域之外访问它,并使用 three.js 来修改它:

    + import { useRef } from 'react';
      export default function Box() {
    +   const mesh = useRef();
    
        return (
          <mesh
            scale={2}
    +       ref={mesh}
          >
            <boxGeometry />
            <meshStandardMaterial color='blue' />
          </mesh>
        );
      }
    
  3. 使用 three.js 修改mesh元素可以通过更改我们现在可以通过ref访问的mesh的值来完成。这些更改必须在@react-three/fiberuseFrame Hook 内完成,该 Hook 由 three.js 在每一帧渲染时触发。当渲染新帧时,我们可以稍微改变网格的旋转,使其旋转:

      import { useRef } from 'react';
    Box component continuously on every frame render, you can also use a useEffect Hook to rotate the mesh just once on the initial render or on a set interval.
    
  4. src/App.js文件中,我们需要将boxGeometry元素替换为这个新组件,以便在应用程序中使其可见:

      import { Canvas } from '@react-three/fiber';
    + import Box from './components/Box';
      export default function App() {
        return (
          <div id='canvas-container'>
            <Canvas>
    -         <mesh>
    -           <boxGeometry />
    -           <meshStandardMaterial color='blue' /> */}
    +           <Box />
                <ambientLight intensity={0.5} />
    -          </mesh>
            </Canvas>
          </div>
        );
      }
    
  5. 最后,我们需要添加两个额外的光源元素来突出显示我们正在渲染 3D 元素,这些是spotLightpointLight元素:

      // ...
      export default function App() {
        return (
          <div id='canvas-container'>
            <Canvas>
              <Box />
              <ambientLight intensity={0.5} />
    position is an array with three numbers. These numbers represent a Vector3 position. This is a format to describe the position of an object in 3D space where the numbers are the *x*, *y*, and *z* values.
    

通过在浏览器中打开应用程序,你现在可以看到一个正在 3D 中旋转的蓝色方块:

图 10.1 – 使用 Three.js 渲染 3D 元素

我们可以使用 three.js 做的一些其他事情是使用我们的鼠标控制Canvas。现在Box组件在每一帧渲染时都会旋转,但我们可以使用 three.js 控制整个Canvas的旋转。Canvas组件已经设置了一个相机,我们可以使用 three.js 中的OrbitControls组件来控制它。为了在我们的应用程序中实现这一点,我们需要做以下操作:

  1. 通过添加一个名为rotate的 prop 来禁用Box组件的旋转,该 prop 可以是truefalse。如果没有提供值,默认值将是false,这意味着Box组件不会旋转:

      // ...
    - export default function Box() {
    + export default function Box({ rotate  = false }) {
        const mesh = useRef();
        useFrame(() => {
    +     if (rotate) {
            mesh.current.rotation.x = 
            mesh.current.rotation.y += 0.01;
    +     }
        });
        return (
          // ...
    
  2. src/App.js文件中,我们不需要设置这个属性,因为我们不希望Box组件旋转。相反,我们将在src/components/Controls.js文件中创建一个新的组件来控制整个Canvas的旋转,从而控制应用程序的相机。为此,我们需要将以下内容添加到这个文件中:

    import { useEffect } from 'react';
    import { useThree } from '@react-three/fiber';
    import { OrbitControls } from 
      'three/examples/jsm/controls/OrbitControls';
    export default function Controls() {
      const { camera, gl } = useThree();
      useEffect(() => {
        const controls = new OrbitControls(camera,
          gl.domElement);
        return () => {
          controls.dispose();
        };
      }, [camera, gl]);
      return null;
    };
    

这将创建Controls组件,并使用 three.js 中的OrbitControls组件作为其基础。从useThree钩子中,它将获取 three.js 中的cameragl,其中第一个是视图视角,第二个是 WebGL 的渲染。在useEffect钩子中,OrbitControls组件将被创建,并在不再需要时使用dispose方法进行清理。

  1. 我们需要在src/App.js文件中导入这个新的Controls组件,并将其放置在Canvas组件内部:

      import { Canvas } from '@react-three/fiber';
      import Box from './components/Box';
    + import Controls from './components/Controls';
      export default function App() {
        return (
          <div id='canvas-container'>
            <Canvas>
    +         <Controls />
              <Box />
              // ...
    
  2. 通过之前的更改,我们可以在点击并拖动浏览器中的此组件时旋转Box组件。为了使这个体验更流畅,我们可以在src/components/Box.js中为此组件添加一个最小和最大控制距离:

      // ...
      export default function Controls() {
        // ...
        useEffect(() => {
          const controls = new OrbitControls(camera,
            gl.domElement);
    +     controls.minDistance = 2;
    +     controls.maxDistance = 20;
    
          // ...
      }, [camera, gl]);
      return null;
    }
    
  3. 最后,我们可以允许Controls组件,例如,进行缩放或平移。这可以通过设置以下值来完成:

      // ...
      export default function Controls() {
        // ...
        useEffect(() => {
          const controls = new OrbitControls(camera,
            gl.domElement);
          controls.minDistance = 2;
          controls.maxDistance = 20;
    +     controls.enableZoom = true;
    +     controls.enablePan = true;
    
          // ...
    

添加这两个值后,你可以使用 three.js 和 React 在浏览器中旋转、缩放和平移Box组件。在下一节中,我们将渲染 360 度全景图像以便与之交互。

渲染 360 度全景图像

应用程序正在使用一个默认的背景,用于显示场景,但也可以动态地更改场景的背景。对于这个应用程序,我们希望场景的背景是 360 度或 3D。符合要求的在线图片可以在众多股票照片网站上找到。

在这本书的 GitHub 仓库中,你可以在chapter-10-assets目录下找到本章的资产选择,包括两张 360 度全景图像。你需要下载beach.jpegmountain.jpeg这两个文件,并将它们放置在应用程序的public目录中。因此,本章的文件结构将如下所示:

chapter-10
  |- /node_modules
  |- package.json
  |- /public
     |- index.html
     |- beach.jpeg
     |- mountain.jpeg
  |- /src
     |- /components
        |- Box.js
        |- Controls.js
     |- App.css
     |- App.js
     |- index.css
     |- index.js

在我们将 360 度全景图像添加到项目中后,我们可以通过在场景的Canvas背景中渲染它们来继续操作。使用来自 three.js 和@react-three/fiber的组件和钩子,我们可以创建一个 360 度视图,在其中我们还可以渲染本章前一部分创建的 3D 对象。

要添加 360 度背景,我们需要遵循几个步骤:

  1. 在项目的src文件中的components目录下创建一个名为Panorama.js的新文件。在这个文件中,添加了使用 360 度图像作为纹理创建新网格的设置。首先,我们需要导入依赖项:

    import { useLoader } from '@react-three/fiber';
    import * as THREE from 'three';
    import Box from './Box';
    
  2. 在导入之后,我们需要定义我们想要为这个应用程序使用的背景:

    // ...
    const backgrounds = [
      {
        id: 1,
        url: '/mountain.jpeg',
      },
      {
        id: 2,
        url: '/beach.jpeg',
      },
    ];
    
  3. 在此文件的底部,必须创建实际的Panorama组件,它使用@react-three/fiber的钩子,并返回一个来自 three.js 的mesh元素以及两个其他 three.js 元素:

    // ...
    export default function Panorama() {
      const background = useLoader(THREE.TextureLoader,
        backgrounds[0].url);
      return (
        <mesh>
          <sphereBufferGeometry args={[500, 60, 40]} />
          <meshBasicMaterial map={background} 
            side={THREE.BackSide} />
        </mesh>
      );
    }
    

useLoader钩子使用THREE.TextureLoader和背景图像来创建一个可以被meshBasicMaterial用作纹理的对象。它将使用backgrounds数组的第一个条目,这是我们稍后可以使其动态化的内容。sphereBufferGeometry定义了我们场景中的 360 度视图,在Canvas上。

  1. 这个新的Panorama组件必须在src/App.js中导入,以便它可以被渲染。确保在 React 的Suspense组件内渲染此组件,因为它是一个动态组件,这是由于使用了useLoader钩子:

    + import { Suspense } from 'react';
      import { Canvas } from '@react-three/fiber';
    + import Panorama from './components/Panorama';
      // ...
      export default function App() {
        return (
          <div id='Canvas-container'>
             <Canvas>
               <Controls />
    +          <Suspense fallback={null}>
    +           <Panorama />
    +          </Suspense>
               <Box />
               // ...
    

现在,你可以再次在浏览器中打开应用程序,看看 360 度全景图像是如何渲染的,也包括我们之前创建的 3D 对象:

图 10.2 – 渲染 360 度全景图像

图 10.2 – 渲染 360 度全景图像

注意

如果你尝试放大和缩小 360 度全景图像,你会注意到只有Box组件的大小在改变。背景图像被设置为覆盖整个背景,并且不在 3D 中。

除了渲染 360 度全景图像外,我们还可以使其具有交互性。通过添加更多的 three.js 元素和使用 React,我们可以让用户通过点击,例如 3D 盒子来更改背景图像。

要更改背景,我们需要将 three.js 与 React 结合使用,并使用本地状态管理来跟踪应该渲染哪个 360 度全景图像。由@react-three/fiber渲染的 three.js 元素也可以处理onClick事件,使其成为可点击的组件。让我们来实现这个:

  1. src/components/Panorama.js文件中,我们需要从 React 导入useState钩子,并使用它创建一个局部状态变量:

    + import { useState } from 'react';
      import { useLoader } from '@react-three/fiber';
      import * as THREE from 'three';
      // ...
      export default function Panorama() {
    +   const [activeBackground, setActiveBackground] =
          useState(1);
    
        // ...
    
  2. 根据activeBackground的值,我们可以选择应该作为背景渲染的 360 度全景图像。backgrounds数组的id字段用于将本地状态变量与正确的背景匹配:

      // ...
      export default function Panorama() {
        const [activeBackground, setActiveBackground] =
          useState(1);
    
    -   const background = useLoader(THREE.TextureLoader, 
          backgrounds[0].url);
    +   const { url } = backgrounds.find(({ id }) => 
          id === activeBackground);
    +   const background = useLoader(
    +     THREE.TextureLoader,
    +     url
    +   );
        return (
          // ...
    
  3. 在这个Panorama组件的返回语句中,我们需要将返回的mesh元素包裹在一个group元素中。这个group元素允许 three.js 一次返回多个交互元素:

      // ...
      export default function Panorama() {
        // ...
        return (
    +     <group>
            <mesh>
              <sphereBufferGeometry args={[500, 60, 40]} />
              <meshBasicMaterial map={background}
                side={THREE.BackSide} />
            </mesh>
    +     </group>
        );
      }
    
  4. 在这个group元素中,我们可以添加另一个带有onClick事件的可点击的group元素,当点击时将更新activeBackground的值:// ...添加带有onClick事件的group元素,当点击时将更新activeBackground的值:

      // ...
      export default function Panorama() {
        // ...
        return (
          <group>
            // ...
    +       <group
    +         onClick={(e) => {
    +           e.stopPropagation();
    +           setActiveBackground(activeBackground === 
                                    1 ? 2 : 1);
    +         }}
    +      >
    +         <Box />
    +      </group>
          </group>
        );
      }
    
  5. 为了防止Box组件被多次渲染,我们需要将其从src/App.js文件中移除:

      import { Suspense } from 'react';
      import { Canvas } from '@react-three/fiber';
    - import Box from './components/Box';
      // ...
      export default function App() {
        return (
          <div id='Canvas-container'>
            <Canvas>
              <Controls />
    -         <Box />
              // ...
          </div>
        );
      }
    

从我们的应用程序中,你现在可以通过点击 3D 正方形来更改正在渲染的 360 度全景图像。我们可以通过使mesh元素交互式来进一步提高用户体验,例如,当用户悬停在Box上时。

  1. src/components/Box.js文件中,我们可以添加一个本地状态变量来检查组件是否被hovered,这由mesh元素触发:

    - import { useRef } from 'react';
    + import { useRef, useState } from 'react';
      import { useFrame } from '@react-three/fiber';
      export default function Box({ rotate = false }) {
        const mesh = useRef();
    +   const [hovered, setHovered] = useState(false);
        // ...
        return (
          <mesh
            scale={2}
            ref={mesh}
    +       onPointerOver={(e) => setHovered(true)}
    +       onPointerOut={(e) => setHovered(false)}
          >
            <boxGeometry />
            <meshStandardMaterial color='blue' />
          </mesh>
        );
      }
    
  2. 当本地状态变量hoveredtrue时,我们希望meshStandardMaterial元素的color属性改变为不同的颜色:

        // ...
        return (
          // ...
            <boxGeometry />
    -       <meshStandardMaterial color='blue' />
    +       <meshStandardMaterial color={hovered ? 
             'purple' : 'blue'} />
          </mesh>
        );
      }
    

如果你现在打开http://localhost:3000上的应用程序,你可以看到当鼠标悬停在Box组件上时,它会从蓝色变为紫色。点击它将渲染一个不同的 360 度全景图像,即海滩:

![图 10.3 – 悬停在 3D 元素上并点击

![img/Figure_10.3_B17390.jpg]

图 10.3 – 悬停在 3D 元素上并点击

除了渲染 360 度背景和创建交互式 3D 组件外,我们还可以使用 three.js 在 React 中导入外部 3D 对象并对其动画化。这将在下一节中展示,通过向我们的应用程序添加react-spring

动画 3D 对象

到目前为止,你在这个章节中添加的所有使用 three.js 创建的组件都没有动画。使用 three.js,你还可以导入外部 3D 对象,并使用react-spring动画化组件。这个库的工作方式与我们在本书早期用于 React Native 的 Animated API 类似。

导入 3D 对象

在 React 中开始动画化 3D 对象之前,让我们首先使用 three.js 导入一个外部 3D 对象。Three.js 可以导入多种 3D 对象的文件格式,包括.obj.gltf.glb。这些文件格式是创建可用于其他程序的 3D 对象中最常见的格式。对于本章,我们将使用一个包含 NASA Ingenuity 火星直升机的 3D 版本的.glb文件。这个文件可以在本书的chapter-10-assets目录中的仓库中找到,并且你可以将它放在你之前在上一节下载的 360 度全景图像旁边的public目录中。

.gltf.glb文件都可以使用GLTFLoader加载到 three.js 中,它可以加载 GLTF 对象。GLTF 是 3D 对象中最受欢迎的格式之一,也可以将public目录导入到components目录中的一个新的文件Helicopter.js中:

import { useLoader } from '@react-three/fiber';
import { GLTFLoader } from 
  'three/examples/jsm/loaders/GLTFLoader';
export default function Helicopter() {
  const gltf = useLoader(GLTFLoader, './ Ingenuity_v3.glb' 
);
  return (
    <group position={[2, 2, 1]}>
      <primitive object={gltf.scene} />
    </group>
  );
}

这个组件再次使用了来自@react-three/fiberuseLoader钩子,并且也导入了它需要的GLTFLoader来渲染 3D GLTF 对象。一个包含 GLTF 对象的原始元素被返回在具有自定义位置的group元素中:

src/App.js中,我们可以在Suspense组件内部返回这个新的Helicopter组件,因为useLoader钩子使它成为一个动态组件:

  import { Suspense } from 'react';
  import { Canvas } from '@react-three/fiber';
+ import Helicopter from './components/Helicopter';
  // ...
  export default function App() {
    return (
      <div id='Canvas-container'>
        <Canvas>
          // ...
          <Suspense fallback={null}>
            <Panorama />
+           <Helicopter />
          </Suspense>
        </Canvas>
      </div>
    );
  }

这将把美国国家航空航天局(NASA)的 Ingenuity Mars Helicopter 添加到我们的应用程序中,渲染在一个接近我们的 3D 盒子的位置。你可以在下面的屏幕截图中看到它的样子:

![图 10.4 – 使用 three.js 渲染外部 3D 对象图 10.4

图 10.4 – 使用 three.js 渲染外部 3D 对象

在本节的下一部分,我们将使用一个流行的 React 库 react-spring 来动画化这个 3D 对象。

使用 React 动画化 3D 对象

在本书的前一章中,我们使用 Animated API 在 React Native 中动画化组件。对于基于 Web 的 React 应用程序,我们可以使用另一个库来完成这项工作,这个库是 react-spring。使用这个库,我们可以添加动画,例如旋转、移动或淡入淡出组件。正如在其他使用 React 的例子中一样,这个库提供了 Hooks 来添加这些交互。

react-spring 有一个与 @react-three/fiber 协作良好的特殊库,我们可以从 npm 中安装它:

npm install @react-spring/three

安装完成后,我们可以在 src/components/Helicopter.js 中的 Helicopter 组件中导入 useSpring 钩子和 animated 元素:

  import { useLoader } from '@react-three/fiber';
  import { GLTFLoader } from 
    'three/examples/jsm/loaders/GLTFLoader';
+ import { useSpring, animated } from 
    '@react-spring/three';
  export default function Helicopter() {
    // ...

我们可以将动画配置传递给 useSpring 钩子,这样它就会创建我们可以传递给想要动画化的元素的属性:

  // ...
  export default function Helicopter() {
    const gltf = 
      useLoader(GLTFLoader, './Ingenuity_v3.glb');
+   const props = useSpring({
+     loop: true,
+     to: [
+       { position: [2, 2, 3] },
+       { position: [2, 2, 6] },
+       { position: [2, 2, 9] },
+       { position: [2, 4, 9] },
+       { position: [2, 6, 9] },
+     ],
+     from: { position: [2, 2, 1] },
+   });
  return (
    // ...

我们的动画配置对象描述了我们要改变 3D 对象的 position 属性。描述了起始位置以及它应该移动到的不同位置。这个动画也将循环播放。

然后,我们可以使用来自 react-spring 的动画元素来扩展 three.js 中的 group 元素,该元素包裹着我们的 3D 对象。这个 group 元素将变成一个动画元素,并接受由 useSpring 钩子创建的属性:

    // ...  
    return (
-     <group position={[2, 2, 1]}>
+     <animated.group {...props}>
        <primitive object={gltf.scene} />
+     </animated.group>
-     </group>
    );
  }

现在,当你打开应用程序时,直升机将在 360 度全景图像的不同位置移动。

摘要

在本章的最后,你将本书中积累的所有知识结合起来,使用 React 创建了一个 VR 应用程序。我们能够做到这一点,是通过将其与 three.js 结合,这是一个用于创建 3D 应用的 JavaScript 库。本章中创建的项目与其他本书中的 React 项目相比,具有不同的和更专业的用途。它具有基本的动画,以及一个飞向远方的 3D 直升机对象。

通过本章,你已经完成了这本书的所有 10 章,并使用 React 和 React Native 创建了 10 个项目。现在,你对可以使用 React 做什么以及如何在不同的平台上使用它有了坚实的理解。虽然 React 和 React Native 已经是成熟的库,但新功能仍在持续添加。即使你在阅读这本书的过程中,也可能会有新的功能可以探索。我的主要建议是永远不要停止学习,并在新功能宣布时密切关注文档。

进一步阅读

图 10.1 – 使用 Three.js 渲染 3D 元素

posted @ 2025-09-08 13:02  绝不原创的飞龙  阅读(18)  评论(0)    收藏  举报