3.4 表单与数据处理 本节摘要:表单是 Web 应用收集用户输入的主战场,React 里它从「受控 vs 非受控」的选择开始。本节先讲透受控组件的价值(实时校验、联动、统一管理),再演示实时校验与数据提交的完整流程,最后给出 React Hook Form 这类库在复杂表单中的取舍——什么时候值得引库。 先说结论 阅读完本节,你应当能够: 用受控组件管理表单值,理解 value 与 onChange 的配对关系 实现表单的实时校验与提交拦截 用 fetch 或 axios 提交表单数据并处理提交状态 判断何时该引入 React Hook Form 等表单库 处理表单常见的坑:value 未定义、提交防抖、错误回显 一、问题与直觉:表单为什么难写
本节摘要:表单是 Web 应用收集用户输入的主战场,React 里它从「受控 vs 非受控」的选择开始。本节先讲透受控组件的价值(实时校验、联动、统一管理),再演示实时校验与数据提交的完整流程,最后给出 React Hook Form 这类库在复杂表单中的取舍——什么时候值得引库。
阅读完本节,你应当能够:
表单看起来是最简单的东西——几个输入框、一个提交按钮。但真实项目的表单往往长这样:十几个字段、每个字段有必填/格式校验、输入时实时报错、提交时按钮 loading、提交失败要把服务端错误回显到对应字段、密码框还要看强度。
这些需求全压到「受控组件 + state」上时,代码会膨胀:每个字段要 state、onChange、校验函数、错误 state。字段一多,模板代码成堆。
所以表单问题的本质是:如何在「React 管控的确定性」和「字段多带来的模板成本」之间平衡。 本节先给不用库的完整解法(理解原理),再讲引库的判断(工程效率)。
第 2.3 节介绍过受控与非受控,这里把受控的完整链路走一遍。受控组件的核心是「value 绑定 state,onChange 更新 state」——值永远在 React 手里:
import { useState } from 'react'; function NameForm() { const [name, setName] = useState(''); const [email, setEmail] = useState(''); const handleSubmit = (e) => { e.preventDefault(); console.log('提交的数据:', { name, email }); }; return ( <form onSubmit={handleSubmit}> <div> <label htmlFor="name">姓名</label> <input id="name" value={name} onChange={e => setName(e.target.value)} /> </div> <div> <label htmlFor="email">邮箱</label> <input id="email" value={email} onChange={e => setEmail(e.target.value)} /> </div> <button type="submit">提交</button> </form> ); }
几个细节:
第 2.3 节讲过非受控组件用 ref 直接读 DOM 值。在表单语境里,非受控适合两类场景:一是只有一个字段、提交时才需要值的极简表单——比如一个搜索框,不需要实时校验,提交时读一下值就行;二是集成第三方组件,组件内部自己管理输入状态(如日期选择器、富文本编辑器),React 无法也不该接管。
import { useRef } from 'react'; function SearchBox({ onSearch }) { const inputRef = useRef(null); const handleSubmit = (e) => { e.preventDefault(); onSearch(inputRef.current.value); // 提交时才读值 }; return ( <form onSubmit={handleSubmit}> <input type="text" ref={inputRef} placeholder="搜索" /> <button type="submit">搜索</button> </form> ); }
判断标准回到那句话:需要实时校验、联动、统一管理的数据用受控;一次性取值、第三方托管用非受控。 别为了「统一用受控」而让简单表单变繁琐,也别为了「少写代码」而在需要校验时用非受控。
这条链路是受控组件的全部。核心特征是「状态即真相」——界面上显示什么,state 里就是什么,两者永远同步。这是受控组件相对于非受控组件的根本优势:数据只在一个地方,没有第二份副本需要同步。
验证的目标是「把不合法输入挡在提交之前」。两种时机:输入时实时校验、提交时统一校验。
每个字段的 onChange 里跑校验规则,把错误信息存进错误 state:
import { useState } from 'react'; function ValidatedForm() { const [name, setName] = useState(''); const [email, setEmail] = useState(''); const [nameError, setNameError] = useState(''); const [emailError, setEmailError] = useState(''); const handleNameChange = (e) => { const value = e.target.value; setName(value); setNameError(value.length < 3 ? '姓名至少需要 3 个字符' : ''); }; const handleEmailChange = (e) => { const value = e.target.value; setEmail(value); // 邮箱格式正则 setEmailError(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? '邮箱格式不正确' : ''); }; const handleSubmit = (e) => { e.preventDefault(); if (nameError || emailError || !name || !email) { alert('请检查表单填写是否正确'); return; } console.log('提交的数据:', { name, email }); }; return ( <form onSubmit={handleSubmit}> <div> <label htmlFor="name">姓名</label> <input id="name" value={name} onChange={handleNameChange} /> {nameError && <p className="error">{nameError}</p>} </div> <div> <label htmlFor="email">邮箱</label> <input id="email" value={email} onChange={handleEmailChange} /> {emailError && <p className="error">{emailError}</p>} </div> <button type="submit">提交</button> </form> ); }
这个模式的核心是「校验函数即状态」:每个字段的错误信息本身是 state,输入变化时同步更新。JSX 里 {nameError && <p>...} 在无错时渲染 false(什么都不显示),有错时显示错误文案。
💡 关键直觉:表单验证的本质是「把规则写成函数,把结果放进 state」。规则可以升级——单独抽成校验函数、复用多字段、支持异步校验(比如查重)。但骨架永远是「规则函数 + 错误 state + JSX 回显」三件套。
真实表单常有字段间依赖:确认密码要和密码一致、选了「企业用户」才有公司名称字段。联动校验的做法是「校验函数能读到其他字段的值」:
function RegisterForm() { const [password, setPassword] = useState(''); const [confirm, setConfirm] = useState(''); const [confirmError, setConfirmError] = useState(''); const handleConfirmChange = (value) => { setConfirm(value); setConfirmError(value !== password ? '两次密码不一致' : ''); }; // ... }
密码一变,确认框的错误可能就过期了。所以密码的 onChange 里也要重新校验确认框:
const handlePasswordChange = (value) => { setPassword(value); setConfirmError(confirm && confirm !== value ? '两次密码不一致' : ''); };
联动字段的注意点:改 A 字段时,B 字段的校验状态可能失效,要一并重新评估。 这是联动校验最容易漏的地方——用户先输对确认密码,再改密码,错误信息没刷新,一直挂在页面上。
字段多了以后,校验函数建议单独抽出来,方便复用和测试:
// 校验规则集中定义 const validators = { required: (value) => value ? '' : '此项必填', minLength: (n) => (value) => value.length >= n ? '' : `至少 ${n} 个字符`, email: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? '' : '邮箱格式不正确', }; // 使用 const nameError = validators.required(name) || validators.minLength(3)(name);
规则函数统一返回「空字符串表示通过,非空表示错误信息」。这个约定让校验函数可以组合、复用、单测。第 3.6 节测试会把这类纯函数列为单元测试的首选对象——它们不依赖 React,测起来最快。
输入时校验已经挡住了大部分问题,提交时再兜底一次:检查是否有残留错误、必填字段是否为空。上面的 handleSubmit 里那行 if (nameError || emailError || !name || !email) 就是统一校验。它防止「用户绕过输入触发提交」的漏洞,也处理「校验逻辑有 bug」的兜底。
验证通过后把数据发给服务器。用 fetch:
import { useState } from 'react'; function SubmitForm() { const [name, setName] = useState(''); const [email, setEmail] = useState(''); const [status, setStatus] = useState('idle'); // idle | loading | done | error const handleSubmit = async (e) => { e.preventDefault(); setStatus('loading'); try { const response = await fetch('/api/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, email }), }); if (response.ok) { setStatus('done'); setName(''); setEmail(''); } else { setStatus('error'); } } catch (error) { setStatus('error'); } }; return ( <form onSubmit={handleSubmit}> {/* 表单元素 */} <button type="submit" disabled={status === 'loading'}> {status === 'loading' ? '提交中...' : '提交'} </button> {status === 'done' && <p>提交成功</p>} {status === 'error' && <p>提交失败,请重试</p>} </form> ); }
三个要点:
axios 是 fetch 的替代品,支持拦截器、请求取消、更一致的错误处理。SOURCE 原文里两种都提到了——fetch 零依赖够用,axios 在需要拦截器(统一加 token、统一处理 401)时更顺。请求层的完整方案(缓存、重试、竞态处理)在第 4.5 节会展开。
真实提交还会遇到服务端错误:用户名已存在、验证码错误。这类错误不能只弹个 alert,要回显到对应字段。做法是把服务端返回的错误信息放进错误 state:
const handleSubmit = async (e) => { e.preventDefault(); setStatus('loading'); try { const res = await fetch('/api/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, email }), }); const data = await res.json(); if (!res.ok) { // 服务端返回字段级错误 setServerErrors(data.fieldErrors ?? {}); setStatus('error'); return; } setStatus('done'); } catch (err) { setStatus('error'); } };
然后渲染时把服务端错误合并进对应字段的错误显示。客户端校验挡常规错误,服务端校验挡业务规则(查重、权限),两层配合才是完整的表单防线。前端校验是体验,服务端校验是安全——这句话值得刻下来。
高频提交场景(搜索、联查)需要防抖——用户停止输入后才发请求,避免每敲一个字符都请求一次。防抖的简单实现:
import { useRef } from 'react'; function SearchForm() { const timerRef = useRef(null); const handleChange = (e) => { const keyword = e.target.value; clearTimeout(timerRef.current); // 清掉上一次的定时器 timerRef.current = setTimeout(() => { doSearch(keyword); // 停顿 300ms 后才搜索 }, 300); }; // ... }
竞态问题是另一个坑:两次请求先后发出,慢的请求后返回,覆盖了快的结果。处理竞态的通用手段是请求序号或取消:
const latestRequestRef = useRef(0); const doSearch = async (keyword) => { const reqId = ++latestRequestRef.current; const data = await fetchData(keyword); if (reqId === latestRequestRef.current) { setResults(data); // 只有最新一次请求能更新 } };
「请求序号比对」是前端处理竞态最朴素也最可靠的手法。理解了它,后续接数据请求库时(它们内置竞态处理)你就知道自己在用什么东西。
字段一多、校验一复杂,手写模式就膨胀了。React Hook Form 是当前主流的表单库,思路是「用 ref 减少重渲染 + 统一校验注册」。
import { useForm } from 'react-hook-form'; function ProfileForm() { const { register, handleSubmit, formState: { errors } } = useForm(); const onSubmit = (data) => { console.log('提交的数据:', data); }; return ( <form onSubmit={handleSubmit(onSubmit)}> <input {...register('name', { required: '姓名必填', minLength: { value: 3, message: '至少 3 个字符' } })} /> {errors.name && <p>{errors.name.message}</p>} <input {...register('email', { required: '邮箱必填', pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: '邮箱格式不对' } })} /> {errors.email && <p>{errors.email.message}</p>} <button type="submit">提交</button> </form> ); }
register 把字段名和校验规则注册进表单,handleSubmit 统一收集数据并触发校验,errors 存校验错误。相比手写模式,字段多了之后的优势明显:校验规则和字段绑定、错误信息自动回显、数据自动收集。
| 场景 | 手写受控组件 | React Hook Form |
|---|---|---|
| 2-3 个字段 | 合适 | 略重 |
| 5-10 个字段 | 模板代码变多 | 推荐 |
| 嵌套/动态字段 | 繁琐 | 支持好 |
| 校验规则复杂 | 手写散落 | 集中定义 |
⚠️ 常见坑:所有表单都引 React Hook Form。表单就两三个字段还引库,是给自己加依赖负担。判断标准:字段数、校验复杂度、动态字段需求。这几个指标上去了,引库才划算;不然手写受控组件完全够。
坑一:value 绑定了 undefined。 受控组件的 value 必须始终有值。如果初始 state 是 undefined(比如绑定到还没初始化的对象属性),React 会把组件当非受控处理,行为混乱。初始化 state 时给空字符串或空对象。
坑二:提交按钮不在 form 内。 按钮必须放在 <form> 里才能触发 onSubmit。放在外面,点击只会……什么都不发生。
坑三:没 preventDefault。 表单提交默认会刷新页面,不调用 e.preventDefault(),你收集的数据瞬间蒸发。检查表单第一眼看 handleSubmit 里有没有这行。
坑四:错误信息存 localStorage。 用户在提交失败后刷新页面,填好的表单数据全没了。体验好的表单会在刷新后恢复草稿——用 localStorage 或 sessionStorage 存草稿,是长表单的常见增强。
「受控组件和非受控组件到底怎么选?」 判断标准只有一条:这个输入框的值,React 需不需要「知道并管理」。需要实时校验、需要和其他字段联动、需要在提交时统一读取,就受控——值进 state,一切由 React 说了算。值只在提交那一刻需要,React 平时不用管它,非受控更省事——用一个 ref 在提交时读值即可。还有一个「中间态」值得知道:半受控(defaultValue),让 React 管理初始值、之后交给浏览器自己维护。别把「受控」当成正确写法,它是「需要管控时」的写法。
「实时校验还是提交时校验?」 两段都要,但分工不同。实时校验(onChange 里跑规则)管「用户体验」——输入过程中即时反馈,用户不用等提交才知道错了;提交时校验管「最终防线」——兜住绕过输入、遗留错误、必填漏填。别只做一段:只做实时校验,用户可能「看着没错但提交失败」;只做提交校验,用户得等到最后一刻才知道错了。前端的标准形态是「输入时实时 + 提交时兜底」,第 3.4 节的 ValidatedForm 就是这个形态。
「React Hook Form 和手写受控,性能真的差很多吗?」 差在「重渲染次数」上。手写受控表单,每次敲键盘都触发 setState → 组件重渲染 → 整个表单重新执行。React Hook Form 用 ref 把输入值「挂在 DOM 上」,输入时不触发重渲染,只有提交时才统一取值。字段少的时候差异不明显,字段多(十几二十个)、表单重(每字段都带校验与联动)的时候,输入卡顿的体感差异就出来了。这也解释了它的定位:小表单用不用差别不大,复杂表单的收益才明显。
「表单的校验规则应该写在组件里还是抽出去?」 抽出去,但要看抽的边界。单组件用的简单规则可以留在组件里;会被多个表单复用、逻辑比较复杂(跨字段校验、异步查重)的规则,抽成独立的校验函数模块。抽取的核心收益是「可测试」——第 3.6 节讲过,纯函数是单元测试的首选对象,校验规则是典型代表。规则独立之后,测试它不需要渲染任何组件,几毫秒跑完一批用例,改规则时回归成本几乎为零。
下一节看组件设计模式——表单是「怎么用组件」,设计模式是「怎么写组件」。组合、HOC、Render Props、Context 四种模式,帮你把重复逻辑抽出来、把复杂组件拆干净。这节学完,你写组件就不再只有「函数 + props」一种思路,而是有了一整套工具。