一、从生活出发:什么是"控制"?
想象你正坐在一辆汽车里。如果这辆车是自动驾驶 的,你只需要告诉它目的地,它自己会决定什么时候加速、什么时候转弯、什么时候刹车------你不需要时刻握着方向盘。但如果这辆车是手动驾驶 的,那么每一刻的车速、每一次转向,都需要你亲手操作,汽车的状态与你手中的方向盘、脚下的踏板实时同步。
现在,把这个类比迁移到网页开发中。当你在网页上看到一个输入框(input),你在里面打字,字符一个一个地显示出来------你有没有想过,这些字符是怎么"出现"在屏幕上的?
有的输入框像自动驾驶汽车:它自己管理自己的内容,React 只在需要的时候(比如点击提交按钮)才去"看一眼"它写了什么。这就是非受控组件(Uncontrolled Component)。
有的输入框像手动驾驶汽车:它的每一个字符都被 React "盯"着,你敲下一个键,React 立刻捕获这个变化,更新自己的内部状态,然后把新的值重新渲染到输入框中。这就是受控组件(Controlled Component)。
这篇文章将带你从零开始,理解 React 中这两个核心概念。不需要你有很深的编程基础,只要了解最基本的 React 语法(比如什么是组件、什么是 JSX),你就能读懂。
二、前置知识:React 组件与 Hooks 三分钟速览
在深入正题之前,让我们花三分钟回顾一下必备的基础知识。
React 组件本质上就是一个函数,这个函数返回一段 HTML(准确说是 JSX)。比如:
jsx
function Hello() {
return <h1>你好,世界!</h1>;
}
这就是一个最简单的 React 组件。它在页面上渲染一个标题。
Hooks 是 React 16.8 引入的革命性特性,它让你在函数组件中"记住"一些东西。这篇文章会涉及两个最常用的 Hook:
useState:给组件添加"状态记忆"。调用useState会返回一个值和一个修改这个值的函数。每当你调用修改函数,React 就会用新的值重新渲染组件。
jsx
const [count, setCount] = useState(0);
// count 是当前值,setCount 是修改它的函数
// 调用 setCount(5) 后,count 变成 5,组件重新渲染
useRef:创建一个"引用",你可以把它想象成一个标签,贴在页面上的某个 DOM 元素上。之后你就可以通过这个标签找到那个元素,直接读取它的属性。关键区别:修改 ref 的值不会触发组件重新渲染。
jsx
const inputRef = useRef(null);
// 把它贴在 <input ref={inputRef}> 上
// 之后用 inputRef.current.value 就能读到输入框的当前内容
好,基础知识就绪。现在让我们正式进入受控与非受控的世界。
三、受控组件:React 是唯一的事实来源
3.1 核心思想
受控组件的核心哲学可以用一句话概括:组件的状态是唯一的真相来源(Single Source of Truth)。
在受控组件中,表单元素(如 <input>、<textarea>、<select>)的值完全由 React 的状态(state)控制。用户输入 → 触发事件 → 更新 state → React 重新渲染组件 → 输入框显示新的值。这是一个闭环。
3.2 最简单的受控组件
让我们看一个项目中真实的例子------ControlledInput 组件:
jsx
import { useState } from 'react';
function ControlledInput() {
const [value, setValue] = useState('');
return (
<>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</>
);
}
让我们逐行理解这段代码:
const [value, setValue] = useState(''):创建一个状态value,初始值为空字符串。value={value}:这是关键!输入框的value属性被绑定到了 React 的状态上。这意味着输入框里显示什么,完全由value这个状态决定。onChange={(e) => setValue(e.target.value)}:当用户在输入框中键入任何内容时,onChange事件被触发,我们立即调用setValue把最新的输入内容存到状态中。
那数据流是什么样的呢?假设你在输入框中敲下了字母 "a":
- 你的按键触发了
onChange事件 e.target.value此时是"a"setValue("a")被调用,value状态更新为"a"- React 检测到状态变化,重新渲染组件
- 输入框的
value属性现在是"a",所以你看到了 "a"
你可能会问:"这看起来绕了一大圈,为什么不让输入框自己管理自己的内容呢?" 好问题!答案在下一节的示例中揭晓。
3.3 受控组件的威力:实时表单验证
受控组件真正的威力不在于简单的输入框,而在于你可以在每一个输入变化的瞬间介入处理 。来看项目中的 LoginForm 组件:
jsx
function LoginForm() {
const [form, setForm] = useState({
username: "",
password: ""
});
const [errors, setErrors] = useState({});
const validate = (name, value) => {
let msg = "";
if (name === 'username') {
if (!value) {
msg = '用户名为空';
} else if (value.length < 3) {
msg = '用户名长度不能小于3';
}
}
if (name === 'password') {
if (!value) {
msg = '密码不能为空';
} else if (value.length < 6) {
msg = '密码长度不能小于6位';
}
}
setErrors(prev => ({
...prev,
[name]: msg
}));
};
const handleChange = (e) => {
const { name, value } = e.target;
setForm(prev => ({
...prev,
[name]: value
}));
validate(name, value);
};
const isValid = form.username && form.password
&& !errors.username && !errors.password;
return (
<form className="login-card" onSubmit={handleSubmit}>
<h2>登录</h2>
<div className="form-item">
<label>用户名</label>
<input type="text" name="username"
value={form.username} onChange={handleChange} />
{errors.username && (
<span className="error">{errors.username}</span>
)}
</div>
<div className="form-item">
<label>密码</label>
<input type="text" name="password"
value={form.password} onChange={handleChange} />
{errors.password && (
<span className="error">{errors.password}</span>
)}
</div>
<button type="submit" disabled={!isValid}>登录</button>
</form>
);
}
这个登录表单展示了受控组件的几个关键优势:
实时验证 :用户每输入一个字符,validate 函数就会被调用,立即检查输入是否合法。用户名不够3个字符?密码不到6位?错误提示立刻出现在输入框下方。这种即时反馈是受控组件的"杀手级特性"。
动态按钮状态 :登录按钮的 disabled 属性由 isValid 变量控制。只有当用户名和密码都填写了且没有错误时,按钮才会变为可点击状态。按钮的状态与表单数据始终保持同步,不需要写任何额外的逻辑。
集中式数据管理 :所有的表单数据都存储在 form 这个对象中。如果你想在提交时获取表单数据,直接用 form.username 和 form.password 就行了,不需要去页面上查找 DOM 元素。
这就是"单一数据源"的优雅之处------你的 UI 始终是状态的忠实反映。
四、非受控组件:让 DOM 自己管自己
4.1 核心思想
非受控组件的哲学是:表单元素自己管理自己的状态,React 只在需要的时候通过 ref 去读取。
这更接近传统的 HTML 表单行为------输入框的值存储在 DOM 中,而不是 React 的状态中。你不需要为每一个按键编写事件处理函数,也不需要维护一大堆 state。
4.2 最简单的非受控组件
项目中的 UncontrolledInput 组件完美展示了非受控模式:
jsx
import { useRef } from 'react';
function UncontrolledInput() {
const inputRef = useRef(null);
const handleClick = () => {
console.log(inputRef.current.value);
};
return (
<>
<input type="text" ref={inputRef} />
<button onClick={handleClick}>获取输入值</button>
</>
);
}
这段代码和受控版本的区别在哪里?
- 没有
useState:没有状态来追踪输入框的值。 - 没有
value属性:输入框没有被 React 控制,它可以自由地显示用户输入的内容。 - 没有
onChange:我们并不关心每一次按键。 - 使用
ref={inputRef}:这是非受控的关键。inputRef像一个"指针",直接指向页面上的 DOM 元素。 - 按需读取 :用户点击按钮时,通过
inputRef.current.value直接读取 DOM 中当前的输入值。
数据流极其简单:用户输入 → 字符显示在输入框中(由浏览器处理)→ 需要时,React 通过 ref 去"看一眼"。
4.3 另一个非受控示例:评论框
CommentBox 组件展示了非受控模式在 textarea 上的应用:
jsx
function CommentBox() {
const textareaRef = useRef();
const handleSubmit = () => {
const comment = textareaRef.current.value;
if (!comment) return;
console.log(comment);
};
return (
<div>
<textarea placeholder="输入评论..." ref={textareaRef} />
<button onClick={handleSubmit}>提交评论</button>
</div>
);
}
这里的逻辑和非受控输入框完全一样:textarea 自由接受用户输入,只有在点击"提交评论"按钮时,React 才通过 ref 获取内容。
4.4 为什么叫"非受控"?
"非受控"并不意味着"失控"或"不受管理"。它只是说:这个表单元素的值不被 React 的 state 控制。浏览器(DOM)才是这个值的"所有者",React 只是偶尔去访问它一下。
这就好比一个学生(表单元素)自己管理自己的作业进度(输入值),老师(React)不会每一步都盯着,但会在交作业那天(提交事件)查看结果。
五、受控 vs 非受控:一张表看懂区别
| 对比维度 | 受控组件 | 非受控组件 |
|---|---|---|
| 数据存储位置 | React state | DOM 节点 |
| 值的更新方式 | 每次 onChange 触发 setState |
浏览器自动更新,React 通过 ref 读取 |
| 是否需要 state | 是(每个输入都对应一个 state) | 否 |
| 重新渲染 | 每次输入都触发重新渲染 | 不触发(除非手动处理) |
| 实时验证 | 天然支持 | 需要额外实现 |
| 代码量 | 相对多一些(需要 state + handler) | 相对少一些 |
| 适合场景 | 需要即时反馈的表单(搜索、验证、联动) | 简单表单、只提交时读取的场景 |
| React 哲学的契合度 | 高度契合(声明式、数据驱动) | 偏低(命令式、DOM 操作) |
六、实战指南:什么时候用哪个?
6.1 优先使用受控组件
React 官方文档推荐在大多数情况下使用受控组件。为什么?因为受控组件更符合 React 的声明式编程范式。
回想一下 React 的核心公式:UI = f(state)。你的界面是状态的函数。当状态改变时,界面自动更新。受控组件完美地实现了这一公式------输入框的值就是 state 的直接映射。
具体来说,以下场景你应该选择受控组件:
- 需要实时验证(如注册表单的用户名长度检查、密码强度指示器)
- 需要条件渲染(如"密码不匹配"提示仅在某些条件下显示)
- 需要禁用/启用提交按钮(如表单未填完时按钮灰色)
- 需要格式化输入(如手机号自动加空格、信用卡号分组)
- 多个输入之间有联动关系(如选择国家后自动切换区号)
6.2 非受控组件的适用场景
非受控组件并非"过时的"或"不好的"选择。在以下情况下,它可能更合适:
- 简单的、一次性的表单(如一个搜索框或评论框,只在提交时关心内容)
- 需要集成非 React 代码(如使用第三方富文本编辑器、文件上传组件)
- 性能敏感场景(避免每次按键都触发整个组件树的重新渲染)
- 快速原型开发(不想写太多样板代码的时候)
记住一句话:选择取决于你需不需要在每次输入变化时做些什么。如果需要,用受控;如果不需要,非受控可以让你少写很多代码。
七、深层理解:两种范式背后的设计哲学
7.1 声明式 vs 命令式
受控组件和非受控组件之争,本质上是声明式编程 和命令式编程两种思维方式的碰撞。
声明式(受控) :你告诉 React "输入框的值应该是 value",React 负责确保这一点。你描述的是你想要什么(what),而不是怎么做(how)。
jsx
// 声明式:"输入框的值应该永远是 name 这个 state"
<input value={name} onChange={handleChange} />
命令式(非受控):你直接操作 DOM,告诉浏览器"去把这个输入框的值拿给我"。你描述的是怎么做(how),而不是你想要什么(what)。
jsx
// 命令式:"去给我拿输入框里的值"
const value = inputRef.current.value;
React 的设计哲学整体偏向声明式。这也是为什么受控组件被认为是"更 React 化"的写法------它让数据流变得清晰、可预测。
7.2 类比 Vue 的理解
如果你之前学过 Vue,可能会觉得 React 的非受控组件中的 useRef 和 Vue 的 ref 很像------确实如此!它们都允许你直接访问 DOM 元素。
但有一个重要区别:Vue 的 ref 在 <script setup> 中是响应式的,而 React 的 useRef 不是响应式的 ------修改 ref.current 不会触发重新渲染。这是很多从 Vue 转 React 的开发者容易踩的坑。
另外,Vue 有 v-model 这个语法糖,它本质上就是一种"受控"模式------输入框的值和 data 中的属性双向绑定。React 没有这样的语法糖,你需要手动写 value + onChange,这显得更啰嗦,但也让数据流更加显式 和单向。
八、React 19 与本文的关系
本文的示例代码使用的是 React 19("react": "^19.2.6")。React 19 在表单处理方面带来了一些改进,但与本文讨论的"受控 vs 非受控"核心概念完全兼容。受控与非受控的范式在 React 19 中依然有效,你不需要担心版本升级导致这些知识过时。
事实上,React 团队在 React 19 中引入了 useActionState 和 useFormStatus 等新的 Hook,它们为表单处理提供了更高级的抽象,但这些新特性的底层,依然是受控与非受控思想的延伸。
九、常见误区与注意事项
误区一:非受控 = 不安全的
很多初学者听到"非受控"这个词,会天然地觉得它不好。实际上,"受控"和"非受控"只是两种策略,没有绝对的好坏之分。React 官方文档明确说:"在大多数情况下,我们推荐使用受控组件......但非受控组件也是一种有效的选择。"
误区二:受控组件一定要为每个字段写一个 state
看看项目中的 LoginForm------它使用了一个对象 { username: "", password: "" } 来管理整个表单,而不是为每个字段分别创建 state。这是一种实用的模式,避免了 state 数量的膨胀。
误区三:使用非受控组件就不能做验证了
你可以做验证,只是时机不同。受控组件在每次按键时验证,非受控组件可以在提交时一次性验证。选择哪种验证时机,取决于你的产品需求。
注意:受控组件中 value 为 undefined 或 null
如果你给受控组件的 value 设置了 undefined 或 null,React 会认为你"放弃控制",输入框会变成非受控状态。这在 React 中会产生一个警告。所以,如果你要使用受控组件,确保 value 始终是一个有效的字符串(哪怕是空字符串 "")。
十、总结
让我们回顾一下本文的核心要点:
- 受控组件 的表单值存储在 React state 中,通过
value+onChange实现数据绑定,适合需要实时响应用户输入的场景。 - 非受控组件 的表单值存储在 DOM 中,通过
useRef在需要时读取,适合简单表单或提交时一次性处理的场景。 - 受控组件更符合 React 的声明式哲学(UI = f(state)),也是官方推荐的主要方式。
- 两者没有绝对的好坏,关键是根据实际场景做出合理选择。