在 React 开发中,处理表单和 DOM 元素是每个开发者都会遇到的基础挑战。很多初学者在使用 useState 和 useRef 时容易混淆,对于到底是选择“受控组件”还是“非受控组件”也常常感到困惑。
今天,我们将深入剖析这两个核心概念,通过实际的代码案例(如评论框、登录框、计时器等),带你彻底打通 React 数据流管理的任督二脉。
一、 useRef:不仅仅是 DOM 的“钩子”
提到 useRef,很多开发者的第一反应是:“哦,这是用来获取 <input> 焦点或者滚动位置的。”
没错,但访问 DOM 只是 useRef 能力的一半。为了真正理解它,我们需要从两个维度来看待它:DOM 访问与变量存储。
1. 场景一:访问 DOM 节点(命令式操作)
在 React 的声明式 UI 中,有时我们需要“强行”执行一些命令式操作,比如页面加载后自动聚焦输入框。
import { useRef, useEffect } from 'react';
function AutoFocusInput() {
const inputRef = useRef(null); // 1. 创建 ref 容器,初始值为空
useEffect(() => {
// 3. 组件挂载后,current 属性已指向真实的 DOM 元素
// 此时我们可以直接调用原生 DOM API
console.log(inputRef.current);
inputRef.current.focus();
}, []);
// 2. 将 ref 绑定到元素上
return <input ref={inputRef} />;
}
原理: useRef 创建了一个包含 .current 属性的普通 JavaScript 对象。当 React 创建 DOM 节点后,会将其引用赋值给这个 .current 属性。
2. 场景二:“默默奉献”的可变容器
useRef 和 useState 都可以存储数据,但它们有一个本质区别:useRef 的变化不会触发组件重新渲染。
- useState: 是响应式的。一旦改变,React 会感知并更新视图。
- useRef: 是持久化的容器。它像一个盒子,你可以随时往里存取东西,React 即使重新渲染组件,这个盒子里的东西也不会丢,但修改它也不会通知 React 更新 UI。
这非常适合存储像 定时器 ID 这种“不需要渲染在页面上”的数据:
import { useRef, useState } from 'react';
function Timer() {
let intervalId = useRef(null); // 存储计时器 ID
const [count, setCount] = useState(0);
function start() {
// 修改 current 不会触发重渲染,也不会丢失
intervalId.current = setInterval(() => {
console.log('tick~~~~');
}, 1000);
}
function stop() {
// 直接读取 current 清理计时器
clearInterval(intervalId.current);
}
return (
<>
<button onClick={start}>开始</button>
<button onClick={stop}>停止</button>
</>
)
}
二、 非受控组件 (Uncontrolled Components)
理解了 useRef,就很好理解非受控组件了。所谓的“非受控”,是指表单数据由 DOM 节点本身处理,而不是由 React 组件的状态处理。
代码演示:评论框
在这种模式下,React 并不实时掌握输入框里的内容。我们通常在提交那一刻,利用 ref 去 DOM 里“拉取”数据。
import { useRef } from 'react';
export default function CommentBox() {
const textareaRef = useRef(null);
const handleSubmit = () => {
// 只有在需要的时候(比如提交时),才去 DOM 里面拿值
const comment = textareaRef.current.value;
if(!comment) return alert('请输入评论');
console.log(comment);
}
return (
<div>
{/* defaultValue 可以设置初始值,但之后就是用户自由控制了 */}
<textarea ref={textareaRef} placeholder="请输入评论..."></textarea>
<button onClick={handleSubmit}>提交</button>
</div>
)
}
优缺点分析
- 优点: 代码量少,容易集成非 React 代码(如 jQuery 插件),适合文件上传等场景。
- 缺点: 无法实时验证(例如无法在用户打字时提示“密码太短”),难以实现字段间的联动。
- 适用场景: 一次性读取数据、性能敏感场景、文件上传。
三、 受控组件 (Controlled Components)
受控组件是 React 官方推荐的处理表单的方式。在这里,React 的 state 成为“单一数据源” (Single Source of Truth) 。
这意味着:输入框的 value 来源于 state,而用户的输入(onChange)通过更新 state 来改变 value。
代码演示:登录表单
import { useState } from 'react';
export default function LoginForm() {
const [form, setForm] = useState({
username: '',
password: ''
});
const handleChange = (e) => {
// 状态更新驱动页面变化
setForm({
...form,
// 使用计算属性名处理多个输入
[e.target.name]: e.target.value
})
}
const handleSubmit = (e) => {
e.preventDefault();
console.log(form); // 直接打印 state,不需要去 DOM 查值
}
return (
<form onSubmit={handleSubmit}>
<input
type="text"
name="username"
value={form.username} // 数据 -> 视图
onChange={handleChange} // 视图 -> 数据
/>
<input
type="password"
name="password"
value={form.password}
onChange={handleChange}
/>
<button type="submit">登录</button>
</form>
)
}
为什么要这么麻烦?
虽然代码看起来比非受控组件多了几行,但这带来了巨大的好处:
- 即时验证: 你可以在
handleChange里立即判断输入是否合法,并显示错误信息。 - 条件禁用: 例如
disabled={!form.username},没填用户名不能点登录。 - 数据格式化: 可以强制用户输入的内容符合特定格式(如大写转换)。
四、 巅峰对决:如何选择?
总结来说,两者的核心区别在于谁掌握了数据的所有权。
| 特性 | 非受控组件 (Uncontrolled) | 受控组件 (Controlled) |
|---|---|---|
| 数据来源 | DOM 节点 | React State |
| 获取值的方式 | 使用 useRef (current.value) | 使用 useState (直接读取变量) |
| 实时交互 | 弱 (适合一次性获取) | 强 (适合即时反馈) |
| 代码量 | 较少 | 稍多 |
| 典型用途 | 文件上传、简单表单 | 表单校验、联动、实时搜索 |
最佳实践建议
在绝大多数日常开发中(90% 的场景),建议优先使用受控组件。它能让你完全掌控数据流,避免状态与 UI 不一致的问题。
只有在以下情况考虑使用非受控组件:
- 需要通过
<input type="file" />上传文件(因为文件是只读的,React 无法控制)。 - 集成了大量第三方 DOM 库。
- 非常简单的表单,完全不需要验证逻辑。
掌握了 useRef 的双重用法以及两种组件模式的区别,你就已经跨过了 React 表单开发中最重要的一道门槛。接下来,去尝试用受控组件重构你复杂的表单逻辑吧!