前端新人避坑指南:React表单受控与非受控组件实战解析
重复代码太多?// 通用表单hook// 如果已经触碰过这个字段,实时验证// 失焦时验证// 使用if (!values.email) errors.email = '必填';else if (!/^\S+@\S+$/.test(values.email)) errors.email = '格式错误';if (!values.password) errors.password = '必填';
前端新人避坑指南:React表单受控与非受控组件实战解析
前端新人避坑指南:React表单受控与非受控组件实战解析
开篇先唠两句
你是不是也被React表单搞到头秃过?明明照着文档写,一跑就报错,控制台红得跟过年似的。受控非受控傻傻分不清楚的痛谁懂啊——昨天我刚看到组里实习生小哥盯着屏幕发呆,过去一问,好家伙,他在一个input上同时写了value和defaultValue,React直接给他抛了个警告,他愣是看了半小时没看出门道。
今天就把这俩货给你扒得明明白白。不整那些官方文档的八股文,咱们就聊点实在的:什么时候该用哪个,怎么写才不踩坑,以及那些面试官爱问但你总答不利索的细节。看完这篇,至少下次遇到表单问题你不会想砸键盘了。
这俩玩意儿到底是啥
先打个比方。受控组件就像那种管得特别细的亲妈,你穿啥衣服、吃几碗饭她都要过问,所有信息都得经过她手。非受控组件呢,更像放养式教育,孩子(DOM)自己玩自己的,家长(React)偶尔瞅一眼,关键时刻出来收个场。
技术层面说人话:
受控组件就是React完全接管了表单数据。你的input显示啥值,不是DOM说了算,是React的state说了算。用户每敲一个字符,都得先通知React"嘿我变了",React更新state,然后重新渲染,input才能显示新值。
非受控组件则是把控制权交还给浏览器原生的DOM。React只是帮忙搭个架子,数据存在DOM节点里,你要取值的时候通过ref去抓。
核心区别就在数据流向和状态管理权在谁手里。这个区别看起来简单,但实际写代码的时候,很多人就在这上面翻车。
受控组件那些事儿
基础写法:最啰嗦但最稳妥
来,先看个最基础的例子,感受一下什么叫"亲妈式管理":
import { useState } from 'react';
function ControlledForm() {
// 每个字段都要有个state,这是受控的代价
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [age, setAge] = useState('');
// 提交的时候直接从state拿,不用操作DOM
const handleSubmit = (e) => {
e.preventDefault();
console.log('提交的数据:', { username, email, age });
// 这里可以发请求,或者做验证
// fetch('/api/register', { body: JSON.stringify({ username, email, age }) })
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>用户名:</label>
<input
type="text"
value={username} // 值从state来
onChange={(e) => setUsername(e.target.value)} // 变化回写state
placeholder="请输入用户名"
/>
</div>
<div>
<label>邮箱:</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="请输入邮箱"
/>
</div>
<div>
<label>年龄:</label>
<input
type="number"
value={age}
onChange={(e) => setAge(e.target.value)}
placeholder="请输入年龄"
/>
</div>
<button type="submit">注册</button>
</form>
);
}
看到没?每个input都得配一个state和一个onChange handler。三个字段还好,要是三十个字段…你懂的,代码量直接爆炸。但这就是受控组件的代价:啰嗦,但是可控。
进阶玩法:实时验证和动态交互
受控组件真正香的地方在于,你可以随时知道用户输入了啥,然后做点实时反馈。比如这个带验证的版本:
import { useState } from 'react';
function ValidatedForm() {
const [formData, setFormData] = useState({
username: '',
password: '',
confirmPassword: ''
});
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
// 统一处理所有字段的变化,不用写三十个handler
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
// 实时验证,用户每敲一个字都检查
validateField(name, value);
};
const validateField = (name, value) => {
let error = '';
switch(name) {
case 'username':
if (value.length < 3) error = '用户名至少3个字符';
else if (!/^[a-zA-Z0-9]+$/.test(value)) error = '只能包含字母和数字';
break;
case 'password':
if (value.length < 8) error = '密码至少8位';
else if (!/[A-Z]/.test(value)) error = '需要包含大写字母';
else if (!/[0-9]/.test(value)) error = '需要包含数字';
break;
case 'confirmPassword':
if (value !== formData.password) error = '两次密码不一致';
break;
}
setErrors(prev => ({
...prev,
[name]: error
}));
};
// 检查整个表单是否有效,用来控制提交按钮
const isFormValid = () => {
return (
formData.username.length >= 3 &&
formData.password.length >= 8 &&
formData.password === formData.confirmPassword &&
!Object.values(errors).some(e => e !== '')
);
};
const handleSubmit = async (e) => {
e.preventDefault();
setIsSubmitting(true);
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('注册成功', formData);
alert('注册成功!');
} catch (error) {
alert('注册失败:' + error.message);
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<input
name="username"
value={formData.username}
onChange={handleChange}
placeholder="用户名"
style={{ borderColor: errors.username ? 'red' : '#ccc' }}
/>
{errors.username && <span style={{color: 'red'}}>{errors.username}</span>}
</div>
<div>
<input
name="password"
type="password"
value={formData.password}
onChange={handleChange}
placeholder="密码"
style={{ borderColor: errors.password ? 'red' : '#ccc' }}
/>
{errors.password && <span style={{color: 'red'}}>{errors.password}</span>}
</div>
<div>
<input
name="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={handleChange}
placeholder="确认密码"
style={{ borderColor: errors.confirmPassword ? 'red' : '#ccc' }}
/>
{errors.confirmPassword && <span style={{color: 'red'}}>{errors.confirmPassword}</span>}
</div>
{/* 按钮根据表单状态动态禁用,这是受控组件的精髓 */}
<button type="submit" disabled={!isFormValid() || isSubmitting}>
{isSubmitting ? '提交中...' : '注册'}
</button>
</form>
);
}
这段代码展示了受控组件的几个核心优势:
- 统一handler:用一个handleChange处理所有字段,通过name属性区分,不用写三十个函数
- 实时验证:用户每输入一个字符就验证,即时反馈
- 动态UI:提交按钮根据验证状态自动禁用,这种体验用非受控组件很难做
处理不同类型的表单元素
文本框简单,但表单不止有文本框。来看看其他类型的受控写法:
import { useState } from 'react';
function ComplexControlledForm() {
const [formData, setFormData] = useState({
// 文本输入
username: '',
description: '',
// 选择类
gender: 'male', // radio
hobbies: [], // checkbox组
country: 'cn', // select
// 开关类
subscribe: false, // 单个checkbox
// 文件类(注意:文件上传不能用纯受控,后面会讲)
avatar: null
});
const handleChange = (e) => {
const { name, value, type, checked, files } = e.target;
setFormData(prev => {
// 根据类型处理不同的值
if (type === 'checkbox') {
// 单个checkbox用boolean
return { ...prev, [name]: checked };
}
else if (type === 'file') {
// 文件特殊处理
return { ...prev, [name]: files[0] };
}
else if (name === 'hobbies') {
// checkbox组的特殊逻辑
const hobbyValue = value;
const newHobbies = checked
? [...prev.hobbies, hobbyValue]
: prev.hobbies.filter(h => h !== hobbyValue);
return { ...prev, hobbies: newHobbies };
}
// 其他情况直接用value
return { ...prev, [name]: value };
});
};
return (
<form>
{/* 文本域 - 和input几乎一样 */}
<div>
<label>个人简介:</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
rows="4"
placeholder="写点啥..."
/>
</div>
{/* Radio组 - name相同,value不同 */}
<div>
<label>性别:</label>
<label>
<input
type="radio"
name="gender"
value="male"
checked={formData.gender === 'male'}
onChange={handleChange}
/> 男
</label>
<label>
<input
type="radio"
name="gender"
value="female"
checked={formData.gender === 'female'}
onChange={handleChange}
/> 女
</label>
</div>
{/* Select下拉框 - 注意value绑定在select上,不是option */}
<div>
<label>国家:</label>
<select name="country" value={formData.country} onChange={handleChange}>
<option value="cn">中国</option>
<option value="us">美国</option>
<option value="jp">日本</option>
<option value="uk">英国</option>
</select>
</div>
{/* Checkbox组 - 这个稍微复杂点 */}
<div>
<label>爱好(多选):</label>
{['reading', 'gaming', 'coding', 'sports'].map(hobby => (
<label key={hobby}>
<input
type="checkbox"
name="hobbies"
value={hobby}
checked={formData.hobbies.includes(hobby)}
onChange={handleChange}
/> {hobby}
</label>
))}
</div>
{/* 单个checkbox - 一般用于同意条款 */}
<div>
<label>
<input
type="checkbox"
name="subscribe"
checked={formData.subscribe}
onChange={handleChange}
/> 订阅邮件通知
</label>
</div>
</form>
);
}
注意几个细节:
- textarea在React里不是闭合标签写内容,而是用value属性,这和传统HTML不一样
- select的value写在select标签上,不是给option加selected属性
- radio组靠name关联,靠checked判断是否选中
- checkbox组是最麻烦的,需要维护一个数组,选中就push,取消就filter
非受控组件怎么玩
基础写法:简单粗暴直接上手
如果说受控组件是亲妈式管理,非受控就是"你随意,我不管,最后给我结果就行"。看代码:
import { useRef } from 'react';
function UncontrolledForm() {
// 用ref建立与DOM的直接联系
const usernameRef = useRef(null);
const emailRef = useRef(null);
const passwordRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
// 直接从DOM节点取值,不经过React的state
const formData = {
username: usernameRef.current.value,
email: emailRef.current.value,
password: passwordRef.current.value
};
console.log('提交的数据:', formData);
// 如果要重置表单,直接操作DOM
// usernameRef.current.value = '';
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>用户名:</label>
<input
type="text"
ref={usernameRef}
defaultValue="" // 初始值,之后React不管了
placeholder="请输入用户名"
/>
</div>
<div>
<label>邮箱:</label>
<input
type="email"
ref={emailRef}
defaultValue=""
placeholder="请输入邮箱"
/>
</div>
<div>
<label>密码:</label>
<input
type="password"
ref={passwordRef}
defaultValue=""
placeholder="请输入密码"
/>
</div>
<button type="submit">注册</button>
</form>
);
}
看到区别了吗?代码量少了一半不止。没有useState,没有onChange,没有value绑定。你只需要:
- 用useRef创建引用
- 把ref挂到input上
- 提交的时候通过ref.current.value取值
defaultValue是关键,它只设置初始值,之后input的值变化不会通知React,React也不会去干预。
文件上传:非受控的主场
文件上传是少数必须用非受控的场景,因为文件对象的值是只读的,你不能用state去"控制"它:
import { useRef, useState } from 'react';
function FileUploadForm() {
const fileInputRef = useRef(null);
const [preview, setPreview] = useState(null);
const [uploadProgress, setUploadProgress] = useState(0);
// 文件选择后的预览处理
const handleFileSelect = (e) => {
const file = e.target.files[0];
if (file) {
// 生成预览URL
const previewUrl = URL.createObjectURL(file);
setPreview(previewUrl);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
const file = fileInputRef.current.files[0];
if (!file) {
alert('请先选择文件');
return;
}
// 用FormData组装数据,这是文件上传的标准做法
const formData = new FormData();
formData.append('avatar', file);
formData.append('username', 'testUser');
try {
// 模拟上传进度
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100;
setUploadProgress(percentComplete);
}
};
xhr.onload = () => {
console.log('上传完成');
setUploadProgress(0);
};
xhr.open('POST', '/api/upload');
xhr.send(formData);
} catch (error) {
console.error('上传失败:', error);
}
};
// 清空文件选择
const handleClear = () => {
fileInputRef.current.value = ''; // 直接操作DOM清空
setPreview(null);
};
return (
<form onSubmit={handleSubmit}>
<div>
<input
type="file"
ref={fileInputRef}
onChange={handleFileSelect} // 虽然用了onChange,但只是为了预览,不是受控
accept="image/*" // 限制文件类型
/>
<button type="button" onClick={handleClear}>清空</button>
</div>
{preview && (
<div>
<img src={preview} alt="预览" style={{width: '200px', height: '200px', objectFit: 'cover'}} />
</div>
)}
{uploadProgress > 0 && (
<div>
上传进度: {uploadProgress.toFixed(2)}%
<div style={{width: '100%', backgroundColor: '#eee'}}>
<div style={{
width: `${uploadProgress}%`,
height: '20px',
backgroundColor: '#1890ff',
transition: 'width 0.3s'
}} />
</div>
</div>
)}
<button type="submit">上传</button>
</form>
);
}
这里有个细节:虽然我们用onChange来更新预览图,但input本身还是非受控的——我们没绑value,也没法绑value(文件输入的value是只读的)。这种"半受控"的写法在实际开发中很常见。
集成第三方库:非受控的优势
很多第三方UI库(比如富文本编辑器、地图组件、日期选择器)内部自己管理状态,这时候用非受控更自然:
import { useRef, useEffect } from 'react';
// 假设我们集成一个富文本编辑器,比如wangEditor或Quill
// 这里用伪代码演示思路
function RichTextEditor() {
const editorRef = useRef(null);
const editorInstanceRef = useRef(null);
useEffect(() => {
// 初始化第三方编辑器
if (editorRef.current && !editorInstanceRef.current) {
editorInstanceRef.current = new SomeRichEditor({
el: editorRef.current,
onChange: (content) => {
// 编辑器内容变化时的回调
console.log('内容变了:', content);
}
});
// 设置初始内容
editorInstanceRef.current.setHtml('<p>初始内容</p>');
}
// 清理
return () => {
if (editorInstanceRef.current) {
editorInstanceRef.current.destroy();
}
};
}, []);
const handleGetContent = () => {
// 通过编辑器实例的方法获取内容
const content = editorInstanceRef.current.getHtml();
console.log('当前内容:', content);
return content;
};
const handleSetContent = (html) => {
// 通过编辑器实例的方法设置内容
editorInstanceRef.current.setHtml(html);
};
return (
<div>
<div ref={editorRef} style={{border: '1px solid #ccc', minHeight: '300px'}} />
<button type="button" onClick={handleGetContent}>获取内容</button>
<button type="button" onClick={() => handleSetContent('<p>新内容</p>')}>设置内容</button>
</div>
);
}
这种场景下,你硬要改成受控组件反而别扭,因为第三方库有自己的内部状态,React强行接管容易出bug。
俩货各自的优缺点得拎清楚
受控组件:啰嗦但靠谱
优点:
- 数据流清晰:所有数据都在React的state里,调试的时候一眼能看到当前值,时间旅行调试(Redux DevTools那种)也能抓到表单状态
- 易于验证:用户每输入一个字符你都能拦截检查,实时反馈错误,体验好
- 动态交互:根据输入内容动态禁用按钮、显示隐藏其他字段、计算联动值,这些都很自然
- 单向数据流:符合React的设计理念,数据只能从state流向UI,不会乱
缺点:
- 代码量大:每个字段都要配state和handler,大表单写起来想死
- 性能开销:每次输入都触发重新渲染,虽然现代React有优化,但大表单还是能感觉到卡顿
- 学习曲线:新手容易搞混value和defaultValue,或者忘记写onChange导致输入不了
非受控组件:省事但黑盒
优点:
- 写法简洁:代码量少一半,快速原型的时候特别爽
- 性能稍好:不经过React的渲染流程,输入再快也不会卡
- 集成方便:第三方库自己管自己的状态,React不用强行介入
- 文件上传唯一选择:input type="file"必须用非受控
缺点:
- 数据流不透明:数据藏在DOM里,调试的时候你得手动去查ref.current.value,不方便
- 难以实时验证:想做个实时密码强度检测?你得给input加onChange,那还不如直接用受控
- 不适合复杂表单:字段联动、条件渲染、动态增删表单项,这些用非受控做很费劲
- React生态不友好:很多表单库(Formik、React Hook Form)都是基于受控理念设计的
实际项目里咋选
别纠结,直接看场景:
后台管理系统:全用受控
后台系统表单通常有这些特点:字段多、验证复杂、需要实时反馈、经常要联动。这种场景受控组件是标配:
// 后台系统的典型复杂表单
function AdminUserForm() {
const [formData, setFormData] = useState({
basicInfo: { name: '', email: '', phone: '' },
roles: [],
permissions: {},
status: 'active',
metadata: {}
});
// 大量验证逻辑、动态字段、实时保存草稿...
// 这种场景非受控根本玩不转
return (
<form>
{/* 几十个字段,各种嵌套结构 */}
</form>
);
}
简单登录注册:非受控省代码
就俩字段(用户名+密码),提交时才验证,这种用非受控完全够用:
function SimpleLogin() {
const usernameRef = useRef();
const passwordRef = useRef();
const handleSubmit = () => {
const username = usernameRef.current.value;
const password = passwordRef.current.value;
if (!username || !password) {
alert('请填写完整');
return;
}
loginApi({ username, password });
};
return (
<form onSubmit={handleSubmit}>
<input ref={usernameRef} placeholder="用户名" />
<input ref={passwordRef} type="password" placeholder="密码" />
<button>登录</button>
</form>
);
}
文件上传:必须用非受控
前面说了,input type="file"的值是只读的,你不能这样写:
// ❌ 错误!这会报错
<input type="file" value={fileState} onChange={...} />
只能这样:
// ✅ 正确
<input type="file" ref={fileRef} onChange={handleFileSelect} />
大表单优化:用表单库别自己造轮子
字段超过10个,还全是受控组件,性能真的会卡。这时候该上专业工具了:
// 用react-hook-form,既能享受受控的数据管理,又有非受控的性能
import { useForm } from 'react-hook-form';
function OptimizedForm() {
const { register, handleSubmit, formState: { errors } } = useForm();
const onSubmit = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('username', { required: '用户名必填', minLength: 3 })} />
{errors.username && <span>{errors.username.message}</span>}
<input {...register('email', { required: true, pattern: /^\S+@\S+$/i })} />
{errors.email && <span>邮箱格式错误</span>}
<button type="submit">提交</button>
</form>
);
}
react-hook-form内部用了非受控的策略(通过ref直接操作DOM),但对外提供了受控的API,算是两全其美。
踩坑实录和排查思路
坑一:受控组件警告"A component is changing an uncontrolled input to be controlled"
症状: 控制台报warning,input有时候输不进去。
原因: value的值从undefined变成了字符串。React认为undefined是非受控,字符串是受控,来回切换就会报警告。
翻车代码:
const [value, setValue] = useState(); // 初始值是undefined!
<input value={value} onChange={e => setValue(e.target.value)} />
修复:
const [value, setValue] = useState(''); // 初始给个空字符串
// 或者
const [value, setValue] = useState(null);
<input value={value || ''} onChange={...} /> // 渲染时兜底
坑二:非受控组件取值是undefined
症状: 提交的时候ref.current.value是undefined。
原因: ref绑定时机不对。组件还没挂载就去读值,或者ref没挂到正确的元素上。
翻车代码:
const MyComponent = () => {
const inputRef = useRef();
const handleClick = () => {
console.log(inputRef.current.value); // 如果这时候组件还没渲染完,current是null
};
return <input ref={inputRef} />;
};
修复: 确保DOM已挂载再取值,或者用可选链:
const handleSubmit = () => {
const value = inputRef.current?.value; // 加个问号,防止报错
};
坑三:表单重置不生效
症状: 点了重置按钮,input里的值还在。
翻车代码(受控组件):
<button type="reset">重置</button> // HTML的reset按钮对受控组件无效!
修复(受控):
const handleReset = () => {
setFormData({ username: '', email: '' }); // 手动清空state
};
<button type="button" onClick={handleReset}>重置</button>
非受控组件的reset:
// 非受控可以用原生的reset,或者手动清空
const handleReset = () => {
if (inputRef.current) {
inputRef.current.value = '';
}
};
坑四:受控组件输入卡顿
症状: 输入快的时候明显卡顿,特别是大表单。
原因: 每次onChange都setState,触发整个表单重新渲染。
优化方案1:用React.memo隔离子组件
const InputField = React.memo(({ value, onChange, name }) => {
console.log(`${name} render`); // 观察渲染次数
return <input value={value} onChange={onChange} />;
});
function BigForm() {
const [formData, setFormData] = useState({...});
// 如果InputField是memo的,其他字段变化不会导致它重新渲染
return (
<form>
<InputField
name="field1"
value={formData.field1}
onChange={handleChange}
/>
<InputField
name="field2"
value={formData.field2}
onChange={handleChange}
/>
{/* 几十个字段... */}
</form>
);
}
优化方案2:防抖处理
import { debounce } from 'lodash';
function SearchInput() {
const [displayValue, setDisplayValue] = useState(''); // 控制input显示
const [searchValue, setSearchValue] = useState(''); // 实际搜索用的值
// 防抖处理,用户停止输入500ms后才更新searchValue
const debouncedSearch = useCallback(
debounce((value) => {
setSearchValue(value);
// 这里发搜索请求
}, 500),
[]
);
const handleChange = (e) => {
const value = e.target.value;
setDisplayValue(value); // 立即更新,保证输入流畅
debouncedSearch(value); // 延迟更新搜索值
};
return <input value={displayValue} onChange={handleChange} />;
}
优化方案3:直接用react-hook-form
前面展示过了,这是终极解决方案。
几个让代码更骚的技巧
技巧一:useReducer管理复杂表单
字段多且有关联关系的时候,useState写起来很乱,用useReducer更清晰:
import { useReducer } from 'react';
// 定义所有可能的操作类型
const formReducer = (state, action) => {
switch (action.type) {
case 'UPDATE_FIELD':
return {
...state,
[action.field]: action.value
};
case 'UPDATE_NESTED_FIELD':
return {
...state,
[action.section]: {
...state[action.section],
[action.field]: action.value
}
};
case 'ADD_ARRAY_ITEM':
return {
...state,
[action.field]: [...state[action.field], action.value]
};
case 'REMOVE_ARRAY_ITEM':
return {
...state,
[action.field]: state[action.field].filter((_, idx) => idx !== action.index)
};
case 'RESET':
return action.initialState;
default:
return state;
}
};
function ComplexForm() {
const initialState = {
userInfo: { firstName: '', lastName: '' },
addresses: [{ city: '', street: '' }],
preferences: { newsletter: false, theme: 'light' }
};
const [state, dispatch] = useReducer(formReducer, initialState);
// 更新普通字段
const updateField = (field, value) => {
dispatch({ type: 'UPDATE_FIELD', field, value });
};
// 更新嵌套字段
const updateNestedField = (section, field, value) => {
dispatch({ type: 'UPDATE_NESTED_FIELD', section, field, value });
};
// 动态增删表单项
const addAddress = () => {
dispatch({
type: 'ADD_ARRAY_ITEM',
field: 'addresses',
value: { city: '', street: '' }
});
};
const removeAddress = (index) => {
dispatch({ type: 'REMOVE_ARRAY_ITEM', field: 'addresses', index });
};
return (
<form>
<input
value={state.userInfo.firstName}
onChange={e => updateNestedField('userInfo', 'firstName', e.target.value)}
/>
{state.addresses.map((addr, idx) => (
<div key={idx}>
<input
value={addr.city}
onChange={e => {
// 这里需要特殊处理数组更新,实际可以写个更通用的reducer
const newAddresses = [...state.addresses];
newAddresses[idx] = { ...addr, city: e.target.value };
dispatch({ type: 'UPDATE_FIELD', field: 'addresses', value: newAddresses });
}}
/>
<button type="button" onClick={() => removeAddress(idx)}>删除</button>
</div>
))}
<button type="button" onClick={addAddress}>添加地址</button>
</form>
);
}
reducer把状态变更逻辑集中管理,比到处setState清晰多了,特别是团队协作的时候,大家看reducer就知道有哪些操作是允许的。
技巧二:自定义Hook封装表单逻辑
重复代码太多?抽个hook:
// 通用表单hook
function useForm(initialValues, validate) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
const val = type === 'checkbox' ? checked : value;
setValues(prev => ({ ...prev, [name]: val }));
// 如果已经触碰过这个字段,实时验证
if (touched[name]) {
const fieldErrors = validate({ ...values, [name]: val });
setErrors(prev => ({ ...prev, [name]: fieldErrors[name] }));
}
};
const handleBlur = (e) => {
const { name } = e.target;
setTouched(prev => ({ ...prev, [name]: true }));
// 失焦时验证
const fieldErrors = validate(values);
setErrors(prev => ({ ...prev, [name]: fieldErrors[name] }));
};
const handleSubmit = (onSubmit) => (e) => {
e.preventDefault();
const formErrors = validate(values);
setErrors(formErrors);
setTouched(Object.keys(values).reduce((acc, key) => ({ ...acc, [key]: true }), {}));
if (Object.keys(formErrors).length === 0) {
onSubmit(values);
}
};
const reset = () => {
setValues(initialValues);
setErrors({});
setTouched({});
};
return { values, errors, touched, handleChange, handleBlur, handleSubmit, reset };
}
// 使用
function LoginForm() {
const validate = (values) => {
const errors = {};
if (!values.email) errors.email = '必填';
else if (!/^\S+@\S+$/.test(values.email)) errors.email = '格式错误';
if (!values.password) errors.password = '必填';
else if (values.password.length < 6) errors.password = '至少6位';
return errors;
};
const form = useForm({ email: '', password: '' }, validate);
return (
<form onSubmit={form.handleSubmit(data => console.log(data))}>
<input
name="email"
value={form.values.email}
onChange={form.handleChange}
onBlur={form.handleBlur}
/>
{form.touched.email && form.errors.email && <span>{form.errors.email}</span>}
<input
name="password"
type="password"
value={form.values.password}
onChange={form.handleChange}
onBlur={form.handleBlur}
/>
{form.touched.password && form.errors.password && <span>{form.errors.password}</span>}
<button type="submit">登录</button>
<button type="button" onClick={form.reset}>重置</button>
</form>
);
}
这个hook把表单的核心逻辑(值管理、验证、触碰状态)都封装了,业务组件只负责渲染,清爽很多。
技巧三:受控转非受控的混合写法
有些场景需要两者结合,比如搜索框:输入的时候要实时显示(受控),但搜索请求要防抖(非受控思想):
function HybridSearch() {
const [inputValue, setInputValue] = useState('');
const inputRef = useRef(null);
const [results, setResults] = useState([]);
// 受控部分:实时更新输入显示
const handleInputChange = (e) => {
setInputValue(e.target.value);
};
// 非受控部分:直接读DOM值,避免state延迟
const handleSearch = () => {
// 直接从ref读值,确保拿到最新
const currentValue = inputRef.current.value;
console.log('搜索:', currentValue);
fetchResults(currentValue);
};
// 或者用键盘事件直接操作DOM
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
// e.target.value也是直接读DOM
handleSearch(e.target.value);
}
};
return (
<div>
<input
ref={inputRef}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="搜索..."
/>
<button onClick={handleSearch}>搜索</button>
<ul>
{results.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
</div>
);
}
这种写法看起来有点别扭,但在某些性能敏感的场景很有用。
技巧四:配合Yup做schema验证
手写验证逻辑太烦,用Yup这种schema库:
import * as yup from 'yup';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
const schema = yup.object({
username: yup.string().required('用户名必填').min(3, '至少3个字符'),
email: yup.string().email('邮箱格式不对').required('邮箱必填'),
age: yup.number().positive('必须是正数').integer('必须是整数').required('年龄必填'),
website: yup.string().url('网址格式错误').nullable(),
createdOn: yup.date().default(() => new Date())
}).required();
function ValidatedForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: yupResolver(schema)
});
const onSubmit = (data) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('username')} />
<p>{errors.username?.message}</p>
<input {...register('email')} />
<p>{errors.email?.message}</p>
<input type="number" {...register('age')} />
<p>{errors.age?.message}</p>
<button type="submit">提交</button>
</form>
);
}
Yup的API很直观,链式调用写验证规则,比手写if-else优雅多了。而且错误信息也能统一配置。
最后叨叨几句
写表单这事儿,真别死磕一种写法。我见过有人为了"纯受控"的洁癖,强行用state管理文件上传,结果各种bug;也见过图省事全用非受控,后来需求加个实时验证就傻眼了。
记住几个原则:
- 要实时验证、字段联动、动态UI,用受控
- 简单表单、文件上传、集成第三方库,用非受控
- 字段超过10个,考虑react-hook-form这种库
- 性能卡了,上debounce或者memo
- 代码复用多,抽自定义hook
小项目怎么快怎么来,大项目还是规范点。毕竟代码是写给人看的,顺便给机器执行。你写得爽,维护的人也爽,这才是真的好。
实在记不住就记住一句话:需要实时知道用户输入了啥,就用受控;只需要最后提交时知道,就用非受控。
下班前把表单搞定,准时跑路才是王道。毕竟React不会因为你多写了几行受控组件就给你发奖金,但产品经理会因为表单bug让你加班改。

更多推荐



所有评论(0)