阅读前先想
- 如果有 5 个输入框,你会为每个输入框写一个独立的
onChange处理函数吗?有没有办法共用一个? - 表单校验的结果应该存在哪里------和表单数据同一个 state,还是单独一个 state?
isValid这个变量,应该用useState定义吗?为什么?
本文主线
从一个字段的受控组件出发,依次解决三个实际问题:字段多了怎么办(共用一个 handler)、校验逻辑加在哪(双 state 分离)、UI 如何自动联动(派生值 + 条件渲染)。每个问题都从上一步的"不够用"自然引出下一步的"更好写法",最终串起一个完整表单的数据流闭环。
useRef 与 useState:两种"管数据"的方式
知识点
React 中管理表单数据只有两种选择:放在 useRef 里(值留 DOM,不触发渲染),或放在 useState 里(值存 state,每次变化都渲染)。
useRef 返回一个 { current: null } 形状的普通 JS 对象,整个组件生命周期中引用不变。修改 ref.current 是普通赋值,React 不知道也不关心。通过 ref 读取 DOM 值是"静默"的------不触发重渲染。
useState 返回 [value, setValue]。调用 setValue 后 React 排队重渲染,重新执行组件函数。把 state 传给 <input value={state}> 形成闭环------输入框显示值永远精确等于 state。
关键代码
javascript
// 非受控:值在 DOM,需要时"瞄一眼"
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>
</>
);
}
javascript
// 受控:值在 state,每次 keystroke 都重渲染
import { useState } from 'react';
function ControlledInput() {
const [value, setValue] = useState('');
return (
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
);
}
运行过程
非受控组件:用户输入 "hello" → DOM 的 value 逐字符变化 → React 不感知,无 setState,无重渲染。点击按钮 → inputRef.current.value 读 DOM 拿到 "hello" → 打印。整个过程一次都没有重渲染。
受控组件:用户按 "h" → onChange 触发 → setValue("h") → state 变为 "h" → React 重渲染 → <input value="h"> 让 DOM 与 state 同步。再按 "e" → 再跑一遍。敲 5 个字符,组件渲染 5 次。
两种模式的执行路径差异:
markdown
非受控(单向读取):
用户输入 → DOM 更新 → React 不感知,无重渲染
→ 点按钮 → ref.current.value 读 DOM → 打印
受控(闭合环):
用户输入 → onChange → setState → 重渲染 → value 同步 DOM
→ 用户继续输入 → 回到 onChange
左侧非受控:数据只在 DOM 和 React 之间单向读取 ,路径短但 React 感知力弱。右侧受控:数据形成闭合环,每次都经过 React,路径长但每一步都可控。
为什么这样设计
受控组件的"每次 keystroke 都渲染"不是性能缺陷,而是设计选择------对于单个输入框,渲染开销可忽略,换来的是 state 始终是最新值,你可以在 onChange 里随时做校验、格式化、联动 UI。非受控组件是务实的"逃生舱":只需提交时取一次值的简单场景,没必要让 React 管每一次 keystroke。
容易混淆
判断一个组件是受控还是非受控,不看用了 useRef 还是 useState,只看 input 的 value 属性是否由 React state 驱动 。一个组件可以同时有 useState 和 <input ref={...}>,只要 value 没绑到 state,它仍然是非受控。
使用边界
useRef 的值变化不触发渲染,所以 <p>{ref.current?.value}</p> 不会随输入自动更新。如果需要实时展示输入值,只能走受控路线。
非受控实战:CommentBox
javascript
function CommentBox() {
const textareaRef = useRef(null);
const handleSubmit = () => {
const comment = textareaRef.current.value;
if (!comment) return; // early return: 空内容不提交
console.log(comment);
};
return (
<div>
<textarea placeholder="输入评论..." ref={textareaRef} />
<button onClick={handleSubmit}>提交评论</button>
</div>
);
}
这是非受控组件在 <textarea> 上的变体,逻辑和输入框完全一致。值得注意的模式是 if (!comment) return------用 early return 做防御。不需要弹错误提示,不需要禁用按钮,一行守卫代码足够。这也体现了非受控组件的典型判断:如果交互足够简单,没必要拉起受控的全套机制。
多字段表单:一个 handler 管所有字段
知识点
当一个表单有多个输入框时,所有字段共用一个 handler ,靠 e.target.name 决定更新哪个字段。[e.target.name]: e.target.value 使用了 ES6 的计算属性名 语法,让变量的值成为对象的 key。...form 用展开运算符保留旧对象中未被本次更新的字段。
真实问题
RegisterForm 有 username、password 两个字段。如果各写一个 handler,每个都要手动硬编码字段名。再加第三个字段就要再写一个。共用 handler 方案只需要给新 <input> 加个 name 属性即可。
关键代码
ini
function RegisterForm() {
const [form, setForm] = useState({
username: "",
password: ""
});
const handleChange = (e) => {
const { name, value } = e.target;
setForm({
...form, // 保留未变化的字段
[name]: value // 更新当前字段
});
};
return (
<>
<input
name="username"
value={form.username}
onChange={handleChange}
type="text"
/>
<input
name="password"
value={form.password}
onChange={handleChange}
type="text"
/>
</>
);
}
运行过程
用户在 username 框输入 "z":
php
e.target.name = "username",e.target.value = "z"
→ handleChange 执行
→ setForm({ ...{username:"",password:""}, username:"z" })
→ form 变为 { username:"z", password:"" }
用户在 password 框输入 "1":
php
e.target.name = "password", e.target.value = "1"
→ setForm({ ...{username:"z",password:""}, password:"1" })
→ form 变为 { username:"z", password:"1" }
为什么这样设计
...form 确保每次更新只改一个字段。去掉它,setForm({ [name]: value }) 会让 form 只剩一个字段------先填的 username 在输入 password 时丢失。
onChange={handleChange} 传的是函数引用,不是调用结果。React 在事件触发时才调用它,并自动传入合成事件对象 e。e 不是调用者传的,是 React 内部捕获原生事件 → 包装成 SyntheticEvent → 自动调用 handleChange(e)。如果写成 onChange={handleChange()},会在渲染时立即执行,且 e 为 undefined------这是 JSX 中常见的错误写法。
容易混淆
RegisterForm 虽然已经是多字段,但它只管数据不管校验。校验、错误提示、按钮联动在它身上一概没有------这些是下一步 LoginForm 要解决的问题。
RegisterForm vs LoginForm:从"能用"到"好用"
在进入 LoginForm 之前,先把两个组件的差异摆出来:
| RegisterForm | LoginForm | |
|---|---|---|
| 表单数据 | form state |
form state |
| 错误状态 | 无 | errors state |
| 校验逻辑 | 无 | validate 函数 |
| 提交按钮 | 无限制,随时可点 | disabled={!isValid} |
| 错误提示 UI | 无 | 错误提示 |
| errors 更新方式 | --- | 函数式 prev => |
RegisterForm 解决了"怎么收集多字段数据"的问题。LoginForm 在它上面加了四层:校验、错误提示、按钮联动、提交防御。
数据与校验分家:双 state 设计
知识点
当需要实时校验时,应该用两个 state :form 管值,errors 管校验结果。职责分离后,数据更新和校验各自独立,互不污染。
校验函数的 msg 初始值为 ""(空字符串)。规则不命中时 msg 保持 "",命中才赋错误文案。这意味着 msg = "" 这一个值同时承担了"无错误"和"清除旧错误"两个语义。
关键代码
ini
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);
};
// ...
}
运行过程
handleChange 每次执行做两件事:更新数据 + 触发校验。validate 接收参数 value 而不是去读 form[name]------因为 setForm 只是排队更新,闭包中的 form 还是旧值,直接读会拿到前一版数据。
ini
用户输入
↓
handleChange 执行
├── setForm(写入数据)
└── validate(name, value)
↓
规则命中?
├── 是 → msg = "用户名为空"
└── 否 → msg = ""
↓
setErrors(prev => ({...prev, [name]: msg}))
↓
React 合并两次 setState,一次重渲染
React 批处理让 setForm 和 setErrors 合并在一次渲染中完成------用户看到的是数据更新和错误变化同时生效,不会出现"输入框变了但错误提示没变"的中间帧。
为什么这样设计
msg 的初始值是 "" 而不是 null 或 undefined,是因为条件渲染用 && 短路:{errors.xxx && <span>} 中,"" 是 falsy,React 不渲染。所以校验通过时把 "" 写入 errors,错误提示自动消失------不需要额外写"清除错误"逻辑。一条数据通路覆盖了"报错"和"纠错"两个方向。
为什么 validate 不读 form 而通过参数传值
setForm 只是排队更新,此时闭包中的 form 还是旧值。如果 validate 内部去读 form.username,读到的是用户输入前的内容。传参绕开了闭包问题,保证校验的是最新输入。
函数式更新:prev 能救你的数据
知识点
setState 有两种传参形式:传对象 (setXxx({...old, key: new}))和传函数 (setXxx(prev => ({...prev, key: new})))。区别在于"旧值从哪来"------传对象从闭包快照中读,传函数从 React 内部的更新队列中拿,prev 一定是最新的。
真实问题
validate 函数写在组件体内,它闭包捕获的 errors 是本次渲染的值。如果用传对象方式 setErrors({...errors, [name]: msg}),多次快速校验时后执行的拿到的闭包 errors 可能已经过期,导致前一次更新被覆盖。
关键代码
csharp
// 传对象 ------ 依赖闭包中的 errors
setErrors({
...errors, // ← 这里的 errors 可能是过期快照
[name]: msg
});
// 传函数 ------ React 保证 prev 是最新的
setErrors(prev => ({
...prev, // ← prev 总是最新的
[name]: msg
}));
运行过程
假设 errors 当前为 {},用户快速触发 username 和 password 两次校验。用传对象方式,两次都从各自的闭包读到 errors = {},结果后执行的覆盖先生效的。用传函数方式,第二次的 prev 已包含第一次更新结果,两次都不丢失。
为什么这样设计
React 的更新队列依次执行。传函数时 React 把前一次返回的 state 传给下一个 updater 的 prev,形成链式更新。传对象则绕过链条------直接从闭包拿值。
RegisterForm 里 setForm({...form, [name]: value}) 用对象没问题:onChange 每次 keystroke 触发一次渲染,不存在同一周期内多次 setForm。setErrors 用函数式是防御性的------validate 可能被多处调用,用 prev 保证任何时候都不丢数据。
使用边界
不确定闭包会不会过期,就用函数式。函数式永远正确,只是多写几个字符。
派生值、条件渲染与表单提交
知识点
派生值 是从已有 state 直接计算出的值,不需要单独的 useState。isValid 由 form 和 errors 组合计算得出,每次渲染自动重算,自然与 UI 联动。
条件渲染 利用 JS 的 && 短路求值:a && b 当 a 为 falsy 时返回 a 且 b 不执行。React 不渲染 undefined、null、"",所以 <span> 不出现在 DOM 中。
<span> 是 HTML 行内元素,没有默认样式、不换行,只用来包裹文字片段以便挂 CSS 类名。
关键代码
ini
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="text" name="password"
value={form.password} onChange={handleChange} />
{errors.password && (
<span className="error">{errors.password}</span>
)}
</div>
<button type="submit" disabled={!isValid}>登录</button>
</form>
</div>
);
运行过程
下面跟踪用户从打开页面到提交的完整推演:
初始化:
css
form: { username: "", password: "" }
errors: {}
isValid: "" && "" && !undefined && !undefined = false
结果:两个输入框为空,无错误提示,按钮灰色
用户输入 username = "zh"(长度 2):
arduino
handleChange → setForm → form = { username: "zh", password: "" }
→ validate → errors = { username: "用户名长度不能小于3" }
isValid: "zh" && "" && !"用户名长度不能小于3" && !undefined
= true && false && false && true = false
结果:用户名框显示 "zh",红色提示"用户名长度不能小于3",按钮灰色
用户补一个 "g" → "zhg"(长度 3):
arduino
handleChange → setForm → form = { username: "zhg", password: "" }
→ validate → msg="" → errors = { username: "" }
isValid: "zhg" && "" && !"" && !undefined
= true && false && true && true = false
结果:用户名框显示 "zhg",错误提示消失,按钮仍然灰色(密码还空着)
用户输入 password = "123456"(长度 6):
arduino
handleChange → setForm → form = { username: "zhg", password: "123456" }
→ validate → msg="" → errors = { username: "", password: "" }
isValid: "zhg" && "123456" && !"" && !""
= true && true && true && true = true
结果:两个输入框都有值,无错误提示,按钮亮起
用户点击"登录":
arduino
onSubmit → e.preventDefault() → isValid 为 true 跳过 return → console.log(form)
以下是 state 驱动的 UI 联动:
arduino
state 变化(form 或 errors)
↓
组件重渲染
├── isValid 重算
│ ├── true → 按钮亮起
│ └── false → 按钮灰掉
│
└── errors.xxx 判断
├── truthy 字符串 → <span> 渲染,错误提示出现
└── falsy / "" → <span> 不渲染,错误提示消失
为什么这样设计
isValid 不单独设 state 是最佳实践。如果 setIsValid 散布在各处,迟早出现"某个场景忘记更新了"。派生值只有一个来源------渲染时计算,不会漏也不会错。
条件渲染用 && 是 React 最简洁的惯例。不过有一个注意点:如果 errors.xxx 的值是 0 或 NaN,&& 会把它们渲染到页面上。当前场景中 errors 的值只有 "" 和错误文案字符串,"" 不渲染,字符串正常渲染,刚好安全。
!errors.username 需要单独说明:errors.username 是字符串(如 "用户名为空")或空串 ""。! 取反将 truthy 字符串变为 false,将 falsy 空串变为 true。所以 !errors.username 成立意味着"该字段无错误"。
关于 .error 类名
className="error" 在当前项目中没有对应的 CSS 定义------项目中的 CSS 文件都没有 .error 规则。运行时错误提示以浏览器默认样式显示,不会变红。error 是一个语义约定 :代码层面已经预留了样式钩子,CSS 可以随时补上。命名本身就是文档------看到 className="error" 就知道这是错误提示。
form-item 的布局设计
xml
<div className="form-item">
<label>用户名</label>
<input name="username" value={form.username} onChange={handleChange} />
{errors.username && <span className="error">{errors.username}</span>}
</div>
每个 form-item 把 label、input、error 三个元素包成一个纵向排列的独立单元。结构化的好处:字段之间互有间距、内部三元素垂直排列,CSS 只需选中 .form-item 就能统一控制所有字段的布局。多个 form-item 并行就是表单的骨架。
代码串起来
各组件的全景结构:
kotlin
App
├── ControlledInput ← 受控单字段
├── UncontrolledInput ← 非受控单字段
├── CommentBox ← 非受控 textarea + early return
├── RegisterForm ← 多字段共用 handler
└── LoginForm ← 双 state + 实时校验 + 派生值
LoginForm 完整交互流程:
arduino
用户敲键盘
↓
onChange → handleChange
├── setForm(写数据)
└── validate(写错误)
↓
React 批处理 → 一次重渲染
├── input value 同步最新 form
├── error span 显/隐
└── isValid 重算 → 按钮亮/灰
↓
{ 继续输入?} ── 是 ──→ 回到起点
↓否
isValid = true?
↓是
用户点击登录或按 Enter
↓
onSubmit → e.preventDefault()
↓
if (!isValid) return(防御)
↓
提交数据
拓展:组件组织
components/index.jsx 把所有组件集中在一个文件中统一导出:
javascript
import ControlledInput from './controlledInput';
import UncontrolledInput from './UncontrolledInput';
import CommentBox from './CommentBox';
import RegisterForm from './RegisterForm';
import LoginForm from './LoginForm';
export {
ControlledInput,
UncontrolledInput,
CommentBox,
RegisterForm,
LoginForm
};
这种 barrel export(桶式导出) 的好处是引入时一条 import 搞定:import { ControlledInput, LoginForm } from './components'。组件越多,收益越明显。
最后回顾
从 ControlledInput 到 LoginForm,每一步解决一个实际问题:
- 单字段受控:state → value 形成闭环
- 多字段共用 handler :
[e.target.name]做字段路由 - 双 state:数据归数据,校验归校验
- 函数式更新 :
prev =>防止闭包过期 - 派生值 :
isValid自动跟随 state,不需要手动 set - 条件渲染 :
&&+ 空字符串,报错纠错一条路 - onSubmit:接管浏览器默认行为,加防守代码
- form-item:label/input/error 三元组,结构即文档
这些模式可以独立使用也可以组合,理解它们的数据流向比记住代码本身更重要