在 React 开发中,表单处理是绕不开的核心场景。你是选择"受控组件"让 React 状态全面接管,还是用"非受控组件"通过 ref 直接操作 DOM?本文将逐行拆解真实代码,带你彻底掌握两者的区别、选型与最佳实践。
一、为什么会有受控与非受控?
React 的核心思想是 UI = f(state),即页面完全由状态驱动。但表单元素(input、textarea、select 等)天生具有自己的内部状态(比如用户输入时立即显示字符)。这就产生了两种设计模式:
- 受控组件(Controlled Component) :表单元素的值完全由 React 状态控制,
value绑定 state,onChange更新 state。React 是唯一的"数据源"。 - 非受控组件(Uncontrolled Component) :表单元素的值保存在 DOM 节点自身内部,React 通过
ref在需要时"拿过来用"。DOM 是数据源。
下面我们将通过项目中的五个文件,逐行看透这两种模式的实现细节、连接关系以及适用场景。
完整项目链接:gitee.com/hong-strong...
二、受控组件实例:LoginForm.jsx 逐行解析
jsx
import { useState } from "react"
解析 :从 React 中导入 useState Hook。受控组件的核心就是依赖 state 存储表单数据,所以必须引入该 Hook。
jsx
export default function LoginForm() {
解析 :导出一个函数组件 LoginForm,展示一个登录表单(用户名 + 密码)。
jsx
const [form, setForm] = useState({
username: "",
password: ""
});
解析 :声明一个 state 对象 form,包含 username 和 password 两个字段。初始值均为空字符串。这个 state 将成为两个输入框的 唯一真实数据源。
jsx
const handleChange = (e) => {
// 返回一个新的对象
setForm({
...form,
[e.target.name]: e.target.value
})
}
逐行解释:
handleChange作为所有输入框的公共事件处理函数。- 参数
e是合成事件对象,e.target指向触发事件的<input>元素。 ...form:展开运算符复制原 state 中的其他字段(例如修改用户名时保留密码字段)。[e.target.name]: e.target.value:计算属性名。因为每个 input 都有name属性(username或password),所以可以动态更新对应的字段。setForm触发重新渲染,新值会立刻写回 state。这就是 "单向数据流 + 显式变更" 的受控模式。
jsx
const handleSubmit = (e) => {
e.preventDefault();
console.log(form);
}
解析:
- 表单提交时触发,
e.preventDefault()阻止页面刷新。 - 直接打印
form对象(此时已包含用户最新输入)。因为 state 始终与输入同步,所以提交时无需再读取 DOM。
jsx
return (
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="请输入用户名"
name="username"
onChange={handleChange}
value={form.username}
/>
<input
type="password"
placeholder="请输入密码"
name="password"
onChange={handleChange}
value={form.password}
/>
<button type="submit">注册</button>
</form>
)
}
关键点解析:
- 每个
<input>都显式设置了value={form.xxx}:这使 input 变为受控组件。用户输入时,value 不会改变(因为 React 会强制使用 state 中的值),只有通过onChange更新 state 后,value 才会变化。 name属性与 handleChange 中的计算属性名配合,实现多字段复用。- 按钮
type="submit"会触发表单的onSubmit事件。
受控组件特点总结:
- 实时同步:每次按键都会更新 state → 重新渲染 → 输入框显示新值。
- 便于校验、动态启用/禁用按钮、格式化输入(如手机号自动加空格)。
- 性能代价:频繁的 state 更新和重渲染。
三、非受控组件实例:CommentBox.jsx 逐行解析
jsx
import { useRef } from 'react';
解析 :从 React 中导入 useRef Hook。非受控组件通常通过 ref 获取 DOM 节点的值。
jsx
export default function CommentBox() {
// UnControlled
解析:注释明确说明这是一个非受控组件。
jsx
const textareaRef = useRef(null);
解析 :创建一个 ref 对象,初始值为 null。该 ref 将赋值给 <textarea> 的 ref 属性。之后 textareaRef.current 会指向真实的 DOM 节点。
jsx
const handleSubmit = () => {
const comment = textareaRef.current.value;
if (!comment) return alert('请输入评论');
console.log(comment);
}
逐行解释:
- 提交时直接通过
textareaRef.current.value从 DOM 中读取当前文本域的内容。 - 如果为空则弹窗警告。
- 非受控组件 不需要 为每次输入维护 state,只有提交时才去获取值。这减少了渲染次数,尤其适合长文本或大量表单。
jsx
return (
<div>
<textarea
ref={textareaRef}
placeholder="输入评论...">
</textarea>
<button onClick={handleSubmit}>提交</button>
</div>
)
}
解析:
<textarea>没有value属性,也没有onChange处理。它的内容完全由用户直接操控 DOM 来决定。ref={textareaRef}将 DOM 节点挂载到textareaRef.current。- 按钮的
onClick直接调用handleSubmit(注意没有使用<form>,因此不会自动触发提交事件)。
非受控组件特点:
- 代码更少,不需要写 onChange 和 state。
- 性能更好,没有实时渲染开销。
- 无法在输入过程中做实时校验、格式化或条件控制。
- 适合一次性获取数据(如表单提交、文件上传)。
四、App.jsx:串联两个组件
jsx
import CommentBox from './components/CommentBox';
import LoginForm from './components/LoginForm';
解析:分别导入上面写好的受控组件和非受控组件。
jsx
export default function App () {
return(
<>
<CommentBox />
< LoginForm />
</>
)
}
解析:
- 使用 Fragment(
<>...</>)同时渲染两个组件。 - 注意注释写法
< LoginForm />中间有空格,但不影响运行。 - 这个 App 展示了在同一个应用中,开发者可以根据场景灵活选择受控或非受控模式。
链接关系:
LoginForm演示典型的受控表单(实时更新 state,提交时直接使用 state)。CommentBox演示非受控表单(提交时通过 ref 获取数据)。- 两者互不干扰,可以共存。
五、混合示例:App2.jsx 逐行解析
jsx
import { useState, useRef } from 'react';
解析:同时导入 state 和 ref,表示该组件中两种模式都会出现。
jsx
export default function App () {
// 受控组件 组件里有被状态控制的表单元素
// 单向数据流 单向绑定
// 状态绑定输入框 输入框被控制了
// 状态控制 输入框的值
const [value, setValue] = useState("");
const inputRef = useRef(null);
解析:
value和setValue用于第一个受控输入框。inputRef用于第二个非受控输入框。- 注释再次强调受控的本质:状态控制输入框的值。
jsx
const doLogin = (e) => {
e.preventDefault();
console.log(inputRef.current.value)
}
解析:提交时只打印了非受控输入框的值,没有打印受控输入框的值。这说明了混合使用下可以按需获取。
jsx
return(
<form onSubmit={doLogin}>
{value}
<input type="text"
value={value}
onChange={(e) => setValue(e.target.value)}/>
<input type="text" ref={inputRef} />
<button type="submit">登录</button>
</form>
)
}
逐行解释:
{value}:在表单顶部实时显示受控输入框的值,直观展示受控数据的流动性。- 第一个
input:受控组件,value绑定 state,onChange更新 state。用户输入 → 触发 setValue → 组件重渲染 → value 更新 → 输入框显示最新值,同时上方的{value}也会同步。 - 第二个
input:非受控组件,没有 value 也没有 onChange,只通过ref标记,后续获取其值。 - 提交时只打印了非受控组件的值,说明受控组件的值其实早已存在于 state 中(
value),可以直接使用。
这个示例完美展示了:
- 受控组件数据实时反馈,可被页面其他部分消费。
- 非受控组件仅在需要时读取,且读取时机完全由开发者控制。
- 两者可以在同一个表单中并存。
六、main.jsx:项目入口
jsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
解析:
StrictMode:React 的严格模式工具,用于检测潜在问题(如不安全的生命周期、意外副作用)。createRoot:React 18+ 的新根渲染 API。- 引入样式文件和根组件
App。
jsx
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
解析 :获取 HTML 中 id 为 root 的节点,并将 <App /> 渲染进去。整个应用从 App 开始,而 App 又引用了 LoginForm 和 CommentBox,所以受控和非受控的对比将直接呈现在页面上。
七、核心差异对比表
| 对比维度 | 受控组件 | 非受控组件 |
|---|---|---|
| 数据存储 | React 组件的 state | DOM 节点自身 |
| 获取值方式 | 直接从 state 读取 | 通过 ref.current.value 获取 |
| 输入实时反应 | 每次输入都会触发 setState 和重渲染 | 无重渲染,性能更优 |
| 代码复杂度 | 需要维护 state + onChange | 只需创建 ref 并在需要时读取 |
| 表单校验 | 可实时校验(即时提示错误) | 只能在提交时校验或通过 ref 手动触发 |
| 动态控制 | 可根据其他状态动态修改输入框的值(如清空、填充默认值) | 需要直接操作 DOM(不推荐) |
| 适用场景 | 表单联动、实时校验、动态输入(如信用卡号格式化) | 简单表单、文件上传、性能敏感场景、与第三方非 React 代码集成 |
| 默认值设置 | 通过初始 state 设置 | 使用 defaultValue / defaultChecked |
| React 官方推荐 | 大多数场景优先使用受控组件 | 仅在必要或简单场景使用 |
八、面试高频问题与深度扩展
Q1:受控组件每次输入都 setState,会不会导致性能问题?
A:对于大多数普通表单(几十个输入框以内),React 的 diff 和批量更新机制完全可以接受。如果确实有极高频率的输入(比如富文本编辑器、画板),可以考虑:
- 使用
useMemo/React.memo减少子组件重渲染。 - 使用非受控组件 +
ref,只在提交时读取。 - 采用 debounce 或 throttle 减少 setState 频率(如搜索框)。
- 选用第三方表单库(React Hook Form)------它默认使用非受控模式,性能极佳。
Q2:什么时候必须用非受控组件?
A :最常见的场景是 文件上传 (<input type="file">)。因为文件对象的 value 是只读的,无法通过 state 控制,只能通过 ref 获取 files 属性。另外,与 jQuery 插件或原生 DOM 操作库集成时,非受控组件更方便。
Q3:非受控组件如何设置默认值?
A :使用 defaultValue 属性(对于 checkbox/radio 用 defaultChecked)。例如:
jsx
<input type="text" defaultValue="初始文本" ref={inputRef} />
这样输入框一开始会显示"初始文本",但后续用户修改不会影响任何 React 状态。
Q4:为什么 React 官方文档说"大多数情况下推荐使用受控组件"?
A:因为受控组件符合 React 声明式编程范式------UI 与状态一一对应,数据流清晰可追踪,便于调试和测试。而非受控组件引入命令式操作(直接读 DOM),可能会让代码逻辑分散,在复杂表单中难以维护。
Q5:如何在不使用 ref 的情况下实现非受控?
A:非受控的本质就是不将 value 绑定到 state。你甚至可以直接忽略 value:
jsx
<input name="email" onChange={() => {}} />
但没有 ref 的话你无法获取到它的值。所以通常还是需要 ref,或者通过表单的 onSubmit 事件配合 FormData 获取所有字段(现代浏览器支持)。
Q6:useRef 和 createRef 的区别?
A :useRef 用于函数组件,每次渲染返回同一个 ref 对象。createRef 主要用于类组件,在函数组件中每次重新渲染都会创建新的 ref,所以不要混用。
九、进阶实践:受控组件的性能优化与 React Hook Form
1. 受控组件避免不必要的重渲染
jsx
// 错误示范:每次渲染都新建函数
<input onChange={(e) => setValue(e.target.value)} />
// 优化:使用 useCallback 稳定函数引用
const handleChange = useCallback((e) => {
setValue(e.target.value);
}, []);
2. React Hook Form ------ 现代表单解决方案
该库结合了非受控组件的性能和受控组件的灵活性:
- 默认使用 ref 注册表单字段,避免频繁重渲染。
- 提供
watch方法实现部分受控效果。 - 内置校验、错误处理、联动等。
示例对比:
jsx
// 传统受控组件需要写大量样板代码
const [name, setName] = useState('');
<input value={name} onChange={e => setName(e.target.value)} />
// React Hook Form 只需一句
const { register, handleSubmit } = useForm();
<input {...register('name')} />
十、总结与建议
| 你的需求 | 推荐方案 |
|---|---|
| 简单表单(登录、注册、搜索) | 受控组件(清晰、可靠) |
| 复杂表单(几十个字段,需要高性能) | React Hook Form(非受控核心) |
| 文件上传 / 与第三方非 React 代码集成 | 非受控组件 + ref |
| 实时校验、动态依赖字段(如国家-城市联动) | 受控组件 |
| 一次性获取数据,无需中间反馈 | 非受控组件 |