Appearance
深度解析:React 中的受控与非受控组件
在 React 面试和架构设计中,"受控组件(Controlled)"与"非受控组件(Uncontrolled)"是绕不开的问题。
React 告诉我们:"为了数据的一致性,请优先使用受控组件。"但真实业务告诉我们:"在大规模表单中,无脑使用受控组件是性能灾难的开始。"
本文将剥离表面的 API 差异,从数据流、渲染机制、业务痛点以及第三方库实现四个维度,彻底讲透这一话题。通过原生实现、问题复现以及库的扩展用法等代码示例,帮助你更好地理解和应用这些概念。
受控与非受控不限于表单
受控组件和非受控组件的概念并不局限于表单元素(如 <input>、<textarea>、<select>)。虽然 React 官方文档主要以表单输入为例介绍这个概念,但其核心是关于"谁控制组件的状态"——是 React 的 state(受控),还是组件/DOM 自身(非受控)。
这个模式可以扩展到任何支持状态管理的组件,包括自定义组件、媒体元素(如 <video>、<audio>)或其他 UI 元素(如可拖拽元素):
- 受控组件:常用于需要精确控制 UI 状态的场景,如实时动画或交互反馈
- 非受控组件:适合性能敏感或"一次性读取"的场景,如文件上传或第三方插件集成
一、核心定义:谁掌握了"数据的方向盘"?
区别受控与非受控的唯一标准是:当前组件的状态(value 或其他属性),到底是由 React 的 state 实时驱动,还是由组件/DOM 自身持有?
1. 受控组件(Controlled)
- 数据流模式:
State→组件属性 - 本质:React 是"单一事实来源"(Single Source of Truth)。组件只是数据的投影,用户交互必须先经过 React state 的更新,再回填给组件
- 实现要求:必须同时提供状态属性(如
value)和变更处理器(如onChange) - 适用场景:不限于表单,可用于任何需要 React 严格控制的组件
表单受控组件示例
下面是一个简单的受控输入框示例,使用 useState 管理状态。每当用户输入时,onChange 会触发 setValue,导致组件重新渲染。
tsx
import { useState } from 'react';
export default function ControlledDemo() {
const [value, setValue] = useState('');
// 打印渲染次数:每输入一个字符,这里都会执行
console.log("Controlled Component Rendered");
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value); // 更新状态,触发重渲染
};
return (
<div>
<input value={value} onChange={handleChange} />
<p>当前值: {value}</p> {/* 实时显示状态 */}
</div>
);
}在这个例子中,输入 "hello" 会触发 5 次重新渲染(每个字符一次)。在复杂表单中,这会放大性能问题。
非表单受控组件示例
下面是一个受控的 <video> 组件示例,使用 state 控制播放进度(currentTime),确保 React 可以同步其他 UI(如进度条)。
tsx
import { useState, useRef } from 'react';
export default function ControlledVideoDemo() {
const [currentTime, setCurrentTime] = useState(0);
const videoRef = useRef<HTMLVideoElement>(null);
// 打印渲染次数:进度变化时会触发重渲染
console.log("Controlled Video Rendered");
const handleTimeUpdate = () => {
if (videoRef.current) {
setCurrentTime(videoRef.current.currentTime); // 更新 state
}
};
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = parseFloat(e.target.value);
setCurrentTime(newTime);
if (videoRef.current) {
videoRef.current.currentTime = newTime; // 受控:state 驱动 DOM
}
};
return (
<div>
<video
ref={videoRef}
src="example.mp4"
onTimeUpdate={handleTimeUpdate} // 监听时间变化
controls
/>
<input
type="range"
min={0}
max={videoRef.current?.duration || 0}
value={currentTime} // 受控进度条
onChange={handleSeek}
/>
<p>当前时间: {currentTime.toFixed(2)} 秒</p>
</div>
);
}在这个示例中,视频的 currentTime 由 React state 控制,确保与进度条同步。但频繁更新可能导致较大的渲染开销。
2. 非受控组件(Uncontrolled)
- 数据流模式:
组件/DOM→Ref - 本质:信任组件自身。组件维持内部状态,React 只在需要时通过 ref "读取"
- 实现要求:使用默认属性(如
defaultValue),通过useRef获取实例 - 适用场景:同样不限于表单,可用于媒体、自定义组件等
表单非受控组件示例
下面是一个使用 useRef 访问 DOM 值的示例,只有在提交时才读取数据。
tsx
import { useRef } from 'react';
export default function UncontrolledDemo() {
const inputRef = useRef<HTMLInputElement>(null);
// 打印渲染次数:打字时,这里不会执行
console.log("Uncontrolled Component Rendered");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (inputRef.current) {
console.log("提交值:", inputRef.current.value); // 通过 ref 读取 DOM 值
}
};
return (
<form onSubmit={handleSubmit}>
<input ref={inputRef} defaultValue="初始值" />
<button type="submit">提交</button>
</form>
);
}在这里,打字不会触发 React 重新渲染,只有提交时通过 ref 获取值。这在性能敏感场景下非常高效。
非表单非受控组件示例
下面是一个非受控的 <audio> 组件示例,只在暂停时读取当前时间:
tsx
import { useRef } from 'react';
export default function UncontrolledAudioDemo() {
const audioRef = useRef<HTMLAudioElement>(null);
// 打印渲染次数:播放时不会触发重渲染
console.log("Uncontrolled Audio Rendered");
const handlePause = () => {
if (audioRef.current) {
console.log("暂停时时间:", audioRef.current.currentTime); // 通过 ref 读取
}
};
return (
<audio
ref={audioRef}
src="example.mp3"
onPause={handlePause}
controls // DOM 自身管理状态
/>
);
}这里,音频的播放状态由 DOM 管理,React 不干预渲染循环,适合性能优化场景。
二、受控组件的性能缺陷
受控组件看似完美(数据可控、易于校验),但在以下场景下存在明显缺陷。
1. 性能瓶颈:全量渲染
在受控模式下,数据流是:用户交互 → onChange/onUpdate → setState → 重新渲染 → 组件属性更新
这意味着在复杂组件树中直接使用受控管理时:
- 用户每交互一次(如拖动视频进度),整个父组件都会触发 Diff 计算
- 在低性能设备上,这将导致明显的卡顿
问题复现示例
假设一个包含多个输入框的父组件,所有输入都受控于父组件的状态:
tsx
import { useState } from 'react';
export default function ParentWithControlled() {
const [formData, setFormData] = useState({ name: '', email: '', address: '' });
// 打印渲染次数:任何输入变化都会触发整个父组件重渲染
console.log("Parent Component Rendered");
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
return (
<form>
<input name="name" value={formData.name} onChange={handleChange} />
<input name="email" value={formData.email} onChange={handleChange} />
<input name="address" value={formData.address} onChange={handleChange} />
{/* 假设这里还有 97 个类似输入... */}
</form>
);
}输入 "name" 字段会重新渲染整个组件,包括无关的 "email" 和 "address" 字段。这时就需要使用子组件隔离或外部状态管理来优化渲染性能。
非表单场景:拖拽元素
假设一个受控的可拖拽 div,使用 state 管理位置。
tsx
import { useState } from 'react';
export default function ControlledDraggable() {
const [position, setPosition] = useState({ x: 0, y: 0 });
// 打印渲染次数:每拖动都会触发重渲染
console.log("Controlled Draggable Rendered");
const handleMouseMove = (e: React.MouseEvent) => {
setPosition({ x: e.clientX, y: e.clientY });
};
return (
<div
style={{ position: 'absolute', left: position.x, top: position.y }}
onMouseMove={handleMouseMove}
>
拖我!
</div>
);
}频繁拖动会导致连续重新渲染,而非受控版本可以使用 ref 和原生事件监听来避免这个问题。
2. 实战巨坑:中文输入法(IME)问题
这是非受控组件最大的天然优势,也是受控组件最难处理的细节。主要影响文本输入,但类似"中断"问题也可能在其他交互中出现(如受控的富文本编辑器)。
场景:用户使用拼音输入法输入"中"(zhong)
- 受控组件:当你打出 "z" 时,
onChange触发,setState更新。如果onChange里有格式化逻辑(如转大写、限制长度),它会打断输入法的"组字模式"(Composition Mode),导致拼音被打断或乱码 - 非受控组件:DOM 自身处理输入流,天然知道何时该等待组字完成,何时该上屏
修复方案:虽然 React 内部处理了部分 IME 问题,但在受控组件中做复杂逻辑时,你依然需要手动监听
compositionstart和compositionend事件来加锁,代码复杂度较高。
IME 问题复现与修复示例
以下受控组件示例添加了简单格式化(转大写),会干扰 IME:
tsx
import { useState } from 'react';
export default function ControlledWithIMEIssue() {
const [value, setValue] = useState('');
const [composing, setComposing] = useState(false); // 用于修复 IME
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (composing) return; // 修复:输入法组字时不更新
setValue(e.target.value.toUpperCase()); // 格式化逻辑干扰 IME
};
return (
<input
value={value}
onChange={handleChange}
onCompositionStart={() => setComposing(true)} // 开始组字
onCompositionEnd={(e) => {
setComposing(false);
setValue(e.currentTarget.value.toUpperCase()); // 结束时更新
}}
/>
);
}没有修复逻辑时,输入 "zhong" 会导致拼音面板闪烁或错误上屏。添加事件监听后,虽然复杂度增加,但能有效缓解问题。
三、对受控与非受控的极致运用
现代流行的表单库,本质上就是对这两种模式的封装与改良,它们分别代表了两个流派。下面通过扩展代码示例,展示校验、watch 和提交逻辑。这些库主要针对表单,但原理可借鉴到非表单组件(如使用 RHF 的 Controller 来"受控"媒体元素)。
流派一:非受控的极致 — React Hook Form
React Hook Form(RHF) 是"非受控组件"理念的集大成者。
核心思想:既然 DOM 更新最快,那就让 DOM 自己管自己,React 别插手渲染,只管逻辑。
- 原理:RHF 的
register函数本质上是把ref注入到 input 中,并监听onBlur或onChange(但在内部处理,不触发 React 重新渲染)。它使用订阅机制,仅在需要时更新 - 性能优势:当你打字时,React 组件一次都不会渲染。只有在触发表单校验错误或提交时,才会通知 React 更新 UI
- 扩展到非表单:使用
Controller可以"伪受控"自定义组件,而不牺牲性能
RHF 代码演示
添加了 watch 局部订阅、自定义校验和默认值:
tsx
import { useForm } from "react-hook-form";
export default function UncontrolledDemo() {
// RHF 默认采用非受控模式,watch 实现了局部订阅
const { register, handleSubmit, formState: { errors }, watch } = useForm({
defaultValues: { firstName: '默认名' } // 设置默认值
});
// 打印渲染次数:你会发现打字时这里根本不会执行
console.log("React Component Rendered");
const onSubmit = (data) => console.log(data);
// watch 局部订阅:仅监控 firstName,不触发全渲染
const watchedValue = watch('firstName');
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* register 内部完成了 ref 的绑定,支持自定义规则 */}
<input
{...register("firstName", {
required: "必填",
minLength: { value: 2, message: "至少2字符" }
})}
/>
{errors.firstName && <span>{errors.firstName.message}</span>}
<p>实时监控: {watchedValue}</p> {/* 不触发重渲染 */}
<button type="submit">提交</button>
</form>
);
}这个示例展示了 RHF 如何在非受控基础上添加类似受控的功能,而不牺牲性能。
RHF 非表单扩展:受控自定义组件
使用 Controller 管理一个自定义的颜色选择器(非标准输入):
tsx
import { useForm, Controller } from "react-hook-form";
export default function RHFNonFormDemo() {
const { control, handleSubmit } = useForm();
const onSubmit = (data) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="color"
control={control}
defaultValue="#000000"
render={({ field }) => (
<input type="color" {...field} /> // 伪受控:RHF 内部优化渲染
)}
/>
<button type="submit">提交</button>
</form>
);
}流派二:受控的隔离 — Ant Design Form
Ant Design 代表了另一种流派:外部 Store 托管模式。
很多人以为 Ant Design 是受控组件,因为写法上有 value。但实际上,Ant Design 的 <Form> 解决性能问题的思路是:将状态从 React 组件树中剥离出去。
- 原理:Ant Design 内部使用了一个独立的 Store(
rc-field-form)。当 input 变化时,数据更新到 Store 中,Store 精准通知对应的那个 Form.Item 进行重绘,而阻断了父组件的重绘 - 定义归属:它在 API 层面表现为受控(你可以控制 value),但在 React 渲染层面,它表现得像非受控(父组件不感知输入变化)
- 扩展到非表单:Ant Design 的 Form 可以包裹自定义组件,通过
getValueFromEvent等自定义数据流
Ant Design 托管模式扩展版
添加了初始值、自定义规则和动态字段:
tsx
import { Form, Input, Button } from 'antd';
const AntdDemo = () => {
const [form] = Form.useForm();
// 打印渲染次数:打字时,父组件(AntdDemo)不会重新渲染!
// 因为更新被 Form.Item 内部消化了
console.log("Parent Component Rendered");
// 设置初始值
form.setFieldsValue({ username: '默认用户' });
return (
<Form form={form} onFinish={(v) => console.log(v)}>
{/* Form.Item 充当了 update blocker,支持规则链 */}
<Form.Item
name="username"
rules={[
{ required: true, message: '必填' },
{ min: 3, message: '至少3字符' }
]}
>
<Input placeholder="用户名" />
</Form.Item>
{/* 另一个字段,变化不影响父组件 */}
<Form.Item name="email" rules={[{ type: 'email', message: '无效邮箱' }]}>
<Input placeholder="邮箱" />
</Form.Item>
<Button htmlType="submit">提交</Button>
</Form>
);
};扩展后,你可以看到 Ant Design 如何通过 Store 实现隔离更新,即使有多个字段。
Ant Design 非表单扩展:自定义组件
包裹一个第三方日期选择器:
tsx
import { Form, DatePicker, Button } from 'antd';
import dayjs from 'dayjs';
const AntdNonFormDemo = () => {
const [form] = Form.useForm();
return (
<Form form={form} onFinish={(v) => console.log(v)}>
<Form.Item name="date" getValueFromEvent={(value) => value.format('YYYY-MM-DD')}>
<DatePicker /> // 自定义数据处理
</Form.Item>
<Button htmlType="submit">提交</Button>
</Form>
);
};四、还有其他流派吗?(架构延伸)
除了上述两大主流,围绕"如何控制状态"这一核心,还有两个值得提及的流派。下面通过简单代码示例来演示,这些流派也可用于非表单状态管理。
1. 响应式模型流派(Formily)
Formily 认为 React 的受控/非受控都太局限了,它引入了 MVVM 概念,使用 Observable(可观察对象) 来管理表单状态。
- 核心特点:它既不完全依赖 React state,也不依赖 DOM ref,而是依赖一个独立的 Reactive Graph(响应式图谱)。React 只是这个图谱的"渲染层",这允许更复杂的联动和计算属性
- 扩展应用:可管理非表单场景如动画状态
Formily 响应式示例
tsx
import { createForm } from '@formily/core';
import { FormProvider, Field } from '@formily/react';
import { Input } from 'antd'; // 可与 UI 库集成
const form = createForm(); // 独立响应式模型
export default function FormilyDemo() {
return (
<FormProvider form={form}>
<Field name="username" required decorator={Input} component={Input}>
{/* 响应式:变化自动通知依赖项 */}
</Field>
<button onClick={() => console.log(form.values)}>提交</button>
</FormProvider>
);
}Formily 的响应式机制允许字段间自动联动(如计算字段),而不需手动编写 onChange 逻辑。
细粒度原子流派(Valtio / Jotai)
这种流派通过 Proxy 或 Atom 技术,将每一个 input 的 value 变成一个独立的原子状态。
- 核心特点:这是受控组件的"微服务化"。每个 input 依然是受控的,但它们各自订阅自己的 Atom,从而避免了父组件级别的全量重新渲染。Valtio 使用 Proxy,Jotai 使用 Atom
- 扩展应用:适合非表单场景如游戏状态管理
Valtio 代码演示
tsx
import { proxy, useSnapshot } from 'valtio';
const store = proxy({ username: '' }); // 独立 Proxy 状态
export default function ValtioDemo() {
const snap = useSnapshot(store); // 订阅快照
// 打印渲染次数:仅当 username 变化时重渲染此组件
console.log("Valtio Component Rendered");
return (
<input
value={snap.username}
onChange={(e) => (store.username = e.target.value)} // 直接更新 Proxy
/>
);
}每个字段的原子状态确保最小化渲染,适合大型表单场景。
总结与选型指南
追求极致性能场景
如果你在做 C 端移动端、超长列表表单,或移动端动画、媒体播放等性能敏感场景:
- 请选择 非受控模式 的代表 React Hook Form,它是最接近原生 DOM 性能的方案
- 或使用 RHF 的 Controller 来管理自定义组件
B 端管理后台场景
如果你在做 B 端管理后台,注重开发效率、UI 统一,或复杂 UI 的状态同步:
- 请选择 Ant Design Form。虽然它 API 看起来像受控,但其内部的 Store 托管机制 已经帮你解决了受控组件的性能问题
- 或考虑使用原子状态库(Valtio/Jotai)
何时必须手写原生受控组件(useState)?
- 强格式化场景:例如信用卡输入框(每 4 位加空格)、金额输入框(实时千分位)。这种场景下,视图必须严格等于状态,非受控组件无法做到实时的光标修正和格式强刷
- 强同步场景:如视频进度与字幕联动,需要精确控制多个 UI 元素的同步更新
何时使用原生非受控组件(useRef)?
- 极简场景:一个简单的"搜索框"或"文件上传",不需要复杂的校验和联动
- 简单交互:如一次性读取文件或媒体属性,不需要实时反馈
结语
不要被"React 哲学"束缚。受控提供了控制力,非受控提供了性能。而现代优秀的库,都是在 API 上伪装成受控(易用),在底层利用非受控或外部 Store(性能),来达到两全其美。
受控/非受控的核心是状态所有权,不限于表单。通过本文的扩展示例,你可以看到它在媒体、自定义组件中的应用。选择时,权衡控制力 vs 性能,根据具体场景做出最优决策。