原文链接:Data binding in React: how to work with forms in React (joshwcomeau.com)
如果你在 React 中有一些状态,并且你想将其与表单字段同步。你该怎么做?
这取决于表单控件的类型:文本输入、选择框、复选框和单选按钮都有一些不同的工作方式。
好消息是,尽管细节各不相同,但它们都共享相同的基本机制。在 React 中,数据绑定方面有一致的哲学。
在本教程中,我们首先将学习 React 如何处理数据绑定,然后我将逐一向您展示每个表单字段的工作原理。我们将查看完整的实际示例。
Introduction to controlled fields 受控字段简介
假设我们渲染一个 <input>
:
jsx
function App() {
return (
<input />
);
}
默认情况下,React采取了一种非常"不干涉"的方式。它为我们创建了 DOM 节点,然后就不再干涉了。这被称为非受控元素,因为 React 不主动管理它。
然而,作为替代,我们可以选择让 React 来管理表单字段。对于文本输入,我们可以使用 value 属性进行选择:
jsx
import React from 'react';
function App() {
return (
<input value="Hello World" />
);
}
export default App;
尝试编辑输入框中的文本。它不起作用!
这被称为受控元素。React 会保持警惕,确保输入始终显示字符串"Hello World"。
现在,将 value 锁定为静态字符串并没有太大用处!我在这里这样做纯粹是为了说明受控元素的工作原理:React"锁定"输入,使其始终包含我们传入的 value 。
真正的魔力在于传递动态值。让我们看另一个例子:
jsx
import React from 'react';
function App() {
const [count, setCount] = React.useState(0);
return (
<>
<input value={count} />
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</>
);
}
export default App;
试着点击"增加"按钮,注意一下文本输入框会发生什么变化。😮
我们没有将输入框绑定到一个静态字符串,而是绑定到一个状态变量 count 。当我们点击"增加"按钮时,这个状态变量从 0 变为 1 。React 重新渲染这个组件,并更新 <input>
中的值以反映这个新的状态。
不过,我们仍然无法在文本输入框中输入文字!React 将输入框锁定在 count 状态变量的值上。
在数据绑定术语中,这被称为"单向"数据绑定。当状态改变时,输入会更新,但当输入被编辑时,状态不会更新。
为了完成循环,我们需要双向数据绑定。以下是我们实现它的方法:
jsx
import React from 'react';
function App() {
const [state, setState] = React.useState('Hello World');
return (
<>
<input
value={state}
onChange={(event) => {
setState(event.target.value);
}}
/>
<p>
<strong>Current value:</strong>
{state}
</p>
</>
);
}
export default App;
我们使用 onChange 属性附加一个事件监听器。当用户编辑文本输入时,该函数被调用,并传入 event 对象。
event.target 是指触发事件的 DOM 节点:在这种情况下,它是文本输入框。该文本输入框具有 value 属性,它表示用户刚刚尝试输入到输入框中的值。
我们更新 React 状态,使其保存这个新值。React 重新渲染,并将该新值推送到输入框中。循环完成!
这是 React 中数据绑定的基本思想。两个要素是:
- 一个"受控"字段,将输入框锁定到 React 状态的一部分。
- 当用户编辑输入时,一个处理程序会更新状态变量。
有了这个连接,我们就有了正确的双向数据绑定。
React 的核心理念之一是 UI 是由状态派生的。当状态发生变化时,UI 会重新绘制以匹配新的状态。受控元素是这个理念的自然延伸。例如,通过为文本输入指定一个 value ,我们就是在说输入框的内容也是由 React 状态派生的。
好的,让我们来看看这个模式如何应用于不同的输入类型。
Text inputs 文本输入
这是一个更完整的示例,将文本输入绑定到 React 状态:
jsx
import React from 'react';
function App() {
const [name, setName] = React.useState('');
return (
<>
<form>
<label htmlFor="name-field">
Name:
</label>
<input
id="name-field"
value={name}
onChange={event => {
setName(event.target.value);
}}
/>
</form>
<p>
<strong>Current value:</strong>
{name || '(empty)'}
</p>
</>
);
}
export default App;
这里的两个关键属性是 value 和 onChange :
- value 将输入锁定,强制始终显示我们状态变量的当前值。
- onChange 在用户编辑输入时触发,并更新状态。
我还提供了一个 id 。这不是数据绑定所必需的,但它是一个重要的可用性和可访问性要求。ID 需要是全局唯一的;稍后,我们将学习如何使用新的 React 钩子自动生成它们。
Text input variants 文本输入变体
除了普通文本输入外,我们还可以选择不同的"格式化"文本输入,用于电子邮件地址、电话号码和密码等内容。
好消息是:就数据绑定而言,所有这些变体都以相同的方式工作。
例如,这是我们如何绑定一个 password 输入:
jsx
const [secret, setSecret] = React.useState('');
<input
type="password"
value={secret}
onChange={(event) => {
setSecret(event.target.value);
}}
/>
除了文本输入变体之外, <input>
标签还可以变形成完全独立的表单控件。
在本博客文章的后面,我们将讨论单选按钮、复选框以及滑块和颜色选择器等特殊输入方式。
Gotchas 注意事项
在处理文本输入时,请确保将空字符串( '' )作为初始状态:
jsx
// 🚫 Incorrect:
const [name, setName] = React.useState();
// ✅ Correct:
const [name, setName] = React.useState('');
Textareas 文本区域
在 React 中, <textarea>
元素的工作方式与文本输入框完全相同。我们使用相同的组合 value + onChange :
jsx
import React from 'react';
function App() {
const [comment, setComment] = React.useState('');
return (
<>
<form>
<label htmlFor="comment-field">
Share your experiences:
</label>
<textarea
id="comment-field"
value={comment}
onChange={event => {
setComment(
event.target.value
);
}}
/>
</form>
<p>
<strong>Current value:</strong>
{comment || '(empty)'}
</p>
</>
);
}
export default App;
Gotchas 注意事项
与输入一样,确保将空字符串( '' )作为状态变量的初始值
jsx
// 🚫 Incorrect:
const [comment, setComment] = React.useState();
// ✅ Correct:
const [comment, setComment] = React.useState('');
Radio buttons 单选按钮
当涉及到单选按钮时,情况有些不同!
让我们从一个例子开始:
jsx
import React from 'react';
function App() {
const [hasAgreed, setHasAgreed] = React.useState();
return (
<>
<form>
<fieldset>
<legend>
Do you agree?
</legend>
<input
type="radio"
name="agreed-to-terms"
id="agree-yes"
value="yes"
checked={hasAgreed === "yes"}
onChange={event => {
setHasAgreed(event.target.value)
}}
/>
<label htmlFor="agree-yes">
Yes
</label>
<br />
<input
type="radio"
name="agreed-to-terms"
id="agree-no"
value="no"
checked={hasAgreed === "no"}
onChange={event => {
setHasAgreed(event.target.value)
}}
/>
<label htmlFor="agree-no">
No
</label>
</fieldset>
</form>
<p>
<strong>Has agreed:</strong>
{hasAgreed || "undefined"}
</p>
</>
);
}
export default App;
首先,我想解释一下我们的"受控字段"策略在这里的应用。
使用文本输入时,我们的状态和表单控件之间存在一对一的关系。一个状态只绑定一个 <input>
标签。
而对于单选按钮,多个输入绑定到一个状态上!这是一对多的关系。正因为这个区别,所以界面看起来如此不同。
在上面的示例中,我们的状态始终等于三个可能的值之一:
- undefined (未选择选项)
- "yes" (第一个单选按钮的 value )
- "no" (第二个单选按钮的 value )
我们的状态变量跟踪的是哪个选项被选中,而不是追踪特定输入的值。
我们可以在 onChange 处理程序中看到这一点
jsx
<input
value="yes"
onChange={(event) => {
setHasAgreed(event.target.value);
// Equivalent to: setHasAgreed("yes")
}}
/>
当用户勾选此特定输入(代表"yes"选项)时,我们将该"yes"值复制到状态中。
为了实现真正的双向数据绑定,我们需要将其变为受控输入。在 React 中,单选按钮通过 checked 属性进行控制:
jsx
<input
value="yes"
checked={hasAgreed === "yes"}
/>
通过为 checked 指定一个布尔值,React 将主动管理此单选按钮,根据 hasAgreed === "yes" 表达式来选中或取消选中 DOM 节点。
很不幸的是,文本输入和单选按钮在建立受控输入方面依赖不同的属性( value vs. checked )。这导致了很多混淆。
但是当我们考虑到 React 实际上控制的是什么时,这种做法有点合理:
- 对于文本输入,React 控制用户输入的自由文本(用 value 指定)。
- 对于单选按钮,React 控制用户是否选择了此特定选项(用 checked 指定)。
其他属性呢?下面是一个表格,显示每个属性的责任:
Attribute | Type | Explanation |
---|---|---|
id | string | 一个全局唯一的标识符,用于改善单选按钮的可访问性和可用性。 |
name | string | 将一组单选按钮组合在一起,以便一次只能选择一个。组内所有单选按钮的值必须相同。 |
value | string | 指定此单选按钮所代表的"事物"。如果选择了此特定选项,将捕获/存储此事物。 |
checked | boolean | 控制单选按钮是否被选中。通过传递一个布尔值,React 将使其成为"受控"输入。 |
onChange | function | 与其他表单控件一样,当用户更改所选选项时,将调用此函数。我们使用此函数来更新我们的状态。 |
Iterative example 迭代示例
由于单选按钮需要很多属性,通常最好使用迭代动态生成它们。这样,我们只需要编写一次所有的内容!
此外,在许多情况下,选项本身也是动态的(例如从后端 API 获取)。在这些情况下,我们需要使用迭代生成它们。
以下是示例:
jsx
import React from 'react';
function App() {
const [
language,
setLanguage
] = React.useState('english');
return (
<>
<form>
<fieldset>
<legend>
Select language:
</legend>
{VALID_LANGUAGES.map(option => (
<div key={option}>
<input
type="radio"
name="current-language"
id={option}
value={option}
checked={option === language}
onChange={event => {
setLanguage(event.target.value);
}}
/>
<label htmlFor={option}>
{option}
</label>
</div>
))}
</fieldset>
</form>
<p>
<strong>Selected language:</strong>
{language || "undefined"}
</p>
</>
);
}
const VALID_LANGUAGES = [
'mandarin',
'spanish',
'english',
'hindi',
'arabic',
'portugese',
];
export default App;
这可能看起来更加复杂一些,但最终,所有的属性都是以完全相同的方式使用的。
Checkboxes 复选框
复选框与单选按钮非常相似,但它们也有自己的复杂性。
我们的策略将取决于我们是在讨论单个复选框还是一组复选框。
让我们从一个基本的例子开始,只使用一个复选框:
jsx
import React from 'react';
function App() {
const [optIn, setOptIn] = React.useState(false);
return (
<>
<form>
<input
type="checkbox"
id="opt-in-checkbox"
checked={optIn}
onChange={event => {
setOptIn(event.target.checked);
}}
/>
<label htmlFor="opt-in-checkbox">
<strong>Yes,</strong> I would like to join the newsletter.
</label>
</form>
<p>
<strong>Opt in:</strong> {optIn.toString()}
</p>
</>
);
}
export default App;
与单选按钮一样,我们使用 checked 属性来指定这应该是一个受控输入。这使我们能够将复选框是否被选中与我们的 optIn 状态变量同步。当用户切换复选框时,我们使用熟悉的 onChange 模式更新 optIn 状态。
Checkbox groups 复选框组
当我们想要用 React 状态来控制多个复选框时,情况会变得更加复杂。
让我们来看一个例子。通过勾选不同的复选框并观察其对应的状态变化,看看你能否理解这里发生了什么:
jsx
import React from 'react';
const initialToppings = {
anchovies: false,
chicken: false,
tomatoes: false,
}
function App() {
const [
pizzaToppings,
setPizzaToppings
] = React.useState(initialToppings);
// Get a list of all toppings.
// ['anchovies', 'chicken', 'tomato'];
const toppingsList = Object.keys(initialToppings);
return (
<>
<form>
<fieldset>
<legend>
Select toppings:
</legend>
{/*
Iterate over those toppings, and
create a checkbox for each one:
*/}
{toppingsList.map(option => (
<div key={option}>
<input
type="checkbox"
id={option}
value={option}
checked={pizzaToppings[option] === true}
onChange={event => {
setPizzaToppings({
...pizzaToppings,
[option]: event.target.checked,
})
}}
/>
<label htmlFor={option}>
{option}
</label>
</div>
))}
</fieldset>
</form>
<p>
<strong>Stored state:</strong>
</p>
<p className="output">
{JSON.stringify(pizzaToppings, null, 2)}
</p>
</>
);
}
export default App;
就HTML属性而言,情况与我们迭代式单选按钮的方法相似...但是我们的React状态到底是怎么回事?为什么它是一个对象?!
与单选按钮不同,可以选择多个复选框。这在涉及我们的状态变量时会改变事情。
对于单选按钮,我们可以将需要知道的所有内容放入一个字符串中:所选选项的 value 。但是对于复选框,我们需要存储更多数据,因为用户可以选择多个选项。
有很多方法可以做到这一点。我最喜欢的方法是使用一个对象,为每个选项保存一个布尔值:
jsx
const initialToppings = {
anchovies: false,
chicken: false,
tomatoes: false,
}
在JSX中,我们遍历这个对象的键,并为每个键渲染一个复选框。在迭代过程中,我们查找这个特定选项是否被选中,并使用它来控制带有 checked 属性的复选框。
我们还将一个函数传递给 onChange ,该函数将翻转所讨论的复选框的值。由于 React 状态需要是不可变的,我们通过创建一个几乎相同的新对象来解决这个问题,其中所讨论的选项在 true/false 之间翻转。
下面是一个显示每个属性用途的表格:
Attribute | Type | Explanation |
---|---|---|
id | string | 用于改善可访问性和可用性的全局唯一标识符,用于此复选框。 |
value | string | 指定我们用这个复选框来勾选和取消勾选的"事物"。 |
checked | boolean | 控制复选框是否被选中。 |
onChange | function | 与其他表单控件类似,当用户勾选或取消勾选复选框时,将调用此函数。我们使用此函数来更新我们的状态。 |
我们也可以指定一个 name ,就像单选按钮一样,尽管在使用受控输入时这并不是严格必要的。
Select 选择
与单选按钮类似, <select>
标签允许用户从一组可能的值中选择一个选项。通常在选项太多以至于无法舒适地使用单选按钮时,我们会使用 <select>
。
这是一个示例,展示了如何将其绑定到状态变量:
jsx
import React from 'react';
function App() {
const [age, setAge] = React.useState('0-18');
return (
<>
<form>
<label htmlFor="age-select">
How old are you?
</label>
<select
id="age-select"
value={age}
onChange={event => {
setAge(event.target.value)
}}
>
<option value="0-18">
18 and under
</option>
<option value="19-39">
19 to 39
</option>
<option value="40-64">
40 to 64
</option>
<option value="65-infinity">
65 and over
</option>
</select>
</form>
<p>
<strong>Selected value:</strong>
{age}
</p>
</>
);
}
export default App;
在 React 中,<select>
标签与文本输入非常相似。我们使用相同的 value + onChange 组合。
如果你在原生 JS 中使用过 <select>
标签,这可能看起来有点疯狂。通常情况下,我们需要动态设置适当的 <option>
子元素上的 selected 属性。React 团队在 <select>
方面做了很多改进,去除了粗糙的边缘,让我们可以使用熟悉的 value + onChange 组合将这个表单字段绑定到一些 React 状态上。
话虽如此,我们仍然需要创建 <option>
个子项,并为每个子项指定适当的值。当用户选择不同的选项时,这些字符串将被设置到状态中。
Gotchas 注意事项
与文本输入类似,我们需要将状态初始化为有效值。这意味着我们的状态变量的初始值必须与选项之一匹配。
jsx
// This initial value:
const [age, setAge] = React.useState("0-18");
// Must match one of the options:
<select>
<option
value="0-18"
>
18 and under
</option>
</select>
我更喜欢动态生成 <option>
标签,使用一个统一的数据源:
jsx
import React from 'react';
// The source of truth!
const OPTIONS = [
{
label: '18 and under',
value: '0-18'
},
{
label: '19 to 39',
value: '19-39'
},
{
label: '40 to 64',
value: '40-64'
},
{
label: '65 and over',
value: '65-infinity'
},
];
function App() {
// Grab the first option from the array.
// Set its value into state:
const [age, setAge] = React.useState(OPTIONS[0].value);
return (
<>
<form>
<label htmlFor="age-select">
How old are you?
</label>
<select
id="age-select"
value={age}
onChange={event => {
setAge(event.target.value)
}}
>
{/*
Iterate over that array, to create
the <option> tags dynamically:
*/}
{OPTIONS.map(option => (
<option
key={option.value}
value={option.value}
>
{option.label}
</option>
))}
</select>
</form>
<p>
<strong>Selected value:</strong>
{age}
</p>
</>
);
}
export default App;
Specialty inputs 特色输入
正如我们所见, <input>
HTML 标签可以有很多不同的形式。根据 type 属性的不同,它可以是文本输入框、密码输入框、复选框、单选按钮...
事实上,MDN 列出了 type 属性的 22 个不同有效值。其中一些是"特殊"的,具有独特的外观:
- Sliders (with type="range")
- Date pickers (with type="date")
- Color pickers (with type="color")
幸运的是,它们都遵循与文本输入相同的模式。我们使用 value 将输入锁定到状态的值,并使用 onChange 在编辑输入时更新该值。
这是一个使用 <input type="range">
的例子:
jsx
import React from 'react';
function App() {
const [volume, setVolume] = React.useState(50);
return (
<>
<form>
<label htmlFor="volume-slider">
Audio volume:
</label>
<input
type="range"
id="volume-slider"
min={0}
max={100}
value={volume}
onChange={event => {
setVolume(event.target.value);
}}
/>
</form>
<p>
<strong>Current value:</strong>
{volume}
</p>
</>
);
}
export default App;
这是另一个例子,带有 <input type="color">
:
jsx
import React from 'react';
function App() {
const [color, setColor] = React.useState('#FF0000');
return (
<>
<form>
<label htmlFor="color-picker">
Select a color:
</label>
<input
type="color"
id="color-picker"
value={color}
onChange={event => {
setColor(event.target.value);
}}
/>
</form>
<p>
<strong>Current value:</strong>
{color}
</p>
</>
);
}
export default App;
Generating unique IDs 生成唯一的ID
在我们看到的每个例子中,我们的表单字段都被赋予了一个 id 属性。这个 ID 唯一地标识了该字段,并且我们使用它来连接一个 <label>
标签,使用 htmlFor 进行链接(React 版本的"for"属性)。
这个很重要,有两个原因:
- 可访问性。表单字段需要标签;没有标签,用户怎么知道要输入什么?对于使用屏幕阅读器的人来说,需要正确连接以确保他们知道每个给定表单字段的标签。
- 可用性。连接标签使用户可以点击文本来聚焦/触发表单控件。这对于单选按钮和复选框特别方便,因为它们通常太小而难以点击。
为了让一切正常运作, id 属性应该是全局唯一的。我们不允许有多个具有相同 ID 的表单字段。
但是!React 的核心原则之一是可重用性。我们可能希望在同一页上多次渲染包含表单字段的组件!
为了帮助我们解决这个难题,React 团队最近推出了一个新的钩子:useId。以下是它的样子:
jsx
import React from 'react';
function LoginForm() {
const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState('');
const id = React.useId();
const usernameId = `${id}-username`;
const passwordId = `${id}-password`;
return (
<>
<form>
<div>
<label htmlFor={usernameId}>
Username:
</label>
<input
id={usernameId}
value={username}
onChange={event => {
setUsername(event.target.value);
}}
/>
</div>
<div>
<label htmlFor={passwordId}>
Password:
</label>
<input
id={passwordId}
type="password"
value={password}
onChange={event => {
setPassword(event.target.value);
}}
/>
</div>
<button>
Login
</button>
</form>
</>
);
}
export default LoginForm;
每当我们渲染这个 LoginForm 组件时,React 会生成一个新的、保证唯一的 ID。您可以在新的 React 文档(new React docs.)中了解更多关于这个钩子的信息。
使用嵌套解决问题? 不仅可以提供唯一的 ID,还可以通过嵌套将标签和表单字段进行关联: 我不是无障碍专家,但我听说这种结构并不适用于所有屏幕阅读器。已经确立的最佳实践是使用 ID 进行连接。