摘要
从受控组件value+onChange单向数据流,到非受控组件useRef按需读取,再到多字段表单useState对象管理和LoginForm实时校验,完整覆盖React表单处理两种范式及适用场景。
React 处理表单输入有两种截然不同的哲学:受控组件 将表单数据交给 React state 管理,非受控组件将数据留在 DOM 中按需读取。这两种范式各有优劣,理解它们的本质差异,是写出高质量 React 表单代码的基础。
受控组件:React 作为唯一数据源
受控组件的核心思想是"单一数据源 "(Single Source of Truth)------输入框的值完全由 React 的 useState 驱动,用户每次输入都通过 onChange 事件更新 state,state 的变化再通过 value 属性回写到输入框。
jsx
import { useState } from 'react';
function ControlledInput() {
const [value, setValue] = useState('');
return (
<input
type="text"
value={value}
onChange={e => setValue(e.target.value)}
/>
);
}
数据流是单向闭环的:用户输入 → onChange 事件 → setValue 更新 state → value 属性回写 → UI 更新。React state 始终是输入框内容的唯一真实来源,DOM 只是 state 的投影。
受控组件的最大优势在于对数据的完全掌控 。你可以在 onChange 中做任何事------格式化输入、实时校验、限制字符数------因为每次输入都经过你的代码。缺点也很明显:每个输入框都需要一个对应的 state 和事件处理函数,字段多时代码量线性增长。
非受控组件:DOM 作为数据源
非受控组件将数据管理权交还给 DOM。React 不追踪输入框的值,只在需要时(比如表单提交)通过 useRef 读取:
jsx
import { useRef } from 'react';
function UnControlledInput() {
const inputRef = useRef(null);
const handleClick = () => {
console.log(inputRef.current.value); // 按需读取
};
return (
<>
<input type="text" ref={inputRef} />
<button onClick={handleClick}>获取输入值</button>
</>
);
}
这里没有 value 属性,没有 onChange 事件,没有 useState。输入框由浏览器原生行为管理------用户输入什么,输入框就显示什么。useRef 只在需要时提供一个"快照"式的读取通道。
非受控组件的优势是简洁和性能。没有每次按键触发的渲染,适合输入框数量极多或输入频率极高的场景。代价是失去了对数据的实时控制------你无法在用户输入时做格式化、校验或联动,只能在"提交时刻"获取最终值。
CommentBox:非受控的实战场景
评论框是一个典型的非受控组件适用场景------用户输入一段长文本,点击提交按钮时一次性读取,中间不需要实时干预:
jsx
import { useRef } from 'react';
function CommentBox() {
const textareaRef = useRef(null);
const handleSubmit = () => {
const comment = textareaRef.current.value;
if (!comment) return;
console.log(comment);
textareaRef.current.value = ''; // 提交后清空
};
return (
<div>
<textarea placeholder="输入评论" ref={textareaRef} />
<button onClick={handleSubmit}>提交评论</button>
</div>
);
}
<textarea> 在 React 中的用法与 <input> 一致------受控时使用 value + onChange,非受控时使用 ref。注意 React 中 <textarea> 是单标签写法(<textarea />),而不是 HTML 中的双标签(<textarea>内容</textarea>)。
多字段表单:一个 useState 管理全部字段
当表单包含多个字段时,为每个字段单独声明 useState 会导致代码臃肿。更优雅的做法是使用一个对象类型的 state 统一管理:
jsx
import { useState } from 'react';
function RegisterForm() {
const [form, setForm] = useState({
username: '',
password: ''
});
const handleChange = (e) => {
setForm({
...form,
[e.target.name]: e.target.value
});
};
const handleSubmit = (e) => {
e.preventDefault();
console.log(form);
};
return (
<div>
<input
name="username"
value={form.username}
onChange={handleChange}
placeholder="请输入用户名"
/>
<input
name="password"
value={form.password}
onChange={handleChange}
placeholder="请输入密码"
/>
<button onClick={handleSubmit}>提交</button>
</div>
);
}
这里的核心技巧是 [e.target.name]: e.target.value------利用计算属性名,让一个 handleChange 函数处理所有字段的更新。每个 <input> 的 name 属性与 form 对象中的键名一一对应,...form 展开保证其他字段不被覆盖。
这种模式的关键前提是每个输入框必须有 name 属性 ,且 name 值与 state 对象的键名一致。这种约定优于配置的设计,让多字段表单的代码量几乎不随字段数量增长。
表单验证:在 onChange 中实时校验
受控组件的最大优势在于可以在每次输入时介入数据流。LoginForm 展示了在 onChange 中附加实时校验的完整模式:
jsx
import { useState } from 'react';
function LoginForm() {
const [form, setForm] = useState({ username: '', password: '' });
const [errors, setErrors] = useState({});
const validate = (name, value) => {
let msg = '';
if (name === 'username') {
if (!value) msg = '用户名为空';
else if (value.length < 3) msg = '用户名长度不能小于3';
}
if (name === 'password') {
if (!value) msg = '密码不能为空';
else if (value.length < 6) msg = '密码长度不能小于6位';
}
setErrors(prev => ({ ...prev, [name]: msg }));
};
const handleChange = (e) => {
const { name, value } = e.target;
setForm({ ...form, [name]: value });
validate(name, value); // 每次输入后立即校验
};
const isValid = form.username && form.password &&
!errors.username && !errors.password;
const handleSubmit = (e) => {
e.preventDefault();
if (!isValid) return;
console.log(form);
};
return (
<form onSubmit={handleSubmit}>
<input name="username" value={form.username}
onChange={handleChange} placeholder="用户名" />
{errors.username && <span>{errors.username}</span>}
<input name="password" value={form.password}
onChange={handleChange} placeholder="密码" type="password" />
{errors.password && <span>{errors.password}</span>}
<button type="submit" disabled={!isValid}>登录</button>
</form>
);
}
校验逻辑 validate 与表单数据 form 分离为两个独立的 state,各司其职:form 负责数据,errors 负责校验结果。isValid 是一个派生值------它不需要 useState,直接从 form 和 errors 计算得出,React 每次渲染时重新计算。
这种"数据 + 校验 + 派生状态"的三层结构,是 React 表单的经典模式。validate 函数在 handleChange 中调用,确保每次输入后校验结果即时更新,用户能第一时间看到错误提示。
受控 vs 非受控:选择指南
| 维度 | 受控组件 | 非受控组件 |
|---|---|---|
| 数据源 | React state | DOM 原生 |
| 核心 API | useState + value + onChange |
useRef + ref |
| 实时响应 | 每次按键触发渲染和回调 | 仅在主动读取时获取值 |
| 表单验证 | 天然支持实时校验 | 只能在提交时校验 |
| 条件渲染 | 可基于输入值动态显示/隐藏 UI | 需要额外的事件监听 |
| 代码量 | 字段多时较冗长 | 极简 |
| 性能 | 高频输入可能引发频繁渲染 | 无额外渲染开销 |
| 适用场景 | 需要校验、格式化、联动的表单 | 简单表单、评论框、文件上传 |
选择受控组件当且仅当:你需要对输入值做实时处理 ------校验、格式化、搜索建议、字符计数、条件禁用按钮。选择非受控组件当且仅当:你只需要在提交时刻获取值,中间不需要任何干预。
文件上传(<input type="file">)是一个天然的非受控组件------出于安全原因,浏览器不允许 JavaScript 设置文件输入框的值,所以它只能是 useRef 读取的非受控模式。
总结
受控与非受控是 React 表单处理的两种范式,并非非此即彼的二选一。ControlledInput 展示了受控的基本模式,UnControlledInput 展示了非受控的按需读取,RegisterForm 展示了多字段的优雅管理,LoginForm 展示了"数据 + 校验 + 派生状态"的经典三层结构。
理解它们的本质差异------React state 代理 DOM vs DOM 自治------之后,选择就变得简单:需要实时控制,用受控;只需要最终结果,用非受控。大多数生产级表单是两者的混合体:核心字段受控以支持校验和联动,次要字段非受控以保持简洁。