React 中的受控组件和非受控组件是什么?
受控和非受控说的是“重要的状态由谁决定”。受控组件的值由父组件通过 props 决定,比如带 value 和 onChange 的 input;非受控组件自己保存状态,父组件只能给初值,比如只写 defaultValue 的 input。受控更灵活,非受控更省事,同一个输入框不能在两者之间切换。
简答
看一个输入框:写
<input value={name} onChange={e => setName(e.target.value)} />,输入框显示什么完全由name这个 state 决定,这是受控的。只写<input defaultValue="张三" />,React 只给了一个初值,之后的值由浏览器的输入框自己保存,这是非受控的。这对概念不只用于表单。react.dev 的说法是:组件的重要信息由自己的局部 state 决定,父组件影响不了,就叫非受控;由 props 驱动、父组件能完全指定它的行为,就叫受控。官方也说明这不是严格的技术术语,一个组件常常两者混用。
选择的依据是灵活性和使用成本。非受控组件用起来简单,父组件不用管它;可是多个组件要协调时就不方便。受控组件最灵活,父组件可以随时改它、校验它、让它和别的组件联动,代价是父组件要把 props 全部配好。
输入框有几条硬规则:传了
value就必须同时传onChange,否则输入框变成只读;onChange里要同步把 state 更新为e.target.value;value不能先是undefined后来变成字符串,因为 React 会把它从非受控切换成受控并发出警告,初值应该写成''。
两种输入框写法对比
import { useState } from 'react';
function Controlled() {
const [name, setName] = useState('');
return (
<>
<input value={name} onChange={(e) => setName(e.target.value)} />
<p>你好,{name || '陌生人'}</p>
</>
);
}
function Uncontrolled() {
function handleSubmit(e) {
e.preventDefault();
const formData = new FormData(e.target);
console.log(formData.get('name'));
}
return (
<form onSubmit={handleSubmit}>
<input name="name" defaultValue="张三" />
<button type="submit">提交</button>
</form>
);
}
受控版本里,每敲一个字都先进 onChange,更新 name,React 重新渲染,输入框再显示新的 name。所以下面的问候语能跟着实时变化。
非受控版本里,React 不知道用户输入了什么,要用的时候再去读,比如提交时用 FormData 按 name 取值,或者用 ref 读 DOM 的 value。
为什么受控的输入框必须同步更新
受控的意思是“React 强制输入框显示你传的 value”。所以只传 value 不传 onChange,用户敲字时 state 不变,输入框就一直显示原来的值,React 会在控制台报错,提示你要么加 onChange,要么改用 defaultValue,要么明确写 readOnly。
onChange 里的更新也有讲究。官方文档举了两个会出问题的写法:把 state 更新成跟 e.target.value 不一样的值,比如直接转大写;或者放进 setTimeout 里异步更新。这两种都可能让光标在每次按键后跳到开头。正确的做法是在 onChange 里同步地 setName(e.target.value)。
为什么不能在受控和非受控之间切换
value 是 undefined 时,React 认为输入框是非受控的;后来变成字符串,又成了受控的。React 不知道你到底想要哪种,就会报“A component is changing an uncontrolled input to be controlled”。
这个问题常见于数据从接口来的时候:user.name 一开始是 undefined,加载完才有值。官方的改法是初值写成 '',或者写 value={user.name ?? ''},保证 value 从头到尾都是字符串。复选框同理,checked 要始终是布尔值。
自己写组件时怎么同时支持两种用法
很多组件库的组件既能受控也能非受控:传了 value 就听父组件的,没传就用自己的内部状态。下面用一个普通对象模拟这个判断,省掉了 React 的渲染过程:
function createToggle({ value, defaultValue = false, onChange } = {}) {
let inner = defaultValue;
const isControlled = value !== undefined;
return {
get checked() {
return isControlled ? value : inner;
},
click() {
const next = !this.checked;
if (!isControlled) inner = next;
onChange?.(next);
},
};
}
const uncontrolled = createToggle({ defaultValue: false });
uncontrolled.click();
console.log(uncontrolled.checked); // true
let parentState = false;
const controlled = createToggle({ value: parentState, onChange: (next) => { parentState = next; } });
controlled.click();
console.log(controlled.checked, parentState); // false true
非受控的开关点一下就变了,因为它改的是自己的 inner。受控的开关点一下,checked 还是 false,只是通过 onChange 把新值告诉了父组件。为什么不自己变?因为受控组件的值只能由父组件传进来,在 React 里,父组件 setState 后重新渲染,把新的 value 传下来,开关才会变。这就是“单一数据源”。
常见追问
表单应该选受控还是非受控?
看什么时候要用这个值。输入时就要校验、格式化或联动,用受控;只在提交时要值,用非受控加
FormData更省事。具体对比见《受控组件与非受控组件应该如何选择?》。
受控输入框每敲一个字都渲染,会不会卡?
如果 state 所在的组件下面有一棵很大的树,可能会。官方的建议是把 state 放到离输入框更近的组件里,避免整棵树跟着渲染;实在避免不了,可以用
useDeferredValue让输入框保持响应。
改了 defaultValue,为什么输入框没变?
defaultValue只决定初值,之后的值由输入框自己保存。想让它重新取初值,可以给它换一个key,让 React 重新创建这个输入框。