React 受控组件与非受控组件
从表单说起
表单是 Web 开发中最常见的交互元素之一。当你在 React 中处理表单时,你会遇到一个根本性的问题:表单的值应该由 React 管理,还是由 DOM 自己管理?
这是一个看似简单但实际有深度的问题。它涉及 React 的数据流理念、组件状态设计、以及性能和工程实践的权衡。
受控组件的定义
受控组件是指:表单的值由 React state 完全控制的组件。
对于受控组件,每个表单元素都有一个对应的 state 和 onChange 处理函数:
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
function handleEmailChange(e) {
setEmail(e.target.value);
}
function handlePasswordChange(e) {
setPassword(e.target.value);
}
function handleSubmit(e) {
e.preventDefault();
console.log({ email, password });
}
return (
<form onSubmit={handleSubmit}>
<div>
<label>Email</label>
<input
type="email"
value={email}
onChange={handleEmailChange}
/>
</div>
<div>
<label>Password</label>
<input
type="password"
value={password}
onChange={handlePasswordChange}
/>
</div>
<button type="submit">Login</button>
</form>
);
}
在这个例子里:
email和password都是 React state- 输入框的
value属性绑定到 React state - 输入框的
onChange事件更新 React state - 表单的值完全由 React 控制
非受控组件的定义
非受控组件是指:表单的值由 DOM 自己维护,用 ref 来获取值的组件。
function LoginForm() {
const emailRef = useRef(null);
const passwordRef = useRef(null);
function handleSubmit(e) {
e.preventDefault();
console.log({
email: emailRef.current.value,
password: passwordRef.current.value
});
}
return (
<form onSubmit={handleSubmit}>
<div>
<label>Email</label>
<input type="email" ref={emailRef} />
</div>
<div>
<label>Password</label>
<input type="password" ref={passwordRef} />
</div>
<button type="submit">Login</button>
</form>
);
}
在这个例子里:
- 没有 state 来存储 email 和 password
- 用
ref获取 DOM 元素的引用 - 在提交时才通过
ref.current.value读取值 - 表单的值由 DOM 自己管理
使用 defaultValue 初始化
非受控组件可以用 defaultValue 来设置初始值:
function LoginForm() {
const [defaultEmail] = useState('[email protected]');
return (
<form>
<input
type="email"
ref={emailRef}
defaultValue={defaultEmail}
/>
<input
type="password"
ref={passwordRef}
defaultValue="secret"
/>
</form>
);
}
注意:只能用 defaultValue 设置初始值,之后值的控制权就在 DOM 手里了。如果你在非受控组件上使用 value 属性,会导致输入框无法编辑(除非你也绑定了 onChange)。
什么时候用受控组件
受控组件适合这些场景:
场景一:需要实时验证
function EmailInput() {
const [email, setEmail] = useState('');
const [error, setError] = useState('');
function handleChange(e) {
const value = e.target.value;
setEmail(value);
if (!value.includes('@')) {
setError('请输入有效的邮箱地址');
} else {
setError('');
}
}
return (
<div>
<input value={email} onChange={handleChange} />
{error && <span style={{ color: 'red' }}>{error}</span>}
</div>
);
}
实时验证需要每次输入都触发验证逻辑,这必须是受控的。
场景二:需要联动其他字段
function BillingForm() {
const [country, setCountry] = useState('US');
const [state, setState] = useState('');
const [zipCode, setZipCode] = useState('');
const stateOptions = getStateOptions(country);
const zipPattern = getZipPattern(country);
return (
<div>
<select value={country} onChange={e => setCountry(e.target.value)}>
<option value="US">United States</option>
<option value="CA">Canada</option>
</select>
<select value={state} onChange={e => setState(e.target.value)}>
{stateOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<input
value={zipCode}
onChange={e => setZipCode(e.target.value)}
pattern={zipPattern}
/>
</div>
);
}
联动逻辑需要:
- 改变 country 时重置 state 和 zipCode
- 根据 country 显示不同的 zip code 格式
这种联动逻辑在受控组件中很自然,在非受控组件中几乎无法实现。
场景三:需要动态禁用/启用输入
function FormWithTerms() {
const [agreed, setAgreed] = useState(false);
return (
<div>
<label>
<input
type="checkbox"
checked={agreed}
onChange={e => setAgreed(e.target.checked)}
/>
I agree to terms
</label>
<button disabled={!agreed}>Submit</button>
</div>
);
}
按钮的 disabled 状态依赖 checkbox 的值,这天然是受控的。
什么时候用非受控组件
非受控组件适合这些场景:
场景一:文件上传
function FileUpload() {
const fileRef = useRef(null);
function handleSubmit(e) {
e.preventDefault();
const file = fileRef.current.files[0];
uploadFile(file);
}
return (
<form onSubmit={handleSubmit}>
<input type="file" ref={fileRef} />
<button type="submit">Upload</button>
</form>
);
}
<input type="file"> 的值只能由用户选择,不能通过 JavaScript 设置。这使得它天然适合非受控模式。
场景二:集成第三方控件
当你使用第三方日期选择器、富文本编辑器等控件时,这些控件自己管理内部状态。强行改成受控模式可能需要大量工作:
function ThirdPartyDatePicker({ onDateChange }) {
const pickerRef = useRef(null);
function handleSubmit() {
// 从第三方控件读取日期
const date = datePickerRef.current.getDate();
onDateChange(date);
}
return (
<div>
<DatePicker ref={datePickerRef} />
<button onClick={handleSubmit}>Confirm</button>
</div>
);
}
场景三:性能敏感的简单表单
对于非常简单的表单,且不需要验证或联动,受控组件可能显得「过度工程」:
// 简单场景:只需要提交,不需要中间处理
function SimpleContactForm() {
const nameRef = useRef(null);
const messageRef = useRef(null);
function handleSubmit() {
sendForm({
name: nameRef.current.value,
message: messageRef.current.value
});
}
return (
<form onSubmit={handleSubmit}>
<input ref={nameRef} placeholder="Name" />
<textarea ref={messageRef} placeholder="Message" />
<button>Send</button>
</form>
);
}
但要注意:一旦你发现需要加验证或联动,就需要改成受控模式。
受控/非受控混用:部分受控
有时候你会遇到一个值同时支持 onChange 和 defaultValue:
function PartiallyControlled({ value, onChange }) {
return (
<input
value={value}
onChange={e => onChange(e.target.value)}
defaultValue="initial"
/>
);
}
这个组件同时接收 value 和 defaultValue:
- 如果传了
value,组件是受控的 - 如果没传
value,组件是非受控的,初始值是defaultValue
这种「部分受控」模式有一个术语:「受控组件优先」(controlled component is preferred)。
React 对这个的处理是:
- 如果提供了
value,React 使用value - 如果没有提供
value(value === undefined),React 使用defaultValue作为初始值,之后由 DOM 管理
为什么不推荐混用
混用容易导致困惑和 bug:
// 问题:defaultValue 会被忽略
<input value={undefined} defaultValue="initial" />
// React 会忽略 defaultValue,因为 value 不是 undefined
// 问题:value 和 onChange 不配套
<input
value="hello"
// 没有 onChange!输入会被锁定,用户无法输入
/>
正确的做法是:要么完全受控(value + onChange),要么完全非受控(ref 或 defaultValue),不要混用除非你明确知道自己在做什么。
defaultValue vs value:controlled vs uncontrolled 的分界线
理解 defaultValue 和 value 的区别是理解受控/非受控的关键。
defaultValue(uncontrolled)
<input defaultValue="hello" />
- 只设置初始值
- 之后 DOM 完全控制这个输入框
- 适合不需要追踪中间值的场景
value(controlled)
const [value, setValue] = useState('hello');
<input value={value} onChange={e => setValue(e.target.value)} />
- React state 完全控制值
- 每次输入都会触发状态更新
- 适合需要追踪、验证、联动的场景
不要同时用 value 和 defaultValue
// 错误:混用导致行为不一致
<input value="hello" defaultValue="world" />
// React 会忽略 defaultValue,只使用 value
// 但如果没有 onChange,输入会被锁定
面试高频题:为什么 React 推荐受控组件
面试中经常会被问到:「既然非受控组件更简单,为什么 React 更推荐受控组件?」
原因一:数据流的可预测性
受控组件的数据流是单向的:用户输入 → onChange → state 更新 → 重新渲染。这个循环清晰可追踪。
// 数据流清晰
User types 'a'
→ onChange fires
→ setEmail('a')
→ component re-renders
→ value becomes 'a'
非受控组件的数据流是「隐式的」:用户输入 → DOM 内部更新 → 在某个时候(提交时)读取 ref.current.value。这个数据流没有在 React 的渲染循环中体现,难以追踪。
原因二:状态初始化的灵活性
受控组件可以在任何时候重置状态:
function ResetableForm() {
const [email, setEmail] = useState('');
function handleReset() {
setEmail('');
}
return (
<div>
<input value={email} onChange={e => setEmail(e.target.value)} />
<button onClick={handleReset}>Reset</button>
</div>
);
}
非受控组件要重置,需要操作 DOM:
// 非受控组件重置比较 hacky
function ResetableForm() {
const emailRef = useRef(null);
function handleReset() {
emailRef.current.value = ''; // 直接操作 DOM
}
return (
<div>
<input ref={emailRef} defaultValue="" />
<button onClick={handleReset}>Reset</button>
</div>
);
}
原因三:测试的便利性
受控组件的测试更容易,因为状态变化是通过 props 和回调函数进行的:
// 受控组件测试
render(<Input value="" onChange={handleChange} />);
fireEvent.change(input, { target: { value: 'test' } });
expect(handleChange).toHaveBeenCalledWith('test');
非受控组件测试需要模拟 DOM 交互:
// 非受控组件测试
render(<Input defaultValue="" />);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: 'test' } });
expect(input.value).toBe('test');
原因四:与 React 的设计理念一致
React 的核心哲学是**「UI 是状态的函数」**:UI = f(state)。受控组件完美符合这个理念:给定一个状态,就有一个确定的 UI。非受控组件引入了 React 之外的「隐式状态」,与这个理念相悖。
常见误区
误区一:受控组件一定比非受控组件性能差
受控组件每次输入都触发重新渲染,但这不一定意味着性能差:
- 现代 React 的 re-render 优化(如 React.memo、useMemo)可以避免不必要的重渲染
- 对于简单的输入,性能差异通常可以忽略
- 真正的性能问题往往来自其他方面(不必要的重渲染、大列表等)
误区二:所有表单都应该用受控组件
不一定。文件上传、第三方控件集成等场景,非受控组件更合适。
误区三:受控组件必须用 useState
不一定。可以用 useReducer、Zustand、Redux 等任何状态管理方案。关键是值由某种「React 可追踪的状态」控制,而不是由 DOM 自己控制。
延展阅读
- [React Docs: Forms](https://react.dev/learn/managing-state# CHOOSING-the-state-structure) — 官方表单文档
- [React Docs: Uncontrolled Components](https://react.dev/learn/refs-and-the-dom#) — 非受控组件文档
- Kent C. Dodds: React state management — 关于状态管理的最佳实践
- Dan Abramov: The GitHub issue about controlled vs uncontrolled — React 官方对受控/非受控组件的设计讨论