React 受控与非受控组件:表单场景怎么选
写表单的时候你八成遇到过这个纠结:输入框到底要不要绑 value 和 onChange?绑了,每敲一个字符都要 setState,几十个字段的大表单卡到怀疑人生;不绑,又不知道怎么拿到用户填的值。这就是受控(controlled)和非受控(uncontrolled)组件的核心分歧。这篇不讲概念背诵,直接从真实场景讲清楚什么时候用哪个。
先分清:值到底存在哪
判断一个组件是受控还是非受控,只看一件事:输入框显示的值,由谁掌管。
- 受控:值存在 React 的 state 里,DOM 只是 state 的投影。
- 非受控:值存在 DOM 自己身上,React 平时不管,要用的时候临时去 DOM 里捞。
jsx
// 受控:state 是唯一真相,DOM 跟着 state 走
function ControlledInput() {
const [name, setName] = useState("");
return (
<input
value={name} // 值从 state 来
onChange={(e) => setName(e.target.value)} // 每次输入回写 state
/>
);
}
// 非受控:值住在 DOM 里,用 ref 去读
function UncontrolledInput() {
const inputRef = useRef(null);
const handleSubmit = () => {
// 提交时才去 DOM 拿值,平时 React 不参与
console.log(inputRef.current.value);
};
return (
<>
<input ref={inputRef} defaultValue="" />
<button onClick={handleSubmit}>提交</button>
</>
);
}
注意受控用 value,非受控用 defaultValue。两者混用是新手最常见的报错来源,下面会专门讲。
一个经典的坑:value 写死却没给 onChange
很多人第一次写受控组件会这么写:
jsx
// 错误:只给了 value,没给 onChange
<input value={name} />
React 会在控制台警告:You provided a value prop to a form field without an onChange handler。更要命的是这个输入框根本打不进字 ------因为 value 被 state 钉死了,而你没有任何地方去更新 state,DOM 永远显示旧值。
正确写法要么补上 onChange,要么你压根不想控制它就用 defaultValue:
jsx
// 想要受控:补 onChange
<input value={name} onChange={(e) => setName(e.target.value)} />
// 只想设初始值、不想管后续:用 defaultValue(非受控)
<input defaultValue={name} />
还有一个隐蔽版本:value={user.name},而 user.name 是 undefined,React 会把这个输入框当成非受控;等数据加载回来变成字符串,又切成受控,于是报 A component is changing an uncontrolled input to be controlled。解决办法是给个兜底:value={user.name ?? ""},让它从头到尾都是受控。
什么时候该用受控
受控的价值在于:输入的每一刻,你都能拿到值并据此做事。这些场景几乎只能用受控:
- 实时校验(边打边提示"密码太弱")
- 联动(选了省份,自动刷新城市下拉)
- 格式化(手机号自动加空格、金额自动加千分位)
- 提交按钮根据表单是否合法动态禁用
jsx
function SignupForm() {
const [email, setEmail] = useState("");
const isValid = /^[^@]+@[^@]+\.[^@]+$/.test(email);
return (
<form>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
{/* 边输入边校验,这只有受控做得到 */}
{!isValid && email && <span>邮箱格式不对</span>}
<button disabled={!isValid}>注册</button>
</form>
);
}
这里 isValid 每次输入都会重算,提示和按钮状态实时更新。换成非受控,你只能在提交那一刻才知道对不对,体验差一截。
什么时候该用非受控
非受控的价值在于:React 不参与输入过程,省掉每次敲键的重渲染。适合这些场景:
- 大表单、纯收集,提交时一次性读值,不需要实时反应
- 文件上传
<input type="file">------它天生只能非受控,文件值是只读的,你没法用 state 控制 - 接入非 React 的第三方 DOM 库
jsx
function Profile() {
const formRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
// 用原生 FormData 一次性把整张表单捞出来,不用给每个字段建 state
const data = new FormData(formRef.current);
console.log(Object.fromEntries(data));
};
return (
<form ref={formRef} onSubmit={handleSubmit}>
<input name="nickname" defaultValue="" />
<input name="bio" defaultValue="" />
<input type="file" name="avatar" /> {/* 文件只能非受控 */}
<button>保存</button>
</form>
);
}
FormData + name 属性是非受控表单的黄金搭档:字段再多,提交逻辑也只有几行,而且完全没有逐字符 setState 的开销。
性能真相:别被"受控很慢"吓住
网上常说受控组件慢,这话只对了一半。慢的根源不是受控本身,而是你把整张大表单的 state 提到了一个组件里,导致每敲一个字符整个表单重渲染。
解法不是放弃受控,而是把状态下沉:
jsx
// 每个字段自己管自己的 state,输入只重渲染当前这个 Field
function Field({ label }) {
const [value, setValue] = useState("");
return (
<label>
{label}
<input value={value} onChange={(e) => setValue(e.target.value)} />
</label>
);
}
function BigForm() {
// 父组件不持有各字段的值,输入不会触发父级重渲染
return (
<form>
{fields.map((f) => <Field key={f.id} label={f.label} />)}
</form>
);
}
字段状态各自独立后,敲字只重渲染那一个 Field,受控的性能问题基本消失。真正字段爆炸(上百个)且要统一管理时,再上 React Hook Form 这类库------它内部其实用非受控 + ref 收集,兼顾了性能和体验,这也侧面说明两种模式没有绝对优劣。
小结
- 判断标准 :值存 state = 受控(
value+onChange),值存 DOM = 非受控(defaultValue+ref),两者别混用。 - 要实时(校验、联动、格式化、动态禁用)→ 受控。
- 纯收集、文件上传、接第三方 → 非受控,配
FormData提交最省心。 - 受控卡顿多半是状态放太高,把 state 下沉到字段就能救,不是非得改非受控。
- 一句话记忆:需要"边打边反应"就受控,只要"最后拿结果"就非受控。