原文链接:certificates.dev/blog/contro…
理解React中的Controlled与Uncontrolled组件,关键在于一个问题:状态归谁所有?掌握表单输入与组件设计模式中的两种含义。
作者:Aurora Scharff
React 文档中频繁出现受控和非受控这两个术语,但它们在不同上下文中似乎具有不同含义,容易造成混淆。实际上,它们都在探讨同一个核心问题:状态的所有权归属——是父组件还是子组件本身?
本文将厘清这些术语的含义,并帮助你识别日常 React 代码中的模式。
核心问题:谁拥有状态?
受控与非受控的两种用法本质相同:
- Controlled:状态由外部拥有并通过 props 传递
- Uncontrolled:状态由组件自身内部拥有
对于表单输入,"内部状态"指 DOM 管理其值。对于自定义组件,"内部状态"通常指React的useState。但本质问题相同:是父组件控制状态,还是组件内部管理状态?
这种区别至关重要,因为它决定了"真实数据源"的存放位置及可修改权限。
表单输入:通用介绍
这些术语通常通过表单输入进行说明,核心问题在于:父组件是通过 props 控制输入值,还是输入通过 DOM 内部自行管理值。
受控输入的值由父组件通过 React 状态管理:
function SearchForm() {
const [query, setQuery] = useState('');
function handleSubmit(event) {
event.preventDefault();
console.log(query);
}
return (
<form onSubmit={handleSubmit}>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button type="submit">Search</button>
</form>
);
}
React 完全控制此输入框的值。value 属性决定显示内容,onChange 事件则更新状态。任何更新query的行为——无论是输入文字、"清除"按钮还是选择建议项——都会更新输入框。
这意味着 value 和 onChange 必须协同工作。若仅传递 value 而未设置 onChange,则无法在输入框中输入内容——React 会将每次键入都还原为指定的值。
非受控输入通过 DOM 内部管理其值:
function SearchForm() {
function handleSubmit(event) {
event.preventDefault();
const formData = new FormData(event.target);
console.log(formData.get('query'));
}
return (
<form onSubmit={handleSubmit}>
<input name="query" defaultValue="" />
<button type="submit">Search</button>
</form>
);
}
React 并不知道这个输入框的值——它既不设置也不更新该值,更不知道值何时发生变化。defaultValue 属性仅用于设置初始值;此后,DOM 便掌控了状态。
组件:props 与内部状态
除了表单,"受控"与"不受控"描述了一种通用的组件设计模式。同样的问题依然存在:父组件是通过 props 拥有状态,还是由组件内部管理状态?
考虑一个简单的 Toggle 组件:
function Toggle({ label }) {
const [isOn, setIsOn] = useState(false);
return (
<button onClick={() => setIsOn(!isOn)}>
{label}: {isOn ? 'ON' : 'OFF'}
</button>
);
}
该组件使用了useState,因此React在管理状态。但这并不意味着它在组件设计意义上属于"受控组件"。父组件无法读取或设置切换按钮的值:
function App() {
return <Toggle label="Dark Mode" />;
}
由于父组件无法影响状态,此Toggle组件属于非受控组件——它管理自身的内部状态,父组件仅负责渲染。
实现控件化组件
要使Toogle组件成为控件组件,需将状态移至父组件并通过 props 传递:
function Toggle({ label, isOn, onToggle }) {
return (
<button onClick={onToggle}>
{label}: {isOn ? 'ON' : 'OFF'}
</button>
);
}
function App() {
const [darkMode, setDarkMode] = useState(false);
return (
<>
<Toggle
label="Dark Mode"
isOn={darkMode}
onToggle={() => setDarkMode(!darkMode)}
/>
<p>Theme: {darkMode ? 'dark' : 'light'}</p>
</>
);
}
现在App控制切换按钮的值。它能读取状态来更新主题、重置状态或协调多个切换按钮。该组件通过 props 渲染而非内部状态。
请注意这与原生输入元素的对应关系:受控输入使用 value 和 onChange 属性,而我们的受控Toggle组件采用类似模式,使用 isOn 和 onToggle 属性。基于原生 HTML 元素设计自定义组件,能使 API 更直观且可预测。
支持双模式
部分组件支持受控与非受控两种模式,如同原生 <input> 元素。以我们的Toggle组件为例,实现方式如下:
function Toggle({ label, isOn, onToggle, defaultOn = false }) {
const [internalOn, setInternalOn] = useState(defaultOn);
const isControlled = isOn !== undefined;
const on = isControlled ? isOn : internalOn;
function handleToggle() {
if (isControlled) {
onToggle?.();
} else {
setInternalOn(!internalOn);
}
}
return (
<button onClick={handleToggle}>
{label}: {on ? 'ON' : 'OFF'}
</button>
);
}
这使得使用方式灵活:
// Uncontrolled - Toggle manages its own state
<Toggle label="Notifications" defaultOn={true} />
// Controlled - Parent manages the state
<Toggle label="Dark Mode" isOn={darkMode} onToggle={() => setDarkMode(!darkMode)} />
关键在于通过检查 isOn !== undefined 来确定模式。受控模式下,组件使用 props 并通过 onToggle 将变更委托给父级;无控模式下,组件内部管理自身状态。
重要规则:组件在其生命周期内不应在受控与无控模式间切换。若 isOn 初始为 undefined 随后变为有效值(或反之),行为将变得不可预测。你可能见过"组件正在将非受控输入转换为受控输入"的警告——这是 React 提示输入模式发生切换,自定义组件也应遵循相同原则。
许多 UI 库都采用此模式。但同时支持两种模式会增加复杂度。对于简单组件,通常更适合选择单一模式。
实际应用示例
受控/非受控模式贯穿于 React UI 库的各个组件:
- Tabs:非受控选项卡组件在内部管理当前选中的标签页。受控版本则接受
activeTab和onTabChange属性,允许父组件通过编程方式切换标签页或与 URL 状态同步。 - Modals:非受控模态框可能持有内部
isOpen状态并暴露trigger元素。受控模态框接受isOpen和onClose属性,允许父组件根据应用状态控制可见性。 - Accordions:非受控折叠面板让每个面板自主管理开合状态。受控版本接受
expandedPanels和onChange属性,实现需要协调的"单面板展开"行为。 - Date Pickers:非受控日期选择器内部管理选项。受控版本接受
selectedDate和onChange属性,允许父组件验证日期、与其他字段同步或实现日期范围约束。
核心要义在于受控组件能实现协同。当多个组件需保持同步,或父组件需实现复杂行为时,受控组件便成为实现途径。
权衡与考量
选择控制与非控制归根结底取决于一个问题:父母是否需要了解或影响孩子的状态?
表单输入
对于表单而言,非受控输入通常更为简洁。您可让 DOM 处理状态,并在提交时通过 FormData 读取值。这种方法与 React 的现代表单特性(如 <form> 的 action 属性、useFormStatus 和 useActionState)配合良好。
当需要在用户输入时获取值时,受控输入便显得尤为重要——例如实时验证、转换输入(如格式化电话号码)或协调多个字段。其权衡在于:受控输入会在每次按键时重新渲染,而无控输入则避免了这种情况。
诸如 React Hook Form 之类的表单库通过默认使用带 ref 的无控输入来平衡性能需求,同时仍提供验证和错误处理功能。当父组件需要访问中间值时,它们通过 Controller 组件支持受控输入。
自定义组件
对于自定义组件,关键在于该组件应独立运行还是由父组件协调。
无控组件自主管理状态且开箱即用——父组件只需渲染即可。当状态属于父组件无需关心的实现细节时,这种模式更为简洁。
受控组件通过 props 接收状态,并在状态变更时通知父组件。这种模式支持协调机制:父组件可同步多个实例、实现"仅允许同时打开一个"等复杂行为,或将状态与应用程序其他部分集成。其代价是父组件需要更多连接逻辑。
构建可复用组件库时,同时支持两种模式能为使用者提供灵活性;但在应用程序代码中,选择单一模式能保持更简洁的结构。
结论
"受控"与"不受控"这两个术语始终指向同一个核心问题:状态归谁所有?无论是表单输入(其内部状态存在于DOM中)还是自定义组件(其内部状态存在于useState中),本质上都是在父组件的props与组件自身管理的内部状态之间进行选择。
关键洞见在于:使用 useState 并不使组件成为"受控"组件。从父组件视角看,若其无法影响子组件状态,该组件即为不受控。理解这一点有助于设计组件 API,为父组件提供符合其需求的恰当控制层级。