React 进阶必修:彻底搞懂受控组件与非受控组件
从源码到实战,一文带你吃透 React 表单数据管理的两种核心模式
📖 为什么你需要理解这个?
在 React 开发中,表单无处不在------登录注册、搜索框、评论输入、数据筛选......几乎每一个应用都离不开表单。
而 React 处理表单的方式,和原生 HTML 有着本质的区别。如果你只是"能写出来"但说不清为什么这么写,那么这篇文章就是为你准备的。
本文基于一个完整的项目 Demo,逐组件逐行剖析,帮你构建起清晰的"受控/非受控"心智模型。
一、两种哲学:谁才是数据的"主人"?
在 React 的世界里,表单数据有两个可能的归属:
受控组件(Controlled Component) :表单数据存储在 React State 中。State 是唯一的真相来源,React 驱动 UI 的每一次更新。
非受控组件(Uncontrolled Component) :表单数据由 DOM 自身维护。React 不参与中间过程,只在需要时才通过 ref 去读取 DOM 的当前值。
💡 一句话区分 :受控组件的值在
state中,React 掌控一切;非受控组件的值在 DOM 中,React"不闻不问",需要时伸手去拿。
二、受控组件(Controlled Component)
2.1 最简示例:一切从 state 开始
javascript
import { useState } from 'react';
function ControlledInput() {
const [value, setValue] = useState('');
return (
<>
Controlled Input
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</>
);
}
export default ControlledInput;
别看这段代码短,它包含了受控组件的全部精髓。让我们来拆解每一行:
第一步:声明状态容器
scss
const [value, setValue] = useState('');
这里 value 就是输入框的"真理之源"。此刻输入框显示什么,不是 DOM 说了算,而是这个 value 变量说了算。
第二步:将 state 绑定到 input
ini
value={value}
这是关键一步------React 接管了 input 的显示 。无论用户在键盘上敲什么,input 真正显示的内容永远是 value 这个 state 的值。
你可能会问:"那用户输入的内容,怎么反映到 state 里?"------答案在第三步。
第三步:通过 onChange 回收控制权
scss
onChange={(e) => setValue(e.target.value)}
每当用户输入,onChange 触发 → 拿到最新的输入值 → 更新 state → React 重新渲染 → input 显示最新 state。
这就是受控组件的完整闭环:用户键入字符 → onChange 触发 → setState 更新 → React 重新渲染 → input 显示最新值 → 用户继续键入 → 循环往复。每一帧画面都经过 React,React 是唯一的真相来源。
2.2 进阶实战:带即时验证的登录表单
上面是最基础的演示,真正的企业级受控组件远不止这么简单。来看 LoginForm 的完整实现:
ini
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 // 用 name 属性区分是哪个字段
});
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
name="username"
value={form.username}
onChange={handleChange}
type="text"
/>
{errors.username && (
<span className="error">{errors.username}</span>
)}
</div>
<div className="form-item">
<label>密码</label>
<input
name="password"
value={form.password}
onChange={handleChange}
type="password"
/>
{errors.password && (
<span className="error">{errors.password}</span>
)}
</div>
<button disabled={!isValid} type="submit">
登录
</button>
</form>
</div>
);
}
export default LoginForm;
2.3 LoginForm 的设计精要
这个表单看似平常,实则包含了受控组件在企业开发中的完整设计模式:
🎯 要点一:对象式 state 管理多个字段
php
const [form, setForm] = useState({
username: '',
password: ''
});
为什么不拆成两个 useState?
拆开写当然也可以:const [username, setUsername] = useState('') + const [password, setPassword] = useState('')。但表单字段一多,拆开写会导致状态分散、难以管理。对象式管理用一个 state 承载所有字段,配合下面的技巧,能用一个 handler 处理所有 input。
🎯 要点二:利用 e.target.name 实现一个 handler 管理所有字段
ini
const handleChange = e => {
const { name, value } = e.target;
setForm({
...form,
[name]: value // ← 动态 key,不管哪个 input 都能处理
});
validate(name, value); // ← 同时触发对应字段的校验
};
注意 :input 上的 name 属性必须和 state 对象的 key 完全一致:
ini
<input name="username" ... /> // → form.username
<input name="password" ... /> // → form.password
这是受控表单最重要的约定------name 属性是连接 DOM 与 State 的桥梁。
🎯 要点三:即时校验
校验函数遵循"以字段名为维度"的设计思路,每次只有一个字段在变化,每次也只更新一个字段的错误信息。校验时机也很讲究------放在 handleChange 中,跟随每次输入立即触发。这意味着用户一边打字,页面一边给出反馈,这是非受控组件几乎做不到的用户体验。
🎯 要点四:派生状态控制按钮
ini
const isValid =
form.username &&
form.password &&
!errors.username &&
!errors.password;
<button disabled={!isValid} type="submit">登录</button>
isValid 不是独立的 state,而是从 form 和 errors 两个已有 state 派生 出来的值。这避免了状态冗余------如果单独维护一个 isValid state,就需要在多个地方手动同步,容易出 bug。
🎯 要点五:错误状态独立管理
scss
const [errors, setErrors] = useState({});
将校验错误和表单数据分离成两个独立的 state,职责清晰:form 存数据,errors 存错误信息。两者各司其职,互不干扰,但又通过 validate 函数紧密协作。这是一种典型的"关注点分离"设计。
2.4 受控组件的完整数据流
把以上设计要点串联起来:
阶段一(用户输入不合法时) :用户输入 "a" → handleChange 触发 → form.username = "a" → validate 判断长度 < 3 不合法 → setErrors 更新 → isValid = false → 红色错误提示显示 → 登录按钮保持 disabled。
阶段二(用户输入合法时) :用户继续输入到 "abc" → handleChange 触发 → form.username = "abc" → validate 判断长度 ≥ 3 合法 → setErrors 清空 → isValid = true → 错误提示消失 → 登录按钮可点击。
这就是受控组件最迷人的地方------任何时刻你都能精确掌控 UI 的每一个像素。
三、非受控组件(Uncontrolled Component)
3.1 基础示例:把控制权还给 DOM
如果说受控组件是"React 说了算",那么非受控组件就是"DOM 说了算":
javascript
import { useRef } from 'react';
function UncontrolledInput() {
const inputRef = useRef(null);
const handleClick = () => {
console.log(inputRef.current.value); // 需要时才读取 DOM
};
return (
<>
Uncontrolled Input
<input type="text" ref={inputRef} />
<button onClick={handleClick}>提交</button>
</>
);
}
export default UncontrolledInput;
和受控组件的关键差异:
| 受控组件 | 非受控组件 | |
|---|---|---|
value 属性 |
✅ 有,绑定到 state | ❌ 没有 |
onChange 事件 |
✅ 有,更新 state | ❌ 没有 |
ref |
不需要 | ✅ useRef 获取 DOM |
| 获取值的方式 | state 直接读 |
ref.current.value 按需读 |
3.2 useRef 在非受控组件中的角色
javascript
const inputRef = useRef(null); // 1. 创建一个"引用容器"
<input ref={inputRef} /> // 2. React 将 DOM 节点放入容器
inputRef.current.value // 3. 任何时候都能读取最新的 DOM 值
useRef 创建一个包含 current 属性的普通 JavaScript 对象。挂载后,React 自动将真实 DOM 节点赋值给 ref.current。之后你可以通过 ref.current 访问任何原生 DOM 属性和方法------.value、.focus()、.style 等等。
⚠️ 重要 :
ref.current的值变化不会触发 React 重新渲染。这正是非受控组件和受控组件的本质差异------数据在 DOM 中流动,React 对此一无所知。
3.3 实践案例:评论输入框
再看一个更贴近真实业务场景的非受控组件------评论框:
javascript
import { useRef } from 'react';
function CommentBox() {
const textareaRef = useRef(null);
const handleSubmit = () => {
const comment = textareaRef.current.value;
if (!comment) {
alert('请输入评论内容');
return;
}
console.log(comment); // 拿到评论内容
// 提交后可以手动清空
// textareaRef.current.value = '';
};
return (
<div>
<textarea
placeholder="请输入评论内容"
ref={textareaRef}
/>
<button onClick={handleSubmit}>提交</button>
</div>
);
}
export default CommentBox;
这个场景用非受控组件非常合适,原因有二:
- 评论内容不需要即时反馈。你不需要在用户打字的时候做任何校验,也不需要根据输入内容实时改变 UI。只需要在点击"提交"的那一刻拿到内容即可。
- 性能更好 。如果用户输入一篇长评(几百上千字),受控组件会在每次按键时触发
setState→ 重新渲染。而在非受控组件中,React 完全不参与打字过程,零额外渲染。
📌 经验法则:凡是"填完一次性提交"的场景(评论、搜索、反馈表单),非受控组件往往更优。
3.4 非受控组件也能做校验
你可能会想:"非受控组件就不能做校验了吗?"
当然可以,只不过校验的时机不同。两者在校验上的差异:受控组件在输入过程中实时校验、实时 UI 反馈;非受控组件在提交时集中校验、一次性反馈(通过 alert 或提示框)。
ini
const handleSubmit = () => {
const comment = textareaRef.current.value;
// ✅ 在提交时做校验,完全可行
if (!comment) {
alert('请输入评论内容');
return;
}
if (comment.length < 10) {
alert('评论至少需要10个字');
return;
}
// 通过校验,提交数据
submitComment(comment);
};
选择哪种取决于产品需求,没有绝对的好坏。
四、工程化实践:组件清单管理
在这个 Demo 项目中,作者还展示了一个值得学习的好习惯------Barrel Export(桶式导出) 。
conponents/index.js:
javascript
// 清单文件
// 组件越来越多,index.js 文件中统一导出所有组件
import ControlledInput from './ControlledInput';
import UncontrolledInput from './UncontrolledInput';
import CommentBox from './CommentBox';
import RegisterForm from './RegisterForm';
import LoginForm from './LoginForm/index';
export {
ControlledInput,
UncontrolledInput,
CommentBox,
RegisterForm,
LoginForm
};
然后在 App.jsx 中一行引入全部:
javascript
import {
ControlledInput,
UncontrolledInput,
CommentBox,
RegisterForm,
LoginForm
} from './conponents';
function App() {
return (
<>
<ControlledInput />
<UncontrolledInput />
<CommentBox />
<RegisterForm />
<LoginForm />
</>
);
}
export default App;
为什么推荐这种做法?
不用 Barrel Export 时,每个组件需要单独一行 import,路径变更要改 N 处,import 语句散落各处。使用 Barrel Export 后,所有组件一行 import 搞定,路径变更只改 index.js,统一入口一目了然,index.js 本身就是组件地图。随着项目增长到几十上百个组件时,这种组织方式的价值会愈发明显。
五、受控 vs 非受控:全景对比
5.1 逐维度对比
| 维度 | 受控组件 | 非受控组件 |
|---|---|---|
| 数据存储位置 | useState |
DOM 元素自身 |
| 更新方式 | onChange → setState |
DOM 自管理,React 无感知 |
| 读取方式 | 直接读 state.xxx |
ref.current.value 按需读取 |
| 重新渲染 | 每次输入都触发重新渲染 | 不触发额外渲染 |
| 实时校验 | ✅ 天然支持,输入即校验 | ❌ 只能在提交时校验 |
| 条件按钮控制 | ✅ 派生状态轻松实现 disabled | ❌ 需要额外逻辑处理 |
| 输入格式化 | ✅ setState 时可以任意转换 | ❌ 需要手动操作 DOM |
| 代码量 | 较多(每个字段需 state + handler) | 较少(一个 ref 搞定) |
| React 哲学契合度 | ✅ "UI 是 state 的函数" | 偏传统 DOM 操作方式 |
5.2 场景决策表
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 登录/注册表单 | 受控组件 | 需要即时校验、按钮状态联动 |
| 搜索输入框 | 看情况 | 需要搜索建议→受控;只需回车搜索→非受控 |
| 评论输入框 | 非受控组件 | 一次性提交、不需要即时反馈 |
| 文件上传 | 非受控组件 | 安全限制,文件 input 必须是 Uncontrolled |
| 即时预览(Markdown 等) | 受控组件 | 每次输入都需要反映到预览区 |
| 复杂多步骤表单 | 受控组件 | 需要跨步骤的状态共享和校验 |
| 筛选器/下拉框 | 受控组件 | 选择结果需要驱动其他 UI 变化 |
| 内联编辑(点击编辑) | 受控组件 | 进入编辑态时需要注入初始值 |
5.3 如何判断?一个简单的自问清单
在做选择时,问自己三个问题:
- 用户输入时,UI 的其他部分需要同步变化吗? 是 → 受控组件
- 需要在输入过程中做实时验证吗? 是 → 受控组件
- 这个表单只是一个"填完提交"的简单场景吗? 是 → 非受控组件
六、常见误区与踩坑指南
误区 1:受控组件中忘记 onChange
less
// ❌ 错误:设置了 value 但没有 onChange
<input value={name} />
// 后果:输入框变成只读状态,用户无法输入
// 控制台警告:You provided a `value` prop without an `onChange` handler
React 的规则很简单:一旦设置了 value,就必须提供 onChange。否则 React 牢牢把 value 锁死在初始值上,用户键入的任何字符都会被 React 覆盖回去。
误区 2:非受控组件中混用 value 和 ref
xml
// ❌ 混乱的写法
<input value={someState} ref={inputRef} onChange={...} />
// 这样既不是纯受控也不是纯非受控,行为难以预测
选择一种模式就贯彻到底,不要在一个 input 上混用两种模式。
误区 3:受控表单中每个字段单独写 handler
ini
// ❌ 每个字段一个 handler,代码臃肿
const handleUsernameChange = e => setUsername(e.target.value);
const handlePasswordChange = e => setPassword(e.target.value);
const handleEmailChange = e => setEmail(e.target.value);
// ... 10 个字段 = 10 个 handler
// ✅ 一个通用 handler 处理所有字段
const handleChange = e => {
const { name, value } = e.target;
setForm(prev => ({ ...prev, [name]: value }));
};
误区 4:用 state 存储 ref 的值
scss
// ❌ 多余:把 ref 的值又同步到 state
const [value, setValue] = useState('');
const inputRef = useRef(null);
const handleClick = () => {
setValue(inputRef.current.value); // 把 DOM 值复制到 state
};
如果数据最终要在 state 中管理,那一开始就应该用受控组件。非受控组件的值放在 ref 中直接使用就够了,没必要画蛇添足同步到 state。
七、思维模型:从"怎么做"到"为什么"
7.1 React 的数据哲学
React 的核心理念是 UI = f(state) ------界面是状态的函数。受控组件是这一理念在表单领域的完美体现------表单的每一个像素,都是 state 的直接映射。
而非受控组件则是在"React 哲学"和"DOM 现实"之间的务实妥协。有些场景下,逐键追踪 state 的收益抵不上它的成本(代码复杂度、渲染开销),此时把管理权交还给 DOM 反而更明智。
7.2 一个类比帮你记住
- 受控组件像自动挡汽车:你踩油门(onChange),车载电脑决定档位(state 决定显示),你不需要关心发动机转速,电脑帮你管理一切。
- 非受控组件像手动挡汽车:你直接操作变速箱(ref 操作 DOM),更灵活、更直接,但也需要你更清楚自己在做什么。
🎯 总结
| 核心认知 | 一句话 |
|---|---|
| 本质区别 | 数据的"单一真相来源"在 state 中还是 DOM 中 |
| 受控组件三要素 | useState + value + onChange 三位一体 |
| 非受控组件核心 | useRef + 需要时读取 ref.current.value |
| 何时用受控 | 需要即时校验、UI 联动、状态驱动的场景 |
| 何时用非受控 | 简单提交、文件上传、不在乎中间过程的场景 |
| 工程化实践 | 用 Barrel Export 统一管理组件导出 |