从零搞懂React受控与非受控组件------一个Demo串起表单处理全流程
刚学 React 的时候,我被"受控""非受控"这两个词唬住了,觉得很高深。后来发现不过是"数据存在哪"的区别------就像你记账,要么记在自己脑子里(state),要么翻本子看(DOM)。这篇文章用 5 个层层递进的组件把它讲透。
前言
学 React 表单处理的时候,你一定会遇到两个词:受控组件 和 非受控组件。刚开始我总搞混------哪个是 state 管,哪个是 ref 管?什么时候该用哪个?
这篇文章用一个完整的 demo 项目,从最基础的 <input> 到带校验的登录表单,把这两个概念一次讲清楚。
你将会收获:
- 🎯 用大白话理解"受控"和"非受控"的本质区别
- 🧩 手写从简单到复杂的 5 个组件,层层递进
- 📊 一张对比表搞懂 useState vs useRef 的选择
- 🔐 受控表单实时校验的完整实现思路
- ⚡
React.memo性能优化的入门用法 - 🏗️ 组件导出清单
index.js的工程化习惯
技术栈: React 19 + Vite + JSX
一、用大白话讲清楚:什么叫"受控"和"非受控"
痛点场景
假设页面上有一个输入框,你想拿到用户输入的内容。最简单的想法是------"输入框里有什么,我去读一下不就行了?"
没问题,这确实是一种方式。但 React 还有另一种思路:"输入框里是什么,由我来决定"。
这就是受控和非受控的本质区别。
大白话类比
受控组件:就像银行的柜员窗口------你填完单子递进去,柜员帮你处理,钱从哪来到哪去,每笔都记录在系统里。银行(React)全程掌握你账户的状态。
非受控组件:就像街边的自助取款机------你自己操作,取钱的时候才验一下身份。机器(DOM)只是工具,ATM 不关心你卡里还有多少钱。
换个更生活化的说法:
| 受控组件 | 非受控组件 | |
|---|---|---|
| 类比 | 你口头告诉会计"记一笔账",会计写在账本上 | 你自己写在自己本子上,会计不管 |
| 数据存哪 | React state | DOM 节点 |
| React 知道值吗 | 实时知道(每次按键都更新 state) | 不知道(需要时才去 DOM 读取) |
| 核心 API | useState + value + onChange |
useRef + ref |
| 一句话 | "输入框的值,由我 React 说了算" | "输入框的值,你自己管,我要的时候再问你" |
数据流对比
perl
受控组件(数据从 state → DOM,再通过 onChange → state):
┌─────────────┐ value属性 ┌──────────┐
│ useState │ ──────────────→ │ <input> │
│ (value) │ │ (DOM) │
└─────────────┘ └──────────┘
↑ │
└──────── onChange ────────────┘
每次按键都更新 state
非受控组件(数据留在 DOM,需要时用 ref 拿):
┌──────────┐ ref绑定 ┌──────────┐
│ useRef │ ← ─ ─ ─ ─ ─ ─ ─ → │ <input> │
│ (ref) │ │ (DOM) │
└──────────┘ └──────────┘
│ │
└── ref.current.value ─────────┘
需要时才读取,React 不追踪中间变化
二、受控组件:一切尽在 state 掌控中
2.1 最简受控输入------ControlledInput
先看一个极简的受控组件,只有 5 行核心代码:
jsx
import { useState } from 'react';
function ControlledInput() {
const [value, setValue] = useState(''); // state 是数据的"唯一真相来源"
return (
<>
<h3>受控组件</h3>
<input
value={value} // ① input 的值由 state 决定
onChange={(e) => setValue(e.target.value)} // ② 每次输入都更新 state
/>
<p>当前输入:{value}</p> // ③ 实时显示,state 一变就渲染
</>
)
}
export default ControlledInput;
逐行拆解:
| 行 | 做什么 | 为什么 |
|---|---|---|
const [value, setValue] = useState('') |
声明状态 | 这是数据的"家",所有值都存这里 |
value={value} |
state 同步到 input | 让 React 接管 input 的显示,而不是 DOM 自己管 |
onChange={(e) => setValue(e.target.value)} |
用户输入同步回 state | 形成一个闭环:输入 → 更新 state → 重新渲染 → input 显示新值 |
核心机制:单向数据流闭环
scss
用户按键 → onChange 触发 → setValue(新值) → React重渲染 → value属性更新 → input显示新字符
这就是"受控"的含义------input 的值完全受 React state 控制,没有 state 的允许,input 的值不会改变。state 是唯一数据源(Single Source of Truth)。
2.2 多字段受控表单------RegisterForm
一个输入框搞懂了,两个输入框(用户名 + 密码)呢?每个字段都写一个 state 当然可以,但更好的做法是用一个对象统一管理:
jsx
import { useState } from 'react';
function RegisterForm() {
const [form, setForm] = useState({
username: '',
password: '',
});
// 一个 handleChange 处理所有字段
const handleChange = (e) => {
const { name, value } = e.target; // 从事件中解构出 name 和 value
setForm({
...form, // 保留其他字段不变
[name]: value, // 动态属性名,精准更新当前字段
});
};
const handleSubmit = () => {
if (!form.username || !form.password) return;
console.log('提交:', form);
};
return (
<div>
<input
name="username" // name 用来区分是哪个字段
value={form.username}
onChange={handleChange}
placeholder="请输入用户名"
type="text"
/>
<input
name="password"
value={form.password}
onChange={handleChange}
placeholder="请输入密码"
type="password"
/>
<button type="submit" onClick={handleSubmit}>提交</button>
</div>
)
}
export default RegisterForm;
这里有两个关键技巧:
① [name]: value 动态属性名
jsx
[name]: value // 相当于 name='username' 时写 username: value
不同字段的 input 只要设不同的 name,就能用同一个 handleChange 处理。这就是为什么 input 上要写 name="username"------它不是摆设,是函数里用来区分字段的钥匙。
② ...form 展开保留其他字段
jsx
setForm({ ...form, [name]: value })
// ^^^^^^ 不写这个,改 username 时 password 就丢了!
setForm 是整体替换 ,不是合并。如果不展开 ...form,更新 username 时 password 变成 undefined。
⚠️ 常见坑: 新手容易写成
setForm({ [name]: value }),结果改一个字段另一个就消失了。记住:setState不会自动合并,得自己展开。
2.3 受控组件小结
perl
受控组件三要素:
┌──────────────────────────────────────┐
│ ① useState 声明状态 │
│ ② value 属性绑定 state │
│ ③ onChange 回写 state │
└──────────────────────────────────────┘
三、非受控组件:让 DOM 自己做主
3.1 最简非受控输入------UncontrolledInput
受控组件每次按键都触发渲染,那如果我不需要实时跟踪输入呢?比如我只是提交的时候拿一下值。
这时候就用非受控组件------input 的值 DOM 自己管,React 不插手,需要时通过 ref 去拿:
jsx
import { useRef } from 'react';
function UncontrolledInput() {
const inputRef = useRef(null); // 创建一个"引用",初始值是 null
const handleClick = () => {
console.log(inputRef.current.value); // 从 DOM 节点上直接拿值
}
return (
<>
<h3>非受控组件</h3>
<input
type="text"
ref={inputRef} // ref 绑定到 DOM 元素上
/>
<button onClick={handleClick}>获取输入值</button>
</>
)
}
export default UncontrolledInput;
和非受控的关键区别:
jsx
// ❌ 受控 ------ 有 value 和 onChange,state 管着一切
<input value={value} onChange={(e) => setValue(e.target.value)} />
// ✅ 非受控 ------ 没有 value,没有 onChange,只有 ref 绑定
<input ref={inputRef} />
输入过程中 React 根本不知道用户打了什么字,只有点击按钮时通过 inputRef.current.value 才读到值。
3.2 为什么要用 useRef 而不是普通变量?
你可能会想:为什么要 useRef?直接声明一个变量不行吗?
jsx
// ❌ 错误:每次渲染都会重新创建变量
let myRef = null;
// 组件重新渲染 → myRef 又变成 null → 绑定丢失
useRef 的关键特性: 返回的对象在组件的整个生命周期中保持不变 ------无论组件渲染多少次,inputRef 始终是同一个对象。这就是为什么它必须用 useRef 而不是 useState 或普通变量。
| 普通变量 | useState | useRef | |
|---|---|---|---|
| 渲染后保持值 | ❌ 每次重置 | ✅ | ✅ |
| 值变化触发重渲染 | ❌ | ✅ | ❌ |
| 适合场景 | --- | UI 数据 | DOM 引用/持久化值 |
一句话记住:
useRef是 React 给你的"保险箱"------东西放进去,不管组件渲染多少次,它都在那里,而且取东西的时候不需要重新渲染。
3.3 更实用的例子------CommentBox
文本框 + 提交按钮是典型场景:不需要实时跟踪输入,提交时一次性读取并清空即可。
jsx
import { useRef } from 'react';
function CommentBox() {
const textareaRef = useRef(null);
const handleSubmit = () => {
const comment = textareaRef.current.value; // ① 从 DOM 取值
if (!comment) return; // ② 空值拦截
console.log(comment); // ③ 提交
textareaRef.current.value = ''; // ④ 提交后清空输入框
}
return (
<div>
<textarea
placeholder="输入评论"
ref={textareaRef}
/>
<button onClick={handleSubmit}>提交评论</button>
</div>
)
}
export default CommentBox;
注意第 ④ 步: textareaRef.current.value = '' 是直接操作 DOM 来清空输入框。这在受控组件里绝不可能出现------受控组件通过 setValue('') 更新 state 来清空。非受控意味着你直接和 DOM 打交道,这正是它灵活的地方。
四、受控 vs 非受控:什么时候用谁?
核心对比
| 维度 | 受控组件 | 非受控组件 |
|---|---|---|
| 数据存在哪 | React state | DOM 节点 |
| 更新方式 | onChange → setState |
直接操作 DOM(用户输入) |
| 读取方式 | 直接读 state 变量 | ref.current.value 按需读取 |
| 实时校验 | ✅ 天然支持(state 每次都变) | ❌ 麻烦(要手动触发) |
| 条件禁用按钮 | ✅ 直接算 isValid |
❌ 需要额外的 onChange 或 submit 时校验 |
| 渲染次数 | 每次按键都渲染 | 不触发渲染 |
| 代码量 | 稍多(需要 value + onChange) | 更少(只需 ref) |
| "React 味" | 地道 ✓ | 偶尔用 |
选择速查
markdown
需要实时反馈?(搜索建议、即时校验、按钮状态联动)
├── 是 → 受控组件(useState)
└── 否 → 只需要提交时一次性取值?
├── 是 → 非受控组件(useRef)
└── 否 → 看情况,大多数场景受控更灵活
一句话记住:"实时关注用受控,一锤子买卖用非受控。"
五、进阶实战:受控表单 + 实时校验 ------ LoginForm
掌握基础后,来一个真正实用的:带实时输入校验的登录表单。这是受控组件的"完全体"------把所有优势发挥出来。
5.1 完整代码
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 // 只更新当前字段的 error
}));
}
const handleChange = e => {
const {name, value} = e.target;
setForm({ ...form, [name]: value }); // ① 更新 form
validate(name, value); // ② 同步校验
}
// 派生状态:不存 state,每次渲染当场算
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;
5.2 设计思路逐层拆解
第一层:两个 state,各司其职
jsx
const [form, setForm] = useState({ username: "", password: "" }); // 存值
const [errors, setErrors] = useState({}); // 存校验信息
数据和错误分开管理。为什么不合在一起?
因为 error 是"派生数据"------由输入值计算得出,不是用户直接输入的。单独存放职责更清晰,更新时也不用解构 errors。
第二层:validate 函数------校验逻辑独立抽离
jsx
const validate = (name, value) => {
let msg = "";
// 按字段名分发校验规则...
setErrors(prev => ({ ...prev, [name]: msg }));
}
这里用了函数式更新 setErrors(prev => ...),这是一个重要细节。
jsx
// ❌ 错误:直接读 errors,可能拿到过期值
setErrors({ ...errors, [name]: msg });
// ✅ 正确:用 prev 参数,保证总是拿到最新值
setErrors(prev => ({ ...prev, [name]: msg }));
连续快速输入时,
handleChange还没来得及把上一次的setErrors反映到errors变量上,下一次又调了。用prev参数可以避免这种"闭包陷阱",React 保证prev永远是最新的。
第三层:isValid ------ 派生状态,不存 state
jsx
// 每次渲染即时计算,不额外声明 state
const isValid = form.username && form.password
&& !errors.username && !errors.password;
为什么不写成 const [isValid, setIsValid] = useState(false) 然后在 validate 里更新?
因为
isValid完全由form和errors决定------它俩变了,isValid自然就变了。多存一份 state 不仅冗余,还可能因为没有同步更新而出 bug。能算出来的东西就不要存。
这个 isValid 用在了两个地方:
jsx
<button disabled={!isValid}>登录</button> // ① 按钮视觉禁用
// handleSubmit 里 if (!isValid) return; // ② 二次拦截(防止绕过)
第四层:handleSubmit ------ 最后一道防线
jsx
const handleSubmit = e => {
e.preventDefault(); // 阻止表单默认提交(会刷新页面)
if (!isValid) return; // 拦截:即使绕过了 disabled,校验也拦住
console.log(form); // 真正的提交逻辑
}
你不能只依赖
disabled属性来限制提交------HTML 表单按回车也可能触发提交。handleSubmit里的if (!isValid) return是最后一道保险。
5.3 整体数据流
scss
┌──────────────┐
│ 用户输入 │
└──────┬───────┘
▼
handleChange
╱ ╲
setForm(form) validate(name, value)
│ │
▼ ▼
form 更新 setErrors(...)
│ │
└──────┬─────────────┘
▼
组件重新渲染
│
┌──────────┴──────────┐
▼ ▼
input 显示新值 error 红色提示
│ │
└──────────┬──────────┘
▼
isValid 计算
│
▼
button disabled
六、组件管理的工程化习惯------index.js 导出清单
组件多了以后,App.jsx 的 import 会变成这样:
jsx
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';
5 个组件就 5 行,20 个呢?所以加一个 components/index.js 做统一导出清单:
jsx
// components/index.js ------ "组件清单文件"
import ControlledInput from './ControlledInput';
import UncontrolledInput from './UncontrolledInput';
import CommentBox from './components/CommentBox';
import RegisterForm from './RegisterForm';
import LoginForm from './components/LoginForm';
export {
ControlledInput,
UncontrolledInput,
CommentBox,
RegisterForm,
LoginForm,
}
然后 App.jsx 就可以一行搞定:
jsx
import { ControlledInput, UncontrolledInput, CommentBox, RegisterForm, LoginForm } from './components';
这个
index.js不包含任何业务逻辑,只是一个"出口"。就像超市收银台------消费者不需要知道货物从哪个仓库来的,统一从这里出去就对了。
七、延伸:React.memo 性能优化入门
上面讲的都是"怎么管理数据",再简单延伸一个相关的性能话题------组件重渲染。
7.1 问题:无关状态变化也触发子组件重渲染
jsx
import { useState, memo } from 'react';
function RegularChild({ name }) {
console.log('RegularChild 渲染了');
return <h1>{name}</h1>;
}
const MemoChild = memo(({ name }) => {
console.log('MemoChild 渲染了');
return <div>Hello, {name}</div>;
});
function App() {
const [count, setCount] = useState(0); // count 和 name 无关
const [name, setName] = useState('少林队');
return (
<>
<button onClick={() => setCount(count + 1)}>点击计数 {count}</button>
<button onClick={() => setName("峨眉队")}>改变名字</button>
<RegularChild name={name} /> {/* count 变了它也重新渲染! */}
<MemoChild name={name} /> {/* count 变了它不动,只有 name 变才渲染 */}
</>
);
}
export default App;
运行效果:
| 操作 | RegularChild | MemoChild |
|---|---|---|
| 点击"计数"(count+1) | 🔄 重新渲染(浪费!) | ✅ 不渲染 |
| 点击"改变名字"(name改) | 🔄 重新渲染(合理) | 🔄 重新渲染(合理) |
7.2 memo 做了什么?
arduino
React.memo 就是一个"门卫":
→ 每次父组件渲染,子组件也准备渲染
→ memo 拦住:"等等,让我看看你的 props 变了没"
→ props 没变 → 不渲染,直接用上一次的结果
→ props 变了 → 放行,正常渲染
jsx
const MemoChild = memo(RegularChild);
// 相当于给 RegularChild 包了一层"智能防抖"
⚠️ 注意:
memo做的是浅比较 ------只比 prop 的引用是否相同。如果你传了一个对象{ a: 1 },每次渲染都会创建新对象,memo就失效了。这时候需要配合useMemo/useCallback,但那是另一个话题了。
一句话记住:"不想让子组件跟着瞎渲染,就用 memo 包一层。"
总结
核心概念速查
| 概念 | 一句话 |
|---|---|
| 受控组件 | 表单数据存在 React state 里,value + onChange 绑定 |
| 非受控组件 | 表单数据留在 DOM,用 useRef 按需读取 |
| 用谁? | 需要实时反馈→受控;一次性取值→非受控 |
| form 对象 | 多字段用 {...form, [name]: value} 统一管理 |
| validate 抽离 | 校验逻辑独立成函数,和 handleChange 解耦 |
| 派生状态 | 能从已有 state 算出的值,不要存成新 state |
| prev => | 函数式更新避免闭包陷阱 |
| index.js 导出清单 | 多组件的统一出口,减少 import 行数 |
| React.memo | props 没变就不重渲染,给子组件加个"门卫" |
核心代码骨架
jsx
// 受控组件的"最小公式"
const [value, setValue] = useState('');
<input value={value} onChange={e => setValue(e.target.value)} />
// 非受控组件的"最小公式"
const ref = useRef(null);
<input ref={ref} />
// 需要时:ref.current.value
这个 demo 的完整代码我放在了 GitHub(项目地址),从 ControlledInput → UncontrolledInput → CommentBox → RegisterForm → LoginForm 五个组件,按照本文的顺序阅读就能从零理解受控和非受控。
希望这篇文章对你有帮助!有问题欢迎在评论区交流 🔥