一、从表单说起
如果你正在开发一个 React 应用,表单处理绝对是你绕不开的话题。无论是登录页面、注册页面,还是评论输入框,表单几乎存在于每一个需要用户输入的地方。
在处理表单时,React 提供了两种完全不同的思路:受控组件 和非受控组件。很多初学者在这两种模式之间反复横跳,不知道什么时候该用哪一种。今天我们就从最基础的代码开始,一步步把这个问题聊透彻。
二、受控组件:让 React 状态接管一切
2.1 什么是受控组件
受控组件,简单来说就是输入框的值由 React 的 state 来驱动 。当用户在输入框中敲击键盘时,每一次按键都会触发 onChange 事件,事件处理函数更新 state,state 的变化又让输入框展示新的值。这就形成了一个闭环:用户操作 → 触发事件 → 更新 state → 重新渲染 → 输入框显示最新值。
你可以把受控组件理解成一个"双向绑定"的简化版本------用户的每一次输入,React 都知道。
2.2 从最简单的受控输入框开始
我们来看一个非常基础的例子:
jsx
javascript
import { useState } from 'react';
function ControlledInput() {
const [value, setValue] = useState('');
return (
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
);
}
这段代码只有 10 行不到,但它完整地展示了受控组件的核心思想:
useState定义了一个叫value的状态,初始值为空字符串。- 输入框的
value属性被绑定到这个状态上。 - 当用户输入时,
onChange事件触发,调用setValue更新状态。 - 状态更新后,组件重新渲染,输入框展示新的内容。
在这个过程中,React 的 state 是"唯一数据源"。输入框本身不保存任何值,它只是一个展示 state 的"窗口"。
2.3 处理多个输入字段
当表单变得复杂,有多个输入字段时,我们通常会用一个对象来管理所有字段的值:
jsx
ini
import { useState } from 'react';
function RegisterForm() {
const [form, setForm] = useState({
username: '',
password: ''
});
const handleChange = (e) => {
setForm({
...form,
[e.target.name]: e.target.value
});
};
return (
<div>
<input
name="username"
value={form.username}
onChange={handleChange}
placeholder="请输入用户名"
type="text"
/>
<input
name="password"
value={form.password}
onChange={handleChange}
placeholder="请输入密码"
type="text"
/>
<button type="submit">提交</button>
</div>
);
}
这里有两个关键点需要留意:
第一点是 name 属性 。每个输入框都有一个 name,它的值正好对应 state 对象中的 key。这样在 handleChange 中,我们就可以通过 e.target.name 动态地知道当前更新的是哪个字段。
第二点是展开运算符 ...form。在更新对象状态时,我们需要先复制原有的所有字段,再覆盖其中某一个字段的值。如果不这样做,其他字段的数据就会丢失。
2.4 加入实时校验
受控组件最大的优势之一,就是可以在用户输入的同时进行实时校验。我们来写一个登录表单,包含用户名和密码两个字段,并在输入过程中验证它们的合法性:
jsx
ini
import { useState } from 'react';
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({
...form,
[name]: value
});
validate(name, value);
};
const isValid = form.username && form.password &&
!errors.username && !errors.password;
const handleSubmit = e => {
e.preventDefault();
if (!isValid) return;
console.log(form, '表单数据提交');
};
return (
<div className="login-wrapper">
<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="password"
name="password"
value={form.password}
onChange={handleChange}
/>
{errors.password && (
<span className="error">{errors.password}</span>
)}
</div>
<button type="submit" disabled={!isValid}>登录</button>
</form>
</div>
);
}
这个例子比之前复杂了不少,我们来拆解一下:
校验逻辑 :validate 函数接收字段名和当前值,根据规则生成错误信息。用户名不能为空且长度至少为 3,密码不能为空且长度至少为 6。
错误状态管理 :errors 是一个对象,key 是字段名,value 是错误信息。更新时使用 setErrors(prev => ({ ...prev, [name]: msg })),只覆盖当前字段的错误信息,保留其他字段的。
提交按钮的禁用状态 :isValid 变量同时检查了三个条件------用户名和密码都不为空,并且两个字段都没有错误信息。只有当这三个条件同时满足时,登录按钮才可用。
表单提交 :handleSubmit 中调用 e.preventDefault() 阻止浏览器的默认提交行为,然后检查表单是否有效,有效才输出数据。
这就是一个完整的受控组件表单,包含数据收集、实时校验和提交控制。你可以感受到,所有的逻辑都集中在 React 的掌控之中。
三、非受控组件:使用 DOM 原生能力
3.1 什么是非受控组件
与受控组件相对,非受控组件的输入框值不存储在 React 的 state 中,而是由 DOM 元素自身来维护 。当你需要获取用户输入时,通过 useRef 直接去 DOM 节点上读取值。
这种方式的写法更接近传统的 HTML 表单,在 React 中适用的场景包括:文件上传、与第三方非 React 库集成、或者你确实不需要实时响应每个输入变化。
3.2 useRef 的基本用法
useRef 是 React 提供的一个 Hook,它返回一个可变的引用对象,这个对象的 .current 属性指向传入的初始值。当我们将 ref 属性赋给一个 DOM 元素时,.current 就会指向该 DOM 节点。
来看一个最直接的非受控组件例子:
jsx
javascript
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>
</>
);
}
这个组件的工作方式是:
useRef(null)创建了一个引用,初始值为null。- 输入框通过
ref={inputRef}将 DOM 节点关联到该引用。 - 点击按钮时,
inputRef.current就是那个输入框 DOM 元素,直接读取value属性就能拿到用户输入的内容。
整个过程没有任何 state 参与,数据完全由 DOM 自己管理。
3.3 实际应用场景:评论输入框
我们来看一个更贴近实际使用的例子------评论提交功能:
jsx
javascript
import { useRef } from 'react';
function CommentBox() {
const textareaRef = useRef(null);
const handleSubmit = () => {
const comment = textareaRef.current.value;
if (!comment) return;
console.log(comment);
};
return (
<div>
<textarea
placeholder="输入评论..."
ref={textareaRef}
/>
<button onClick={handleSubmit}>提交评论</button>
</div>
);
}
在这个评论组件中,我们并不需要实时知道用户输入了什么,只需要在点击提交按钮时一次性获取内容即可。这种情况使用非受控组件就非常合适,代码简洁且性能更好,因为没有多余的渲染。
3.4 受控与非受控:如何选择
| 对比维度 | 受控组件 | 非受控组件 |
|---|---|---|
| 数据存储 | React state | DOM 自身 |
| 实时响应 | 支持 | 不支持 |
| 实时校验 | 天然支持 | 需要额外处理 |
| 代码量 | 相对较多 | 相对较少 |
| 适用场景 | 绝大多数表单场景 | 文件上传、简单表单 |
在实际开发中,受控组件是更主流的选择,因为它让数据流清晰可控。而非受控组件在特定场景下也能发挥重要作用,关键在于根据需求做出合理选择。
四、性能优化:让组件只做必要的事情
当我们的应用逐渐变大,组件嵌套层次变深,性能问题就会浮现出来。最典型的情况是:父组件更新时,所有子组件也跟着重新渲染,即使某些子组件依赖的数据根本没有变化。
4.1 问题的复现
来看下面这个例子,它很好地展示了性能浪费的场景:
jsx
javascript
import { useState } from 'react';
function RegularChild({ name }) {
console.log('渲染了 RegularChild');
return <h1>{name}</h1>;
}
function App() {
const [count, setCount] = useState(0);
const [name, setName] = useState('少林队');
return (
<>
<button onClick={() => setCount(count + 1)}>点击计数 {count}</button>
<button onClick={() => setName('峨眉队')}>改变名字</button>
<RegularChild name={name} />
</>
);
}
在这个例子中,点击计数按钮时发生了什么?
count状态发生变化。- App 组件重新渲染。
RegularChild组件也跟着重新渲染了,即便它只依赖name,而name并没有变化。
控制台会打印出 "渲染了 RegularChild",每次点击计数按钮都会出现一行。这就是一种性能浪费------子组件做了完全不必要的渲染。
4.2 memo:记住组件的渲染结果
React 提供了 memo 这个高阶组件来解决这个问题。它的作用是对比组件的 props 是否有变化,如果所有 prop 都和上一次渲染时相同,就跳过这次渲染,直接复用之前的结果。
jsx
javascript
import { useState, memo } from 'react';
const MemoChild = memo(({ name }) => {
console.log('MemoChild 渲染了');
return <div>Hello, {name}</div>;
});
function App() {
const [count, setCount] = useState(0);
const [name, setName] = useState('少林队');
return (
<>
<button onClick={() => setCount(count + 1)}>点击计数 {count}</button>
<button onClick={() => setName('峨眉队')}>改变名字</button>
<MemoChild name={name} />
</>
);
}
现在点击计数按钮,控制台不会再打印 "MemoChild 渲染了"。因为 memo 发现传入的 name 属性没有变化(还是 '少林队'),于是直接跳过了渲染。
但这里有一个非常重要的细节 :memo 对 props 的比较是浅层比较 。如果传入的 props 是对象或数组,比较的是引用地址,而不是内容是否相等。所以在使用 memo 时,要确保传入的 props 是稳定的引用,或者使用 useMemo、useCallback 来保持引用一致。
4.3 受控与非受控的完整对照
到这里,我们已经完整介绍了受控组件、非受控组件以及性能优化,把这些知识点放在一起对比会更清晰:
| 特性 | 受控组件 | 非受控组件 |
|---|---|---|
| 数据由谁控制 | React state | DOM 自身 |
| 是否能实时获取输入值 | 可以,随时从 state 读取 | 需要时通过 ref 读取 |
| 实时校验的便利性 | 天然支持 | 需要额外的处理逻辑 |
| 是否会导致额外渲染 | 每次输入都会触发 | 只在需要时读取 |
| 与表单整体状态管理的结合 | 非常容易 | 相对困难 |
| 适用场景 | 需要校验、联动、格式化的复杂表单 | 文件上传、简单表单、与第三方库集成 |
五、写在最后
受控组件和非受控组件是 React 表单处理的两大基石。受控组件通过 state 驱动输入,让数据流清晰可追踪,适合大多数表单场景;非受控组件借助 ref 直接操作 DOM,在某些特定场景下能让我们写出更简洁的代码。
选择哪一种方式,取决于你的具体需求。如果你需要实时校验、字段联动、或者条件性地禁用提交按钮,受控组件是更好的选择。如果表单非常简单,或者你需要处理文件上传这类原生 DOM 操作更便捷的场景,非受控组件也是一个合理的选择。
而当我们面对性能问题时,memo 提供了一种轻量级的优化手段,通过缓存渲染结果来避免不必要的重新渲染。不过需要注意,性能优化应该基于实际需要,而不是在开发初期就过度使用,保持代码的简洁性和可读性同样重要。
希望这篇文章能帮助你更清晰地理解 React 中的表单处理和性能优化思路,在实际开发中做出更合适的技术选型。