
一句话:看「是否需要实时响应输入」——需要(实时校验、格式化、联动)就用受控;只需要「提交时的最终值」、字段很多、或集成非 React 控件就用非受控。
一、结论先行
问题:受控组件和非受控组件怎么选?
一句话回答:
「你需要【实时】知道用户输入的内容吗?」
├─ 需要(实时校验/格式化/联动/字数统计/条件禁用)→ 受控
└─ 不需要(只取提交时的值、字段太多、集成第三方控件)→ 非受控
⭐ 补充三条硬性事实:
① <input type="file"> 【只能】非受控(浏览器不允许 JS 设置它的值)
② 受控的性能代价是「每次输入都重渲染」(大表单要注意)
③ 大表单的现实选择是「非受控 + FormData」或「react-hook-form」
| 维度 | 受控 | 非受控 |
|---|---|---|
| 值的所有者 | React state | DOM 自己 |
| 读取方式 | 直接读 state | ref.current.value / FormData |
| 实时知道值 | ✅ | ❌(要主动读) |
| 每次输入都重渲染 | ✅(代价) | ❌ |
| 实时校验/格式化 | ✅ 容易 | ⚠️ 要监听原生事件 |
| 控件联动(禁用按钮) | ✅ 容易 | ❌ 困难 |
file 输入 |
❌ 不支持 | ✅ 唯一选择 |
| 集成第三方控件 | ⚠️ 需要包装 | ✅ 更自然 |
| 代码量 | 多(state + onChange) | 少 |
二、受控组件的写法与要点
function Controlled() {
const [value, setValue] = useState('');
return (
<input
value={value} {/* ⭐ 值由 state 决定 */}
onChange={(e) => setValue(e.target.value)} {/* ⭐ 必须有 onChange */}
/>
);
}
⭐ 受控的「数据流」:
用户输入 → onChange → setState → 重渲染 → input 的 value 被设为新值
→ state 是唯一数据源(Single Source of Truth)
⭐ 三类元素的关键属性:
· text / textarea / select → 用 value
· checkbox / radio → 用 checked(读 e.target.checked)
· file → 【无法受控】
// ⚠️ 最常见的三个坑
// ① 有 value 没 onChange(React 会警告,输入无效果)
<input value={value} /> // ❌
// ② value 从 undefined 变成有值(受控/非受控切换警告)
const [v, setV] = useState<string | undefined>(undefined);
<input value={v} onChange={...} /> // ⚠️ 首次非受控 → 之后受控
<input value={v ?? ''} onChange={...} /> // ✅ 始终是字符串
// ③ checkbox 用 value 而不是 checked
<input type="checkbox" value={agree} onChange={...} /> // ❌
<input type="checkbox" checked={agree} onChange={...} /> // ✅
// ✅ 受控的典型价值(实时响应)
// ① 实时校验 + 失焦后显示错误(体验最好)
const [email, setEmail] = useState('');
const [touched, setTouched] = useState(false);
const error = !email ? '请输入邮箱' : !/^\S+@\S+\.\S+$/.test(email) ? '邮箱格式不正确' : '';
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={() => setTouched(true)}
aria-invalid={!!error && touched}
/>
{touched && error && <p role="alert">{error}</p>}
// ② 实时格式化(手机号加空格)
const handleChange = (e) => {
const digits = e.target.value.replace(/\D/g, '').slice(0, 11);
setPhone(digits.replace(/(\d{3})(\d{4})(\d{0,4})/, '$1 $2 $3').trim());
};
// ③ 控件联动(禁用提交按钮)
<button disabled={!value.trim() || !agree}>提交</button>
三、非受控组件的写法与要点
function Uncontrolled() {
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = () => {
console.log(inputRef.current?.value); // ⭐ 需要时去 DOM 取
};
return (
<>
<input ref={inputRef} defaultValue="初始值" /> {/* ⭐ defaultValue 设初值 */}
<button onClick={handleSubmit}>提交</button>
</>
);
}
// ✅ 更推荐的方式:用 FormData 收集(一次拿全,代码最少)
function SimpleForm() {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.currentTarget));
console.log(data); // { username: '张三', age: '18' }
};
return (
<form onSubmit={handleSubmit}>
<input name="username" defaultValue="张三" /> {/* ⭐ 必须有 name */}
<input name="age" type="number" defaultValue={18} />
<input type="file" name="avatar" /> {/* ✅ file 只能这样 */}
<button type="submit">提交</button>
</form>
);
}
⭐ 非受控的三个关键点:
① 用 `defaultValue` / `defaultChecked` 设【初始值】(只在首次挂载生效)
② 用 `ref` 或 `FormData` 读【当前值】
(用 FormData 时,元素必须有 `name`,否则收集不到)
③ 输入过程【不触发重渲染】→ 性能更好
四、性能对比:大表单怎么办
// ❌ 100 个受控输入框:每次输入让整个表单重渲染
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 }))}
/>
))}
</form>
);
}
// ⚠️ 每敲一个字,100 个 input 全部重渲染
// ✅ 方案一:非受控 + FormData(提交时收集)
function GoodForm() {
const ref = useRef<HTMLFormElement>(null);
const submit = () => {
const data = Object.fromEntries(new FormData(ref.current!));
console.log(data);
};
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 [value, setValue] = useState(''); // ⭐ 重渲染只影响这个 Field
return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}
// → 输入时只有「当前这个 Field」重渲染
// ⚠️ 但如果「父组件需要一次性拿到所有值」,需要「状态提升」或「用一个 store」
// ✅ 方案三:react-hook-form(非受控 + 订阅,性能与开发体验都好)
import { useForm } from 'react-hook-form';
function Form() {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm();
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register('username', { required: '请输入用户名' })} />
{errors.username && <span>{errors.username.message}</span>}
<button disabled={isSubmitting}>提交</button>
</form>
);
}
| 方案 | 适合 | 代价 |
|---|---|---|
受控(useState) |
字段少 + 需要实时联动 | 每次输入重渲染 |
| 拆分受控子组件 | 中等数量 + 需要受控 | 状态分布在子组件里 |
非受控 + FormData |
只需提交时的值、含 file |
无法实时响应 |
react-hook-form |
中大型表单 | 引入依赖 |
| React 19 的 Actions | 新项目 + 服务端提交 | 需要新版本 |
五、混合模式(现实中很常见)
// ✅ 「受控 + 非受控」的折中:用「本地 state 承载输入,防抖/失焦时同步到上层」
function HybridInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [draft, setDraft] = useState(value); // 本地草稿
// 外部变化时同步回来(⭐ 注意避免「输入中被打断」)
useEffect(() => {
setDraft(value);
}, [value]);
return (
<input
value={draft}
onChange={(e) => setDraft(e.target.value)} {/* 本地实时更新(不重渲染父组件)*/}
onBlur={() => onChange(draft)} {/* 失焦时才通知父组件 */}
/>
);
}
// ⭐ 这样「输入过程」只影响这个组件,「父组件」只在失焦时更新
// ✅ 「受控的值为准 + 非受控的输入」:用 ref 读值,用 state 做只读展示
function AmountInput({ max }: { max: number }) {
const ref = useRef<HTMLInputElement>(null);
const [display, setDisplay] = useState('');
return (
<>
<input
ref={ref}
defaultValue=""
onChange={(e) => setDisplay(e.target.value.replace(/\D/g, '').slice(0, max))}
// ⚠️ 注意:这里的 display 与输入框的「真实值」需要自己保持同步
/>
<p>已输入 {display.length} / {max} 个字符</p>
</>
);
}
// ⚠️ 这种「混合」写法要小心「值不一致」(通常不是好选择,受控更清晰)
六、file 输入为什么只能非受控
// ❌ 无法受控
<input type="file" value={file} onChange={...} /> // 浏览器会忽略 value
// ✅ 只能这样
const fileRef = useRef<HTMLInputElement>(null);
const file = fileRef.current?.files?.[0];
⭐ 原因(安全):
浏览器出于【安全考虑】不允许 JavaScript「设置」文件输入的值——
否则恶意脚本就能「伪造『用户选择了一个文件』」(如自动上传磁盘上的敏感文件)。
所以 `value` 对它无效,React 也无法让它受控。
七、常见追问
Q: 受控组件一定比非受控「慢」吗?
A: 不一定的。字段少时差异可以忽略;只有「字段极多」或「输入触发重计算」时明显。
关键不是「受控慢」,而是「输入驱动的重渲染范围有多大」。
Q: 为什么「受控」会「每次输入都重渲染」?
A: 因为每次输入 onChange 都要 setState → 触发组件重新渲染。
(可以用「拆分组件」把重渲染限制在「当前这个输入框」)
Q: 「非受控」能实时校验吗?
A: 能,但要「自己监听原生事件」(用 ref + addEventListener('input')),
比受控麻烦,且状态来源不唯一(DOM 与 state 两份)。
Q: 受控组件里「输入中文」有问题吗?
A: 有——「拼音输入过程中」onChange 会以「拼音字母」触发(composition 过程),
某些「实时格式化」逻辑会打断输入法。
✅ 用 e.nativeEvent.isComposing 判断(见 1.6);
或在 onCompositionEnd 后再处理。
Q: 「实时格式化」导致的「光标跳末尾」怎么解决?
A: 需要「手动保存/恢复光标位置」(setSelectionRange)——
记录「输入前的光标位置对应的原始字符索引」,格式化后再映射回去(见 1.5)。
Q: React 19 的 form action 属于哪一类?
A: 它更接近「非受控」——`<form action={fn}>` 会把「FormData」传给 action,
由 React 处理「preventDefault 与表单重置」;
不需要为每个字段维护 state(配合 useActionState/useFormStatus 管状态)。
Q: 用 `ref` 拿值的「受控组件」算受控还是非受控?
A: 算非受控(值的所有者是 DOM)。
「受控」的定义是「值由 React state 决定」。
Q: 如何判断「值是不是受控」?
A: 看「输入时是否必须经过 React」——
如果「删掉 onChange 输入框就动不了」→ 受控;
如果「输入框能自由输入」→ 非受控。
八、常见坑
// ① 受控组件忘了 onChange(输入无效果 + 警告)
// ② checkbox 用了 value(应该用 checked)
// ③ select 的 value 写在 option 上(应该写在 select 上)
// ④ textarea 用 children 而不是 value
<textarea>{value}</textarea> // ❌
<textarea value={value} onChange={...} /> // ✅
// ⑤ 试图受控 file 输入
// ⑥ value 从 undefined 变有值(受控/非受控切换)
// ⑦ 非受控组件忘了 name(FormData 收集不到)
// ⑧ 用 FormData 时「忘记 preventDefault」(页面刷新)
// ⑨ 大表单「每个字段一个 state 且都在父组件」(输入卡顿)
// ⑩ 在 onChange 里做「昂贵计算/请求」(输入卡顿)→ 用防抖/useDeferredValue
// ⑪ 实时格式化导致「光标跳末尾」
// ⑫ 实时格式化「打断中文输入法」
// ⑬ 认为「非受控就不能校验」(能在提交时校验)
// ⑭ 认为「受控一定更好」(字段多时是负担)
// ⑮ 在「第三方非受控控件」上硬套受控(应该用 ref 包装)
// ⑯ 忘记「表单重置」时受控字段也需要重置(要手动 setState 或设计好 key)
// ⑥ 的正确处理
const [v, setV] = useState<string | undefined>(undefined);
<input value={v ?? ''} onChange={(e) => setV(e.target.value)} /> // ✅ 保证类型稳定
// ⑯ 的重置方案
// ✅ 方案一:手动重置所有 state
const handleReset = () => setForm(INITIAL_FORM);
// ✅ 方案二:用 key 强制重建表单(⭐ 更彻底)
<Form key={formKey} />
// 点「重置」时 setFormKey((k) => k + 1)
九、面试延伸
- 「受控组件和非受控组件怎么选?」
判断标准是「是否需要实时响应输入」:① 需要(实时校验、格式化、字数统计、条件禁用按钮、字段联动)→ 受控;② 不需要(只要提交时的最终值)→ 非受控(代码更少、不重渲染);③ 字段很多(几十上百个)→ 非受控(或拆分受控子组件、或用react-hook-form);④ 集成非 React 的第三方控件(富文本、日期选择器)→ 非受控更自然;⑤<input type="file">→ 只能非受控。 -
「为什么受控组件必须写
onChange?」
因为 React 认为你「想让 React 控制这个值」(你提供了value),但没有提供「值怎么变」的路径(onChange)——于是用户的输入会被value立即覆盖回去,输入框变成只读,React 会发出警告(You provided a value prop to a form field without an onChange handler)。如果确实要只读,要显式写readOnly(这样不会警告)。 -
「
<input type="file" />为什么不能受控?」
因为浏览器出于安全考虑不允许 JavaScript 设置文件输入的值——否则恶意脚本就能「伪造『用户选择了一个文件』」(如自动上传用户磁盘上的敏感文件)。所以value属性对它无效,React 也无法让它受控,只能用ref读取files(或用FormData收集)。它是表单元素里唯一无法受控的类型。 -
「大表单的性能问题怎么解决?」
三个方向:① 拆分组件——每个字段自己管理 state(输入只重渲染那个字段,而不是整张表单);② 改用非受控(ref/FormData)——完全不触发重渲染;③ 用react-hook-form——它内部用「非受控 + 订阅」,只有「注册了该字段的组件」会在它变化时重渲染(这是它性能好的原因)。此外要「避免在onChange里做昂贵计算/请求」(用防抖或useDeferredValue)。 -
「受控组件里做实时格式化有什么坑?」
两个坑:① 光标位置——如果你在onChange里返回「格式化后的字符串」,React 会替换input.value,光标会跳到末尾(用户无法在中间插入字符);解法是「记录输入前的光标位置 → 映射到格式化后的位置 → 用setSelectionRange恢复」(通常在requestAnimationFrame里设置);② 中文输入法——拼音输入过程中onChange会以「拼音字母」触发,实时格式化会打断输入法;解法是用e.nativeEvent.isComposing判断(为true时跳过格式化)。 -
「
defaultValue和value有什么区别?」
value是「受控」——React 持续控制它的显示(必须配onChange,输入过程经过 React);defaultValue是「非受控」——只在「首次挂载」把值写进 DOM,之后由 DOM 自己管(React 不再干预)。同理defaultChecked与checked。两者同时提供会警告(React 不知道该听谁的)。 -
「
value={undefined}为什么会导致『受控变非受控』的警告?」
因为 React 用「value是否为undefined」来判断「这个组件是受控还是非受控」:首次渲染时value === undefined→ 视为非受控;后续某次渲染value变成字符串 → React 认为它变成了受控。这种「中途切换」让 React 无法确定「该由谁管理值」,于是发出警告。修复方式是value={v ?? ''}(保证始终是字符串)。 -
「React 19 里的
form action属于哪一类?」
更接近「非受控」:<form action={fn}>提交时 React 会把FormData传给fn,并自动处理preventDefault与「表单重置」——所以你不需要为每个字段维护 state。配合useActionState(管理返回值与pending)、useFormStatus(子组件读提交状态)、useOptimistic(乐观更新),可以覆盖「受控表单」的绝大部分需求,代码量更少。它代表 React 官方在「表单」上的方向:减少手写 state。
一句话速记
选择标准是「是否需要实时响应输入」:需要(实时校验/格式化/联动/字数统计/条件禁用)→ 受控(
value+onChange,代价是每次输入重渲染);只要提交时的值 / 字段多 / 集成第三方控件 → 非受控(defaultValue+ref/FormData,元素要有name);<input type="file">只能非受控(浏览器安全限制);checkbox用checked、textarea/select用value、select的 value 写在 select 上;value={v ?? ''}避免「受控/非受控切换」警告;大表单用「拆分受控子组件 / 非受控 + FormData /react-hook-form(非受控 + 订阅)」;实时格式化要注意「光标跳末尾」与「打断中文输入法(isComposing)」;React 19 的<form action>更接近非受控(配合useActionState/useFormStatus/useOptimistic)。



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