React16-高级教程-全-

React16 高级教程(全)

原文:Pro React 16

协议:CC BY-NC-SA 4.0

一、您的第一个 React 应用

开始使用 React 的最佳方式是深入研究。在这一章中,我将带你通过一个简单的开发过程来创建一个跟踪待办事项的应用。在第五章第五章–第八章中,我将向您展示如何创建一个更加复杂和真实的应用,但是,现在,一个简单的例子将足以展示 React 应用是如何创建的,以及基本功能是如何工作的。如果您不理解本章的所有内容,也不要担心——这是为了对 React 的工作原理有一个总体的了解。我会在后面的章节中详细解释一切。

注意

如果您需要对 React 特性的常规描述,可以跳到本书的第二部分,在那里我将开始深入描述各个特性的过程。在您开始之前,确保您安装了本章中描述的开发工具和软件包。

准备开发环境

React 开发需要一些准备工作。在接下来的部分中,我将解释如何设置和准备创建您的第一个项目。

安装 Node.js

用于 React 开发的工具依赖于 Node.js,也称为 Node,它创建于 2009 年,是用 JavaScript 编写的服务器端应用的简单高效的运行时。Node.js 基于 Chrome 浏览器中使用的 JavaScript 引擎,并提供了一个在浏览器环境之外执行 JavaScript 代码的 API。

Node.js 作为应用服务器已经取得了成功,但对于本书来说,它很有趣,因为它为新一代跨平台开发和构建工具提供了基础。

重要的是,您下载的 Node.js 版本与我在本书中使用的版本相同。尽管 Node.js 相对稳定,但仍不时会有突破性的 API 变化,这可能会使我在本章中包含的示例无法工作。我使用的版本是 10.14.1,这是我撰写本文时的最新长期支持版本。在您阅读本文时,可能会有更高的版本,但是对于本书中的示例,您应该坚持使用 10.14.1 版本。在 https://nodejs.org/dist/v10.14.1 可以获得完整的 10.14.1 版本,包括 Windows 和 macOS 的安装程序以及其他平台的二进制包。

安装 Node.js 时,请确保选择了将 Node.js 可执行文件添加到路径的选项。安装完成后,运行清单 1-1 中所示的命令。

node -v

Listing 1-1Checking the Node Version

如果安装正常进行,您将会看到下面显示的版本号:

v10.14.1

Node.js 安装包括节点包管理器(NPM),用于管理项目中的包。运行清单 1-2 中所示的命令,确保 NPM 正在工作。

npm -v

Listing 1-2Checking NPM Works

如果一切正常,您将看到以下版本号:

6.4.1

安装 create-react-app 包

create-react-app包是创建和管理复杂的 React 包的标准方式,为开发人员提供了完整的工具链。开始使用 React 还有其他方法,但这是最适合大多数项目的方法,也是我在本书中一直使用的方法。

要安装这个包,打开一个新的命令提示符并运行清单 1-3 中所示的命令。如果使用的是 Linux 或者 macOS,可能需要使用sudo

npm install --global create-react-app@2.1.2

Listing 1-3Installing the create-react-app Package

安装 Git

需要 Git 修订版控制工具来管理 React 开发所需的一些包。如果您使用的是 Windows 或 macOS,那么从 https://git-scm.com/downloads 下载并运行安装程序。(在 macOS 上,您可能需要更改安全设置才能打开安装程序,开发人员尚未对该安装程序进行签名。)

Git 已经包含在大多数 Linux 发行版中。如果您想安装最新版本,请查阅 https://git-scm.com/download/linux 上的安装说明。举个例子,对于我使用的 Linux 发行版 Ubuntu,我使用了清单 1-4 中所示的命令。

sudo apt-get install git

Listing 1-4Installing Git

一旦完成安装,打开一个新的命令提示符并运行清单 1-5 中所示的命令,检查 Git 是否已安装并可用。

git --version

Listing 1-5Checking Git

这个命令打印出已经安装的 Git 包的版本。在撰写本文时,针对 Windows 和 Linux 的 Git 最新版本是 2.20.1,针对 macOS 的 Git 最新版本是 2.19.2。

安装编辑器

React 开发可以用任何一个程序员的编辑器来完成,从中有数不尽的选择。一些编辑器增强了对使用 React 的支持,包括突出显示关键字和表达式。如果您还没有 web 应用开发的首选编辑器,那么您可以考虑表 1-1 中的一些流行选项。对于这本书,我不依赖任何特定的编辑器,你应该使用任何你觉得舒服的编辑器。

表 1-1

流行的编程编辑

|

名字

|

描述

|
| --- | --- |
| 崇高的文本 | Sublime Text 是一个商业跨平台编辑器,它有支持大多数编程语言、框架和平台的包。详见 www.sublimetext.com 。 |
| 原子 | Atom 是一个开源的跨平台编辑器,特别强调定制和可扩展性。详见atom.io。 |
| 括号 | 括号是 Adobe 开发的免费开源编辑器。详见brackets.io。 |
| Visual Studio 代码 | Visual Studio Code 是微软的一款开源的跨平台编辑器,强调可扩展性。详见code.visualstudio.com。 |
| 可视化工作室 | Visual Studio 是微软的旗舰开发工具。有免费版和商业版可用,它附带了大量集成到 Microsoft 生态系统中的附加工具。 |

安装浏览器

最后要选择的是在开发过程中用来检查工作的浏览器。所有当代浏览器都有良好的开发人员支持,并且与 React 配合良好,但 Chrome 和 Firefox 有一个名为react-devtools的有用扩展,它提供了对 React 应用状态的洞察,在复杂项目中特别有用。安装分机详见 https://github.com/facebook/react-devtools 。在这本书里,我一直使用谷歌浏览器,这是我推荐你使用的浏览器。

创建项目

从命令行创建和管理项目。打开一个新的命令提示符,导航到一个方便的位置,运行清单 1-6 中所示的命令,为本章创建项目。

小费

你可以从 https://github.com/Apress/pro-react-16 下载本章以及本书其他章节的示例项目。

npx create-react-app todo

Listing 1-6Creating the Project

在上一节中,npx命令是作为 Node.js/NPM 包的一部分安装的,用于运行 Node.js 包。create-react-app参数告诉npx运行create-react-app包,该包用于创建新的 React 项目,安装在清单 1-3 中。最后一个参数是todo,这是要创建的项目的名称。当您运行这个命令时,项目将被创建,并且开发和运行 React 项目所需的所有包都将被下载和安装。安装过程可能需要一段时间,因为有大量的软件包要下载。

注意

创建新项目时,您可能会看到有关安全漏洞的警告。React 开发依赖于大量的包,每个包都有自己的依赖关系,不可避免的会发现安全问题。对于本书中的示例,使用指定的包版本以确保获得预期的结果是很重要的。对于您自己的项目,您应该查看警告并更新到解决问题的版本。

了解项目结构

使用您喜欢的编辑器打开todo文件夹,您将看到如图 1-1 所示的项目结构。该图显示了我首选的编辑器(Visual Studio)中的布局,如果您选择了不同的编辑器,您可能会看到项目内容的呈现略有不同。

img/473159_1_En_1_Fig1_HTML.jpg

图 1-1

项目结构

这是所有项目的起点,虽然每个文件的目的目前可能不明显,但在本书结束时,你会知道每个文件和文件夹的用途。目前,表 1-2 简要描述了本章重要的文件,我在第九章提供了 React 项目的详细说明。

表 1-2

本章为项目中的重要文件

|

名字

|

描述

|
| --- | --- |
| public/index.html | 这是浏览器加载的 HTML 文件。它包含一个显示应用的元素和一个加载应用的 JavaScript 文件的script元素。 |
| src/index.js | 这是负责配置和启动 React 应用的 JavaScript 文件。在下一节中,我使用这个文件将引导 CSS 框架添加到应用中。 |
| src/App.js | 这是 React 组件,它包含将显示给用户的 HTML 内容和 HTML 所需的 JavaScript 代码。组件是 React 应用中的主要构件,您将在本书中看到它们的使用。 |

添加引导 CSS 框架

我使用优秀的 Bootstrap CSS 框架来设计本书中的例子所展示的 HTML。我在第三章中描述了 Bootstrap 的基本用法,但是在本章开始之前,运行清单 1-7 中所示的命令来导航到todo文件夹并将 Bootstrap 包添加到项目中。

小费

用于管理一个项目中的包的命令是npm,它与npx很容易混淆,后者仅在创建一个新项目时使用。重要的是不要混淆这两个命令。

cd todo
npm install bootstrap@4.1.2

Listing 1-7Adding the Bootstrap CSS Framework

要在应用中包含 Bootstrap,请将清单 1-8 中所示的语句添加到index.js文件中。

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

import 'bootstrap/dist/css/bootstrap.css';

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

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

Listing 1-8Including Bootstrap in the index.js File in the src Folder

正如我在第四章中解释的那样,import语句用于声明一个依赖项,这样它就成为应用的一部分。import关键字最常用于声明对 JavaScript 代码的依赖,但是它也可以用于 CSS 样式表。

启动开发工具

当您使用create-react-app包创建一个项目时,会安装一套完整的开发工具,这样项目就可以被编译、打包并交付给浏览器。使用命令提示符,运行todo文件夹中清单 1-9 所示的命令来启动开发工具。

npm start

Listing 1-9Starting the Development Tools

开发工具启动时有一个初始准备过程,可能需要一段时间才能完成。不要因为准备所花费的时间而推迟,因为这个过程只有在您开始开发会话时才需要。启动过程完成后,您将看到如下消息,确认应用正在运行,并告诉您要连接到哪个 HTTP 端口:

Compiled successfully!
You can now view todo in the browser.
  Local:            http://localhost:3000/
  On Your Network:  http://192.168.0.77:3000/
Note that the development build is not optimized.
To create a production build, use npm run build.

用于监听 HTTP 请求的默认端口是 3000,但是如果使用 3000,将会选择不同的端口。一旦项目的初始准备工作完成,一个新的浏览器窗口将打开并显示 URL http://localhost:3000和占位符内容,如图 1-2 所示。

img/473159_1_En_1_Fig2_HTML.jpg

图 1-2

运行示例应用

替换占位符内容

图 1-2 中显示的内容是一个占位符,用于确保开发工具正常工作。为了替换默认内容,我修改了App.js文件,如清单 1-10 所示。

import React, { Component } from 'react';

//import logo from './logo.svg';

//import './App.css';

export default class App extends Component {

    render() {
        return (
            <div>
                <h4 className="bg-primary text-white text-center p-2">
                    To Do List
                </h4>
            </div>
        )
    };
}

Listing 1-10Removing the Placeholder in the App.js File in the src Folder

App.js文件包含一个 React 组件,命名为App。组件是 React 应用的主要构建块,它们是使用 JSX 编写的,它是 JavaScript 的超集,允许 HTML 包含在代码文件中,而不需要任何特殊的引号。我在第三章中更详细地描述了 JSX,但是在这个清单中,App组件定义了一个render方法,该方法调用 React 来获取显示给用户的内容。

小费

React 支持 JavaScript 语言中最近增加的内容,比如清单 1-10 中使用的class关键字。我在第四章中提供了最有用的 JavaScript 特性的初级读本。

当您保存App.js文件时,React 开发工具会自动检测更改,重建应用,并指示浏览器重新加载,显示图 1-3 中的内容。

img/473159_1_En_1_Fig3_HTML.jpg

图 1-3

替换占位符内容

React 开发中使用的 JSX 文件使得混合 HTML 和 JavaScript 变得容易,但是与常规的 HTML 文件有一些重要的不同。您可以在清单 1-10 的h4元素中看到一个常见的例子,如下所示:

...
<h4 className="bg-primary text-white text-center p-2">
    To Do List
</h4>
...

在常规 HTML 中,class属性用于将元素分配给类,这就是使用 Bootstrap CSS 框架时元素的样式。即使看起来不是这样,JSX 文件是 JavaScript 文件,JavaScript 通过className属性配置类。当您第一次开始 React 开发时,纯 HTML 和 JSX 之间的差异可能会很不协调,但它们很快就会成为您的第二天性。

小费

我在第三章中提供了一个关于使用引导 CSS 框架的简要概述,在那里我解释了在清单 1-10 中h4元素被赋予的类的含义,比如bg-primarytext-whitep-2。但是,您可以暂时忽略这些类,只关注应用的基本结构。

如果你忘记了你正在使用 JSX,而是使用标准 HTML,React 将向浏览器的 JavaScript 控制台写一条警告消息。例如,如果您使用class属性而不是className,您将会看到Invalid DOM property 'class'. Did you mean 'className'?警告。要查看浏览器的 JavaScript 控制台,请按 F12 键并选择控制台或 JavaScript 控制台选项卡。

显示动态内容

所有的 web 应用都需要向用户显示动态内容,React 通过支持表达式特性使这变得简单。表达式是 JavaScript 的一个片段,在组件的render方法被调用时被计算,并提供向用户显示数据的方法。许多表达式用于显示组件定义的数据值,以跟踪应用的状态,称为状态数据。当您看到一个例子时,状态数据和表达式更容易理解,清单 1-11 将两者都添加到了App组件中。

import React, { Component } from 'react';

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            userName: "Adam"
        }
    }

    render() {
        return (
            <div>
                <h4 className="bg-primary text-white text-center p-2">
                    { this.state.userName }'s To Do List
                </h4>
            </div>
        )
    };
}

Listing 1-11Adding State Data and Data Bindings in the App.js File in the src Folder

正如我在第十一章中解释的那样,constructor是一个特殊的方法,当组件被初始化时,调用构造函数中的super方法是确保组件被正确设置所必需的。由构造函数定义的props参数在 React 开发中很重要,因为它允许一个组件配置另一个组件,您很快就会看到这一点。

小费

术语 props属性的缩写,它反映了 React 创建显示在浏览器中的 HTML 内容的方式,正如我在第三章中解释的那样。

React 组件有一个名为state的特殊属性,用于定义状态数据,如下所示:

...
this.state = {
    userName: "Adam"
}
...

this关键字引用当前对象,并用于访问其属性和方法。突出显示的语句将一个具有userName属性的对象分配给this.state,这是设置状态数据所需的全部内容。一旦定义了状态数据,它就可以包含在由表达式中的组件生成的内容中,如下所示:

...
<h4 className="bg-primary text-white text-center p-2">
    { this.state.userName }'s To Do List
</h4>
...

表达式用花括号表示({}字符)。当调用render方法时,表达式被求值,其结果包含在呈现给用户的内容中。清单 1-11 中的表达式读取userName状态数据属性的值,产生如图 1-4 所示的结果。

img/473159_1_En_1_Fig4_HTML.jpg

图 1-4

使用 src 文件夹中 App.js 文件中的状态数据和表达式

了解状态数据更改

React 应用的动态特性基于对状态数据的更改,React 通过再次调用组件的render方法来响应这些更改,这将导致使用新的状态数据值重新计算表达式。在清单 1-12 中,我已经更新了App组件,因此userName状态数据属性的值被更改。

import React, { Component } from 'react';

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            userName: "Adam"
        }
    }

    changeStateData = () => {
        this.setState({
            userName: this.state.userName === "Adam" ? "Bob" : "Adam"
        })
    }

    render() {
        return (
            <div>
                <h4 className="bg-primary text-white text-center p-2">
                    { this.state.userName }'s To Do List
                </h4>
                <button className="btn btn-primary m-2"
                        onClick={ this.changeStateData }>
                    Change
                </button>
            </div>
        )
    };
}

Listing 1-12Changing State Data in the App.js File in the src Folder

将更改保存到App.js文件,您将在浏览器窗口中看到一个按钮。点击按钮改变用户名,如图 1-5 所示。

img/473159_1_En_1_Fig5_HTML.jpg

图 1-5

更改用户名

这个例子包含了几个协同工作的重要 React 特性。第一个是button元素上的onClick属性。

...
<button className="btn btn-primary m-2" onClick={ this.changeStateData }>
    Change
</button>
...

onClick属性被赋予一个表达式,当按钮被单击时,React 对该表达式求值。点击一个按钮会触发一个事件,而onClick就是一个事件处理程序 prop 的例子。每次点击按钮时,都会调用由onClick指定的函数或方法。清单 1-12 中的表达式指定了changeStateData方法,该方法是使用粗箭头语法定义的,这允许简洁地表达函数,如下所示:

...

changeStateData = () => {

    this.setState({ userName: this.state.userName === "Adam" ? "Bob" : "Adam" })
}
...

正如我在第四章中解释的那样,粗箭头函数用于简化对事件的响应,但是它们的使用范围更广,有助于在 React 应用中保持 HTML 和 JavaScript 的混合可读性。changeStateData方法使用setState方法为userName属性设置一个新值。当调用setState方法时,React 用新值更新组件的状态数据,然后调用render方法,这样表达式将生成更新的内容。这就是为什么点击按钮会将浏览器窗口中显示的名称从Adam更改为Bob。我不必显式地告诉 React 表达式使用的值发生了变化——我只是调用了setState方法来设置新值,并让 React 更新浏览器中的内容。

小费

无论何时使用组件定义的属性和方法,包括setState方法,都需要this关键字。忘记使用this是 React 开发中常见的错误,如果没有得到预期的行为,这是首先要检查的。

使用粗箭头语法定义的函数不使用return关键字,也不需要用花括号括住函数体,这可以产生更简单、更清晰的render方法,例如,如清单 1-13 所示。

import React, { Component } from 'react';

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            userName: "Adam"
        }
    }

    changeStateData = () => {
        this.setState({
            userName: this.state.userName === "Adam" ? "Bob" : "Adam"
        })
    }

    render = () =>
        <div>
            <h4 className="bg-primary text-white text-center p-2">
                { this.state.userName }'s To Do List
            </h4>
            <button className="btn btn-primary m-2"
                    onClick={ this.changeStateData }>
                Change
            </button>
        </div>
}

Listing 1-13Redefining a Method Using a Fat Arrow Function in the App.js File in the src Folder

在本书中,我使用这两种风格来定义函数和方法。大多数情况下,您可以在传统的 JavaScript 函数和粗箭头函数之间进行选择,尽管在第十二章中解释了一些重要的注意事项。

添加待办事项应用功能

现在您已经看到了 React 如何显示动态内容,是时候开始添加应用所需的特性了,从附加的状态数据和表达式开始,如清单 1-14 所示。

import React, { Component } from 'react';

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            userName: "Adam",
            todoItems: [{ action: "Buy Flowers", done: false },
                        { action: "Get Shoes", done: false },
                        { action: "Collect Tickets", done: true },
                        { action: "Call Joe", done: false }],
            newItemText: ""
        }
    }

    updateNewTextValue = (event) => {
        this.setState({ newItemText: event.target.value });
    }

    createNewTodo = () => {
        if (!this.state.todoItems
                 .find(item => item.action === this.state.newItemText)) {
            this.setState({
                todoItems: [...this.state.todoItems,
                    { action: this.state.newItemText, done: false }],
                newItemText: ""
            });
        }
    }

    render = () =>
        <div>
            <h4 className="bg-primary text-white text-center p-2">
                {this.state.userName}'s To Do List
                ({ this.state.todoItems.filter(t => !t.done).length} items to do)
            </h4>
            <div className="container-fluid">
                <div className="my-1">
                    <input className="form-control"
                        value={ this.state.newItemText }
                        onChange={ this.updateNewTextValue } />
                    <button className="btn btn-primary mt-1"
                        onClick={ this.createNewTodo }>Add</button>
                </div>
            </div>
        </div>
}

Listing 1-14Adding Application Features in the App.js File in the src Folder

因为 React 表达式是 JavaScript,所以它们可用于检查数据值并动态生成结果,就像下面的表达式:

...
<h4 className="bg-primary text-white text-center p-2">
    {this.state.userName}'s To Do List
    ({ this.state.todoItems.filter(t => !t.done).length} items to do)
</h4>
...

该表达式过滤todoItems状态数据数组中的对象,以便只选择不完整的项目,然后读取length属性的值,这是绑定将向用户显示的值。JSX 格式使得像这样混合 HTML 元素和代码变得容易,尽管复杂的表达式可能难以阅读,并且通常在属性或方法中定义,以使 HTML 尽可能简单。

清单 1-14 中的变化引入了一个input元素,允许用户输入新待办事项的文本。input 元素有两个属性,用于管理元素的内容和响应更改,如下所示:

...
<input className="form-control"
    value={ this.state.newItemText } onChange={ this.updateNewTextValue } />
...

属性用于设置元素的内容。在这种情况下,value属性包含的表达式将返回newItemText状态数据属性的值,这意味着对状态数据属性的任何更改都将更新input元素的内容。onChange prop 告诉 React 当change事件被触发时做什么,这将在用户输入input元素时发生。这个表达式告诉 React 调用组件的updateNewTextValue方法,它使用setState方法更新newItemText状态数据属性。这似乎是一种循环的方法,但是它确保了 React 知道如何处理由代码和用户执行的更改。

button元素使用onClick属性告诉 React 调用createNewTodo方法来响应click事件。createNewTodo方法检查是否存在具有相同文本的现有项目,如果没有,则使用setState方法向todoItems数组添加一个新项目,并重置newItemText属性,这具有清除input元素的效果。向数组中添加新项的语句是通过 JavaScript 的 spread 操作符来完成的,spread 操作符是 JavaScript 语言的一个新特性。

...
todoItems: [...this.state.todoItems,
    { action: this.state.newItemText, done: false }],
...

spread 运算符是三个句点,它扩展了一个数组。用于 React 开发的工具允许使用最新的 JavaScript 特性,并将它们转换成旧的 web 浏览器可以理解的兼容代码。我在第四章中描述了 spread 操作符和其他有用的 JavaScript 特性。

要查看清单 1-14 中更改的效果,请在文本字段中输入任务的描述,然后单击 Add 按钮。React 通过调用按钮的onClick属性指定的方法来响应事件,该方法使用input元素的值来创建一个新的待办事项。现在还看不到任务的描述,但是会看到未完成任务的数量增加,如图 1-6 。

img/473159_1_En_1_Fig6_HTML.jpg

图 1-6

添加新任务

显示待办事项

下一步是向用户显示每个待办事项,以便他们可以看到任务的细节,并在完成后将它们标记为完成,如清单 1-15 所示。

import React, { Component } from 'react';

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            userName: "Adam",
            todoItems: [{ action: "Buy Flowers", done: false },
                        { action: "Get Shoes", done: false },
                        { action: "Collect Tickets", done: true },
                        { action: "Call Joe", done: false }],
            newItemText: ""
        }
    }

    updateNewTextValue = (event) => {
        this.setState({ newItemText: event.target.value });
    }

    createNewTodo = () => {
        if (!this.state.todoItems
                .find(item => item.action === this.state.newItemText)) {
            this.setState({
                todoItems: [...this.state.todoItems,
                    { action: this.state.newItemText, done: false }],
                newItemText: ""
            });
        }
    }

    toggleTodo = (todo) => this.setState({ todoItems:
        this.state.todoItems.map(item => item.action === todo.action
            ? { ...item, done: !item.done } : item) });

    todoTableRows = () => this.state.todoItems.map(item =>
        <tr key={ item.action }>
            <td>{ item.action}</td>
            <td>
                <input type="checkbox" checked={ item.done }
                    onChange={ () => this.toggleTodo(item) } />
            </td>
        </tr> );

    render = () =>
        <div>
            <h4 className="bg-primary text-white text-center p-2">
                {this.state.userName}'s To Do List
                ({ this.state.todoItems.filter(t => !t.done).length} items to do)
            </h4>
            <div className="container-fluid">
                <div className="my-1">
                    <input className="form-control"
                        value={ this.state.newItemText }
                        onChange={ this.updateNewTextValue } />
                    <button className="btn btn-primary mt-1"
                        onClick={ this.createNewTodo }>Add</button>
                </div>
                <table className="table table-striped table-bordered">
                    <thead>
                        <tr><th>Description</th><th>Done</th></tr>
                    </thead>
                    <tbody>{ this.todoTableRows() }</tbody>
                </table>
            </div>
        </div>
}

Listing 1-15Displaying To-Do Items in the App.js File in the src Folder

到目前为止,App.js文件的重点是在 HTML 片段中嵌入 JavaScript 表达式。但是 JSX 格式允许 HTML 和 JavaScript 自由混合,这意味着 JavaScript 方法可以返回 HTML 内容。您可以在清单 1-15 中看到一个例子,其中todoTableRows方法使用 JavaScript map方法为todoItems数组中的每个对象生成一系列 HTML 元素,如下所示:

...
todoTableRows = () => this.state.todoItems.map(item =>
    <tr key={ item.action }>
        <td>{ item.action}</td>
        <td>
            <input type="checkbox" checked={ item.done }
                onChange={ () => this.toggleTodo(item) } />
        </td>
    </tr> );
...

数组中的每一项都映射到一个tr元素,这是一个表格行的 HTML 元素。在tr元素中有一组定义 HTML 表格单元格的td元素。由map方法生成的 HTML 内容包含更多的 JavaScript 表达式,这些表达式用状态数据值或函数填充td元素,这些值或函数将被调用来处理事件。

React 确实对它处理的内容施加了一些限制,比如通过todoTableRows方法添加到每个tr元素的key属性,如下所示:

...
<tr key={ item.action }>
...

正如你将在第十三章中详细了解到的,当有变化时,React 调用组件的render方法,并将结果与浏览器中显示的 HTML 进行比较,以便只应用差异。React 需要key prop,以便它能够将显示的内容与产生它的数据相关联,并有效地管理更改。

清单 1-15 中变化的结果是,每个待办事项都显示有一个复选框,用户可以切换该复选框来指示任务已经完成。由todoTableRows方法生成的每个表格行包含一个配置为复选框的input元素。

清单 1-15 中变化的结果是待办事项列表显示在一个表格中,勾选一项为完成会减少标题中显示的数字,如图 1-7 所示。

img/473159_1_En_1_Fig7_HTML.jpg

图 1-7

显示待办事项

引入附加组件

目前,示例应用的所有功能都包含在一个组件中,随着新功能的增加,这个组件会变得难以管理。为了帮助保持组件的可管理性,功能被委托给负责特定功能的独立组件。这些被称为子组件,而委托功能的组件被称为父组件

在本节中,我将介绍几个子组件,每个子组件负责一个特性。我首先将一个名为TodoBanner.js的文件添加到src文件夹中,并使用它来定义清单 1-16 中所示的组件。

import React, { Component } from 'react';

export class TodoBanner extends Component {

    render = () =>
        <h4 className="bg-primary text-white text-center p-2">
            { this.props.name }'s To Do List
            ({ this.props.tasks.filter(t => !t.done).length } items to do)
        </h4>
}

Listing 1-16The Contents of the TodoBanner.js File in the src Folder

该组件负责显示横幅。父组件使用 props 为其子组件提供数据,数据值通过props属性访问,通过this关键字访问。这个名为TodoBanner的组件期望接收两个属性:一个name属性,包含用户的名字,另一个tasks属性,包含一组任务,过滤后显示未完成的任务数。例如,为了显示name属性的值,组件使用包含this.props.name的表达式,如下所示:

...
{ this.props.name }'s To Do List
...

当 React 调用TodoBanner组件的render方法时,父组件提供的name属性的值将包含在结果中。TodoBanner组件的render方法中的另一个表达式使用 JavaScript filter方法来选择不完整的项目并确定有多少,这表明 props 可以用在表达式中,而不仅仅是显示它们的值。

接下来,我在src文件夹中创建了一个名为TodoRow.js的文件,并用它来定义清单 1-17 中所示的组件。

import React, { Component } from 'react';

export class TodoRow extends Component {

    render = () =>
        <tr>
            <td>{ this.props.item.action}</td>
            <td>
                <input type="checkbox" checked={ this.props.item.done }
                    onChange={ () => this.props.callback(this.props.item) }
                />
            </td>
        </tr>
}

Listing 1-17The Contents of the TodoRow.js File in the src Folder

该组件将负责在表中显示一行,显示待办事项的详细信息。子组件通过其 props 接收的数据是只读的,不得更改。为了进行更改,父组件可以使用函数 props 为子组件提供回调函数,当重要事件发生时调用这些函数。这种组合允许组件之间的协作:数据属性允许父母向孩子提供数据,而功能属性允许孩子与父母通信。

清单 1-17 中的组件定义了一个名为item的数据属性,用于接收要显示的待办事项,它还定义了一个名为callback的函数属性,提供了一个当用户切换复选框时调用的函数。对于最后一个子组件,我在src文件夹中添加了一个名为TodoCreator.js的文件,并添加了清单 1-18 中所示的代码。

import React, { Component } from 'react';

export class TodoCreator extends Component {

    constructor(props) {
        super(props);
        this.state = { newItemText: "" }
    }

    updateNewTextValue = (event) => {
        this.setState({ newItemText: event.target.value});
    }

    createNewTodo = () => {
        this.props.callback(this.state.newItemText);
        this.setState({ newItemText: ""});
    }

    render = () =>
        <div className="my-1">
            <input className="form-control" value={ this.state.newItemText }
                onChange={ this.updateNewTextValue } />
            <button className="btn btn-primary mt-1"
                onClick={ this.createNewTodo }>Add</button>
        </div>
}

Listing 1-18The Contents of the TodoCreator.js File in the src Folder

子组件可以有自己的状态数据,这是该组件用来处理其input元素内容的数据。当用户单击 Add 按钮时,组件调用 function prop 来通知其父组件。

使用子组件

我在上一节中定义的组件负责待办事项应用的特定功能。在清单 1-19 中,我更新了App组件以使用三个新组件,每个组件都是使用 props 配置的,为它们提供所需的数据和回调函数。

import React, { Component } from 'react';

import { TodoBanner } from "./TodoBanner";

import { TodoCreator } from "./TodoCreator";

import { TodoRow } from "./TodoRow";

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            userName: "Adam",
            todoItems: [{ action: "Buy Flowers", done: false },
                        { action: "Get Shoes", done: false },
                        { action: "Collect Tickets", done: true },
                        { action: "Call Joe", done: false }],
            //newItemText: ""
        }
    }

    updateNewTextValue = (event) => {
        this.setState({ newItemText: event.target.value });
    }

    createNewTodo = (task) => {
        if (!this.state.todoItems.find(item => item.action === task)) {
            this.setState({
                todoItems: [...this.state.todoItems, { action: task, done: false }]
            });
        }
    }

    toggleTodo = (todo) => this.setState({ todoItems:
        this.state.todoItems.map(item => item.action === todo.action
            ? { ...item, done: !item.done } : item) });

    todoTableRows = () => this.state.todoItems.map(item =>
        <TodoRow key={ item.action } item={ item } callback={ this.toggleTodo } />)

    render = () =>
        <div>
            <TodoBanner name={ this.state.userName } tasks={this.state.todoItems } />
            <div className="container-fluid">
                <TodoCreator callback={ this.createNewTodo } />
                <table className="table table-striped table-bordered">
                    <thead>
                        <tr><th>Description</th><th>Done</th></tr>
                    </thead>
                    <tbody>{ this.todoTableRows() }</tbody>
                </table>
            </div>
        </div>
}

Listing 1-19Applying Child Components in the App.js File in the src Folder

新的import语句声明了对子组件的依赖,这确保了它们在构建过程中包含在应用中。子组件用作自定义 HTML 元素,其属性和表达式定义了组件将接收的属性,如下所示:

...
<TodoBanner name={ this.state.userName } tasks={this.state.todoItems } />
...

用于设置属性值的表达式为子组件提供了对由其父组件定义的特定数据和方法的访问。在这种情况下,nametasks属性用于向TodoBanner组件提供userNametodoItems状态数据属性的值。

添加最后的润色

应用的基本功能已经就绪,提供这些功能的一组组件正在协同工作。在这一部分,我添加了一些收尾工作来完成待办事项应用。

管理已完成任务的可见性

目前,即使任务已经完成,它们仍然对用户可见。为了解决这个问题,我将为用户提供已完成和未完成任务的单独列表,并允许隐藏未完成的任务。我在src文件夹中添加了一个名为VisibilityControl.js的文件,并用它来定义清单 1-20 中所示的组件。

import React, { Component } from 'react';

export class VisibilityControl extends Component {

    render = () =>
        <div className="form-check">
            <input className="form-check-input" type="checkbox"
                checked={ this.props.isChecked }
                onChange={ (e) => this.props.callback(e.target.checked) } />
            <label className="form-check-label">
                Show { this.props.description }
            </label>
        </div>
}

Listing 1-20The Contents of the VisibilityControl.js File in the src Folder

使用 props 从父节点接收数据和回调函数使得向应用添加新特性变得容易。清单 1-20 中定义的组件是一个通用的特性,它不知道被用来管理的内容,它完全通过它的属性工作:description属性提供它显示的标签文本,isChecked属性提供复选框的初始状态,callback属性提供当用户切换复选框并触发change事件时调用的函数。

在清单 1-21 中,我已经更新了App组件,将VisibilityControl组件作为子组件应用,同时还做了必要的修改,以分别显示已完成和未完成的任务。

import React, { Component } from 'react';
import { TodoBanner } from "./TodoBanner";
import { TodoCreator } from "./TodoCreator";
import { TodoRow } from "./TodoRow";

import { VisibilityControl } from "./VisibilityControl";

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            userName: "Adam",
            todoItems: [{ action: "Buy Flowers", done: false },
                        { action: "Get Shoes", done: false },
                        { action: "Collect Tickets", done: true },
                        { action: "Call Joe", done: false }],
            showCompleted: true
        }
    }

    updateNewTextValue = (event) => {
        this.setState({ newItemText: event.target.value });
    }

    createNewTodo = (task) => {
        if (!this.state.todoItems.find(item => item.action === task)) {
            this.setState({
                todoItems: [...this.state.todoItems, { action: task, done: false }]
            });
        }
    }

    toggleTodo = (todo) => this.setState({ todoItems:
        this.state.todoItems.map(item => item.action === todo.action
            ? { ...item, done: !item.done } : item) });

    todoTableRows = (doneValue) => this.state.todoItems
        .filter(item => item.done === doneValue).map(item =>
            <TodoRow key={ item.action } item={ item }
                  callback={ this.toggleTodo } />)

    render = () =>
        <div>
            <TodoBanner name={ this.state.userName }
                tasks={this.state.todoItems } />
            <div className="container-fluid">
                <TodoCreator callback={ this.createNewTodo } />
                <table className="table table-striped table-bordered">
                    <thead>
                        <tr><th>Description</th><th>Done</th></tr>
                    </thead>
                    <tbody>{ this.todoTableRows(false) }</tbody>
                </table>
                <div className="bg-secondary text-white text-center p-2">
                    <VisibilityControl description="Completed Tasks"
                        isChecked={this.state.showCompleted}
                        callback={ (checked) =>
                            this.setState({ showCompleted: checked })} />
                </div>

                { this.state.showCompleted &&
                    <table className="table table-striped table-bordered">
                        <thead>
                            <tr><th>Description</th><th>Done</th></tr>
                        </thead>
                        <tbody>{ this.todoTableRows(true) }</tbody>
                    </table>
                }
            </div>
        </div>
}

Listing 1-21Managing Completed Tasks in the App.js File in the src Folder

VisibilityControl组件进行了配置,因此当用户切换复选框时,它会更改App组件的名为showCompleted的状态数据属性的值。为了区分完成的和未完成的任务,我向todoTableRows方法添加了一个参数,并使用filter方法根据done属性的值从状态数据数组中选择对象。

为了显示已完成的任务,我添加了第二个table元素。只有当showCompleted属性为true时,该表才会显示,所以我将table及其内容放在一个数据绑定表达式中,并使用了&&运算符,如下所示:

...
{ this.state.showCompleted && <table className="table table-striped table-bordered">
...

当表达式求值时,只有当showCompleted属性为true时,table元素才会包含在组件的内容中。这是 JSX 如何混合内容和代码的另一个例子。在很大程度上,JSX 在混合元素和代码语句方面做得很好,但它并不擅长所有事情,条件语句所需的语法也很笨拙,正如这个例子所示。

当您保存对App.js文件的更改时,您将看到不同的任务集。当您切换任务的复选框时,它将被移动到另一个表格,如图 1-8 所示。当您切换“显示已完成的任务”复选框时,第二个表将被隐藏。

img/473159_1_En_1_Fig8_HTML.jpg

图 1-8

更改任务显示

持久存储数据

最后一个变化是存储数据,以便在离开应用时保留用户列表。在本书的后面,我将演示处理存储在服务器上的数据的不同方法,但是在本章中,我将保持应用的简单性,并要求浏览器使用本地存储 API 来存储数据,如清单 1-22 所示。

小费

本地存储 API 是一个标准的浏览器特性,并不特定于 React 开发。参见 https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage 了解本地存储如何工作的详细描述。

import React, { Component } from 'react';
import { TodoBanner } from "./TodoBanner";
import { TodoCreator } from "./TodoCreator";
import { TodoRow } from "./TodoRow";
import { VisibilityControl } from "./VisibilityControl";

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            userName: "Adam",
            todoItems: [{ action: "Buy Flowers", done: false },
                        { action: "Get Shoes", done: false },
                        { action: "Collect Tickets", done: true },
                        { action: "Call Joe", done: false }],
            showCompleted: true
        }
    }

    updateNewTextValue = (event) => {
        this.setState({ newItemText: event.target.value });
    }

    createNewTodo = (task) => {
        if (!this.state.todoItems.find(item => item.action === task)) {
            this.setState({
                todoItems: [...this.state.todoItems, { action: task, done: false }]
            }, () => localStorage.setItem("todos", JSON.stringify(this.state)));
        }
    }

    toggleTodo = (todo) => this.setState({ todoItems:
        this.state.todoItems.map(item => item.action === todo.action
            ? { ...item, done: !item.done } : item) });

    todoTableRows = (doneValue) => this.state.todoItems
        .filter(item => item.done === doneValue).map(item =>
            <TodoRow key={ item.action } item={ item }
                callback={ this.toggleTodo } />)

    componentDidMount = () => {
        let data = localStorage.getItem("todos");
        this.setState(data != null
            ? JSON.parse(data)
            :  {
                userName: "Adam",
                todoItems: [{ action: "Buy Flowers", done: false },
                            { action: "Get Shoes", done: false },
                            { action: "Collect Tickets", done: true },
                            { action: "Call Joe", done: false }],
                showCompleted: true
            });
    }

    render = () =>
        <div>
            <TodoBanner name={ this.state.userName }
                tasks={this.state.todoItems } />
            <div className="container-fluid">
                <TodoCreator callback={ this.createNewTodo } />
                <table className="table table-striped table-bordered">
                    <thead>
                        <tr><th>Description</th><th>Done</th></tr>
                    </thead>
                    <tbody>{ this.todoTableRows(false) }</tbody>
                </table>

                <div className="bg-secondary text-white text-center p-2">
                    <VisibilityControl description="Completed Tasks"
                        isChecked={this.state.showCompleted}
                        callback={ (checked) =>
                            this.setState({ showCompleted: checked })} />
                </div>

                { this.state.showCompleted &&
                    <table className="table table-striped table-bordered">
                        <thead>
                            <tr><th>Description</th><th>Done</th></tr>
                        </thead>
                        <tbody>{ this.todoTableRows(true) }</tbody>
                    </table>
                }
            </div>
        </div>
}

Listing 1-22Persistently Storing Data in the App.js File in the src Folder

通过localStorage对象访问本地存储 API,当创建新的待办事项时,组件使用setItem方法存储待办事项。本地存储特性只能存储字符串值,所以在存储之前,我将数据对象序列化为 JSON。如第十一章所述,setState方法可以接受一个函数,一旦状态数据被更新,该函数将被更新,这确保了最新的数据被存储。

组件有一个明确定义的生命周期,这在第十三章中有描述,并且可以实现接收重要事件通知的方法。清单中的组件实现了componentDidMount方法,该方法在组件生命周期的早期被调用,并提供了执行诸如加载数据等任务的好机会。

为了检索存储的数据,我使用了本地存储 API 的getItem方法。我使用setState方法用存储的数据更新组件,或者如果没有可用的存储数据,用一些默认数据更新组件。

视觉上没有变化,但应用将持久存储您创建的任何待办事项,这意味着当您重新加载浏览器窗口或导航到不同的 URL(如 Apress 主页),然后返回到http://localhost:3000,它们仍然可用,如图 1-9 所示。

img/473159_1_En_1_Fig9_HTML.jpg

图 1-9

存储数据

摘要

在本章中,我创建了一个简单的示例应用,向您介绍 React 开发过程,并演示一些重要的 React 概念。您看到了 React 开发关注于组件,这些组件是在结合了 JavaScript 代码和 HTML 内容的 JSX 文件中定义的。创建项目时,包括了处理 JSX 文件、构建应用以及将其交付给浏览器进行测试所需的一切,以便您可以快速轻松地开始。

您还了解了 React 应用可以包含多个组件,每个组件负责一个特定的特性,并使用 props 接收它们所需的数据和回调函数。

从这本书的篇幅中可以看出,React 还有更多的特性可用,但是我在本章中创建的基本应用已经向您展示了 React 开发的最基本特征,并将为后面的章节提供基础。在下一章,我将 React 放在上下文中,描述这本书的结构和内容。

二、理解 React

React 是一个灵活而强大的开源框架,用于开发客户端应用;它从服务器端开发的世界中获得线索,并将它们应用于 HTML 元素,并且它创建了一个基础,使得构建富 web 应用变得更加容易。在本书中,我解释了 React 是如何工作的,并演示了它提供的不同特性。

这本书和 React 发布时间表

React 团队频繁发布,这意味着会有持续的补丁和特性流。次要版本倾向于不破坏现有的特性,并且主要包含错误修复。主要版本可能包含重大更改,并且可能不提供向后兼容性。

要求读者每隔几个月就购买这本书的新版本似乎不公平也不合理,尤其是因为即使在一个主要版本中,React 的大多数特性也不太可能改变。取而代之的是,我将在本书 https://github.com/Apress/pro-react-16 的 GitHub 资源库中发布主要版本的更新。

这是我(和 press)正在进行的实验,我还不知道这些更新会采取什么形式——尤其是因为我不知道 React 的主要版本会包含什么——但目标是通过补充书中包含的示例来延长这本书的寿命。

我不承诺更新会是什么样的,它们会采取什么形式,或者在我把它们折叠成这本书的新版本之前,我会花多长时间来制作它们。当新的 React 版本发布时,请保持开放的心态并检查这本书的资源库。如果你有关于如何改进更新的想法,请发电子邮件到 adam@adam-freeman.com 告诉我。

我应该使用 React 吗?

React 不是所有问题的解决方案,知道何时应该使用 React 以及何时应该寻求替代方案是很重要的。React 提供了以前只有服务器端开发人员才能使用的功能,但现在完全在浏览器中提供。每次加载应用了 React 的 HTML 文档时,浏览器都必须做大量的工作:必须加载数据,必须创建和编写组件,必须计算表达式,等等,为我在第一章中描述的功能以及我在本书其余部分解释的功能创建基础。

这种工作需要时间来完成,时间的长短取决于 React 应用的复杂性,关键取决于浏览器的质量和设备的处理能力。在功能强大的台式机上使用最新的浏览器时,您不会注意到任何性能问题,但功能不足的智能手机上的旧浏览器确实会降低 React 应用的初始设置速度。

因此,目标是尽可能少地执行这种设置,并在执行时向用户交付尽可能多的应用。这意味着仔细考虑您构建的 web 应用的类型。从广义上讲,web 应用有两种基本类型:往返单页

了解往返应用

很长一段时间以来,web 应用的开发都遵循一个往返模型。浏览器向服务器请求一个初始的 HTML 文档。用户交互(如单击链接或提交表单)会导致浏览器请求并接收一个全新的 HTML 文档。在这种应用中,浏览器本质上是 HTML 内容的呈现引擎,所有的应用逻辑和数据都驻留在服务器上。浏览器发出一系列无状态的 HTTP 请求,服务器通过动态生成 HTML 文档来处理这些请求。

许多当前的 web 开发仍然是针对往返应用的,尤其是业务线项目,尤其是因为它们对浏览器的要求很少,并且拥有尽可能广泛的客户端支持。但是往返应用也有一些严重的缺点:它们让用户在请求和加载下一个 HTML 文档时等待,它们需要一个大型的服务器端基础设施来处理所有请求和管理所有应用状态,并且它们需要更多的带宽,因为每个 HTML 文档都必须是自包含的,这可能导致服务器的每个响应中都包含相同的内容。React 不太适合往返应用,因为浏览器必须为从服务器收到的每个新 HTML 文档执行初始设置过程。

理解单页应用

单页应用采取了不同的方法。一个初始的 HTML 文档被发送到浏览器,但是用户交互会导致对插入到向用户显示的现有元素集中的 HTML 或数据的小片段的 HTTP 请求。初始的 HTML 文档永远不会被重新加载或替换,当 HTTP 请求被异步执行时,用户可以继续与现有的 HTML 交互,即使这只是意味着看到一个“数据加载”消息。

React 非常适合单页面应用,因为浏览器初始化应用的工作只需执行一次,之后应用在浏览器中运行,响应用户交互并请求后台所需的数据或内容。

比较 React to Vue.js 和 Angular

有两个主要的竞争对手需要应对:Angular 和 Vue.js。它们之间存在差异,但是,在大多数情况下,所有这些框架都非常优秀,它们都以相似的方式工作,并且它们都可以用于创建丰富而流畅的客户端应用。

这些框架之间的真正区别在于开发人员的体验。例如,Angular 需要使用 TypeScript 才能有效,而它只是 React 和 Vue.js 项目的一个选项。React 和 Vue.js 将 HTML 和 JavaScript 混合在一个文件中,这不是每个人都喜欢的,尽管每个框架的实现方式不同。

我的建议很简单:选择你最喜欢的框架,如果你不喜欢,就换一个。这可能看起来是一种不科学的方法,但是这是一个不错的选择,并且您会发现许多核心概念会在框架之间延续,即使您改变了您使用的框架。

了解应用的复杂性

在决定 React 是否适合某个项目时,应用的类型并不是唯一的考虑因素。项目的复杂性也很重要,我经常从那些使用客户端框架(如 React、Angular 或 Vue.js)开始项目的读者那里了解到,简单得多的框架就足够了。像 React 这样的框架需要投入大量的时间来掌握(正如本书的篇幅所展示的),如果你只是需要验证一个表单或者编程填充一个select元素,这种努力是不值得的。

在围绕客户端框架的兴奋中,很容易忘记浏览器提供了一组丰富的可以直接使用的 API,并且 React 的所有功能都依赖这些 API。如果您有一个简单且独立的问题,那么您应该考虑直接使用浏览器 API,从文档对象模型(DOM) API 开始。你会看到本书中的一些例子直接使用了浏览器 API,但是如果你是浏览器开发新手,那么 https://developer.mozilla.org 是一个很好的起点,它包含了浏览器支持的所有 API 的良好文档。

浏览器 API 的缺点,尤其是 DOM API,是它们可能很难使用,并且旧的浏览器倾向于以不同的方式实现特性。jQuery ( https://jquery.org )是直接使用浏览器 API 的一个很好的替代方法,尤其是如果您必须支持旧的浏览器。jQuery 简化了 HTML 元素的使用,并为处理事件、动画和异步 HTTP 请求提供了出色的支持。

React 在大型应用中发挥了自己的作用,在这些应用中,要实现复杂的工作流,要处理不同类型的用户,还要处理大量的数据。在这些情况下,您可以直接使用浏览器 API,但是管理代码和扩展应用会变得很困难。React 提供的特性使得构建大型复杂的应用变得更加容易,并且不会陷入大量不可读的代码中,而不采用框架的复杂项目往往会陷入这种困境。

我需要知道什么?

如果您认为 React 是您项目的正确选择,那么您应该熟悉 web 开发的基础知识,了解 HTML 和 CSS 的工作原理,并掌握 JavaScript 的工作知识。如果你对这些细节有一点模糊,我在第三章 3 和第四章 4 中提供了我在本书中使用的特性的初级读本。 https://developer.mozilla.org 是温习 HTML、CSS 和 JavaScript 基础知识的好地方。

如何设置我的开发环境?

React 开发所需的唯一开发工具是您在第一章创建第一个应用时安装的工具。后面的一些章节需要额外的软件包,但是提供了完整的说明。如果你在第一章成功地构建了应用,那么你就为 React 开发和本书的其余章节做好了准备。

这本书的结构是什么?

这本书分为三部分,每一部分都涵盖了一系列相关的主题。

第一部分:React 入门

本书的第一部分提供了开始 React 开发所需的信息。它包括本章和 React 开发中使用的关键技术的入门/复习章节,包括 HTML、CSS 和 JavaScript。第一章向您展示了如何创建一个简单的 React 应用,第 5–8 章带您了解构建一个更真实的应用的过程,这个应用叫做 SportsStore。

第二部分:使用 React

本书的第二部分涵盖了大多数项目中需要的核心 React 特性。React 提供了许多内置功能,我将深入描述这些功能,以及将自定义代码和内容添加到项目中以创建定制功能的方式。

第三部分:创建完整的 React 应用

React 依靠额外的包来提供大多数复杂应用所需的高级功能。在本书的第三部分,我介绍了其中最重要的包,向您展示了它们是如何工作的,并解释了它们是如何添加到 React 核心特性中的。

有很多例子吗?

个载荷的例子。学习 React 的最好方法是通过例子,我已经尽可能多地将它们打包到本书中,并附有截图,以便您可以看到每个特性的效果。为了最大限度地增加本书中的示例数量,我采用了一个简单的约定来避免重复列出相同的代码或内容。当我创建一个文件时,我会显示它的全部内容,就像我在清单 2-1 中所做的那样。我在清单的标题中包含了文件及其文件夹的名称,并且用粗体显示了我所做的更改。

import React, { Component } from "react";

export class SimpleButton extends Component {

    constructor(props) {
        super(props);
        this.state = {
            counter: 0,
            hasButtonBeenClicked: false
        }
    }

    render = () =>
        <button onClick={ this.handleClick }
            className={ this.props.className }
            disabled={ this.props.disabled === "true"
                    || this.props.disabled === true  }>
                { this.props.text} { this.state.counter }
                { this.state.hasButtonBeenClicked &&
                    <div>Button Clicked!</div>
                }
                </button>
    }

    handleClick = () => {
        this.setState({ counter: this.state.counter + 1 },
            () => this.setState({ hasButtonBeenClicked: this.state.counter > 0 }));
        this.props.callback();
    }
}

Listing 2-1Using a Callback in the SimpleButton.js File in the src Folder

这是第十一章的清单,显示了在src文件夹中可以找到的一个名为SimpleButton.js的文件的内容。不要担心清单的内容或文件的目的;请注意,这种类型的清单包含文件的完整内容,您需要按照示例进行的更改以粗体显示。

React 应用中的一些文件可能很长,但是我描述的特性只需要很小的改变。我没有列出完整的文件,而是使用省略号(三个句点串联)来表示部分列表,它只显示了文件的一部分,如清单 2-2 所示。

...
handleClick = () => {
    for (let i = 0; i < 5; i++) {
        this.setState({ counter: this.state.counter + 1});
    }
    this.setState({ hasButtonBeenClicked: true });
    this.props.callback();
}
...

Listing 2-2Making Multiple Updates in the SimpleButton.js File in the src Folder

这是第十一章的后续清单,它显示了一组只应用于一个大得多的文件的一部分的更改。当您看到部分清单时,您会知道文件的其余部分不必更改,只有粗体部分不同。

在某些情况下,需要在文件的不同部分进行更改,这使得很难显示为部分列表。在这种情况下,我省略了文件的部分内容,如清单 2-3 所示。

import React, { Component } from "react";
import { ActionButton } from "./ActionButton";

export class Message extends Component {

    // ...other methods omitted for brevity...

    componentDidMount() {
        console.log("componentDidMount Message Component");
    }

    componentDidUpdate() {
        console.log("componentDidUpdate Message Component");
    }
}

Listing 2-3Implementing a Lifecycle Method in the Message.js File in the src Folder

更改仍然用粗体标记,清单中省略的文件部分不受此示例的影响。

从哪里可以获得示例代码?

你可以从 https://github.com/Apress/pro-react-16 下载本书所有章节的范例项目。该下载是免费的,它包含了您学习示例所需的一切,而不必键入所有的代码。

你在哪里可以得到这本书的修改?

你可以在 https://github.com/Apress/pro-react-16 找到这本书的勘误表。

你怎么联系我?

如果你在使用本章中的例子时有问题,或者你在书中发现了问题,那么你可以给我发电子邮件到 adam@adam-freeman.com,我会尽力帮助你。在联系我之前,请检查这本书的勘误表,看看它是否包含您的问题的解决方案。

摘要

在这一章中,我解释了 React 何时是项目的好选择,并概述了备选方案和竞争对手。我还概述了这本书的内容和结构,解释了从哪里获得更新,并解释了如果您对本书中的示例有问题,如何与我联系。在下一章中,我将介绍本书中用来解释 React 开发的 HTML 和 CSS 特性。

三、HTML、JSX 和 CSS 入门

在这一章中,我提供了 HTML 的简要概述,并解释了如何在使用 JSX 时将 HTML 内容与 JavaScript 代码混合,这是 React 开发工具支持的 JavaScript 的超集,允许 HTML 与代码混合。我还介绍了 Bootstrap CSS 框架,我用它来设计本书示例中的内容。

注意

如果本章中描述的所有特性都没有直接意义,也不要担心。有些依赖于 JavaScript 语言中最近添加的你以前可能没有遇到过的东西,这些在第四章中有描述或者在其他章节中有详细解释。

为本章做准备

为了创建本章的项目,打开一个新的命令提示符,导航到一个方便的位置,并运行清单 3-1 中所示的命令。

小费

你可以从 https://github.com/Apress/pro-react-16 下载本章以及本书所有其他章节的示例项目。

npx create-react-app primer

Listing 3-1Creating the Example Project

一旦创建了项目,运行清单 3-2 中所示的命令,导航到项目文件夹并安装引导 CSS 框架。

注意

创建新项目时,您可能会看到有关安全漏洞的警告。React 开发依赖于大量的包,每个包都有自己的依赖关系,不可避免的会发现安全问题。对于本书中的示例,使用指定的包版本以确保获得预期的结果是很重要的。对于您自己的项目,您应该查看警告并更新到解决问题的版本。

cd primer
npm install bootstrap@4.1.2

Listing 3-2Adding the Bootstrap Package to the Project

要在应用中包含 Bootstrap,将清单 3-3 中所示的语句添加到index.js文件中。

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

import 'bootstrap/dist/css/bootstrap.css';

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

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

Listing 3-3Including Bootstrap in the index.js File in the src Folder

准备 HTML 文件和组件

为了准备本章中的例子,用清单 3-4 中显示的内容替换public文件夹中index.html文件的内容。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Primer</title>
  </head>
  <body>
    <h4 class="bg-primary text-white text-center p-2 m-1">
        Static HTML Element
    </h4>
    <div id="domParent"></div>
    <div id="root"></div>
  </body>
</html>

Listing 3-4Replacing the Contents of the index.html File in the public Folder

用清单 3-5 中所示的代码替换src文件夹中App.js文件的内容。

import React, { Component } from "react";

export default class App extends Component {
    render = () =>
        <h4 className="bg-primary text-white text-center p-2 m-1">
            Component Element
        </h4>
}

Listing 3-5Replacing the Contents of the App.js File in the src folder

运行示例应用

确保所有的更改都已保存,并使用命令提示符运行primer文件夹中清单 3-6 所示的命令。

npm start

Listing 3-6Starting the Development Tools

React 开发工具将会启动,一旦初始准备工作完成,一个新的浏览器窗口将会打开并显示如图 3-1 所示的内容。

img/473159_1_En_3_Fig1_HTML.jpg

图 3-1

运行示例应用

理解 HTML 和 DOM 元素

所有 React web 应用的核心都是 HTML 元素,用于描述将呈现给用户的内容。在 React 应用中,public文件夹中静态index.html文件的内容与 React 动态创建的 HTML 元素相结合,生成一个浏览器显示给用户的 HTML 文档。

HTML 元素告诉浏览器 HTML 文档的每个部分代表什么样的内容。以下是来自public文件夹中的index.html文件的 HTML 元素:

...
<h4 class="bg-primary text-white text-center p-2 m-1">
        Static HTML Element
</h4>
...

如图 3-2 所示,这个元素有几个部分:开始标签、结束标签、属性和内容。

img/473159_1_En_3_Fig2_HTML.jpg

图 3-2

HTML 元素的剖析

这个元素的名称(也称为标签名称或者仅仅是标签)是h4,它告诉浏览器标签之间的内容应该被当作一个头。有一系列的头元素,从h1h6,其中h1通常用于最重要的内容,h2用于稍微不太重要的内容,等等。

定义 HTML 元素时,首先将标记名放在尖括号中(<>字符),然后以类似的方式使用标记结束元素,除了在左尖括号(<)后添加一个/字符,以创建开始标记结束标记

标签表明元素的用途,HTML 规范定义了大量的元素类型。在表 3-1 中,我描述了我在本书中最常用的元素。要获得标签类型的完整列表,您应该查阅 HTML 规范。

表 3-1

示例中使用的常见 HTML 元素

|

元素

|

描述

|
| --- | --- |
| a | 一个链接(更正式的说法是锚点),用户单击它可以导航到当前文档中的新 URL 或新位置 |
| button | 一个按钮,用户可以点击它来启动一个动作 |
| div | 通用元素;通常用于为文档添加结构,以用于演示目的 |
| h1 to h6 | 头球 |
| input | 用于从用户处收集单个数据项的字段 |
| table | 表格,用于将内容组织成行和列 |
| tbody | 表格的正文(与页眉或页脚相对) |
| td | 表格行中的内容单元格 |
| th | 表格行中的标题单元格 |
| thead | 表格的标题 |
| tr | 表格中的一行 |

了解元素内容

出现在开始和结束标签之间的就是元素的内容。一个元素可以包含文本(比如本例中的Static HTML Element)或其他 HTML 元素。在清单 3-7 中,我添加了一个包含另一个元素的新 HTML 元素。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Primer</title>
  </head>
  <body>
    <h4 class="bg-primary text-white text-center p-2 m-1">
        Static HTML Element
    </h4>
    <div class="text-center m-2">
      <div>This is a span element</div>
      <div>This is another span element</div>
    </div>
    <div id="domParent"></div>
    <div id="root"></div>
  </body>
</html>

Listing 3-7Adding a New Element in the index.html File in the public Folder

外部元素被称为父元素,而它包含的元素被称为子元素。清单 3-7 中的附加内容定义了一个父div元素,它有两个子元素,也是div元素。每个子div元素的内容是一条文本消息,产生如图 3-3 所示的结果。能够创建元素的层次结构是 HTML 的一个基本特性。它是 React 应用的关键构建块之一,允许创建复杂的内容。

img/473159_1_En_3_Fig3_HTML.jpg

图 3-3

添加父元素和子元素

了解元素内容限制

有些元素对可以成为其子元素的元素类型有限制。示例中的div元素可以包含任何其他元素,并用于向 HTML 文档添加结构,通常这样可以很容易地对内容进行样式化。其他元素具有更具体的角色,需要将特定类型的元素用作子元素。例如,一个tbody元素,你将在后面的章节中看到,它代表一个表格的主体,可以只包含一个或多个tr元素,每个元素代表一个表格行。

小费

不要担心学习所有的 HTML 元素和它们之间的关系。当你按照后面章节中的例子学习时,你会学到你需要知道的一切,如果你试图创建无效的 HTML,大多数代码编辑器会显示一个警告。

了解空元素

有些元素根本不允许包含任何内容。这些被称为 void自闭元素,它们没有单独的结束标记,就像这样:

...
<input />
...

在单个标记中定义了一个 void 元素,并在最后一个尖括号(>字符)前添加了一个/字符。这里显示的元素是 void 元素最常见的例子,它用于在 HTML 表单中收集来自用户的数据。在后面的章节中,你会看到很多关于 void 元素的例子。

了解属性

通过向元素添加属性,可以向浏览器提供额外的信息。下面是应用于图 3-2 中所示的h4元素的属性:

...
<h4 class="bg-primary text-white text-center p-2 m-1">
        Static HTML Element
</h4>
...

属性总是被定义为开始标签的一部分,并且大多数属性都有一个名称和一个值,用等号分隔,如图 3-4 所示。

img/473159_1_En_3_Fig4_HTML.jpg

图 3-4

属性的名称和值

该属性的名称是class,用于对相关元素进行分组,这样它们的外观就可以得到一致的管理。这就是为什么在这个例子中使用了class属性,属性值将h4元素与许多类相关联,这些类与引导 CSS 包提供的样式相关,我将在本章的后面描述。

动态创建 HTML 元素

index.html文件中定义的 HTML 元素是静态的。浏览器接收并显示这些元素,就像它们被定义一样,您可以通过在浏览器窗口中单击鼠标右键并从弹出菜单中选择“检查”或“检查元素”来查看它们。F12 开发人员工具将打开并显示 HTML 文档的内容,其中包括以下元素:

...
<h4 class="bg-primary text-white text-center p-2 m-1">
    Static HTML Element
</h4>
...

HTML 元素也可以使用 JavaScript 和所有现代浏览器都支持的域对象模型(DOM) API 来动态创建。在清单 3-8 中,我向index.html文件添加了一些 JavaScript,该文件使用 DOM API 向 HTML 文档添加新元素。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Primer</title>
  </head>
  <body>
    <h4 class="bg-primary text-white text-center p-2 m-1">
        Static HTML Element
    </h4>
    <div class="text-center m-2">
      <div>This is a span element</div>
      <div>This is another span element</div>
    </div>
    <div id="domParent"></div>
    <div id="root"></div>
    <script>
      let element =  document.createElement("h4")
      element.className = "bg-primary text-white text-center p-2 m-1";
      element.textContent = "DOM API HTML Element";
      document.getElementById("domParent").appendChild(element);
    </script>
  </body>
</html>

Listing 3-8Creating an Element Dynamically in the index.html File in the public Folder

script元素表示一段 JavaScript 代码,浏览器在处理index.html文件的内容时会执行这段代码,并创建一个新的 HTML 元素,如图 3-5 所示。

img/473159_1_En_3_Fig5_HTML.jpg

图 3-5

使用 DOM API 创建元素

清单 3-8 中的第一个 JavaScript 语句创建了一个新的h4元素。

...
let element =  document.createElement("h4")
...

document对象代表浏览器正在显示的 HTML 文档,而createElement方法返回一个代表新 HTML 元素的对象。DOM API 提供的表示新 HTML 元素的对象具有与定义静态 HTML 时使用的属性相对应的属性。清单 3-8 中的第二个 JavaScript 语句使用了对应于class属性的属性。

...
element.className = "bg-primary text-white text-center p-2 m-1";
...

元素对象定义的大多数属性都与它们对应的属性同名。也有一些例外,包括className,使用它是因为class关键字在很多编程语言中都是保留的,包括 JavaScript。

其余的 JavaScript 语句设置 HTML 元素的文本内容,并将其添加到 HTML 文档中,以便由浏览器显示。如果您通过在浏览器窗口中右键单击并从弹出菜单中选择 Inspect 来检查新元素,您将会看到由清单 3-8 中的 JavaScript 语句创建的对象已经像来自index.html文件的静态元素一样被表示。

...
<h4 class="bg-primary text-white text-center p-2 m-1">DOM API HTML Element</h4>
...

值得强调的是,index.html文件不包含这个 HTML 元素。相反,它包含一系列 JavaScript 语句,指示浏览器创建元素并将其添加到呈现给用户的内容中。

使用 React 组件动态创建元素

如果您检查App.js文件的内容,您会看到App组件的render方法结合了前面章节中静态和动态 HTML 元素的一些方面:

...
import React, { Component } from "react";

export default class App extends Component {
    render = () =>
        <h4 className="bg-primary text-white text-center p-2 m-1">
            Component Element
        </h4>
}
...

React 使用 DOM API 创建由render方法指定的 HTML 元素,这是通过创建一个通过其属性配置的对象来实现的。用于 React 开发的 JSX 格式允许以声明方式定义 HTML 元素,但当文件被开发工具处理时,结果仍然是 JavaScript,这就是为什么在App呈现方法中使用className而不是class来配置h4元素。JSX 让元素看起来像是使用属性配置的,但它们只是为属性指定值的手段,这也是为什么术语 prop 在 React 开发中如此频繁使用的原因。

注意

使用 JSX 不需要特殊的步骤,它由create-react-app包添加到项目中的工具支持。我会在第九章中解释使用 JSX 定义的元素是如何转换成 JavaScript 的。

在 React 元素中使用表达式

使用表达式配置元素的能力是 React 和 JSX 的关键特性之一。表达式用花括号表示({}字符),结果被插入到组件生成的内容中。在清单 3-9 中,我使用了一个表达式来设置由App组件呈现的h4元素的内容。

import React, { Component } from "react";

const message = "This is a constant"

export default class App extends Component {

    render = () =>
        <h4 className="bg-primary text-white text-center p-2 m-1">
            { message }
        </h4>
}

Listing 3-9Using an Expression in the App.js File in the src Folder

我定义了一个名为message的常量,并使用一个表达式将message值作为h4元素的内容。为了简化这个例子,我从index.html文件中注释掉了静态 HTML 元素和 DOM API 代码,如清单 3-10 所示。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Primer</title>
  </head>
  <body>
    <!-- <h4 class="bg-primary text-white text-center p-2 m-1">
        Static HTML Element
    </h4>
    <div class="text-center m-2">
      <div>This is a span element</div>
      <div>This is another span element</div>
    </div>
    <div id="domParent"></div> -->
    <div id="root"></div>
    <!-- <script>
      let element =  document.createElement("h4")
      element.className = "bg-primary text-white text-center p-2 m-1";
      element.textContent = "DOM API HTML Element";
      document.getElementById("domParent").appendChild(element);
    </script> -->
  </body>
</html>

Listing 3-10Removing Elements in the index.html File in the public Folder

保存修改,你会看到清单 3-9 中定义的常量的值显示在App组件产生的h4元素中,如图 3-6 所示。

img/473159_1_En_3_Fig6_HTML.jpg

图 3-6

使用表达式设置元素的内容

混合表达式和静态内容

表达式可以与静态值结合来创建更复杂的结果,如清单 3-11 所示,它使用一个表达式来设置h4元素的部分内容。

import React, { Component } from "react";

const count = 4

export default class App extends Component {

    render = () =>
        <h4 className="bg-primary text-white text-center p-2 m-1">
            Number of things: { count }
        </h4>
}

Listing 3-11Mixing an Expression with Static Content in the App.js File in the src Folder

表达式将count值包含在h4元素的内容中,该值与静态内容相结合,产生如图 3-7 所示的结果。

img/473159_1_En_3_Fig7_HTML.jpg

图 3-7

混合表达式和静态内容

在表达式中执行计算

表达式不仅可以将值注入到组件呈现的内容中,还可以用于任何计算,如清单 3-12 所示。

import React, { Component } from "react";

const count = 4

export default class App extends Component {

    render = () =>
        <h4 className="bg-primary text-white text-center p-2 m-1">
            Number of things: { count % 2 === 0 ? "Even" : "Odd" }
        </h4>
}

Listing 3-12Performing a Computation in the App.js File in the src Folder

该示例使用三元运算符来确定count值是奇数还是偶数,并产生如图 3-8 所示的结果。

img/473159_1_En_3_Fig8_HTML.jpg

图 3-8

在表达式中执行计算

表达式非常适合简单的操作,但是试图在表达式中包含太多代码会导致令人困惑的组件。对于更复杂的操作,函数应该由表达式定义和调用,以便函数结果被合并到组件产生的内容中,如清单 3-13 所示。

import React, { Component } from "react";

const count = 4

function isEven() {

    return count % 2 === 0 ? "Even" : "Odd";

}

export default class App extends Component {

    render = () =>
        <h4 className="bg-primary text-white text-center p-2 m-1">
            Number of things: { isEven() }
        </h4>
}

Listing 3-13Defining a Function in the App.js File in the src Folder

当您在表达式中使用函数时,您必须用圆括号(()字符)调用它,如清单所示,这样函数的结果就包含在组件生成的内容中。

访问组件属性和方法

需要关键字this来指定组件定义的属性和方法,如清单 3-14 所示。正如我在第二部分中解释的,创建组件有不同的方法,但是我在本书中使用的技术如清单所示,它提供了最广泛的特性,适合大多数项目。

import React, { Component } from "react";

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            count: 4
        }
    }

    isEven() {
        return this.state.count % 2 === 0 ? "Even" : "Odd";
    }

    render = () =>
        <h4 className="bg-primary text-white text-center p-2 m-1">
            Number of things: { this.isEven() }
        </h4>
}

Listing 3-14Using the this Keyword in an Expression in the App.js File in the src Folder

这个清单中的组件定义了一个构造函数,正如我在第四章中解释的,这就是组件初始状态的配置方式。构造函数给state属性分配一个对象,其count值为4。该组件还定义了一个名为isEven的方法,该方法以this.state.count的形式访问count值。this关键字指的是组件实例,如第四章所述;state是指在构造函数中创建的状态属性;并且count选择在计算中使用的值。这个this关键字也用于调用表达式中的isEven方法。结果与前面的清单相同。一些方法需要参数,这些参数可以被指定为表达式的一部分,如清单 3-15 所示。

import React, { Component } from "react";

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            count: 4
        }
    }

    isEven(val) {
        return val % 2 === 0 ? "Even" : "Odd";
    }

    render = () =>
        <h4 className="bg-primary text-white text-center p-2 m-1">
            Number of things: { this.isEven(this.state.count) }
        </h4>
}

Listing 3-15Passing an Argument to a Method in the App.js File in the src Folder

本例中的表达式调用isEven方法,使用count值作为参数。结果与前面的清单相同。

使用表达式设置属性值

表达式还可以用来设置 props 的值,这允许配置 HTML 元素和子组件。在清单 3-16 中,我为App组件添加了一个方法,其结果用于设置h4元素的className属性。

import React, { Component } from "react";

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            count: 4
        }
    }

    isEven(val) {
        return val % 2 === 0 ? "Even" : "Odd";
    }

    getClassName(val) {
        return val % 2 === 0
            ? "bg-primary text-white text-center p-2 m-1"
            : "bg-secondary text-white text-center p-2 m-1"
    }

    render = () =>
        <h4 className={this.getClassName(this.state.count)}>
            Number of things: { this.isEven(this.state.count) }
        </h4>
}

Listing 3-16Setting a Prop Value in the App.js File in the src Folder

结果与前面的清单相同。

使用表达式处理事件

表达式用于告诉 React 当事件被元素触发时如何响应事件。在清单 3-17 中,我为App组件返回的内容添加了一个按钮,并使用onClick属性告诉 React 当click事件被触发时如何响应。

import React, { Component } from "react";

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            count: 4
        }
    }

    isEven(val) {
        return val % 2 === 0 ? "Even" : "Odd";
    }

    getClassName(val) {
        return val % 2 === 0
            ? "bg-primary text-white text-center p-2 m-1"
            : "bg-secondary text-white text-center p-2 m-1"
    }

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

    render = () =>
        <h4 className={this.getClassName(this.state.count)}>
            <button className="btn btn-info m-2" onClick={ this.handleClick }>
                Click Me
            </button>
            Number of things: { this.isEven(this.state.count) }
        </h4>
}

Listing 3-17Handling an Event in the App.js File in the src Folder

使用onClick属性配置button元素,它告诉 React 调用handleClick方法来响应click事件。注意,该方法不是用括号指定的。另外,请注意,handleClick方法是使用粗箭头语法定义的;正如我在第十二章中解释的,处理事件是定义方法的方式很重要的少数情况之一。点击按钮更新count属性的值,这将改变render方法中其他表达式的结果,产生如图 3-9 所示的效果。

img/473159_1_En_3_Fig9_HTML.jpg

图 3-9

处理事件

了解引导程序

HTML 元素告诉浏览器它们代表什么样的内容,但是它们不提供任何关于内容应该如何显示的信息。关于如何显示元素的信息是使用级联样式表 (CSS)提供的。CSS 由一组全面的属性和一组选择器组成,前者可用于配置元素外观的各个方面,后者允许应用这些属性。

CSS 的一个主要问题是,一些浏览器对属性的解释略有不同,这可能导致 HTML 内容在不同设备上的显示方式有所不同。跟踪和纠正这些问题可能很困难,CSS 框架已经出现,以帮助 web 应用开发人员以简单和一致的方式设计他们的 HTML 内容。

最流行的 CSS 框架是 Bootstrap,它最初是在 Twitter 上开发的,但已经成为一个广泛使用的开源项目。Bootstrap 由一组 CSS 类和一些可选的 JavaScript 代码组成,这些 CSS 类可以应用于元素以保持一致的样式,这些可选的 JavaScript 代码执行额外的增强功能(但我在本书中没有用到)。我在自己的项目中使用 Bootstrap 它跨浏览器运行良好,并且使用简单。我在本书中使用了 Bootstrap CSS 样式,因为它们让我不必在每一章中定义和列出我自己的定制 CSS 就可以设计我的例子。Bootstrap 提供了比我在本书中使用的更多的特性;详见 http://getbootstrap.com

关于 Bootstrap,我不想说得太详细,因为这不是本书的主题,但是我想给你足够的信息,这样你就可以知道例子的哪些部分是 React 特性,哪些与 Bootstrap 相关。

应用基本引导类

引导样式是通过className属性应用的,它是class属性的对应属性,用于对相关元素进行分组。className属性不仅用于应用 CSS 样式,而且是最常见的用途,它支撑着 Bootstrap 和类似框架的运行方式。下面是一个带有classNae属性的 HTML 元素,取自清单 3-9 :

...
<h4 className="bg-primary text-white text-center p-2 m-1">
    { message }
</h4>
...

className prop 将h4元素分配给五个类,它们的名称由空格分隔:bg-primarytext-whitetext-centerp-2m-1。这些类对应于 Bootstrap 定义的样式集合,如表 3-2 所述。

表 3-2

h4 元素类

|

名字

|

描述

|
| --- | --- |
| bg-primary | 该类应用样式上下文来提供关于元素用途的视觉提示。请参见“使用上下文类”一节。 |
| text-white | 这个类应用一种样式,将元素内容的文本颜色设置为白色。 |
| text-center | 这个类应用一种水平居中元素内容的样式。 |
| p-2 | 该类应用一种样式,在元素内容周围增加间距,如“使用边距和填充”一节所述。 |
| m-1 | 这个类应用一种样式,在元素周围增加间距,如“使用边距和填充”一节所述。 |

使用上下文类

使用像 Bootstrap 这样的 CSS 框架的主要优点之一是简化了在整个应用中创建一致主题的过程。Bootstrap 定义了一组样式上下文,用于一致地设计相关元素的样式。这些上下文在表 3-3 中描述,用于将引导样式应用于元素的类的名称中。

表 3-3

自举风格的上下文

|

名字

|

描述

|
| --- | --- |
| primary | 表示主要动作或内容区域 |
| secondary | 指明内容的支持领域 |
| success | 表示成功的结果 |
| info | 显示附加信息 |
| warning | 显示警告 |
| danger | 提出严重警告 |
| muted | 不强调内容 |
| dark | 通过使用深色来增加对比度 |
| white | 通过使用白色增加对比度 |

Bootstrap 提供了允许样式上下文应用于不同类型元素的类。我在开始本节时使用的h4元素已经被添加到了bg-primary类中,它设置了元素的背景颜色,以表明它与应用的主要目的相关。其他类特定于某一组元素,例如btn-primary,它用于配置buttona元素,使它们显示为按钮,其颜色与主上下文中的其他元素一致。其中一些上下文类必须与配置元素基本样式的其他类结合使用,比如与btn-primary类结合使用的btn类。

使用边距和填充

Bootstrap 包括一组实用程序类,用于添加填充,即元素边缘与其内容之间的空间,以及边距,即元素边缘与其周围元素之间的空间。使用这些类的好处是它们在整个应用中应用一致的间距。

这些类的名称遵循一种定义良好的模式。下面是清单 3-9 中的h4元素:

...
<h4 className="bg-primary text-white text-center p-2 m-1">
    { message }
...

将边距和填充应用于元素的类遵循一个定义良好的命名模式:首先是字母m(用于边距)或p(用于填充),接着是一个可选的字母,用于选择特定的边缘(t用于顶部、b用于底部、l用于左侧、或r用于右侧),然后是一个连字符,最后是一个数字,用于指示应该应用多少空间(0用于无间距,或12345用于增加数量)。如果没有字母来指定边缘,则边距或填充将应用于所有边缘。为了帮助将这个模式放在上下文中,添加了h4元素的p-2类将填充级别 2 应用于元素的所有边缘。

使用引导程序创建网格

Bootstrap 提供了样式类,可以用来创建不同种类的网格布局,从一列到十二列不等。我在本书的许多例子中使用了网格布局,并且在清单 3-18 中创建了一个简单的网格布局。

import React, { Component } from "react";

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            count: 4
        }
    }

    isEven(val) {
        return val % 2 === 0 ? "Even" : "Odd";
    }

    getClassName(val) {
        return val % 2 === 0
            ? "bg-primary text-white text-center p-2 m-1"
            : "bg-secondary text-white text-center p-2 m-1"
    }

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

    render = () =>
        <div className="container-fluid p-4">
            <div className="row bg-info text-white p-2">
                <div className="col font-weight-bold">Value</div>
                <div className="col-6 font-weight-bold">Even?</div>
            </div>
            <div className="row bg-light p-2 border">
                <div className="col">{ this.state.count }</div>
                <div className="col-6">{ this.isEven( this.state.count) }</div>
            </div>
            <div className="row">
                <div className="col">
                    <button className="btn btn-info m-2"
                            onClick={ this.handleClick }>
                        Click Me
                    </button>
                </div>
            </div>
        </div>
}

Listing 3-18Creating a Grid in the App.js File in the src Folder

自举网格布局系统易于使用。一个顶级的div元素被分配给container类(或者是container-fluid类,如果你想让它跨越可用空间的话)。通过将row类应用到div元素来指定列,这具有为div元素包含的内容设置网格布局的效果。

每行定义 12 列,您可以通过指定一个名为col-后跟列数的类来指定每个子元素将占用多少列。例如,类col-1指定一个元素占据一列,col-2指定两列,依此类推,直到col-12,它指定一个元素填充整个行。如果您省略了列数,而只是将一个元素分配给了col类,那么 Bootstrap 将分配等量的剩余列。清单 3-18 中的网格产生如图 3-10 所示的布局。

img/473159_1_En_3_Fig10_HTML.jpg

图 3-10

使用网格布局

使用引导程序设计表格

Bootstrap 包括对样式化table元素及其内容的支持,这是我在后面章节的一些例子中使用的一个特性。表 3-4 列出了使用表的关键引导类。

表 3-4

表格的引导 CSS 类

|

名字

|

描述

|
| --- | --- |
| table | 对一个table元素及其行应用常规样式 |
| table-striped | 对table正文中的行应用隔行条带化 |
| table-bordered | 将边框应用于所有行和列 |
| table-sm | 减少表格中的间距以创建更紧凑的布局 |

所有这些类都直接应用于table元素,如清单 3-19 所示,其中我用表格替换了网格布局。

import React, { Component } from "react";

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            count: 4
        }
    }

    isEven(val) {
        return val % 2 === 0 ? "Even" : "Odd";
    }

    getClassName(val) {
        return val % 2 === 0
            ? "bg-primary text-white text-center p-2 m-1"
            : "bg-secondary text-white text-center p-2 m-1"
    }

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

    render = () =>
        <table className="table table-striped table-bordered table-sm">
            <thead  className="bg-info text-white">
                <tr><th>Value</th><th>Even?</th></tr>
            </thead>
            <tbody>
                <tr>
                    <td>{ this.state.count }</td>
                    <td>{ this.isEven(this.state.count) } </td>
                </tr>
            </tbody>
            <tfoot className="text-center">
                <tr>
                    <td colSpan="2">
                        <button className="btn btn-info m-2"
                                onClick={ this.handleClick }>
                            Click Me
                        </button>
                    </td>
                </tr>
            </tfoot>
        </table>
}

Listing 3-19Using a Table Layout in the App.js File in the src Folder

小费

注意,在定义清单 3-19 中的表格时,我使用了thead元素。如果一个tbody元素没有被使用,浏览器会自动添加任何tr元素,这些元素是table元素的直接后代。如果在使用 Bootstrap 时依赖于这种行为,您将会得到奇怪的结果,并且在定义表时使用完整的元素集总是一个好主意。

图 3-11 显示了用表格代替网格的结果。

img/473159_1_En_3_Fig11_HTML.jpg

图 3-11

设计表格

使用引导程序设计表单

Bootstrap 包括表单元素的样式,允许它们与应用中的其他元素保持一致。在清单 3-20 中,我向由App组件产生的内容添加了表单元素。

import React, { Component } from "react";

export default class App extends Component {

    render = () =>
        <div className="m-2">
            <div className="form-group">
                <label>Name:</label>
                <input className="form-control" />
            </div>
            <div className="form-group">
                <label>City:</label>
                <input className="form-control" />
            </div>
        </div>
}

Listing 3-20Adding Form Elements in the App.js File in the src Folder

表单的基本样式是通过将form-group类应用于包含labelinput元素的div元素来实现的,其中input元素被分配给form-control类。Bootstrap 对元素进行样式化,使label显示在input元素上方,而input元素占据 100%的可用水平空间,如图 3-12 所示。

img/473159_1_En_3_Fig12_HTML.jpg

图 3-12

样式表单元素

摘要

在这一章中,我提供了 HTML 的简要概述,并解释了它如何在 React 开发中与 JavaScript 代码混合,尽管有一些变化和限制。我还介绍了 Bootstrap CSS 框架,我在本书中一直使用它,但它与 React 没有直接关系。您需要很好地掌握 HTML 和 CSS,以便在 web 应用开发中真正有效,但最好的学习方法是通过第一手经验,本章中的描述和示例将足以让您入门,并为前面的示例提供足够的背景信息。在下一章,我将继续初级主题,介绍本书中使用的最重要的 JavaScript 特性。

四、JavaScript 优先

在这一章中,我快速浏览了 JavaScript 语言应用于 React 开发的最重要的特性。我没有足够的空间来完整地描述 JavaScript,所以我把重点放在了你需要快速掌握并遵循本书中的例子的要点上。

近年来,JavaScript 已经现代化,增加了方便的语言特性,并对常见任务(如数组处理)可用的实用函数进行了大量扩展。并非所有的浏览器都支持最新的特性,因此 React 开发工具包括 Babel 包,它负责将使用最新特性编写的 JavaScript 转换成可以在大多数主流浏览器中工作的代码。这意味着您可以享受现代开发体验,而无需关注处理浏览器之间的差异和跟踪每个浏览器支持的功能。表 4-1 总结了本章内容。

表 4-1

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 提供将由浏览器执行的指令 | 使用 JavaScript 语句 | four |
| 将语句的执行延迟到需要的时候 | 使用 JavaScript 函数 | 5–7, 10–12 |
| 定义参数数量可变的函数 | 使用默认和 rest 参数 | 8, 9 |
| 简洁地表达功能 | 使用粗箭头功能 | Thirteen |
| 定义变量和常数 | 使用letconst关键字 | 14, 15 |
| 使用 JavaScript 基本类型 | 使用stringnumberboolean关键字 | 16, 17, 19 |
| 定义包含其他值的字符串 | 使用模板字符串 | Eighteen |
| 有条件地执行语句 | 使用ifelseswitch关键字 | Twenty |
| 比较价值观和身份 | 使用等式和标识运算符 | 21, 22 |
| 转换类型 | 使用类型转换关键字 | 23–25 |
| 分组相关项目 | 定义一个数组 | 26, 27 |
| 读取或更改数组中的值 | 使用索引访问器符号 | 28, 29 |
| 枚举数组的内容 | 使用for循环或forEach方法 | Thirty |
| 展开数组的内容 | 使用扩展运算符 | 31, 32 |
| 处理数组的内容 | 使用内置数组方法 | Thirty-three |
| 将相关值收集到一个单元中 | 使用文本或类定义对象 | 34–36, 40 |
| 定义可以对对象的值执行的操作 | 定义一种方法 | 37, 39, 43, 44 |
| 将属性和值从一个对象复制到另一个对象 | 使用Object.assign方法或使用扩展运算符 | 41, 42 |
| 群组相关功能 | 定义一个 JavaScript 模块 | 45–54 |
| 观察异步操作 | 定义一个Promise并使用asyncawait关键字 | 55–58 |

为本章做准备

在这一章中,我继续使用在第三章中创建的primer项目。为了准备本章,我在src文件夹中添加了一个名为example.js的文件,并添加了清单 4-1 中所示的代码。

小费

你可以从 https://github.com/Apress/pro-react-16 下载本章以及本书其他章节的示例项目。

console.log("Hello");

Listing 4-1The Contents of the example.js File in the src Folder

为了将example.js文件合并到应用中,我将清单 4-2 中所示的语句添加到了src文件夹中的index.js文件中。

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

import "./example";

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

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

Listing 4-2Importing a File in the index.js File in the src Folder

打开命令提示符,导航到primer文件夹,运行清单 4-3 中所示的命令来启动 React 开发工具。

npm start

Listing 4-3Starting the Development Tools

项目的初始准备需要一段时间,之后将打开一个新的浏览器窗口或选项卡并导航到http://localhost:3000,显示如图 4-1 所示的内容。

img/473159_1_En_4_Fig1_HTML.jpg

图 4-1

运行示例应用

打开浏览器的 F12 开发工具,这通常可以通过按键盘上的 F12 或在浏览器窗口中右键单击并从弹出菜单中选择 Inspect 来完成。检查控制台选项卡,您会看到清单 4-1 中的example.js文件中的语句产生了一个简单的结果,如图 4-2 所示。

img/473159_1_En_4_Fig2_HTML.jpg

图 4-2

浏览器控制台中的结果

本章中的所有示例都会产生文本输出,因此我将只使用文本,而不是显示控制台选项卡的屏幕截图,如下所示:

Hello

使用语句

基本的 JavaScript 构建块是语句。每条语句代表一条命令,语句通常以分号(;)结束。分号是可选的,但是使用分号会使代码更容易阅读,并且允许在一行中有多个语句。在清单 4-4 中,我向 JavaScript 文件添加了语句。

console.log("Hello");

console.log("Apples");

console.log("This is a statement");

console.log("This is also a statement");

Listing 4-4Adding JavaScript Statements in the example.js File in the src Folder

浏览器依次执行每条语句。在本例中,所有语句都只是将消息写入控制台。结果如下:

Hello
Apples
This is a statement
This is also a statement

定义和使用函数

当浏览器收到 JavaScript 代码时,它会按照定义的顺序执行其中包含的语句。这就是上一个示例中发生的情况。example.js文件中的语句被逐一执行,所有语句都向控制台写入一条消息,所有语句都是按照它们在example.js中定义的顺序执行的。您还可以将语句打包到一个函数中,直到浏览器遇到一个调用该函数的语句,该函数才会被执行,如清单 4-5 所示。

const myFunc = function () {
    console.log("This statement is inside the function");
};

console.log("This statement is outside the function");

myFunc();

Listing 4-5Defining a JavaScript Function in the example.js File in the src Folder

定义一个函数很简单:使用const关键字,后跟您想要给函数起的名字,再加上等号(=)和function关键字,再加上括号(()字符)。您希望函数包含的语句用大括号括起来(字符{})。

在清单中,我使用了名称myFunc,该函数包含一个向 JavaScript 控制台写入消息的语句。在浏览器到达另一个调用myFunc函数的语句之前,函数中的语句不会被执行,如下所示:

...
myFunc();
...

当您保存对example.js文件的更改时,更新的 JavaScript 代码将被发送到浏览器,在浏览器中执行并产生以下输出:

This statement is outside the function
This statement is inside the function

您可以看到函数内部的语句并没有立即执行,但是除了演示函数是如何定义的以外,这个例子并不是特别有用,因为函数是在定义后立即被调用的。当响应某种变化或事件(如用户交互)而调用函数时,函数会更有用。

您还可以定义函数,这样就不必显式地创建和分配变量,如清单 4-6 所示。

function myFunc() {

    console.log("This statement is inside the function");
}

console.log("This statement is outside the function");

myFunc();

Listing 4-6Defining a Function in the example.js File in the src Folder

代码的工作方式与清单 4-5 相同,但对大多数开发人员来说更熟悉。这个例子产生了与清单 4-5 相同的结果。

用参数定义函数

JavaScript 允许您为函数定义参数,如清单 4-7 所示。

function myFunc(name, weather) {
    console.log("Hello " + name + ".");
    console.log("It is " + weather + " today.");
}

myFunc("Adam", "sunny");

Listing 4-7Defining Functions with Parameters in the example.js File in the src Folder

我给myFunc函数添加了两个参数,称为nameweather。JavaScript 是一种动态类型语言,这意味着在定义函数时不必声明参数的数据类型。当我在本章后面讲述 JavaScript 变量时,我会回到动态类型。要调用带参数的函数,需要在调用函数时提供值作为参数,如下所示:

...
myFunc("Adam", "sunny");
...

该清单的结果如下:

Hello Adam.
It is sunny today.

使用默认和 Rest 参数

调用函数时提供的参数数量不需要与函数中的参数数量相匹配。如果调用函数时使用的参数少于它拥有的参数,那么任何没有提供值的参数的值都是undefined,这是一个特殊的 JavaScript 值。如果调用函数时使用的参数多于实际参数,那么多余的参数将被忽略。

这样做的结果是,您不能创建两个具有相同名称和不同参数的函数,并期望 JavaScript 根据您在调用函数时提供的参数来区分它们。这被称为多态性,尽管它在 Java 和 C#等语言中受支持,但在 JavaScript 中不可用。相反,如果您定义了两个同名的函数,那么第二个定义将替换第一个定义。

有两种方法可以修改函数,以响应函数定义的参数数量和用于调用函数的参数数量之间的不匹配。默认参数处理实参比参数少的情况,允许你为没有实参的参数提供默认值,如清单 4-8 所示。

function myFunc(name, weather = "raining") {

    console.log("Hello " + name + ".");
    console.log("It is " + weather + " today.");
}

myFunc("Adam");

Listing 4-8Using a Default Parameter in the example.js File in the src Folder

函数中的weather参数已被赋予默认值raining,如果仅使用一个参数调用该函数,将使用该值,产生以下结果:

Hello Adam.
It is raining today.

Rest 参数用于在用附加参数调用函数时捕获任何附加参数,如清单 4-9 所示。

function myFunc(name, weather, ...extraArgs) {

    console.log("Hello " + name + ".");
    console.log("It is " + weather + " today.");
    for (let i = 0; i < extraArgs.length; i++) {

        console.log("Extra Arg: " + extraArgs[i]);

    }

}

myFunc("Adam", "sunny", "one", "two", "three");

Listing 4-9Using a Rest Parameter in the example.js File in the src Folder

rest 参数必须是函数定义的最后一个参数,其名称以省略号为前缀(三个句点,...)。rest 参数是一个数组,任何额外的参数都将被赋给它。在清单中,该函数将每个额外的参数打印到控制台,产生以下结果:

Hello Adam.
It is sunny today.
Extra Arg: one
Extra Arg: two
Extra Arg: three

定义返回结果的函数

您可以使用return关键字从函数中返回结果。清单 4-10 显示了一个返回结果的函数。

function myFunc(name) {

    return ("Hello " + name + ".");

}

console.log(myFunc("Adam"));

Listing 4-10Returning a Result from a Function in the example.js File in the src Folder

这个函数定义了一个参数,并用它来产生一个结果。我调用函数并将结果作为参数传递给console.log函数,如下所示:

...
console.log(myFunc("Adam"));
...

请注意,您不必声明该函数将返回一个结果或表示结果的数据类型。该清单的结果如下:

Hello Adam.

将函数用作其他函数的参数

JavaScript 函数可以被视为对象,这意味着您可以使用一个函数作为另一个函数的参数,如清单 4-11 所示。

function myFunc(nameFunction) {

    return ("Hello " + nameFunction() + ".");

}

console.log(myFunc(function () {

    return "Adam";

}));

Listing 4-11Using a Function as an Arguments in the example.js File in the src Folder

myFunc函数定义了一个名为nameFunction的参数,它调用这个参数来获取插入到它返回的字符串中的值。我将一个返回Adam作为参数的函数传递给myFunc,它产生以下输出:

Hello Adam.

函数可以链接在一起,从小而容易测试的代码片段中构建更复杂的功能,如清单 4-12 所示。

function myFunc(nameFunction) {
    return ("Hello " + nameFunction() + ".");
}

function printName(nameFunction, printFunction) {

    printFunction(myFunc(nameFunction));

}

printName(function () { return "Adam" }, console.log);

Listing 4-12Chaining Functions Calls in the example.js File in the src Folder

此示例产生以下输出:

Hello Adam.

使用箭头功能

箭头函数——也称为胖箭头函数λ表达式——是定义函数的另一种方式,通常用于定义仅用作其他函数参数的函数。清单 4-13 用箭头函数替换了前一个例子中的函数。

const myFunc = (nameFunction) => ("Hello " + nameFunction() + ".");

const printName = (nameFunction, printFunction) =>

    printFunction(myFunc(nameFunction));

printName(function () { return "Adam" }, console.log);

Listing 4-13Using Arrow Functions in the example.js File in the src Folder

这些函数与清单 4-12 中的函数执行相同的工作。箭头函数有三个部分:输入参数、等号和大于号(“箭头”),最后是函数结果。只有当 arrow 函数需要执行多条语句时,才需要关键字return和花括号。在这一章的后面有更多的箭头函数的例子,你会在整本书中看到它们的使用。

注意

在 React 开发中,您可以决定您更喜欢使用哪种风格的函数,您将会看到我在本书的示例中两者都使用。然而,如第十二章所述,在定义响应事件的函数时必须小心。

使用变量和类型

let关键字用于声明变量,也可以在一条语句中为变量赋值——与我在前面的例子中使用的const关键字相反,它创建一个不可修改的常量值。

当您使用letconst时,您创建的变量或常量只能在定义它们的代码区域中被访问,这被称为变量或常量的作用域,如清单 4-14 所示。

function messageFunction(name, weather) {

    let message = "Hello, Adam";

    if (weather === "sunny") {

        let message = "It is a nice day";

        console.log(message);

    } else {

        let message = "It is " + weather + " today";

        console.log(message);

    }

    console.log(message);

}

messageFunction("Adam", "raining");

Listing 4-14Using let to Declare Variables in the example.js File in the src Folder

在这个例子中,有三个语句使用let关键字来定义一个名为message的变量。每个变量的范围限于定义它的代码区域,产生以下结果:

It is raining today
Hello, Adam

这似乎是一个奇怪的例子,但是还有另一个关键字可以用来声明变量:varletconst关键字是 JavaScript 规范中相对较新的补充,旨在解决var行为方式中的一些奇怪之处。清单 4-15 以清单 4-14 为例,将let替换为var

使用 Let 和 Const

对于您不希望更改的任何值,使用const关键字是一个很好的实践,这样,如果试图进行任何修改,您都会收到一个错误。然而,这是我很少遵循的一种做法——一部分是因为我仍然在努力适应不使用var关键字,另一部分是因为我用一系列语言编写代码,并且有一些我避免的功能,因为当我从一种语言切换到另一种语言时它们会绊倒我。如果你是 JavaScript 新手,那么我建议你试着正确使用constlet,避免步我后尘。

function messageFunction(name, weather) {
    var message = "Hello, Adam";

    if (weather === "sunny") {
        var message = "It is a nice day";

        console.log(message);
    } else {
        var message = "It is " + weather + " today";

        console.log(message);
    }
    console.log(message);
}

messageFunction("Adam", "raining");

Listing 4-15Using var to Declare Variables in the example.js File in the src Folder

当您保存列表中的更改时,您将看到以下结果:

It is raining today
It is raining today

有些浏览器会将重复的语句显示为一行,旁边有一个数字,表示该输出发生了多少次。这意味着您可能会看到一个旁边带有数字 2 的语句,表明它出现了两次。

问题是var关键字创建的变量的作用域是包含函数,这意味着所有对message的引用都是指同一个变量。这甚至会给有经验的 JavaScript 开发人员带来意想不到的结果,这也是引入更传统的let关键字的原因。React 开发工具包括常见问题的警告,这就是为什么您还会在 JavaScript 控制台中看到以下消息:

Line 4:  'message' is already defined  no-redeclare
Line 7:  'message' is already defined  no-redeclare

在您熟悉这些消息之前,它们可能是神秘的,了解它们的最简单方法是查阅 ESLint 包的文档,该包将一组规则应用于 JavaScript 代码,React 开发工具使用它来创建警告。规则的名称包含在警告中,产生清单 4-15 警告的规则名称是 no-redeclare,在 https://eslint.org/docs/rules/no-redeclare 中有描述。

使用可变闭包

如果你在另一个函数内部定义一个函数——创建内部外部函数——那么内部函数能够访问外部函数的变量,使用一个叫做闭包的特性,就像这样:

function myFunc(name) {
    let myLocalVar = "sunny";
    let innerFunction = function () {

        return ("Hello " + name + ". Today is " + myLocalVar + ".");

    }

    return innerFunction();

}

console.log(myFunc("Adam"));

这个例子中的内部函数能够访问外部函数的局部变量,包括它的参数。这是一个强大的特性,意味着您不必在内部函数上定义参数来传递数据值,但是需要小心,因为当使用像counterindex这样的普通变量名时,很容易得到意外的结果,您可能没有意识到您正在重用外部函数中的变量名。

使用基本类型

JavaScript 定义了一组基本的原语类型:stringnumberboolean。这似乎是一个很短的列表,但是 JavaScript 设法将很多灵活性融入到这些类型中。

小费

我在这里简化。您可能会遇到另外三种原语。已经声明但没有赋值的变量是undefined,而null值用来表示一个变量没有值,就像其他语言一样。最后一个原语类型是Symbol,它是一个不可变的值,表示一个惟一的 ID,但是在编写本文时还没有广泛使用。

使用布尔值

boolean类型有两个值:truefalse。清单 4-16 显示了正在使用的两个值,但是这种类型在条件语句中使用时最有用,比如一个if语句。这个清单没有控制台输出,尽管您会看到警告,因为变量已经定义并且没有使用。

let firstBool = true;
let secondBool = false;

Listing 4-16Defining boolean Values in the example.js File in the src Folder

使用字符串

您可以使用双引号或单引号字符来定义string值,如清单 4-17 所示。

let firstString = "This is a string";
let secondString = 'And so is this';

Listing 4-17Defining string Variables in the example.js File in the src Folder

您使用的引号字符必须匹配。例如,你不能用单引号开始一个字符串,然后用双引号结束。此列表没有控制台输出。JavaScript 为string对象提供了一组基本的属性和方法,其中最有用的在表 4-2 中有描述。

表 4-2

有用的字符串属性和方法

|

名字

|

描述

|
| --- | --- |
| length | 此属性返回字符串中的字符数。 |
| charAt(index) | 此方法返回包含指定索引处的字符的字符串。 |
| concat(string) | 此方法返回一个新字符串,该字符串将调用该方法的字符串和作为参数提供的字符串连接在一起。 |
| indexOf(term, start) | 该方法返回第一个索引,在该索引处term出现在字符串中,如果没有匹配,则返回-1。可选的start参数指定搜索的起始索引。 |
| replace(term, newTerm) | 该方法返回一个新字符串,其中所有的term实例都被替换为newTerm。 |
| slice(start, end) | 此方法返回包含起始和结束索引之间的字符的子字符串。 |
| split(term) | 这个方法将一个字符串分割成一个由term分隔的值数组。 |
| toUpperCase()``toLowerCase() | 这些方法返回所有字符都是大写或小写的新字符串。 |
| trim() | 此方法返回一个新字符串,其中所有的前导和尾随空白字符都已被删除。 |

使用模板字符串

一个常见的编程任务是将静态内容与数据值结合起来,以生成可以呈现给用户的字符串。传统的方法是通过字符串连接,这是我在本章的例子中一直使用的方法,如下所示:

...
let message = "It is " + weather + " today";
...

JavaScript 还支持模板字符串,它允许内联指定数据值,这有助于减少错误,带来更自然的开发体验。清单 4-18 展示了模板字符串的使用。

function messageFunction(weather) {
    let message = `It is ${weather} today`;
    console.log(message);
}

messageFunction("raining");

Listing 4-18Using a Template String in the example.js File in the src Folder

模板字符串以反斜杠(```jsx 字符)开始和结束,数据值由花括号表示,前面有一个美元符号。例如,这个字符串将变量weather的值合并到模板字符串中:

...
let message = `It is ${weather} today`;
...

```jsx

此示例产生以下输出:

It is raining today


#### 使用数字

`number`类型用于表示*整数*和*浮点*(也称为*实数*)。清单 4-19 提供了一个演示。

let daysInWeek = 7;
let pi = 3.14;
let hexValue = 0xFFFF;

Listing 4-19Defining number Values in the example.js File in the src Folder


您不必指定使用哪种号码。您只需表达您需要的值,JavaScript 就会相应地执行。在清单中,我定义了一个整数值、一个浮点值,并在一个值前面加上了`0x`来表示一个十六进制值。

## 使用 JavaScript 运算符

JavaScript 定义了一组非常标准的操作符。我在表 4-3 中总结了最有用的。

表 4-3

有用的 JavaScript 运算符

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

操作员

 | 

描述

 |
| --- | --- |
| `++, --` | 前或后递增和递减 |
| `+, -, *, /, %` | 加法、减法、乘法、除法、余数 |
| `<, <=, >, >=` | 小于,小于等于,大于,大于等于 |
| `==, !=` | 平等和不平等测试 |
| `===, !==` | 同一性和非同一性测试 |
| `&&, &#124;&#124;` | 逻辑 AND 和 OR (&#124;&#124;用于合并空值) |
| `=` | 分配 |
| `+` | 串并置 |
| `?:` | 三操作数条件语句 |

### 使用条件语句

许多 JavaScript 操作符与条件语句一起使用。在本书中,我倾向于使用`if/else`和`switch`语句。清单 4-20 展示了两者的用法,这对大多数开发者来说都是熟悉的。

let name = "Adam";

if (name === "Adam") {
console.log("Name is Adam");
} else if (name === "Jacqui") {
console.log("Name is Jacqui");
} else {
console.log("Name is neither Adam or Jacqui");
}

switch (name) {
case "Adam":
console.log("Name is Adam");
break;
case "Jacqui":
console.log("Name is Jacqui");
break;
default:
console.log("Name is neither Adam or Jacqui");
break;
}

Listing 4-20Using Conditional Statements in the example.js File in the src Folder


此示例产生以下结果:

Name is Adam
Name is Adam


### 相等运算符与相同运算符

等式和等式运算符特别值得注意。相等运算符将尝试将操作数强制(转换)为相同的类型来评估相等性。这是一个方便的特性,只要你意识到它正在发生。清单 4-21 展示了等式操作符的作用。

let firstVal = 5;
let secondVal = "5";

if (firstVal == secondVal) {
console.log("They are the same");
} else {
console.log("They are NOT the same");
}

Listing 4-21Using the Equality Operator in the example.js File in the src Folder


该示例的输出如下:

They are the same


JavaScript 将两个操作数转换成相同的类型,并对它们进行比较。本质上,相等运算符测试值是否相同,而不管它们的类型如何。这造成了足够的混乱,以至于您还会在 JavaScript 控制台中看到一条警告:

Line 4: Expected '=' and instead saw '' eqeqeq


一种更容易预测的比较方式是使用恒等运算符(`===`,三个等号,而不是两个等号),如清单 4-22 所示。

let firstVal = 5;
let secondVal = "5";

if (firstVal === secondVal) {

console.log("They are the same");

} else {
console.log("They are NOT the same");
}

Listing 4-22Using the Identity Operator in the example.js File in the src Folder


在本例中,identity 运算符将认为这两个变量是不同的。该运算符不强制类型。结果如下:

They are NOT the same


### 显式转换类型

字符串连接操作符(`+`)优先于加法操作符(还有`+`),这意味着 JavaScript 将优先于加法连接变量。这可能会造成混乱,因为 JavaScript 也会自由地转换类型以产生结果——而不总是预期的结果,如清单 4-23 所示。

let myData1 = 5 + 5;

let myData2 = 5 + "5";

console.log("Result 1: " + myData1);
console.log("Result 2: " + myData2);

Listing 4-23String Concatenation Operator Precedence in the example.js File in the src Folder


这些语句会产生以下结果:

Result 1: 10
Result 2: 55


第二种结果是引起混乱的那种。通过运算符优先级和过急类型转换的组合,原本应该是加法运算的操作被解释为字符串串联。为了避免这种情况,可以显式转换值的类型,以确保执行正确的操作,如以下部分所述。

#### 将数字转换为字符串

如果您正在处理多个数字变量,并希望将它们连接成字符串,那么您可以使用`toString`方法将数字转换成字符串,如清单 4-24 所示。

let myData1 = (5).toString() + String(5);

console.log("Result: " + myData1);

Listing 4-24Using the number.toString Method in the example.js File in the src Folder


注意,我将数值放在括号中,然后调用了`toString`方法。这是因为在调用`number`类型定义的方法之前,您必须允许 JavaScript 将文字值转换成`number`。我还展示了实现相同效果的另一种方法,即调用`String`函数,并将数值作为参数传入。这两种技术具有相同的效果,都是将一个`number`转换成一个`string`,这意味着`+`操作符用于字符串连接而不是加法。该脚本的输出如下:

Result: 55


还有一些其他的方法可以让你更好地控制一个数字如何被表示成一个字符串。我在表 4-4 中简要描述了这些方法。表格中显示的所有方法都由`number`类型定义。

表 4-4

有用的数字到字符串的方法

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

方法

 | 

描述

 |
| --- | --- |
| `toString()` | 此方法返回一个表示以 10 为基数的数字的字符串。 |
| `toString(2)``toString(8)``toString(16)` | 此方法返回以二进制、八进制或十六进制表示法表示数字的字符串。 |
| `toFixed(n)` | 该方法返回一个表示小数点后有`n`位的实数的字符串。 |
| `toExponential(n)` | 该方法返回一个字符串,该字符串使用指数表示法表示一个数字,小数点前有一位数字,小数点后有`n`位数字。 |
| `toPrecision(n)` | 该方法返回一个字符串,该字符串表示一个具有`n`个有效数字的数字,如果需要,可以使用指数符号。 |

#### 将字符串转换为数字

补充技术是将字符串转换为数字,这样您就可以执行加法而不是连接。你可以用`Number`函数来实现,如清单 4-25 所示。

let firstVal = "5";
let secondVal = "5";

let result = Number(firstVal) + Number(secondVal);
console.log("Result: " + result);

Listing 4-25Converting Strings to Numbers in the example.js File in the src Folder


该脚本的输出如下:

Result: 10


`Number`函数解析字符串值的方式非常严格,但是您可以使用另外两个更灵活的函数,它们会忽略后面的非数字字符。这些功能是`parseInt`和`parseFloat`。我已经在表 4-5 中描述了所有三种方法。

表 4-5

对数字方法有用的字符串

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

方法

 | 

描述

 |
| --- | --- |
| `Number(str)` | 此方法分析指定的字符串以创建整数或实数值。 |
| `parseInt(str)` | 此方法分析指定的字符串以创建整数值。 |
| `parseFloat(str)` | 此方法分析指定的字符串以创建整数或实数值。 |

## 使用数组

JavaScript 数组的工作方式类似于大多数其他编程语言中的数组。清单 4-26 展示了如何创建和填充一个数组。

let myArray = new Array();

myArray[0] = 100;

myArray[1] = "Adam";

myArray[2] = true;

Listing 4-26Creating and Populating an Array in the example.js File in the src Folder


我通过调用`new Array()`创建了一个新数组。这创建了一个空数组,我将它赋给了变量`myArray`。在随后的语句中,我为数组中的不同索引位置赋值。(这个清单没有输出。)

在这个例子中有一些事情需要注意。首先,在创建数组时,我不需要声明数组中的项数。JavaScript 数组会自动调整大小以容纳任意数量的项目。第二点是,我不必声明数组将保存的数据类型。任何 JavaScript 数组都可以包含任何混合的数据类型。在这个例子中,我给数组分配了三个项目:一个`number`、一个`string`和一个`boolean`。

### 使用数组文本

清单 4-26 中的例子产生了一个警告,因为使用`new Array()`不是创建数组的标准方式。相反,array literal 风格允许您在一条语句中创建和填充一个数组,如清单 4-27 所示。

let myArray = [100, "Adam", true];

Listing 4-27Using the Array Literal Style in the example.js File in the src Folder


在这个例子中,我通过在方括号(`[`和`]`)之间指定我想要的数组中的项目,指定了应该给`myArray`变量分配一个新的数组。(这个清单没有控制台输出,尽管会有一个警告,因为数组已经定义但没有使用。)

### 读取和修改数组的内容

使用方括号(`[`和`]`)读取给定索引处的值,将所需的索引放在括号之间,如清单 4-28 所示。

let myArray = [100, "Adam", true];

console.log(Index 0: ${myArray[0]});

Listing 4-28Reading the Data from an Array Index in the example.js File in the src Folder


只需给索引赋值,就可以修改 JavaScript 数组中任何位置的数据。就像常规变量一样,您可以在索引处切换数据类型,不会有任何问题。清单的输出如下所示:

Index 0: 100


清单 4-29 演示了如何修改数组的内容。

let myArray = [100, "Adam", true];

myArray[0] = "Tuesday";

console.log(Index 0: ${myArray[0]});

Listing 4-29Modifying the Contents of an Array in the example.js File in the src Folder


在这个例子中,我将一个`string`赋值给数组中的位置`0`,这个位置以前是由一个`number`持有的,并产生以下输出:

Index 0: Tuesday


### 枚举数组的内容

使用一个`for`循环或者使用`forEach`方法来枚举数组的内容,该方法接收一个被调用来处理数组中每个元素的函数。两种方法如清单 4-30 所示。

let myArray = [100, "Adam", true];

for (let i = 0; i < myArray.length; i++) {
console.log(Index ${i}: ${myArray[i]});
}

console.log("---");

myArray.forEach((value, index) => console.log(Index ${index}: ${value}));

Listing 4-30Enumerating the Contents of an Array in the example.js File in the src Folder


JavaScript `for`循环的工作方式与许多其他语言中的循环一样。使用`length`属性确定数组中有多少个元素。

传递给`forEach`方法的函数有两个参数:要处理的当前项的值和该项在数组中的位置。在这个清单中,我使用了一个 arrow 函数作为`forEach`方法的参数,这是它们擅长的一种用法(您将在本书中看到这种用法)。清单的输出如下所示:

Index 0: 100
Index 1: Adam
Index 2: true

Index 0: 100
Index 1: Adam
Index 2: true


### 使用扩展运算符

spread 运算符用于扩展数组,以便其内容可以用作函数参数。清单 4-31 定义了一个函数,该函数接受多个参数,并使用数组中的值调用它,使用或不使用 spread 运算符。

function printItems(numValue, stringValue, boolValue) {
console.log(Number: ${numValue});
console.log(String: ${stringValue});
console.log(Boolean: ${boolValue});
}

let myArray = [100, "Adam", true];

printItems(myArray[0], myArray[1], myArray[2]);

printItems(...myArray);

Listing 4-31Using the Spread Operator in the example.js File in the src Folder


spread 操作符是一个省略号(三个句点的序列),它导致数组被解包并作为单独的参数传递给`printItems`函数。

...
printItems(...myArray);
...


spread 操作符也使得连接数组变得容易,如清单 4-32 所示。

let myArray = [100, "Adam", true];
let myOtherArray = [200, "Bob", false, ...myArray];

myOtherArray.forEach((value, index) => console.log(Index ${index}: ${value}));

Listing 4-32Concatenating Arrays in the example.js File in the src Folder


使用 spread 操作符,我可以在定义`myOtherArray`时将`myArray`指定为一个项,结果是第一个数组的内容将被解包并作为项添加到第二个数组中。此示例产生以下结果:

Index 0: 200
Index 1: Bob
Index 2: false
Index 3: 100
Index 4: Adam
Index 5: true


### 注意

数组也可以被去结构化,数组的各个元素被分配给不同的变量,这样`[var1, var2] = [3, 4]`将值`3`分配给`var1`,将值`4`分配给`var2`。钩子特性使用数组分解,这在第十一章中有描述。

### 使用内置数组方法

JavaScript `Array`对象定义了许多可以用来处理数组的方法,表 4-6 中描述了其中最有用的方法。

表 4-6

有用的数组方法

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

方法

 | 

描述

 |
| --- | --- |
| `concat(otherArray)` | 此方法返回一个新数组,该数组将调用它的数组与指定为参数的数组连接起来。可以指定多个数组。 |
| `join(separator)` | 该方法将数组中的所有元素连接起来形成一个字符串。该参数指定用于分隔各项的字符。 |
| `pop()` | 此方法移除并返回数组中的最后一项。 |
| `shift()` | 此方法移除并返回数组中的第一个元素。 |
| `push(item)` | 此方法将指定的项追加到数组的末尾。 |
| `unshift(item)` | 此方法在数组的开头插入一个新项。 |
| `reverse()` | 此方法返回一个新数组,该数组包含逆序排列的项。 |
| `slice(start,end)` | 此方法返回数组的一部分。 |
| `sort()` | 此方法对数组进行排序。可选的比较功能可用于执行自定义比较。 |
| `splice(index, count)` | 该方法从指定的`index`开始,从数组中移除`count`项。移除的项作为方法的结果返回。 |
| `unshift(item)` | 此方法在数组的开头插入一个新项。 |
| `every(test)` | 该方法为数组中的每一项调用`test`函数,如果函数为所有项返回`true`,则返回`true`,否则返回`false`。 |
| `some(test)` | 如果为数组中的每一项调用`test`函数至少返回一次`true`,则该方法返回`true`。 |
| `filter(test)` | 该方法返回一个新数组,其中包含了`test`函数返回的`true`项。 |
| `find(test)` | 该方法返回数组中第一个项目,对于该项目,`test`函数返回`true`。 |
| `findIndex(test)` | 该方法返回数组中第一项的索引,对于该数组,`test`函数返回`true`。 |
| `forEach(callback)` | 这个方法为数组中的每一项调用`callback`函数,如前一节所述。 |
| `includes(value)` | 如果数组包含指定的值,这个方法返回`true`。 |
| `map(callback)` | 该方法返回一个新数组,其中包含为数组中的每一项调用`callback`函数的结果。 |
| `reduce(callback)` | 该方法返回通过调用回调函数为数组中的每一项生成的累计值。 |

由于表 4-6 中的许多方法返回一个新数组,这些方法可以链接在一起处理数据,如清单 4-33 所示。

let products = [
{ name: "Hat", price: 24.5, stock: 10 },
{ name: "Kayak", price: 289.99, stock: 1 },
{ name: "Soccer Ball", price: 10, stock: 0 },
{ name: "Running Shoes", price: 116.50, stock: 20 }
];

let totalValue = products
.filter(item => item.stock > 0)
.reduce((prev, item) => prev + (item.price * item.stock), 0);

console.log(Total value: $${totalValue.toFixed(2)});

Listing 4-33Processing an Array in the example.js File in the src Folder


我使用`filter`方法选择数组中`stock`值大于零的项目,并使用`reduce`方法确定这些项目的总值,产生以下输出:

Total value: $2864.99


## 使用对象

有几种方法可以在 JavaScript 中创建对象。清单 4-34 给出了一个简单的例子。

let myData = new Object();
myData.name = "Adam";
myData.weather = "sunny";

console.log(Hello ${myData.name}.);
console.log(Today is ${myData.weather}.);

Listing 4-34Creating an Object in the example.js File in the src Folder


我通过调用`new Object()`创建一个对象,并将结果(新创建的对象)赋给一个名为`myData`的变量。一旦创建了对象,我就可以通过赋值来定义对象的属性,就像这样:

...

myData.name = "Adam";

...


在这个语句之前,我的对象没有名为`name`的属性。当语句执行后,该属性确实存在,并被赋予了值`Adam`。您可以通过将变量名和属性名与句点组合来读取属性值,如下所示:

...
console.log(Hello ${myData.name}.);
...


清单的结果如下:

Hello Adam.
Today is sunny.


### 使用对象文字

前面的例子产生了一个警告,因为定义对象的标准方法是使用对象文字格式,这也允许在一个步骤中定义属性,如清单 4-35 所示。

let myData = {

name: "Adam",

weather: "sunny"

};

console.log(Hello ${myData.name}.);
console.log(Today is ${myData.weather}.);

Listing 4-35Using the Object Literal Format in the example.js File in the src Folder


使用冒号(`:`)将您要定义的每个属性与其值分开,使用逗号(`,`)将属性分开。效果与前面的示例相同,清单的结果如下:

Hello Adam.
Today is sunny.


#### 使用变量作为对象属性

如果使用变量作为对象属性,JavaScript 将使用变量名作为属性名,变量值作为属性值,如清单 4-36 所示。

let name = "Adam"

let myData = {
name,

weather: "sunny"
};

console.log(Hello ${myData.name}.);
console.log(Today is ${myData.weather}.);

Listing 4-36Using a Variable in an Object Literal in the example.js File in the src Folder


`name`变量用于向`myData`对象添加一个属性,这样该属性从变量`name`中获取,作为其值`Adam`。当您想要将一组数据值组合成一个对象时,这是一种有用的技术,您将在后面章节的示例中看到它的使用。清单 4-37 中的代码产生以下输出:

Hello Adam.
Today is sunny.


### 将函数用作方法

我最喜欢 JavaScript 的一个特性是可以向对象添加函数。定义在对象上的函数被称为*方法*。清单 4-37 展示了如何以这种方式添加方法。

let myData = {
name: "Adam",
weather: "sunny",
printMessages: function () {

    console.log(`Hello ${myData.name}.`);

    console.log(`Today is ${myData.weather}.`);

}

};

myData.printMessages();

Listing 4-37Adding Methods to an Object in the example.js File in the src Folder


在这个例子中,我使用了一个函数来创建一个名为`printMessages`的方法。注意,为了引用对象定义的属性,我必须使用`this`关键字。当一个函数作为一个方法使用时,该函数通过特殊变量`this`被隐式传递给调用该方法的对象作为参数。清单的输出如下所示:

Hello Adam.
Today is sunny.


您也可以不使用`function`关键字来定义方法,如清单 4-38 所示。

let myData = {
name: "Adam",
weather: "sunny",
printMessages() {

    console.log(`Hello ${myData.name}.`);

    console.log(`Today is ${myData.weather}.`);

}

};

myData.printMessages();

Listing 4-38Defining a Method in the example.js File in the src Folder


该清单的输出如下:

Hello Adam.
Today is sunny.


粗箭头语法也可以用来定义方法,如清单 4-39 所示。

let myData = {
name: "Adam",
weather: "sunny",
printMessages: () => {

    console.log(`Hello ${myData.name}.`);

    console.log(`Today is ${myData.weather}.`);

}

};

myData.printMessages();

Listing 4-39Defining a Fat Arrow Method in the example.js File in the src Folder


### 小费

如果您从胖箭头函数返回一个对象文字,那么您必须将该对象括在括号中,例如`myFunc = () => ({ data: "hello"})`。如果省略括号,您将收到一个错误,因为构建工具将假设对象文字的花括号是函数体的开始和结束。

### 使用类

类是对象的模板,定义新实例将拥有的属性和方法。类是 JavaScript 语言的新成员,它们在 React 开发中用于定义具有状态数据的组件,如第十一章所述。在清单 4-40 中,我用一个类替换了对象文字。

class MyData {

constructor() {

    this.name = "Adam";

    this.weather = "sunny";

}

printMessages = () => {

    console.log(`Hello ${this.name}.`);

    console.log(`Today is ${this.weather}.`);

}

}

let myData = new MyData();

myData.printMessages();

Listing 4-40Using a Class in the example.js File in the src Folder


使用关键字`class`定义类。`constructor`是一个特殊的方法,当从类中创建一个对象时,这个方法被自动调用,这就是所谓的*实例化类*。从一个类创建的对象被称为该类的一个*实例*。

在 JavaScript 中,构造函数用于定义实例将拥有的属性,当前对象使用关键字`this`引用。清单 4-40 中的构造函数通过给`this.name`和`this.weather`赋值来定义`name`和`weather`属性。类通过给名字分配函数来定义方法,在清单 4-40 中,类定义了一个`printMessages`方法,该方法使用胖箭头语法来定义,并将消息打印到控制台。注意,访问`name`和`weather`变量的值需要`this`关键字。

### 小费

使用 JavaScript 类还有其他方法,但是我主要关注的是它们在 React 开发和本书示例中的使用方式。详见 [`https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes) 。

使用`new`关键字创建该类的一个新实例,一个类可以用来创建多个对象,每个对象都有自己的数据值,这些数据值与其他实例是分开的。在清单中,`new`关键字用于从`MyData`类创建一个对象,然后将该对象赋给一个名为`myData`的变量。调用对象的`printMessages`方法,产生以下输出:

Hello Adam.
Today is sunny.


在其他语言和框架中,类用于继承,其中一个类建立在另一个类定义的方法和属性上。React 开发不直接使用类继承,而是使用一种替代方法,称为*组合*,来创建复杂的特性,如第十四章所述。一个例外是使用类定义 React 组件,其中必须使用关键字`extends`来确保类继承组件所需的核心特性。如果您检查`App.js`文件的内容,您会看到组件是使用`class`和`extends`关键字定义的,就像这样:

...
import React, { Component } from "react";

export default class App extends Component {

render = () =>
    <div className="m-2">
        <div className="form-group">
            <label>Name:</label>
            <input className="form-control" />
        </div>
        <div className="form-group">
            <label>City:</label>
            <input className="form-control" />
        </div>
    </div>

}
...


### 将属性从一个对象复制到另一个对象

React 和我在第三部分中描述的包提供的一些重要特性依赖于将属性从一个对象复制到另一个对象。JavaScript 为此提供了`Object.assign`方法,如清单 4-41 所示。

class MyData {

constructor() {
    this.name = "Adam";
    this.weather = "sunny";
}

printMessages = () => {
    console.log(`Hello ${this.name}.`);
    console.log(`Today is ${this.weather}.`);
}

}

let myData = new MyData();

let secondObject = {};

Object.assign(secondObject, myData);

secondObject.printMessages();

Listing 4-41Copying Object Properties in the example.js File in the src Folder


这个示例使用文本形式创建一个没有属性的新对象,并使用`Object.assign`方法从`myData`对象中复制属性及其值。此示例产生以下输出:

Hello Adam.
Today is sunny.


析构操作符——与 spread 操作符相同——可用于将属性从一个对象复制到另一个对象,我在后面章节中使用的一种技术是使用析构操作符复制所有现有属性,然后为其中一些属性定义一个新值,如清单 4-42 所示。

class MyData {

constructor() {
    this.name = "Adam";
    this.weather = "sunny";
}

printMessages = () => {
    console.log(`Hello ${this.name}.`);
    console.log(`Today is ${this.weather}.`);
}

}

let myData = new MyData();

let secondObject = { ...myData, weather: "cloudy"};

console.log(myData: ${ myData.weather}, secondObject: ${secondObject.weather});

Listing 4-42Copying Using a Spread in the example.js File in the src Folder


该示例从`myData`对象复制属性,并为`weather`属性提供一个新值,产生以下输出:

myData: sunny, secondObject: cloudy


### 从对象中捕获参数名

当一个对象作为函数或方法参数被接收时,浏览属性以获取所需的数据可能会很困难。举个简单的例子,清单 4-43 定义了一个对象结构,通过导航来获取数据值。

const myData = {
name: "Bob",
location: {
city: "Paris",
country: "France"
},
employment: {
title: "Manager",
dept: "Sales"
}
}

function printDetails(data) {
console.log(Name: ${data.name}, City: ${data.location.city}, Role: ${data.employment.title});
}

printDetails(myData);

Listing 4-43Navigating Object Properties in the example.js File in the src Folder


`printDetails`函数必须在对象中导航,以获得它需要的`name`、`city`和`title`属性。通过将特定属性捕获为命名参数,可以更好地实现相同的结果,如清单 4-44 所示。

const myData = {
name: "Bob",
location: {
city: "Paris",
country: "France"
},
employment: {
title: "Manager",
dept: "Sales"
}
}

function printDetails({ name, location: { city }, employment: { title }}) {

console.log(`Name: ${name}, City: ${city}, Role: ${title}`);

}

printDetails(myData);

Listing 4-44Capturing Named Parameters in the example.js File in the src Folder


这个例子应用了清单 4-36 中描述的技术来从对象中选择特定的属性。此清单和清单 4-43 产生相同的输出。

Name: Bob, City: Paris, Role: Manager


## 理解 JavaScript 模块

React 应用太复杂,无法在一个 JavaScript 文件中定义。为了将应用分成更易管理的块,JavaScript 支持*模块*,其中包含应用其他部分所依赖的 JavaScript 代码。在接下来的部分中,我将解释定义和使用模块的不同方式。

### 创建和使用 JavaScript 模块

示例项目中已经有 JavaScript 模块,但是理解它们如何工作的最好方法是创建并使用一个新模块。我在`src`文件夹中添加了一个名为`sum.js`的文件,并添加了清单 4-45 中所示的代码。

export default function(values) {
return values.reduce((total, val) => total + val, 0);
}

Listing 4-45The Contents of the sum.js File in the src Folder


`sum.js`文件包含一个函数,该函数接受一组值,并使用 JavaScript array `reduce`方法对它们求和并返回结果。这个例子的重要之处不在于它做了什么,而在于函数是在自己的文件中定义的,这是模块的基本构造块。

清单 4-45 中使用了两个你在定义模块时会经常遇到的关键词:`export`和`default`。`export`关键字用于表示模块外部可用的特性。默认情况下,JavaScript 文件的内容是私有的,必须使用`export`关键字显式共享,然后才能在应用的其余部分使用。当模块包含单个特性时,使用`default`关键字,例如清单 4-45 中定义的函数。`export`和`default`关键字一起用于指定`sum.js`文件中的唯一函数可用于应用的其余部分。

#### 使用 JavaScript 模块

使用模块需要另一个关键字:`import`关键字。在清单 4-46 中,我使用了`import`关键字来访问上一节中定义的函数,以便它可以在`example.js`文件中使用。

import additionFunction from "./sum";

let values = [10, 20, 30, 40, 50];

let total = additionFunction(values);

console.log(Total: ${total});

Listing 4-46Using a JavaScript Module in the example.js File in the src Folder


`import`关键字用于声明对模块的依赖。`import`关键字可以有多种不同的用法,但这是你在处理自己创建的模块时最常使用的格式,关键部分如图 4-3 所示。

![img/473159_1_En_4_Fig3_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-frontend-framework-zh/raw/master/docs/pro-react16/img/473159_1_En_4_Fig3_HTML.jpg)

图 4-3

声明对模块的依赖

`import`关键字后面是一个标识符,它是函数被使用时的名字,在这个例子中标识符是`additionFunction`。

### 小费

请注意,应用标识符的是`import`语句,这意味着使用模块中函数的代码选择它将被识别的名称,并且应用不同部分中同一模块的多个`import`语句可以使用不同的名称来引用同一函数。请参阅下一节,了解模块如何指定它所包含的特性的名称。

`from`关键字跟在标识符后面,然后是模块的位置。密切关注位置很重要,因为不同的位置格式会产生不同的行为,如侧栏中所述。

在构建过程中,React 工具将检测到`import`语句,并将来自`sum.js`文件的函数包含在发送到浏览器的 JavaScript 文件中,以便浏览器可以执行应用。在`import`语句中使用的标识符可以用来访问模块中的函数,就像使用本地定义的函数一样。

...
let total = additionFunction(values);
...


如果您检查浏览器的 JavaScript 控制台,您会看到清单 4-42 中的代码使用该模块的函数产生以下结果:

Total: 150


### 了解模块位置

模块的位置改变了构建工具在创建发送到浏览器的 JavaScript 文件时查找模块的方式。对于自己定义的模块,位置指定为相对路径;它以一个或两个句点开始,这表示该路径相对于当前文件或当前文件的父目录。在清单 4-46 中,位置以句点开始。

...
import additionFunction from "./sum";
...


这个位置告诉构建工具存在对`sum`模块的依赖,该模块可以在包含`import`语句的文件所在的文件夹中找到。请注意,文件扩展名不包括在位置中。

如果您省略了初始阶段,那么`import`语句声明了对`node_modules`文件夹中的模块的依赖,该文件夹是项目设置期间安装包的位置。这种位置用于访问第三方包提供的特性,包括 React 包,这就是为什么您会在 React 项目中看到这样的语句:

...
import React, { Component } from "react";
...


这个`import`语句的位置不是以句点开始的,它将被解释为对项目的`node_modules`文件夹中的`react`模块的依赖,该文件夹是提供核心 React 应用特性的包。

### 从模块中导出命名特征

一个模块可以为它输出的特性命名,这是我在本书的大部分例子中采用的方法。在清单 4-47 中,我给由`sum`模块导出的函数起了一个名字。

export function sumValues (values) {

return values.reduce((total, val) => total + val, 0);

}

Listing 4-47Exporting a Named Feature in the sum.js File in the src Folder


该函数提供了相同的特性,但是使用名称`sumValues`导出,不再使用`default`关键字。在清单 4-48 中,我在`example.js`文件中导入了使用新名称的特征。

import { sumValues } from "./sum";

let values = [10, 20, 30, 40, 50];

let total = sumValues(values);

console.log(Total: ${total});

Listing 4-48Importing a Named Feature in the example.js File in the src Folder


要导入的特征的名称在花括号中指定(字符`{`和`}`),并在代码中由该名称使用。一个模块可以导出默认的和命名的特征,如清单 4-49 所示。

export function sumValues (values) {
return values.reduce((total, val) => total + val, 0);
}

export default function sumOdd(values) {

return sumValues(values.filter((item, index) => index % 2 === 0));

}

Listing 4-49Exporting Named and Default Features in the sum.js File in the src Folder


使用`default`关键字导出新特征。在清单 4-50 中,我从模块中导入了新的特性作为默认导出。

import oddOnly, { sumValues } from "./sum";

let values = [10, 20, 30, 40, 50];

let total = sumValues(values);

let odds = oddOnly(values);

console.log(Total: ${total}, Odd Total: ${odds});

Listing 4-50Importing a Default Feature in the example.js File in the src Folder


这是您将在本书示例的 React 组件开头看到的模式,因为 JSX 所需的核心 React 特性是从`react`模块默认导出的,而`Component`类是一个命名特性:

...
import React, { Component } from "react";
...


清单 4-50 中的示例产生以下输出:

Total: 150, Odd Total: 90


### 在模块中定义多个命名特征

模块可以包含多个命名函数或值,这对于相关功能的分组非常有用。为了演示,我在`src`文件夹中创建了一个名为`operations.js`的文件,并添加了清单 4-51 中所示的代码。

export function multiply(values) {
return values.reduce((total, val) => total * val, 1);
}

export function subtract(amount, values) {
return values.reduce((total, val) => total - val, amount);
}

export function divide(first, second) {
return first / second;
}

Listing 4-51The Contents of the operations.js File in the src Folder


该模块定义了三个应用了关键字`export`的函数。与前面的例子不同,没有使用`default`关键字,每个函数都有自己的名称。当从包含多个特性的模块导入时,所需特性的名称被指定为大括号之间的逗号分隔列表,如清单 4-52 所示。

import oddOnly, { sumValues } from "./sum";

import { multiply, subtract } from "./operations";

let values = [10, 20, 30, 40, 50];

let total = sumValues(values);
let odds = oddOnly(values);

console.log(Total: ${total}, Odd Total: ${odds});

console.log(Multiply: ${multiply(values)});

console.log(Subtract: ${subtract(1000, values)});

Listing 4-52Importing Named Features in the example.js File in the src Folder


`import`关键字后面的括号包围了我要使用的函数列表,在本例中是用逗号分隔的`multiply`和`subtract`函数。我只声明对我需要的函数的依赖,对`divide`函数没有依赖,它在模块中定义了,但没有使用。此示例产生以下输出:

Total: 150, Odd Total: 90
Multiply: 12000000
Subtract: 850


#### 更改模块功能名称

从模块中导入命名特性时,您可能会发现有两个模块使用相同的名称,或者模块使用的名称在导入时不会生成可读的代码。您可以使用`as`关键字选择一个新名称,如清单 4-53 所示。

import oddOnly, { sumValues } from "./sum";

import { multiply, subtract as deduct } from "./operations";

let values = [10, 20, 30, 40, 50];

let total = sumValues(values);
let odds = oddOnly(values);

console.log(Total: ${total}, Odd Total: ${odds});
console.log(Multiply: ${multiply(values)});

console.log(Subtract: ${deduct(1000, values)});

Listing 4-53Assigning a Name to a Feature in the example.js File in the src Folder


我使用了`as`关键字来指定`subtract`函数在导入到`example.js`文件时应该被命名为`deduct`。此清单产生与清单 4-53 相同的输出。

#### 导入整个模块

列出一个模块中所有函数的名称对于复杂的模块来说是无法控制的。一种更优雅的方法是导入一个模块提供的所有特性,并只使用您需要的特性,如清单 4-54 所示。

import oddOnly, { sumValues } from "./sum";

import * as ops from "./operations";

let values = [10, 20, 30, 40, 50];

let total = sumValues(values);
let odds = oddOnly(values);

console.log(Total: ${total}, Odd Total: ${odds});

console.log(Multiply: ${ops.multiply(values)});

console.log(Subtract: ${ops.subtract(1000, values)});

Listing 4-54Importing an Entire Module in the example.js File in the src Folder


星号用于导入模块中的所有内容,后跟关键字`as`和一个标识符,通过它可以访问模块函数和值。在这种情况下,标识符是`ops`,这意味着`multiply`、`subtract`和`divide`功能可以作为`ops.multiply`、`ops.subtract`和`ops.divide`来访问。该清单产生与清单 4-53 相同的输出。

## 理解 JavaScript 承诺

承诺是将在未来某个时间点完成的后台活动。在本书中,承诺最常见的用法是使用 HTTP 请求来请求数据,这是异步执行的,当从 web 服务器收到响应时会产生一个结果。

### 理解异步操作问题

web 应用的经典异步操作是 HTTP 请求,通常用于获取用户需要的数据和内容。我在本书的第三部分解释了如何发出 HTTP 请求,但是本章我需要更简单的东西,所以我用清单 4-55 所示的代码在`src`文件夹中添加了一个名为`async.js`的文件。

import { sumValues } from "./sum";

export function asyncAdd(values) {
setTimeout(() => {
let total = sumValues(values);
console.log(Async Total: ${total});
return total;
}, 500);
}

Listing 4-55The Contents of the async.js File in the src Folder


`setTimeout`函数在指定的延迟后异步调用一个函数。在清单中,`asyncAdd`函数接收一个参数,该参数在 500 毫秒的延迟后被传递给在`sum`模块中定义的`sumValues`函数,为本章中的示例创建一个不会立即完成的后台操作,并作为更有用操作的占位符,比如发出一个 HTTP 请求。在清单 4-56 中,我已经更新了`example.js`文件以使用`asyncAdd`函数。

import { asyncAdd } from "./async";

let values = [10, 20, 30, 40, 50];

let total = asyncAdd(values);

console.log(Main Total: ${total});

Listing 4-56Performing Background Work in the example.js File in the src Folder


这个例子说明的问题是,`asyncAdd`函数的结果直到`example.js`文件中的语句被执行后才产生,这可以在浏览器的 JavaScript 控制台的输出中看到:

Main Total: undefined
Async Total: 150


浏览器执行`example.js`文件中的语句,并按照指示调用`asyncAdd`函数。浏览器移动到`example.js`文件中的下一条语句,该语句使用`asyncAdd`提供的结果向控制台写入一条消息——但这发生在异步任务完成之前,这就是为什么输出是`undefined`。异步任务随后完成,但是结果被`example.js`文件使用已经太晚了。

### 使用 JavaScript Promise

为了解决上一节中的问题,我需要一种机制,允许我观察异步任务,以便我可以等待它完成,然后写出结果。这就是 JavaScript `Promise`的作用,我已经将它应用于清单 4-57 中的`asyncAdd`函数。

import { sumValues } from "./sum";

export function asyncAdd(values) {
return new Promise(callback =>

    setTimeout(() => {
        let total = sumValues(values);
        console.log(`Async Total: ${total}`);
        callback(total);

    }, 500));

}

Listing 4-57Using a Promise in the async.js File in the src Folder


在这个例子中很难解开函数。`new`关键字用于创建一个`Promise`,它接受要观察的函数。观察到的函数提供了一个回调,当异步任务完成时调用该回调,并接受任务的结果作为参数。调用`callback`函数被称为*解析承诺*。

已经成为`asyncAdd`函数结果的`Promise`对象允许观察异步任务,以便在任务完成时执行后续工作,如清单 4-58 所示。

import { asyncAdd } from "./async";

let values = [10, 20, 30, 40, 50];

asyncAdd(values).then(total => console.log(Main Total: ${total}));

Listing 4-58Observing a Promise in the example.js File in the src Folder


`then`方法接受一个函数,该函数将在使用回调时被调用。传递给回调的结果被提供给`then`函数。在这种情况下,这意味着在异步任务完成并产生以下输出之前,总数不会写入浏览器的 JavaScript 控制台:

Async Total: 150
Main Total: 150


### 简化异步代码

JavaScript 提供了两个关键字——`async`和`await`——支持异步操作,而不必直接使用承诺。在清单 4-59 中,我在`example.js`文件中应用了这些关键字。

### 警告

理解使用`async` / `await`不会改变应用的行为方式是很重要的。操作仍然是异步执行的,直到操作完成,结果才可用。这些关键字只是为了简化异步代码的工作,这样你就不必使用`then`方法了。

import { asyncAdd } from "./async";

let values = [10, 20, 30, 40, 50];

async function doTask() {

let total = await asyncAdd(values);

console.log(`Main Total: ${total}`);

}

doTask();

Listing 4-59Using async and await in the example.js File in the src Folder


这些关键字只能应用于函数,这就是为什么我在清单中添加了`doTask`函数。`async`关键字告诉 JavaScript 这个函数依赖于需要承诺的功能。当调用一个返回`Promise`的函数时,使用`await`关键字,其作用是将提供的结果分配给`Promise`对象的回调,然后执行后面的语句,产生以下结果:

Async Total: 150
Main Total: 150


## 摘要

在这一章中,我提供了一个关于 JavaScript 的简单入门,重点是让你开始 React 开发的核心功能。在下一章中,我将开始构建一个更加复杂和现实的项目,名为 SportsStore。

# 五、SportsStore:一个真正的应用

在第二章中,我构建了一个快速简单的 React 应用。小而集中的例子允许我展示具体的特征,但是它们可能缺乏上下文。为了帮助克服这个问题,我将创建一个简单但现实的电子商务应用。

我的应用名为 SportsStore,将遵循各地在线商店采用的经典方法。我将创建一个客户可以按类别和页面浏览的在线产品目录,一个用户可以添加和删除产品的购物车,以及一个客户可以输入详细信息并下订单的收银台。我还将创建一个管理区域,其中包括用于管理产品和订单的创建、读取、更新和删除(CRUD)工具——我将保护它,以便只有登录的管理员才能进行更改。最后,我将向您展示如何为部署准备 React 应用。

我在这一章和后面几章的目标是通过创建尽可能真实的例子,让你对真正的 React 开发有所了解。当然,我想把重点放在 React 和大多数项目中使用的相关包上,所以我简化了与外部系统的集成,比如数据库,并完全省略了其他部分,比如支付处理。

我在所有的书中都使用了 SportsStore 的例子,尤其是因为它展示了使用不同的框架、语言和开发风格来实现相同结果的方法。你不需要阅读我的任何其他书籍来理解这一章,但是如果你已经拥有我的*Pro ASP.NET 核心 MVC 2* 或 *Pro Angular 6* 书籍,你会发现这些对比很有趣。

我在 SportsStore 应用中使用的 React 特性将在后面的章节中详细介绍。我不会在这里重复所有的内容,我告诉您的内容足以让您理解示例应用,并让您参考其他章节以获得更深入的信息。您可以从头到尾阅读 SportsStore 章节,了解 React 的工作原理,或者在详细章节之间跳转,深入了解。

无论哪种方式,都不要指望马上理解所有的东西——React 应用有许多活动的部分,并依赖于许多软件包,而 SportsStore 应用旨在向您展示它们是如何组合在一起的,而不会深入到本书其余部分描述的细节中。

### 小费

你可以从 [`https://github.com/Apress/pro-react-16`](https://github.com/Apress/pro-react-16) 下载本章以及本书其他章节的示例项目。

## 准备项目

要创建项目,打开一个新的命令提示符,导航到一个方便的位置,并运行清单 5-1 中所示的命令。

```jsx
npx create-react-app sportsstore

Listing 5-1Creating the SportsStore Project

create-react-app工具将创建一个名为sportsstore的新 React 项目,其中包含开始开发所需的包、配置文件和占位符内容。项目设置过程可能需要一些时间来完成,因为有大量的 NPM 软件包需要下载和安装。

注意

创建新项目时,您可能会看到有关安全漏洞的警告。React 开发依赖于大量的包,每个包都有自己的依赖关系,不可避免的会发现安全问题。对于本书中的示例,使用指定的包版本以确保获得预期的结果是很重要的。对于您自己的项目,您应该查看警告并更新到解决问题的版本。

安装附加的 NPM 软件包

除了由create-react-app包安装的核心 React 库和开发工具之外,SportsStore 项目还需要额外的包。运行清单 5-2 中所示的命令,导航到sportsstore文件夹并添加包。(npm install命令可以用来一次添加多个包,但是结果是一个很长的命令,很容易忽略一个包。为了避免错误,我在本书中单独添加了包。)

注意

使用清单中显示的版本号很重要。在添加包时,您可能会看到关于未满足对等依赖关系的警告,但是这些可以忽略。

cd sportsstore
npm install bootstrap@4.1.2
npm install @fortawesome/fontawesome-free@5.6.1
npm install redux@4.0.1
npm install react-redux@6.0.0
npm install react-router-dom@4.3.1
npm install axios@0.18.0
npm install graphql@14.0.2
npm install apollo-boost@0.1.22
npm install react-apollo@2.3.2

Listing 5-2Installing Additional Packages

不要被所需的额外软件包的数量所吓倒。React 专注于 web 应用所需的一组核心功能,并依赖支持包来创建完整的应用。为了提供一些背景知识,在列表 5-2 中添加的包在表 5-1 中有描述,我将在本书的第三部分深入讨论它们。

表 5-1

SportsStore 项目所需的包

|

名字

|

描述

|
| --- | --- |
| bootstrap | 这个包提供了我在整本书中用来呈现 HTML 内容的 CSS 样式。 |
| fontawesome-free | 这个包提供了可以包含在 HTML 内容中的图标。我用过免费软件,但也有更全面的付费选项。 |
| redux | 这个包提供了一个数据存储,它简化了协调应用不同部分的过程。详见第十九章。 |
| react-redux | 这个包将 Redux 数据存储集成到 React 应用中,如第 19 和 20 章所述。 |
| react-router-dom | 这个包提供 URL 路由,允许根据浏览器的当前 URL 选择呈现给用户的内容,如第 21 和 22 章所述。 |
| axios | 这个包用于发出 HTTP 请求,并将用于访问 RESTful 和 GraphQL 服务,如第 23–25 章所述。 |
| graphql | 这个包包含 GraphQL 规范的参考实现。 |
| apollo-boost | 这个包包含一个用于消费 GraphQL 服务的客户端,如第二十五章所述。 |
| react-apollo | 这个包用于将 GraphQL 客户端集成到 React 应用中,如第二十五章所述。 |

需要更多的包来创建 SportsStore 应用将使用的后端服务。使用命令提示符,运行sportsstore文件夹中清单 5-3 所示的命令。这些包是使用--save-dev参数安装的,这表明它们是在开发过程中使用的,在部署时不会成为 SportsStore 应用的一部分。

npm install --save-dev json-server@0.14.2
npm install --save-dev jsonwebtoken@8.1.1
npm install --save-dev express@4.16.4
npm install --save-dev express-graphql@0.7.1
npm install --save-dev cors@2.8.5
npm install --save-dev faker@4.1.0
npm install --save-dev chokidar@2.0.4
npm install --save-dev npm-run-all@4.1.3
npm install --save-dev connect-history-api-fallback@1.5.0

Listing 5-3Adding Further Packages

对于使用来自现有服务的数据的应用,您不需要这些包,但是我需要为 SportsStore 应用创建一个完整的基础设施。表 5-2 简要描述了清单 5-3 中安装的每个包的用途。

表 5-2

SportsStore 项目所需的附加包

|

名字

|

描述

|
| --- | --- |
| json-server | 这个包将用于在第六章中提供一个 RESTful web 服务。 |
| jsonwebtoken | 该包将用于在第八章中验证用户。 |
| graphql | 这个包将用于在第七章中定义 GraphQL 服务器的模式。 |
| express | 这个包将用于托管后端服务器。 |
| express-graphql | 这个包将用于创建一个 GraphQL 服务器。 |
| cors | 此包用于启用跨来源请求共享(CORS)请求。 |
| faker | 该包生成用于测试的假数据,并在第六章中使用。 |
| chokidar | 这个包监控文件的变化。 |
| npm-run-all | 这个包用于在一个命令中运行多个 NPM 脚本。 |
| connect-history-api-fallback | 该包用于响应带有index.html文件的 HTTP 请求,并用于第八章中的生产服务器。 |

将 CSS 样式表添加到项目中

为了使用 Bootstrap 和 Font Awesome 包,我需要将import语句添加到应用的index.js文件中。index.js文件的目的是启动应用,如第九章所述,添加清单 5-4 中所示的导入语句确保我需要的样式可以应用到 SportsStore 应用显示的 HTML 内容。

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

import "bootstrap/dist/css/bootstrap.css";

import "@fortawesome/fontawesome-free/css/all.min.css";

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

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

Listing 5-4Adding CSS Stylesheets in the index.js File in the src Folder

准备 Web 服务

一旦应用的基本结构就绪,我将添加对从 web 服务消费数据的支持。在准备过程中,我在sportsstore文件夹中添加了一个名为data.js的文件,其内容如清单 5-5 所示。

module.exports = function () {
    return {
        categories: ["Watersports", "Soccer", "Chess"],
        products: [
            { id: 1, name: "Kayak", category: "Watersports",
                description: "A boat for one person", price: 275 },
            { id: 2, name: "Lifejacket", category: "Watersports",
                description: "Protective and fashionable", price: 48.95 },
            { id: 3, name: "Soccer Ball", category: "Soccer",
                description: "FIFA-approved size and weight", price: 19.50 },
            { id: 4, name: "Corner Flags", category: "Soccer",
                description: "Give your playing field a professional touch",
                price: 34.95 },
            { id: 5, name: "Stadium", category: "Soccer",
                description: "Flat-packed 35,000-seat stadium", price: 79500 },
            { id: 6, name: "Thinking Cap", category: "Chess",
                description: "Improve brain efficiency by 75%", price: 16 },
            { id: 7, name: "Unsteady Chair", category: "Chess",
                description: "Secretly give your opponent a disadvantage",
                price: 29.95 },
            { id: 8, name: "Human Chess Board", category: "Chess",
                description: "A fun game for the family", price: 75 },
            { id: 9, name: "Bling Bling King", category: "Chess",
                description: "Gold-plated, diamond-studded King", price: 1200 }
        ],
        orders: []
    }
}

Listing 5-5The Contents of the data.js File in the sportsstore Folder

清单 5-5 中的代码创建了应用将使用的三个数据集合。products集合包含销售给客户的产品,categories集合包含产品被组织成的类别集,orders集合包含客户已经下的订单(但是当前是空的)。

我用清单 5-6 中所示的代码在sportsstore文件夹中添加了一个名为server.js的文件。这是创建为应用提供数据的 web 服务的代码。在后面的章节中,我将为后端服务器添加一些特性,比如认证和对 GraphQL 的支持。

const express = require("express");
const jsonServer = require("json-server");
const chokidar = require("chokidar");
const cors = require("cors");

const fileName = process.argv[2] || "./data.js"
const port = process.argv[3] || 3500;

let router = undefined;

const app = express();

const createServer = () => {
    delete require.cache[require.resolve(fileName)];
    setTimeout(() => {
        router = jsonServer.router(fileName.endsWith(".js")
            ? require(fileName)() : fileName);
    }, 100)
}

createServer();

app.use(cors());
app.use(jsonServer.bodyParser)
app.use("/api", (req, resp, next) => router(req, resp, next));

chokidar.watch(fileName).on("change", () => {
    console.log("Reloading web service data...");
    createServer();
    console.log("Reloading web service data complete.");
});

app.listen(port, () => console.log(`Web service running on port ${port}`));

Listing 5-6The Contents of the server.js File in the sportsstore Folder

为了确保 web 服务与 React 开发工具一起启动,我修改了package.json文件的scripts部分,如清单 5-7 所示。

...
"scripts": {
  "start": "npm-run-all --parallel reactstart webservice",

  "reactstart": "react-scripts start",

  "webservice": "node server.js",

  "build": "react-scripts build",
  "test": "react-scripts test",
  "eject": "react-scripts eject"
},
...

Listing 5-7Enabling the Web Service in the package.json File in the sportsstore Folder

这个变化使用npm-run-all包来同时运行 React 开发服务器和 web 服务。

运行示例应用

要启动应用和 web 服务,使用命令提示符运行清单 5-8 中的命令。

npm start

Listing 5-8Starting the Application

初始编译完成后会有一个暂停,然后会打开一个新的浏览器窗口,显示如图 5-1 所示的占位符内容。

img/473159_1_En_5_Fig1_HTML.jpg

图 5-1

运行示例应用

为了确保 web 服务正在运行,打开一个新的浏览器窗口并请求 URL http://localhost:3500/api/products/1。浏览器将显示清单 5-5 中定义的产品之一的 JSON 表示,如下所示:

{ "id":1, "name":"Kayak", "category":"Watersports",
   "description":"A boat for one person","price":275 }

创建数据存储

SportsStore 的起点是数据存储,它将是呈现给用户的数据以及协调应用功能(如分页)所需的支持细节的存储库。

我将从使用本地占位符数据的数据存储开始。稍后,我将添加对从 web 服务获取数据的支持,但是静态数据是一个很好的起点,因为它将焦点放在 React 应用上。将使用 Redux 包创建 SportsStore 数据存储,这是 React 项目中最流行的数据存储,我在第 19 和 20 章中对此进行了描述。首先,我创建了src/data文件夹,并在其中添加了一个名为placeholderData.js的文件,其内容如清单 5-9 所示。

export const data = {
    categories: ["Watersports", "Soccer", "Chess", "Running"],
    products: [
        { id: 1, name: "P1", category: "Watersports",
            description: "P1 (Watersports)", price: 3 },
        { id: 2, name: "P2", category: "Watersports",
           description: "P2 (Watersports)", price: 4 },
        { id: 3, name: "P3", category: "Running",
           description: "P3 (Running)", price: 5 },
        { id: 4, name: "P4", category: "Chess",
           description: "P4 (Chess)", price: 6 },
        { id: 5, name: "P5", category: "Chess",
           description: "P6 (Chess)", price: 7 },
    ]
}

Listing 5-9The Contents of the placeholderData.js File in the src/data Folder

创建数据存储操作和操作创建者

Redux 数据将读取数据与更改数据的操作分开存储。这一开始可能会感觉很尴尬,但它类似于 React 开发的其他部分,如组件状态数据和使用 GraphQL,并且它很快成为第二天性。

动作是发送到数据存储以对其包含的数据进行更改的对象。动作有类型,动作对象是使用动作创建器创建的。目前我需要的唯一动作是将数据加载到存储中,最初使用清单 5-9 中定义的占位符数据,但最终来自 web 服务。有不同的方法可以构造数据存储的操作,但是有必要确定不同类型的数据之间共享的共同主题,以避免以后的代码重复。我在src/data文件夹中添加了一个名为Types.js的文件,并使用它来列出存储中的数据类型以及可以对它们执行的一组操作,如清单 5-10 所示。

export const DataTypes = {
    PRODUCTS: "products",
    CATEGORIES: "categories"
}

export const ActionTypes = {
    DATA_LOAD: "data_load"
}

Listing 5-10The Contents of the Types.js File in the src/data Folder

有两种数据类型——PRODUCTSCATEGORIES——以及一个动作DATA_LOAD,它将填充数据存储。不要求以这种方式定义动作类型,但是在应用的其他地方指定动作类型时,使用常量值可以避免输入错误。

接下来,我需要定义一个动作创建器函数,它将创建一个动作对象,该对象可以被数据存储处理以改变它包含的数据。我在src/data文件夹中添加了一个名为ActionCreators.js的文件,代码如清单 5-11 所示。

import { ActionTypes} from "./Types";
import { data as phData} from "./placeholderData";

export const loadData = (dataType) => ({
    type: ActionTypes.DATA_LOAD,
    payload: {
        dataType: dataType,
        data: phData[dataType]
    }
});

Listing 5-11The Contents of the ActionCreators.js File in the src/data Folder

在第十九章中描述了动作创建器的使用,但是对动作创建器产生的对象的唯一要求是它们必须有一个type属性,该属性的值指定了数据存储所需的变化类型。在动作对象中使用一组公共属性是一个好主意,这样它们可以被一致地处理,在清单 5-11 中定义的动作创建器返回一个具有payload属性的动作对象,这是我将用于所有 SportsStore 数据存储动作的约定。

清单 5-11 中动作对象的payload属性有一个dataType属性,指示动作所涉及的数据类型,还有一个data属性,提供要添加到数据存储中的数据。此时,data属性的值是从占位符数据中获得的,但是在第六章中,我用从 web 服务中获得的数据替换它。

动作由数据存储库还原器处理,还原器是接收数据存储库和动作对象的当前内容并使用它们进行更改的功能。我在src/data文件夹中添加了一个名为ShopReducer.js的文件,并定义了清单 5-12 中所示的减速器。

import { ActionTypes } from "./Types";

export const ShopReducer = (storeData, action) => {
    switch(action.type) {
        case ActionTypes.DATA_LOAD:
            return {
                ...storeData,
                [action.payload.dataType]: action.payload.data
            };
        default:
            return storeData || {};
    }
}

Listing 5-12The Contents of the ShopReducer.js File in the src/data Folder

Reducers 需要创建并返回包含任何所需更改的新对象。如果动作类型没有被识别,reducer 必须返回它接收到的数据存储对象。清单 5-12 中的 reducer 通过创建一个新对象来处理DATA_LOAD动作,这个新对象包含旧存储的所有属性以及动作中接收到的新数据。第十九章对减速器进行了更详细的描述。

作为创建数据存储的最后一步,我在src/data文件夹中添加了一个名为DataStore.js的文件,并添加了清单 5-13 中所示的代码。

import { createStore } from "redux";
import { ShopReducer } from "./ShopReducer";

export const SportsStoreDataStore = createStore(ShopReducer);

Listing 5-13The Contents of the DataStore.js File in the src/data Folder

Redux 包提供了createStore函数,它使用一个 reducer 建立一个新的数据存储。这足以创建一个数据存储,但是我将在以后添加额外的特性,以便可以执行进一步的操作,并且可以从 web 服务加载数据。

创建购物功能

用户将看到的应用的第一部分是店面,它将以两列布局呈现可用的产品,允许按类别进行过滤,如图 5-2 所示。

img/473159_1_En_5_Fig2_HTML.jpg

图 5-2

应用的基本结构

我将构建应用,以便浏览器的 URL 用于选择呈现给用户的内容。首先,该应用将支持表 5-3 中描述的 URL,这将允许用户查看待售产品并按类别过滤它们。

表 5-3

SportsStore 网址

|

名字

|

描述

|
| --- | --- |
| /shop/products | 这个 URL 将向用户显示所有的产品,不考虑类别。 |
| /shop/products/chess | 该 URL 将显示特定类别的产品。在这种情况下,URL 将选择象棋类别。 |

注意

对于应用中向客户提供销售产品的部分,我采用了英国术语 shop 。我想避免在保存应用数据的数据存储和用户购物的产品存储之间产生混淆。

在应用中响应浏览器的 URL 被称为 URL 路由,它由清单 5-2 中添加的 React Router 包提供,在章节 21 和 22 中有详细描述。

创建产品和类别组件

组件是 React 应用的构建块,负责呈现给用户的内容。我创建了src/shop文件夹,并在其中添加了一个名为ProductList.js的文件,其内容如清单 5-14 所示。

import React, { Component } from "react";

export class ProductList extends Component {

    render() {
        if (this.props.products == null || this.props.products.length === 0) {
            return <h5 className="p-2">No Products</h5>
        }
        return this.props.products.map(p =>
                <div className="card m-1 p-1 bg-light" key={ p.id }>
                    <h4>
                        { p.name }
                        <span className="badge badge-pill badge-primary float-right">
                            ${ p.price.toFixed(2) }
                        </span>
                    </h4>
                    <div className="card-text bg-white p-1">
                        { p.description }
                    </div>
                </div>
            )
    }
}

Listing 5-14The Contents of the ProductList.js File in the src/shop Folder

创建组件是为了执行小任务或显示少量内容,而组合组件是为了创建更复杂的功能。清单 5-14 中定义的ProductList组件负责显示产品列表的细节,其细节通过名为product的属性接收。Props 用于配置组件并允许它们完成工作——例如显示产品的细节——而不涉及数据的来源。ProductList组件生成包含每个产品的namepricedescription属性值的 HTML 内容,但是它不知道这些产品是如何在应用中定义的,也不知道它们是在本地定义的还是从远程服务器检索的。

接下来,我在src/shop文件夹中添加了一个名为CategoryNavigation.js的文件,并定义了清单 5-15 中所示的组件。

import React, { Component } from "react";
import { Link } from "react-router-dom";

export class CategoryNavigation extends Component {

    render() {
        return <React.Fragment>
            <Link className="btn btn-secondary btn-block"
                to={ this.props.baseUrl }>All</Link>
            { this.props.categories && this.props.categories.map(cat =>
                <Link className="btn btn-secondary btn-block" key={ cat }
                    to={ `${this.props.baseUrl}/${cat.toLowerCase()}`}>
                    { cat }
                </Link>
            )}
        </React.Fragment>
    }
}

Listing 5-15The Contents of the CategoryNavigation.js File in the src/shop Folder

类别的选择将通过导航到一个新的 URL 来处理,这是使用 React Router 包提供的Link组件来完成的。当用户点击一个Link时,浏览器被要求导航到一个新的 URL,而无需发送任何 HTTP 请求或重新加载应用。新 URL 中包含的细节,比如本例中的选定类别,允许应用的不同部分协同工作。

CategoryNavigation组件通过一个名为categories的属性接收类别数组。该组件检查以确保数组已被定义,并使用map方法为每个数组项生成内容。React 要求将一个key prop 应用到由map方法生成的元素上,以便可以有效地处理数组的变化,如第十章所述。结果是数组中接收的每个类别都有一个Link组件,还有一个额外的Link,这样用户就可以选择所有产品,而不考虑类别。Link组件被设计成按钮,浏览器将导航到的 URL 是一个名为baseUrl的属性和类别名称的组合。

为了将产品表和类别按钮放在一起,我在src/shop文件夹中添加了一个名为Shop.js的文件,并添加了清单 5-16 中所示的代码。

import React, { Component } from "react";
import { CategoryNavigation } from "./CategoryNavigation";
import { ProductList } from "./ProductList";

export class Shop extends Component {

    render() {
        return <div className="container-fluid">
            <div className="row">
                <div className="col bg-dark text-white">
                    <div className="navbar-brand">SPORTS STORE</div>
                </div>
            </div>
            <div className="row">
                <div className="col-3 p-2">
                    <CategoryNavigation baseUrl="/shop/products"
                        categories={ this.props.categories } />
                </div>
                <div className="col-9 p-2">
                    <ProductList products={ this.props.products } />
                </div>
            </div>
        </div>
    }
}

Listing 5-16The Contents of the Shop.js File in the src/shop Folder

组件可以将其部分内容的责任委托给其他组件。在它的render方法中,清单 5-16 中定义的Shop组件包含使用引导 CSS 类建立网格结构的 HTML 元素,但是将填充一些网格单元的责任委托给了CategoryNavigationProductList组件。这些委托的组件在render方法中被表示为定制的 HTML 元素,其中元素标记与组件的名称相匹配,如下所示:

...
<ProductList products={ this.props.products } />
...

在两个组件之间创建了一个关系:Shop组件是ProductList的父组件,而ProductList组件是Shop的子组件。父组件通过提供属性来配置它们的子组件,在清单 5-16 中,Shop组件将从其父组件收到的products属性传递给它的ProductList子组件,后者将用于向用户显示产品列表。本书的第二部分描述了组件之间的关系以及使用它们创建复杂特征的方式。

连接到数据存储和 URL 路由

组件Shop及其CategoryNavigationProductList子组件需要访问数据存储。为了将这些组件与它们需要的特性联系起来,我在src/shop文件夹中添加了一个名为ShopConnector.js的文件,代码如清单 5-17 所示。

import React, { Component } from "react";
import { Switch, Route, Redirect }
    from "react-router-dom"
import { connect } from "react-redux";
import { loadData } from "../data/ActionCreators";
import { DataTypes } from "../data/Types";
import { Shop } from "./Shop";

const mapStateToProps = (dataStore) => ({
    ...dataStore
})

const mapDispatchToProps = {
    loadData
}

const filterProducts = (products = [], category) =>
    (!category || category === "All")
        ? products
        : products.filter(p => p.category.toLowerCase() === category.toLowerCase());

export const ShopConnector = connect(mapStateToProps, mapDispatchToProps)(
    class extends Component {
        render() {
            return <Switch>
                <Route path="/shop/products/:category?"
                    render={ (routeProps) =>
                        <Shop { ...this.props } { ...routeProps }
                            products={ filterProducts(this.props.products,
                                routeProps.match.params.category) } />} />
                <Redirect to="/shop/products" />
            </Switch>
        }

        componentDidMount() {
            this.props.loadData(DataTypes.CATEGORIES);
            this.props.loadData(DataTypes.PRODUCTS);
        }
    }
)

Listing 5-17The Contents of the ShopConnector.js File in the src/shop Folder

如果清单 5-17 中的代码目前看起来难以理解,也不用担心。代码比早期的清单更复杂,因为这个组件集合并合并了几个特性,所以它们可以更容易地在项目的其他地方使用,如图 5-3 所示。

img/473159_1_En_5_Fig3_HTML.jpg

图 5-3

将应用连接到其服务

这种方法的优点是,它简化了添加功能或对应用进行更改,因为向用户呈现内容的组件通过 props 接收数据,而不需要直接从数据存储或 URL 路由系统获取数据。缺点是,将应用的其余部分连接到其服务的组件可能很难编写和维护,因为它必须组合不同包的功能并将其呈现给其子代。这个组件的复杂性将会增加,直到第六章结束,那时我将围绕最后一组 SportsStore 购物功能整合代码。

清单 5-17 中的组件将 Redux 数据存储和 URL 路由连接到Shop组件。Redux 包提供了connect函数,用于将一个组件链接到一个数据存储,这样它的属性要么是来自数据存储的值,要么是在被调用时调度数据存储动作的函数,如第二十章所述。正是connect函数产生了清单 5-17 中的大部分代码,因为它需要数据存储和组件属性之间的映射,这可能会很冗长。清单 5-17 中的映射让Shop组件可以访问数据存储中定义的所有属性,数据存储目前包含产品和类别数据,但以后会包含其他特性。

小费

您可以在映射到 props 的数据存储属性中更加具体,如第二十章所演示的,但是我已经映射了所有的产品,这在您开始开发新项目时是一个有用的方法,因为这意味着您不必在每次增强数据存储时都记得映射新的属性。

必须使用选定的类别来过滤产品数据,该类别可通过 React 路由包提供的功能来访问。一个Route用于选择当浏览器导航到一个特定的 URL 时将向用户显示的组件。列表 5-17 中的Route匹配表 5-3 中的 URL,如下所示:

...
<Route path="/shop/products/:category?" render={ (routeProps) =>
...

path prop 告诉Route等待,直到浏览器导航到/shop/products URL。如果 URL 中有一个额外的段,比如/shop/products/running,那么该段的内容将被分配给一个名为category的参数,这就是用户的类别选择将如何被确定。

当浏览器导航到与path属性匹配的 URL 时,Route会显示由render属性指定的内容,如下所示:

...
<Route path="/shop/products/:category?" render={ (routeProps) =>

    <Shop { ...this.props } { ...routeProps }

        products={ filterProducts(this.props.products,

            routeProps.match.params.category) } />} />

...

这是数据存储和 URL 路由功能的结合点。Shop组件需要知道用户选择了哪个类别,这可以通过传递给Route组件的 render prop 的参数获得。类别与来自数据存储的数据相结合,两者都被传递给Shop组件。props 应用于组件的顺序允许覆盖 props,我依赖它用来自filterProduct函数的结果替换从数据存储中获得的products数据,该函数只选择用户选择的类别中的产品。

RouteSwitchRedirect组件结合使用,这两个组件都是 React 路由包的一部分,如果浏览器的当前 URL 与Route不匹配,它们会将浏览器重定向到/shop/products

ShopConnector组件使用componentDidMount方法将数据加载到数据存储中。componentDidMount方法是 React 组件生命周期的一部分,在第十三章中有详细描述。

将商店添加到应用中

在清单 5-18 中,我已经设置了数据存储和 URL 路由特性,并将ShopConnector组件合并到应用中。

import React, { Component } from "react";
import { SportsStoreDataStore } from "./data/DataStore";
import { Provider } from "react-redux";
import { BrowserRouter as Router, Route, Switch, Redirect }
    from "react-router-dom";
import { ShopConnector } from "./shop/ShopConnector";

export default class App extends Component {

    render() {
        return <Provider store={ SportsStoreDataStore }>
            <Router>
                <Switch>
                    <Route path="/shop" component={ ShopConnector } />
                    <Redirect to="/shop" />
                </Switch>
            </Router>
        </Provider>
    }
}

Listing 5-18Adding Routing and a Data Store to the App.js File in the src Folder

使用Provider将数据存储应用于应用,其中store属性被分配给在清单 5-13 中创建的数据存储。使用Router组件将 URL 路由特性应用于应用,我已经使用SwitchRouteRedirect组件对其进行了补充。Redirect将导航到/shop URL,该 URL 匹配Routepath属性并显示ShopConnector组件,产生如图 5-4 所示的结果。单击一个类别按钮会将浏览器重定向到一个新的 URL,比如/shop/products/watersports,它具有过滤所显示产品的作用。

img/473159_1_En_5_Fig4_HTML.jpg

图 5-4

创建基本购物功能

改进类别选择按钮

类别选择按钮可以工作,但是不能清楚地向用户反映当前的类别。为了解决这个问题,我在src文件夹中添加了一个名为ToggleLink.js的文件,并用它来定义清单 5-19 中所示的组件。

小费

我将这个组件添加到src文件夹中,因为一旦商店完成,我将在应用的其他部分使用它。关于如何组织 React 项目,没有严格的规则,但是我倾向于将相关的文件放在文件夹中。

import React, { Component } from "react";
import { Route, Link } from "react-router-dom";

export class ToggleLink extends Component {

    render() {
        return <Route path={ this.props.to } exact={ this.props.exact }
                children={ routeProps => {

            const baseClasses = this.props.className || "m-2 btn btn-block";
            const activeClass = this.props.activeClass || "btn-primary";
            const inActiveClass = this.props.inActiveClass || "btn-secondary"

            const combinedClasses =
                `${baseClasses} ${routeProps.match ? activeClass : inActiveClass}`

            return <Link to={ this.props.to } className={ combinedClasses }>
                        { this.props.children }
                    </Link>
         }} />
    }
}

Listing 5-19The Contents of the ToggleLink.js File in the src Folder

React Router 包提供了一个组件来指示特定的 URL 何时匹配,但是它不能很好地与引导 CSS 类一起工作,正如我在第二十二章中描述的,在那里我详细解释了ToggleLink组件是如何工作的。对于本章来说,知道可以使用Route组件来提供对 URL 路由系统的访问,以便获得关于当前路由的细节就足够了。在清单 5-20 中,我已经更新了CategoryNavigation组件来使用ToggleLink组件。

import React, { Component } from "react";

//import { Link } from "react-router-dom";

import { ToggleLink } from "../ToggleLink";

export class CategoryNavigation extends Component {

    render() {
        return <React.Fragment>
            <ToggleLink to={ this.props.baseUrl } exact={ true }>All</ToggleLink>

            { this.props.categories && this.props.categories.map(cat =>
                <ToggleLink key={ cat }

                    to={ `${this.props.baseUrl}/${cat.toLowerCase()}`}>
                    { cat }
                </ToggleLink>

            )}
        </React.Fragment>
    }
}

Listing 5-20Using ToggleLinks in the CategoryNavigation.js File in the src/shop Folder

其作用是清楚地表明选择了哪个类别,如图 5-5 所示。

img/473159_1_En_5_Fig5_HTML.jpg

图 5-5

突出显示选定的组件

添加购物车

购物车将允许用户在结帐前选择几个产品。在接下来的小节中,我将添加扩展数据存储以跟踪用户的产品选择,并创建提供详细和概要购物车视图的组件。

扩展数据存储

为了扩展数据存储以添加对跟踪用户产品选择的支持,我添加了清单 5-21 中所示的动作类型。

export const DataTypes = {
    PRODUCTS: "products",
    CATEGORIES: "categories"
}

export const ActionTypes = {
    DATA_LOAD: "data_load",
    CART_ADD: "cart_add",

    CART_UPDATE: "cart_update",

    CART_REMOVE: "cart_delete",

    CART_CLEAR: "cart_clear"

}

Listing 5-21Defining Action Types in the Types.js File in the src/data Folder

新的操作将允许在购物车中添加和删除产品,以及清除整个购物车的内容。

您可以在同一个文件中为应用的不同部分定义动作创建者和减少者,但是将它们分成单独的文件可以使开发更容易,尤其是在大型项目中。我在src/data文件夹中添加了一个名为CartActionCreators.js的文件,并用它来定义新动作类型的动作创建者,如清单 5-22 所示。

import { ActionTypes} from "./Types";

export const addToCart = (product, quantity) => ({
    type: ActionTypes.CART_ADD,
    payload: {
        product,
        quantity: quantity || 1
    }
});

export const updateCartQuantity = (product, quantity) => ({
    type: ActionTypes.CART_UPDATE,
    payload: { product, quantity }
})

export const removeFromCart = (product) => ({
    type: ActionTypes.CART_REMOVE,
    payload: product
})

export const clearCart = () => ({
    type: ActionTypes.CART_CLEAR
})

Listing 5-22The Contents of the CartActionCreators.js File in the src/data Folder

清单 5-22 中的函数创建的动作对象有一个payload属性,携带执行动作所需的数据。为了定义一个将处理购物车相关动作的 reducer,我在src/data文件夹中添加了一个名为CartReducer.js的文件,并定义了清单 5-23 中所示的函数。

import { ActionTypes } from "./Types";

export const CartReducer = (storeData, action) => {
    let newStore = { cart: [], cartItems: 0, cartPrice: 0, ...storeData }
    switch(action.type) {
        case ActionTypes.CART_ADD:
            const p = action.payload.product;
            const q = action.payload.quantity;

            let existing = newStore.cart.find(item => item.product.id === p.id);
            if (existing) {
                existing.quantity += q;
            } else {
                newStore.cart = [...newStore.cart, action.payload];
            }
            newStore.cartItems += q;
            newStore.cartPrice += p.price * q;
            return newStore;

        case ActionTypes.CART_UPDATE:
            newStore.cart = newStore.cart.map(item => {
                if (item.product.id === action.payload.product.id) {
                    const diff = action.payload.quantity - item.quantity;
                    newStore.cartItems += diff;
                    newStore.cartPrice+= (item.product.price * diff);
                    return action.payload;
                } else {
                    return item;
                }
            });
            return newStore;

        case ActionTypes.CART_REMOVE:
            let selection = newStore.cart.find(item =>
                item.product.id === action.payload.id);
            newStore.cartItems -= selection.quantity;
            newStore.cartPrice -= selection.quantity * selection.product.price;
            newStore.cart = newStore.cart.filter(item => item !== selection );
            return newStore;

        case ActionTypes.CART_CLEAR:
            return { ...storeData, cart: [], cartItems: 0, cartPrice: 0}

        default:
            return storeData || {};
    }
}

Listing 5-23The Contents of the CartReducer.js File in the src/data Folder

购物车动作的 reducer 通过向数据存储添加一个cart属性并为其分配一个具有productquantity属性的对象数组来跟踪用户的产品选择。还有cartItemscartPrice属性记录购物车中的商品数量及其总价。

小费

保持数据存储的结构扁平很重要,因为对象层次结构中的深层变化不会被检测到,也不会显示给用户。正是由于这个原因,cartcartItemscartPrice属性在数据存储中与productscategories属性一起定义,而不是组合在一个结构中。

默认情况下,Redux 数据存储只使用一个 reducer,但是很容易组合多个 reducer 来适应您的项目。如第十九章所述,有在多个缩减器之间划分数据存储责任的内置支持,但是这分割了数据,所以每个缩减器只能看到模型的一部分。对于 SportsStore 应用,我希望每个 reducer 都能访问完整的数据存储,所以我在src/data文件夹中添加了一个名为CommonReducer.js的文件,并用它来定义清单 5-24 中所示的函数。

export const CommonReducer = (...reducers) => (storeData, action) => {
    for (let i = 0; i < reducers.length; i++ ) {
        let newStore = reducersi;
        if (newStore !== storeData) {
            return newStore;
        }
    }
    return storeData;
}

Listing 5-24The Contents of the CommonReducer.js File in the src/data Folder

commonReducer函数将多个 reducerss 组合成一个函数,并要求每个 reducer 处理动作。Reducers 在修改数据存储的内容时会返回新的对象,这使得在处理动作时很容易检测到。结果是,SportsStore 数据存储可以支持多个 reducerss,其中第一个更改数据存储的 reducer 被视为已经处理了操作。在清单 5-25 中,我已经更新了数据存储配置,以使用commonReducer函数来组合商店和手推车减速器。

import { createStore } from "redux";
import { ShopReducer } from "./ShopReducer";

import { CartReducer } from "./CartReducer";

import { CommonReducer } from "./CommonReducer";

export const SportsStoreDataStore

    = createStore(CommonReducer(ShopReducer, CartReducer));

Listing 5-25Combining Reducers in the DataStore.js File in the src/data Folder

创建购物车摘要组件

为了向用户显示他们购物车的摘要,我在src/shop文件夹中添加了一个名为CartSummary.js的文件,并用它来定义清单 5-26 中所示的组件。

import React, { Component } from "react";
import { Link } from "react-router-dom";

export class CartSummary extends Component {

    getSummary = () => {
        if (this.props.cartItems > 0) {
            return <span>
                { this.props.cartItems } item(s),
                ${ this.props.cartPrice.toFixed(2)}
            </span>
        } else {
            return <span>Your cart: (empty) </span>
        }
    }

    getLinkClasses = () => {
        return `btn btn-sm bg-dark text-white
            ${ this.props.cartItems === 0 ? "disabled": ""}`;
    }

    render() {
        return <div className="float-right">
            <small>
                 { this.getSummary() }
                <Link className={ this.getLinkClasses() }
                        to="/shop/cart">
                    <i className="fa fa-shopping-cart"></i>
                </Link>
            </small>
        </div>
    }
}

Listing 5-26The Contents of the CartSummary.js File in the src/shop Folder

清单 5-26 中定义的组件通过cartItemscartPrice props 接收它所需要的数据,这两个 props 用于创建组件的摘要,还有一个Link,当点击时它将导航到/shop/cart URL。当items属性的值为零时,Link被禁用,以防止用户在没有选择至少一个产品的情况下继续操作。

小费

用作Link内容的i元素应用了添加到清单 5-2 项目中的字体 Awesome 包中的购物车图标。参见 https://fontawesome.com 了解更多详情和所有可用图标。

React 很好地处理了 web 应用开发的许多方面,但是有一些常见的任务比您可能习惯的更难完成。一个例子是条件呈现,其中数据值用于选择不同的内容呈现给用户,或者为属性选择不同的值。React 中最干净的方法是定义一个使用 JavaScript 返回以 HTML 表示的结果的方法,就像清单 5-26 中的getSummarygetLinkClasses方法,它们在组件的render方法中被调用。另一种方法是使用内联的&&操作符,这对于简单的表达式很有效。

在清单 5-27 中,我将数据存储中与购物车相关的添加内容与应用的其余部分连接起来,同时还连接了 action creator 函数。

import React, { Component } from "react";
import { Switch, Route, Redirect }
    from "react-router-dom"
import { connect } from "react-redux";
import { loadData } from "../data/ActionCreators";
import { DataTypes } from "../data/Types";
import { Shop } from "./Shop";

import { addToCart, updateCartQuantity, removeFromCart, clearCart }

    from "../data/CartActionCreators";

const mapStateToProps = (dataStore) => ({
    ...dataStore
})

const mapDispatchToProps = {
    loadData,addToCart, updateCartQuantity, removeFromCart, clearCart

}

const filterProducts = (products = [], category) =>
    (!category || category === "All")
        ? products
        : products.filter(p => p.category.toLowerCase() === category.toLowerCase());

export const ShopConnector = connect(mapStateToProps, mapDispatchToProps)(
    class extends Component {
        render() {
            return <Switch>
                <Route path="/shop/products/:category?"
                    render={ (routeProps) =>
                        <Shop { ...this.props } { ...routeProps }
                            products={ filterProducts(this.props.products,
                                routeProps.match.params.category) } />} />
                <Redirect to="/shop/products" />
            </Switch>
        }

        componentDidMount() {
            this.props.loadData(DataTypes.CATEGORIES);
            this.props.loadData(DataTypes.PRODUCTS);
        }
    }
)

Listing 5-27Connecting the Cart in the ShopConnector.js File in the src/shop Folder

在清单 5-28 中,我向由Shop组件呈现的内容添加了一个CartSummary,这将确保用户选择的细节显示在产品列表的上方。

import React, { Component } from "react";
import { CategoryNavigation } from "./CategoryNavigation";
import { ProductList } from "./ProductList";

import { CartSummary } from "./CartSummary";

export class Shop extends Component {

    render() {
        return <div className="container-fluid">
            <div className="row">
                <div className="col bg-dark text-white">
                    <div className="navbar-brand">SPORTS STORE</div>
                    <CartSummary { ...this.props } />

                </div>
            </div>
            <div className="row">
                <div className="col-3 p-2">
                    <CategoryNavigation baseUrl="/shop/products"
                        categories={ this.props.categories } />
                </div>
                <div className="col-9 p-2">
                    <ProductList products={ this.props.products }
                        addToCart={ this.props.addToCart } />
                </div>
            </div>
        </div>
    }
}

Listing 5-28Adding the Summary in the Shop.js File in the src/shop Folder

为了允许用户向购物车添加产品,我在由ProductList组件生产的每个产品的描述旁边添加了一个button,如清单 5-29 所示。

import React, { Component } from "react";

export class ProductList extends Component {

    render() {
        if (this.props.products == null || this.props.products.length === 0) {
            return <h5 className="p-2">No Products</h5>
        }
        return this.props.products.map(p =>
                <div className="card m-1 p-1 bg-light" key={ p.id }>
                    <h4>
                        { p.name }
                        <span className="badge badge-pill badge-primary float-right">
                            ${ p.price.toFixed(2) }
                        </span>
                    </h4>
                    <div className="card-text bg-white p-1">
                        { p.description }
                        <button className="btn btn-success btn-sm float-right"

                            onClick={ () => this.props.addToCart(p) } >

                                Add To Cart

                        </button>

                    </div>
                </div>
            )
    }
}

Listing 5-29Adding a Button in the ProductList.js File in the src/shop Folder

React 提供了用于注册事件处理程序的属性,如第十二章所述。单击元素时触发的click事件的处理程序是onClick,指定的函数调用addToCart属性,该属性映射到同名的数据存储操作创建者。

结果是每个产品都显示有一个“添加到购物车”按钮。当点击按钮时,数据存储被更新,用户选择的摘要反映了附加项目和新的总价,如图 5-6 所示。

img/473159_1_En_5_Fig6_HTML.jpg

图 5-6

将产品添加到购物车

添加购物车详细信息组件

为了向用户提供他们选择的详细视图,我在src/shop文件夹中添加了一个名为CartDetails.js的文件,并用它来定义清单 5-30 中所示的组件。

import React, { Component } from "react";
import { Link } from "react-router-dom";
import { CartDetailsRows } from "./CartDetailsRows";

export class CartDetails extends Component {

    getLinkClasses = () => `btn btn-secondary m-1
        ${this.props.cartItems === 0 ? "disabled": ""}`;

    render() {
        return <div className="m-3">
            <h2 className="text-center">Your Cart</h2>
            <table className="table table-bordered table-striped">
                <thead>
                    <tr>
                        <th>Quantity</th>
                        <th>Product</th>
                        <th className="text-right">Price</th>
                        <th className="text-right">Subtotal</th>
                        <th/>
                    </tr>
                </thead>
                <tbody>
                    <CartDetailsRows cart={ this.props.cart}
                        cartPrice={ this.props.cartPrice }
                        updateQuantity={ this.props.updateCartQuantity }
                        removeFromCart={ this.props.removeFromCart } />
                </tbody>
            </table>
            <div className="text-center">
                <Link className="btn btn-primary m-1" to="/shop">
                    Continue Shopping
                </Link>
                <Link className={ this.getLinkClasses() } to="/shop/checkout">
                    Checkout
                </Link>
            </div>
        </div>
    }
}

Listing 5-30The Contents of the CartDetails.js File in the src/shop Folder

CartDetails组件向用户呈现一个表格,同时还有Link组件返回产品列表或导航到/shop/checkout URL,这将启动结帐过程。

CartDetails组件依赖于CartDetailsRows组件来显示用户产品选择的细节。为了创建这个组件,我在src/shop文件夹中添加了一个名为CartDetailsRows.js的文件,并用它来定义清单 5-31 中所示的组件。

import React, { Component } from "react";

export class CartDetailsRows extends Component {

    handleChange = (product, event) => {
        this.props.updateQuantity(product, event.target.value);
    }

    render() {
        if (!this.props.cart || this.props.cart.length === 0) {
            return <tr>
                <td colSpan="5">Your cart is empty</td>
            </tr>
        } else {
            return <React.Fragment>
                { this.props.cart.map(item =>
                    <tr key={ item.product.id }>
                        <td>
                            <input type="number" value={ item.quantity }
                                onChange={ (ev) =>
                                    this.handleChange(item.product, ev) } />
                        </td>
                        <td>{ item.product.name }</td>
                        <td>${ item.product.price.toFixed(2) }</td>
                        <td>${ (item.quantity * item.product.price).toFixed(2) }</td>
                        <td>
                            <button className="btn btn-sm btn-danger"
                                onClick={ () =>
                                        this.props.removeFromCart(item.product)}>
                                    Remove
                                </button>
                        </td>
                    </tr>
                )}
                <tr>
                    <th colSpan="3" className="text-right">Total:</th>
                    <th colSpan="2">${ this.props.cartPrice.toFixed(2) }</th>
                </tr>
            </React.Fragment>
        }
    }
}

Listing 5-31The Contents of the CartDetailsRows.js File in the src/shop Folder

render方法必须返回一个单一的顶级元素,当 HTML 文档生成时,该元素被插入到 HTML 中,代替组件的元素,如第九章所述。在不破坏内容布局的情况下,返回一个 HTML 元素并不总是可能的,比如在这个例子中,需要多个表格行。对于这些情况,使用了React.Fragment元素。当内容被处理并且它包含的元素被添加到 HTML 文档中时,这个元素被丢弃。

将购物车 URL 添加到路由配置中

在清单 5-32 中,我更新了ShopConnector组件中的路由配置,以添加对/shop/cart URL 的支持。

import React, { Component } from "react";
import { Switch, Route, Redirect }
    from "react-router-dom"
import { connect } from "react-redux";
import { loadData } from "../data/ActionCreators";
import { DataTypes } from "../data/Types";
import { Shop } from "./Shop";
import { addToCart, updateCartQuantity, removeFromCart, clearCart }
    from "../data/CartActionCreators";

import { CartDetails } from "./CartDetails";

const mapStateToProps = (dataStore) => ({
    ...dataStore
})

const mapDispatchToProps = {
    loadData,
    addToCart, updateCartQuantity, removeFromCart, clearCart
}

const filterProducts = (products = [], category) =>
    (!category || category === "All")
        ? products
        : products.filter(p => p.category.toLowerCase() === category.toLowerCase());

export const ShopConnector = connect(mapStateToProps, mapDispatchToProps)(
    class extends Component {
        render() {
            return <Switch>
                <Route path="/shop/products/:category?"
                    render={ (routeProps) =>
                        <Shop { ...this.props } { ...routeProps }
                            products={ filterProducts(this.props.products,
                                routeProps.match.params.category) } />} />
                <Route path="/shop/cart" render={ (routeProps) =>

                        <CartDetails { ...this.props } { ...routeProps }  />} />

                <Redirect to="/shop/products" />
            </Switch>
        }

        componentDidMount() {
            this.props.loadData(DataTypes.CATEGORIES);
            this.props.loadData(DataTypes.PRODUCTS);
        }
    }
)

Listing 5-32Adding a New URL in the ShopConnector.js File in the src/shop Folder

新的Route通过显示CartDetails组件来处理/shop/cart URL,该组件从数据存储和路由系统接收属性。在清单 5-33 中,我已经更新了Shop组件来定义一个包装器函数,它围绕着addToCart动作创建器,也导航到新的 URL。

import React, { Component } from "react";
import { CategoryNavigation } from "./CategoryNavigation";
import { ProductList } from "./ProductList";
import { CartSummary } from "./CartSummary";

export class Shop extends Component {

    handleAddToCart = (...args) => {

        this.props.addToCart(...args);

        this.props.history.push("/shop/cart");

    }

    render() {
        return <div className="container-fluid">
            <div className="row">
                <div className="col bg-dark text-white">
                    <div className="navbar-brand">SPORTS STORE</div>
                    <CartSummary { ...this.props } />
                </div>
            </div>
            <div className="row">
                <div className="col-3 p-2">
                    <CategoryNavigation baseUrl="/shop/products"
                        categories={ this.props.categories } />
                </div>
                <div className="col-9 p-2">
                    <ProductList products={ this.props.products }
                        addToCart={ this.handleAddToCart } />

                </div>
            </div>
        </div>
    }
}

Listing 5-33Navigating to the Cart in the Shop.js File in the src/shop Folder

结果是,单击产品的“添加到购物车”按钮会显示更新的购物车,这为用户提供了返回产品列表并进行进一步选择、编辑购物车内容或开始结账过程的选择,如图 5-7 所示。

img/473159_1_En_5_Fig7_HTML.jpg

图 5-7

将购物车集成到 SportsStore 项目

此时,结帐按钮将用户返回到/store/products URL,但是我在第六章中添加了对结帐的支持。

摘要

在这一章中,我开始开发一个真实的 React 项目。本章的第一部分是建立 Redux 数据存储,它引入了一系列术语——动作、动作创建者、减少者——这些术语您可能不熟悉,但很快就会成为习惯。我还设置了 React 路由包,以便浏览器的 URL 可以用来选择呈现给用户的内容和数据。这些功能提供的基础需要时间来建立,但随着我向 SportsStore 添加更多功能,你会看到它开始产生回报。在下一章中,我将向 SportsStore 应用添加更多功能。

六、SportsStore:REST 和结帐

在本章中,我继续向我在第五章中创建的 SportsStore 应用添加特性。我添加了对从 web 服务中检索数据、在页面中显示大量数据以及结账和下订单的支持。

为本章做准备

本章不需要任何准备,它使用了在第五章中创建的 SportsStore 项目。要启动 React 开发工具和 RESTful web 服务,打开命令提示符,导航到sportsstore文件夹,运行清单 6-1 中所示的命令。

小费

你可以从 https://github.com/Apress/pro-react-16 下载本章以及本书其他章节的示例项目。

npm start

Listing 6-1Starting the Development Tools and Web Service

初始构建过程需要几秒钟,之后一个新的浏览器窗口或选项卡将会打开并显示 SportsStore 应用,如图 6-1 所示。

img/473159_1_En_6_Fig1_HTML.jpg

图 6-1

运行 SportsStore 应用

使用 RESTful Web 服务

SportsStore 应用的基本结构正在成形,我已经有了足够的功能来移除占位符数据并开始使用 RESTful web 服务。在第七章中,我使用 GraphQL,它是 REST web 服务的一个更灵活(也更复杂)的替代方案,但是常规的 web 服务也很常见,我将使用 REST web 服务向 SportsStore 应用提供其产品数据,并在结账过程结束时提交订单。

我在第二十三章中更详细地描述了 REST,但是对于本章,我只需要一个基本的 HTTP 请求就可以开始了。打开新的浏览器选项卡并请求http://localhost:3500/api/products。浏览器将向 web 服务发送一个 HTTP GET 请求,该服务是在第五章中创建的,并由清单 6-1 中的命令启动。与 URL 结合的 GET 方法告诉 web 服务需要一个产品列表,并产生以下结果:

...
[{"id":1,"name":"Kayak","category":"Watersports",
     "description":"A boat for one person","price":275},
 {"id":2,"name":"Lifejacket","category":"Watersports",
     "description":"Protective and fashionable","price":48.95},
 {"id":3,"name":"Soccer Ball","category":"Soccer",
     "description":"FIFA-approved size and weight","price":19.5},
 {"id":4,"name":"Corner Flags","category":"Soccer",
     "description":"Give your playing field a professional touch","price":34.95},
 {"id":5,"name":"Stadium","category":"Soccer",
     "description":"Flat-packed 35,000-seat stadium","price":79500},
 {"id":6,"name":"Thinking Cap","category":"Chess",
     "description":"Improve brain efficiency by 75%","price":16},
 {"id":7,"name":"Unsteady Chair","category":"Chess",
     "description":"Secretly give your opponent a disadvantage","price":29.95},
 {"id":8,"name":"Human Chess Board","category":"Chess",
     "description":"A fun game for the family","price":75},
 {"id":9,"name":"Bling Bling King","category":"Chess",
     "description":"Gold-plated, diamond-studded King","price":1200}]
...

web 服务使用 JSON 数据格式响应请求,这在 React 应用中很容易处理,因为它类似于第四章中描述的 JavaScript 对象文字格式。在接下来的小节中,我将创建一个使用 web 服务的基础,并使用它来替换当前由 SportsStore 应用显示的静态数据。

创建配置文件

项目在生产和开发中经常需要不同的 URL。为了避免将 URL 硬编码到单独的 JavaScript 文件中,我在src/data文件夹中添加了一个名为Urls.js的文件,并用它来定义清单 6-2 中所示的配置数据。

import { DataTypes } from "./Types";

const protocol = "http";
const hostname = "localhost";
const port = 3500;

export const RestUrls = {
    [DataTypes.PRODUCTS]: `${protocol}://${hostname}:${port}/api/products`,
    [DataTypes.CATEGORIES]: `${protocol}://${hostname}:${port}/api/categories`
}

Listing 6-2The Contents of the Urls.js File in the src/data Folder

当我在第八章准备部署 SportsStore 应用时,我将能够在一个地方配置访问 web 服务所需的 URL。为了保持一致性,我使用了已经为数据存储定义的数据类型,这有助于保持对不同类型数据的引用的一致性,并降低了输入错误的风险。

创建数据源

我在src/data文件夹中添加了一个名为RestDataSource.js的文件,并添加了清单 6-3 中所示的代码。我想合并负责向 web 服务发送 HTTP 请求并处理结果的代码,这样我就可以将它放在项目的一个地方。

import Axios from "axios";
import { RestUrls } from "./Urls";

export class RestDataSource {

    GetData = (dataType) =>
        this.SendRequest("get", RestUrls[dataType]);

    SendRequest = (method, url) => Axios.request({ method, url });
}

Listing 6-3The Contents of the RestDataSource.js File in the src/data Folder

RestDataSource类使用 Axios 包向 web 服务发出 HTTP 请求。Axios 在第二十三章中有所描述,它是一个处理 HTTP 的流行包,因为它提供了一致的 API,并自动处理响应,将 JSON 转换成 JavaScript 对象。在清单 6-3 中,GetData方法使用 Axios 向 web 服务发送 HTTP 请求,以获取指定数据类型的所有可用对象。来自GetData方法的结果是一个Promise,当从 web 服务收到响应时,这个结果被解析。

扩展数据存储

JavaScript 代码发送的 HTTP 请求是异步执行的。这不太符合 Redux 数据存储的默认行为,Redux 数据存储只在 reducer 处理一个动作时才响应变化。

Redux 数据存储可以扩展为支持使用中间件功能的异步操作,该功能检查发送到数据存储的动作,并在处理它们之前更改它们。在第二十章中,我创建了数据存储中间件,它在执行异步请求以获取数据时拦截动作并延迟它们。

对于 SportsStore 应用,我将采用一种不同的方法,并添加对有效负载为 a Promise的动作的支持,我在第四章中简要描述了这一点。中间件将一直等到Promise被解析,然后使用Promise的结果作为有效负载来传递动作。我在src/data文件夹中添加了一个名为AsyncMiddleware.js的文件,并添加了清单 6-4 中所示的代码。

const isPromise = (payload) =>
    (typeof(payload) === "object" || typeof(payload) === "function")
        && typeof(payload.then) === "function";

export const asyncActions = () => (next) => (action) => {
    if (isPromise(action.payload)) {
        action.payload.then(result => next({...action, payload: result}));
    } else {
        next(action)
    }
}

Listing 6-4The Contents of the AsyncMiddleware.js File in the src/data Folder

清单 6-4 中的代码包含一个函数,它通过查找函数或具有then函数的对象来检查动作的有效负载是否为PromiseasyncAction函数将被用作数据存储中间件,它调用Promise上的then来等待它被解析,此时它使用结果来替换有效负载并传递它,使用next函数,它继续通过数据存储的正常路径。有效载荷不是Promise的动作会被立即传递。在清单 6-5 中,我已经将中间件添加到数据存储中。

import { createStore, applyMiddleware } from "redux";

import { ShopReducer } from "./ShopReducer";
import { CartReducer } from "./CartReducer";
import { CommonReducer } from "./CommonReducer";

import { asyncActions } from "./AsyncMiddleware";

export const SportsStoreDataStore

    = createStore(CommonReducer(ShopReducer, CartReducer),

        applyMiddleware(asyncActions));

Listing 6-5Adding Middleware in the DataStore.js File in the src/data Folder

applyMiddleware用于包装中间件,以便它接收动作,结果作为参数传递给创建数据存储的createStore函数。其效果是清单 6-4 中定义的asyncActions函数将能够检查发送到数据存储的所有动作,并无缝地处理那些带有Promise有效负载的动作。

更新活动创建者

在清单 6-6 中,我从 store action creator 中删除了占位符数据,并用一个使用数据源发送请求的Promise代替了它。

import { ActionTypes} from "./Types";

//import { data as phData} from "./placeholderData";

import { RestDataSource } from "./RestDataSource";

const dataSource = new RestDataSource();

export const loadData = (dataType) => ({
    type: ActionTypes.DATA_LOAD,
    payload: dataSource.GetData(dataType)

        .then(response => ({ dataType, data: response.data}))

});

Listing 6-6Using a Promise in the ActionCreators.js File in the src/data Folder

当数据存储接收到由loadData函数创建的动作对象时,清单 6-5 中定义的中间件将等待从 web 服务接收到响应,然后传递动作进行正常处理,结果是 SportsStore 应用显示远程获取的数据,如图 6-2 所示。

img/473159_1_En_6_Fig2_HTML.jpg

图 6-2

使用来自 web 服务的数据

将数据分页

SportsStore 应用现在从 web 服务接收数据,但是大多数应用必须处理大量的数据,这些数据必须以页面的形式呈现给用户。在清单 6-7 中,我已经使用 Faker.js 包生成了大量的产品来替换 web 服务提供的数据。

var faker = require("faker");
var data = [];
var categories = ["Watersports", "Soccer", "Chess", "Running"];
faker.seed(100);
for (let i = 1; i <= 503; i++) {
    var category = faker.helpers.randomize(categories);
    data.push({
        id: i,
        name: faker.commerce.productName(),
        category: category,
        description: `${category}: ${faker.lorem.sentence(3)}`,
        price: Number(faker.commerce.price())
    })
}

module.exports = function () {
    return {
        categories: categories,
        products: data,
        orders: []
    }
}

Listing 6-7Increasing the Amount of Data in the data.js File in the sportsstore Folder

Faker.js 包是一个很好的工具,可以很容易地为开发和测试生成数据,通过在 https://github.com/Marak/Faker.js 描述的 API 提供上下文数据。当您保存data.js文件时,在第五章中创建的服务器代码将会检测到该更改,并将其加载到 web 服务中。在浏览器窗口中重新加载 SportsStore 应用,您将看到所有新产品都显示在一个列表中,如图 6-3 所示。用户仍然可以使用类别按钮来过滤产品,但是一次显示的数据仍然太多。

img/473159_1_En_6_Fig3_HTML.jpg

图 6-3

为测试分页生成更多数据

小费

清单 6-7 中的代码创建了 503 个产品对象。使用不可被您打算支持的页面大小整除的对象数量是一个好主意,这样您就可以确保您的代码处理最后一页上的一些掉队者。

了解 Web 服务分页支持

分页需要服务器的支持,以便它为客户端提供请求可用数据子集和关于有多少数据可用的信息的方法。没有提供分页的标准方法,您应该查阅所使用的服务器或服务的文档。

为 SportsStore 应用提供 RESTful web 服务的json-server包支持通过查询字符串进行分页。打开一个新的浏览器窗口,请求清单 6-8 中显示的 URL,看看分页是如何工作的。

http://localhost:3500/api/products?category_like=watersports&_page=2&_limit=3&_sort=name

Listing 6-8Requesting a Page of Data

这个 URL 的查询字符串——跟在?字符后面的部分——要求 web 服务使用表 6-1 中描述的字段返回一个特定类别的产品页面。

表 6-1

分页所需的查询字符串字段

|

名字

|

描述

|
| --- | --- |
| category_like | 该字段过滤结果,只包括那些 category 属性与字段值匹配的对象,字段值在示例 URL 中是Watersports。如果省略类别字段,则所有类别的产品都将包含在结果中。 |
| _page | 该字段选择页码。 |
| _limit | 该字段选择页面尺寸。 |
| _sort | 此字段指定对象在分页前排序所依据的属性。 |

清单 6-8 中的 URL 要求 web 服务返回第二个页面,该页面包含集合中的三个产品,这些产品的category值为Watersports,按name属性排序,产生以下结果:

...
[
 {"id":469,"name":"Awesome Fresh Pants","category":"Watersports",
    "description":"Watersports: Quia quam aut.","price":864},
 {"id":19,"name":"Awesome Frozen Car","category":"Watersports",
     "description":"Watersports: A rerum mollitia.","price":314},
  {"id":182,"name":"Awesome Granite Fish", "category":"Watersports",
      description":"Watersports: Hic omnis incidunt.","price":521}
]
...

web 服务响应包含帮助客户端发出未来请求的标头。使用浏览器请求清单 6-9 中显示的 URL。

http://localhost:3500/api/products?_page=2&_limit=3

Listing 6-9Making a Simpler Pagination Request

更简单的 URL 使得结果标题更容易理解。使用浏览器的 F12 开发工具检查响应,您将看到它包含以下标题:

...
X-Total-Count: 503
Link: <http://localhost:3500/api/products?_page=1&_limit=3>; rel="first",
      <http://localhost:3500/api/products?_page=1&_limit=3>; rel="prev",
      <http://localhost:3500/api/products?_page=3&_limit=3>; rel="next",
      <http://localhost:3500/api/products?_page=168&_limit=3>; rel="last"
...

这些并不是响应中仅有的头,但它们是专门添加来帮助客户端处理未来的分页请求的。X-Total-Count头提供了请求 URL 匹配的对象总数,这对于确定页面总数很有用。由于在清单 6-9 的 URL 中没有category字段,服务器报告有 503 个对象可用。

Link头提供了一组 URL,可用于查询第一页和最后一页,以及当前页面之前和之后的页面,尽管客户端不需要使用Link头来制定后续请求。

更改 HTTP 请求和操作

在清单 6-10 中,我修改了获取产品数据的请求的 URL 的公式,以包含请求参数,这些参数将用于请求页面和指定类别。Axios 包将使用参数向请求 URL 添加查询字符串。

import Axios from "axios";
import { RestUrls } from "./Urls";

export class RestDataSource {

    GetData = async(dataType, params) =>

        this.SendRequest("get", RestUrls[dataType], params);

    SendRequest = (method, url, params) => Axios.request({
                method, url, params

    });
}

Listing 6-10Adding URL Parameters in the RestDataSource.js File in the src/data/rest Folder

在清单 6-11 中,我已经更新了由loadData动作创建器创建的动作,这样它就包含了参数,并且将响应中的附加信息添加到数据存储中。

import { ActionTypes } from "./Types";
import { RestDataSource } from "./RestDataSource";

const dataSource = new RestDataSource();

export const loadData = (dataType, params) => (

    {
        type: ActionTypes.DATA_LOAD,
        payload: dataSource.GetData(dataType, params).then(response =>

             ({ dataType,
                data: response.data,
                total: Number(response.headers["x-total-count"]),

                params

             })
        )
    })

Listing 6-11Changing the Action in the ActionCreators.js File in the src/data Folder

当数据存储中间件解析了Promise之后,发送给 reducer 的动作对象将包含payload.totalpayload.params属性。total属性将包含X-Total-Count头的值,我将使用它来创建分页导航控件。params属性将包含用于发出请求的参数,我将使用这些参数来确定用户何时做出了需要更多数据的 HTTP 请求的更改。在清单 6-12 中,我已经更新了处理DATA_LOAD动作的 reducer,这样新的动作属性就被添加到了数据存储中。

import { ActionTypes } from "./Types";

export const ShopReducer = (storeData, action) => {
    switch(action.type) {
        case ActionTypes.DATA_LOAD:
            return {
                ...storeData,
                [action.payload.dataType]: action.payload.data,
                [`${action.payload.dataType}_total`]: action.payload.total,

                [`${action.payload.dataType}_params`]: action.payload.params

            };
        default:
            return storeData || {};
    }
}

Listing 6-12Adding Data Store Properties in the ShopReducer.js File in the src/data Folder

创建数据加载组件

为了创建一个负责获取产品数据的组件,我在src/data文件夹中添加了一个名为DataGetter.js的文件,并用它来定义清单 6-13 中所示的组件。

import React, { Component } from "react";
import { DataTypes } from "../data/Types";

export class DataGetter extends Component {

    render() {
        return <React.Fragment>{ this.props.children }</React.Fragment>
    }

    componentDidUpdate = () => this.getData();
    componentDidMount = () => this.getData();

    getData = () => {
        const dsData = this.props.products_params || {} ;
        const rtData = {
            _limit: this.props.pageSize || 5,
            _sort: this.props.sortKey || "name",
            _page: this.props.match.params.page || 1,
            category_like: (this.props.match.params.category || "") === "all"
                ? "" : this.props.match.params.category
        }

        if (Object.keys(rtData).find(key => dsData[key] !== rtData[key])) {
            this.props.loadData(DataTypes.PRODUCTS, rtData);
        }
    }
}

Listing 6-13The Contents of the DataGetter.js File in the src/data Folder

该组件使用children属性呈现其父组件在开始和结束标记之间提供的内容。这对于定义向应用提供服务但不向用户呈现内容的组件很有用。在这种情况下,我需要一个组件,它可以接收当前路线及其参数的详细信息,还可以访问数据存储。组件的componentDidMountcomponentDidUpdate方法都是第十三章中描述的组件生命周期的一部分,它们调用getData方法,该方法从 URL 获取参数,并将它们与上次请求后添加到数据存储中的参数进行比较。如果发生了变化,将会分派一个新的动作来加载用户所需的数据。

除了从 URL 获取的类别和页码之外,还使用参数_sort_limit来创建操作,这些参数对结果进行排序并设置数据大小。用于排序和设置页面大小的值将从数据存储中获得。

更新存储连接器组件

为了在应用中引入分页支持,我更新了ShopConnector组件,它负责将应用中的商店功能连接到数据存储和 URL 路由。清单 6-14 中的更改添加了DataGetter组件,并删除了产品数据的类别过滤器(因为产品已经被 web 服务过滤了)。

import React, { Component } from "react";
import { Switch, Route, Redirect }
    from "react-router-dom"
import { connect } from "react-redux";
import { loadData } from "../data/ActionCreators";
import { DataTypes } from "../data/Types";
import { Shop } from "./Shop";
import { addToCart, updateCartQuantity, removeFromCart, clearCart }
    from "../data/CartActionCreators";
import { CartDetails } from "./CartDetails";

import { DataGetter } from "../data/DataGetter";

const mapStateToProps = (dataStore) => ({
    ...dataStore
})

const mapDispatchToProps = {
    loadData,
    addToCart, updateCartQuantity, removeFromCart, clearCart
}

// const filterProducts = (products = [], category) =>

//     (!category || category === "All")

//         ? products

//         : products.filter(p =>

//               p.category.toLowerCase() === category.toLowerCase());

export const ShopConnector = connect(mapStateToProps, mapDispatchToProps)(
    class extends Component {
        render() {
            return <Switch>
                <Redirect from="/shop/products/:category"

                    to="/shop/products/:category/1" exact={ true } />

                <Route path={ "/shop/products/:category/:page" }

                    render={ (routeProps) =>
                        <DataGetter { ...this.props } { ...routeProps }>

                            <Shop { ...this.props } { ...routeProps }  />
                        </DataGetter>

                    } />
                <Route path="/shop/cart" render={ (routeProps) =>
                        <CartDetails { ...this.props } { ...routeProps }  />} />
                <Redirect to="/shop/products/all/1" />

            </Switch>
        }

        componentDidMount() {
            this.props.loadData(DataTypes.CATEGORIES);
            //this.props.loadData(DataTypes.PRODUCTS);

        }
    }
)

Listing 6-14Adding Pagination in the ShopConnector.js File in the src/shop Folder

我已经更新了路由配置以支持分页。第一个路由更改是添加了一个Redirect,它匹配有类别但没有页面的 URL,并将它们重定向到所选类别的第一个页面的 URL。我还修改了现有的Redirect,使其将任何不匹配的 URL 重定向到/shop/products/all

结果是代码块看起来比实际更复杂。当ShopConnector组件被请求呈现其内容时,它使用一个Route来匹配 URL 并获得categoryparameters,如下所示:

...
<Route path={ "/shop/products/:category/:page" }
...

紧接在Route之前的是一个Redirect,它匹配只有一个段的 URL,并将浏览器重定向到一个将选择第一页的 URL:

...
<Redirect from="/shop/products/:category"
          to="/shop/products/:category/1" exact={ true } />
...

这种重定向确保了总是有类别和页面值可以使用。另一个Redirect匹配任何其他 URL,并将它们重定向到产品第一页的 URL,不按类别过滤。

...
<Redirect to="/shop/products/all/1" />
...

更新所有类别按钮

清单 6-14 中使用的路由组件需要对All类别按钮进行相应的更改,以便在没有选择类别时高亮显示,如清单 6-15 所示。

import React, { Component } from "react";
import { ToggleLink } from "../ToggleLink";

export class CategoryNavigation extends Component {

    render() {
        return <React.Fragment>
            <ToggleLink to={ `${this.props.baseUrl}/all` } exact={ false }>

                All
            </ToggleLink>
            { this.props.categories && this.props.categories.map(cat =>
                <ToggleLink key={ cat }
                    to={ `${this.props.baseUrl}/${cat.toLowerCase()}`}>
                    { cat }
                </ToggleLink>
            )}
        </React.Fragment>
    }
}

Listing 6-15Updating the All Button in the CategoryNavigation.js File in the src/shop Folder

我已经将/all添加到由ToggleLink组件匹配的 URL 中,并将exact属性设置为false,这样像/shop/products/all/1这样的 URL 就会被匹配。效果是应用从 web 服务请求单独的数据页面,web 服务也负责基于类别进行过滤。每当用户点击一个类别按钮,DataGetter组件就会请求新的数据,如图 6-4 所示。

img/473159_1_En_6_Fig4_HTML.jpg

图 6-4

从 web 服务请求数据页面

创建分页控件

下一步是创建一个允许用户导航到不同页面并更改页面大小的组件。清单 6-16 定义了新的数据存储动作类型,这些动作类型将用于更改页面大小并指定将用于排序结果的属性。

export const DataTypes = {
    PRODUCTS: "products",
    CATEGORIES: "categories"
}

export const ActionTypes = {
    DATA_LOAD: "data_load",
    DATA_SET_SORT_PROPERTY: "data_set_sort",

    DATA_SET_PAGESIZE: "data_set_pagesize",

    CART_ADD: "cart_add",
    CART_UPDATE: "cart_update",
    CART_REMOVE: "cart_delete",
    CART_CLEAR: "cart_clear"
}

Listing 6-16Adding New Action Types in the Types.js File in the src/data Folder

在清单 6-17 中,我添加了使用新类型创建动作的新动作创建器。

import { ActionTypes } from "./Types";
import { RestDataSource } from "./RestDataSource";

const dataSource = new RestDataSource();

export const loadData = (dataType, params) => (
    {
        type: ActionTypes.DATA_LOAD,
        payload: dataSource.GetData(dataType, params).then(response =>
             ({ dataType,
                data: response.data,
                total: Number(response.headers["x-total-count"]),
                params
             })
        )
    })

export const setPageSize = (newSize) =>

    ({ type: ActionTypes.DATA_SET_PAGESIZE, payload: newSize});

export const setSortProperty = (newProp) =>

    ({ type: ActionTypes.DATA_SET_SORT_PROPERTY, payload: newProp});

Listing 6-17Defining Creators in the ActionCreators.js File in the src/data Folder

在清单 6-18 中,我扩展了 reducer 来支持新的动作。

import { ActionTypes } from "./Types";

export const ShopReducer = (storeData, action) => {
    switch(action.type) {
        case ActionTypes.DATA_LOAD:
            return {
                ...storeData,
                [action.payload.dataType]: action.payload.data,
                [`${action.payload.dataType}_total`]: action.payload.total,
                [`${action.payload.dataType}_params`]: action.payload.params
            };
        case ActionTypes.DATA_SET_PAGESIZE:

            return { ...storeData, pageSize: action.payload }

        case ActionTypes.DATA_SET_SORT_PROPERTY:

            return { ...storeData, sortKey: action.payload }

        default:
            return storeData || {};
    }
}

Listing 6-18Supporting New Actions in the ShopReducer.js File in the src/data Folder

为了生成允许用户使用分页特性的 HTML 元素,我在src文件夹中添加了一个名为PaginationControls.js的文件,并用它来定义清单 6-19 中所示的组件。

import React, { Component } from "react";
import { PaginationButtons } from "./PaginationButtons";

export class PaginationControls extends Component {

    constructor(props) {
        super(props);
        this.pageSizes = this.props.sizes || [5, 10, 25, 100];
        this.sortKeys = this.props.keys || ["Name", "Price"];
    }

    handlePageSizeChange = (ev) => {
        this.props.setPageSize(ev.target.value);
    }

    handleSortPropertyChange = (ev) => {
        this.props.setSortProperty(ev.target.value);
    }

    render() {
        return <div className="m-2">
                <div className="text-center m-1">
                    <PaginationButtons currentPage={this.props.currentPage}
                        pageCount={this.props.pageCount}
                        navigate={ this.props.navigateToPage }/>
                </div>
                <div className="form-inline justify-content-center">
                    <select className="form-control"
                            onChange={ this.handlePageSizeChange }
                            value={ this.props.pageSize|| this.pageSizes[0] }>
                        { this.pageSizes.map(s =>
                            <option value={s} key={s}>{s} per page</option>
                        )}
                    </select>
                    <select className="form-control"
                            onChange={ this.handleSortPropertyChange }
                            value={ this.props.sortKey || this.sortKeys[0] }>
                        { this.sortKeys.map(k =>
                            <option value={k.toLowerCase()} key={k}>
                                Sort By { k }
                            </option>
                        )}
                    </select>
            </div>
        </div>
    }
}

Listing 6-19The Contents of the PaginationControls.js File in the src Folder

PaginationControls组件使用select元素来允许用户更改页面大小和用于对结果进行排序的属性。提供可选择的单个值的option元素可以使用 props 进行配置,这将允许我在第七章的管理特性中重用这个组件。如果没有提供 props,那么使用适合分页产品的缺省值。

onChange属性应用于select元素以响应用户更改,这些更改由接收由更改触发的事件的方法处理,并调用从父组件接收的函数属性。

生成允许页面间移动的按钮的过程已经委托给一个名为PaginationButtons的组件。为了创建这个组件,我在src文件夹中添加了一个名为PaginationButtons.js的文件,并添加了清单 6-20 中所示的代码。

import React, { Component } from "react";

export class PaginationButtons extends Component {

    getPageNumbers = () => {
        if (this.props.pageCount < 4) {
            return [...Array(this.props.pageCount + 1).keys()].slice(1);
        } else if (this.props.currentPage <= 4) {
            return [1, 2, 3, 4, 5];
        } else  if (this.props.currentPage > this.props.pageCount - 4) {
            return [...Array(5).keys()].reverse()
                .map(v => this.props.pageCount - v);
        } else {
            return [this.props.currentPage -1, this.props.currentPage,
                this.props.currentPage + 1];
        }
    }

    render() {
        const current = this.props.currentPage;
        const pageCount = this.props.pageCount;
        const navigate = this.props.navigate;
        return <React.Fragment>
            <button onClick={ () => navigate(current  - 1) }
                disabled={ current === 1 } className="btn btn-secondary mx-1">
                    Previous
            </button>
            { current > 4 &&
                <React.Fragment>
                    <button className="btn btn-secondary mx-1"
                        onClick={ () => navigate(1)}>1</button>
                    <span className="h4">...</span>
                </React.Fragment>
            }
            { this.getPageNumbers().map(num =>
                <button className={ `btn mx-1 ${num === current
                        ? "btn-primary": "btn-secondary"}`}
                    onClick={ () => navigate(num)} key={ num }>
                        { num }
                </button>)}
            { current <= (pageCount - 4) &&
                <React.Fragment>
                    <span className="h4">...</span>
                    <button className="btn btn-secondary mx-1"
                            onClick={ () => navigate(pageCount)}>
                        { pageCount }
                    </button>
                </React.Fragment>
            }
            <button onClick={ () => navigate(current + 1) }
                disabled={ current === pageCount }
                className="btn btn-secondary mx-1">
                    Next
            </button>
        </React.Fragment>
    }
}

Listing 6-20The Contents of the PaginationButtons.js File in the src Folder

创建分页按钮是一个复杂的过程,很容易陷入细节中。我在清单 6-20 中采用的方法旨在简单性和为用户提供足够的上下文来浏览大量数据之间取得平衡。

为了将分页控件连接到商店中的产品数据,我在src/shop文件夹中添加了一个名为ProductPageConnector.js的文件,并定义了清单 6-21 中所示的组件。

import { connect } from "react-redux";
import { withRouter } from "react-router-dom";
import { setPageSize, setSortProperty } from "../data/ActionCreators";

const mapStateToProps = dataStore => dataStore;
const mapDispatchToProps = { setPageSize, setSortProperty };

const mergeProps = (dataStore, actionCreators, router) => ({
    ...dataStore, ...router, ...actionCreators,
    currentPage: Number(router.match.params.page),
    pageCount: Math.ceil((dataStore.products_total
        | dataStore.pageSize || 5)/(dataStore.pageSize || 5)),
    navigateToPage: (page) => router.history
        .push(`/shop/products/${router.match.params.category}/${page}`),
})

export const ProductPageConnector = (PageComponent) =>
    withRouter(connect(mapStateToProps, mapDispatchToProps,
        mergeProps)(PageComponent))

Listing 6-21The Contents of the ProductPageConnector.js File in the src/shop Folder

正如我前面解释的那样,React 应用中的复杂性通常集中在不同功能组合的地方,这就是 SportsStore 应用中的连接器组件。清单 6-21 中的代码创建了一个高阶组件(称为 HOC 并在第十四章中描述),这是一个通过其 props 向另一个组件提供特性的函数。这个 HOC 被命名为ProductPageConnector,它结合了数据存储属性、动作创建器和路由参数,为分页控件组件提供了对它们所需特性的访问。connect函数与我在第五章中使用的用于将组件连接到数据存储的函数相同,它与withRouter函数一起使用,后者是 React 路由包中的对应函数,它为组件提供了来自最近的Route的路由细节。在清单 6-22 中,我将高阶组件应用于PaginationControls组件,并将结果添加到呈现给用户的内容中。

import React, { Component } from "react";
import { CategoryNavigation } from "./CategoryNavigation";
import { ProductList } from "./ProductList";
import { CartSummary } from "./CartSummary";

import { ProductPageConnector } from "./ProductPageConnector";

import { PaginationControls } from "../PaginationControls";

const ProductPages = ProductPageConnector(PaginationControls);

export class Shop extends Component {

    handleAddToCart = (...args) => {
        this.props.addToCart(...args);
        this.props.history.push("/shop/cart");
    }

    render() {
        return <div className="container-fluid">
            <div className="row">
                <div className="col bg-dark text-white">
                    <div className="navbar-brand">SPORTS STORE</div>
                    <CartSummary { ...this.props } />
                </div>
            </div>
            <div className="row">
                <div className="col-3 p-2">
                    <CategoryNavigation baseUrl="/shop/products"
                        categories={ this.props.categories } />
                </div>
                <div className="col-9 p-2">
                    <ProductPages />

                    <ProductList products={ this.props.products }
                        addToCart={ this.handleAddToCart } />
                </div>
            </div>
        </div>
    }
}

Listing 6-22Adding Pagination Controls in the Shop.js File in the src/shop Folder

结果是一系列允许用户在页面间移动的按钮,以及改变排序属性和页面大小的select元素,如图 6-5 所示。

img/473159_1_En_6_Fig5_HTML.jpg

图 6-5

添加对分页产品的支持

添加结账流程

该应用的核心功能已经就绪,允许用户过滤和浏览产品数据,并将商品添加到以摘要和详细视图显示的购物篮中。一旦用户完成了结帐过程,一个新的订单必须被发送到 web 服务,web 服务将完成购物,重置用户的购物车,并显示一条摘要消息。在接下来的小节中,我将添加对结账和下订单的支持。

扩展 REST 数据源和数据存储

正如我在第二十三章中解释的,当一个 RESTful web 服务接收到一个 HTTP 请求时,它使用请求方法(也称为动词)和 URL 的组合来决定应该执行什么操作。为了向 web 服务发送订单,我将向 web 服务的/orders URL 发送一个 POST 请求。为了使新特性与现有应用保持一致,我首先添加了一个标识订单数据类型的常数和一个存储订单的新动作,如清单 6-23 所示。

export const DataTypes = {
    PRODUCTS: "products",
    CATEGORIES: "categories",
    ORDERS: "orders"

}

export const ActionTypes = {
    DATA_LOAD: "data_load",
    DATA_STORE: "data_store",

    DATA_SET_SORT_PROPERTY: "data_set_sort",
    DATA_SET_PAGESIZE: "data_set_pagesize",
    CART_ADD: "cart_add",
    CART_UPDATE: "cart_update",
    CART_REMOVE: "cart_delete",
    CART_CLEAR: "cart_clear"
}

Listing 6-23Adding Types in the Types.js File in the src/data Folder

新的数据类型允许我定义下订单的 URL,如清单 6-24 所示。当我添加对管理特性的支持时,我也在第七章中使用它。

import { DataTypes } from "./Types";

const protocol = "http";
const hostname = "localhost";
const port = 3500;

export const RestUrls = {
    [DataTypes.PRODUCTS]: `${protocol}://${hostname}:${port}/api/products`,
    [DataTypes.CATEGORIES]: `${protocol}://${hostname}:${port}/api/categories`,
    [DataTypes.ORDERS]: `${protocol}://${hostname}:${port}/api/orders`

}

Listing 6-24Adding a New URL in the Urls.js File in the src/data Folder

在清单 6-25 中,我向 REST 数据源添加了一个方法,该方法接收订单对象并将其发送给 web 服务。

import Axios from "axios";
import { RestUrls } from "./Urls";

export class RestDataSource {

    constructor(err_handler) {
        this.error_handler = err_handler || (() => {});
    }

    GetData = (dataType, params) =>
        this.SendRequest("get", RestUrls[dataType], params);

    StoreData = (dataType, data) =>

        this.SendRequest("post", RestUrls[dataType], {}, data);

    SendRequest = (method, url, params, data) =>

        Axios.request({ method, url, params, data });
}

Listing 6-25Adding a Method in the RestDataSource.js File in the src/data Folder

Axios 包将接收一个数据对象,并负责对其进行格式化,以便将其发送到 web 服务。在清单 6-26 中,我添加了一个新的动作创建器,它使用一个Promise向 web 服务发送订单。web 服务将返回存储的数据,其中包括一个唯一的标识符。

import { ActionTypes, DataTypes } from "./Types";

import { RestDataSource } from "./RestDataSource";

const dataSource = new RestDataSource();

export const loadData = (dataType, params) => (
    {
        type: ActionTypes.DATA_LOAD,
        payload: dataSource.GetData(dataType, params).then(response =>
             ({ dataType,
                data: response.data,
                total: Number(response.headers["x-total-count"]),
                params
             })
        )
    })

export const setPageSize = (newSize) => {
    return ({ type: ActionTypes.DATA_SET_PAGESIZE, payload: newSize});
}

export const setSortProperty = (newProp) =>
    ({ type: ActionTypes.DATA_SET_SORT_PROPERTY, payload: newProp});

export const placeOrder = (order) => ({

        type: ActionTypes.DATA_STORE,

        payload: dataSource.StoreData(DataTypes.ORDERS, order).then(response => ({

            dataType: DataTypes.ORDERS, data: response.data

        }))

    })

Listing 6-26Adding a Creator to the ActionCreators.js File in the src/data Folder

为了处理结果并将订单添加到数据存储中,我添加了清单 6-27 中所示的缩减器。

import { ActionTypes, DataTypes } from "./Types";

export const ShopReducer = (storeData, action) => {
    switch(action.type) {
        case ActionTypes.DATA_LOAD:
            return {
                ...storeData,
                [action.payload.dataType]: action.payload.data,
                [`${action.payload.dataType}_total`]: action.payload.total,
                [`${action.payload.dataType}_params`]: action.payload.params
            };
        case ActionTypes.DATA_SET_PAGESIZE:
            return { ...storeData, pageSize: action.payload }
        case ActionTypes.DATA_SET_SORT_PROPERTY:
            return { ...storeData, sortKey: action.payload }
        case ActionTypes.DATA_STORE:

            if (action.payload.dataType === DataTypes.ORDERS) {

                return { ...storeData, order: action.payload.data }

            }

            break;

        default:
            return storeData || {};
    }
}

Listing 6-27Storing an Order in the ShopReducer.js File in the src/data Folder

创建签出表单

要完成 SportsStore 订单,用户必须填写一个包含个人详细信息的表单,这意味着我必须向用户提供一个表单。React 支持两种使用表单元素的方式:受控非受控。对于受控元素,React 管理元素的内容并响应其更改事件。用于配置分页的select元素就属于这一类。对于 checkout 表单,我将使用不受控制的元素,这些元素不受 React 的严密管理,更多地依赖于浏览器的功能。使用不受控制的 for 元素的关键是一个名为 refs 的特性,在第十六章中有描述,它允许 React 组件在 HTML 元素显示给用户后,跟踪由它的render方法产生的 HTML 元素。对于结帐表单,使用refs的好处是我可以使用 HTML5 验证 API 来验证表单,我在第十五章中对此进行了描述。验证 API 要求直接访问表单元素,如果不使用 refs,这是不可能的。

注意

React 应用中有一些可用于创建和验证表单的包,但是它们可能很难使用,也很难对表单的外观或它生成的数据结构施加限制。使用第十五章和第十六章中描述的特性很容易创建定制表单和验证,这是我在 SportsStore 章节中采用的方法。

创建经过验证的表单

我将创建一个带有验证的可重用表单,它将通过编程生成所需的字段。我创建了src/forms文件夹,并在其中添加了一个名为ValidatedForm.js的文件,我用它来定义清单 6-28 中所示的组件。

import React, { Component } from "react";
import { ValidationError } from "./ValidationError";
import { GetMessages } from "./ValidationMessages";

export class ValidatedForm extends Component {

    constructor(props) {
        super(props);
        this.state = {
            validationErrors: {}
        }
        this.formElements = {};
    }

    handleSubmit = () => {
        this.setState(state => {
            const newState = { ...state, validationErrors: {} }
            Object.values(this.formElements).forEach(elem => {
                if (!elem.checkValidity()) {
                    newState.validationErrors[elem.name] = GetMessages(elem);
                }
            })
            return newState;
        }, () => {
            if (Object.keys(this.state.validationErrors).length === 0) {
                const data =  Object.assign(...Object.entries(this.formElements)
                    .map(e => ({[e[0]]: e[1].value})) )
                this.props.submitCallback(data);
            }
        });
    }

    registerRef = (element) => {
        if (element !== null) {
            this.formElements[element.name] = element;
        }
    }

    renderElement = (modelItem) => {
        const name = modelItem.name || modelItem.label.toLowerCase();
        return <div className="form-group" key={ modelItem.label }>
            <label>{ modelItem.label }</label>
            <ValidationError errors={ this.state.validationErrors[name] } />
            <input className="form-control" name={ name } ref={ this.registerRef }
                { ...this.props.defaultAttrs } { ...modelItem.attrs } />
        </div>
    }

    render() {
        return <React.Fragment>
            { this.props.formModel.map(m => this.renderElement(m))}
            <div className="text-center">
                <button className="btn btn-secondary m-1"
                        onClick={ this.props.cancelCallback }>
                    { this.props.cancelText || "Cancel" }
                </button>
                <button className="btn btn-primary m-1"
                        onClick={ this.handleSubmit }>
                    { this.props.submitText || "Submit"}
                </button>
            </div>
        </React.Fragment>
    }
}

Listing 6-28The Contents of the ValidatedForm.js File in the src/forms Folder

ValidatedForm组件接收一个数据模型并使用它创建一个表单,该表单使用 HTML5 API 进行验证。每个表单元素都有一个标签和一个向用户显示验证消息的ValidationError组件。该表单显示有按钮,这些按钮使用作为属性提供的回调函数来取消或提交表单。除非所有元素都满足验证约束,否则不会调用submit回调。

当调用提交回调时,它将接收一个对象,该对象的属性是表单元素的name属性值,其值是用户输入到每个字段中的数据。

定义表单

为了创建用于显示错误消息的组件,我在src/forms文件夹中添加了一个名为ValidationError.js的文件,并添加了清单 6-29 中所示的代码。

import React, { Component } from "react";

export class ValidationError extends Component {

    render() {
        if (this.props.errors) {
            return this.props.errors.map(err =>
                <h6 className="text-danger" key={err}>
                    { err }
                </h6>
            )
        }
        return null;
    }
}

Listing 6-29The Contents of the ValidationError.js File in the src/forms Folder

验证 API 以一种尴尬的方式呈现验证错误,如第十六章所述。为了创建可以显示给用户的消息,我在src/forms文件夹中添加了一个名为ValidationMessages.js的文件,并定义了清单 6-30 中所示的函数。

export const GetMessages = (elem) => {
    const messages = [];
    if (elem.validity.valueMissing) {
        messages.push("Value required");
    }
    if (elem.validity.typeMismatch) {
        messages.push(`Invalid ${elem.type}`);
    }
    return messages;
}

Listing 6-30The Contents of the ValidationMessages.js File in the src/forms Folder

为了使用验证过的表单进行签出,我在src/shop文件夹中添加了一个名为Checkout.js的文件,并定义了清单 6-31 中所示的组件。

import React, { Component } from "react";
import { ValidatedForm } from "../forms/ValidatedForm";

export class Checkout extends Component {

    constructor(props) {
        super(props);
        this.defaultAttrs = { type: "text", required: true };
        this.formModel = [
                { label: "Name"},
                { label: "Email", attrs: { type: "email" }},
                { label: "Address" },
                { label: "City"},
                { label: "Zip/Postal Code", name: "zip"},
                { label: "Country"}]
    }

    handleSubmit = (formData) => {
        const order = { ...formData, products: this.props.cart.map(item =>
            ({  quantity: item.quantity, product_id: item.product.id})) }
        this.props.placeOrder(order);
        this.props.clearCart();
        this.props.history.push("/shop/thanks");
    }

    handleCancel = () => {
        this.props.history.push("/shop/cart");
    }

    render() {
        return <div className="container-fluid">
            <div className="row">
                <div className="col bg-dark text-white">
                    <div className="navbar-brand">SPORTS STORE</div>
                </div>
            </div>
            <div className="row">
                <div className="col m-2">
                    <ValidatedForm formModel={ this.formModel }
                        defaultAttrs={ this.defaultAttrs }
                        submitCallback={ this.handleSubmit }
                        cancelCallback={ this.handleCancel }
                        submitText="Place Order"
                        cancelText="Return to Cart" />
                </div>
            </div>
        </div>
    }
}

Listing 6-31The Contents of the Checkout.js File in the src/shop Folder

Checkout组件使用一个ValidatedForm向用户显示他们的姓名、电子邮件和地址。每个表单元素都将创建有required属性,电子邮件地址的input元素的type属性被设置为email。这些属性由 HTML5 约束验证 API 使用,并且将阻止用户下订单,除非他们为所有字段提供值并且在电子邮件字段中输入有效的电子邮件地址(尽管应当注意,仅验证电子邮件地址的格式)。

当用户提交有效的表单数据时,将调用handleSubmit方法。这个方法接收表单数据,并在调用placeOrderclearCart动作创建者之前,将表单数据与用户购物车的详细信息结合起来,然后导航到/shop/thanks URL。

创建感谢组件

为了向用户提供订单确认并完成结账过程,我在src/shop文件夹中添加了一个名为Thanks.js的文件,并定义了清单 6-32 中所示的组件。

import React, { Component } from "react";
import { Link } from "react-router-dom";

export class Thanks extends Component {

    render() {
        return <div>
            <div className="col bg-dark text-white">
                <div className="navbar-brand">SPORTS STORE</div>
            </div>
            <div className="m-2 text-center">
                <h2>Thanks!</h2>
                <p>Thanks for placing your order.</p>
                <p>Your order is #{ this.props.order ? this.props.order.id : 0 }</p>
                <p>We'll ship your goods as soon as possible.</p>
                <Link to="/shop" className="btn btn-primary">
                    Return to Store
                </Link>
            </div>
        </div>
    }
}

Listing 6-32The Contents of the Thanks.js File in the src/shop Folder

Thanks组件显示一条简单的消息,并包含来自order对象的id属性的值,该值是通过其order属性获得的。这个组件将连接到数据存储,它包含的order对象将有一个由 RESTful web 服务分配的id值。

应用新组件

为了给应用添加新的组件,我修改了ShopConnector组件中的路由配置,如清单 6-33 所示。

import React, { Component } from "react";
import { Switch, Route, Redirect }
    from "react-router-dom"
import { connect } from "react-redux";

import { loadData, placeOrder } from "../data/ActionCreators";

import { DataTypes } from "../data/Types";
import { Shop } from "./Shop";
import { addToCart, updateCartQuantity, removeFromCart, clearCart }
    from "../data/CartActionCreators";
import { CartDetails } from "./CartDetails";
import { DataGetter } from "../data/DataGetter";

import { Checkout } from "./Checkout";

import { Thanks } from "./Thanks";

const mapStateToProps = (dataStore) => ({
    ...dataStore
})

const mapDispatchToProps = {
    loadData,
    addToCart, updateCartQuantity, removeFromCart, clearCart,
    placeOrder

}

export const ShopConnector = connect(mapStateToProps, mapDispatchToProps)(
    class extends Component {
        render() {
            return <Switch>
                <Redirect from="/shop/products/:category"
                    to="/shop/products/:category/1" exact={ true } />
                <Route path={ "/shop/products/:category/:page" }
                    render={ (routeProps) =>
                        <DataGetter { ...this.props } { ...routeProps }>
                            <Shop { ...this.props } { ...routeProps } />
                        </DataGetter>
                    } />
                <Route path="/shop/cart" render={ (routeProps) =>
                        <CartDetails { ...this.props } { ...routeProps } />} />
                <Route path="/shop/checkout" render={ routeProps =>

                    <Checkout { ...this.props } { ...routeProps } /> } />

                <Route path="/shop/thanks" render={ routeProps =>

                    <Thanks { ...this.props } { ...routeProps } /> } />

                <Redirect to="/shop/products/all/1" />
            </Switch>
        }

        componentDidMount() {
            this.props.loadData(DataTypes.CATEGORIES);
        }
    }
)

Listing 6-33Adding New Routes in the ShopConnector.js File in the src/shop Folder

结果允许用户结帐。要测试新功能,请导航到http://localhost:3000,将一个或多个产品添加到购物车,然后单击结账按钮,这将显示如图 6-6 所示的表单。如果您在填写表单之前单击 Place Order 按钮,您将看到验证警告,如图所示。

img/473159_1_En_6_Fig6_HTML.jpg

图 6-6

签出时出现验证错误

注意

只有当用户单击按钮时,才会执行验证。参见第十五章和第十六章,查看每次击键后验证表单元素内容的示例。

如果您已经填写了所有字段并输入了有效的电子邮件地址,当您单击下单按钮时,您的订单将被下单,显示图 6-7 所示的摘要。

img/473159_1_En_6_Fig7_HTML.jpg

图 6-7

下订单

打开一个新的浏览器选项卡并请求http://localhost:3500/api/orders,响应将显示您所下订单的 JSON 表示,如下所示:

...
[{
  "name":"Bob Smith","email":"bob@example.com",
  "address":"123 Main Street","city":"New York","zip":"NY 10036",
  "country":"USA","products":[{"quantity":1,"product_id":318}],"id":1
 }]
...

每次下订单时,RESTful web 服务都会给它分配一个id,然后显示在订单摘要中。

小费

每次使用npm start命令启动开发工具时,web 服务使用的数据都会重新生成,这使得重置应用变得很容易。在第八章中,我将 SportsStore 应用切换到一个持久数据库,作为部署准备的一部分。

简化车间连接器组件

SportsStore 应用的购物部分所需的所有特性都已完成,但我将在本章中再做一处修改。

React 应用由其 props 驱动,props 为组件提供它们所需的数据和功能。当使用 URL 路由和数据存储等功能时,将它们的功能转化为属性的过程会变得复杂。对于 SportsStore 应用,这是ShopConnector组件,它包含数据存储属性、动作创建器和应用购物部分的 URL 路由。整合这些功能的好处是,其他购物组件的编写、维护和测试更简单。缺点是合并会导致代码难以阅读,并且很可能出现错误。

当我向应用添加特性时,我添加了一个新的Route,它选择了一个组件,并为它提供了从数据存储和 URL 路由访问 props 的权限。我本可以更具体地说明每个组件收到的属性,这是我在本书后面的许多示例中遵循的做法。然而,对于 SportsStore 项目,我让每个组件都可以访问所有的属性,这是一种使开发更容易的方法,并且允许在添加所有功能后整理路由代码。在清单 6-34 中,我简化了购物功能的连接器。

import React, { Component } from "react";
import { Switch, Route, Redirect }
    from "react-router-dom"
import { connect } from "react-redux";

import * as ShopActions from "../data/ActionCreators";

import { DataTypes } from "../data/Types";
import { Shop } from "../shop/Shop";

import  * as CartActions from "../data/CartActionCreators";

import { CartDetails } from "../shop/CartDetails";
import { DataGetter } from "../data/DataGetter";
import { Checkout } from "../shop/Checkout";
import { Thanks } from "../shop/Thanks";

const mapDispatchToProps = { ...ShopActions, ...CartActions};

export const ShopConnector = connect(ds => ds, mapDispatchToProps)(

    class extends Component {

        selectComponent = (routeProps) => {

            const wrap = (Component, Content) =>

                <Component { ...this.props}  { ...routeProps}>

                    { Content && wrap(Content)}

                </Component>

            switch (routeProps.match.params.section) {

                case "products":

                    return wrap(DataGetter, Shop);

                case "cart":

                    return wrap(CartDetails);

                case "checkout":

                    return wrap(Checkout);

                case "thanks":

                    return wrap(Thanks);

                default:

                    return <Redirect to="/shop/products/all/1" />

            }

        }

        render() {
            return <Switch>
                <Redirect from="/shop/products/:category"
                    to="/shop/products/:category/1" exact={ true } />
                <Route path={ "/shop/:section?/:category?/:page?"}

                    render = { routeProps => this.selectComponent(routeProps) } />

            </Switch>
        }

        componentDidMount = () => this.props.loadData(DataTypes.CATEGORIES);

    }
)

Listing 6-34Simplifying the Code in the ShopConnector.js File in the src/connectors Folder

在第九章中,我解释了 JSX 是如何被翻译成 JavaScript 的,但是很容易忘记,所有的组件都可以被重构,从而更少地依赖 HTML 元素的声明性,更多地依赖纯 JavaScript。在清单 6-34 中,我将多个Route组件合并成一个组件,其render函数选择应该向用户显示的组件,并从数据存储和 URL 路由为其提供属性。我还为动作创建者修改了import语句,并在将它们映射到功能属性时使用了 spread 操作符,我之前没有这么做,因为我想展示我是如何将每个数据存储特性连接到应用的其余部分的。

摘要

在本章中,我继续开发了 SportsStore 文件夹,添加了对使用 RESTful web 服务器的支持,增加了应用可以处理的数据量,并添加了对结账和下订单的支持。在下一章中,我将管理特性添加到 SportsStore 应用中。

七、SportsStore:管理

在本章中,我将管理特性添加到 SportsStore 应用中,提供管理订单和产品所需的工具。我在本章中使用 GraphQL,而不是扩展我在 SportsStore 的面向客户部分使用的 RESTful web 服务。GraphQL 是传统 web 服务的一种替代方案,它让客户端控制它接收的数据,尽管它需要更多的初始设置,并且使用起来可能更复杂。

为本章做准备

本章基于在第五章创建并在第六章修改的 SportsStore 项目。为了准备本章,我将生成一些假订单,这样就有数据可以处理,如清单 7-1 所示。

小费

你可以从 https://github.com/Apress/pro-react-16 下载本章以及本书其他章节的示例项目。

var faker = require("faker");
faker.seed(100);
var categories = ["Watersports", "Soccer", "Chess"];
var products = [];
for (let i = 1; i <= 503; i++) {
    var category = faker.helpers.randomize(categories);
    products.push({
        id: i,
        name: faker.commerce.productName(),
        category: category,
        description: `${category}: ${faker.lorem.sentence(3)}`,
        price: Number(faker.commerce.price())
    })
}
var orders = [];
for (let i = 1; i <= 103; i++) {
    var fname = faker.name.firstName(); var sname = faker.name.lastName();
    var order = {
        id: i, name: `${fname} ${sname}`,
        email: faker.internet.email(fname, sname),
        address: faker.address.streetAddress(), city: faker.address.city(),
        zip: faker.address.zipCode(), country: faker.address.country(),
        shipped: faker.random.boolean(), products:[]
    }
    var productCount = faker.random.number({min: 1, max: 5});
    var product_ids = [];
    while (product_ids.length < productCount) {
        var candidateId = faker.random.number({ min: 1, max: products.length});
        if (product_ids.indexOf(candidateId) === -1) {
            product_ids.push(candidateId);
        }
    }
    for (let j = 0; j < productCount; j++) {
        order.products.push({
            quantity: faker.random.number({min: 1, max: 10}),
            product_id: product_ids[j]
        })
    }
    orders.push(order);
}

module.exports = () => ({ categories, products, orders })

Listing 7-1Altering the Application Data in the data.js File in the sportsstore Folder

运行示例应用

打开一个新的命令提示符,导航到sportsstore文件夹,运行清单 7-2 中所示的命令。

npm start

Listing 7-2Running the Example Application

React 开发工具和 RESTful web 服务将会启动。一旦开发工具编译了 SportsStore 应用,将会打开一个新的浏览器窗口,并显示图 7-1 中所示的内容。

img/473159_1_En_7_Fig1_HTML.jpg

图 7-1

运行示例应用

创建 GraphQL 服务

我在本章中添加到 SportsStore 应用的管理特性将使用 GraphQL,而不是 RESTful web 服务。很少有真正的应用需要混合 REST 和 GraphQL 来处理相同的数据,但是我想演示这两种远程服务方法。

GraphQL 并不特定于 React 开发,但它与 React 的关系如此密切,以至于我在第二十四章中介绍了 GraphQL,并在第二十五章中演示了 React 应用使用 GraphQL 服务的不同方式。

小费

我将为 SportsStore 应用创建一个定制的 GraphQL 服务器,这样我就可以与优秀的json-server包提供的 RESTful web 服务共享数据。正如我在第二十四章中解释的,有开源和商业 GraphQL 服务器可用。

定义 GraphQL 模式

GraphQL 要求它的所有操作都在一个模式中定义。为了定义服务将支持的查询模式,我在sportsstore文件夹中创建了一个名为serverQueriesSchema.graphql的文件,其内容如清单 7-3 所示。

type product { id: ID!, name: String!, description: String! category: String!
               price: Float! }

type productPage { totalSize: Int!, products(sort: String, page: Int, pageSize: Int): [product]}

type orderPage { totalSize: Int, orders(sort: String, page: Int, pageSize: Int): [order]}

type order {
    id: ID!, name: String!, email: String!, address: String!, city: String!,
    zip: String!, country: String!, shipped: Boolean, products: [productSelection]
}

type productSelection { quantity: Int!, product: product }

type Query {
    product(id: ID!): product
    products(category: String, sort: String, page: Int, pageSize: Int): productPage
    categories: [String]
    orders(onlyUnshipped: Boolean): orderPage
}

Listing 7-3The Contents of serverQueriesSchema.graphql in the sportsstore Folder

GraphQL 规范包括一种模式语言,用于定义服务提供的特性。清单 7-3 中的模式定义了对产品、类别和订单的查询。productorder查询支持分页,并返回包含一个totalSize属性的结果,该属性报告可用项目的数量,因此客户端可以向用户提供分页控件。可以按类别过滤产品,也可以过滤订单,以便只显示未发货的订单。

在 GraphQL 中,使用突变来执行更改,遵循分离读写数据操作的主题,这在 React 开发中很常见。我在sportsstore文件夹中添加了一个名为serverMutationsSchema.graphql的文件,并用它来定义清单 7-4 中显示的突变。

input productStore {
    name: String!, description: String!, category: String!, price: Float!
}

input productUpdate {
    id: ID!, name: String, description: String, category: String, price: Float
}

type Mutation {
    storeProduct(product: productStore): product
    updateProduct(product: productUpdate): product
    deleteProduct(id: ID!): product
    shipOrder(id: ID!, shipped: Boolean!): order
}

Listing 7-4The Contents of the serverMutationsSchema.graphql File in the sportsstore Folder

清单 7-4 中的模式定义了存储新产品、更新和删除现有产品以及将订单标记为已装运或未装运的变化。

定义 GraphQL 解析器

GraphQL 服务中的模式是由解析器实现的。为了给查询提供解析器,我在sportsstore文件夹中添加了一个名为serverQueriesResolver.js的文件,代码如清单 7-5 所示。

const paginateQuery = (query, page = 1, pageSize = 5) =>
    query.drop((page - 1) * pageSize).take(pageSize);

const product = ({id}, {db}) => db.get("products").getById(id).value();

const products = ({ category }, { db}) => ({
    totalSize: () => db.get("products")
        .filter(p => category ? new RegExp(category, "i").test(p.category) : p)
        .size().value(),
    products: ({page, pageSize, sort}) => {
        let query = db.get("products");
        if (category) {
            query = query.filter(item =>
                new RegExp(category, "i").test(item.category))
        }
        if (sort) { query = query.orderBy(sort) }
        return paginateQuery(query, page, pageSize).value();
    }
})

const categories = (args, {db}) => db.get("categories").value();

const resolveProducts = (products, db) =>
    products.map(p => ({
        quantity: p.quantity,
        product: product({ id: p.product_id} , {db})
    }))

const resolveOrders = (onlyUnshipped, { page, pageSize, sort}, { db }) => {
    let query = db.get("orders");
    if (onlyUnshipped) { query = query.filter({ shipped: false}) }
    if (sort) { query = query.orderBy(sort) }
    return paginateQuery(query, page, pageSize).value()
        .map(order => ({ ...order, products: () =>
            resolveProducts(order.products, db) }));
}

const orders = ({onlyUnshipped = false}, {db}) => ({
    totalSize: () => db.get("orders")
        .filter(o => onlyUnshipped ? o.shipped === false : o).size().value(),
    orders: (...args) => resolveOrders(onlyUnshipped, ...args)
})

module.exports = { product, products, categories, orders }

Listing 7-5The Contents of the serverQueriesResolver.js File in the sportsstore Folder

清单 7-5 中的代码实现了清单 7-3 中定义的查询。你可以在第二十四章看到一个独立的定制 GraphQL 服务器的例子,但是清单 7-5 中的代码依赖于 Lowdb 数据库,这个数据库是json-server包用来存储数据的,在 https://github.com/typicode/lowdb 中有详细描述。

当客户端请求特定字段时,使用一系列调用的函数来解析每个查询,确保服务器只加载和处理需要的数据。例如,对于orders查询,函数链确保服务器只需在客户机请求时查询数据库中相关的product对象,避免检索不需要的数据。

为了实现这些变化,我在sportsstore文件夹中添加了一个名为serverMutationsResolver.js的文件,并添加了清单 7-6 中所示的代码。

const storeProduct = ({ product}, {db }) =>
    db.get("products").insert(product).value();

const updateProduct = ({ product }, { db }) =>
    db.get("products").updateById(product.id, product).value();

const deleteProduct = ({ id }, { db }) => db.get("products").removeById(id).value();

const shipOrder = ({ id, shipped }, { db }) =>
    db.get("orders").updateById(id, { shipped: shipped}).value()

module.exports = {
    storeProduct, updateProduct, deleteProduct, shipOrder
}

Listing 7-6The Contents of the serverMutationsResolver.js File in the sportsstore Folder

清单 7-6 中定义的每个函数对应于清单 7-4 中定义的一个变异。实现变异所需的代码比查询简单,因为查询需要额外的语句来过滤和分页数据。

更新服务器

在第五章中,我向 SportsStore 项目添加了创建 GraphQL 服务器所需的包。在清单 7-7 中,我已经使用这些包为后端服务器添加了对 GraphQL 的支持,该服务器已经为 SportsStore 应用提供了 RESTful web 服务。

const express = require("express");
const jsonServer = require("json-server");
const chokidar = require('chokidar');
const cors = require("cors");

const fs = require("fs");

const { buildSchema } = require("graphql");

const graphqlHTTP = require("express-graphql");

const queryResolvers  = require("./serverQueriesResolver");

const mutationResolvers = require("./serverMutationsResolver");

const fileName = process.argv[2] || "./data.js"
const port = process.argv[3] || 3500;

let router = undefined;

let graph = undefined;

const app = express();

const createServer = () => {
    delete require.cache[require.resolve(fileName)];
    setTimeout(() => {
        router = jsonServer.router(fileName.endsWith(".js")
                ? require(fileName)() : fileName);
        let schema =  fs.readFileSync("./serverQueriesSchema.graphql", "utf-8")

            + fs.readFileSync("./serverMutationsSchema.graphql", "utf-8");

        let resolvers = { ...queryResolvers, ...mutationResolvers };

        graph = graphqlHTTP({

            schema: buildSchema(schema), rootValue: resolvers,

            graphiql: true, context: { db: router.db }

        })

    }, 100)
}

createServer();

app.use(cors());
app.use(jsonServer.bodyParser)
app.use("/api", (req, resp, next) => router(req, resp, next));

app.use("/graphql", (req, resp, next) => graph(req, resp, next));

chokidar.watch(fileName).on("change", () => {
    console.log("Reloading web service data...");
    createServer();
    console.log("Reloading web service data complete.");
});

app.listen(port, () => console.log(`Web service running on port ${port}`));

Listing 7-7Adding GraphQL in the server.js File in the sportsstore Folder

添加的内容加载模式和解析器,并使用它们创建一个与现有 RESTful web 服务共享数据库的 GraphQL 服务。停止开发工具,运行sportsstore文件夹中清单 7-8 所示的命令,再次启动它们,这也将启动 GraphQL 服务器。

npm start

Listing 7-8Starting the Development Tools and Services

为了确保 GraphQL 服务器正在运行,导航到http://localhost:3500/graphql,这将显示如图 7-2 所示的工具。

img/473159_1_En_7_Fig2_HTML.jpg

图 7-2

图形浏览器

我用来创建 GraphQL 服务器的包包括 GraphQL 浏览器,这使得探索 graph QL 服务变得很容易。用清单 7-9 中所示的 GraphQL 变体替换窗口左侧的欢迎消息。

注意

每次使用npm start命令 2 时,RESTful web 服务和 GraphQL 服务使用的数据都会被重置,这意味着当您下次启动服务器时,清单 7-9 中的变化所做的更改将会丢失。在第八章中,我将 SportsStore 应用转换为一个持久数据库,作为部署准备的一部分。

mutation {
    updateProduct(product: {
        id: 272, price: 100
    }) { id, name, category, price }
}

Listing 7-9A GraphQL Mutation

单击“执行查询”按钮将变异发送到 GraphQL 服务器,这将更新数据库中的产品并产生以下结果:

...
{
  "data": {
    "updateProduct": {
      "id": "272",
      "name": "Awesome Concrete Pizza",
      "category": "Soccer",
      "price": 100

    }
  }
}
...

导航回http://localhost:3000(或者重新加载浏览器标签,如果它仍然打开),你会看到列表中显示的第一个产品的价格已经改变,如图 7-3 所示。

img/473159_1_En_7_Fig3_HTML.jpg

图 7-3

GraphQL 突变的影响

创建订单管理功能

GraphQL 需要在服务器端做更多的工作来创建模式和编写解析器,但好处是客户端比使用 RESTful web 服务的客户端简单得多。在某种程度上,这是因为 GraphQL 使用定义良好但灵活的查询的方式,但也是因为 GraphQL 客户端包提供了许多有用的功能,我不得不在第 5 和 6 章手动创建这些功能。

注意

我在 SportsStore 一章中使用 GraphQL 的方式是最简单的方法,但是它隐藏了 GraphQL 如何工作的细节。在第二十五章中,我演示了如何通过 HTTP 直接使用 GraphQL,以及如何将 GraphQL 集成到使用数据存储的应用中。

定义订单表组件

我将从创建订单显示开始。为了定义显示订单数据的组件,我在src/admin文件夹中添加了一个名为OrdersTable.js的文件,并添加了清单 7-10 中所示的代码。

import React, { Component } from "react";
import { OrdersRow } from "./OrdersRow";
import { PaginationControls } from "../PaginationControls";

export class OrdersTable extends Component {

    render = () =>
        <div>
            <h4 className="bg-info text-white text-center p-2">
                { this.props.totalSize } Orders
            </h4>

            <PaginationControls keys={["ID", "Name"]}
                { ...this.props } />

            <table className="table table-sm table-striped">
                <thead>
                    <tr><th>ID</th>
                        <th>Name</th><th>Email</th>
                        <th className="text-right">Total</th>
                        <th className="text-center">Shipped</th>
                    </tr>
                </thead>
                <tbody>
                    { this.props.orders.map(order =>
                        <OrdersRow key={ order.id }
                            order={ order} toggleShipped={ () =>
                                this.props.toggleShipped(order.id, !order.shipped) }
                        />
                    )}
                </tbody>
            </table>
        </div>
}

Listing 7-10The Contents of the OrdersTable.js File in the src/admin Folder

OrdersTable组件显示订单总数,并呈现一个表格,其中每一行的责任都委托给了OrdersRow组件,我通过将一个名为OrdersRow.js的文件添加到src/admin文件夹中来定义该组件,代码如清单 7-11 所示。

import React, { Component } from "react";

export class OrdersRow extends Component {

    calcTotal = (products) => products.reduce((total, p) =>
        total += p.quantity * p.product.price, 0).toFixed(2)

    getShipping = (order) => order.shipped
        ? <i className="fa fa-shipping-fast text-success" />
        : <i className="fa fa-exclamation-circle text-danger" />

    render = () =>
        <tr>
            <td>{ this.props.order.id }</td>
            <td>{this.props.order.name}</td>
            <td>{ this.props.order.email }</td>
            <td className="text-right">
                ${ this.calcTotal(this.props.order.products) }
            </td>
            <td className="text-center">
                <button className="btn btn-sm btn-block bg-muted"
                        onClick={ this.props.toggleShipped }>
                    { this.getShipping(this.props.order )}
                    <span>
                        { this.props.order.shipped
                            ? " Shipped" : " Pending"}
                    </span>
                </button>
            </td>
        </tr>
}

Listing 7-11The Contents of the OrdersRow.js File in the src/admin Folder

定义连接器组件

当 GraphQL 客户机查询它的服务器时,它为查询定义的任何参数提供值,并指定它想要接收的数据字段。这是与大多数 RESTful web 服务的最大区别,这意味着 GraphQL 客户端只接收它们需要的数据值。但是,这意味着在从服务器检索数据之前,必须定义客户端查询。我喜欢将查询与组件分开定义,我在src/admin文件夹中添加了一个名为clientQueries.js的文件,其内容如清单 7-12 所示。

import gql from "graphql-tag";

export const ordersSummaryQuery = gql`
    query($onlyShipped: Boolean, $page:Int, $pageSize:Int, $sort:String) {
        orders(onlyUnshipped: $onlyShipped) {
            totalSize,
            orders(page: $page, pageSize: $pageSize, sort: $sort) {
                id, name, email, shipped
                products {
                    quantity, product { price }
                }
            }
        }
    }`

Listing 7-12The Contents of the clientQueries.js File in the src/admin Folder

GraphQL 查询在客户端应用中被定义为 JavaScript 字符串文字,但是必须使用来自graphql-tag包的gql函数进行处理。清单 7-12 中的查询以服务器的orders查询为目标,并将接受用于查询的onlyShippedpagepageSize,sort参数的变量。客户机查询只选择它需要的字段,并包含与每个订单相关的产品数据的详细信息,这些信息包含在服务器解析器为orders查询生成的查询结果中。

GraphQL 客户端包React-Apollo提供了graphql函数,它是前面使用的connectwithRouter函数的对应物,它通过创建一个高阶组件将一个组件连接到 GraphQL 特性,该组件是一个为组件提供特性的函数,如第十四章所述。为了在OrdersTable组件和清单 7-12 中定义的查询之间创建连接,我向src/admin文件夹添加了一个名为OrdersConnector.js的文件,并添加了清单 7-13 中所示的代码。

import { graphql } from "react-apollo";
import { ordersSummaryQuery } from "./clientQueries";
import { OrdersTable } from "./OrdersTable";

const vars = {
    onlyShipped: false, page: 1, pageSize: 10, sort: "id"
}

export const OrdersConnector = graphql(ordersSummaryQuery,
    {
        options: (props) => ({ variables: vars }),
        props: ({data: { loading, orders, refetch }}) => ({
            totalSize: loading ? 0 : orders.totalSize,
            orders: loading ? []: orders.orders,
            currentPage: vars.page,
            pageCount: loading ? 0 : Math.ceil(orders.totalSize / vars.pageSize),
            navigateToPage: (page) => { vars.page = Number(page); refetch(vars)},
            pageSize: vars.pageSize,
            setPageSize: (size) => { vars.pageSize = Number(size); refetch(vars)},
            sortKey: vars.sort,
            setSortProperty: (key) => { vars.sort = key; refetch(vars)},
        })
    }
)(OrdersTable)

Listing 7-13The Contents of the OrdersConnector.js File in the src/admin Folder

graphql函数接受查询和配置对象的参数,并返回一个用于包装组件并为其提供查询功能的函数。配置对象支持许多属性,但我只需要两个。第一个是options属性,它用于创建将应用于 GraphQL 查询的变量集,使用一个函数接收父组件应用的属性。

小费

Apollo GraphQL 客户机缓存查询结果,这样就不会向服务器发送重复的请求,例如,当使用带有路由的组件时,这就很有用。

第二个是props属性,用于创建将被传递给显示组件的属性,并提供有一个data对象,该对象组合了查询进度的详细信息、来自服务器的响应以及用于刷新查询的函数。

我从数据对象中选择了三个属性,并用它们来为OrdersTable组件创建属性。当查询被发送到服务器并等待响应时,loading属性是true,这允许我在收到 GraphQL 响应之前使用占位符值。查询的结果被分配给一个给定查询名的属性,在本例中是orders。来自查询的响应的结构如下:

...
{ "orders":
  { "totalSize":103,
    "orders":
      {"id":"1","name":"Velva Dietrich","email":"Velva_Dietrich@yahoo.com",
       "shipped":false, "products":[{"quantity":8,"product":{"price":84 },
      {"quantity":7,"product":{"price":125}, {"quantity":3,"product":{"price":352}
          ...other data values omitted for brevity...

  }
}
...

例如,为了获得可用订单的总数,我读取了orders.totalSize属性的值,如下所示:

...
totalSize: loading ? 0 : orders.totalSize,
...

在收到来自服务器的结果之前,totalSize属性的值为零,然后被赋予orders.totalSize的值。

我从data对象中选择的第三个属性是refetch,它是一个重新发送查询的函数,我用它来响应分页更改。

...
navigateToPage: (page) => { vars.page = Number(page); refetch(vars)},
...

为了简洁起见,我将所有的查询变量传递给了refetch函数,但是该函数接收到的任何值都与原始变量合并,这对于更复杂的查询来说非常有用。

小费

还有一个可用的fetchMore函数,可用于检索数据并将其与现有结果合并,这对于逐渐构建呈现给用户的数据的组件非常有用。我对 SportsStore 应用采用了一种更简单的方法,每一页数据都替换了之前的查询结果。

配置 GraphQL 客户端

对 GraphQL 客户端特性的访问是通过ApolloProvider组件提供的。为了配置 GraphQL 客户端并为其他管理特性创建一个方便的占位符,我创建了src/admin文件夹并向其中添加了一个名为Admin.js的文件,我用它来定义清单 [7-14 中所示的组件。

import React, { Component } from "react";
import  ApolloClient from "apollo-boost";
import { ApolloProvider} from "react-apollo";
import { GraphQlUrl } from "../data/Urls";
import { OrdersConnector } from "./OrdersConnector"

const graphQlClient = new ApolloClient({
    uri: GraphQlUrl
});

export class Admin extends Component {

    render() {
        return <ApolloProvider client={ graphQlClient }>
            <div className="container-fluid">
                <div className="row">
                <div className="col bg-info text-white">
                    <div className="navbar-brand">SPORTS STORE</div>
                </div>
            </div>
            <div className="row">
                <div className="col p-2">
                    <OrdersConnector />
                </div>
            </div>
        </div>
        </ApolloProvider>
    }
}

Listing 7-14The Contents of the Admin.js File in the src/admin Folder

为了开始使用管理特性,我将显示一个OrdersTable组件,我将在下一节中创建它。我将返回到Admin并使用 URL 路由来显示附加功能。为了设置将用于与 GraphQL 服务器通信的 URL,我将清单 7-15 中所示的语句添加到了Urls.js文件中。

import { DataTypes } from "./Types";

const protocol = "http";
const hostname = "localhost";
const port = 3500;

export const RestUrls = {
    [DataTypes.PRODUCTS]: `${protocol}://${hostname}:${port}/api/products`,
    [DataTypes.CATEGORIES]: `${protocol}://${hostname}:${port}/api/categories`,
    [DataTypes.ORDERS]: `${protocol}://${hostname}:${port}/api/orders`
}

export const GraphQlUrl = `${protocol}://${hostname}:${port}/graphql`;

Listing 7-15Adding a URL in the Urls.js File in the src/data Folder

GraphQL 只需要一个 URL,因为与 REST 不同,它不使用 URL 或 HTTP 方法来描述操作。在第八章中,我将在准备项目部署时更改应用使用的 URL。

为了将新特性加入到应用中,我将清单 7-16 中所示的路线添加到了App组件中。

import React, { Component } from "react";
import { SportsStoreDataStore } from "./data/DataStore";
import { Provider } from "react-redux";
import { BrowserRouter as Router, Route, Switch, Redirect }
    from "react-router-dom";
import { ShopConnector } from "./shop/ShopConnector";

import { Admin } from "./admin/Admin";

export default class App extends Component {

    render() {
        return <Provider store={ SportsStoreDataStore }>
            <Router>
                <Switch>
                    <Route path="/shop" component={ ShopConnector } />
                    <Route path="/admin" component={ Admin } />

                    <Redirect to="/shop" />
                </Switch>
            </Router>
        </Provider>
    }
}

Listing 7-16Adding a Route in the App.js File in the src Folder

将更改保存到文件并导航到http://localhost:3000/admin,您将看到如图 7-4 所示的结果。

img/473159_1_En_7_Fig4_HTML.jpg

图 7-4

从组件生成 GraphQL 查询

配置突变

相同的查询基本方法可以应用于将突变集成到 React 应用中。为了允许管理员将订单标记为已发货,我在src/admin文件夹中添加了一个名为clientMutations.js的文件,其内容如清单 7-17 所示。

import gql from "graphql-tag";

export const shipOrder = gql`
    mutation($id: ID!, $shipped: Boolean!) {
        shipOrder(id: $id, shipped: $shipped) {
            id, shipped
        }
    }`

Listing 7-17The Contents of the clientMutations.js File in the src/admin Folder

GraphQL 的目标是shipOrder突变,它更新由订单的id属性的值指定的订单的shipped属性。在清单 7-18 中,我使用了graphql函数来提供对突变及其结果的访问。

import { graphql, compose } from "react-apollo";

import { ordersSummaryQuery } from "./clientQueries";
import { OrdersTable } from "./OrdersTable";

import { shipOrder } from "./clientMutations";

const vars = {
    onlyShipped: false, page: 1, pageSize: 10, sort: "id"
}

export const OrdersConnector = compose(

    graphql(ordersSummaryQuery,
        {
            options: (props) => ({ variables: vars }),
            props: ({data: { loading, orders, refetch }}) => ({
                totalSize: loading ? 0 : orders.totalSize,
                orders: loading ? []: orders.orders,
                currentPage: vars.page,
                pageCount: loading ? 0 : Math.ceil(orders.totalSize / vars.pageSize),
                navigateToPage: (page) => { vars.page = Number(page); refetch(vars)},
                pageSize: vars.pageSize,
                setPageSize: (size) =>
                    { vars.pageSize = Number(size); refetch(vars)},
                sortKey: vars.sort,
                setSortProperty: (key) => { vars.sort = key; refetch(vars)},
            })
        }
    ),
    graphql(shipOrder, {

        props: ({ mutate }) => ({

            toggleShipped: (id, shipped) => mutate({ variables: { id, shipped }})

        })

    })

)(OrdersTable);

Listing 7-18Applying a Mutation in the OrdersConnector.js File in the src/admin Folder

React-Apollo 包提供了简化查询和变异组合的compose函数。现有的查询与对graphql函数的另一个调用相结合,该函数被传递了清单 7-17 中的变异。当使用变异时,配置对象中的props属性接收一个名为mutate的函数,我用它来创建一个名为toggleShipped的属性,对应于OrdersRow组件用来更改订单状态的属性。要查看结果,单击表格中订单的已发货/待处理指示器,其状态将会改变,如图 7-5 所示。

img/473159_1_En_7_Fig5_HTML.jpg

图 7-5

利用突变

当有变化时,Apollo 客户机自动更新其数据缓存,这意味着 shipped 属性的值的变化会自动反映在由OrdersTable组件显示的数据中。

创建产品管理功能

为了管理呈现给用户的产品,我在src/admin文件夹中添加了一个名为ProductsTable.js的文件,并用它来定义清单 7-19 中所示的组件。

import React, { Component } from "react";
import { Link } from "react-router-dom";
import { PaginationControls } from "../PaginationControls";
import { ProductsRow } from "./ProductsRow";

export class ProductsTable extends Component {

    render = () =>
         <div>
            <h4 className="bg-info text-white text-center p-2">
                { this.props.totalSize } Products
            </h4>

        <PaginationControls keys={["ID", "Name", "Category"]}
            { ...this.props } />

        <table className="table table-sm table-striped">
            <thead>
                <tr><th>ID</th>
                    <th>Name</th><th>Category</th>
                    <th className="text-right">Price</th>
                    <th className="text-center"></th>
                </tr>
            </thead>
            <tbody>
                { this.props.products.map(prod =>
                    <ProductsRow key={ prod.id} product={ prod }
                        deleteProduct={ this.props.deleteProduct } />
                )}
            </tbody>
        </table>
        <div className="text-center">
            <Link to="/admin/products/create" className="btn btn-primary">
                Create Product
            </Link>
        </div>
    </div>
}

Listing 7-19The Contents of the ProductsTable.js File in the src/admin Folder

ProductsTable组件通过其products属性接收一个对象数组,并使用ProductsRow组件为每个对象生成一个表格行。还有一个Link按钮,用于导航到允许创建新产品的组件。

为了创建负责单个表格行的ProductsRow组件,我在src/admin文件夹中添加了一个名为ProductsRow.js的文件,并添加了清单 7-20 中所示的代码。

import React, { Component } from "react";
import { Link } from "react-router-dom";

export class ProductsRow extends Component {

    render = () =>
        <tr>
            <td>{ this.props.product.id }</td>
            <td>{this.props.product.name}</td>
            <td>{ this.props.product.category }</td>
            <td className="text-right">
                ${ this.props.product.price.toFixed(2) }
            </td>
            <td className="text-center">
                <button className="btn btn-sm btn-danger mx-1"
                    onClick={ () =>
                        this.props.deleteProduct(this.props.product.id) }>
                            Delete
                </button>
                <Link to={`/admin/products/${this.props.product.id}`}
                    className="btn btn-sm btn-warning">
                        Edit
                </Link>
            </td>
        </tr>
}

Listing 7-20The Contents of the ProductsRow.js File in the src/admin Folder

idnamecategoryprice属性呈现表格单元格。有一个button调用一个名为deleteProduct的函数 prop,它将从数据库中删除一个产品,还有一个Link将导航到用于编辑产品细节的组件。

连接产品表组件

为了将产品表组件连接到 GraphQL 数据,我将清单 7-21 中所示的查询添加到了clientQueries.js文件中,其中还包括编辑产品所需的查询。这些查询对应于本章开始时定义的服务器端 GraphQL。

import gql from "graphql-tag";

export const ordersSummaryQuery = gql`
    query($onlyShipped: Boolean, $page:Int, $pageSize:Int, $sort:String) {
        orders(onlyUnshipped: $onlyShipped) {
            totalSize,
            orders(page: $page, pageSize: $pageSize, sort: $sort) {
                id, name, email, shipped
                products {
                    quantity, product { price }
                }
            }
        }
    }`

export const productsList = gql`

    query($page: Int, $pageSize: Int, $sort: String) {

        products {

            totalSize,

            products(page: $page, pageSize: $pageSize, sort: $sort) {

                id, name, category, price

            }

        }

    }`

export const product = gql`

    query($id: ID!) {

        product(id: $id) {

            id, name, description, category, price

        }

    }`

Listing 7-21Adding Queries in the clientQueries.js File in the src/admin Folder

分配给名为productsList的常量的查询将检索一页产品的idnamecategoryprice属性。分配给名为product的常量的查询将检索单个product对象的idnamedescriptioncategoryprice属性。为了添加对删除、创建和编辑对象的支持,我将清单 7-22 中所示的变化添加到了clientMutations.js文件中。

import gql from "graphql-tag";

export const shipOrder = gql`
    mutation($id: ID!, $shipped: Boolean!) {
        shipOrder(id: $id, shipped: $shipped) {
            id, shipped
        }
    }`

export const storeProduct = gql`

    mutation($product: productStore) {

        storeProduct(product: $product) {

            id, name, category, description, price

        }

    }`

export const updateProduct = gql`

    mutation($product: productUpdate) {

        updateProduct(product: $product) {

            id, name, category, description, price

        }

    }`

export const deleteProduct = gql`

    mutation($id: ID!) {

        deleteProduct(id: $id) {

            id

        }

    }`

Listing 7-22Adding Mutations in the clientMutations.js File in the src/admin Folder

新的变化对应于本章开始时定义的服务器端 GraphQL,并允许客户端存储新产品、编辑现有产品和删除产品。

定义了查询和突变之后,我将一个名为ProductsConnector.js的文件添加到src/admin文件夹中,并定义了清单 7-23 中所示的高阶组件。

import { graphql, compose } from "react-apollo";
import { ProductsTable } from "./ProductsTable";
import { productsList } from "./clientQueries";
import { deleteProduct } from "./clientMutations";

const vars = {
    page: 1, pageSize: 10, sort: "id"
}

export const ConnectedProducts = compose(
    graphql(productsList,
        {
            options: (props) => ({ variables: vars }),
            props: ({data: { loading, products, refetch }}) => ({
                totalSize: loading ? 0 : products.totalSize,
                products: loading ? []: products.products,
                currentPage: vars.page,
                pageCount: loading ? 0
                    : Math.ceil(products.totalSize / vars.pageSize),
                navigateToPage: (page) => { vars.page = Number(page); refetch(vars)},
                pageSize: vars.pageSize,
                setPageSize: (size) =>
                    { vars.pageSize = Number(size); refetch(vars)},
                sortKey: vars.sort,
                setSortProperty: (key) => { vars.sort = key; refetch(vars)},
            })
        }
    ),
    graphql(deleteProduct,
        {
            options: {
                update: (cache, { data: { deleteProduct: { id }}}) => {
                    const queryDetails = { query: productsList, variables: vars };
                    const data = cache.readQuery(queryDetails)
                    data.products.products =
                        data.products.products.filter(p => p.id !== id);
                    data.products.totalSize = data.products.totalSize - 1;
                    cache.writeQuery({...queryDetails, data });
                }
        },
        props: ({ mutate }) => ({
            deleteProduct: (id) => mutate({ variables: { id }})
        })
    })
)(ProductsTable);

Listing 7-23The Contents of the ProductsConnector.js File in the src/admin Folder

清单 7-23 中的代码类似于订单管理特性的相应代码。一个关键的区别是删除对象的突变不会自动更新本地缓存的数据。对于这种类型的变异,必须定义一个直接修改缓存数据的update函数,如下所示:

...
update: (cache, { data: { deleteProduct: { id }}}) => {
    const queryDetails = { query: productsList, variables: vars };
    const data = cache.readQuery(queryDetails)
    data.products.products = data.products.products.filter(p => p.id !== id);
    data.products.totalSize = data.products.totalSize - 1;
    cache.writeQuery({...queryDetails, data });
}
...

这个函数读取缓存的数据,删除一个对象,减少totalSize以反映删除,然后将数据写回缓存,这样会有更新产品列表的效果,而不需要查询服务器。

小费

这种方法的缺点是,它不会对数据重新分页以反映删除,这意味着在用户导航到另一个页面之前,页面显示的项目较少。在下一节中,我将演示如何通过清除缓存数据来解决这个问题,这将导致一个额外的 GraphQL 查询,但可以确保应用的一致性。

创建编辑器组件

为了允许用户创建新产品,我在src/admin文件夹中添加了一个名为ProductEditor.js的文件,并定义了清单 7-24 中所示的组件。

import React, { Component } from "react";
import { Query } from "react-apollo";
import { ProductCreator } from "./ProductCreator";
import { product } from "./clientQueries";

export class ProductEditor extends Component {

    render = () =>
        <Query query={ product } variables={ {id: this.props.match.params.id} } >
            { ({ loading, data }) => {
                if (!loading) {
                    return <ProductCreator {...this.props } product={data.product}
                        mode="edit" />
                }
                return null;
            }}
        </Query>
}

Listing 7-24The Contents of the ProductEditor.js File in the src/admin Folder

Query组件是作为graphql函数的替代提供的,它允许以声明方式执行 GraphQL 查询,结果和其他客户端特性通过渲染属性函数呈现,这将在第十四章中描述。清单 7-24 中定义的ProductEditor组件将获取管理员想要编辑的产品的id,并使用Query组件获取,该组件使用其queryvariables属性进行配置。render prop 函数接收一个具有loadingdata属性的对象,其目的与我之前使用的graphql函数相同。当 loading 属性为true时,ProductEditor组件不呈现任何内容,然后显示一个ProductCreator组件,通过名为product的属性传递从查询中接收的数据。

ProductCreator组件将在 SportsStore 应用中执行双重任务。当单独使用时,它将向管理员呈现一个空表单,该表单将被发送到storeProduct突变。当被ProductEditor组件使用时,它将显示现有产品的详细信息,并将表单数据发送给updateProduct变异。为了定义组件,我用清单 7-25 所示的代码向src/admin文件夹添加了一个名为ProductCreator.js的文件。

import React, { Component } from "react";
import { ValidatedForm } from "../forms/ValidatedForm";
import { Mutation } from "react-apollo";
import { storeProduct, updateProduct } from "./clientMutations";

export class ProductCreator extends Component {

    constructor(props) {
        super(props);
        this.defaultAttrs = { type: "text", required: true };
        this.formModel = [
            { label: "Name" }, { label: "Description" },
            { label: "Category" },
            { label: "Price", attrs: { type: "number"}}
        ];
        this.mutation = storeProduct;
        if (this.props.mode === "edit" ) {
            this.mutation = updateProduct;
            this.formModel = [ { label: "Id", attrs: { disabled: true }},
                     ...this.formModel]
                .map(item => ({ ...item, attrs: { ...item.attrs,
                    defaultValue: this.props.product[item.label.toLowerCase()]} }));
        }
    }

    navigate = () => this.props.history.push("/admin/products");

    render = () => {
        return <div className="container-fluid">
            <div className="row">
                <div className="col bg-dark text-white">
                    <div className="navbar-brand">SPORTS STORE</div>
                </div>
            </div>
            <div className="row">
                <div className="col m-2">
                    <Mutation mutation={ this.mutation }>
                        { (saveMutation, {client }) => {
                            return <ValidatedForm formModel={ this.formModel }
                                defaultAttrs={ this.defaultAttrs }
                                submitCallback={ data => {
                                    saveMutation({variables: { product:
                                        { ...data, price: Number(data.price) }}});
                                    if (this.props.mode !== "edit" ) {
                                        client.resetStore();
                                    }
                                    this.navigate();
                                }}
                                cancelCallback={ this.navigate }
                                submitText="Save" cancelText="Cancel" />
                        }}
                    </Mutation>
                </div>
            </div>
        </div>
    }
}

Listing 7-25The Contents of the ProductCreator.js File in the src/admin Folder

ProductCreator组件依赖于我在第六章中创建的ValidatedForm来处理应用购物部分的结账。该表单配置了编辑产品所需的字段,当通过product prop 提供时,这些字段将包括从 GraphQL 查询中获得的值。

Query组件相对应的是Mutation,它允许在render函数中使用变异。render prop 函数接收一个函数,该函数被调用以将突变发送到服务器,并接受一个为突变提供变量的对象,如下所示:

...
<Mutation mutation={ this.mutation }>
    { (saveMutation, {client }) => {
        return <ValidatedForm formModel={ this.formModel }
            defaultAttrs={ this.defaultAttrs }
            submitCallback={ data => {

                saveMutation({variables: { product:

                    { ...data, price: Number(data.price) }}});

                    if (this.props.mode !== "edit" ) {

                        client.resetStore();

                    }

                    this.navigate();

                }}

                cancelCallback={ this.navigate }
                submitText="Save" cancelText="Cancel" />
        }
    }
</Mutation>
...

我突出显示了设置函数 prop 的代码部分,该函数 prop 被传递给ValidatedForm组件,并在被调用时发送变异。当一个对象被更新时,Apollo 客户机自动更新它的缓存数据以反映这一变化,就像我在本章前面将订单标记为已发货一样。新对象不会被自动处理,这意味着应用必须负责管理缓存。我删除一个对象的方法是更新现有的缓存,但是对于一个新的条目来说,这是一个复杂得多的过程,因为这意味着要确定它是否应该显示在当前页面上,如果应该,它应该出现在排序顺序中的什么位置。作为一个更简单的选择,我从 render prop 函数接收了一个client参数,它允许我通过它的resetStore方法清除缓存的数据。当navigate函数将浏览器发送回产品列表时,一个新的 GraphQL 将被发送到服务器,这确保了数据被一致地分页和排序,尽管代价是额外的查询。

更新路由配置

最后一步是更新路由配置,添加允许选择订单和产品管理特性的导航按钮,如清单 7-26 所示。

import React, { Component } from "react";
import  ApolloClient from "apollo-boost";
import { ApolloProvider} from "react-apollo";
import { GraphQlUrl } from "../data/Urls";
import { OrdersConnector } from "./OrdersConnector"

import { Route, Redirect, Switch } from "react-router-dom";

import { ToggleLink } from "../ToggleLink";

import { ConnectedProducts } from "./ProductsConnector";

import { ProductEditor } from "./ProductEditor";

import { ProductCreator } from "./ProductCreator";

const graphQlClient = new ApolloClient({
    uri: GraphQlUrl
});

export class Admin extends Component {

    render() {
        return <ApolloProvider client={ graphQlClient }>
            <div className="container-fluid">
                <div className="row">
                <div className="col bg-info text-white">
                    <div className="navbar-brand">SPORTS STORE</div>
                </div>
            </div>
            <div className="row">
                <div className="col-3 p-2">

                    <ToggleLink to="/admin/orders">Orders</ToggleLink>

                    <ToggleLink to="/admin/products">Products</ToggleLink>

                </div>

                <div className="col-9 p-2">

                    <Switch>

                        <Route path="/admin/orders" component={ OrdersConnector } />

                        <Route path="/admin/products/create"

                            component={ ProductCreator} />

                        <Route path="/admin/products/:id"

                            component={ ProductEditor} />

                        <Route path="/admin/products"

                            component={ ConnectedProducts } />

                        <Redirect to="/admin/orders" />

                    </Switch>

                </div>
            </div>
        </div>
        </ApolloProvider>
    }
}

Listing 7-26Updating the Routing Configuration in the Admin.js File in the src/admin Folder

保存更改,您将看到如图 7-6 所示的布局。单击“产品”按钮将显示一个分页的产品表,可以使用每个表行中的按钮删除和编辑这些产品。

img/473159_1_En_7_Fig6_HTML.jpg

图 7-6

产品管理功能

点击创建产品按钮将显示一个编辑器,允许定义新产品,如图 7-7 所示。

img/473159_1_En_7_Fig7_HTML.jpg

图 7-7

创造新产品

摘要

在本章中,我向 SportsStore 应用添加了管理功能。我首先创建了一个 GraphQL 服务,其中包含管理订单和产品数据所需的查询和变化。我使用 GraphQL 服务来扩展应用特性,依靠 GraphQL 客户端来管理应用中的数据,这样我就不需要创建和管理数据存储。在下一章中,我将为管理特性添加身份验证,并为应用的部署做准备。

八、SportsStore:认证和部署

在本章中,我向 SportsStore 应用添加了身份验证,以保护管理功能免受未经授权的使用。我还准备将 SportsStore 应用部署到 Docker 容器中,该容器可以在大多数托管平台上使用。

为本章做准备

为了准备本章,我将向提供 RESTful web 服务和 GraphQL 服务的简单服务器添加对认证和授权的支持。目前,任何客户端都可以执行任何操作,这意味着购物者可以改变价格,创建产品,以及执行其他应该仅限于管理员的任务。表 8-1 列出了应该可以公开访问的 HTTP 方法和 URL 的组合;其他一切都将受到保护,包括所有 GraphQL 查询和变异。

表 8-1

可公开访问的 HTTP 方法和 URL 组合

|

HTTP 方法

|

统一资源定位器

|

描述

|
| --- | --- | --- |
| GET | /api/products | 该组合用于为购物者请求产品页面。 |
| GET | /api/categories | 该组合用于请求类别集,并用于为购物者提供导航按钮。 |
| POST | /api/orders | 此组合用于提交订单。 |
| POST | /login | 该组合将用于提交用户名和密码进行身份验证。 |

小费

你可以从 https://github.com/Apress/pro-react-16 下载本章以及本书其他章节的示例项目。

为了实现认证并提供授权的方法,我在sportsstore文件夹中添加了一个名为authMiddleware.js的文件,并添加了清单 8-1 中所示的代码。

const jwt = require("jsonwebtoken");

const APP_SECRET = "myappsecret", USERNAME = "admin", PASSWORD = "secret";

const anonOps = [{ method: "GET", urls: ["/api/products", "/api/categories"]},
                 { method: "POST", urls: ["/api/orders"]}]

module.exports = function (req, res, next) {
    if (anonOps.find(op => op.method === req.method
            && op.urls.find(url => req.url.startsWith(url)))) {
        next();
    } else if (req.url === "/login" && req.method === "POST") {
        if (req.body.username === USERNAME && req.body.password === PASSWORD) {
            res.json({
                success: true,
                token: jwt.sign({ data: USERNAME, expiresIn: "1h" }, APP_SECRET)
            });
        } else {
            res.json({ success: false });
        }
        res.end();
    } else {
        let token = req.headers["authorization"];
        if (token != null && token.startsWith("Bearer<")) {
            token = token.substring(7, token.length - 1);
            jwt.verify(token, APP_SECRET);
            next();
        } else {
            res.statusCode = 401;
            res.end();
        }
    }
}

Listing 8-1The Contents of the authMiddleware.js File in the sportsstore Folder

清单 8-1 中的代码将检查 HTTP 服务器收到的每个请求,该服务器提供 RESTful web 服务和 GraphQL 服务。如果请求不是针对 HTTP 方法和 URL 的不安全组合之一,则返回 401 未授权响应。/login URL 用于认证,硬连线凭证如表 8-2 所示。

表 8-2

SportsStore 应用使用的凭据

|

名字

|

描述

|
| --- | --- |
| name | admin |
| password | secret |

警告

SportsStore 项目中的所有服务器端代码都可以用于实际项目,除了清单 8-1 ,它包含硬编码的凭证,不适合除基本开发和测试之外的任何事情。

为了将中间件添加到服务器,我将清单 8-2 中所示的语句添加到server.js文件中。

const express = require("express");
const jsonServer = require("json-server");
const chokidar = require('chokidar');
const cors = require("cors");
const fs = require("fs");
const { buildSchema } = require("graphql");
const graphqlHTTP = require("express-graphql");
const queryResolvers  = require("./serverQueriesResolver");
const mutationResolvers = require("./serverMutationsResolver");

const auth = require("./authMiddleware");

const fileName = process.argv[2] || "./data.js"
const port = process.argv[3] || 3500;

let router = undefined;
let graph = undefined;

const app = express();

const createServer = () => {
    delete require.cache[require.resolve(fileName)];
    setTimeout(() => {
        router = jsonServer.router(fileName.endsWith(".js")
                ? require(fileName)() : fileName);
        let schema =  fs.readFileSync("./serverQueriesSchema.graphql", "utf-8")
            + fs.readFileSync("./serverMutationsSchema.graphql", "utf-8");
        let resolvers = { ...queryResolvers, ...mutationResolvers };
        graph = graphqlHTTP({
            schema: buildSchema(schema), rootValue: resolvers,
            graphiql: true, context: { db: router.db }
        })
    }, 100)
}

createServer();

app.use(cors());
app.use(jsonServer.bodyParser)

app.use(auth);

app.use("/api", (req, resp, next) => router(req, resp, next));
app.use("/graphql", (req, resp, next) => graph(req, resp, next));

chokidar.watch(fileName).on("change", () => {
    console.log("Reloading web service data...");
    createServer();
    console.log("Reloading web service data complete.");
});

app.listen(port, () => console.log(`Web service running on port ${port}`));

Listing 8-2Adding Middleware in the server.js File in the sportsstore Folder

打开一个新的命令提示符,导航到sportsstore文件夹,运行清单 8-3 中所示的命令,启动 React 开发工具、RESTful web 服务和 GraphQL 服务。

npm start

Listing 8-3Starting the Development Tool and Web Services

项目编译完成后,将会打开一个新的浏览器窗口,显示 SportsStore 购物功能,如图 8-1 所示。

img/473159_1_En_8_Fig1_HTML.jpg

图 8-1

运行示例应用

为 GraphQL 请求添加身份验证

身份验证中间件的引入破坏了管理特性,这些特性依赖于不再公开访问的 HTTP 请求。如果您导航到http://localhost:3000/admin,您将看到服务器对 GraphQL HTTP 请求做出的 401-未授权响应的效果,如图 8-2 所示。

img/473159_1_En_8_Fig2_HTML.jpg

图 8-2

遇到错误

在接下来的部分中,我将解释 SportsStore 应用将如何对用户进行身份验证并实现所需的功能,以防止出现图中所示的错误,并为经过身份验证的用户恢复管理功能。

了解认证系统

当服务器对用户进行身份验证时,它将返回一个 JSON Web 令牌(JWT ),应用必须将该令牌包含在后续的 HTTP 请求中,以表明身份验证已经成功执行。您可以在 https://tools.ietf.org/html/rfc7519 阅读 JWT 规范,但是对于 SportsStore 项目来说,只要知道应用可以通过向/login URL 发送 POST 请求来验证用户就足够了,在请求体中包含一个 JSON 格式的对象,该对象包含名称和密码属性。在清单 8-1 中定义的验证码中只有一组有效凭证,我在表 8-3 中已经重复了。您不应该在实际项目中对凭证进行硬编码,但这是 SportsStore 应用需要的用户名和密码。

表 8-3

RESTful Web 服务支持的身份验证凭证

|

用户名

|

密码

|
| --- | --- |
| admin | secret |

如果正确的凭证被发送到/login URL,那么来自服务器的响应将包含一个 JSON 对象,如下所示:

{
  "success": true,
  "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjoiYWRtaW4iLCJleHBpcmVz
           SW4iOiIxaCIsImlhdCI6MTQ3ODk1NjI1Mn0.lJaDDrSu-bHBtdWrz0312p_DG5tKypGv6cA
           NgOyzlg8"
}

success属性描述认证操作的结果,而token属性包含 JWT,它应该包含在使用Authorization HTTP 头的后续请求中,格式如下:

Authorization: Bearer<eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjoiYWRtaW4iLC
                     JleHBpcmVzSW4iOiIxaCIsImlhdCI6MTQ3ODk1NjI1Mn0.lJaDDrSu-bHBtd
                     Wrz0312p_DG5tKypGv6cANgOyzlg8>

我配置了服务器返回的 JWT 令牌,使它们在一小时后过期。

如果向服务器发送了错误的凭证,那么响应中返回的 JSON 对象将只包含一个设置为falsesuccess属性,如下所示:

{
  "success": false
}

创建身份验证上下文

SportsStore 应用需要能够确定用户是否已经过身份验证,并跟踪必须包含在 HTTP 请求中的 web 令牌,确保只有在成功通过身份验证后才显示管理功能。

这是应用中多个地方经常需要的信息类型,以确保组件可以轻松协作。对于 SportsStore 应用,我将使用 React 上下文特性,该特性允许以一种简单和轻量级的方式在组件之间轻松共享功能,这在第十四章中有所描述。我创建了src/auth文件夹,并添加了一个名为AuthContext.js的文件,代码如清单 8-4 所示。

import React from "react";

export const AuthContext = React.createContext({
    isAuthenticated: false,
    webToken: null,
    authenticate: (username, password) => {},
    signout: () => {}
})

Listing 8-4The Contents of the AuthContext.js File in the src/auth Folder

React.createContext方法用于创建上下文,它接收的对象用于默认值,这就是为什么authenticatesignout函数是空的。上下文的真正功能由提供者组件提供,我通过在src/auth文件夹中创建一个名为AuthProviderImpl.js的文件并添加清单 8-5 中所示的代码来定义它。

import React, { Component } from "react";
import Axios from "axios";
import { AuthContext } from "./AuthContext";
import { authUrl } from "../data/Urls";

export class AuthProviderImpl extends Component {

    constructor(props) {
        super(props);
        this.state = {
            isAuthenticated: false,
            webToken: null
        }
    }

    authenticate = (credentials) => {
        return Axios.post(authUrl,  credentials).then(response => {
            if (response.data.success === true) {
                this.setState({
                    isAuthenticated: true,
                    webToken:response.data.token
                })
                return true;
            } else {
                throw new Error("Invalid Credentials");
            }
        })
    }

    signout = () => {
        this.setState({ isAuthenticated: false, webToken: null });
    }

    render = () =>
        <AuthContext.Provider value={ {...this.state,
                authenticate: this.authenticate, signout: this.signout}}>
            { this.props.children }
        </AuthContext.Provider>
}

Listing 8-5The Contents of the AuthProviderImpl.js File in the src/auth Folder

该组件在其 render 方法中使用 React 上下文特性来提供AuthContext属性和功能的实现,这是通过特殊的AuthContext.Provider元素的value属性来实现的。其效果是将对状态数据以及authenticatesignout方法的访问权直接共享给任何应用了相应的AuthContext.Consumer元素的后代组件,我很快就会用到这个元素。

authenticate方法的实现使用 Axios 包发送 POST 请求,以验证将从用户处获得的凭证。authenticate 方法的结果是一个Promise,当服务器响应确认凭证时将解析该结果,如果凭证不正确,将拒绝该结果。

为了定义用于执行身份验证的 URL,我添加了清单 8-6 中所示的 URL。

import { DataTypes } from "./Types";

const protocol = "http";
const hostname = "localhost";
const port = 3500;

export const RestUrls = {
    [DataTypes.PRODUCTS]: `${protocol}://${hostname}:${port}/api/products`,
    [DataTypes.CATEGORIES]: `${protocol}://${hostname}:${port}/api/categories`,
    [DataTypes.ORDERS]: `${protocol}://${hostname}:${port}/api/orders`
}

export const GraphQlUrl = `${protocol}://${hostname}:${port}/graphql`;

export const authUrl = `${protocol}://${hostname}:${port}/login`;

Listing 8-6Adding a URL in the Urls.js File in the src/data Folder

为了将上下文应用于 SportsStore 应用,我对文件App.js进行了清单 8-7 中所示的更改。

import React, { Component } from "react";
import { SportsStoreDataStore } from "./data/DataStore";
import { Provider } from "react-redux";
import { BrowserRouter as Router, Route, Switch, Redirect }
    from "react-router-dom";
import { ShopConnector } from "./shop/ShopConnector";
import { Admin } from "./admin/Admin";

import { AuthProviderImpl } from "./auth/AuthProviderImpl";

export default class App extends Component {

    render() {
        return <Provider store={ SportsStoreDataStore }>
            <AuthProviderImpl>

                <Router>
                    <Switch>
                        <Route path="/shop" component={ ShopConnector } />
                        <Route path="/admin" component={ Admin } />
                        <Redirect to="/shop" />
                    </Switch>
                </Router>
            </AuthProviderImpl>

        </Provider>
    }
}

Listing 8-7Adding a Context Provider to the App.js File in the src Folder

为了更容易使用由AuthContext定义的特性,我在src/auth文件夹中添加了一个名为AuthWrapper.js的文件,并定义了清单 8-8 中所示的高阶组件。

import React, { Component } from "react";
import { AuthContext } from "./AuthContext";

export const authWrapper = (WrappedComponent) =>
    class extends Component {
        render = () =>
            <AuthContext.Consumer>
                { context =>
                    <WrappedComponent { ...this.props } { ...context } />
                }
            </AuthContext.Consumer>
    }

Listing 8-8The Contents of the AuthWrapper.js File in the src/auth Folder

上下文功能依赖于 render prop 函数,该函数很难直接集成到组件中。使用authWrapper函数将允许组件接收由AuthContext定义的特性作为属性。(高阶组件和渲染属性功能都在第十四章中描述。)

创建身份验证表单

为了允许用户提供他们的凭证,我在src/auth文件夹中添加了一个名为AuthPrompt.js的文件,并用它来定义清单 8-9 中所示的组件。

import React, { Component } from "react";
import { withRouter } from "react-router-dom";
import { authWrapper } from "./AuthWrapper";
import { ValidatedForm } from "../forms/ValidatedForm";

export const AuthPrompt = withRouter(authWrapper(class extends Component {

    constructor(props) {
        super(props);
        this.state = {
            errorMessage: null
        }
        this.defaultAttrs = { required: true };
        this.formModel = [
            { label: "Username", attrs: { defaultValue: "admin"}},
            { label: "Password", attrs: { type: "password"} },
        ];
    }

    authenticate = (credentials) => {
        this.props.authenticate(credentials)
            .catch(err => this.setState({ errorMessage: err.message}))
            .then(this.props.history.push("/admin"));
    }

    render = () =>
        <div className="container-fluid">
            <div className="row">
                <div className="col bg-dark text-white">
                    <div className="navbar-brand">SPORTS STORE</div>
                </div>
            </div>
            <div className="row">
                <div className="col m-2">
                    { this.state.errorMessage != null &&
                        <h4 className="bg-danger text-center text-white m-1 p-2">
                            { this.state.errorMessage }
                        </h4>
                    }
                    <ValidatedForm formModel={ this.formModel }
                        defaultAttrs={ this.defaultAttrs }
                        submitCallback={ this.authenticate }
                        submitText="Login"
                        cancelCallback={ () => this.props.history.push("/")}
                        cancelText="Cancel"
                    />
                </div>
            </div>
        </div>
}))

Listing 8-9The Contents of the AuthPrompt.js File in the src/auth Folder

该组件从withRouter函数接收路由特性,从authWrapper函数接收认证特性,这两个特性都将通过组件的 props 呈现。我在第六章中定义的ValidatedForm用于向用户显示用户名和密码字段,这两个字段都需要值。当提交表单数据时,authenticate方法会转发详细信息进行身份验证。如果认证成功,那么由 URL 路由系统提供的history对象(在章节 21 和 22 中描述)用于将用户重定向到/admin URL。如果身份验证失败,将显示一条错误消息。

保护身份验证功能

为了防止在用户通过身份验证之前访问管理特性,我对Admin组件进行了清单 8-10 中所示的更改。

import React, { Component } from "react";
import  ApolloClient from "apollo-boost";
import { ApolloProvider} from "react-apollo";
import { GraphQlUrl } from "../data/Urls";
import { OrdersConnector } from "./OrdersConnector"
import { Route, Redirect, Switch } from "react-router-dom";
import { ToggleLink } from "../ToggleLink";
import { ConnectedProducts } from "./ProductsConnector";
import { ProductEditor } from "./ProductEditor";
import { ProductCreator } from "./ProductCreator";

import { AuthPrompt } from "../auth/AuthPrompt";

import { authWrapper } from "../auth/AuthWrapper";

// const graphQlClient = new ApolloClient({

//     uri: GraphQlUrl

// });

export const Admin = authWrapper(class extends Component {

    constructor(props) {

        super(props);

        this.client = new ApolloClient({

            uri: GraphQlUrl,

            request: gqloperation => gqloperation.setContext({

                headers: {

                    Authorization: `Bearer<${this.props.webToken}>`

                },

            })

        })

    }

    render() {
        return <ApolloProvider client={ this.client }>

            <div className="container-fluid">
                <div className="row">
                <div className="col bg-info text-white">
                    <div className="navbar-brand">SPORTS STORE</div>
                </div>
            </div>
            <div className="row">
                <div className="col-3 p-2">
                    <ToggleLink to="/admin/orders">Orders</ToggleLink>
                    <ToggleLink to="/admin/products">Products</ToggleLink>
                    { this.props.isAuthenticated &&

                        <button onClick={ this.props.signout }

                            className=

                                "btn btn-block btn-secondary m-2 fixed-bottom col-3">

                            Log Out

                        </button>

                    }

                </div>
                <div className="col-9 p-2">
                    <Switch>
                        {

                            !this.props.isAuthenticated &&

                                <Route component={ AuthPrompt } />

                        }

                        <Route path="/admin/orders" component={ OrdersConnector } />
                        <Route path="/admin/products/create"
                            component={ ProductCreator} />
                        <Route path="/admin/products/:id"
                            component={ ProductEditor} />
                        <Route path="/admin/products"
                            component={ ConnectedProducts } />
                        <Redirect to="/admin/orders" />
                    </Switch>
                </div>
            </div>
        </div>
        </ApolloProvider>
    }
})

Listing 8-10Guarding Features in the Admin.js File in the src/admin Folder

Admin组件由authWrapper函数包装,因此它可以访问认证特性。在构造函数中创建了ApolloClient对象,这样我就可以添加一个函数来修改每个请求,为每个 GraphQL HTTP 请求添加一个Authorization头。

render方法中有两个新的代码片段。如果用户通过了身份验证,第一个会显示一个注销按钮。第二个片段检查身份验证状态,并生成一个显示AuthPrompt组件的Route组件,而不考虑 URL。(没有path属性的Route组件将始终显示其组件,并可与Switch一起使用,以防止其他Route组件被评估。)

为管理功能添加导航链接

为了更容易使用管理特性,我给CategoryNavigation组件添加了一个Link,如清单 8-11 所示。

import React, { Component } from "react";
import { ToggleLink } from "../ToggleLink";

import { Link } from "react-router-dom";

export class CategoryNavigation extends Component {

    render() {
        return <React.Fragment>
            <ToggleLink to={ `${this.props.baseUrl}/all` } exact={ false }>
                All
            </ToggleLink>
            { this.props.categories && this.props.categories.map(cat =>
                <ToggleLink key={ cat }
                    to={ `${this.props.baseUrl}/${cat.toLowerCase()}`}>
                    { cat }
                </ToggleLink>
            )}
            <Link className="btn btn-block btn-secondary fixed-bottom m-2 col-3"

                to="/admin">

                Administration

            </Link>

        </React.Fragment>
    }
}

Listing 8-11Adding a Link in the CategoryNavigation.js File in the src/shop Folder

要查看认证功能,导航至http://localhost:3000并点击新管理按钮。警卫将确保显示验证表格。在密码栏中输入 secret ,点击登录按钮进行认证,然后显示管理功能,如图 8-3 所示。单击“注销”按钮返回到未验证状态。

img/473159_1_En_8_Fig3_HTML.jpg

图 8-3

认证以使用管理功能

为部署准备应用

在接下来的小节中,我准备了 SportsStore 应用,以便可以对其进行部署。

为管理功能启用延迟加载

部署应用时,各个 JavaScript 文件将合并成一个文件,浏览器可以更高效地下载该文件。大多数用户将是购物者,这意味着他们不太可能需要管理功能。为了防止他们下载不太可能用到的代码,我在将顶层管理组件合并到应用其余部分的import语句上启用了延迟加载,如清单 8-12 所示。

import React, { Component, lazy, Suspense } from "react";

import { SportsStoreDataStore } from "./data/DataStore";
import { Provider } from "react-redux";
import { BrowserRouter as Router, Route, Switch, Redirect }
    from "react-router-dom";
import { ShopConnector } from "./shop/ShopConnector";
//import { Admin } from "./admin/Admin";
import { AuthProviderImpl } from "./auth/AuthProviderImpl";

const Admin = lazy(() => import("./admin/Admin"));

export default class App extends Component {

    render() {
        return <Provider store={ SportsStoreDataStore }>
            <AuthProviderImpl>
                <Router>
                    <Switch>
                        <Route path="/shop" component={ ShopConnector } />
                        <Route path="/admin" render={
                            routeProps =>
                                <Suspense fallback={ <h3>Loading...</h3> }>

                                    <Admin { ...routeProps } />

                                </Suspense>

                            } />
                        <Redirect to="/shop" />
                    </Switch>
                </Router>
            </AuthProviderImpl>
        </Provider>
    }
}

Listing 8-12Using Lazy Loading in the App.js File in the src Folder

Suspense组件用于表示仅在需要时才加载的内容,并与lazy功能结合使用。这些共同确保了Admin组件在需要时才会被加载。延迟加载特性是 React 最近新增的功能,在撰写本文时,它还不支持从文件中延迟加载命名导出。为了适应这个需求,我修改了清单 8-13 中所示的Admin组件的定义。

import React, { Component } from "react";
import  ApolloClient from "apollo-boost";
import { ApolloProvider} from "react-apollo";
import { GraphQlUrl } from "../data/Urls";
import { OrdersConnector } from "./OrdersConnector"
import { Route, Redirect, Switch } from "react-router-dom";
import { ToggleLink } from "../ToggleLink";
import { ConnectedProducts } from "./ProductsConnector";
import { ProductEditor } from "./ProductEditor";
import { ProductCreator } from "./ProductCreator";
import { AuthPrompt } from "../auth/AuthPrompt";
import { authWrapper } from "../auth/AuthWrapper";

export default authWrapper(class extends Component {

    // ...constructor and render method omitted for brevity...

})

Listing 8-13Changing the Export in the Admin.js File in the src/admin Folder

创建数据文件

RESTful 和 GraphQL 服务使用的数据文件使用 JavaScript 在服务器每次启动时生成相同的数据。这在开发过程中很有用,因为它使返回到已知状态变得容易,但是它不适合生产应用。

json-server包在提供 JSON 文件时会创建一个持久数据库,所以我在sportstore文件夹中添加了一个名为productionData.json的文件,内容如清单 8-14 所示。

{
    "products": [
        { "id": 1, "name": "Kayak", "category": "Watersports",
            "description": "A boat for one person", "price": 275 },
        { "id": 2, "name": "Lifejacket", "category": "Watersports",
            "description": "Protective and fashionable", "price": 48.95 },
        { "id": 3, "name": "Soccer Ball", "category": "Soccer",
            "description": "FIFA-approved size and weight", "price": 19.50 },
        { "id": 4, "name": "Corner Flags", "category": "Soccer",
            "description": "Give your playing field a professional touch",
            "price": 34.95 },
        { "id": 5, "name": "Stadium", "category": "Soccer",
            "description": "Flat-packed 35,000-seat stadium", "price": 79500 },
        { "id": 6, "name": "Thinking Cap", "category": "Chess",
            "description": "Improve brain efficiency by 75%", "price": 16 },
        { "id": 7, "name": "Unsteady Chair", "category": "Chess",
            "description": "Secretly give your opponent a disadvantage",
            "price": 29.95 },
        { "id": 8, "name": "Human Chess Board", "category": "Chess",
            "description": "A fun game for the family", "price": 75 },
        { "id": 9, "name": "Bling Bling King", "category": "Chess",
            "description": "Gold-plated, diamond-studded King", "price": 1200 }
    ],
    "categories": ["Watersports", "Soccer", "Chess"],
    "orders": []
}

Listing 8-14The Contents of the productionData.json File in the sportsstore Folder

配置请求 URL

当我部署应用时,我将把 React development HTTP 服务器替换为一个结合了提供静态 HTML 和 JavaScript 文件以及 RESTful 和 GraphQL 服务的服务器。为了准备在一个端口上组合所有的服务,我改变了 SportsStore 使用的 URL 的格式,如清单 8-15 所示。

import { DataTypes } from "./Types";

// const protocol = "http";

// const hostname = "localhost";

// const port = 3500;

export const RestUrls = {
    [DataTypes.PRODUCTS]: `/api/products`,

    [DataTypes.CATEGORIES]: `/api/categories`,

    [DataTypes.ORDERS]: `/api/orders`

}

export const GraphQlUrl = `/graphql`;

export const authUrl = `/login`;

Listing 8-15Changing URLs in the Urls.js File in the src/data Folder

构建应用

为了创建适合生产使用的应用的优化版本,打开一个新的命令提示符,导航到sportsstore文件夹,并运行清单 8-16 中所示的命令。

npm run build

Listing 8-16Building the Application for Deployment

构建过程可能需要一些时间来完成,结果是在build文件夹中得到一组优化的文件。

创建应用服务器

React 开发 HTTP 服务器不适合生产。在清单 8-17 中,我扩展了一直提供 RESTful 和 GraphQL 服务的服务器,这样它也将服务于来自build文件夹的文件。

const express = require("express");
const jsonServer = require("json-server");
const chokidar = require('chokidar');
const cors = require("cors");
const fs = require("fs");
const { buildSchema } = require("graphql");
const graphqlHTTP = require("express-graphql");
const queryResolvers  = require("./serverQueriesResolver");
const mutationResolvers = require("./serverMutationsResolver");
const auth = require("./authMiddleware");

const history = require("connect-history-api-fallback");

const fileName = process.argv[2] || "./data.js"
const port = process.argv[3] || 3500;

let router = undefined;
let graph = undefined;

const app = express();

const createServer = () => {
    delete require.cache[require.resolve(fileName)];
    setTimeout(() => {
        router = jsonServer.router(fileName.endsWith(".js")
                ? require(fileName)() : fileName);
        let schema =  fs.readFileSync("./serverQueriesSchema.graphql", "utf-8")
            + fs.readFileSync("./serverMutationsSchema.graphql", "utf-8");
        let resolvers = { ...queryResolvers, ...mutationResolvers };
        graph = graphqlHTTP({
            schema: buildSchema(schema), rootValue: resolvers,
            graphiql: true, context: { db: router.db }
        })
    }, 100)
}

createServer();

app.use(history());

app.use("/", express.static("./build"));

app.use(cors());
app.use(jsonServer.bodyParser)
app.use(auth);
app.use("/api", (req, resp, next) => router(req, resp, next));
app.use("/graphql", (req, resp, next) => graph(req, resp, next));

chokidar.watch(fileName).on("change", () => {
    console.log("Reloading web service data...");
    createServer();
    console.log("Reloading web service data complete.");
});

app.listen(port, () => console.log(`Web service running on port ${port}`));

Listing 8-17Configuring the Server in the server.js File in the sportsstore Folder

connect-history-api-fallback包用index.html文件的内容响应任何 HTTP 请求。这对于使用 URL 路由的应用很有用,因为这意味着用户可以直接导航到应用使用 HTML5 历史 API 导航到的 URL。

测试生产版本和服务器

为了确保生产构建正在工作,并且服务器已经被正确配置,运行在sportsstore文件夹中的清单 8-18 中显示的命令。

node server.js ./productionData.json 4000

Listing 8-18Testing the Production Build

一旦服务器启动,打开一个新的浏览器窗口并导航到http://localhost:4000;你会看到熟悉的内容如图 8-4 所示。

img/473159_1_En_8_Fig4_HTML.jpg

图 8-4

测试应用

容器化 SportsStore 应用

为了完成本章,我将为 SportsStore 应用创建一个容器,以便将其部署到生产中。在撰写本文时,Docker 是创建容器最流行的方式,它是 Linux 的精简版,功能仅够运行应用。大多数云平台或托管引擎都支持 Docker,其工具运行在最流行的操作系统上。

安装 Docker

第一步是在你的开发机器上下载并安装 Docker 工具,可以从 www.docker.com/products/docker 获得。有适用于 macOS、Windows 和 Linux 的版本,也有一些适用于 Amazon 和 Microsoft 云平台的专门版本。对于这一章,免费的社区版已经足够了。

警告

使用 Docker 的一个缺点是,生产该软件的公司因做出突破性的改变而获得了声誉。这可能意味着后面的示例在以后的版本中可能无法正常工作。如果您有问题,请查看这本书的资源库以获取更新( https://github.com/Apress/pro-react-16 )或通过adam@adam-freeman.com联系我。

准备应用

第一步是为 NPM 创建一个配置文件,该文件将用于下载应用在容器中使用所需的附加包。我在sportsstore文件夹中创建了一个名为deploy-package.json的文件,内容如清单 8-19 所示。

{
    "name": "sportsstore",
    "description": "SportsStore",
    "repository": "https://github.com/Apress/pro-react-16",
    "license": "0BSD",

    "devDependencies": {
      "graphql": "¹⁴.0.2",
      "chokidar": "².0.4",
      "connect-history-api-fallback": "¹.5.0",
      "cors": "².8.5",
      "express": "⁴.16.4",
      "express-graphql": "⁰.7.1",
      "json-server": "⁰.14.2",
      "jsonwebtoken": "⁸.1.1"
    }
}

Listing 8-19The Contents of the deploy-package.json File in the sportsstore Folder

devDependencies部分将运行应用所需的包分类到容器中。浏览器中使用的所有包都包含在由build c命令生成的 JavaScript 文件中。其他字段描述应用,它们的主要用途是防止在创建容器时出现警告。

创建 Docker 容器

为了定义容器,我在sportsstore文件夹中添加了一个名为Dockerfile(没有扩展名)的文件,并添加了清单 8-20 中所示的内容。

FROM node:10.14.1

RUN mkdir -p /usr/src/sportsstore

COPY build /usr/src/sportsstore/build

COPY authMiddleware.js /usr/src/sportsstore/
COPY productionData.json /usr/src/sportsstore/
COPY server.js /usr/src/sportsstore/
COPY deploy-package.json /usr/src/sportsstore/package.json

COPY serverQueriesSchema.graphql /usr/src/sportsstore/
COPY serverQueriesResolver.js /usr/src/sportsstore/
COPY serverMutationsSchema.graphql /usr/src/sportsstore/
COPY serverMutationsResolver.js /usr/src/sportsstore/

WORKDIR /usr/src/sportsstore

RUN echo 'package-lock=false' >> .npmrc

RUN npm install

EXPOSE 80

CMD ["node", "server.js", "./productionData.json", "80"]

Listing 8-20The Contents of the Dockerfile File in the sportsstore Folder

Dockerfile的内容使用已经用 Node.js 配置的基本映像,并复制运行应用所需的文件,包括包含应用的包文件和将用于安装在部署中运行应用所需的 NPM 包的文件。

为了加快容器化过程,我在sportsstore文件夹中创建了一个名为.dockerignore的文件,其内容如清单 8-21 所示。这告诉 Docker 忽略node_modules文件夹,这在容器中是不需要的,并且需要很长的处理时间。

node_modules

Listing 8-21The Contents of the .dockerignore File in the sportsstore Folder

sportsstore文件夹中运行清单 8-22 中所示的命令,创建一个包含 SportsStore 应用及其所需的所有包的映像。

docker build . -t sportsstore  -f  Dockerfile

Listing 8-22Building the Docker Image

图像是容器的模板。当 Docker 处理 Docker 文件中的指令时,将下载并安装 NPM 包,并将配置和代码文件复制到映像中。

运行应用

一旦创建了映像,使用清单 8-23 中所示的命令创建并启动一个新的容器。

docker run -p 80:80 sportsstore

Listing 8-23Starting the Docker Container

您可以通过在浏览器中打开http://localhost来测试应用,这将显示运行在容器中的 web 服务器提供的响应,如图 8-5 所示。

img/473159_1_En_8_Fig5_HTML.jpg

图 8-5

运行容器化 SportsStore 应用

要停止容器,运行清单 8-24 中所示的命令。

docker ps

Listing 8-24Listing the Containers

您将看到一个正在运行的容器列表,如下所示(为简洁起见,我省略了一些字段):

CONTAINER ID        IMAGE               COMMAND             CREATED
ecc84f7245d6        sportsstore         "node server.js"    33 seconds ago

使用容器 ID 列中的值,运行清单 8-25 中所示的命令。

docker stop ecc84f7245d6

Listing 8-25Stopping the Container

该应用可以部署到任何支持 Docker 的平台上。

摘要

本章完成了 SportsStore 应用,展示了如何准备 React 应用以进行部署,以及将 React 应用放入 Docker 之类的容器中是多么容易。这部分书到此结束。在第二部分中,我开始深入研究细节,并向您展示我用来创建 SportsStore 应用的特性是如何深入工作的。

九、了解 React 项目

在本书的第一部分中,我创建了 SportsStore 应用来演示如何将不同的 React 特性与其他包结合起来创建一个真实的应用。在本书的这一部分,我深入探讨了内置 React 特性的细节。在本章中,我将描述 React 项目的结构,并解释为开发人员提供的工具,以及编译、打包代码和内容并将其发送到浏览器的过程。表 9-1 将本章放在上下文中。

表 9-1

将 React 项目放在上下文中

|

问题

|

回答

|
| --- | --- |
| 它们是什么? | create-react-app包用于创建项目和设置有效的 React 开发所需的工具。 |
| 它们为什么有用? | 用create-react-app包创建的项目是为复杂应用的开发而设计的,并且提供了一整套用于开发、测试和部署的工具。 |
| 它们是如何使用的? | 使用npx create-react-app包创建一个项目,使用npm start命令启动开发工具。 |
| 有什么陷阱或限制吗? | create-react-app包是“开放的”,这意味着它提供了一种使用很少配置选项的特定工作方式。如果你习惯了不同的工作流程,这可能会令人沮丧。 |
| 有其他选择吗? | 你不必使用create-react-app来创建项目。本章后面会提到一些可供选择的包。 |

表 9-2 总结了本章内容。

表 9-2

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 创建新的 React 项目 | 使用create-react-app包并添加可选包 | 1–3 |
| 将 HTML 转换为 JavaScript | 使用 JSX 格式混合 HTML 和代码语句 | six |
| 包括静态内容 | 将文件添加到src文件夹,并使用import关键字将它们合并到应用中 | 9–10 |
| 包括开发工具之外的静态内容 | 将文件添加到公共文件夹,并使用PUBLIC_URL属性定义引用 | 11–13 |
| 禁用林挺消息 | 向 JavaScript 文件添加注释 | 15–19 |
| 配置 React 开发工具 | 创建一个.env文件并设置配置属性 | Twenty |
| 调试 React 应用 | 使用 React Devtools 浏览器扩展或使用浏览器调试器 | 22–26 |

为本章做准备

为了创建本章的示例项目,打开一个新的命令提示符,导航到一个方便的位置,并运行清单 9-1 中所示的命令。

npx create-react-app projecttools

Listing 9-1Creating the Project

小费

你可以从 https://github.com/Apress/pro-react-16 下载本章以及本书其他章节的示例项目。

注意

创建新项目时,您可能会看到有关安全漏洞的警告。React 开发依赖于大量的包,每个包都有自己的依赖关系,不可避免的会发现安全问题。对于本书中的示例,使用指定的包版本以确保获得预期的结果是很重要的。对于您自己的项目,您应该查看警告并更新到解决问题的版本。

运行清单 9-2 中所示的命令,导航到项目文件夹,并将引导包添加到项目中。

cd projecttools
npm install bootstrap@4.1.2

Listing 9-2Adding the Bootstrap CSS Framework

为了在应用中包含引导 CSS 样式表,将清单 9-3 中所示的语句添加到index.js文件中,该文件可以在src文件夹中找到。

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

import 'bootstrap/dist/css/bootstrap.css';

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

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

Listing 9-3Including Bootstrap in the index.js File in the src Folder

使用命令提示符,运行projecttools文件夹中清单 9-4 所示的命令来启动开发工具。

警告

注意,开发工具是使用npm命令启动的,而不是清单 9-1 中使用的npx命令。

npm start

Listing 9-4Starting the Development Tools

一旦项目的初始准备工作完成,一个新的浏览器窗口将打开并显示 URL http://localhost:3000并显示如图 9-1 所示的占位符内容。

img/473159_1_En_9_Fig1_HTML.jpg

图 9-1

运行示例应用

了解 React 项目结构

当您创建一个新项目时,您将从一组基本的 React 应用文件、一些占位符内容和一套完整的开发工具开始。图 9-2 显示了projecttools文件夹的内容。

img/473159_1_En_9_Fig2_HTML.jpg

图 9-2

新项目的内容

注意

您不必使用create-react-app包来创建 React 项目,但这是最常用的方法,它负责配置支持本章所述特性的构建工具。如果您愿意,您可以创建所有文件并直接配置工具,或者使用其他可用于创建项目的技术之一,在 https://reactjs.org/docs/create-a-new-react-app.html 中有所描述。

表 9-3 描述了项目中的每一个文件,我在接下来的章节中提供了关于最重要文件的更多细节。

表 9-3

项目文件和文件夹

|

名字

|

描述

|
| --- | --- |
| node_modules | 该文件夹包含应用和开发工具所需的包,如“了解包文件夹”一节中所述。 |
| public | 该文件夹用于静态内容,包括用于响应 HTTP 请求的index.html文件,如“理解静态内容”一节所述。 |
| src | 该文件夹包含应用代码和内容,如“了解源代码文件夹”一节中所述。 |
| .gitignore | 这个文件用于从 Git 版本控制包中排除文件和文件夹。 |
| package.json | 该文件夹包含项目的顶层包依赖项集,如“了解包文件夹”一节中所述。 |
| package-lock.json | 该文件包含项目的包依赖项的完整列表,如“了解包文件夹”一节中所述。 |
| README.md | 该文件包含项目工具的信息,相同的内容可以在 https://github.com/facebook/create-react-app 找到。 |

了解源代码文件夹

src文件夹是项目中最重要的,因为它是放置应用代码和内容文件的地方,也是您定义项目所需的定制特性的地方。create-react-app包向跳转开发添加文件,如表 9-4 所述。

表 9-4

src 文件夹中的文件

|

名字

|

描述

|
| --- | --- |
| index.js | 这个文件负责配置和启动应用。 |
| index.css | 该文件包含应用的全局 CSS 样式。有关使用 CSS 文件的详细信息,请参见“理解静态内容”一节。 |
| App.js | 该文件包含顶级 React 组件。第章 10 和第章 11 对部件进行了描述。 |
| App.css | 该文件包含新项目的占位符 CSS 样式。有关详细信息,请参见“理解静态内容”一节。 |
| App.test.js | 该文件包含顶级组件的单元测试。有关单元测试的详细信息,请参见第十七章。 |
| registerServiceWorker.js | 该文件由渐进式 web 应用使用,这些应用可以脱机工作。我没有在本书中描述渐进式应用,但你可以在 https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 中找到细节。 |
| logo.svg | 该图像文件包含 React 徽标,并由创建时添加到项目中的占位符组件显示。请参见“理解静态内容”一节。 |

了解包文件夹

JavaScript 应用开发依赖于丰富的包生态系统,从包含将被发送到浏览器的代码的包,到在特定任务的开发过程中在后台使用的小包。React 项目中需要大量的包:例如,本章开始时创建的示例项目需要 900 多个包。

这些包之间有一个复杂的依赖层次结构,手工管理起来太困难了,只能用一个包管理器来处理。React 项目可以使用两个不同的包管理器来创建:NPM,它是节点包管理器,在第一章中与 Node.js 一起安装 Yarn,它是最近的竞争对手,旨在改进包管理。为简单起见,我在本书中通篇使用 NPM。

小费

你应该按照书中的例子使用 NPM,但如果你想在自己的项目中使用它,你可以在 https://yarnpkg.com 找到纱的细节。

当创建一个项目时,包管理器会得到一个 React 开发所需的包的初始列表,对每个包进行检查以获得它所依赖的包的集合。再次执行该过程以获得那些包的依赖性,并且重复该过程,直到构建了完整的包列表。包管理器下载并安装所有的包,并将它们安装到node_modules文件夹中。

使用dependenciesdevDependencies属性在package.json文件中定义初始的一组包。dependencies部分用于列出应用运行所需的包。devDependencies部分用于列出开发所需的包,但这些包不是作为应用的一部分部署的。

您可能会在项目中看到不同的细节,但这里是我的示例项目中的package.json文件的dependencies部分:

...
"dependencies": {
    "bootstrap": "⁴.1.2",
    "react": "¹⁶.7.0",
    "react-dom": "¹⁶.7.0",
    "react-scripts": "2.1.2"
},
...

React 项目的dependencies部分只需要三个包:react包包含主要特性,react-dom包包含 web 应用所需的特性,react-scripts包包含我在本章中描述的开发工具命令。第四个包是 Bootstrap CSS 框架,添加到清单 9-2 的项目中。对于每个包,package.json文件包括可接受版本号的详细信息,使用表 9-5 中描述的格式。

表 9-5

软件包版本编号系统

|

格式

|

描述

|
| --- | --- |
| 16.7.0 | 直接表示版本号将只接受具有精确匹配版本号的包,例如 16.7.0。 |
| * | 使用星号表示接受要安装的任何版本的软件包。 |
| >16.7.0 >=16.7.0 | 在版本号前面加上>或> =接受任何大于或等于给定版本的软件包版本。 |
| <16.7.0 <=16.7.0 | 在版本号前加上 |
| ~16.7.0 | 在版本号前加一个波浪号(字符)接受要安装的版本,即使修补程序级别号(三个版本号中的最后一个)不匹配。例如,指定16.7.0 将接受版本 16.7.1 或 16.7.2(将包含版本 16.7.0 的修补程序),但不接受版本 16.8.0(将是新的次要版本)。 |
| ¹⁶.7.0 | 在版本号前加一个插入符号(^字符)将接受版本,即使次要版本号(三个版本号中的第二个)或补丁号不匹配。例如,指定¹⁶.7.0 将允许 16.8.0 和 16.9.0 版本,但不允许 17.0.0 版本。 |

package.json文件的dependencies部分指定的版本号将接受较小的更新和补丁。

了解全球和本地包

软件包管理员可以安装软件包,使它们特定于单个项目(称为本地安装)或者可以从任何地方访问它们(称为全局安装)。很少有软件包需要全局安装,但有一个例外,那就是我在第一章安装的create-react-app软件包,它是本书准备工作的一部分。create-react-app包需要全局安装,因为它用于创建新项目。项目所需的单个包被本地安装到node_modules文件夹中。

当你创建一个 React 项目时,开发所需的所有包都被自动下载并安装到node_modules文件夹中,但是表 9-6 列出了一些你可能会发现在开发过程中有用的 NPM 命令。所有这些命令都应该在项目文件夹中运行,这个文件夹包含了package.json文件。

表 9-6

有用的 NPM 命令

|

命令

|

描述

|
| --- | --- |
| npx create-react-app <name> | 该命令创建一个新的 React 项目。 |
| npm install | 该命令执行在package.json文件中指定的包的本地安装。 |
| npm install package@version | 该命令执行包的特定版本的本地安装,并更新package.json文件以将包添加到dependencies部分。 |
| npm install --save-dev package@version | 该命令执行包的特定版本的本地安装,并更新package.json文件以将包添加到devDependencies部分,该部分用于将包添加到项目中,这些包是开发所需的,但不是应用的一部分。 |
| npm install --global package@version | 此命令将对特定版本的软件包执行全局安装。 |
| npm list | 该命令将列出所有本地包及其依赖项。 |
| npm run | 该命令将执行在package.json文件中定义的脚本之一,如下所述。 |

表 9-6 中描述的最后一个命令很奇怪,但是包管理器传统上包含了对运行命令的支持,这些命令在package.json文件的scripts部分中定义。在 React 项目中,该特性用于提供对工具的访问,这些工具在开发过程中使用,并为应用的部署做准备。下面是示例项目中package.json文件的scripts部分:

...
"scripts": {
  "start": "react-scripts start",
  "build": "react-scripts build",
  "test": "react-scripts test",
  "eject": "react-scripts eject"
},
...

这些命令总结在表 9-7 中,我将在后面的章节中演示它们的用法。

表 9-7

package.json 文件的脚本部分中的命令

|

名字

|

描述

|
| --- | --- |
| start | 该命令启动开发工具,如“使用 React 开发工具”一节所述。 |
| build | 此命令执行构建过程。 |
| test | 该命令运行单元测试,如第十七章所述。 |
| eject | 该命令将所有开发工具的配置文件复制到项目文件夹中。这是一种单向操作,只有当开发工具的默认配置不适合某个项目时才应该使用。 |

表 9-7 中的命令通过使用npm run后跟您需要的命令名来执行,这必须在包含package.json文件的文件夹中完成。因此,如果您想在示例项目中运行build命令,您可以导航到projecttools文件夹并键入npm run build。例外是使用npm start执行的start命令。

使用 React 开发工具

添加到项目中的开发工具会自动检测到src文件夹中的更改,编译应用,并将文件打包以备浏览器使用。这些任务可以手动执行,但是自动更新会带来更愉快的开发体验。如果它们还没有运行,通过打开命令提示符,导航到projecttools文件夹,并运行清单 9-5 中所示的命令来启动开发工具。

npm start

Listing 9-5Starting the Development Tools

开发工具使用的关键包是 webpack ,它是许多 JavaScript 开发工具和框架的主干。Webpack 是一个模块捆绑器,这意味着它打包了在浏览器中使用的 JavaScript 模块——尽管这对于一个重要功能来说是一个平淡无奇的描述,并且它是开发 React 应用时您将依赖的关键工具之一。

当您运行清单 9-5 中的命令时,当 webpack 准备运行示例应用所需的包时,您会看到一系列消息。Webpack 从index.js文件开始,加载所有有import语句的模块,以创建一组依赖关系。对index.js依赖的每个模块重复这个过程,webpack 继续在应用中工作,直到它拥有整个应用的一组完整的依赖项,然后这些依赖项被组合成一个文件,称为

绑定过程可能需要一点时间,但是只需要在启动开发工具时执行。一旦完成了初始准备,您将会看到一条类似这样的消息,它告诉您应用已经被编译和绑定了:

...
Compiled successfully!

You can now view projecttools in the browser.

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

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

当初始过程完成时,将为http://localhost:3000打开一个新的浏览器窗口,显示图 9-3 中的占位符内容。

img/473159_1_En_9_Fig3_HTML.jpg

图 9-3

使用开发工具

了解编译和转换过程

Webpack 负责构建过程,其中一个关键步骤是由 Babel 包执行的代码转换。在 React 项目中,Babel 有两个重要的任务:转换 JSX 内容,以及将使用最新 JavaScript 特性的 JavaScript 代码转换成可以由旧浏览器执行的代码。

了解 JSX 转型

正如我在第三章中解释的,JSX 格式是 JavaScript 的超集,允许 HTML 与常规代码语句混合使用。JSX 并不完全支持标准 HTML,最明显的区别是纯 HTML 中的属性如class在 JSX 文件中被表示为className。出现这些奇怪现象的原因是,在构建过程中,JSX 文件的内容被 Babel 转换成对 React API 的调用,因此每个 HTML 元素都被转换成对React.createElement方法的调用。在清单 9-6 中,我用一个组件替换了App.js文件中的占位符内容,该组件的render方法返回一些简单的 HTML 元素。

import React, { Component } from "react";

export default class extends Component {

    render = () =>
        <h4 className="bg-primary text-white text-center p-3">
            This is an HTML element
        </h4>
}

Listing 9-6Replacing the Placeholder Content in the App.js File in the src Folder

在转换过程中,h4元素被替换为对React.createElement方法的调用,产生的结果完全是 JavaScript,不需要浏览器对 JSX 有特别的理解。作为一个简单的演示,清单 9-7 直接使用React.createElement方法来实现相同的结果。

import React, { Component } from "react";

export default class extends Component {

    render = () => React.createElement("h4",
                    { className: "bg-primary text-white text-center p-3" },
                    "This is an HTML element")
}

Listing 9-7Using the React API Directly in the App.js File in the src Folder

清单 9-6 和清单 9-7 产生相同的结果,当 Babel 处理清单 9-6 中App.js文件的内容时,它产生清单 9-7 中的代码。当 React 在浏览器中执行 JavaScript 代码时,它使用 DOM API 创建 HTML 元素,如第三章所示。这似乎是一种循环的方法,但是 JSX 转换只在构建过程中执行,目的是使编写 React 特性更容易。

理解 JavaScript 语言转换

经过多年的停滞之后,JavaScript 语言已经重新焕发了活力,并通过简化开发和提供其他编程语言中常见的功能(如第四章中描述的那些功能)实现了现代化。并非所有浏览器都支持所有最新的语言功能,尤其是较旧的浏览器或在企业环境中使用的浏览器,在这些环境中,更新通常很慢(如果有的话)。Babel 通过将现代功能翻译成代码来解决这个问题,这些代码使用了更广泛的浏览器支持的功能,包括 JavaScript 文艺复兴之前的浏览器。

在清单 9-8 中,我返回了App.js文件以使用 HTML 元素,并使用最新的 JavaScript 特性来设置h4元素的内容。

import React, { Component } from "react";

let name = "Adam";

const city = "London";

export default class extends Component {

    message = () => `Hello ${name} from ${city}`;

    render = () =>
        <h4 className="bg-primary text-white text-center p-3">
            { this.message() }
        </h4>
}

Listing 9-8Using Modern JavaScript Features in the App.js File in the src Folder

这个组件依赖于几个最新的 JavaScript 特性:定义类的关键字classextends,定义变量和常量的关键字letconst,以及message方法中的 lambda 函数和模板字符串。当您保存更改时,React 开发工具将自动编译并捆绑 JavaScript 代码,并将其发送到浏览器,产生如图 9-4 所示的内容。

img/473159_1_En_9_Fig4_HTML.jpg

图 9-4

使用现代语言功能

要查看 Babel 是如何处理现代 JavaScript 特性的,打开 F12 开发者工具,选择 Sources 选项卡,在窗口左侧的树中找到main.chunk.js项,如图 9-5 所示。对于编写本文时的 Chrome 版本,该文件位于树的localhost:3000 > static/js部分下。

img/473159_1_En_9_Fig5_HTML.jpg

图 9-5

定位编译后的源代码

小费

谷歌 Chrome 开发者工具经常变化,你可能不得不四处寻找 Babel 产生的代码。使用 Ctrl+F 并搜索伦敦是找到你要找的代码的好方法。另一种方法是将清单 9-8 中的代码粘贴到解释器的 https://babeljs.io/repl 处,这将产生类似的结果。

如果你向下滚动——或者搜索 London ,如前所述——那么你会看到巴别塔生成的代码。旧浏览器不支持的所有功能都被替换为向后兼容的代码,如下所示:

...
var name = "Adam";
var city = "London";

var App = function (_Component) {
    _inherits(App, _Component);

    function App() {
        var _ref;

        var _temp, _this, _ret;

        _classCallCheck(this, App);

        for (var _len = arguments.length, args = Array(_len), _key = 0;
                _key < _len; _key++) {
            args[_key] = arguments[_key];
        }

        return _ret = (_temp = (_this = _possibleConstructorReturn(this,
            (_ref = App.__proto__ || Object.getPrototypeOf(App)).call.apply(_ref,
                [this].concat(args))), _this), _this.message = function () {
                   return "Hello " + name + " from " + city;
                }, _temp), _possibleConstructorReturn(_this, _ret);
    }

    _createClass(App, [{
        key: "render",
        value: function render() {
            return __WEBPACK_IMPORTED_MODULE_0_react___default.a.createElement(
                "div",
                { className: "h1 bg-primary text-white text-center p-3", __source: {
                        fileName: _jsxFileName,
                        lineNumber: 12
                    },
                    __self: this
                },
                this.message()
            );
        }
    }]);
    return App;
}
...

您不需要详细了解这些代码是如何工作的,尤其是因为其中一些代码非常复杂,难以阅读。重要的是如何处理在App.js文件中使用的特性,例如letconst关键字,它们被传统的var关键字所取代。

...
var name = "Adam";
var city = "London";
...

您还可以看到模板字符串已被替换为字符串串联,如下所示:

...
return "Hello " + name + " from " + city;
...

一些特性,比如类,是使用 Babel 添加到发送给浏览器的包中的函数来处理的。JSX 的 HTML 片段被翻译成对React.createElement方法的调用。

现代特性的翻译是复杂的,但是 JavaScript 语言最近增加的内容很大程度上是语法糖,旨在使开发人员更喜欢编码。翻译这些功能剥夺了这些租赁功能的代码,需要一些扭曲来创建一个老浏览器可以执行的等效效果。

理解巴别塔的极限

Babel 是一个优秀的工具,但是它只处理 JavaScript 语言特性。Babel 不能在不支持最新 JavaScript APIs 的浏览器上增加对这些 API 的支持。您仍然可以使用这些 API——正如我在第一部分中使用本地存储 API 时所演示的那样——但是这样做限制了可以运行应用的浏览器的范围。

了解开发 HTTP 服务器

为了简化开发过程,该项目包含了webpack-dev-server包,这是一个与 webpack 集成的 HTTP 服务器。服务器被配置为在初始绑定过程完成后立即开始侦听端口 3000 上的 HTTP 请求。当接收到 HTTP 请求时,开发 HTTP 服务器返回public/index.html文件的内容。在处理index.html文件时,开发服务器做了一些重要的添加,您可以通过在浏览器窗口中右键单击并从弹出菜单中选择 View Page Source 来查看。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,
        initial-scale=1, shrink-to-fit=no">
    <meta name="theme-color" content="#000000">
    <link rel="manifest" href="/manifest.json">
    <link rel="shortcut icon" href="/favicon.ico">
    <title>React App</title>
  </head>
  <body>
    <noscript>
      You need to enable JavaScript to run this app.
    </noscript>
    <div id="root"></div>
    <script src="/static/js/bundle.js"></script>
    <script src="/static/js/0.chunk.js"></script>
    <script src="/static/js/main.chunk.js"></script>
    <script src="/main.a5f0dcc648ccc4241725.hot-update.js"></script>
  </body>
</html>

开发服务器添加了script元素,告诉浏览器加载包含 React 框架、应用代码、静态内容(如 CSS)和一些支持开发工具的附加功能的文件,并在检测到更改时自动重新加载浏览器。

理解静态内容

有两种方法可以在 React 应用中包含静态内容,比如图像或 CSS 样式表。在大多数情况下,最好的方法是将您需要的文件添加到src文件夹中,然后在代码文件中使用import语句声明对它们的依赖。

为了演示如何处理src文件夹中的静态内容,我用清单 9-9 中所示的 CSS 样式替换了App.css文件的内容,该文件是在创建时添加到项目中的。

img {
  background-color: lightcyan;
  width: 50%;
}

Listing 9-9Replacing the Styles in the App.css File in the src Folder

我定义的样式选择img元素并设置背景颜色和宽度。在清单 9-10 中,我向App组件的src文件夹中的两个静态文件添加了依赖项,包括我在前面的清单中更新的 CSS 文件和创建项目时添加到项目中的占位符图像。

小费

index.css文件由index.js文件导入,后者是负责启动 React 应用的 JavaScript 文件。您可以在 CSS 文件中定义全局样式,它们将包含在发送到浏览器的内容中。

import React, { Component } from "react";

import "./App.css";

import reactLogo from "./logo.svg";

let name = "Adam";
const city = "London";

export default class extends Component {

    message = () => `Hello ${name} from ${city}`;

    render = () =>
        <div className="text-center">
            <h4 className="bg-primary text-white text-center p-3">
                { this.message() }
            </h4>
            <img src={ reactLogo } alt="reactLogo" />
        </div>
}

Listing 9-10Declaring a Static Dependency in the App.js File in the src Folder

要导入在使用时不需要引用的内容,如 CSS 样式表,import关键字后接文件名,文件名必须包括文件扩展名,如下所示:

...
import "./App.css";
...

要导入将在 HTML 元素中引用的内容,如图像,则必须使用为导入的特性指定名称的import语句的形式,如以下语句:

...
import reactLogo from "./logo.svg";
...

这个statement导入了logo.svg文件,并给它命名为reactLogo,然后我可以在一个img元素的表达式中使用它,就像这样:

...
<img src={ reactLogo } alt="reactLogo" />
...

当您使用import关键字声明对静态内容的依赖时,如何处理内容的决定就留给了开发工具。对于小于 10Kb 的文件,内容将包含在bundle.js文件中,以及将内容添加到 HTML 文档所需的 JavaScript 代码。这就是清单 9-10 中导入的App.css文件的情况:CSS 文件的内容将包含在bundle.js文件中,以及创建style元素所需的代码。

对于较大的文件,以及任何大小的 SVG 文件,在单独的 HTTP 请求中请求导入的文件。由import语句指定的相对路径被自动替换为定位文件的 URL,并且文件名被更改为包含校验和,这确保了过时的数据不会被浏览器缓存。

您可以看到清单 9-10 中使用的静态内容的效果,方法是保存对App.js文件的更改,等待浏览器重新加载,然后使用 F12 开发人员的工具检查 Elements 选项卡,这将显示以下 HTML(尽管为了简洁起见,我省略了大量的 Bootstrap CSS 样式):

<html lang="en">
  <head>
    <meta charset="utf-8">
    <link rel="shortcut icon" href="/favicon.ico">
    <meta name="viewport" content="width=device-width,
         initial-scale=1, shrink-to-fit=no">
    <meta name="theme-color" content="#000000">
    <link rel="manifest" href="/manifest.json">
    <title>React App</title>
    <style type="text/css">
      img { background-color: lightcyan; width: 50% }
     </style>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root">
        <div class="text-center">
          <h4 class="bg-primary text-white text-center p-3">
            Hello Adam from London
           </h4>
           <img src="/static/media/logo.5d5d9eef.svg" alt="reactLogo">
         </div>
    </div>
    <script src="/static/js/bundle.js"></script>
    <script src="/static/js/1.chunk.js"></script>
    <script src="/static/js/main.chunk.js"></script>
    <script src="/main.00ec8a0c115561c18137.hot-update.js"></script>
  </body>
</html>

您可以看到 CSS 样式已经从 JavaScript 包中解包,并通过一个style元素添加到 HTML 文档中,而图像文件是通过 URL /static/media/logo.5d5d9eef.svg访问的。在构建过程中,大型文件会自动复制到应用代码中包含的 URL 所指定的位置,这意味着您不必担心它们是否可用。清单 9-10 中的变化产生如图 9-6 所示的结果。

img/473159_1_En_9_Fig6_HTML.jpg

图 9-6

src 文件夹中的静态内容

将公共文件夹用于静态内容

src文件夹用于静态内容有几个好处,但是您可能会发现它并不总是适合每个项目,尤其是在静态内容在构建时不可用并且不能被 React 开发工具处理的情况下。在这些情况下,您可以将静态内容放在public文件夹中,尽管这意味着您有责任确保应用拥有它所需要的文件。为了演示public文件夹的使用,我给它添加了一个名为static.css的文件,内容如清单 9-11 所示。

img {
    border: 8px solid black;
}

Listing 9-11The Contents of the static.css File in the public Folder

打开一个新的命令提示符,导航到projecttools文件夹,运行清单 9-12 所示的命令,将logo.svg文件从src文件夹复制到public文件夹。

cp src/logo.svg public/

Listing 9-12Copying an Image File into the Public Folder

在清单 9-13 中,我为public文件夹中的图像和样式表向由App组件呈现的内容添加了 HTML 元素。

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

let name = "Adam";
const city = "London";

export default class extends Component {

    message = () => `Hello ${name} from ${city}`;

    render = () =>
        <div className="text-center">
            <h4 className="bg-primary text-white text-center p-3">
                { this.message() }
            </h4>
            <img src={ reactLogo } alt="reactLogo" />
            <link rel="stylesheet"
                    href={ process.env.PUBLIC_URL + "/static.css"} />
            <img src={ process.env.PUBLIC_URL + "/logo.svg" } alt="reactLogo" />
        </div>
}

Listing 9-13Accessing Static Files in the App.js File in the src Folder

为了指定静态文件的 URL,process.env.PUBLIC_URL属性与表达式中的文件名结合在一起。注意,我已经为样式表添加了一个link元素,因为我不能依靠bundle.js文件中的代码来自动创建样式。添加元素到组件的结果如图 9-7 所示。

img/473159_1_En_9_Fig7_HTML.jpg

图 9-7

公共文件夹中的静态内容

了解错误显示

自动重新加载特性提供的即时性的一个影响是,在开发过程中,您会倾向于停止观察控制台输出,因为您的注意力会自然地被吸引到浏览器窗口。风险在于,当代码包含错误时,浏览器显示的内容保持不变,因为编译过程无法生成新的模块通过 HMR 功能发送给浏览器。为了解决这个问题,webpack 开发的包中包含了一个集成的错误显示,可以在浏览器窗口中显示问题的详细信息。为了演示处理错误的方式,我将清单 9-14 中所示的语句添加到了App.js文件中。

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

let name = "Adam";
const city = "London";

not a valid statement

export default class extends Component {

    message = () => `Hello ${name} from ${city}`;

    render = () =>
        <div className="text-center">
            <h4 className="bg-primary text-white text-center p-3">
                { this.message() }
            </h4>
            <img src={ reactLogo } alt="reactLogo" />
            <link rel="stylesheet"
                    href={ process.env.PUBLIC_URL + "/static.css"} />
            <img src={ process.env.PUBLIC_URL + "/logo.svg" } alt="reactLogo" />
        </div>
}

Listing 9-14Creating an Error in the App.js File in the src Folder

加法不是有效的 JavaScript 语句。保存对文件的更改后,构建过程会尝试编译代码,并在命令提示符下生成以下错误信息:

...
Failed to compile.

./src/App.js
  Line 8:  Parsing error: Unexpected token, expected ";"

   6 | const city = "London";
   7 |
>  8 | not a valid statement
     |     ^
   9 |
  10 | export default class extends Component {
  11 |
...

浏览器窗口中会显示相同的错误消息,因此即使您没有注意命令行消息,您也会意识到存在问题。如果你点击栈跟踪,那么浏览器会向开发服务器发送一个 HTTP 请求,开发服务器会试图找出你使用的是哪个代码编辑器,并突出显示问题,如图 9-8 所示。

img/473159_1_En_9_Fig8_HTML.jpg

图 9-8

在源代码文件出现错误后

小费

您可能需要配置 React 开发工具来指定您的编辑器,如“配置开发工具”一节中所述,并且不是所有的编辑器都受支持。图 9-8 显示了 Visual Studio 代码,这是提供支持的编辑器之一。

了解棉绒

React 开发工具包括一个 linter,它负责检查项目中的代码和内容是否符合一组规则。当您使用create-react-app包创建一个项目时,ESLint 包被用作 linter,带有一组规则,旨在帮助程序员避免常见错误。作为演示,我在App.js文件中添加了一个变量,如清单 9-15 所示。(此更改还会删除上一节中导致编译器错误的语句)。

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

let name = "Adam";
const city = "London";

let error = "not a valid statement";

export default class extends Component {

    message = () => `Hello ${name} from ${city}`;

    render = () =>
        <div className="text-center">
            <h4 className="bg-primary text-white text-center p-3">
                { this.message() }
            </h4>
            <img src={ reactLogo } alt="reactLogo" />
            <link rel="stylesheet"
                    href={ process.env.PUBLIC_URL + "/static.css"} />
            <img src={ process.env.PUBLIC_URL + "/logo.svg" } alt="reactLogo" />
        </div>
}

Listing 9-15Adding a Variable in the App.js File in the src Folder

保存文件时,您会在命令行和浏览器的 JavaScript 控制台中看到以下警告:

...
Compiled with warnings.

./src/App.js
  Line 8:  'error' is assigned a value but never used  no-unused-vars
...

linter 不能被禁用或重新配置,这意味着您将收到一组固定规则的林挺警告,包括清单 9-15 破坏的no-unused-vars规则。您可以在 https://github.com/facebook/create-react-app/tree/master/packages/eslint-config-react-app .看到 React 项目中应用的一组规则

当您收到警告时,搜索规则名称将为您提供问题的描述。在这种情况下,搜索 no-unused-vars 会将您带到 https://eslint.org/docs/rules/no-unused-vars ,这解释了变量不能被定义和不被使用。

禁用单个语句和文件的林挺

虽然 linter 不能被禁用,但是您可以向文件添加注释来防止警告。在清单 9-16 中,我通过添加注释禁用了单个语句的no-unused-var规则。

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

let name = "Adam";
const city = "London";

// eslint-disable-next-line no-unused-vars

let error = "not a valid statement";

export default class extends Component {

    message = () => `Hello ${name} from ${city}`;

    render = () =>
        <div className="text-center">
            <h4 className="bg-primary text-white text-center p-3">
                { this.message() }
            </h4>
            <img src={ reactLogo } alt="reactLogo" />
            <link rel="stylesheet"
                    href={ process.env.PUBLIC_URL + "/static.css"} />
            <img src={ process.env.PUBLIC_URL + "/logo.svg" } alt="reactLogo" />
        </div>
}

Listing 9-16Disabling a Single Linting Rule in the App.js File in the src Folder

如果您想禁用下一条语句的所有规则,那么您可以省略规则名称,如清单 9-17 所示。

...

// eslint-disable-next-line

let error = "not a valid statement";
...

Listing 9-17Disabling All Linting Rules in the App.js File in the src Folder

如果您想要禁用整个文件的规则,那么您可以在文件的顶部添加一个注释,如清单 9-18 所示。

/* eslint-disable no-unused-vars */

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

let name = "Adam";
const city = "London";

let error = "not a valid statement";

export default class extends Component {

    message = () => `Hello ${name} from ${city}`;

    render = () =>
        <div className="text-center">
            <h4 className="bg-primary text-white text-center p-3">
                { this.message() }
            </h4>
            <img src={ reactLogo } alt="reactLogo" />
            <link rel="stylesheet"
                    href={ process.env.PUBLIC_URL + "/static.css"} />
            <img src={ process.env.PUBLIC_URL + "/logo.svg" } alt="reactLogo" />
        </div>
}

Listing 9-18Disabling a Single Rule for a File in the App.js File in the src Folder

如果您想对单个文件的所有规则禁用林挺,那么您可以在注释中省略规则名称,如清单 9-19 所示。

...

/* eslint-disable */

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

let name = "Adam";
const city = "London";
...

Listing 9-19Disabling All Rules for a File in the App.js File in the src Folder

linter 将忽略 App.js 文件的内容,但仍然会检查项目中其他文件的内容。

使用类型脚本或流

林挺并不是检测常见错误的唯一方法,一种很好的补充技术是静态类型检查,其中您将变量和函数结果的数据类型的细节添加到您的代码中,以创建由编译器强制执行的策略。例如,您可以指定函数总是返回一个字符串,或者它的第一个参数只能是一个数字。编译应用时,会检查使用该函数的代码,以确保它只将数值作为参数传递,并且只将结果作为字符串处理。

向 React 项目添加静态类型检查有两种常见的方法。首先是使用 TypeScript,它是微软生产的 JavaScript 的超集。TypeScript 使得使用 JavaScript 更像 C#或 Java,并且包括对静态类型检查的支持。如果要使用 TypeScript,那么在创建项目时使用- scripts-version 参数,如下所示:

...
npx create-react-app projecttools --scripts-version=react-scripts-ts
...

react-scripts-ts值产生一个用 TypeScript 工具和特性建立的项目。你可以在 https://www.typescriptlang.org 了解更多关于 TypeScript 的知识。

一个替代方案是一个名为 Flow 的包,它只关注类型检查,不具备 TypeScript 的更广泛的特性。您可以在 https://flow.org 了解更多心流知识

配置开发工具

React 开发工具提供了少量的配置选项,尽管在大多数项目中并不需要。可用选项如表 9-8 所述。

表 9-8

React 开发工具配置选项

|

名字

|

描述

|
| --- | --- |
| 浏览器 | 此选项用于指定开发工具完成初始生成过程时打开的浏览器。您可以通过指定路径来指定浏览器,或者使用none来禁用此功能。 |
| 宿主 | 该选项用于指定开发 HTTP 服务器绑定的主机名,默认为localhost。 |
| 港口 | 该选项用于指定开发 HTTP 服务器使用的端口,默认为3000。 |
| 安全超文本传输协议 | 当设置为true时,该选项为开发 HTTP 服务器启用 SSL,生成自签名证书。默认为false。 |
| PUBLIC_URL | 该选项用于更改用于从public文件夹请求内容的 URL,如理解静态内容一节所述。 |
| 海峡群岛 | 当设置为true时,该选项将所有警告视为构建过程中的错误。默认值为false。 |
| REACT _ 编辑器 | 如了解错误显示部分所述,该选项用于指定当您点击浏览器中的栈跟踪时打开代码文件的特性的编辑器。 |
| CHOKIDAR_USEPOLLING | 当开发工具无法检测到对src文件夹的更改时,该选项应该设置为true,如果您在虚拟机或容器中工作,这种情况可能会发生。 |
| 生成 _ 源地图 | 将此选项设置为false会禁用源映射的生成,浏览器在调试过程中使用源映射将捆绑的 JavaScript 代码与项目中的源文件相关联。默认为true。 |
| 节点路径 | 此设置用于指定将搜索 Node.js 模块的位置。 |

这些选项要么通过设置环境变量来指定,要么通过创建一个.env文件来指定,这是我认为最可靠的方法。为了演示配置过程,我在projecttools文件夹中添加了一个名为.env的文件,并添加了清单 9-20 中所示的配置语句。

PORT=3500
HTTPS=true

Listing 9-20The Contents of the .env File in the projecttools Folder

我使用了PORT选项来指定用于接收请求的端口 3500,并使用了HTTPS选项来启用开发服务器中的 SSL。要查看更改的效果,停止开发工具并运行清单 9-21 中所示的命令来再次启动它们。

npm start

Listing 9-21Starting the React Development Tools

当初始构建过程完成时,打开的浏览器窗口将导航至https://localhost:3500。大多数浏览器会显示一个关于自签名证书的警告,然后在你点击高级链接(或其等效物)并告诉浏览器继续时显示 web 应用,如图 9-9 所示。

img/473159_1_En_9_Fig9_HTML.jpg

图 9-9

配置开发工具

调试 React 应用

并不是所有的问题都能被编译器或 linter 检测到,能够完美编译的代码可能会以意想不到的方式运行。有两种方法可以理解您的应用的行为,如下面几节所述。为了帮助演示调试特性,我在src文件夹中添加了一个名为Display.js的文件,并用它来定义清单 9-22 中所示的组件。

import React, {Component } from "react";

export class Display extends Component {

    constructor(props) {
        super(props);
        this.state = {
            counter: 1
        }
    }

    incrementCounter = () => {
        this.setState({ counter: this.state.counter + 1 });
    }

    render() {
        return (
            <div>
                <h2 className="bg-primary text-white text-center p-2">
                    <div>Props Value: { this.props.value }</div>
                    <div>Local Value: { this.state.counter } </div>
                </h2>
                <div className="text-center">
                    <button className="btn btn-primary m-2"
                            onClick={ this.props.callback }>
                        Parent
                    </button>
                    <button  className="btn btn-primary m-2"
                            onClick={ this.incrementCounter }>
                        Local
                    </button>
                </div>
            </div>
        )
    }
}

Listing 9-22The Contents of the Display.js File in the src Folder

该组件显示自己的状态属性和从其父组件接收的属性值。它显示两个button元素,其中一个更改 state 属性,另一个调用作为 prop 提供的回调。在清单 9-23 中,我替换了App组件的现有内容,为调试部分做准备。

import React, { Component } from "react";
import { Display } from "./Display";

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            city: "London"
        }
    }

    changeCity = () => {
        this.setState({ city: this.state.city === "London" ? "New York" : "London"})
    }

    render() {
        return (
            <Display value={ this.state.city } callback={ this.changeCity } />
        );
    }
}

Listing 9-23Replacing the Contents of the App.js File in the src Folder

当您保存对 JavaScript 文件的更改时,应用将被编译,您将看到如图 9-10 所示的内容。

img/473159_1_En_9_Fig10_HTML.jpg

图 9-10

向示例应用添加功能

注意

您可能会发现,当.env文件中的HTTPS选项设置为 true 时,浏览器不会自动更新。您可以手动重新加载浏览器以查看更改,或者禁用此选项并重新启动开发工具。

探索应用状态

React Devtools 浏览器扩展是探索 React 应用状态的优秀工具。谷歌 Chrome 和 Mozilla Firefox 都有可用的版本,项目的细节——包括对其他平台的支持和独立版本的细节——可以在 https://github.com/facebook/react-devtools 找到。安装完扩展后,您将在浏览器的“开发人员工具”窗口中看到一个附加选项卡,可通过按 F12 按钮访问该选项卡(这也是这些工具也称为 F12 工具的原因)。

F12 工具窗口中的 React 选项卡允许您浏览和更改应用的结构和状态。您可以看到提供应用功能的一组组件,以及它们的状态数据和属性。

对于示例应用,如果您打开 React 选项卡并在左窗格中展开应用结构,您将在左窗格中看到AppDisplay组件,它们与应用呈现的 HTML 元素的视图一起显示。当您在左侧页面中选择一个组件时,其属性和状态数据会显示在右侧窗格中,如图 9-11 所示。

img/473159_1_En_9_Fig11_HTML.jpg

图 9-11

使用 React Devtools 探索组件

如果您单击浏览器窗口中的按钮,您将看到 React Devtools 显示的值发生变化,反映了应用的实时状态。您还可以单击一个状态数据值,并通过 React Devtools 更改其值,这允许直接操作应用的状态。

小费

Redux 数据存储包也有调试工具,我在第十九章描述了它,它通常用于管理复杂项目的数据。

使用浏览器调试器

现代浏览器包括复杂的调试器,可用于控制应用的执行并检查其状态。React 开发工具包括对创建源映射的支持,这允许浏览器将它正在执行的缩小和捆绑的代码与高效调试所需的开发人员友好的源代码相关联。

有些浏览器允许您使用这些源代码映射来浏览应用的源代码,并创建断点,当到达断点时,断点将暂停应用的执行,并将控制权交给调试器。当我写这篇文章时,创建断点的能力是一个脆弱的功能,在 Chrome 上不起作用,在其他浏览器上也有混合的可靠性。因此,将应用控制权传递给调试器的最可靠方式是使用 JavaScript debugger关键字,如清单 9-24 所示。

import React, { Component } from "react";
import { Display } from "./Display";

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            city: "London"
        }
    }

    changeCity = () => {
        debugger
        this.setState({ city: this.state.city === "London" ? "New York" : "London"})
    }

    render() {
        return (
            <Display value={ this.state.city } callback={ this.changeCity } />
        );
    }
}

Listing 9-24Triggering the Debugger in the App.js File in the src Folder

为了有效地使用调试器,禁用.env文件中的 HTTPS 选项,如清单 9-25 所示。如果您不禁用此选项,您将只能看到由 Babel 生成的代码,而不能看到您的原始源代码。

PORT=3500

HTTPS=false

Listing 9-25Disabling Secure Connections in the .env File in the projecttools Folder

停止开发工具,并通过运行projecttools文件夹中清单 9-26 所示的命令再次启动它们。

npx start

Listing 9-26Starting the Development Tools

应用将正常执行,但是当单击父按钮并调用changeCity方法时,浏览器将遇到debugger关键字并暂停应用的执行。然后你可以使用 F12 工具窗口中的控件来检查执行停止点的变量及其值,并手动控制执行,如图 9-12 所示。浏览器正在执行由开发工具创建的缩小和捆绑的代码,但是显示来自源地图的相应代码。

img/473159_1_En_9_Fig12_HTML.jpg

图 9-12

使用浏览器调试器

小费

大多数浏览器会忽略debugger关键字,除非 F12 工具窗口是打开的,但是在调试会话结束时删除它是一个好习惯。

摘要

在本章中,我描述了用create-react-app包创建的 React 项目的结构,并解释了 React 开发中使用的文件和文件夹的用途。我还解释了如何使用 React 开发工具,如何在浏览器中捆绑应用,错误显示和 linter 如何帮助避免常见问题,以及如何在没有收到预期结果时调试应用。在下一章中,我将介绍组件,它们是 React 应用的关键构件。

十、组件和属性

在这一章中,我描述了 React 应用中的关键构件:组件。在本章中,我主要关注最简单的组件类型,即无状态组件。在第十一章中,我描述了更复杂的替代方案,有状态组件。在本章中,我还解释了 props 特性是如何工作的,它允许一个组件向另一个组件提供呈现其内容所需的数据,以及在发生重要事情时应该调用的函数。表 10-1 将无状态组件和属性放在上下文中。

表 10-1

将无状态组件和属性放在上下文中

|

问题

|

回答

|
| --- | --- |
| 它们是什么? | 组件是 React 应用中的关键构件。无状态组件是 JavaScript 函数,它呈现 React 可以呈现给用户的内容。Props 是一个组件向另一个组件提供数据的方式,这样它就可以调整它所呈现的内容。 |
| 它们为什么有用? | 组件很有用,因为它们通过组合 JavaScript、HTML 和其他组件来提供对创建特性的 React 支持的访问。属性很有用,因为它们允许组件修改它们产生的内容。 |
| 它们是如何使用的? | 无状态组件被定义为返回 React 元素的 JavaScript 函数,通常使用 JSX 格式的 HTML 来定义。属性被定义为元素的属性。 |
| 有什么陷阱或限制吗? | React 要求组件以特定的方式运行,例如返回单个 React 元素并总是返回一个结果,并且需要一段时间来适应这些限制。props 最常见的缺陷是在需要 JavaScript 表达式时指定文字值。 |
| 还有其他选择吗? | 组件是 React 应用中的关键构建块,没有办法避免使用它们。正如在第十四章和第三部分中所描述的,在更大更复杂的项目中,props 有其他的选择。 |

表 10-2 总结了本章内容。

表 10-2

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 向 React 应用添加内容 | 定义一个返回 HTML 元素或调用React.createElement方法的函数 | 1–9 |
| 向 React 应用添加附加功能 | 定义组件,并使用与组件名称相对应的元素将它们组合成父子关系 | 10–14 |
| 配置子组件 | 应用组件时定义属性 | 15–19 |
| 为数据数组中的每个对象呈现 HTML 元素 | 使用 map 方法创建元素,确保它们有一个key属性 | 20–24 |
| 从一个组件呈现多个元素 | 使用React.Fragment元素或使用不带标签的元素 | 25–28 |
| 不呈现任何内容 | 返回null | Twenty-nine |
| 从子组件接收通知 | 用功能属性配置组件 | 31–34 |
| 给孩子传递属性 | 使用从父代接收的属性值或使用析构运算符 | 35–39 |
| 定义默认属性值 | 使用defaultProps属性 | 40, 41 |
| 检查属性类型 | 使用propTypes属性 | 42–44 |

为本章做准备

为了创建本章的示例项目,打开一个新的命令提示符,导航到一个方便的位置,并运行清单 10-1 中所示的命令。

小费

你可以从 https://github.com/Apress/pro-react-16 下载本章以及本书其他章节的示例项目。

npx create-react-app components

Listing 10-1Creating the Example Project

运行清单 10-2 中所示的命令,导航到项目文件夹,并将引导包添加到项目中。

cd components
npm install bootstrap@4.1.2

Listing 10-2Adding the Bootstrap CSS Framework

为了在应用中包含引导 CSS 样式表,将清单 10-3 中所示的语句添加到index.js文件中,该文件可以在src文件夹中找到。

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

import 'bootstrap/dist/css/bootstrap.css';

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

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

Listing 10-3Including Bootstrap in the index.js File in the src Folder

使用命令提示符,运行components文件夹中清单 10-4 所示的命令来启动开发工具。

npm start

Listing 10-4Starting the Development Tools

一旦项目的初始准备工作完成,一个新的浏览器窗口将打开并显示 URL http://localhost:3000并显示如图 10-1 所示的占位符内容。

img/473159_1_En_10_Fig1_HTML.jpg

图 10-1

运行示例应用

了解组件

从组件开始的最佳方式是定义一个组件,然后看看它是如何工作的。在清单 10-5 中,我用一个简单的组件替换了App.js文件的内容。

export default function App() {
    return "Hello Adam";
}

Listing 10-5Defining a Component in the App.js File in the src Folder

这是一个无状态组件的例子,它就像一个组件一样简单:一个函数返回 React 将显示给用户的内容,这被称为呈现。当应用启动时,index.js文件中的代码被执行,包括呈现App组件的语句。React 调用该函数并将结果显示给用户,如图 10-2 所示。

img/473159_1_En_10_Fig2_HTML.jpg

图 10-2

定义和应用组件

尽管结果可能很简单,但它揭示了组件的主要用途,即向用户提供 React 显示内容。

呈现 HTML 内容

当组件呈现一个字符串值时,它作为文本内容包含在父元素中。当组件返回 HTML 内容时,它们变得更加有用,利用 JSX 和它允许 HTML 与 JavaScript 代码混合的方式,这是最容易做到的。在清单 10-6 中,我更改了组件的结果,使其呈现 HTML 的一个片段。

小费

使用 JSX 时,必须从react模块声明对 React 的依赖,如清单所示。如果你忘记了,你会收到一个警告。

import React from "react";

export default function App() {
    return  <h1 className="bg-primary text-white text-center p-2">
                Hello Adam
            </h1>
}

Listing 10-6Rendering HTML in the App.js File in the src Folder

您记得在组件的函数中使用return关键字来呈现结果。这可能会令人感到尴尬,但是请记住,JSX 文件中的 HTML 片段被转换为对createElement方法的调用,这将产生一个 React 可以向用户显示的对象。

当您考虑在构建过程中用createElement方法替换 HTML 片段后代码看起来是什么样时,使用return关键字是有意义的。

...
import React from "react";

export default function App() {
    return React.createElement("h1",
                { className: "bg-primary text-white text-center p-2" },
                "Hello Adam");
}
...

component 函数返回来自React.createElement方法的结果,该方法是 React 可以用来向域对象模型(DOM)添加内容的元素。

如果你想在与关键字return不同的一行开始 HTML,那么你可以用括号将结果括起来,如清单 10-7 所示。

import React from "react";

export default function App() {
    return (
        <h1 className="bg-primary text-white text-center p-2">
            Hello Adam
        </h1>
    )
}

Listing 10-7Using Parentheses in the App.js File in the src Folder

这允许 HTML 元素一致地缩进,尽管悬空的()字符可能会让一些开发人员感到别扭。

功能组件也可以使用粗箭头语法来定义,该语法省略了关键字return,如清单 10-8 所示。

import React from "react";

export default () =>

    <h1 className="bg-primary text-white text-center p-2">
        Hello Adam
    </h1>

Listing 10-8Using a Fat Arrow Function in the App.js File in the src Folder

粗箭头函数在没有名称的情况下导出,这在示例应用中有效,因为从App.js文件导入组件的index.js文件中的语句使用默认导出,如下所示:

...
import App from './App';
...

按名称导出一个胖箭头函数并作为缺省值需要一个额外的语句,如清单 10-9 所示。

import React from "react";

 export const App = () =>
    <h1 className="bg-primary text-white text-center p-2">
        Hello Adam
    </h1>

export default App;

Listing 10-9Creating a Named and Default Export in the App.js File in the src Folder

粗箭头函数被分配给一个按名称导出的const,一个单独的语句使用该名称创建缺省导出,这允许组件按名称导入并作为缺省值。

注意

我之所以包括这个例子,是因为模块导出会引起混淆,但是在实际项目中,它们要么使用命名导出,要么使用默认导出,并且不需要适应两种工作方式。我更喜欢使用命名导出,这是我在本书的例子中采用的方法。

我在这一章中使用了常规函数,并在有助于使 HTML 内容更具可读性的地方使用了括号,但是这一节中的所有例子都产生了相同的结果,如图 10-3 所示。

img/473159_1_En_10_Fig3_HTML.jpg

图 10-3

返回 HTML 内容

渲染其他组件

React 最重要的特性之一是,由一个组件呈现的内容可以包含其他组件,从而允许组合这些特性来创建复杂的应用。我在src文件夹中添加了一个名为Message.js的文件,并用它来定义清单 10-10 中所示的组件。

import React from "react";

export function Message() {
    return  <h4 className="bg-success text-white text-center p-2">
                This is a message
            </h4>
}

Listing 10-10The Contents of the Message.js File in the src Folder

Message组件呈现一个包含消息的h4元素。在清单 10-11 中,我已经更新了App组件,以便它将Message内容作为其内容的一部分呈现。

import React from "react";

import { Message } from "./Message";

export default function App() {

    return  (
        <div>
            <h1 className="bg-primary text-white text-center p-2">
                Hello Adam
            </h1>
            <Message />
        </div>
    )
}

Listing 10-11Rendering Another Component in the App.js File in the src Folder

import语句声明了对Message组件的依赖,该组件使用Message元素呈现。当 React 接收到由App组件呈现的内容时,它将包含Message元素,它将通过调用Message组件的函数并用其呈现的内容替换Message元素来处理该元素,产生如图 10-4 所示的结果。

img/473159_1_En_10_Fig4_HTML.jpg

图 10-4

呈现其他内容

当一个组件像这样使用另一个组件时,就形成了父子关系。在这个例子中,App组件是Message组件的父组件,而Message组件是App组件的子组件。通过为子组件定义多个元素,一个组件可以多次应用同一个组件,如清单 10-12 所示。

import React from "react";
import { Message } from "./Message";

export default function App() {
    return  (
        <div>
            <h1 className="bg-primary text-white text-center p-2">
                Hello Adam
            </h1>
            <Message />
            <Message />
            <Message />
        </div>
    )
}

Listing 10-12Applying a Child Component in the App.js File in the src Folder

每当 React 遇到Message元素时,它就会调用Message组件,并使用其呈现的内容来替换Message元素,如图 10-5 所示。

img/473159_1_En_10_Fig5_HTML.jpg

图 10-5

应用多个子项

一个组件可以有不同类型的子组件,这意味着一个组件可以利用多个组件提供的功能。我用清单 10-13 中所示的代码,通过将名为Summary.js的文件添加到src文件夹中,创建了另一个简单的组件。

import React from "react";

export function Summary() {
    return  <h4 className="bg-info text-white text-center p-2">
                This is a summary
            </h4>
}

Listing 10-13The Contents of the Summary.js File in the src Folder

在清单 10-14 中,我更新了App组件以声明对Summary组件的依赖,并使用Summary元素呈现其内容。

import React from "react";
import { Message } from "./Message";

import { Summary } from "./Summary";

export default function App() {
    return (
        <div>
            <h1 className="bg-primary text-white text-center p-2">
                Hello Adam
            </h1>
            <Message />
            <Message />
            <Message />
            <Summary />
        </div>
    )
}

Listing 10-14Adding a Child Component in the App.js File in the src Folder

当 React 处理由App组件呈现的内容时,它遇到子组件的元素,调用它们的函数,并用它们呈现的内容替换MessageSummary元素。结果如图 10-6 所示。

img/473159_1_En_10_Fig6_HTML.jpg

图 10-6

使用不同的子组件

理解属性

当每个组件呈现相同的内容时,能够呈现来自多个子组件的内容就没有那么有用了。幸运的是,React 支持props——properties的缩写——它允许父组件向其子组件提供数据,子组件可以使用这些数据来呈现它们的内容。在接下来的章节中,我将解释属性是如何工作的,并演示它们的不同用法。

在父组件中定义属性

通过向应用组件的自定义 HTML 元素添加属性来定义属性。属性的名称是属性的名称,值可以是静态值或表达式。在清单 10-15 中,我为App组件使用的Message元素添加了属性。

import React from "react";
import { Message } from "./Message";
import { Summary } from "./Summary";

export default function App() {
    return  (
        <div>
            <h1 className="bg-primary text-white text-center p-2">
                Hello Adam
            </h1>
            <Message greeting="Hello" name="Bob" />
            <Message greeting="Hola" name={ "Alice" + "Smith" } />
            <Message greeting="Hi there" name="Dora" />
            <Summary />
        </div>
    )
}

Listing 10-15Defining Props in the App.js File in the src Folder

我为每个Message组件提供了两个属性greetingname。大多数属性值都是静态值,用文字字符串表示。第二个Message元素上的greeting属性的值是一个表达式,它连接了两个字符串值。(您将看到一个关于清单 10-15 中表达式的 linter 警告,因为串联字符串文字值是 linter 配置要检测的不良实践之一。在本章中,可以忽略棉绒警告。)

定义属性

Props 可用于将静态值或动态表达式的结果传递给子组件。静态值按字面引用,就像这样:

...
<Message greeting="Hello" name="Bob" />
...

这个属性为子组件的name属性提供了值Bob。如果您想使用 JavaScript 表达式的结果作为属性的值,那么使用数据绑定表达式,如下所示:

...
<Message greeting="Hola" name={ "Alice" + "Smith" } />
...

React 将计算表达式,并将结果(在本例中是两个字符串的连接)用作属性的值。一个常见的错误是将 JavaScript 表达式放在引号中,就像这样:

...
<Message greeting="Hola" name="{ "Alice" + "Smith" }" />
...

React 会将此解释为使用静态值{ "Alice" + "Smith" }作为属性值的请求。用表情做属性的时候,一定要记得不要用引号。如果您不想使用 JSX,而想使用纯 JavaScript 创建 React 元素,那么 props 作为第二个参数提供给createElement方法,如下所示:

...
React.createElement(Message, { greeting: "Hola",  name: "Alice" + "Smith"})
...

如果你没有得到你期望的结果,在 JSX 或纯 JavaScript 中,React Devtools 浏览器扩展(在第九章中描述)可以显示应用中每个组件收到的属性,这使得很容易看到哪里出错了。

在子组件中接收属性

通过定义一个名为props的参数,Props 被接收到组件中(尽管这只是一个约定,您可以给参数取任何合法的 JavaScript 名称)。props对象对于每个属性都有一个属性,该属性被赋予了属性值。举个例子,这些属性来自清单 10-15 :

...
<Message greeting="Hello" name="Bob" />
...

会被翻译成这样一个物体:

...
{
    greeting: "Hello",
    name: "Bob"
}
...

在清单 10-16 中,我修改了Message组件,这样它定义了一个属性参数,并在结果中使用父组件提供的值。

import React from "react";

export function Message(props) {

    return  <h4 className="bg-success text-white text-center p-2">
                {props.greeting}, {props.name}
            </h4>
}

Listing 10-16Using Props in the Message.js File in the src Folder

子组件不需要担心属性值是静态指定的还是用表达式指定的,它像其他 JavaScript 对象一样使用这些属性。在清单中,我在表达式中使用了greetingname属性来设置组件呈现的h4元素的内容,产生了如图 10-7 所示的结果。

img/473159_1_En_10_Fig7_HTML.jpg

图 10-7

使用属性渲染内容

结合 JavaScript 和 Props 来呈现内容

清单 10-16 中的App组件定义的每个Message元素的属性值会产生不同的内容,允许父组件以不同的方式使用相同的功能。

选择性呈现内容

组件可以使用 JavaScript if关键字来检查属性,并根据其值呈现不同的内容。在清单 10-17 中,我使用了if语句来改变由Message组件呈现的内容。

import React from "react";

export function Message(props) {
    if (props.name === "Bob") {
        return  <h4 className="bg-warning p-2">{props.greeting}, {props.name}</h4>
    } else {
        return  <h4 className="bg-success text-white text-center p-2">
                {props.greeting}, {props.name}
            </h4>
    }
}

Listing 10-17Selectively Rendering in the Message.js File in the src Folder

如果name属性的值为Bob,组件将呈现一个具有不同类成员关系的h4元素,如图 10-8 所示。

img/473159_1_En_10_Fig8_HTML.jpg

图 10-8

使用 if 语句选择内容

这种类型的选择性呈现,其中只有属性的值发生变化,可以通过将属性的值从 HTML 的其余部分中分离出来,以更少的重复来表示,如清单 10-18 所示。

import React from "react";

export function Message(props) {

    let classes = props.name === "Bob" ? "bg-warning p-2"
        : "bg-success text-white text-center p-2";

    return  <h4 className={ classes }>
                {props.greeting}, {props.name}
            </h4>
}

Listing 10-18Selecting a Property Value in the Message.js File in the src Folder

我已经使用了 JavaScript 三元条件操作符来选择将分配给h4元素的类,并使用一个用于className属性的表达式来应用这些类。结果与清单 10-17 相同,但是没有复制 HTML 元素的不变部分。

当一个组件需要从一个更复杂的列表中选择内容时,可以使用一个switch语句,如清单 10-19 所示。

import React from "react";

export function Message(props) {
    let classes;
    switch (props.name) {
        case "Bob":
            classes = "bg-warning p-2";
            break;
        case "Dora":
            classes = "bg-secondary text-white text-center p-2"
            break;
        default:
            classes = "bg-success text-white text-center p-2"
    }
    return  <h4 className={ classes }>
                {props.greeting}, {props.name}
            </h4>
}

Listing 10-19Using a switch Statement in the Message.js File in the src Folder

这个例子使用props.name值上的switch语句来选择h4元素的类,产生如图 10-9 所示的结果。

img/473159_1_En_10_Fig9_HTML.jpg

图 10-9

使用 switch 语句选择内容

渲染数组

组件通常必须为数组中的每个元素创建 HTML 元素,通常以列表或表格中的行的形式显示项目。处理数组所需的技术会引起混淆,值得仔细研究。为了做好准备,我更新了App组件,以便它用一个属性配置Summary组件,如清单 10-20 所示。(为了保持示例简单,我还删除了一些元素。)

import React from "react";

//import { Message } from "./Message";

import { Summary } from "./Summary";

export default function App() {
    return  (
        <div>
            <h1 className="bg-primary text-white text-center p-2">
                Hello Adam
            </h1>
            <Summary names={ ["Bob", "Alice", "Dora"]} />
        </div>
    )
}

Listing 10-20Adding a Prop in the App.js File in the src Folder

names属性为Summary组件提供了一个字符串值数组。在清单 10-21 中,我修改了由Summary组件呈现的内容,这样它就可以为数组中的每个值生成元素。

import React from "react";

function createInnerElements(names) {

    let arrayElems  = [];
    for (let i = 0; i < names.length; i++) {
        arrayElems.push(
            <div>
                {`${names[i]} contains ${names[i].length} letters`}
            </div>
        )
    }
    return arrayElems;

}

export function Summary(props) {
    return  <h4 className="bg-info text-white text-center p-2">
                { createInnerElements(props.names)}
            </h4>
}

Listing 10-21Rendering an Array in the Summary.js File in the src Folder

组件函数使用一个表达式来设置h4元素的内容,这是通过调用createInnerElements函数来完成的。createInnerElements函数使用 JavaScript for循环来枚举names数组的内容,并将div元素添加到结果数组中。

...
arrayElems.push(<div>{`${names[i]} contains ${names[i].length} letters`}</div>)
...

每个div元素的内容由另一个表达式设置,该表达式使用模板字符串创建特定于数组中每个元素的消息。div元素的数组作为createInnerElements函数的结果返回,并用作h4元素的内容,产生如图 10-10 所示的结果。

img/473159_1_En_10_Fig10_HTML.jpg

图 10-10

为数组中的对象创建 React 元素

使用映射方法处理数组对象

虽然for循环是大多数程序员用来枚举数组的方式,但它并不是 React 中处理数组的最优雅的方式。在第四章中描述的map方法可以用来将数组中的对象转换成 HTML 元素,如清单 10-22 所示。

import React from "react";

function createInnerElements(names) {
    return names.map(name =>
        <div>
            {`${name} contains ${name.length} letters`}
        </div>
    )
}

export function Summary(props) {
    return  <h4 className="bg-info text-white text-center p-2">
                { createInnerElements(props.names)}
            </h4>
}

Listing 10-22Transforming an Array in the Summary.js File in the src Folder

map方法的参数是为数组中的每个对象调用的函数。每次调用传递给 map 方法的函数时,数组中的下一项都会传递给函数,我用它来创建表示该对象的元素。每次调用该函数的结果都被添加到一个数组中,该数组用作map结果。清单 10-22 中的代码产生与清单 10-21 相同的结果。

小费

您不必在map方法中使用粗箭头函数,但是它会产生一个更简洁的组件。

既然createInnerElement函数包含一行代码,我可以通过将创建内部元素的语句移到组件函数中来进一步简化组件,如清单 10-23 所示。

import React from "react";

export function Summary(props) {
    return (
        <h4 className="bg-info text-white text-center p-2">
            {   props.names.map(name =>
                    <div>
                        {`${name} contains ${name.length} letters`}
                    </div>
                )
            }
        </h4>
    )
}

Listing 10-23Simplifying the Code in the Summary.js File in the src Folder

这种改变不会改变输出,并且产生与清单 10-21 和清单 10-22 相同的结果。

使用 Map 方法时接收其他参数

在清单 10-23 中,我传递给map方法的函数接收当前数组对象作为它的参数。map 方法还提供了两个附加参数:数组中当前对象的从零开始的索引和完整的对象数组。您可以在本章后面的“呈现多个元素”一节中看到数组索引的示例。

添加关键属性

要完成这个示例,还需要做最后一项更改。React 需要将一个key prop 添加到为数组中的对象生成的元素中,以便可以有效地处理变化,正如我在第十三章中解释的。属性的值应该是一个表达式,其值在数组中唯一地标识对象,如清单 10-24 所示。

import React from "react";

export function Summary(props) {
    return (
        <h4 className="bg-info text-white text-center p-2">
            {   props.names.map(name =>
                    <div key={ name }>
                        {`${name} contains ${name.length} letters`}
                    </div>
                )
            }
        </h4>
    )
}

Listing 10-24Adding the Key Prop in the Summary.js File in the src Folder

我使用了name变量的值,当传递给map方法的函数被调用时,数组中的每个对象都被分配给这个变量,它允许 React 区分从数组对象创建的元素。

React 将显示没有 key prop 的元素,如本节前面的示例所示,但是浏览器的 JavaScript 控制台中将显示一条警告。

呈现多个元素

React 要求组件返回单个顶级元素,尽管该元素可以包含应用所需的任意多个其他元素。例如,Summary组件返回一个顶级的h4元素,它包含一系列为names属性中的元素生成的div元素。

有时候,对单个顶级元素的需求会导致问题。HTML 规范对如何组合元素施加了限制,这可能与单个元素的 React 要求相冲突。为了演示这个问题,我修改了由App组件呈现的内容,使其包含一个表格,其中每个tr元素的内容由一个子组件生成,如清单 10-25 所示。

import React from "react";
import { Summary } from "./Summary";

let names = ["Bob", "Alice", "Dora"]

export default function App() {
    return  (
        <table className="table table-sm table-striped">
            <thead>
                <tr><th>#</th><th>Name</th><th>Letters</th></tr>
            </thead>
            <tbody>
                { names.map((name, index) =>
                        <tr key={ name }>
                            <Summary index={index} name={name} />
                        </tr>
                )}
            </tbody>
        </table>
    )
}

Listing 10-25Rendering a Table in the App.js File in the src Folder

Summary组件通过indexname属性。在清单 10-26 中,我已经更新了Summary组件,这样它就可以使用属性值生成一系列表格单元格。

import React from "react";

export function Summary(props) {
    return  <td>{ props.index + 1} </td>
            <td>{ props.name } </td>
            <td>{ props.name.length } </td>
}

Listing 10-26Rendering Table Cells in the Summary.js File in the src Folder

Summary组件呈现一组td元素,因为这是 HTML 规范要求的td元素的子元素。但是当您保存更改时,您会看到以下错误:

...
Syntax error: src/Summary.js: Adjacent JSX elements must be wrapped
 in an enclosing tag (5:12)

  3 | export function Summary(props) {
  4 |     return  <td>{ props.index + 1} </td>
> 5 |             <td>{ props.name } </td>
    |             ^
  6 |             <td>{ props.name.length } </td>
  7 | }
...

该错误消息表明组件呈现的内容不符合单个顶级元素的 React 要求。没有一个 HTML 元素可以用来包装td元素,并且仍然是对表的合法添加。对于这些情况,React 提供了一个特殊的元素,如清单 10-27 所示。

import React from "react";

export function Summary(props) {
    return  <React.Fragment>
                <td>{ props.index + 1} </td>
                <td>{ props.name } </td>
                <td>{ props.name.length } </td>
            </React.Fragment>
}

Listing 10-27Wrapping Elements in the Summary.js File in the src Folder

React 处理Summary组件渲染的元素时,会丢弃React.Fragment元素,用剩余的内容替换应用了该组件的Summary元素,如图 10-11 所示。

img/473159_1_En_10_Fig11_HTML.jpg

图 10-11

呈现多个元素

对于这些情况,React 支持另一种语法,即使用不带标记名的封闭元素,如清单 10-28 所示。

import React from "react";

export function Summary(props) {
    return  <>
                <td>{ props.index + 1} </td>
                <td>{ props.name } </td>
                <td>{ props.name.length } </td>
            </>
}

Listing 10-28Wrapping Elements in the Summary.js File in the src Folder

这相当于列出 10-27 并产生相同的结果。我在本书的例子中使用了React.Fragment或者将多个元素包装在一个div中,这样就产生了 HTML 元素的合法组合。

不呈现任何内容

一个组件必须总是返回一个结果,即使它没有为 React 显示产生任何内容。在这些情况下,组件的函数应该返回null,在清单 10-29 中,我修改了Summary组件,这样当它的name属性的长度小于四个字符时,它就不会产生任何内容。

import React from "react";

export function Summary(props) {
    if (props.name.length >= 4) {
        return  <React.Fragment>
                    <td>{ props.index + 1} </td>
                    <td>{ props.name } </td>
                    <td>{ props.name.length } </td>
                </React.Fragment>
    } else {
        return null;
    }
}

Listing 10-29Rendering No Content in the Summary.js File in the src Folder

父组件仍然会应用Summary元素三次,每次都会导致Summary组件的函数被调用,但是只有两次调用会产生结果,如图 10-12 所示。

img/473159_1_En_10_Fig12_HTML.jpg

图 10-12

不呈现任何内容

试图改变属性

属性是只读的,不能被组件更改。当 React 创建props对象时,它会配置其属性,以便在进行任何更改时显示错误。在清单 10-30 中,我向Summary组件添加了一条语句,该语句更改了name属性的值。

import React from "react";

export function Summary(props) {
    props.name = `Name: ${props.name}`;
    if (props.name.length >= 4) {
        return  <React.Fragment>
                    <td>{ props.index + 1} </td>
                    <td>{ props.name } </td>
                    <td>{ props.name.length } </td>
                </React.Fragment>
    } else {
        return null;
    }
}

Listing 10-30Changing a Prop Value in the Summary.js File in the src Folder

当您保存更改并且浏览器重新加载时,您将看到如图 10-13 所示的错误消息。这是一个运行时错误,这意味着编译器在命令提示符下不会显示任何警告。

img/473159_1_En_10_Fig13_HTML.jpg

图 10-13

试图修改一个属性

小费

当使用第八章中描述的过程为部署构建应用时,不会显示此错误,这意味着您应该在开发期间进行彻底的测试,以确保您的组件不会无意中试图更改属性。

使用功能属性

到目前为止,我在本章中使用的所有属性都是数据属性,它为子组件提供了一个只读数据值。React 还支持函数 props,其中父组件为子组件提供一个函数,子组件可以调用该函数来通知父组件发生了重要的事情。父组件可以通过更改数据属性的值来响应,这将触发更新,并允许子组件向用户呈现更新的内容。

为了展示这是如何工作的,我在包含App组件的文件中定义了一个函数,它改变了用于Summary元素的name属性的值的顺序,如清单 10-31 所示。

import React from "react";
import { Summary } from "./Summary";

import ReactDOM from "react-dom";

let names = ["Bob", "Alice", "Dora"]

function reverseNames() {

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

}

export default function App() {
    return  (
        <table className="table table-sm table-striped">
            <thead>
                <tr><th>#</th><th>Name</th><th>Letters</th></tr>
            </thead>
            <tbody>
                { names.map((name, index) =>
                    <tr key={ name }>
                        <Summary index={index} name={name}
                            reverseCallback={reverseNames} />
                    </tr>
                )}
            </tbody>
        </table>
    )
}

Listing 10-31Defining a Change Function in the App.js File in the src Folder

我定义的函数叫做reverseNames,它使用 JavaScript reverse方法来反转names数组中值的顺序。reverseNames函数作为名为reverseCallbackprop的值提供给Summary组件,如下所示:

...
<Summary index={index} name={name} reverseCallback={reverseNames} />
...

Summary组件将接收一个具有三个属性的 prop 对象:index prop 提供当前对象的索引,该对象由map方法处理,name prop 提供来自数组的当前值,reverseCallback prop 提供反转数组内容顺序的函数。在清单 10-32 中,我已经更新了Summary组件,以利用它作为属性接收的函数。(我还删除了试图改变属性值的语句,并删除了阻止组件呈现短名称内容的if语句。)

import React from "react";

export function Summary(props) {
    return (
        <React.Fragment>
            <td>{ props.index + 1} </td>
            <td>{ props.name } </td>
            <td>{ props.name.length } </td>
            <td>
                <button className="btn btn-primary btn-sm"
                    onClick={ props.reverseCallback }>
                        Change
                </button>
            </td>
        </React.Fragment>
    )
}

Listing 10-32Using a Function Prop in the Summary.js File in the src Folder

该组件呈现一个button元素,其onClick属性选择它从其父元素接收的函数属性。我在第十二章中描述了onClick属性,但是,正如您在前面的章节中看到的,这个属性告诉 React 当用户点击一个元素时如何响应,在这种情况下,表达式告诉 React 调用reverseCallback属性,这是由父组件提供的功能。

结果是点击一个button元素导致 React 调用在App.js文件中定义的changeValues函数,这颠倒了用于name属性的值的顺序,产生如图 10-14 所示的结果。

img/473159_1_En_10_Fig14_HTML.jpg

图 10-14

使用作为属性接收的函数

理解 Update 语句

Summary组件调用函数 prop 时,调用reverseCallback函数,并执行清单 10-31 中的语句:

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

render方法用于将组件的内容添加到浏览器显示的文档对象模型(DOM)中,并在index.js文件中用于启动应用;在第十三章中有描述。这不是一个通常直接使用的特性,但是我需要能够执行更新来响应被调用的函数 prop。我在第十一章中描述了通常用于执行更新的特性。目前,只需知道调用这个方法会更新显示给用户的 HTML 元素,反映用于属性值的数据值的变化。

使用参数调用属性函数

在清单 10-32 中,onClick属性的表达式指定了函数 prop,如下所示:

...
<button className="btn btn-primary btn-sm" onClick={ props.reverseCallback } >
    Change
</button>
...

当函数被一个表达式选中时,它将被传递一个事件对象,我在第十二章中描述了这个对象,它提供了被调用的函数以及触发事件的 HTML 元素的细节。

这在调用 function prop 时并不总是有用的,因为它要求父组件对子组件有足够的了解才能理解事件并相应地采取行动。通常,更有帮助的方法是为函数提供一个自定义参数,直接向父组件提供它需要的细节。在清单 10-33 中,我向App.js文件添加了一个函数,将指定的名称移动到数组的前面,并更新了App组件,这样它就可以使用一个属性将函数传递给其子节点。

import React from "react";
import { Summary } from "./Summary";
import ReactDOM from "react-dom";

let names = ["Bob", "Alice", "Dora"]

function reverseNames() {
    names.reverse();
    ReactDOM.render(<App />, document.getElementById('root'));
}

function promoteName(name) {

    names = [name, ...names.filter(val => val !== name)];
    ReactDOM.render(<App />, document.getElementById('root'));

}

export default function App() {
    return (
        <table className="table table-sm table-striped">
            <thead>
                <tr><th>#</th><th>Name</th><th>Letters</th></tr>
            </thead>
            <tbody>
                { names.map((name, index) =>
                    <tr key={ name }>
                        <Summary index={index} name={name}
                            reverseCallback={reverseNames}
                            promoteCallback={promoteName} />
                    </tr>
                )}
            </tbody>
        </table>
    )
}

Listing 10-33Adding a Function in the App.js File in the src Folder

新函数接收应该移动到数组开头的名称作为它的参数。在清单 10-34 中,我向由Summary组件呈现的内容添加了另一个button元素,并使用onClick属性来调用新的函数 prop。

import React from "react";

export function Summary(props) {
    return (
        <React.Fragment>
            <td>{ props.index + 1} </td>
            <td>{ props.name } </td>
            <td>{ props.name.length } </td>
            <td>
                <button className="btn btn-primary btn-sm"
                    onClick={ props.reverseCallback }>
                        Change
                </button>
                <button className="btn btn-info btn-sm m-1"
                    onClick={ () => props.promoteCallback(props.name) }>
                            Promote
                </button>
            </td>
        </React.Fragment>
    )
}

Listing 10-34Invoking a Function Prop in the Summary.js File in the src Folder

不是让App组件计算出选择了哪个名称,而是用一个参数调用函数 prop。

...
<button className="btn btn-info btn-sm m-1"
        onClick={ () => props.promoteCallback(props.name) }>
    Promote
</button>
...

onClick表达式是一个粗箭头函数,在被调用时调用函数 prop。像这样定义一个函数是很重要的,如果你只是简单地在表达式中直接指定函数 prop,你不会得到你期望的结果,正如侧栏中所描述的。点击其中一个升级按钮会将相应的名称移动到数组的第一个位置,使其显示在表格的顶部,如图 10-15 所示。

img/473159_1_En_10_Fig15_HTML.jpg

图 10-15

使用参数调用函数属性

避免过早调用的陷阱

当您需要使用参数调用函数属性时,您应该始终指定一个调用属性的粗箭头函数,如下所示:

...
<button onClick={ () => props.promoteCallback(props.name) }>
    Promote
</button>
...

您几乎肯定会忘记至少这样做一次,并在表达式中直接调用函数 prop,就像这样:

...
<button onClick={ props.promoteCallback(props.name) }>
    Promote
</button>
...

React 将在组件呈现其内容时计算表达式,这将调用 prop,即使用户没有单击 button 元素。这很少是预期的效果,可能会导致意外的行为或产生错误,这取决于调用 prop 时它做了什么。例如,在清单 10-34 中的组件的情况下,效果是创建一个“超过最大更新深度”错误,这是因为函数 prop 要求 React 重新呈现组件,这导致Summary组件呈现内容,这再次调用 prop。这一直持续到 React 停止执行并报告错误。

将属性传递给子组件

React 应用是通过组合组件创建的,创建了一系列父子关系。这种安排通常要求组件从其父组件接收数据值或回调函数,并将其传递给其子组件。为了演示属性是如何传递的,我在src文件夹中添加了一个名为CallbackButton.js的文件,并用它来定义清单 10-35 中所示的组件。

import React from "react";

export function CallbackButton(props) {
    return (
        <button className={`btn btn-${props.theme} btn-sm m-1`}
                onClick={ props.callback }>
            { props.text}
        </button>
    )
}

Listing 10-35The Contents of the CallbackButton.js File in the src Folder

这个组件呈现一个button元素,它的文本内容是使用名为text的属性设置的,当点击这个元素时,它会调用通过名为callback的属性提供的一个函数。还有一个用于为button元素选择引导 CSS 样式的theme属性。

在清单 10-36 中,我已经更新了Summary组件以使用CallbackButton组件,它通过从其父组件传递属性并添加自己的额外属性来进行配置。

import React from "react";

import { CallbackButton } from "./CallbackButton";

export function Summary(props) {
    return  (
        <React.Fragment>
            <td>{ props.index + 1} </td>
            <td>{ props.name } </td>
            <td>{ props.name.length } </td>
            <td>
                <CallbackButton theme="primary"
                    text="Reverse" callback={ props.reverseCallback } />
                <CallbackButton theme="info" text="Promote"
                    callback={ () => props.promoteCallback(props.name)} />
            </td>
        </React.Fragment>
    )
}

Listing 10-36Adding a Component in the Summary.js File in the src Folder

接收属性的组件不知道——也不关心——它们来自哪里,它们是通过同一个props参数接收的,产生如图 10-16 所示的结果。

img/473159_1_En_10_Fig16_HTML.jpg

图 10-16

传递属性

将所有属性传递给子组件

如果组件的父级提供的属性与组件的子级所期望的属性具有相同的名称,则可以使用析构运算符。为了演示,我在src文件夹中添加了一个名为SimpleButton.js的文件,并用它来定义清单 10-37 中所示的组件。

import React from "react";

export function SimpleButton(props) {
    return (
        <button onClick={ props.callback } className={props.className}>
            { props.text}
        </button>
    )
}

Listing 10-37The Contents of the SimpleButton.js File in the src Folder

SimpleButton组件需要callbackclassNametext属性。当CallbackButton组件应用SimpleButton组件时,父组件提供的属性之间有重叠,这意味着析构操作符可以用来传递属性,如清单 10-38 所示。

import React from "react";

import { SimpleButton } from "./SimpleButton";

export function CallbackButton(props) {
    return (
        <SimpleButton {...props} className={`btn btn-${props.theme} btn-sm m-1`} />
    )
}

Listing 10-38Passing on Props in the CallbackButton.js File in the src Folder

{...props}表达式传递从父组件接收的所有属性,这些属性由className属性补充。如果一个组件想要对其子组件保留特定的属性,那么可以使用稍微不同的方法,如清单 10-39 所示。

import React from "react";
import { SimpleButton } from "./SimpleButton";

export function CallbackButton(props) {
    let { theme, ...childProps} = props;
    return (
        <SimpleButton { ...childProps }
            className={`btn btn-${props.theme} btn-sm m-1`} />
    )
}

Listing 10-39Selectively Passing on Props in the CallbackButton.js File in the src Folder

rest 操作符在一个语句中使用,该语句创建一个包含除了theme之外的所有父对象的childProps对象。析构操作符用于将属性从childProps对象传递给子组件。

提供默认的属性值

随着应用中使用的属性数量的增加,您可能会发现自己在重复相同的一组属性值,即使这些值每次都相同。另一种方法是定义一组默认值,并在需要使用不同的值时仅覆盖它们。在清单 10-40 中,我为CallbackButton组件定义了一组默认属性值。

import React from "react";
import { SimpleButton } from "./SimpleButton";

export function CallbackButton(props) {
    let { theme, ...childProps} = props;
    return (
        <SimpleButton {...childProps}
            className={`btn btn-${props.theme} btn-sm m-1`} />
    )
}

CallbackButton.defaultProps = {

    text: "Default Text",
    theme: "warning"

}

Listing 10-40Defining Default Values in the CallbackButton.js File in the src Folder

名为defaultProps的属性被添加到组件中,并被赋予一个对象,该对象为在父组件不提供值时使用的属性提供默认值。在清单 10-41 中,我修改了Summary组件,使其依赖于一个CallbackButton元素的默认属性,但为另一个元素提供值。

import React from "react";
import { CallbackButton } from "./CallbackButton";

export function Summary(props) {
    return  (
        <React.Fragment>
            <td>{ props.index + 1} </td>
            <td>{ props.name } </td>
            <td>{ props.name.length } </td>
            <td>
                <CallbackButton callback={props.reverseCallback} />
                <CallbackButton theme="info" text="Promote"
                    callback={ () => props.promoteCallback(props.name)} />
            </td>
        </React.Fragment>
    )
}

Listing 10-41Relying on Prop Defaults in the Summary.js File in the src Folder

第一个CallbackButton元素依赖默认值,产生如图 10-17 所示的结果。

img/473159_1_En_10_Fig17_HTML.jpg

图 10-17

使用默认属性值

类型检查属性值

prop 不能指示它们期望接收什么数据类型,并且当它们不能使用作为 prop 接收的数据值时,没有办法通知它们的祖先组件。为了帮助避免这些问题,React 允许一个组件声明它所期望的属性类型,如清单 10-42 所示。

import React from "react";

import PropTypes from "prop-types";

export function SimpleButton(props) {
    return (
        <button onClick={ props.callback } className={props.className}>
            { props.text}
        </button>
    )
}

SimpleButton.defaultProps = {

    disabled: false

}

SimpleButton.propTypes = {

    text: PropTypes.string,
    theme: PropTypes.string,
    callback: PropTypes.func,
    disabled: PropTypes.bool

}

Listing 10-42Declaring Prop Types in the SimpleButton.js File in the src Folder

一个propTypes属性被添加到组件中,并被赋予一个对象,该对象的属性名对应于属性名,其值指定组件期望的类型。使用从prop-types包中导入的PropTypes值指定类型,表 10-3 中描述了最有用的PropTypes值。

表 10-3

有用的 PropTypes 值

|

名字

|

描述

|
| --- | --- |
| array | 这个值指定一个属性应该是一个数组。 |
| bool | 这个值指定了一个属性应该是布尔值。 |
| func | 该值指定一个属性应该是一个函数。 |
| number | 该值指定属性应该是一个数字值。 |
| object | 该值指定属性应该是一个对象。 |
| string | 该值指定属性应该是字符串。 |

小费

您可以将表 10-3 中的任何类型与isRequired组合,以便在父组件PropTypes.bool.isRequired没有提供该属性的值时生成警告。

为了演示如何检查类型,在清单 10-43 中,我为disabled属性的CallbackButton元素添加了一个值,使用了一个字符串值,而不是清单 10-42 中指定的bool

import React from "react";
import { CallbackButton } from "./CallbackButton";

export function Summary(props) {
    return  (
        <React.Fragment>
            <td>{ props.index + 1} </td>
            <td>{ props.name } </td>
            <td>{ props.name.length } </td>
            <td>
                <CallbackButton callback={props.reverseCallback} />
                <CallbackButton theme="info" text="Promote"
                    callback={ () => props.promoteCallback(props.name)}
                    disabled="true" />
            </td>
        </React.Fragment>
    )
}

Listing 10-43Providing the Wrong Type in the Summary.js File in the src Folder

这是一个常见的错误,在应该是boolnumber的地方使用了字符串值。很难找出问题出在哪里,尤其是因为 prop 是由出现问题的组件的祖先定义的。使用 prop 类型使问题变得明显。当您保存更改时,浏览器将重新加载,您将看到浏览器的 JavaScript 控制台中显示以下消息:

...
index.js:2178 Warning: Failed prop type: Invalid prop `disabled` of type `string` supplied to `SimpleButton`, expected `boolean`.
...

为了解决这个问题,我可以更改 prop 值,以便它将预期的类型发送给组件。另一种方法是使组件更加灵活,以便它能够处理disabled属性的Booleanstring值。考虑到在需要Boolean值时创建string属性值是多么常见,这是一个好主意,尤其是如果您正在编写将被其他开发团队使用的组件。在清单 10-44 中,我添加了对SimpleButton组件的支持来处理这两种类型,并更新了它的propTypes配置来反映这一变化。

注意

prop 类型检查仅在开发过程中执行,当应用准备好进行部署时将被禁用。请参见第八章,了解准备应用进行部署的示例。

import React from "react";
import PropTypes from "prop-types";

export function SimpleButton(props) {
    return (
        <button onClick={ props.callback } className={props.className}
                disabled={ props.disabled === "true" || props.disabled === true }>
            { props.text}
        </button>
    )
}

SimpleButton.defaultProps = {
    disabled: false
}

SimpleButton.propTypes = {
    text: PropTypes.string,
    theme: PropTypes.string,
    callback: PropTypes.func,
    disabled: PropTypes.oneOfType([PropTypes.bool, PropTypes.string])
}

Listing 10-44Accepting Multiple Prop Types in the SimpleButton.js File in the src Folder

有两种有用的PropTypes方法可用于指定多种类型或特定值,如表 10-4 所述。

表 10-4

有用的 PropTypes 方法

|

名字

|

描述

|
| --- | --- |
| oneOfType | 该方法接受组件愿意接收的一组PropTypes值。 |
| oneOf | 该方法接受组件愿意接收的一组值。 |

在清单 10-44 中,我使用oneOfType方法告诉 React】属性可以接受Booleanstring值。组件能够处理清单 10-43 中为disabled属性提供的值 I,这将禁用button元素,如图 10-18 所示。

img/473159_1_En_10_Fig18_HTML.jpg

图 10-18

接受多种属性类型

小费

另一种方法是在应用组件时将 prop 值改为一个Boolean,这可以通过使用disabled属性的表达式来完成:disabled={ true }

摘要

在本章中,我介绍了无状态组件,它是 React 应用中关键构件的最简单版本。我演示了如何定义无状态组件,如何呈现内容,以及如何组合组件来创建更复杂的功能。我还解释了父组件如何使用 props 将数据传递给其子组件,并向您展示了 props 如何用于函数,这提供了组件间通信所需的基本特性。我已经向你展示了定义默认值和属性类型的特性,从而结束了这一章。在下一章,我将解释如何创建有状态数据的组件。

十一、有状态组件

在这一章中,我将介绍有状态组件,它建立在第十章中描述的特性之上,并添加了每个组件独有的状态数据,这些数据可用于改变呈现的输出。表 11-1 将有状态组件放在上下文中。

表 11-1

将有状态组件放在上下文中

|

问题

|

回答

|
| --- | --- |
| 它们是什么? | 组件是 React 应用中的关键构件。有状态组件有自己的数据,可以用来改变组件呈现的内容。 |
| 它们为什么有用? | 有状态组件使跟踪每个组件提供的应用状态变得更加容易,并提供了改变数据值和反映呈现给用户的内容变化的方法。 |
| 它们是如何使用的? | 有状态组件是使用类或通过向功能组件添加挂钩来定义的。 |
| 有什么陷阱或限制吗? | 必须注意确保状态数据被正确修改,如本章“修改状态数据”一节所述。 |
| 有其他选择吗? | 组件是 React 应用中的关键构建块,没有办法避免使用它们。在更大更复杂的项目中,有一些替代属性是有用的,如后面章节所述。 |

表 11-2 总结了本章内容。

表 11-2

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 向组件添加状态数据 | 定义一个类,其构造函数设置状态属性或调用useState函数为单个状态属性创建属性和函数 | 4–5, 12, 13 |
| 修改状态数据 | 调用setState函数或调用useState返回的函数 | 6–11 |
| 在组件之间共享数据 | 将状态数据提升到祖先组件,并使用 props 分发它 | 14–18 |
| 在基于类组件中定义适当的类型和默认值 | 将属性应用于该类或在该类中定义静态属性 | 19–20 |

为本章做准备

在本章中,我继续使用在第十章中创建的components项目。为了准备这一章,我修改了由Summary组件呈现的内容,使其直接使用SimpleButton组件,如清单 11-1 所示,而不是我用来描述属性如何分发的CallbackButton

小费

你可以从 https://github.com/Apress/pro-react-16 下载本章以及本书其他章节的示例项目。

import React from "react";

//import { CallbackButton } from "./CallbackButton";

import { SimpleButton } from "./SimpleButton";

export function Summary(props) {
    return (
        <React.Fragment>
            <td>{ props.index + 1} </td>
            <td>{ props.name } </td>
            <td>{ props.name.length } </td>
            <td>
                <SimpleButton
                    className="btn btn-warning btn-sm m-1"
                    callback={ props.reverseCallback }
                    text={ `Reverse (${ props.name })`}
                />
                <SimpleButton
                    className="btn btn-info btn-sm m-1"
                    callback={ () => props.promoteCallback(props.name)}
                    text={ `Promote (${ props.name})`}
                />
            </td>
        </React.Fragment>
    )
}

Listing 11-1Changing the Content in the Summary.js File in the src Folder

在清单 11-2 中,我已经删除了SimpleButton组件的属性的类型和默认值,我将在本章末尾恢复这些属性。

import React from "react";

export function SimpleButton(props) {
    return (
        <button onClick={ props.callback } className={props.className}
                disabled={ props.disabled === "true" || props.disabled === true }>
            { props.text}
        </button>
    )
}

Listing 11-2Removing Properties in the SimpleButton.js File in the src Folder

打开命令提示符,导航到components文件夹,运行清单 11-3 中所示的命令来启动 React 开发工具。

npm start

Listing 11-3Starting the Development Tools

在初始构建过程之后,一个新的浏览器窗口将会打开并显示如图 11-1 所示的内容。

img/473159_1_En_11_Fig1_HTML.jpg

图 11-1

运行示例应用

了解不同的组件类型

在接下来的小节中,我将解释 React 支持的组件类型之间的区别。当您看到有状态组件与第十章中描述的无状态组件的主要区别时,理解有状态组件如何工作将会更容易。

理解无状态组件

正如你在第十章中看到的,无状态组件由一个函数组成,React 调用该函数来响应定制的 HTML 元素,并将正确的值作为参数传递。自定义 HTML 元素上相同的一组属性值将导致相同的属性参数并产生相同的结果,如图 11-2 所示。

img/473159_1_En_11_Fig2_HTML.jpg

图 11-2

无状态组件的可预测结果

一个无状态组件将总是呈现相同的 HTML 元素,给定相同的一组属性值,不管函数被调用的频率如何。它完全依赖父组件提供的属性值来呈现其内容。这意味着无论应用中有多少个SimpleButton元素,React 都可以继续调用同一个函数,并且只需跟踪哪些属性与每个SimpleButton元素相关联。

了解有状态组件

一个有状态组件有自己的数据,这些数据影响组件呈现的内容。这个数据被称为状态数据,与父组件和它提供的属性是分开的。

想象一下,SimpleButton组件必须记录用户点击它所呈现的button元素的次数,并将当前次数显示为元素的内容。为了提供这一功能,组件需要一个计数器,每次单击按钮时该计数器都会递增,并且在呈现其内容时必须包含计数器的当前值。

由父组件定义的每个SimpleButton元素将产生一个button元素,需要一个单独的计数器,因为每个按钮可以独立于其他按钮被点击。有状态组件是 JavaScript 对象,应用组件的SimpleButton HTML 元素和组件对象之间是一一对应的关系,每个组件都有自己的状态,可能呈现不同的输出,如图 11-3 所示。

img/473159_1_En_11_Fig3_HTML.jpg

图 11-3

带计数器的有状态组件

向有状态组件提供相同的属性将会产生相同的结果,这不再是确定无疑的了,因为每个组件对象的状态数据可以有不同的值,并使用它来生成不同的结果。

正如您将了解到的,有状态组件有许多无状态组件所没有的特性,如果您记得每个有状态组件都是一个 JavaScript 对象,有自己的状态数据,并且与一个单独的定制 HTML 元素相关联,您会发现这些特性更容易理解。

创建有状态组件

首先,我将把示例应用中的一个现有的SimpleButton组件从无状态组件转换为有状态组件,这将让我在转到更复杂的特性之前解释一些基础知识。

定义一个有状态的组件是使用一个类来完成的,这个类是一个描述每个组件对象将拥有的功能的模板,如第四章所述。在清单 11-4 中,我用一个类替换了SimpleButton组件的功能。

注意

这是一个没有任何状态数据的有状态组件。我将解释如何定义组件,然后在“添加状态数据”一节中向您展示如何添加状态数据。

import React, { Component } from "react";

export class SimpleButton extends Component {

    render() {
        return (
            <button onClick={ this.props.callback }
                    className={ this.props.className }
                    disabled={ this.props.disabled === "true"
                        || this.props.disabled === true  }>
                { this.props.text}
            </button>
        )
    }
}

Listing 11-4Introducing a Class in the SimpleButton.js File in the src Folder

在接下来的部分中,我将描述清单 11-4 中的每一个变化,并解释它们是如何被用来创建有状态组件的。

了解组件类

当您定义一个有状态组件时,您使用classextends关键字来表示一个类,该类继承了在react包中定义的Component类所提供的功能,就像这样:

...
export class SimpleButton extends Component {
...

这种关键字组合定义了一个名为SimpleButton的类,它扩展了 React 提供的Component类。export关键字使得SimpleButton类可以在定义它的 JavaScript 文件之外使用,就像组件被定义为函数时一样。

理解导入语句

为了从Component类扩展,使用了一个import,如下所示:

...
import React, { Component } from "react";
...

正如我在第四章中解释的,这个语句中有两种类型的导入。从react包的默认导出被导入并被赋予名称React,这允许 JSX 工作。react包还有一个名为Component的导出,它是用花括号({}字符)导入的。创建有状态组件时,严格按照所示使用import语句是很重要的。

了解渲染方法

有状态组件的主要目的是呈现内容以供显示。不同之处在于,这是在一个名为render的方法中完成的,当 React 希望组件进行渲染时会调用该方法。render方法必须返回一个 React 元素,该元素可以使用React.createElement方法创建,或者更常见的是,作为 HTML 的一个片段。

...

render() {

    return (
        <button onClick={ this.props.callback }
                className={ this.props.className }
                disabled={ this.props.disabled === "true"
                    || this.props.disabled === true  }>
            { this.props.text}
        </button>
    )
}
...

了解有状态组件属性

当您开始使用有状态组件时,最明显的区别之一是您必须使用this关键字来访问属性值,如下所示:

...
return (
    <button onClick={ this.props.callback }
            className={ this.props.className }
            disabled={ this.props.disabled === "true"
                || this.props.disabled === true  }>
        { this.props.text}
    </button>
)
...

this关键字指的是组件的 JavaScript 对象。当使用有状态组件时,您必须使用this关键字来访问props属性,如果您忘记了:

./src/SimpleButton.js  Line 7:  'props' is not defined  no-undef

虽然我重新定义了组件,但我没有改变它呈现的内容或改变它的行为方式,结果和组件被定义为函数时一样,如图 11-4 所示。

img/473159_1_En_11_Fig4_HTML.jpg

图 11-4

引入有状态组件

添加状态数据

有状态组件最重要的特性是组件的每个实例都有自己的数据,称为状态数据。在清单 11-5 中,我向SimpleButton组件添加了状态数据。

import React, { Component } from "react";

export class SimpleButton extends Component {

    constructor(props) {
        super(props);
        this.state = {
            counter: 0,
            hasButtonBeenClicked: false
        }
    }

    render() {
        return (
            <button onClick={ this.props.callback }
                className={ this.props.className }
                disabled={ this.props.disabled === "true"
                            || this.props.disabled === true  }>
                    { this.props.text} { this.state.counter }
                    { this.state.hasButtonBeenClicked &&
                        <div>Button Clicked!</div>
                    }
            </button>
        )
    }
}

Listing 11-5Adding State Data in the SimpleButton.js File in the src Folder

状态数据是使用一个构造函数定义的,这是一个特殊的方法,当使用类创建一个新对象时调用,并且必须遵循清单中所示的形式:构造函数应该定义一个props参数,第一条语句应该是使用props对象作为参数调用特殊的super方法,该方法调用Component类的构造函数并设置有状态组件中可用的特性。

一旦调用了super,就可以定义状态数据,这是通过将一个对象分配给this.state来完成的。

...
constructor(props) {
    super(props);
    this.state = {
        counter: 0,
        hasButtonBeenClicked: false
    }
}
...

状态数据被定义为对象的属性。本例中有一个属性,它创建了名为counter的状态数据属性,其值为0hasButtonBeenClicked,其值为false

读取状态数据

访问状态数据是通过读取您通过this.state定义的属性来完成的,类似于访问 props 的方式。

...
render() {
    return (
        <button onClick={ this.props.callback }
            className={ this.props.className }
            disabled={ this.props.disabled === "true"
                        || this.props.disabled === true  }>
                { this.props.text} { this.state.counter }
                { this.state.hasButtonBeenClicked &&
                    <div>Button Clicked!</div>
                }
        </button>
    )
}
...

清单 11-5 中的render方法设置button元素的内容,使其包含一个属性值和counter状态数据属性的值,产生如图 11-5 所示的效果。我在清单 11-5 中定义的额外的div元素将不会显示,直到hasButtonBeenClicked属性的值为true,我将在下一节演示。

img/473159_1_En_11_Fig5_HTML.jpg

图 11-5

定义和读取状态数据

修改状态数据

只有当状态数据可以被修改时,使用状态数据才有意义,因为这允许组件对象呈现不同的内容。React 需要一种特定的技术来修改状态数据,如清单 11-6 所示。

import React, { Component } from "react";

export class SimpleButton extends Component {

    constructor(props) {
        super(props);
        this.state = {
            counter: 0,
            hasButtonBeenClicked: false
        }
    }

    render() {
        return (
            <button onClick={ this.handleClick }
                className={ this.props.className }
                disabled={ this.props.disabled === "true"
                            || this.props.disabled === true  }>
                    { this.props.text} { this.state.counter }
                    { this.state.hasButtonBeenClicked &&
                        <div>Button Clicked!</div>
                    }
            </button>
        )
    }

    handleClick = () => {
        this.setState({
            counter: this.state.counter + 1,
            hasButtonBeenClicked: true
        });
        this.props.callback();
    }
}

Listing 11-6Modifying State Data in the SimpleButton.js File in the src Folder

React 不允许直接修改状态数据,如果您试图直接为状态属性赋值,它会报告一个错误。相反,修改是通过继承自Component类的setState方法进行的。在清单中,我添加了一个名为handleClick的方法,它由button元素的onClick表达式选择,并使用setState方法增加counter状态属性。

小费

onClick属性选择的方法必须以特定的方式定义。我将在第十二章中解释如何使用onClick属性以及如何定义它的方法。

setState方法的参数是一个对象,其属性指定要更新的状态数据,如下所示:

...
this.setState({
    counter: this.state.counter + 1,
    hasButtonBeenClicked: true
});
...

这个语句告诉 React 应该通过增加当前值来修改counter属性,并且hasButtonBeenClicked属性应该是true。注意,我没有对counter使用增量操作符(++),因为那样会给属性分配一个新值,导致错误。

小费

使用setState方法时,您只需为您想要更改的值定义属性。React 会将您指定的更改与组件状态数据的其余部分合并,并保持任何未提供值的属性不变。

虽然使用setState方法可能会感到尴尬,但好处是 React 负责重新呈现应用以反映更改的影响,这意味着我不必像在第十一章中那样手动调用ReactDOM.render方法。效果是点击按钮增加相关组件的计数器状态数据,如图 11-6 所示。(单击按钮会对表格中的行进行重新排序,这意味着您单击的按钮可能会移动到新的位置。)

img/473159_1_En_11_Fig6_HTML.jpg

图 11-6

修改状态数据

单击按钮会更改其中一个组件对象的状态,而保持其他五个组件对象不变。

避免状态数据修改陷阱

React 异步执行对状态数据的更改,并可能选择将几个更新组合在一起以提高性能,这意味着调用setState的效果可能不会以您预期的方式生效。在更新状态数据时有一些常见的陷阱,我将在接下来的小节中描述这些陷阱,以及如何避免它们的细节。

小费

React Devtools 浏览器扩展向您显示有状态组件的状态数据,这对于查看应用如何响应更改以及当您没有获得预期的行为时跟踪问题非常有用。

避免依赖值陷阱

状态数据值通常是相关的,一个常见的问题是假设每个变化的效果是单独应用的,如清单 11-7 所示。

import React, { Component } from "react";

export class SimpleButton extends Component {

    constructor(props) {
        super(props);
        this.state = {
            counter: 0,
            hasButtonBeenClicked: false
        }
    }

    render() {
        return (
            <button onClick={ this.handleClick }
                className={ this.props.className }
                disabled={ this.props.disabled === "true"
                            || this.props.disabled === true  }>
                    { this.props.text} { this.state.counter }
                    { this.state.hasButtonBeenClicked &&
                        <div>Button Clicked!</div>
                    }
            </button>
        )
    }

    handleClick = () => {
        this.setState({
            counter: this.state.counter + 1,
            hasButtonBeenClicked: this.state.counter > 0
        });
        this.props.callback();
    }
}

Listing 11-7Performing Related State Changes in the SimpleButton.js File in the src Folder

hasButtonBeenClicked属性的更新假定counter属性在其表达式被求值之前已经被改变。React 不会单独应用更改,并且使用当前的counter值来计算hasButtonBeenClicked属性的表达式。当使用对setState方法的单独调用来执行相关更新时,也会出现这个问题,如清单 11-8 所示。

import React, { Component } from "react";

export class SimpleButton extends Component {

    constructor(props) {
        super(props);
        this.state = {
            counter: 0,
            hasButtonBeenClicked: false
        }
    }

    render() {
        return (
            <button onClick={ this.handleClick }
                className={ this.props.className }
                disabled={ this.props.disabled === "true"
                            || this.props.disabled === true  }>
                    { this.props.text} { this.state.counter }
                    { this.state.hasButtonBeenClicked &&
                        <div>Button Clicked!</div>
                    }
            </button>
        )
    }

    handleClick = () => {
        this.setState({ counter: this.state.counter + 1 });
        this.setState({ hasButtonBeenClicked: this.state.counter > 0 });
        this.props.callback();
    }
}

Listing 11-8Making Dependent Updates in the SimpleButton.js File in the src Folder

为了提高效率,React 会将这些更新批处理在一起,这将产生与清单 11-6 相同的结果,并且意味着在按钮被点击两次之前hasButtonBeenClicked属性不会是true,如图 11-7 所示。

img/473159_1_En_11_Fig7_HTML.jpg

图 11-7

依赖值陷阱

当您要进行一系列相关的更改时,您可以将一个函数传递给setState方法,该方法将在状态数据更新后被调用,并可用于执行依赖于已更改的状态值的任务,如清单 11-9 所示。

import React, { Component } from "react";

export class SimpleButton extends Component {

    constructor(props) {
        super(props);
        this.state = {
            counter: 0,
            hasButtonBeenClicked: false
        }
    }

    render() {
        return (
            <button onClick={ this.handleClick }
                className={ this.props.className }
                disabled={ this.props.disabled === "true"
                            || this.props.disabled === true  }>
                    { this.props.text} { this.state.counter }
                    { this.state.hasButtonBeenClicked &&
                        <div>Button Clicked!</div>
                    }
            </button>
        )
    }

    handleClick = () => {
        this.setState({ counter: this.state.counter + 1 },
            () => this.setState({ hasButtonBeenClicked: this.state.counter > 0 }));
        this.props.callback();
    }
}

Listing 11-9Using a Callback in the SimpleButton.js File in the src Folder

使用回调函数可以确保在应用新的counter属性之前hasButtonBeenClicked的值不会改变,从而确保这些值是同步的,如图 11-8 所示。

img/473159_1_En_11_Fig8_HTML.jpg

图 11-8

强制状态改变按顺序执行

避免遗漏更新陷阱

React 应用更新的方式意味着对同一状态数据属性的多次更改将被忽略,只有最近的值被应用,如清单 11-10 所示。

...
handleClick = () => {
    for (let i = 0; i < 5; i++) {
        this.setState({ counter: this.state.counter + 1});
    }
    this.setState({ hasButtonBeenClicked: true });
    this.props.callback();
}
...

Listing 11-10Making Multiple Updates in the SimpleButton.js File in the src Folder

在实际项目中,多次更新通常是在处理数据时进行的,而不是在一个for循环中进行,例如,对数组中的每个对象执行一次状态更改。这个清单展示了重复修改相同属性的效果:点击一个按钮将值增加 1,而不是增加 5 次counter值,如图 11-9 所示。

img/473159_1_En_11_Fig9_HTML.jpg

图 11-9

对状态属性应用多个更新

如果您需要执行多次更新并让每次更新按顺序生效,那么您可以使用接受一个函数作为第一个参数的setState方法版本。该函数提供了当前状态数据和一个 props 对象,如清单 11-11 所示。

小费

这个版本的setState方法对于更新嵌套状态属性也很有用,你可以在第十四章中看到演示。

...
handleClick = () => {
    for (let i = 0; i < 5; i++) {
        this.setState((state, props) => { return { counter: state.counter + 1 }});
    }
    this.setState({ hasButtonBeenClicked: true });
    this.props.callback();
}
...

Listing 11-11Making Multiple Updates in the SimpleButton.js File in the src Folder

传递给setState方法的函数使用与前面示例相同的格式返回一个更新对象。不同之处在于,状态数据对象反映了所有以前的变化,这些变化被组合在一起并可用于重复更新,产生如图 11-10 所示的效果。

img/473159_1_En_11_Fig10_HTML.jpg

图 11-10

对状态属性应用多个更新

使用钩子定义有状态组件

并不是所有的开发人员都喜欢使用类来定义有状态组件,所以 React 提供了一种替代方法,叫做钩子,它允许功能组件定义状态数据。在清单 11-12 中,我在src文件夹中添加了一个名为HooksButton.js的文件,并重新创建了清单 11-11 中的有状态组件,作为一个使用钩子的函数。

import React, { useState } from "react";

export function HooksButton(props) {
    const [counter, setCounter] = useState(0);
    const [hasButtonBeenClicked, setHasButtonBeenClicked] = useState(false);

    const handleClick = () => {
        setCounter(counter + 5);
        setHasButtonBeenClicked(true);
        props.callback();
    }

    return (
        <button onClick={ handleClick }
            className={ props.className }
            disabled={ props.disabled === "true" || props.disabled === true  }>
                { props.text} { counter }
                { hasButtonBeenClicked && <div>Button Clicked!</div>}
        </button>
    )
}

Listing 11-12The Contents of the HooksButton.js File in the src Folder

useState函数用于创建状态数据。它的参数是 state data 属性的初始值,它返回一个提供当前值的属性和一个更改值并触发更新的函数。属性和函数以数组形式返回,并使用数组析构为其分配有意义的名称,如下所示:

...
const [counter, setCounter] = useState(0);
...

该语句创建了一个名为counter的状态数据属性,其初始值为零,其值可以使用名为setCounter的函数进行更改。用于更改状态数据属性值的函数不具备setState方法的所有特性,这就是为什么我在handleClick函数中将值增加了 5,而不是执行一系列单独的更新,如清单 11-11 所示。

...
const handleClick = () => {
    setCounter(counter + 5);
    setHasButtonBeenClicked(true);
    props.callback();
}
...

在清单 11-13 中,我已经更新了Summary,所以它使用了HooksButton组件。

import React from "react";
import { SimpleButton } from "./SimpleButton";
import { HooksButton } from "./HooksButton";

export function Summary(props) {
    return (
        <React.Fragment>
            <td>{ props.index + 1} </td>
            <td>{ props.name } </td>
            <td>{ props.name.length } </td>
            <td>
                <SimpleButton
                    className="btn btn-warning btn-sm m-1"
                    callback={ props.reverseCallback }
                    text={ `Reverse (${ props.name })`} />
                <HooksButton
                    className="btn btn-info btn-sm m-1"
                    callback={ () => props.promoteCallback(props.name)}
                    text={ `Promote (${ props.name})`} />
            </td>
        </React.Fragment>
    )
}

Listing 11-13Using the Hooks Component in the Summary.js File in the src Folder

钩子的使用对于Summary组件是不可见的,它像往常一样通过 props 提供数据和函数。这个例子产生了相同的结果,如图 11-10 所示。

你应该使用钩子还是类?

钩子为不喜欢使用类的开发人员提供了另一种创建有状态组件的方法。根据您的个人偏好,要么这将是适合您的编码风格的重要特性,要么您将继续定义类并完全忘记钩子。

React 的未来版本将支持钩子和类特性,因此您可以使用最适合自己的特性,也可以随意混合搭配。我喜欢钩子的特性,但是,除了在第十三章描述一些相关的钩子特性之外,本书中的所有例子都使用了类。这部分是因为 hooks 特性是新的——但也是因为我已经使用基于类的编程语言很长时间了,使用类来定义组件符合我思考代码的方式,即使是简单的无状态组件。

如果你更喜欢使用钩子,但是不使用类就无法表达书中的例子,那么给我发电子邮件到 adam@adam-freeman.com,我会试着给你指出正确的方向。

提升状态数据

目前,每个SimpleButtonHooksButton组件都是独立存在的,并且有自己的状态数据,所以单击一个按钮只会影响单个组件的状态值,而不会影响其他组件。

当组件需要访问相同的数据时,需要不同的方法。在这种情况下,状态数据被提升,这意味着它被移动到第一个公共祖先组件,并使用 props 分发回需要它的组件。

小费

在 React 组件之间共享数据还有其他方法。第十三章描述了上下文特性,更复杂的项目可以受益于使用数据存储(参见第 19 和 20 章)或 URL 路由(参见第 21 和 22 章)。

例如,如果我希望同一个表行中的SimpleButtonHooksButton组件共享一个counter值,我需要在第一个公共祖先中定义 state data 属性,这就是Summary组件。在清单 11-14 中,我将Summary转换成了一个基于类的有状态组件,它定义了一个计数器值。

import React, { Component } from "react";

import { SimpleButton } from "./SimpleButton";
import { HooksButton } from "./HooksButton";

export class Summary extends Component {

    constructor(props) {
        super(props);
        this.state = {
            counter: 0
        }
    }

    incrementCounter = (increment) => {
        this.setState((state) => { return { counter: state.counter + increment}});
    }

    render() {
        const props = this.props;
        return (
            <React.Fragment>
                <td>{ props.index + 1} </td>
                <td>{ props.name } </td>
                <td>{ props.name.length } </td>
                <td>
                    <SimpleButton
                        className="btn btn-warning btn-sm m-1"
                        callback={ props.reverseCallback }
                        text={ `Reverse (${ props.name })`}
                        counter={ this.state.counter }
                        incrementCallback={this.incrementCounter }
                    />
                    <HooksButton
                        className="btn btn-info btn-sm m-1"
                        callback={ () => props.promoteCallback(props.name)}
                        text={ `Promote (${ props.name})`}
                        counter={ this.state.counter }
                        incrementCallback={this.incrementCounter }
                    />
                </td>
            </React.Fragment>
        )
    }
}

Listing 11-14Lifting Up State Data in the Summary.js File in the src Folder

Summary组件定义了一个counter属性,并将其作为属性传递给其子组件。该组件还定义了一个incrementCounter方法,子组件将调用该方法来更改counter属性,该属性是使用一个名为incrementCallback的属性传递的。这是必需的,不仅因为状态数据不是直接修改的,而且因为属性是只读的。incrementCounter方法使用带有函数的setState方法,这样它可以被子组件重复调用。

小费

我在render方法中定义了一个props属性,这样我就不必为了使用this关键字而改变所有的引用,这在转换函数组件以使用类时是一个有用的快捷方式。

在清单 11-15 中,我从SimpleButton组件中移除了counter状态数据属性,并使用了counterincrementCounter属性。

import React, { Component } from "react";

export class SimpleButton extends Component {

    constructor(props) {
        super(props);
        this.state = {
            // counter: 0,
            hasButtonBeenClicked: false
        }
    }

    render() {
        return (
            <button onClick={ this.handleClick }
                className={ this.props.className }
                disabled={ this.props.disabled === "true"
                            || this.props.disabled === true  }>
                    { this.props.text} { this.props.counter }
                    { this.state.hasButtonBeenClicked &&
                        <div>Button Clicked!</div>
                    }
            </button>
        )
    }

    handleClick = () => {
        this.props.incrementCallback(5);
        this.setState({ hasButtonBeenClicked: true });
        this.props.callback();
    }
}

Listing 11-15Replacing State Data with Props in the SimpleButton.js File in the src Folder

需要对HooksButton组件进行相应的修改,这些组件将共享相同的属性集,如清单 11-16 所示。

import React, { useState } from "react";

export function HooksButton(props) {
    //const [counter, setCounter] = useState(0);
    const[ hasButtonBeenClicked, setHasButtonBeenClicked] = useState(false);

    const handleClick = () => {
        //setCounter(counter + 5);
        props.incrementCallback(5);
        setHasButtonBeenClicked(true);
        props.callback();
    }

    return (
        <button onClick={ handleClick }
            className={ props.className }
            disabled={ props.disabled === "true" || props.disabled === true  }>
                { props.text} { props.counter }
                { hasButtonBeenClicked && <div>Button Clicked!</div>}
        </button>
    )
}

Listing 11-16Replacing State Data with Props in the HooksButton.js File in the src Folder

counter状态属性提升到父组件意味着每一行表格中呈现给用户的两个按钮共享其父组件的状态数据,这样点击其中一个按钮元素就会导致两个按钮都被更新,如图 11-11 所示。

img/473159_1_En_11_Fig11_HTML.jpg

图 11-11

提升状态数据

不是每一项状态数据都必须被提升,并且单独的组件仍然有它们自己的本地状态数据,因此hasButtonBeenClicked属性保持在本地并且独立于其他组件。

进一步提升状态数据

状态数据可以比父组件提升得更远。如果我想让所有的SimpleButtonHooksButton组件共享同一个counter属性,那么我可以把它提升到App组件,如清单 11-17 所示,其中我使用钩子特性创建了有状态。

import React, { useState } from "react";

import { Summary } from "./Summary";
import ReactDOM from "react-dom";

let names = ["Bob", "Alice", "Dora"]

function reverseNames() {
    names.reverse();
    ReactDOM.render(<App />, document.getElementById('root'));
}

function promoteName(name) {
    names = [name, ...names.filter(val => val !== name)];
    ReactDOM.render(<App />, document.getElementById('root'));
}

export default function App() {
    const [counter, setCounter] = useState(0);

    const incrementCounter = (increment) => setCounter(counter + increment);

    return (
        <table className="table table-sm table-striped">
            <thead>
                <tr><th>#</th><th>Name</th><th>Letters</th></tr>
            </thead>
            <tbody>
                { names.map((name, index) =>
                    <tr key={ name }>
                        <Summary index={index} name={name}
                            reverseCallback={reverseNames}
                            promoteCallback={promoteName}
                            counter={ counter }
                            incrementCallback={ incrementCounter }
                        />
                    </tr>
                )}
            </tbody>
        </table>
    )
}

Listing 11-17Lifting State Data in the App.js File in the src Folder

App组件定义了counter状态属性和通过调用setCounter函数修改它的incrementCounter方法。在清单 11-18 中,我已经从Summary组件中移除了状态数据,并将从App组件接收的属性传递给子组件。

import React, { Component } from "react";
import { SimpleButton } from "./SimpleButton";
import { HooksButton } from "./HooksButton";

export class Summary extends Component {

    // constructor(props) {
    //     super(props);
    //     this.state = {
    //         counter: 0
    //     }
    // }

    // incrementCounter = (increment) => {
    //     this.setState((state) => { return { counter: state.counter + increment}});
    // }

    render() {
        const props = this.props;
        return (
            <React.Fragment>
                <td>{ props.index + 1} </td>
                <td>{ props.name } </td>
                <td>{ props.name.length } </td>
                <td>
                    <SimpleButton
                        className="btn btn-warning btn-sm m-1"
                        callback={ props.reverseCallback }
                        text={ `Reverse (${ props.name })`}
                        { ...this.props }
                    />
                    <HooksButton
                        className="btn btn-info btn-sm m-1"
                        callback={ () => props.promoteCallback(props.name)}
                        text={ `Promote (${ props.name})`}
                        { ...this.props }
                    />
                </td>
            </React.Fragment>
        )
    }
}

Listing 11-18Removing State Data in the Summary.js File in the src Folder

当有状态组件没有状态数据时,不需要构造函数,如果您定义了一个构造函数,它除了使用super将属性传递给基类之外什么也不做,您将会收到一个警告。我使用析构操作符将从App组件收到的属性传递给SimpleButtonHooksButton组件。

现在状态数据已经提升到了App组件,所有作为App组件的后代的SimpleButton组件共享一个counter值,如图 11-12 所示。

img/473159_1_En_11_Fig12_HTML.jpg

图 11-12

将状态数据提升到顶级组件

不需要对SimpleButtonHooksButton组件进行任何更改,它们不知道状态数据是在哪里定义的,并且接收数据值和作为属性更改数据值所需的回调函数。

定义属性类型和默认值

在这一章的开始,我删除了适当的默认值和类型,这样我就可以专注于从无状态到有状态组件的转换。基于类的组件以与功能组件相同的方式支持这些特性,如清单 11-19 所示。

import React, { Component } from "react";

import PropTypes from "prop-types";

export class SimpleButton extends Component {

    constructor(props) {
        super(props);
        this.state = {
            // counter: 0,
            hasButtonBeenClicked: false
        }
    }

    render() {
        return (
            <button onClick={ this.handleClick }
                className={ this.props.className }
                disabled={ this.props.disabled === "true"
                            || this.props.disabled === true  }>
                    { this.props.text} { this.props.counter }
                    { this.state.hasButtonBeenClicked &&
                        <div>Button Clicked!</div>
                    }
            </button>
        )
    }

    handleClick = () => {
        this.props.incrementCallback(5);
        this.setState({ hasButtonBeenClicked: true });
        this.props.callback();
    }

}

SimpleButton.defaultProps = {

    disabled: false

}

SimpleButton.propTypes = {

    text: PropTypes.string,
    theme: PropTypes.string,
    callback: PropTypes.func,
    disabled: PropTypes.oneOfType([PropTypes.bool, PropTypes.string ])

}

Listing 11-19Adding Prop Types and Values in the SimpleButton.js File in the src Folder

您还可以使用用关键字static修饰的类属性来定义类型和默认属性值,如清单 11-20 所示。static关键字定义了一个属性,该属性应用于组件的类,而不是从该类创建的对象,并由构建过程转换成清单 11-19 中使用的相同形式。

import React, { Component } from "react";
import PropTypes from "prop-types";

export class SimpleButton extends Component {

    constructor(props) {
        super(props);
        this.state = {
            // counter: 0,
            hasButtonBeenClicked: false
        }
    }

    render() {
        return (
            <button onClick={ this.handleClick }
                className={ this.props.className }
                disabled={ this.props.disabled === "true"
                            || this.props.disabled === true  }>
                    { this.props.text} { this.props.counter }
                    { this.state.hasButtonBeenClicked &&
                        <div>Button Clicked!</div>
                    }
            </button>
        )
    }

    handleClick = () => {
        this.props.incrementCallback(5);
        this.setState({ hasButtonBeenClicked: true });
        this.props.callback();
    }

    static defaultProps = {
        disabled: false
    }

    static propTypes = {
        text: PropTypes.string,
        theme: PropTypes.string,
        callback: PropTypes.func,
        disabled: PropTypes.oneOfType([PropTypes.bool, PropTypes.string ])
    }
}

Listing 11-20Defining Static Properties in the SimpleButton.js File in the src Folder

这些改变并没有改变示例应用的外观,但是它们确保了组件只接收它所期望的属性类型,并且有一个默认的属性值disabled

摘要

在这一章中,我介绍了有状态组件,它有自己的数据值,可以用来改变呈现的输出。我解释了有状态组件是使用类定义的,并向您展示了如何在构造函数中定义状态数据。我还向您展示了修改状态数据的不同方式,以及如何避免最常见的陷阱。在下一章,我将解释 React 如何处理事件。

十二、处理事件

在这一章中,我描述了对事件的 React 支持,这些事件是由 HTML 元素生成的,通常是为了响应用户交互。如果您使用过 DOM event API 特性,那么 React 事件特性应该是很熟悉的,但是有一些重要的区别会让粗心的开发人员感到困惑。表 12-1 将 React 事件特性放在上下文中。

表 12-1

将 React 事件放在上下文中

|

问题

|

回答

|
| --- | --- |
| 它们是什么? | React 事件由元素触发以报告重要事件,通常是用户交互。 |
| 它们为什么有用? | 事件允许组件响应与它们呈现的内容的交互,这构成了交互式应用的基础。 |
| 它们是如何使用的? | 通过向组件呈现的元素添加属性来表示对事件的兴趣。当组件感兴趣的事件被触发时,由属性指定的函数被调用,允许组件更新其状态、调用函数属性或以其他方式反映事件的效果。 |
| 有什么陷阱或限制吗? | React 事件类似于 DOM API 提供的事件,但是有一些不同之处,可能会给粗心的人带来隐患,特别是在事件阶段,如“管理事件传播”一节中所述。并非所有由 DOM API 定义的事件都受支持(参见 https://reactjs.org/docs/events.html 获取 React 支持的事件列表)。 |
| 有其他选择吗? | 除了使用事件之外,别无选择,事件在用户交互和组件呈现的内容之间提供了必要的链接。 |

表 12-2 总结了本章内容。

表 12-2

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 处理事件 | 添加与事件名称对应的属性,并使用表达式来处理事件 | 6–10 |
| 确定事件类型 | 使用事件对象的type属性 | Eleven |
| 防止事件在使用前被重置 | 使用事件对象的persist方法 | 12, 13 |
| 用自定义参数调用事件处理程序 | 在 prop 表达式中定义一个内联函数,用所需的数据调用处理程序方法 | 14, 15 |
| 阻止事件的默认行为 | 使用事件对象的preventDefault方法 | Sixteen |
| 管理事件的传播 | 确定事件阶段 | 17–23 |
| 停止一项活动 | 使用事件对象的stopPropagation方法 | Twenty-four |

为本章做准备

为了创建本章的示例项目,打开一个新的命令提示符,导航到一个方便的位置,并运行清单 12-1 中所示的命令。

小费

你可以从 https://github.com/Apress/pro-react-16 下载本章以及本书其他章节的示例项目。

npx create-react-app reactevents

Listing 12-1Creating the Example Project

运行清单 12-2 中所示的命令,导航到reactevents文件夹,并将引导包添加到项目中。

cd reactevents
npm install bootstrap@4.1.2

Listing 12-2Adding the Bootstrap CSS Framework

为了在应用中包含引导 CSS 样式表,将清单 12-3 中所示的语句添加到index.js文件中,该文件可以在src文件夹中找到。

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

import 'bootstrap/dist/css/bootstrap.css';

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

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

Listing 12-3Including Bootstrap in the index.js File in the src Folder

接下来,用清单 12-4 中所示的代码替换App.js文件的内容,这将为本章中的示例提供起点。清单用一个使用类的组件替换了现有的功能组件。

import React, { Component } from 'react';

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            message: "Ready"
        }
    }

    render() {
        return  (
            <div className="m-2">
                <div className="h4 bg-primary text-white text-center p-2">
                    { this.state.message }
                </div>
                <div className="text-center">
                    <button className="btn btn-primary">Click Me</button>
                </div>
            </div>
        )
    }
}

Listing 12-4The Contents of the App.js File in the src Folder

使用命令提示符,运行reactevents文件夹中清单 12-5 所示的命令来启动开发工具。

npm start

Listing 12-5Starting the Development Tools

一旦项目的初始准备完成,一个新的浏览器窗口将打开并显示 URL http://localhost:3000,它将显示如图 12-1 所示的内容。

img/473159_1_En_12_Fig1_HTML.jpg

图 12-1

运行示例应用

了解事件

事件由 HTML 元素触发,以表示重要的更改,例如当用户单击按钮或在文本字段中键入内容时。在 React 中处理事件类似于使用域对象模型 API,尽管有一些重要的区别。在清单 12-6 中,我添加了一个事件处理程序,当点击button元素时会调用它。

import React, { Component } from 'react';

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            message: "Ready"
        }
    }

    render() {
        return (
            <div className="m-2">
                <div className="h4 bg-primary text-white text-center p-2">
                    { this.state.message }
                </div>
                <div className="text-center">
                    <button className="btn btn-primary"
                        onClick={ () => this.setState({ message: "Clicked!"})}>
                            Click Me
                    </button>
                </div>
            </div>
        )
    }
}

Listing 12-6Adding an Event Handler in the App.js File in the src Folder

使用共享相应 DOM API 属性名称的属性来处理事件,用 camel case 表示。DOM API onclick属性在 React 应用中表示为onClick,并指定如何处理click事件,该事件在用户单击元素时触发。事件处理属性的表达式是一个函数,当指定的事件被触发时将被调用,如下所示:

...
<button className="btn btn-primary"
        onClick={ () => this.setState({ message: "Clicked!"})}>
    Click Me
</button>
...

这是一个内联函数的例子,它调用setState方法来改变message状态数据属性的值。当button元素被点击时,click事件被触发,React 将调用 inline 函数,产生如图 12-2 所示的结果。

img/473159_1_En_12_Fig2_HTML.jpg

图 12-2

处理事件

调用方法来处理事件

有状态组件可以定义方法并使用它们来响应事件,这有助于避免当几个元素以相同的方式处理相同的事件时在表达式中重复代码。对于不改变应用状态或访问其他组件特性的简单方法,可以如清单 12-7 所示指定方法。

import React, { Component } from 'react';

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            message: "Ready"
        }
    }

    handleEvent() {
        console.log("handleEvent method invoked");
    }

    render() {
        return  <div className="m-2">
                    <div className="h4 bg-primary text-white text-center p-2">
                        { this.state.message }
                    </div>
                    <div className="text-center">
                        <button className="btn btn-primary"
                            onClick={ this.handleEvent }>
                                Click Me
                        </button>
                    </div>
            </div>
    }
}

Listing 12-7Adding an Event Handling Method in the App.js File in the src Folder

注意,onClick表达式不包含括号,这将导致 React 在调用render方法时调用函数,如侧栏中所解释的。handleEvent方法不改变应用的状态,只是向浏览器的 JavaScript 控制台写出一条消息。如果您单击浏览器窗口中的按钮,您将在控制台中看到以下输出:

handleEvent method invoked

避免事件函数调用陷阱

分配给事件处理属性(如onClick)的值必须是一个表达式,该表达式返回 React 可以调用来处理事件的函数。使用事件处理属性时有两个常见错误。第一个错误是用引号而不是大括号将您需要的函数括起来,就像这样:

...
<button className="btn btn-primary" onClick="this.handleEvent" >
...

这为 React 提供了一个字符串值,而不是一个函数,并在浏览器的 JavaScript 控制台中产生一个错误。另一个常见错误是使用调用所需函数的表达式。

...
<button className="btn btn-primary" onClick={ this.handleEvent() } >
...

该表达式导致 React 在创建组件对象时调用handleEvent方法,而不是在触发事件时调用。您不会收到关于此错误的错误或警告,这使得问题更难发现。

在事件处理方法中访问组件功能

如果您需要在处理事件的方法中访问组件的功能,则需要做额外的工作。调用 JavaScript 类方法时,默认情况下不会设置关键字this的值,这意味着handleEvent方法中的语句无法访问组件的方法和属性。在清单 12-8 中,我在handleEvent方法中添加了一个调用setState方法的语句,可以使用this关键字访问该方法。

import React, { Component } from 'react';

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            message: "Ready"
        }
    }

    handleEvent() {
        this.setState({ message: "Clicked!"});
    }

    render() {
        return  <div className="m-2">
                    <div className="h4 bg-primary text-white text-center p-2">
                        { this.state.message }
                    </div>
                    <div className="text-center">
                        <button className="btn btn-primary"
                            onClick={ this.handleEvent }>
                                Click Me
                        </button>
                    </div>
            </div>
    }
}

Listing 12-8Accessing Component Features in the App.js File in the src Folder

点击button时会调用handleEvent方法,但由于this未定义,会产生以下错误:

Uncaught TypeError: Cannot read property 'setState' of undefined

为了确保给this赋值,可以使用 JavaScript 公共类字段语法来表达事件处理方法,如清单 12-9 所示。

import React, { Component } from 'react';

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            message: "Ready"
        }
    }

    handleEvent = () => {
        this.setState({ message: "Clicked!"});
    }

    render() {
        return  <div className="m-2">
                    <div className="h4 bg-primary text-white text-center p-2">
                        { this.state.message }
                    </div>
                    <div className="text-center">
                        <button className="btn btn-primary"
                            onClick={ this.handleEvent }>
                                Click Me
                        </button>
                    </div>
            </div>
    }
}

Listing 12-9Redefining an Event Handling Method in the App.js File in the src Folder

方法名后面是等号、左括号和右括号、粗箭头符号,然后是消息体,如清单所示。这是一个笨拙的语法,但我更喜欢它而不是其他的(在侧栏中描述的),这是我在本章和本书其余部分使用的方法。当你点击按钮元素时,handleEvent方法被提供一个this的值,产生如图 12-3 所示的结果。

img/473159_1_En_12_Fig3_HTML.jpg

图 12-3

事件处理程序的绑定

访问组件特征的替代方法

有两种方法可以为事件处理方法提供一个值this。第一种是在事件属性的表达式中使用内联函数。

...
<button className="btn btn-primary"
        onClick={ () => this.handleEvent() }>
    Click Me
</button>
...

请注意,事件处理程序方法是由表达式调用的,这意味着在方法名后面需要左括号和右括号。另一种方法是为组件的每个事件处理程序方法的构造函数添加一条语句。

...
constructor(props) {
    super(props);
    this.state = {
        message: "Ready"
    }
    this.handleEvent = this.handleEvent.bind(this);
}
...

这三种方法都需要一段时间来适应——而且都有点不优雅——你应该遵循你觉得最舒服的方法。

接收事件对象

当事件被触发时,React 向 handler 对象提供一个描述事件的SyntheticEvent对象。SyntheticEvent是由 DOM API 提供的Event对象的包装器,它定义了相同的特性,但是增加了代码以确保事件在不同的浏览器中得到一致的描述。SyntheticEvent对象具有表 12-3 中描述的基本属性和方法。(我将在后面的章节中描述更多的方法和属性。)

表 12-3

由合成事件对象定义的基本属性和方法

|

名字

|

描述

|
| --- | --- |
| nativeEvent | 该属性返回 DOM API 提供的Event对象。 |
| target | 此属性返回表示作为事件源的元素的对象。 |
| timeStamp | 该属性返回一个时间戳,指示事件的触发时间。 |
| type | 此属性返回一个指示事件类型的字符串。 |
| isTrusted | 当浏览器启动事件时,该属性返回true,当代码创建事件对象时,该属性返回false。 |
| preventDefault() | 调用此方法是为了防止事件的默认行为,如“防止默认行为”一节所述。 |
| defaultPrevented | 如果对事件对象调用了preventDefault方法,该属性返回true,否则返回false。 |
| persist() | 调用此方法是为了避免重用事件对象,这对于异步操作很重要,如“避免事件重用陷阱”一节所述。 |

React 事件与 DOM 事件

React 事件在组件和它所呈现的内容之间提供了一个必要的链接——但是 React 事件不是 DOM 事件,即使它们在大多数时间都是相同的。如果您超越了最常用的特性,您将会遇到重要的差异,这些差异可能会产生意想不到的结果。

首先,React 不支持所有事件,这意味着有些 DOM API 事件没有组件可以使用的相应 React 属性。在 https://reactjs.org/docs/events.html 可以看到 React 支持的事件集合。列表中包含了最常用的事件,但并非每个事件都可用。

其次,React 不允许组件创建和发布定制事件。组件间交互的 React 模型是通过函数 props 实现的,如第十章所述,当使用Event.dispatchEvent方法时,自定义事件不会被分发。

第三,React 提供了一个自定义对象作为 DOM 事件对象的包装器,它并不总是以与 DOM 事件相同的方式运行。您可以通过包装器访问 DOM 事件,但是这样做要小心,因为它可能会导致意想不到的副作用。

最后,React 截获处于冒泡阶段的 DOM 事件(将在本章后面描述),并通过组件的层次结构提供它们,为组件提供响应事件和更新它们呈现的内容的机会。这意味着事件包装器对象提供的一些特性不能按预期工作,特别是在传播方面,如“管理事件传播”一节所述。

在清单 12-10 中,我更新了handleEvent方法,以便它使用 React 提供的事件对象来更新组件的状态。

import React, { Component } from 'react';

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            message: "Ready"
        }
    }

    handleEvent = (event) => {
        this.setState({ message:  `Event: ${event.type} `});
    }

    render() {
        return  <div className="m-2">
                    <div className="h4 bg-primary text-white text-center p-2">
                        { this.state.message }
                    </div>
                    <div className="text-center">
                        <button className="btn btn-primary"
                            onClick={ this.handleEvent }>
                                Click Me
                        </button>
                    </div>
            </div>
    }
}

Listing 12-10Receiving an Event Object in the App.js File in the src Folder

我在handleEvent方法中添加了一个event参数,我用它在显示给用户的消息中包含了type属性的值,如图 12-4 所示。

img/473159_1_En_12_Fig4_HTML.jpg

图 12-4

接收事件对象

区分事件类型

React 在调用事件处理函数时总是提供一个SyntheticEvent对象,如果您习惯于使用instanceof关键字来区分由 DOM API 创建的事件,这可能会引起混淆。在清单 12-11 中,我更改了button元素,因此handleEvent方法用于响应MouseUpMouseDown事件。

import React, { Component } from 'react';

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            message: "Ready"
        }
    }

    handleEvent = (event) => {
        if (event.type === "mousedown") {
            this.setState({ message: "Down"});
        } else {
            this.setState({ message: "Up"});
        }
    }

    render() {
        return  <div className="m-2">
                    <div className="h4 bg-primary text-white text-center p-2">
                        { this.state.message }
                    </div>
                    <div className="text-center">
                        <button className="btn btn-primary"
                            onMouseDown={ this.handleEvent }
                            onMouseUp={ this.handleEvent } >
                                Click Me
                        </button>
                    </div>
            </div>
    }
}

Listing 12-11Differentiating Events in the App.js File in the src Folder

handleEvent方法使用type属性来确定正在处理哪个事件,并相应地更新message值。当你按下鼠标按钮时,触发一个mousedown事件,当你松开鼠标按钮时,触发一个mouseup事件,如图 12-5 所示。

img/473159_1_En_12_Fig5_HTML.jpg

图 12-5

区分事件类型

避免事件重用陷阱

一旦一个事件被处理,React 重用SyntheticEvent对象并将所有属性重置为null。如第十一章所述,如果你依赖异步更新状态数据,这会导致问题。清单 12-12 展示了这个问题。

import React, { Component } from 'react';

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            message: "Ready",
            counter: 0
        }
    }

    handleEvent = (event) => {
        this.setState({ counter: this.state.counter + 1},
            () => this.setState({ message: `${event.type}: ${this.state.counter}`}));
    }

    render() {
        return  <div className="m-2">
                    <div className="h4 bg-primary text-white text-center p-2">
                        { this.state.message }
                    </div>
                    <div className="text-center">
                        <button className="btn btn-primary"
                            onClick={ this.handleEvent } >
                                Click Me
                        </button>
                    </div>
            </div>
    }
}

Listing 12-12Using an Event Object Asynchronously in the App.js File in the src Folder

在应用了对counter属性的更新之后,handleEvent方法使用setState方法的回调特性来更新message属性。分配给message属性的值包括事件对象的type属性,这是一个问题,因为当setState回调函数被调用时,该属性将被设置为null,这可以通过单击按钮看到,如图 12-6 所示。

img/473159_1_En_12_Fig6_HTML.jpg

图 12-6

异步使用事件对象

persist方法用于防止 React 重置事件对象,如清单 12-13 所示。

...
handleEvent = (event) => {
    event.persist();
    this.setState({ counter: this.state.counter + 1},
        () => this.setState({ message: `${event.type}: ${this.state.counter}`}));
}
...

Listing 12-13Persisting an Event Object in the App.js File in the src Folder

结果是可以从setState方法的回调函数中读取事件的属性,产生如图 12-7 所示的结果。

img/473159_1_En_12_Fig7_HTML.jpg

图 12-7

持续事件

使用自定义参数调用事件处理程序

如果为事件处理程序提供自定义参数,而不是 React 默认提供的SythenticEvent对象,那么它们通常会更有用。为了演示为什么事件对象并不总是有用,我向由App组件呈现的内容添加了另一个button元素,并设置了事件处理程序,以便它使用该事件来确定哪个按钮被点击了,如清单 12-14 所示。

import React, { Component } from 'react';

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            message: "Ready",
            counter: 0,
            theme: "secondary"
        }
    }

    handleEvent = (event) => {
        event.persist();
        this.setState({
            counter: this.state.counter + 1,
            theme: event.target.innerText === "Normal" ? "primary" : "danger"
        }, () => this.setState({ message: `${event.type}: ${this.state.counter}`}));
    }

    render() {
        return  <div className="m-2">
                    <div className={ `h4 bg-${this.state.theme}
                            text-white text-center p-2`}>
                        { this.state.message }
                    </div>
                    <div className="text-center">
                        <button className="btn btn-primary"
                            onClick={ this.handleEvent } >
                                Normal
                        </button>
                        <button className="btn btn-danger m-1"
                            onClick={ this.handleEvent } >
                                Danger
                        </button>
                    </div>
            </div>
    }
}

Listing 12-14Identifying the Source of an Event in the App.js File in the src Folder

这种方法的问题在于,事件处理程序必须理解组件所呈现内容的重要性。在这种情况下,这意味着知道innerText属性的值可以用来计算事件的来源并确定theme状态数据属性的值。如果组件呈现的内容发生变化,或者如果有多个交互可以产生相同的结果,这可能很难管理。一种更优雅的方法是为事件处理程序属性使用内联表达式,该表达式调用处理程序方法并为其提供所需的信息,如清单 12-15 所示。

import React, { Component } from 'react';

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            message: "Ready",
            counter: 0,
            theme: "secondary"
        }
    }

    handleEvent = (event, newTheme) => {
        event.persist();
        this.setState({
            counter: this.state.counter + 1,
            theme: newTheme
        }, () => this.setState({ message: `${event.type}: ${this.state.counter}`}));
    }

    render() {
        return  <div className="m-2">
                    <div className={ `h4 bg-${this.state.theme}
                            text-white text-center p-2`}>
                        { this.state.message }
                    </div>
                    <div className="text-center">
                        <button className="btn btn-primary"
                            onClick={ (e) => this.handleEvent(e, "primary") } >
                                Normal
                        </button>
                        <button className="btn btn-danger m-1"
                            onClick={ (e) => this.handleEvent(e, "danger") } >
                                Danger
                        </button>
                    </div>
            </div>
    }
}

Listing 12-15Invoking a Handler with a Custom Argument in the App.js File in the src Folder

结果是一样的,但是handleEvent方法不必为了设置theme属性而检查触发事件的元素。要查看设置主题的效果,单击任一按钮元素,如图 12-8 所示。

img/473159_1_En_12_Fig8_HTML.jpg

图 12-8

使用自定义参数

小费

如果你的 handler 方法不需要 event 对象,那么你可以使用 inline 表达式来调用没有它的 handler:() => handleEvent("primary")

防止默认行为

有些事件具有浏览器默认执行的行为。例如,单击复选框的默认行为是切换复选框的状态。可以在事件对象上调用preventDefault方法来防止默认行为,为了进行演示,我在内容中添加了一个checkbox元素,只有在其中一个按钮元素被单击后才会被切换,如清单 12-16 所示。

import React, { Component } from 'react';

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            message: "Ready",
            counter: 0,
            theme: "secondary"
        }
    }

    handleEvent = (event, newTheme) => {
        event.persist();
        this.setState({
            counter: this.state.counter + 1,
            theme: newTheme
        },  () => this.setState({ message: `${event.type}: ${this.state.counter}`}));
    }

    toggleCheckBox = (event) => {
        if (this.state.counter === 0) {
            event.preventDefault();
        }
    }

    render() {
        return  <div className="m-2">
                    <div className="form-check">
                        <input className="form-check-input" type="checkbox"
                             onClick={ this.toggleCheckBox }/>
                        <label>This is a checkbox</label>
                    </div>

                    <div className={ `h4 bg-${this.state.theme}
                            text-white text-center p-2`}>
                        { this.state.message }
                    </div>
                    <div className="text-center">
                        <button className="btn btn-primary"
                            onClick={ (e) => this.handleEvent(e, "primary") } >
                                Normal
                        </button>
                        <button className="btn btn-danger m-1"
                            onClick={ (e) => this.handleEvent(e, "danger") } >
                                Danger
                        </button>
                    </div>
            </div>
    }
}

Listing 12-16Preventing Default Behavior in the App.js File in the src Folder

input元素上的onClick属性告诉 React 在用户单击复选框时调用toggleCheckBox方法。如果counter状态数据属性的值为零,则在事件上调用preventDefault方法,结果是直到点击按钮后才能切换复选框,如图 12-9 所示。

img/473159_1_En_12_Fig9_HTML.jpg

图 12-9

防止事件默认行为

管理事件传播

事件有一个生命周期,它允许元素的祖先接收由它们的后代触发的事件,并且在事件到达元素之前拦截事件。在接下来的章节中,我将描述事件如何通过 HTML 元素传播,并解释这对 React 应用的影响,使用表 12-4 中描述的SyntheticEvent定义的属性和方法。

表 12-4

事件传播的综合事件属性和方法

|

名字

|

描述

|
| --- | --- |
| eventPhase | 此属性返回事件的传播阶段。但是,React 处理事件的方式意味着该属性没有用,如“确定事件阶段”一节所述。 |
| bubbles | 如果事件将进入冒泡阶段,该属性返回true。 |
| currentTarget | 此属性返回一个对象,该对象表示其事件处理程序正在处理事件的元素。 |
| stopPropagation() | 调用此方法来停止事件传播,如“停止事件传播”一节中所述。 |
| isPropagationStopped() | 如果在一个事件上调用了stopPropagation,这个方法返回true。 |

了解目标和气泡阶段

当一个事件第一次被触发时,它进入目标阶段,在这里调用应用于作为事件源的元素的事件处理程序。一旦这些事件处理程序完成,事件就进入冒泡阶段,在这个阶段,事件沿着祖先元素链向上运行,并被用来调用任何已经应用于该类型事件的处理程序。为了帮助演示这些阶段,我在src文件夹中添加了一个名为ThemeButton.js的文件,并用它来定义清单 12-17 中所示的组件。

import React, { Component } from "react";

export class ThemeButton extends Component {

    handleClick = (event) => {
        console.log(`ThemeButton: Type: ${event.type} `
            + `Target: ${event.target.tagName} `
            + `CurrentTarget: ${event.currentTarget.tagName}`);
        this.props.callback(this.props.theme);
    }

    render() {
        return  <span className="m-1" onClick={ this.handleClick }>
                    <button className={`btn btn-${this.props.theme}`}
                        onClick={ this.handleClick }>
                            Select {this.props.theme } Theme
                    </button>
                </span>
    }
}

Listing 12-17The Contents of the ThemeButton.js File in the src Folder

该组件呈现一个包含一个buttonspan元素,并提供一个theme属性,它指定了一个引导 CSS 主题名称,以及一个被调用来选择属性的callback属性。onClick属性已经应用于spanbutton元素。在清单 12-18 中,我更新了App组件以使用ThemeButton组件,并删除了一些在早期示例中使用的代码。

import React, { Component } from 'react';

import { ThemeButton } from "./ThemeButton";

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            message: "Ready",
            counter: 0,
            theme: "secondary"
        }
    }

    selectTheme = (newTheme) => {
        this.setState({
            theme: newTheme,
            message: `Theme: ${newTheme}`
        });
    }

    render() {
        return  (
            <div className="m-2">
                <div className={ `h4 bg-${this.state.theme}
                        text-white text-center p-2`}>
                    { this.state.message }
                </div>
                <div className="text-center">
                    <ThemeButton theme="primary" callback={ this.selectTheme } />
                    <ThemeButton theme="danger" callback={ this.selectTheme } />
                </div>
            </div>
        )
    }
}

Listing 12-18Applying a Component in the App.js File in the src Folder

单击任一button元素,您将在浏览器的 JavaScript 控制台中看到以下输出:

...
ThemeButton: Type: click Target: BUTTON CurrentTarget: BUTTON
ThemeButton: Type: click Target: BUTTON CurrentTarget: SPAN
...

控制台中有两条消息,因为在由ThemeButton组件呈现的内容中有两个onClick属性。第一条消息是在目标阶段生成的,此时事件由触发它的元素的处理程序处理,在本例中是button元素。然后,事件进入冒泡阶段,通过按钮元素的祖先向上传播,并调用任何合适的事件处理程序。在这个例子中,按钮的父元素span也有一个onClick属性,这导致对handleClick方法的两次调用和两条消息被写入控制台。

小费

并非所有类型的事件都有泡沫阶段。根据经验,特定于单个元素的事件——比如获得和失去焦点——不会冒泡。应用于多个元素的事件(例如单击被多个元素占据的屏幕区域)将冒泡。您可以通过读取事件对象的bubbles属性来查看特定事件是否将经历冒泡阶段。

冒泡阶段超出了组件所呈现的内容,并在 HTML 元素的整个层次结构中传播。为了演示,我向由App组件呈现的元素添加了onClick处理程序,当它从由ThemeButton组件呈现的button元素冒泡时,将接收到click事件,如清单 12-19 所示。

import React, { Component } from 'react';
import { ThemeButton } from "./ThemeButton";

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            message: "Ready",
            counter: 0,
            theme: "secondary"
        }
    }

    selectTheme = (newTheme) => {
        this.setState({
            theme: newTheme,
            message: `Theme: ${newTheme}`
        });
    }

    handleClick= (event) => {
        console.log(`App: Type: ${event.type} `
            + `Target: ${event.target.tagName} `
            + `CurrentTarget: ${event.currentTarget.tagName}`);
    }

    render() {
        return  (
            <div className="m-2" onClick={ this.handleClick }>
                    <div className={ `h4 bg-${this.state.theme}
                            text-white text-center p-2`}>
                        { this.state.message }
                    </div>
                    <div className="text-center" onClick={ this.handleClick }>
                        <ThemeButton theme="primary" callback={ this.selectTheme } />
                        <ThemeButton theme="danger" callback={ this.selectTheme } />
                    </div>
            </div>
        )
    }
}

Listing 12-19Adding Event Handlers in the App.js File in the src Folder

我将onClick属性添加到两个div元素中,当您单击其中一个按钮时,您将看到浏览器的 JavaScript 控制台中显示以下一系列消息(有些浏览器将最后两条消息组合在一起,因为它们是相同的):

...
ThemeButton: Type: click Target: BUTTON CurrentTarget: BUTTON
ThemeButton: Type: click Target: BUTTON CurrentTarget: SPAN
App: Type: click Target: BUTTON CurrentTarget: DIV
App: Type: click Target: BUTTON CurrentTarget: DIV
...

SyntheticEvent对象提供了currentTarget属性,该属性返回其事件处理程序被调用的元素,而target属性返回触发事件的元素。

...
console.log(`ThemeButton: Type: ${event.type} `
    + `Target: ${event.target.tagName} `
    + `CurrentTarget: ${event.currentTarget.tagName}`);
...

这些消息显示了click事件在 HTML 元素层次结构中向上传播时的目标和冒泡阶段,如图 12-10 所示。

img/473159_1_En_12_Fig10_HTML.jpg

图 12-10

事件的目标和泡沫阶段

应用组件的事件和元素

事件处理由组件呈现的 HTML 元素执行,不包括用于应用组件的自定义 HTML 元素。例如,向ThemeButton元素添加事件处理程序属性(如onClick)没有任何效果。没有报告错误,但是自定义元素被排除在浏览器显示的 HTML 之外,并且永远不会调用处理程序。

了解捕获阶段

捕获阶段为元素提供了在目标阶段之前处理事件的机会。在捕获阶段,浏览器从body元素开始,沿着与冒泡阶段相反的路径,沿着元素的层次结构向目标前进,并给每个元素处理事件的机会,如图 12-11 所示。

img/473159_1_En_12_Fig11_HTML.jpg

图 12-11

事件捕获阶段

需要一个单独的属性来告诉 React 应该在捕获阶段应用一个事件处理程序,如清单 12-20 所示。

import React, { Component } from "react";

export class ThemeButton extends Component {

    handleClick = (event) => {
        console.log(`ThemeButton: Type: ${event.type} `
            + `Target: ${event.target.tagName} `
            + `CurrentTarget: ${event.currentTarget.tagName}`);
        this.props.callback(this.props.theme);
    }

    render() {
        return  <span className="m-1" onClick={ this.handleClick }
                        onClickCapture={ this.handleClick }>
                    <button className={`btn btn-${this.props.theme}`}
                        onClick={ this.handleClick }>
                            Select {this.props.theme } Theme
                    </button>
                </span>
    }
}

Listing 12-20Capturing an Event in the ThemeButton.js File in the src Folder

对于每个事件处理属性,比如onClick,都有一个相应的捕获属性onClickCapture,它在捕获阶段接收事件。在清单中,我将onClickCapture属性应用于span元素,并在表达式中指定了handleClick方法。结果是,span元素将在capturebubble阶段接收到click事件,因为事件沿着 HTML 元素的层次结构向下,然后又向上返回。单击任何一个button元素都会在浏览器的 JavaScript 控制台中产生一条额外的消息。

...

ThemeButton: Type: click Target: BUTTON CurrentTarget: SPAN

ThemeButton: Type: click Target: BUTTON CurrentTarget: BUTTON
ThemeButton: Type: click Target: BUTTON CurrentTarget: SPAN
App: Type: click Target: BUTTON CurrentTarget: DIV
App: Type: click Target: BUTTON CurrentTarget: DIV
...

确定事件阶段

ThemeButton组件定义的handleClick方法将为每个click事件处理几次事件,并且它从捕获阶段移动到目标阶段,然后是冒泡阶段。每次调用handleClick方法时,它都会调用父组件提供的 function prop,其效果是重复更改App组件的theme state 属性的值。这是一种无害的效果,但是在实际项目中,重复调用回调会导致问题,并且对于子组件来说,假设可以在没有问题的情况下调用 props 是一种不好的做法。为了突出这个问题,我向ThemeButton组件的handleEvent方法添加了一条语句,当调用函数 prop 时,该语句向浏览器的 JavaScript 控制台写入一条消息,如清单 12-21 所示。

import React, { Component } from "react";

export class ThemeButton extends Component {

    handleClick = (event) => {
        console.log(`ThemeButton: Type: ${event.type} `
            + `Target: ${event.target.tagName} `
            + `CurrentTarget: ${event.currentTarget.tagName}`);
        console.log("Invoked function prop");
        this.props.callback(this.props.theme);
    }

    render() {
        return  <span className="m-1" onClick={ this.handleClick }
                        onClickCapture={ this.handleClick }>
                    <button className={`btn btn-${this.props.theme}`}
                        onClick={ this.handleClick }>
                            Select {this.props.theme } Theme
                    </button>
                </span>
    }
}

Listing 12-21Adding a Debugging Message in the ThemeButton.js File in the src Folder

单击示例应用提供的一个按钮,您将会看到函数 prop 为click事件经历的三个阶段中的每一个阶段调用。

...
ThemeButton: Type: click Target: BUTTON CurrentTarget: SPAN

Invoked function prop

ThemeButton: Type: click Target: BUTTON CurrentTarget: BUTTON

Invoked function prop

ThemeButton: Type: click Target: BUTTON CurrentTarget: SPAN

Invoked function prop

App: Type: click Target: BUTTON CurrentTarget: DIV
App: Type: click Target: BUTTON CurrentTarget: DIV
...

React 使用的SythenticEvent对象定义了一个eventPhase属性,该属性从原生 DOM API 事件对象返回相应属性的值。不幸的是,该属性的值总是指示事件处于冒泡阶段,因为 React 截获了本机事件,并使用它来模拟三个传播阶段。因此,需要做更多的工作来识别事件阶段。

第一步是在捕获阶段识别事件,这可以通过使用不同的处理程序方法或向通用处理程序提供额外的参数来完成,这是我在清单 12-22 中采用的方法。

import React, { Component } from "react";

export class ThemeButton extends Component {

    handleClick = (event, capturePhase = false) => {
        console.log(`ThemeButton: Type: ${event.type} `
            + `Target: ${event.target.tagName} `
            + `CurrentTarget: ${event.currentTarget.tagName}`);
        if (capturePhase) {
            console.log("Skipped function prop: capture phase");
        } else {
            console.log("Invoked function prop");
            this.props.callback(this.props.theme);
        }
    }

    render() {
        return  <span className="m-1" onClick={ this.handleClick }
                        onClickCapture={ (e) => this.handleClick(e, true) }>
                    <button className={`btn btn-${this.props.theme}`}
                        onClick={ this.handleClick }>
                            Select {this.props.theme } Theme
                    </button>
                </span>
    }
}

Listing 12-22Identifying Capture Phase Events in the ThemeButton.js File in the src Folder

我为接收SythenticEvent对象的onClickCapture属性使用了一个内联表达式,并使用它来调用handleClick方法,以及一个指示事件处于捕获阶段的附加参数。在handleClick方法中,我检查了capturePhase参数的值,以识别捕获阶段的事件。

分离目标和气泡阶段更加困难,因为两个阶段中的事件都由onClick属性处理。确定阶段最可靠的方法是查看targetcurrentTarget属性的值是否不同,以及查看bubbles属性是否为true。如果currentTarget返回的对象不同于target的值,并且事件有一个冒泡阶段,那么有理由假设事件正在冒泡,如清单 12-23 所示。

import React, { Component } from "react";

export class ThemeButton extends Component {

    handleClick = (event, capturePhase = false) => {
        console.log(`ThemeButton: Type: ${event.type} `
            + `Target: ${event.target.tagName} `
            + `CurrentTarget: ${event.currentTarget.tagName}`);
        if (capturePhase) {
            console.log("Skipped function prop: capture phase");
        } else if (event.bubbles && event.currentTarget !== event.target) {
            console.log("Skipped function prop: bubble phase");
        } else {
            console.log("Invoked function prop");
            this.props.callback(this.props.theme);
        }
    }

    render() {
        return  <span className="m-1" onClick={ this.handleClick }
                        onClickCapture={ (e) => this.handleClick(e, true) }>
                    <button className={`btn btn-${this.props.theme}`}
                        onClick={ this.handleClick }>
                            Select {this.props.theme } Theme
                    </button>
                </span>
    }
}

Listing 12-23Identifying Bubble Phase Events in the ThemeButton.js File in the src Folder

当您单击一个按钮时,您将在浏览器的 JavaScript 控制台中看到以下消息序列,表明每个阶段都已被识别,并且只在目标阶段调用了函数 prop。

...
ThemeButton: Type: click Target: BUTTON CurrentTarget: SPAN

Skipped function prop: capture phase

ThemeButton: Type: click Target: BUTTON CurrentTarget: BUTTON

Invoked function prop

ThemeButton: Type: click Target: BUTTON CurrentTarget: SPAN

Skipped function prop: bubble phase

App: Type: click Target: BUTTON CurrentTarget: DIV
App: Type: click Target: BUTTON CurrentTarget: DIV
...

这些消息还确认了事件阶段的顺序:捕获、瞄准,然后冒泡。

停止事件传播

如果您想要中断正常的传播序列并阻止元素接收事件,了解事件阶段也很重要。在清单 12-24 中,我修改了ThemeButton组件,使其在捕获阶段拦截点击事件并阻止它们到达目标元素。

import React, { Component } from "react";

export class ThemeButton extends Component {

    handleClick = (event, capturePhase = false) => {
        console.log(`ThemeButton: Type: ${event.type} `
            + `Target: ${event.target.tagName} `
            + `CurrentTarget: ${event.currentTarget.tagName}`);
        if (capturePhase) {
            if (this.props.theme === "danger") {
                event.stopPropagation();
                console.log("Stopped event");
            } else {
                console.log("Skipped function prop: capture phase");
            }
        } else if (event.bubbles && event.currentTarget !== event.target) {
            console.log("Skipped function prop: bubble phase");
        } else {
            console.log("Invoked function prop");
            this.props.callback(this.props.theme);
        }
    }

    render() {
        return  <span className="m-1" onClick={ this.handleClick }
                        onClickCapture={ (e) => this.handleClick(e, true) }>
                    <button className={`btn btn-${this.props.theme}`}
                        onClick={ this.handleClick }>
                            Select {this.props.theme } Theme
                    </button>
                </span>
    }
}

Listing 12-24Stopping Event Propagation in the ThemeButton.js File in the src Folder

当在捕获阶段收到一个click事件时,span元素上的onClickCapture属性将调用handleClick方法。当theme属性的值为danger时,调用stopPropagation方法,阻止事件到达button元素,具有阻止用户选择danger主题的效果,如图 12-12 所示。

img/473159_1_En_12_Fig12_HTML.jpg

图 12-12

停止事件

摘要

在本章中,我描述了 React 提供的处理事件的特性。我演示了定义处理函数的不同方法,展示了如何使用事件对象,并展示了如何使用自定义参数。我还解释了 React 事件为什么不同于 DOM API 事件,尽管它们是相似且密切相关的。在本章的最后,我介绍了事件生命周期,并向您展示了事件是如何传播的。在下一章,我将描述组件的生命周期,并解释如何协调状态数据的变化。

十三、协调与生命周期

在这一章中,我将解释 React 如何使用一个叫做协调的过程来有效地处理组件产生的内容。协调过程是 React 为组件提供的更大生命周期的一部分,我将描述不同的生命周期阶段,并向您展示有状态组件如何实现方法以成为主动的生命周期参与者。表 13-1 将协调和组件生命周期放在上下文中。

表 13-1

将协调和生命周期文本置于上下文中

|

问题

|

回答

|
| --- | --- |
| 这是什么? | 协调是有效处理由组件产生的内容以最小化对文档对象模型(DOM)的更改的过程。协调是应用于有状态组件的更大生命周期的一部分。 |
| 为什么有用? | 协调过程有助于应用的性能,而更广泛的组件生命周期为应用开发提供了一致的模型,并为高级项目提供了有用的功能。 |
| 如何使用? | 协调过程是自动执行的,不需要任何明确的操作。所有有状态组件都经历相同的生命周期,并且可以通过实现特定的方法(对于基于类的组件)或效果挂钩(对于功能组件)来积极参与。 |
| 有什么陷阱或限制吗? | 编写组件时必须小心,使它们适合整个生命周期,包括能够呈现内容,即使它可能不用于更新 DOM。 |
| 还有其他选择吗? | 不,生命周期和协调过程是 React 的基本特性。 |

表 13-2 总结了本章内容。

表 13-2

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 触发协调 | 调用forceUpdate方法 | 15, 16 |
| 响应生命周期阶段 | 实现与生命周期阶段相对应的方法 | 17–20 |
| 在功能组件中接收通知 | 使用效果挂钩 | 21–23 |
| 阻止更新 | 实现shouldComponentUpdate方法 | 24, 25 |
| 从 props 设置状态数据 | 实现getDerivedStateFromProps方法 | 26, 27 |

为本章做准备

为了创建本章的示例项目,打开一个新的命令提示符,导航到一个方便的位置,并运行清单 13-1 中所示的命令。

小费

你可以从 https://github.com/Apress/pro-react-16 下载本章以及本书其他章节的示例项目。

npx create-react-app lifecycle

Listing 13-1Creating the Example Project

运行清单 13-2 中所示的命令,导航到lifecycle文件夹,并将引导包添加到项目中。

cd lifecycle
npm install bootstrap@4.1.2

Listing 13-2Adding the Bootstrap CSS Framework

为了在应用中包含引导 CSS 样式表,将清单 13-3 中所示的语句添加到index.js文件中,该文件可以在src文件夹中找到。

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

import 'bootstrap/dist/css/bootstrap.css';

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

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

Listing 13-3Including Bootstrap in the index.js File in the src Folder

创建示例组件

本章中的示例需要一些基本组件。在src文件夹中添加一个名为ActionButton.js的文件,并添加清单 13-4 所示的内容。

import React, { Component } from "react";

export class ActionButton extends Component {

    render() {
        console.log(`Render ActionButton (${this.props.text}) Component `);
        return <button className="btn btn-primary m-2"
                        onClick={ this.props.callback }>
                            { this.props.text }
                </button>
    }
}

Listing 13-4The Contents of the ActionButton.js File in the src Folder

该组件呈现一个按钮,该按钮调用一个函数 prop 来响应click事件。接下来,将名为Message.js的文件添加到src文件夹中,并添加清单 13-5 中所示的内容。

import React, { Component } from "react";
import { ActionButton } from "./ActionButton";

export class Message extends Component {

    render() {
        console.log(`Render Message Component `);
        return (
            <div>
                <ActionButton theme="primary"  {...this.props} />
                <div className="h5 text-center p-2">
                    { this.props.message }
                </div>
            </div>
        )
    }
}

Listing 13-5The Contents of the Message.js File in the src Folder

这个组件显示一个作为 prop 接收的消息,并传递一个函数 prop 作为对一个ActionButton的回调,如清单 13-4 中所定义的。接下来,将名为List.js的文件添加到src文件夹中,并添加清单 13-6 中所示的内容。

import React, { Component } from "react";
import { ActionButton } from "./ActionButton";

export class List extends Component {

    constructor(props) {
        super(props);
        this.state = {
            names: ["Bob", "Alice", "Dora"]
        }
    }

    reverseList = () => {
        this.setState({ names: this.state.names.reverse()});
    }

    render() {
        console.log("Render List Component");
        return (
            <div>
                <ActionButton callback={ this.reverseList }
                    text="Reverse Names" />
                { this.state.names.map((name, index) => {
                    return <h5 key={ name }>{ name }</h5>
                })}

            </div>
        )
    }
}

Listing 13-6The Contents of the List.js File in the src Folder

这个组件有自己的状态数据,用来呈现一个列表。一个ActionButton组件被提供了一个reverseList方法作为它的函数 prop,它反转了列表中条目的顺序。

最后的修改是用清单 13-7 中所示的代码替换App.js文件的内容,该代码呈现使用其他组件的内容,并定义Message组件所需的状态数据。

import React, { Component } from 'react';
import { Message } from "./Message";
import { List } from "./List";

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            counter: 0
        }
    }

    incrementCounter = () => {
        this.setState({ counter: this.state.counter + 1 });
    }

    render() {
        console.log("Render App Component");
        return  <div className="container text-center">
                    <div className="row p-2">
                        <div className="col-6">
                            <Message message={ `Counter: ${this.state.counter}`}
                                callback={ this.incrementCounter }
                                text="Increment Counter" />
                        </div>
                        <div className="col-6">
                            <List />
                        </div>
                    </div>
                </div>

    }
}

Listing 13-7The Contents of the App.js File in the src Folder

App组件呈现的内容使用引导 CSS 网格特性并排显示MessageList组件。counter属性由incrementCounter方法递增,该方法用作Message组件的函数 prop。使用命令提示符,运行lifecycle文件夹中清单 13-8 所示的命令来启动开发工具。

npm start

Listing 13-8Starting the Development Tools

一旦项目的初始准备完成,一个新的浏览器窗口将打开并显示 URL http://localhost:3000,它将显示如图 13-1 所示的内容。

img/473159_1_En_13_Fig1_HTML.jpg

图 13-1

运行示例应用

了解内容是如何呈现的

呈现过程的起点是调用ReactDOM.render方法的index.js文件中的语句。

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

此方法启动初始呈现过程。React 创建一个新的App组件实例,它由ReactDOM.render方法的第一个参数指定,并调用它的render方法。由App组件呈现的内容包括MessageList元素,React 创建这些组件的实例并调用它们的render方法。流程继续到由MessageList元素呈现的内容中的ActionButton元素,创建ActionButton组件的两个实例,并为每个实例调用render方法。在每个组件上调用render方法的结果是一个 HTML 元素的层次结构,这些元素被插入到由ReactDOM.render方法的第二个参数选择的元素中,创建如图 13-1 所示的内容。初始渲染过程的结果是组件对象和 HTML 元素的层次结构,如图 13-2 所示。

img/473159_1_En_13_Fig2_HTML.jpg

图 13-2

组件及其内容

React 使用浏览器的 API 将 HTML 元素添加到文档对象模型(DOM)中,以便将它们呈现给用户,如图 13-3 所示,并在组件和它们呈现的内容之间创建映射。

img/473159_1_En_13_Fig3_HTML.jpg

图 13-3

将组件映射到它们呈现的内容

浏览器不知道或者不关心组件,它唯一的工作就是在 DOM 中呈现 HTML 元素。React 负责管理组件和处理呈现的内容。

示例应用中的每个组件在其render方法中都有一个console.log语句,浏览器的 JavaScript 控制台中显示的消息显示五个组件对象中的每一个都被要求呈现其内容。

...
Render App Component
Render Message Component
Render ActionButton (Increment Counter) Component
Render List Component
Render ActionButton (Reverse Names) Component
...

有来自一个App组件、一个Message组件、一个List组件和两个ActionButton组件的消息,匹配图 13-2 和 13-3 所示的结构。

了解更新过程

当应用第一次启动时,React 要求所有组件呈现它们的内容,以便可以显示给用户。一旦显示了内容,应用就处于协调的状态,其中显示给用户的内容与组件的状态一致。

当应用被协调时,React 等待某些事情发生变化。在大多数应用中,变化是由用户交互引起的,用户交互触发一个事件并导致对setState方法的调用。setState方法更新组件的状态数据,但是它也将组件标记为“陈旧”,这意味着显示给用户的 HTML 内容可能是过期的。一个事件可能导致多个状态数据更改,一旦它们都被处理,React 将为每个脏组件及其子组件调用render方法。要查看更改的效果,点击浏览器窗口中的增量计数器按钮,如图 13-4 所示。

img/473159_1_En_13_Fig4_HTML.jpg

图 13-4

点击按钮以触发更改

响应click事件的处理程序更新App组件的counter状态数据属性。因为App是顶层组件,这意味着render方法在应用的所有组件上都被调用,这可以在浏览器的 JavaScript 控制台显示的消息中看到。

...
Render App Component
Render Message Component
Render ActionButton (Increment Counter) Component
Render List Component
Render ActionButton (Reverse Names) Component
...

React 只更新受更改影响的组件,最大限度地减少了应用在再次协调之前必须做的工作量。你可以通过点击反转名称按钮来查看这是如何工作的,如图 13-5 所示。

img/473159_1_En_13_Fig5_HTML.jpg

图 13-5

点击一个按钮来触发有限的改变

该按钮的click事件导致List组件的状态数据发生变化,并在浏览器的 JavaScript 控制台中产生以下消息:

...
Render List Component
Render ActionButton (Reverse Names) Component
...

组件List及其子组件ActionButton被标记为陈旧,但是这种变化并没有影响到AppMessage组件或者其他的ActionButton。React 假设这些组件呈现的内容仍然是最新的,不需要更新。

了解调节过程

尽管 React 将调用任何被标记为陈旧的组件的render方法,但它并不总是使用生成的内容。对域对象模型中的 HTML 元素进行更改是一项昂贵的操作,因此 React 将组件返回的内容与先前的结果进行比较,以便它可以要求浏览器执行最少数量的操作,这一过程称为协调

为了演示 React 如何最小化它所做的更改,我对由Message组件呈现的内容做了一个更改,如清单 13-9 所示。

import React, { Component } from "react";
import { ActionButton } from "./ActionButton";

export class Message extends Component {

    render() {
        console.log(`Render Message Component `);
        return (
            <div>
                <ActionButton theme="primary"  {...this.props} />
                <div id="messageDiv" className="h5 text-center p-2">
                    { this.props.message }
                </div>
            </div>
        )
    }
}

Listing 13-9Changing Content in the Message.js File in the src Folder

添加了id属性使得操作div元素变得更加容易。使用 F12 开发工具,切换到控制台选项卡,输入清单 13-10 中所示的语句,然后按回车键。所有浏览器都允许执行 JavaScript 任意语句,在 Google Chrome 中,这是通过在控制台选项卡底部的提示中输入代码来实现的。

document.getElementById("messageDiv").classList.add("bg-info")

Listing 13-10Manipulating an HTML Element

该语句使用 DOM API 选择由Message组件呈现的div元素,并将其分配给bg-info类,后者选择由引导 CSS 框架定义的背景颜色。当你点击 Increment Counter 按钮时,div元素的内容被更新,但颜色不变,因为 React 已经将Message组件的render方法返回的内容与之前的结果进行了比较,检测到只有div元素的内容有所不同,如图 13-6 所示。

img/473159_1_En_13_Fig6_HTML.jpg

图 13-6

和解的效果

React 将组件产生的内容与它自己的以前结果的缓存进行比较,这个缓存被称为虚拟 DOM,它是以一种允许有效比较的格式定义的。其效果是,React 不必查询 DOM 中的元素来找出一组更改。

小费

不要混淆专用于 React 的术语虚拟 DOM影子 DOM ,后者是一种最新的浏览器功能,允许内容限定在 HTML 文档的特定部分。

需要第二个示例来确认协调行为,演示 React 如何处理更复杂的更改。在清单 13-11 中,我向Message组件添加了状态数据,并使用它在两种不同的元素类型之间进行切换。

import React, { Component } from "react";
import { ActionButton } from "./ActionButton";

export class Message extends Component {

    constructor(props) {
        super(props);
        this.state = {
            showSpan: false
        }
    }

    handleClick = (event) => {
        this.setState({ showSpan: !this.state.showSpan });
        this.props.callback(event);
    }

    getMessageElement() {
        let div = <div id="messageDiv" className="h5 text-center p-2">
                        { this.props.message }
                  </div>
        return this.state.showSpan ? <span>{ div } </span> : div;
    }

    render() {
        console.log(`Render Message Component `);
        return (
            <div>
                <ActionButton theme="primary" {...this.props}
                    callback={ this.handleClick } />
                { this.getMessageElement() }
            </div>
        )
    }
}

Listing 13-11Alternating Elements in the Message.js File in the src Folder

该组件在直接显示一个div元素或将其包装在一个span元素中之间交替。保存更改,并在浏览器的 JavaScript 控制台中执行清单 13-12 中所示的语句来设置div元素的背景颜色。注意,在使用 spread 操作符将 props 传递给ActionButton组件之后,我已经定义了callback属性。Message组件从它的父组件接收一个callback属性,所以我必须在之后定义我的替换来覆盖它。

警告

不要在实际项目中更改组件中的顶级元素,因为这会导致 React 替换 DOM 中的元素,而不执行详细的比较来检测更改。

document.getElementById("messageDiv").classList.add("bg-info")

Listing 13-12Manipulating an HTML Element

当您单击增量计数器按钮时,Message组件的render方法将返回包含span元素的内容。第二次点击按钮,render方法将返回原来的内容,但不显示背景颜色,如图 13-7 所示。

img/473159_1_En_13_Fig7_HTML.jpg

图 13-7

协调不同类型的元素

React 将来自render方法的输出与之前的结果进行比较,并检测span元素的引入。React 并不研究新的span元素的内容来执行更详细的比较,只是用它来替换浏览器正在显示的现有的div元素。

了解列表协调

React 特别支持处理显示数据数组的元素。对列表的大多数操作将大部分元素留在数组中,尽管它们可能经常在不同的位置,比如对对象进行排序时。为了确保 React 能够最大限度地减少显示更改所需的更改次数,从数组生成的元素需要有一个key属性,比如由List组件定义的属性。

...
render() {
    console.log("Render List Component");
    return (
        <div>
            <ActionButton callback={ this.reverseList }
                text="Reverse Names" />
            { this.state.names.map((name, index) => {
                return <h5 key={ name }>{ name }</h5>
            })}
        </div>
    )
}
...

属性的值在元素集合中必须是惟一的,这样 React 才能识别每个元素。为了演示 React 如何最小化更新列表所需的更改,我向由List组件呈现的h5元素添加了一个属性,如清单 13-13 所示。

小费

键值应该是稳定的,这样即使在对数组进行更改的操作之后,它们也应该继续引用同一个对象。一个常见的错误是使用对象在数组中的位置作为其索引,这是不稳定的,因为数组上的许多操作会影响对象的顺序。

import React, { Component } from "react";
import { ActionButton } from "./ActionButton";

export class List extends Component {

    constructor(props) {
        super(props);
        this.state = {
            names: ["Bob", "Alice", "Dora"]
        }
    }

    reverseList = () => {
        this.setState({ names: this.state.names.reverse()});
    }

    render() {
        console.log("Render List Component");
        return (
            <div>
                <ActionButton callback={ this.reverseList }
                    text="Reverse Names" />
                { this.state.names.map((name, index) => {
                    return <h5 id={ name.toLowerCase() } key={ name }>{ name }</h5>
                })}
            </div>
        )
    }
}

Listing 13-13Adding an Attribute in the List.js File in the src Folder

添加了id属性使得使用浏览器的 JavaScript 控制台操作元素变得容易,使用的方法与前面的例子相同。使用 JavaScript 控制台执行清单 13-14 中所示的语句,这些语句将h5元素分配给应用引导背景颜色的类。

document.getElementById("bob").classList.add("bg-primary")
document.getElementById("alice").classList.add("bg-secondary")
document.getElementById("dora").classList.add("bg-info")

Listing 13-14Adding Classes to Elements

单击 Reverse Names 按钮,您将看到 h5 元素的顺序发生了变化,但是没有元素被销毁和重新创建,如图 13-8 所示。

img/473159_1_En_13_Fig8_HTML.jpg

图 13-8

对列表中的元素重新排序

明确触发和解

协调过程依赖于 React 通过setState方法得到的更改通知,这允许它确定哪些数据是陈旧的。如果您需要响应应用外部发生的变化,比如外部数据到达时,并不总是可以调用setState方法。对于这些情况,React 提供了forceUpdate方法,该方法可用于显式触发更新,并确保任何更改都反映在呈现给用户的内容中。为了演示显式协调,我在src文件夹中添加了一个名为ExternalCounter.js的文件,并用它来定义清单 13-15 中所示的组件。

警告

如果您发现自己正在使用forceUpdate方法,那么考虑您的应用的设计是值得的。forceUpdate方法是一种钝器,通常可以通过扩展状态数据的使用或应用第十四章中描述的组合技术之一来避免使用。

import React, {Component } from "react";
import { ActionButton } from "./ActionButton";

let externalCounter = 0;

export class ExternalCounter extends Component {

    incrementCounter = () => {
        externalCounter++;
        this.forceUpdate();
    }

    render() {
        return (
            <div>
                <ActionButton callback={ this.incrementCounter }
                    text="External Counter" />
                <div  className="h5 text-center p-2">
                    External: { externalCounter }
                </div>
            </div>
        )
    }
}

Listing 13-15The Contents of the ExternalCounter.js File in the src Folder

对于可以作为状态数据处理的数据来说,这是一个显而易见的候选者,但是并不是所有真实世界的情况都是明确的。在这种情况下,组件依赖于 React 控制之外的变量,这意味着更改变量的值不会将组件标记为状态并启动协调过程。相反,incrementCounter方法调用forceUpdate方法,后者显式地开始协调,并确保新值被合并到显示给用户的内容中。为了将新组件合并到应用中,我对App组件进行了清单 13-16 中所示的更改。

import React, { Component } from 'react';
import { Message } from "./Message";
import { List } from "./List";

import { ExternalCounter } from './ExternalCounter';

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            counter: 0
        }
    }

    incrementCounter = () => {
        this.setState({ counter: this.state.counter + 1 });
    }

    render() {
        console.log("Render App Component");
        return  <div className="container text-center">
                    <div className="row p-2">
                        <div className="col-4">
                            <Message message={ `Counter: ${this.state.counter}`}
                                callback={ this.incrementCounter }
                                text="Increment Counter" />
                        </div>
                        <div className="col-4">
                            <List />
                        </div>
                        <div className="col-4">
                            <ExternalCounter />
                        </div>
                    </div>
                </div>
    }
}

Listing 13-16Adding a New Component in the App.js File in the src Folder

新组件显示在应用布局的右侧,单击外部计数器按钮会明确地将该组件标记为陈旧,并触发协调过程,如图 13-9 所示。

img/473159_1_En_13_Fig9_HTML.jpg

图 13-9

明确开始协调

了解组件生命周期

大多数基于类的有状态组件实现一个构造函数和render方法。构造函数用于从父节点接收属性并定义状态数据。render方法用于在应用启动和 React 响应更新时生成内容。

构造函数和render方法是更大的组件生命周期的一部分,有状态组件可以通过实现方法来参与其中,这些方法对调用作出 React,以表示生命周期中的变化。在接下来的部分中,我将解释组件生命周期的不同阶段以及每个阶段的方法。为了快速参考,表 13-3 列出了常用的生命周期方法。我在“使用高级生命周期方法”一节中描述了三种高级方法。

表 13-3

有状态组件生命周期方法

|

名字

|

描述

|
| --- | --- |
| constructor | 当创建组件类的新实例时,调用这个特殊的方法。 |
| render | 当 React 需要组件中的内容时,调用此方法。 |
| componentDidMount | 此方法在组件呈现的初始内容处理完毕后调用。 |
| componentDidUpdate | 在 React 完成更新后的协调过程后,将调用此方法。 |
| componentWillUnmount | 在销毁组件之前调用此方法。 |
| componentDidCatch | 该方法用于处理错误,如第十四章所述。 |

注意

请参阅“使用效果挂钩”部分,了解挂钩功能如何提供对功能组件的生命周期功能的访问。

了解安装阶段

React 创建一个组件并首次呈现其内容的过程称为挂载,组件实现参与挂载过程的常用方法有三种,如图 13-10 所示。

img/473159_1_En_13_Fig10_HTML.jpg

图 13-10

安装阶段

当 React 需要创建一个组件的新实例时,将调用构造函数,这将使组件有机会从其父组件接收属性,定义其状态数据,并执行其他准备工作。

接下来,调用render方法,以便组件提供将被添加到 DOM 的内容。最后,React 调用componentDidMount方法,告诉组件它的内容已经被添加到 DOM 中。

componentDidMount方法通常用于执行从 web 服务获取数据的 Ajax 请求,我将在第三部分中演示。出于本章的目的,我在Message组件中实现了componentDidMount方法,并用它向浏览器的 JavaScript 控制台写一条消息,如清单 13-17 所示。

import React, { Component } from "react";
import { ActionButton } from "./ActionButton";

export class Message extends Component {

    constructor(props) {
        super(props);
        this.state = {
            showSpan: false
        }
    }

    handleClick = (event) => {
        this.setState({ showSpan: !this.state.showSpan });
        this.props.callback(event);
    }

    getMessageElement() {
        let div = <div id="messageDiv" className="h5 text-center p-2">
                        { this.props.message }
                  </div>
        return this.state.showSpan ? <span>{ div } </span> : div;
    }

    render() {
        console.log(`Render Message Component `);
        return (
            <div>
                <ActionButton theme="primary" {...this.props}
                    callback={ this.handleClick } />
                { this.getMessageElement() }
            </div>
        )
    }

    componentDidMount() {
        console.log("componentDidMount Message Component");
    }
}

Listing 13-17Implementing a Lifecycle Method in the Message.js File in the src Folder

保存对Message组件的更改,并在应用更新时检查浏览器的 JavaScript 控制台中显示的消息,您将看到调用了componentDidMount方法。

...
Render App Component
Render Message Component
Render ActionButton (Increment Counter) Component
Render List Component
Render ActionButton (Reverse Names) Component
Render ActionButton (External Counter) Component

componentDidMount Message Component

...

您可以看到,在调用了所有组件的render方法之后,已经调用了componentDidMount方法。当 React 需要组件的新实例时,调用componentDidMount方法,这包括应用启动。但是当 React 在应用运行时创建一个组件实例时,也会发生挂载,比如当内容被有条件地呈现时,如清单 13-18 所示。

import React, { Component } from 'react';
import { Message } from "./Message";
import { List } from "./List";
import { ExternalCounter } from './ExternalCounter';

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            counter: 0,
            showMessage: true
        }
    }

    incrementCounter = () => {
        this.setState({ counter: this.state.counter + 1 });
    }

    handleChange = () => {
        this.setState({ showMessage: !this.state.showMessage });
    }

    render() {
        console.log("Render App Component");
        return  (
            <div className="container text-center">
                <div className="row p-2">
                    <div className="col-4">
                        <div className="form-check">
                            <input type="checkbox" className="form-check-input"
                                checked={ this.state.showMessage }
                                onChange={ this.handleChange } />
                            <label className="form-check-label">Show</label>
                        </div>
                        { this.state.showMessage &&
                            <Message message={ `Counter: ${this.state.counter}`}
                                callback={ this.incrementCounter }
                                text="Increment Counter" />
                        }
                    </div>
                    <div className="col-4">
                        <List />
                    </div>
                    <div className="col-4">
                        <ExternalCounter />
                    </div>
                </div>
            </div>
        )
    }
}

Listing 13-18Conditionally Displaying a Component in the App.js File in the src Folder

我添加了一个复选框,并使用onChange属性注册了handleChange方法来接收change事件,当复选框被切换时就会触发这些事件。该复选框用于控制Message组件的可见性,如图 13-11 所示。

img/473159_1_En_13_Fig11_HTML.jpg

图 13-11

控制组件的可见性

每次复选框被选中时,React 都会创建一个新的Message对象,并遍历挂载过程,依次调用每个方法:constructorrendercomponentDidMount。这可以在浏览器的 JavaScript 控制台中显示的消息中看到。

了解更新阶段

React 响应变更并进行协调的过程被称为更新阶段,它调用render方法从组件获取内容,然后在协调过程完成后调用componentDidUpdate,如图 13-12 所示。

img/473159_1_En_13_Fig12_HTML.jpg

图 13-12

更新阶段

componentDidUpdate方法的主要用途是使用 React refs 特性直接操纵 DOM 中的 HTML 元素,我在第十六章第十六章中对此进行了描述。对于这一章,我已经在Message组件中实现了这个方法,并用它向浏览器的 JavaScript 控制台写一条消息,如清单 13-19 所示。

小费

即使协调过程确定组件生成的内容没有改变,也会调用componentDidUpdate方法。

import React, { Component } from "react";
import { ActionButton } from "./ActionButton";

export class Message extends Component {

    // ...other methods omitted for brevity...

    componentDidMount() {
        console.log("componentDidMount Message Component");
    }

    componentDidUpdate() {
        console.log("componentDidUpdate Message Component");
    }
}

Listing 13-19Implementing a Lifecycle Method in the Message.js File in the src Folder

在挂载阶段执行初始呈现之后,一旦 React 完成了协调过程并更新了 DOM,对render方法的任何后续调用都将跟随着对componentDidUpdate方法的调用。单击递增计数器按钮将启动更新阶段,并在浏览器的 JavaScript 控制台中生成以下消息:

...
Render App Component
Render Message Component
Render ActionButton (Increment Counter) Component
Render List Component
Render ActionButton (Reverse Names) Component
Render ActionButton (External Counter) Component

componentDidUpdate Message Component

...

了解卸载阶段

当一个组件即将被销毁时,React 将调用componentWillUnmount方法,该方法为组件提供了释放资源、关闭网络连接和停止任何异步任务的机会。在清单 13-20 中,我在Message组件中实现了componentWillUnmount方法,并使用它向浏览器的 JavaScript 控制台写一条消息。

import React, { Component } from "react";
import { ActionButton } from "./ActionButton";

export class Message extends Component {

    // ...other methods omitted for brevity...

    componentDidMount() {
        console.log("componentDidMount Message Component");
    }

    componentDidUpdate() {
        console.log("componentDidUpdate Message Component");
    }

    componentWillUnmount() {
        console.log("componentWillUnmount Message Component");
    }
}

Listing 13-20Implementing a Lifecycle Method in the Message.js File in the src Folder

您可以通过取消选中我在清单 13-20 中添加的复选框来触发卸载阶段。当 React 协调由App组件呈现的新内容时,它确定不再需要Message组件,并在销毁对象之前调用componentWillUnmount方法,在浏览器的 JavaScript 控制台中产生以下消息:

...
Render App Component
Render List Component
Render ActionButton (Reverse Names) Component
Render ActionButton (External Counter) Component

componentWillUnmount Message Component

...

一旦组件被卸载,React 将不会重用它们。如果需要另一个Message组件,React 将创建一个新对象并执行安装序列,例如当复选框再次切换时。这意味着你总是可以依靠constructorcomponentDidMount方法来初始化一个组件,组件对象永远不会被要求从卸载状态中恢复。

使用效果挂钩

定义为功能的组件不能实现方法,也不能以同样的方式参与生命周期。对于这种类型的组件,钩子特性提供了效果钩子,大致相当于componentDidMountcomponentDidUpdatecomponentWillUnmount方法。为了展示效果挂钩的用法,我在src文件夹中添加了一个名为HooksMessage.js的文件,并添加了清单 13-21 中所示的代码。

import React, { useState, useEffect} from "react";
import { ActionButton } from "./ActionButton";

export function HooksMessage(props) {
    const [showSpan, setShowSpan] = useState(false);

    useEffect(() => console.log("useEffect function invoked"));

    const handleClick = (event) => {
        setShowSpan(!showSpan);
        props.callback(event);
    }

    const getMessageElement = () => {
        let div = <div id="messageDiv" className="h5 text-center p-2">
                        { props.message }
                  </div>
        return showSpan ? <span>{ div } </span> : div;
    }

    return (
        <div>
            <ActionButton theme="primary" {...props} callback={ handleClick } />
            { getMessageElement() }
        </div>
    )
}

Listing 13-21The Contents of the HooksMessage.js File in the src Folder

该组件提供了与Message组件相同的功能,但是被表达为一个使用钩子的函数。useEffect函数用于注册一个函数,该函数将在组件被安装、更新和卸载时被调用。在所有三种情况下都调用相同的函数,这反映了将函数用于组件的本质,而不是类。在清单 13-22 中,我已经将新组件添加到由App组件呈现的内容中。

import React, { Component } from 'react';
import { Message } from "./Message";
import { List } from "./List";
import { ExternalCounter } from './ExternalCounter';

import { HooksMessage } from './HooksMessage';

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            counter: 0,
            showMessage: true
        }
    }

    incrementCounter = () => {
        this.setState({ counter: this.state.counter + 1 });
    }

    handleChange = () => {
        this.setState({ showMessage: !this.state.showMessage });
    }

    render() {
        console.log("Render App Component");
        return  (
            <div className="container text-center">
                <div className="row p-2">
                    <div className="col-4">
                        <div className="form-check">
                            <input type="checkbox" className="form-check-input"
                                checked={ this.state.showMessage }
                                onChange={ this.handleChange } />
                            <label className="form-check-label">Show</label>
                        </div>
                        { this.state.showMessage &&
                            <div>
                                <Message message={ `Counter: ${this.state.counter}`}
                                    callback={ this.incrementCounter }
                                    text="Increment Counter" />
                                <HooksMessage
                                    message={ `Counter: ${this.state.counter}`}
                                    callback={ this.incrementCounter }
                                    text="Increment Counter" />
                            </div>
                        }
                    </div>
                    <div className="col-4">
                        <List />
                    </div>
                    <div className="col-4">
                        <ExternalCounter />
                    </div>
                </div>
            </div>
        )
    }
}

Listing 13-22Rendering a New Component in the App.js File in the src Folder

保存对组件的更改,并检查浏览器的 JavaScript 控制台中显示的消息,以查看组件安装和更新时调用的效果钩子函数,如下所示:

...
Render List Component
ActionButton.js:6 Render ActionButton (Reverse Names) Component
ActionButton.js:6 Render ActionButton (External Counter) Component
Message.js:37 componentDidMount Message Component

HooksMessage.js:7 useEffect function invoked

...

传递给useState的函数可以返回一个清理函数,该函数将在组件被卸载时被调用,提供一个类似于componentWillUnmount方法的特性,如清单 13-23 所示。

import React, { useState, useEffect} from "react";
import { ActionButton } from "./ActionButton";

export function HooksMessage(props) {
    const [showSpan, setShowSpan] = useState(false);

    useEffect(() => {
        console.log("useEffect function invoked")
        return () => console.log("useEffect cleanup");
    });

    const handleClick = (event) => {
        setShowSpan(!showSpan);
        props.callback(event);
    }

    const getMessageElement = () => {
        let div = <div id="messageDiv" className="h5 text-center p-2">
                        { props.message }
                  </div>
        return showSpan ? <span>{ div } </span> : div;
    }

    return (
        <div>
            <ActionButton theme="primary" {...props} callback={ handleClick } />
            { getMessageElement() }
        </div>
    )
}

Listing 13-23Using a Cleanup Function in the HooksMessage.js File in the src Folder

切换复选框以卸载组件,您将在浏览器的 JavaScript 控制台中看到以下消息:

...
Render ActionButton (Reverse Names) Component
ActionButton.js:6 Render ActionButton (External Counter) Component
Message.js:45 componentWillUnmount Message Component

HooksMessage.js:9 useEffect cleanup

...

使用高级生命周期方法

前面几节中描述的特性在许多项目中都很有用,尤其是使用componentDidMount方法请求远程数据,这将在第三部分中演示。React 为基于类的组件提供了高级的生命周期方法,这些方法在我在下面的章节中描述的特定情况下非常有用,尽管其中一个方法是与我在第十六章中描述的 refs 特性结合使用的。为了快速参考,表 13-4 描述了先进的生命周期方法。

表 13-4

高级组件生命周期方法

|

名字

|

描述

|
| --- | --- |
| shouldComponentUpdate | 此方法允许组件指示它不需要更新。 |
| getDerivedStateFromProps | 这个方法允许一个组件根据它收到的属性来设置它的状态数据值。 |
| getSnapshotBeforeUpdate | 该方法允许组件在协调过程更新 DOM 之前捕获有关其状态的信息。该方法与第十六章所述的 ref 功能结合使用。 |

防止不必要的组件更新

React 的默认行为是将组件标记为陈旧,并在其状态数据发生变化时呈现其内容。而且,由于组件的状态可以作为属性传递给其子组件,所以子组件也会被呈现,正如您在前面的示例中看到的那样。

组件可以通过实现shouldComponentUpdate方法来覆盖默认行为。这个特性允许组件通过避免在不需要的时候调用render方法来提高应用的性能。

在更新阶段调用shouldComponentUpdate方法,其结果决定 React 是否会调用render方法从组件中获取新鲜内容,如图 13-13 所示。shouldComponentUpdate方法的参数是新的属性和状态对象,可以对它们进行检查并与现有值进行比较。如果shouldComponentUpdate方法返回true,React 将继续更新阶段。如果shouldComponentUpdate方法返回 false,React 将放弃组件的更新阶段,并且不会调用rendercomponentDidUpdate方法。

img/473159_1_En_13_Fig13_HTML.jpg

图 13-13

更新方法的高级序列

在清单 13-24 中,我在Message组件中实现了showComponentUpdate方法,如果消息属性的值没有改变,我用它来防止更新。(为了简洁起见,我还从前面的例子中删除了生命周期方法。)

import React, { Component } from "react";
import { ActionButton } from "./ActionButton";

export class Message extends Component {

    constructor(props) {
        super(props);
        this.state = {
            showSpan: false
        }
    }

    handleClick = (event) => {
        this.setState({ showSpan: !this.state.showSpan });
        this.props.callback(event);
    }

    getMessageElement() {
        let div = <div id="messageDiv" className="h5 text-center p-2">
                        { this.props.message }
                  </div>
        return this.state.showSpan ? <span>{ div } </span> : div;
    }

    render() {
        console.log(`Render Message Component `);
        return (
            <div>
                <ActionButton theme="primary" {...this.props}
                        callback={ this.handleClick } />
                { this.getMessageElement() }
            </div>
        )
    }

    shouldComponentUpdate(newProps, newState) {
        let change = newProps.message !== this.props.message;
        if (change) {
            console.log(`shouldComponentUpdate ${this.props.text}: Update Allowed`)
        } else {
            console.log(`shouldComponentUpdate ${this.props.text}: Update Prevented`)
        }
        return change;
    }
}

Listing 13-24Preventing Updates in the Message.js File in the src Folder

在清单 13-25 中,我修改了App组件,使其呈现两个Message组件,每个组件接收并修改一个状态数据值作为属性。

import React, { Component } from 'react';
import { Message } from "./Message";

//import { List } from "./List";

//import { ExternalCounter } from './ExternalCounter';

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            counterLeft: 0,
            counterRight: 0
        }
    }

    incrementCounter = (counter) => {
        if (counter === "left") {
            this.setState({ counterLeft: this.state.counterLeft + 1});
        } else {
            this.setState({ counterRight: this.state.counterRight+ 1});
        }
    }

    render() {
        console.log("Render App Component");
        return (
            <div className="container text-center">
                <div className="row p-2">
                    <div className="col-6">
                        <Message
                            message={ `Left: ${this.state.counterLeft}`}
                            callback={ () => this.incrementCounter("left") }
                            text="Increment Left Counter" />
                    </div>
                    <div className="col-6">
                        <Message
                            message={ `Right: ${this.state.counterRight}`}
                            callback={ () => this.incrementCounter("right") }
                            text="Increment Right Counter" />
                    </div>
                </div>
            </div>
        )
    }
}

Listing 13-25Displaying Side-By-Side Components in the App.js File in the src Folder

App组件渲染的新内容并排显示Message组件,如图 13-14 所示。单击任一按钮元素都会增加该组件的计数器。

img/473159_1_En_13_Fig14_HTML.jpg

图 13-14

并排显示组件

默认的 React 行为是当counterLeftcounterRight状态数据值发生变化时,呈现两个Message组件,这会导致其中一个组件不必要地呈现内容。清单 13-25 中shouldComponentUpdate方法的实现覆盖了这种行为,并确保只有受变更影响的组件被更新。如果您单击应用显示的任何一个按钮,您将在浏览器的 JavaScript 控制台中看到一条消息,指出shouldComponentUpdate阻止了其中一个组件的更新。

...
Render App Component

shouldComponentUpdate Increment Left Counter: Update Allowed

Render Message Component
Render ActionButton (Increment Left Counter) Component

shouldComponentUpdate Increment Right Counter: Update Prevented

...

根据属性值设置状态数据

getDerivedStateFromProps方法在挂载阶段先于render方法调用,在更新阶段先于shouldComponentUpdate方法调用,如图 13-15 所示。getDerivedStateFromProps方法为组件提供了检查属性值的机会,并在呈现其内容之前使用它们来更新其状态数据,旨在供其行为受属性值随时间变化影响的组件使用。

img/473159_1_En_13_Fig15_HTML.jpg

图 13-15

从 props 更新状态数据

getDerivedStateFromProps方法是static,这意味着它不能通过this关键字访问任何实例方法或属性。相反,该方法接收一个包含父组件提供的 props 值的props对象和一个表示当前state数据的state对象。getDerivedStateFromProps方法返回一个新的状态数据对象,它是从属性数据中派生出来的。

为了演示这个方法,我在src文件夹中添加了一个名为DirectionDisplay.js的文件,并用它来定义清单 13-26 中所示的组件。

import React, { Component } from "react";

export class DirectionDisplay extends Component {

    constructor(props) {
        super(props);
        this.state = {
            direction: "up",
            lastValue: 0
        }
    }

    getClasses() {
        return (this.state.direction === "up" ? "bg-success" : "bg-danger")
            + " text-white text-center p-2 m-2";
    }

    render() {
        return <h5 className={ this.getClasses() }>
                    { this.props.value }
                </h5>
    }

    static getDerivedStateFromProps(props, state) {
        if (props.value !== state.lastValue) {
            return {
                lastValue: props.value,
                direction: state.lastValue > props.value ? "down" : "up"
            }
        }
        return state;
    }
}

Listing 13-26The Contents of the DirectionDisplay.js File in the src Folder

该组件显示一个带有背景色的数值,该背景色指示当前值是大于还是小于上一个值。getDerivedStateFromProps方法接收新的属性值和组件的当前状态数据,并使用它们来创建新的状态数据对象,该对象包括属性value已经改变的方向。在清单 13-27 中,我已经更新了App组件,这样它可以呈现DirectionDisplay组件和改变其属性数据值的按钮。

import React, { Component } from 'react';

//import { Message } from "./Message";

import { DirectionDisplay } from './DirectionDisplay';

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            counter: 100
        }
    }

    changeCounter = (val) => {
        this.setState({ counter: this.state.counter + val })
    }

    render() {
        console.log("Render App Component");
        return  (
            <div className="container text-center">
                <DirectionDisplay value={ this.state.counter } />
                <div className="text-center">
                    <button className="btn btn-primary m-1"
                        onClick={ () => this.changeCounter(-1)}>Decrease</button>
                    <button className="btn btn-primary m-1"
                        onClick={ () => this.changeCounter(1)}>Increase</button>
                </div>
            </div>
        )
    }
}

Listing 13-27Rendering a New Component in the App.js File in the src Folder

结果是DirectionDisplay组件选择的背景颜色根据getDerivedStateFromProps方法的输出而改变,如图 13-16 所示。

img/473159_1_En_13_Fig16_HTML.jpg

图 13-16

从属性值导出状态数据

小费

注意,只有当属性的值不同时,我才创建一个新的状态数据对象。记住,当祖先的状态改变时,React 将触发组件的更新阶段,这意味着即使组件所依赖的属性值都没有改变,也可以调用getDerivedStateFromProps方法。

摘要

在这一章中,我解释了 React 如何在协调过程中处理组件呈现的内容。我还描述了更广泛的组件生命周期,并向您展示了如何通过实现方法在有状态组件中接收通知。在下一章中,我将描述组合组件来创建复杂功能的不同方式。

十四、编写应用

在这一章中,我描述了组合组件来创建复杂特性的不同方法。这些组合模式可以一起使用,您会发现大多数问题都可以用几种方法来解决,这让您可以自由地应用您最喜欢的方法。表 14-1 将本章置于上下文中。

表 14-1

将应用组合放在上下文中

|

问题

|

回答

|
| --- | --- |
| 这是什么? | 应用组合是组件的组合,用于创建复杂的功能。 |
| 为什么有用? | 组合使开发变得更容易,因为它允许在组合到一起工作之前编写和测试小而简单的组件。 |
| 如何使用? | 有不同的模式可用,但基本的方法是组合多个组件。 |
| 有什么陷阱或限制吗? | 如果您习惯于从类中派生功能,比如在 C#或 Java 开发中,那么组合模式可能会令人感到尴尬。许多问题可以通过多个模式同样很好地解决,这会导致决策瘫痪。 |
| 还有其他选择吗? | 您可以编写实现应用所需的所有特性的整体组件,尽管这会导致项目的测试和维护有所不同。 |

表 14-2 总结了本章内容。

表 14-2

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 显示从父组件接收的内容 | 使用children属性 | 8–9 |
| 操纵通过children属性接收的组件 | 使用React.children属性 | 10, 11 |
| 增强现有组件 | 创建专门的组件或更高级别的组件 | 12–18 |
| 组合高阶组件 | 将函数调用链接在一起 | 19, 20 |
| 为组件提供它应该呈现的内容 | 使用渲染属性 | 21–24 |
| 在没有线程支持的情况下分发数据和函数 | 使用上下文 | 25–34 |
| 在不使用渲染属性的情况下使用上下文 | 对基于类的组件使用简化的上下文 API,对功能组件使用useContext钩子 | 35, 36 |
| 防止卸载应用时出错 | 定义误差边界 | 37–39 |

为本章做准备

为了创建本章的示例项目,打开一个新的命令提示符,导航到一个方便的位置,并运行清单 14-1 中所示的命令。

小费

你可以从 https://github.com/Apress/pro-react-16 下载本章以及本书其他章节的示例项目。

npx create-react-app composition

Listing 14-1Creating the Example Project

运行清单 14-2 中所示的命令,导航到composition文件夹,并将引导包添加到项目中。

cd composition
npm install bootstrap@4.1.2

Listing 14-2Adding the Bootstrap CSS Framework

为了在应用中包含引导 CSS 样式表,将清单 14-3 中所示的语句添加到index.js文件中,该文件可以在src文件夹中找到。

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

import 'bootstrap/dist/css/bootstrap.css';

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

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

Listing 14-3Including Bootstrap in the index.js File in the src Folder

创建示例组件

src文件夹中添加一个名为ActionButton.js的文件,并添加清单 14-4 所示的内容。

import React, { Component } from "react";

export class ActionButton extends Component {

    render() {
        return (
            <button className={` btn btn-${this.props.theme} m-2` }
                    onClick={ this.props.callback }>
                { this.props.text }
            </button>
        )
    }
}

Listing 14-4The Contents of the ActionButton.js File in the src Folder

这是一个类似于我在第十三章中使用的按钮组件,它通过 prop 接受配置设置,包括一个响应点击事件的函数。接下来,将名为Message.js的文件添加到src文件夹中,并添加清单 14-5 中所示的内容。

import React, { Component } from "react";

export class Message extends Component {

    render() {
        return (
            <div className={`h5 bg-${this.props.theme } text-white p-2`}>
                { this.props.message }
            </div>
        )
    }
}

Listing 14-5The Contents of the Message.js File in the src Folder

该组件显示作为属性接收的消息。最后的修改是用清单 14-6 中所示的代码替换App.js文件的内容,该代码呈现使用其他组件的内容,并定义Message组件所需的状态数据。

import React, { Component } from 'react';
import { Message } from "./Message";
import { ActionButton } from './ActionButton';

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            counter: 0
        }
    }

    incrementCounter = () => {
        this.setState({ counter: this.state.counter + 1 });
    }

    render() {
        return  <div className="m-2 text-center">
                    <Message theme="primary"
                        message={ `Counter: ${this.state.counter}`} />
                    <ActionButton theme="secondary"
                        text="Increment" callback={ this.incrementCounter } />
                </div>
    }
}

Listing 14-6The Contents of the App.js File in the src Folder

App组件呈现的内容显示MessageActionButton组件,并对它们进行配置,以便单击按钮将更新counter状态数据值,该值已作为属性传递给Message组件。

使用命令提示符,运行composition文件夹中清单 14-7 所示的命令来启动开发工具。

npm start

Listing 14-7Starting the Development Tools

一旦项目的初始准备工作完成,一个新的浏览器窗口将打开并显示 URL http://localhost:3000并显示如图 14-1 所示的内容。

img/473159_1_En_14_Fig1_HTML.jpg

图 14-1

运行示例应用

了解基本组件关系

示例项目中的组件很简单,但它们说明了支撑 React 开发的基本关系:父组件用数据属性配置子组件,并通过函数属性接收通知,这导致状态数据变化并触发更新过程,如图 14-2 所示。

img/473159_1_En_14_Fig2_HTML.jpg

图 14-2

基本组件关系

这种关系是 React 开发的基础,也是用于在应用中安排特性的基本模式。这种模式在一个简单的例子中很容易理解,它在更复杂的情况下的使用可能不太明显,并且很难知道如何在不复制代码和数据的情况下定位和分发状态数据、属性和回调。

使用儿童属性

React 提供了一个特殊的children属性,当一个组件需要显示其父组件提供的内容,但事先不知道该内容是什么时,就使用这个属性。这是一种减少重复的有用方法,它将容器中的特性标准化,可以在整个应用中重用。为了演示,我在src文件夹中创建了一个名为ThemeSelector.js的文件,并用它来定义清单 14-8 中所示的组件。

import React, { Component } from "react";

export class ThemeSelector extends Component {

    render() {
        return (
            <div className="bg-dark p-2">
                <div className="bg-info p-2">
                    { this.props.children }
                </div>
            </div>
        )
    }
}

Listing 14-8The Contents of the ThemeSelector.js File in the src Folder

该组件呈现两个包含表达式的div元素,表达式的值是children属性。为了展示如何提供儿童属性的内容,清单 14-9 应用了App组件中的ThemeSelector

import React, { Component } from 'react';
import { Message } from "./Message";
import { ActionButton } from './ActionButton';

import { ThemeSelector } from './ThemeSelector';

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            counter: 0
        }
    }

    incrementCounter = () => {
        this.setState({ counter: this.state.counter + 1 });
    }

    render() {
        return  <div className="m-2 text-center">
                    <ThemeSelector>
                        <Message theme="primary"
                            message={ `Counter: ${this.state.counter}`} />
                        <ActionButton theme="secondary"
                            text="Increment" callback={ this.incrementCounter } />
                    </ThemeSelector>
                </div>
    }
}

Listing 14-9Using a Container Component in the App.js File in the src Folder

App组件通过在开始和结束标签之间定义元素来为ThemeSelector组件提供内容。在这种情况下,元素应用了MessageActionButton组件。当 React 处理由App组件呈现的内容时,ThemeSelector标记之间的内容被赋给props.children属性,产生如图 14-3 所示的结果。

img/473159_1_En_14_Fig3_HTML.jpg

图 14-3

容器组件

ThemeSelector组件目前并没有增加很多价值,但是您可以看到它是如何作为App组件提供的内容的容器的。

操纵属性儿童

使用children prop 的组件只有当它们能够向它们的子组件提供服务时才是有用的,当没有这些子组件将提供什么的高级知识时,这可能是困难的。为了帮助解决这个限制,React 提供了许多方法,容器可以使用这些方法来操作其子容器,如表 14-3 中所述。

表 14-3

容器子方法

|

名字

|

描述

|
| --- | --- |
| React.Children.map | 该方法为每个孩子调用一个函数,并返回函数结果的数组。 |
| React.Children.forEach | 这个方法为每个孩子调用一个函数,而不返回数组。 |
| React.Children.count | 此方法返回子节点的数量。 |
| React.Children.only | 如果此方法接收的子集合包含多个子级,则引发一个数组。 |
| React.Children.toArray | 此方法返回一个子数组,可用于重新排序或移除元素。 |
| React.cloneElement | 该方法用于复制子元素,并允许容器添加新的属性。 |

向容器子项添加属性

组件不能直接操作它从父组件接收的内容,所以为了给通过children属性接收的组件提供额外的数据或功能,React.Children.map方法与React.cloneElement方法结合使用,以复制子组件并分配额外的属性。

清单 14-10 向由ThemeSelector呈现的内容添加了一个select元素,该元素更新了一个状态数据属性,并允许用户选择由 Bootstrap CSS 框架提供的一种主题颜色,然后将其作为属性传递给容器的子容器。

import React, { Component } from "react";

export class ThemeSelector extends Component {

    constructor(props) {
        super(props);
        this.state = {
            theme: "primary"
        }
        this.themes = ["primary", "secondary", "success", "warning", "dark"];
    }

    setTheme = (event) => {
        this.setState({ theme : event.target.value });
    }

    render() {

        let modChildren = React.Children.map(this.props.children,
            (c => React.cloneElement(c, { theme: this.state.theme})));

        return (
            <div className="bg-dark p-2">
                <div className="form-group text-left">
                    <label className="text-white">Theme:</label>
                    <select className="form-control" value={ this.state.theme }
                            onChange={ this.setTheme }>
                        { this.themes.map(theme =>
                            <option key={ theme } value={ theme }>{theme}</option>) }
                    </select>
                </div>

                <div className="bg-info p-2">
                    { modChildren }
                </div>
            </div>
        )
    }
}

Listing 14-10Adding Theme Selection in the ThemeSelector.js File in the src Folder

因为 props 是只读的,所以我不能使用React.Children.forEach方法简单地枚举子组件并为它们的props对象分配一个新属性。相反,我使用map方法来枚举孩子,并使用React.cloneElement方法用一个额外的属性复制每个孩子。

...
let modChildren = React.Children.map(this.props.children,
    (c => React.cloneElement(c, { theme: this.state.theme})));
...

cloneElement方法接受一个子组件和一个 props 对象,该对象与子组件现有的 props 合并。

使用map方法将子组件枚举到数组中的一个结果是,React 期望每个组件都有一个key属性,并将在浏览器的 JavaScript 控制台中报告一个警告。

结果是传递给MessageActionButton组件的属性是由App组件定义的属性和由ThemeSelector组件使用cloneElement方法添加的属性的组合。当您从select元素中选择一个主题时,一个更新被执行,并且所选择的主题被应用到MessageActionButton组件,如图 14-4 所示。

img/473159_1_En_14_Fig4_HTML.jpg

图 14-4

向包含的组件添加属性

订购或省略组件

尽管一个容器没有任何关于其子容器的高级知识,但是表 14-3 中描述的toArray方法可以用来将子容器转换成一个数组,这个数组可以使用标准的 JavaScript 特性来操作,比如排序或者添加和删除项目。这种类型的操作也可以在来自React.Children.map方法的结果上执行,也在表 14-3 中描述,它也返回一个数组。

在清单 14-11 中,我在ThemeSelector组件中添加了一个按钮,当它被点击时会反转子组件的顺序,这是通过在由map方法产生的数组上调用reverse方法来实现的。

import React, { Component } from "react";

export class ThemeSelector extends Component {

    constructor(props) {
        super(props);
        this.state = {
            theme: "primary",
            reverseChildren: false
        }
        this.themes = ["primary", "secondary", "success", "warning", "dark"];
    }

    setTheme = (event) => {
        this.setState({ theme : event.target.value });
    }

    toggleReverse = () => {
        this.setState({ reverseChildren: !this.state.reverseChildren});
    }

    render() {

        let modChildren = React.Children.map(this.props.children,
            (c => React.cloneElement(c, { theme: this.state.theme})));

        if (this.state.reverseChildren) {
            modChildren.reverse();
        }

        return (
            <div className="bg-dark p-2">
                <button className="btn btn-primary" onClick={ this.toggleReverse }>
                    Reverse
                </button>
                <div className="form-group text-left">
                    <label className="text-white">Theme:</label>
                    <select className="form-control" value={ this.state.theme }
                            onChange={ this.setTheme }>
                        { this.themes.map(theme =>
                            <option key={ theme } value={ theme }>{theme}</option>) }
                    </select>
                </div>

                <div className="bg-info p-2">
                    { modChildren }
                </div>
            </div>
        )
    }
}

Listing 14-11Reversing Children in the ThemeSelector.js File in the src Folder

这种类型的操作通常用于相似对象的列表,例如在线商店中的产品,但它可以应用于任何子对象,如图 14-5 所示。

img/473159_1_En_14_Fig5_HTML.jpg

图 14-5

更改容器中子组件的顺序

创建专用组件

一些组件提供由另一个更通用的组件提供的功能的专用版本。在一些框架中,专门化是通过使用类继承等特性来处理的,但 React 依赖于专门化的组件来呈现更一般的组件,并使用 props 来管理其行为。为了演示,我在src文件夹中添加了一个名为GeneralList.js的文件,并用它来定义清单 14-12 中所示的组件。

注意

如果您习惯于基于类的语言,比如 C#或 Java,您可能希望使用与有状态组件用来从 React Component类继承功能的关键字相同的extends来创建一个子类。这不是 React 的预期用途,您应该组合组件,即使一开始这样做可能会感觉很奇怪。

import React, { Component } from "react";

export class GeneralList extends Component {

    render() {
        return (
            <div className={`bg-${this.props.theme} text-white p-2`}>
                { this.props.list.map((item, index) =>
                    <div key={ item }>{ index + 1 }: { item }</div>
                )}
            </div>
        )
    }
}

Listing 14-12The Contents of the GeneralList.js File in the src Folder

该组件接收一个名为list的属性,使用 array map方法对其进行处理,以呈现一系列div元素。为了创建一个接收列表并允许对其进行排序的组件,我可以创建一个更加专门化的组件,它构建在由GeneralList提供的特性之上。我在src文件夹中添加了一个名为SortedList.js的文件,并用它来定义清单 14-13 中所示的组件。

import React, { Component } from "react";
import { GeneralList } from "./GeneralList";
import { ActionButton } from "./ActionButton";

export class SortedList extends Component {

    constructor(props) {
        super(props);
        this.state = {
            sort: false
        }
    }

    getList() {
        return this.state.sort
            ? [...this.props.list].sort() : this.props.list;
    }

    toggleSort = () => {
        this.setState({ sort : !this.state.sort });
    }

    render() {
        return (
            <div>
                <GeneralList list={ this.getList() } theme="info" />
                <div className="text-center m-2">
                    <ActionButton theme="primary" text="Sort"
                        callback={this.toggleSort} />
                </div>
            </div>
        )
    }
}

Listing 14-13The Contents of the SortedList.js File in the src Folder

SortedList呈现一个GeneralList作为其输出的一部分,并使用list属性来控制数据的显示,允许用户选择一个排序或未排序的列表。在清单 14-14 中,我已经改变了App组件的布局,以便并排显示一般的和更具体的组件。

import React, { Component } from 'react';

//import { Message } from "./Message";

//import { ActionButton } from './ActionButton';

//import { ThemeSelector } from './ThemeSelector';

import { GeneralList } from './GeneralList';

import { SortedList } from "./SortedList";

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            // counter: 0
            names: ["Zoe", "Bob", "Alice", "Dora", "Joe"]
        }
    }

    // incrementCounter = () => {
    //     this.setState({ counter: this.state.counter + 1 });
    // }

    render() {
        return  (
            <div className="container-fluid">
                <div className="row">
                    <div className="col-6">
                        <GeneralList list={ this.state.names } theme="primary" />
                    </div>
                    <div className="col-6">
                        <SortedList list={ this.state.names } />
                    </div>
                </div>
            </div>
        )
    }
}

Listing 14-14Changing the Component Layout in the App.js File in the src Folder

结果是一般列表和可排序列表都呈现给用户,如图 14-6 所示。

img/473159_1_En_14_Fig6_HTML.jpg

图 14-6

通用和更专业的组件

创建高阶组件

高阶组件(hoc)提供了专用组件的替代方案,在组件需要公共代码但可能无法呈现相关内容时非常有用。hoc 通常用于横切关注点,这个术语指的是跨越整个应用的任务,否则会导致在几个地方实现相同的特性。最常见的横切关注点的例子是授权、日志和数据检索。为了演示 HOCs 的使用,我在src文件夹中添加了一个名为ProFeature.js的文件,并用它来定义清单 14-15 中所示的组件。

import React from "react";

export function ProFeature(FeatureComponent) {
    return function(props) {

        if (props.pro) {
            let { pro, ...childProps}  = props;
            return <FeatureComponent {...childProps} />
        } else {
            return (
                <h5 className="bg-warning text-white text-center">
                    This is a Pro Feature
                </h5>
            )
        }
    }
}

Listing 14-15The Contents of the ProFeature.js File in the src Folder

一个 HOC 是一个函数,它接受一个组件并返回一个新组件,这个新组件包装了这个组件以提供额外的特性。在清单 14-15 中,HOC 是一个名为ProFeature的函数,它接受一个只有当名为pro的属性的值为true时才应该呈现给用户的组件,作为一个简单的授权特性。为了显示组件,render方法使用接收到的组件作为函数参数,并传递它的所有属性,除了名为pro的属性。

...
let { pro, ...childProps}  = props;
return <FeatureComponent {...childProps} />
...

pro属性为false时,ProFeature函数返回一个显示警告的 header 元素。清单 14-16 更新了App组件以使用ProFeature来保护它的一个子组件。

import React, { Component } from 'react';
import { GeneralList } from './GeneralList';
import { SortedList } from "./SortedList";

import { ProFeature } from "./ProFeature";

const ProList = ProFeature(SortedList);

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            names: ["Zoe", "Bob", "Alice", "Dora", "Joe"],
            cities: ["London", "New York", "Paris", "Milan", "Boston"],
            proMode: false
        }
    }

    toggleProMode = () => {
        this.setState({ proMode: !this.state.proMode});
    }

    render() {
        return (
            <div className="container-fluid">
                <div className="row">
                    <div className="col-12 text-center p-2">
                        <div className="form-check">
                            <input type="checkbox" className="form-check-input"
                                value={ this.state.proMode }
                                onChange={ this.toggleProMode } />
                            <label className="form-check-label">Pro Mode</label>
                        </div>
                    </div>
                </div>
                <div className="row">
                    <div className="col-3">
                        <GeneralList list={ this.state.names } theme="primary" />
                    </div>
                    <div className="col-3">
                        <ProList list={ this.state.names }
                                pro={ this.state.proMode } />
                    </div>
                    <div className="col-3">
                        <GeneralList list={ this.state.cities } theme="secondary" />
                    </div>
                    <div className="col-3">
                        <ProList list={ this.state.cities }
                            pro={ this.state.proMode } />
                    </div>
                </div>
            </div>
        )
    }
}

Listing 14-16Using an HOC in the App.js File in the src Folder

hoc 用于通过调用函数来创建新组件,如下所示:

...

const ProList = ProFeature(SortedList);

...

因为 hoc 是函数,所以您可以定义额外的参数来配置行为,但是在本例中,我将我想要包装的组件作为唯一的参数进行传递。我将函数的结果赋给一个名为ProList的常量,我像 render 方法中的任何其他组件一样使用它。

...
<ProList list={ this.state.cities } pro={ this.state.proMode } />
...

我为 HOC 定义了pro属性,为它包装的SortedList组件定义了list属性。通过切换复选框来设置pro属性的值,效果如图 14-7 所示。

img/473159_1_En_14_Fig7_HTML.jpg

图 14-7

使用高阶元件

创建有状态的高阶组件

高阶组件可以是有状态的,这允许将更复杂的特性添加到应用中。我在src文件夹中添加了一个名为ProController.js的文件,并用它来定义清单 14-17 中所示的 HOC。

import React, { Component } from "react";
import { ProFeature } from "./ProFeature";

export function ProController(FeatureComponent) {

    const ProtectedFeature = ProFeature(FeatureComponent);

    return class extends Component {

        constructor(props) {
            super(props);
            this.state = {
                proMode: false
            }
        }

        toggleProMode = () => {
            this.setState({ proMode: !this.state.proMode});
        }

        render() {
            return (
                <div className="container-fluid">
                    <div className="row">
                        <div className="col-12 text-center p-2">
                            <div className="form-check">
                                <input type="checkbox" className="form-check-input"
                                    value={ this.state.proMode }
                                    onChange={ this.toggleProMode } />
                                <label className="form-check-label">Pro Mode</label>
                            </div>
                        </div>
                    </div>
                    <div className="row">
                        <div className="col-12">
                            <ProtectedFeature {...this.props}
                                 pro={ this.state.proMode } />
                        </div>
                    </div>
                </div>
            )
        }
    }
}

Listing 14-17The Contents of the ProController.js File in the src Folder

HOC 函数返回一个基于类的有状态组件,该组件提供复选框并使用ProFeature HOC 来控制包装组件的可见性。清单 14-18 更新App组件以使用ProController组件。

import React, { Component } from 'react';
import { GeneralList } from './GeneralList';
import { SortedList } from "./SortedList";

//import { ProFeature } from "./ProFeature";

import { ProController } from "./ProController";

const ProList = ProController(SortedList);

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            names: ["Zoe", "Bob", "Alice", "Dora", "Joe"],
            cities: ["London", "New York", "Paris", "Milan", "Boston"],
            //proMode: false
        }
    }
    render() {
        return (
            <div className="container-fluid">
                <div className="row">
                    <div className="col-3">
                        <GeneralList list={ this.state.names } theme="primary" />
                    </div>
                    <div className="col-3">
                        <ProList list={ this.state.names }  />
                    </div>
                    <div className="col-3">
                        <GeneralList list={ this.state.cities } theme="secondary" />
                    </div>
                    <div className="col-3">
                        <ProList list={ this.state.cities }  />
                    </div>
                </div>
            </div>
        )
    }
}

Listing 14-18Using a New HOC in the App.js File in the src Folder

图 14-8 显示了 HOC 的效果,它为每个受保护的组件提供了自己的复选框。

img/473159_1_En_14_Fig8_HTML.jpg

图 14-8

有状态的高阶组件

组合高阶分量

hoc 的一个有用特性是,它们可以通过只改变创建包装组件类的函数调用来组合。为了演示,我在src文件夹中添加了一个名为LogToConsole.js的文件,并用它来定义清单 14-19 中所示的 HOC。

import React, { Component } from "react";

export function LogToConsole(FeatureComponent, label, logMount, logRender, logUnmount) {
    return class extends Component {

        componentDidMount() {
            if (logMount) {
                console.log(`${label}: mount`);
            }
        }

        componentWillUnmount() {
            if (logUnmount) {
                console.log(`${label}: unmount`);
            }
        }

        render() {
            if (logRender) {
                console.log(`${label}: render`);
            }
            return <FeatureComponent { ...this.props } />
        }
    }
}

Listing 14-19The Contents of the LogToConsole.js File in the src Folder

HOC 函数接收将要包装的组件,以及用于将消息写入浏览器 JavaScript 控制台的标签参数。按照第十一章中描述的有状态组件生命周期,还有三个参数指定当组件被挂载、呈现和卸载时是否会写入日志消息。为了应用新的特设,我只改变了创建包装组件的函数,如清单 14-20 所示。

import React, { Component } from 'react';
import { GeneralList } from './GeneralList';
import { SortedList } from "./SortedList";
//import { ProFeature } from "./ProFeature";
import { ProController } from "./ProController";

import { LogToConsole } from "./LogToConsole";

const ProList = ProController(LogToConsole(SortedList, "Sorted", true, true, true));

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            names: ["Zoe", "Bob", "Alice", "Dora", "Joe"],
            cities: ["London", "New York", "Paris", "Milan", "Boston"],
            //proMode: false
        }
    }
    render() {
        return (
            <div className="container-fluid">
                <div className="row">
                    <div className="col-3">
                        <GeneralList list={ this.state.names }
                            theme="primary" />
                    </div>
                    <div className="col-3">
                        <ProList list={ this.state.names }  />
                    </div>
                    <div className="col-3">
                        <GeneralList list={ this.state.cities }
                            theme="secondary" />
                    </div>
                    <div className="col-3">
                        <ProList list={ this.state.cities }  />
                    </div>
                </div>
            </div>
        )
    }
}

Listing 14-20Combining HOCs in the App.js File in the src Folder

其效果是SortedList组件被LogToConsole组件包装,后者又被ProFeature组件包装。当您切换专业模式复选框时,您将在浏览器的 JavaScript 控制台中看到以下消息:

...
Sorted: render
Sorted: mount
Sorted: unmount
...

使用渲染属性

一个渲染属性是一个函数属性,它为一个组件提供了它应该渲染的内容,提供了将一个组件包装在另一个组件中的替代模型。在清单 14-21 中,我重写了ProFeature组件,使其使用渲染属性。

import React from "react";

export function ProFeature(props) {

    if (props.pro) {
        return props.render();
    } else {
        return (
            <h5 className="bg-warning text-white text-center">
                This is a Pro Feature
            </h5>
        )
    }
}

Listing 14-21Using a Render Prop in the ProFeature.js File in the src Folder

使用渲染属性的组件以正常方式定义。不同之处在于render方法,其中调用名为render的函数 prop 来显示父节点提供的内容。

...
return props.render();
...

父组件在应用组件时为render属性提供功能。在清单 14-22 中,我已经修改了App组件,因此它为ProFeature组件提供了它所需要的功能。(为了简洁起见,我还删除了一些内容。)

小费

父母用来提供功能的属性的名字不一定是render,尽管这是惯例。您可以使用任何名称,只要它在父组件和子组件中应用一致即可。

import React, { Component } from 'react';
import { GeneralList } from './GeneralList';
import { SortedList } from "./SortedList";

import { ProFeature } from "./ProFeature";

// import { ProController } from "./ProController";

// import { LogToConsole } from "./LogToConsole";

// const ProList = ProController(LogToConsole(SortedList, "Sorted", true, true));

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            names: ["Zoe", "Bob", "Alice", "Dora", "Joe"],
            cities: ["London", "New York", "Paris", "Milan", "Boston"],
            proMode: false
        }
    }

    toggleProMode = () => {
        this.setState({ proMode: !this.state.proMode});
    }

    render() {
        return (
            <div className="container-fluid">
                <div className="row">
                    <div className="col-12 text-center p-2">
                        <div className="form-check">
                            <input type="checkbox" className="form-check-input"
                                value={ this.state.proMode }
                                onChange={ this.toggleProMode } />
                            <label className="form-check-label">Pro Mode</label>
                        </div>
                    </div>
                </div>
                <div className="row">
                    <div className="col-6">
                        <GeneralList list={ this.state.names }
                            theme="primary" />
                    </div>
                    <div className="col-6">
                        <ProFeature pro={ this.state.proMode }
                            render={ () => <SortedList list={ this.state.names } /> }
                        />
                    </div>
                </div>
            </div>
        )
    }
}

Listing 14-22Using a Render Prop in the App.js File in the src Folder

ProFeature组件提供了一个用于确定是否显示某个特性的pro属性和一个设置为返回SortedList元素的函数的render属性。

...
<ProFeature pro={ this.state.proMode }
    render={ () => <SortedList list={ this.state.names } /> } />
...

当 React 呈现应用的内容时,ProFeature组件的render方法被调用,这又调用了render prop 函数,从而创建了一个新的SortedList组件。使用一个渲染属性可以达到与特设相同的效果,如图 14-9 所示。

img/473159_1_En_14_Fig9_HTML.jpg

图 14-9

使用渲染属性

使用带参数的渲染属性

Render props 是常规的 JavaScript 函数,这意味着它们可以用参数调用。这本身就是一个有用的特性,但它也有助于理解上下文特性是如何工作的,我将在下一节中对此进行描述。

使用参数允许调用render属性的组件向包装器内容提供属性。这是一种通过示例更容易理解的技术。在清单 14-23 中,我更改了ProFeature组件,以便它将一个字符串参数传递给render prop 函数。

import React from "react";

export function ProFeature(props) {
    if (props.pro) {
        return props.render("Pro Feature");
    } else {
        return (
            <h5 className="bg-warning text-white text-center">
                This is a Pro Feature
            </h5>
        )
    }
}

Listing 14-23Adding an Argument in the ProFeature.js File in the src Folder

参数可以由定义 render prop 函数的组件接收,并在它产生的内容中使用,如清单 14-24 所示。

import React, { Component } from 'react';
import { GeneralList } from './GeneralList';
import { SortedList } from "./SortedList";
import { ProFeature } from "./ProFeature";

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            names: ["Zoe", "Bob", "Alice", "Dora", "Joe"],
            cities: ["London", "New York", "Paris", "Milan", "Boston"],
            proMode: false
        }
    }

    toggleProMode = () => {
        this.setState({ proMode: !this.state.proMode});
    }

    render() {
        return (
            <div className="container-fluid">
                <div className="row">
                    <div className="col-12 text-center p-2">
                        <div className="form-check">
                            <input type="checkbox" className="form-check-input"
                                value={ this.state.proMode }
                                onChange={ this.toggleProMode } />
                            <label className="form-check-label">Pro Mode</label>
                        </div>
                    </div>
                </div>

                <div className="row">
                    <div className="col-6">
                        <GeneralList list={ this.state.names }
                            theme="primary" />
                    </div>
                    <div className="col-6">
                        <ProFeature pro={ this.state.proMode }
                            render={ text =>
                                <React.Fragment>
                                    <h4 className="text-center">{ text }</h4>
                                    <SortedList list={ this.state.names } />
                                </React.Fragment>
                            } />
                    </div>
                </div>
            </div>
        )
    }
}

Listing 14-24Receiving a Render Prop Argument in the App.js File in the src Folder

复选框被选中时产生的内容显示了ProFeature组件如何能够影响渲染属性功能产生的内容,如图 14-10 所示。

img/473159_1_En_14_Fig10_HTML.jpg

图 14-10

使用带参数的渲染属性

为全局数据使用上下文

不管您选择如何编写应用,属性的管理都会变得很困难。随着应用复杂性的增加,需要协作的组件数量也在增加。随着组件层次的增长,状态数据在应用中被提升到更高的位置,离使用该数据的地方更远,结果是每个组件都必须传递它不直接使用但它的后代依赖的属性。

为了帮助避免这个问题,React 提供了上下文特性,该特性允许状态数据从定义它的地方传递到需要它的地方,而不必通过中间组件进行中继。为了进行演示,我将使示例应用中的 Pro 模式更细粒度,这样它可以禁用排序按钮,而不是完全隐藏数据列表。

在清单 14-25 中,我向由ActionButton组件呈现的按钮元素添加了一个属性,该组件基于一个属性设置disabled属性,并更改引导主题,使其在按钮被禁用时更加明显。

小费

Redux 包通常用于更复杂的项目,在大型应用中更容易使用。详见第十九章和第二十章。

import React, { Component } from "react";

export class ActionButton extends Component {

    render() {
        return (
            <button className={ this.getClasses(this.props.proMode)}
                    disabled={ !this.props.proMode }
                    onClick={ this.props.callback }>
                { this.props.text }
            </button>
        )
    }

    getClasses(proMode) {
        let col = proMode ? this.props.theme : "danger";
        return `btn btn-${col} m-2`;
    }
}

Listing 14-25Disabling a Button in the ActionButton.js File in the src Folder

ActionButton所依赖的proMode属性是App组件状态的一部分,它也定义了用于改变其值的复选框。结果是组件链必须从它们的父组件接收proMode属性,并将其传递给它们的子组件。即使在简单的示例应用中,这也意味着SortedList组件必须传递proMode数据值,即使它不直接使用它,如图 14-11 所示。

img/473159_1_En_14_Fig11_HTML.jpg

图 14-11

在示例应用中传递属性

这被称为属性钻属性线程,其中数据值通过组件层次结构传递到可以使用它们的点。很容易忘记传递后代所需的属性,并且很难找出复杂应用中某个属性的线程在哪里遗漏了某个步骤。在清单 14-26 中,我更新了App组件,从前面的部分中删除了ProFeature组件,并将proMode状态属性的值作为属性传递给SortedList组件,开始线程化属性的过程。

import React, { Component } from 'react';
import { GeneralList } from './GeneralList';
import { SortedList } from "./SortedList";

//import { ProFeature } from "./ProFeature";

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            names: ["Zoe", "Bob", "Alice", "Dora", "Joe"],
            cities: ["London", "New York", "Paris", "Milan", "Boston"],
            proMode: false
        }
    }

    toggleProMode = () => {
        this.setState({ proMode: !this.state.proMode});
    }

    render() {
        return (
            <div className="container-fluid">
                <div className="row">
                    <div className="col-12 text-center p-2">
                        <div className="form-check">
                            <input type="checkbox" className="form-check-input"
                                value={ this.state.proMode }
                                onChange={ this.toggleProMode } />
                            <label className="form-check-label">Pro Mode</label>
                        </div>
                    </div>
                </div>

                <div className="row">
                    <div className="col-6">
                        <GeneralList list={ this.state.names }
                            theme="primary" />
                    </div>
                    <div className="col-6">
                        <SortedList proMode={this.state.proMode}
                            list={ this.state.names } />
                    </div>
                </div>
            </div>
        )
    }
}

Listing 14-26Threading a Prop in the App.js File in the src Folder

SortedList组件不直接使用proMode属性,而是必须传递给ActionButton组件,完成属性线程,如清单 14-27 所示。

import React, { Component } from "react";
import { GeneralList } from "./GeneralList";
import { ActionButton } from "./ActionButton";

export class SortedList extends Component {

    constructor(props) {
        super(props);
        this.state = {
            sort: false
        }
    }

    getList() {
        return this.state.sort
            ? [...this.props.list].sort() : this.props.list;
    }

    toggleSort = () => {
        this.setState({ sort : !this.state.sort });
    }

    render() {
        return (
            <div>
                <GeneralList list={ this.getList() } theme="info" />
                <div className="text-center m-2">
                    <ActionButton theme="primary" text="Sort"
                        proMode={ this.props.proMode }
                        callback={this.toggleSort} />
                </div>
            </div>
        )
    }
}

Listing 14-27Threading a Prop in the SortedList.js File in the src Folder

结果是proMode状态值的值从App组件通过SortedList组件传递,并由ActionButton组件接收和使用,如图 14-12 所示。

img/473159_1_En_14_Fig12_HTML.jpg

图 14-12

将属性穿入应用

上下文功能解决的就是这个问题,它允许将状态数据直接传递给使用它的组件,而不需要通过在层次结构中分隔它们的中间组件。

定义背景

第一步是定义上下文,这是分发状态数据的机制。可以在应用中的任何地方定义上下文。我在src文件夹中添加了一个名为ProModeContext.js的文件,代码如清单 14-28 所示。

import React from "react";

export const ProModeContext = React.createContext({
    proMode: false
})

Listing 14-28The Contents of the ProModeContext.js in the src Folder

React.createContext方法用于创建一个新的上下文,并提供了一个数据对象,用于指定上下文的默认值,这些值在使用上下文时会被覆盖,稍后我会演示。在清单中,我定义的上下文称为ProModeContext,它定义了一个proMode属性,默认为false

创建上下文消费者

下一步是消费需要数据值的上下文,如清单 14-29 所示。

import React, { Component } from "react";

import { ProModeContext } from "./ProModeContext";

export class ActionButton extends Component {

    render() {
        return (
            <ProModeContext.Consumer>
                { contextData =>
                    <button
                        className={ this.getClasses(contextData.proMode)}
                        disabled={ !contextData.proMode }
                        onClick={ this.props.callback }>
                            { this.props.text }
                    </button>
                }
            </ProModeContext.Consumer>
        )
    }

    getClasses(proMode) {
        let col = proMode ? this.props.theme : "danger";
        return `btn btn-${col} m-2`;
    }
}

Listing 14-29Creating a Context Consumer in the ActionButton.js File in the src Folder

使用上下文类似于定义渲染属性,只是添加了一个自定义 HTML 元素来选择所需的上下文。首先是 HTML 元素,它的标记名是上下文名,后面是句点,再后面是Consumer,就像这样:

...
return <ProModeContext.Consumer>

   // ...context can be consumed here...

</ProModeContext.Consumer>

...

在 HTML 元素的开始和结束标记之间是一个函数,它接收上下文对象并呈现依赖于它的内容。

...
<ProModeContext.Consumer>
    { contextData =>
        <button
            className={ this.getClasses(contextData.proMode)}
            disabled={ !contextData.proMode }
            onClick={ this.props.callback }>
                { this.props.text }
        </button>
    }
</ProModeContext.Consumer>
...

组件仍然可以访问组件的状态和属性数据,这些数据可以与上下文提供的数据自由混合。在这个例子中,callback属性仍然用于处理click事件,而proMode上下文属性用于设置classNamedisabled属性的值。

创建上下文提供程序

最后一步是创建一个上下文提供者,它将源状态数据与上下文相关联,如清单 14-30 所示。

import React, { Component } from 'react';
import { GeneralList } from './GeneralList';
import { SortedList } from "./SortedList";

import { ProModeContext } from './ProModeContext';

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            names: ["Zoe", "Bob", "Alice", "Dora", "Joe"],
            cities: ["London", "New York", "Paris", "Milan", "Boston"],
            //proMode: false,
            proContextData: {
                proMode: false
            }
        }
    }

    toggleProMode = () => {
        this.setState(state =>  state.proContextData.proMode
            = !state.proContextData.proMode);
    }

    render() {
        return (
            <div className="container-fluid">
                <div className="row">
                    <div className="col-12 text-center p-2">
                        <div className="form-check">
                            <input type="checkbox" className="form-check-input"
                                value={ this.state.proContextData.proMode }
                                onChange={ this.toggleProMode } />
                            <label className="form-check-label">Pro Mode</label>
                        </div>
                    </div>
                </div>
                <div className="row">
                    <div className="col-6">
                        <GeneralList list={ this.state.names }
                            theme="primary" />
                    </div>
                    <div className="col-6">
                        <ProModeContext.Provider value={ this.state.proContextData }>
                            <SortedList list={ this.state.names } />
                        </ProModeContext.Provider>
                    </div>
                </div>
            </div>
        )
    }
}

Listing 14-30Creating a Context Provider in the App.js File in the src Folder

我不想向上下文消费者公开所有的App组件的状态数据,所以我创建了一个嵌套的proContextData状态对象,它有一个proMode属性。为了应用上下文,使用了另一个定制的 HTML 元素,它带有上下文名称的标记名,后跟一个句点,再加上Provider

...

<ProModeContext.Provider value={ this.state.proContextData }>

    <SortedList list={ this.state.names } />

</ProModeContext.Provider>

...

value属性用于为上下文提供覆盖清单 14-28 中定义的默认值的数据值,在本例中是proContextData状态对象。

小费

如果需要更新嵌套的状态属性,请使用接受函数的setState方法版本,如清单 14-28 所示。参见第十一章了解setState不同使用方式的详细信息。

在开始和结束标签ProModeContext.Provider之间定义的组件能够通过使用ProModeContext.Consumer元素直接访问状态数据。在示例应用中,这意味着App组件的proMode状态数据属性可以直接在ActionButton组件中获得,而不需要通过SortedList组件,如图 14-13 所示。

img/473159_1_En_14_Fig13_HTML.jpg

图 14-13

使用上下文分发状态数据属性的效果

更改使用者中的上下文数据值

上下文中的数据值是只读的,但是您可以在更新源状态数据的上下文对象中包含一个函数,从而创建一个等效的函数属性。在清单 14-31 中,我为函数添加了一个占位符,如果提供者在不使用value属性的情况下应用内容,将会使用这个占位符。

import React from "react";

export const ProModeContext = React.createContext({
    proMode: false,
    toggleProMode: () => {}
})

Listing 14-31Adding a Function in the ProModeContext.js file in the src Folder

该函数有一个空体,仅在使用者收到默认数据对象时用于防止错误。为了演示如何修改上下文数据值,我将创建一个组件来呈现用于切换 pro 模式的复选框。我在src文件夹中添加了一个名为ProModeToggle.js的文件,并用它来定义清单 14-32 中所示的组件。

import React, { Component } from "react";
import { ProModeContext } from "./ProModeContext";

export class ProModeToggle extends Component {

    render() {
        return <ProModeContext.Consumer>
            { contextData => (
                <div className="form-check">
                    <input type="checkbox" className="form-check-input"
                        value={ contextData.proMode }
                        onChange={ contextData.toggleProMode } />
                    <label className="form-check-label">
                        { this.props.label }
                    </label>
                </div>
                )
            }
        </ProModeContext.Consumer>
    }
}

Listing 14-32The Contents of the ProModeToggle.js File in the src Folder

该组件是一个上下文消费者,使用proMode属性来设置复选框的值,并在它发生变化时调用toggleProMode函数。该组件还使用一个属性来设置一个label元素的内容,只是为了显示一个使用上下文的组件仍然能够从其父组件接收属性。在清单 14-33 中,我已经更新了App组件,这样它就可以使用ProModeToggle组件并为上下文提供一个函数。

警告

避免在提供者的render方法中为上下文创建对象的诱惑,这可能很有吸引力,因为它避免了创建嵌套状态对象和为状态属性分配方法的需要。每次调用render方法时创建一个新对象会破坏 React 应用于上下文的更改检测过程,并可能导致额外的更新。

import React, { Component } from 'react';
import { GeneralList } from './GeneralList';
import { SortedList } from "./SortedList";
import { ProModeContext } from './ProModeContext';

import { ProModeToggle } from './ProModeToggle';

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            names: ["Zoe", "Bob", "Alice", "Dora", "Joe"],
            cities: ["London", "New York", "Paris", "Milan", "Boston"],
            //proMode: false,
            proContextData: {
                proMode: false,
                toggleProMode: this.toggleProMode
            }
        }
    }

    toggleProMode = () => {
        this.setState(state =>  state.proContextData.proMode
            = !state.proContextData.proMode);
    }

    render() {
        return (
            <div className="container-fluid">
                <ProModeContext.Provider value={ this.state.proContextData }>
                    <div className="row">
                        <div className="col-12 text-center p-2">
                            <ProModeToggle label="Pro Mode" />
                        </div>
                    </div>
                    <div className="row">
                        <div className="col-6">
                            <GeneralList list={ this.state.names }
                                theme="primary" />
                        </div>
                        <div className="col-6">
                            <SortedList list={ this.state.names } />
                        </div>
                    </div>
                </ProModeContext.Provider>
            </div>
        )
    }
}

Listing 14-33Expanding the Context in the App.js File in the src Folder

为了提供一个既有状态数据又有函数的对象,我添加了一个属性,它的值是toggleProMode方法,允许上下文消费者更改状态数据属性的值,并在这样做时触发更新。注意,我已经提升了ProModeContext.Provider元素,这样ProModeToggleSortedList组件都在范围内。这是可选的,我可以给每个子组件自己的上下文元素,只要value属性使用相同的对象。当您想要将上下文的多个实例与不同的组件组一起使用时,使用单独的元素会很有用,如清单 14-34 所示。

import React, { Component } from 'react';
//import { GeneralList } from './GeneralList';
import { SortedList } from "./SortedList";
import { ProModeContext } from './ProModeContext';
import { ProModeToggle } from './ProModeToggle';

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            names: ["Zoe", "Bob", "Alice", "Dora", "Joe"],
            cities: ["London", "New York", "Paris", "Milan", "Boston"],
            proContextData: {
                proMode: false,
                toggleProMode: this.toggleProMode
            },
            superProContextData: {
                proMode: false,
                toggleProMode: this.toggleSuperMode
            }
        }
    }

    toggleProMode = () => {
        this.setState(state =>  state.proContextData.proMode
            = !state.proContextData.proMode);
    }

    toggleSuperMode = () => {
        this.setState(state =>  state.superProContextData.proMode
            = !state.superProContextData.proMode);
    }

    render() {
        return  (
            <div className="container-fluid">
                <div className="row">
                    <div className="col-6 text-center p-2">
                        <ProModeContext.Provider value={ this.state.proContextData }>
                            <ProModeToggle label="Pro Mode" />
                        </ProModeContext.Provider>
                    </div>
                    <div className="col-6 text-center p-2">
                        <ProModeContext.Provider
                                value={ this.state.superProContextData }>
                            <ProModeToggle label="Super Pro Mode" />
                        </ProModeContext.Provider>
                    </div>
                </div>
                <div className="row">
                    <div className="col-6">
                        <ProModeContext.Provider value={ this.state.proContextData }>
                            <SortedList list={ this.state.names } />
                        </ProModeContext.Provider>
                    </div>
                    <div className="col-6">
                        <ProModeContext.Provider
                                value={ this.state.superProContextData }>
                            <SortedList list={ this.state.cities } />
                        </ProModeContext.Provider>
                    </div>
                </div>
            </div>
        )
    }
}

Listing 14-34Using Multiple Contexts in the App.js File in the src Folder

App组件使用不同的上下文来管理两个专业级别,如图 14-14 所示。每个上下文都有自己的数据对象,React 跟踪每个对象的提供者和消费者。

img/473159_1_En_14_Fig14_HTML.jpg

图 14-14

使用多种上下文

使用简化的上下文消费者 API

React 提供了另一种访问上下文的方法,比 render prop 函数更容易使用,如清单 14-35 所示。

import React, { Component } from "react";
import { ProModeContext } from "./ProModeContext";

export class ProModeToggle extends Component {
    static contextType = ProModeContext;

    render() {
        return (
            <div className="form-check">
                <input type="checkbox" className="form-check-input"
                    value={ this.context.proMode }
                    onChange={ this.context.toggleProMode } />
                <label className="form-check-label">
                    { this.props.label }
                </label>
            </div>
        )
    }
}

Listing 14-35Using the Simpler Context API in the ProModeToggle.js File in the src Folder

一个名为contextTypestatic属性被分配了上下文,该上下文作为this.context在整个组件中可用。这是 React 的一个相对较新的特性,但是它可能更容易使用,但要注意一个组件只能使用一个上下文。

使用钩子消费上下文

useContext钩子为功能组件提供了与contextType属性相对应的属性。在清单 14-36 中,我重写了ProModeToggle组件,将其定义为依赖于useContext钩子的函数。

import React, { useContext } from "react";

import { ProModeContext } from "./ProModeContext";

export function ProModeToggle(props) {

    const context = useContext(ProModeContext);

    return (
        <div className="form-check">
            <input type="checkbox" className="form-check-input"
                value={ context.proMode }
                onChange={ context.toggleProMode } />
            <label className="form-check-label">
                { props.label }
            </label>
        </div>
    )
}

Listing 14-36Using a Hook in the ProModeToggle.js File in the src Folder

useContext函数返回一个上下文对象,通过它可以访问属性和函数。

定义误差边界

当组件在其 render 方法或生命周期方法中生成错误时,它会沿组件层次结构向上传播,直到到达应用的顶部,此时应用的所有组件都会被卸载。这意味着任何错误都可以有效地终止应用,这很少是理想的,特别是如果错误是应用可以恢复的。为了演示默认的错误处理行为,我修改了ActionButton组件,使它在第二次单击按钮元素时抛出一个错误,如清单 14-37 所示。

import React, { Component } from "react";
import { ProModeContext } from "./ProModeContext";

export class ActionButton extends Component {

    constructor(props) {
        super(props);
        this.state = {
            clickCount: 0
        }
    }

    handleClick = () => {
        this.setState({ clickCount: this.state.clickCount + 1});
        this.props.callback();
    }

    render() {
        return (
            <ProModeContext.Consumer>
                { contextData => {
                    if (this.state.clickCount > 1) {
                        throw new Error("Click Counter Error");
                    }
                    return <button
                        className={ this.getClasses(contextData.proMode)}
                        disabled={ !contextData.proMode }
                        onClick={ this.handleClick }>
                                { this.props.text }
                    </button>
                }}
            </ProModeContext.Consumer>
        )
    }

    getClasses(proMode) {
        let col = proMode ? this.props.theme : "danger";
        return `btn btn-${col} m-2`;
    }
}

Listing 14-37Throwing an Error in the ActionButton.js File in the src Folder

要查看默认行为,请启用其中一个复选框并单击相关按钮。当您第一次单击时,列表的顺序将会改变。再次点击会抛出错误,会看到如图 14-15 所示的响应。该消息在开发过程中显示,但在为部署构建应用时被禁用,如第八章所示。单击浏览器窗口右上角的关闭图标,您将看到应用的所有组件都已卸载,留下一个空的浏览器窗口。

img/473159_1_En_14_Fig15_HTML.jpg

图 14-15

默认的错误处理

浏览器的 JavaScript 控制台显示错误的栈跟踪。

...
Uncaught Error: Click Counter Error
    at ActionButton.js:23
    at updateContextConsumer (react-dom.development.js:13799)
    at beginWork (react-dom.development.js:13987)
    at performUnitOfWork (react-dom.development.js:16249)
...

创建误差边界组件

基于类的组件可以实现componentDidCatch生命周期方法,当子组件抛出错误时调用该方法。React 惯例是使用专用的错误处理组件,称为错误边界,它拦截错误,或者恢复应用以便它可以继续执行,或者向用户显示一条消息来指示问题的性质。我在src文件夹中添加了一个名为ErrorBoundary.js的文件,并用它来定义清单 14-38 中所示的错误边界。

警告

错误边界仅适用于在生命周期方法中引发的错误,不响应在事件处理程序中引发的错误。错误边界也不能用于异步 HTTP 请求,必须使用一个try / catch块来代替,如第三部分所示。

import React, { Component } from "react";

export class ErrorBoundary extends Component {

    constructor(props) {
        super(props);
        this.state = {
            errorThrown: false
        }
    }

    componentDidCatch = (error, info) => this.setState({ errorThrown: true});

    render() {
        return (
            <React.Fragment>
                { this.state.errorThrown &&
                    <h3 className="bg-danger text-white text-center m-2 p-2">
                        Error Detected
                    </h3>
                }
                { this.props.children }
            </React.Fragment>
        )
    }
}

Listing 14-38The Contents of the ErrorBoundary.js File in the src Folder

componentDidCatch方法接收由问题组件抛出的错误对象和提供组件栈跟踪的附加信息对象,这对日志记录很有用。

当使用错误边界时,React 将调用componentDidCatch方法,然后调用render方法。如第十三章所述,使用组件生命周期的安装阶段处理由错误边界呈现的内容,以便创建所有组件的新实例。此序列允许错误边界更改呈现的内容以避免问题或更改应用的状态,以便错误不会再次发生。对于这个例子,我选择了第三个选项,即呈现相同的内容,但是显示一条消息,指出已经检测到错误。当由于应用范围之外的问题而出现错误时,例如无法从 web 服务获取数据时,可以使用这种方法。错误边界被应用为容器组件,如清单 14-39 所示。

import React, { Component } from "react";
import { GeneralList } from "./GeneralList";
import { ActionButton } from "./ActionButton";

import { ErrorBoundary } from "./ErrorBoundary";

export class SortedList extends Component {

    constructor(props) {
        super(props);
        this.state = {
            sort: false
        }
    }

    getList() {
        return this.state.sort
            ? [...this.props.list].sort() : this.props.list;
    }

    toggleSort = () => {
        this.setState({ sort : !this.state.sort });
    }

    render() {
        return (
            <div>
                <ErrorBoundary>
                    <GeneralList list={ this.getList() } theme="info" />
                    <div className="text-center m-2">
                        <ActionButton theme="primary" text="Sort"
                            proMode={ this.props.proMode }
                            callback={this.toggleSort} />
                    </div>
                </ErrorBoundary>
            </div>
        )
    }
}

Listing 14-39Applying an Error Boundary in the SortedList.js File in the src Folder

错误边界将处理由它包含的任何组件及其任何后代引发的错误。要查看效果,双击其中一个排序按钮并关闭错误警告消息,以查看指示已检测到错误的消息,如图 14-16 所示。

img/473159_1_En_14_Fig16_HTML.jpg

图 14-16

误差边界的影响

摘要

在这一章中,我描述了组合组件来组成应用的不同方式,包括容器、高阶组件和渲染属性。我还向您展示了如何使用上下文来分发全局数据和避免适当的线程,以及如何使用错误边界来处理组件生命周期方法中的问题。在下一章中,我将描述 React 提供的处理表单的特性。

十五、表单和验证

表单允许应用从用户那里收集数据。在本章中,我将解释 React 如何处理表单元素,使用状态属性来设置它们的值,使用事件处理程序来响应用户交互。我将向您展示如何使用不同的元素类型,以及如何验证用户在表单中提供的数据,以便应用接收它可以使用的数据。表 15-1 将表单和验证放在上下文中。

表 15-1

将表单和验证放在上下文中

|

问题

|

回答

|
| --- | --- |
| 它们是什么? | 表单是允许应用提示用户输入数据的基本机制。验证是检查数据以确保应用可以使用该数据的过程。 |
| 它们为什么有用? | 大多数应用都需要用户输入某种程度的数据,比如电子邮件地址、付款细节或送货地址。表单允许用户以自由文本形式或从一系列预定义选项中选择来输入数据。验证用于确保数据的格式符合应用的预期并且可以处理。 |
| 它们是如何使用的? | 在这一章中,我描述了被控制的表单元素,它们的值是使用valuechecked属性来设置的,它们的change事件被用来处理用户的编辑或选择。这些功能也用于验证。 |
| 有什么陷阱或限制吗? | 不同表单元素的行为方式有所不同,React 和标准 HTML 表单元素之间也有细微的差异,这将在后面的章节中介绍。 |
| 还有其他选择吗? | 应用根本不需要使用表单元素。在某些应用中,不受控制的表单元素可能是更合适的选择,因为 React 不负责管理元素的数据,如第十六章所述。 |

表 15-2 总结了本章内容。

表 15-2

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 向组件添加表单元素 | 将元素添加到由组件呈现的内容中。使用value属性设置元素的初始值,并使用onChange属性响应变化。 | 1–10, 12, 13 |
| 确定复选框的状态 | 处理变更事件时,检查target元素的 checked 属性 | Eleven |
| 验证表单数据 | 定义验证规则,并在用户编辑字段和触发更改事件时应用这些规则 | 14–25 |

为本章做准备

为了创建本章的示例项目,打开一个新的命令提示符,导航到一个方便的位置,并运行清单 15-1 中所示的命令。

小费

你可以从 https://github.com/Apress/pro-react-16 下载本章以及本书其他章节的示例项目。

npx create-react-app forms

Listing 15-1Creating the Example Project

运行清单 15-2 中所示的命令,导航到forms文件夹,将引导包和验证包添加到项目中。(我使用“验证表单数据”一节中的验证包。)

cd forms
npm install bootstrap@4.1.2
npm install validator@10.7.1

Listing 15-2Adding Packages to the Project

为了在应用中包含引导 CSS 样式表,将清单 15-3 中所示的语句添加到index.js文件中,该文件可以在src文件夹中找到。

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

import 'bootstrap/dist/css/bootstrap.css';

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

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

Listing 15-3Including Bootstrap in the index.js File in the src Folder

定义示例组件

src文件夹中添加一个名为Editor.js的文件,并添加清单 15-4 所示的内容。

import React, { Component } from "react";

export class Editor extends Component {

    render() {
        return <div className="h5 bg-info text-white p-2">
                    Form Will Go Here
                </div>
    }
}

Listing 15-4The Contents of the Editor.js File in the src Folder

我将使用这个组件向用户显示一个表单。然而,首先,该组件呈现一个占位符消息。接下来,将名为Display.js的文件添加到src文件夹中,并添加清单 15-5 中所示的内容。

import React, { Component } from "react";

export class Display extends Component {

    formatValue = (data) => Array.isArray(data)
        ? data.join(", ") : data.toString();

    render() {
        let keys = Object.keys(this.props.data);
        if (keys.length === 0) {
            return <div className="h5 bg-secondary p-2 text-white">
                No Data
            </div>
        } else {
            return <div className="container-fluid bg-secondary p-2">
                    { keys.map(key =>
                        <div key={key} className="row h5 text-white">
                            <div className="col">{ key }:</div>
                            <div className="col">
                                { this.formatValue(this.props.data[key]) }
                            </div>
                        </div>
                    )}
                </div>
        }
    }
}

Listing 15-5The Contents of the Display.js File in the src Folder

该组件接收一个data属性,并在网格中枚举它的属性和值。最后,更改App.js文件中的内容,用清单 15-6 中所示的组件替换创建项目时添加的内容。

import React, { Component } from "react";
import { Editor } from "./Editor";
import { Display } from "./Display";

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            formData: {}
        }
    }

    submitData = (newData) => {
        this.setState({ formData: newData});
    }

    render() {
        return <div className="container-fluid">
            <div className="row p-2">
                <div className="col-6">
                    <Editor submit={ this.submitData } />
                </div>
                <div className="col-6">
                    <Display data={ this.state.formData } />
                </div>
            </div>
        </div>
    }
}

Listing 15-6The Contents of the App.js File in the src Folder

启动开发工具

使用命令提示符,运行forms文件夹中清单 15-7 所示的命令来启动开发工具。

npm start

Listing 15-7Starting the Development Tools

一旦项目的初始准备工作完成,一个新的浏览器窗口将打开并显示 URL http://localhost:3000,它显示如图 15-1 所示的内容。

img/473159_1_En_15_Fig1_HTML.jpg

图 15-1

运行示例应用

使用表单元素

使用表单元素最简单的方法是使用状态和事件特性,在前面章节描述的 React 功能的基础上进行构建。其结果就是所谓的受控组件,这在之前的例子中已经很熟悉了。在清单 15-8 中,我添加了一个input元素,它的内容由 React toEditor组件管理。

小费

还有一种方法叫做非受控组件,我在第十六章中描述过。

import React, { Component } from "react";

export class Editor extends Component {

    constructor(props) {
        super(props);
        this.state = {
            name: ""
        }
    }

    updateFormValue = (event) => {
        this.setState({ [event.target.name]: event.target.value },
            () => this.props.submit(this.state));
    }

    render() {
        return <div className="h5 bg-info text-white p-2">
                    <div className="form-group">
                        <label>Name</label>
                        <input className="form-control"
                            name="name"
                            value={ this.state.name }
                            onChange={ this.updateFormValue } />
                    </div>
                </div>
    }
}

Listing 15-8Adding a Form Element in the Editor.js File in the src Folder

使用name状态属性设置input元素的value属性,使用updateFormValue方法处理更改,该方法是使用onChange属性选择的。大多数表单需要多个字段,与其为每个字段定义不同的事件处理方法,不如使用一种方法,并确保表单元素被配置为指示与哪个状态值相关联。在这个例子中,我使用了name属性来指定 state 属性的名称,然后我从 handler 方法接收的事件中读取这个名称:

...
updateFormValue = (event) => {
    this.setState({ [event.target.name]: event.target.value },
        () => this.props.submit(this.state));
}
...

方括号中的内容([]字符)被求值以获得状态更新的属性名,这允许我通过setState方法使用来自event.target对象的name属性。正如您将在后面的示例中看到的,并非所有类型的表单元素都能以相同的方式处理,但是这种方法减少了组件中事件处理方法的数量。

小费

如果希望向用户显示一个空的input元素,请将 state 属性设置为空字符串("")。你可以在清单 15-8 中看到空元素的例子。不要使用nullundefined,因为这些值会导致 React 在浏览器的 JavaScript 控制台中生成警告。

注意,在状态数据更新后,我使用了由setState方法提供的回调选项来调用submit函数 prop,这允许我将表单数据发送到父组件。这意味着Editor组件的状态数据的任何变化也会被推送到App组件,这样它就可以被Display组件显示出来,结果是输入到input元素中的数据会立即反映在呈现给用户的内容中,如图 15-2 所示。这看起来像是不必要的状态数据复制,但是它将允许我在本章后面更容易地实现验证特性。

img/473159_1_En_15_Fig2_HTML.jpg

图 15-2

使用受控组件

使用选择元素

一旦基本结构就绪,控制器组件就可以轻松地支持其他表单元素。在清单 15-9 中,我向Editor组件添加了两个select元素。

import React, { Component } from "react";

export class Editor extends Component {

    constructor(props) {
        super(props);
        this.state = {
            name: "Bob",
            flavor: "Vanilla",
            toppings: ["Strawberries"]
        }

        this.flavors = ["Chocolate", "Double Chocolate",
            "Triple Chocolate", "Vanilla"];
        this.toppings = ["Sprinkles", "Fudge Sauce",
                            "Strawberries", "Maple Syrup"]
    }

    updateFormValue = (event) => {
        this.setState({ [event.target.name]: event.target.value },
            () => this.props.submit(this.state));
    }

    updateFormValueOptions = (event) => {
        let options = [...event.target.options]
            .filter(o => o.selected).map(o => o.value);
        this.setState({ [event.target.name]: options },
            () => this.props.submit(this.state));
    }

    render() {
        return <div className="h5 bg-info text-white p-2">
                    <div className="form-group">
                        <label>Name</label>
                        <input className="form-control"
                            name="name"
                            value={ this.state.name }
                            onChange={ this.updateFormValue } />
                    </div>
                    <div className="form-group">
                        <label>Ice Cream Flavors</label>
                        <select className="form-control"
                                name="flavor" value={ this.state.flavor }
                                onChange={ this.updateFormValue } >
                            { this.flavors.map(flavor =>
                                <option value={ flavor } key={ flavor }>
                                    { flavor }
                                </option>
                            )}
                        </select>
                    </div>
                    <div className="form-group">
                        <label>Ice Cream Toppings</label>
                        <select className="form-control" multiple={true}
                                name="toppings" value={ this.state.toppings }
                                onChange={ this.updateFormValueOptions }>
                            { this.toppings.map(top =>
                                <option value={ top } key={ top }>
                                    { top }
                                </option>
                            )}
                        </select>
                    </div>
                </div>
    }
}

Listing 15-9Adding Select Elements in the Editor.js File in the src Folder

虽然需要注意显示多个值的元素,但是select元素很容易使用。对于一个基本的select元素,value属性用于设置选择的值,选择使用onChange属性处理。由select元素表示的option元素可以被指定为常规的 HTML 元素,或者以编程方式生成,在这种情况下,它们将需要一个key属性,如下所示:

...
<select className="form-control" name="flavor" value={ this.state.flavor }
        onChange={ this.updateFormValue } >
    { this.flavors.map(flavor =>
        <option value={ flavor } key={ flavor }>{ flavor }</option>
    )}
</select>
...

对呈现单个选择项的select元素的更改可以使用为input元素定义的相同方法来处理,因为选择的值是通过event.target.value属性访问的。

使用表示多个项目的选择元素

允许多重选择的元素需要更多的工作。定义元素时,使用表达式将multiple属性设置为true

...
<select className="form-control" multiple={true} name="toppings"
    value={ this.state.toppings } onChange={ this.updateFormValueOptions }>
...

使用表达式可以避免一个常见的问题,即给multiple属性分配一个字符串值会启用多个元素,即使字符串是"false"。处理用户的选择需要一个不同的处理change事件的方法,如下所示:

...
updateFormValueOptions = (event) => {
    let options = [...event.target.options]
        .filter(o => o.selected).map(o => o.value);
    this.setState({ [event.target.name]: options },
        () => this.props.submit(this.state));
}
...

用户所做的选择是通过event.target.options属性访问的,其中所选择的项目有一个值为trueselected属性。在清单中,我从选项中创建了一个数组,使用filter方法获取选择的项目,使用map方法获取value属性,这留下了一个数组,其中包含每个选择的option元素的value属性的值。两个select元件都可以在图 15-3 中看到。(在您做出更改之前,Display组件不会显示数据。)

img/473159_1_En_15_Fig3_HTML.jpg

图 15-3

使用选择元素

使用单选按钮

使用单选按钮需要与文本化input元素类似的过程,其中用户的选择可以通过目标元素的value属性来访问,如清单 15-10 所示。

import React, { Component } from "react";

export class Editor extends Component {

    constructor(props) {
        super(props);
        this.state = {
            name: "Bob",
            flavor: "Vanilla"
        }

        this.flavors = ["Chocolate", "Double Chocolate",
            "Triple Chocolate", "Vanilla"];
        this.toppings = ["Sprinkles", "Fudge Sauce",
                            "Strawberries", "Maple Syrup"]
    }

    updateFormValue = (event) => {
        this.setState({ [event.target.name]: event.target.value },
            () => this.props.submit(this.state));
    }

    render() {
        return <div className="h5 bg-info text-white p-2">
                    <div className="form-group">
                        <label>Name</label>
                        <input className="form-control"
                            name="name"
                            value={ this.state.name }
                            onChange={ this.updateFormValue } />
                    </div>

                    <div className="form-group">
                        <label>Ice Cream Flavors</label>
                        { this.flavors.map(flavor =>
                            <div className="form-check" key={ flavor }>
                                <input className="form-check-input"
                                    type="radio" name="flavor"
                                    value={ flavor }
                                    checked={ this.state.flavor === flavor }
                                    onChange={ this.updateFormValue } />
                                <label className="form-check-label">
                                    { flavor }
                                </label>
                            </div>
                        )}
                    </div>
                </div>
    }
}

Listing 15-10Using Radio Buttons in the Editor.js File in the src Folder

单选按钮允许用户从选项列表中选择单个值。单选按钮代表的选择由其value属性指定,其checked属性用于确保元素被正确选择,如图 15-4 所示。

img/473159_1_En_15_Fig4_HTML.jpg

图 15-4

使用单选按钮来提供选择

使用复选框

复选框需要不同的方法,因为必须读取目标元素的checked属性来确定用户是否选中了该元素,如清单 15-11 所示。

import React, { Component } from "react";

export class Editor extends Component {

    constructor(props) {
        super(props);
        this.state = {
            name: "Bob",
            flavor: "Vanilla",
            twoScoops: false
        }

        this.flavors = ["Chocolate", "Double Chocolate",
            "Triple Chocolate", "Vanilla"];
        this.toppings = ["Sprinkles", "Fudge Sauce",
                            "Strawberries", "Maple Syrup"]
    }

    updateFormValue = (event) => {
        this.setState({ [event.target.name]: event.target.value },
            () => this.props.submit(this.state));
    }

    updateFormValueCheck = (event) => {
        this.setState({ [event.target.name]: event.target.checked },
            () => this.props.submit(this.state));
    }

    render() {
        return <div className="h5 bg-info text-white p-2">
                    <div className="form-group">
                        <label>Name</label>
                        <input className="form-control"
                            name="name"
                            value={ this.state.name }
                            onChange={ this.updateFormValue } />
                    </div>

                    <div className="form-group">
                        <label>Ice Cream Flavors</label>
                        { this.flavors.map(flavor =>
                            <div className="form-check" key={ flavor }>
                                <input className="form-check-input"
                                    type="radio" name="flavor"
                                    value={ flavor }
                                    checked={ this.state.flavor === flavor }
                                    onChange={ this.updateFormValue } />
                                <label className="form-check-label">
                                    { flavor }
                                </label>
                            </div>
                        )}
                    </div>

                    <div className="form-group">
                        <div className="form-check">
                            <input className="form-check-input"
                                type="checkbox" name="twoScoops"
                                checked={ this.state.twoScoops }
                                onChange={ this.updateFormValueCheck } />
                            <label className="form-check-label">Two Scoops</label>
                        </div>
                    </div>
                </div>
    }
}

Listing 15-11Using a Checkbox in the Editor.js File in the src Folder

checked属性用于指定复选框显示时是否被选中,checked属性用于处理change事件时确定用户是否选中该元素,如图 15-5 所示。

img/473159_1_En_15_Fig5_HTML.jpg

图 15-5

使用复选框

使用复选框填充数组

复选框也可以用来填充数组,允许用户从相关选项中进行选择,这种方式可能比多选select元素更熟悉,如清单 15-12 所示。

import React, { Component } from "react";

export class Editor extends Component {

    constructor(props) {
        super(props);
        this.state = {
            name: "",
            flavor: "Vanilla",
            toppings: ["Strawberries"]
        }

        this.flavors = ["Chocolate", "Double Chocolate",
            "Triple Chocolate", "Vanilla"];
        this.toppings = ["Sprinkles", "Fudge Sauce",
                            "Strawberries", "Maple Syrup"]
    }

    updateFormValue = (event) => {
        this.setState({ [event.target.name]: event.target.value },
            () => this.props.submit(this.state));
    }

    updateFormValueCheck = (event) => {
        event.persist();
        this.setState(state => {
            if (event.target.checked) {
                state.toppings.push(event.target.name);
            } else {
                let index = state.toppings.indexOf(event.target.name);
                state.toppings.splice(index, 1);
            }
        }, () => this.props.submit(this.state));
    }

    render() {
        return <div className="h5 bg-info text-white p-2">
                    <div className="form-group">
                        <label>Name</label>
                        <input className="form-control"
                            name="name"
                            value={ this.state.name }
                            onChange={ this.updateFormValue } />
                    </div>

                    <div className="form-group">
                        <label>Ice Cream Toppings</label>
                        { this.toppings.map(top =>
                            <div className="form-check" key={ top }>
                                <input className="form-check-input"
                                    type="checkbox" name={ top }
                                    value={ this.state[top] }
                                    checked={ this.state.toppings.indexOf(top) > -1 }
                                    onChange={ this.updateFormValueCheck } />
                                <label className="form-check-label">{ top }</label>
                            </div>
                        )}
                    </div>
                </div>
    }
}

Listing 15-12Using Related Checkboxes in the Editor.js File in the src Folder

元素以同样的方式生成,但是需要对updateFormValueCheck方法进行修改来管理toppings数组的内容,以便它只包含用户选择的值。标准的 JavaScript 数组特性用于在相应的复选框未选中时从数组中移除一个值,并在复选框被选中时添加一个值,产生如图 15-6 所示的结果。

img/473159_1_En_15_Fig6_HTML.jpg

图 15-6

使用复选框填充数组

使用文本区域

与常规 HTML 不同,textarea元素的内容是使用value属性来设置和读取的。在清单 15-13 中,我向示例应用添加了一个textarea元素,并使用onChange处理程序来响应编辑。

import React, { Component } from "react";

export class Editor extends Component {

    constructor(props) {
        super(props);
        this.state = {
            name: "Bob",
            order: ""
        }
    }

    updateFormValue = (event) => {
        this.setState({ [event.target.name]: event.target.value },
            () => this.props.submit(this.state));
    }

    render() {
        return <div className="h5 bg-info text-white p-2">
                    <div className="form-group">
                        <label>Name</label>
                        <input className="form-control"
                            name="name"
                            value={ this.state.name }
                            onChange={ this.updateFormValue } />
                    </div>

                    <div className="form-group">
                        <label>Order</label>
                        <textarea className="form-control" name="order"
                            value={ this.state.order }
                            onChange={ this.updateFormValue } />
                    </div>
                </div>
    }
}

Listing 15-13Using a Text Area in the Editor.js File in the src Folder

可以通过我最初为文本输入元素定义的相同方法来处理更改,清单产生如图 15-7 所示的结果。

img/473159_1_En_15_Fig7_HTML.jpg

图 15-7

使用文本区域元素

验证表单数据

用户几乎可以在表单域中输入任何内容,要么是因为他们犯了错误,要么是因为他们试图跳过表单而不填写,如侧栏中所述。验证检查用户提供的数据,以确保应用拥有可以使用的数据。在接下来的小节中,我将向您展示如何在 React 应用中执行表单验证。

尽量减少表单的使用

用户将错误数据输入表单的一个原因是他们不认为结果有价值。当表单用一些不重要的东西打断了对用户来说很重要的过程时,就会发生这种情况,例如在阅读文章时出现创建帐户的干扰性提示,或者在用户经常执行的过程开始时出现相同的表单。

当用户不重视表单时,验证没有用,因为他们只是输入了通过检查的坏数据,但仍然是坏数据。如果你发现你的电子邮件地址的打扰式提示导致了大量的a@a.com回复,那么你应该考虑到你的用户并不认为你的每周简讯和你一样有趣。

尽量少用表单,只在用户认为有用的过程中使用,比如提供送货地址。对于其他表单,找到一种替代的方式向用户请求数据,这种方式不会中断他们的工作流,也不会在他们每次尝试执行任务时打扰他们。

验证表单时,验证过程的不同部分可以分布在复杂的 HTML 和组件层次结构中。我将使用上下文来跟踪验证问题,而不是使用线程来连接不同的部分。我在src文件夹中添加了一个名为ValidationContext.js的文件,内容如清单 15-14 所示。(上下文在第十四章中描述。)

注意

本节中的例子依赖于清单 15-2 中添加到项目中的validator包。如果您跳过了安装,您应该返回并添加软件包,然后再继续这些示例。

import React from "react";

export const ValidationContext = React.createContext({
    getMessagesForField: (field) => []
})

Listing 15-14The Contents of the ValidationContext.js File in the src Folder

我将把每个表单元素的验证问题存储为一个数组,并在元素旁边显示每个问题的消息。上下文提供了对一个函数的访问,该函数将返回特定字段的验证消息。

定义验证规则

接下来,我在src文件夹中添加了一个名为validation.js的文件,并添加了清单 15-15 中所示的代码。这是验证表单数据的代码,使用本章开始时安装的validator包。

import validator from "validator";

export function ValidateData(data, rules) {
    let errors = {};
    Object.keys(data).forEach(field => {
        if (rules.hasOwnProperty(field)) {
            let fielderrors = [];
            let val = data[field];
            if (rules[field].required && validator.isEmpty(val)) {
                fielderrors.push("Value required");
            }
            if (!validator.isEmpty(data[field])) {
                if (rules[field].minlength
                        && !validator.isLength(val, rules[field].minlength)) {
                    fielderrors.push(`Enter at least ${rules[field].minlength}`
                        + " characters");
                }
                if (rules[field].alpha && !validator.isAlpha(val)) {
                    fielderrors.push("Enter only letters");
                }
                if (rules[field].email && !validator.isEmail(val)) {
                    fielderrors.push("Enter a valid email address");
                }
            }
            if (fielderrors.length > 0) {
                errors[field] = fielderrors;
            }
        }
    })
    return errors;
}

Listing 15-15The Contents of the validation.js File in the src Folder

ValidateData函数将接收一个属性为表单值的对象和一个指定要应用的验证规则的对象。validation包提供了可用于执行各种检查的方法,但是我在这个例子中只关注四个验证检查:确保用户提供了一个值,确保最小长度,确保有效的电子邮件地址,以及确保只使用字母字符。表 15-3 描述了我在下面的例子中使用的验证包提供的方法。查看 https://www.npmjs.com/package/validator 了解验证器包提供的全部功能。

表 15-3

验证器方法

|

名字

|

描述

|
| --- | --- |
| isEmpty | 如果值是空字符串,此方法返回 true。 |
| isLength | 如果值超过最小长度,此方法返回 true。 |
| isAlpha | 如果值只包含字母,此方法返回 true。 |
| isEmail | 如果值是有效的电子邮件地址,此方法返回 true。 |
| isEqual | 如果两个值相同,此方法返回 true。 |

创建容器组件

为了创建验证组件,我在src文件夹中添加了一个名为FormValidator.js的文件,并用它来定义清单 15-16 中所示的组件。

import React, { Component } from "react";
import { ValidateData } from "./validation";
import { ValidationContext } from "./ValidationContext";

export class FormValidator extends Component {

    constructor(props) {
        super(props);
        this.state = {
            errors: {},
            dirty: {},
            formSubmitted: false,
            getMessagesForField: this.getMessagesForField
        }
    }

    static getDerivedStateFromProps(props, state) {
        return {
            errors: ValidateData(props.data, props.rules)
        };
    }

    get formValid() {
        return Object.keys(this.state.errors).length === 0;
    }

    handleChange = (ev) => {
        let name = ev.target.name;
        this.setState(state => state.dirty[name] = true);
    }

    handleClick = (ev) => {
        this.setState({ formSubmitted: true }, () => {
            if (this.formValid) {
                this.props.submit(this.props.data)
            }
        });
    }

    getButtonClasses() {
        return this.state.formSubmitted && !this.formValid
            ? "btn-danger" : "btn-primary";
    }

    getMessagesForField = (field) => {
        return (this.state.formSubmitted || this.state.dirty[field]) ?
            this.state.errors[field] || [] : []
    }

    render() {
        return <React.Fragment>
            <ValidationContext.Provider value={ this.state }>
                <div onChange={ this.handleChange }>
                    { this.props.children }
                </div>
            </ValidationContext.Provider>

            <div className="text-center">
                <button className={ `btn ${ this.getButtonClasses() }`}
                        onClick={ this.handleClick }
                        disabled={ this.state.formSubmitted && !this.formValid } >
                    Submit
                </button>
            </div>
        </React.Fragment>
    }
}

Listing 15-16The Contents of the FormValidator.js File in the src Folder

验证是在getDerivedStateFromProps生命周期方法中执行的,该方法为组件提供了一个变更,以便根据它接收到的属性对其状态进行更改。该组件接收一个包含要验证的表单数据的data属性和一个定义应该应用的验证检查的rules属性,并将它们传递给清单 15-15 中定义的ValidateData函数。ValidateData函数的结果被分配给state.errors属性,它是一个对象,每个有验证问题的表单字段都有一个属性,还有一组应该显示给用户的消息。

在用户开始编辑字段或尝试提交表单之前,不应开始表单验证。如第十二章所述,当change事件从组件包含的表单元素中冒泡时,通过监听该事件来处理单独的编辑。

...
<div onChange={ this.handleChange }>
    { this.props.children }
</div>
...

handleChange方法将change事件的目标元素的name属性的值添加到dirty状态对象中(在验证期间,元素被视为原始的,直到用户开始编辑,之后它们被视为脏的)。该组件向用户呈现一个带有处理程序的button元素,当单击该元素时,处理程序会更改formSubmitted状态属性。如果在存在无效表单元素的情况下单击该按钮,则该按钮会被禁用,直到问题得到解决,并且其颜色会发生变化,以表明无法处理数据。

...
<button className={ `btn ${ this.getButtonClasses() }`}
        onClick={ this.handleClick }
        disabled={ this.state.formSubmitted && !this.formValid } >
    Submit
</button>
...

如果验证检查没有产生错误,那么handleClick方法调用一个名为submit的函数 prop,并使用经过验证的数据作为参数。

显示验证消息

为了在表单元素旁边显示验证消息,我在src文件夹中添加了一个名为ValidationMessage.js的文件,并用它来定义清单 15-17 中所示的组件。

import React, { Component } from "react";
import { ValidationContext } from "./ValidationContext";

export class ValidationMessage extends Component {
    static contextType = ValidationContext;

    render() {
        return this.context.getMessagesForField(this.props.field).map(err =>
            <div className="small bg-danger text-white mt-1 p-1"
                    key={ err } >
                { err }
            </div>
        )
    }
}

Listing 15-17The Contents of the ValidationMessage.js File in the src Folder

该组件使用由FormValidator组件提供的上下文,并使用它来获取单个表单字段的验证消息,该表单字段的名称通过field属性指定。该组件不了解它报告的验证问题的表单元素的类型,也不了解表单的整体有效性——它只是请求消息并显示它们。如果没有要显示的消息,则不会呈现任何内容。

应用表单验证

最后一步是对表单进行验证,如清单 15-18 所示。FormValidator组件必须是表单域的祖先,这样当表单域冒泡时,它就可以从表单域接收变更事件。它还必须是ValidationMessage组件的祖先,以便它们可以通过共享上下文访问验证消息。

import React, { Component } from "react";

import { FormValidator } from "./FormValidator";

import { ValidationMessage } from "./ValidationMessage";

export class Editor extends Component {

    constructor(props) {
        super(props);
        this.state = {
            name: "",
            email: "",
            order: ""
        }
        this.rules = {
            name: { required: true, minlength: 3, alpha: true },
            email: { required: true, email: true },
            order: { required: true }
        }
    }

    updateFormValue = (event) => {
        this.setState({ [event.target.name]: event.target.value });
    }

    render() {
        return <div className="h5 bg-info text-white p-2">
                    <FormValidator data={ this.state } rules={ this.rules }
                            submit={ this.props.submit }>
                        <div className="form-group">
                            <label>Name</label>
                            <input className="form-control"
                                name="name"
                                value={ this.state.name }
                                onChange={ this.updateFormValue } />
                            <ValidationMessage field="name" />
                        </div>

                        <div className="form-group">
                            <label>Email</label>
                            <input className="form-control"
                                name="email"
                                value={ this.state.email }
                                onChange={ this.updateFormValue } />
                            <ValidationMessage field="email" />
                        </div>

                        <div className="form-group">
                            <label>Order</label>
                            <textarea className="form-control"
                                name="order"
                                value={ this.state.order }
                                onChange={ this.updateFormValue } />
                            <ValidationMessage field="order" />
                        </div>
                    </FormValidator>
                </div>
    }
}

Listing 15-18Applying Validation in the Editor.js File in the src Folder

除了应用验证组件之外,我还添加了一个电子邮件字段并更改了updateFormValue方法,这样它就不会自动发送数据进行显示。结果如图 15-8 所示。在您开始编辑字段或单击按钮之前,不会显示任何验证消息,并且在您输入的数据满足所有验证要求之前,您不能提交数据。

img/473159_1_En_15_Fig8_HTML.jpg

图 15-8

验证表单数据

验证其他元素和数据类型

注意,验证特性并不直接处理输入和textarea元素。相反,标准的状态和事件特性用于将数据置于 React 的控制之下,由不知道或不关心数据来源的组件对数据进行验证和处理。这意味着一旦基本验证功能就绪,它们可以是不同类型的表单元素和不同类型的数据。每个项目都有自己的验证需求,但是下面几节中的示例展示了一些最常用的方法,您可以根据自己的需求进行调整。

确保复选框被选中

一个常见的验证要求是确保用户选中一个框来接受条款和条件。在清单 15-19 中,我添加了一个检查来确保一个值为真,当复选框元素被选中时就是这种情况。

import validator from "validator";

export function ValidateData(data, rules) {
    let errors = {};
    Object.keys(data).forEach(field => {
        if (rules.hasOwnProperty(field)) {
            let fielderrors = [];
            let val = data[field];
            if (rules[field].true) {
                if (!val) {
                    fielderrors.push("Must be checked");
                }
            } else {
                if (rules[field].required && validator.isEmpty(val)) {
                    fielderrors.push("Value required");
                }
                if (!validator.isEmpty(data[field])) {
                    if (rules[field].minlength
                            && !validator.isLength(val, rules[field].minlength)) {
                        fielderrors.push(`Enter at least ${rules[field].minlength}`
                            + " characters");
                    }
                    if (rules[field].alpha && !validator.isAlpha(val)) {
                        fielderrors.push("Enter only letters");
                    }
                    if (rules[field].email && !validator.isEmail(val)) {
                        fielderrors.push("Enter a valid email address");
                    }
                }
            }
            if (fielderrors.length > 0) {
                errors[field] = fielderrors;
            }
        }
    })
    return errors;
}

Listing 15-19Adding a Validation Option in the validation.js File in the src Folder

我用来执行验证检查的验证器包只对字符串值进行操作,如果要求它检查布尔值,它会报告一个错误。为了避免问题,我将新的验证检查作为一个特例,不能与其他规则结合。在清单 15-20 中,我删除了一些现有的表单元素,并添加了一个复选框,以及一个确保它被选中的验证规则。

import React, { Component } from "react";
import { FormValidator } from "./FormValidator";
import { ValidationMessage } from "./ValidationMessage";

export class Editor extends Component {

    constructor(props) {
        super(props);
        this.state = {
            name: "",
            terms: false
        }
        this.rules = {
            name: { required: true, minlength: 3, alpha: true },
            terms: { true: true}
        }
    }

    updateFormValue = (event) => {
        this.setState({ [event.target.name]: event.target.value });
    }

    updateFormValueCheck = (event) => {
        this.setState({ [event.target.name]: event.target.checked });
    }

    render() {
        return <div className="h5 bg-info text-white p-2">
                    <FormValidator data={ this.state } rules={ this.rules }
                            submit={ this.props.submit }>
                        <div className="form-group">
                            <label>Name</label>
                            <input className="form-control"
                                name="name"
                                value={ this.state.name }
                                onChange={ this.updateFormValue } />
                            <ValidationMessage field="name" />
                        </div>

                    <div className="form-group">
                        <div className="form-check">
                            <input className="form-check-input"
                                type="checkbox" name="terms"
                                checked={ this.state.terms }
                                onChange={ this.updateFormValueCheck } />
                            <label className="form-check-label">
                                Agree to terms
                             </label>
                        </div>
                        <ValidationMessage field="terms" />
                    </div>
                    </FormValidator>
                </div>
    }
}

Listing 15-20Validating a Checkbox in the Editor.js File in the src Folder

用户会看到一个复选框,在提交表单之前必须选中它,如图 15-9 所示。

img/473159_1_En_15_Fig9_HTML.jpg

图 15-9

验证复选框

确保匹配的值

有些值需要在两个输入中进行确认,例如用于联系目的的密码和电子邮件地址。在清单 15-21 中,我添加了一个验证规则来检查两个值是否相同。

import validator from "validator";

export function ValidateData(data, rules) {
    let errors = {};
    Object.keys(data).forEach(field => {
        if (rules.hasOwnProperty(field)) {
            let fielderrors = [];
            let val = data[field];
            if (rules[field].true) {
                if (!val) {
                    fielderrors.push("Must be checked");
                }
            } else {
                if (rules[field].required && validator.isEmpty(val)) {
                    fielderrors.push("Value required");
                }
                if (!validator.isEmpty(data[field])) {
                    if (rules[field].minlength
                            && !validator.isLength(val, rules[field].minlength)) {
                        fielderrors.push(`Enter at least ${rules[field].minlength}`
                            + " characters");
                    }
                    if (rules[field].alpha && !validator.isAlpha(val)) {
                        fielderrors.push("Enter only letters");
                    }
                    if (rules[field].email && !validator.isEmail(val)) {
                        fielderrors.push("Enter a valid email address");
                    }
                    if (rules[field].equals
                            && !validator.equals(val, data[rules[field].equals])) {
                        fielderrors.push("Values don't match");
                    }
                }
            }
            if (fielderrors.length > 0) {
                errors[field] = fielderrors;
            }
        }
    })
    return errors;
}

Listing 15-21Ensuring Equal Values in the validation.js File in the src Folder

在清单 15-22 中,我向Editor组件添加了两个输入元素,并添加了一个验证检查来确保用户在两个字段中输入相同的值。

import React, { Component } from "react";
import { FormValidator } from "./FormValidator";
import { ValidationMessage } from "./ValidationMessage";

export class Editor extends Component {

    constructor(props) {
        super(props);
        this.state = {
            name: "",
            email: "",
            emailConfirm: ""
        }
        this.rules = {
            name: { required: true, minlength: 3, alpha: true },
            email: { required: true, email: true, equals: "emailConfirm"},
            emailConfirm: { required: true, email: true, equals: "email"}
        }
    }

    updateFormValue = (event) => {
        this.setState({ [event.target.name]: event.target.value });
    }

    render() {
        return <div className="h5 bg-info text-white p-2">
                    <FormValidator data={ this.state } rules={ this.rules }
                            submit={ this.props.submit }>
                        <div className="form-group">
                            <label>Name</label>
                            <input className="form-control"
                                name="name"
                                value={ this.state.name }
                                onChange={ this.updateFormValue } />
                            <ValidationMessage field="name" />
                        </div>

                        <div className="form-group">
                            <label>Email</label>
                            <input className="form-control"
                                name="email"
                                value={ this.state.email }
                                onChange={ this.updateFormValue } />
                            <ValidationMessage field="email" />
                        </div>

                        <div className="form-group">
                            <label>Confirm Email</label>
                            <input className="form-control"
                                name="emailConfirm"
                                value={ this.state.emailConfirm }
                                onChange={ this.updateFormValue } />
                            <ValidationMessage field="emailConfirm" />
                        </div>
                    </FormValidator>
                </div>
    }
}

Listing 15-22Adding Related Elements in the Editor.js File in the src Folder

结果是只有当emailemailConfirm字段的内容相同时,该表单才有效,如图 15-10 所示。

img/473159_1_En_15_Fig10_HTML.jpg

图 15-10

确保匹配的值

执行整体表单验证

某些类型的验证不能在单个值上执行,例如确保选项组合的一致性。只有当用户将有效数据输入表单并提交时,才能执行这种验证,此时应用可以在处理数据之前执行最后一组检查。

对单个字段的验证可以使用相同的代码应用于多个表单,而对值组合的验证往往特定于单个表单。为了避免将通用代码与特定于表单的特性混淆,我在src文件夹中添加了一个名为wholeFormValidation.js的文件,并用它来定义清单 15-23 中所示的验证函数。

export function ValidateForm(data) {
    let errors = [];
    if (!data.email.endsWith("@example.com")) {
        errors.push("Only example.com users allowed");
    }
    if (!data.email.toLowerCase().startsWith(data.name.toLowerCase())) {
        errors.push("Email address must start with name");
    }
    if (data.name.toLowerCase() === "joe") {
        errors.push("Go away, Joe")
    }
    return errors;
}

Listing 15-23The Contents of the wholeFormValidation.js File in the src Folder

ValidateForm函数接收表单数据,并检查电子邮件地址是否以@example.com结尾,name属性是否不是joe,以及email值是否以name值开始。在清单 15-24 中,我扩展了FormValidator组件,以便它接收一个表单验证函数作为属性,并在提交表单数据之前使用它。

import React, { Component } from "react";
import { ValidateData } from "./validation";
import { ValidationContext } from "./ValidationContext";

export class FormValidator extends Component {

    constructor(props) {
        super(props);
        this.state = {
            errors: {},
            dirty: {},
            formSubmitted: false,
            getMessagesForField: this.getMessagesForField
        }
    }

    static getDerivedStateFromProps(props, state) {
        state.errors = ValidateData(props.data, props.rules);
        if (state.formSubmitted && Object.keys(state.errors).length === 0) {
            let formErrors = props.validateForm(props.data);
            if (formErrors.length > 0) {
                state.errors.form = formErrors;
            }
        }
        return state;
    }

    get formValid() {
        return Object.keys(this.state.errors).length === 0;
    }

    handleChange = (ev) => {
        let name = ev.target.name;
        this.setState(state => state.dirty[name] = true);
    }

    handleClick = (ev) => {
        this.setState({ formSubmitted: true }, () => {
            if (this.formValid) {
                let formErrors = this.props.validateForm(this.props.data);
                if (formErrors.length === 0) {
                    this.props.submit(this.props.data)
                }
            }
        });
    }

    getButtonClasses() {
        return this.state.formSubmitted && !this.formValid
            ? "btn-danger" : "btn-primary";
    }

    getMessagesForField = (field) => {
        return (this.state.formSubmitted || this.state.dirty[field]) ?
            this.state.errors[field] || [] : []
    }

    render() {
        return <React.Fragment>
            <ValidationContext.Provider value={ this.state }>
                <div onChange={ this.handleChange }>
                    { this.props.children }
                </div>
            </ValidationContext.Provider>

            <div className="text-center">
                <button className={ `btn ${ this.getButtonClasses() }`}
                        onClick={ this.handleClick }
                        disabled={ this.state.formSubmitted && !this.formValid } >
                    Submit
                </button>
            </div>
        </React.Fragment>
    }
}

Listing 15-24Adding Support for Whole-Form Validation in the FormValidator.js File in the src Folder

用户一单击 Submit 按钮,更改就开始验证整个表单。在清单 15-25 中,我已经更新了Editor组件,这样它为FormValidator提供了一个完整的表单验证功能,并定义了一个新的ValidationMessage组件来显示特定于表单的错误。

import React, { Component } from "react";
import { FormValidator } from "./FormValidator";
import { ValidationMessage } from "./ValidationMessage";

import { ValidateForm } from "./wholeFormValidation";

export class Editor extends Component {

    constructor(props) {
        super(props);
        this.state = {
            name: "",
            email: "",
            emailConfirm: ""
        }
        this.rules = {
            name: { required: true, minlength: 3, alpha: true },
            email: { required: true, email: true, equals: "emailConfirm"},
            emailConfirm: { required: true, email: true, equals: "email"}
        }
    }

    updateFormValue = (event) => {
        this.setState({ [event.target.name]: event.target.value });
    }

    render() {
        return <div className="h5 bg-info text-white p-2">
                    <FormValidator data={ this.state } rules={ this.rules }
                            submit={ this.props.submit }
                            validateForm={ ValidateForm }>

                        <ValidationMessage field="form" />

                        <div className="form-group">
                            <label>Name</label>
                            <input className="form-control"
                                name="name"
                                value={ this.state.name }
                                onChange={ this.updateFormValue } />
                            <ValidationMessage field="name" />
                        </div>

                        <div className="form-group">
                            <label>Email</label>
                            <input className="form-control"
                                name="email"
                                value={ this.state.email }
                                onChange={ this.updateFormValue } />
                            <ValidationMessage field="email" />
                        </div>

                        <div className="form-group">
                            <label>Confirm Email</label>
                            <input className="form-control"
                                name="emailConfirm"
                                value={ this.state.emailConfirm }
                                onChange={ this.updateFormValue } />
                            <ValidationMessage field="emailConfirm" />
                        </div>
                    </FormValidator>
                </div>
    }
}

Listing 15-25Applying Whole-Form Validation in the Editor.js File in the src Folder

如果用户试图提交不符合清单 15-23 中检查的条件的数据,则会向用户显示额外的验证消息,如图 15-11 所示。

img/473159_1_En_15_Fig11_HTML.jpg

图 15-11

执行整体表单验证

摘要

在本章中,我向您展示了如何创建受控组件,它们是表单元素,其内容通过状态属性管理,其编辑由事件处理程序处理。我向您展示了不同类型的表单元素,并演示了如何验证表单数据。受控表单组件只是 React 支持的一种类型,在下一章中,我将介绍 refs 特性,并解释如何使用非受控表单元素。

十六、使用引用和门户

在正常情况下,组件不直接与文档对象模型(DOM)中的元素交互。正常的交互是通过 props 和事件处理程序进行的,这使得在不知道组件所处理的内容的情况下组合应用和组件协同工作成为可能。

有些情况下,组件需要与 DOM 中的元素进行交互,React 为此提供了两个特性。 refs 特性 references 的简称——提供了对 HTML 元素的访问,这些元素是在被添加到 DOM 后由组件呈现的。门户特性提供了对应用内容之外的 HTML 元素的访问。

应该谨慎使用这些特性,因为它们破坏了应用中组件之间的隔离,这使得编写、测试和维护更加困难。这些特性导致了“兔子洞”,它们解决了一个问题,但是引入了另一个问题,这导致了另一个解决方案和另一个问题,等等。如果使用不当,这些特性会产生重复 React 提供的核心功能的组件,这很少是有益的结果。表 16-1 将参考和门户放在上下文中。

表 16-1

将引用和门户放在上下文中

|

问题

|

回答

|
| --- | --- |
| 它们是什么? | Refs 是对 DOM 中由组件呈现的元素的引用。门户允许在应用内容之外呈现内容。 |
| 它们为什么有用? | 如果不直接访问 DOM,HTML 元素的一些特性是不容易管理的,比如聚焦一个元素。这些特性对于与其他框架和库的集成也很有用。 |
| 它们是如何使用的? | 引用是使用特殊的ref属性创建的,并且可以使用React.createRef方法或使用回调函数来创建。门户是使用ReactDOM.createPortal方法创建的。 |
| 有什么陷阱或限制吗? | 这些特性很容易被滥用,以至于它们破坏了组件隔离,并被用来复制 React 提供的特性。 |
| 有其他选择吗? | 参考和门户是许多项目中不需要的高级功能。 |

表 16-2 总结了本章内容。

表 16-2

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 访问为组件创建的 HTML 元素对象 | 使用引用 | 1–9, 11, 12, 18, 19 |
| 在不使用状态数据和事件处理程序的情况下使用表单元素 | 使用不受控制的表单组件 | 10, 13–15 |
| 防止更新过程中的数据丢失 | 使用getSnapshotBeforeUpdate方法 | 16, 17 |
| 访问子组件的内容 | 使用参考属性或参考转发 | 20–23 |
| 将内容投影到特定的 DOM 元素中 | 使用门户网站 | 24–26 |

为本章做准备

为了创建本章的示例项目,打开一个新的命令提示符,导航到一个方便的位置,并运行清单 16-1 中所示的命令。

小费

你可以从 https://github.com/Apress/pro-react-16 下载本章以及本书其他章节的示例项目。

npx create-react-app refs

Listing 16-1Creating the Example Project

运行清单 16-2 中所示的命令,导航到refs文件夹添加引导包。

cd refs
npm install bootstrap@4.1.2

Listing 16-2Adding the Bootstrap CSS Framework

在这一章中,我创建了一个依赖 jQuery 的例子。在refs文件夹中运行清单 16-3 中所示的命令,将 jQuery 包添加到项目中。

npm install jquery@3.3.1

Listing 16-3Installing jQuery

为了在应用中包含引导 CSS 样式表,将清单 16-4 中所示的语句添加到index.js文件中,该文件可以在src文件夹中找到。

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

import 'bootstrap/dist/css/bootstrap.css';

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

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

Listing 16-4Including Bootstrap in the index.js File in the src Folder

src文件夹中添加一个名为Editor.js的文件,并添加清单 16-5 中所示的代码。

import React, { Component } from "react";

export class Editor extends Component {

    constructor(props) {
        super(props);
        this.state = {
            name: "",
            category: "",
            price: ""
        }
    }

    handleChange = (event) => {
        event.persist();
        this.setState(state => state[event.target.name] = event.target.value);
    }

    handleAdd = () => {
        this.props.callback(this.state);
        this.setState({ name: "", category:"", price:""});
    }

    render() {
        return <React.Fragment>
            <div className="form-group p-2">
                <label>Name</label>
                <input className="form-control" name="name"
                    value={ this.state.name } onChange={ this.handleChange }
                    autoFocus={ true } />
            </div>
            <div className="form-group p-2">
                <label>Category</label>
                <input className="form-control" name="category"
                    value={ this.state.category } onChange={ this.handleChange }  />
            </div>
            <div className="form-group p-2">
                <label>Price</label>
                <input className="form-control" name="price"
                    value={ this.state.price } onChange={ this.handleChange }  />
            </div>
            <div className="text-center">
                <button className="btn btn-primary" onClick={ this.handleAdd }>
                    Add
                </button>
            </div>
        </React.Fragment>
    }

}

Listing 16-5The Contents of the Editor.js File in the src Folder

Editor组件呈现一系列input元素,这些元素的值是使用状态数据属性设置的,它们的更改事件由handleChange方法处理。有一个button元素,它的 click 事件调用handleAdd方法,该方法使用状态数据调用一个函数 prop,然后该函数被重置。

接下来,将名为ProductTable.js的文件添加到src文件夹中,并添加清单 16-6 中所示的代码。

import React, { Component } from "react";

export class ProductTable extends Component {

    render() {
        return <table className="table table-sm table-striped">
            <thead><tr><th>Name</th><th>Category</th><th>Price</th></tr></thead>
            <tbody>
                {
                    this.props.products.map(p =>
                        <tr key={ p.name }>
                            <td>{ p.name }</td>
                            <td>{ p.category }</td>
                            <td>${ Number(p.price).toFixed(2) }</td>
                        </tr>
                    )
                }
            </tbody>
        </table>
    }
}

Listing 16-6The Contents of the ProductTable.js File in the src Folder

ProductTable组件呈现一个表格,该表格包含在products属性中接收的每个对象的一行。接下来,用清单 16-7 中所示的代码替换App.js文件的内容。

import React, { Component } from "react";
import { Editor } from "./Editor"
import { ProductTable } from "./ProductTable";

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            products: []
        }
    }

    addProduct = (product) => {
        if (this.state.products.indexOf(product.name) === -1) {
            this.setState({ products: [...this.state.products, product ]});
        }
    }

    render() {
        return <div>
            <Editor callback={ this.addProduct } />
            <h6 className="bg-secondary text-white m-2 p-2">Products</h6>
                <div className="m-2">
                    {
                        this.state.products.length === 0
                            ? <div className="text-center">No Products</div>
                                : <ProductTable products={ this.state.products } />
                    }
                </div>
        </div>
    }
}

Listing 16-7Replacing the Contents of the App.js File in the src Folder

使用命令提示符,运行refs文件夹中清单 16-8 所示的命令来启动开发工具。

npm start

Listing 16-8Starting the Development Tools

一旦项目的初始准备工作完成,一个新的浏览器窗口将打开并显示 URL http://localhost:3000,它显示如图 16-1 所示的内容。填写表单并单击 Add 按钮,您将看到表格中显示了一个新条目。

img/473159_1_En_16_Fig1_HTML.jpg

图 16-1

运行示例应用

创建参考

当组件需要访问 DOM 以使用特定 HTML 元素的特性时,可以使用 Refs。有一些 HTML 特性是不能通过使用 props 来实现的,其中之一就是要求一个元素获得焦点。第一次呈现内容时,autoFocus属性可用于聚焦一个元素,但一旦用户单击它,焦点将切换到button元素,这意味着用户不能开始键入以创建另一个项目,直到他们重新聚焦,无论是通过单击input元素还是通过使用 Tab 键。

当点击添加按钮触发的事件被处理时,可以使用 ref 来访问 DOM 并调用input元素上的focus方法,如清单 16-9 所示。

不要急着用引用

能够访问 DOM 是 web 开发人员的自然期望,refs 看起来是一个使 React 开发更容易的特性,特别是如果您是从 Angular 这样的框架开始使用 React。

很容易被 refs 冲昏头脑,最终得到一个组件,它复制了应该由 React 执行的内容处理特性。过度使用引用的组件很难管理,可能会依赖于特定的浏览器功能,并且很难在不同的平台上运行。

仅在万不得已的情况下才使用 refs,并且始终考虑是否可以使用 state 和 props 功能获得相同的结果。

import React, { Component } from "react";

export class Editor extends Component {

    constructor(props) {
        super(props);
        this.state = {
            name: "",
            category: "",
            price: ""
        }
        this.nameRef = React.createRef();
    }

    handleChange = (event) => {
        event.persist();
        this.setState(state => state[event.target.name] = event.target.value);
    }

    handleAdd = () => {
        this.props.callback(this.state);
        this.setState({ name: "", category:"", price:""},
            () => this.nameRef.current.focus());
    }

    render() {
        return <React.Fragment>
            <div className="form-group p-2">
                <label>Name</label>
                <input className="form-control" name="name"
                    value={ this.state.name } onChange={ this.handleChange }
                    autoFocus={ true } ref={ this.nameRef } />
            </div>
            <div className="form-group p-2">
                <label>Category</label>
                <input className="form-control" name="category"
                    value={ this.state.category } onChange={ this.handleChange }  />
            </div>
            <div className="form-group p-2">
                <label>Price</label>
                <input className="form-control" name="price"
                    value={ this.state.price } onChange={ this.handleChange }  />
            </div>
            <div className="text-center">
                <button className="btn btn-primary" onClick={ this.handleAdd }>
                    Add
                </button>
            </div>
        </React.Fragment>
    }
}

Listing 16-9Using a Ref in the Editor.js File in the src Folder

引用是使用React.createRef方法创建的,该方法在构造函数中被调用,因此结果可以在整个组件中使用。ref 使用特殊的ref属性与一个元素相关联,通过一个表达式为元素选择 ref。

...
<input className="form-control" name="name"
    value={ this.state.name } onChange={ this.handleChange }
    autoFocus={ true } ref={ this.nameRef } />
...

createRef方法返回的 ref 对象只定义了一个名为current的属性,该属性返回代表 DOM 中元素的HTMLElement对象。状态数据更新完成后,我使用handleAdd方法中的current属性来调用focus方法,如下所示:

...
this.setState({ name: "", category:"", price:""},
    () => this.nameRef.current.focus());
...

结果是,当添加按钮触发的更新完成时,name input元素将重新获得焦点,允许用户开始输入下一个新产品,而不必手动选择该元素,如图 16-2 所示。

img/473159_1_En_16_Fig2_HTML.jpg

图 16-2

使用引用

使用参照创建不受控制的形状构件

示例应用使用我在第十五章中介绍的受控表单组件技术,其中 React 负责每个表单元素的内容,使用状态数据属性存储其值,使用事件处理程序响应更改。

表单元素已经具有存储值和响应更改的能力,但是这些功能不被受控表单组件使用。另一种技术是创建一个不受控制的表单组件,其中 ref 用于访问表单元素,浏览器负责管理元素的值并响应更改。在清单 16-10 中,我删除了用于管理由Editor组件呈现的input元素的状态数据,并使用 refs 来创建不受控制的表单组件。

import React, { Component } from "react";

export class Editor extends Component {

    constructor(props) {
        super(props);
        // this.state = {
        //     name: "",
        //     category: "",
        //     price: ""
        // }
        this.nameRef = React.createRef();
        this.categoryRef = React.createRef();
        this.priceRef = React.createRef();
    }

    // handleChange = (event) => {
    //     event.persist();
    //     this.setState(state => state[event.target.name] = event.target.value);
    // }

    handleAdd = () => {
        this.props.callback({
            name: this.nameRef.current.value,
            category: this.categoryRef.current.value,
            price: this.priceRef.current.value
        });
        this.nameRef.current.value = "";
        this.categoryRef.current.value = "";
        this.priceRef.current.value = "";
        this.nameRef.current.focus();
    }

    render() {
        return <React.Fragment>
            <div className="form-group p-2">
                <label>Name</label>
                <input className="form-control" name="name"
                    autoFocus={ true } ref={ this.nameRef } />
            </div>
            <div className="form-group p-2">
                <label>Category</label>
                <input className="form-control" name="category"
                    ref={ this.categoryRef } />
            </div>
            <div className="form-group p-2">
                <label>Price</label>
                <input className="form-control" name="price" ref={ this.priceRef } />
            </div>
            <div className="text-center">
                <button className="btn btn-primary" onClick={ this.handleAdd }>
                    Add
                </button>
            </div>
        </React.Fragment>
    }
}

Listing 16-10Creating Uncontrolled Form Components in the Editor.js File in the src Folder

在用户单击 Add 按钮之前,input元素值是不需要的。在单击按钮时调用的handleAdd方法中,每个input元素的引用用于读取value属性。用户看到的结果与前面的示例一样,但是在幕后,React 不再负责管理元素值或响应变更事件。

为非受控元素设置初始值

React 不对不受控制的元素负责,但它仍然可以提供一个初始值,然后由浏览器管理。要设置该值,请使用defaultValuedefaultChecked属性,但是请记住,您指定的值将仅在元素首次呈现时使用,并且不会在元素发生变化时更新元素。

使用回调函数创建引用

前面的例子展示了如何在表单元素中使用 refs,但是结果与我在本章开始时使用的受控表单组件没有太大的不同。有一种替代技术可以用来创建引用,并且可以产生更简洁的组件,如清单 16-11 所示,称为回调引用

import React, { Component } from "react";

export class Editor extends Component {

    constructor(props) {
        super(props);
        this.formElements = {
            name: { },
            category: { },
            price: { }
        }
    }

    setElement = (element) => {
        if (element !== null) {
            this.formElements[element.name].element = element;
        }
    }

    handleAdd = () => {
        let data = {};
        Object.values(this.formElements)
            .forEach(v => {
                data[v.element.name] = v.element.value;
                v.element.value = "";
            });
        this.props.callback(data);
        this.formElements.name.element.focus();
    }

    render() {
        return <React.Fragment>
            <div className="form-group p-2">
                <label>Name</label>
                <input className="form-control" name="name"
                    autoFocus={ true } ref={ this.setElement } />
            </div>
            <div className="form-group p-2">
                <label>Category</label>
                <input className="form-control" name="category"
                    ref={ this.setElement } />
            </div>
            <div className="form-group p-2">
                <label>Price</label>
                <input className="form-control" name="price"
                    ref={ this.setElement } />
            </div>
            <div className="text-center">
                <button className="btn btn-primary" onClick={ this.handleAdd }>
                    Add
                </button>
            </div>
        </React.Fragment>
    }
}

Listing 16-11Using Callback Refs in the Editor.js File in the src Folder

input元素的ref属性的值被设置为一个方法,该方法在呈现内容时被调用。指定的方法不是处理一个ref对象,而是直接接收HTMLElement对象,而不是一个具有current属性的引用对象。在清单中,setElement方法接收元素,使用name值将这些元素添加到formElements对象中,这样我就可以区分这些元素。

如果元素被卸载,您为回调 ref 提供的函数也将被调用,参数为null。对于这个例子,如果元素被删除,我不需要做任何整理,所以我只需要检查setElement方法中的空值。

...
setElement = (element) => {
    if (element !== null) {
        this.formElements[element.name].element = element;
    }
}
...

一旦有了 refs 的函数,就可以很容易地以编程方式生成表单,如清单 16-12 所示,因为 refs 不必单独创建和分配给元素。

import React, { Component } from "react";

export class Editor extends Component {

    constructor(props) {
        super(props);
        this.formElements = {
            name: { label: "Name", name: "name" },
            category: { label: "Category", name: "category" },
            price: { label: "Price", name: "price" }
        }
    }

    setElement = (element) => {
        if (element !== null) {
            this.formElements[element.name].element = element;
        }
    }

    handleAdd = () => {
        let data = {};
        Object.values(this.formElements)
            .forEach(v => {
                data[v.element.name] = v.element.value;
                v.element.value = "";
            });
        this.props.callback(data);
        this.formElements.name.element.focus();
    }

    render() {
        return <React.Fragment>
             {
                Object.values(this.formElements).map(elem =>
                    <div className="form-group p-2" key={ elem.name }>
                        <label>{ elem.label }</label>
                        <input className="form-control"
                            name={ elem.name }
                            autoFocus={ elem.name === "name" }
                            ref={ this.setElement }  />
                    </div>)
            }
            <div className="text-center">
                <button className="btn btn-primary" onClick={ this.handleAdd }>
                    Add
                </button>
            </div>
        </React.Fragment>
    }
}

Listing 16-12Generating a Form Programmatically in the Editor.js File in the src Folder

使用formElements对象的属性生成input元素,其中每个属性被分配一个具有labelname属性的对象,这些属性在render方法中用于配置元素。

定义和管理表单所需的代码更简洁,但效果是一样的,填写表单并点击添加按钮会显示一个新的对象,如图 16-3 所示。

img/473159_1_En_16_Fig3_HTML.jpg

图 16-3

以编程方式创建表单元素和引用

验证不受控制的表单组件

表单元素通过 HTML 约束验证 API 具有内置的验证支持,可以使用 refs 访问该 API。验证 API 使用如下对象描述元素的验证状态:

...
{
    valueMissing: true, tooShort: false, rangeUnderflow: false
}
...

当我指定元素必须有值但为空时,valueMissing属性将是true。当元素值中的字符少于验证规则指定的字符数时,tooShort属性将为true。对于小于指定最小值的数值,rangeUnderflow属性将是true

为了处理这种类型的验证对象,我在src文件夹中添加了一个名为ValidationMessages.js的文件,并用它来定义清单 16-13 中所示的函数。

export function GetValidationMessages(elem) {
    let errors = [];
    if (!elem.checkValidity()) {
        if (elem.validity.valueMissing) {
            errors.push("Value required");
        }
        if (elem.validity.tooShort) {
            errors.push("Value is too short");
        }
        if (elem.validity.rangeUnderflow) {
            errors.push("Value is too small");
        }
    }
    return errors;
}

Listing 16-13The Contents of the ValidationMessages.js File in the src Folder

GetValidationMessages函数接收一个 HTML 元素对象,并通过调用元素的checkValidity方法请求浏览器进行数据验证。如果元素的值是valid,则checkValidity方法返回true,否则返回false。如果元素的值不是valid,则检查元素的validity属性,以获得具有true值的valueMissingtooShortrangeUnderflow属性,并用于创建可以显示给用户的错误数组。

小费

HTML 验证特性包括比我在本章中使用的更广泛的验证检查和有效性属性。参见 https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/HTML5/Constraint_validation 了解可用功能的详细描述。

我在src文件夹中添加了一个名为ValidationDisplay.js的文件,并使用它来定义一个组件,该组件将显示单个元素的验证消息,如清单 16-14 所示。

import React, { Component } from "react";

export class ValidationDisplay extends Component {

    render() {
        return this.props.errors
            ? this.props.errors.map(err =>
                <div className="small bg-danger text-white mt-1 p-1"
                        key={ err } >
                    { err }
                </div>)
            : null
    }
}

Listing 16-14The Contents of the ValidationDisplay.js File in the src Folder

该组件接收一个应该显示的错误消息数组,如果没有错误消息要显示,则返回null表示没有内容。在清单 16-15 中,我已经更新了Editor组件,以便在使用表单数据之前将验证属性应用于表单元素并执行验证检查。

import React, { Component } from "react";

import { ValidationDisplay } from "./ValidationDisplay";

import { GetValidationMessages } from "./ValidationMessages";

export class Editor extends Component {

    constructor(props) {
        super(props);
        this.formElements = {
            name: { label: "Name", name: "name",
                validation: { required: true, minLength: 3 }},
            category: { label: "Category", name:"category",
                validation: { required: true, minLength: 5 }},
            price: { label: "Price", name: "price",
                validation: { type: "number", required: true, min: 5 }}
        }
        this.state = {
            errors: {}
        }
    }

    setElement = (element) => {
        if (element !== null) {
            this.formElements[element.name].element = element;
        }
    }

    handleAdd = () => {
        if (this.validateFormElements()) {
            let data = {};
            Object.values(this.formElements)
                .forEach(v => {
                    data[v.element.name] = v.element.value;
                    v.element.value = "";
                });
            this.props.callback(data);
            this.formElements.name.element.focus();
        }
    }

    validateFormElement = (name) => {
        let errors = GetValidationMessages(this.formElements[name].element);
        this.setState(state => state.errors[name] = errors);
        return errors.length === 0;
    }

    validateFormElements = () => {
        let valid = true;
        Object.keys(this.formElements).forEach(name => {
            if (!this.validateFormElement(name)) {
                valid = false;
            }
        })
        return valid;
    }

    render() {
        return <React.Fragment>
             {
                Object.values(this.formElements).map(elem =>
                    <div className="form-group p-2" key={ elem.name }>
                        <label>{ elem.label }</label>
                        <input className="form-control"
                            name={ elem.name }
                            autoFocus={ elem.name === "name" }
                            ref={ this.setElement }
                            onChange={ () => this.validateFormElement(elem.name) }
                            { ...elem.validation} />
                        <ValidationDisplay
                             errors={ this.state.errors[elem.name] } />
                    </div>)
            }
            <div className="text-center">
                <button className="btn btn-primary" onClick={ this.handleAdd }>
                    Add
                </button>
            </div>
        </React.Fragment>
    }
}

Listing 16-15Applying Validation in the Editor.js File in the src Folder

我在描述元素的对象中包含了每个元素的验证属性,如下所示:

...
name: { label: "Name", name: "name", validation: { required: true, minLength: 3 }},
...

required属性表示需要一个值,而minLength属性指定该值应该至少包含三个字符。当通过render方法创建input元素时,这些属性被应用于它们。

...
<input className="form-control" name={ elem.name }
    autoFocus={ elem.name === "name" } ref={ this.setElement }
    onChange={ () => this.validateFormElement(elem.name) }
    { ...elem.validation} />
...

我不必担心我在第十五章中描述的原始/肮脏元素的问题,因为直到调用checkValidity方法才执行验证,这将发生在对change事件的响应中,我使用onChange事件属性和validateFormElement方法处理该事件,结果是只有当用户开始键入时,元素的验证才开始,如图 16-4 所示。

img/473159_1_En_16_Fig4_HTML.jpg

图 16-4

验证元素

当用户点击添加按钮时,handleAdd方法调用validateFormElements按钮,该按钮验证所有元素并确保表单数据在问题解决之前不会被使用,如图 16-5 所示。更改的效果会立即显示出来,因为每次编辑都会触发一个change事件,导致元素的值被再次验证。

img/473159_1_En_16_Fig5_HTML.jpg

图 16-5

验证所有元素

了解参考文献和生命周期

在 React 调用组件的render方法之前,Refs 不会被赋值。如果您使用的是createRef方法,那么在组件呈现其内容之前,current属性不会被赋值。类似地,回调引用不会调用它们的方法,直到组件已经呈现。

引用的分配在组件生命周期中似乎很晚,但是引用提供了对 DOM 元素的访问,这些元素直到呈现阶段才创建,这意味着 React 直到调用render方法才创建引用所引用的元素。与 ref 相关联的元素只能在componentDidMountcomponentDidUpdate生命周期方法中访问,因为它们发生在渲染已经完成并且 DOM 已经被填充或更新之后。

使用 refs 的一个后果是,当 React 替换它在 DOM 中呈现的元素时,组件不能依赖状态特性来保留它的上下文。React 试图最小化 DOM 变化,但是你不能依赖于在应用的整个生命周期中使用相同的元素。如第十三章所述,改变组件呈现的顶层元素会导致 React 替换其在 DOM 中的元素,如清单 16-16 所示。

import React, { Component } from "react";
import { ValidationDisplay } from "./ValidationDisplay";
import { GetValidationMessages } from "./ValidationMessages";

export class Editor extends Component {

    constructor(props) {
        super(props);
        this.formElements = {
            name: { label: "Name", name: "name",
                validation: { required: true, minLength: 3 }},
            category: { label: "Category", name:"category",
                validation: { required: true, minLength: 5 }},
            price: { label: "Price", name: "price",
                validation: { type: "number", required: true, min: 5 }}
        }
        this.state = {
            errors: {},
            wrapContent: false
        }
    }

    setElement = (element) => {
        if (element !== null) {
            this.formElements[element.name].element = element;
        }
    }

    handleAdd = () => {
        if (this.validateFormElements()) {
            let data = {};
            Object.values(this.formElements)
                .forEach(v => {
                    data[v.element.name] = v.element.value;
                    v.element.value = "";
                });
            this.props.callback(data);
            this.formElements.name.element.focus();
        }
    }

    validateFormElement = (name) => {
        let errors = GetValidationMessages(this.formElements[name].element);
        this.setState(state => state.errors[name] = errors);
        return errors.length === 0;
    }

    validateFormElements = () => {
        let valid = true;
        Object.keys(this.formElements).forEach(name => {
            if (!this.validateFormElement(name)) {
                valid = false;
            }
        })
        return valid;
    }

    toggleWrap = () => {
        this.setState(state => state.wrapContent = !state.wrapContent);
    }

    wrapContent(content) {
        return this.state.wrapContent
            ? <div className="bg-secondary p-2">
                    <div className="bg-light">{ content }</div>
              </div>
            : content;
    }

    render() {
        return this.wrapContent(
            <React.Fragment>
                    <div className="form-group text-center p-2">
                        <div className="form-check">
                            <input className="form-check-input"
                                type="checkbox"
                                checked={ this.state.wrapContent }
                                onChange={ this.toggleWrap } />
                            <label className="form-check-label">Wrap Content</label>
                        </div>
                    </div>
                {
                    Object.values(this.formElements).map(elem =>
                        <div className="form-group p-2" key={ elem.name }>
                            <label>{ elem.label }</label>
                            <input className="form-control"
                               name={ elem.name }
                               autoFocus={ elem.name === "name" }
                               ref={ this.setElement }
                               onChange={ () => this.validateFormElement(elem.name) }
                               { ...elem.validation} />
                            <ValidationDisplay
                                errors={ this.state.errors[elem.name] } />
                        </div>)
                }
                <div className="text-center">
                    <button className="btn btn-primary" onClick={ this.handleAdd }>
                        Add
                    </button>
                </div>
            </React.Fragment>)
    }
}

Listing 16-16Rendering a Different Top-Level Element in the Editor.js File in the src Folder

我添加了一个wrapContent state 属性,该属性是使用一个受控的复选框设置的,它包装了组件呈现的内容,并确保 React 用新的元素替换 DOM 中组件的现有元素。要查看效果,请在“名称”字段中输入文本,并选中“换行”复选框,如图 16-6 所示。

img/473159_1_En_16_Fig6_HTML.jpg

图 16-6

替换元素

您输入文本的input元素已被破坏,其内容已丢失。让用户更加困惑的是,检测到的任何验证错误都是组件状态数据的一部分,这意味着它们将显示在新的input元素旁边,即使它们描述的数据值不再可见。

为了帮助避免这个问题,有状态组件生命周期包括了getSnapshotBeforeUpdate方法,在更新阶段在rendercomponentDidUpdate方法之间调用,如图 16-7 所示。

img/473159_1_En_16_Fig7_HTML.jpg

图 16-7

快照流程

这个getSnapshotBeforeUpdate方法允许组件在调用render方法之前检查其当前内容并生成一个定制的快照对象。一旦更新完成,就会调用componentDidUpdate方法并提供快照对象,这样组件就可以修改现在在 DOM 中的元素。

警告

如果组件被卸载并重新创建,快照无助于保留上下文,当祖先的内容改变时会发生这种情况。在这些情况下,componentWillUnmount方法可用于访问引用,数据可通过上下文保存,如第十五章所述。

在清单 16-17 中,我使用了快照特性来捕捉更新前输入到 input 元素中的值,并在更新后恢复这些值。

import React, { Component } from "react";
import { ValidationDisplay } from "./ValidationDisplay";
import { GetValidationMessages } from "./ValidationMessages";

export class Editor extends Component {

    constructor(props) {
        super(props);
        this.formElements = {
            name: { label: "Name", name: "name",
                validation: { required: true, minLength: 3 }},
            category: { label: "Category", name:"category",
                validation: { required: true, minLength: 5 }},
            price: { label: "Price", name: "price",
                validation: { type: "number", required: true, min: 5 }}
        }
        this.state = {
            errors: {},
            wrapContent: false
        }
    }

    // ...other methods omitted for brevity...

    getSnapshotBeforeUpdate(props, state) {
        return Object.values(this.formElements).map(item =>
            {return { name: [item.name], value: item.element.value }})
    }

    componentDidUpdate(oldProps, oldState, snapshot) {
        snapshot.forEach(item => {
            let element = this.formElements[item.name].element
            if (element.value !== item.value) {
                element.value = item.value;
            }
        });
    }
}

Listing 16-17Taking a Snapshot in the Editor.js File in the src Folder

getSnapshotBeforeUpdate方法接收组件在更新被触发之前的属性和状态,并在更新后返回一个将被传递给componentDidUpdate方法的对象。在这个例子中,我不需要访问 props 或 state,因为我需要保存的数据包含在input元素中。React 并没有要求快照对象使用特定的格式,而且getSnapshotBeforeUpdate方法可以以任何有用的格式返回数据。在这个例子中,getSnapshotBeforeUpdate方法返回一个带有namevalue属性的对象数组。

React 完成更新后,它调用componentDidUpdate并提供快照作为参数,以及旧的属性和状态数据。在示例中,我处理对象数组并设置输入元素的值。结果是当复选框被切换时,输入到input元素中的数据被保留,如图 16-8 所示。

img/473159_1_En_16_Fig8_HTML.jpg

图 16-8

使用快照数据

每次更新都会调用getSnapshotBeforeUpdatecomponentDidUpdate方法,即使 React 没有替换 DOM 中的组件元素,这就是为什么我只在更新完成后元素的值与快照值不同时才应用快照值。

了解参考文献兔子洞

在前面的例子中使用 HTML5 约束验证 API 有一个意想不到的后果。只有当用户编辑文本字段的内容时,才执行验证,而不是当值以编程方式设置时。当我使用快照数据来设置新创建的input元素的值时,它将通过验证,即使该值之前没有通过验证。其效果是,用户可以通过在namecategory输入元素中输入错误的值,选中 wrap content 复选框,然后单击 Add 按钮来绕过验证。

这是一个可以解决的问题,但潜在的问题是使用 refs 直接访问 DOM 会出现一系列小冲突,每个冲突都可以通过添加几行代码来解决。但是这些修复通常会带来其他问题或妥协,需要额外的工作,结果是由复杂组件构成的脆弱的应用。

在某些项目中,直接使用 DOM 可能是必不可少的,避免复制已经存在于 DOM 中的数据和特性可能会有好处。但是只有在需要的时候才使用引用,因为它们可以制造和解决一样多的问题。

对其他库或框架使用引用

一些项目被转移到逐渐 React,因此组件必须与在另一个库或框架中编写的现有特性进行互操作。最常见的例子是 jQuery,在 React 和 Angular 这样的框架出现之前,它是 web 应用开发最流行的选择,现在仍然广泛用于简单的项目。例如,如果您有大量用 jQuery 编写的特性,那么您可以使用 refs 将它们应用于组件呈现的 HTML 元素。为了演示,我将使用 jQuery 将带有无效元素的表单元素分配给一个将应用 Bootstrap 样式的类。我在src文件夹中添加了一个名为jQueryColorizer.js的文件,并添加了清单 16-18 中所示的代码。

注意

这个例子需要添加到清单 16-3 中的项目的 jQuery 包。如果没有安装 jQuery,应该在继续之前安装。

var $ = require('jquery');

export function ColorInvalidElements(rootElement) {
    $(rootElement)
        .find("input:invalid").addClass("border-danger")
            .removeClass("border-success")
        .end()
        .find("input:valid").removeClass("border-danger")
             .addClass("border-success");
}

Listing 16-18The Contents of the jQueryColorizer.js in the src Folder

jQuery 语句定位分配给invalid伪类的所有input元素,并将它们添加到border-danger类,并将valid伪类中的任何input元素添加到border-success类。HTML 约束验证 API 使用validinvalid类来指示元素的验证状态。在清单 16-19 中,我添加了一个 ref 并使用它从App组件中调用 jQuery 函数。

混合框架

使用 refs 来合并其他框架很困难,而且容易出现问题。像任何引用的使用一样,应该谨慎地使用,并且只有在无法重写 React 中的功能时才使用。您可能觉得通过构建现有的代码可以节省时间,但是我的经验是,节省下来的时间将会花在尝试解决一系列小问题上,这些小问题是由于两个框架以不同的方式工作而产生的。

如果您不得不在 React 旁边使用另一个库或框架,那么您应该密切关注框架处理 DOM 的方式。您会发现 React 和其他框架希望完全控制它们创建的内容,当以框架开发人员没有预料到的方式添加、删除或更改元素时,可能会出现意想不到的结果。

import React, { Component } from "react";
import { Editor } from "./Editor"
import { ProductTable } from "./ProductTable";

import { ColorInvalidElements } from "./jQueryColorizer";

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            products: []
        }
        this.editorRef = React.createRef();
    }

    addProduct = (product) => {
        if (this.state.products.indexOf(product.name) === -1) {
            this.setState({ products: [...this.state.products, product ]});
        }
    }

    colorFields = () => {
        ColorInvalidElements(this.editorRef.current);
    }

    render() {
        return <div>
                <div className="text-center m-2">
                    <button className="btn btn-primary" onClick={ this.colorFields }>
                        jQuery
                    </button>
                </div>
                <div ref={ this.editorRef} >
                    <Editor callback={ this.addProduct } />
                </div>
                <h6 className="bg-secondary text-white m-2 p-2">Products</h6>
                    <div className="m-2">
                        {
                            this.state.products.length === 0
                                ? <div className="text-center">No Products</div>
                                : <ProductTable products={ this.state.products } />
                        }
                    </div>
            </div>
    }
}

Listing 16-19Invoking a Function in the App.js File in the src Folder

结果是,单击 jQuery 按钮会调用colorFields方法,该方法使用 ref 为 jQuery 函数提供它需要的 HTML 元素。jQuery 函数将边框应用于输入元素以指示它们的验证状态,如图 16-9 所示。(在本书的印刷版本中,边框颜色的差异不会很明显,这是一个最好在浏览器中运行以查看效果的示例。)

img/473159_1_En_16_Fig9_HTML.jpg

图 16-9

通过 ref 为 jQuery 提供元素

使用参照访问元件

在清单 16-19 中,我在Editor元素周围添加了一个div元素。当 React 将内容呈现到 DOM 中时,Editor元素不会成为 HTML 文档的一部分,添加div元素可以确保 jQuery 能够访问应用的内容。

引用确实与组件一起工作,如果我将ref属性应用于Editor元素,那么引用的current属性的值将被分配给在呈现App组件内容时创建的Editor对象。

对组件引用允许访问组件的状态数据和方法。使用 refs 来调用子组件的方法是很诱人的,因为它产生的开发体验更类似于传统上使用对象的方式。

通过引用操作一个组件是不好的做法。它产生了紧密耦合的组件,最终与 React 背道而驰。开始时,状态数据、属性和事件特性可能感觉不太自然,但是您会习惯于它们,结果是一个充分利用 React 的应用,并且更容易编写、测试和维护。

访问子组件的内容

React 对refs属性进行了特殊处理,这意味着当一个组件需要引用由它的一个后代呈现的 DOM 元素时,必须小心。最简单的方法是使用不同的名称传递 ref 对象或回调函数,在这种情况下,React 将像传递任何其他属性一样传递 ref。为了演示,我在src文件夹中添加了一个名为FormField.js的文件,并用它来定义清单 16-20 中所示的组件。

注意

访问子组件的内容应该小心,因为它会创建更难编写和测试的紧密耦合的组件。在可能的情况下,应该使用 props 在组件之间进行通信。

import React, { Component } from "react";

export class FormField extends Component {

    constructor(props) {
        super(props);
        this.state = {
            fieldValue: ""
        }
    }

    handleChange = (ev) => {
        this.setState({ fieldValue: ev.target.value});
    }

    render() {
        return <div className="form-group">
            <label>{ this.props.label }</label>
            <input className="form-control" value={ this.state.fieldValue }
                onChange={ this.handleChange } ref={ this.props.fieldRef } />
        </div>
    }
}

Listing 16-20The Contents of the FormField.js File in the src Folder

该组件呈现一个受控的input元素,并使用一个名为fieldRef的属性将从父元素接收到的ref与该元素相关联。在清单 16-21 中,我已经替换了由App组件呈现的内容,以使用FormField组件并为其提供一个引用。

import React, { Component } from "react";
import { FormField } from "./FormField";

export default class App extends Component {

    constructor(props) {
        super(props);
        this.fieldRef = React.createRef();
    }

    handleClick = () => {
        this.fieldRef.current.focus();
    }

    render() {
        return <div className="m-2">
                <FormField label="Name" fieldRef={ this.fieldRef } />
                <div className="text-center m-2">
                    <button className="btn btn-primary"
                            onClick={ this.handleClick }>
                        Focus
                    </button>
                </div>
            </div>
    }
}

Listing 16-21Replacing the Contents of the App.js File in the src Folder

App组件创建一个引用,并使用fieldRef属性将其传递给FormField组件,然后使用ref将其应用于input元素。结果是点击由App组件呈现的焦点按钮,将聚焦由其子组件呈现的输入元素,如图 16-10 所示。

img/473159_1_En_16_Fig10_HTML.jpg

图 16-10

访问孩子的内容

使用引用转发

React 提供了一种将引用传递给子对象的替代方法,称为引用转发,它允许使用ref来代替常规属性。在清单 16-22 中,我为FormField组件使用了引用转发。

import React, { Component } from "react";

export const ForwardFormField = React.forwardRef((props, ref) =>

     <FormField { ...props } fieldRef={ ref } />

)

export class FormField extends Component {

    constructor(props) {
        super(props);
        this.state = {
            fieldValue: ""
        }
    }

    handleChange = (ev) => {
        this.setState({ fieldValue: ev.target.value});
    }

    render() {
        return <div className="form-group m-2">
            <label>{ this.props.label }</label>
            <input className="form-control" value={ this.state.fieldValue }
                onChange={ this.handleChange } ref={ this.props.fieldRef } />
        </div>
    }
}

Listing 16-22Using Ref Forwarding in the FormField.js File in the src Folder

React.forwardRef方法传递一个接收 props 和ref值并呈现内容的函数。在这种情况下,我接收到了ref值,并将其转发给了fieldRef prop,这是FormField组件期望接收的 prop 名称。我将来自forwardRef方法的结果导出为ForwardFormField,我已经在App组件中使用了它,如清单 16-23 所示。

import React, { Component } from "react";

import { ForwardFormField } from "./FormField";

export default class App extends Component {

    constructor(props) {
        super(props);
        this.fieldRef = React.createRef();
    }

    handleClick = () => {
        this.fieldRef.current.focus();
    }

    render() {
        return <div>
                <ForwardFormField label="Name" ref={ this.fieldRef } />
                <div className="text-center m-2">
                    <button className="btn btn-primary"
                            onClick={ this.handleClick }>
                        Focus
                    </button>
                </div>
            </div>
    }
}

Listing 16-23Using Ref Forwarding in the App.js File in the src Folder

这个例子产生了与图 16-10 所示相同的效果,优点是App组件不需要任何关于ref如何在子组件中处理的特殊知识。

使用门户网站

门户允许组件将其内容呈现到特定的 DOM 元素中,而不是作为其父内容的一部分呈现。该特性允许组件脱离普通的 React 组件模型,但是要求在应用之外创建和管理目标元素,这意味着您不能使用门户将内容呈现到不同的组件中。因此,这个特性在有限的情况下很有用,比如为用户创建对话框或模型警告,或者将 React 集成到另一个框架或库创建的内容中。在清单 16-24 中,我在index.html文件中添加了新的 HTML 元素,这样在示例应用呈现的内容之外就有了一个 DOM 元素,我可以将它作为门户的目标。

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  <meta name="theme-color" content="#000000">
  <link rel="manifest" href="%PUBLIC_URL%/manifest.json">
  <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
  <title>React App</title>
</head>

<body>
  <noscript>
    You need to enable JavaScript to run this app.
  </noscript>

  <div class="container">
    <div class="row">
      <div class="col">
        <div id="root"></div>
      </div>
      <div class="col">
        <div id="portal" class="m-2">
          <h6 class="bg-info text-white text-center p-2">
            This is the portal target
          </h6>
        </div>
      </div>
    </div>
  </div>
</body>
</html>

Listing 16-24Adding Elements in the index.html File in the public Folder

新元素被分配给引导 CSS 网格类,以便门户目标元素显示在应用呈现的内容旁边,如图 16-11 所示。

img/473159_1_En_16_Fig11_HTML.jpg

图 16-11

向 HTML 文档中添加元素

我在src文件夹中添加了一个名为PortalWrapper.js的文件,并使用它来定义清单 16-25 中所示的组件,该组件在 DOM 中定位目标元素并使用它来创建门户。

import React, { Component } from "react";
import ReactDOM from "react-dom";

export class PortalWrapper extends Component {

    constructor(props) {
        super(props);
        this.portalElement = document.getElementById("portal");
    }

    render() {
        return ReactDOM.createPortal(
            <div className="border p-3">{ this.props.children }</div>
        , this.portalElement);
    }
}

Listing 16-25The Contents of the PortalWrapper.js File in the src Folder

使用props.children属性创建容器来定义PortalWrapper组件,但是使用ReactDOM.createPortal方法返回其内容,该方法的参数是要呈现的内容和 DOM 目标元素。在这个例子中,我使用 DOM API 的getElementById方法来定位添加到清单 16-24 中的 HTML 文件的目标元素。在清单 16-26 中,我使用了App组件中的门户。

对门户使用引用

不能使用门户通过引用将内容呈现给元素。在呈现过程中使用门户,直到呈现完成时才给 ref 分配元素,这意味着在生命周期中不能通过 ref 为ReactDOM.createPortal方法访问元素。如果你需要在应用的不同部分的组件之间进行协调,或者使用第三部分中描述的一个包,使用上下文,如第十四章所述。

import React, { Component } from "react";
import { ForwardFormField } from "./FormField";
import { PortalWrapper } from "./PortalWrapper";

export default class App extends Component {

    constructor(props) {
        super(props);
        this.fieldRef = React.createRef();
        this.portalFieldRef = React.createRef();
    }

    focusLocal = () => {
        this.fieldRef.current.focus();
    }

    focusPortal = () => {
        this.portalFieldRef.current.focus();
    }

    render() {
        return <div>
                <PortalWrapper>
                    <ForwardFormField label="Name" ref={ this.portalFieldRef } />
                </PortalWrapper>

                <ForwardFormField label="Name" ref={ this.fieldRef } />
                <div className="text-center m-2">
                    <button className="btn btn-primary m-1"
                            onClick={ this.focusLocal }>
                        Focus Local
                    </button>
                    <button className="btn btn-primary m-1"
                            onClick={ this.focusPortal }>
                        Focus Portal
                    </button>
                </div>
            </div>
    }
}

Listing 16-26Using a Portal in the App.js File in the src Folder

PortalWrapper元素用于应用新组件作为ForwardFormField的容器。门户显示的内容被视为是App组件内容的一部分,这样即使门户的内容是在应用之外呈现的,事件也会像平常一样冒泡,并且可以分配引用。App组件不知道一个门户正在被使用,点击聚焦本地和聚焦门户按钮使用相同的引用技术聚焦每个ForwardFormField组件呈现的input元素,如图 16-12 所示。

img/473159_1_En_16_Fig12_HTML.jpg

图 16-12

使用门户网站

摘要

在本章中,我描述了直接使用 DOM 的 React 特性。我解释了 refs 如何提供对组件呈现的内容的访问,以及这如何使不受控制的表单元素成为可能。我还演示了一个门户,它允许在应用的组件层次结构之外呈现内容。这些特性是非常宝贵的,但应该谨慎使用,因为它们破坏了正常的 React 开发模型,并导致紧密耦合的组件。在下一章,我将向您展示如何在 React 组件上执行单元测试。

十七、单元测试

在本章中,我将向您展示如何测试 React 组件。我介绍了一个使测试变得更容易的包,并演示了如何用它来单独测试组件并测试它们与其子组件的交互。表 17-1 将单元测试放在上下文中。

表 17-1

将单元测试放在上下文中

|

问题

|

回答

|
| --- | --- |
| 这是什么? | React 组件需要特殊的测试支持,以便可以隔离和检查它们与应用其他部分的交互。 |
| 为什么有用? | 独立的单元测试能够评估组件提供的基本逻辑,而不受与应用其余部分的交互的影响。 |
| 如何使用? | 用create-react-app创建的项目配置了基本的测试工具,这些工具补充了简化组件工作过程的包。 |
| 有什么陷阱或限制吗? | 有效的单元测试可能是困难的,并且可能需要花费时间和精力来达到单元测试容易编写和运行的程度,并且您确信您已经隔离了应用的正确部分来进行测试。 |
| 还有其他选择吗? | 单元测试不是一项要求,也不是在所有项目中都采用。 |

决定是否进行单元测试

单元测试是一个有争议的话题。本章假设您确实想进行单元测试,并向您展示如何设置工具并将它们应用到 React 应用中。这不是对单元测试的介绍,我也没有努力说服持怀疑态度的读者单元测试是值得的。如果想了解单元测试,这里有一篇好文章: https://en.wikipedia.org/wiki/Unit_testing

我喜欢单元测试,我也在自己的项目中使用它——但并不是所有的项目,也不像你所期望的那样始终如一。我倾向于专注于为我知道很难编写的特性和功能编写单元测试,这些特性和功能很可能是部署中的错误来源。在这些情况下,单元测试有助于我思考如何最好地实现我需要的东西。我发现仅仅考虑我需要测试什么就有助于产生关于潜在问题的想法,这是在我开始处理实际的错误和缺陷之前。

也就是说,单元测试是一种工具,而不是宗教,只有你自己知道你需要多少测试。如果你不觉得单元测试有用,或者如果你有更适合你的不同的方法论,那么不要仅仅因为它是时髦的就觉得你需要单元测试。(然而,如果你没有更好的方法论,你根本没有在测试,那么你很可能是在让用户发现你的 bug,这很少是理想的。)

表 17-2 总结了本章内容。

表 17-2

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 对 React 组件执行单元测试 | 使用 Jest(或其他可用的测试框架)和 Enzyme 来创建测试 | 9–11 |
| 隔离组件进行测试 | 使用浅渲染进行测试 | Twelve |
| 测试组件及其后代 | 使用完全渲染进行测试 | Thirteen |
| 测试组件的行为 | 测试使用 Enzyme 特性来处理属性、状态、方法和事件 | 14–17 |

为本章做准备

对于这一章,我将使用一个新的项目。打开一个新的命令提示符,导航到一个方便的位置,运行清单 17-1 中所示的命令来创建一个名为testapp的项目。

小费

你可以从 https://github.com/Apress/pro-react-16 下载本章以及本书其他章节的示例项目。

npx create-react-app testapp

Listing 17-1Creating the Example Project

运行清单 17-2 中所示的命令,导航到testapp文件夹添加引导包。

cd testapp
npm install bootstrap@4.1.2

Listing 17-2Adding the Bootstrap CSS Framework

为了在应用中包含引导 CSS 样式表,将清单 17-3 中所示的语句添加到index.js文件中,该文件可以在testapp/src文件夹中找到。

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

import 'bootstrap/dist/css/bootstrap.css';

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

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

Listing 17-3Including Bootstrap in the index.js File in the src Folder

create-react-app工具创建包含基本测试工具的项目,但是有一些有用的附加工具使得测试更加容易。运行testapp文件夹中清单 17-4 中所示的命令,将测试包添加到项目中。

npm install --save-dev enzyme@3.8.0
npm install --save-dev enzyme-adapter-react-16@1.7.1

Listing 17-4Adding Packages to the Example Project

表 17-3 描述了已经添加到项目中的包。

表 17-3

单元测试包

|

名字

|

描述

|
| --- | --- |
| enzyme | Enzyme 是 Airbnb 创建的一个测试包,通过探索组件呈现的内容并检查其属性和状态,可以轻松测试组件。 |
| enzyme-adapter-react-16 | Enzyme 需要一个适用于所用 React 特定版本的适配器。这个包适用于本书中使用的 React 版本。 |

创建组件

我需要一些简单的组件来演示如何对 React 应用进行单元测试。我在src文件夹中添加了一个名为Result.js的文件,并用它来定义清单 17-5 中所示的组件。

import React from "react";

export const Result = (props) => {
    return <div className="bg-light text-dark border border-dark p-2 ">
        { props.result || 0 }
    </div>
}

Listing 17-5The Contents of the Result.js File in the src Folder

Result是一个简单的功能组件,显示通过其结果属性接收的计算结果。接下来,我在src文件夹中添加了一个名为ValueInput.js的文件,并用它来定义清单 17-6 中所示的组件。

import React, { Component } from "react";

export class ValueInput extends Component {

    constructor(props) {
        super(props);
        this.state = {
            fieldValue: 0
        }
    }

    handleChange = (ev) => {
        this.setState({ fieldValue: ev.target.value },
            () => this.props.changeCallback(this.props.id, this.state.fieldValue));
    }

    render() {
        return <div className="form-group p-2">
            <label>Value #{this.props.id}</label>
            <input className="form-control"
                value={ this.state.fieldValue}
                onChange={ this.handleChange } />
        </div>
    }
}

Listing 17-6The Contents of the ValueInput.js File in the src Folder

这是一个有状态的组件,它呈现输入元素,并在发生变化时调用回调函数。清单 17-7 展示了我对App组件所做的修改,删除了占位符内容,使用了新的组件。

import React, { Component } from "react";
import { ValueInput } from "./ValueInput";
import { Result } from "./Result";

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            title: this.props.title || "Simple Addition" ,
            fieldValues: [],
            total: 0
        }
    }

    updateFieldValue = (id, value) => {
        this.setState(state => {
            state.fieldValues[id] = Number(value);
            return state;
        });
    }

    updateTotal = () => {
        this.setState(state => ({
            total: state.fieldValues.reduce((total, val) => total += val, 0)
        }))
    }

    render() {
        return <div className="m-2">
                    <h5 className="bg-primary text-white text-center p-2">
                        { this.state.title }
                    </h5>
                    <Result result={ this.state.total } />
                    <ValueInput id="1" changeCallback={ this.updateFieldValue } />
                    <ValueInput id="2" changeCallback={ this.updateFieldValue } />
                    <ValueInput id="3" changeCallback={ this.updateFieldValue } />
                    <div className="text-center">
                    <button className="btn btn-primary" onClick={ this.updateTotal}>
                        Total
                    </button>
            </div>
        </div>
    }
}

Listing 17-7Completing the Example Application in the App.js File in the src Folder

App 创建三个ValueInput组件,并对它们进行配置,以便用户输入的值存储在fieldValues状态数组中。一个按钮被配置为点击事件调用updateTotal方法,该方法对来自ValueInput组件的值求和,并更新由Result组件显示的状态数据值。

运行示例应用

使用命令提示符导航到testapp文件夹并运行清单 17-8 中所示的命令来启动 React 开发者工具。

npm start

Listing 17-8Starting the Development Tools

一个新的浏览器窗口将会打开,您将会看到示例应用,如图 17-1 所示。在字段中输入数值,然后单击总计按钮显示结果。

img/473159_1_En_17_Fig1_HTML.jpg

图 17-1

运行示例应用

运行占位符单元测试

create-react-app创建的项目包含 Jest test runner,这是一个执行单元测试并报告结果的工具。作为项目设置过程的一部分,创建了一个名为App.test.js的文件,其中包含以下代码:

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

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

这是一个基本的单元测试,封装在it函数中。该函数的第一个参数是测试的描述。第二个参数是测试本身,它是一个执行一些工作的函数。在这种情况下,单元测试将App组件呈现为一个div元素,然后卸载它。打开一个新的命令提示符,导航到testapp文件夹,运行清单 17-9 中所示的命令来执行单元测试。(测试工具的设计使得您可以让它们与开发工具一起运行。)

npm run test

Listing 17-9Running a Unit Test

该命令定位项目中定义的所有测试并执行它们。目前只有一个测试,会产生以下结果:

...
PASS  src/App.test.js
  √ renders without crashing (24ms)

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

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

测试运行后,测试工具进入观察模式。当文件改变时,测试被定位和执行,结果被再次显示。为了查看单元测试失败时会发生什么,将清单 17-10 中所示的语句添加到App组件的render方法中。

...
render() {
    throw new Error("something went wrong");
    return <div className="m-2">
                <h5 className="bg-primary text-white text-center p-2">
                    { this.state.title }
                </h5>
                <Result result={ this.state.total } />
                <ValueInput id="1" changeCallback={ this.updateFieldValue } />
                <ValueInput id="2" changeCallback={ this.updateFieldValue } />
                <ValueInput id="3" changeCallback={ this.updateFieldValue } />
                <div className="text-center">
                <button className="btn btn-primary" onClick={ this.updateTotal}>
                    Total
                </button>
        </div>
    </div>
}
...

Listing 17-10Making a Test Fail in the App.js File in the src Folder

调用 render 方法时将会抛出一个错误,这是单元测试所期待的行为。当您保存更改时,单元测试将再次执行,但这一次它将失败,并向您提供所检测到的问题的详细信息。

...
renders without crashing

   something went wrong

     27 |
     28 |     render() {
   > 29 |         throw new Error("something went wrong");
        |               ^
     30 |         return <div className="m-2">
     31 |                     <h5 className="bg-primary text-white text-center p-2">
     32 |                         Simple Addition
...

组件抛出的错误会在单元测试中上升到it函数,并被视为测试失败。要将应用恢复到其工作状态,从App组件中注释掉throw语句,如清单 17-11 所示。

...
render() {
    //throw new Error("something went wrong");
    return <div className="m-2">
                <h5 className="bg-primary text-white text-center p-2">
                    { this.state.title }
                </h5>
                <Result result={ this.state.total } />
                <ValueInput id="1" changeCallback={ this.updateFieldValue } />
                <ValueInput id="2" changeCallback={ this.updateFieldValue } />
                <ValueInput id="3" changeCallback={ this.updateFieldValue } />
                <div className="text-center">
                <button className="btn btn-primary" onClick={ this.updateTotal}>
                    Total
                </button>
        </div>
    </div>
}
...

Listing 17-11Removing the throw Statement in the App.js File in the src Folder

当您保存更改时,测试将再次运行并通过这次测试。

使用浅层渲染测试组件

浅层渲染将组件与其子组件隔离开来,允许它自己进行测试。这是一种测试组件基本功能的有效技术,而不会受到与其内容交互的影响。为了使用浅层渲染测试 App 组件,我将名为appContent.test.js的文件添加到了src文件夹中,并添加了清单 17-12 中所示的代码。

小费

Jest 将在文件名以test.jsspec.js结尾的文件中或者在名为__tests__(在tests前后有两个下划线)的文件夹中的任何文件中找到测试。

import React from "react";
import Adapter from 'enzyme-adapter-react-16';
import Enzyme, { shallow } from "enzyme";
import App from "./App";
import { ValueInput } from "./ValueInput";

Enzyme.configure({ adapter: new Adapter() });

it("Renders three ValueInputs", () => {
    const wrapper = shallow(<App />);
    const valCount = wrapper.find(ValueInput).length;
    expect(valCount).toBe(3)
});

Listing 17-12The Contents of the appContent.test.js File in the src Folder

这是本章中第一个真正的单元测试,所以我将解释每个部分,并向您展示它们是如何组合在一起的。

第一条语句配置酶包并应用适配器,该适配器允许酶与 React 的正确版本一起工作。

...
Enzyme.configure({ adapter: new Adapter() });
...

Enzyme.configure方法传递一个配置对象,该对象的adapter属性被赋予适配器包的导入内容。如果你需要测试 React 的不同版本,你可以在 https://airbnb.io/enzyme 看到可用的适配器列表。

下一步是单元测试的定义。不需要导入it方法,因为它是由 Jest 测试包全局定义的。

...
it("Renders three ValueInputs", () => {
...

第一个论点应该是对测试目的的有意义的描述。在这种情况下,测试检查App是否呈现了三个ValueInput组件。

下一条语句设置组件,这是使用从enzyme包导入的浅层函数完成的。

...
const wrapper = shallow(<App />);
...

shallow函数接受组件元素。一个组件被实例化,并经历第十三章中描述的生命周期,其内容被呈现。但是,由于这是浅层呈现,子组件不用于呈现,将它们的元素留在来自App组件的输出中。这意味着在呈现内容时使用了App组件的属性和状态数据,但是没有处理子组件,结果如下:

...
<div className="m-2">
    <h5 className="bg-primary text-white text-center p-2">
        Simple Addition
    </h5>
    <Result result={0} />
    <ValueInput id="1" changeCallback={[Function]} />
    <ValueInput id="2" changeCallback={[Function]} />
    <ValueInput id="3" changeCallback={[Function]} />
    <div className="text-center">
        <button className="btn btn-primary" onClick={[Function]}>
            Total
        </button>
    </div>
</div>
...

输出显示在一个包装器对象中,可以对其进行检查以进行测试。Enzyme 包提供了一组方法,可以用来检查从 DOM 呈现的内容,这些方法以流行的 jQuery DOM 操纵包提供的 API 为模型。最有用的方法在表 17-4 中描述,全套特征在 https://airbnb.io/enzyme 中描述。

表 17-4

检测成分含量的有用的酶方法

|

名字

|

描述

|
| --- | --- |
| find(selector) | 该方法查找 CSS 选择器匹配的所有元素,它将匹配元素类型、属性和类。 |
| findWhere(predicate) | 该方法查找与指定谓词匹配的所有元素。 |
| first(selector) | 返回选择器匹配的第一个元素。如果省略选择器,那么将返回任何类型的第一个元素。 |
| children() | 创建包含当前元素的子元素的新选择。 |
| hasClass(class) | 如果元素是指定类的成员,此方法返回 true。 |
| text() | 此方法从元素中返回文本内容。 |
| html() | 该方法从组件返回深度呈现的内容,以便处理所有的后代组件。 |
| debug() | 此方法从组件返回浅层呈现的内容。 |

这些方法可用于浏览组件呈现的内容并检查内容。清单 17-12 中的测试使用find选择器来选择由App组件呈现的所有ValueInput元素,并使用结果的length属性来确定找到了多少元素。

...
const valCount = wrapper.find(ValueInput).length;
...

测试的最后一步是将结果与预期结果进行比较,这是使用 Jest 提供的全局expect函数来完成的。

...
expect(valCount).toBe(3)
...

测试的结果被传递给expect函数,然后对结果调用一个匹配器方法。Jest 支持大量的匹配,在 https://jestjs.io/docs/en/expect 描述,最有用的在表 17-5 中显示。

表 17-5

有用的期望匹配器

|

名字

|

描述

|
| --- | --- |
| toBe(value) | 此方法断言结果与指定的值相同(但不必是同一个对象)。 |
| toEqual(object) | 此方法断言结果是与指定值相同的对象。 |
| toMatch(regexp) | 此方法断言结果匹配指定的正则表达式。 |
| toBeDefined() | 这个方法断言结果已经被定义。 |
| toBeUndefined() | 此方法断言结果尚未定义。 |
| toBeNull() | 该方法断言结果为空。 |
| toBeTruthy() | 这个方法断言结果是真实的。 |
| toBeFalsy() | 这个方法断言结果是假的。 |
| toContain(substring) | 此方法断言结果包含指定的子字符串。 |
| toBeLessThan(value) | 此方法断言结果小于指定值。 |
| toBeGreaterThan(value) | 此方法断言结果大于指定值。 |

Jest 跟踪哪些匹配失败,并在项目中的所有测试都运行后报告结果。清单 17-12 中的匹配器检查由App呈现的内容中有三个ValueInput组件。

文件一保存,Jest 就运行清单 17-12 中的测试,产生以下结果:

...
PASS  src/App.test.js
 PASS  src/App.shallow.test.js

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

Watch Usage: Press w to show more.
...

项目中现在有两个测试,并且都在运行。您可以让测试自动运行,也可以使用按下 W 键时显示的选项按需运行一个或多个测试。

使用完全渲染测试组件

完全渲染处理所有派生组件。派生组件元素保留在呈现的内容中,这意味着App组件在完全呈现后将产生以下内容:

...
<App>
<div className="m-2">
  <h5 className="bg-primary text-white text-center p-2">
    Simple Addition
  </h5>
  <Result result={0}>
    <div className="bg-light text-dark border border-dark p-2 ">0</div>
  </Result>
  <ValueInput id="1" changeCallback={[Function]}>
    <div className="form-group p-2">
      <label>Value #1</label>
      <input className="form-control" value={0} onChange={[Function]} />
    </div>
  </ValueInput>
  <ValueInput id="2" changeCallback={[Function]}>
    <div className="form-group p-2">
      <label>Value #2</label>
      <input className="form-control" value={0} onChange={[Function]} />
    </div>
  </ValueInput>
  <ValueInput id="3" changeCallback={[Function]}>
    <div className="form-group p-2">
      <label>Value #3</label>
      <input className="form-control" value={0} onChange={[Function]} />
    </div>
  </ValueInput>
  <div className="text-center">
    <button className="btn btn-primary" onClick={[Function]}>Total</button>
  </div>
</div>
</App>
...

使用mount方法进行完全渲染,如清单 17-13 所示。

import React from "react";
import Adapter from 'enzyme-adapter-react-16';

import Enzyme, { shallow, mount } from "enzyme";

import App from "./App";
import { ValueInput } from "./ValueInput";

Enzyme.configure({ adapter: new Adapter() });

it("Renders three ValueInputs", () => {
    const wrapper = shallow(<App />);
    const valCount = wrapper.find(ValueInput).length;
    expect(valCount).toBe(3)
});

it("Fully renders three inputs", () => {

    const wrapper = mount(<App title="tester" />);
    const count = wrapper.find("input.form-control").length
    expect(count).toBe(3);

});

it("Shallow renders zero inputs", () => {

    const wrapper = shallow(<App />);
    const count = wrapper.find("input.form-control").length
    expect(count).toBe(0);

})

Listing 17-13Fully Rendering a Component in the appContent.test.js File in the src Folder

第一个新测试使用酶mount函数来完全渲染App及其后代。mount返回的包装器支持表 17-5 中描述的方法,全套特性在 https://airbnb.io/enzyme/docs/api/mount.html 中描述。我使用 find 方法来定位已经分配给form-control类的input元素,并使用expect来确保有三个这样的元素。第二个新测试定位相同的元素,但是使用浅层呈现,并检查内容中是否没有input元素。

保存对文件的更改后,将运行测试并产生以下结果:

...
PASS  src/App.test.js
PASS  src/appContent.test.js

Test Suites: 2 passed, 2 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        3.109s
Ran all test suites.

Watch Usage: Press w to show more.
...

用属性、状态、方法和事件进行测试

组件呈现的内容可以根据用户输入或应用状态的更新而改变。为了帮助测试组件的行为,Enzyme 提供了表 17-6 中描述的方法。

表 17-6

测试行为的酶方法

|

名字

|

描述

|
| --- | --- |
| instance() | 该方法返回组件对象,以便可以调用其方法。 |
| prop(key) | 此方法返回指定属性的值。 |
| props() | 这个方法返回组件的所有属性。 |
| setProps(props) | 此方法用于指定新的属性,这些属性在组件更新之前与组件的现有属性合并。 |
| state(key) | 此方法用于获取指定的状态值。如果没有指定值,则返回组件的所有状态数据。 |
| setState(state) | 此方法更改组件的状态数据,然后重新呈现组件。 |
| simulate(event, args) | 此方法将事件调度到组件。 |
| update() | 此方法强制组件重新呈现其内容。 |

最简单的行为测试是确保组件反映了它的属性。我在src文件夹中创建了一个名为appBehavior.test.js的文件,并用它来定义清单 17-14 中所示的测试。

import React from "react";
import Adapter from 'enzyme-adapter-react-16';
import Enzyme, { shallow } from "enzyme";
import App from "./App";

Enzyme.configure({ adapter: new Adapter() });

it("uses title prop", () => {

    const titleVal = "test title"
    const wrapper = shallow(<App title={ titleVal } />);

    const firstTitle = wrapper.find("h5").text();
    const stateValue = wrapper.state("title");

    expect(firstTitle).toBe(titleVal);
    expect(stateValue).toBe(titleVal);
});

Listing 17-14Testing a Prop in the appBehavior.test.js File in the src Folder

App组件被传递给shallow方法时,它被配置了一个title属性。该测试通过定位h5元素并获取其文本内容,以及读取title状态属性的值,来检查 prop 是否用于覆盖默认值。只有当h5元素和state属性的内容与title属性的值相同时,测试才通过。

测试方法的效果

instance方法用于获取组件对象,然后组件对象可用于调用其方法。在清单 17-15 中,我定义了一个调用updateFieldupdateTotal方法的测试,并检查对组件状态数据的影响。

import React from "react";
import Adapter from 'enzyme-adapter-react-16';
import Enzyme, { shallow } from "enzyme";
import App from "./App";

Enzyme.configure({ adapter: new Adapter() });

it("uses title prop", () => {

    const titleVal = "test title"
    const wrapper = shallow(<App title={ titleVal } />);

    const firstTitle = wrapper.find("h5").text();
    const stateValue = wrapper.state("title");

    expect(firstTitle).toBe(titleVal);
    expect(stateValue).toBe(titleVal);
});

it("updates state data", () => {

    const wrapper = shallow(<App />);
    const values = [10, 20, 30];

    values.forEach((val, index) =>
        wrapper.instance().updateFieldValue(index + 1, val));
    wrapper.instance().updateTotal();

    expect(wrapper.state("total"))
        .toBe(values.reduce((total, val) => total + val), 0);

});

Listing 17-15Invoking Methods in the appBehavior.test.js File in the src Folder

新的测试 shallow 呈现了一个App组件,然后在调用updateTotal方法之前用一个值数组调用updateFieldValue方法。state方法用于获取总state属性的值,该值与传递给updateFieldValue方法的值的总和进行比较。

测试事件的影响

simulate方法用于向组件的事件处理程序发送事件。这种类型的测试必须小心,因为很容易测试 React 调度事件的能力,而不是组件处理事件的能力。在大多数情况下,调用将在响应事件时执行的方法更有用。清单 17-16 定位由App组件呈现的button元素,并触发一个click事件,以确保它导致总数被计算。

import React from "react";
import Adapter from 'enzyme-adapter-react-16';
import Enzyme, { shallow } from "enzyme";
import App from "./App";

Enzyme.configure({ adapter: new Adapter() });

it("uses title prop", () => {

    const titleVal = "test title"
    const wrapper = shallow(<App title={ titleVal } />);

    const firstTitle = wrapper.find("h5").text();
    const stateValue = wrapper.state("title");

    expect(firstTitle).toBe(titleVal);
    expect(stateValue).toBe(titleVal);
});

it("updates state data", () => {
    const wrapper = shallow(<App />);
    const values = [10, 20, 30];

    values.forEach((val, index) =>
        wrapper.instance().updateFieldValue(index + 1, val));
    wrapper.instance().updateTotal();

    expect(wrapper.state("total"))
        .toBe(values.reduce((total, val) => total + val), 0);
})

it("updates total when button is clicked", () => {

    const wrapper = shallow(<App />);
    const button = wrapper.find("button").first();

    const values = [10, 20, 30];
    values.forEach((val, index) =>
        wrapper.instance().updateFieldValue(index + 1, val));

    button.simulate("click")

    expect(wrapper.state("total"))
        .toBe(values.reduce((total, val) => total + val), 0);

})

Listing 17-16Simulating an Event in the appBehavior.test.js File in the src Folder

新的测试模拟了click事件,该事件的处理程序调用组件的updateTotal方法。为了确保事件已经被处理,读取了total状态数据属性的值。

测试组件之间的交互

导航由组件呈现的内容的能力可以与表 17-6 中描述的方法相结合,以测试组件之间的交互,如清单 17-17 所示。

import React from "react";
import Adapter from 'enzyme-adapter-react-16';

import Enzyme, { shallow, mount } from "enzyme";

import App from "./App";

import { ValueInput } from "./ValueInput";

Enzyme.configure({ adapter: new Adapter() });

it("uses title prop", () => {

    const titleVal = "test title"
    const wrapper = shallow(<App title={ titleVal } />);

    const firstTitle = wrapper.find("h5").text();
    const stateValue = wrapper.state("title");

    expect(firstTitle).toBe(titleVal);
    expect(stateValue).toBe(titleVal);
});

it("updates state data", () => {
    const wrapper = shallow(<App />);
    const values = [10, 20, 30];

    values.forEach((val, index) =>
        wrapper.instance().updateFieldValue(index + 1, val));
    wrapper.instance().updateTotal();

    expect(wrapper.state("total"))
        .toBe(values.reduce((total, val) => total + val), 0);
})

it("updates total when button is clicked", () => {
    const wrapper = shallow(<App />);
    const button = wrapper.find("button").first();

    const values = [10, 20, 30];
    values.forEach((val, index) =>
        wrapper.instance().updateFieldValue(index + 1, val));

    button.simulate("click")

    expect(wrapper.state("total"))
        .toBe(values.reduce((total, val) => total + val), 0);
})

it("child function prop updates state", () => {

    const wrapper = mount(<App />);
    const valInput = wrapper.find(ValueInput).first();
    const inputElem = valInput.find("input").first();

    inputElem.simulate("change", { target: { value: "100"}});
    wrapper.instance().updateTotal();

    expect(valInput.state("fieldValue")).toBe("100");
    expect(wrapper.state("total")).toBe(100);

})

Listing 17-17Testing Component Interaction in the appBehavior.test.js File in the src Folder

新的测试定位由第一个ValueInput呈现的输入元素,并触发它的 change 事件,提供一个参数,该参数将为组件的处理程序提供它需要的值。instance方法用于调用App组件的updateTotal方法,state方法用于检查AppValueInput组件的状态数据是否已被正确更新。

摘要

在本章中,我向您展示了如何在 React 组件上执行单元测试。我向您展示了如何使用 Jest 运行测试,以及如何使用 Enzyme 包提供的浅渲染和完全渲染来执行这些测试。我解释了如何检查组件呈现的内容,如何调用其方法,如何探索其状态,以及如何管理其属性。总的来说,这些特性允许单独测试一个组件,也允许结合其子组件进行测试。在本书的下一部分,我将描述如何补充 React 的核心特性来创建完整的 web 应用。

十八、创建完整的应用

React 为向用户呈现 HTML 内容提供了一组优秀的特性,并依赖第三方包来提供开发完整的 web 应用所需的支持功能。可以和 React 一起使用的包数不胜数,在本书的这一部分,我介绍了那些使用最广泛,也最有可能被本书读者需要的包。这些软件包都是开源的,可以免费获得,在某些情况下还有付费支持选项。

在这一章中,我仅使用本书第二部分中描述的特性构建了一个示例应用。在接下来的章节中,我将介绍第三方软件包,演示它们提供的特性,并解释它们解决的问题。表 18-1 提供了本书这一部分涵盖的包的简要概述。

表 18-1

本书这一部分描述的包

|

名字

|

描述

|
| --- | --- |
| Redux | Redux 提供了一个数据存储,用于管理应用组件之外的数据。我在第十九章和第二十章中使用了这个包。 |
| React 还原 | React Redux 通过其 props 将 React 组件连接到 Redux 数据存储,允许直接访问数据,而不依赖于 prop 线程。我在第十九章和第二十章中使用了这个包。 |
| React 路由 | React 路由为 React 应用提供 URL 路由,允许根据浏览器的 URL 选择显示给用户的组件。我在第二十一章和第二十二章使用这个包。 |
| 阿克斯 | Axios 为异步 HTTP 请求提供了一致的 API。我在第二十三章使用这个包来消费一个 RESTful web 服务,在第二十五章使用一个 GraphQL 服务。 |
| 阿波罗助推 | Apollo 是一个使用 GraphQL 服务的客户端,它比传统的 RESTful web 服务更加灵活。我在第二十五章中使用这个包的 Boost 版本来消费一个 GraphQL 服务,它为 React 应用提供了合理的缺省值。 |
| 阿波罗 React | React Apollo 将 React 组件连接到 GraphQL 查询和变异,允许通过 props 消费 GraphQL 服务。 |

我选择的每一个包都有可信的替代方案,我在每一章都提出了建议,以防你无法与所涵盖的包相处。如果您对我在本书的这一部分没有涉及的包感兴趣,请发电子邮件到adam@adam-freeman.com给我。虽然我不做任何承诺,但我会尝试在本书的下一个版本中,或者如果有足够的需求,在发布到本书的 GitHub 资源库的更新中,包含经常被请求的包。

创建项目

打开一个新的命令提示符,导航到一个方便的位置,并运行清单 18-1 中所示的命令。

小费

你可以从 https://github.com/Apress/pro-react-16 下载本章以及本书其他章节的示例项目。

npx create-react-app productapp

Listing 18-1Creating the Example Project

运行清单 18-2 中所示的命令,导航到productapp文件夹添加引导包。

cd productapp
npm install bootstrap@4.1.2

Listing 18-2Adding the Bootstrap CSS Framework

为了在应用中包含引导 CSS 样式表,将清单 18-3 中所示的语句添加到index.js文件中,该文件可以在productapp/src文件夹中找到。

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

import 'bootstrap/dist/css/bootstrap.css';

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

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

Listing 18-3Including Bootstrap in the index.js File in the src Folder

启动开发工具

使用命令提示符,运行productapp文件夹中清单 18-4 所示的命令来启动开发工具。

npm start

Listing 18-4Starting the Development Tools

一旦项目的初始准备工作完成,一个新的浏览器窗口将打开并显示 URL http://localhost:3000,它显示了图 18-1 中的占位符内容。

img/473159_1_En_18_Fig1_HTML.jpg

图 18-1

运行示例应用

创建示例应用

本章中的应用很简单,但代表了仅使用 React 提供的功能构建的典型项目。该应用为用户提供了两种类型的数据(产品和供应商)的创建、读取、更新和删除(CRUD)功能,用户可以在被管理的数据之间切换。图 18-2 显示了一旦创建了以下章节中定义的组件,应用将如何出现。

img/473159_1_En_18_Fig2_HTML.jpg

图 18-2

示例应用

当然,示例应用是人为设计的,在这种情况下,我的目标是展示核心的 React 特性是强大的,但它们本身不足以创建复杂的 web 应用。一旦定义了应用,我就强调它所包含的问题,每一个问题我都使用下面章节中描述的工具和软件包来解决。

创建产品功能

为了开始应用的功能,我在src文件夹中添加了一个名为ProductTableRow.js的文件,并用它来定义清单 18-5 中所示的组件。

import React, { Component } from "react";

export class ProductTableRow extends Component {

    render() {
        let p = this.props.product;
        return <tr>
            <td>{ p.id }</td>
            <td>{ p.name }</td>
            <td>{ p.category}</td>
            <td className="text-right">${ Number(p.price).toFixed(2) }</td>
            <td>
                <button className="btn btn-sm btn-warning m-1"
                    onClick={ () => this.props.editCallback(p) }>
                        Edit
                </button>
                <button className="btn btn-sm btn-danger m-1"
                    onClick={ () => this.props.deleteCallback(p) }>
                        Delete
                    </button>
            </td>
        </tr>
    }
}

Listing 18-5The Contents of the ProductTableRow.js File in the src Folder

该组件在一个表中呈现一个单独的行,包含属性的列idnamecategoryprice,这些属性是从一个名为product的属性对象中获得的。还有一列显示编辑和删除按钮,这些按钮调用名为editCallbackdeleteCallback的函数属性,并将product属性作为参数传递。

创建产品表

我在src文件夹中添加了一个名为ProductTable.js的文件,并用它来定义清单 18-6 中所示的组件。

import React, { Component } from "react";
import { ProductTableRow } from "./ProductTableRow";

export class ProductTable extends Component {

    render() {
        return <table className="table table-sm table-striped table-bordered">
                <thead>
                    <tr>
                        <th colSpan="5"
                                className="bg-primary text-white text-center h4 p-2">
                            Products
                        </th>
                    </tr>
                    <tr>
                        <th>ID</th><th>Name</th><th>Category</th>
                        <th className="text-right">Price</th>
                        <th></th>
                    </tr>
                </thead>
                <tbody>
                    {
                        this.props.products.map(p =>
                            <ProductTableRow product={ p }
                                key={ p.id }
                                editCallback={ this.props.editCallback }
                                deleteCallback={ this.props.deleteCallback } />)
                    }
                </tbody>
            </table>
    }
}

Listing 18-6The Contents of the ProductTable.js File in the src Folder

该组件呈现一个表,表体由名为products的数组prop中每个对象的ProductTableRow组件填充。该组件将deleteCallbackeditCallback功能属性传递给ProductTableRow实例。

创建产品编辑器

为了允许用户编辑产品或为新产品提供价值,我在src文件夹中添加了一个名为ProductEditor.js的文件,并添加了清单 18-7 中所示的代码。

import React, { Component } from "react";

export class ProductEditor extends Component {

    constructor(props) {
        super(props);
        this.state = {
            formData: {
                id: props.product.id || "",
                name: props.product.name || "",
                category: props.product.category || "",
                price: props.product.price || ""
            }
        }
    }

    handleChange = (ev) => {
        ev.persist();
        this.setState(state => state.formData[ev.target.name] =  ev.target.value);
    }

    handleClick = () => {
        this.props.saveCallback(this.state.formData);
    }

    render() {
        return <div className="m-2">
            <div className="form-group">
                <label>ID</label>
                <input className="form-control" name="id"
                    disabled
                    value={ this.state.formData.id }
                    onChange={ this.handleChange } />
            </div>
            <div className="form-group">
                <label>Name</label>
                <input className="form-control" name="name"
                    value={ this.state.formData.name }
                    onChange={ this.handleChange } />
            </div>
            <div className="form-group">
                <label>Category</label>
                <input className="form-control" name="category"
                    value={ this.state.formData.category }
                    onChange={ this.handleChange } />
            </div>
            <div className="form-group">
                <label>Price</label>
                <input className="form-control" name="price"
                    value={ this.state.formData.price }
                    onChange={ this.handleChange } />
            </div>
            <div className="text-center">
                <button className="btn btn-primary m-1" onClick={ this.handleClick }>
                    Save
                </button>
                <button className="btn btn-secondary"
                        onClick={ this.props.cancelCallback }>
                    Cancel
                </button>
            </div>
        </div>
    }
}

Listing 18-7The Contents of the ProductEditor.js File in the src Folder

ProductEditor组件为用户提供编辑对象属性的字段。这些字段的初始值是从名为product的 prop 接收的,用于填充状态数据。有一个保存按钮,当它被单击时调用一个名为saveCallback的函数属性,传递状态数据值以便保存。还有一个取消按钮,当它被点击时调用一个名为cancelCallback的函数回调。

创建产品展示组件

接下来,我需要一个在产品表和产品编辑器之间切换的组件。我在src文件夹中添加了一个名为ProductDisplay.js的文件,并用它来定义清单 18-8 中所示的组件。

import React, { Component } from "react";
import { ProductTable } from "./ProductTable"
import { ProductEditor } from "./ProductEditor";

export class ProductDisplay extends Component {

    constructor(props) {
        super(props);
        this.state = {
            showEditor: false,
            selectedProduct: null
        }
    }

    startEditing = (product) => {
        this.setState({ showEditor: true, selectedProduct: product })
    }

    createProduct = () => {
        this.setState({ showEditor: true, selectedProduct: {} })
    }

    cancelEditing = () => {
        this.setState({ showEditor: false, selectedProduct: null })
    }

    saveProduct = (product) => {
        this.props.saveCallback(product);
        this.setState({ showEditor: false, selectedProduct: null })
    }

    render() {
        if (this.state.showEditor) {
            return <ProductEditor
                key={ this.state.selectedProduct.id || -1 }
                product={ this.state.selectedProduct }
                saveCallback={ this.saveProduct }
                cancelCallback={ this.cancelEditing } />
        } else {
            return <div className="m-2">
                <ProductTable products={ this.props.products }
                    editCallback={ this.startEditing }
                    deleteCallback={ this.props.deleteCallback } />
                <div className="text-center">
                    <button className="btn btn-primary m-1"
                        onClick={ this.createProduct }>
                        Create Product
                    </button>
                </div>
            </div>
        }
    }
}

Listing 18-8The Contents of the ProductDisplay.js File in the src Folder

该组件定义状态数据,以确定是否应该显示数据表或编辑器,如果是编辑器,则确定用户想要修改哪个产品。该组件将函数属性传递给ProductEditorProductTable组件,并引入自己的功能。

创建供应商功能

应用中处理供应商数据的部分遵循与前面部分中创建的组件相似的模式。我在src文件夹中添加了一个名为SupplierTableRow.js的文件,并用它来定义清单 18-9 中所示的组件。

import React, { Component } from "react";

export class SupplierTableRow extends Component {

    render() {
        let s = this.props.supplier;
        return <tr>
            <td>{ s.id }</td>
            <td>{ s.name }</td>
            <td>{ s.city}</td>
            <td>{ s.products.join(", ") }</td>
            <td>
                <button className="btn btn-sm btn-warning m-1"
                    onClick={ () => this.props.editCallback(s) }>
                        Edit
                </button>
                <button className="btn btn-sm btn-danger m-1"
                    onClick={ () => this.props.deleteCallback(s) }>
                        Delete
                    </button>
            </td>
        </tr>
    }
}

Listing 18-9The Contents of the SupplierTableRow.js File in the src Folder

该组件使用名为supplier的属性对象的idnamecityproducts属性呈现一个表格行。还有调用功能属性的编辑和删除按钮。

创建供应商表

为了向用户呈现一个供应商表,我在src文件夹中添加了一个名为SupplierTable.js的文件,并添加了清单 18-10 中所示的代码。

import React, { Component } from "react";
import { SupplierTableRow } from "./SupplierTableRow";

export class SupplierTable extends Component {

    render() {
        return <table className="table table-sm table-striped table-bordered">
                <thead>
                    <tr>
                        <th>ID</th><th>Name</th><th>City</th>
                        <th>Products</th><th></th>
                    </tr>
                </thead>
                <tbody>
                    {
                        this.props.suppliers.map(s =>
                            <SupplierTableRow supplier={ s }
                                key={ s.id }
                                editCallback={ this.props.editCallback }
                                deleteCallback={ this.props.deleteCallback } />)
                    }
                </tbody>
            </table>
    }
}

Listing 18-10The Contents of the SupplierTable.js File in the src Folder

该组件呈现一个表格,将suppliers属性数组中的每个对象映射到一个SupplierTableRow。回调的属性从父组件接收并传递。

创建供应商编辑器

为了创建供应商编辑器,我在src文件夹中添加了一个名为SupplierEditor.js的文件,并用它来定义清单 18-11 中所示的组件。

import React, { Component } from "react";

export class SupplierEditor extends Component {

    constructor(props) {
        super(props);
        this.state = {
            formData: {
                id: props.supplier.id || "",
                name: props.supplier.name || "",
                city: props.supplier.city || "",
                products: props.supplier.products || [],
            }
        }
    }

    handleChange = (ev) => {
        ev.persist();
        this.setState(state =>
            state.formData[ev.target.name] =
                ev.target.name === "products"
                    ? ev.target.value.split(",") : ev.target.value);
    }

    handleClick = () => {
        this.props.saveCallback(
            {
                ...this.state.formData,
                products: this.state.formData.products.map(val => Number(val))
            });
    }

    render() {
        return <div className="m-2">
            <div className="form-group">
                <label>ID</label>
                <input className="form-control" name="id"
                    disabled
                    value={ this.state.formData.id }
                    onChange={ this.handleChange } />
            </div>
            <div className="form-group">
                <label>Name</label>
                <input className="form-control" name="name"
                    value={ this.state.formData.name }
                    onChange={ this.handleChange } />
            </div>
            <div className="form-group">
                <label>City</label>
                <input className="form-control" name="city"
                    value={ this.state.formData.city }
                    onChange={ this.handleChange } />
            </div>

            <div className="form-group">
                <label>Products</label>
                <input className="form-control" name="products"
                    value={ this.state.formData.products }
                    onChange={ this.handleChange } />
            </div>

            <div className="text-center">
                <button className="btn btn-primary m-1" onClick={ this.handleClick }>
                    Save
                </button>
                <button className="btn btn-secondary"
                        onClick={ this.props.cancelCallback }>
                    Cancel
                </button>
            </div>
        </div>
    }
}

Listing 18-11The Contents of the SupplierEditor.js File in the src Folder

创建供应商显示组件

为了管理应用中处理供应商数据的部分,以便只显示表格或编辑器,我在src文件夹中添加了一个名为SupplierDisplay.js的文件,并用它来定义清单 18-12 中所示的组件。

import React, { Component } from "react";
import { SupplierEditor } from "./SupplierEditor";
import { SupplierTable } from "./SupplierTable";

export class SupplierDisplay extends Component {

    constructor(props) {
        super(props);
        this.state = {
            showEditor: false,
            selected: null
        }
    }

    startEditing = (supplier) => {
        this.setState({ showEditor: true, selected: supplier })
    }

    createSupplier = () => {
        this.setState({ showEditor: true, selected: {} })
    }

    cancelEditing = () => {
        this.setState({ showEditor: false, selected: null })
    }

    saveSupplier= (supplier) => {
        this.props.saveCallback(supplier);
        this.setState({ showEditor: false, selected: null })
    }

    render() {
        if (this.state.showEditor) {
            return <SupplierEditor
                key={ this.state.selected.id || -1 }
                supplier={ this.state.selected }
                saveCallback={ this.saveSupplier }
                cancelCallback={ this.cancelEditing } />
        } else {
            return <div className="m-2">
                    <SupplierTable suppliers={ this.props.suppliers }
                        editCallback={ this.startEditing }
                        deleteCallback={ this.props.deleteCallback }
                    />
                    <div className="text-center">
                        <button className="btn btn-primary m-1"
                            onClick={ this.createSupplier }>
                                Create Supplier
                        </button>
                    </div>
            </div>
        }
    }
}

Listing 18-12The Contents of the SupplierDisplay.js File in the src Folder

SupplierDisplay组件有自己的状态数据,用于确定是否应该显示编辑器或表格组件。

完成申请

为了允许用户在产品或供应商特性之间进行选择,我在src文件夹中添加了一个名为Selector.js的文件,并添加了清单 18-13 中所示的代码。

import React, { Component } from "react";

export class Selector extends Component {

    constructor(props) {
        super(props);
        this.state = {
            selection: React.Children.toArray(props.children)[0].props.name
        }
    }

    setSelection = (ev) => {
        ev.persist();
        this.setState({ selection: ev.target.name});
    }

    render() {
        return <div className="container-fluid">
            <div className="row">
                <div className="col-2">
                    { React.Children.map(this.props.children, c =>
                        <button
                            name={ c.props.name }
                            onClick={ this.setSelection }
                            className={`btn btn-block m-2
                            ${this.state.selection === c.props.name
                                ? "btn-primary active": "btn-secondary"}`}>
                                    { c.props.name }
                        </button>
                    )}

                </div>
                <div className="col">
                    {
                        React.Children.toArray(this.props.children)
                            .filter(c => c.props.name === this.state.selection)
                    }
                </div>
            </div>
        </div>
    }
}

Listing 18-13The Contents of the Selector.js File in the src Folder

Selector组件是一个容器,它为每个子组件呈现一个按钮,并且只显示用户选择的那个按钮。为了提供将由应用显示的数据以及对其进行操作的回调函数的实现,我向src文件夹添加了一个名为ProductsAndSuppliers.js的文件,并使用它来定义清单 18-14 中所示的组件。

import React, { Component } from 'react';
import { Selector } from './Selector';
import { ProductDisplay } from './ProductDisplay';
import { SupplierDisplay } from './SupplierDisplay';

export default class ProductsAndSuppliers extends Component {

    constructor(props) {
        super(props);
        this.state = {
            products: [
                { id: 1, name: "Kayak",
                category: "Watersports", price: 275 },
                { id: 2, name: "Lifejacket",
                    category: "Watersports", price: 48.95 },
                { id: 3, name: "Soccer Ball", category: "Soccer", price: 19.50 }
            ],
            suppliers: [
                { id: 1, name: "Surf Dudes", city: "San Jose", products: [1, 2] },
                { id: 2, name: "Field Supplies", city: "New York", products: [3] },
            ]
        }
        this.idCounter = 100;
    }

    saveData = (collection, item) => {
        if (item.id === "") {
            item.id = this.idCounter++;
            this.setState(state => state[collection]
                = state[collection].concat(item));
        } else {
            this.setState(state => state[collection]
                = state[collection].map(stored =>
                      stored.id === item.id ? item: stored))
        }
    }

    deleteData = (collection, item) => {
        this.setState(state => state[collection]
            = state[collection].filter(stored => stored.id !== item.id));
    }

    render() {
        return <div>
            <Selector>
                <ProductDisplay
                    name="Products"
                    products={ this.state.products }
                    saveCallback={ p => this.saveData("products", p) }
                    deleteCallback={ p => this.deleteData("products", p) } />
                <SupplierDisplay
                    name="Suppliers"
                    suppliers={ this.state.suppliers }
                    saveCallback={ s => this.saveData("suppliers", s) }
                    deleteCallback={ s => this.deleteData("suppliers", s) } />
            </Selector>
        </div>
    }
}

Listing 18-14The Contents of the ProductsAndSuppliers.js File in the src Folder

该组件定义了productsuppliers状态数据属性,并定义了允许为每个数据类别删除或保存对象的方法。该组件呈现一个Selector,并提供类别显示组件作为其子组件。

最后一步是替换App组件的内容,以便向用户显示前面几节中定义的定制组件,如清单 18-15 所示。

import React, { Component } from "react";
import ProductsAndSuppliers from "./ProductsAndSuppliers";

export default class App extends Component {

    render() {
        return <ProductsAndSuppliers/>
    }
}

Listing 18-15Adding Data and Methods to the App.js File in the src Folder

一旦保存了对App组件的更改,浏览器将显示完整的示例应用。为了确保一切正常,请单击“供应商”按钮,单击“创建供应商”按钮,并填写表单。点击保存按钮,您应该会在表格中看到一个新条目,其中包含您输入的详细信息,如图 18-3 所示。

img/473159_1_En_18_Fig3_HTML.jpg

图 18-3

测试示例应用

理解示例应用的局限性

示例应用展示了如何组合 React 组件来创建应用——但也展示了 React 提供的特性的局限性。

示例应用的最大限制是,它使用硬编码到App组件中的静态定义数据。每次启动应用时都会显示相同的数据,当浏览器重新加载或关闭时,更改会丢失。

尽管现代浏览器支持在本地存储有限数量的数据,但在 web 应用之外保存数据的最常见方式是使用 web 服务。React 不包括对使用 web 服务的集成支持,但是有一些好的选择,既包括简单的 web 服务,我在第二十三章中描述了它们,也包括那些呈现更复杂数据的服务,我在第 24 和 25 章中描述了它们。

下一个限制是状态数据一直被提升到应用的顶部。正如我在第二部分中所解释的,状态数据可以用于组件之间的协调,并且状态数据可以提升到需要访问相同数据的组件的共同祖先。

示例应用展示了这种方法的缺点,即重要的数据——在本例中是productssuppliers数组——最终被推到应用的顶层。当组件被卸载并且它们的状态数据丢失时,React 会销毁组件,这意味着示例应用中位于Selector下面的任何组件都不适合存储应用的数据。因此,应用的所有数据都已经在App组件中定义了,还有操作这些数据的方法。我为应用选择的结构加剧了这个问题,但潜在的问题是,组件的状态非常适合跟踪管理呈现给用户的内容所需的数据——例如是否应该显示数据表或编辑器——但不太适合管理与应用目的相关的数据,通常称为域数据模型数据

防止模型数据被推送到顶层组件的最好方法是将它放在一个单独的数据存储中,这样 React 组件就可以处理数据的表示,而不必管理它。我将在第十九章和第二十章中解释数据存储的用途,并向您展示如何创建数据存储。

该应用还受到限制,因为它要求用户通过特定的任务序列来获得特定的功能。在许多应用中,尤其是那些被设计来支持特定公司功能的应用,用户必须执行一小部分任务,并且希望能够尽可能容易地启动它们。示例应用仅呈现其响应于点击特定元素的特性。在第 21 和 22 章中,我添加了对 URL 路由的支持,这使得用户可以直接导航到特定的功能。

摘要

在这一章中,我创建了一个示例应用,我将在本书的这一部分对其进行增强。在下一章中,我将通过引入一个数据存储来开始这个过程,它将允许模型数据从App组件中移除,并直接分发给应用中需要它的部分。

十九、使用 Redux 数据存储

一个数据存储库将应用的数据移动到 React 组件层次结构之外。使用数据存储意味着数据不必提升到顶层组件,也不必通过线程来确保在需要的地方访问数据。结果是一个更自然的应用结构,让 React 组件专注于他们擅长的事情,即为用户呈现内容。

但是数据存储可能很复杂,将它们引入应用可能是一个违反直觉的过程。在这一章中,我将介绍 Redux,它是 React 项目中最受欢迎的数据存储选择,并向您展示如何创建数据存储并集成到应用中。在第二十章中,我将更深入地解释 Redux 的工作原理,并解释它的一些高级特性。表 19-1 将使用 Redux 数据存储放在上下文中。

表 19-1

将 Redux 数据存储放在上下文中

|

问题

|

回答

|
| --- | --- |
| 这是什么? | 数据存储将应用的数据移动到组件层次结构之外,这意味着数据不必提升,然后通过适当的线程提供给后代。 |
| 为什么有用? | 数据存储可以简化项目中的组件,生成更易于开发和测试的应用。 |
| 如何使用? | 数据被移动到应用的专用部分,需要数据的组件可以直接访问这些数据。在 Redux 的情况下,组件通过 props 连接到数据存储,这利用了 React 的性质,尽管映射过程本身可能很笨拙,需要密切关注。 |
| 有什么陷阱或限制吗? | 数据存储可能很复杂,并且经常以违反直觉的方式工作。一些数据存储包,包括 Redux,强制执行一些特定的方法来处理一些开发人员认为有限制性的数据。 |
| 还有其他选择吗? | 并非所有应用都需要数据存储。对于较少量的数据,使用组件状态特性可能是可接受的,第十四章中描述的 React 上下文 API 可用于基本的数据管理特性。 |

表 19-2 总结了本章内容。

表 19-2

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 创建数据存储 | 定义初始数据、活动类型、创建者和缩减者 | 3–8, 13–21 |
| 向 React 应用添加数据存储 | 使用 React-Redux 包中的Provider组件 | nine |
| 使用 React 组件中的数据存储 | 使用connect函数将组件的属性映射到数据存储的数据和动作创建者 | 10, 12 |
| 分派多个数据存储操作 | 将数据存储动作创建者映射到组件功能属性时,直接使用dispatch函数。 | Twenty-two |

为本章做准备

在本章中,我继续使用在第十八章中创建的productapp项目。为了准备本章,打开一个新的命令提示符并运行清单productapp文件夹中的 19-1 所示的命令。

小费

你可以从 https://github.com/Apress/pro-react-16 下载本章以及本书其他章节的示例项目。

npm install redux@4.0.1
npm install react-redux@6.0.0

Listing 19-1Installing Packages

为了快速参考,表 19-3 描述了清单 19-1 中的命令添加到项目中的包。

表 19-3

添加到项目中的包

|

名字

|

描述

|
| --- | --- |
| redux | 这个包包含 Redux 数据存储的主要特性。 |
| react-redux | 这个包包含使用 Redux 和 React 的集成特性。 |

一旦软件包安装完毕,运行productapp文件夹中清单 19-2 所示的命令来启动 React 开发工具。

npm start

Listing 19-2Starting the Development Tools

一旦应用被编译,开发 HTTP 服务器将启动并显示如图 19-1 所示的内容。

img/473159_1_En_19_Fig1_HTML.jpg

图 19-1

运行示例应用

创建数据存储

与 React 非常相似,Redux 为数据和更改强加了一个特定的流。而且,像 React 一样,理解 Redux 的不同部分是如何组合在一起的,一开始可能会很困难。Redux 有两个特点会引起混淆。

首先,Redux 中的更改不会直接应用于存储中的数据,即使这些数据被表示为常规的 JavaScript 对象。相反,Redux 依赖于接受有效负载并更新存储中数据的函数,类似于 React 组件强制使用setState方法来更新状态数据的方式。

第二个困惑点是术语。Redux 数据存储中有许多不同的部分,它们的名称不能直观地描述它们的用途。作为 Redux 入门的快速参考,表 19-4 描述了您将会遇到的术语,并且在接下来的章节中有更详细的解释,在后面的章节中,我创建了一个数据存储并将其集成到示例应用中。

表 19-4

重要的冗余术语

|

名字

|

描述

|
| --- | --- |
| 行为 | 动作描述了将改变存储中的数据的操作。Redux 不允许直接修改数据,需要动作来指定更改。 |
| 动作类型 | 动作是普通的 JavaScript 对象,有一个指定动作类型的类型参数。这确保了动作可以被正确地识别和处理。 |
| 动作创建者 | 动作创建器是创建动作的功能。动作创建者被呈现来将组件作为功能属性进行 React,以便调用动作创建者功能来将改变应用于数据存储。 |
| 还原剂 | 缩减器是接收动作并处理它在数据存储中表示的变化的功能。动作指定应该对数据存储应用哪个操作,但是包含 JavaScript 代码的 reducer 实现了这一点。 |
| 选择器 | 选择器为组件提供了对数据存储中所需数据的访问。选择器的作用是将组件作为数据属性。 |

选择替代的数据存储包

Redux 只是可与 React 一起使用的数据存储包之一,尽管它是最知名的,也是大多数项目选择的。如果你不喜欢 Redux 的工作方式,那么 MobX ( https://github.com/mobxjs/mobx )可能是一个不错的选择。MobX 与 React 配合得很好,并允许直接的状态改变。主要缺点是它依赖于 decorators,一些开发人员觉得这很笨拙,而且它还不是 JavaScript 规范的一部分(尽管它们被广泛使用,包括 Angular)。

在第二十四章和第二十五章中,我介绍了 GraphQL 并解释了它在为应用检索数据中的用途。如果您是一个忠实的 React 用户,那么您可能会考虑使用 Relay ( https://facebook.github.io/relay )进行数据管理。Relay 只适用于 GraphQL,这意味着它并不适合所有项目,但是它有一些有趣的特性,并且可以很好地与 React 集成。

定义数据类型

示例应用包含应用于两种类型数据的类似功能集。在这种情况下,很容易导致管理数据存储的代码重复,执行本质上相同的操作,但在不同的对象集合上执行,结果是数据存储更难编写、更难理解,并且容易因复制一种类型数据的代码并不正确地对其进行调整而导致错误。

这是一个非常常见的问题,因此我将展示一个数据存储,它整合了尽可能多的常见代码。第一步是定义常量值,让我能够一致地识别整个数据存储中不同类型的数据。我创建了src/store文件夹,并在其中添加了一个名为dataTypes.js的文件,其语句如清单 19-3 所示。

export const PRODUCTS = "products";
export const SUPPLIERS = "suppliers";

Listing 19-3The Contents of the dataTypes.js File in the src/store Folder

定义初始数据

在后面的章节中,我将向您展示如何从 web 服务中获取数据,但是目前我将继续使用静态定义的数据。为了定义数据存储的初始内容,我在store文件夹中创建了一个名为initialData.js的文件,并添加了清单 19-4 中所示的语句。

注意

随着我向示例应用添加更多的特性,我将创建数据存储的不同部分来保持特性的分离。我将把呈现给用户的产品和供应商数据称为模型数据,以将其与用于在组件之间进行协调的内部数据区分开来,我将把内部数据称为状态数据

import { PRODUCTS, SUPPLIERS } from "./dataTypes";

export const initialData = {
    [PRODUCTS]: [
        { id: 1, name: "Trail Shoes", category: "Running", price: 100 },
        { id: 2, name: "Thermal Hat", category: "Running", price: 12 },
        { id: 3, name: "Heated Gloves", category: "Running", price: 82.50 }],
    [SUPPLIERS]: [
        { id: 1, name: "Zoom Shoes", city: "London", products: [1] },
        { id: 2, name: "Cosy Gear", city: "New York", products: [2, 3] }],
}

Listing 19-4The Contents of the initialData.js File in the src/store Folder

数据存储的初始状态被定义为常规的 JavaScript 对象;使用 Redux 的一个特点是,它的许多特性都依赖于纯 JavaScript。为了清楚地说明数据存储何时被使用,我对PRODUCTSSUPPLIERS数组中的对象使用了不同的细节。

定义模型数据操作类型

下一步是描述可以对存储中的数据执行的操作,这些操作被称为操作。在一个复杂的应用中可能会有很多操作,定义常量值来标识它们会很有帮助。我在store文件夹中添加了一个名为modelActionTypes.js的文件,并添加了清单 19-5 中所示的内容。

export const STORE  = "STORE";
export const UPDATE = "UPDATE";
export const DELETE = "DELETE";

Listing 19-5The Contents of the modelActionTypes.js File in the src/store Folder

为了提供示例应用的功能,我需要三个事件:STORE向数据存储添加对象,UPDATE修改现有对象,以及DELETE删除对象。

分配给动作类型的值并不重要,只要它是唯一的,最简单的方法是为每个动作类型分配一个名称字符串值。

定义模型动作创建者

动作是从应用发送到数据存储以请求更改的对象。动作具有动作类型和数据有效负载,其中动作类型指定操作,有效负载提供操作所需的数据。动作是普通的 JavaScript 对象,可以定义描述操作所需的任何属性组合。约定是定义一个type属性来指示事件类型,我将用dataTypepayload属性来补充这个属性,以指定动作应该应用到的数据和动作所需的数据。

动作是由动作创建者创建的,?? 是从应用接受数据并返回描述数据存储变化的动作的函数的名字。为了定义动作创建者,我在store文件夹中添加了一个名为modelActionCreators.js的文件,并添加了清单 19-6 中所示的代码。

import { PRODUCTS, SUPPLIERS } from "./dataTypes"
import { STORE, UPDATE, DELETE } from "./modelActionTypes";

let idCounter = 100;

export const saveProduct = (product) => {
    return createSaveEvent(PRODUCTS, product);
}

export const saveSupplier = (supplier) => {
    return createSaveEvent(SUPPLIERS, supplier);
}

const createSaveEvent = (dataType, payload)  => {
    if (!payload.id) {
        return {
            type: STORE,
            dataType: dataType,
            payload: { ...payload, id: idCounter++ }
        }
    } else {
        return {
            type: UPDATE,
            dataType: dataType,
            payload: payload
        }
    }
}

export const deleteProduct = (product) => ({
    type: DELETE,
    dataType: PRODUCTS,
    payload: product.id
})

export const deleteSupplier = (supplier) => ({
    type: DELETE,
    dataType: SUPPLIERS,
    payload: supplier.id
})

Listing 19-6The Contents of the modelActionCreators.js File in the src/store Folder

清单中有四个动作创建者。saveProductsaveSupplier函数接收一个对象参数并将其传递给createSaveEvent,后者检查id属性的值以确定是否需要STOREUPDATE动作。deleteProductdeleteSupplier动作创建器更简单,创建一个DELETE动作,其有效负载是要删除的对象的id属性值。

定义减速器

被称为缩减器的 JavaScript 函数将动作应用于数据存储。换句话说,一个动作描述了所需要的改变的类型,而 reducer 包含了实现它的逻辑。我在store文件夹中添加了一个名为modelReducer.js的文件,并添加了清单 19-7 中所示的代码。

import { STORE, UPDATE, DELETE } from "./modelActionTypes";
import { initialData } from "./initialData";

export default function(storeData, action) {
    switch (action.type) {
        case STORE:
            return {
                ...storeData,
                [action.dataType]:
                    storeData[action.dataType].concat([action.payload])
            }
        case UPDATE:
            return {
                ...storeData,
                [action.dataType]: storeData[action.dataType].map(p =>
                    p.id === action.payload.id ? action.payload : p)
            }
        case DELETE:
            return {
                ...storeData,
                [action.dataType]: storeData[action.dataType]
                    .filter(p => p.id !== action.payload)
            }
        default:
            return storeData || initialData;
    }
}

Listing 19-7The Contents of the modelReducer.js File in the src/store Folder

reducer 从数据存储中接收当前数据,并接收一个动作作为其参数。它检查该动作并使用它来创建新的数据对象,该对象将替换数据存储中的现有数据。

有两条重要的规则要遵循。首先,reducer 必须创建一个新的对象,并且不返回作为参数接收的对象,因为 Redux 将忽略已经做出的任何更改。其次,因为 reducer 创建的对象替换了存储中的数据,所以复制现有对象的属性很重要,而不仅仅是被操作修改的对象。复制属性的最简单方法是使用 spread 运算符,如下所示:

...
case STORE:
    return {
        ...store,
        [action.dataType]: store[action.dataType].concat([action.payload])
}
...

这确保了所有属性都被复制到结果对象中。然后,被更改的数据的属性被替换为由该操作修改的数据。

reducer 的另一个重要方面是,它将在创建数据存储以获取初始数据时被调用。这由switch语句的default子句处理,如下所示:

...
default:
    return storeData || initialData;
...

如果函数返回undefined,Redux 会报告一个错误,确保你返回一个有用的结果是很重要的。在清单中,我返回清单 19-4 中定义的initialData对象。

避免缩减器中的代码重复

大多数数据集需要一组核心的常见操作。这可以在示例应用中看到,其中产品和供应商数据都需要存储、更新和删除操作。当您使用相似的动作类型、动作创建者和缩减器代码定义数据存储时,这可能会导致代码重复。我在本节中采用的方法是在操作中包含一个属性,该属性指定操作应该应用于哪种类型的数据,然后我依靠 JavaScript 属性访问器特性在 reducer 中选择适当的数据存储属性,如下所示:

...
case STORE:
    return {
        ...store,
        [action.dataType]: store[action.dataType].concat([action.payload])
}
...

当创建新的数据存储对象时,JavaScript 将评估action.dataType属性,并使用它的值定义对象的新属性,并访问旧数据存储的属性,使用我在清单 19-5 中定义的值,因此值PRODUCTSdataType选择产品数据,值SUPPLIERS选择供应商数据。您不必在自己的项目中使用这种技术,但它有助于保持代码简洁和易于管理。

创建数据存储

Redux 提供了createStore函数,该函数创建数据存储并准备使用。我在store文件夹中添加了一个名为index.js的文件,并添加了清单 19-8 中所示的代码。

小费

您不必使用index.js文件名,但是这样做允许只使用文件夹的名称来导入数据存储,如清单 19-9 所示。

import { createStore } from "redux";
import modelReducer from "./modelReducer";

export default createStore(modelReducer);

export { saveProduct, saveSupplier, deleteProduct, deleteSupplier }
    from "./modelActionCreators";

Listing 19-8The Contents of the index.js File in the src/store Folder

index.js文件的默认导出是调用createStore的结果,它接受 reducer 函数作为它的参数。我还导出了动作创建器,这样所有数据存储的功能都可以通过应用中其他地方的单个import语句来访问,这使得使用数据存储稍微简单了一些。

在 React 应用中使用数据存储

我在上一节中创建的操作、缩减器和选择器还没有集成到应用中,应用中的组件和数据存储中的数据之间也没有链接。在接下来的小节中,我将向您展示如何使用数据存储来替换当前管理应用数据的状态数据和方法。

将数据存储应用于顶级组件

React-Redux 包包括一个 React 容器组件,它提供对数据存储的访问。这个组件叫做Provider,应用在组件层次结构的顶层,这样数据存储在整个应用中都是可用的。在清单 19-9 中,我从清单 19-8 中创建的index.js文件中导入数据存储,并使用Provider组件将数据存储应用于应用中的组件。

import React, { Component } from "react";
import ProductsAndSuppliers from "./ProductsAndSuppliers";

import { Provider } from "react-redux";

import dataStore from "./store";

export default class App extends Component {

    render() {
        return (
            <Provider store={ dataStore }>
               <ProductsAndSuppliers/>
            </Provider>
        )
    }
}

Listing 19-9Applying the Data Store in the App.js File in the src Folder

Provider组件有一个用于指定数据存储的store属性,我在import语句中将其命名为dataStore

连接产品数据

下一步是将数据存储连接到需要它所包含的数据的组件和操作它的动作创建器。我将采用最直接的方法,即使用 React-Redux 包提供的特性将ProductDisplay组件连接到数据存储,如清单 19-10 所示。

import React, { Component } from "react";
import { ProductTable } from "./ProductTable"
import { ProductEditor } from "./ProductEditor";

import { connect } from "react-redux";

import { saveProduct, deleteProduct } from "./store"

const mapStateToProps = (storeData) => ({

    products: storeData.products

})

const mapDispatchToProps = {

    saveCallback: saveProduct,
    deleteCallback: deleteProduct

}

const connectFunction = connect(mapStateToProps, mapDispatchToProps);

export const ProductDisplay = connectFunction(

        class extends Component {

        constructor(props) {
            super(props);
            this.state = {
                showEditor: false,
                selectedProduct: null
            }
        }

        startEditing = (product) => {
            this.setState({ showEditor: true, selectedProduct: product })
        }

        createProduct = () => {
            this.setState({ showEditor: true, selectedProduct: {} })
        }

        cancelEditing = () => {
            this.setState({ showEditor: false, selectedProduct: null })
        }

        saveProduct = (product) => {
            this.props.saveCallback(product);
            this.setState({ showEditor: false, selectedProduct: null })
        }

        render() {
            if (this.state.showEditor) {
                return <ProductEditor
                    key={ this.state.selectedProduct.id || -1 }
                    product={ this.state.selectedProduct }
                    saveCallback={ this.saveProduct }
                    cancelCallback={ this.cancelEditing } />
            } else {
                return <div className="m-2">
                    <ProductTable products={ this.props.products }
                        editCallback={ this.startEditing }
                        deleteCallback={ this.props.deleteCallback } />
                    <div className="text-center">
                        <button className="btn btn-primary m-1"
                            onClick={ this.createProduct }>
                            Create Product
                        </button>
                    </div>
                </div>
            }
        }
    })

Listing 19-10Connecting to the Data Store in the ProductDisplay.js File in the src Folder

第一步是定义一个接收数据存储并选择连接组件和存储的属性的函数,如下所示:

...
const mapStateToProps = (storeData) => ({
    products: storeData.products
})
...

这个函数通常被命名为mapStateToProps,它返回一个对象,该对象将连接组件的正确名称映射到存储中的数据。这些映射被称为选择器,因为它们选择将被映射到组件属性的数据。在这种情况下,选择器将商店的products数组映射到一个名为products的属性。

下一步是创建对象,该对象将组件所需的功能属性映射到数据存储操作创建者,如下所示:

...
const mapDispatchToProps = {
    saveCallback: saveProduct,
    deleteCallback: deleteProduct
}
...

React-Redux 包支持动作创建者与功能属性的不同连接方式,但这是最简单的,就是创建一个对象,将属性名称映射到动作创建者功能。当组件连接到数据存储时,这个对象中定义的 action creator 函数将被连接起来,以便自动调用 reducer。在这种情况下,我将saveProductdeleteProduct动作创建者映射到名为saveCallbackdeleteCallback的功能属性。

一旦定义了数据和函数属性的映射,它们就被传递给由 React-Redux 包提供的connect函数。

...
const connectFunction = connect(mapStateToProps, mapDispatchToProps);
...

connect函数创建一个更高阶的组件(HOC ),它传递连接到数据存储的属性,这些属性与父组件提供的属性合并。

小费

高阶组件在第十四章中描述。

最后一步是将一个组件传递给由connect返回的函数,就像这样:

...
export const ProductDisplay = connectFunction(class extends Component {
...

结果是一个组件,其属性连接到数据存储。当您保存清单 19-10 中的更改时,应用将显示清单 19-4 中定义的数据,如图 19-2 所示。

img/473159_1_En_19_Fig2_HTML.jpg

图 19-2

使用产品数据的数据存储

因为数据存储提供的属性替换了来自父组件的属性,ProductDisplay组件完全在数据存储数据上操作,包括创建、编辑和删除对象。

连接供应商数据

同样的过程可以应用于连接供应商数据,如清单 19-11 所示,其中我使用了connect方法为SupplierDisplay组件提供对数据存储的访问。

import React, { Component } from "react";
import { SupplierEditor } from "./SupplierEditor";
import { SupplierTable } from "./SupplierTable";

import { connect } from "react-redux";

import { saveSupplier, deleteSupplier} from "./store";

const mapStateToProps = (storeData) => ({

    suppliers: storeData.suppliers

})

const mapDispatchToProps = {

    saveCallback: saveSupplier,
    deleteCallback: deleteSupplier

}

const connectFunction = connect(mapStateToProps, mapDispatchToProps);

export const SupplierDisplay = connectFunction(

    class extends Component {

        constructor(props) {
            super(props);
            this.state = {
                showEditor: false,
                selected: null
            }
        }

        startEditing = (supplier) => {
            this.setState({ showEditor: true, selected: supplier })
        }

        createSupplier = () => {
            this.setState({ showEditor: true, selected: {} })
        }

        cancelEditing = () => {
            this.setState({ showEditor: false, selected: null })
        }

        saveSupplier= (supplier) => {
            this.props.saveCallback(supplier);
            this.setState({ showEditor: false, selected: null })
        }

        render() {
            if (this.state.showEditor) {
                return <SupplierEditor
                    key={ this.state.selected.id || -1 }
                    supplier={ this.state.selected }
                    saveCallback={ this.saveSupplier }
                    cancelCallback={ this.cancelEditing } />
            } else {
                return <div className="m-2">
                        <SupplierTable suppliers={ this.props.suppliers }
                            editCallback={ this.startEditing }
                            deleteCallback={ this.props.deleteCallback }
                        />
                        <div className="text-center">
                            <button className="btn btn-primary m-1"
                                onClick={ this.createSupplier }>
                                    Create Supplier
                            </button>
                        </div>
                </div>
            }
        }
    })

Listing 19-11Connecting to the Data Store in the SupplierDisplay.js File in the src Folder

结果是SupplierDisplay组件接收连接它到数据存储的属性,如图 19-3 所示。

img/473159_1_En_19_Fig3_HTML.jpg

图 19-3

使用供应商数据的数据存储

有了数据存储之后,ProductsAndSuppliers组件是多余的,因为它的作用是提供产品和供应商数据以及存储和删除这些数据的方法。在清单 19-12 中,我已经更新了App组件以直接显示SelectorProductDisplaySupplierDisplay组件。

import React, { Component } from "react";

//import ProductsAndSuppliers from "./ProductsAndSuppliers";

import { Provider } from "react-redux";
import dataStore from "./store";

import { Selector } from "./Selector";

import { ProductDisplay } from "./ProductDisplay";

import { SupplierDisplay } from "./SupplierDisplay";

export default class App extends Component {

    render() {
        return (
            <Provider store={ dataStore }>
                <Selector>
                    <ProductDisplay name="Products" />
                    <SupplierDisplay name="Suppliers" />
                </Selector>
            </Provider>
        )
    }
}

Listing 19-12Displaying Content Directly in the App.js File in the src Folder

注意,我不必为ProductDisplaySupplierDisplay组件提供属性来让它们访问数据和方法;这些将通过将组件连接到数据存储的connect方法来设置。

扩展数据存储

数据存储不仅用于显示给用户的数据,还可以用于存储用于协调和管理组件的状态数据。扩展数据存储以包含状态数据将允许我将存储中的模型数据直接连接到使用它的组件,这在当前是不可能的,因为ProductDisplaySupplierDisplay维护用于选择呈现给用户的内容的状态数据。

在接下来的小节中,我将状态数据和管理它的代码移到数据存储中,这样我可以进一步简化应用。

将状态数据添加到存储中

我希望将状态数据与模型数据分开,所以我将向存储中添加一些结构。我喜欢在用来填充数据存储的初始数据中表示结构,尽管这完全是为了帮助我理解我正在处理的数据的形状,而不是 Redux 强制要求的。

为了构建商店数据,我将现有数据移动到名为modelData的属性中,并添加了一个新的stateData部分,如清单 19-13 所示。

import { PRODUCTS, SUPPLIERS } from "./dataTypes";

export const initialData = {
    modelData: {
        [PRODUCTS]: [
            { id: 1, name: "Trail Shoes", category: "Running", price: 100 },
            { id: 2, name: "Thermal Hat", category: "Running", price: 12 },
            { id: 3, name: "Heated Gloves", category: "Running", price: 82.50 }],
        [SUPPLIERS]: [
            { id: 1, name: "Zoom Shoes", city: "London", products: [1] },
            { id: 2, name: "Cosy Gear", city: "New York", products: [2, 3] }],
    },
    stateData: {
        editing: false,
        selectedId: -1,
        selectedType: PRODUCTS
    }
}

Listing 19-13Expanding the Data in the initialData.js File in the src/store Folder

我的目标是将ProductDisplaySupplierDisplay组件中的状态数据和逻辑移动到数据存储中。这些组件跟踪用户对编辑的选择,以及是否应该呈现表格或编辑器组件。为了在商店中提供这些信息,我在stateData部分定义了editingselectedselectedType属性。

定义状态数据的操作类型和创建者

接下来,我需要为存储中的状态数据定义操作。当我建立数据存储时,我在不同的文件中定义了动作类型和创建者,但这不是必需的,两者可以一起定义。为了将状态数据动作与存储中的其他动作分开,我在src/store文件夹中添加了一个名为stateActions.js的文件,并使用它来定义动作类型和创建者,如清单 19-14 所示。

import { PRODUCTS, SUPPLIERS } from "./dataTypes";

export const STATE_START_EDITING = "state_start_editing";
export const STATE_END_EDITING = "state_end_editing";
export const STATE_START_CREATING = "state_start_creating";

export const startEditingProduct = (product) => ({
    type: STATE_START_EDITING,
    dataType: PRODUCTS,
    payload: product
})

export const startEditingSupplier = (supplier) => ({
    type: STATE_START_EDITING,
    dataType: SUPPLIERS,
    payload: supplier
})

export const endEditing = () => ({
    type: STATE_END_EDITING
})

export const startCreatingProduct = () => ({
    type: STATE_START_CREATING, dataType: PRODUCTS
})

export const startCreatingSupplier = () => ({
    type: STATE_START_CREATING, dataType: SUPPLIERS
})

Listing 19-14The Contents of the stateActions.js File in the src/store Folder

动作创建器对应于由ProductDisplaySupplierDisplay组件定义的方法,并允许用户开始编辑对象、取消编辑和开始创建新对象。

定义状态数据缩减器

为了更新数据存储以响应动作,我需要定义一个缩减器。我将定义一个单独的函数来处理状态数据,而不是在现有的 reducer 中添加代码。我在src/store文件夹中添加了一个名为stateReducer.js的文件,并添加了清单 19-15 中所示的代码。

import { STATE_START_EDITING, STATE_END_EDITING, STATE_START_CREATING }
    from "./stateActions";
import { initialData } from "./initialData";

export default function(storeData, action) {
    switch(action.type) {
        case STATE_START_EDITING:
        case STATE_START_CREATING:
            return {
                ...storeData,
                editing: true,
                selectedId: action.type === STATE_START_EDITING
                    ? action.payload.id : -1,
                selectedType: action.dataType
            }
        case STATE_END_EDITING:
            return {
                ...storeData,
                editing: false
            }
        default:
            return storeData || initialData.stateData;
    }
}

Listing 19-15The Contents of the stateReducer.js File in the src/store Folder

状态数据的缩减器跟踪用户正在编辑或创建的内容,这与示例应用中现有组件所采用的方法相呼应,尽管我将使用一组属性来协调应用中两种模型数据的编辑器。

将状态数据特征合并到存储中

Redux 提供了combineReducers函数,它允许在一个数据存储中组合使用多个 reducer,每个 reducer 负责数据存储数据的一个部分。在清单 19-16 中,我使用了combineReducers函数来组合模型和状态数据的归约器。

import { createStore, combineReducers } from "redux";

import modelReducer from "./modelReducer";

import stateReducer from "./stateReducer";

export default createStore(combineReducers(

    {
        modelData: modelReducer,
        stateData: stateReducer
    }));

export { saveProduct, saveSupplier, deleteProduct, deleteSupplier }
    from "./modelActionCreators";

Listing 19-16Configuring the Data Store in the index.js File in the src/store Folder

createReducers函数的参数是一个对象,其属性名对应于数据存储的各个部分以及管理它们的 reducers。在清单中,我让原来的缩减器负责数据存储的modelData部分,让清单 19-15 中定义的缩减器负责stateData部分。组合的缩减器被传递给createStore函数来创建数据存储。

注意

每个缩减器在数据存储的一个单独部分上操作,但是当一个动作被处理时,每个缩减器被传递该动作,直到其中一个缩减器返回一个新的数据存储对象,指示该动作已经被处理。

向存储中的数据添加结构需要对模型数据的 reducer 函数返回的初始状态进行相应的更改,如清单 19-17 所示。

import { STORE, UPDATE, DELETE } from "./modelActionTypes";
import { initialData } from "./initialData";

export default function(storeData, action) {
    switch (action.type) {
        case STORE:
            return {
                ...storeData,
                [action.dataType]:
                    storeData[action.dataType].concat([action.payload])
            }
        case UPDATE:
            return {
                ...storeData,
                [action.dataType]: storeData[action.dataType].map(p =>
                    p.id === action.payload.id ? action.payload : p)
            }
        case DELETE:
            return {
                ...storeData,
                [action.dataType]: storeData[action.dataType]
                    .filter(p => p.id !== action.payload)
            }
        default:
            return storeData || initialData.modelData;
    }
}

Listing 19-17Changing the Initial State in the modelReducer.js File in the src/store Folder

当使用combineReducers功能时,每个缩减器仅被提供其在存储器中的数据部分,并不知道其余的数据和其他缩减器。这意味着我只需要改变初始数据的来源,而不必担心在应用一个动作时在新的数据结构中导航。

将 React 组件连接到存储的状态数据

现在,状态数据已经放入数据存储中,我可以将它连接到组件。我将定义单独的连接器组件,负责将数据存储特性映射到组件属性,而不是分别配置每个组件。我在src/store文件夹中创建了一个名为EditorConnector.js的文件,代码如清单 19-18 所示。

了解演示者/连接器模式

使用数据存储的一种常见方法是使用两种不同类型的组件。演示者组件负责向用户呈现内容并响应用户输入。它们接收数据和函数属性,但不直接连接到数据存储。连接器组件——令人困惑的是,也被称为容器组件——连接到数据存储,为 presenter 组件提供属性。这是我在本章的这一部分中所采用的一般方法,尽管与 React/Redux 世界中的许多方法一样,实现细节可能会有所不同,并且对于如何最好地实现这种分离还存在争议。

import { connect } from "react-redux";
import { endEditing } from "./stateActions";
import { saveProduct, saveSupplier } from "./modelActionCreators";
import { PRODUCTS, SUPPLIERS  } from "./dataTypes";

export const EditorConnector = (dataType, presentationComponent) => {

    const mapStateToProps = (storeData) => ({
        editing: storeData.stateData.editing
            && storeData.stateData.selectedType === dataType,
        product: (storeData.modelData[PRODUCTS]
            .find(p => p.id === storeData.stateData.selectedId)) || {},
        supplier:(storeData.modelData[SUPPLIERS]
            .find(s => s.id === storeData.stateData.selectedId)) || {}
    })

    const mapDispatchToProps = {
        cancelCallback: endEditing,
        saveCallback: dataType === PRODUCTS ? saveProduct: saveSupplier
    }

    return connect(mapStateToProps, mapDispatchToProps)(presentationComponent);
}

Listing 19-18The Contents of the EditorConnector.js File in the src/store Folder

EditorConnector是一个高阶组件,它为表示组件提供了ProductEditorSupplierEditor组件所需的属性,这意味着这些组件可以使用相同的代码连接到数据存储,而不需要单独使用connect函数。为了支持这两种类型的编辑器,HOC 函数接受一种数据类型,用于选择将被映射到 props 的数据和动作创建者。

小费

注意,由combineReducers函数创建的数据存储的分段对数据选择没有任何影响,这意味着我可以从整个存储中选择数据。

为了给显示表格组件的组件提供相同的服务,我在src/store文件夹中添加了一个名为TableConnector.js的文件,并用它来定义清单 19-19 中所示的 HOC。

import { connect } from "react-redux";
import { startEditingProduct, startEditingSupplier } from "./stateActions";
import { deleteProduct, deleteSupplier } from "./modelActionCreators";
import { PRODUCTS, SUPPLIERS } from "./dataTypes";

export const TableConnector = (dataType, presentationComponent) => {

    const mapStateToProps = (storeData) => ({
        products: storeData.modelData[PRODUCTS],
        suppliers: storeData.modelData[SUPPLIERS]
    })

    const mapDispatchToProps = {
        editCallback: dataType === PRODUCTS
            ? startEditingProduct : startEditingSupplier,
        deleteCallback: dataType === PRODUCTS ? deleteProduct : deleteSupplier
    }

    return connect(mapStateToProps, mapDispatchToProps)(presentationComponent);
}

Listing 19-19The Contents of the TableConnector.js File in the src/store Folder

应用连接器组件

连接器组件就位后,我可以从ProductDisplaySupplierDisplay组件中移除状态数据和方法。清单 19-20 显示了ProductDisplay组件的简化。

import React, { Component } from "react";
import { ProductTable } from "./ProductTable"
import { ProductEditor } from "./ProductEditor";
import { connect } from "react-redux";

//import { saveProduct, deleteProduct } from "./store"

import { EditorConnector } from "./store/EditorConnector";

import { PRODUCTS } from "./store/dataTypes";

import { TableConnector } from "./store/TableConnector";

import { startCreatingProduct } from "./store/stateActions";

const ConnectedEditor = EditorConnector(PRODUCTS, ProductEditor);

const ConnectedTable = TableConnector(PRODUCTS, ProductTable);

const mapStateToProps = (storeData) => ({
    editing: storeData.stateData.editing,
    selected: storeData.modelData.products
        .find(item =>  item.id === storeData.stateData.selectedId) || {}
})

const mapDispatchToProps = {
    createProduct: startCreatingProduct,
}

const connectFunction = connect(mapStateToProps, mapDispatchToProps);

export const ProductDisplay = connectFunction(
    class extends Component {

        // constructor(props) {
        //     super(props);
        //     this.state = {
        //         showEditor: false,
        //         selectedProduct: null
        //     }
        // }

        // startEditing = (product) => {
        //     this.setState({ showEditor: true, selectedProduct: product })
        // }

        // createProduct = () => {
        //     this.setState({ showEditor: true, selectedProduct: {} })
        // }

        // cancelEditing = () => {
        //     this.setState({ showEditor: false, selectedProduct: null })
        // }

        // saveProduct = (product) => {
        //     this.props.saveCallback(product);
        //     this.setState({ showEditor: false, selectedProduct: null })
        // }

        render() {
            if (this.props.editing) {
                return <ConnectedEditor key={ this.props.selected.id || -1 } />
                // return <ProductEditor
                //     key={ this.state.selectedProduct.id || -1 }
                //     product={ this.state.selectedProduct }
                //     saveCallback={ this.saveProduct }
                //     cancelCallback={ this.cancelEditing } />
            } else {
                return <div className="m-2">
                    <ConnectedTable />
                    {/* <ProductTable products={ this.props.products }
                        editCallback={ this.startEditing }
                        deleteCallback={ this.props.deleteCallback } />  */}
                    <div className="text-center">
                        <button className="btn btn-primary m-1"
                            onClick={ this.props.createProduct }>
                            Create Product
                        </button>
                    </div>
                </div>
            }
        }
    })

Listing 19-20Using Connector Components in the ProductDisplay.js File in the src Folder

注释掉的语句的数量显示了专用于向其子级提供数据和函数属性的ProductDisplay组件的数量,所有这些现在都通过数据存储和连接器组件来处理。不再需要本地状态数据,因此可以移除构造函数和除了render c之外的所有方法。但是,该组件仍然需要连接到数据存储,因为它需要知道显示哪个子组件,并且需要为编辑器组件生成键值。

清单 19-21 显示了简化的SupplierDisplay组件,去掉了多余的语句,而不仅仅是注释掉。

import React, { Component } from "react";
import { SupplierEditor } from "./SupplierEditor";
import { SupplierTable } from "./SupplierTable";
import { connect } from "react-redux";
import { startCreatingSupplier } from "./store/stateActions";
import { SUPPLIERS } from "./store/dataTypes";
import { EditorConnector } from "./store/EditorConnector";
import { TableConnector } from "./store/TableConnector";

const ConnectedEditor = EditorConnector(SUPPLIERS, SupplierEditor);
const ConnectedTable = TableConnector(SUPPLIERS, SupplierTable);

const mapStateToProps = (storeData) => ({
    editing: storeData.stateData.editing,
    selected: storeData.modelData.suppliers
        .find(item => item.id === storeData.stateData.selectedId) || {}
})

const mapDispatchToProps = {
    createSupplier: startCreatingSupplier
}

const connectFunction = connect(mapStateToProps, mapDispatchToProps);

export const SupplierDisplay = connectFunction(
    class extends Component {

        render() {
            if (this.props.editing) {
                return <ConnectedEditor key={ this.props.selected.id || -1 } />
            } else {
                return <div className="m-2">
                    <ConnectedTable />
                    <div className="text-center">
                        <button className="btn btn-primary m-1"
                            onClick={ this.props.createSupplier }>
                                Create Supplier
                        </button>
                    </div>
                </div>
            }
        }
    })

Listing 19-21Using Connector Components in the SupplierDisplay.js File in the src Folder

过度简化组件

随着我将数据存储的使用进一步推进到组件层次结构中,产品和供应商数据组件之间的差异已经减少,这些组件正在融合。此时,我可以用处理两种数据类型的单个组件替换ProductDisplaySupplierDisplay组件,并继续从数据存储中驱动整个应用。然而,实际上,到了某一点,融合不再简化应用,而是开始简单地转移复杂性。随着您获得使用数据存储的经验,您将会发现您对数据存储的依赖程度和组件中的重复量感到满意。像 React 和 Redux 开发一样,这既是一种好的实践,也是一种个人偏好,值得尝试,直到找到适合自己的方法。

调度多个操作

示例应用使用数据存储的方式存在问题。如果您创建一个新对象或编辑一个现有对象,点击保存按钮更新数据存储但不改变显示给用户的组件,如图 19-4 所示,并且您必须点击取消按钮来更新改变所选组件的数据值。

img/473159_1_En_19_Fig4_HTML.jpg

图 19-4

使用示例应用进行更改

问题在于,将动作创建者映射到属性的connect函数默认只允许选择一个动作创建者,但是我需要两个动作创建者来解决这个问题:更新模型数据的saveProductsaveSupplier创建者和发出编辑完成信号并向用户显示表格的endEditing创建者。

我不能定义一个新的创建者来执行这两项任务,因为每个操作都由一个缩减器处理,每个缩减器负责存储中数据的一个独立部分,这意味着一个操作可以导致模型数据或状态数据的改变,但不能同时导致两者的改变。

幸运的是,connect函数提供了另一种将属性映射到动作创建者的方法,这种方法提供了更多的灵活性。当 connect 方法的mapDispatchToProps参数为对象时,connect函数将每个动作创建者函数包装在一个dispatch方法中,该方法负责将动作创建者返回的动作发送给 reducer。这意味着这样映射创建者的对象:

...
const mapDispatchToProps = {
    createSupplier: startCreatingSupplier
}
...

被转化成这样一个物体:

...
const mapDispatchToProps = {
    createSupplier: payload => dispatch(startCreatingSupplier(payload))
}
...

调用动作创建器来获取动作,然后将动作传递给dispatch函数,这样它就可以被一个缩减器处理。不要定义一个对象,让connect函数包装每个创建者,你可以定义一个接受dispatch作为参数的函数,并产生明确处理动作创建和分派的属性,如清单 19-22 所示。

import { connect } from "react-redux";
import { endEditing } from "./stateActions";
import { saveProduct, saveSupplier } from "./modelActionCreators";
import { PRODUCTS, SUPPLIERS  } from "./dataTypes";

export const EditorConnector = (dataType, presentationComponent) => {

    const mapStateToProps = (storeData) => ({
        editing: storeData.stateData.editing
            && storeData.stateData.selectedType === dataType,
        product: (storeData.modelData[PRODUCTS]
            .find(p => p.id === storeData.stateData.selectedId)) || {},
        supplier:(storeData.modelData[SUPPLIERS]
            .find(s => s.id === storeData.stateData.selectedId)) || {}
    })

    const mapDispatchToProps = dispatch => ({
        cancelCallback: () => dispatch(endEditing()),
        saveCallback: (data) => {
            dispatch((dataType === PRODUCTS ? saveProduct: saveSupplier)(data));
            dispatch(endEditing());
        }
    });

    return connect(mapStateToProps, mapDispatchToProps)(presentationComponent);
}

Listing 19-22Dispatching Actions in the EditorConnector.js File in the src Folder

需要一个调度动作的函数作为每个映射属性的值,实现可以简单地调用动作创建者,或者在使用saveCallback属性的情况下,创建并调度多个动作。结果是由编辑器组件呈现的保存按钮调用一个功能属性,该属性分派更新模型数据和状态数据的动作,如图 19-5 所示。

img/473159_1_En_19_Fig5_HTML.jpg

图 19-5

调度多个操作

理解对参考文献的需求

您可能已经注意到,我使用id属性值和数据类型的组合来跟踪用户选择的对象,如下所示:

...
stateData: {
    editing: false,
    selectedId: -1,
    selectedType: PRODUCTS
}
...

表组件将一个完整的对象传递给启动编辑过程的动作创建者,您可能想知道为什么我选择只保留对所选对象的 ID 引用,而不存储对象本身,特别是因为这种方法需要一些额外的工作来为编辑器组件获取对象。

...
const mapStateToProps = (storeData) => ({
    editing: storeData.stateData.editing
        && storeData.stateData.selectedType === dataType,
    product: (storeData.modelData[PRODUCTS]
        .find(p => p.id === storeData.stateData.selectedId)) || {},
    supplier:(storeData.modelData[SUPPLIERS]
        .find(s => s.id === storeData.stateData.selectedId)) || {}
})
...

间接性是必需的,因为数据存储代表应用中的权威数据源,它可能会被将数据连接到组件的选择器更改。作为演示,我在TableConnector连接器组件中更改了供应商数据的选择器,如清单 19-23 所示。

import { connect } from "react-redux";
import { startEditingProduct, startEditingSupplier } from "./stateActions";
import { deleteProduct, deleteSupplier } from "./modelActionCreators";
import { PRODUCTS, SUPPLIERS } from "./dataTypes";

export const TableConnector = (dataType, presentationComponent) => {

    const mapStateToProps = (storeData) => ({
        products: storeData.modelData[PRODUCTS],
        suppliers: storeData.modelData[SUPPLIERS].map(supp => ({
            ...supp,
            products: supp.products.map(id =>
                storeData.modelData[PRODUCTS].find(p => p.id === Number(id)) || id)
                    .map(val => val.name || val)
        }))
    })

    const mapDispatchToProps = {
        editCallback: dataType === PRODUCTS
            ? startEditingProduct : startEditingSupplier,
        deleteCallback: dataType === PRODUCTS ? deleteProduct : deleteSupplier
    }

    return connect(mapStateToProps, mapDispatchToProps)(presentationComponent);
}

Listing 19-23Changing a Selector in the TableConnector.js File in the src Folder

新的选择器匹配供应商和产品数据,用包含相应产品的名称而不是值的属性替换每个供应商对象的products p属性,如图 19-6 所示。

img/473159_1_En_19_Fig6_HTML.jpg

图 19-6

改变选择器中的数据

每当需要相同的数据视图时,在选择器中转换数据可以确保一致性,但这并不意味着连接的组件不再使用数据存储中的原始数据。因此,依赖一个组件接收的数据来驱动另一个组件的行为可能会导致问题,正因为如此,我使用 ID 值来跟踪用户选择进行编辑的对象。

摘要

在本章中,我创建了一个 Redux 数据存储,并将其连接到示例应用中的组件。我向您展示了如何定义动作、动作创建者、缩减者和选择器,并且演示了如何将数据存储特性作为属性呈现给组件。在下一章,我将描述 Redux 通过其 API 提供的高级特性。

二十、使用数据存储 API

在第十九章中,我向您展示了如何使用 Redux 和 React-Redux 包来创建数据存储并将其连接到示例应用。在这一章中,我描述了这两个包提供的高级使用的 API,允许直接访问数据存储和管理组件和它需要的数据特性之间的连接。表 20-1 将数据存储 API 放在上下文中。

表 20-1

将数据存储 API 放在上下文中

|

问题

|

回答

|
| --- | --- |
| 它们是什么? | Redux 和 React-Redux 包都定义了支持高级使用的 API,超出了第十九章描述的基本技术。 |
| 它们为什么有用? | 这些 API 对于探索数据存储如何工作以及组件如何与它们连接非常有用。它们还可以用于向数据存储添加功能,以及微调应用对数据存储的使用。 |
| 它们是如何使用的? | Redux API 直接用在数据存储对象上或在其创建期间使用。React-Redux API 在将组件连接到数据存储时使用,要么使用connect函数,要么使用其更灵活的connectAdvanced替代函数。 |
| 有什么陷阱或限制吗? | 本章中描述的 API 需要仔细考虑,以确保达到预期的效果。很容易创建不能正确响应数据存储更改或更新过于频繁的应用。 |
| 还有其他选择吗? | 您不必使用本章中描述的 API,大多数项目只需使用第十九章中描述的基本技术就能有效地利用数据存储。 |

表 20-2 总结了本章内容。

表 20-2

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 访问 Redux 数据存储 API | 使用由createStore方法返回的数据存储对象定义的方法 | 2–4 |
| 观察数据存储的变化 | 使用 subscribe 方法 | five |
| 调度操作 | 使用分派方法 | six |
| 创建自定义连接器 | 将组件的属性映射到数据存储功能 | 7–8 |
| 将要素添加到数据存储中 | 创建一个还原增强器 | 9–11 |
| 在传递给缩减器之前处理操作 | 创建中间件功能 | 12–16 |
| 扩展数据存储 API | 创建一个增强功能 | 17–19 |
| 将组件的属性合并到数据存储映射中 | 使用 connect 函数的可选参数 | 20–24 |

为本章做准备

在本章中,我继续使用在第十八章创建并在第十九章修改的productapp项目。本章不需要修改。打开一个新的命令提示符,导航到productapp文件夹,运行清单 20-1 中所示的命令来启动开发工具。

小费

你可以从 https://github.com/Apress/pro-react-16 下载本章以及本书其他章节的示例项目。

npm start

Listing 20-1Starting the Development Tools

一旦开发工具启动,一个新的浏览器窗口将打开并显示如图 20-1 所示的内容。

img/473159_1_En_20_Fig1_HTML.jpg

图 20-1

运行示例应用

使用 Redux 数据存储 API

在大多数 React 应用中,对 Redux 数据存储的访问是通过 React-Redux 包进行的,该包将数据存储特性映射到 props。这是使用 Redux 最方便的方式,但是还有一个完整的 API 提供对数据存储特性的直接访问,我将在接下来的小节中描述,从提供对存储中数据的访问的特性开始。

在第十九章中,我使用 Redux createStore函数创建了一个新的数据存储,这样我就可以将它作为属性从 React-Redux 包中传递给Provider组件。createStore函数返回的对象也可以通过表 20-3 中描述的四种方法直接使用。

表 20-3

数据存储方法

|

名字

|

描述

|
| --- | --- |
| getState() | 该方法从数据存储中返回数据,如“获取数据存储状态”一节中所述。 |
| subscribe(listener) | 该方法注册一个函数,每次对数据存储进行更改时都会调用该函数,如“观察数据存储更改”一节中所述。 |
| dispatch(action) | 该方法接受一个动作,通常由动作创建者产生,并将其发送到数据存储,以便 reducer 可以处理它,如“调度动作”一节所述。 |
| replaceReducer(next) | 此方法取代了数据存储用来处理动作的 reducer。这种方法在大多数项目中没有用,中间件提供了一种更有用的机制来改变数据存储的行为。 |

获取数据存储状态

getState方法返回数据存储中的数据,并允许读取存储的内容。作为演示,我在store文件夹中添加了一个名为StoreAccess.js的文件,并用它来定义清单 20-2 中所示的组件。

import React, { Component } from "react";

export class StoreAccess extends Component {

    render() {
        return <div className="bg-info">
            <pre className="text-white">
                { JSON.stringify(this.props.store.getState(), null, 2) }
            </pre>
        </div>
    }
}

Listing 20-2The Contents of the StoreAccess.js File in the src/store Folder

组件接收数据存储对象作为属性,并调用getState方法,该方法返回存储的数据对象。为了格式化数据,我使用了JSON.stringify方法,该方法将 JavaScript 对象序列化为 JSON,然后格式化结果以便于阅读。在清单 20-3 中,我添加了一个网格布局,这样新的组件就会显示在应用其余功能的旁边。

import React, { Component } from "react";
import { Provider } from "react-redux";
import dataStore from "./store";
import { Selector } from "./Selector";
import { ProductDisplay } from "./ProductDisplay";
import { SupplierDisplay } from "./SupplierDisplay";

import { StoreAccess } from "./store/StoreAccess";

export default class App extends Component {

    render() {
        return <div className="container-fluid">
            <div className="row">
                <div className="col-3">
                    <StoreAccess store={ dataStore } />
                </div>
                <div className="col">
                    <Provider store={ dataStore }>
                        <Selector>
                            <ProductDisplay name="Products" />
                            <SupplierDisplay name="Suppliers" />
                        </Selector>
                    </Provider>
                </div>
            </div>
        </div>
    }
}

Listing 20-3Displaying the Data Store Contents in the App.js File in the src Folder

一个商店中可能有很多数据,所以我显示了 JSON 文本,这样它将出现在自己的列中,如图 20-2 所示。如果您不能在屏幕上显示所有的文本,请不要担心,因为我很快会将焦点缩小到数据的子集。

img/473159_1_En_20_Fig2_HTML.jpg

图 20-2

获取数据存储的内容

如果您检查通过getState方法获得的数据,您将会看到所有的内容都包含在内,因此modelDatastateData属性的内容都是可用的。应用于 reducers 的分段不会影响由getState方法返回的数据,该方法提供了对数据存储中所有内容的访问。

缩小对特定数据的关注

为了更容易跟踪数据存储的内容,我将重点关注由getState方法返回的数据的子集,这将允许我更容易地演示其他 Redux 特性。在清单 20-4 中,我修改了StoreAccess组件,使其只显示第一个产品对象和一组状态数据变量。

import React, { Component } from "react";

export class StoreAccess extends Component {

    constructor(props) {
        super(props);
        this.selectors = {
            product: (storeState) => storeState.modelData.products[0],
            state: (storeState) => storeState.stateData
        }
    }

    render() {
        return <div className="bg-info">
            <pre className="text-white">
                { JSON.stringify(this.selectData(), null, 2) }
            </pre>
        </div>
    }

    selectData() {
        let storeState = this.props.store.getState();
        return Object.entries(this.selectors).map(([k, v]) => [k, v(storeState)])
            .reduce((result, [k, v]) => ({ ...result, [k]: v}), {});
    }
}

Listing 20-4Focusing the Data in the StoreAccess.js File in the src/store Folder

我定义了一个selectors对象,它的属性值是从存储中选择数据的函数。selectData方法使用getState方法从数据存储中获取数据,并调用每个选择器函数来生成由组件呈现的数据。(entriesmapreduce方法的使用产生了一个对象,该对象具有与selectors属性相同的属性名称,并具有通过调用每个选择器函数产生的值。)

对组件的更改从存储中选择了更易管理的数据部分,如图 20-3 所示。

img/473159_1_En_20_Fig3_HTML.jpg

图 20-3

选择商店数据的子集

观察数据存储更改

getState方法返回的对象是存储中数据的快照,当存储改变时不会自动更新。通常的 React 变化检测特性对存储不起作用,因为它不是组件状态数据的一部分。因此,对存储中数据的更改不会触发 React 更新。

Redux 提供了subscribe方法来在数据存储发生变化时接收通知,这允许再次调用getState方法来获得数据的新快照。在清单 20-5 中,我在StoreAccess组件中使用了subscribe方法来确保组件显示的数据是最新的。

import React, { Component } from "react";

export class StoreAccess extends Component {

    constructor(props) {
        super(props);
        this.selectors = {
            product: (storeState) => storeState.modelData.products[0],
            state: (storeState) => storeState.stateData
        }
        this.state = this.selectData();
    }

    render() {
        return <div className="bg-info">
            <pre className="text-white">
                { JSON.stringify(this.state, null, 2) }
            </pre>
        </div>
    }

    selectData() {
        let storeState = this.props.store.getState();
        return Object.entries(this.selectors).map(([k, v]) => [k, v(storeState)])
            .reduce((result, [k, v]) => ({ ...result, [k]: v}), {});
    }

    handleDataStoreChange() {
        let newData = this.selectData();
        Object.keys(this.selectors)
            .filter(key => this.state[key] !== newData[key])
            .forEach(key => this.setState({ [key]: newData[key]}));
    }

    componentDidMount() {
        this.unsubscriber =
            this.props.store.subscribe(() => this.handleDataStoreChange());
    }

    componentWillUnmount() {
        this.unsubscriber();
    }
}

Listing 20-5Subscribing to Change Notifications in the StoreAccess.js File in the src/store Folder

我在componentDidMount方法中订阅更新。subscribe方法的结果是一个可以用来取消订阅更新的函数,我在componentWillUnmount方法中调用了这个函数。

subscribe方法的参数是一个当数据存储发生变化时将被调用的函数。没有向该函数提供任何参数,这只是一个信号,表明已经发生了更改,并且可以使用getState方法来获取数据存储的新内容。

Redux 不提供任何关于哪些数据发生了变化的信息,所以我定义了handleStoreChange方法来检查每个选择器函数获得的数据,以查看组件呈现的数据是否发生了变化。我使用组件状态数据特性来跟踪显示的数据,并使用setState方法来触发更新。重要的是,只有当组件显示的数据已经改变时,才执行状态改变;否则,将对数据存储的每次更改执行更新。

要查看更改的效果,请单击 Trail Shoes 产品的“编辑”按钮,对“名称”字段进行更改,然后单击“保存”按钮。当你经历这个过程时,StoreAccess组件显示的数据将反映数据存储中的变化,如图 20-4 所示。

img/473159_1_En_20_Fig4_HTML.jpg

图 20-4

从数据存储接收变更通知

调度操作

可以使用dispatch方法来分派动作,当我需要分派多个动作时,React-Redux 包在第十九章中提供了对同一dispatch的访问。

正如我在第十九章中解释的,行动是通过行动创造者创造的。在清单 20-6 中,我向StoreAccess组件添加了一个button,该组件使用一个动作创建器来获取一个动作对象,然后使用dispatch方法将该对象发送到数据存储。

import React, { Component } from "react";

import { startCreatingProduct } from "./stateActions";

export class StoreAccess extends Component {

    constructor(props) {
        super(props);
        this.selectors = {
            product: (storeState) => storeState.modelData.products[0],
            state: (storeState) => storeState.stateData
        }
        this.state = this.selectData();
    }

    render() {
        return <React.Fragment>
            <div className="text-center">
                <button className="btn btn-primary m-1"
                    onClick={ this.dispatchAction }>
                        Dispatch Action
                </button>
            </div>
            <div className="bg-info">
                <pre className="text-white">
                    { JSON.stringify(this.state, null, 2) }
                </pre>
            </div>
        </React.Fragment>
    }

    dispatchAction = () => {
        this.props.store.dispatch(startCreatingProduct())
    }

    selectData() {
        let storeState = this.props.store.getState();
        return Object.entries(this.selectors).map(([k, v]) => [k, v(storeState)])
            .reduce((result, [k, v]) => ({ ...result, [k]: v}), {});
    }

    handleDataStoreChange() {
        let newData = this.selectData();
        Object.keys(this.selectors)
            .filter(key => this.state[key] !== newData[key])
            .forEach(key => this.setState({ [key]: newData[key]}));
    }

    componentDidMount() {
        this.unsubscriber =
            this.props.store.subscribe(() => this.handleDataStoreChange());
    }

    componentWillUnmount() {
        this.unsubscriber();
    }
}

Listing 20-6Dispatching an Action in the StoreAccess.js File in the src/store Folder

按钮通过调用dispatchAction方法来响应点击事件,该方法调用startCreatingProduct动作创建器并将结果传递给数据存储的dispatch方法。结果是点击按钮切换显示编辑器,如图 20-5 所示。

img/473159_1_En_20_Fig5_HTML.jpg

图 20-5

调度操作

创建连接器组件

从存储中获取当前数据、接收更改通知和分派操作的能力提供了创建基本连接器组件的所有特性,该组件提供了我在示例应用中使用的 React-Redux 包的基本等价物。为了创建通过 Redux API 将组件连接到数据存储的工具,我在store文件夹中添加了一个名为CustomConnector.js的文件,并添加了清单 20-7 中所示的代码。

警告

我不建议在实际项目中使用自定义连接器。React-Redux 包具有额外的特性,并且已经过全面的测试,但是将核心 React 特性与 Redux 数据存储 API 相结合提供了一个如何创建高级特性的有用示例。

import React, { Component } from "react";

export const CustomConnectorContext = React.createContext();

export class CustomConnectorProvider extends Component {

    render() {
        return <CustomConnectorContext.Provider value={ this.props.dataStore }>
            { this.props.children }
        </CustomConnectorContext.Provider>
    }
}

export class CustomConnector extends React.Component {
    static contextType = CustomConnectorContext;

    constructor(props, context) {
        super(props, context);
        this.state = this.selectData();
        this.functionProps = Object.entries(this.props.dispatchers)
            .map(([k, v]) => [k, (...args) => this.context.dispatch(v(...args))])
            .reduce((result, [k, v]) => ({...result, [k]: v}), {})
    }

    render() {
        return  React.Children.map(this.props.children, c =>
            React.cloneElement(c, { ...this.state, ...this.functionProps }))
    }

    selectData() {
        let storeState = this.context.getState();
        return Object.entries(this.props.selectors).map(([k, v]) =>
                [k, v(storeState)])
            .reduce((result, [k, v]) => ({ ...result, [k]: v}), {});
    }

    handleDataStoreChange() {
        let newData = this.selectData();
        Object.keys(this.props.selectors)
            .filter(key => this.state[key] !== newData[key])
            .forEach(key => this.setState({ [key]: newData[key]}));
    }

    componentDidMount() {
        this.unsubscriber =
            this.context.subscribe(() => this.handleDataStoreChange());
    }

    componentWillUnmount() {
        this.unsubscriber();
    }
}

Listing 20-7The Contents of the CustomConnector.js File in the src/store Folder

我已经使用上下文 API 通过一个CustomConnectorProvider组件使数据存储可用,该组件由一个接收选择器和动作创建者属性的CustomConnector组件接收。选择器属性被处理来设置组件的状态,以便检测和处理变化,而动作创建器属性被包装在dispatch方法中,以便它们可以作为功能属性被连接的子组件调用。为了演示定制连接器,我将清单 20-8 中所示的内容添加到了App组件中。

import React, { Component } from "react";
import { Provider } from "react-redux";
import dataStore, { deleteProduct } from "./store";
import { Selector } from "./Selector";
import { ProductDisplay } from "./ProductDisplay";
import { SupplierDisplay } from "./SupplierDisplay";
import { StoreAccess } from "./store/StoreAccess";

import { CustomConnector, CustomConnectorProvider } from "./store/CustomConnector";

import { startEditingProduct } from "./store/stateActions";

import { ProductTable } from "./ProductTable";

const selectors = {

    products: (store) => store.modelData.products

}

const dispatchers = {

    editCallback: startEditingProduct,
    deleteCallback: deleteProduct

}

export default class App extends Component {

    render() {
        return <div className="container-fluid">
            <div className="row">
                <div className="col-3">
                    <StoreAccess store={ dataStore } />
                </div>
                <div className="col">
                    <Provider store={ dataStore }>
                        <Selector>
                            <ProductDisplay name="Products" />
                            <SupplierDisplay name="Suppliers" />
                        </Selector>
                    </Provider>
                </div>
            </div>
            <div className="row">
                <div className="col">
                    <CustomConnectorProvider dataStore={ dataStore }>
                        <CustomConnector selectors={ selectors }
                                dispatchers={ dispatchers }>
                            <ProductTable/>
                        </CustomConnector>
                    </CustomConnectorProvider>
                </div>
            </div>
        </div>
    }
}

Listing 20-8Using the Custom Connector in the App.js File in the src Folder

我不想替换现有的应用内容,所以我在引导网格布局中添加了一行,并用它来显示一个使用清单 20-8 中定义的组件连接到数据存储的ProductTable组件。为了简洁起见,CustomConnector组件被定义为CustomConnectorProvider组件的子组件,其作用是将选择器和动作创建者映射到呈现给ProductTable组件的属性。结果是应用显示第二个产品表,这两个表都反映了它们所显示的数据的变化,如图 20-6 所示。

img/473159_1_En_20_Fig6_HTML.jpg

图 20-6

使用自定义数据存储连接器

增强还原剂

正如我在第十九章中解释的,缩减器是一个处理动作和更新数据存储的函数。一个缩减器增强器是一个函数,它接受一个或多个正常的缩减器,并使用它们向数据存储添加额外的特性。

当使用一个缩减器增强器时,Redux 没有特别的意识,因为结果看起来就像一个常规的缩减器,并以同样的方式传递给createStore方法,正如来自src/store文件夹中的index.js文件的语句所示:

...
export default createStore(combineReducers(
    {
        modelData: modelReducer,
        stateData: stateReducer
    }));
...

combineReducers函数是 Redux 内置的一个 reducer 增强器,我在第十九章中使用它来保持模型和状态数据的 reducer 逻辑分离。

缩减增强器很有用,因为它们在处理动作之前接收动作,这意味着它们可以改变动作,拒绝动作,或者以特殊的方式处理动作,比如使用多个缩减器来处理动作,这就是combineReducers函数所做的。

为了演示一个 reducer enhancer,我在store文件夹中添加了一个名为customReducerEnhancer.js的文件,并添加了清单 20-9 中所示的代码。

import { initialData } from "./initialData";

export const STORE_RESET = "store_clear";

export const resetStore = () => ({ type: STORE_RESET });

export function customReducerEnhancer(originalReducer) {

    let intialState = null;

    return (storeData, action) => {
        if (action.type === STORE_RESET && initialData != null) {
            return intialState;
        } else {
            const result = originalReducer(storeData, action);
            if (intialState == null) {
                intialState = result;
            }
            return result;
        }
    }
}

Listing 20-9The Contents of the customReducerEnhancer.js File in the src/store Folder

customReducerEnhancer函数接受一个 reducer 作为它的参数,并返回一个新的 reducer 函数,这个函数可以被数据存储使用。enhancer 函数记录数据存储的初始状态,这是通过发送给 reducers 的第一个操作获得的。新的动作类型STORE_RESET使增强器函数返回初始数据存储状态,这具有重置数据存储的效果。所有其他动作都传递给普通减速器。为了帮助实现商店重置特性,清单 20-9 定义了一个resetStore动作创建函数。在清单 20-10 中,我将 reducer enhancer 应用于数据存储。

import { createStore, combineReducers } from "redux";
import modelReducer from "./modelReducer";
import stateReducer from "./stateReducer";

import { customReducerEnhancer } from "./customReducerEnhancer";

const enhancedReducer = customReducerEnhancer(

    combineReducers(
        {
            modelData: modelReducer,
            stateData: stateReducer
        })

);

export default createStore(enhancedReducer);

export { saveProduct, saveSupplier, deleteProduct, deleteSupplier }
    from "./modelActionCreators";

Listing 20-10Applying a Reducer Enhancer in the index.js File in the src/store Folder

还原增强剂可以结合使用。在这个清单中,我使用由combineReducers函数创建的缩减器作为customReducerEnhancer函数的参数。在清单 20-11 中,我使用了resetStore动作创建器在用户点击由StoreAccess组件呈现的按钮时创建一个动作。

import React, { Component } from "react";

//import { startCreatingProduct } from "./stateActions";

import { resetStore } from "./customReducerEnhancer";

export class StoreAccess extends Component {

    constructor(props) {
        super(props);
        this.selectors = {
            product: (storeState) => storeState.modelData.products[0],
            state: (storeState) => storeState.stateData
        }
        this.state = this.selectData();
    }

    render() {
        return <React.Fragment>
            <div className="text-center">
                <button className="btn btn-primary m-1"
                    onClick={ this.dispatchAction }>
                        Dispatch Action
                </button>
            </div>
            <div className="bg-info">
                <pre className="text-white">
                    { JSON.stringify(this.state, null, 2) }
                </pre>
            </div>
        </React.Fragment>
    }

    dispatchAction = () => {
        this.props.store.dispatch(resetStore())
    }

    // ...other methods omitted for brevity...
}

Listing 20-11Changing Actions in the StoreAccess.js File in the src/store Folder

增强器的作用是当用户点击 Dispatch Action 按钮时,应用的状态和模型数据被重置,结果是任何更改都被丢弃,如图 20-7 所示。

img/473159_1_En_20_Fig7_HTML.jpg

图 20-7

重置存储中的数据

使用数据存储中间件

Redux 提供了对数据存储中间件的支持,这些中间件是在动作被传递给dispatch方法之后、到达 reducer 之前接收动作的函数,允许它们被拦截、转换或以其他方式处理。中间件最常见的用途是添加对执行异步任务的动作的支持,并将动作包装在函数中,以便它们可以有条件地或在将来被调度。

注意

有一些中间件包可以解决常见的项目需求,您应该考虑用它们来代替编写定制代码。redux-promise包支持异步动作(见https://github.com/redux-utilities/redux-promise),redux-thunk包支持返回函数的动作创建器(见 https://github.com/reduxjs/redux-thunk )。然而,我发现这两个包都不符合我的要求,所以我更喜欢创建自己的中间件。

为了演示中间件的使用,我在src/store文件夹中添加了一个名为multiActionMiddleware.js的文件,并添加了清单 20-12 中所示的代码。

export function multiActions({dispatch, getState}) {
    return function receiveNext(next) {
        return function processAction(action) {
            if (Array.isArray(action)) {
                action.forEach(a => next(a));
            } else {
                next(action);
            }
        }
    }
}

Listing 20-12The Contents of the multiActionMiddleware.js File in the src/store Folder

中间件被表达为一组返回其他函数的函数,为了更容易理解,我在清单 20-12 中使用了function关键字。当中间件向数据存储注册时,调用外部函数multiActions,它接收数据存储的dispatchgetState方法,如下所示:

...
export function multiActions({dispatch, getState}) {
...

这为中间件提供了分派动作和获取数据存储中当前数据的能力。一个数据存储可以使用多个中间件组件;动作在链中从一个传递到下一个,然后传递给数据存储的dispatch方法。multiActions函数的工作是返回一个函数,该函数将在中间件链组装完成后被调用,并提供链中的下一个中间件组件。

...
export function multiActions({dispatch, getState}) {
    return function receiveNext(next) {
...

中间件组件通常会处理一个动作,然后通过调用next函数将它传递给链中的下一个组件。

receiveNext函数的结果是返回最里面的函数,当一个动作被分派到数据存储时调用这个函数,我在清单 20-12 中调用了这个函数processAction

...
export function multiActions({dispatch, getState}) {
    return function receiveNext(next) {
        return function processAction(action) {
...

这个函数能够在动作对象被传递到下一个中间件组件之前改变或替换它。也可以通过调用外部函数接收的dispatch方法来缩短链,或者什么都不做(在这种情况下,数据存储不会处理该动作)。我在清单 20-12 中定义的中间件组件检查动作是否是一个数组,在这种情况下,它将数组中包含的每个对象传递给下一个中间件组件进行处理。

定义嵌套函数有助于解释中间件组件是如何定义的,但是惯例是使用粗箭头函数,如清单 20-13 所示。

export const multiActions = ({dispatch, getState}) => next => action => {

    if (Array.isArray(action)) {
        action.forEach(a => next(a));
    } else {
        next(action);
    }
}

Listing 20-13Using Fat Arrow Functions in the multiActionMiddleware.js File in the src/store Folder

这与清单 20-12 的功能相同,但表述更简洁。Redux 提供了一个applyMiddlware函数,该函数用于创建与数据存储一起使用的中间件链,我在清单 20-14 中使用该函数将新的中间件组件添加到应用中。

import { createStore, combineReducers, applyMiddleware } from "redux";

import modelReducer from "./modelReducer";
import stateReducer from "./stateReducer";
import { customReducerEnhancer } from "./customReducerEnhancer";

import { multiActions } from "./multiActionMiddleware";

const enhancedReducer = customReducerEnhancer(
    combineReducers(
        {
            modelData: modelReducer,
            stateData: stateReducer
        })
);

export default createStore(enhancedReducer, applyMiddleware(multiActions));

export { saveProduct, saveSupplier, deleteProduct, deleteSupplier }
    from "./modelActionCreators";

Listing 20-14Registering Middleware in the index.js File in the src/store Folder

中间件函数作为参数传递给 Redux applyMiddleware函数,然后 ReduxapplyMiddleware函数的结果作为参数传递给createStore函数。

小费

多个中间件函数可以作为单独的参数传递给applyMiddleware函数,该函数将按照它们被指定的顺序将它们链接在一起。

既然数据存储可以处理动作数组,我就可以定义动作创建器来生成更复杂的结果,并允许更简单地表达连接器组件。我在src/store文件夹中添加了一个名为multiActionCreators.js的文件,并用它来定义清单 20-15 中所示的动作创建器。

import { PRODUCTS } from "./dataTypes";
import { saveProduct, saveSupplier } from "./modelActionCreators";
import { endEditing } from "./stateActions";

export const saveAndEndEditing = (data, dataType) =>
    [dataType === PRODUCTS ? saveProduct(data) : saveSupplier(data), endEditing()];

Listing 20-15The Contents of the multiActionCreators.js File in the src/store Folder

不要求将这样的动作创建器放在一个单独的文件中,但是这个创建器混合了影响模型和状态数据的动作,我更喜欢将它们分开。saveAndEndEditing动作接收一个数据对象和类型,并使用它产生一个动作数组,该数组将被中间件接收并按顺序分派。在清单 20-16 中,我替换了直接使用dispatch方法发送多个事件的EditorConnector组件中的语句。

import { connect } from "react-redux";
import { endEditing } from "./stateActions";

//import { saveProduct, saveSupplier } from "./modelActionCreators";

import { PRODUCTS, SUPPLIERS  } from "./dataTypes";

import { saveAndEndEditing } from "./multiActionCreators";

export const EditorConnector = (dataType, presentationComponent) => {

    const mapStateToProps = (storeData) => ({
        editing: storeData.stateData.editing
            && storeData.stateData.selectedType === dataType,
        product: (storeData.modelData[PRODUCTS]
            .find(p => p.id === storeData.stateData.selectedId)) || {},
        supplier:(storeData.modelData[SUPPLIERS]
            .find(s => s.id === storeData.stateData.selectedId)) || {}
    })

    const mapDispatchToProps = {
        cancelCallback: endEditing,
        saveCallback: (data) => saveAndEndEditing(data, dataType)
    }

    return connect(mapStateToProps, mapDispatchToProps)(presentationComponent);
}

Listing 20-16Dispatching Multiple Actions in the EditorConnector.js File in the src/store Folder

应用的行为没有变化,但是代码更简洁,更容易理解。

增强数据存储

大多数项目不需要修改数据存储的行为,如果需要,前一章描述的中间件特性就足够了。但是如果中间件不能提供足够的灵活性,一个更高级的选择是使用一个增强函数,这个函数负责创建数据存储对象,并且可以提供标准方法的包装器或者定义新的方法。

我之前用的applyMiddleware函数是增强器函数。这个函数取代了数据存储的dispatch方法,因此它可以在将动作传递给 reducer 之前,通过它的中间件组件链来引导动作。

为了演示增强器函数的使用,我将向数据存储中添加一个新方法,该方法异步调度操作。我在src/store文件夹中添加了一个名为asyncEnhancer.js的文件,并添加了清单 20-17 中所示的代码。

export function asyncEnhancer(delay) {
    return function(createStoreFunction) {
        return function(...args) {
            const store = createStoreFunction(...args);
            return {
                ...store,
                dispatchAsync: (action) => new Promise((resolve, reject) => {
                    setTimeout(() => {
                        store.dispatch(action);
                        resolve();
                    }, delay);
                })
            };
        }
    }
}

Listing 20-17The Contents of the asyncEnhancer.js File in the src/store Folder

增强器异步调度动作,返回一个Promise,一旦动作被调度,它就会被解析。示例应用中目前没有需要异步工作的任务,因此我在将动作分派给类似的后台活动之前引入了一个延迟。

这是另一个 Redux 特性,它需要一组嵌套的函数,我使用function关键字定义了这些函数,以便解释它们是如何组合在一起的。当增强器被应用到数据存储时,外部函数被调用,并提供一个机会来接收配置增强器行为的参数。清单 20-17 中最外层的函数接收一个动作被分派前应用的延迟长度。

...
export function asyncEnhancer(delay) {
...

现在它变得更加复杂:外部函数的结果是一个接收createStore函数的函数。功能这个词出现了太多次,以至于前面的句子无法立即理解,因此有必要解释一下发生了什么。

为了给增强器完全的控制权,Redux 让它们用一个定制的替代函数来代替createStore函数。但是大多数 reducers 只需要向标准数据存储添加特性,所以 Redux 提供了现有的createStore函数。

...
export function asyncEnhancer(delay) {
    return function(createStoreFunction) {
...

当增强器被应用时,这个函数将被调用,结果将被用来替换标准的createStore函数,这将我们带到清单 20-17 中最里面的函数,它完成所有的工作。

...
return function(...args) {
    const store = createStoreFunction(...args);
    return {
        ...store,
        dispatchAsync: (action) => new Promise((resolve, reject) => {

             // ...statements omitted for brevity...

        })
    };
}
...

创建数据存储时,Redux 调用增强器提供的函数,并将结果用作数据存储对象,确保应用的其余部分可以使用任何附加特性。在这种情况下,增强器使用标准的createStore函数,然后向结果添加一个dispatchAsync方法。新方法接收一个动作,并在一段延迟后调度它。使用function关键字可以更容易地看到嵌套函数之间的关系,但是增强器通常使用粗箭头函数来表示,如清单 20-18 所示。这是相同的功能,但表达更简洁。

export const asyncEnhancer = delay => createStoreFunction => (...args) => {

    const store = createStoreFunction(...args);
    return {
        ...store,
        dispatchAsync: (action) => new Promise((resolve, reject) => {
            setTimeout(() => {
                store.dispatch(action);
                resolve();
            }, delay);
        })
    };
}

Listing 20-18Using Arrow Functions in the asyncEnhancer.js File in the src/store Folder

应用增强剂

标准的createStore函数只能接受一个增强函数,我已经在使用applyMiddleware增强函数了。幸运的是,reducer 函数可以被组合,这样来自一个增强器的结果可以传递给另一个增强器。为了简化组合函数的过程,Redux 提供了compose函数,我在清单 20-19 中使用了这个函数来将新的增强器应用到数据存储中。

import { createStore, combineReducers, applyMiddleware, compose } from "redux";

import modelReducer from "./modelReducer";
import stateReducer from "./stateReducer";
import { customReducerEnhancer } from "./customReducerEnhancer";
import { multiActions } from "./multiActionMiddleware";
import { asyncEnhancer } from "./asyncEnhancer";

const enhancedReducer = customReducerEnhancer(
    combineReducers(
        {
            modelData: modelReducer,
            stateData: stateReducer
        })
);

export default createStore(enhancedReducer,
    compose(applyMiddleware(multiActions), asyncEnhancer(2000)));

export { saveProduct, saveSupplier, deleteProduct, deleteSupplier }
    from "./modelActionCreators";

Listing 20-19Adding an Enhancer in the index.js File in the src/store Folder

来自compose函数的结果被传递给createStore,两个增强器都被应用于数据存储,增加了中间件和新的dispatchAsync方法。在清单 20-20 中,我更新了StoreAccess组件,以便它在调度动作时使用增强的数据存储方法,并禁用button元素,直到后台任务完成。

import React, { Component } from "react";
import { resetStore } from "./customReducerEnhancer";

export class StoreAccess extends Component {

    constructor(props) {
        super(props);
        this.selectors = {
            product: (storeState) => storeState.modelData.products[0],
            state: (storeState) => storeState.stateData
        }
        this.state = this.selectData();
        this.buttonRef = React.createRef();
    }

    render() {
        return <React.Fragment>
            <div className="text-center">
                <button className="btn btn-primary m-1" ref={ this.buttonRef }
                    onClick={ this.dispatchAction }>
                        Dispatch Action
                </button>
            </div>
            <div className="bg-info">
                <pre className="text-white">
                    { JSON.stringify(this.state, null, 2) }
                </pre>
            </div>
        </React.Fragment>
    }

    dispatchAction = () => {
        this.buttonRef.current.disabled = true;
        this.props.store.dispatchAsync(resetStore())
            .then(data => this.buttonRef.current.disabled = false);
    }

    // ...other methods omitted for brevity...
}

Listing 20-20Using the Enhanced Data Store in the StoreAccess.js File in the src/store Folder

结果是,单击按钮将调度操作,该操作将在两秒钟的显示后被处理。组件在调度动作时会收到一个Promise,这个问题一旦被调度就会被解决,允许组件再次启用按钮元素,如图 20-8 所示。

img/473159_1_En_20_Fig8_HTML.jpg

图 20-8

使用增强型数据存储

使用 React-Redux API

前面几节演示了您可以直接使用 Redux API 将组件连接到数据存储。然而,对于大多数项目来说,使用 React-Redux 包更简单、更容易,如第十九章所示。在接下来的小节中,我将描述 React-Redux 包提供的高级选项,用于配置组件如何连接到数据存储。

高级连接功能

connect方法通常与两个参数一起使用,这两个参数选择数据属性和函数属性,就像来自TableConnector组件的语句:

...
return connect(mapStateToProps, mapDispatchToProps)(presentationComponent);
...

connect函数可以接受一些高级特性的附加参数,并且可以接收以不同方式表达的参数。在本节中,我将解释使用您已经熟悉的参数的选项,介绍新的参数并演示它们的用法。

映射数据属性

connect函数的第一个参数从存储中为组件的数据属性选择数据。通常,选择器被定义为从商店的getState方法接收值并返回一个对象的函数,该对象的属性对应于属性名。当数据存储发生变化时,选择器函数被调用,由connect函数创建的高阶组件使用shouldComponentUpdate生命周期方法(在第十三章中描述)来查看是否有任何变化的值需要连接器组件更新。

数据值的选择是灵活的,不仅仅是将数据存储属性映射到属性。例如,在TableConnector组件中,我使用选择器函数来映射来自商店不同部分的数据值,如下所示:

...
const mapStateToProps = (storeData) => ({
    products: storeData.modelData[PRODUCTS],
    suppliers: storeData.modelData[SUPPLIERS].map(supp => ({
        ...supp,
        products: supp.products.map(id =>
            storeData.modelData[PRODUCTS].find(p => p.id === Number(id)) || id)
                .map(val => val.name || val)
    }))
})
...

选择器函数也可以用第二个参数表示,该参数用于接收父连接器组件为连接器组件提供的属性。这允许在选择数据时使用组件的属性,并确保当组件的属性发生变化以及数据存储发生变化时,选择器函数将被重新评估。清单 20-21 演示了附加参数的用法。

import { connect } from "react-redux";
import { startEditingProduct, startEditingSupplier } from "./stateActions";
import { deleteProduct, deleteSupplier } from "./modelActionCreators";
import { PRODUCTS, SUPPLIERS } from "./dataTypes";

export const TableConnector = (dataType, presentationComponent) => {

    const mapStateToProps = (storeData, ownProps) => {
        if (!ownProps.needSuppliers) {
            return { products: storeData.modelData[PRODUCTS] };
        } else {
            return {
                suppliers: storeData.modelData[SUPPLIERS].map(supp => ({
                    ...supp,
                    products: supp.products.map(id =>
                        storeData.modelData[PRODUCTS]
                            .find(p => p.id === Number(id)) || id)
                            .map(val => val.name || val)
                    }))
            }
        }
    }

    const mapDispatchToProps = {
        editCallback: dataType === PRODUCTS
            ? startEditingProduct : startEditingSupplier,
        deleteCallback: dataType === PRODUCTS ? deleteProduct : deleteSupplier
    }

    return connect(mapStateToProps, mapDispatchToProps)(presentationComponent);
}

Listing 20-21Using an Additional Selector Argument in the TableConnector.js File in the src/store Folder

创建应用于多个组件的连接器的一个问题是选择了太多的数据,当数据存储中的更改影响一个组件使用而另一个组件不使用的属性时,这可能导致不必要的更新。TableConnector组件是产品和供应商数据表的连接器,但是只有供应商数据需要从数据存储映射suppliers属性。对于产品表来说,这不仅意味着浪费了suppliers属性的计算,而且当没有显示的数据发生变化时,它还会导致更新。

附加参数——通常命名为ownProps—允许通过标准的 React prop 特性定制连接器组件的每个实例。在清单 20-21 中,我使用了ownProps参数,根据应用于连接器组件的名为needSuppliers的属性的值,决定将哪些属性映射到数据存储。如果值是true,则suppliers属性被映射到数据存储器,否则products属性被映射。

在清单 20-22 中,我向由SupplierDisplay组件呈现的ConnectedTable组件添加了needSuppliers属性,这将确保它映射其表示组件所需的数据。由ProductDisplay组件渲染的相应的ConnectedTable组件没有needSuppliers属性,不会从商店接收数据。

...
export const SupplierDisplay = connectFunction(
    class extends Component {

        render() {
            if (this.props.editing) {
                return <ConnectedEditor key={ this.props.selected.id || -1 } />
            } else {
                return <div className="m-2">
                    <ConnectedTable needSuppliers={ true } />
                    <div className="text-center">
                        <button className="btn btn-primary m-1"
                            onClick={ this.props.createSupplier }>
                                Create Supplier
                        </button>
                    </div>
                </div>
            }
        }
    })
...

Listing 20-22Adding a Prop in the SupplierDisplay.js File in the src Folder

应用的行为没有区别,但是在幕后,通过ConnectedTable组件连接到数据存储的每个表示组件使用不同的属性。

过早优化的危险

在发现性能问题之前,不要过于担心优化更新。几乎所有的优化都会增加项目的复杂性,您可能会发现未优化的代码带来的性能损失是不可察觉的,或者不足以成为一个需要担心的问题。试图优化可能不存在的问题很容易陷入困境,更好的方法是尽可能编写最清晰、最简单的代码,然后优化不符合您要求的部分。

映射功能属性

正如我在第十九章中所解释的,第二个connect参数在函数属性之间映射,可以被指定为一个对象或者一个函数。当提供一个对象时,该对象的每个属性的值被假定为一个动作创建者函数,并被自动包装在dispatch方法中,映射到一个函数 prop。当提供一个函数时,该函数被赋予dispatch方法,并负责使用它来创建函数属性映射。

小费

您可以省略connect函数的第二个参数,其中dispatch方法被映射到一个 prop,也称为dispatch,它允许组件创建动作并直接调度它们。

如果您指定了一个函数,那么您也可以选择接收连接器组件 props,如前一节所述。这允许高级组件从其父组件接收关于映射到数据存储的功能属性集的指示。在清单 20-23 中,我使用了一个函数来配置函数属性,并定义了第二个参数来接收组件的属性。

import { connect } from "react-redux";
import { startEditingProduct, startEditingSupplier } from "./stateActions";
import { deleteProduct, deleteSupplier } from "./modelActionCreators";
import { PRODUCTS, SUPPLIERS } from "./dataTypes";

export const TableConnector = (dataType, presentationComponent) => {

    const mapStateToProps = (storeData, ownProps) => {
        if (!ownProps.needSuppliers) {
            return { products: storeData.modelData[PRODUCTS] };
        } else {
            return {
                suppliers: storeData.modelData[SUPPLIERS].map(supp => ({
                    ...supp,
                    products: supp.products.map(id =>
                        storeData.modelData[PRODUCTS]
                            .find(p => p.id === Number(id)) || id)
                            .map(val => val.name || val)
                    }))
            }
        }
    }

    const mapDispatchToProps = (dispatch, ownProps) => {
        if (!ownProps.needSuppliers) {
            return {
                editCallback: (...args) => dispatch(startEditingProduct(...args)),
                deleteCallback: (...args) => dispatch(deleteProduct(...args))
            }
        } else {
            return {
                editCallback: (...args) => dispatch(startEditingSupplier(...args)),
                deleteCallback: (...args) => dispatch(deleteSupplier(...args))
            }
        }
    }

    return connect(mapStateToProps, mapDispatchToProps)(presentationComponent);
}

Listing 20-23Using Props in the TableConnector.js File in the src/store Folder

合并属性

connect函数接受第三个参数,该参数用于在将属性传递给表示组件之前组成属性。这个参数称为mergeProps,是一个函数,它接收映射的数据属性、函数属性和连接的组件属性,并返回一个对象,将它们合并到用作表示组件属性的对象中。

默认情况下,属性从从父属性接收的属性开始组成,然后与数据属性和功能属性组合。这意味着从父节点接收的属性将被具有相同名称的映射数据属性替换,并且如果存在具有相同名称的映射函数属性,则两者都将被替换。mergeProps函数可用于在名称冲突时更改优先级,以及绑定动作,以便使用从父节点接收的值作为属性来调度它们。清单 20-24 展示了如何使用mergeProps参数显式合并属性。

import { connect } from "react-redux";
import { endEditing } from "./stateActions";
import { PRODUCTS, SUPPLIERS  } from "./dataTypes";
import { saveAndEndEditing } from "./multiActionCreators";

export const EditorConnector = (dataType, presentationComponent) => {

    const mapStateToProps = (storeData) => ({
        editing: storeData.stateData.editing
            && storeData.stateData.selectedType === dataType,
        product: (storeData.modelData[PRODUCTS]
            .find(p => p.id === storeData.stateData.selectedId)) || {},
        supplier:(storeData.modelData[SUPPLIERS]
            .find(s => s.id === storeData.stateData.selectedId)) || {}
    })

    const mapDispatchToProps = {
        cancelCallback: endEditing,
        saveCallback: (data) => saveAndEndEditing(data, dataType)
    }

    const mergeProps = (dataProps, functionProps, ownProps) =>
        ({ ...dataProps, ...functionProps, ...ownProps })

    return connect(mapStateToProps, mapDispatchToProps,
        mergeProps)(presentationComponent);
}

Listing 20-24Merging Props in the EditorConnector.js File in the src/store Folder

清单 20-24 中的mergeProps函数组合了每个属性对象的属性。属性是按照指定的顺序从对象中复制的,这意味着函数最后从ownProps中复制,也意味着当有相同名称的属性时,将使用从父对象接收的属性。

设置连接选项

connect方法的最后一个参数通常被命名为options,它是一个用于配置到数据存储的连接的对象。options对象可以用属性定义,属性名称如表 20-4 所示。

表 20-4

选项对象属性名称

|

名字

|

描述

|
| --- | --- |
| pure | 默认情况下,连接器组件仅在其自身属性发生变化或数据存储中的某个选定值发生变化时才会更新,这允许由connect创建的高阶组件(HOC)在属性或数据未发生变化时阻止更新。将该属性设置为false表示连接器组件可能依赖于其他数据,并且特设不会试图阻止更新。该属性的默认值为true。 |
| areStatePropsEqual | 当pure属性为true时,该函数用于覆盖mapStateToProps值的默认相等比较,以最小化更新。 |
| areOwnPropsEqual | 当pure属性为true时,该函数用于覆盖mapDispatchToProps值的默认相等比较,以最小化更新。 |
| areMergedPropsEqual | 当pure属性为true时,该函数用于覆盖mergeProps值的默认相等比较,以最小化更新。 |
| areStatesEqual | 当pure属性为true时,该函数用于覆盖整个组件状态的默认相等比较,以最小化更新。 |

摘要

在本章中,我描述了 Redux 和 React-Redux 包提供的 API,并演示了如何使用它们。我向您展示了如何使用 Redux API 将组件直接连接到数据存储,如何增强数据存储及其 reducers,以及如何定义中间件组件。我还演示了使用 React-Redux 包时可用的高级选项,该包可用于管理组件到数据存储的连接。在下一章中,我将向示例应用介绍 URL 路由。

二十一、使用 URL 路由

目前,显示给用户的内容选择是由应用的状态数据控制的。有些状态数据是特定于单个组件的,比如管理产品和供应商数据选择的Selector组件。其余的数据位于 Redux 数据存储中,连接的组件使用这些数据来决定是否需要数据表或编辑器组件,并获取数据来填充这些组件的内容。

在这一章中,我将介绍一种构建应用的不同方法,这种方法是基于浏览器的 URL 选择内容,称为 URL 路由。我将呈现导航到新 URL 的锚元素,并通过选择内容并将其呈现给用户来响应这些 URL,而不是按钮元素的事件处理程序调度 Redux 操作。对于复杂的应用,URL 路由可以使构建项目变得更容易,并使扩展和维护功能变得更容易。表 21-1 将 URL 路由放在上下文中。

表 21-1

将 URL 路由置于上下文中

|

问题

|

回答

|
| --- | --- |
| 这是什么? | URL 路由使用浏览器的当前 URL 来选择呈现给用户的内容。 |
| 为什么有用? | URL 路由允许在不需要共享状态数据的情况下构建应用,共享状态数据被编码在 URL 中,这也使得更改应用的结构变得更加容易。 |
| 如何使用? | 呈现的导航元素会更改浏览器的 URL,而不会触发新的 HTTP 请求。新的 URL 用于选择呈现给用户的内容。 |
| 有什么陷阱或限制吗? | 需要进行彻底的测试,以确保用户可以导航到的所有 URL 都得到正确处理,并显示适当的内容。 |
| 有其他选择吗? | URL 路由完全是可选的,还有其他方式来组成应用及其数据,如前面的章节所演示的。 |

表 21-2 总结了本章内容。

表 21-2

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 创建导航元素 | 使用Link组件 | 4, 13 |
| 响应导航 | 使用Route组件 | 5–6 |
| 匹配特定的 URL | 使用Route组件的exact属性 | seven |
| 匹配多个 URL | 在Route组件的path属性中将 URL 指定为数组,或者使用正则表达式 | 8–9 |
| 选择一条路线 | 使用Switch组件 | Ten |
| 定义后备路线 | 使用Redirect组件 | 11, 12 |
| 指示活动航路 | 使用NavLink组件 | 14, 15 |
| 选择用于在 URL 中表示路由的机制 | 选择路由组件 | Sixteen |

为本章做准备

在这一章中,我继续使用在第十八章创建的productapp项目,最近一次使用是在第二十章。为了准备本章,打开一个新的命令提示符,导航到productapp文件夹,运行清单 21-1 中所示的命令,将一个包添加到项目中。React 路由包可用于一系列应用类型。清单 21-1 中安装的包是用于 web 应用的。

小费

你可以从 https://github.com/Apress/pro-react-16 下载本章以及本书其他章节的示例项目。

npm install react-router-dom@4.3.1

Listing 21-1Adding a Package to the Project

为了简化呈现给用户的内容,我删除了一些由App组件呈现的内容,如清单 21-2 所示。

import React, { Component } from "react";
import { Provider } from "react-redux";
import dataStore from "./store";
import { Selector } from "./Selector";
import { ProductDisplay } from "./ProductDisplay";
import { SupplierDisplay } from "./SupplierDisplay";

export default class App extends Component {

    render() {
        return  <Provider store={ dataStore }>
                    <Selector>
                        <ProductDisplay name="Products" />
                        <SupplierDisplay name="Suppliers" />
                    </Selector>
                </Provider>
    }
}

Listing 21-2Simplifying Content in the App.js File in the src Folder

保存对组件 JavaScript 文件的更改,并使用命令提示符运行productapp文件夹中清单 21-3 中所示的命令,以启动 React 开发工具。

npm start

Listing 21-3Starting the Development Tools

项目将被编译,开发 HTTP 服务器将被启动。一个新的浏览器窗口将打开并显示应用,如图 21-1 所示。

img/473159_1_En_21_Fig1_HTML.jpg

图 21-1

运行示例应用

URL 路由入门

首先,我将在Selector组件中使用 URL 路由,这样它就不需要自己的状态数据来跟踪用户是否想要与产品或供应商合作。

设置 URL 路由有两个步骤。第一步是创建链接,用户单击这些链接可以导航到应用的不同部分。第二步是选择将为用户可以导航到的每个 URL 显示的内容。这些步骤使用 React-Router 包提供的 React 组件来执行,如清单 21-4 所示。

import React, { Component } from "react";

import { BrowserRouter as Router, Link, Route } from "react-router-dom";

import { ProductDisplay } from "./ProductDisplay";

import { SupplierDisplay } from "./SupplierDisplay";

export class Selector extends Component {

    // constructor(props) {
    //     super(props);
    //     this.state = {
    //         selection: React.Children.toArray(props.children)[0].props.name
    //     }
    // }

    // setSelection = (ev) => {
    //     ev.persist();
    //     this.setState({ selection: ev.target.name});
    // }

    render() {
        return <Router>
            <div className="container-fluid">
                <div className="row">
                    <div className="col-2">
                        <div><Link to="/products">Products</Link></div>
                        <div><Link to="/suppliers">Suppliers</Link></div>
                    </div>
                    <div className="col">
                        <Route path="/products" component={ ProductDisplay } />
                        <Route path="/suppliers" component={ SupplierDisplay} />
                    </div>
                </div>
            </div>
        </Router>
    }
}

Listing 21-4Adding URL Routing in the Selector.js File in the src Folder

设置基本路由配置需要三个组件。Router组件用于提供对 URL 路由特性的访问。使用 URL 进行导航有不同的方式,每种方式都有自己的 React-Router 组件,我在“选择和配置路由”一节中对此进行了描述。约定是导入您需要的组件,在本例中是BrowserRouter,并为其指定名称Router,然后将其用作需要访问路由特性的内容的容器。

选择替代路由包

React-Router 是目前为止 React 项目中使用最广泛的路由包,对于大多数应用来说是一个很好的起点。还有其他可用的路由包,但并不是所有的路由包都是专门针对 React 的,可能需要进行笨拙的改编。

如果你无法和 React-Router 相处,那么最好的替代品就是 Backbone ( https://backbonejs.org )。这个广受好评的包为任何 JavaScript 应用提供了路由,并且与 React 配合得很好。

链接组件入门

Link组件呈现一个元素,用户可以单击它来导航到一个新的 URL,如下所示:

...
<div><Link to="/products">Products</Link></div>
...

使用to属性指定导航 URL,这个Link将导航到/products URL。导航 URL 是相对于应用的起始 URL 指定的,起始 URL 是开发过程中的http://localhost:3000。这意味着为Link组件的to属性指定/products会告诉它呈现一个将导航到http://localhost:3000/products的元素。当应用被部署并具有公共 URL 时,这些相对 URL 将继续工作。

路线组件入门

添加到清单 21-4 的最后一个组件是Route,它等待浏览器导航到一个特定的 URL,然后显示其内容,如下所示:

...
<Route path="/products" component={ ProductDisplay } />
...

这个Route组件已经被配置为等待,直到浏览器导航到/products URL,这时它将显示ProductDisplay组件。对于所有其他 URL,这个Route组件不会呈现任何内容。

如图 21-2 所示,清单 21-4 中变化的结果在视觉上并不令人印象深刻,但它展示了 URL 路由的基本性质。当浏览器显示应用的起始 URLhttp://localhost:3000时,不会显示任何内容。点击ProductsSuppliers链接,浏览器导航到http://localhost:3000/productshttp://localhost:3000/suppliers,显示ProductDisplaySupplierDisplay组件。

img/473159_1_En_21_Fig2_HTML.png

图 21-2

添加导航元素

右键单击由Link组件创建的任一导航元素,并从弹出菜单中选择 Inspect 或 Inspect Element,您将看到已经呈现的 HTML,如下所示:

...
<div><a href="/products">Products</a></div>
<div><a href="/suppliers">Suppliers</a></div>
...

已经呈现了Link组件以产生锚(标签为a的元素)元素,并且to属性的值已经转换为锚元素的href属性的 URL。当您单击其中一个锚元素时,浏览器会导航到一个新的 URL,相应的Route组件会显示其内容。如果浏览器导航到一个没有配置Route组件的 URL,那么就不会显示任何内容,这就是为什么在单击其中一个链接之前组件不会显示的原因。

警告

不要试图为导航创建自己的锚元素,因为它们会导致浏览器向服务器发送一个 HTTP 请求,请求您指定的 URL,从而导致应用被重新加载。由Link组件呈现的锚元素具有使用 HTML5 历史 API 更改 URL 的事件处理程序,而不会触发新的 HTTP 请求。

响应导航

Route组件用于实现应用的路由方案,它通过等待浏览器导航到一个特定的 URL 并在它到达时显示一个组件来实现。在真实的应用中,URL 和组件之间的映射可能很复杂,URL 的匹配和组件对内容的选择可以使用表 21-3 中描述的属性进行配置,我将在下面的章节中演示。

表 21-3

路线组件属性

|

名字

|

描述

|
| --- | --- |
| path | 属性用于指定组件应该等待的一个或多个 URL。 |
| exact | 当这个属性为true时,只有与路径属性完全相同的 URL 才会被匹配,如“限制与属性的匹配”一节中所示。 |
| sensitive | 当这个属性是true时,匹配的 URL 是区分大小写的。 |
| strict | 当该属性为true时,以/结尾的path值将只匹配其对应段也以/结尾的 URL。 |
| component | 该属性用于指定当path属性与浏览器的当前 URL 匹配时将显示的单个组件。 |
| render | 该属性用于指定一个函数,该函数返回当path属性与浏览器的当前 URL 匹配时将显示的内容。 |
| children | 这个属性用于指定一个总是呈现内容的函数,即使 path 属性指定的 URL 不匹配。这对于显示派生组件中的内容或者不响应 URL 变化而呈现的组件非常有用,如第二十二章所述。 |

选择组件和内容

component属性用于指定当当前 URL 与path属性匹配时将显示的单个组件。组件类型被直接指定为component属性值,如下所示:

...
<Route path="/products" component={ ProductDisplay } />
...

属性的值不应该是一个函数,因为它会导致应用每次更新时创建一个指定组件的新实例。

使用渲染属性

component属性的优点是简单,它适用于具有独立组件的项目,这些组件可以呈现所有需要的内容,并且不需要属性。Route组件为更复杂的内容提供了render属性,并传递属性,如清单 21-5 所示。

import React, { Component } from "react";
import { BrowserRouter as Router, Link, Route } from "react-router-dom";
import { ProductDisplay } from "./ProductDisplay";
import { SupplierDisplay } from "./SupplierDisplay";

export class Selector extends Component {

    render() {
        return <Router>
            <div className="container-fluid">
                <div className="row">
                    <div className="col-2">
                        <div><Link to="/products">Products</Link></div>
                        <div><Link to="/suppliers">Suppliers</Link></div>
                    </div>
                    <div className="col">
                        <Route path="/products" render={ (routeProps) =>
                            <ProductDisplay myProp="myValue" /> } />
                        <Route path="/suppliers" render={ (routeProps) =>
                             <React.Fragment>
                                <h4 className="bg-info text-center text-white p-2">
                                    Suppliers
                                </h4>
                                <SupplierDisplay />
                            </React.Fragment>
                        } />
                    </div>
                </div>
            </div>
        </Router>
    }
}

Listing 21-5Using the render Prop in the Selector.js File in the src Folder

函数的结果是应该由Route组件显示的内容。在清单中,我将一个属性传递给了ProductDisplay组件,并将SupplierDisplay组件包含在一个更大的内容片段中,如图 21-3 所示。

img/473159_1_En_21_Fig3_HTML.jpg

图 21-3

使用路线组件的渲染属性

小费

传递给render prop 的函数接收一个提供路由系统状态信息的对象,我将在第二十二章中描述。

匹配 URL

使用 URL 路由最困难的一个方面是确保您想要支持的 URL 与Route组件正确匹配。Route组件提供了一系列特性,允许您扩大或缩小将要匹配的 URL 的范围,我将在接下来的章节中对此进行描述。

使用线段匹配

匹配 URL 最简单的方法是向Route组件的path属性提供一个或多个目标段。这将匹配以您指定的段开头的任何 URL,如清单 21-6 所示。

import React, { Component } from "react";
import { BrowserRouter as Router, Link, Route } from "react-router-dom";

//import { ProductDisplay } from "./ProductDisplay";

//import { SupplierDisplay } from "./SupplierDisplay";

export class Selector extends Component {

    renderMessage = (msg) => <h5 className="bg-info text-white m-2 p-2">{ msg }</h5>

    render() {
        return <Router>
            <div className="container-fluid">
                <div className="row">
                    <div className="col-2">
                        <div><Link to="/data/one">Link #1</Link></div>
                        <div><Link to="/data/two">Link #2</Link></div>
                        <div><Link to="/people/bob">Bob</Link></div>
                    </div>
                    <div className="col">
                        <Route path="/data"
                            render={ () => this.renderMessage("Route #1") } />
                        <Route path="/data/two"
                            render={ () => this.renderMessage("Route #2") } />
                    </div>
                </div>
            </div>
        </Router>
    }
}

Listing 21-6Matching URLs in the Selector.js File in the src Folder

我用一种叫做renderMessage的方法生成的内容替换了ProductDisplaySupplierDisplay组件。有三个链接组件,分别指向 URL/data/onedata/two/people/bob

第一个Route组件配置有/data作为其path支柱。这将匹配第一段是data的任何 URL,这意味着它将匹配/data/one/data/twoURL,但不匹配/people/bob。第二个Route组件将/data/two作为其path属性的值,因此它将只匹配/data/two URL。每个Route组件独立评估它的path属性,你可以通过点击导航链接看到它们是如何匹配 URL 的,如图 21-4 所示。

img/473159_1_En_21_Fig4_HTML.jpg

图 21-4

将 URL 与路由组件匹配

一个Route组件匹配/data/one URL,两个都匹配/data/two URL,两个都不匹配/people/bob,因此不显示任何内容。

用属性限制比赛

Route组件的默认行为会导致过度匹配,即组件在您不希望的时候匹配 URL。例如,我可能想要区分/data/data/oneURL,这样第一个 URL 显示数据项列表,第二个显示特定对象的细节。默认匹配使这变得困难,因为/datapath属性匹配任何第一段是/data的 URL,不管 URL 总共包含多少段。

为了帮助限制路径匹配的 URL 的范围,Route组件支持三个额外的属性:exactstrictsensitive。三个属性中最有用的是exact,只有当它与path属性值完全匹配时,它才会匹配一个 URL,因此/data/one的 URL 不会与/data的路径匹配,如清单 21-7 所示。

import React, { Component } from "react";
import { BrowserRouter as Router, Link, Route } from "react-router-dom";

export class Selector extends Component {

    renderMessage = (msg) => <h5 className="bg-info text-white m-2 p-2">{ msg }</h5>

    render() {
        return <Router>
            <div className="container-fluid">
                <div className="row">
                    <div className="col-2">
                        <div><Link to="/data">Data</Link></div>
                        <div><Link to="/data/one">Link #1</Link></div>
                        <div><Link to="/data/two">Link #2</Link></div>
                        <div><Link to="/people/bob">Bob</Link></div>
                    </div>
                    <div className="col">
                        <Route path="/data" exact={ true }
                            render={ () => this.renderMessage("Route #1") } />
                        <Route path="/data/two"
                            render={ () => this.renderMessage("Route #2") } />
                    </div>
                </div>
            </div>
        </Router>
    }
}

Listing 21-7Making Exact Matches in the Selector.js File in the src Folder

设置exact属性只影响它所应用的Route组件。在示例中,exact prop 阻止第一个Route组件匹配/data/one/data/twoURL,如图 21-5 所示。

img/473159_1_En_21_Fig5_HTML.jpg

图 21-5

进行精确匹配

当设置为true时,strict属性用于将有斜杠的path的匹配限制到也有斜杠的 URL,因此/data/path将只匹配/data/ URL 而不是/data。然而,strict prop 确实匹配带有附加段的 URL,因此路径/data/将匹配/data/one

sensitive属性用于控制区分大小写。当true时,只有当path属性的大小写与 URL 的大小写匹配时,它才允许匹配,因此/data的路径不会与/Data的 URL 匹配。

在一个路径中指定多个 URL

组件的属性的值可以是一个 URL 数组,如果其中任何一个匹配,就会显示内容。当响应不具有公共结构的 URL 需要相同的内容时(例如响应/data/list/people/list显示相同的组件),或者当需要特定数量的精确匹配时,例如匹配/data/one/data/two,但不匹配以/data开头的任何其他 URL 时,这可能很有用,如清单 21-8 所示。

注意

在编写本文时,与Route组件所期望的属性类型不匹配,导致在使用数组时出现 JavaScript 控制台警告。此警告可以忽略,在您阅读本章时可能会得到解决。参见第十章和第十一章了解如何指定组件对其属性的数据类型。

import React, { Component } from "react";
import { BrowserRouter as Router, Link, Route } from "react-router-dom";

export class Selector extends Component {

    renderMessage = (msg) => <h5 className="bg-info text-white m-2 p-2">{ msg }</h5>

    render() {
        return <Router>
            <div className="container-fluid">
                <div className="row">
                    <div className="col-2">
                        <div><Link to="/data">Data</Link></div>
                        <div><Link to="/data/one">Link #1</Link></div>
                        <div><Link to="/data/two">Link #2</Link></div>
                        <div><Link to="/people/bob">Bob</Link></div>
                    </div>
                    <div className="col">
                        <Route path={["/data/one", "/people/bob" ] }  exact={ true }
                            render={ () => this.renderMessage("Route #1") } />
                        <Route path={["/data", "/people" ] }
                            render={ () => this.renderMessage("Route #2") } />
                    </div>
                </div>
            </div>
        </Router>
    }
}

Listing 21-8Using an Array of Paths in the Selector.js File in the src Folder

path数组是使用花括号作为表达式提供的。第一个Route组件的path属性被设置为包含/data/one/people/bob的数组。这些路径与exact属性结合起来限制组件匹配的 URL。第二个Route组件被配置为更广泛地匹配,并将响应任何第一段是datapeople的 URL,如图 21-6 所示。

img/473159_1_En_21_Fig6_HTML.jpg

图 21-6

使用数组指定路径

用正则表达式匹配 URL

并不是所有的 URL 组合都可以用单独的段来表达,Route组件在它的path属性中支持正则表达式来进行更复杂的匹配,如清单 21-9 所示。

正则表达式清晰与简洁

大多数程序员倾向于用尽可能少的正则表达式来表示路由,但结果可能是路由配置难以阅读,并且在需要更改时很容易被破坏。当决定如何匹配 URL 时,保持表达式简单,并使用一个path数组来扩展一个Route可以匹配的 URL 的范围,而不用使用难以理解的正则表达式。

import React, { Component } from "react";
import { BrowserRouter as Router, Link, Route } from "react-router-dom";

export class Selector extends Component {

    renderMessage = (msg) => <h5 className="bg-info text-white m-2 p-2">{ msg }</h5>

    render() {
        return <Router>
            <div className="container-fluid">
                <div className="row">
                    <div className="col-2">
                        <div><Link to="/data">Data</Link></div>
                        <div><Link to="/data/one">Link #1</Link></div>
                        <div><Link to="/data/two">Link #2</Link></div>
                        <div><Link to="/data/three">Link #3</Link></div>
                        <div><Link to="/people/bob">Bob</Link></div>
                        <div><Link to="/people/alice">Alice</Link></div>
                    </div>
                    <div className="col">
                        <Route path={["/data/(one|three)", "/people/b*" ] }
                            render={ () => this.renderMessage("Route #1") } />
                    </div>
                </div>
            </div>
        </Router>
    }
}

Listing 21-9Using a Regular Expression in the Selector.js File in the src Folder

路径数组中的第一项匹配第一段是data而第二段是onethree的 URL。第二项匹配第一段为 people,第二段以b开头的 URL。结果是Route组件将匹配/data/one/data/two/people/bobURL,但不匹配/data/two/people/aliceURL。

注意

参见 https://github.com/pillarjs/path-to-regexp 了解可用于匹配 URL 的全部正则表达式特性。

进行单一路线匹配

每个Route组件独立评估其path属性;这可能是有用的,但如果您希望基于当前 URL 只显示一个组件,这并不理想。对于这些情况,Redux-Router 包提供了Switch组件,它充当多个Route组件的包装器,按顺序查询它们,并显示第一个组件呈现的内容以匹配当前 URL。清单 21-10 显示了Switch组件的使用。

import React, { Component } from "react";
import { BrowserRouter as Router, Link, Route, Switch } from "react-router-dom";
import { ProductDisplay } from "./ProductDisplay";
import { SupplierDisplay } from "./SupplierDisplay";

export class Selector extends Component {

    renderMessage = (msg) => <h5 className="bg-info text-white m-2 p-2">{ msg }</h5>

    render() {
        return <Router>
            <div className="container-fluid">
                <div className="row">
                    <div className="col-2">
                        <div><Link to="/">Default URL</Link></div>
                        <div><Link to="/products">Products</Link></div>
                        <div><Link to="/suppliers">Suppliers</Link></div>
                    </div>
                    <div className="col">
                        <Switch>
                            <Route path="/products" component={ ProductDisplay} />
                            <Route path="/suppliers" component={ SupplierDisplay } />
                            <Route render={ () =>
                                this.renderMessage("Fallback Route")} />
                        </Switch>
                    </div>
                </div>
            </div>
        </Router>
    }
}

Listing 21-10Using the Switch Component in the Selector.js File in the src Folder

Switch组件按照它们被定义的顺序检查它的子组件,这意味着Route组件必须被安排成最具体的 URL 最先出现。没有path属性的Route组件将总是匹配当前的 URL,并且可以被Switch组件用作默认值,类似于常规 JavaScript switch 语句中的default子句。

清单中的变化将/products URL 与ProductDisplay组件相关联,将/suppliers URL 与SupplierDisplay组件相关联。任何其他的 URL 都会使用renderMessage方法呈现一条消息,如图 21-7 所示。

img/473159_1_En_21_Fig7_HTML.jpg

图 21-7

使用开关组件

使用Switch组件允许我在用户点击其中一个导航链接之前应用第一次启动时呈现内容。然而,这只是为默认 URL 选择内容的一种方式,更好的方式是使用Redirect组件,如下一节所述。

使用重定向作为备用路由

对于某些应用,引入一个单独的 URL 作为后备是没有意义的,在这种情况下,Redirect组件可以用来自动触发导航到一个可以由Route组件处理的 URL。在清单 21-11 中,我用重定向到/product URL 替换了现有的回退。

import React, { Component } from "react";

import { BrowserRouter as Router, Link, Route, Switch, Redirect }

    from "react-router-dom";
import { ProductDisplay } from "./ProductDisplay";
import { SupplierDisplay } from "./SupplierDisplay";

export class Selector extends Component {

    renderMessage = (msg) => <h5 className="bg-info text-white m-2 p-2">{ msg }</h5>

    render() {
        return <Router>
            <div className="container-fluid">
                <div className="row">
                    <div className="col-2">
                        <div><Link to="/">Default URL</Link></div>
                        <div><Link to="/products">Products</Link></div>
                        <div><Link to="/suppliers">Suppliers</Link></div>
                    </div>
                    <div className="col">
                        <Switch>
                            <Route path="/products" component={ ProductDisplay} />
                            <Route path="/suppliers" component={ SupplierDisplay } />
                            <Redirect to="/products" />
                        </Switch>
                    </div>
                </div>
            </div>
        </Router>
    }
}

Listing 21-11Using a Redirection in the Selector.js File in the src Folder

to属性指定了Redirect组件将导航到的 URL。如果Route组件能够匹配当前 URL,就不会使用Redirect组件。但是如果Switch组件到达Redirect组件而没有找到匹配的Route,那么将执行到/products的重定向。

执行选择性重定向

使用Redirect组件最常见的方式是只使用to属性,但是还有其他可用的属性可以用来限制何时执行重定向,如表 21-4 中所述。

表 21-4

重定向组件属性

|

名字

|

描述

|
| --- | --- |
| to | 这个属性指定了浏览器应该被重定向到的位置。 |
| from | 该属性限制重定向,以便仅当当前 URL 与指定路径匹配时才执行重定向。 |
| exact | 当true时,该属性限制重定向,以便仅当当前 URL 与from属性完全匹配时才执行重定向,执行与Route组件的exact属性相同的角色。 |
| strict | 当true时,该属性限制重定向,因此只有当当前 URL 以/结尾并且path也以/结尾时才执行重定向,执行与Route组件的strict属性相同的角色。 |
| push | 当true时,重定向将向浏览器的历史记录中添加一个新项目。当false时,重定向将替换当前位置。 |

有选择地重定向到一个新的 URL 是保持对不再由Route直接处理的 URL 的支持的一种有用的方式,如清单 21-12 所示。(对Route使用path数组可以达到类似的效果,但是在匹配 URL 参数时会导致复杂化,如第二十二章所述。)

import React, { Component } from "react";
import { BrowserRouter as Router, Link, Route, Switch, Redirect }
    from "react-router-dom";
import { ProductDisplay } from "./ProductDisplay";
import { SupplierDisplay } from "./SupplierDisplay";

export class Selector extends Component {

    renderMessage = (msg) => <h5 className="bg-info text-white m-2 p-2">{ msg }</h5>

    render() {
        return <Router>
            <div className="container-fluid">
                <div className="row">
                    <div className="col-2">
                        <div><Link to="/">Default URL</Link></div>
                        <div><Link to="/products">Products</Link></div>
                        <div><Link to="/suppliers">Suppliers</Link></div>
                        <div><Link to="/old/data">Old Link</Link></div>
                    </div>
                    <div className="col">
                        <Switch>
                            <Route path="/products" component={ ProductDisplay} />
                            <Route path="/suppliers" component={ SupplierDisplay } />
                            <Redirect from="/old/data" to="/suppliers" />
                            <Redirect to="/products" />
                        </Switch>
                    </div>
                </div>
            </div>
        </Router>
    }
}

Listing 21-12Selectively Redirecting URLs in the Selector.js File in the src Folder

新的Redirect将执行从/old/data URL 到/suppliers的重定向。选择性Redirect组件的顺序很重要,必须放在非选择性重定向之前;否则,Switch将无法到达它们,因为它会遍历路由组件列表。

呈现导航链接

Link组件负责生成导航到新 URL 的元素,这是通过使用事件处理程序呈现锚元素来实现的,该事件处理程序在不重新加载应用的情况下更改浏览器的 URL。为了配置其行为,Link组件接受表 21-5 中描述的属性。

表 21-5

链接组件属性

|

名字

|

描述

|
| --- | --- |
| to | 该属性用于指定单击链接将导航到的位置。 |
| replace | 该属性用于指定单击导航链接是将一个条目添加到浏览器的历史记录中还是替换当前条目,这决定了用户是否能够使用 back 按钮返回到上一个位置。默认值为 false。 |
| innerRef | 这个属性用于访问底层 HTML 元素的引用。参考文献详情见第十六章。 |

Link组件将把任何其他属性传递给它所呈现的锚元素。这个特性的主要用途是将className属性应用到Link来设计导航链接的样式,如清单 21-13 所示。

import React, { Component } from "react";
import { BrowserRouter as Router, Link, Route, Switch, Redirect }
    from "react-router-dom";
import { ProductDisplay } from "./ProductDisplay";
import { SupplierDisplay } from "./SupplierDisplay";

export class Selector extends Component {

    renderMessage = (msg) => <h5 className="bg-info text-white m-2 p-2">{ msg }</h5>

    render() {
        return <Router>
            <div className="container-fluid">
                <div className="row">
                    <div className="col-2">
                        <Link className="m-2 btn btn-block btn-primary"
                            to="/">Default URL</Link>
                        <Link className="m-2 btn btn-block btn-primary"
                            to="/products">Products</Link>
                        <Link className="m-2 btn btn-block btn-primary"
                            to="/suppliers">Suppliers</Link>
                        <Link className="m-2 btn btn-block btn-primary"
                            to="/old/data">Old Link</Link>
                    </div>
                    <div className="col">
                        <Switch>
                            <Route path="/products" component={ ProductDisplay} />
                            <Route path="/suppliers" component={ SupplierDisplay } />
                            <Redirect from="/old/data" to="/suppliers" />
                            <Redirect to="/products" />
                        </Switch>
                    </div>
                </div>
            </div>
        </Router>
    }
}

Listing 21-13Applying Classes in the Selector.js File in the src Folder

Bootstrap CSS 框架能够将锚元素样式化为按钮,我在清单 21-13 中应用的类应用了一个按钮样式,它填充了可用的水平空间,并允许我删除用于垂直堆叠导航链接的div元素。当Link组件呈现它们的内容时,结果是一个显示为按钮的导航链接,如图 21-8 所示。

img/473159_1_En_21_Fig8_HTML.jpg

图 21-8

将类传递给导航元素

指示活动航路

NavLink组件建立在基本的Link特性之上,但是当它的to属性的值与当前 URL 匹配时,它会向锚元素添加一个类或样式。表 21-6 描述了NavLink组件提供的属性,这些属性是对表 21-5 中描述的属性的补充。在清单 21-14 中,我介绍了应用active类的NavLink组件。

表 21-6

NavLink 组件属性

|

名字

|

描述

|
| --- | --- |
| activeClassName | 这个属性指定了当链接处于活动状态时将被添加到锚元素中的类。 |
| activeStyle | 该属性指定当链接激活时将添加到锚元素的样式。样式被指定为 JavaScript 对象,其属性是样式名称。 |
| exact | 当true时,这个属性执行精确匹配,如“匹配 URL”一节所述。 |
| strict | 当true时,该属性强制严格匹配,如“匹配 URL”一节所述。 |
| isActive | 该属性可用于指定一个自定义函数,以确定链接是否处于活动状态。该函数接收matchlocation参数,如第二十二章所述。默认行为是将当前 URL 与to属性进行比较。 |

import React, { Component } from "react";

import { BrowserRouter as Router, NavLink, Route, Switch, Redirect }

    from "react-router-dom";
import { ProductDisplay } from "./ProductDisplay";
import { SupplierDisplay } from "./SupplierDisplay";

export class Selector extends Component {

    renderMessage = (msg) => <h5 className="bg-info text-white m-2 p-2">{ msg }</h5>

    render() {
        return <Router>
            <div className="container-fluid">
                <div className="row">
                    <div className="col-2">
                        <NavLink className="m-2 btn btn-block btn-primary"
                            activeClassName="active"
                            to="/">Default URL</NavLink>
                        <NavLink className="m-2 btn btn-block btn-primary"
                            activeClassName="active"
                            to="/products">Products</NavLink>
                        <NavLink className="m-2 btn btn-block btn-primary"
                            activeClassName="active"
                            to="/suppliers">Suppliers</NavLink>
                        <NavLink className="m-2 btn btn-block btn-primary"
                            activeClassName="active"
                            to="/old/data">Old Link</NavLink>
                    </div>
                    <div className="col">
                        <Switch>
                            <Route path="/products" component={ ProductDisplay} />
                            <Route path="/suppliers" component={ SupplierDisplay } />
                            <Redirect from="/old/data" to="/suppliers" />
                            <Redirect to="/products" />
                        </Switch>
                    </div>
                </div>
            </div>
        </Router>
    }
}

Listing 21-14Using NavLink Components in the Selector.js File in the src Folder

当浏览器的 URL 与组件的to属性的值匹配时,锚元素被添加到active类中,为用户提供一个有用的指示器,如图 21-9 所示。

img/473159_1_En_21_Fig9_HTML.jpg

图 21-9

响应路由激活

请注意,默认的 URL 按钮总是高亮显示。NavLink组件依赖于Route URL 匹配,这意味着/to属性将匹配任何 URL。表 21-6 中描述的exactstrict属性与应用于Route时的目的相同,清单 21-15 显示了使用exact属性来限制匹配。

...
<div className="col-2">
    <NavLink className="m-2 btn btn-block btn-primary"
        activeClassName="active" exact={ true }
        to="/">Default URL</NavLink>
    <NavLink className="m-2 btn btn-block btn-primary"
        activeClassName="active"
        to="/products">Products</NavLink>
    <NavLink className="m-2 btn btn-block btn-primary"
        activeClassName="active"
        to="/suppliers">Suppliers</NavLink>
    <NavLink className="m-2 btn btn-block btn-primary"
        activeClassName="active"
        to="/old/data">Old Link</NavLink>
</div>
...

Listing 21-15Restricting NavLink Matching in the Selector.js File in the src Folder

结果是NavLink不再突出显示,如图 21-10 所示。

img/473159_1_En_21_Fig10_HTML.jpg

图 21-10

限制突出显示的 URL 匹配

注意

当应用activeClassName值时,NavLink组件不允许移除类,这意味着我不能准确地从示例项目中重新创建原始效果。我将在第二十二章中演示如何用自定义导航组件创建这一功能。

选择和配置路由

URL 路由依赖于操纵浏览器的 URL 来执行导航,而无需向服务器发送 HTTP 请求。在 web 应用中,核心路由功能由BrowserRouterHashRouter组件提供;两者在进口时都被习惯性地命名为Router,就像这样:

...
import { BrowserRouter as Router, Link, Switch, Route, Redirect }
    from "react-router-dom";
...

BrowserRouter使用 HTML5 历史 API。这个 API 为路由提供了自然的 URL,比如http://localhost:3000/products,这是你在本章的例子中看到的 URL 类型。BrowserRouter组件可以接受一系列配置其行为的属性,如表 21-7 所述。props 的默认值适用于大多数应用。

表 21-7

浏览器路由属性

|

名字

|

描述

|
| --- | --- |
| basename | 当应用不在它的 URL 的根目录时,例如http://localhost:3000/myapp,使用这个属性。 |
| getUserConfirmation | 该属性用于指定通过Prompt组件获得用户导航确认的功能,如第二十二章所述。 |
| forceRefresh | 当true时,这个 prop 在导航过程中使用发送到服务器的 HTTP 请求强制进行完全刷新。这削弱了富客户端应用的作用,应该只用于测试和浏览器不能使用历史 API 的时候。 |
| keyLength | 导航中的每个变化都有一个唯一的键。该属性用于指定密钥的长度,默认为六个字符。密钥包含在识别每个导航位置的location对象中,如第二十二章所述。 |
| history | 这个属性允许使用一个自定义的history对象。第二十二章中描述了history对象。 |

使用 HashRouter 组件

老版本的浏览器不支持历史 API,导航细节必须作为一个片段添加到 URL 的末尾,跟在#字符后面。使用 URL 片段的路由由HashRouter组件提供,如清单 21-16 所示。

import React, { Component } from "react";

import { HashRouter as Router, NavLink, Route, Switch, Redirect }

    from "react-router-dom";
import { ProductDisplay } from "./ProductDisplay";
import { SupplierDisplay } from "./SupplierDisplay";

export class Selector extends Component {

    // ...methods omitted for brevity...
}

Listing 21-16Using the HashRouter Component in the Selector.js File in the src Folder

使用as关键字导入路由组件意味着只有import语句需要修改。将修改保存到文件中,导航到http://localhost:3000,你会看到 URL 的样式已经改变,如图 21-11 所示。

img/473159_1_En_21_Fig11_HTML.jpg

图 21-11

使用哈希路由

小费

当浏览器首次重新加载时,您可能会看到类似于http://localhost:3000/suppliers/#/suppliers的 URL。发生这种情况是因为浏览器从其当前 URL 重新加载,然后该 URL 被假定为应用的基本 URL。手动导航到http://localhost:3000,您应该会看到如图所示的 URL。

URL 中用于路由的部分现在跟在#字符之后。URL 路由仍然以同样的方式工作,但是与由BrowserRouter组件生成的 URL 相比,URL 不那么自然。HashRouter组件可以配置表 21-8 所示的属性。

表 21-8

HashRouter 组件属性

|

名字

|

描述

|
| --- | --- |
| basename | 当应用不在它的 URL 的根目录时,例如http://localhost:3000/myapp,使用这个属性。 |
| getUserConfirmation | 该属性用于指定通过Prompt组件获得用户导航确认的功能,如第二十二章所述。 |
| hashType | 该属性设置用于在 URL 中编码路由的样式。选项有slash,创建如图 21-11 所示的 URL 样式;noslash,省略了#字符后的前导/;还有hashbang,它通过在#字符后插入感叹号来创建像#!/products这样的 URL。 |

摘要

在本章中,我向您展示了如何使用 React-Router 包向 React 应用添加 URL 路由。我解释了路由如何通过将状态数据移入 URL 来简化应用,以及如何使用LinkRoute组件来创建导航元素并响应 URL 的变化。在下一章,我将描述高级 URL 路由特性。

二十二、高级 URL 路由

在这一章中,我描述了 React-Router 包中 URL 路由可用的高级特性。我将向您展示如何创建可以参与路由过程的组件,如何以编程方式导航,如何以编程方式生成路由,以及如何在连接到数据存储的组件中使用 URL 路由。表 22-1 将高级 URL 路由功能放在上下文中。

表 22-1

将高级 URL 路由放在上下文中

|

问题

|

回答

|
| --- | --- |
| 这是什么? | 高级路由功能提供对 URL 路由系统的编程访问。 |
| 为什么有用? | 这些功能允许组件知道路由系统和当前活动的路由。 |
| 如何使用? | props 提供对高级路由功能的访问。 |
| 有什么陷阱或限制吗? | 这些都是高级功能,需要注意确保它们正确集成到组件中。 |
| 还有其他选择吗? | 这些是可选功能。应用可以使用第二十一章中描述的标准功能,或者完全避免 URL 路由。 |

表 22-2 总结了本章内容。

表 22-2

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 接收组件中路由系统的详细信息 | 使用Route组件提供的属性或使用withRouter高阶组件 | 3, 4, 10–12, 19–23 |
| 获取当前导航位置的详细信息 | 使用location属性 | five |
| 从当前路线获取 URL 段 | 向 URL 添加参数 | 6–9 |
| 以编程方式导航 | 使用由history属性定义的方法 | 13, 14 |
| 导航前提示用户 | 使用Prompt组件 | 15–17 |

为本章做准备

在本章中,我继续使用第二十一章中的productapp项目。为了准备本章,将应用使用的路由从HashRouter更改为BrowserRouter,以便使用 HTML5 历史 API 进行导航,并简化了LinkRouter组件,如清单 22-1 所示。

小费

你可以从 https://github.com/Apress/pro-react-16 下载本章以及本书其他章节的示例项目。

import React, { Component } from "react";

import { BrowserRouter as Router, NavLink, Route, Switch, Redirect }

    from "react-router-dom";
import { ProductDisplay } from "./ProductDisplay";
import { SupplierDisplay } from "./SupplierDisplay";

export class Selector extends Component {

    render() {
        return <Router>
            <div className="container-fluid">
                <div className="row">
                    <div className="col-2">
                        <NavLink className="m-2 btn btn-block btn-primary"
                            activeClassName="active"
                            to="/products">Products</NavLink>
                        <NavLink className="m-2 btn btn-block btn-primary"
                            activeClassName="active"
                            to="/suppliers">Suppliers</NavLink>
                    </div>
                    <div className="col">
                        <Switch>
                            <Route path="/products" component={ ProductDisplay} />
                            <Route path="/suppliers" component={ SupplierDisplay } />
                            <Redirect to="/products" />
                        </Switch>
                    </div>
                </div>
            </div>
        </Router>
    }
}

Listing 22-1Changing Routers and Routes in the Selector.js File in the src Folder

打开命令提示符,导航到productapp文件夹,运行清单 22-2 中所示的命令来启动开发工具。

npm start

Listing 22-2Starting the Development Tools

应用编译完成后,开发 HTTP 服务器将启动并显示如图 22-1 所示的内容。

img/473159_1_En_22_Fig1_HTML.jpg

图 22-1

运行示例应用

创建路由感知组件

当一个Route显示一个组件时,它向组件提供描述当前路线的上下文数据,并提供对可用于导航的 API 的访问,允许组件知道当前位置并参与路线选择。当使用component属性时,Route将数据和 API 传递给它显示为属性的组件,名为matchlocationhistory。当使用render属性时,渲染函数被传递一个具有matchlocationhistory属性的对象,这些属性的值与用作render属性的对象相同。表 22-3 中描述了matchlocationhistory对象。

表 22-3

路由组件提供的属性

|

名字

|

描述

|
| --- | --- |
| match | 此属性提供有关路由组件如何匹配当前浏览器 URL 的信息。 |
| location | 该属性提供了当前位置的表示,可用于导航,而不是表示为字符串的 URL。 |
| history | 这个属性提供了一个可用于导航的 API,如“以编程方式导航”一节中所示。 |

了解比赛属性

match prop 提供了一个组件,其中包含了父节点Route如何匹配当前 URL 的细节。正如我在第二十一章中所演示的,单个Route可以用来匹配一系列 URL,路由感知组件通常需要关于当前 URL 的细节,这些细节可以通过表 22-4 中所示的属性获得。

表 22-4

匹配属性

|

名字

|

描述

|
| --- | --- |
| url | 该属性返回与Route匹配的 URL。 |
| path | 该属性返回用于匹配 URL 的path值。 |
| params | 该属性返回路由参数,这些参数允许将 URL 段映射到变量,如“使用 URL 参数”一节中所述。 |
| isExact | 如果路由路径与 URL 完全匹配,该属性返回true。 |

为了演示路由属性的使用,我创建了src/routing文件夹,并添加了一个名为RouteInfo.js的文件,其组件如清单 22-3 所示,显示了match属性的值。

import React, { Component } from "react";

export class RouteInfo extends Component {

    renderTable(title, prop, propertyNames) {
        return <React.Fragment>
            <tr><th colSpan="2" className="text-center">{ title }</th></tr>
            { propertyNames.map(p =>
                <tr key={p }>
                    <td>{ p }</td>
                    <td>{ JSON.stringify(prop[p]) }</td>
                </tr>)
            }
        </React.Fragment>
    }

    render() {
        return <div className="bg-info m-2 p-2">
            <h4 className="text-white text-center">Route Info</h4>
            <table className="table table-sm table-striped bg-light">
                <tbody>
                    { this.renderTable("Match", this.props.match,
                        ["url", "path", "params", "isExact"] )}
                </tbody>
            </table>
        </div>
    }
}

Listing 22-3The Contents of the RouteInfo.js File in the src/routing Folder

RouteInfo组件在一个表格中显示匹配属性的urlpathparamsisExact属性,并允许我稍后轻松地添加其他路由属性的附加细节。属性是序列化的,因为值是对象和布尔值的混合,如果按字面意思使用,可能会导致显示问题。在清单 22-4 中,我添加了一个到Selector组件的导航链接,以及一个显示RouteInfo组件的Route

import React, { Component } from "react";
import { BrowserRouter as Router, NavLink, Route, Switch, Redirect }
    from "react-router-dom";
import { ProductDisplay } from "./ProductDisplay";
import { SupplierDisplay } from "./SupplierDisplay";

import { RouteInfo } from "./routing/RouteInfo";

export class Selector extends Component {

    render() {
        return <Router>
            <div className="container-fluid">
                <div className="row">
                    <div className="col-2">
                        <NavLink className="m-2 btn btn-block btn-primary"
                            activeClassName="active"
                            to="/products">Products</NavLink>
                        <NavLink className="m-2 btn btn-block btn-primary"
                            activeClassName="active"
                            to="/suppliers">Suppliers</NavLink>
                        <NavLink className="m-2 btn btn-block btn-primary"
                            activeClassName="active" to="/info">Route Info</NavLink>
                    </div>
                    <div className="col">
                        <Switch>
                            <Route path="/products" component={ ProductDisplay} />
                            <Route path="/suppliers" component={ SupplierDisplay } />
                            <Route path="/info" component={ RouteInfo } />
                            <Redirect to="/products" />
                        </Switch>
                    </div>
                </div>
            </div>
        </Router>
    }
}

Listing 22-4Adding a Route in the Selector.js File in the src Folder

保存更改并点击Route Info链接,您将看到match属性的详细信息,如图 22-2 所示。显示的值表明Route组件的路径属性是/info,它匹配新Link组件指向的/info URL。随着我引入更高级的路由特性,特别是当我在“使用 URL 参数”一节中引入 URL 参数时,match prop 提供的信息将变得更加有用。

img/473159_1_En_22_Fig2_HTML.jpg

图 22-2

匹配路由属性提供的详细信息

了解位置属性

location对象用于描述导航位置。作为属性提供的location对象描述了当前位置,具有表 22-5 中描述的属性。

表 22-5

位置属性

|

名字

|

描述

|
| --- | --- |
| key | 此属性返回标识位置的键。 |
| pathname | 此属性返回位置的路径。 |
| search | 该属性返回位置 URL 的搜索词(URL 中跟在?字符后面的部分)。 |
| hash | 该属性返回位置 URL 的 URL 片段(跟在#字符后面的部分)。 |
| state | 该属性用于将任意数据与位置相关联。 |

location属性提供了一些与match属性的重叠,但是其思想是组件可以保留一个位置对象,并使用它来引用一个位置,而不是使用字符串作为LinkNavLinkRedirect组件的to属性的值。在清单 22-5 中,我将 location prop 添加到了由RouteInfo显示的数据中,同时添加了一个使用location对象作为导航目标的Link元素。

import React, { Component } from "react";

import { Link } from "react-router-dom";

export class RouteInfo extends Component {

    renderTable(title, prop, propertyNames) {
        return <React.Fragment>
            <tr><th colSpan="2" className="text-center">{ title }</th></tr>
            { propertyNames.map(p =>
                <tr key={p }>
                    <td>{ p }</td>
                    <td>{ JSON.stringify(prop[p]) }</td>
                </tr>)
            }
        </React.Fragment>
    }

    render() {
        return <div className="bg-info m-2 p-2">
            <h4 className="text-white text-center">Route Info</h4>
            <table className="table table-sm table-striped bg-light">
                <tbody>
                    { this.renderTable("Match", this.props.match,
                        ["url", "path", "params", "isExact"] )}
                    { this.renderTable("Location", this.props.location,
                        ["key", "pathname", "search", "hash", "state"] )}
                </tbody>
            </table>
            <div className="text-center m-2 bg-light">
                <Link className="btn btn-primary m-2"
                    to={ this.props.location }>Location</Link>
            </div>
        </div>
    }
}

Listing 22-5Using the Location Prop in the RouteInfo.js File in the src/routing Folder

图 22-3 显示了定位支柱和新Link组件的细节。

img/473159_1_En_22_Fig3_HTML.jpg

图 22-3

显示位置路由属性的详细信息

使用location属性作为Link组件的to属性的值目前并不是特别有用,因为它只能导航到当前位置。正如您将看到的,组件可用于响应多条路线,并可能随着时间的推移接收一系列位置,这使得使用location对象既有用又比使用字符串表示的 URL 更方便。

使用 URL 参数

当组件知道 URL 路由系统时,它通常需要调整其行为以适应当前的 URL。React-Router 包支持 URL 参数,该参数将 URL 段的内容分配给一个可由组件读取的变量,从而允许组件响应当前位置,而无需解析 URL 或理解其结构。清单 22-6 显示添加了一个Route,它的路径包括一个 URL 参数和指向它的Link组件。

import React, { Component } from "react";
import { BrowserRouter as Router, NavLink, Route, Switch, Redirect }
    from "react-router-dom";
import { ProductDisplay } from "./ProductDisplay";
import { SupplierDisplay } from "./SupplierDisplay";
import { RouteInfo } from "./routing/RouteInfo";

export class Selector extends Component {

    render() {
        return <Router>
            <div className="container-fluid">
                <div className="row">
                    <div className="col-2">
                        <NavLink className="m-2 btn btn-block btn-primary"
                            activeClassName="active"
                            to="/products">Products</NavLink>
                        <NavLink className="m-2 btn btn-block btn-primary"
                            activeClassName="active"
                            to="/suppliers">Suppliers</NavLink>
                        <NavLink className="m-2 btn btn-block btn-primary"
                            activeClassName="active"
                            to="/info/match">Match</NavLink>
                        <NavLink className="m-2 btn btn-block btn-primary"
                            activeClassName="active"
                            to="/info/location">Location</NavLink>
                    </div>
                    <div className="col">
                        <Switch>
                            <Route path="/products" component={ ProductDisplay} />
                            <Route path="/suppliers" component={ SupplierDisplay } />
                            <Route path="/info/:datatype" component={ RouteInfo } />
                            <Redirect to="/products" />
                        </Switch>
                    </div>
                </div>
            </div>
        </Router>
    }
}

Listing 22-6Defining a URL Parameter in the Selector.js File in the src Folder

URL 参数被指定为以冒号(:字符)开头的path属性段。在这个例子中,RouteInfo组件的Route有一个带有名为datatype的 URL 参数的path属性。

...
<Route path="/info/:datatype" component={ RouteInfo } />
...

Route匹配一个 URL 时,它会将第二段的值赋给一个名为datatype的 URL 参数,该参数将通过match prop 的params属性传递给RouteInfo组件。如果您点击添加到清单 22-6 中示例的导航链接,您将看到为params属性显示不同的值,如图 22-4 所示。

img/473159_1_En_22_Fig4_HTML.jpg

图 22-4

通过匹配属性接收 URL 参数

当 URL 为/info/match时,datatype参数的值为match。当 URL 为/info/location时,datatype 参数的值为location。在清单 22-7 中,我已经更新了RouteInfo组件,使用datatype属性来选择呈现给用户的上下文数据。

import React, { Component } from "react";
import { Link } from "react-router-dom";

export class RouteInfo extends Component {

    renderTable(title, prop, propertyNames) {
        return <React.Fragment>
            <tr><th colSpan="2" className="text-center">{ title }</th></tr>
            { propertyNames.map(p =>
                <tr key={p }>
                    <td>{ p }</td>
                    <td>{ JSON.stringify(prop[p]) }</td>
                </tr>)
            }
        </React.Fragment>
    }

    render() {
        return <div className="bg-info m-2 p-2">
            <h4 className="text-white text-center">Route Info</h4>
            <table className="table table-sm table-striped bg-light">
                <tbody>
                    { this.props.match.params.datatype ==="match"
                        && this.renderTable("Match", this.props.match,
                            ["url", "path", "params", "isExact"] )}
                    { this.props.match.params.datatype === "location"
                        &&  this.renderTable("Location", this.props.location,
                            ["key", "pathname", "search", "hash", "state"] )}
                </tbody>
            </table>
            <div className="text-center m-2 bg-light">
                <Link className="btn btn-primary m-2"
                    to={ this.props.location }>Location</Link>
            </div>
        </div>
    }
}

Listing 22-7Using a URL Parameter Prop in the RouteInfo.js File in the src/routing Folder

该组件接收 URL 参数作为路由属性的一部分,并像使用任何其他属性一样使用它们。在清单中,datatype URL 参数的值用于显示匹配或位置对象的内联表达式,如图 22-5 所示。

img/473159_1_En_22_Fig5_HTML.jpg

图 22-5

通过选择内容来响应 URL 参数

了解不透明的 URL 结构

URL 参数不仅仅是组件接收 URL 段内容的一种便捷方式。它们还将 URL 的结构从它所针对的组件中分离出来,允许在不修改组件的情况下修改 URL 的结构或多个 URL 针对相同的内容。例如,清单 22-7 中的组件依赖于datatype URL 参数,但是不依赖于获取它的 URL 部分。这意味着组件将与路径如/info/:datatype一起工作,但是也可以与路径如/diagnostics/routing/:datatype匹配,而不需要改变组件的代码。

URL 参数的优点是组件只需要知道它所需要的 URL 参数的名称,而不需要知道它们出现在 URL 中的详细位置。

使用可选的 URL 参数

添加 URL 参数意味着/info URL 将不再与Route组件匹配。我可以通过添加另一个Route来解决这个问题,但是一个更好的方法是使用一个可选参数,这将允许 URL 匹配path,即使没有相应的段。在清单 22-8 中,我添加了一个导航到/info URL 的NavLink,并更改了Route组件的路径,因此datatype参数是可选的。

import React, { Component } from "react";
import { BrowserRouter as Router, NavLink, Route, Switch, Redirect }
    from "react-router-dom";
import { ProductDisplay } from "./ProductDisplay";
import { SupplierDisplay } from "./SupplierDisplay";
import { RouteInfo } from "./routing/RouteInfo";

export class Selector extends Component {

    render() {
        return <Router>
            <div className="container-fluid">
                <div className="row">
                    <div className="col-2">
                        <NavLink className="m-2 btn btn-block btn-primary"
                            activeClassName="active"
                            to="/products">Products</NavLink>
                        <NavLink className="m-2 btn btn-block btn-primary"
                            activeClassName="active"
                            to="/suppliers">Suppliers</NavLink>
                        <NavLink className="m-2 btn btn-block btn-primary"
                            activeClassName="active"
                            to="/info/match">Match</NavLink>
                        <NavLink className="m-2 btn btn-block btn-primary"
                            activeClassName="active"
                            to="/info/location">Location</NavLink>
                        <NavLink className="m-2 btn btn-block btn-primary"
                            activeClassName="active" to="/info">All Info</NavLink>
                    </div>
                    <div className="col">
                        <Switch>
                            <Route path="/products" component={ ProductDisplay} />
                            <Route path="/suppliers" component={ SupplierDisplay } />
                            <Route path="/info/:datatype?" component={ RouteInfo } />
                            <Redirect to="/products" />
                        </Switch>
                    </div>
                </div>
            </div>
        </Router>
    }
}

Listing 22-8Using an Optional URL Parameter in the Selector.js File in the src Folder

可选的 URL 参数在参数名称后用一个问号(?字符)表示,因此datatype?表示一个可选参数,如果在 URL 中有相应的段,它将被命名为datatype。如果没有段,路径仍然是match,但是没有datatype值。在清单 22-9 中,我已经更新了RouteInfo组件,如果没有datatype值,它将显示matchlocation对象的详细信息。

小费

关于指定 URL 参数的不同方式的完整列表,请参见 https://github.com/pillarjs/path-to-regexp ,它是处理 URL 的包的 GitHub 存储库。

import React, { Component } from "react";
import { Link } from "react-router-dom";

export class RouteInfo extends Component {

    renderTable(title, prop, propertyNames) {
        return <React.Fragment>
            <tr><th colSpan="2" className="text-center">{ title }</th></tr>
            { propertyNames.map(p =>
                <tr key={p }>
                    <td>{ p }</td>
                    <td>{ JSON.stringify(prop[p]) }</td>
                </tr>)
            }
        </React.Fragment>
    }

    render() {
        return <div className="bg-info m-2 p-2">
            <h4 className="text-white text-center">Route Info</h4>
            <table className="table table-sm table-striped bg-light">
                <tbody>
                    { (this.props.match.params.datatype === undefined ||
                            this.props.match.params.datatype ==="match")
                        && this.renderTable("Match", this.props.match,
                            ["url", "path", "params", "isExact"] )}
                    { (this.props.match.params.datatype === undefined ||
                            this.props.match.params.datatype === "location")
                        &&  this.renderTable("Location", this.props.location,
                            ["key", "pathname", "search", "hash", "state"] )}
                </tbody>
            </table>
            <div className="text-center m-2 bg-light">
                <Link className="btn btn-primary m-2"
                    to={ this.props.location }>Location</Link>
            </div>
        </div>
    }
}

Listing 22-9Handling an Optional URL Parameter in the RouteInfo.js File in the src Folder

如果 URL 中没有匹配的段,datatype参数的值将是undefined。清单中的变化和可选 URL 参数的添加允许组件响应更广泛的 URL,而不需要使用额外的Route组件。

访问其他组件中的路由数据

A Route会给它显示的组件添加属性但是不能直接提供给其他组件,包括它显示的组件的后代。为了避免正确的线程化,React-Router 包提供了两种不同的方法来访问后代组件中的路由数据,如下面几节所述。

直接在组件中访问路由数据

访问路由数据最直接的方法是在render方法中使用Route。为了演示,我在src/routing文件夹中添加了一个名为ToggleLink.js的文件,并用它来定义清单 22-10 中所示的组件。

小费

这是我在第一部分的 SportsStore 应用中用来突出显示活动路线的相同组件。

import React, { Component } from "react";
import { Route, Link } from "react-router-dom";

export class ToggleLink extends Component {

    render() {
        return <Route path={ this.props.to } exact={ this.props.exact }
                children={ routeProps => {

            const baseClasses = this.props.className || "m-2 btn btn-block";
            const activeClass = this.props.activeClass || "btn-primary";
            const inActiveClass = this.props.inActiveClass || "btn-secondary"

            const combinedClasses =
                `${baseClasses} ${routeProps.match ? activeClass : inActiveClass}`

            return <Link to={ this.props.to } className={ combinedClasses }>
                        { this.props.children }
                    </Link>
         }} />
    }
}

Listing 22-10The Contents of the ToggleLink.js File in the src/routing Folder

不管当前 URL 是什么,Route组件的children属性用于呈现内容,并被分配一个接收路由上下文数据的函数。path属性用于表示对一个 URL 的兴趣,当当前 URL 与path匹配时,传递给子函数的routeProps对象包含一个match对象,该对象定义了表 22-4 中描述的属性。

ToggleLink组件允许我解决在NavLink组件和引导 CSS 框架之间出现的一个小问题。NavLink的工作原理是,当路径匹配时,向它呈现的锚元素添加一个类,其余时间删除它。这给一些引导类的组合带来了问题,因为它们在 CSS 样式表中定义的顺序意味着一些类,比如btn-primary,在一个相关的类,比如btn-secondary被移除之前不会生效。

ToggleLink组件通过在有match对象时添加一个活动类,在没有match时添加一个非活动类来解决这个问题。

...
const combinedClasses =
    `${baseClasses} ${routeProps.match ? activeClass : inActiveClass}`
...

一个Link仍然被用来生成导航元素和响应点击,但是被ToggleLink组件设计成这样,我可以自由使用引导 CSS 类。在清单 22-11 中,我用一个ToggleLink替换了每个NavLink

import React, { Component } from "react";

import { BrowserRouter as Router, Route, Switch, Redirect }

    from "react-router-dom";
import { ProductDisplay } from "./ProductDisplay";
import { SupplierDisplay } from "./SupplierDisplay";
import { RouteInfo } from "./routing/RouteInfo";

import { ToggleLink } from "./routing/ToggleLink";

export class Selector extends Component {

    render() {
        return <Router>
            <div className="container-fluid">
                <div className="row">
                    <div className="col-2">
                        <ToggleLink to="/products">Products</ToggleLink>
                        <ToggleLink to="/suppliers">Suppliers</ToggleLink>
                        <ToggleLink to="/info/match">Match</ToggleLink>
                        <ToggleLink to="/info/location">Location</ToggleLink>
                        <ToggleLink to="/info" exact={ true }>All Info</ToggleLink>
                    </div>
                    <div className="col">
                        <Switch>
                            <Route path="/products" component={ ProductDisplay} />
                            <Route path="/suppliers" component={ SupplierDisplay } />
                            <Route path="/info/:datatype?" component={ RouteInfo } />
                            <Redirect to="/products" />
                        </Switch>
                    </div>
                </div>
            </div>
        </Router>
    }
}

Listing 22-11Replacing Navigation Components in the Selector.js File in the src Folder

我依赖于清单 22-10 中指定的默认类,结果是导航按钮在活动时被添加到引导程序btn-primary类,在不活动时被添加到btn-secondary类,如图 22-6 所示。

img/473159_1_En_22_Fig6_HTML.jpg

图 22-6

直接在组件中访问路由数据

使用高阶组件访问路由数据

withRouter函数是一个高阶组件,它提供对路由系统的访问,而不直接使用Route(尽管这是在withRouter函数中使用的技术)。当一个组件被传递给withRouter时,它接收matchlocationhistory对象作为属性,就好像它是由Route使用component属性直接渲染的一样。对于编写呈现Route的组件来说,这是一种方便的替代方式。在清单 22-12 中,我使用了withRouter函数来允许在Route之外使用RouteInfo组件。

import React, { Component } from "react";

import { BrowserRouter as Router, Route, Switch, Redirect, withRouter }

    from "react-router-dom";
import { ProductDisplay } from "./ProductDisplay";
import { SupplierDisplay } from "./SupplierDisplay";
import { RouteInfo } from "./routing/RouteInfo";
import { ToggleLink } from "./routing/ToggleLink";

const RouteInfoHOC = withRouter(RouteInfo)

export class Selector extends Component {

    render() {
        return <Router>
            <div className="container-fluid">
                <div className="row">
                    <div className="col-2">
                        <ToggleLink to="/products">Products</ToggleLink>
                        <ToggleLink to="/suppliers">Suppliers</ToggleLink>
                        <ToggleLink to="/info/match">Match</ToggleLink>
                        <ToggleLink to="/info/location">Location</ToggleLink>
                        <ToggleLink to="/info" exact={ true }>All Info</ToggleLink>
                    </div>
                    <div className="col">
                        <RouteInfoHOC />
                        <Switch>
                            <Route path="/products" component={ ProductDisplay} />
                            <Route path="/suppliers" component={ SupplierDisplay } />
                            <Route path="/info/:datatype?" component={ RouteInfo } />
                            <Redirect to="/products" />
                        </Switch>
                    </div>
                </div>
            </div>
        </Router>
    }
}

Listing 22-12Creating a Routing HOC in the Selector.js File in the src Folder

withRouter函数用于向RouteInfo组件提供它所需要的数据,即使它没有被Route显示。结果是matchlocation对象的细节总是被显示,如图 22-7 所示。

img/473159_1_En_22_Fig7_HTML.jpg

图 22-7

使用 withrouter HOC

withRouter函数不提供对匹配路径的支持,这意味着 match 对象用处不大。然而,location对象提供了应用当前位置的细节,而history对象可以用于编程导航,这将在下一节中描述。

以编程方式导航

并不是所有的导航都可以使用LinkNavLink组件来处理,特别是当应用需要执行一些内部动作来响应一个事件,然后才执行导航时。提供给组件的history对象提供了一个 API,允许编程访问路由系统,使用表 22-6 中描述的方法。history对象为导航提供了一致的接口,不管应用是使用 HTML5 历史 API 还是 URL 片段。

表 22-6

历史方法

|

名字

|

描述

|
| --- | --- |
| push(path) | 此方法导航到指定的路径,并在浏览器的历史记录中添加一个新条目。可以通过location.state属性提供一个可选的状态属性。 |
| replace(path) | 此方法导航到指定的路径,并替换浏览器历史记录中的当前位置。可以通过location.state属性提供一个可选的状态属性。 |
| goBack() | 此方法导航到浏览器历史记录中的上一个位置。 |
| goForward() | 此方法导航到浏览器历史记录中的下一个位置。 |
| go(n) | 该方法从当前位置导航到历史位置n处。使用正值向前移动,负值向后移动。 |
| block(prompt) | 该方法会阻止导航,直到用户对提示做出响应,如“导航前提示用户”一节中所述。 |

在清单 22-13 中,我用一个按钮替换了ToggleLink组件中的Link,该按钮的事件处理程序以编程方式导航。

import React, { Component } from "react";

import { Route } from "react-router-dom";

export class ToggleLink extends Component {

    handleClick = (history) => {
        history.push(this.props.to);
    }

    render() {
        return <Route path={ this.props.to } exact={ this.props.exact }
                children={ routeProps => {

            const baseClasses = this.props.className || "m-2 btn btn-block";
            const activeClass = this.props.activeClass || "btn-primary";
            const inActiveClass = this.props.inActiveClass || "btn-secondary"

            const combinedClasses =
                `${baseClasses} ${routeProps.match ? activeClass : inActiveClass}`

            return  <button className={ combinedClasses }
                        onClick={ () => this.handleClick(routeProps.history) }>
                        {this.props.children}
                    </button>
         }} />
    }
}

Listing 22-13Navigating Programmatically in the ToggleLink.js File in the src/router Folder

onClick处理程序将从Route组件接收的history对象传递给handleClick方法,后者使用push方法导航到由to属性指定的位置。没有明显的区别,因为由Link组件呈现的锚元素已经被设计成按钮,但是ToggleLink组件现在直接处理它的导航。

使用组件以编程方式导航

使用history对象的另一种方法是呈现执行导航的组件。在清单 22-14 中,我已经更改了ToggleLink组件,因此单击button元素会更新状态数据,从而导致呈现一个Redirect

import React, { Component } from "react";

import { Route, Redirect } from "react-router-dom";

export class ToggleLink extends Component {

    constructor(props) {
        super(props);
        this.state = {
            doRedirect: false
        }
    }

    handleClick = () => {
        this.setState({ doRedirect: true},
            () => this.setState({ doRedirect: false }));
    }

    render() {
        return <Route path={ this.props.to } exact={ this.props.exact }
                children={ routeProps => {

            const baseClasses = this.props.className || "m-2 btn btn-block";
            const activeClass = this.props.activeClass || "btn-primary";
            const inActiveClass = this.props.inActiveClass || "btn-secondary"

            const combinedClasses =
                `${baseClasses} ${routeProps.match ? activeClass : inActiveClass}`

            return  <React.Fragment>
                { this.state.doRedirect && <Redirect to={ this.props.to } /> }
                <button className={ combinedClasses } onClick={ this.handleClick }>
                    {this.props.children}
                </button>
            </React.Fragment>
         }} />
    }
}

Listing 22-14Navigating Using Components in the ToggleLink.js File in the src/router Folder

单击该按钮会将doRedirect属性设置为true,这将触发呈现Redirect组件的更新。doRedirect属性被自动设置回false,以便组件的正常内容被再次呈现。结果和清单 22-13 一样,选择一种方式是个人喜好和个人风格的问题。

导航前提示用户

可以通过呈现一个Prompt来延迟导航,它允许用户确认或取消导航,通常用于避免意外放弃表单数据。Prompt组件支撑表 22-7 中所述的支柱。

表 22-7

提示组件属性

|

名字

|

描述

|
| --- | --- |
| message | 这个属性定义了显示给用户的消息。它可以表示为一个字符串,也可以表示为接受一个location对象并返回一个字符串的函数。 |
| when | 只有当这个属性的值等于true时,它才会提示用户,并且可以用来有条件地阻止导航。 |

只使用了一个Prompt,但是它呈现在哪里并不重要,因为它不会执行任何操作,直到应用更改到一个新位置,这时用户将被要求确认导航。在清单 22-15 中,我给Selector组件添加了一个Prompt

小费

只需要一个Prompt,并且您不应该在执行导航的组件中呈现额外的Prompt实例,例如示例应用中的ToggleLink组件。如果您呈现多个Prompt组件,您将在 JavaScript 控制台中收到一个警告。

import React, { Component } from "react";

import { BrowserRouter as Router, Route, Switch, Redirect, withRouter, Prompt }

    from "react-router-dom";
import { ProductDisplay } from "./ProductDisplay";
import { SupplierDisplay } from "./SupplierDisplay";
import { RouteInfo } from "./routing/RouteInfo";
import { ToggleLink } from "./routing/ToggleLink";

const RouteInfoHOC = withRouter(RouteInfo)

export class Selector extends Component {

    render() {
        return <Router>
            <div className="container-fluid">
                <div className="row">
                    <div className="col-2">
                        <ToggleLink to="/products">Products</ToggleLink>
                        <ToggleLink to="/suppliers">Suppliers</ToggleLink>
                        <ToggleLink to="/info/match">Match</ToggleLink>
                        <ToggleLink to="/info/location">Location</ToggleLink>
                        <ToggleLink to="/info" exact={ true }>All Info</ToggleLink>
                    </div>
                    <div className="col">
                        <Prompt message={ loc =>
                            `Do you want to navigate to ${loc.pathname}`} />
                        <RouteInfoHOC />
                        <Switch>
                            <Route path="/products" component={ ProductDisplay} />
                            <Route path="/suppliers" component={ SupplierDisplay } />
                            <Route path="/info/:datatype?" component={ RouteInfo } />
                            <Redirect to="/products" />
                        </Switch>
                    </div>
                </div>
            </div>
        </Router>
    }
}

Listing 22-15Prompting the User in the Selector.js File in the src Folder

要查看Prompt的效果,单击由ToggleLink组件呈现的按钮元素之一。你将被要求确认导航,如图 22-8 所示。

img/473159_1_En_22_Fig8_HTML.jpg

图 22-8

导航前提示用户

小费

如果您喜欢使用history对象进行导航,那么可以使用block方法来设置一个提示,该提示将呈现给用户,如下一节所示。

呈现自定义导航提示

BrowserRouterHashRouter组件提供了一个getUserConfirmation属性,用于用自定义函数替换默认提示。为了向用户显示与应用其余内容一致的提示,我在src/routing文件夹中添加了一个名为CustomPrompt.js的文件,并用它来定义清单 22-16 中所示的组件。

import React, { Component } from "react";

export class CustomPrompt extends Component {

    render() {
        if (this.props.show) {
            return <div className="alert alert-warning m-2 text-center">
                <h4 className="alert-heading">Navigation Warning</h4>
                    { this.props.message }
                <div className="p-1">
                    <button className="btn btn-primary m-1"
                        onClick={ () => this.props.callback(true) }>
                            Yes
                    </button>
                    <button className="btn btn-secondary m-1"
                        onClick={ () => this.props.callback(false )}>
                            No
                    </button>
                </div>
            </div>
        }
        return null;
    }
}

Listing 22-16The Contents of the CustomPrompt.js File in the src/routing Folder

CustomPrompt组件负责向用户显示一条消息,并提供 Yes 和 No 按钮,这些按钮调用一个回调函数来确认或阻止导航。在清单 22-17 中,我在Selector组件中应用了CustomPrompt,以及管理提示过程所需的状态数据。

import React, { Component } from "react";
import { BrowserRouter as Router, Route, Switch, Redirect, withRouter, Prompt }
    from "react-router-dom";
import { ProductDisplay } from "./ProductDisplay";
import { SupplierDisplay } from "./SupplierDisplay";
import { RouteInfo } from "./routing/RouteInfo";
import { ToggleLink } from "./routing/ToggleLink";

import { CustomPrompt } from "./routing/CustomPrompt";

const RouteInfoHOC = withRouter(RouteInfo)

export class Selector extends Component {

    constructor(props) {
        super(props);
        this.state = {
            showPrompt: false,
            message: "",
            callback: () => {}
        }
    }

    customGetUserConfirmation = (message, navCallback) => {
        this.setState({
            showPrompt: true, message: message,
            callback: (allow) => { navCallback(allow);
                this.setState({ showPrompt: false}) }
        });
    }

    render() {
        return <Router getUserConfirmation={ this.customGetUserConfirmation }>
            <div className="container-fluid">
                <div className="row">
                    <div className="col-2">
                        <ToggleLink to="/products">Products</ToggleLink>
                        <ToggleLink to="/suppliers">Suppliers</ToggleLink>
                        <ToggleLink to="/info/match">Match</ToggleLink>
                        <ToggleLink to="/info/location">Location</ToggleLink>
                        <ToggleLink to="/info" exact={ true }>All Info</ToggleLink>
                    </div>
                    <div className="col">
                        <CustomPrompt show={ this.state.showPrompt }
                            message={ this.state.message }
                            callback={ this.state.callback } />
                        <Prompt message={ loc =>
                            `Do you want to navigate to ${loc.pathname}?`} />
                        <RouteInfoHOC />
                        <Switch>
                            <Route path="/products" component={ ProductDisplay} />
                            <Route path="/suppliers" component={ SupplierDisplay } />
                            <Route path="/info/:datatype?" component={ RouteInfo } />
                            <Redirect to="/products" />
                        </Switch>
                    </div>
                </div>
            </div>
        </Router>
    }
}

Listing 22-17Appling a Custom Prompt in the Selector.js File in the src Folder

BrowserRouterHashRouter支持的getUserConfirmation prop 被分配一个函数,该函数接收一个显示给用户的消息和一个由用户决定调用的回调:true处理导航,falseblock它。在清单中,getUserConfirmation属性将调用customGetUserConfirmation方法,该方法更新用于CustomPrompt属性的状态数据,结果是提示用户,如图 22-9 所示。

img/473159_1_En_22_Fig9_HTML.jpg

图 22-9

使用自定义提示

小费

注意,我仍然需要使用一个Prompt,它负责触发显示CustomPrompt的流程。

以编程方式生成路线

Selector组件使用ToggleLinkRoute组件来建立应用支持的 URL 和它们相关的内容之间的映射,但是在我添加对 URL 路由的支持之前,这不是应用的工作方式。相反,App组件将Selector视为一个容器,并为其提供子组件进行显示,如下所示:

import React, { Component } from "react";
import { Provider } from "react-redux";
import dataStore from "./store";
import { Selector } from "./Selector";
import { ProductDisplay } from "./ProductDisplay";
import { SupplierDisplay } from "./SupplierDisplay";

export default class App extends Component {

    render() {
        return  <Provider store={ dataStore }>
                    <Selector>
                        <ProductDisplay name="Products" />
                        <SupplierDisplay name="Suppliers" />
                    </Selector>
                </Provider>
    }
}

在 React 开发中,使用提供服务的容器组件而不使用其子组件的硬编码知识是很重要的,并且在使用 React-Router 时可以很容易地应用,因为路由是使用组件定义和处理的。在清单 22-18 中,我修改了Selector组件,删除了本地定义的路线,改为从children属性中生成。

import React, { Component } from "react";

import { BrowserRouter as Router, Route, Switch, Redirect, Prompt }

    from "react-router-dom";

// import { ProductDisplay } from "./ProductDisplay";

// import { SupplierDisplay } from "./SupplierDisplay";

//import { RouteInfo } from "./routing/RouteInfo";

import { ToggleLink } from "./routing/ToggleLink";
import { CustomPrompt } from "./routing/CustomPrompt";

//const RouteInfoHOC = withRouter(RouteInfo)

export class Selector extends Component {

    constructor(props) {
        super(props);
        this.state = {
            showPrompt: false,
            message: "",
            callback: () => {}
        }
    }

    customGetUserConfirmation = (message, navCallback) => {
        this.setState({
            showPrompt: true, message: message,
            callback: (allow) => { navCallback(allow);
                this.setState({ showPrompt: false}) }
        });
    }

    render() {

        const routes = React.Children.map(this.props.children, child => ({
            component: child,
            name: child.props.name,
            url: `/${child.props.name.toLowerCase()}`
        }));

        return <Router getUserConfirmation={ this.customGetUserConfirmation }>
            <div className="container-fluid">
                <div className="row">
                    <div className="col-2">
                        { routes.map(r => <ToggleLink key={ r.url } to={ r.url }>
                                            { r.name }
                                        </ToggleLink>)}
                    </div>
                    <div className="col">
                        <CustomPrompt show={ this.state.showPrompt }
                            message={ this.state.message }
                            callback={ this.state.callback } />
                        <Prompt message={ loc =>
                            `Do you want to navigate to ${loc.pathname}?`} />
                        <Switch>
                            { routes.map( r => <Route key={ r.url } path={ r.url }
                                    render={ () => r.component } />)}
                            <Redirect to={ routes[0].url } />
                        </Switch>
                    </div>
                </div>
            </div>
        </Router>
    }
}

Listing 22-18Generating Routes from Children in the Selector.js File in the src Folder

Selector处理其子进程以建立 URL 和组件之间的映射,并生成所需的ToggleLinkRoute组件,我用一个Redirect组件对其进行了补充,产生了如图 22-10 所示的结果。

img/473159_1_En_22_Fig10_HTML.jpg

图 22-10

以编程方式生成路线

对连接的数据存储组件使用路由

为了在示例应用中完成路由的采用,我将把协调组件的剩余状态数据移出数据存储,并使用表 22-8 中描述的一组 URL 对其进行管理。

表 22-8

示例应用的 URL

|

名字

|

描述

|
| --- | --- |
| /products/table | 该 URL 将显示产品列表。 |
| /products/create | 该 URL 将显示编辑器,允许创建新产品。 |
| /products/edit/4 | 此 URL 将显示编辑器,允许编辑现有产品,其中最后一个 URL 段标识要更改的产品。 |
| /suppliers/table | 该 URL 将显示供应商列表。 |
| /suppliers/create | 该 URL 将显示编辑器,允许创建新的供应商。 |
| /suppliers/edit/4 | 该 URL 将显示编辑器,允许编辑现有的供应商,其中最后一个 URL 段标识要更改的供应商。 |

应用需要的 URL 可以用带有 URL 参数的单一路径来处理,如下所示:

...
/:datatype/:mode?/:id?
...

在接下来的小节中,我将更新应用中的组件,以便数据存储仅用于模型数据,而应该向用户显示的内容的细节在 URL 中表示。(这种硬分离只是一种方法,如果适合您的项目,您可以采取更柔和的方法,以便一些状态数据在数据存储中处理,一些通过 URL 处理。在 React 开发中,没有绝对正确的方法。)

更换显示器组件

ProductDisplaySupplierDisplay组件负责决定是否为特定的数据类型显示表格或编辑器。由于示例应用中增加了一些功能,这些组件之间的差异已经减少,URL 路由的引入意味着单个组件可以轻松处理两种类型数据的内容选择。我在src/routing文件夹中添加了一个名为RoutedDisplay.js的文件,并用它来定义清单 22-19 中所示的组件。

import React, { Component } from "react";
import { ProductTable } from "../ProductTable"
import { ProductEditor } from "../ProductEditor";
import { EditorConnector } from "../store/EditorConnector";
import { PRODUCTS } from "../store/dataTypes";
import { TableConnector } from "../store/TableConnector";
import { Link } from "react-router-dom";
import { SupplierEditor } from "../SupplierEditor";
import { SupplierTable } from "../SupplierTable";

export const RoutedDisplay = (dataType) => {

    const ConnectedEditor = EditorConnector(dataType, dataType === PRODUCTS
        ? ProductEditor: SupplierEditor);
    const ConnectedTable = TableConnector(dataType, dataType === PRODUCTS
        ? ProductTable : SupplierTable);

    return class extends Component {
        render() {
            const modeParam = this.props.match.params.mode;
            if (modeParam === "edit" || modeParam === "create") {
                return <ConnectedEditor key={ this.props.match.params.id || -1 } />
            } else {
                return <div className="m-2">
                    <ConnectedTable />
                    <div className="text-center">
                        <Link to={`/${dataType}/create`}
                                className="btn btn-primary m-1">
                            Create
                        </Link>
                    </div>
                </div>
            }
        }
    }
}

Listing 22-19The Contents of the RoutedDisplay.js File in the src/routing Folder

该组件执行与ProductDisplaySupplierDisplay组件相同的任务,但是接收它负责的数据类型作为参数,这允许创建EditorConnectorTableConnector组件。

更新连接的编辑器组件

EditorConnector组件负责创建一个连接到 Redux 数据存储的ProductEditorSupplierEditor。在清单 22-20 中,我使用了withRouter函数来创建一个组件,该组件提供了路由数据,但仍然保持与数据存储的连接。

import { connect } from "react-redux";

//import { endEditing } from "./stateActions";

import { PRODUCTS, SUPPLIERS  } from "./dataTypes";
import { saveAndEndEditing } from "./multiActionCreators";
import { withRouter } from "react-router-dom";

export const EditorConnector = (dataType, presentationComponent) => {

    const mapStateToProps = (storeData, ownProps) => {
        const mode = ownProps.match.params.mode;
        const id = Number(ownProps.match.params.id);
        return {
            editing: mode === "edit" || mode === "create",
            product: (storeData.modelData[PRODUCTS].find(p => p.id === id)) || {},
            supplier:(storeData.modelData[SUPPLIERS].find(s => s.id === id)) || {}
        }
    }

    const mapDispatchToProps = {
        //cancelCallback: endEditing,
        saveCallback: (data) => saveAndEndEditing(data, dataType)
    }

    const mergeProps = (dataProps, functionProps, ownProps) => {
        let routedDispatchers = {
            cancelCallback: () => ownProps.history.push(`/${dataType}`),
            saveCallback: (data) => {
                functionProps.saveCallback(data);
                ownProps.history.push(`/${dataType}`);
            }
        }
        return Object.assign({}, dataProps, routedDispatchers, ownProps);
    }

    return withRouter(connect(mapStateToProps,
        mapDispatchToProps, mergeProps)(presentationComponent));
}

Listing 22-20Using Routing in the EditorConnector.js File in the src/store Folder

组件不再使用数据存储来判断用户是在编辑还是在创建一个对象,而是从 URL 获取该信息,以及编辑对象时的id值。

小费

注意,我使用Number来解析id URL 参数,它以字符串的形式出现。我需要id值是一个数字,以便定位对象。

我已经使用了第二十章中描述的合并属性的能力,来围绕数据存储动作创建器创建包装器,以便数据被保存到存储中,然后history对象被用于导航。不再需要取消动作,并且可以通过导航离开当前位置来直接处理。

避免被阻止的更新

withRouterconnect函数都使用shouldComponentUpdate方法产生试图最小化更新的组件,这将在第十三章中描述。当withRouterconnect函数一起使用时,结果可能是一个组件并不总是更新,因为 React-Router 和 React-Redux 包对 props 执行简单的比较,并没有意识到已经发生了变化。要避免这个问题,请简化 props 结构,使更改更容易被检测到。

更新连接的表组件

必须在将显示对象的表连接到数据存储的组件上执行相同的过程,如清单 22-21 所示。

import { connect } from "react-redux";

//import { startEditingProduct, startEditingSupplier } from "./stateActions";

import { deleteProduct, deleteSupplier } from "./modelActionCreators";
import { PRODUCTS, SUPPLIERS } from "./dataTypes";

import { withRouter } from "react-router-dom";

export const TableConnector = (dataType, presentationComponent) => {

    const mapStateToProps = (storeData, ownProps) => {
        if (dataType === PRODUCTS) {
            return { products: storeData.modelData[PRODUCTS] };
        } else {
            return {
                suppliers: storeData.modelData[SUPPLIERS].map(supp => ({
                    ...supp,
                    products: supp.products.map(id =>
                        storeData.modelData[PRODUCTS]
                            .find(p => p.id === Number(id)) || id)
                            .map(val => val.name || val)
                    }))
            }
        }
    }

    const mapDispatchToProps = (dispatch, ownProps) => {
        if (dataType === PRODUCTS) {
            return {
                //editCallback: (...args) => dispatch(startEditingProduct(...args)),
                deleteCallback: (...args) => dispatch(deleteProduct(...args))
            }
        } else {
            return {
                //editCallback: (...args) => dispatch(startEditingSupplier(...args)),
                deleteCallback: (...args) => dispatch(deleteSupplier(...args))
            }
        }
    }

    const mergeProps = (dataProps, functionProps, ownProps) => {
        let routedDispatchers = {
            editCallback: (target) => {
                ownProps.history.push(`/${dataType}/edit/${target.id}`);
            },
            deleteCallback: functionProps.deleteCallback
        }
        return Object.assign({}, dataProps, routedDispatchers, ownProps);
    }

    return withRouter(connect(mapStateToProps,
        mapDispatchToProps, mergeProps)(presentationComponent));
}

Listing 22-21Using Routing in the TableConnector.js File in the src/store Folder

我再次使用了withRouterconnect函数来生成一个可以访问路由数据和数据存储的组件。通过导航到指示数据类型和id值的 URL 来处理编辑功能。删除数据是完全由数据存储处理的任务,不需要导航。

完成路由配置

最后一步是更新路由配置,以支持表 22-8 中定义的 URL。在清单 22-22 中,我更新了Selector组件,使其在render函数中应用RoutedDisplay组件。(为了简洁起见,我还删除了导航提示组件和代码。)

import React, { Component } from "react";
import { BrowserRouter as Router, Route, Switch, Redirect }
    from "react-router-dom";
import { ToggleLink } from "./routing/ToggleLink";

//import { CustomPrompt } from "./routing/CustomPrompt";

import { RoutedDisplay } from "./routing/RoutedDisplay";

export class Selector extends Component {

    render() {

        const routes = React.Children.map(this.props.children, child => ({
            component: child,
            name: child.props.name,
            url: `/${child.props.name.toLowerCase()}`,
            datatype: child.props.datatype
        }));

        return <Router getUserConfirmation={ this.customGetUserConfirmation }>
            <div className="container-fluid">
                <div className="row">
                    <div className="col-2">
                        { routes.map(r => <ToggleLink key={ r.url } to={ r.url }>
                                            { r.name }
                                        </ToggleLink>)}
                    </div>
                    <div className="col">
                        <Switch>
                            { routes.map(r =>
                               <Route key={ r.url }
                                   path={ `/:datatype(${r.datatype})/:mode?/:id?`}
                                   component={ RoutedDisplay(r.datatype)} />
                            )}
                            <Redirect to={ routes[0].url } />
                        </Switch>
                    </div>
                </div>
            </div>
        </Router>
    }
}

Listing 22-22Changing the Routing Configuration in the Selector.js File in the src Folder

父组件提供的子组件不再是组件,它们的存在只是为了给Selector提供适当的值,以便它可以设置Route组件。在清单 22-23 中,我在App组件中反映了这一变化,它现在使用一个定制的 HTML 元素来配置Selector,而不是直接使用特定于数据的组件。

import React, { Component } from "react";
import { Provider } from "react-redux";
import dataStore from "./store";
import { Selector } from "./Selector";

// import { ProductDisplay } from "./ProductDisplay";

// import { SupplierDisplay } from "./SupplierDisplay";

import { PRODUCTS, SUPPLIERS } from "./store/dataTypes";

export default class App extends Component {

    render() {
        return  <Provider store={ dataStore }>
                    <Selector>
                        <data name="Products" datatype={ PRODUCTS } />
                        <data name="Suppliers" datatype ={ SUPPLIERS } />
                    </Selector>
                </Provider>
    }
}

Listing 22-23Completing the Routing Configuration in the App.js File in the src Folder

结果是数据存储不再用于组件之间的协调,现在完全通过 URL 来处理,如图 22-11 所示。

img/473159_1_En_22_Fig11_HTML.jpg

图 22-11

使用 URL 路由来协调组件

摘要

在本章中,我向您展示了如何使用 React-Router 包提供的高级特性。我演示了如何创建能够识别路由系统的组件,如何使用 URL 参数为组件提供对当前路由数据的轻松访问,以及如何以编程方式使用路由功能。我还演示了组件如何参与路由系统,同时还连接到 Redux,允许通过 URL 处理状态数据,而应用的模型数据由数据存储管理。在下一章,我将向您展示如何使用 RESTful web 服务。

二十三、使用 RESTful Web 服务

在这一章中,我通过创建一个 web 服务并使用它来管理应用的数据来解决示例应用缺乏永久数据存储的问题。应用将向 web 服务发送 HTTP 请求,以检索数据并提交更改。本章一开始,我将向您展示如何在组件中直接使用 web 服务,然后演示如何将 web 服务用于数据存储。在第二十四章中,我解释了如何使用 GraphQL,这是处理 web 服务的另一种方法。表 23-1 将这一章放在上下文中。

表 23-1

将消费 Web 服务放在上下文中

|

问题

|

回答

|
| --- | --- |
| 这是什么? | Web 服务充当应用的数据存储库,允许使用 HTTP 请求读取、存储、修改和删除数据。 |
| 为什么有用? | Web 服务非常适合浏览器中可用的特性,并且避免了处理本地存储问题。 |
| 如何使用? | Web 服务的实现方式不尽相同,但一般的方法是发送 HTTP 请求,其中请求方法标识要执行的操作,请求 URL 标识要操作的数据。 |
| 有什么陷阱或限制吗? | web 服务实现的不一致性意味着每个 web 服务可能需要一组稍微不同的请求。在组件中使用 web 服务时必须小心,以确保不会在每次更新时都发送请求。 |
| 还有其他选择吗? | 现代 web 浏览器支持本地存储选项,这对于某些项目来说是一个很好的选择。然而,主要的缺点是每个客户端都有自己的数据,这就错过了单一中央存储库的一些优势。 |

表 23-2 总结了本章内容。

表 23-2

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 从 web 服务获取数据 | 创建一个发出 HTTP 请求的数据源,并使用调用setState方法的回调将数据反馈给应用。 | 1–11 |
| 执行附加数据操作 | 扩展数据源以发送 HTTP 方法和 URL 的不同组合来指示所需的操作。通过响应组件事件来触发请求 | 12–15 |
| 处理请求错误 | 使用try / catch块捕捉错误并将其传递给组件,以便向用户显示警告。 | 16–19 |
| 使用带有数据存储的 web 服务 | 使用中间件拦截数据存储操作,并将所需的请求发送到 web 服务。请求完成后,将操作转发到数据存储,以便可以更新它。 | 20–24 |

为本章做准备

在这一章中,我继续使用第二十二章中的productapp项目,该项目在之后的章节中进行了修改。需要做一些准备工作来将附加的包安装到项目中,并创建应用将依赖的 web 服务。

小费

你可以从 https://github.com/Apress/pro-react-16 下载本章以及本书其他章节的示例项目。

将包添加到项目中

运行productapp文件夹中清单 23-1 中所示的命令,将所需的包添加到项目中。

npm install json-server@0.14.0 --save-dev
npm install npm-run-all@4.1.3 --save-dev
npm install axios@0.18.0

Listing 23-1Installing Additional Packages to the Project

为了快速参考,表 23-3 中描述了清单 23-1 中命令的包。

表 23-3

添加到项目中的包

|

名字

|

描述

|
| --- | --- |
| json-server | 这个包提供了一个 web 服务,应用将查询该服务中的数据。该命令与save-dev命令一起安装,因为它是开发所必需的,不是应用的一部分。 |
| npm-run-all | 这个包允许多个命令并行运行,以便 web 服务和开发服务器可以同时启动。该命令与save-dev命令一起安装,因为它是开发所必需的,不是应用的一部分。 |
| axios | 应用将使用这个包向 web 服务发出 HTTP 请求。 |

准备 Web 服务

为了给json-server包提供要处理的数据,在productapp文件夹中添加一个名为restData.js的文件,并添加清单 23-2 中所示的代码。

module.exports = function () {
    var data = {
        products: [
            { id: 1, name: "Kayak", category: "Watersports", price: 275 },
            { id: 2, name: "Lifejacket", category: "Watersports", price: 48.95 },
            { id: 3, name: "Soccer Ball", category: "Soccer", price: 19.50 },
            { id: 4, name: "Corner Flags", category: "Soccer", price: 34.95 },
            { id: 5, name: "Stadium", category: "Soccer", price: 79500 },
            { id: 6, name: "Thinking Cap", category: "Chess", price: 16 },
            { id: 7, name: "Unsteady Chair", category: "Chess", price: 29.95 },
            { id: 8, name: "Human Chess Board", category: "Chess", price: 75 },
            { id: 9, name: "Bling Bling King", category: "Chess", price: 1200 }
        ],
        suppliers: [
            { id: 1, name: "Surf Dudes", city: "San Jose", products: [1, 2] },
            { id: 2, name: "Goal Oriented", city: "Seattle", products: [3, 4, 5] },
            { id: 3, name: "Bored Games", city: "New York", products: [6, 7, 8, 9] },
        ]
    }
    return data
}

Listing 23-2The Contents of the restData.js File in the productapp Folder

json-server包可以处理 JSON 或 JavaScript 文件。如果使用 JSON 文件,它的内容将被修改,以反映客户机发出的更改请求。相反,我选择了 JavaScript 选项,它允许以编程方式生成数据,并意味着重新启动该过程将返回到原始数据。这不是您在实际项目中会做的事情,但是对于这个例子来说是有用的,因为它使得返回到一个已知的状态变得容易,同时仍然允许应用访问持久数据。

为了配置json-server包,使其响应以/api开头的 URL 请求,在productapp文件夹中创建一个名为api.routes.json的文件,其内容如清单 23-3 所示。

{ "/api/*": "/$1" }

Listing 23-3The Contents of the api.routes.json File in the productapp Folder

要配置开发工具,使 web 服务与开发 web 服务器同时启动,对productapp文件夹中的package.json文件进行清单 23-4 所示的更改。

...
"scripts": {
    "start": "npm-run-all --parallel reactstart json",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "reactstart": "react-scripts start",
    "json": "json-server --p 3500 -r api.routes.json restData.js"
},
...

Listing 23-4Configuring Tools in the package.json File in the productapp Folder

package.json文件的scripts部分的更改使用了npm-run-all包,这样 HTTP 开发服务器和json-servernpm start启动。

添加组件和路线

我将演示如何孤立地使用 web 服务,然后向您展示如何使用数据存储中的数据。应用中的现有组件已经连接到数据存储,因此为了展示如何使用未连接的组件,我在src文件夹中创建了一个名为IsolatedTable.js的文件,并用它来创建清单 23-5 中所示的组件。

import React, { Component } from "react";

export class IsolatedTable extends Component {

    render() {
        return <table className="table table-sm table-striped table-bordered">
            <thead>
                <tr><th colSpan="5"
                        className="bg-info text-white text-center h4 p-2">
                    (Isolated) Products
                </th></tr>
                <tr>
                    <th>ID</th><th>Name</th><th>Category</th>
                    <th className="text-right">Price</th>
                    <th></th>
                </tr>
            </thead>
            <tbody>
                <tr><td colSpan="5" className="text-center p-2">No Data</td></tr>
            </tbody>
        </table>
    }
}

Listing 23-5The Contents of the IsolatedTable.js File in the src Folder

该组件呈现一个空表作为该时刻的占位符。为了将组件合并到应用中,我更新了Selector组件中的路由配置,添加了一个新的Route和一个相应的导航链接,如清单 23-6 所示。

import React, { Component } from "react";
import { BrowserRouter as Router, Route, Switch, Redirect }
    from "react-router-dom";
import { ToggleLink } from "./routing/ToggleLink";
import { RoutedDisplay } from "./routing/RoutedDisplay";

import { IsolatedTable } from "./IsolatedTable";

export class Selector extends Component {

    render() {

        const routes = React.Children.map(this.props.children, child => ({
            component: child,
            name: child.props.name,
            url: `/${child.props.name.toLowerCase()}`,
            datatype: child.props.datatype
        }));

        return <Router getUserConfirmation={ this.customGetUserConfirmation }>
            <div className="container-fluid">
                <div className="row">
                    <div className="col-2">
                        <ToggleLink to="/isolated">Isolated Data</ToggleLink>
                        { routes.map(r => <ToggleLink key={ r.url } to={ r.url }>
                                            { r.name }
                                        </ToggleLink>)}
                    </div>
                    <div className="col">
                        <Switch>
                            <Route path="/isolated" component={ IsolatedTable } />
                            { routes.map(r =>
                               <Route key={ r.url }
                                   path={ `/:datatype(${r.datatype})/:mode?/:id?`}
                                   component={ RoutedDisplay(r.datatype)} />
                            )}
                            <Redirect to={ routes[0].url } />
                        </Switch>
                    </div>
                </div>
            </div>
        </Router>
    }
}

Listing 23-6Adding a Route in the Selector.js File in the src Folder

运行 Web 服务和示例应用

使用命令提示符,运行productapp文件夹中清单 23-7 所示的命令来启动开发工具和 web 服务。

npm start

Listing 23-7Starting the Development Tools

一旦项目的初始准备完成,一个新的浏览器窗口将打开并显示 URL http://localhost:3000,如图 23-1 所示。

img/473159_1_En_23_Fig1_HTML.jpg

图 23-1

运行示例应用

打开新的浏览器窗口并导航至http://localhost:3500/api/products/2。服务器将以下列数据响应,这些数据也显示在图 23-2 中:

img/473159_1_En_23_Fig2_HTML.jpg

图 23-2

测试 web 服务

...
{ "id": 2, "name": "Lifejacket", "category": "Watersports", "price": 48.95 }
...

我为本章选择的配置意味着有两个 HTTP 服务器在运行。React development server 在端口 3000 上侦听请求,并提供启动应用的 HTML 文档,以及将应用呈现给用户所需的 JavaScript 和 CSS 文件。RESTful web 服务在端口 3500 上监听请求,并以数据响应。这些数据以 JSON 格式表示,这意味着 JavaScript 应用很容易处理这些数据,但是不应该直接呈现给大多数用户。

理解 RESTful Web 服务

交付和存储应用数据的最常见方法是应用称为 REST 的表示性状态转移模式来创建数据 web 服务。REST 没有详细的规范,这导致很多不同的方法都打着 RESTful 的旗号。然而,在 web 应用开发中有一些有用的统一思想。

RESTful web 服务的核心前提是包含 HTTP 的特性,以便请求方法——也称为动词——指定服务器要执行的操作,请求 URL 指定操作将应用到的一个或多个数据对象。

例如,在示例应用中,下面是一个可能指向特定产品的 URL:

http://localhost:3500/api/products/2

URL 的第一段—api—通常表示请求的是数据。下一个段—products—用于指示将要操作的对象集合,并允许单个服务器提供多个服务,每个服务都有自己的数据。最后一个片段——2——在products集合中选择一个单独的对象。在这个例子中,id属性的值唯一地标识了一个对象,并将在 URL 中使用,在这个例子中,指定了Lifejacket对象。

用于发出请求的 HTTP 动词或方法告诉 RESTful 服务器应该对指定的对象执行什么操作。在上一节中测试 RESTful 服务器时,浏览器发送了一个 HTTP GET 请求,服务器将其解释为检索指定对象并将其发送给客户机的指令。

表 23-4 显示了 HTTP 方法和 URL 的最常见组合,并解释了当发送到 RESTful 服务器时它们各自的作用。

表 23-4

RESTful Web 服务中常见的 HTTP 动词及其作用

|

动词

|

统一资源定位器

|

描述

|
| --- | --- | --- |
| GET | /api/products | 这种组合检索products集合中的所有对象。 |
| GET | /api/products/2 | 这个组合从products集合中检索出id2的对象。 |
| POST | /api/products | 该组合用于向products集合添加一个新对象。请求体包含新对象的 JSON 表示。 |
| PUT | /api/products/2 | 该组合用于替换products集合中id为 2 的对象。请求体包含替换对象的 JSON 表示。 |
| PATCH | /api/products/2 | 该组合用于更新products集合中对象属性的子集,该集合的id为 2。请求体包含要更新的属性和新值的 JSON 表示。 |
| DELETE | /api/products/2 | 该组合用于从products集合中删除id为 2 的产品。 |

web 服务的实现方式有相当大的差异,这是由创建它们所使用的框架和开发团队的偏好的差异造成的。确认 web 服务如何使用动词以及在 URL 和请求正文中需要什么来执行操作是很重要的。

常见的变化包括不接受任何包含id值的请求主体的 web 服务(以确保它们是由服务器的数据存储唯一生成的)和不支持所有动词的 web 服务(通常忽略PATCH请求,只接受使用PUT动词的更新)。

小费

您可能已经注意到,编辑器组件不允许用户为id属性提供值。这是因为我在本章中创建的 web 服务会自动生成id值以确保唯一性。

选择 HTTP 请求库

在本章中,我使用 Axios 库向 web 服务发送 HTTP 请求,因为它易于使用,可以自动处理常见的数据类型,并且不需要复杂的代码来处理像 CORS 这样的特性(参见侧栏“生成跨源请求”)。Axios 广泛应用于 web 应用开发,尽管它不是专门针对 React 的。

Axios 并不是向 web 服务发送 HTTP 请求的唯一方式。最基本的选择是使用XMLlHttpRequest对象,该对象提供了使用 JavaScript 进行请求的原始 API(尽管名称中有 XML,但它能够处理一系列数据类型)。XMLHttpRequest对象使用起来有些笨拙,但是有广泛的浏览器支持,你可以在 https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest 获得进一步的细节。(Axios 使用XMLHttpRequest来发出 HTTP 请求,但简化了它们的创建和处理方式。)

Fetch API 是现代浏览器提供的最新 API,旨在取代XMLHttpRequest,在 https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API 有描述。主流浏览器的最新版本支持 Fetch API,但旧版本的浏览器不支持,这对某些应用来说可能是个问题。

如果你正在使用 GraphQL,那么你应该考虑使用 Apollo 客户端,如第二十五章所述。

消费 Web 服务

在接下来的小节中,我将介绍消费 web 服务所需的步骤,首先是请求应用将向用户显示的初始数据,然后添加对存储和更新对象的支持。

创建数据源组件

将使用 Axios 消费 web 服务的代码与使用它的组件分开是一个好主意,这样可以更容易地在应用的其他地方测试和使用它。我创建了src/webservice文件夹,并添加了一个名为RestDataSource.js的文件,代码如清单 23-8 所示。

import Axios from "axios";

export class RestDataSource {

    constructor(base_url) {
        this.BASE_URL = base_url;
    }

    GetData(callback) {
        this.SendRequest("get", this.BASE_URL, callback);
    }

    SendRequest(method, url, callback) {
        Axios.request({
            method: method,
            url: url
        }).then(response => callback(response.data));
    }
}

Listing 23-8The Contents of the RestDataSource.js File in the src/webservice Folder

Listing 23-8The Contents of the RestDataSource.js File in the src/webservice Folder

RestDataSource类定义了一个接收 web 服务的基本 URL 的构造函数,并定义了一个调用SendRequestGetData方法。

我从axios包中导入了 HTTP 功能,并将其命名为AxiosSendRequest方法使用 Axios 通过request方法发送 HTTP 请求,其中请求的细节使用具有methodurl属性的配置对象指定。

Axios 提供了发送不同类型 HTTP 请求的方法——例如,getpostput方法,但是使用清单中的方法可以更容易地应用影响所有请求类型的特性,当我在本章后面添加错误处理时,您会看到这一点。

使用 JavaScript 发出的 HTTP 请求是异步的。request方法返回一个代表请求最终结果的Promise对象(关于如何使用Promise对象的详细信息,见第四章)。在清单 23-8 中,我使用then方法为 Axios 提供一个回调函数,以便在请求完成时使用。回调函数被传递一个对象,该对象使用表 23-5 中描述的属性描述响应。

表 23-5

Axios 响应属性

|

名字

|

描述

|
| --- | --- |
| status | 该属性返回响应的状态代码,如 200 或 404。 |
| statusText | 此属性返回伴随状态代码的说明性文本,如 OK 或 Not Found。 |
| headers | 此属性返回一个对象,该对象的属性表示响应标头。 |
| data | 该属性从响应中返回有效负载。 |
| config | 此属性返回一个对象,该对象包含用于发出请求的配置选项。 |
| request | 该属性返回用于发出请求的底层XMLHttpRequest对象,如果您需要直接访问浏览器提供的 API,这会很有用。 |

Axios 自动将 JSON 数据格式转换成 JavaScript 对象,并通过 response data属性呈现出来。正如第四章所解释的,使用承诺的代码可以通过使用asyncawait关键字来简化,如清单 23-9 所示。

import Axios from "axios";

export class RestDataSource {

    constructor(base_url) {
        this.BASE_URL = base_url;
    }

    GetData(callback) {
        this.SendRequest("get", this.BASE_URL, callback);
    }

    async SendRequest(method, url, callback) {
        let response = await Axios.request({
            method: method,
            url: url
        });
        callback(response.data);
    }
}

Listing 23-9Using async and await in the RestDataSource.js File in the src/webservice Folder

我可以通过组合GetData方法中的语句来进一步简化代码,如清单 23-10 所示。

import Axios from "axios";

export class RestDataSource {

    constructor(base_url) {
        this.BASE_URL = base_url;
    }

    GetData(callback) {
        this.SendRequest("get", this.BASE_URL, callback);
    }

    async SendRequest(method, url, callback) {
        callback((await Axios.request({
            method: method,
            url: url
        })).data);
    }
}

Listing 23-10Combining Statements in the RestDataSource.js File in the src/webservice Folder

这种方法更简洁,但重要的是要确保将括号放在正确的位置,这样,await关键字被应用到由SendRequest方法返回的对象,而data属性从它产生的对象中读取。如果不遵循这种模式,您很容易就会出现这样的情况:发送了 HTTP 请求,但是响应被忽略。

获取组件中的数据

下一步是将数据放入组件,以便可以向用户显示。在清单 23-11 中,我已经更新了IsolatedTable组件,以便它创建一个数据源并使用它从 web 服务请求数据。

注意

组件名称中的术语 isolated 表示该组件不与任何其他组件共享数据,而是直接处理 web 服务。在“使用带有数据存储的 Web 服务”一节中,我向您展示了一种替代方法,其中组件通过数据存储共享数据。

import React, { Component } from "react";

import { RestDataSource } from "./webservice/RestDataSource";

export class IsolatedTable extends Component {

    constructor(props) {
        super(props);
        this.state = {
            products: []
        }
        this.dataSource = new RestDataSource("http://localhost:3500/api/products")
    }

    render() {
        return <table className="table table-sm table-striped table-bordered">
            <thead>
                <tr><th colSpan="5"
                        className="bg-info text-white text-center h4 p-2">
                    (Isolated) Products
                </th></tr>
                <tr>
                    <th>ID</th><th>Name</th><th>Category</th>
                    <th className="text-right">Price</th>
                    <th></th>
                </tr>
            </thead>
            <tbody>
                {
                    this.state.products.map(p => <tr key={ p.id }>
                        <td>{ p.id }</td><td>{ p.name }</td><td>{p.category}</td>
                        <td className="text-right">
                            ${ Number(p.price).toFixed(2)}
                        </td><td/>
                    </tr>)
                }
            </tbody>
        </table>
    }

    componentDidMount() {
        this.dataSource.GetData(data => this.setState({products: data}));
    }
}

Listing 23-11Getting Data in the IsolatedTable.js File in the src Folder

数据是在componentDidMount方法中请求的,这确保了在组件呈现其内容之前不会发送 HTTP 请求。提供给GetData方法的回调函数更新组件的状态数据,这将触发更新并确保数据呈现给用户。

避免无关的数据请求

不要在render方法中请求数据。正如我在第十三章中解释的,组件的render方法可能会被经常调用,而在render方法中启动任务可能会生成大量不必要的 HTTP 请求,并增加 React 在处理响应数据时必须执行的更新次数。

即使在使用componentDidMount方法时,当从可能被卸载和重新安装的组件发出请求时也应该小心,例如示例中的IsolatedTable组件,它将由路由系统为/isolated URL 安装,当用户导航到另一个位置时卸载。每次安装组件时,它都会从 web 服务请求新的数据,这可能不是应用所需要的。为了避免不必要的数据请求,可以将数据提升到一个不会被卸载的组件,存储在一个上下文中(如第十四章中所述),或者合并到一个数据存储中,如“使用数据存储消费 Web 服务”一节中所述。

结果是当点击隔离数据按钮时,从 web 服务中获取数据并显示给用户,如图 23-3 所示。

img/473159_1_En_23_Fig3_HTML.jpg

图 23-3

从 web 服务获取数据

保存、更新和删除数据

为了实现保存、更新和删除数据所需的操作,我将清单 23-12 中所示的方法添加到数据源类中,使用 Axios 通过不同的 HTTP 方法向 web 服务发送请求。

import Axios from "axios";

export class RestDataSource {

    constructor(base_url) {
        this.BASE_URL = base_url;
    }

    GetData(callback) {
        this.SendRequest("get", this.BASE_URL, callback);
    }

    async GetOne(id, callback) {
        this.SendRequest("get", `${this.BASE_URL}/${id}`, callback);
    }

    async Store(data, callback) {
        this.SendRequest("post", this.BASE_URL, callback, data)
    }

    async Update(data, callback) {
        this.SendRequest("put", `${this.BASE_URL}/${data.id}`, callback, data);
    }

    async Delete(data, callback) {
        this.SendRequest("delete", `${this.BASE_URL}/${data.id}`, callback, data);
    }

    async SendRequest(method, url, callback, data) {
        callback((await Axios.request({
            method: method,
            url: url,
            data: data
        })).data);
    }
}

Listing 23-12Adding Methods in the RestDataSource.js File in the src/webservice Folder

传递给Axios.request方法的请求配置对象使用一个data属性来指定请求的有效负载,这允许应用提供 JavaScript 对象并让 Axios 自动序列化它们。

当您实现数据源方法时,您会发现需要进行一些调整,以适应 web 服务实现的各种方式。例如,示例 web 服务将自动为 POST 请求中接收的对象分配一个唯一的id属性值,并在响应中包含完整的对象。清单 23-12 中的Store方法使用data属性从 HTTP 响应中获取完整的对象,并使用它来调用回调,这确保应用接收 web 服务存储的对象。并非所有的 web 服务都以这种方式运行——有些可能要求应用包含唯一的标识符,或者只在响应中返回标识符,而不是发送完整的对象。

修改对象时,会发送一个 PUT 请求,其中包含标识要修改的对象的 URL,如下所示:

...
this.SendRequest("put", `${this.BASE_URL}/${data.id}`, callback, data);
...

web 服务返回完整的更新对象,该对象用于调用回调函数。同样,并不是所有的 web 服务都返回完整的对象,但这是一种常见的方法,因为它确保 web 服务应用的任何附加转换都反映在客户机中。

添加对创建、编辑和删除数据的应用支持

为了提供创建和编辑数据的支持,我在src文件夹中添加了一个名为IsolatedEditor.js的文件,并用它来定义清单 23-13 中所示的组件。

import React, { Component } from "react";
import { RestDataSource } from "./webservice/RestDataSource";
import { ProductEditor } from "./ProductEditor";

export class IsolatedEditor extends Component {

    constructor(props) {
        super(props);
        this.state = {
            dataItem: {}
        };
        this.dataSource = this.props.dataSource
            || new RestDataSource("http://localhost:3500/api/products");
    }

    save = (data) => {
        const callback = () => this.props.history.push("/isolated");
        if (data.id === "") {
            this.dataSource.Store(data, callback);
        } else {
            this.dataSource.Update(data, callback);
        }
    }

    cancel = () => this.props.history.push("/isolated");

    render() {
        return <ProductEditor key={ this.state.dataItem.id }
            product={ this.state.dataItem } saveCallback={ this.save }
            cancelCallback={ this.cancel } />
    }

    componentDidMount() {
        if (this.props.match.params.mode === "edit") {
            this.dataSource.GetOne(this.props.match.params.id,
                data => this.setState({ dataItem: data}));
        }
    }
}

Listing 23-13The Contents of the IsolatedEditor.js File in the src Folder

React 使得以新的方式使用现有组件变得容易,IsolatedEditor组件使用现有的ProductEditor及其 props 为其提供来自 web 服务数据源的数据和回调。当用户选择了一个对象进行编辑时,当前路由的细节被用来使用GetOne方法请求单个对象的细节,并且使用StoreUpdate方法将更改发送回 web 服务。在清单 23-14 中,我添加了对IsolatedTable组件的支持,通过导航到新的 URL 来创建和编辑对象。我还添加了一个删除按钮,其事件处理程序调用数据源的Delete方法,该方法向 web 服务发送删除请求。

import React, { Component } from "react";
import { RestDataSource } from "./webservice/RestDataSource";

import { Link } from "react-router-dom";

export class IsolatedTable extends Component {

    constructor(props) {
        super(props);
        this.state = {
            products: []
        }
        this.dataSource = new RestDataSource("http://localhost:3500/api/products")
    }

    deleteProduct(product) {
        this.dataSource.Delete(product,
            () => this.setState({products: this.state.products.filter(p =>
                p.id !== product.id)}));
    }

    render() {
        return <table className="table table-sm table-striped table-bordered">
            <thead>
                <tr><th colSpan="5"
                        className="bg-info text-white text-center h4 p-2">
                    (Isolated) Products
                </th></tr>
                <tr>
                    <th>ID</th><th>Name</th><th>Category</th>
                    <th className="text-right">Price</th>
                    <th></th>
                </tr>
            </thead>
            <tbody>
                {
                    this.state.products.map(p => <tr key={ p.id }>
                        <td>{ p.id }</td><td>{ p.name }</td><td>{p.category}</td>
                        <td className="text-right">
                            ${ Number(p.price).toFixed(2)}
                        </td>
                        <td>
                            <Link className="btn btn-sm btn-warning mx-2"
                                    to={`/isolated/edit/${p.id}`}>
                                Edit
                            </Link>
                            <button className="btn btn-sm btn-danger mx-2"
                                onClick={ () => this.deleteProduct(p)}>
                                    Delete
                            </button>
                        </td>
                    </tr>)
                }
            </tbody>
            <tfoot>
                <tr className="text-center">
                    <td colSpan="5">
                        <Link to="/isolated/create"
                            className="btn btn-info">Create</Link>
                    </td>
                </tr>
            </tfoot>
        </table>
    }

    componentDidMount() {
        this.dataSource.GetData(data => this.setState({products: data}));
    }
}

Listing 23-14Adding Data Operations in the IsolatedTable.js File in the src Folder

最后一步是更新Selector组件中的路由配置,以便/isolated/edit/isolated/createURL 选择IsolatedEditor组件。我还为/isolated URL 设置了完全匹配的路由,以确保IsolatedTable组件的Route不匹配其他 URL,如清单 23-15 所示。

import React, { Component } from "react";
import { BrowserRouter as Router, Route, Switch, Redirect }
    from "react-router-dom";
import { ToggleLink } from "./routing/ToggleLink";
import { RoutedDisplay } from "./routing/RoutedDisplay";
import { IsolatedTable } from "./IsolatedTable";

import { IsolatedEditor } from "./IsolatedEditor";

export class Selector extends Component {

    render() {

        const routes = React.Children.map(this.props.children, child => ({
            component: child,
            name: child.props.name,
            url: `/${child.props.name.toLowerCase()}`,
            datatype: child.props.datatype
        }));

        return <Router getUserConfirmation={ this.customGetUserConfirmation }>
            <div className="container-fluid">
                <div className="row">
                    <div className="col-2">
                        <ToggleLink to="/isolated">Isolated Data</ToggleLink>
                        { routes.map(r => <ToggleLink key={ r.url } to={ r.url }>
                                            { r.name }
                                        </ToggleLink>)}
                    </div>
                    <div className="col">
                        <Switch>
                            <Route path="/isolated" component={ IsolatedTable }
                                exact={ true } />
                            <Route path="/isolated/:mode/:id?"
                                component={ IsolatedEditor } />
                            { routes.map(r =>
                               <Route key={ r.url }
                                   path={ `/:datatype(${r.datatype})/:mode?/:id?`}
                                   component={ RoutedDisplay(r.datatype)} />
                            )}
                            <Redirect to={ routes[0].url } />
                        </Switch>
                    </div>
                </div>
            </div>
        </Router>
    }
}

Listing 23-15Adding a Route in the Selector.js File in the src Folder

IsolatedTable组件显示创建、编辑、删除按钮,如图 23-4 所示。Create 和 Edit 按钮向用户显示编辑器组件,然后该组件通过发送 POST 或 PUT 请求,用用户所做的更改来更新 web 服务。删除按钮通过向 web 服务发送删除请求来删除与其相关联的对象。

img/473159_1_En_23_Fig4_HTML.jpg

图 23-4

消费 web 服务

注意

应用所做的更改存储在 web 服务中,这意味着您可以重新加载浏览器,并且更改仍然可见。本章开头的json-server包的配置意味着重新启动开发工具将重置 web 服务提供的数据。参见第八章中的 SportsStore 应用,了解使用json-server获取真正持久数据的示例,这些数据在工具重启时不会重置。

处理错误

应用假设所有的 HTTP 请求都会成功,这是一种不切实际的乐观方法。HTTP 请求失败的原因有很多,比如连接问题或服务器故障。我在第十四章中描述的错误边界不能处理异步操作中出现的问题,比如 HTTP 请求,所以需要一种不同的方法。在清单 23-16 中,我已经更改了数据源,这样它就可以接收一个在出现问题时调用的函数,并在请求失败时使用try / catch关键字来调用该函数。

import Axios from "axios";

export class RestDataSource {

    constructor(base_url, errorCallback) {
        this.BASE_URL = base_url;
        this.handleError = errorCallback;
    }

    GetData(callback) {
        this.SendRequest("get", this.BASE_URL, callback);
    }

    async GetOne(id, callback) {
        this.SendRequest("get", `${this.BASE_URL}/${id}`, callback);
    }

    async Store(data, callback) {
        this.SendRequest("post", this.BASE_URL, callback, data)
    }

    async Update(data, callback) {
        this.SendRequest("put", `${this.BASE_URL}/${data.id}`, callback, data);
    }

    async Delete(data, callback) {
        this.SendRequest("delete", `${this.BASE_URL}/${data.id}`, callback, data);
    }

    async SendRequest(method, url, callback, data) {
        try {
            callback((await Axios.request({
                method: method,
                url: url,
                data: data
            })).data);
        } catch(err) {
            this.handleError("Operation Failed: Network Error");
        }
    }
}

Listing 23-16Handling Errors in the RestDataSource.js File in the src/webservice Folder

通过SendRequest方法合并所有请求的好处是,我可以使用一个单独的try / catch块来处理所有请求类型的错误。catch块处理由请求引起的错误,并调用作为构造函数参数接收的回调函数。

向用户显示错误消息

当出现问题时,Axios 包会显示详细的错误,包括响应的状态代码和 web 服务提供的任何描述性文本。然而,对于大多数应用来说,将这些信息呈现给用户是没有意义的,因为用户不知道发生了什么,也不知道如何修复。相反,我建议向用户显示一条一般的错误消息,并在服务器上记录问题的详细信息,以便可以识别常见问题。

为了接收错误并将它们显示给用户,我在src/webservice文件夹中添加了一个名为RequestError.js的文件,并用它来定义清单 23-17 中所示的组件。

import React, { Component } from "react";
import { Link } from "react-router-dom";

export class RequestError extends Component {

    render() {
        return <div>
            <h5 className="bg-danger text-center text-white m-2 p-3">
                { this.props.match.params.message }
            </h5>
            <div className="text-center">
                <Link to="/" className="btn btn-secondary">OK</Link>
            </div>
        </div>
    }
}

Listing 23-17The Contents of the RequestError.js File in the src/webservice Folder

该组件显示从 URL 参数获得的消息。清单 23-18 向Selector组件添加了一个新的Route,它将为/error URL 显示这个组件。

import React, { Component } from "react";
import { BrowserRouter as Router, Route, Switch, Redirect }
    from "react-router-dom";
import { ToggleLink } from "./routing/ToggleLink";
import { RoutedDisplay } from "./routing/RoutedDisplay";
import { IsolatedTable } from "./IsolatedTable";
import { IsolatedEditor } from "./IsolatedEditor";

import { RequestError } from "./webservice/RequestError";

export class Selector extends Component {

    render() {

        const routes = React.Children.map(this.props.children, child => ({
            component: child,
            name: child.props.name,
            url: `/${child.props.name.toLowerCase()}`,
            datatype: child.props.datatype
        }));

        return <Router getUserConfirmation={ this.customGetUserConfirmation }>
            <div className="container-fluid">
                <div className="row">
                    <div className="col-2">
                        <ToggleLink to="/isolated">Isolated Data</ToggleLink>
                        { routes.map(r => <ToggleLink key={ r.url } to={ r.url }>
                                            { r.name }
                                        </ToggleLink>)}
                    </div>
                    <div className="col">
                        <Switch>
                            <Route path="/isolated" component={ IsolatedTable }
                                exact={ true } />
                            <Route path="/isolated/:mode/:id?"
                                component={ IsolatedEditor } />
                            <Route path="/error/:message"
                                component={ RequestError } />
                            { routes.map(r =>
                               <Route key={ r.url }
                                   path={ `/:datatype(${r.datatype})/:mode?/:id?`}
                                   component={ RoutedDisplay(r.datatype)} />
                            )}
                            <Redirect to={ routes[0].url } />
                        </Switch>
                    </div>
                </div>
            </div>
        </Router>
    }
}

Listing 23-18Adding a Route in the Selector.js File in the src Folder

清单 23-19 为数据源提供了一个回调函数,当问题出现时,它会导航到/error URL,并添加一个按钮,通过请求一个总是会产生 404-Not Found 错误的 URL 来创建一个错误。

import React, { Component } from "react";
import { RestDataSource } from "./webservice/RestDataSource";
import { Link } from "react-router-dom";

export class IsolatedTable extends Component {

    constructor(props) {
        super(props);
        this.state = {
            products: []
        }
        this.dataSource = new RestDataSource("http://localhost:3500/api/products",
            (err) => this.props.history.push(`/error/${err}`));
    }

    deleteProduct(product) {
        this.dataSource.Delete(product,
           () => this.setState({products: this.state.products.filter(p =>
                p.id !== product.id)}));
    }

    render() {
        return <table className="table table-sm table-striped table-bordered">
            <thead>
                <tr><th colSpan="5"
                        className="bg-info text-white text-center h4 p-2">
                    (Isolated) Products
                </th></tr>
                <tr>
                    <th>ID</th><th>Name</th><th>Category</th>
                    <th className="text-right">Price</th>
                    <th></th>
                </tr>
            </thead>
            <tbody>
                {
                    this.state.products.map(p => <tr key={ p.id }>
                        <td>{ p.id }</td><td>{ p.name }</td><td>{p.category}</td>
                        <td className="text-right">
                            ${ Number(p.price).toFixed(2)}
                        </td>
                        <td>
                            <Link className="btn btn-sm btn-warning mx-2"
                                    to={`/isolated/edit/${p.id}`}>
                                Edit
                            </Link>
                            <button className="btn btn-sm btn-danger mx-2"
                                onClick={ () => this.deleteProduct(p)}>
                                    Delete
                            </button>
                        </td>
                    </tr>)
                }
            </tbody>
            <tfoot>
                <tr className="text-center">
                    <td colSpan="5">
                        <Link to="/isolated/create"
                            className="btn btn-info">Create</Link>
                        <button className="btn btn-danger mx-2"
                            onClick={ () => this.dataSource.GetOne("err")}>
                            Error
                        </button>
                    </td>
                </tr>
            </tfoot>
        </table>
    }

    componentDidMount() {
        this.dataSource.GetData(data => this.setState({products: data}));
    }
}

Listing 23-19Handling Errors in the IsolatedTable.js File in the src Folder

点击由IsolatedTable呈现的错误按钮将发送一个请求,该请求从 web 服务接收一个错误响应,这触发导航到显示错误消息的 URL,如图 23-5 所示。

img/473159_1_En_23_Fig5_HTML.jpg

图 23-5

显示错误消息

提出跨来源请求

默认情况下,浏览器会强制执行一个安全策略,只允许 JavaScript 代码在包含异步 HTTP 请求的文档的同一来源内发出这些请求。该政策旨在降低跨站点脚本(CSS)攻击的风险,这种攻击会诱使浏览器执行恶意代码,详见 http://en.wikipedia.org/wiki/Cross-site_scripting 。对于 web 应用开发人员来说,同源策略在使用 web 服务时可能是一个问题,因为它们通常位于包含应用 JavaScript 代码的源之外。如果两个 URL 具有相同的协议、主机和端口,则它们被认为是来源相同,否则它们具有不同的来源。我在本章中为 RESTful web 服务使用的 URL 与主应用使用的 URL 有不同的来源,因为它们使用不同的 TCP 端口。

跨源资源共享(CORS)协议用于向不同的源发送请求。使用 CORS,浏览器在异步 HTTP 请求中包含标头,向服务器提供 JavaScript 代码的来源。来自服务器的响应包括告诉浏览器它是否愿意接受请求的头。CORS 的详细情况不在本书讨论范围之内,但在 https://en.wikipedia.org/wiki/Cross-origin_resource_sharing 有题目介绍,在 www.w3.org/TR/cors 有 CORS 规格。

CORS 是在这一章中自动发生的事情。提供 RESTful web 服务的json-server包支持 CORS,并将接受来自任何来源的请求,而我用来发出 HTTP 请求的 Axios 包自动应用 CORS。当您为自己的项目选择软件时,您必须选择一个允许通过单一来源处理所有请求的平台,或者配置 CORS 以便服务器接受应用的数据请求。

使用带有数据存储的 Web 服务

我在上一节中定义的组件是相互隔离的,只能通过 URL 路由系统进行协调。这种方法的优点是简单,但是当用户在应用中导航时,它会导致重复地从 web 服务请求相同的数据,并且每个组件在安装时都会发送它的 HTTP 请求。如果应用使用数据存储,那么数据可以在组件之间共享。

创建新的中间件

商店已经有了接收对象和更新数据的动作,所以我要采用的方法是创建新的 Redux 中间件,它将拦截现有的动作,并向 web 服务发送相应的 HTTP 请求。我在src/webservice文件夹中添加了一个名为RestMiddleware.js的文件,内容如清单 23-20 所示。

import { STORE, UPDATE, DELETE} from "../store/modelActionTypes";
import { RestDataSource } from "./RestDataSource";
import { PRODUCTS, SUPPLIERS } from "../store/dataTypes";

export const GET_DATA = "rest_get_data";

export const getData = (dataType) => {
    return {
        type: GET_DATA,
        dataType: dataType
    }
}

export const createRestMiddleware = (productsURL, suppliersURL) => {

    const dataSources = {
        [PRODUCTS]: new RestDataSource(productsURL, () => {}),
        [SUPPLIERS]: new RestDataSource(suppliersURL, () => {})
    }

    return ({dispatch, getState}) => next => action => {
        switch (action.type) {
            case GET_DATA:
                if (getState().modelData[action.dataType].length === 0) {
                    dataSources[action.dataType].GetData((data) =>
                        data.forEach(item => next({ type: STORE,
                            dataType: action.dataType, payload: item})));
                }
                break;
            case STORE:
                action.payload.id = null;
                dataSources[action.dataType].Store(action.payload, data =>
                    next({ ...action, payload: data }))
                break;
            case UPDATE:
                dataSources[action.dataType].Update(action.payload, data =>
                     next({ ...action, payload: data }))
                break;
            case DELETE:
                dataSources[action.dataType].Delete({id: action.payload },
                    () => next(action));
                break;
            default:
                next(action);
        }
    }
}

Listing 23-20The Contents of the RestMiddleware.js File in the src/webservice Folder

需要一个新动作,即从 web 服务请求数据。以前不需要这样做,因为数据存储已经用数据自动初始化了。动作类型为GET_DATA,清单 23-20 定义了一个getData动作创建者。

createRestMiddleware函数接受产品和供应商数据的数据源,并返回处理新的GET_DATA动作和现有的STOREUPDATEDELETE动作的中间件,方法是向 web 服务发送一个请求,然后在收到结果时使用数据存储的现有特性分派附加动作。

将中间件添加到数据存储中

在清单 23-21 中,我已经将新的中间件添加到数据存储中。如第二十章所述,中间件组件是按照它们被添加到商店的顺序来应用的。

import { createStore, combineReducers, applyMiddleware, compose } from "redux";
import modelReducer from "./modelReducer";
import stateReducer from "./stateReducer";
import { customReducerEnhancer } from "./customReducerEnhancer";
import { multiActions } from "./multiActionMiddleware";
import { asyncEnhancer } from "./asyncEnhancer";

import { createRestMiddleware } from "../webservice/RestMiddleware";

const enhancedReducer = customReducerEnhancer(
    combineReducers(
        {
            modelData: modelReducer,
            stateData: stateReducer
        })
);

const restMiddleware = createRestMiddleware(

    "http://localhost:3500/api/products",
    "http://localhost:3500/api/suppliers");

export default createStore(enhancedReducer,
    compose(applyMiddleware(multiActions),
        applyMiddleware(restMiddleware),
        asyncEnhancer(2000)));

export { saveProduct, saveSupplier, deleteProduct, deleteSupplier }
    from "./modelActionCreators";

Listing 23-21Applying Middleware in the index.js File in the src/store Folder

在考虑应用中现有组件如何使用数据存储时,顺序非常重要。在第二十章中创建的multiActions中间件允许一系列动作被分派,这必须放在第一位;否则,新的中间件将不能正确地处理动作。

完成应用更改

为了按需自动请求数据,我在src文件夹中添加了一个名为DataGetter.js的文件,并用它来定义清单 23-22 中所示的高阶组件。

import React, { Component } from "react";
import { PRODUCTS, SUPPLIERS } from "./store/dataTypes";

export const DataGetter = (dataType, WrappedComponent) => {

    return class extends Component {
        render() {
            return <WrappedComponent { ...this.props } />
        }

        componentDidMount() {
            this.props.getData(PRODUCTS);
            if (dataType === SUPPLIERS) {
                this.props.getData(SUPPLIERS);
            }
        }
    }
}

Listing 23-22The Contents of the DataGetter.js File in the src Folder

组件在安装后请求数据,并知道供应商数据必须由产品数据补充,以便向用户正确显示数据,从而可以显示产品名称。在清单 23-23 中,我在TableConnector组件中添加了对新的 HOC 的支持,这确保了应用启动时请求应用所需的数据。

import { connect } from "react-redux";
//import { startEditingProduct, startEditingSupplier } from "./stateActions";
import { deleteProduct, deleteSupplier } from "./modelActionCreators";
import { PRODUCTS, SUPPLIERS } from "./dataTypes";
import { withRouter } from "react-router-dom";

import { getData } from "../webservice/RestMiddleware";

import { DataGetter } from "../DataGetter";

export const TableConnector = (dataType, presentationComponent) => {

    const mapStateToProps = (storeData, ownProps) => {
        if (dataType === PRODUCTS) {
            return { products: storeData.modelData[PRODUCTS] };
        } else {
            return {
                suppliers: storeData.modelData[SUPPLIERS].map(supp => ({
                    ...supp,
                    products: supp.products.map(id =>
                        storeData.modelData[PRODUCTS]
                            .find(p => p.id === Number(id)) || id)
                            .map(val => val.name || val)
                    }))
            }
        }
    }

    const mapDispatchToProps = (dispatch, ownProps) => {
        return {
            getData: (type) => dispatch(getData(type)),
            deleteCallback: dataType === PRODUCTS
                ? (...args) => dispatch(deleteProduct(...args))
                : (...args) => dispatch(deleteSupplier(...args))
        }
    }

    const mergeProps = (dataProps, functionProps, ownProps) => {
        let routedDispatchers = {
            editCallback: (target) => {
                ownProps.history.push(`/${dataType}/edit/${target.id}`);
            },
            deleteCallback: functionProps.deleteCallback,
            getData: functionProps.getData

        }
        return Object.assign({}, dataProps, routedDispatchers, ownProps);
    }

    return withRouter(connect(mapStateToProps,
        mapDispatchToProps, mergeProps)(DataGetter(dataType,
            presentationComponent)));
}

Listing 23-23Dispatching Actions in the TableConnector.js File in the src/store Folder

最后一个变化是删除用于播种数据存储的静态内容,如清单 23-24 所示。

import { PRODUCTS, SUPPLIERS } from "./dataTypes";

export const initialData = {
    modelData: {
        [PRODUCTS]: [],
        [SUPPLIERS]: []
    },
    stateData: {
        editing: false,
        selectedId: -1,
        selectedType: PRODUCTS
    }
}

Listing 23-24Removing the Static Data in the initialData.js File in the src/store Folder

结果是最初的产品和供应商数据是从 web 服务中获得的,任何变化都会触发 web 服务的更新,如图 23-6 所示。

img/473159_1_En_23_Fig6_HTML.jpg

图 23-6

将 web 服务与数据存储一起使用

摘要

在本章中,我介绍了一个 web 服务,并使用它来获取用户显示的数据、存储新数据、进行更改以及删除数据。在本章中,我使用了 Axios 库,但是还有许多其他可用的选项,在 React 应用中使用 web 服务是一个相对简单的过程。在下一章中,我将介绍 GraphQL,它是 REST 的一个更灵活的 web 服务替代方案。

二十四、了解 GraphQL

GraphQL 是一个用于创建和消费 API 的端到端系统,它提供了一种比使用传统 RESTful web 服务更灵活的替代方案,例如在第二十三章中创建的服务。在这一章中,我将解释 GraphQL 服务是如何定义的以及查询是如何执行的。在第二十五章中,我展示了 React 应用使用 GraphQL API 的不同方式。表 24-1 将 GraphQL 放在上下文中。

表 24-1

将 GraphQL 放在上下文中

|

问题

|

回答

|
| --- | --- |
| 这是什么? | GraphQL 是一种生成 API 的查询语言。 |
| 为什么有用? | GraphQL 为客户机提供了对数据的灵活访问,确保客户机只接收它需要的数据,并允许在不需要服务器端更改的情况下制定新的查询。 |
| 如何使用? | 在服务器端,使用解析器函数定义和实现模式。客户端使用 GraphQL 语言发送查询和请求更改。 |
| 有什么陷阱或限制吗? | GraphQL 很复杂,编写一个有用的模式可能需要技巧。 |
| 还有其他选择吗? | 客户端可以使用 RESTful web 服务,如第二十三章所述。 |

注意

我描述了对 React 开发最有用的 GraphQL 特性。有关 GraphQL 的完整描述,请参见 https://facebook.github.io/graphql/June2018 的 GraphQL 规范。

表 24-2 总结了本章内容。

表 24-2

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 定义 GraphQL 服务 | 描述将被支持的查询和变异,并实现提供它们的解析器 | 3, 4, 8–10, 20–21 |
| 查询 GraphQL 服务 | 指定查询名称和结果中需要的字段 | 7, 11, 27, 28 |
| 过滤结果 | 指定查询参数 | 12–19 |
| 使用 GraphQL 服务进行更改 | 为更新指定突变和字段 | 22–24 |
| 参数化查询 | 使用查询变量 | 25, 26 |
| 从多个查询中请求同一组字段 | 使用查询片段 | Twenty-nine |

为本章做准备

在本章中,我继续使用第二十三章中的示例应用。为了准备本章,打开一个命令提示符,导航到productapp文件夹,运行清单 24-1 中所示的命令,将包添加到项目中。

小费

你可以从 https://github.com/Apress/pro-react-16 下载本章以及本书其他章节的示例项目。

npm install --save-dev graphql@14.0.2
npm install --save-dev express@4.16.4
npm install --save-dev express-graphql@0.7.1
npm install --save-dev graphql-import@0.7.1
npm install --save-dev cors@2.8.5

Listing 24-1Adding Packages

为了快速参考,表 24-3 中描述了清单 24-1 中命令的包。

表 24-3

添加到项目中的包

|

名字

|

描述

|
| --- | --- |
| graphql | 这个包包含 GraphQL 的参考实现。 |
| express | 这个包提供了一个可扩展的 HTTP 服务器,并将成为本章中使用的 GraphQL 服务器的基础。 |
| express-graphql | 这个包通过express包在 HTTP 上提供 GraphQL 服务。 |
| graphql-import | 这个包允许在多个文件中定义 GraphQL 模式,并且导入模式比直接读取文件更容易。 |
| cors | 此软件包为 Express HTTP 服务器启用跨源资源共享(CORS)。 |

一旦软件包安装完毕,使用命令提示符运行清单 24-2 中的命令来启动开发工具。第二十三章中定义的 RESTful web 服务也被启动,并且仍然被应用使用。

npm start

Listing 24-2Starting the Development Tools

一旦项目的初始准备完成,一个新的浏览器窗口将打开并显示 URL http://localhost:3000,如图 24-1 所示。

img/473159_1_En_24_Fig1_HTML.jpg

图 24-1

运行示例应用

了解 GraphQL

RESTful web 服务很容易上手,但是随着客户端需求的发展和使用该服务的客户端应用数量的增加,它们可能会变得不灵活。

无法做出适合一个应用的更改,因为它们会在另一个应用中引起问题,工作会积压,因此无法在客户端应用发布日期前及时做出更改,并且基础架构开发团队要努力平衡对功能的竞争需求。当您依赖第三方 web 服务时,您可能没有任何途径来请求更改,因为您是所有要求新功能的几十或几百个开发团队中的一员。

结果是应用和它所依赖的 web 服务之间的不匹配。客户端经常需要向 web 服务发出多个请求来获取它们需要的数据;这然后被合并成一个有用的格式。客户端必须理解从不同 REST 请求返回的对象是如何相互关联的,并且经常必须请求数据,然后因为只需要数据字段的子集而丢弃这些数据。

REST web 服务的根本问题是它们提供的数据和提供数据的方式是固定的,随着客户端应用需求的变化,这就成了一个问题。GraphQL 通过允许客户端更多地控制请求什么数据以及如何表达数据来解决这个问题,结果是客户端应用可以添加以新的方式使用数据的功能,而只需要较少的服务器端更改。

了解 GraphQL 的缺点

GraphQL 并不适合所有情况。GraphQL 很复杂,不像 REST 那样被广泛理解,这使得很难找到有经验的开发人员和健壮且经过良好测试的工具和库。GraphQL 可以将原本由客户端应用执行的工作转移到服务器上,这会增加数据中心的成本,并且需要支持 GraphQL 的后端服务器的许可证。

将 GraphQL 作为一个选项来考虑是很重要的,特别是如果您的应用在部署后可能需要持续的开发,或者您打算开发或支持多个客户端应用。但是我的建议是不要急于使用 GraphQL,直到你确定 REST web 服务不会给你所需要的灵活性。

创建 GraphQL 服务器

我将创建一个自定义的 GraphQL 服务器,它提供与第二十三章中的 web 服务相同的数据。创建 GraphQL 服务的过程并不是所有项目都需要的,尤其是在使用第三方 API 的时候,但是了解服务器上发生的事情有助于深入了解 GraphQL 是如何工作的。在接下来的小节中,我将介绍描述客户机能够发出的请求类型的过程,并编写处理这些请求所需的代码。

选择替代的 GraphQL 服务器

我使用 GraphQL 参考实现为本章创建了一个简单的 GraphQL 服务器。它很容易演示 GraphQL 是如何工作的,但没有为处理真实数据做任何准备。

对于小而简单的项目,用 Lowdb ( https://github.com/typicode/lowdb )或 MongoDB ( https://www.mongodb.com )之类的包添加持久数据支持可能比较合适。

对于更复杂的项目,阿波罗服务器( https://github.com/apollographql/apollo-server )是最常见的选择。有开源的和付费的计划可用,有大量的数据集成选项可用,比如使用 GraphQL 作为现有 REST web 服务的前端。

创建模式

GraphQL 描述了可以使用模式执行的请求,该模式是用 GraphQL 模式语言编写的。我创建了src/graphql文件夹,并在其中添加了一个名为schema.graphql的文件,其内容如清单 24-3 所示。

type product {
    id: ID!,
    name: String!,
    category: String!
    price: Float!
}

type supplier {
    id: ID!,
    name: String!,
    city: String!,
    products: [ID]
}

type Query {
    products: [product],
    suppliers: [supplier]
}

Listing 24-3The Contents of the schema.graphql File in the src/graphql Folder

清单 24-3 中定义的模式定义了两个定制类型:productsupplier。这些类型将被用作 GraphQL 服务器支持的查询结果。每种结果类型都由一组字段定义,每个字段的类型如下:

...
category: String!
...

该字段的名称为category,类型为String。GraphQL 提供了一组内置类型,如表 24-4 所述。字段类型后的感叹号(!字符)表示该字段的值是必填的。字段也可以返回值数组,如下所示:

表 24-4

内置的 GraphQL 类型

|

名字

|

描述

|
| --- | --- |
| ID | 此类型表示唯一的标识符。 |
| String | 此类型表示一个字符串。 |
| Int | 此类型表示一个有符号整数 |
| Float | 此类型表示浮点值 |
| Boolean | 这种类型代表一个truefalse值。 |

...
products: [ID]
...

方括号表示supplier类型的products字段将是一个ID值的数组。

小费

目前不要太担心 GraphQL 类型的系统。当您看到服务器的不同部分如何组合在一起并被客户端使用时,这将变得更有意义。

除了内置类型,GraphQL 还支持Query类型,用于定义服务器将支持的查询。在清单 24-3 的模式中定义了两个查询。

...
type Query {
    products: [product],
    suppliers: [supplier]
}
...

第一条语句定义了一个名为products的查询,该查询将返回一组product对象。第二条语句定义了一个名为suppliers的查询,该查询将返回一组supplier对象。

创建解析器

下一步是编写实现清单 24-3 中定义的productssuppliers查询的函数。我在src/graphql文件夹中添加了一个名为resolvers.js的文件,代码如清单 24-4 所示。

var data = require("../../restData")();

module.exports = {

    products: () => data.products,
    suppliers: () => data.suppliers
}

Listing 24-4The Contents of the resolvers.js File in the src/graphql Folder

每个解析器都是一个函数,其名称对应于一个查询,并以模式声明的格式返回数据。productssuppliers解析器使用的数据使用从restData.js文件加载的数据。

注意

GraphQL 服务器将由 Node.js 运行,而 node . js 在本文撰写时并不支持 JavaScript 模块,这意味着不能使用importexport关键字。相反,require函数用于声明对文件的依赖,module.exports用于使代码或数据在 JavaScript 文件之外可用。

创建服务器

最后一步是创建处理模式和解析器的代码,并创建 GraphQL 服务器。我在productapp文件夹中添加了一个名为graphqlServer.js的文件,并添加了清单 24-5 中所示的代码。

var { buildSchema } = require("graphql");
var { importSchema } = require("graphql-import");
var express = require("express");
var graphqlHTTP = require("express-graphql")
var cors = require("cors")
var schema = importSchema("./src/graphql/schema.graphql");
var resolvers = require("./src/graphql/resolvers");

var app = express();

app.use(cors());
app.use("/graphql", graphqlHTTP({
  schema: buildSchema(schema),
  rootValue: resolvers,
  graphiql: true,
}));
app.listen(3600, () => console.log("GraphQL Server Running on Port 3600"));

Listing 24-5The Contents of the graphqlServer.js File in the productapp Folder

graphql包提供了buildSchema函数,它获取一个模式字符串并准备使用。模式文件的内容使用graphql-import包导入,并传递给buildSchema函数。express-graphql包将 GraphQL 支持集成到流行的express服务器中,我已经将它配置为在端口 3600 上监听。

要启动 GraphQL 服务器,打开一个新的命令提示符,导航到productapp文件夹,并运行清单 24-6 中所示的命令。(当模式或解析器发生变化时,GraphQL 服务器不会自动重新加载,对于本章中的一些示例,它必须重启,这就是为什么我没有将它集成到npm start命令中,就像我对 RESTful web 服务所做的那样。)

node graphqlServer.js

Listing 24-6Starting the GraphQL Server

GraphQL 服务器包括对 GraphQL(读作 graphical )的支持,这是一个基于浏览器的 graph QL 工具。为了确保 GraphQL 服务器正在工作,打开一个新的浏览器选项卡并导航到http://localhost:3600/graphql,这将显示图 24-2 中的工具。

img/473159_1_En_24_Fig2_HTML.jpg

图 24-2

图形浏览器

进行 GraphQL 查询

GraphQL 工具使得在将 graph QL 集成到示例应用之前执行查询变得很容易。例如,要查询所有的供应商对象,在 GraphiQL 窗口的左窗格中输入清单 24-7 中所示的查询。

query {
  suppliers {
    id,
    name,
    city,
    products
  }
}

Listing 24-7A Query for Supplier Data

该查询是基本的,但它揭示了许多关于 GraphQL 查询如何工作的信息。query关键字用于区分检索数据的请求和突变,后者用于进行更改(在“进行 GraphQL 突变”一节中有描述)。查询本身被括在花括号中,也称为括号。在大括号内,指定了查询名称,在本例中是suppliers

当查询 GraphQL 服务时,必须指定想要接收的数据字段。与总是呈现相同数据结构的 REST web 服务不同,GraphQL 允许客户端指定它想要接收的结果,这些结果包含在另一组括号中。清单 24-7 中的查询选择了idnamecityproducts字段。

注意

没有允许选择所有字段的通配符。如果要接收某个数据类型的所有字段,则必须在查询中包含所有字段。

单击 Execute Query 按钮将请求发送到 GraphQL 服务器,它将返回以下结果:

...
{
  "data": {
    "suppliers": [
      {
        "id": "1",
        "name": "Surf Dudes",
        "city": "San Jose",
        "products": ["1","2"]
      },
      {
        "id": "2",
        "name": "Goal Oriented",
        "city": "Seattle",
        "products": ["3","4","5"]
      },
      {
        "id": "3",
        "name": "Bored Games",
        "city": "New York",
        "products": ["6","7","8","9"]
      }
    ]
  }
}
...

这似乎与 REST web 服务没有太大的不同,但是即使有了这个基本的查询,客户机也能够选择它需要的字段以及它们将被表达的顺序。

查询相关数据

GraphQL 服务正在工作,它可以用来获取productsupplier数据,满足示例应用中数据表的基本需求。然而,GraphQL 最强大的特性之一是它支持查询中的相关数据,允许单个查询返回包含多种类型的结果。在清单 24-8 中,我已经将products字段更改为supplier数据类型的模式。

获取 GraphQL 服务的架构详细信息

编写模式可以最好地洞察 GraphQL 服务支持的查询,但这并不总是可能的。如果您没有编写自己的模式,首先要做的是查找开发人员文档;许多公共的 GraphQL 服务发布了全面的模式文档,比如在 https://developer.github.com/v4 描述的 GitHub API。

许多服务还支持 GraphiQL 或类似的工具,其中大部分都支持模式导航。例如,GraphiQL 通过它的Docs链接可以很容易地浏览模式,让您浏览服务支持的查询和变化。

如果没有文档和对 GraphQL 的支持,您可以使用 graph QL 自省特性来发送关于模式的查询。例如,以下模式查询将列出服务支持的常规查询:

...
{
     __schema {
    queryType {
      fields {
        name
      }
    }
  }
}
...

特殊的__schema查询数据类型用于请求关于模式的信息。你可以在 https://graphql.org/learn/introspection 找到 GraphQL 内省特性的更多细节。

type product {
    id: ID!,
    name: String!,
    category: String!
    price: Float!
}

type supplier {
    id: ID!,
    name: String!,
    city: String!,
    products: [product]
}

type Query {
    products: [product],
    suppliers: [supplier]
}

Listing 24-8Changing a Data Field in the schema.graphql File in the src/graphql Folder

products 字段现在返回一个由supplier对象组成的数组,而不是返回一个 ID 值数组。为了支持这种变化,我需要处理解析器使用的数据,以解析每个supplier及其相关的product对象之间的关系,如清单 24-9 所示。

var data = require("../../restData")();

module.exports = {

    products: () => data.products,

    suppliers: () => data.suppliers.map(s => ({
        ...s, products: () => s.products.map(id =>
            data.products.find(p => p.id === Number(id)))
    }))
}

Listing 24-9Resolving Related Data in the resolvers.js File in the src/graphql Folder

数据经过处理后,每个supplier对象都有一个products属性。products 属性是一个将解析相关数据的函数,只有当客户端请求该数据字段时才会被调用,这可以确保服务器不会获取没有被请求的数据。

使用 Control+C 停止 GraphQL 服务器,并运行在productapp文件夹中的清单 24-10 中显示的命令来再次启动它。

node graphqlServer.js

Listing 24-10Starting the GraphQL Server

导航到http://localhost:3600/graphql并将清单 24-11 中显示的查询输入到 GraphiQL 窗口的左窗格中。该查询利用对 GraphQL 模式的更改,在单个查询中请求供应商及其相关产品数据。

query {
  suppliers {
    id,
    name,
    city,
    products {
      name
    }
  }
}

Listing 24-11Querying for Related Data

当字段返回复杂类型时,如product,查询必须选择所需的字段。清单 24-11 中添加的查询要求服务器提供每个supplier对象的idnamecity字段以及每个相关product对象的name字段。单击执行查询按钮,您将收到以下结果:

...
{
  "data": {
    "suppliers": [
      {
        "id": "1", "name": "Surf Dudes", "city": "San Jose",
        "products": [{ "name": "Kayak" }, { "name": "Lifejacket" }]
      },
      {
        "id": "2", "name": "Goal Oriented", "city": "Seattle",
        "products": [{ "name": "Soccer Ball" },{ "name": "Corner Flags" },
          { "name": "Stadium" }]
      },
      {
        "id": "3", "name": "Bored Games", "city": "New York",
        "products": [{ "name": "Thinking Cap" },{ "name": "Unsteady Chair" },
          { "name": "Human Chess Board" }, { "name": "Bling Bling King" }]
      }
    ]
  }
}
...

注意,客户机指定了supplier对象和相关的product数据所需的字段,这确保了只检索应用所需的数据。

注意

除了常规查询,GraphQL 规范还包括对订阅的支持,它为服务器上正在变化的数据提供持续更新。订阅没有得到广泛或一致的支持,我在这本书里也不描述。

创建带参数的查询

GraphQL 服务器目前提供的查询允许用户选择所需的字段,但不选择结果中的对象,这是对单个对象的请求的要求。为了让客户端能够定制请求,GraphQL 支持参数,如清单 24-12 所示。

type product {
    id: ID!,
    name: String!,
    category: String!
    price: Float!
}

type supplier {
    id: ID!,
    name: String!,
    city: String!,
    products: [product]
}

type Query {
    products: [product],
    product(id: ID!): product,
    suppliers: [supplier]
    supplier(id: ID!): supplier
}

Listing 24-12Using Arguments in the schema.graphql File in the src/graphql Folder

参数在查询名称后的括号中定义,每个参数都被分配一个名称和一个类型。在清单 24-12 中,我添加了名为productsupplier的查询,每个查询都定义了一个类型为IDid参数,并用感叹号表示为强制的。在清单 24-13 中,我为使用id值选择数据对象的查询添加了解析器。

var data = require("../../restData")();

module.exports = {

    products: () => data.products,

    product: (args) => data.products.find(p => p.id === parseInt(args.id)),

    suppliers: () => data.suppliers.map(s => ({
        ...s, products: () => s.products.map(id =>
            data.products.find(p => p.id === Number(id)))
    })),

    supplier: (args) => {
        const result = data.suppliers.find(s => s.id === parseInt(args.id));
        if (result) {
            return {
                ...result,
                products: () => result.products.map(id =>
                    data.products.find(p => p.id === Number(id)))
            }
        }
    }
}

Listing 24-13Defining Resolvers in the resolvers.js File in the src/graphql Folder

resolver 函数接收一个对象,该对象的属性对应于查询参数。为了获得查询中指定的id值,解析器函数读取args.id属性。我可以通过析构参数对象来简化这段代码,如清单 24-14 所示。

小费

注意,我使用了parseInt函数来转换id参数进行比较。使用===在 ID 值和 JavaScript 数字值之间进行直接比较将返回false

var data = require("../../restData")();

module.exports = {

    products: () => data.products,

    product: ({id}) => data.products.find(p => p.id === parseInt(id)),

    suppliers: () => data.suppliers.map(s => ({
        ...s, products: () => s.products.map(id =>
            data.products.find(p => p.id === Number(id)))
    })),

    supplier: ({id}) => {
        const result = data.suppliers.find(s => s.id === parseInt(id));
        if (result) {
            return {
                ...result,
                products: () => result.products.map(id =>
                    data.products.find(p => p.id === Number(id)))
            }
        }
    }
}

Listing 24-14Destructing Arguments in the resolvers.js File in the src/graphql Folder

重启 GraphQL 服务器,并在 graph QL 窗口中输入清单 24-15 中所示的查询。

query {
  supplier(id: 1) {
    id,
    name,
    city,
    products {
      name
    }
  }
}

Listing 24-15Querying with an Argument

该查询请求id值为 1 的供应商对象,并请求相关产品的idnamecity字段以及name字段,产生以下结果:

...
{
  "data": {
    "supplier": {
      "id": "1",
      "name": "Surf Dudes",
      "city": "San Jose",
      "products": [{ "name": "Kayak" },{ "name": "Lifejacket" }]
    }
  }
}
...

向字段添加参数

可以为单个字段定义参数,这允许客户端更具体地指定它需要的数据。在清单 24-16 中,我为supplier类型的模式定义添加了一个参数,这将允许客户通过名称过滤相关的产品对象。

type product {
    id: ID!,
    name: String!,
    category: String!
    price: Float!
}

type supplier {
    id: ID!,
    name: String!,
    city: String!,
    products(nameFilter: String = ""): [product]
}

type Query {
    products: [product],
    product(id: ID!): product,
    suppliers: [supplier]
    supplier(id: ID!): supplier
}

Listing 24-16Adding a Field Argument in the schema.graphql File in the src/graphql Folder

字段products已被重新定义,以接收字符串nameFilter参数。没有使用感叹号,这意味着参数是可选的。如果没有使用值,将使用空字符串的默认值。该参数的实现如清单 24-17 所示。

var data = require("../../restData")();

const mapIdsToProducts = (supplier, nameFilter) =>

    supplier.products.map(id => data.products.find(p => p.id === Number(id)))
        .filter(p => p.name.toLowerCase().includes(nameFilter.toLowerCase()));

module.exports = {

    products: () => data.products,

    product: ({id}) => data.products
        .find(p => p.id === parseInt(id)),

    suppliers: () => data.suppliers.map(s => ({
        ...s, products: ({nameFilter}) => mapIdsToProducts(s, nameFilter)
    })),

    supplier: ({id}) => {
        const result = data.suppliers.find(s => s.id === parseInt(id));
        if (result) {
            return {
                ...result,
                products: ({ nameFilter }) => mapIdsToProducts(result, nameFilter)
            }
        }
    }
}

Listing 24-17Implementing a Field Argument in the resolvers.js File in the src/graphql Folder

为了支持字段参数,解析supplier对象的products属性的函数接受一个参数,该参数被解构以获得nameFilter值,并用于按名称过滤相关的产品对象。重启 GraphQL 服务器,并将清单 24-18 中所示的查询输入到 graph QL 中,查看字段参数是如何在查询中使用的。

query {
  supplier(id: 1) {
    id,
    name,
    city,
    products(nameFilter: "ak") {
      name
    }
  }
}

Listing 24-18Querying with a Field Argument

单击 Execute Query 按钮,您将看到以下结果,这些结果显示相关的产品对象已经被过滤,因此只包括那些名称字段包含ak的对象。

...
{
  "data": {
    "supplier": {
      "id": "1",
      "name": "Surf Dudes",
      "city": "San Jose",
      "products": [{ "name": "Kayak" }]
    }
  }
}
...

警告

每个请求都会调用用于接收字段参数的方法,这会给服务器带来大量工作。对于复杂的结果,可以考虑使用记忆化包,比如fast-memoize ( https://github.com/caiogondim/fast-memoize.js )。

因为字段参数应用于类型而不是特定的查询,所以该筛选器可用于任何包含相关产品数据的供应商数据查询。将清单 24-19 中所示的查询输入到 GraphiQL 中进行演示。

query {
  suppliers {
    id,
    name,
    city,
    products(nameFilter: "g") {
      name
    }
  }
}

Listing 24-19Using a Field Argument in Another Query

单击“执行查询”按钮,您将看到结果中每个供应商对象的相关产品数据已经被过滤。

...
{
  "data": {
    "suppliers": [
      {
        "id": "1",
        "name": "Surf Dudes",
        "city": "San Jose",
        "products": []
      },
      {
        "id": "2",
        "name": "Goal Oriented",
        "city": "Seattle",
        "products": [{ "name": "Corner Flags" }]
      },
      {
        "id": "3",
        "name": "Bored Games",
        "city": "New York",
        "products": [{ "name": "Thinking Cap" }, { "name": "Bling Bling King"}]
      }
    ]
  }
}
...

制造 GraphQL 突变

突变用于要求 GraphQL 服务器对其数据进行更改。使用特殊的Mutation类型将突变添加到模式中,有两种广泛的方法可用,如清单 24-20 所示。

type product {
    id: ID!,
    name: String!,
    category: String!
    price: Float!
}

type supplier {
    id: ID!,
    name: String!,
    city: String!,
    products(nameFilter: String = ""): [product]
}

type Query {
    products: [product],
    product(id: ID!): product,
    suppliers: [supplier]
    supplier(id: ID!): supplier
}

input productInput {

    id: ID, name: String!, category: String!, price: Int!

}

type Mutation {

    storeProduct(product: productInput): product
    storeSupplier(id: ID, name: String!, city: String!, products: [Int]): supplier

}

Listing 24-20Defining Mutations in the schema.graphql File in the src/graphql Folder

第一种变异称为storeProduct,使用专用的输入类型,允许客户端提供值来描述所需的更改。输入类型是使用input关键字定义的,支持与常规类型相同的特性。在清单中,我定义了一个名为productInput的输入类型,它有一个可选的id字段和强制的namecategoryprice字段。这基本上是已经在模式中定义的product类型的复制,这是一种常见的方法,因为您不能使用常规类型作为突变的参数。

storeSupplier突变采用了一种简单的方法,即定义多个参数,允许客户端在不需要输入类型的情况下表达数据对象的细节。这对于基本突变是一种有效的方法,但是对于复杂突变来说可能变得不实用。两种变异都会产生一个结果,该结果为客户端提供了一个对象的权威视图,该视图是作为变异的结果而创建或更新的,并使用常规查询类型来表达。在清单 24-21 中,我为突变添加了解析器。

var data = require("../../restData")();

const mapIdsToProducts = (supplier, nameFilter) =>
    supplier.products.map(id => data.products.find(p => p.id === Number(id)))
        .filter(p => p.name.toLowerCase().includes(nameFilter.toLowerCase()));

let nextId = 100;

module.exports = {

    products: () => data.products,

    product: ({id}) => data.products
        .find(p => p.id === parseInt(id)),

    suppliers: () => data.suppliers.map(s => ({
        ...s, products: ({nameFilter}) => mapIdsToProducts(s, nameFilter)
    })),

    supplier: ({id}) => {
        const result = data.suppliers.find(s => s.id === parseInt(id));
        if (result) {
            return {
                ...result,
                products: ({ nameFilter }) => mapIdsToProducts(result, nameFilter)
            }
        }
    },

    storeProduct({product}) {
        if (product.id == null) {
            product.id = nextId++;
            data.products.push(product);
        } else {
            product = { ...product, id: Number(product.id)};
            data.products = data.products
                .map(p => p.id === product.id ? product : p);
        }
        return product;
    },

    storeSupplier(args) {
        const supp = { ...args, id: Number(args.id)};
        if (args.id == null) {
            supp.id = nextId++;
            data.suppliers.push(supp)
        } else {
            data.suppliers = data.suppliers.map(s => s.id === supp.id ? supp: s);
        }
        let result = data.suppliers.find(s => s.id === supp.id);
        if (result) {
            return {
                ...result,
                products: ({ nameFilter }) => mapIdsToProducts(result, nameFilter)
            }
        }
    }
}

Listing 24-21Implementing Mutations in the resolvers.js File in the src Folder

这些变异被实现为接收参数的函数,就像查询一样。这些变化使用 ID 字段来确定客户机是更新现有对象还是存储新对象,并且它们更新查询使用的表示数据以反映变化。要更新带有storeProduct突变的产品,重启服务器并将清单 24-22 中所示的 GraphQL 输入 graph QL。

mutation {
  storeProduct(product: {
    id: 1,
    name: "Green Kayak",
    category: "Watersports",
    price: 290
  }) {
    id, name, category, price
  }
}

Listing 24-22Using the storeProduct Mutation

使用关键字mutation执行突变,它是上一个示例中使用的关键字query的对应关键字。指定了突变的名称,以及提供了idnamecategorypriceproduct参数。然后指定结果中需要的字段,在这种情况下,选择产品定义的所有字段。

单击执行查询按钮,您将看到以下结果:

...
{
  "data": {
    "storeProduct": {
      "id": "1",
      "name": "Green Kayak",
      "category": "Watersports",
      "price": 290
    }
  }
}
...

为了确认变异已经生效,使用 GraphiQL 执行清单 24-23 中的查询。

query {
  product(id: 1) {
    id, name, category, price
  }
}

Listing 24-23Querying Product Data

当您执行查询时,您将看到以下结果,这些结果反映了突变所做的更改:

...
{
  "data": {
    "product": {
      "id": "1",
      "name": "Green Kayak",
      "category": "Watersports",
      "price": 290
    }
  }
}
...

使用不依赖于输入类型的变异的过程是相似的,如清单 24-24 所示。

mutation {
  storeSupplier(
    name: "AcmeCo",
    city: "Chicago",
    products: [1, 3]
  ){ id, name, city, products {
      name
    }
  }
}

Listing 24-24Using a Mutation Without an Input Type

执行查询时,将创建一个新的供应商,并显示以下结果:

...
{
  "data": {
    "storeSupplier": {
      "id": "100",
      "name": "AcmeCo",
      "city": "Chicago",
      "products": [{ "name": "Green Kayak" }, { "name": "Soccer Ball" }]
    }
  }
}
...

注意,变异使用product字段中的id值来表示供应商和产品对象之间的关系,但是结果包括产品名称。变异从更新的表示数据中获得结果,表明变异的结果不需要与它接收的数据直接相关。

其他 GraphQL 特性

为了完成这一章,我将描述一些建立在前面描述的基础上的有用的特性。这些都是可选的,但是它们可以用来使 GraphQL 服务更容易使用。

使用请求变量

GraphQL 变量旨在允许一个请求被定义一次,然后在每次使用时使用参数进行定制,而不强制客户端为每个操作动态生成和序列化完整的请求数据。清单 24-25 中显示的查询定义了一个变量,该变量用作产品查询的参数。

query ($id: ID!) {
  product(id: $id) {
    id, name, category, price
  }
}

Listing 24-25A Query with a Variable

变量被应用于查询或变异,并使用以美元符号($字符)开头的名称和分配的类型进行定义。在这种情况下,查询定义了一个名为id的变量,其类型是强制的ID。在查询内部,变量被用作$id,并被传递给product查询参数。

要使用该变量,请将查询输入 GraphiQL 展开窗口左下方的查询变量部分。并输入清单 24-26 所示的代码。

{
  "id": 2
}

Listing 24-26Defining a Value for a Variable

这为id变量提供了值 2。点击执行查询按钮,查询和变量将被发送到 GraphQL 服务器,结果是id为 2 的产品对象被选中,如图 24-3 所示。

img/473159_1_En_24_Fig3_HTML.jpg

图 24-3

使用查询变量

使用 GraphiQL 时,变量可能看起来没什么用,但它们可以简化客户端开发,如第二十四章所示。

提出多个请求

一个操作可以包含多个请求或变异。在 GraphiQL 窗口中输入清单 24-27 中所示的查询。

query {
  product(id: 1) {
    id, name, category, price
  },
  supplier(id: 1) {
    id, name, city
  }
}

Listing 24-27Making Multiple Queries

查询由逗号分隔,包含在外层大括号中,跟在关键字query后面。单击 Execute Query 按钮,您将看到以下输出,它将两个查询的结果合并成一个响应:

...
{
  "data": {
    "product": {
      "id": "1",
      "name": "Kayak",
      "category": "Watersports",
      "price": 275
    },
    "supplier": {
      "id": "1",
      "name": "Surf Dudes",
      "city": "San Jose"
    }
  }
}
...

请注意,每个查询的名称用于表示其响应部分,这使得区分来自productsupplier查询的结果变得容易。当您希望多次使用同一个查询时,这可能会带来一个问题,因此 GraphQL 支持别名,即分配一个应用于结果的名称。将清单 24-28 中所示的查询输入到 GraphiQL 中。

query {
  first: product(id: 1) {
    id, name, category, price
  },
  second: product(id: 2) {
    id, name, category, price
  }
}

Listing 24-28Using a Query Alias

别名出现在查询之前,后跟一个冒号(:字符)。在清单中,有两个product查询被赋予了别名firstsecond。单击 Execute Query 按钮,您将看到这些名称是如何在查询结果中使用的。

...
{
  "data": {
    "first": {
      "id": "1",
      "name": "Kayak",
      "category": "Watersports",
      "price": 275
    },
    "second": {
      "id": "2",
      "name": "Lifejacket",
      "category": "Watersports",
      "price": 48.95
    }
  }
}

...

使用查询片段进行字段选择

从每个查询中选择结果字段的要求会导致客户端中的重复,如清单 24-28 ,其中firstsecond查询都选择了idnamecategoryprice字段。可以使用 GraphQL 片段特性定义一次字段选择,然后应用于多个请求。在清单 24-29 中,我定义了一个片段,并在查询中使用了它。

fragment coreFields on product {

  id, name, category

}

query {
  first: product(id: 1) {
    ...coreFields,
    price
  },
  second: product(id: 2) {
    ...coreFields
  }
}

Listing 24-29Using a Query Fragment

片段是使用关键字fragmenton定义的,并且特定于单一类型。在清单 24-29 中,该片段被命名为coreFields,并为product对象定义。spread 运算符用于应用片段,片段可以与常规字段选择混合使用。单击执行查询按钮,您将看到以下结果:

...
{
  "data": {
    "first": {
      "id": "1",
      "name": "Kayak",
      "category": "Watersports",
      "price": 275
    },
    "second": {
      "id": "2",
      "name": "Lifejacket",
      "category": "Watersports"
    }
  }
}
...

摘要

在这一章中,我介绍了 GraphQL。我解释了模式及其解析器的作用,并演示了为静态数据创建简单的 GraphQL 服务的过程。我向您展示了如何定义查询来从 GraphQL 服务中获取数据,以及如何使用突变来进行更改。本章中的所有示例都是使用 GraphQL 工具执行的,在下一章中,我将向您展示如何在 React 应用中使用 graph QL。

二十五、使用 GraphQL

本章是本书的最后一章,我将向您展示 React 应用使用 GraphQL 服务的不同方式。我将向您展示如何直接处理 HTTP 请求,如何将 GraphQL 与数据存储集成,以及如何使用专用的 GraphQL 客户端。

为本章做准备

在这一章中,我继续使用第二十四章中的productapp项目和它包含的 GraphQL 服务。要为本章做准备,需要进行以下各节中描述的更改。

将包添加到项目中

在本章的后面,我将创建直接接收 GraphQL 数据的组件,这需要额外的包。打开一个新的命令提示符,导航到productapp文件夹,运行清单 25-1 中显示的命令。

小费

你可以从 https://github.com/Apress/pro-react-16 下载本章以及本书其他章节的示例项目。

npm install apollo-boost@0.1.22
npm install react-apollo@2.3.2

Listing 25-1Adding Packages to the Example Project

表 25-1 描述了新包装的用途。

表 25-1

添加到项目中的包

|

名字

|

描述

|
| --- | --- |
| apollo-boost | 这个包包含 Apollo GraphQL 客户端,其配置适合大多数项目。 |
| react-apollo | 这个包包含 Apollo 客户机的 React 集成。 |

更改 GraphQL 服务器的数据

在第二十四章中,我使用了与之前 web 服务相同的数据来强调 REST 和 GraphQL 处理相同问题的不同方式。对于这一章,我想弄清楚示例应用何时停止使用 REST 获取数据,并开始使用 GraphQL。我在productapp文件夹中创建了一个名为graphqlData.js的文件,内容如清单 25-2 所示。

module.exports = function () {
    var data = {
        products: [
            { id: 1, name: "Trail Shoes", category: "Running", price: 120 },
            { id: 2, name: "Heated Gloves", category: "Running", price: 20.95 },
            { id: 3, name: "Padded Shorts", category: "Cycling", price: 19.50 },
            { id: 4, name: "Puncture Kit", category: "Cycling", price: 34.95 },
            { id: 5, name: "Mirror Goggles", category: "Swimming", price: 79500 },

        ],
        suppliers: [
            { id: 1, name: "Just Running", city: "Houston", products: [1, 2] },
            { id: 2, name: "Miles and Smiles", city: "Paris", products: [3, 4] },
            { id: 3, name: "Deep Dive", city: "New York", products: [5] },
        ]
    }
    return data
}

Listing 25-2The Contents of the graphqlData.js File in the productapp Folder

更新模式和解析程序

为了准备本章,我需要扩展 GraphQL 模式来定义删除数据的突变,如清单 25-3 所示。我还删除了输入类型,这样storeProductstoreSupplier突变就一致了。

type product {
    id: ID!,
    name: String!,
    category: String!
    price: Float!
}

type supplier {
    id: ID!,
    name: String!,
    city: String!,
    products(nameFilter: String = ""): [product]
}

type Query {
    products: [product],
    product(id: ID!): product,
    suppliers: [supplier]
    supplier(id: ID!): supplier
}

type Mutation {
    storeProduct(id: ID, name: String!, category: String!, price: Float!): product
    storeSupplier(id: ID, name: String!, city: String!, products: [Int]): supplier
    deleteProduct(id: ID!): ID
    deleteSupplier(id: ID!): ID
}

Listing 25-3Defining and Updating Mutations in the schema.graphql File in the src/graphql Folder

在清单 25-4 中,我为deleteProductdeleteSupplier突变定义了新的解析器,并更新了storeProduct解析器以反映输入类型的移除。我还修改了加载数据的语句,以使用在清单 25-2 中创建的文件。

var data = require("../../graphqlData")();

const mapIdsToProducts = (supplier, nameFilter) =>
    supplier.products.map(id => data.products.find(p => p.id === Number(id)))
        .filter(p => p.name.toLowerCase().includes(nameFilter.toLowerCase()));

let nextId = 100;

module.exports = {

    products: () => data.products,

    product: ({id}) => data.products
        .find(p => p.id === parseInt(id)),

    suppliers: () => data.suppliers.map(s => ({
        ...s, products: ({nameFilter}) => mapIdsToProducts(s, nameFilter)
    })),

    supplier: ({id}) => {
        const result = data.suppliers.find(s => s.id === parseInt(id));
        if (result) {
            return {
                ...result,
                products: ({ nameFilter }) => mapIdsToProducts(result, nameFilter)
            }
        }
    },

    storeProduct(args) {
        const product = { ...args, id: Number(args.id)};
        if (args.id == null || product.id === 0) {
            product.id = nextId++;
            data.products.push(product);
        } else {
            data.products = data.products
                .map(p => p.id === product.id ? product : p);
        }
        return product;
    },

    storeSupplier(args) {
        const supp = { ...args, id: Number(args.id)};
        if (args.id == null) {
            supp.id = nextId++;
            data.suppliers.push(supp)
        } else {
            data.suppliers = data.suppliers.map(s => s.id === supp.id ? supp: s);
        }
        let result = data.suppliers.find(s => s.id === supp.id);
        if (result) {
            return {
                ...result,
                products: ({ nameFilter }) => mapIdsToProducts(result, nameFilter)
            }
        }
    },

    deleteProduct({id}) {
        id = Number(id);
        data.products = data.products.filter(p => p.id !== id);
        data.suppliers = data.suppliers.map(s => {
            s.products = s.products.filter(p => p !== id);
            return s;
        })
        return id;
    },

    deleteSupplier({id}) {
        data.suppliers = data.suppliers.filter(s => s.id !== Number(id));
        return id;
    }
}

Listing 25-4Adding and Updating Resolvers in the resolvers.js File in the src/graphql Folder

新的解析器从数据数组中删除一项,并返回它们的id参数的值,对应于模式中使用的 ID 类型。当产品被删除时,供应商对它的任何引用也被删除,以避免随后查询供应商数据时出现错误。

小费

我还修改了storeProductstoreSupplier函数,这样它们将把对象的id值为零的请求视为不包含任何id值。在处理表单数据时,这是一项有用的技术,因为这意味着所有的表单值都可以发送到服务器,而不需要删除id属性来区分新的和修改过的对象。

将 GraphQL 服务器与开发工具集成

在第二十四章中,我直接启动了 GraphQL 工具,没有使用任何 React 开发工具。对于这一章,我将自动启动 GraphQL 服务器,同时启动开发 HTTP 服务器和 RESTful web 服务,示例应用仍然配置为使用它们。在清单 25-5 中,我修改了package.json文件的scripts部分,以便 GraphQL 服务器作为npm start命令的一部分启动。

...
"scripts": {
  "start": "npm-run-all --parallel reactstart json graphql",
  "build": "react-scripts build",
  "test": "react-scripts test",
  "eject": "react-scripts eject",
  "reactstart": "react-scripts start",
  "json": "json-server --p 3500 -r api.routes.json restData.js",
  "graphql": "node graphqlServer.js"
},
...

Listing 25-5Configuring the Project Startup in the package.json File in the productapp Folder

要启动示例应用,打开一个新的命令提示符,导航到productapp文件夹,并运行清单 25-6 中所示的命令。

npm start

Listing 25-6Running the Example Application

开发服务器、RESTful web 服务和 GraphQL 服务器都将启动。一个新的浏览器选项卡将打开并显示如图 25-1 所示的内容。

img/473159_1_En_25_Fig1_HTML.jpg

图 25-1

运行示例应用

为了确保 GraphQL 服务器正确运行,导航到http://localhost:3600/graphql并将清单 25-7 中显示的查询输入到 graph QL 中。

query {
  product(id: 1) {
    id, name, category, price
  }
}

Listing 25-7Querying the GraphQL Server

单击执行查询按钮,您应该会看到以下结果:

...
{
  "data": {
    "product": {
      "id": "1",
      "name": "Trail Shoes",
      "category": "Running",
      "price": 120
    }
  }
}
...

使用 GraphQL 服务

GraphQL 查询使用 HTTP POST 请求发送到服务器,带有一个 JSON 请求体,如下所示:

...
{"query":"query { product(id: 1) { id, name, category, price }", "variables": null }
...

响应是包含结果的 JSON 字符串,如下所示:

...
{"data":{"product":{"id":"1","name":"Trail Shoes","category":"Running","price":120}}}
...

HTTP 的使用以及请求和响应的结构使得将 GraphQL 集成到 React 应用中变得很容易,遵循了我在第二十三章中使用的用于处理 RESTful web 服务的相同模式。

定义查询和变异

使用 GraphQL 的起点是定义将发送到服务器的查询和变化。我在src/graphql文件夹中添加了一个名为queries.js的文件,并添加了清单 25-8 中所示的代码。

export const products = {
    getAll: {
        name: "products",
        graphql: `query {
                    products { id, name, category, price}
                }`
    },
    getOne: {
        name: "product",
        graphql: `query ($id: ID!) {
                product(id: $id) {
                    id, name, category, price
                }
             }`
        }
    }

export const suppliers = {
    getAll: {
        name: "suppliers",
        graphql:`query {
              suppliers { id, name, city, products { id, name }}
            }`
    },
    getOne: {
        name: "supplier",
        graphql: `query($id: ID!) {
                supplier(id: $id) {
                    id, name, city, products { id, name }
                }
            }`
    }
}

Listing 25-8The Contents of the queries.js File in the src/graphql Folder

每个查询都定义了一个 GraphQL 表达式和一个名称,应用将使用它从响应中检索数据。这些查询依赖于第二十四章中描述的可变特征。接下来,我在src/graphql文件夹中添加了一个名为mutations.js的文件,并定义了应用需要的突变,如清单 25-9 所示。

小费

不需要将查询从变异中分离出来,因为它们都只是字符串,但我发现这很有帮助,尤其是对于大量使用 GraphQL 的应用。

export const products = {
    store: {
        name: "storeProduct",
        graphql: `mutation ($id: ID, $name: String!,
                    $category: String!, $price: Float!) {

                    storeProduct(id : $id, name: $name,
                        category: $category, price: $price) {
                            id, name, category, price
                        }
                    }`
    },
    delete: {
        name: "deleteProduct",
        graphql: `mutation ($id: ID!) { deleteProduct(id: $id) }`
    }
}

export const suppliers = {
    store: {
        name: "storeSupplier",
        graphql: `mutation ($id: ID, $name: String!,
                    $city: String!, $products: [Int]) {

                    storeSupplier(id : $id, name: $name,
                        city: $city, products: $products) {
                            id, name, city, products { name }
                    }
                }`
    },
    delete: {
        name: "deleteSupplier",
        graphql: `mutation ($id: ID!) { deleteSupplier(id: $id) }`
    }
}

Listing 25-9The Contents of the mutations.js File in the src/graphql Folder

存在用于存储和删除产品和供应商对象的变异,并且每个变异的名称在name属性中使用,遵循为查询建立的相同模式。

定义数据源

在这一章中,我将以不同的方式使用相同的查询和变化,并且我想对应用的其余部分隐藏数据处理的细节,同时遵循我用于处理 RESTful web 服务的相同的广泛模式。为了提供一个将使用 GraphQL 执行数据操作的数据源,我在src/graphql文件夹中添加了一个名为GraphQLDataSource.js的文件,并使用它来定义清单 25-10 中所示的类。

import Axios from "axios";
import * as allQueries from "./queries";
import * as allMutations from "./mutations";

export class GraphQLDataSource {

    constructor(dataType, errorCallback) {
        this.GRAPHQL_URL = "http://localhost:3600/graphql";
        this.queries = allQueries[dataType];
        this.mutations = allMutations[dataType];
        this.handleError = errorCallback;
    }

    GetData(callback) {
        this.SendRequest(callback, this.queries.getAll);
    }

    GetOne(id, callback) {
        this.SendRequest(callback, this.queries.getOne, { id });
    }

    Store(data, callback) {
        this.SendRequest(callback, this.mutations.store, { ...data });
    }

    Update(data, callback) {
        this.Store(data, callback);
    }

    Delete(data, callback) {
        this.SendRequest(callback, this.mutations.delete, { id: data.id });
    }

    async SendRequest(callback, query, data) {
        try {
            let payload = {
                query: query.graphql,
                variables: data == null ? null : { ...data }
            }
            callback((await Axios.post(this.GRAPHQL_URL,
                payload)).data.data[query.name]);
        } catch(err) {
            this.handleError("Operation Failed: Network Error");
        }
    }
}

Listing 25-10The Contents of the GraphQLDataSource.js File in the src/graphql Folder

该类定义了与第二十三章中创建的 REST 数据源相同的方法,这不是必需的,但有助于展示不同类型的服务之间的区别。为了为特定类型的数据配置数据源,构造函数接收数据类型字符串,该字符串用于选择查询和变异。当发出请求时,GraphQL 和一个variables对象一起被发送给用户。结果包括所执行的变异查询的名称,该名称是使用name属性的值从响应中检索的。

...
callback((await Axios.post(this.GRAPHQL_URL, payload)).data.data[query.name]);
...

配置隔离的组件

使用与第二十三章中 REST 数据源相同的 API,通过改变使用数据的组件中的数据源,简化了将 GraphQL 数据集成到应用中的过程。在清单 25-11 中,我已经更改了IsolatedTable组件使用的数据源。

import React, { Component } from "react";

//import { RestDataSource } from "./webservice/RestDataSource";

import { Link } from "react-router-dom";

import { GraphQLDataSource } from "./graphql/GraphQLDataSource";

import { PRODUCTS } from "./store/dataTypes";

export class IsolatedTable extends Component {

    constructor(props) {
        super(props);
        this.state = {
            products: []
        }
        this.dataSource = new GraphQLDataSource(PRODUCTS,
            (err) => this.props.history.push(`/error/${err}`));
    }

    // ...methods omitted for brevity...
}

Listing 25-11Changing the Data Source in the IsolatedTable.js File in the src Folder

清单 25-12 对IsolatedEditor组件进行相应的修改。

import React, { Component } from "react";

//import { RestDataSource } from "./webservice/RestDataSource";

import { ProductEditor } from "./ProductEditor";

import { GraphQLDataSource } from "./graphql/GraphQLDataSource";

import { PRODUCTS } from "./store/dataTypes";

export class IsolatedEditor extends Component {

    constructor(props) {
        super(props);
        this.state = {
            dataItem: {}
        };
        this.dataSource = new GraphQLDataSource(PRODUCTS,
            (err) => this.props.history.push(`/error/${err}`));
    }

    save = (data) => {
        data = { ...data, price: Number(data.price)}
        const callback = () => this.props.history.push("/isolated");
        if (data.id === "") {
            this.dataSource.Store(data, callback);
        } else {
            this.dataSource.Update(data, callback);
        }
    }

    cancel = () => this.props.history.push("/isolated");

    render() {
        return <ProductEditor key={ this.state.dataItem.id }
            product={ this.state.dataItem } saveCallback={ this.save }
            cancelCallback={ this.cancel } />
    }

    componentDidMount() {
        if (this.props.match.params.mode === "edit") {
            this.dataSource.GetOne(this.props.match.params.id,
                data => this.setState({ dataItem: data}));
        }
    }
}

Listing 25-12Changing the Data Source in the IsolatedEditor.js File in the src Folder

注意,在数据被发送到服务器之前,我将price属性的值解析成一个Number。GraphQL 服务器根据模式中定义的类型检查接收到的数据,如果需要另一种类型,如Float,将拒绝字符串值,这是表单元素通常产生的结果。

当你保存对IsolatedTableIsolatedEditor组件的更改时,应用会更新,点击隔离数据按钮会显示从 GraphQL 服务器获取的数据,如图 25-2 所示。

img/473159_1_En_25_Fig2_HTML.jpg

图 25-2

使用 GraphQL 数据

对数据存储使用 GraphQL

将 GraphQL 用于数据存储的过程类似于我在第二十三章中为 RESTful 数据遵循的过程,使用中间件拦截动作并触发对服务器的请求。我在src/graphql文件夹中添加了一个名为GraphQLMiddleware.js的文件,并用它来定义清单 25-13 中所示的 Redux 中间件。

import { STORE, UPDATE, DELETE} from "../store/modelActionTypes";
import { PRODUCTS, SUPPLIERS } from "../store/dataTypes";
import { GraphQLDataSource } from "./GraphQLDataSource";

export const GET_DATA = "qraphql_get_data";

export const getData = (dataType) => {
    return {
        type: GET_DATA,
        dataType: dataType
    }
}

export const createGraphQLMiddleware = () => {

    const dataSources = {
        [PRODUCTS]: new GraphQLDataSource(PRODUCTS, () => {}),
        [SUPPLIERS]: new GraphQLDataSource(SUPPLIERS, () => {})
    }

    return ({dispatch, getState}) => next => action => {
        switch (action.type) {
            case GET_DATA:
                if (getState().modelData[action.dataType].length === 0) {
                    dataSources[action.dataType].GetData((data) =>
                        data.forEach(item => next({ type: STORE,
                            dataType: action.dataType, payload: item})));
                }
                break;
            case STORE:
                action.payload.id = null;
                dataSources[action.dataType].Store(action.payload, data =>
                    next({ ...action, payload: data }))
                break;
            case UPDATE:
                dataSources[action.dataType].Update(action.payload, data =>
                     next({ ...action, payload: data }))
                break;
            case DELETE:
                dataSources[action.dataType].Delete({id: action.payload },
                    () => next(action));
                break;
            default:
                next(action);
        }
    }
}

Listing 25-13The Contents of the GraphQLMiddleware.js File in the src/graphql Folder

这个中间件拦截应用其余部分分派的动作,并使用数据源类向 GraphQL 服务器发送查询或变异。在清单 25-14 中,我用 GraphQL 代码替换了 REST 中间件。

import { createStore, combineReducers, applyMiddleware, compose } from "redux";
import modelReducer from "./modelReducer";
import stateReducer from "./stateReducer";
import { customReducerEnhancer } from "./customReducerEnhancer";
import { multiActions } from "./multiActionMiddleware";
import { asyncEnhancer } from "./asyncEnhancer";

//import { createRestMiddleware } from "../webservice/RestMiddleware";

import { createGraphQLMiddleware } from "../graphql/GraphQLMiddleware";

const enhancedReducer = customReducerEnhancer(
    combineReducers(
        {
            modelData: modelReducer,
            stateData: stateReducer
        })
);

// const restMiddleware = createRestMiddleware(

//     "http://localhost:3500/api/products",

//     "http://localhost:3500/api/suppliers");

export default createStore(enhancedReducer,
    compose(applyMiddleware(multiActions),
        applyMiddleware(createGraphQLMiddleware()),
        asyncEnhancer(2000)));

export { saveProduct, saveSupplier, deleteProduct, deleteSupplier }
    from "./modelActionCreators";

Listing 25-14Enabling the GraphQL Middleware in the index.js File in the src/store Folder

适应 GraphQL 数据格式

GraphQL 对供应商数据的查询返回的数据格式包括相关的产品数据,这意味着不再需要对产品数据的单独请求,并且显示相关数据的组件必须适应新的格式。在清单 25-15 中,当应用需要供应商数据时,我禁用了产品数据的自动查询。

import React, { Component } from "react";

//import { PRODUCTS, SUPPLIERS } from "./store/dataTypes";

export const DataGetter = (dataType, WrappedComponent) => {

    return class extends Component {
        render() {
            return <WrappedComponent { ...this.props } />
        }

        componentDidMount() {
            // this.props.getData(PRODUCTS);
            // if (dataType === SUPPLIERS) {
            //     this.props.getData(SUPPLIERS);
            // }
            this.props.getData(dataType);
        }
    }
}

Listing 25-15Disabling the Related Data Query in the DataGetter.js File in the src Folder

在清单 25-16 中,我注释掉了TableConnector组件中处理供应商数据的代码,以合并相关产品的名称。由于数据来自 GraphQL 服务器,这些信息将直接为组件所用。TableConnector也触发数据请求。

import { connect } from "react-redux";
//import { startEditingProduct, startEditingSupplier } from "./stateActions";
import { deleteProduct, deleteSupplier } from "./modelActionCreators";
import { PRODUCTS, SUPPLIERS } from "./dataTypes";
import { withRouter } from "react-router-dom";

//import { getData } from "../webservice/RestMiddleware";

import { getData } from "../graphql/GraphQLMiddleware";

import { DataGetter } from "../DataGetter";

export const TableConnector = (dataType, presentationComponent) => {

    const mapStateToProps = (storeData, ownProps) => {
        if (dataType === PRODUCTS) {
            return { products: storeData.modelData[PRODUCTS] };
        } else {
            return { suppliers: storeData.modelData[SUPPLIERS] };
                // suppliers: storeData.modelData[SUPPLIERS].map(supp => ({
                //     ...supp,
                //     products: supp.products.map(id =>
                //         storeData.modelData[PRODUCTS]
                //             .find(p => p.id === Number(id)) || id)
                //             .map(val => val.name || val)
                //     }))
        }
    }

    const mapDispatchToProps = (dispatch, ownProps) => {
        return {
            getData: (type) => dispatch(getData(type)),
            deleteCallback: dataType === PRODUCTS
                ? (...args) => dispatch(deleteProduct(...args))
                : (...args) => dispatch(deleteSupplier(...args))
        }
    }

    const mergeProps = (dataProps, functionProps, ownProps) => {
        let routedDispatchers = {
            editCallback: (target) => {
                ownProps.history.push(`/${dataType}/edit/${target.id}`);
            },
            deleteCallback: functionProps.deleteCallback,
            getData: functionProps.getData

        }
        return Object.assign({}, dataProps, routedDispatchers, ownProps);
    }

    return withRouter(connect(mapStateToProps,
        mapDispatchToProps, mergeProps)(DataGetter(dataType,
            presentationComponent)));
}

Listing 25-16Disabling Data Processing in the TableConnector.js File in the src/store Folder

为了通过 ID 定位对象,我修改了EditorConnector,这样它就不会将 URL 参数解析为Number,如清单 25-17 所示。

import { connect } from "react-redux";
//import { endEditing } from "./stateActions";
import { PRODUCTS, SUPPLIERS  } from "./dataTypes";
import { saveAndEndEditing } from "./multiActionCreators";
import { withRouter } from "react-router-dom";

export const EditorConnector = (dataType, presentationComponent) => {

    const mapStateToProps = (storeData, ownProps) => {
        const mode = ownProps.match.params.mode;
        const id = ownProps.match.params.id;
        return {
            editing: mode === "edit" || mode === "create",
            product: (storeData.modelData[PRODUCTS].find(p => p.id === id)) || {},
            supplier:(storeData.modelData[SUPPLIERS].find(s => s.id === id)) || {}
        }
    }

    const mapDispatchToProps = {
        //cancelCallback: endEditing,
        saveCallback: (data) => saveAndEndEditing(data, dataType)
    }

    const mergeProps = (dataProps, functionProps, ownProps) => {
        let routedDispatchers = {
            cancelCallback: () => ownProps.history.push(`/${dataType}`),
            saveCallback: (data) => {
                functionProps.saveCallback(data);
                ownProps.history.push(`/${dataType}`);
            }
        }
        return Object.assign({}, dataProps, routedDispatchers, ownProps);
    }

    return withRouter(connect(mapStateToProps,
        mapDispatchToProps, mergeProps)(presentationComponent));
}

Listing 25-17Changing ID Matching in the EditorConnector.js File in the src/store Folder

为了显示产品的名称,我对SupplierTableRow组件进行了清单 25-18 中所示的更改。

import React, { Component } from "react";

export class SupplierTableRow extends Component {

    render() {
        let s = this.props.supplier;
        return <tr>
            <td>{ s.id }</td>
            <td>{ s.name }</td>
            <td>{ s.city}</td>
            <td>{ s.products != null ?
                    s.products.map(p => p.name).join(", ") : "" }</td>
            <td>
                <button className="btn btn-sm btn-warning m-1"
                    onClick={ () => this.props.editCallback(s) }>
                        Edit
                </button>
                <button className="btn btn-sm btn-danger m-1"
                    onClick={ () => this.props.deleteCallback(s) }>
                        Delete
                    </button>
            </td>
        </tr>
    }
}

Listing 25-18Selecting Product Names in the SupplierTableRow.js File in the src Folder

下一个变化是在编辑供应商数据时适应新的数据格式,确保相关产品区域的id值显示给用户,如清单 25-19 所示。

import React, { Component } from "react";

export class SupplierEditor extends Component {

    constructor(props) {
        super(props);
        this.state = {
            formData: {
                id: props.supplier.id || "",
                name: props.supplier.name || "",
                city: props.supplier.city || "",
                products: props.supplier.products != null
                    ? props.supplier.products.map(p => p.id) : [],
            }
        }
    }

    // ...methods omitted for brevity...
}

Listing 25-19Selecting Product IDs in the SupplierEditor.js File in the src Folder

最后一个变化是将从产品price属性的表单元素获得的字符串值解析为一个数字,这确保发送到服务器的数据与模式中指定的Float类型相匹配,如清单 25-20 所示。

...
handleClick = () => {
    this.props.saveCallback(
        {
            ...this.state.formData,
            price: Number(this.state.formData.price)
        });
}
...

Listing 25-20Parsing the Price Value in the ProductEditor.js File in the src Folder

保存所有的更改,应用将完全使用从 GraphQL 服务器获得的数据,并放置在 Redux 数据存储中,如图 25-3 所示。

img/473159_1_En_25_Fig3_HTML.jpg

图 25-3

在数据存储中使用 GraphQL 数据

小费

如果遇到错误,停止开发工具并使用npm start再次启动它们。这将重置 GraphQL 服务器使用的数据,并撤消在前面部分中所做的更改的效果。

使用 GraphQL 客户端框架

在上一节中,我演示了如何将 GraphQL 与 Redux 数据存储一起使用,因为它展示了使用数据是多么容易,并提供了与使用 RESTful web 服务的比较。

有一种不同的方法,即使用一个包来替换数据存储,并直接向需要它的组件提供 GraphQL 数据,同时缓存数据以避免在独立组件之间导航可能导致的重复 HTTP 请求。

我在这一节中使用的包被称为 Apollo Client,这是一个客户端包,与我在第二十四章中提到的 Apollo GraphQL 服务器来自同一个开发人员(但它适用于任何 GraphQL 服务器)。阿波罗客户端的完整文档可在 https://www.apollographql.com/docs/react 获得。

注意

本章中的示例依赖于本章开始时安装的软件包。

配置客户端

第一步是配置客户机,以便它知道将 GraphQL 请求和变化发送到哪里。清单 25-21 显示了我添加到App组件的配置语句。

import React, { Component } from "react";

// import { Provider } from "react-redux";

// import dataStore from "./store";

import { Selector } from "./Selector";

//import { PRODUCTS, SUPPLIERS } from "./store/dataTypes";

import ApolloClient from "apollo-boost";

import { ApolloProvider } from "react-apollo";

const client = new ApolloClient({

    uri: "http://localhost:3600/graphql"

});

export default class App extends Component {

    render() {
        return  <ApolloProvider client={ client }>
                    <Selector />
                </ApolloProvider>
    }
}

Listing 25-21Configuring Apollo Client in the App.js File in the src Folder

创建一个新的ApolloClient对象来管理与 GraphQL 服务器的关系,其构造函数接受一个配置对象。配置对象的uri属性指定了 GraphQL 请求的 URL。还有其他配置选项,但默认设置适用于大多数项目(详见 www.apollographql.com )。

ApolloProvider组件用于将 GraphQL 特性与 React 集成,而client属性被分配给ApolloClient对象。

为了简化这个例子,我已经删除了包含在Selector组件和数据存储中的内容。我将很快直接定义选择器显示的组件。

小费

我从应用中删除了 Redux 数据存储,只是为了简化示例应用。Apollo 客户端和 Redux 可以在同一个应用中使用,尽管您应该注意不要将数据存储在由 Apollo 客户端管理的 Redux 中,因为这两者很容易不同步。

创建 GraphQL 组件

下一步是创建组件,作为 GraphQL 和显示给用户的内容之间的桥梁。这是我在本书的这一部分一直使用的基本方法,它意味着呈现给用户的内容是由不直接依赖于特定数据机制的组件生成的,无论是 Redux 数据存储、RESTful web 服务还是 GraphQL 客户端。我在src/graphql文件夹中添加了一个名为GraphQLTable.js的文件,并添加了清单 25-22 中所示的代码。

选择替代的 GraphQL 客户端

我选择 Apollo 客户端是因为它灵活且易于使用。主要的替代品是继电器( https://facebook.github.io/relay ),是脸书研发的。Relay 更难上手,并且只适用于遵循特定结构的 GraphQL 模式。

还有一些包提供了 Apollo 客户端或 Relay 提供的功能的子集,比如肾上腺素( https://github.com/gyzerok/adrenaline )。

import React, { Component } from "react";
import { Query } from "react-apollo";
import gql from "graphql-tag";
import * as queries from "./queries";
import { ProductTable } from "../ProductTable";

export const GraphQLTable = () => {

    const getAll = gql(queries.products.getAll.graphql);

    return class extends Component {

        constructor(props) {
            super(props);
            this.editCallback = (item) => this.props.history
                .push(`/products/edit/${item.id}`);
        }

        render() {
            return <Query query={ getAll }>
                {({loading, data, refetch }) => {
                    if (loading) {
                        return <h5
                            className="bg-info text-white text-center m-2 p-2">
                                Loading...
                        </h5>
                    } else {
                        return <React.Fragment>
                            <ProductTable products={data.products}
                                editCallback= { this.editCallback }
                                deleteCallback={ () => {} } />
                            <div className="text-center">
                                <button className="btn btn-primary"
                                        onClick={ () => refetch() }>
                                    Reload Data
                                </button>
                            </div>
                        </React.Fragment>
                    }
                }}
            </Query>
        }
    }
}

Listing 25-22The Contents of the GraphQLTable.js File in the src/graphql Folder

清单 25-22 中有很多内容,值得详细分解组件以理解每一部分。与本书这一部分中使用的其他包一样,Apollo Client 依赖高阶组件来提供特性。在这种情况下,GraphQLTable向示例应用的ProductTable组件提供特性。我首先设置获取数据的 GraphQL 查询,如下所示:

...
const getAll = gql(queries.products.getAll.graphql);
...

gql函数接受表示为字符串的查询,并对其进行处理,以便 Apollo 客户机可以使用。我已经定义了应用所需的查询,并按照数据类型对它们进行了组织。gql函数也可以直接与模板字符串一起使用,这允许像这样定义查询:

...
const getAll = gql`query { products {
    id, name, category, price
}}`
...

Query组件向组件提供对查询返回的数据的访问,这是由query属性指定的,如下所示:

...
return <Query query={ getAll }>
...

组件Query一呈现,查询就被发送到服务器,它使用一个 render prop 函数通过一个定义描述查询结果的属性的对象来提供它的特性,其中最有用的在表 25-2 中描述。

表 25-2

有用 Apollo 客户机呈现正确的对象属性

|

名字

|

描述

|
| --- | --- |
| data | 该属性返回查询产生的数据。 |
| loading | 处理查询时,此属性返回 true。 |
| error | 该属性返回任何查询错误的详细信息。 |
| variables | 该属性返回用于查询的变量。 |
| refetch | 该属性返回一个函数,该函数可用于重新发送查询,可以选择使用新变量。 |

对于清单 25-22 中的查询,我使用 loading、data 和refetch属性。

...
{({loading, error, refetch}) => {
...

当组件第一次被呈现并且请求被发送到 GraphQL 服务器时,loading值是true。当请求完成时,组件更新,用loading值作为false,并通过data属性提供结果。

在呈现Query组件时执行查询,但是结果被缓存,这意味着下次需要数据时不会向服务器发送查询。refetch属性提供了一个函数,当它被调用时再次发送查询,刷新数据,并更新组件。分配给refresh属性的函数接受一个可用于为查询提供新变量的对象。这是一个有用的特性,但这意味着在使用事件处理程序时,您必须确保不要直接调用该函数,如下所示:

...
<button className="btn btn-primary" onClick={ () => refetch() }>
...

如果您没有指定内联函数,如图所示,那么事件对象将被传递给refetch函数,该函数将尝试使用它作为查询的变量源并遇到错误。

应用 GraphQL 组件

在清单 25-23 中,我已经替换了Selector组件使用的路由组件,这样GraphQLTable组件就可以用来响应/product URL。

import React, { Component } from "react";
import { BrowserRouter as Router, Route, Switch, Redirect }
    from "react-router-dom";
import { ToggleLink } from "./routing/ToggleLink";

// import { RoutedDisplay } from "./routing/RoutedDisplay";

// import { IsolatedTable } from "./IsolatedTable";

// import { IsolatedEditor } from "./IsolatedEditor";

// import { RequestError } from "./webservice/RequestError";

import { GraphQLTable } from "./graphql/GraphQLTable";

export class Selector extends Component {

    render() {
        return <Router>
            <div className="container-fluid">
                <div className="row">
                    <div className="col-2">
                        <ToggleLink to="/products">Products</ToggleLink>
                    </div>
                    <div className="col">
                        <Switch>
                            <Route path="/products" exact={true}
                                component={ GraphQLTable()}  />
                            <Redirect to="/products"/>
                        </Switch>
                    </div>
                </div>
            </div>
        </Router>
    }
}

Listing 25-23Changing the Routing Configuration in the Selector.js File in the src Folder

保存更改,您将看到显示的 GraphQL 产品和供应商,如图 25-4 所示。

img/473159_1_En_25_Fig4_HTML.jpg

图 25-4

使用 GraphQL 客户端包

小费

如果遇到错误,停止开发工具并使用npm start再次启动它们。这将重置 GraphQL 服务器使用的数据,并撤消在前面部分中所做的更改的效果。

如果您使用 F12 开发人员工具来查看浏览器发出的网络请求,您将会看到在第一次单击路由按钮时发送数据查询,而不是在再次选择同一个表时。单击 Reload Data 按钮调用查询的刷新功能并触发新的查询。

利用突变

Mutation组件用于提供对 GraphQL 突变的访问。在清单 25-24 中,我使用了Mutation来提供对删除产品或供应商对象的突变的访问。

import React, { Component } from "react";

import { Query, Mutation } from "react-apollo";

import gql from "graphql-tag";
import * as queries from "./queries";
import { ProductTable } from "../ProductTable";

import * as mutations from "./mutations";

export const GraphQLTable = () => {

    const getAll = gql(queries.products.getAll.graphql);
    const deleteItem = gql(mutations.products.delete.graphql);

    return class extends Component {

        constructor(props) {
            super(props);
            this.editCallback = (item) => this.props.history
                .push(`/products/edit/${item.id}`);
        }

        render() {
            return <Query query={ getAll }>
                {({loading, data, refetch }) => {
                    if (loading) {
                        return <h5
                            className="bg-info text-white text-center m-2 p-2">
                                Loading...
                        </h5>
                    } else {
                        return <Mutation mutation={ deleteItem }
                                refetchQueries={ () => [{query: getAll}]  }>
                            { doDelete =>
                                 <React.Fragment>
                                    <ProductTable products={data.products}
                                        editCallback= { this.editCallback }
                                        deleteCallback={ (p) =>
                                            doDelete({variables: {id: p.id} }) }  />
                                    <div className="text-center">
                                        <button className="btn btn-primary"
                                                onClick={ () => refetch() }>
                                            Reload Data
                                        </button>
                                    </div>
                                </React.Fragment>
                        }
                        </Mutation>
                    }
                }}
            </Query>
        }
    }
}

Listing 25-24Using a Mutation in the GraphQLTable.js File in the src/graphql Folder

Mutation组件遵循与Query组件相似的模式,并依赖渲染属性函数来提供对突变的访问。使用属性配置Mutation组件,其中最有用的在表 25-3 中有描述。

表 25-3

有用的变异属性

|

名字

|

描述

|
| --- | --- |
| mutation | 这个属性指定了将被发送到服务器的突变。 |
| variables | 这个属性指定了变异的变量。当执行突变时,也可以提供变量。 |
| refetchQueries | 该属性指定了变异完成后要执行的一个或多个查询。 |
| update | 该属性指定了一个函数,用于在变异完成后更新缓存。 |
| onCompleted | 这个属性指定了一个回调函数,当变异完成时调用这个函数。 |

我首先将突变传递给gql函数,这样它就可以用作Mutation组件上突变属性的值,如下所示:

...
return <Mutation mutation={ deleteMutation }
    refetchQueries={ () => [{ query: getAll}]}>
...

一旦执行了突变,客户端的缓存数据通常会变得过时,而Mutation组件提供了两个可以用来保持缓存同步的属性。refetchQueries属性被赋予一个函数,该函数接收变异结果并返回一个对象数组,每个对象都有query和可选的variables属性。变异完成后,查询被发送到服务器,结果被用来更新缓存。这是我在清单 25-24 中采用的方法,其中我配置了MutationgetAll查询的结果更新缓存:

...
return <Mutation mutation={ deleteMutation }
    refetchQueries={ () => [{ query: getAll}]}>
...

突变作为参数提供给 render prop 函数。

...
return <Mutation mutation={ deleteItem }
    refetchQueries={ () => [{query: getAll}]  }>
        { doDelete => {
...

突变的变量可以作为Mutation组件上的一个属性或作为函数的一个参数提供。我在清单 25-24 中使用了一个函数,因为我通过回调接收到了要删除的对象。

...
deleteCallback={ (p) => doDelete({variables: {id: p.id} }) }
...

效果是点击一个删除按钮调用变异,然后向服务器发送查询更新的数据,显示给用户,如图 25-5 所示。

img/473159_1_En_25_Fig5_HTML.jpg

图 25-5

利用突变

更新缓存数据而不进行查询

当应用不知道变化的影响时,在突变后重新查询服务器是有用的。对于更简单的操作,查询新数据是多余的,因为应用确切地知道突变的影响,并可以将这种变化直接应用到缓存的数据。在清单 25-25 中,我修改了Mutation的配置,这样一旦删除了一个条目,它就不再执行查询,而是使用一个函数来更新数据缓存。

import React, { Component } from "react";
import { Query, Mutation } from "react-apollo";
import gql from "graphql-tag";
import * as queries from "./queries";
import { ProductTable } from "../ProductTable";
import * as mutations from "./mutations";

export const GraphQLTable = () => {

    const getAll = gql(queries.products.getAll.graphql);
    const deleteItem = gql(mutations.products.delete.graphql);

    return class extends Component {

        constructor(props) {
            super(props);
            this.editCallback = (item) => this.props.history
                .push(`/products/edit/${item.id}`);
        }

        removeItemFromCache(cache, mutationResult) {
            const deletedId =  mutationResult.data[mutations.products.delete.name];
            const data =
                cache.readQuery({ query: getAll })[queries.products.getAll.name];
            cache.writeQuery({
                query: getAll,
                data: { products: data.filter(item => item.id !== deletedId) }
            });
        }

        render() {
            return <Query query={ getAll }>
                {({loading, data, refetch }) => {
                    if (loading) {
                        return <h5
                            className="bg-info text-white text-center m-2 p-2">
                                Loading...
                        </h5>
                    } else {
                        return <Mutation mutation={ deleteItem }
                                update={ this.removeItemFromCache }>
                            { doDelete =>
                                 <React.Fragment>
                                    <ProductTable products={data.products}
                                        editCallback= { this.editCallback }
                                        deleteCallback={ (p) =>
                                            doDelete({variables: {id: p.id} }) }  />
                                    <div className="text-center">
                                        <button className="btn btn-primary"
                                                onClick={ () => refetch() }>
                                            Reload Data
                                        </button>
                                    </div>
                                </React.Fragment>
                        }
                        </Mutation>
                    }
                }}
            </Query>
        }
    }
}

Listing 25-25Updating Cached Data in the GraphQLTable.js File in the src/graphql Folder

Mutation上的update属性用于指定一个方法,该方法将在突变完成时被调用。该方法接收阿波罗客户端缓存和变异的结果,并负责使用表 25-4 中描述的方法更新缓存的数据。

表 25-4

Apollo 客户端缓存方法

|

名字

|

描述

|
| --- | --- |
| readQuery | 此方法用于从与特定查询关联的缓存中读取数据。如果试图读取不在缓存中的数据,将会引发错误,这通常发生在查询尚未执行的情况下。 |
| writeQuery | 此方法用于更新缓存中与特定查询关联的数据。 |

清单 25-25 中的removeItemFromCache方法使用readQuery方法检索与products查询相关联的缓存数据,过滤掉被删除的项目,并使用writeQuery方法将剩余的项目写回缓存。readQuery方法接受带有query和可选variables属性的对象,写查询方法接受带有查询、数据和可选variables属性的对象。结果是,当您单击某个产品的“删除”按钮时,该对象会在突变完成后从本地缓存中删除,而无需额外的查询。

添加对供应商数据和编辑的支持

既然基本的 GraphQL 客户端特性已经就绪,我将对GraphQLTable进行修改,使其支持产品和供应商数据,并引入对编辑数据的支持。在清单 25-26 中,我对GrpahQLTable进行了修改,这样它就可以接收正在处理的数据类型作为参数,并选择查询、变异和组件来动态显示给用户。

import React, { Component } from "react";
import { Query, Mutation } from "react-apollo";
import gql from "graphql-tag";
import * as queries from "./queries";
import { ProductTable } from "../ProductTable";
import * as mutations from "./mutations";

import { PRODUCTS, SUPPLIERS } from "../store/dataTypes";

import { SupplierTable } from "../SupplierTable";

export const GraphQLTable = (dataType) => {

    const getAll = gql(queries[dataType].getAll.graphql);
    const deleteItem = gql(mutations[dataType].delete.graphql);

    return class extends Component {

        constructor(props) {
            super(props);
            this.editCallback = (item) => this.props.history
                .push(`/${dataType}/edit/${item.id}`);
        }

        removeItemFromCache = (cache, mutationResult) => {

            const deletedId =  mutationResult.data[mutations[dataType].delete.name];
            const data =
                cache.readQuery({ query: getAll })[queries[dataType].getAll.name];
            cache.writeQuery({
                query: getAll,
                data: { [dataType]: data.filter(item => item.id !== deletedId) }
            });
        }

        getRefetchQueries() {
            return dataType === PRODUCTS
                ? [{query: gql(queries[SUPPLIERS].getAll.graphql)}] : []
        }

        render() {
            return <Query query={ getAll }>
                {({loading, data, refetch }) => {
                    if (loading) {
                        return <h5
                            className="bg-info text-white text-center m-2 p-2">
                                Loading...
                        </h5>
                    } else {
                        return <Mutation mutation={ deleteItem }
                                update={ this.removeItemFromCache }
                                refetchQueries={ this.getRefetchQueries }>
                            { doDelete =>
                                 <React.Fragment>
                                     { dataType === PRODUCTS &&
                                        <ProductTable products={data.products}
                                            editCallback= { this.editCallback }
                                            deleteCallback={ (p) =>
                                                doDelete({variables: {id: p.id} }) }
                                        />
                                    }
                                    { dataType === SUPPLIERS &&
                                        <SupplierTable suppliers={data.suppliers}
                                            editCallback= { this.editCallback }
                                            deleteCallback={ (p) =>
                                                doDelete({variables: {id: p.id} }) }
                                        />
                                    }
                                    <div className="text-center">
                                        <button className="btn btn-primary"
                                                onClick={ () => refetch() }>
                                            Reload Data
                                        </button>
                                    </div>
                                </React.Fragment>
                        }
                        </Mutation>
                    }
                }}
            </Query>
        }
    }
}

Listing 25-26Supporting Multiple Data Types in the GraphQLTable.js File in the src/graphql Folder

注意这个例子结合了Mutation上的updaterefetchQueries属性。我需要在删除产品时保持供应商数据的一致性,但是对不在缓存中的数据使用readQuery方法会产生错误。为了保持示例的简单性——并且不要从 GraphQL 服务器的解析器中复制太多的逻辑——我使用update属性从缓存中执行简单的删减,使用refetchQueries属性获取新的suppliers数据。

创建编辑器组件

为了允许用户编辑对象,我在src/graphql文件夹中添加了一个名为GraphQLEditor.js的文件,并用它来定义清单 25-27 中所示的组件。

import React, { Component } from "react";
import gql from "graphql-tag";
import * as queries from "./queries";
import * as mutations from "./mutations";
import { Query, Mutation } from "react-apollo";
import { PRODUCTS } from "../store/dataTypes";
import { ProductEditor } from "../ProductEditor";
import { SupplierEditor } from "../SupplierEditor";

export const GraphQLEditor = () => {

    return class extends Component {

        constructor(props) {
            super(props);
            this.dataType = this.props.match.params.dataType;
            this.id = this.props.match.params.id;
            this.query = gql(queries[this.dataType].getOne.graphql);
            this.variables = { id: this.id };
            this.mutation = gql(mutations[this.dataType].store.graphql);
            this.navigation = () => props.history.push(`/${this.dataType}`);
        }

        render() {
            return <Query query={ this.query} variables={ this.variables }>
            {
                ({loading, data}) => {
                    if (!loading) {
                        return <Mutation mutation={ this.mutation }
                                onCompleted={ this.navigation }>
                            { (store) => {
                                if (this.dataType === PRODUCTS) {
                                    return <ProductEditor key={ this.id }
                                        product={ data.product }
                                        saveCallback={ (formData) =>
                                            store({variables: formData})}
                                        cancelCallback={ this.navigation } />
                                } else {
                                    return <SupplierEditor key={ this.id }
                                        supplier={ data.supplier }
                                        saveCallback={ (formData =>
                                            store({ variables: formData }))}
                                        cancelCallback={ this.navigation } />
                                }
                            }
                        }
                        </Mutation>
                    } else {
                        return null;
                    }
                }
            }
            </Query>
        }
    }
}

Listing 25-27The Contents of the GraphQLEditor.js File in the src/graphql Folder

该组件建立在用于表的相同功能的基础上。一个Query用于从 GraphQL 服务器请求数据,而variables prop 用于提供查询所需的变量。一个Mutation用于存储数据,一旦变异完成,onCompleted属性用于导航离开编辑器。

请注意,我没有更新缓存的数据。Apollo Client 有一个巧妙的特性,改变单个对象的突变和具有id属性的响应以及已经改变的属性被自动推入缓存,并由其他Query对象使用。对于编辑器组件,这意味着对产品或供应商的更改会自动显示在数据表中,而不需要更新缓存或重新查询数据。

更新路由配置

为了完成示例,清单 25-28 更新了示例应用的路由配置,以添加对供应商数据和新编辑器组件的支持。

import React, { Component } from "react";
import { BrowserRouter as Router, Route, Switch, Redirect }
    from "react-router-dom";
import { ToggleLink } from "./routing/ToggleLink";
import { GraphQLTable } from "./graphql/GraphQLTable";

import { PRODUCTS, SUPPLIERS } from "./store/dataTypes";

import { GraphQLEditor } from "./graphql/GraphQLEditor";

export class Selector extends Component {

    render() {
        return <Router>
            <div className="container-fluid">
                <div className="row">
                    <div className="col-2">
                        <ToggleLink to="/products">Products</ToggleLink>
                        <ToggleLink to="/suppliers">Suppliers</ToggleLink>
                    </div>
                    <div className="col">
                        <Switch>
                            <Route path="/products" exact={true}
                                component={ GraphQLTable(PRODUCTS) }  />
                            <Route path="/suppliers" exact={true}
                                component={ GraphQLTable(SUPPLIERS) }  />
                            <Route path="/:dataType/edit/:id"
                                component= { GraphQLEditor() } />
                            <Redirect to="/products" />
                        </Switch>
                    </div>
                </div>
            </div>
        </Router>
    }
}

Listing 25-28Updating the Routing Configuration in the Selector.js File in the src Folder

当您保存更改时,应用将被更新以支持供应商数据和编辑,如图 25-6 所示。

img/473159_1_En_25_Fig6_HTML.jpg

图 25-6

支持供应商数据和编辑

小费

如果遇到错误,停止开发工具并使用npm start再次启动它们。这将重置 GraphQL 服务器使用的数据,并撤消在前面部分中所做的更改的效果。

摘要

在本章中,我向您展示了 React 应用使用 GraphQL 服务的不同方式。我向您展示了如何在隔离的组件中使用 GraphQL,如何截取数据存储操作并使用 GraphQL 服务它们,以及如何采用代表应用管理数据的 GraphQL 客户端。

这就是我要教你的 React。我首先创建了一个简单的应用,然后带您全面浏览了框架中的不同构建块,向您展示了如何创建、配置和应用它们来创建 web 应用。

我希望你在 React 项目中取得成功,我只希望你能像我喜欢写这本书一样喜欢读这本书。

第一部分:从 React 开始

第二部分:使用 React

第三部分:创建完整的应用

posted @ 2024-10-01 21:04  绝不原创的飞龙  阅读(2)  评论(0编辑  收藏  举报