适用技术栈:React、Vue、SwiftUI、Flutter、Jetpack Compose。以 React 为主要教学语言,思维跨框架通用。
核心理念:不讲空理论,每个知识点都落地到能直接用于工作的实用技能。
本课产出: 能写出无 bug 的受控表单;能正确组织多字段表单的状态;能实现即时校验、失焦校验、提交校验;能处理错误展示、自动聚焦、防重复提交;能处理动态字段和数组字段。
一句话预览: 表单是用户输入的唯一入口,也是 bug 最密集的地方。表单处理得好不好,直接决定应用的可用性和健壮性。
一、本课目标
学完本课,你应该能:
- 写出标准的受控组件:
value+onChange。 - 决定多字段表单用单对象还是多状态。
- 判断什么时候用受控,什么时候用非受控。
- 实现三种校验时机:即时、失焦、提交时。
- 处理错误展示、错误聚焦、错误清除。
- 防止重复提交、处理提交中的状态。
- 处理动态字段:添加、删除、重排。
- 用状态机管理表单的提交状态。
二、从一个真实痛点说起
场景:一个注册表单
需求:邮箱、密码、确认密码。要求:邮箱格式校验、密码长度校验、两次密码一致、提交时禁用按钮、提交失败显示错误。
一个开发者这样写:
jsx
function RegisterForm() {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [confirm, setConfirm] = useState("")
const [emailError, setEmailError] = useState("")
const [passwordError, setPasswordError] = useState("")
const [confirmError, setConfirmError] = useState("")
const [submitting, setSubmitting] = useState(false)
function handleSubmit() {
// 校验
if (!email.includes("@")) {
setEmailError("邮箱格式不正确")
}
if (password.length < 6) {
setPasswordError("密码至少 6 位")
}
if (password !== confirm) {
setConfirmError("两次密码不一致")
}
if (emailError || passwordError || confirmError) return
setSubmitting(true)
api.register({ email, password })
.then(() => { /* 成功 */ })
.catch(err => { /* 失败 */ })
.finally(() => setSubmitting(false))
}
return (
<form>
<input value={email} onChange={e => setEmail(e.target.value)} />
{emailError && <p>{emailError}</p>}
<input value={password} onChange={e => setPassword(e.target.value)} />
{passwordError && <p>{passwordError}</p>}
<input value={confirm} onChange={e => setConfirm(e.target.value)} />
{confirmError && <p>{confirmError}</p>}
<button onClick={handleSubmit} disabled={submitting}>注册</button>
</form>
)
}
能跑,但问题很多:
- 校验在提交时一次性执行。 用户必须点提交才能看到错误,体验差。
- 错误不会自动清除。 用户改了邮箱,错误还显示着。
- 校验逻辑重复。 每次提交都要重新校验,代码冗余。
if (emailError || ...)用的是旧状态。setEmailError是异步的,这里读到的还是上一次的值,校验逻辑失效。- 没有失焦校验。 用户填完一项,不知道对不对。
- 没有错误聚焦。 提交失败后,用户不知道错在哪。
这段代码的 bug 会很隐蔽。 比如第 4 点,setEmailError 后立即读 emailError,读到的是旧值,导致校验形同虚设。
本课要讲的,就是如何写出健壮的表单。
三、受控组件:标准写法
基础受控组件
jsx
function TextField({ value, onChange, ...props }) {
return (
<input
value={value}
onChange={e => onChange(e.target.value)}
{...props}
/>
)
}
用法:
jsx
const [email, setEmail] = useState("")
<TextField value={email} onChange={setEmail} />
关键点
value必须由状态提供。 不能是undefined,否则变成非受控。onChange必须更新状态。 否则输入框无法输入。onChange的签名是(newValue) => void。 不是(event) => void,把 event 处理封装在组件内部。
一个常见错误:value 为 undefined
jsx
// ❌ value 是 undefined,变成非受控
const [email, setEmail] = useState() // 初始值是 undefined
<input value={email} onChange={...} />
// ✅ 初始值必须是空字符串
const [email, setEmail] = useState("")
为什么? React 判断受控还是非受控,看 value 是不是 undefined。undefined 表示非受控,"" 表示受控。从非受控切到受控会警告。
数字输入的处理
jsx
// ❌ 直接存数字,中间态无法表示
const [age, setAge] = useState(0)
<input
type="number"
value={age}
onChange={e => setAge(Number(e.target.value))}
/>
// 用户清空输入框时,Number("") === 0,无法清空
// ✅ 存字符串,提交时转换
const [age, setAge] = useState("")
<input
type="number"
value={age}
onChange={e => setAge(e.target.value)}
/>
// 提交时 Number(age)
原则:输入框的值始终存字符串,需要数字时在提交或使用时转换。
原因: 输入过程中有很多中间态(空、"-"、"1."),只有字符串能表示。
复选框
jsx
// 单个复选框
const [agreed, setAgreed] = useState(false)
<input
type="checkbox"
checked={agreed}
onChange={e => setAgreed(e.target.checked)}
/>
// 注意用 checked 不是 value,用 e.target.checked 不是 e.target.value
jsx
// 多个复选框
const [selected, setSelected] = useState([])
<input
type="checkbox"
checked={selected.includes("a")}
onChange={e => {
setSelected(prev =>
e.target.checked
? [...prev, "a"]
: prev.filter(x => x !== "a")
)
}}
/>
下拉框
jsx
const [city, setCity] = useState("beijing")
<select value={city} onChange={e => setCity(e.target.value)}>
<option value="beijing">北京</option>
<option value="shanghai">上海</option>
</select>
jsx
// 多选下拉框
const [cities, setCities] = useState([])
<select
multiple
value={cities}
onChange={e => {
const selected = Array.from(e.target.selectedOptions, o => o.value)
setCities(selected)
}}
>
<option value="beijing">北京</option>
<option value="shanghai">上海</option>
</select>
单选框
jsx
const [gender, setGender] = useState("male")
<label>
<input
type="radio"
name="gender"
value="male"
checked={gender === "male"}
onChange={e => setGender(e.target.value)}
/>
男
</label>
<label>
<input
type="radio"
name="gender"
value="female"
checked={gender === "female"}
onChange={e => setGender(e.target.value)}
/>
女
</label>
注意: 单选框需要 name 属性相同,才能互斥。checked 由 gender === value 决定。
四、多字段表单:单对象 vs 多状态
方案一:多状态
jsx
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [phone, setPhone] = useState("")
const [address, setAddress] = useState("")
优点: 每个字段独立,更新简单。
缺点: 字段多时代码冗长,提交时要组装对象。
方案二:单对象
jsx
const [form, setForm] = useState({
name: "",
email: "",
phone: "",
address: ""
})
function updateField(field, value) {
setForm(prev => ({ ...prev, [field]: value }))
}
优点: 状态集中,提交时直接拿 form,容易重置。
缺点: 每个字段更新都要创建新对象。
怎么选
| 场景 | 方案 |
|---|---|
| 字段少(≤3),独立性强 | 多状态 |
| 字段多(≥4),需要整体提交 | 单对象 |
| 字段之间有联动 | 单对象 |
| 需要整体重置 | 单对象 |
| 需要整体校验 | 单对象 |
经验法则:字段多、需要一起提交、需要一起校验 → 单对象。否则多状态。
单对象的完整模式
jsx
function Form() {
const [form, setForm] = useState({
name: "",
email: "",
phone: ""
})
// 更新单个字段
function updateField(field, value) {
setForm(prev => ({ ...prev, [field]: value }))
}
// 重置表单
function reset() {
setForm({ name: "", email: "", phone: "" })
}
// 提交
async function submit() {
await api.submit(form)
}
return (
<form>
<input
value={form.name}
onChange={e => updateField("name", e.target.value)}
/>
<input
value={form.email}
onChange={e => updateField("email", e.target.value)}
/>
<input
value={form.phone}
onChange={e => updateField("phone", e.target.value)}
/>
</form>
)
}
注意: updateField 是通用的,任何字段都用同一个函数。
通用输入组件
当表单字段很多时,可以封装通用组件:
jsx
function Field({ label, value, onChange, ...props }) {
return (
<label>
<span>{label}</span>
<input
value={value}
onChange={e => onChange(e.target.value)}
{...props}
/>
</label>
)
}
// 用法
<Field
label="姓名"
value={form.name}
onChange={v => updateField("name", v)}
/>
<Field
label="邮箱"
value={form.email}
onChange={v => updateField("email", v)}
/>
用计算属性简化更新
jsx
function updateField(field) {
return value => setForm(prev => ({ ...prev, [field]: value }))
}
// 用法
<Field
label="姓名"
value={form.name}
onChange={updateField("name")}
/>
这个模式叫"柯里化",能进一步简化代码。
五、受控 vs 非受控:选择标准
非受控组件的写法
jsx
function Form() {
const nameRef = useRef()
const emailRef = useRef()
function handleSubmit(e) {
e.preventDefault()
const data = {
name: nameRef.current.value,
email: emailRef.current.value
}
api.submit(data)
}
return (
<form onSubmit={handleSubmit}>
<input ref={nameRef} defaultValue="" />
<input ref={emailRef} defaultValue="" />
<button>提交</button>
</form>
)
}
关键:
- 用
defaultValue而不是value。 - 用
ref读取值,不用onChange。 - 只在需要时读值。
对比表
| 维度 | 受控 | 非受控 |
|---|---|---|
| 值来源 | 状态 | DOM |
| 更新方式 | onChange 改状态 |
用户输入自动更新 DOM |
| 读取方式 | 直接从状态读 | ref.current.value |
| 即时校验 | 容易 | 困难 |
| 格式化输入 | 容易 | 困难 |
| 联动其他状态 | 容易 | 困难 |
| 动态字段 | 容易 | 困难 |
| 代码量 | 多 | 少 |
| 性能 | 每次输入重新渲染 | 不重新渲染 |
| 推荐度 | 默认 | 特殊场景 |
什么时候用非受控
合理场景:
- 文件上传。
<input type="file" />只能用 ref 读。 - 极简单的表单。 比如只有一个搜索框,只在提交时读值。
- 集成非 React 库。 某些富文本编辑器、日期选择器需要直接操作 DOM。
- 性能极度敏感的大表单。 几十个字段,每次输入都渲染代价高。
- 表单值不需要实时知道。 只在提交时读取。
不合理场景:
- 需要即时校验。 非受控做不到。
- 需要格式化输入。 比如手机号自动加空格。
- 需要联动。 比如选择省份后更新城市选项。
- 需要动态字段。 非受控的 ref 管理很麻烦。
混合使用
实践中常常混合: 大部分字段受控,个别字段非受控。
jsx
function Form() {
const [name, setName] = useState("")
const fileRef = useRef()
function handleSubmit() {
const data = {
name,
file: fileRef.current.files[0]
}
api.submit(data)
}
return (
<form>
<input value={name} onChange={e => setName(e.target.value)} />
<input type="file" ref={fileRef} />
<button onClick={handleSubmit}>提交</button>
</form>
)
}
原则:默认受控,特殊场景非受控。 不要为了"性能优化"而用非受控,除非有明确的性能数据。
六、表单校验:三种时机
时机一:即时校验
每次输入都校验。
jsx
const [email, setEmail] = useState("")
const emailError = validateEmail(email) // 派生
<input value={email} onChange={e => setEmail(e.target.value)} />
{emailError && <p>{emailError}</p>}
优点: 即时反馈,用户立即知道对不对。
缺点: 输入过程中会频繁显示错误。比如用户刚输入 "a",就显示"邮箱格式不正确",体验差。
改进: 只在用户输入过之后再校验。
jsx
const [email, setEmail] = useState("")
const [touched, setTouched] = useState(false)
const emailError = touched ? validateEmail(email) : ""
<input
value={email}
onChange={e => setEmail(e.target.value)}
onBlur={() => setTouched(true)}
/>
适用场景: 格式简单、用户容易理解的字段(如邮箱、手机号)。
时机二:失焦校验
用户离开输入框时校验。
jsx
const [email, setEmail] = useState("")
const [error, setError] = useState("")
function handleBlur() {
setError(validateEmail(email))
}
<input
value={email}
onChange={e => setEmail(e.target.value)}
onBlur={handleBlur}
/>
{error && <p>{error}</p>}
优点: 不打扰输入过程,用户填完一项立即知道对不对。
缺点: 用户可能一直不离开输入框。
适用场景: 大多数字段。失焦校验是推荐默认。
时机三:提交时校验
点击提交时校验所有字段。
jsx
function handleSubmit() {
const errors = validateAll(form)
if (Object.keys(errors).length > 0) {
setErrors(errors)
focusFirstError(errors)
return
}
api.submit(form)
}
优点: 一次性告诉用户所有错误。
缺点: 用户要等到提交才知道错。
适用场景: 作为兜底,配合失焦校验使用。
推荐组合:失焦 + 提交
最佳实践:
- 失焦时校验单个字段。 用户填完立即知道。
- 提交时校验所有字段。 兜底,防止遗漏。
- 提交失败时聚焦第一个错误。 用户体验好。
jsx
function Form() {
const [form, setForm] = useState({ email: "", password: "" })
const [errors, setErrors] = useState({})
function validateField(field, value) {
if (field === "email" && !value.includes("@")) {
return "邮箱格式不正确"
}
if (field === "password" && value.length < 6) {
return "密码至少 6 位"
}
return ""
}
function handleBlur(field) {
const error = validateField(field, form[field])
setErrors(prev => ({ ...prev, [field]: error }))
}
function handleChange(field, value) {
setForm(prev => ({ ...prev, [field]: value }))
// 如果已有错误,输入时重新校验
if (errors[field]) {
const error = validateField(field, value)
setErrors(prev => ({ ...prev, [field]: error }))
}
}
function handleSubmit() {
const newErrors = {}
Object.keys(form).forEach(field => {
const error = validateField(field, form[field])
if (error) newErrors[field] = error
})
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors)
focusFirstError(newErrors)
return
}
api.submit(form)
}
return (
<form>
<input
value={form.email}
onChange={e => handleChange("email", e.target.value)}
onBlur={() => handleBlur("email")}
/>
{errors.email && <p>{errors.email}</p>}
<input
type="password"
value={form.password}
onChange={e => handleChange("password", e.target.value)}
onBlur={() => handleBlur("password")}
/>
{errors.password && <p>{errors.password}</p>}
<button onClick={handleSubmit}>提交</button>
</form>
)
}
这个模式涵盖了三种时机:
- 失焦时校验 →
handleBlur - 输入时清除错误 →
handleChange里判断errors[field] - 提交时校验所有 →
handleSubmit
校验函数应该是纯函数
jsx
// ✅ 纯函数,只依赖输入
function validateEmail(email) {
if (!email) return "邮箱不能为空"
if (!email.includes("@")) return "邮箱格式不正确"
return ""
}
// ❌ 依赖外部状态,难以测试
function validateEmail() {
if (!form.email) return "邮箱不能为空"
// ...
}
好处:
- 可独立测试。
- 可复用。
- 可组合。
用校验库
实际项目中,常用校验库:
- React Hook Form: 基于非受控,性能好。
- Formik: 基于受控,生态成熟。
- Zod / Yup: schema 校验,和表单库配合。
jsx
// React Hook Form + Zod 示例
const schema = z.object({
email: z.string().email("邮箱格式不正确"),
password: z.string().min(6, "密码至少 6 位")
})
function Form() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema)
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
<input {...register("password")} />
{errors.password && <p>{errors.password.message}</p>}
<button>提交</button>
</form>
)
}
库的选择标准:
- 表单简单 → 自己写。
- 表单复杂(多字段、动态字段、复杂校验)→ 用库。
- React Hook Form 性能好,推荐。
- Formik 生态成熟,文档多。
本课讲原理,理解了原理,用什么库都能上手。
七、提交:防重复、加载态、错误
提交状态用状态机
jsx
const [status, setStatus] = useState("editing")
// "editing" | "submitting" | "success" | "error"
const [error, setError] = useState(null)
async function handleSubmit() {
if (!validate()) return
setStatus("submitting")
try {
await api.submit(form)
setStatus("success")
} catch (e) {
setError(e.message)
setStatus("error")
}
}
不要用多个布尔:
jsx
// ❌
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [isError, setIsError] = useState(false)
防重复提交
方式一:禁用按钮。
jsx
<button disabled={status === "submitting"} onClick={handleSubmit}>
{status === "submitting" ? "提交中..." : "提交"}
</button>
方式二:事件处理器守卫。
jsx
async function handleSubmit() {
if (status === "submitting") return
// ...
}
方式三:用 form 的 onSubmit。
jsx
<form onSubmit={handleSubmit}>
<button disabled={status === "submitting"}>提交</button>
</form>
三种方式结合使用,最稳妥。
提交中的界面
jsx
<button disabled={status === "submitting"}>
{status === "submitting" ? (
<>
<Spinner size="small" />
提交中...
</>
) : "提交"}
</button>
提交中要给用户明确的反馈: 按钮禁用、文字变化、加载图标。
提交成功的处理
jsx
async function handleSubmit() {
setStatus("submitting")
try {
await api.submit(form)
setStatus("success")
// 跳转、重置表单、显示成功提示
resetForm()
navigate("/success")
} catch (e) {
setError(e.message)
setStatus("error")
}
}
提交失败的处理
jsx
{status === "error" && (
<div className="error">
<p>提交失败:{error}</p>
<button onClick={handleSubmit}>重试</button>
</div>
)}
错误处理三要素: 错误信息、重试按钮、不丢失用户输入。
表单重置
jsx
const INITIAL_FORM = { email: "", password: "" }
function reset() {
setForm(INITIAL_FORM)
setErrors({})
setStatus("editing")
setError(null)
}
用常量定义初始值,重置时直接引用,避免遗漏字段。
八、动态字段:数组字段
场景
一个表单,用户可以添加多个邮箱地址,每个邮箱可以删除。
状态设计
jsx
const [emails, setEmails] = useState([""])
// 或更完整的结构
const [emails, setEmails] = useState([
{ id: 1, value: "" }
])
用 id 而不是 index: 因为要增删排序,index 会变。
添加字段
jsx
function addEmail() {
setEmails(prev => [...prev, { id: Date.now(), value: "" }])
}
删除字段
jsx
function removeEmail(id) {
setEmails(prev => prev.filter(e => e.id !== id))
}
更新字段
jsx
function updateEmail(id, value) {
setEmails(prev => prev.map(e =>
e.id === id ? { ...e, value } : e
))
}
完整实现
jsx
function EmailListForm() {
const [emails, setEmails] = useState([
{ id: Date.now(), value: "" }
])
function addEmail() {
setEmails(prev => [...prev, { id: Date.now(), value: "" }])
}
function removeEmail(id) {
setEmails(prev => prev.filter(e => e.id !== id))
}
function updateEmail(id, value) {
setEmails(prev => prev.map(e =>
e.id === id ? { ...e, value } : e
))
}
return (
<div>
{emails.map((email, index) => (
<div key={email.id}>
<input
value={email.value}
onChange={e => updateEmail(email.id, e.target.value)}
placeholder={`邮箱 ${index + 1}`}
/>
{emails.length > 1 && (
<button onClick={() => removeEmail(email.id)}>删除</button>
)}
</div>
))}
<button onClick={addEmail}>添加邮箱</button>
</div>
)
}
关键点:
- 每项有唯一 id。 用
Date.now()或 uuid。 - key 用 id,不用 index。 增删时 index 会变。
- 删除时保留至少一项。 用
emails.length > 1判断。 - 更新用
map不可变。
嵌套数组字段
更复杂的场景:一个订单,有多个商品,每个商品有名称和数量。
jsx
const [items, setItems] = useState([
{ id: 1, name: "", quantity: 1 }
])
function updateItem(id, field, value) {
setItems(prev => prev.map(item =>
item.id === id ? { ...item, [field]: value } : item
))
}
function addItem() {
setItems(prev => [...prev, { id: Date.now(), name: "", quantity: 1 }])
}
function removeItem(id) {
setItems(prev => prev.filter(item => item.id !== id))
}
嵌套字段的更新,用 [field] 动态 key,通用性强。
动态字段的校验
jsx
const errors = emails.map(e => validateEmail(e.value))
{emails.map((email, index) => (
<div key={email.id}>
<input
value={email.value}
onChange={e => updateEmail(email.id, e.target.value)}
/>
{errors[index] && <p>{errors[index]}</p>}
</div>
))}
错误按 id 存储更稳定:
jsx
const [errors, setErrors] = useState({}) // { [id]: "错误信息" }
{errors[email.id] && <p>{errors[email.id]}</p>}
九、错误展示与聚焦
错误展示的位置
方式一:字段下方。
jsx
<label>
<span>邮箱</span>
<input value={email} onChange={...} />
{error && <p className="error">{error}</p>}
</label>
方式二:字段右侧。
jsx
<div className="field">
<input value={email} onChange={...} />
{error && <span className="error">{error}</span>}
</div>
方式三:统一错误提示区。
jsx
{Object.keys(errors).length > 0 && (
<div className="error-summary">
<p>请修正以下错误:</p>
<ul>
{Object.entries(errors).map(([field, msg]) => (
<li key={field}>{msg}</li>
))}
</ul>
</div>
)}
推荐: 字段下方 + 提交时统一提示。字段下方让用户知道具体哪里错,统一提示让用户知道有几个错误。
错误聚焦
提交失败后,聚焦到第一个错误的字段:
jsx
function focusFirstError(errors) {
const firstErrorField = Object.keys(errors)[0]
const el = document.querySelector(`[name="${firstErrorField}"]`)
el?.focus()
}
更好的方式:用 ref。
jsx
const fieldRefs = {
email: useRef(),
password: useRef()
}
function focusFirstError(errors) {
const firstErrorField = Object.keys(errors)[0]
fieldRefs[firstErrorField]?.current?.focus()
}
聚焦是命令式操作,必须用 ref。 这是第 13 课会讲的"逃生舱"。
无障碍
jsx
<input
id="email"
value={email}
onChange={...}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && (
<p id="email-error" role="alert">{errors.email}</p>
)}
关键点:
aria-invalid标记字段是否有效。aria-describedby关联错误信息。role="alert"让屏幕阅读器播报错误。
无障碍不是可选项,是专业开发的标配。
十、常见误区与避坑
误区 1:校验时读旧状态
jsx
// ❌ setEmailError 是异步的,读到的还是旧值
function handleSubmit() {
if (!email.includes("@")) {
setEmailError("邮箱格式不正确")
}
if (emailError) return // emailError 还是旧值
// ...
}
// ✅ 用局部变量收集错误
function handleSubmit() {
const newErrors = {}
if (!email.includes("@")) {
newErrors.email = "邮箱格式不正确"
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors)
return
}
// ...
}
原因: setState 是异步的,调用后立即读取状态拿到的还是旧值。校验应该用局部变量收集错误,最后一次性 setErrors。
误区 2:数字输入直接存数字
jsx
// ❌ 无法清空,因为 Number("") === 0
const [age, setAge] = useState(0)
<input
type="number"
value={age}
onChange={e => setAge(Number(e.target.value))}
/>
// ✅ 存字符串
const [age, setAge] = useState("")
误区 3:value 为 undefined
jsx
// ❌ 从非受控切到受控
const [value, setValue] = useState()
<input value={value} onChange={...} />
// ✅ 初始值给空字符串
const [value, setValue] = useState("")
误区 4:把 props 复制到 state
jsx
// ❌ 父组件的 initialValue 变了,子组件不同步
function Child({ initialValue }) {
const [value, setValue] = useState(initialValue)
}
// ✅ 受控
function Child({ value, onChange }) {
return <input value={value} onChange={e => onChange(e.target.value)} />
}
误区 5:表单提交不阻止默认行为
jsx
// ❌ 提交后页面刷新
function handleSubmit() {
api.submit(form)
}
// ✅ 阻止默认行为
function handleSubmit(e) {
e.preventDefault()
api.submit(form)
}
原因: <form> 的默认提交行为是刷新页面。必须 e.preventDefault()。
误区 6:不处理提交中的状态
jsx
// ❌ 用户可以连续点击,发起多次请求
<button onClick={handleSubmit}>提交</button>
// ✅ 提交中禁用
<button disabled={submitting} onClick={handleSubmit}>
{submitting ? "提交中..." : "提交"}
</button>
误区 7:错误不清除
jsx
// ❌ 用户改了邮箱,错误还显示着
<input value={email} onChange={e => setEmail(e.target.value)} />
{errors.email && <p>{errors.email}</p>}
// ✅ 输入时重新校验
function handleChange(value) {
setEmail(value)
if (errors.email) {
setErrors(prev => ({ ...prev, email: validateEmail(value) }))
}
}
误区 8:动态字段用 index 作为 key
jsx
// ❌ 删除中间项后,后面的输入框内容错乱
{emails.map((email, index) => (
<input key={index} value={email} />
))}
// ✅ 用 id
{emails.map(email => (
<input key={email.id} value={email.value} />
))}
误区 9:不处理表单重置
jsx
// ❌ 提交成功后表单数据还在
async function handleSubmit() {
await api.submit(form)
}
// ✅ 重置
async function handleSubmit() {
await api.submit(form)
reset()
}
误区 10:把所有字段都放在一个大对象里
jsx
// ❌ 一个字段变化,整个对象重建,所有字段重新渲染
const [form, setForm] = useState({
field1: "", field2: "", /* ... 20 个字段 */
})
// ✅ 如果字段独立性强,考虑拆开
const [field1, setField1] = useState("")
const [field2, setField2] = useState("")
注意: 这条建议有争议。大对象方便整体提交,但会导致所有字段重新渲染。实践中,如果表单字段多且独立,可以拆开或使用表单库(如 React Hook Form)优化性能。
十一、跨框架对照
受控输入
React:
jsx
const [value, setValue] = useState("")
<input value={value} onChange={e => setValue(e.target.value)} />
Vue 3:
vue
<script setup>
import { ref } from 'vue'
const value = ref("")
</script>
<template>
<input v-model="value" />
</template>
SwiftUI:
swift
@State private var value = ""
TextField("输入", text: $value)
Flutter:
dart
final controller = TextEditingController();
TextField(controller: controller)
Jetpack Compose:
kotlin
var value by remember { mutableStateOf("") }
TextField(
value = value,
onValueChange = { value = it }
)
注意: Vue 的 v-model 是双向绑定的语法糖。SwiftUI 的 $value 是双向绑定。Flutter 的 TextEditingController 内部管理值。Compose 是显式的受控。
思维一致: 值由状态驱动,变化通过回调更新状态。
表单提交
React:
jsx
<form onSubmit={e => { e.preventDefault(); submit() }}>
Vue 3:
vue
<form @submit.prevent="submit">
SwiftUI:
swift
Button("提交") { submit() }
Flutter:
dart
ElevatedButton(onPressed: submit, child: Text("提交"))
Jetpack Compose:
kotlin
Button(onClick = { submit() }) { Text("提交") }
十二、练一练:15 道多元化习题
1. 选择题
题目: 受控组件的两个关键 props 是什么?
A. defaultValue 和 onChange
B. value 和 onChange
C. value 和 onInput
D. ref 和 onChange
参考答案: B
解读: 受控组件的值由 value 提供,变化通过 onChange 通知。defaultValue 是非受控组件的初始值。onInput 也能用,但 onChange 是 React 的标准。
2. 判断题
题目: 数字输入框的值应该直接存为数字类型。
参考答案: 错误。
解读: 输入过程中有很多中间态(空、"-"、"1."),只有字符串能表示。如果直接存数字,用户无法清空输入框(Number("") === 0)。正确做法是存字符串,使用时转换。
3. 填空题
题目: 表单校验的三种时机是 、、______。
参考答案: 即时校验、失焦校验、提交时校验。
解读: 即时校验每次输入都校验,反馈及时但可能打扰。失焦校验用户离开输入框时校验,是推荐的默认。提交时校验一次性校验所有字段,作为兜底。最佳实践是失焦 + 提交组合。
4. 代码阅读题
题目: 下面代码有什么问题?
jsx
function handleSubmit() {
if (!email.includes("@")) {
setEmailError("邮箱格式不正确")
}
if (emailError) return
api.submit(form)
}
参考答案: setEmailError 是异步的,调用后立即读 emailError 拿到的还是旧值,校验形同虚设。
修复:
jsx
function handleSubmit() {
const newErrors = {}
if (!email.includes("@")) {
newErrors.email = "邮箱格式不正确"
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors)
return
}
api.submit(form)
}
解读: setState 是异步的,不能调用后立即读取。校验应该用局部变量收集错误,最后一次性 setErrors。这是表单校验最常见的坑。
5. 找错题
题目: 下面代码有什么问题?
jsx
const [age, setAge] = useState(0)
<input
type="number"
value={age}
onChange={e => setAge(Number(e.target.value))}
/>
参考答案: 用户清空输入框时,Number("") 是 0,无法清空。应该存字符串:
jsx
const [age, setAge] = useState("")
<input
type="number"
value={age}
onChange={e => setAge(e.target.value)}
/>
// 提交时 Number(age)
解读: 输入框的值始终存字符串,需要数字时在提交或使用时转换。这是处理数字输入的标准做法。
6. 状态组织题
题目: 一个表单有 8 个字段,需要整体提交和整体重置。应该用单对象还是多状态?
参考答案: 用单对象。
jsx
const [form, setForm] = useState({
field1: "", field2: "", field3: "", field4: "",
field5: "", field6: "", field7: "", field8: ""
})
function updateField(field, value) {
setForm(prev => ({ ...prev, [field]: value }))
}
function reset() {
setForm({ field1: "", /* ... */ })
}
解读: 字段多、需要整体提交、需要整体重置 → 单对象。单对象便于组装提交数据、重置表单、整体校验。字段少(≤3)且独立性强 → 多状态。
7. 受控 vs 非受控题
题目: 下面三个场景,哪些应该用受控,哪些可以用非受控?
a. 登录表单的邮箱和密码
b. 文件上传的 <input type="file" />
c. 一个只在提交时读取值的搜索框
参考答案:
a. 受控。需要即时校验、按钮禁用联动、错误提示。
b. 非受控。<input type="file" /> 只能用 ref 读取。
c. 可以非受控。只在提交时读取,不需要实时知道。
解读: 默认受控,特殊场景非受控。需要即时校验、格式化、联动 → 受控。文件上传、极简单表单、只在提交时读取 → 非受控。
8. 动态字段题
题目: 实现一个"添加多个邮箱"的表单。要求:初始一个输入框,可以添加、删除,至少保留一个。
参考答案:
jsx
function EmailListForm() {
const [emails, setEmails] = useState([
{ id: Date.now(), value: "" }
])
function addEmail() {
setEmails(prev => [...prev, { id: Date.now(), value: "" }])
}
function removeEmail(id) {
setEmails(prev => prev.filter(e => e.id !== id))
}
function updateEmail(id, value) {
setEmails(prev => prev.map(e =>
e.id === id ? { ...e, value } : e
))
}
return (
<div>
{emails.map((email, index) => (
<div key={email.id}>
<input
value={email.value}
onChange={e => updateEmail(email.id, e.target.value)}
placeholder={`邮箱 ${index + 1}`}
/>
{emails.length > 1 && (
<button onClick={() => removeEmail(email.id)}>删除</button>
)}
</div>
))}
<button onClick={addEmail}>添加</button>
</div>
)
}
解读: 关键点:
- 每项有唯一 id,用
Date.now()或 uuid。 key用 id,不用 index。- 删除时保留至少一项。
- 更新用
map不可变。
9. 防重复提交题
题目: 有哪几种方式防止重复提交?推荐哪种?
参考答案:
方式一:禁用按钮。
jsx
<button disabled={submitting}>提交</button>
方式二:事件处理器守卫。
jsx
async function handleSubmit() {
if (submitting) return
// ...
}
方式三:用 <form> 的 onSubmit。
jsx
<form onSubmit={handleSubmit}>
<button disabled={submitting}>提交</button>
</form>
推荐:三种结合。 按钮禁用给用户视觉反馈,事件处理器守卫防止程序调用,<form> 的 onSubmit 天然支持回车提交。
解读: 防重复提交是表单的基本要求。用户可能连续点击、按回车、或网络慢时重复提交。三种方式结合最稳妥。
10. 校验时机题
题目: 一个邮箱输入框,应该在什么时机校验?为什么?
参考答案: 推荐"失焦 + 提交"组合。
- 失焦校验: 用户填完邮箱,离开输入框时校验。不打扰输入过程,但用户立即知道对不对。
- 提交校验: 兜底,防止用户没失焦就直接提交。
- 输入时清除错误: 如果已有错误,用户修改时重新校验,错误消失。
不推荐即时校验: 用户刚输入 "a" 就显示"邮箱格式不正确",体验差。
解读: 校验时机的选择影响用户体验。即时校验打扰输入,提交校验反馈太晚。失焦校验是平衡点,配合提交校验兜底,是最佳实践。
11. 错误聚焦题
题目: 提交失败后,如何聚焦到第一个错误的字段?
参考答案:
jsx
const fieldRefs = {
email: useRef(),
password: useRef()
}
function focusFirstError(errors) {
const firstErrorField = Object.keys(errors)[0]
fieldRefs[firstErrorField]?.current?.focus()
}
// 在 handleSubmit 中
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors)
focusFirstError(newErrors)
return
}
解读: 聚焦是命令式操作,必须用 ref。这是声明式 UI 的"逃生舱",第 13 课会深入讲。错误聚焦提升用户体验,特别是长表单。
12. 表单重置题
题目: 提交成功后,如何正确重置表单?
参考答案:
jsx
const INITIAL_FORM = { email: "", password: "" }
function reset() {
setForm(INITIAL_FORM)
setErrors({})
setStatus("editing")
setError(null)
}
解读: 用常量定义初始值,重置时直接引用。注意要重置所有相关状态:表单数据、错误、提交状态。遗漏任何一项都会导致残留状态。
13. 提交状态机题
题目: 把一个表单的提交状态从多个布尔改成状态机。
jsx
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [isError, setIsError] = useState(false)
参考答案:
jsx
const [status, setStatus] = useState("editing")
// "editing" | "submitting" | "success" | "error"
const [error, setError] = useState(null)
async function handleSubmit() {
if (status === "submitting") return
setStatus("submitting")
try {
await api.submit(form)
setStatus("success")
} catch (e) {
setError(e.message)
setStatus("error")
}
}
解读: 三个布尔有 8 种组合,只有 4 种合法。状态机用枚举值替代,从结构上杜绝非法组合。这是第 2 课状态机思维在表单场景的具体应用。
14. 无障碍题
题目: 表单错误如何做无障碍处理?
参考答案:
jsx
<input
id="email"
value={email}
onChange={...}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && (
<p id="email-error" role="alert">{errors.email}</p>
)}
关键点:
aria-invalid标记字段是否有效。aria-describedby关联错误信息。role="alert"让屏幕阅读器播报错误。
解读: 无障碍不是可选项,是专业开发的标配。表单是无障碍问题的重灾区,做好无障碍能服务更多用户。
15. 综合题
题目: 实现一个完整的登录表单,要求:
- 邮箱、密码
- 失焦校验格式
- 提交时校验所有字段
- 提交中禁用按钮
- 提交失败显示错误
- 提交失败聚焦第一个错误字段
参考答案:
jsx
function LoginForm() {
const [form, setForm] = useState({ email: "", password: "" })
const [errors, setErrors] = useState({})
const [status, setStatus] = useState("editing")
const [error, setError] = useState(null)
const emailRef = useRef()
const passwordRef = useRef()
const fieldRefs = { email: emailRef, password: passwordRef }
function validateField(field, value) {
if (field === "email") {
if (!value) return "邮箱不能为空"
if (!value.includes("@")) return "邮箱格式不正确"
}
if (field === "password") {
if (!value) return "密码不能为空"
if (value.length < 6) return "密码至少 6 位"
}
return ""
}
function handleChange(field, value) {
setForm(prev => ({ ...prev, [field]: value }))
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: validateField(field, value) }))
}
}
function handleBlur(field) {
setErrors(prev => ({
...prev,
[field]: validateField(field, form[field])
}))
}
async function handleSubmit(e) {
e.preventDefault()
if (status === "submitting") return
const newErrors = {}
Object.keys(form).forEach(field => {
const err = validateField(field, form[field])
if (err) newErrors[field] = err
})
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors)
const firstField = Object.keys(newErrors)[0]
fieldRefs[firstField]?.current?.focus()
return
}
setStatus("submitting")
try {
await api.login(form)
setStatus("success")
} catch (err) {
setError(err.message)
setStatus("error")
}
}
return (
<form onSubmit={handleSubmit}>
<div>
<input
ref={emailRef}
value={form.email}
onChange={e => handleChange("email", e.target.value)}
onBlur={() => handleBlur("email")}
aria-invalid={!!errors.email}
placeholder="邮箱"
/>
{errors.email && <p role="alert">{errors.email}</p>}
</div>
<div>
<input
ref={passwordRef}
type="password"
value={form.password}
onChange={e => handleChange("password", e.target.value)}
onBlur={() => handleBlur("password")}
aria-invalid={!!errors.password}
placeholder="密码"
/>
{errors.password && <p role="alert">{errors.password}</p>}
</div>
{status === "error" && <p role="alert">登录失败:{error}</p>}
<button disabled={status === "submitting"}>
{status === "submitting" ? "登录中..." : "登录"}
</button>
</form>
)
}
解读: 这道题综合了本课所有知识点:
- 单对象组织表单状态。
- 失焦校验 + 提交校验。
- 输入时清除错误。
- 提交状态机。
- 防重复提交。
- 错误聚焦。
- 无障碍。
- 纯函数校验。
十三、本课检查清单
设计表单时,按顺序问自己:
状态组织:
- 字段有几个?
- 少且独立 → 多状态
- 多或需要整体提交 → 单对象
- 有动态字段 → 数组 + id
受控 vs 非受控:
- 需要即时校验、格式化、联动吗?需要 → 受控
- 文件上传、极简单表单 → 可以非受控
- 默认用受控
校验:
- 校验函数是纯函数吗?
- 校验时机是什么?失焦 + 提交?
- 输入时清除错误吗?
- 数字输入存字符串吗?
提交:
- 提交状态用状态机吗?
- 防重复提交了吗?
- 提交成功重置表单了吗?
- 提交失败保留用户输入了吗?
错误:
- 错误展示在字段下方吗?
- 提交失败聚焦第一个错误了吗?
- 无障碍属性加了吗?
动态字段:
- 每项有唯一 id 吗?
key用 id 吗?- 更新用不可变吗?
十四、课后作业
基础题
题目 1: 下面代码有什么问题?
jsx
const [age, setAge] = useState(0)
<input
type="number"
value={age}
onChange={e => setAge(Number(e.target.value))}
/>
参考答案: 用户清空输入框时,Number("") 是 0,无法清空。应该存字符串:
jsx
const [age, setAge] = useState("")
<input
type="number"
value={age}
onChange={e => setAge(e.target.value)}
/>
// 使用时 Number(age)
解读: 输入框的值始终存字符串。输入过程中有中间态(空、"-"、"1."),只有字符串能表示。这是处理数字输入的标准做法。
题目 2: 把下面代码改成受控组件。
jsx
function SearchBox() {
const inputRef = useRef()
return (
<>
<input ref={inputRef} />
<button onClick={() => console.log(inputRef.current.value)}>
搜索
</button>
</>
)
}
参考答案:
jsx
function SearchBox({ value, onChange, onSearch }) {
return (
<>
<input
value={value}
onChange={e => onChange(e.target.value)}
/>
<button onClick={() => onSearch(value)}>搜索</button>
</>
)
}
// 父组件
function Page() {
const [keyword, setKeyword] = useState("")
return (
<SearchBox
value={keyword}
onChange={setKeyword}
onSearch={keyword => console.log(keyword)}
/>
)
}
解读: 非受控改受控的关键:用 value + onChange 替代 ref。子组件变成纯受控,父组件持有状态。这样父组件能实时知道输入值,可以做即时校验、联动等。
题目 3: 下面校验代码有什么问题?
jsx
function handleSubmit() {
if (!email.includes("@")) {
setEmailError("邮箱格式不正确")
}
if (emailError) return
api.submit(form)
}
参考答案: setEmailError 是异步的,调用后立即读 emailError 拿到的还是旧值,校验失效。
修复:
jsx
function handleSubmit() {
const newErrors = {}
if (!email.includes("@")) {
newErrors.email = "邮箱格式不正确"
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors)
return
}
api.submit(form)
}
解读: setState 异步是 React 的核心设计。校验必须用局部变量收集错误,最后一次性 setErrors。这是表单校验最常见的坑。
进阶题
题目 4: 实现一个"多步骤表单",3 步,每步有若干字段。要求:能前进、后退、提交,提交中有加载态,提交失败显示错误。
参考答案:
jsx
function Wizard() {
const [step, setStep] = useState(0)
const [form, setForm] = useState({
name: "", email: "", company: "", role: "", plan: "free"
})
const [status, setStatus] = useState("editing")
const [error, setError] = useState(null)
function updateField(field, value) {
setForm(prev => ({ ...prev, [field]: value }))
}
function next() { setStep(s => s + 1) }
function prev() { setStep(s => s - 1) }
async function submit() {
if (status === "submitting") return
setStatus("submitting")
try {
await api.submit(form)
setStatus("success")
} catch (e) {
setError(e.message)
setStatus("error")
}
}
return (
<div>
{step === 0 && (
<div>
<input
value={form.name}
onChange={e => updateField("name", e.target.value)}
placeholder="姓名"
/>
<input
value={form.email}
onChange={e => updateField("email", e.target.value)}
placeholder="邮箱"
/>
</div>
)}
{step === 1 && (
<div>
<input
value={form.company}
onChange={e => updateField("company", e.target.value)}
placeholder="公司"
/>
<input
value={form.role}
onChange={e => updateField("role", e.target.value)}
placeholder="职位"
/>
</div>
)}
{step === 2 && (
<div>
<select
value={form.plan}
onChange={e => updateField("plan", e.target.value)}
>
<option value="free">免费版</option>
<option value="pro">专业版</option>
</select>
</div>
)}
<div>
<button disabled={step === 0} onClick={prev}>上一步</button>
{step < 2 && <button onClick={next}>下一步</button>}
{step === 2 && (
<button disabled={status === "submitting"} onClick={submit}>
{status === "submitting" ? "提交中..." : "提交"}
</button>
)}
</div>
{status === "error" && <p>提交失败:{error}</p>}
</div>
)
}
解读: 关键点:
step和form分开管理,职责清晰。form是单对象,所有步骤的数据集中。- 提交状态用状态机。
- 提交按钮只在最后一步显示。
- 每步的字段独立渲染。
题目 5: 实现一个"动态字段"表单。用户可以添加多个联系人,每个联系人有姓名、电话。要求:添加、删除、校验。
参考答案:
jsx
function ContactsForm() {
const [contacts, setContacts] = useState([
{ id: Date.now(), name: "", phone: "" }
])
const [errors, setErrors] = useState({})
function add() {
setContacts(prev => [...prev, { id: Date.now(), name: "", phone: "" }])
}
function remove(id) {
setContacts(prev => prev.filter(c => c.id !== id))
setErrors(prev => {
const { [id]: _, ...rest } = prev
return rest
})
}
function update(id, field, value) {
setContacts(prev => prev.map(c =>
c.id === id ? { ...c, [field]: value } : c
))
}
function validate() {
const newErrors = {}
contacts.forEach(c => {
if (!c.name) newErrors[`${c.id}-name`] = "姓名不能为空"
if (!/^\d{11}$/.test(c.phone)) newErrors[`${c.id}-phone`] = "电话格式不正确"
})
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
function handleSubmit(e) {
e.preventDefault()
if (!validate()) return
api.submit(contacts)
}
return (
<form onSubmit={handleSubmit}>
{contacts.map(contact => (
<div key={contact.id}>
<input
value={contact.name}
onChange={e => update(contact.id, "name", e.target.value)}
placeholder="姓名"
/>
{errors[`${contact.id}-name`] && (
<p>{errors[`${contact.id}-name`]}</p>
)}
<input
value={contact.phone}
onChange={e => update(contact.id, "phone", e.target.value)}
placeholder="电话"
/>
{errors[`${contact.id}-phone`] && (
<p>{errors[`${contact.id}-phone`]}</p>
)}
{contacts.length > 1 && (
<button type="button" onClick={() => remove(contact.id)}>
删除
</button>
)}
</div>
))}
<button type="button" onClick={add}>添加联系人</button>
<button type="submit">提交</button>
</form>
)
}
解读: 关键点:
- 每项有唯一 id。
key用 id。- 错误用
${id}-${field}作为 key,避免 id 冲突。 - 删除时同时清除对应错误。
type="button"防止按钮触发提交。
题目 6: 把下面表单的错误处理改成无障碍友好的。
jsx
<input value={email} onChange={...} />
{errors.email && <p>{errors.email}</p>}
参考答案:
jsx
<input
id="email"
value={email}
onChange={...}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && (
<p id="email-error" role="alert">{errors.email}</p>
)}
解读:
aria-invalid告诉屏幕阅读器字段无效。aria-describedby关联错误信息,屏幕阅读器会读出错误。role="alert"让错误出现时立即播报。id用于关联。
无障碍是专业开发的标配,不是可选项。
思考题
题目 7: 为什么数字输入框的值应该存字符串而不是数字?
参考答案:
因为输入过程中有中间态:
- 空字符串(用户清空)
- "-"(负号,还没输入数字)
- "1."(小数点,还没输入小数)
- "1.0"(末尾的 0 有意义)
如果存数字:
Number("")是0,用户无法清空。Number("-")是NaN,状态变成 NaN。Number("1.")是1,用户输入的小数点丢失。
正确做法: 输入框存字符串,需要数字时在提交或使用时转换。
解读: 这是受控组件的常见陷阱。输入框的值始终是字符串,这是 HTML 的规则。 尊重这个规则,可以避免很多 bug。
题目 8: 为什么校验函数应该是纯函数?
参考答案:
纯函数的好处:
- 可独立测试。 不需要渲染组件就能测试。
- 可复用。 同一个校验函数可以用在不同表单。
- 可组合。 多个校验函数可以组合。
- 可预测。 相同输入,相同输出,容易推理。
反例:
jsx
// ❌ 依赖外部状态
function validateEmail() {
if (!form.email) return "邮箱不能为空"
}
正例:
jsx
// ✅ 纯函数
function validateEmail(email) {
if (!email) return "邮箱不能为空"
if (!email.includes("@")) return "邮箱格式不正确"
return ""
}
解读: 校验逻辑是最适合写成纯函数的部分。把校验抽出来,代码更清晰,测试更容易,复用更方便。
题目 9: 表单校验的三种时机各有什么优缺点?为什么推荐"失焦 + 提交"组合?
参考答案:
| 时机 | 优点 | 缺点 |
|---|---|---|
| 即时校验 | 反馈最及时 | 输入过程中频繁报错,体验差 |
| 失焦校验 | 不打扰输入,用户填完立即知道 | 用户可能一直不离开输入框 |
| 提交时校验 | 一次性告诉所有错误 | 反馈太晚 |
推荐"失焦 + 提交"的原因:
- 失焦校验:用户填完一项立即知道,不打扰输入。
- 提交时校验:兜底,防止用户没失焦就提交。
- 输入时清除错误:如果已有错误,用户修改时重新校验,错误消失。
解读: 校验时机的选择是用户体验的平衡。即时校验太打扰,提交校验太晚,失焦校验是平衡点。加上提交兜底和输入时清除,形成完整的校验体验。
挑战题
题目 10: 设计一个"表单"组件库,要求:
- 通用的
Field组件,支持文本、密码、数字、下拉框 - 支持错误显示
- 支持无障碍
- 支持布局(水平/垂直)
- 用组合的方式使用
参考答案:
jsx
// 通用字段容器
function Field({ label, error, required, children, layout = "vertical" }) {
const id = useId()
const errorId = `${id}-error`
return (
<div className={`field field-${layout}`}>
<label htmlFor={id}>
{label}
{required && <span className="required">*</span>}
</label>
<div className="field-control">
{React.cloneElement(children, {
id,
"aria-invalid": !!error,
"aria-describedby": error ? errorId : undefined
})}
{error && (
<p id={errorId} className="error" role="alert">
{error}
</p>
)}
</div>
</div>
)
}
// 文本输入
function TextInput({ value, onChange, ...props }) {
return (
<input
type="text"
value={value}
onChange={e => onChange(e.target.value)}
{...props}
/>
)
}
// 密码输入
function PasswordInput({ value, onChange, ...props }) {
return (
<input
type="password"
value={value}
onChange={e => onChange(e.target.value)}
{...props}
/>
)
}
// 数字输入
function NumberInput({ value, onChange, ...props }) {
return (
<input
type="number"
value={value}
onChange={e => onChange(e.target.value)}
{...props}
/>
)
}
// 下拉框
function Select({ value, onChange, options, ...props }) {
return (
<select
value={value}
onChange={e => onChange(e.target.value)}
{...props}
>
{options.map(opt => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
)
}
// 使用
function LoginForm() {
const [form, setForm] = useState({ email: "", password: "" })
const [errors, setErrors] = useState({})
function updateField(field, value) {
setForm(prev => ({ ...prev, [field]: value }))
}
return (
<form>
<Field label="邮箱" error={errors.email} required>
<TextInput
value={form.email}
onChange={v => updateField("email", v)}
/>
</Field>
<Field label="密码" error={errors.password} required>
<PasswordInput
value={form.password}
onChange={v => updateField("password", v)}
/>
</Field>
</form>
)
}
设计要点:
Field是容器。 负责标签、错误、无障碍、布局,不关心具体输入类型。- 输入组件独立。
TextInput、PasswordInput等,各自处理自己的value/onChange。 - 组合使用。
Field包裹输入组件,通过cloneElement注入无障碍属性。 useId生成唯一 id。 React 18 的useId保证 SSR 和客户端一致。- 无障碍内建。
Field自动处理aria-invalid、aria-describedby。
解读: 这是表单组件库的核心设计思路。容器组件负责布局和无障碍,输入组件负责值管理,通过组合实现灵活复用。 这也是大多数 UI 库(如 Ant Design、Material UI)的表单实现方式。
十五、本课小结
受控组件
值来自 props,变化通过回调通知。
jsx
<input value={value} onChange={e => onChange(e.target.value)} />
- 初始值用
"",不用undefined。 - 数字输入存字符串。
- 复选框用
checked+e.target.checked。
多字段组织
| 场景 | 方案 |
|---|---|
| 字段少且独立 | 多状态 |
| 字段多或整体提交 | 单对象 |
| 动态字段 | 数组 + id |
三种校验时机
| 时机 | 优点 | 缺点 |
|---|---|---|
| 即时 | 反馈及时 | 打扰输入 |
| 失焦 | 不打扰,立即反馈 | 用户可能不离开 |
| 提交 | 一次性所有错误 | 反馈太晚 |
推荐:失焦 + 提交 + 输入时清除错误。
提交状态
用状态机,不用多个布尔。
jsx
const [status, setStatus] = useState("editing")
// "editing" | "submitting" | "success" | "error"
防重复提交
- 禁用按钮。
- 事件处理器守卫。
- 用
<form>的onSubmit。
动态字段
- 每项有唯一 id。
key用 id,不用 index。- 更新用不可变。
错误处理
- 错误展示在字段下方。
- 提交失败聚焦第一个错误字段。
- 无障碍:
aria-invalid、aria-describedby、role="alert"。
关键规则
- 校验用局部变量收集错误,最后一次性
setErrors。 - 数字输入存字符串。
- 校验函数是纯函数。
- 提交状态用状态机。
- 错误输入时清除。
一句话记住本课
表单是用户输入的唯一入口,健壮的表单处理是专业开发的标志。
实用价值回顾
- 无 bug 的受控表单。 避免"value 为 undefined""数字无法清空"等常见坑。
- 清晰的校验时机。 失焦 + 提交,用户体验好。
- 健壮的提交。 状态机 + 防重复 + 错误处理。
- 灵活的动态字段。 添加、删除、校验都清晰。
- 无障碍。 服务更多用户,专业开发的标配。
十六、下一课预告
第 6 课 副作用管理:Effect 的正确用法
本课讲了表单输入,下一课讲与外部世界的交互。核心内容:
- 什么该放 Effect,什么不该
- 依赖数组的正确理解
- 清理函数:取消请求、取消订阅
- 常见陷阱:无限循环、竞态条件、闭包陷阱
- 数据获取的模式
- 事件监听与订阅
表单是内部交互,副作用是与外部交互。两者结合,覆盖了完整的应用场景。