从项目实战出发,完整讲解两种模式的核心概念、本质区别、选择策略以及完整表单实战。
📦 本文代码示例均来自项目
uncontrolled-demo,循序渐进,从入门到实战。🎯 读完本文,你将彻底告别表单相关的"奇怪" bug,清楚地知道什么时候用哪种模式。
📖 目录
- [一、背景:React 与表单的"相遇"](#一、背景:React 与表单的"相遇" "#%E4%B8%80%E8%83%8C%E6%99%AFreact-%E4%B8%8E%E8%A1%A8%E5%8D%95%E7%9A%84%E7%9B%B8%E9%81%87")
- [二、受控组件(Controlled Component)](#二、受控组件(Controlled Component) "#%E4%BA%8C%E5%8F%97%E6%8E%A7%E7%BB%84%E4%BB%B6controlled-component")
- [三、非受控组件(Uncontrolled Component)](#三、非受控组件(Uncontrolled Component) "#%E4%B8%89%E9%9D%9E%E5%8F%97%E6%8E%A7%E7%BB%84%E4%BB%B6uncontrolled-component")
- 四、本质区别:数据归谁管
- 五、复杂表单实战
- 六、选择指南
- 七、项目结构与组件关系
- 八、常见错误
- 九、总结
一、背景:React 与表单的"相遇"
React 的核心思想是 "数据驱动视图" ------你只管改数据(state),界面自动跟着变,完全不用手动操作 DOM:
jsx
// 原生 JS:手动改 DOM
document.getElementById("myInput").value = "新值";
// React:改 state,界面自动刷新
setValue("新值");
一切看起来都很美好,直到我们遇到了 表单元素。
<input>、<textarea>、<select> 这些表单元素非常特殊------它们自己内部就有状态(浏览器帮你记着用户敲了什么)。这就产生了一个核心问题:
🤔 表单的值到底归 React 的 state 管,还是归浏览器 DOM 管?
两种答案,两种模式:受控组件 和 非受控组件。
💡 为什么这很重要?
表单是 Web 应用最常见的交互入口------登录、注册、搜索、评论,几乎每个页面都有表单。但 React 初学者在处理表单时,经常遇到一堆看似"奇怪"的 bug:
- 😵 输入框敲不出字
- 😵 填写第二个字段时,第一个字段的值凭空消失
- 😵 校验提示总是慢半拍
这些问题的根源几乎都是同一个------搞混了受控和非受控,或者用错了方式。
理解本章内容,可以帮你从根本上避开这些坑,写出既优雅又健壮的表单代码。
二、受控组件(Controlled Component)
🎯 先理解概念
受控组件的核心思想很简单:React 接管表单的一切。
你把用户输入存进 state,又从 state 读出来显示在界面上。用户每敲一下键盘,数据都要经过 React 的手------先更新 state,state 变了再驱动界面刷新。整个过程中,React 是唯一的"真相来源",DOM 只是一个听话的展示工具。
打个比方:想象你是一个传令兵,用户每敲一个字,你都得跑一趟------记下新内容 → 报告给 state → state 下令更新界面。没有你的报告,界面纹丝不动。这就是"受控"的含义:每一帧数据流动都经过 React 的手。
这种方式虽然需要多写几行代码(value={} + onChange={}),但收益巨大:
✅ 你可以在数据流动的每一个环节插手------做校验、做格式化、根据当前值控制其他 UI 元素的状态。
📊 数据流向
perl
用户敲键盘 → onChange 触发 → setState 更新 state → state 变 → value 变 → 界面刷新
↑________________________________________________________________________↓
闭环,React 时刻掌握最新值
💻 看看代码(对照项目 ControlledInput.jsx)
jsx
import { useState } from 'react'
// ↑ 从 react 包里引入 useState。它是 React 提供的"状态管理"函数
function ControlledInput() {
const [value, setValue] = useState('')
// ↑ ↑ ↑
// 当前值 修改函数 初始值为空字符串
// 这行叫"数组解构":useState 返回一个数组 [当前值, 修改函数],解构后各取各的名字
return (
<>
ControlledInput(受控组件)
<input
type="text"
value={value}
// ↑ input 显示什么内容,完全由 value 这个 state 决定
// 只要 state 不变,用户在键盘上敲什么都没用------input 不会变
onChange={(e) => setValue(e.target.value)}
// ↑ 每次敲字,浏览器触发 onChange,传一个事件对象 e
// e.target 就是触发的这个 <input> 元素
// e.target.value 是用户刚敲进去的最新文本
// 用 setValue 把它存进 state → state 变了 → value 变了 → input 刷新
/>
</>
)
}
🔍 代码详解
这段代码虽然短,但已经包含了受控组件的全部核心要素:
| 部分 | 作用 | 关键点 |
|---|---|---|
useState('') |
创建 state,初始为空字符串 | 它是 input 值的"主人" |
value={value} |
把 state 绑定到 input | input 永远显示 state 的当前值 |
onChange={...} |
唯一的"入口" | 用户在键盘上敲的任何东西都通过这个入口进入 React |
🚀 这条闭环就是 React 单向数据流的精髓。 数据永远沿着"用户操作 → 更新 state → 重新渲染"的方向流动,清晰可控。
✨ 受控组件能做什么
因为 React 时刻知道表单的值,所以你可以轻松实现:
| 功能 | 说明 |
|---|---|
| 🔍 实时校验 | 每敲一个字就检查规则,立刻给出提示 |
| 📝 格式化输入 | 输入 13800000000,自动变成 138 0000 0000 |
| 🔗 字段联动 | 选了"广东省",城市列表自动变成广州、深圳 |
| 🚫 按钮状态控制 | 表单没填完,提交按钮灰色不可点 |
| 📊 字数统计 | 实时显示"已输入 15/100 字" |
三、非受控组件(Uncontrolled Component)
🎯 先理解概念
非受控组件走的是完全相反的路线:React 撒手不管,让浏览器自己处理。
用户敲了什么字,直接存在 DOM 里,React 不追踪、不拦截、不关心。只有当你主动去"问"的时候------比如用户点了提交按钮------React 才去 DOM 里读取当前值。
数据流向非常简短:
用户敲键盘 → DOM 自己存着值 → ...(React 不知道)... → 点按钮 → 去 DOM 里取
⚖️ 代价 :你在用户输入过程中什么都不知道------不能做实时校验,不能做格式化,不能根据输入状态控制按钮。React 退居二线,只有当"前台"------用户写完了过来问"刚才写了啥?"
💻 看看代码(对照项目 UncontrolledInput.jsx)
jsx
import { useRef } from 'react'
// ↑ useRef 创建一个"引用",用来抓取 DOM 元素
function UncontrolledInput() {
const inputRef = useRef(null)
// ↑ 创建一个 ref 对象,初始 .current = null
// 可以想象成一根绳子,暂时还没系到任何东西上
function handClick() {
console.log(inputRef.current.value)
// ↑ ↑ ↑
// ref对象 .current 真实的 DOM 元素
// 绳子 顺着绳子 找到了!读取它的 value 属性
}
return (
<>
UncontrolledInput(非受控组件)
<input type="text" ref={inputRef} />
{/* ↑ 把绳子系到 input 的 DOM 元素上 */}
<button onClick={handClick}>获取输入值</button>
</>
)
}
🔍 代码详解
这段代码的关键在于 useRef:
| 部分 | 作用 | 比喻 |
|---|---|---|
useRef(null) |
创建一个引用对象 | 一根绳子,还没系到任何东西上 |
ref={inputRef} |
把引用绑定到 DOM 元素 | 把绳子系到 input 上 |
inputRef.current |
获取真实的 DOM 元素 | 顺着绳子找到那个元素 |
📌 核心区别 :用户在 input 里敲了一堆字,React 完全不知道,只有
inputRef.current.value被调用的那一刻才去读取。和受控组件"每敲一下都报告"不同,非受控是"等你问了我再去看"。
🔄 不用 useRef 也能做非受控
非受控不一定非得用 useRef。浏览器触发事件时,会把 e.target(触发事件的元素)白送给你:
jsx
function MyForm() {
const handleSubmit = (e) => {
e.preventDefault(); // 阻止 <form> 默认刷新页面
const formData = new FormData(e.target);
// ↑ 浏览器内置 API,自动收集 <form> 内所有带 name 属性的输入框的值
// 相当于把表单里每个 input 的 name 和 value 打包成一个"键值对集合"
console.log(formData.get("username"));
// ↑ 通过 name 属性名取出对应字段的值,就像查字典
};
return (
<form onSubmit={handleSubmit}>
<input name="username" />
{/* 注意:没有 value={},也没有 onChange={},React 完全不管 */}
<button type="submit">提交</button>
</form>
);
}
这里没用 useRef,但照样是非受控------数据从头到尾存在 DOM 里,提交时才一次性取出来。
💡 那什么时候必须用
useRef? 当你需要操作 DOM 但没有事件帮你传引用的时候。比如组件一加载就自动聚焦输入框:
jsx
// useEffect 是 React 提供的另一个 hook,在组件首次渲染后自动执行一次
useEffect(() => {
inputRef.current.focus(); // 顺着绳子找到 DOM,让它获得焦点
}, []);
📝 非受控进阶:CommentBox(textarea + 提交校验)
下面再看一个非受控的实际例子------评论框。和 UncontrolledInput 同理,用 useRef 抓取 DOM,提交时再读取值:
jsx
// components/CommentBox.jsx
import { useRef } from 'react'
function CommentBox() {
const textareaRef = useRef(null)
function handleSubmit() {
const value = textareaRef.current.value
// 提交前做非空校验
if (!value.trim()) {
alert('评论内容不能为空!')
return
}
console.log('提交评论:', value)
// 实际项目中这里调接口
}
return (
<>
<h3>💬 评论框(非受控)</h3>
<textarea
ref={textareaRef}
placeholder="请输写评论内容..."
rows={4}
cols={40}
/>
<br />
<button onClick={handleSubmit}>提交评论</button>
</>
)
}
export default CommentBox
📌 注意 :这里用的是
onClick而不是onSubmit,因为不存在<form>元素,不需要阻止默认刷新行为。useRef的用法和UncontrolledInput完全一致------抓 DOM → 存引用 → 需要时读值。
四、本质区别:数据归谁管
前面分别看过了两种模式的代码,现在把它们放在一起对比。核心区别只有一句话:数据存在 React 的 state 里,还是存在浏览器的 DOM 里。
📊 对比表格
| 维度 | 受控组件 | 非受控组件 |
|---|---|---|
| 数据存在哪 | React state(内存) | 浏览器 DOM |
| 怎么读值 | 直接读 state 变量 |
ref.current.value 或 FormData,需要主动去取 |
| 实时性 | 每次按键都能感知 | 只在"主动去读"那一刻才知道 |
| 用的 Hook | useState |
useRef(但不是必须的) |
| input 属性 | value={} + onChange={} |
ref={} 或什么都不写 |
⚠️ 为什么不能简单用 hook 来区分?
初学者容易觉得"用了 useState 就是受控,用了 useRef 就是非受控",但这不对。
🔑 关键认知 :
useState和useRef只是"工具",真正的区别是数据归谁管。同一个工具可以服务不同的设计,同一个设计也可以用不同工具实现。
看两个反例:
jsx
// 反例 1:没用 useRef,用 FormData + e.target,照样是非受控 ✓
// 说明:非受控不一定要用 useRef,关键看数据在哪
// 反例 2:同时用了 useState 和 useRef
// 如果 ref 只用来操作 DOM(比如聚焦),数据还是 state 管的 → 这是受控组件!
function Input() {
const [value, setValue] = useState('');
const inputRef = useRef(null); // 这个 ref 纯粹用来做聚焦,跟数据无关
return (
<input
ref={inputRef} // ref:管 DOM 操作(聚焦等)
value={value} // state:管数据
onChange={(e) => setValue(e.target.value)}
/>
);
}
// 这个组件同时用了 useState 和 useRef,但它毫无疑问是受控组件!
// 因为 input 显示的内容完全由 value state 决定,ref 只是辅助操作 DOM 而已。
✅ 判断标准 :表单的值存在 state 里还是 DOM 里,而不是看用了什么 hook。
🔗 数据绑定 vs 数据流(易混概念)
这两个词经常被混淆,但说的是不同层面的事:
| 维度 | 数据绑定 | 数据流 |
|---|---|---|
| 关注点 | 单个组件和数据怎么"接上" | 数据在组件树中往哪"流" |
| 粒度 | 微观(一个 input 和一个 state) | 宏观(父组件→子组件→孙组件) |
| React | 单向绑定:value={state} |
单向数据流:父传子 props |
| Vue | 双向绑定:v-model |
单向数据流:父传子 props |
📌 延伸 :React 两个都是单向的。Vue 的
v-model本质是语法糖------底层拆开就是:value="data" + @input="data=$event.target.value",还是受控,只是写起来像非受控一样省事。React 把每一步都亮出来让你控制,Vue 帮你盖了一层"自动挡"。
五、复杂表单实战
前面两章用单个 input 演示了两种模式的基本原理。但实际开发中,表单往往有多个字段,还需要校验、错误提示、按钮状态控制。
下面用项目中的两个真实组件,展示受控组件如何应对这些需求。
5.1 多字段表单(对照项目 RegisterForm.jsx)
当表单有多个字段时,如果给每个字段都写一个 useState 和一个 handleXxx,代码会迅速膨胀:
jsx
// ❌ 笨办法:每个字段一套 state + 处理函数
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const handleUsername = (e) => setUsername(e.target.value);
const handlePassword = (e) => setPassword(e.target.value);
// 加一个字段就要加两行,10 个字段就是 20 行,维护噩梦 😱
🎯 正确的做法
核心技巧:一个对象 state + 一个通用 handleChange + 每个 input 写 name 属性。
不管有多少字段,都是这一个模式,加字段只需在 state 初始化里加一行属性。
jsx
import { useState } from 'react'
function RegisterForm() {
// 用一个对象管理所有字段。加字段只需在这里加一行属性,比如 email: ""
const [form, setForm] = useState({
username: "",
password: ""
});
/**
* 通用处理函数 ------ 核心技巧在这里 🎯
*
* 不管哪个 input 变化,都走这个函数。奥秘在于 e.target.name:
* 用户改了 name="username" 的 input → e.target.name = "username"
* 用户改了 name="password" 的 input → e.target.name = "password"
*
* 然后用 ES6 的"计算属性名"语法 [e.target.name] 动态决定改对象的哪个属性
*/
const handleChange = (e) => {
setForm({
...form, // ⚠️ 重要:先把旧对象的所有属性复制过来
[e.target.name]: e.target.value // 再更新变化的字段
});
};
const handleSubmit = (e) => {
e.preventDefault(); // 阻止 <form> 默认刷新
console.log(form); // 拿到完整的 { username: "...", password: "..." }
};
return (
<div>
<input
name="username"
value={form.username}
onChange={handleChange}
placeholder="用户名"
/>
<input
name="password"
value={form.password}
onChange={handleChange}
placeholder="密码"
/>
<button type="submit" onClick={handleSubmit}>提交</button>
</div>
)
}
⚠️ 为什么 ...form 不能丢?
这是一个非常容易踩的坑。setState 是替换而非合并------你传给它什么对象,它就用什么对象完全覆盖旧的:
js
// ❌ 没有 ...form ------ 每次只保留一个字段,另一个被覆盖掉了
setForm({ [e.target.name]: e.target.value });
// 输入 username "abc" → form = { username: "abc" } ← password 丢了!
// 再输 password "123" → form = { password: "123" } ← username 又丢了!
// ✅ 有 ...form ------ 先复制旧数据,再覆盖变化的字段
setForm({ ...form, [e.target.name]: e.target.value });
// 输入 username → form = { username: "abc", password: "" } ← 两个都在
// 再输 password → form = { username: "abc", password: "123" } ← 两个都在
5.2 带实时校验的完整表单(对照项目 LoginForm/index.jsx)
RegisterForm 解决了"多字段管理"的问题,但实际项目往往还需要校验。
🎯 完整需求:
- 用户输入时实时校验
- 不合规时显示红色错误提示
- 表单没填对之前提交按钮是灰色的
一句话:一个完整的登录表单!
🏗️ 整体思路
在 RegisterForm 的基础上再加一套机制:
用第二个 state 专门存储错误信息。
form管数据,errors管校验结果。
用户在 handleChange 中每敲一个字,既更新 form,又触发 validate 检查新值是否合规,不合规就把错误信息写进 errors。
📝 完整代码解析
下面把代码拆成四个部分逐一讲解,方便理解每一块在做什么。
① 数据定义 ------ 两套 state 各管各的
先把"仓库"准备好:form 存用户写的内容,errors 存校验结果。
jsx
import { useState } from 'react'
function LoginForm() {
// 📦 form ------ 存用户输入的数据
const [form, setForm] = useState({ username: "", password: "" });
// 📋 errors ------ 存每个字段的错误信息
// 空字符串代表"没错误",有文字代表"有问题"
const [errors, setErrors] = useState({});
② 校验逻辑 ------ 检查输入是否合规
校验函数接收两个参数:哪个字段变了、新的值是什么。
规则很简单------为空、太短、太长都算错,其他情况算对(msg 保持空字符串)。
jsx
const validate = (name, value) => {
let msg = ''; // 初始假设:没错误 ✅
// 校验用户名
if (name === 'username') {
if (!value) {
msg = '请输入用户名';
} else if (value.length < 3) {
msg = '用户名长度不能小于3位';
} else if (value.length > 10) {
msg = '用户名长度不能大于10位';
}
}
// 校验密码
if (name === 'password') {
if (!value) {
msg = '密码不能为空';
} else if (value.length < 6) {
msg = '密码长度不能小于6位';
} else if (value.length > 10) {
msg = '密码长度不能大于10位';
}
}
// ⚠️ 用 prev => 的方式更新,保证快速连续输入时不会拿到旧数据
setErrors(prev => ({
...prev, // 保留其他字段的错误信息不变
[name]: msg // 只覆盖当前字段的错误
}));
};
③ 事件处理 + 合法性判断
用户每敲一个字,handleChange 做两件事:更新 form + 触发校验。
isValid 自动根据最新 form 和 errors 判断表单是否合法。
jsx
const handleChange = (e) => {
const { name, value } = e.target; // ES6 解构,一行取出 name 和 value
setForm({ ...form, [name]: value }); // 更新表单数据
validate(name, value); // 立即校验刚输入的内容
};
// ✅ isValid ------ 四个条件全部满足才算 true
const isValid = form.username && form.password && !errors.username && !errors.password;
// form.username :非空字符串为 true,空字符串 "" 为 false
// form.password :同上
// !errors.username:空字符串取反 → true(没错误);有文本取反 → false(有错误)
// !errors.password:同上
const handleSubmit = (e) => {
e.preventDefault(); // 阻止表单默认刷新
if (!isValid) return; // 双重保险:虽然按钮 disabled 了,再检查一次
console.log(form); // 实际项目中这里调接口登录
};
④ 界面渲染 ------ 把数据和状态呈现出来
两个输入框各自绑定 name/value/onChange,底下用 {errors.xxx && <span>} 实现条件渲染------有错误才显示红色提示。
jsx
return (
<form onSubmit={handleSubmit}>
<h2>📝 登录</h2>
<label>用户名</label>
<input
name="username"
value={form.username}
onChange={handleChange}
placeholder="请输入用户名"
/>
{errors.username && (
<span className="error">{errors.username}</span>
)}
{/* "" && <span> → false,不渲染;"太短了" && <span> → 渲染红色提示 */}
<label>密码</label>
<input
type="password"
name="password"
value={form.password}
onChange={handleChange}
placeholder="请输入密码"
/>
{errors.password && (
<span className="error">{errors.password}</span>
)}
<button type="submit" disabled={!isValid}>登录</button>
{/* isValid=false → disabled=true → 按钮灰色不可点 */}
</form>
);
}
🔄 整个流程串起来看
arduino
用户敲键盘
↓
handleChange 触发
↓
更新 form(数据变了)
↓
调用 validate(检查新数据是否合规)
↓
更新 errors(有错就记,没错就清空)
↓
isValid 自动重新计算
↓
disabled={!isValid} 自动生效(按钮灰不灰由 isValid 决定)
↓
如果 isValid=true 且用户点登录 → handleSubmit 执行 → 打印数据
🚀 这整条链路就是受控组件最大的优势:数据和 UI 始终同步,想做校验、控制、联动,全部基于 state 在一条链上完成。
🎬 实际运行效果
| 操作 | 界面反馈 |
|---|---|
| 页面刚打开 | 两个输入框,灰色"登录"按钮 |
| 输入 "ab"(不够3位) | 🔴 红色文字"用户名长度不能小于3位",按钮保持灰色 |
| 补全到 "admin" | ✅ 红色提示消失,但密码框还是空的,按钮仍然灰色 |
| 输入 "123456" | ✅ 两个字段都填对了、都没报错 → 💙 按钮变亮,可以点击! |
六、选择指南
看了这么多,到底什么时候用受控、什么时候用非受控?
🎯 一个原则定方向:需要"实时知道"用户输入的内容,就用受控;只需要"最后提交时"拿一次结果,非受控就够了。
✅ 优先用受控(90% 的场景)
| 需求 | 为什么非受控做不到 |
|---|---|
| 🔍 实时校验 | 非受控不知道当前值,无法实时判断 |
| 🔗 字段联动(选省份→出城市) | 非受控无法监听变化 |
| 📝 格式化输入(手机号加空格) | 非受控不会在输入过程中拦截和修改 |
| 🚫 按钮动态禁用 | 非受控不知道表单是否填完 |
| 🔄 动态增删字段 / 表单重置 | ref 管不了动态列表,受控 setForm(初始值) 一行搞定 |
✅ 用非受控(约 10% 的场景)
| 场景 | 原因 |
|---|---|
📁 文件上传 <input type="file"> |
浏览器安全机制,React 读不了本地文件路径 |
| 📄 简单一次性表单(搜一下就提交) | 不需要中间过程,提交时拿值就够了 |
| 🔌 对接非 React 老代码 / jQuery 插件 | 别硬改,用 ref 配合 |
🔀 决策流程图
arduino
需要"实时感知"用户的每次输入吗?
├── ✅ 是 → 受控组件(useState)
└── ❌ 否 → 是不是文件上传?
├── ✅ 是 → 非受控(useRef,必须)
└── ❌ 否 → 提交时取一下值就完事?
├── ✅ 是 → 非受控也行(useRef 或 FormData)
└── ❌ 否 → 用受控,后期加功能不用重写
💡 新手建议:不确定 → 用受控。 简单直接,后期加校验、联动都不用重写。
七、项目结构与组件关系
📁 目录结构
css
controlled/
├── readme.md ← 本文档
└── uncontrolled-demo/ ← 项目代码
└── src/
├── main.jsx ← 入口:把 App 挂到页面 #root 上
├── App.jsx ← 主组件,按顺序展示 5 个子组件
└── components/
├── index.js ← "桶文件":把所有组件汇到一个出口
├── ControlledInput.jsx ← ① 受控最简 demo
├── UncontrolledInput.jsx ← ② 非受控最简 demo(useRef)
├── CommentBox.jsx ← ③ 非受控:textarea + 提交校验
├── RegisterForm.jsx ← ④ 受控进阶:一个对象管多字段
└── LoginForm/ ← ⑤ 受控实战:校验 + 提示 + 按钮控制
├── index.jsx
└── index.css
🚪 入口文件 main.jsx
jsx
// main.jsx ------ 入口文件
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
// React 18 使用 createRoot 替代 ReactDOM.render
// createRoot(document.getElementById('root')) 找到页面上的 #root 容器
// .render(<App />) 把 App 组件渲染进去
//
// StrictMode 是开发辅助工具,会在开发模式下额外执行一些检查
// (比如检测意外的副作用),帮助写出更健壮的代码
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
🧩 主组件 App.jsx
jsx
/*
* App 组件 ------ 项目的"大总管"
* 把所有子组件组装在一起,按照顺序摆放在页面上
*
* 注意:这里用 import { xxx } from './components'
* 实际找的是 components/index.js 这个"桶文件",一行引入所有组件
*/
import {
ControlledInput, // 受控组件:state 管理 input 值
UncontrolledInput, // 非受控组件:useRef 直接读 DOM
CommentBox, // 非受控组件:textarea + ref,提交时取值 + 校验
RegisterForm, // 受控组件:一个 state 对象管多个字段
LoginForm // 受控组件:多字段 + 实时校验 + 错误提示
} from './components'
function App() {
return(
<>
{/* 1. 受控组件基础 demo */}
<ControlledInput />
{/* 2. 非受控组件基础 demo */}
<UncontrolledInput />
{/* 3. 非受控组件:评论框(textarea) */}
<CommentBox />
{/* 4. 受控组件进阶:多字段表单(未加校验) */}
<RegisterForm />
{/* 5. 受控组件实战:多字段 + 实时校验 + 按钮状态控制 */}
<LoginForm />
</>
)
}
export default App
📊 数据流向一览
xml
main.jsx → <App /> ← 入口,只负责组装,不持有数据
├── <ControlledInput /> 数据在组件内部 useState
├── <UncontrolledInput /> 数据在浏览器 DOM
├── <CommentBox /> 数据在浏览器 DOM
├── <RegisterForm /> 数据在组件内部 useState 对象
└── <LoginForm /> 数据在组件内部两个 useState
📈 难度递进
| 序号 | 组件 | 知识点 | 数据归谁 |
|---|---|---|---|
| ① | ControlledInput | 受控基础:useState + value + onChange |
React state |
| ② | UncontrolledInput | 非受控基础:useRef + ref.current.value |
浏览器 DOM |
| ③ | CommentBox | 非受控变体:textarea + 提交前非空校验 |
浏览器 DOM |
| ④ | RegisterForm | 受控进阶:一个对象管多字段 + 通用 handleChange |
React state |
| ⑤ | LoginForm | 受控实战:实时校验 + 错误提示 + 按钮禁用 | React state |
📦 桶文件 index.js
jsx
// ❌ 不用桶文件时,App.jsx 要写一堆 import:
import ControlledInput from './components/ControlledInput'
import UncontrolledInput from './components/UncontrolledInput'
// 加一个组件就多一行,维护麻烦 😫
// ✅ 用了桶文件,一行搞定:
import { ControlledInput, UncontrolledInput, CommentBox, RegisterForm, LoginForm } from './components'
jsx
// components/index.js ------ 桶文件
// 命名导出(Named Export)
export { default as ControlledInput } from './ControlledInput'
export { default as UncontrolledInput } from './UncontrolledInput'
export { default as CommentBox } from './CommentBox'
export { default as RegisterForm } from './RegisterForm'
export { default as LoginForm } from './LoginForm'
// ⚠️ 使用方式:命名导入(Named Import)
// import { ControlledInput, UncontrolledInput, CommentBox } from './components'
//
// 注意:命名导出和命名导入必须对齐,都用花括号。
// 如果写成了 export default { Xxx },那就要用 import Xxx from './components',
// 但那样会报错 "does not provide an export",因为导出的对象不是一个组件。
八、常见错误
以下是新手最容易踩的几个坑,每个都源于对受控/非受控的理解不透彻。
| 现象 | 原因 | 修复 |
|---|---|---|
| 😱 输入框敲不出字 | 写了 value={state} 但没写 onChange,state 锁死了 |
加上 onChange={e => setXxx(e.target.value)} |
| 😱 第二个字段把第一个字段覆盖了 | setForm 没加 ...form,新对象替换了旧对象 |
改用 setForm({ ...form, [name]: value }) |
| 😱 输入框报错 "onChange is not a function" | 写了 onChange={xxx} 但 xxx 没定义 |
定义对应的处理函数,或改成受控写法 |
😱 import { Xxx } 报 "does not provide an export" |
index.js 里写成了 export default { Xxx } 而不是 export { Xxx } |
命名导出和命名导入必须对齐,都用花括号 |
| 😱 校验提示总是慢半拍 | 直接读 errors 而不是用 prev => |
setErrors(prev => ...) 保证拿到最新值 |
九、总结
📌 核心要点
- 受控组件:state 管数据,每次按键都能感知,适合复杂表单 ✅
- 非受控组件:DOM 管数据,需要时才去取,适合文件上传和简单表单 📁
- useState 和 useRef 只是工具,判断标准是数据存在哪,不是用了什么 hook 🔧
- 复杂表单:一个对象 state + 通用 handleChange + input 的 name 属性 🎯
- 桶文件 index.js:统一导出,减少 import 行数 📦
- React 哲学:数据驱动视图,state 是唯一真相来源 🧠
🎯 一句话总结
大部分场景用受控组件,特殊场景用非受控组件。受控给你掌控感,非控给你自由度------根据需求选择合适的工具。