- 原文地址:React.js Forms: Controlled Components
- 原文作者:Loren Stewart
- 译者:小 B0Y
- 校对者:珂珂君
本文涵盖以下受控组件:
- 文本输入框
- 数字输入框
- 单选框
- 复选框
- 文本域
- 下拉选择框
同时也包含:
- 表单数据的清除和重置
- 表单数据的提交
- 表单校验
点击这里直接查看示例代码。
查看示例。
请在运行示例时打开浏览器的控制台。
介绍
在学习 React.js 时我遇到了一个问题,那就是很难找到受控组件的真实示例。受控文本输入框的例子倒是很丰富,但复选框、单选框、下拉选择框的例子却不尽人意。
本文列举了真实的受控表单组件示例,要是我在学习 React 的时候早点发现这些示例就好了。除了日期和时间输入框需要另开篇幅详细讨论,文中列举了所有的表单元素。
有时候,为了减少开发时间,有时候人们很容易为了一些东西(譬如表单元素)引入一个库。而对于表单,我发现当需要添加自定义行为或表单校验时,使用库会让事情变得更复杂。不过一旦掌握合适的 React 模式,你会发现构建表单组件并非难事,并且有些东西完全可以自己动手,丰衣足食。请把本文的示例代码当作你创建表单组件的起点或灵感之源。
除了提供单独的组件代码,我还将这些组件放进表单中,方便你理解子组件如何更新父组件 state ,以及接下来父组件如何通过 props(单向数据流)更新子组件。
注意:本表单示例由很赞的 create-react-app 构建配置生成,如果你还没有安装该构建配置,我强烈推荐你安装一下(npm install -g create-react-app
)。目前这是搭建 React 应用最简单的方式。
什么是受控组件?
受控组件有两个特点:
- 受控组件提供方法,让我们在每次
onChange
事件发生时控制它们的数据,而不是一次性地获取表单数据(例如用户点提交按钮时)。“被控制“ 的表单数据保存在 state 中(在本文示例中,是父组件或容器组件的 state)。
(译注:这里作者的意思是通过受控组件, 可以跟踪用户操作表单时的数据,从而更新容器组件的 state ,再单向渲染表单元素 UI。如果不使用受控组件,在用户实时操作表单时,比如在输入框输入文本时,不会同步到容器组件的 state,虽然能同步输入框本身的 value,但与容器组件的 state 无关,因此容器组件只能在某一时间,比如提表单时一次性地拿到(通过 refs 或者选择器)表单数据,而难以跟踪) - 受控组件的展示数据是其父组件通过 props 传递下来的。
这个单向循环 —— (数据)从(1)子组件输入到(2)父组件的 state,接着(3)通过 props 回到子组件,就是 React.js 应用架构中单向数据流的含义。
表单结构
我们的顶级组件叫做 App
,这是它的代码:
import React, { Component } from 'react';
import '../node_modules/spectre.css/dist/spectre.min.css';
import './styles.css';
import FormContainer from './containers/FormContainer';
class App extends Component {
render() {
return (
<div className="container">
<div className="columns">
<div className="col-md-9 centered">
<h3>React.js Controlled Form Componentsh3>
<FormContainer />
div>
div>
div>
);
}
}
export default App;复制代码
App
只负责渲染 index.html
页面。整个 App
组件最有趣的部分是 13 行,FormContainer
组件。
插曲: 容器(智能)组件 VS 普通(木偶)组件
是时候提及一下容器(智能)组件和普通(木偶)组件了。容器组件包含业务逻辑,它会发起数据请求或进行其他业务操作。普通组件则从它的父(容器)组件接收数据。木偶组件有可能触发更新 state (译注:容器组件的 state)这类逻辑行为,但它仅通过从父(容器)组件传入的方法来达到该目的。
注意: 虽然在我们的表单应用里父组件就是容器组件,但我要强调,并非所有的父组件都是容器组件。木偶组件嵌套木偶组件也是可以的。
回到应用结构
FormContainer
组件包含了表单元素组件,它在生命周期钩子方法 componentDidMount
里请求数据,此外还包含更新表单应用 state 的逻辑行为。在下面的预览代码里,我移除了表单元素的 props 和 change 事件处理方法,这样看起来更简洁清晰(拉到文章底部,可以看到完整代码)。
import React, {Component} from 'react';
import CheckboxOrRadioGroup from '../components/CheckboxOrRadioGroup';
import SingleInput from '../components/SingleInput';
import TextArea from '../components/TextArea';
import Select from '../components/Select';
class FormContainer extends Component {
constructor(props) {
super(props);
this.handleFormSubmit = this.handleFormSubmit.bind(this);
this.handleClearForm = this.handleClearForm.bind(this);
}
componentDidMount() {
fetch('./fake_db.json')
.then(res => res.json())
.then(data => {
this.setState({
ownerName: data.ownerName,
petSelections: data.petSelections,
selectedPets: data.selectedPets,
ageOptions: data.ageOptions,
ownerAgeRangeSelection: data.ownerAgeRangeSelection,
siblingOptions: data.siblingOptions,
siblingSelection: data.siblingSelection,
currentPetCount: data.currentPetCount,
description: data.description
});
});
}
handleFormSubmit() {
// 提交逻辑写在这
}
handleClearForm() {
// 清除表单逻辑写在这
}
render() {
return (
<form className="container" onSubmit={this.handleFormSubmit}>
<h5>Pet Adoption Formh5>
<SingleInput /> {/* Full name text input */}
<Select /> {/* Owner age range select */}
<CheckboxOrRadioGroup /> {/* Pet type checkboxes */}
<CheckboxOrRadioGroup /> {/* Will you adopt siblings? radios */}
<SingleInput /> {/* Number of current pets number input */}
<TextArea /> {/* Descriptions of current pets textarea */}
<input
type="submit"
className="btn btn-primary float-right"
value="Submit"/>
<button
className="btn btn-link float-left"
onClick={this.handleClearForm}>Clear formbutton>
form>
);
}复制代码
我们勾勒出了应用基础结构,接下来我们一起浏览下每个子组件的细节。
组件
该组件可以是 text
或 number
输入框,这取决于传入的 props。通过 React 的 PropTypes,我们可以非常好地记录组件拿到的 props。如果漏传 props 或传入错误的数据类型, 浏览器的控制台中会出现警告信息。
下面列举
组件的 PropTypes:
SingleInput.propTypes = {
inputType: React.PropTypes.oneOf(['text', 'number']).isRequired,
title: React.PropTypes.string.isRequired,
name: React.PropTypes.string.isRequired,
controlFunc: React.PropTypes.func.isRequired,
content: React.PropTypes.oneOfType([
React.PropTypes.string,
React.PropTypes.number,
]).isRequired,
placeholder: React.PropTypes.string,
};复制代码
PropTypes 声明了 prop 的类型(string、 number、 array、 object 等等),其中包括了必需(isRequired
)和非必需的 prop,当然它还有更多的用途(欲知更多细节,请查看 React 文档)。
下面我们逐个讨论这些 PropType:
inputType
:接收两个字符串:'text'
或'number'
。该设置指定渲染组件或
组件。
title
:接收一个字符串,我们将它渲染到输入框的 label 元素中。name
:输入框的 name 属性。controlFunc
:它是从父组件或容器组件传下来的方法。因为该方法挂载在 React 的 onChange 处理方法上,所以每当输入框的输入值改变时,该方法都会被执行,从而更新父组件或容器组件的 state。content
:输入框内容。受控输入框只会显示通过 props 传入的数据。placeholder
:输入框的占位符文本,是一个字符串。
既然该组件不需要任何逻辑行为和内部 state,那我们可以将它写成纯函数组件(pure functional component)。我们将纯函数组件赋值给一个 const
常量上。下面是
组件的所有代码。本文列举的所有表单元素组件都是纯函数组件。
import React from 'react';
const SingleInput = (props) => (
<div className="form-group">
<label className="form-label">{props.title}label>
<input
className="form-input"
name={props.name}
type={props.inputType}
value={props.content}
onChange={props.controlFunc}
placeholder={props.placeholder} />
div>
);
SingleInput.propTypes = {
inputType: React.PropTypes.oneOf(['text', 'number']).isRequired,
title: React.PropTypes.string.isRequired,
name: React.PropTypes.string.isRequired,
controlFunc: React.PropTypes.func.isRequired,
content: React.PropTypes.oneOfType([
React.PropTypes.string,
React.PropTypes.number,
]).isRequired,
placeholder: React.PropTypes.string,
};
export default SingleInput;复制代码
接着,我们用 handleFullNameChange
方法(它被传入到 controlFunc
prop 属性)来更新
容器组件的 state。
// FormContainer.js
handleFullNameChange(e) {
this.setState({ ownerName: e.target.value });
}
// constructor 方法里别漏掉了这行:
// this.handleFullNameChange = this.handleFullNameChange.bind(this);复制代码
随后我们将容器组件更新后的 state (译注:这里指 state 上挂载的 ownerName 属性)通过 content
prop 传回
组件。
组件
选择组件(就是下拉选择组件),接收以下 props:
Select.propTypes = {
name: React.PropTypes.string.isRequired,
options: React.PropTypes.array.isRequired,
selectedOption: React.PropTypes.string,
controlFunc: React.PropTypes.func.isRequired,
placeholder: React.PropTypes.string
};复制代码
name
:填充表单元素上name
属性的字符串变量。options
:是一个数组(本例是字符串数组)。通过在组件的 render 方法中使用props.options.map()
, 该数组中的每一项都会被渲染成一个选择项。selectedOption
:用以显示表单填充的默认选项,或用户已选择的选项(例如当用户编辑之前已提交过的表单数据时,可以使用这个 prop)。controlFunc
:它是从父组件或容器组件传下来的方法。因为该方法挂载在 React 的 onChange 处理方法上,所以每当改变选择框组件的值时,该方法都会被执行,从而更新父组件或容器组件的 state。placeholder
:作为占位文本的字符串,用来填充第一个标签。本组件中,我们将第一个选项的值设置成空字符串(参看下面代码的第 10 行)。
import React from 'react';
const Select = (props) => (
<div className="form-group">
<select
name={props.name}
value={props.selectedOption}
onChange={props.controlFunc}
className="form-select">
<option value="">{props.placeholder}option>
{props.options.map(opt => {
return (
<option
key={opt}
value={opt}>{opt}option>
);
})}
select>
div>
);
Select.propTypes = {
name: React.PropTypes.string.isRequired,
options: React.PropTypes.array.isRequired,
selectedOption: React.PropTypes.string,
controlFunc: React.PropTypes.func.isRequired,
placeholder: React.PropTypes.string
};
export default Select;复制代码
请注意 option 标签中的 key
属性(第 14 行)。React 要求被重复操作渲染的每个元素必须拥有独一无二的 key
值,我们这里的 .map()
方法就是所谓的重复操作。既然选择项数组中的每个元素是独有的,我们就把它们当成 key
prop。该 key
值协助 React 追踪 DOM 变化。虽然在循环操作或 mapping 时忘加 key
属性不会中断应用,但是浏览器的控制台里会出现警告,并且渲染性能将受到影响。
以下是控制选择框组件(记住,该组件存在于
组件中)的处理方法(该方法从
组件传入到子组件的 controlFun
prop 中)
// FormContainer.js
handleAgeRangeSelect(e) {
this.setState({ ownerAgeRangeSelection: e.target.value });
}
// constructor 方法里别漏掉了这行:
// this.handleAgeRangeSelect = this.handleAgeRangeSelect.bind(this);复制代码
组件
与众不同, 它从 props 拿到传入的数组(像此前 组件的选项数组一样),通过遍历数组来渲染一组表单元素的集合 —— 可以是复选框集合或单选框集合。
让我们深入 PropTypes 来更好地理解
组件。
CheckboxGroup.propTypes = {
title: React.PropTypes.string.isRequired,
type: React.PropTypes.oneOf(['checkbox', 'radio']).isRequired,
setName: React.PropTypes.string.isRequired,
options: React.PropTypes.array.isRequired,
selectedOptions: React.PropTypes.array,
controlFunc: React.PropTypes.func.isRequired
};复制代码
title
:一个字符串,用以填充单选或复选框集合的 label 标签内容。type
:接收'checkbox'
或'radio'
两种配置的一种,并用指定的配置渲染输入框(译注:这里指复选输入框或单选输入框)。setName
:一个字符串,用以填充每个单选或复选框的name
属性值。options
:一个由字符串元素组成的数组,数组元素用以渲染每个单选框或复选框的值和 label 的内容。例如,['dog', 'cat', 'pony']
数组中的元素将会渲染三个单选框或复选框。selectedOptions
:一个由字符串元素组成的数组,用来表示预选项。在示例 4 中,如果selectedOptions
数组包含'dog'
和'pony'
元素,那么相应的两个选项会被渲染成选中状态,而'cat'
选项则被渲染成未选中状态。当用户提交表单时,该数组将会是用户的选择数据。controlFunc
:一个方法,用来处理从selectedOptions
数组 prop 中添加或删除字符串的操作。
这是本表单应用中最有趣的组件,让我们来看一下:
import React from 'react';
const CheckboxOrRadioGroup = (props) => (
<div>
<label className="form-label">{props.title}label>
<div className="checkbox-group">
{props.options.map(opt => {
return (
<label key={opt} className="form-label capitalize">
<input
className="form-checkbox"
name={props.setName}
onChange={props.controlFunc}
value={opt}
checked={ props.selectedOptions.indexOf(opt) > -1 }
type={props.type} /> {opt}
label>
);
})}
div>
div>
);
CheckboxOrRadioGroup.propTypes = {
title: React.PropTypes.string.isRequired,
type: React.PropTypes.oneOf(['checkbox', 'radio']).isRequired,
setName: React.PropTypes.string.isRequired,
options: React.PropTypes.array.isRequired,
selectedOptions: React.PropTypes.array,
controlFunc: React.PropTypes.func.isRequired
};
export default CheckboxOrRadioGroup;复制代码
checked={ props.selectedOptions.indexOf(option) > -1 }
这一行代码表示单选框或复选框是否被选中的逻辑。
属性 checked
接收一个布尔值,用来表示 input 组件是否应该被渲染成选中状态。我们在检查到 input 的值是否是 props.selectedOptions
数组的元素之一时生成该布尔值。myArray.indexOf(item)
方法返回 item 在数组中的索引值。如果 item 不在数组中,返回 -1
,因此,我们写了 > -1
。
注意,0
是一个合法的索引值,所以我们需要 > -1
,否则代码会有 bug。如果没有 > -1
,selectedOptions
数组中的第一个 item —— 其索引为 0 —— 将永远不会被渲染成选中状态,因为 0
是一个类 false
的值(译注:在 checked
属性中,0
会被当成 false
处理)。
本组件的处理方法同样比其他的有趣。
handlePetSelection(e) {
const newSelection = e.target.value;
let newSelectionArray;
if(this.state.selectedPets.indexOf(newSelection) > -1) {
newSelectionArray = this.state.selectedPets.filter(s => s !== newSelection)
} else {
newSelectionArray = [...this.state.selectedPets, newSelection];
}
this.setState({ selectedPets: newSelectionArray });
}复制代码
如同所有处理方法一样,事件对象被传入方法,这样一来我们就能拿到事件对象的值(译注:准确来说,应该是事件目标元素的值)。我们将该值赋给newSelection
常量。接着我们在函数顶部附近定义 newSelectionArray
变量。因为我们将在一个 if/else
代码块里对该变量进行赋值,所以用 let
而非 const
来定义它。我们在代码块外部进行定义,这样一来被定义变量的作用域就是函数内部的最外沿,并且函数内的代码块都能访问到外部定义的变量。
该方法需要处理两种可能的情况。
如果 input 组件的值不在 selectedOptions
数组中,我们要将值添加进该数组。
如果 input 组件的值在 selectedOptions
数组中,我们要从数组中删除该值。
添加(第 8 - 10 行):
为了将新值添加进选项数组,我们通过解构旧数组(数组前的三点...
表示解构)创建一个新数组,并且将新值添加到数组的尾部 newSelectionArray = [...this.state.selectedPets, newSelection];
。
注意,我们创建了一个新数组,而不是通过类似 .push()
的方法来改变原数组。不改变已存在的对象和数组,而是创建新的对象和数组,这在 React 中是又一个最佳实践。开发者这样做可以更容易地跟踪 state 的变化,而第三方 state 管理库,如 Redux 则可以做高性能的浅比较,而不是阻塞性能的深比较。
删除(第 6 - 8 行):if
代码块借助此前用到的 .indexOf()
小技巧,检查选项是否在数组中。如果选项已经在数组中,通过.filter()
方法,该选项将被移除。 该方法返回一个包含所有满足 filter 条件的元素的新数组(记住要避免在 React 直接修改数组或对象!)。
newSelectionArray = this.state.selectedPets.filter(s => s !== newSelection)复制代码
在这种情况下,除了传入到方法中的选项之外,其他选项都会被返回。
组件
和我们已提到的那些组件非常相似,除了
resize
和 rows
,目前你应该对它的 props 很熟悉了。
TextArea.propTypes = {
title: React.PropTypes.string.isRequired,
rows: React.PropTypes.number.isRequired,
name: React.PropTypes.string.isRequired,
content: React.PropTypes.string.isRequired,
resize: React.PropTypes.bool,
placeholder: React.PropTypes.string,
controlFunc: React.PropTypes.func.isRequired
};复制代码
title
:接收一个字符串,用以渲染文本域的 label 标签内容。rows
:接收一个整数,用来指定文本域的行数。name
:文本域的 name 属性。content
:文本域的内容。受控组件只会显示通过 props 传入的数据。resize
: 接受一个布尔值,用来指定文本域能否调整大小。placeholder
:充当文本域占位文本的字符串。controlFunc
: 它是从父组件或容器组件传下来的方法。因为该方法挂载在 React 的 onChange 处理方法上,所以每当改变选择框组件的值时,该方法都会被执行,从而更新父组件或容器组件的 state。
组件的完整代码:
import React from 'react';
const TextArea = (props) => (
<div className="form-group">
<label className="form-label">{props.title}label>
<textarea
className="form-input"
style={props.resize ? null : {resize: 'none'}}
name={props.name}
rows={props.rows}
value={props.content}
onChange={props.controlFunc}
placeholder={props.placeholder} />
div>
);
TextArea.propTypes = {
title: React.PropTypes.string.isRequired,
rows: React.PropTypes.number.isRequired,
name: React.PropTypes.string.isRequired,
content: React.PropTypes.string.isRequired,
resize: React.PropTypes.bool,
placeholder: React.PropTypes.string,
controlFunc: React.PropTypes.func.isRequired
};
export default TextArea;复制代码
组件的控制方法和
如出一辙。细节部分请参考
组件。
表单操作
handleClearForm
和 handleFormSubmit
方法操作整个表单。
1. handleClearForm
既然我们在表单的各处都使用了单向数据流,那么清除表单数据对我们来说也是小菜一碟。
组件的 state 控制了每个表单元素的值。该容器的 state 通过 props 传入子组件。只有当
组件的 state 改变时,表单组件显示的值才会改变。
清除表单子组件中显示的数据很简单,只要把容器的 state (译注:这里是指 state 对象上挂载的各个变量)设置成空数组和空字符串就可以了(如果有数字输入框的话则是将值设置成 0
)。
handleClearForm(e) {
e.preventDefault();
this.setState({
ownerName: '',
selectedPets: [],
ownerAgeRangeSelection: '',
siblingSelection: [],
currentPetCount: 0,
description: ''
});
}复制代码
注意,e.preventDefault()
阻止了页面重新加载,接着 setState()
方法用来清除表单数据。
2. handleFormSubmit
为了提交表单数据,我们从 state 中抽取需要提交的属性值,创建了一个对象。接着使用 AJAX 库或技术将这些数据发送给 API(本文不包含此类内容)。
handleFormSubmit(e) {
e.preventDefault();
const formPayload = {
ownerName: this.state.ownerName,
selectedPets: this.state.selectedPets,
ownerAgeRangeSelection: this.state.ownerAgeRangeSelection,
siblingSelection: this.state.siblingSelection,
currentPetCount: this.state.currentPetCount,
description: this.state.description
};
console.log('Send this in a POST request:', formPayload);
this.handleClearForm(e);
}复制代码
请注意我们在提交数据后执行 this.handleClearForm(e)
清除了表单。
表单校验
受控表单组件非常适合自定义表单校验。假设要从 组件中排除字母 "e",可以这样做:
handleDescriptionChange(e) {
const textArray = e.target.value.split('').filter(x => x !== 'e');
console.log('string split into array of letters',textArray);
const filteredText = textArray.join('');
this.setState({ description: filteredText });
}复制代码
把 e.target.value
字符串分割成字母数组,就生成了上述的 textArray
。这样字母 “e” (或其他设法排除的字母)就被过滤掉了。再把剩余的字母组成的数组拼成字符串,最后用该新字符串去设置组件 state。还不错吧?
以上代码放在本文的仓库中,但我将它们注释掉了,你可以按自己的需求自由地调整。
组件
下面是我承诺给你们的
组件完整代码,
import React, {Component} from 'react';
import CheckboxOrRadioGroup from '../components/CheckboxOrRadioGroup';
import SingleInput from '../components/SingleInput';
import TextArea from '../components/TextArea';
import Select from '../components/Select';
class FormContainer extends Component {
constructor(props) {
super(props);
this.state = {
ownerName: '',
petSelections: [],
selectedPets: [],
ageOptions: [],
ownerAgeRangeSelection: '',
siblingOptions: [],
siblingSelection: [],
currentPetCount: 0,
description: ''
};
this.handleFormSubmit = this.handleFormSubmit.bind(this);
this.handleClearForm = this.handleClearForm.bind(this);
this.handleFullNameChange = this.handleFullNameChange.bind(this);
this.handleCurrentPetCountChange = this.handleCurrentPetCountChange.bind(this);
this.handleAgeRangeSelect = this.handleAgeRangeSelect.bind(this);
this.handlePetSelection = this.handlePetSelection.bind(this);
this.handleSiblingsSelection = this.handleSiblingsSelection.bind(this);
this.handleDescriptionChange = this.handleDescriptionChange.bind(this);
}
componentDidMount() {
// 模拟请求用户数据
//(create-react-app 构建配置里包含了 fetch 的 polyfill)
fetch('./fake_db.json')
.then(res => res.json())
.then(data => {
this.setState({
ownerName: data.ownerName,
petSelections: data.petSelections,
selectedPets: data.selectedPets,
ageOptions: data.ageOptions,
ownerAgeRangeSelection: data.ownerAgeRangeSelection,
siblingOptions: data.siblingOptions,
siblingSelection: data.siblingSelection,
currentPetCount: data.currentPetCount,
description: data.description
});
});
}
handleFullNameChange(e) {
this.setState({ ownerName: e.target.value });
}
handleCurrentPetCountChange(e) {
this.setState({ currentPetCount: e.target.value });
}
handleAgeRangeSelect(e) {
this.setState({ ownerAgeRangeSelection: e.target.value });
}
handlePetSelection(e) {
const newSelection = e.target.value;
let newSelectionArray;
if(this.state.selectedPets.indexOf(newSelection) > -1) {
newSelectionArray = this.state.selectedPets.filter(s => s !== newSelection)
} else {
newSelectionArray = [...this.state.selectedPets, newSelection];
}
this.setState({ selectedPets: newSelectionArray });
}
handleSiblingsSelection(e) {
this.setState({ siblingSelection: [e.target.value] });
}
handleDescriptionChange(e) {
this.setState({ description: e.target.value });
}
handleClearForm(e) {
e.preventDefault();
this.setState({
ownerName: '',
selectedPets: [],
ownerAgeRangeSelection: '',
siblingSelection: [],
currentPetCount: 0,
description: ''
});
}
handleFormSubmit(e) {
e.preventDefault();
const formPayload = {
ownerName: this.state.ownerName,
selectedPets: this.state.selectedPets,
ownerAgeRangeSelection: this.state.ownerAgeRangeSelection,
siblingSelection: this.state.siblingSelection,
currentPetCount: this.state.currentPetCount,
description: this.state.description
};
console.log('Send this in a POST request:', formPayload)
this.handleClearForm(e);
}
render() {
return (
);
}
}
export default FormContainer;复制代码
总结
我承认用 React 构建受控表单组件要做一些重复劳动(比如容器组件中的处理方法),但就你对应用的掌控度和 state 变更的透明度来说,预先投入精力是超值的。你的代码会变得可维护并且很高效。
如果想在我发布新文章时接到通知,你可以在博客的导航栏部分注册我的邮件发送清单。
iKcamp原创新书《移动Web前端高效开发实战》已在亚马逊、京东、当当开售。