React16 高级教程(七)
原文:Pro React 16
十四、编写应用
在这一章中,我描述了组合组件来创建复杂特性的不同方法。这些组合模式可以一起使用,您会发现大多数问题都可以用几种方法来解决,这让您可以自由地应用您最喜欢的方法。表 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组件呈现的内容显示Message和ActionButton组件,并对它们进行配置,以便单击按钮将更新counter状态数据值,该值已作为属性传递给Message组件。
使用命令提示符,运行composition文件夹中清单 14-7 所示的命令来启动开发工具。
npm start
Listing 14-7Starting the Development Tools
一旦项目的初始准备工作完成,一个新的浏览器窗口将打开并显示 URL http://localhost:3000并显示如图 14-1 所示的内容。
图 14-1
运行示例应用
了解基本组件关系
示例项目中的组件很简单,但它们说明了支撑 React 开发的基本关系:父组件用数据属性配置子组件,并通过函数属性接收通知,这导致状态数据变化并触发更新过程,如图 14-2 所示。
图 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组件提供内容。在这种情况下,元素应用了Message和ActionButton组件。当 React 处理由App组件呈现的内容时,ThemeSelector标记之间的内容被赋给props.children属性,产生如图 14-3 所示的结果。
图 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 控制台中报告一个警告。
结果是传递给Message和ActionButton组件的属性是由App组件定义的属性和由ThemeSelector组件使用cloneElement方法添加的属性的组合。当您从select元素中选择一个主题时,一个更新被执行,并且所选择的主题被应用到Message和ActionButton组件,如图 14-4 所示。
图 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 所示。
图 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 所示。
图 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 所示。
图 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 的效果,它为每个受保护的组件提供了自己的复选框。
图 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 所示。
图 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 所示。
图 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 所示。
图 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 所示。
图 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上下文属性用于设置className和disabled属性的值。
创建上下文提供程序
最后一步是创建一个上下文提供者,它将源状态数据与上下文相关联,如清单 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 所示。
图 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元素,这样ProModeToggle和SortedList组件都在范围内。这是可选的,我可以给每个子组件自己的上下文元素,只要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 跟踪每个对象的提供者和消费者。
图 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
一个名为contextType的static属性被分配了上下文,该上下文作为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 所示的响应。该消息在开发过程中显示,但在为部署构建应用时被禁用,如第八章所示。单击浏览器窗口右上角的关闭图标,您将看到应用的所有组件都已卸载,留下一个空的浏览器窗口。
图 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 所示。
图 14-16
误差边界的影响
摘要
在这一章中,我描述了组合组件来组成应用的不同方式,包括容器、高阶组件和渲染属性。我还向您展示了如何使用上下文来分发全局数据和避免适当的线程,以及如何使用错误边界来处理组件生命周期方法中的问题。在下一章中,我将描述 React 提供的处理表单的特性。
十五、表单和验证
表单允许应用从用户那里收集数据。在本章中,我将解释 React 如何处理表单元素,使用状态属性来设置它们的值,使用事件处理程序来响应用户交互。我将向您展示如何使用不同的元素类型,以及如何验证用户在表单中提供的数据,以便应用接收它可以使用的数据。表 15-1 将表单和验证放在上下文中。
表 15-1
将表单和验证放在上下文中
|问题
|
回答
|
| --- | --- |
| 它们是什么? | 表单是允许应用提示用户输入数据的基本机制。验证是检查数据以确保应用可以使用该数据的过程。 |
| 它们为什么有用? | 大多数应用都需要用户输入某种程度的数据,比如电子邮件地址、付款细节或送货地址。表单允许用户以自由文本形式或从一系列预定义选项中选择来输入数据。验证用于确保数据的格式符合应用的预期并且可以处理。 |
| 它们是如何使用的? | 在这一章中,我描述了被控制的表单元素,它们的值是使用value或checked属性来设置的,它们的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 所示的内容。
图 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 中看到空元素的例子。不要使用null或undefined,因为这些值会导致 React 在浏览器的 JavaScript 控制台中生成警告。
注意,在状态数据更新后,我使用了由setState方法提供的回调选项来调用submit函数 prop,这允许我将表单数据发送到父组件。这意味着Editor组件的状态数据的任何变化也会被推送到App组件,这样它就可以被Display组件显示出来,结果是输入到input元素中的数据会立即反映在呈现给用户的内容中,如图 15-2 所示。这看起来像是不必要的状态数据复制,但是它将允许我在本章后面更容易地实现验证特性。
图 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属性访问的,其中所选择的项目有一个值为true的selected属性。在清单中,我从选项中创建了一个数组,使用filter方法获取选择的项目,使用map方法获取value属性,这留下了一个数组,其中包含每个选择的option元素的value属性的值。两个select元件都可以在图 15-3 中看到。(在您做出更改之前,Display组件不会显示数据。)
图 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 所示。
图 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 所示。
图 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 所示的结果。
图 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 所示的结果。
图 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 所示。在您开始编辑字段或单击按钮之前,不会显示任何验证消息,并且在您输入的数据满足所有验证要求之前,您不能提交数据。
图 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 所示。
图 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
结果是只有当email和emailConfirm字段的内容相同时,该表单才有效,如图 15-10 所示。
图 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 所示。
图 15-11
执行整体表单验证
摘要
在本章中,我向您展示了如何创建受控组件,它们是表单元素,其内容通过状态属性管理,其编辑由事件处理程序处理。我向您展示了不同类型的表单元素,并演示了如何验证表单数据。受控表单组件只是 React 支持的一种类型,在下一章中,我将介绍 refs 特性,并解释如何使用非受控表单元素。