React 表单状态管理:从受控与非受控组件到登录表单校验
表单的核心任务是收集用户输入,但"输入值由谁保存"会直接影响组件的写法。
在 React 中,常见的处理方式可以分为两类:
- 受控组件:输入值保存在 React 状态中,页面展示的值由状态决定。
- 非受控组件:输入值由真实 DOM 自己保存,需要时再通过引用读取。
本文会从最小输入框开始,逐步扩展到评论框、注册表单以及带实时校验的登录表单,重点讲清楚 useState、useRef、统一变更处理、动态属性名和表单校验之间的关系。
一、先认识应用的运行入口
React 应用需要先找到 HTML 中的挂载节点:
html
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
入口脚本通过 createRoot 创建 React 根节点,再渲染最外层的 App 组件:
jsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
这里的调用关系很清楚:
text
index.html 中的 #root
↓
createRoot(...).render(...)
↓
App
↓
各个表单演示组件
index.css 是当前实际引入的全局样式,其中设置了页面宽度、字体、颜色变量、明暗主题和响应式字号。组件列表则集中由 App 渲染:
jsx
function App() {
return (
<>
<ControlledInput />
<UncontrolledInput />
<CommentBox />
<RegisterForm />
<LoginForm />
</>
)
}
这些组件按照"单个输入框 → 提交时读取 → 多字段表单 → 实时校验"的顺序,展示了表单处理方式逐渐完整的过程。
二、受控组件:由 React 状态控制输入值
先看一个最小的受控输入框:
jsx
import { useState } from "react";
function ControlledInput() {
const [value, setValue] = useState("");
return (
<>
ControlledInput
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</>
);
}
关键不在于使用了 <input>,而在于 value 和 onChange 同时出现:
jsx
value={value}
onChange={(e) => setValue(e.target.value)}
它形成了一个完整的数据流:
text
用户输入
↓
触发 onChange
↓
从 e.target.value 取得最新文本
↓
setValue 更新状态
↓
组件重新渲染
↓
新的 value 回到 input
初始状态是空字符串:
jsx
const [value, setValue] = useState("");
因此第一次渲染时输入框为空。用户每输入一个字符,onChange 都会执行。事件对象 e 的 target 就是当前输入框,e.target.value 是它最新的文本值。
由于输入框显示什么始终由 value 决定,所以 React 状态是这份数据的来源。这就是"受控"的含义。
如果只写 value={value} 却不更新状态,输入框展示值会一直受旧状态约束,用户的输入无法正常反映出来。因此,受控输入通常需要同时提供状态值和对应的更新逻辑。
三、非受控组件:需要时直接读取 DOM
非受控输入不把每一次输入同步进 React 状态,而是使用 useRef 保存对 DOM 节点的引用:
jsx
import { useRef } from "react";
function UncontrolledInput() {
const inputRef = useRef(null);
const handleClick = () => {
console.log(inputRef.current.value);
};
return (
<>
UncontrolledInput
<input type="text" ref={inputRef} />
<button onClick={handleClick}>获取输入值</button>
</>
);
}
这段代码可以分成三步理解。
1. 创建引用对象
jsx
const inputRef = useRef(null);
组件第一次执行时还没有拿到真实输入框,所以初始值写成 null。
2. 将引用交给输入框
jsx
<input type="text" ref={inputRef} />
React 完成 DOM 挂载后,inputRef.current 会指向这个输入框节点。
3. 在需要时读取值
jsx
const handleClick = () => {
console.log(inputRef.current.value);
};
用户输入时没有调用 setState。只有点击按钮后,程序才访问 DOM 的 value 属性。因此,此时输入值主要由 DOM 自己维护,React 只保留了找到该节点的方式。
四、同一个评论提交需求,用非受控方式完成
useRef 不只可以引用单行输入框,也可以引用 textarea:
jsx
import { useRef } from "react";
function CommentBox() {
const textareaRef = useRef(null);
const handleSubmit = () => {
const comment = textareaRef.current.value;
if (!comment) return;
console.log(comment);
};
return (
<div>
<textarea
placeholder="输入评论..."
ref={textareaRef}
></textarea>
<button onClick={handleSubmit}>提交评论</button>
</div>
);
}
提交时首先读取当前文本:
jsx
const comment = textareaRef.current.value;
紧接着通过提前返回拦截空内容:
jsx
if (!comment) return;
只有 comment 有值时才会输出。这里体现了非受控组件很典型的使用节奏:平时让 DOM 保存输入,真正提交时再集中读取。
对比受控输入可以发现,评论框没有 value,也没有 onChange,所以用户每次键入都不会主动同步到 React 状态。
五、受控与非受控的核心区别
两种方式的差别可以归纳如下:
| 对比项 | 受控组件 | 非受控组件 |
|---|---|---|
| 输入值主要保存在哪里 | React 状态 | DOM 节点 |
| 常用 Hook | useState |
useRef |
| 如何获取值 | 直接读取状态变量 | 读取 ref.current.value |
| 用户输入时 | onChange 更新状态 |
DOM 自己更新值 |
| 代码中的典型组合 | value + onChange |
ref |
还可以从代码数量上观察多字段场景。非受控表单如果有用户名和密码两个输入框,通常需要分别引用两个 DOM 节点;而受控表单可以把多个字段放进同一个对象状态,再通过字段名统一更新。
这正是注册表单所展示的思路。
六、多个字段共用一个对象状态
用户名和密码属于同一张表单,可以统一保存到一个对象中:
jsx
const [form, setForm] = useState({
username: "",
password: ""
});
此时状态结构与表单字段一一对应:
js
{
username: "当前用户名",
password: "当前密码"
}
两个输入框分别读取对应属性:
jsx
<input
name="username"
value={form.username}
onChange={handleChange}
placeholder="请输入用户名"
type="text"
/>
<input
name="password"
value={form.password}
onChange={handleChange}
placeholder="请输入密码"
type="text"
/>
这里给每个输入框设置 name 非常重要。name 不只是普通标记,它还决定了接下来要更新对象中的哪个属性。
七、用计算属性名统一更新字段
两个输入框共用同一个 handleChange:
jsx
const handleChange = (e) => {
setForm({
...form,
[e.target.name]: e.target.value
});
};
其中有两个关键点。
1. 展开旧表单
jsx
...form
更新对象状态时,需要保留没有变化的其他字段。
假设当前状态为:
js
{
username: "张三",
password: "123456"
}
如果用户只修改用户名,展开旧对象可以先保留 password,再用新用户名覆盖原来的 username。
2. 动态决定属性名
jsx
[e.target.name]: e.target.value
方括号表示计算属性名。输入框的 name 是 username,就更新 form.username;输入框的 name 是 password,就更新 form.password。
等价过程可以直观地理解为:
js
// 修改用户名时
setForm({
...form,
username: e.target.value
});
// 修改密码时
setForm({
...form,
password: e.target.value
});
这样便不需要分别编写 handleUsernameChange 和 handlePasswordChange,字段越多,统一处理的价值越明显。
提交按钮则直接读取完整状态:
jsx
const handleSubmit = () => {
console.log(form);
};
因为受控表单已经持续把最新输入同步到了 form,提交时不需要再查找 DOM。
八、从收集数据扩展到实时校验
一个更完整的登录表单同时维护两类状态:
jsx
const [form, setForm] = useState({
username: "",
password: ""
});
const [errors, setErrors] = useState({});
form 保存用户输入,errors 保存每个字段对应的错误信息。两份状态职责不同:
js
form = {
username: "ab",
password: "123"
}
errors = {
username: "用户名长度不能小于3",
password: "密码长度不能小于6"
}
把输入值和错误信息分开后,校验逻辑与界面展示就更容易组织。
九、按字段名称执行不同校验规则
校验函数接收字段名和字段值:
jsx
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
}));
};
校验规则完全由 name 决定:
username不能为空,并且长度不能小于 3。password不能为空,并且长度不能小于 6。- 没有错误时,
msg保持为空字符串。
更新错误对象时使用了函数式写法:
jsx
setErrors(prev => ({
...prev,
[name]: msg
}));
prev 表示更新前的错误对象。先展开 prev,可以保留另一个字段的错误;随后用 [name] 更新当前字段。
例如当前用户名和密码都有错误,此时只重新校验用户名,就只覆盖 errors.username,不会把 errors.password 丢掉。
十、一次输入事件同时更新数据和错误
登录表单的变更处理函数先解构事件目标:
jsx
const handleChange = e => {
const { name, value } = e.target;
setForm({
...form,
[name]: value
});
validate(name, value);
};
一次 onChange 完成两件事:
- 将最新值写进
form。 - 用同一个最新值执行
validate,更新errors。
这里校验使用的是事件中刚刚取得的 value,所以校验目标就是用户本次输入后的文本。
两个字段继续共享同一个处理函数:
jsx
<input
type="text"
name="username"
value={form.username}
onChange={handleChange}
/>
<input
type="text"
name="password"
value={form.password}
onChange={handleChange}
/>
这说明 name、form 的属性名以及 errors 的属性名需要保持一致:
text
input.name: username
form 属性: username
errors 属性: username
只有命名对应起来,动态属性更新和错误展示才能正确找到同一个字段。
十一、根据错误状态显示提示
错误提示采用条件渲染:
jsx
{errors.username && (
<span className="error">{errors.username}</span>
)}
当 errors.username 是空字符串或不存在时,不渲染 <span>;当它包含错误消息时,再显示具体文字。
密码错误的处理方式相同:
jsx
{errors.password && (
<span className="error">{errors.password}</span>
)}
因此,校验函数只负责生成消息,JSX 负责决定是否展示消息,职责比较明确。
需要注意的是,登录表单单独引入了一个样式表:
jsx
import "./index.css";
当前样式表没有具体规则,所以 login-wrapper、login-card、form-item 和 error 暂时只是类名,还没有对应的视觉效果。错误信息依然会显示,只是不会获得额外样式。
十二、计算整张表单是否可提交
登录按钮是否可用,由 isValid 决定:
jsx
const isValid =
form.username &&
form.password &&
!errors.username &&
!errors.password;
这个表达式要求四个条件同时成立:
- 用户名有内容。
- 密码有内容。
- 用户名没有错误消息。
- 密码没有错误消息。
然后把结果交给按钮的 disabled:
jsx
<button type="submit" disabled={!isValid}>登陆</button>
当表单无效时,!isValid 为真,按钮禁用;满足全部条件后,按钮恢复可用。
这里的 isValid 没有单独使用一个 useState,而是直接由现有的 form 和 errors 计算得到。每当这两份状态变化并触发重新渲染时,isValid 都会重新计算。
十三、使用 form 的 onSubmit 统一处理提交
登录表单与前面按钮点击提交的示例略有不同,它使用了原生表单的提交事件:
jsx
<form className="login-card" onSubmit={handleSubmit}>
{/* 表单内容 */}
<button type="submit" disabled={!isValid}>登陆</button>
</form>
处理函数首先阻止表单默认行为:
jsx
const handleSubmit = e => {
e.preventDefault();
if (!isValid) return;
console.log("--------", form, "--------");
};
代码有两层保护:
- 界面层通过
disabled={!isValid}禁用按钮。 - 逻辑层通过
if (!isValid) return再次阻止无效数据继续提交。
通过校验后,form 已经包含用户名和密码的最新值,因此可以直接作为完整表单数据使用。
十四、完整登录表单
把状态管理、统一变更、校验、错误提示和提交组合起来,完整结构如下:
jsx
import { useState } from "react";
import "./index.css";
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 (
<div className="login-wrapper">
<form className="login-card" onSubmit={handleSubmit}>
<h2>登陆</h2>
<div className="form-item">
<label>用户名</label>
<input
type="text"
name="username"
value={form.username}
onChange={handleChange}
/>
{errors.username && (
<span className="error">{errors.username}</span>
)}
</div>
<div className="form-item">
<label>密码</label>
<input
type="text"
name="password"
value={form.password}
onChange={handleChange}
/>
{errors.password && (
<span className="error">{errors.password}</span>
)}
</div>
<button type="submit" disabled={!isValid}>登陆</button>
</form>
</div>
);
}
export default LoginForm;
这份表单的数据流可以概括为:
text
用户输入
↓
handleChange
├── 更新 form
└── validate → 更新 errors
↓
条件渲染错误信息
↓
重新计算 isValid
↓
控制提交按钮是否禁用
↓
handleSubmit 提交
十五、集中导出组件,简化导入路径
当组件数量增加时,可以使用一个清单模块集中导入并导出:
jsx
import ControlledInput from "./ControlledInput";
import UncontrolledInput from "./UncontrolledInput";
import CommentBox from "./CommentBox";
import RegisterForm from "./RegisterForm";
import LoginForm from "./LoginForm";
export {
ControlledInput,
UncontrolledInput,
CommentBox,
RegisterForm,
LoginForm,
};
使用方就可以从同一个路径一次性导入:
jsx
import {
ControlledInput,
UncontrolledInput,
CommentBox,
RegisterForm,
LoginForm
} from "./components";
这种写法把"组件目录里可以对外使用哪些组件"集中列在一起,也让最外层组件的导入语句更整洁。
十六、样式与组件的实际连接关系
全局入口引入了 index.css:
jsx
import './index.css'
因此其中的规则会生效,例如:
- 使用 CSS 变量保存文字色、背景色、边框色和强调色。
- 通过
prefers-color-scheme: dark切换暗色变量。 - 将
#root设置为有最大宽度的纵向 Flex 容器。 - 在较窄屏幕下调整标题和基础字号。
另有一组 .counter、.hero、#center 等样式没有被当前组件结构使用,并且对应的 App.css 也没有被入口或 App 引入,所以它们不会影响这几个表单示例。判断一段样式是否生效,既要看选择器是否匹配页面元素,也要看样式文件是否进入了导入链路。
十七、几个容易混淆的细节
1. useRef 保存的是引用,不是输入值状态
jsx
const inputRef = useRef(null);
这里创建的是一个带有 current 属性的对象。输入内容仍在 DOM 输入框中,读取时才通过 inputRef.current.value 取得。
2. 受控输入的状态必须与 value 对应
jsx
value={form.username}
name="username"
value 决定显示内容,name 决定统一处理函数更新哪个属性。二者职责不同,但字段名需要保持一致。
3. 更新对象时不能只留下当前字段
多字段表单采用:
jsx
setForm({
...form,
[name]: value
});
...form 用于保留其他字段。如果只写 { [name]: value },新对象将只包含当前修改的字段。
4. 空字符串同时承担"没有错误"的含义
校验函数以空字符串初始化消息:
jsx
let msg = "";
校验通过后,错误属性会被更新为空字符串。条件渲染利用空字符串是假值,不再展示错误提示;!errors.username 也会变为真,参与表单有效性判断。
5. 示例中的密码输入框仍然是文本类型
登录和注册示例都写成了:
jsx
<input type="text" name="password" />
因此输入内容会按普通文本显示。这不影响本文讨论的受控状态、统一变更和校验数据流,但它是当前界面行为的一部分。
总结
从单个输入框到完整登录表单,核心知识可以串成一条清晰的主线:
- 使用
value + onChange + useState构成受控组件。 - 使用
ref + useRef在需要时读取非受控组件的 DOM 值。 - 使用对象状态统一保存多个表单字段。
- 依靠输入框的
name和计算属性名[name]共用变更处理函数。 - 将
form与errors分开保存,分别表达输入数据和校验结果。 - 在
onChange中同步更新字段并执行实时校验。 - 根据错误信息和字段内容计算
isValid,同时控制按钮与提交逻辑。 - 通过集中导出组件简化上层组件的导入代码。
受控与非受控并不是两套相互割裂的语法,它们只是选择了不同的数据保存位置。理解"当前输入值究竟由 React 状态维护,还是由 DOM 维护",就抓住了两者最本质的区别;在此基础上,多字段更新、错误状态和提交校验都只是沿着同一条数据流继续扩展。