
一句话:受控组件的「值」由 React 的 state 掌管(每次输入都要
setState),非受控组件的「值」由 DOM 自己掌管(需要时用ref去取);选择标准是「你是否需要实时响应输入」。
一、受控组件
function ControlledInput() {
const [value, setValue] = useState('');
return (
<input
value={value} // ⭐ 值由 state 决定
onChange={(e) => setValue(e.target.value)} // ⭐ 输入时更新 state
/>
);
}
数据流:
用户输入 → 触发 onChange → setState → 组件重渲染 → input 的 value 被设置为新值
↑ │
└──────────── DOM 上的值是「React 告诉你该显示什么」 ────────┘
特点:state 是唯一数据源(single source of truth)
典型用途:
// ① 实时校验
const [email, setEmail] = useState('');
const isValid = /^[^@]+@[^@]+$/.test(email);
<input value={email} onChange={(e) => setEmail(e.target.value)} />
{!isValid && email && <span>邮箱格式不正确</span>}
// ② 实时格式化(如手机号自动加分隔)
const handleChange = (e) => {
const digits = e.target.value.replace(/\D/g, '').slice(0, 11);
const formatted = digits.replace(/(\d{3})(\d{4})(\d{0,4})/, '$1 $2 $3').trim();
setPhone(formatted);
};
// ③ 强制大写 / 过滤非法字符
<input value={code} onChange={(e) => setCode(e.target.value.toUpperCase())} />
<input value={num} onChange={(e) => setNum(e.target.value.replace(/\D/g, ''))} />
// ④ 联动禁用按钮 / 联动其它字段
<button disabled={!value.trim()}>提交</button>
// ⑤ 受控多选
<select value={city} onChange={(e) => setCity(e.target.value)}>
<option value="bj">北京</option>
<option value="sh">上海</option>
</select>
// ⑥ 受控 checkbox
<input
type="checkbox"
checked={agree}
onChange={(e) => setAgree(e.target.checked)} // ⭐ 用 checked 不是 value
/>
// ⑦ 受控多选 checkbox 组(数组)
{options.map((opt) => (
<label key={opt.value}>
<input
type="checkbox"
checked={selected.includes(opt.value)}
onChange={(e) => {
setSelected((prev) =>
e.target.checked ? [...prev, opt.value] : prev.filter((v) => v !== opt.value)
);
}}
/>
{opt.label}
</label>
))}
受控组件的要素:
| 元素 | 受控属性 | 更新事件 |
|---|---|---|
<input type="text"> |
value |
onChange |
<textarea> |
value(不是 children) |
onChange |
<select> |
value(在 select 上,不在 option 上) |
onChange |
<input type="checkbox"> |
checked |
onChange(取 e.target.checked) |
<input type="radio"> |
checked + name |
onChange |
<input type="file"> |
❌ 无法受控 | — |
⚠️
file类型是唯一无法受控的输入——因为出于安全考虑,浏览器不允许 JS 设置文件输入的值。它只能是「非受控 +ref读取」。
二、非受控组件
function UncontrolledInput() {
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = () => {
console.log(inputRef.current?.value); // ⭐ 需要时去 DOM 取
};
return (
<>
<input ref={inputRef} defaultValue="初始值" />
<button onClick={handleSubmit}>提交</button>
</>
);
}
关键点:
① 用 defaultValue / defaultChecked 设置「初始值」(只在首次挂载生效)
② 用 ref 读取当前值
③ React 不参与值的更新 → 输入过程不会触发重渲染 → 性能更好
④ 适合「只关心提交时的最终值」的场景
// 也可以用 FormData(React 19 的 form action 会用到)
function Form() {
const formRef = useRef<HTMLFormElement>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data = new FormData(formRef.current!);
console.log(Object.fromEntries(data)); // { username: 'a', age: '18' }
};
return (
<form ref={formRef} onSubmit={handleSubmit}>
<input name="username" defaultValue="张三" />
<input name="age" type="number" defaultValue={18} />
<button type="submit">提交</button>
</form>
);
}
重要:非受控组件要用
name属性才能被FormData收集(这也与原生表单行为一致)。
三、对比与选择
| 维度 | 受控 | 非受控 |
|---|---|---|
| 值的来源 | React state | DOM 自身 |
| 读取方式 | 直接读 state | ref.current.value / FormData |
| 实时响应 | ✅ 每一次输入都知道 | ❌ 只能主动去取 |
| 代码量 | 多(要写 state + onChange) | 少 |
| 重渲染 | 每次输入都重渲染 | ❌ 不重渲染(性能更好) |
| 实时校验/格式化 | ✅ 容易 | ❌ 困难(要监听原生事件) |
| 联动(禁用按钮等) | ✅ 容易 | ❌ 困难 |
| 初值设置 | value={initial} |
defaultValue={initial} |
| 第三方表单库 | 需要包装(react-hook-form 用非受控模拟) | 原生支持 |
| 文件输入 | ❌ 不支持 | ✅ 唯一选择 |
选择标准:
需要「实时响应输入」吗?
├─ 是 → 受控
│ (实时校验、格式化、联动、条件禁用、字数统计)
└─ 否 → 非受控可能更合适
① 表单字段非常多,逐个受控很繁琐且性能差
② 只关心「提交时」的最终值
③ 集成非 React 的第三方库(如富文本编辑器、日期选择器)
④ file 输入(只能非受控)
// 性能对比示例:100 个受控输入框
// ❌ 每个输入框一个 state → 输入一个字符会让整个表单重渲染
function BadForm() {
const [fields, setFields] = useState<Record<string, string>>({});
return (
<form>
{Array.from({ length: 100 }, (_, i) => (
<input
key={i}
value={fields[i] ?? ''}
onChange={(e) => setFields((p) => ({ ...p, [i]: e.target.value }))}
/>
// ⚠️ 每敲一个字,100 个 input 全部重渲染
))}
</form>
);
}
// ✅ 方案一:非受控 + 提交时统一收集
function GoodForm() {
const ref = useRef<HTMLFormElement>(null);
const submit = () => console.log(Object.fromEntries(new FormData(ref.current!)));
return (
<form ref={ref}>
{Array.from({ length: 100 }, (_, i) => <input key={i} name={`f${i}`} />)}
<button type="button" onClick={submit}>提交</button>
</form>
);
}
// ✅ 方案二:拆成独立子组件(每个输入框自己管理 state)
function Field({ name }: { name: string }) {
const [v, setV] = useState(''); // 重渲染只影响这个 field
return <input value={v} onChange={(e) => setV(e.target.value)} />;
}
四、混合模式与常见细节
// ① 受控 + defaultValue 混用会警告
<input value={x} defaultValue="a" /> // ⚠️ 同时提供会警告
<input value={x} /> // ✅ 受控
<input defaultValue="a" /> // ✅ 非受控
// ② value 为 undefined 时会变成非受控(易踩坑)
const [v, setV] = useState<string | undefined>(undefined);
<input value={v} onChange={...} />
// 首次渲染:非受控(因为 value=undefined)
// 之后 setV('a'):变成受控
// ⚠️ 控制台报错:"A component is changing an uncontrolled input to be controlled"
// ✅ 用 `value={v ?? ''}` 保证始终是字符串
// ③ 受控但只读(不希望用户改但值来自 state)
<input value={computed} readOnly />
// ④ 用 state 控制「格式化后的显示」与「原始值的同步」
const [raw, setRaw] = useState('');
const display = useMemo(() => format(raw), [raw]);
<input value={display} onChange={(e) => setRaw(unformat(e.target.value))} />
// ⑤ 用第三方非受控组件(如日期选择器)时用 ref 读取
function DatePickerWrapper({ onChange }: { onChange: (d: Date) => void }) {
const pickerRef = useRef<ThirdPartyPicker>(null);
useEffect(() => {
const picker = new ThirdPartyPicker(pickerRef.current!);
picker.onChange((d: Date) => onChange(d));
return () => picker.destroy(); // ⭐ 清理第三方实例
}, [onChange]);
return <div ref={pickerRef} />;
}
五、常见坑
// ① checkbox 用了 value 而不是 checked
<input type="checkbox" value={agree} onChange={...} /> // ❌
<input type="checkbox" checked={agree} onChange={...} /> // ✅
// ② 受控组件忘了 onChange
<input value={text} /> // ⚠️ 输入无效果 + 控制台警告
// 报错:You provided a `value` prop to a form field without an `onChange` handler
// ③ 用 readOnly 消除上一个警告(只在确实只读时)
<input value={text} readOnly /> // ✅ 合法
// ④ textarea 用 children 而不是 value
<textarea>{value}</textarea> // ❌
<textarea value={value} onChange={...} /> // ✅
// ⑤ select 的 value 写在 option 上
<select>
<option value={v} selected>a</option> // ❌ React 用 select 的 value
</select>
<select value={v} onChange={...}> // ✅
// ⑥ 试图受控 file 输入
<input type="file" value={file} onChange={...} /> // ❌ 浏览器不允许
// ⑦ value 从 undefined 变成有值 → 受控/非受控切换警告
value={v ?? ''} // ✅ 保证类型稳定
// ⑧ 在 onChange 里做重计算导致输入卡顿
onChange={(e) => { setValue(e.target.value); heavyCompute(); }} // ⚠️ 每次输入都算
// ✅ 用防抖 或 useDeferredValue 或提交时再算
// ⑨ 需要「受控 + 光标位置」的复杂输入(如带分隔符的手机号)
// ⚠️ 直接格式化会导致光标跳到末尾 —— 需要手动管理 selectionStart/End
// ⑨ 一个「受控 + 光标保持」的简化处理
function PhoneInput() {
const [value, setValue] = useState('');
const ref = useRef<HTMLInputElement>(null);
const handleChange = (e) => {
const input = e.target;
const cursor = input.selectionStart ?? 0;
const before = input.value.slice(0, cursor).replace(/\D/g, '').length;
const digits = input.value.replace(/\D/g, '').slice(0, 11);
const formatted = digits.replace(/(\d{3})(\d{4})(\d{0,4})/, '$1 $2 $3').trim();
setValue(formatted);
// 下一帧把光标移到「与输入前相同数量的数字之后」
requestAnimationFrame(() => {
let pos = 0, count = 0;
while (pos < formatted.length && count < before) {
if (/\d/.test(formatted[pos]!)) count++;
pos++;
}
ref.current?.setSelectionRange(pos, pos);
});
};
return <input ref={ref} value={value} onChange={handleChange} />;
}
六、现代替代:非受控 + 表单库
// react-hook-form:内部用非受控 + ref,性能好、代码少
import { useForm } from 'react-hook-form';
function Form() {
const { register, handleSubmit, formState: { errors } } = useForm();
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register('username', { required: '请输入用户名' })} />
{errors.username && <span>{errors.username.message}</span>}
<input {...register('age', { valueAsNumber: true })} />
<button>提交</button>
</form>
);
}
| 方案 | 适用 |
|---|---|
| 受控(useState) | 字段少、需要实时联动/校验 |
| 非受控(ref / FormData) | 字段多、只需提交时的值、集成第三方控件 |
| react-hook-form | 中大型表单(性能好、校验内置) |
| Formik | 传统受控方案(现在更推荐 react-hook-form) |
React 19 的 <form action> + useActionState |
新项目的表单提交(原生 FormData + Actions) |
面试延伸
- 「受控组件和非受控组件的区别?」
区别在于「值的所有者」:受控组件的值由 React state 掌管(value+onChange),每次输入都要更新 state 并重渲染,因此 React 随时知道当前值;非受控组件的值由 DOM 自己掌管(用defaultValue设初值、ref读当前值),React 只在需要时去取。受控能实时响应,非受控代码更少、性能更好(不重渲染)。 -
「什么时候用非受控组件?」
① 只需要提交时的最终值(登录表单、搜索框提交时取值);② 字段非常多的大表单(受控会带来大量重渲染);③ 集成非 React 的第三方控件(富文本、日期选择器、地图);④<input type="file">——它只能非受控(浏览器出于安全不允许 JS 设置其值);⑤ 需要用原生FormData收集时。 -
「为什么
<input type="file">不能受控?」
因为浏览器出于安全考虑不允许 JavaScript 设置文件输入的值——否则恶意脚本就能「伪造」用户选择了一个文件(比如自动上传用户磁盘上的敏感文件)。所以value属性对它无效,React 也无法让它受控,只能用ref读取files。 -
「受控组件为什么会警告『value without onChange』?」
因为 React 认为你「想让 React 控制这个值」(提供了value),但又没有提供「值怎么变化」的路径(onChange)——这样用户输入将永远被value覆盖,输入框变成只读,属于明显的用法错误。如果确实要只读,应该显式写readOnly(这样 React 不会警告);如果是要显示但可改,就补上onChange。 -
「为什么
value={undefined}会导致「受控变非受控」的警告?」
因为 React 用「value是否为undefined」来判断组件是受控还是非受控:首次渲染时value === undefined→ 视为非受控(只设defaultValue那种行为);后续某次渲染value变成字符串 → React 认为它变成了受控。这种「中途切换」会让 React 无法确定该由谁管理值,于是发出警告。修复方式是value={v ?? ''}保证始终是字符串。 -
「
<textarea>和<select>的受控写法与原生有什么不同?」
两点:①<textarea>用value属性而不是把内容写在标签之间(<textarea>{value}</textarea>在 React 里是错的,会触发警告);②<select>的value写在<select>上(而不是给某个<option>加selected),React 会自动选中对应的选项。此外多选<select multiple>的value要传数组。 -
「受控组件的性能问题怎么解决?」
三个方向:① 拆分组件——把每个字段拆成独立子组件,让输入只重渲染那个字段(而不是整个表单);② 改用非受控 + 提交时收集(或用FormData);③ 用useDeferredValue/ 防抖——当输入要驱动昂贵的计算(如实时搜索列表)时,把「昂贵部分」延迟处理。此外 react-hook-form 就是用「非受控 + 订阅」的方式规避了这个问题。 -
「受控输入里做格式化(如手机号加空格)有什么坑?」
最大的坑是光标位置:如果onChange里返回了格式化后的字符串,React 会把input.value替换成它,光标会跳到末尾(用户无法在中间插入字符)。解决思路是「记录输入前的光标位置 → 计算对应的原始字符索引 → 格式化后根据同样的字符数把光标移回相应位置」(用setSelectionRange,通常在requestAnimationFrame里设置)。
一句话速记
受控 = React 管值(
value+onChange,实时可知、但每次输入都重渲染);非受控 = DOM 管值(defaultValue+ref/FormData,不重渲染、只取最终值);选择标准是「是否要实时响应输入」;file输入只能非受控(浏览器安全限制);checkbox 用checked、textarea/select 都用value、select 的 value 写在 select 上;value={v ?? ''}避免「受控/非受控切换」警告;大表单用「拆子组件 / 非受控 / react-hook-form」解决性能问题。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!