React 表单双雄:受控 vs 非受控组件,从原理到表单校验的完整实战
表单是前端最核心的交互场景之一。React 对表单的处理有两种截然不同的哲学——受控组件让 React 状态成为"唯一数据源",非受控组件则让 DOM 自己管理数据、用
useRef按需读取。本文从数据控制权之争出发,带你完整掌握两种模式的原理、单字段表单、多字段表单、表单校验、组件清单模式,以及实战中的选型策略。全文代码可直接运行,建议收藏后动手实践。
一、表单的本质:数据归谁管?
HTML 表单元素(input、textarea、select)天然有自己的状态——用户输入时,DOM 节点自己维护着当前值。React 的设计哲学是"状态驱动 UI",这就产生了一个根本矛盾:
HTML 表单:DOM 自己管数据
用户输入 → DOM 更新 value → 页面显示
React 不知道数据变了
React 理念:状态驱动 UI
setState → React 重新渲染 → DOM 更新
一切数据由 React 状态控制
矛盾:表单数据到底归谁管?
React 给出了两种解法:
┌──────────────────────────────────────────────────────────┐
│ React 表单两种模式 │
│ │
│ 受控组件(Controlled) │
│ ┌─────────────────────────────────────────┐ │
│ │ React State 是唯一数据源 │ │
│ │ │ │
│ │ useState → value={state} │ │
│ │ onChange → setState │ │
│ │ │ │
│ │ 数据流:State → UI → 用户输入 → State │ │
│ │ React 完全掌控数据 │ │
│ └─────────────────────────────────────────┘ │
│ │
│ 非受控组件(Uncontrolled) │
│ ┌─────────────────────────────────────────┐ │
│ │ DOM 自己管数据,React 按需读取 │ │
│ │ │ │
│ │ useRef → ref={ref} │ │
│ │ 需要时 ref.current.value │ │
│ │ │ │
│ │ 数据流:用户输入 → DOM → 按需读取 │ │
│ │ React 不干预过程,只在需要时拿数据 │ │
│ └─────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
二、受控组件:React 全权掌控
2.1 最简单的受控组件
// ControlledInput.jsx
import { useState } from 'react';
// 受控组件:响应式状态控制 input
// 收集用户输入,React 状态是唯一数据源
function ControlledInput() {
const [value, setValue] = useState('');
return (
<>
<p>ControlledInput</p>
<input
type="text"
value={value} // State 驱动 UI
onChange={(e) => setValue(e.target.value)} // UI 变化驱动 State
/>
<p>当前值:{value}</p>
</>
);
}
export default ControlledInput;
数据流详解:
初始渲染:value = ''
│
▼
<input value="" /> ←──── State 驱动渲染
│
│ 用户输入 "H"
▼
onChange 触发
│
▼
setValue('H') ←──── 更新 State
│
▼
React 重新渲染
│
▼
<input value="H" /> ←──── 新 State 驱动渲染
│
│ 用户输入 "i"(变成 "Hi")
▼
onChange 触发 → setValue('Hi') → 重新渲染
│
▼
<input value="Hi" />
核心特征:每输入一个字符,都会经历 onChange → setState → 重新渲染 的完整循环。React 始终知道数据的最新值。
2.2 多字段受控表单:单一状态对象
当表单有多个字段时,不需要为每个字段创建独立的 useState,可以用一个状态对象统一管理:
// RegisterForm.jsx
import { useState } from 'react';
function RegisterForm() {
const [form, setForm] = useState({
username: '',
password: ''
});
// 统一的 change 处理函数
// 利用 name 属性动态更新对应字段
const handleChange = (e) => {
const { name, value } = e.target;
setForm(prev => ({
...prev, // 展开之前的状态
[name]: value // 动态 key 更新对应字段
}));
};
const handleSubmit = (e) => {
e.preventDefault();
console.log('提交注册:', form);
};
return (
<form onSubmit={handleSubmit}>
<input
name="username"
value={form.username}
onChange={handleChange}
placeholder="请输入用户名"
type="text"
/>
<input
name="password"
value={form.password}
onChange={handleChange}
placeholder="请输入密码"
type="password"
/>
<button type="submit">提交</button>
</form>
);
}
export default RegisterForm;
关键技巧:动态 key 更新
// input 的 name 属性 = state 的 key
<input name="username" ... /> // e.target.name === 'username'
<input name="password" ... /> // e.target.name === 'password'
// handleChange 中用计算属性名动态更新
const { name, value } = e.target;
setForm(prev => ({
...prev,
[name]: value // [name] 是动态 key,等价于 { username: value } 或 { password: value }
}));
为什么用 prev => ({ ...prev, [name]: value }) 而不是 { ...form, [name]: value }?
// ⚠️ 潜在问题:使用 form(闭包中的旧值)
const handleChange = (e) => {
setForm({ ...form, [name]: value });
// 如果用户快速连续输入,form 可能是过时的闭包值
};
// ✅ 安全:使用 prev(React 保证是最新的)
const handleChange = (e) => {
setForm(prev => ({ ...prev, [name]: value }));
// 函数式更新,React 保证 prev 是最新的 state
};
2.3 受控表单 + 校验:LoginForm 完整实战
// LoginForm.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(prev => ({
...prev,
[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="password"
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;
校验流程图:
用户在 username 输入框输入 "ab"
│
▼
handleChange 触发
│
├──▶ setForm: { username: 'ab', password: '' }
│
└──▶ validate('username', 'ab')
│
▼
'ab'.length < 3 → msg = '用户名长度不能小于 3'
│
▼
setErrors: { username: '用户名长度不能小于 3', password: '' }
│
▼
React 重新渲染
│
├── <input value="ab" /> ← 更新后的值
├── <span>用户名长度不能小于 3</span> ← 显示错误
└── <button disabled>登录</button> ← 按钮禁用
用户继续输入 "abc"(长度 ≥ 3)
│
▼
validate('username', 'abc') → msg = ''
│
▼
setErrors: { username: '', password: '' }
│
▼
isValid = true → 按钮启用 ✅
2.4 受控组件的完整数据模型
LoginForm 状态结构:
┌─────────────────────────────────────────┐
│ form: { username: '', password: '' } │ ← 表单数据
├─────────────────────────────────────────┤
│ errors: { username: '', password: '' } │ ← 校验错误
├─────────────────────────────────────────┤
│ isValid: boolean │ ← 派生值(计算得出)
└─────────────────────────────────────────┘
数据流:
用户输入 → handleChange
├── setForm(更新表单数据)
└── validate → setErrors(更新校验状态)
│
▼
重新渲染
├── input value ← form(受控显示)
├── error span ← errors(条件渲染)
└── button disabled ← !isValid(按钮状态)
三、非受控组件:DOM 自治,按需读取
3.1 最简单的非受控组件
// UncontrolledInput.jsx
import { useRef } from 'react';
function UncontrolledInput() {
const inputRef = useRef(null);
const handleClick = () => {
// 需要时才通过 ref 读取 DOM 中的值
console.log(inputRef.current.value);
};
return (
<>
<p>UncontrolledInput</p>
<input
type="text"
ref={inputRef}
/>
<button onClick={handleClick}>获取输入值</button>
</>
);
}
export default UncontrolledInput;
与受控组件的核心差异:
受控组件:
<input value={state} onChange={setState} />
React 始终知道 value 是什么
每输入一个字符都触发渲染
非受控组件:
<input ref={inputRef} />
React 不管 value,DOM 自己维护
需要时才 ref.current.value 读取
输入过程不触发任何渲染
3.2 非受控评论框
// 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);
// 提交后清空 textarea
textareaRef.current.value = '';
};
return (
<div>
<textarea
placeholder="输入评论..."
ref={textareaRef}
/>
<button onClick={handleSubmit}>提交评论</button>
</div>
);
}
export default CommentBox;
为什么评论框适合用非受控?
评论框的特点:
- 用户输入过程中不需要实时校验
- 不需要实时显示字数统计
- 不需要根据输入内容禁用/启用按钮
- 只在提交时才需要拿到值
→ 没有必要每次输入都触发重新渲染
→ 非受控组件更高效
四、受控 vs 非受控:如何选型?
4.1 核心对比
| 维度 | 受控组件 | 非受控组件 |
|---|---|---|
| 数据源 | React State | DOM 自身 |
| 读取方式 | 直接读 state | ref.current.value |
| 实时性 | 每次输入都同步到 State | 需要时才读取 |
| 重新渲染 | 每次输入触发渲染 | 输入过程不渲染 |
| 校验 | 实时校验,即时反馈 | 提交时校验 |
| 默认值 | useState(defaultValue) | defaultValue 属性 |
| 代码量 | 较多(state + onChange) | 较少(ref + 按需读取) |
| 可控性 | 完全可控 | 有限控制 |
4.2 选型决策树
表单需要实时校验吗?
│
├── 是 → 受控组件
│ ├── 需要实时显示错误信息
│ ├── 需要根据输入禁用/启用按钮
│ └── 需要格式化输入内容(如手机号)
│
└── 否 → 表单字段多吗?
│
├── 多(>5个字段)且只需提交时取值
│ └── 非受控组件(性能更好)
│
└── 少,且需要实时处理
└── 受控组件
4.3 典型场景对照
适合受控组件的场景:
✅ 登录/注册表单(需要校验)
✅ 搜索框(需要实时搜索建议)
✅ 手机号/金额输入(需要格式化)
✅ 联动表单(省市区级联)
✅ 需要禁用提交按钮的表单
适合非受控组件的场景:
✅ 评论框(提交时取值即可)
✅ 文件上传(input type="file" 必须非受控)
✅ 简单的一次性表单
✅ 集成第三方 DOM 库(如富文本编辑器)
✅ 性能敏感的大表单
五、组件清单模式:index.js 统一导出
5.1 什么是 Barrel File?
当组件越来越多时,导入路径会变得冗长且混乱。Barrel File(桶文件/清单文件)是一种统一导出的架构模式:
// components/index.js —— 清单文件,统一导出所有组件
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
};
5.2 使用前后的对比
// ❌ 没有 index.js:每个组件都要写完整路径
import ControlledInput from './components/ControlledInput';
import UncontrolledInput from './components/UncontrolledInput';
import CommentBox from './components/CommentBox';
import RegisterForm from './components/RegisterForm';
import LoginForm from './components/LoginForm';
// ✅ 有 index.js:一行搞定,路径统一
import {
ControlledInput,
UncontrolledInput,
CommentBox,
RegisterForm,
LoginForm
} from './components';
5.3 项目结构
src/
├── App.jsx # 根组件
├── components/ # 组件目录
│ ├── index.js # 清单文件(统一导出)
│ ├── ControlledInput.jsx # 受控输入
│ ├── UncontrolledInput.jsx # 非受控输入
│ ├── CommentBox.jsx # 评论框(非受控)
│ ├── RegisterForm.jsx # 注册表单(受控)
│ └── LoginForm.jsx # 登录表单(受控+校验)
└── index.css # 样式文件
清单文件的价值:
- 导入路径统一:所有组件从
./components导入,不用记具体文件路径 - 增删组件只改一处:新增组件只需在 index.js 加一行导出
- API 清晰:index.js 就是组件库的"目录",一目了然有哪些组件
- 解耦:消费方不依赖组件的具体文件路径,文件重命名不影响消费方
六、App.jsx:组件展示墙
// App.jsx
import {
ControlledInput,
UncontrolledInput,
CommentBox,
RegisterForm,
LoginForm
} from './components';
function App() {
return (
<>
<ControlledInput />
<UncontrolledInput />
<CommentBox />
<RegisterForm />
<LoginForm />
</>
);
}
export default App;
通过清单文件导入,App 组件干净清爽——只关心"用哪些组件",不关心"组件在哪"。
七、表单进阶:defaultValue 与受控的中间态
7.1 非受控组件的默认值
// 非受控组件设置默认值用 defaultValue,不是 value
function UncontrolledWithDefault() {
const inputRef = useRef(null);
return (
<input
type="text"
defaultValue="默认文字" // ✅ 非受控用 defaultValue
ref={inputRef}
/>
);
}
// ⚠️ 非受控组件不能用 value(会变成受控)
<input type="text" value="固定值" /> // ❌ 这会让 input 变成只读
<input type="text" value={state} /> // ❌ 没有 onChange 会报警告
7.2 value 和 defaultValue 的区别
value 属性:
├── 受控模式:React 控制 value
│ 必须配合 onChange,否则 input 变成只读
│ value={state} onChange={e => setState(e.target.value)}
│
└── 如果只写 value 不写 onChange
React 会警告:提供了 value 但没有 onChange handler
defaultValue 属性:
└── 非受控模式:只在初始渲染时设置 DOM 的 value
之后 DOM 自行管理,React 不再干预
需要时用 ref.current.value 读取
7.3 file input:必须非受控
// file input 是天生的非受控组件
// 因为它的 value 是只读的,只能由用户通过文件选择器设置
function FileUpload() {
const fileRef = useRef(null);
const handleSubmit = () => {
const file = fileRef.current.files[0];
console.log('选择的文件:', file.name);
};
return (
<>
<input type="file" ref={fileRef} />
<button onClick={handleSubmit}>上传</button>
</>
);
}
八、表单状态管理的设计模式
8.1 三种状态管理方式对比
// 方式一:每个字段独立 useState(适合字段少、无联动)
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
// 方式二:单一状态对象(适合中等规模表单)
const [form, setForm] = useState({
username: '',
password: ''
});
// 方式三:useReducer(适合复杂表单,多字段联动、多动作)
const [state, dispatch] = useReducer(formReducer, initialState);
// dispatch({ type: 'CHANGE', name: 'username', value: 'abc' })
// dispatch({ type: 'RESET' })
// dispatch({ type: 'VALIDATE' })
8.2 单一状态对象的更新模式
// 更新单个字段
setForm(prev => ({
...prev,
[name]: value
}));
// 更新多个字段
setForm(prev => ({
...prev,
username: '',
password: ''
}));
// 重置表单
setForm({
username: '',
password: ''
});
8.3 派生状态:不要把能算出来的值存进 state
// ❌ 不需要把 isValid 存进 state,它可以计算得出
const [isValid, setIsValid] = useState(false);
// 每次 handleChange 都要手动 setIsValid
// ✅ 直接计算,React 每次渲染都会重新计算
const isValid = form.username && form.password &&
!errors.username && !errors.password;
// 自动响应 form 和 errors 的变化
九、总结
9.1 知识体系图
React 表单处理
│
├── 受控组件(Controlled)
│ ├── 原理:useState + value + onChange
│ ├── 数据流:State → UI → 用户输入 → State
│ ├── 单字段:ControlledInput
│ ├── 多字段:RegisterForm(单一状态对象 + 动态 key)
│ ├── 带校验:LoginForm(实时校验 + 错误状态 + 按钮禁用)
│ └── 适用:需要实时校验、格式化、联动的表单
│
├── 非受控组件(Uncontrolled)
│ ├── 原理:useRef + ref + 按需读取
│ ├── 数据流:用户输入 → DOM → 按需 ref.current.value
│ ├── 基础:UncontrolledInput
│ ├── 实战:CommentBox(textarea + 提交时读取)
│ ├── 默认值:defaultValue(不是 value)
│ └── 适用:简单表单、file input、性能敏感场景
│
├── 组件清单模式(Barrel File)
│ ├── index.js 统一导出
│ ├── 导入路径统一:from './components'
│ ├── 增删组件只改 index.js
│ └── 消费方与文件路径解耦
│
└── 表单设计模式
├── 状态管理:独立 useState / 单一对象 / useReducer
├── 派生状态:isValid 等可计算值不存入 state
├── 函数式更新:setForm(prev => ...) 避免闭包陷阱
└── 校验策略:实时校验 vs 提交时校验
9.2 核心概念速查
| 概念 | 要点 |
|---|---|
| 受控组件 | value={state} + onChange={setState},React 全权控制 |
| 非受控组件 | ref={ref} + 按需 ref.current.value,DOM 自治 |
| value vs defaultValue | value 是受控(需 onChange),defaultValue 是非受控(仅初始值) |
| 单一状态对象 | 多字段用 { ...prev, [name]: value } 统一管理 |
| 动态 key 更新 | name 属性映射 state key,[name]: value 动态更新 |
| 函数式更新 | setForm(prev => ...) 避免闭包中的旧值 |
| 派生状态 | isValid 等可计算值不存 state,每次渲染计算 |
| Barrel File | index.js 统一导出,简化导入路径 |
| file input | 天生非受控,value 只读 |
9.3 一句话总结
受控组件让 React 掌控一切——适合需要实时校验和复杂交互的表单;非受控组件让 DOM 自治——适合简单场景和性能敏感的大表单。选择的关键不是"哪个更好",而是"数据归谁管更合适"。
如果这篇文章对你有帮助,欢迎点赞和收藏!