表单与数据交互 —— 打造生产级交互体验
表单是 Web 应用中最古老也最核心的交互方式——从用户注册、搜索框到后台管理系统的数百个筛选项,几乎所有”用户输入”的场景都离不开表单。但在 React 中,表单的处理方式和传统 HTML 有一个根本性的区别:React 用 state 驱动 UI,而 HTML 表单元素天然有自己的内部状态。这两套状态系统的”主权之争”,催生了受控组件和非受控组件两种模式。更进一步,当表单提交后,数据需要通过网络请求发往服务器,“加载中、成功、失败”三种状态的处理又成了新的挑战。本章将从最基础的
<input>开始,一路走到完整的”注册表单 + 数据提交 + 反馈”实战,帮你建立起处理表单和数据交互的完整心智模型。
📋 开篇自测:你已经知道多少?
- 你能说清楚”受控组件”和”非受控组件”的核心区别吗?在什么场景下你会选择非受控?
- 当一个表单有 10 个字段、5 条校验规则,你会怎么组织状态和校验逻辑,而不是写一堆零散的 useState?
- 用 useEffect 发请求时,你知道为什么必须处理”竞态条件”吗?不处理会出什么 bug?
一、受控表单 —— 用 state 完全掌控表单数据
1.1 谁来当”老板”?
在传统 HTML 中,<input> 自己管理值——用户输入什么,它就显示什么,你通过 document.getElementById('myInput').value 去”偷看”它的值。这就像一个员工自己决定做什么,老板只能事后查看结果。
在 React 的受控模式中,情况完全反转:state 才是数据的唯一来源,<input> 只是一个”显示器”。用户的每次输入会触发 onChange,你在 onChange 中更新 state,state 变化后 React 重新渲染,input 显示的值才会更新。整个流程形成一个闭环:
用户输入 → onChange 触发 → setState 更新 → React 重渲染 → input 显示新值
这就像老板收到邮件后才决定是否批准、如何修改,最终由老板决定屏幕上显示什么。
1.2 最基本的受控 input
import { useState } from 'react';
function LoginForm() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e) => {
e.preventDefault(); // 阻止浏览器默认的表单提交行为
console.log('提交的数据:', { username, password });
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>用户名:</label>
<input
type="text"
value={username} // state 决定显示什么
onChange={(e) => setUsername(e.target.value)} // 用户输入更新 state
/>
</div>
<div>
<label>密码:</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<button type="submit">登录</button>
</form>
);
}
关键点:value={username} 是受控模式的标志。如果你只写了 value 却没有 onChange,React 会在控制台报警告,而且 input 会变成”只读”的——因为 state 没有更新机制,值永远不会变。
1.3 受控模式的超能力
受控模式的价值不在于”能拿到值”——非受控也能拿到值。它的真正价值在于你可以在用户输入和显示之间插入任意逻辑:
function PhoneInput() {
const [phone, setPhone] = useState('');
const handleChange = (e) => {
// 只允许输入数字,最多 11 位
const value = e.target.value.replace(/\D/g, '').slice(0, 11);
setPhone(value);
};
// 格式化显示:138-0000-0000
// 注意:此正则在输入不足 3 位时无法匹配,生产环境需要更健壮的处理
const displayValue = phone.replace(/(\d{3})(\d{0,4})(\d{0,4})/, (_, a, b, c) => {
let result = a;
if (b) result += '-' + b;
if (c) result += '-' + c;
return result;
});
return <input value={displayValue} onChange={handleChange} placeholder="请输入手机号" />;
}
用户随便输入什么字符,你都可以过滤、格式化、限制长度。这在非受控模式下几乎无法实现——你没有拦截输入的时机。
🤔 想一想 当你在一个搜索框中需要做”防抖搜索”(用户停止输入 300ms 后才发请求),这个场景更适合受控还是非受控模式?为什么?
二、非受控表单 —— 用 useRef 直接读取 DOM
2.1 有时候,当甩手掌柜更好
受控模式虽然强大,但也有代价:每次用户按下一个键,都会触发一次 setState 和重渲染。对于简单的表单,这完全没问题。但如果一个页面有几十个表单字段,每个字段的每次输入都触发整个表单重渲染,性能可能成为问题。
更重要的是,很多时候你根本不需要”实时掌控”——你只需要在用户点击”提交”按钮时拿到最终的值就够了。这时候,非受控模式更简洁。
2.2 用 useRef 读取 DOM 值
import { useRef } from 'react';
function SimpleForm() {
const nameRef = useRef(null);
const emailRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
// 提交时才去读取值
const data = {
name: nameRef.current.value,
email: emailRef.current.value,
};
console.log('提交的数据:', data);
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>姓名:</label>
<input ref={nameRef} defaultValue="张三" /> {/* 注意是 defaultValue,不是 value */}
</div>
<div>
<label>邮箱:</label>
<input ref={emailRef} type="email" />
</div>
<button type="submit">提交</button>
</form>
);
}
注意两个关键区别:
- 用
defaultValue而不是value。defaultValue只设置初始值,之后由 DOM 自己管理。 - 不需要
onChange,也不需要 state。用户输入时组件不会重渲染。
2.3 还有一种更原生的方式:FormData
其实浏览器自带一个非常好用的 API——FormData,它可以自动收集表单内所有带 name 属性的字段:
function NativeForm() {
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const data = Object.fromEntries(formData);
console.log('表单数据:', data);
// 输出:{ username: '...', email: '...', role: '...' }
};
return (
<form onSubmit={handleSubmit}>
<input name="username" placeholder="用户名" />
<input name="email" type="email" placeholder="邮箱" />
<select name="role">
<option value="user">普通用户</option>
<option value="admin">管理员</option>
</select>
<button type="submit">提交</button>
</form>
);
}
不需要 useState,不需要 useRef,不需要 onChange。对于简单的”填完就交”的表单,这是最简洁的方式。
2.4 受控 vs 非受控:如何选择?
| 场景 | 推荐模式 | 理由 |
|---|---|---|
| 需要实时校验(输入时立即提示错误) | 受控 | 需要实时获取值来运行校验逻辑 |
| 需要格式化输入(手机号、金额) | 受控 | 需要拦截并修改用户输入 |
| 需要联动(A 字段的值影响 B 字段的选项) | 受控 | 需要在 state 变化时重新计算 |
| 简单的登录/搜索表单 | 非受控 | 提交时拿值即可,不需要过程控制 |
文件上传(<input type="file">) | 非受控 | 文件 input 无法被受控,因为它的值是只读的 |
经验法则: 如果你不确定选哪个,先用受控模式。受控模式覆盖面更广,后续要加实时校验、格式化等功能时不需要重构。
三、表单验证 —— 实时校验、提交校验、错误提示
3.1 表单验证的三个时机
一个好的表单验证体验,需要在三个时机触发校验:
- 实时校验(onChange): 用户输入时立即反馈。适合格式类校验,比如”邮箱格式不正确”。
- 失焦校验(onBlur): 用户离开输入框时校验。比实时校验更温和,不会在用户还没输完就报错。
- 提交校验(onSubmit): 点击提交按钮时做全量校验。这是最后一道防线。
最佳实践是组合使用——首次输入时不急着报错(用失焦校验),一旦报过错就切换为实时校验,这样既不打扰用户,又能在用户修改时给出即时反馈。
3.2 从零实现表单验证
import { useState } from 'react';
function ValidatedForm() {
const [values, setValues] = useState({ email: '', password: '' });
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({}); // 记录哪些字段被"触碰"过
// 校验规则
const validate = (name, value) => {
switch (name) {
case 'email':
if (!value) return '邮箱不能为空';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return '邮箱格式不正确';
return '';
case 'password':
if (!value) return '密码不能为空';
if (value.length < 8) return '密码至少 8 位';
if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(value)) return '密码需包含大小写字母和数字';
return '';
default:
return '';
}
};
// 统一的 onChange 处理
const handleChange = (e) => {
const { name, value } = e.target;
setValues((prev) => ({ ...prev, [name]: value }));
// 如果字段已经被触碰过(报过错),则实时校验
if (touched[name]) {
setErrors((prev) => ({ ...prev, [name]: validate(name, value) }));
}
};
// 失焦时校验
const handleBlur = (e) => {
const { name, value } = e.target;
setTouched((prev) => ({ ...prev, [name]: true }));
setErrors((prev) => ({ ...prev, [name]: validate(name, value) }));
};
// 提交时全量校验
const handleSubmit = (e) => {
e.preventDefault();
const newErrors = {};
Object.keys(values).forEach((key) => {
newErrors[key] = validate(key, values[key]);
});
setErrors(newErrors);
setTouched({ email: true, password: true });
const hasError = Object.values(newErrors).some((msg) => msg !== '');
if (!hasError) {
console.log('校验通过,提交数据:', values);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>邮箱:</label>
<input
name="email"
value={values.email}
onChange={handleChange}
onBlur={handleBlur}
style={{ borderColor: errors.email ? 'red' : '#ccc' }}
/>
{errors.email && <p style={{ color: 'red', fontSize: 12 }}>{errors.email}</p>}
</div>
<div>
<label>密码:</label>
<input
name="password"
type="password"
value={values.password}
onChange={handleChange}
onBlur={handleBlur}
style={{ borderColor: errors.password ? 'red' : '#ccc' }}
/>
{errors.password && <p style={{ color: 'red', fontSize: 12 }}>{errors.password}</p>}
</div>
<button type="submit">提交</button>
</form>
);
}
这里有三个状态在协同工作:values 保存表单值,errors 保存每个字段的错误信息,touched 记录哪些字段被用户操作过。三者配合实现了”首次友好、后续严格”的校验体验。
四、复杂表单状态管理 —— useReducer 管理多字段表单
4.1 当 useState 开始失控
上一节的表单只有两个字段,我们就需要三个 useState(values、errors、touched)。想象一下,如果表单有 10 个字段,还需要处理加载状态、提交状态、服务端返回的错误——用一堆独立的 useState 管理,代码会像一盘散沙,改一个功能要改五六个地方。
这时候该让 useReducer 登场了。如果你在第五章已经学过 useReducer,你会知道它的核心思想是:把所有状态修改逻辑集中到一个 reducer 函数里,组件只需要”发命令”(dispatch action)。
4.2 用 useReducer 重构表单
import { useReducer } from 'react';
// 定义初始状态
const initialState = {
values: { username: '', email: '', password: '', confirmPassword: '' },
errors: {},
touched: {},
isSubmitting: false,
};
// 所有状态修改逻辑集中在此
function formReducer(state, action) {
switch (action.type) {
case 'FIELD_CHANGE':
return {
...state,
values: { ...state.values, [action.field]: action.value },
// 如果该字段被触碰过,实时清除错误
errors: state.touched[action.field]
? { ...state.errors, [action.field]: '' }
: state.errors,
};
case 'FIELD_BLUR':
return {
...state,
touched: { ...state.touched, [action.field]: true },
errors: { ...state.errors, [action.field]: action.error },
};
case 'SET_ERRORS':
return {
...state,
errors: action.errors,
touched: Object.keys(state.values).reduce(
(acc, key) => ({ ...acc, [key]: true }),
{}
),
};
case 'SUBMIT_START':
return { ...state, isSubmitting: true };
case 'SUBMIT_END':
return { ...state, isSubmitting: false };
case 'RESET':
return initialState;
default:
return state;
}
}
function RegistrationForm() {
const [state, dispatch] = useReducer(formReducer, initialState);
const { values, errors, isSubmitting } = state;
const validateField = (name, value) => {
switch (name) {
case 'username':
if (!value) return '用户名不能为空';
if (value.length < 3) return '用户名至少 3 个字符';
return '';
case 'email':
if (!value) return '邮箱不能为空';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return '邮箱格式不正确';
return '';
case 'password':
if (!value) return '密码不能为空';
if (value.length < 8) return '密码至少 8 位';
return '';
case 'confirmPassword':
if (value !== values.password) return '两次密码不一致';
return '';
default:
return '';
}
};
const handleChange = (e) => {
dispatch({ type: 'FIELD_CHANGE', field: e.target.name, value: e.target.value });
};
const handleBlur = (e) => {
const { name, value } = e.target;
const error = validateField(name, value);
dispatch({ type: 'FIELD_BLUR', field: name, error });
};
const handleSubmit = async (e) => {
e.preventDefault();
// 全量校验
const newErrors = {};
Object.entries(values).forEach(([key, value]) => {
newErrors[key] = validateField(key, value);
});
const hasError = Object.values(newErrors).some(Boolean);
if (hasError) {
dispatch({ type: 'SET_ERRORS', errors: newErrors });
return;
}
dispatch({ type: 'SUBMIT_START' });
try {
// 模拟 API 请求
await new Promise((resolve) => setTimeout(resolve, 1500));
console.log('注册成功:', values);
dispatch({ type: 'RESET' });
} catch (err) {
console.error('注册失败');
} finally {
dispatch({ type: 'SUBMIT_END' });
}
};
return (
<form onSubmit={handleSubmit}>
{['username', 'email', 'password', 'confirmPassword'].map((field) => (
<div key={field} style={{ marginBottom: 12 }}>
<label>{field}:</label>
<input
name={field}
type={field.includes('password') || field.includes('Password') ? 'password' : 'text'}
value={values[field]}
onChange={handleChange}
onBlur={handleBlur}
/>
{errors[field] && <p style={{ color: 'red', fontSize: 12 }}>{errors[field]}</p>}
</div>
))}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '提交中...' : '注册'}
</button>
</form>
);
}
对比之前的 useState 版本,useReducer 版本的优势显而易见:所有状态变更逻辑都在 formReducer 中,组件里只有 dispatch 调用。当你需要新增一个”重置某个字段”的功能时,只需在 reducer 里加一个 case,组件代码几乎不用改。
🤔 想一想 useReducer 的 reducer 函数是一个纯函数——给定相同的 state 和 action,永远返回相同的新 state。这意味着它可以被单独测试,不依赖 React 组件。试着想象你会怎么给这个 formReducer 写单元测试?
五、数据请求 —— fetch/axios + useEffect 加载数据
5.1 React 中发请求的基本模式
表单填完提交,数据要发到服务器;页面加载时,也需要从服务器拉取数据。在 React 中,最常见的做法是 useEffect + fetch:
import { useState, useEffect } from 'react';
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/users')
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => {
setUsers(data);
setLoading(false);
})
.catch((err) => {
setError(err.message);
setLoading(false);
});
}, []); // 空依赖数组 = 只在组件挂载时执行一次
if (loading) return <p>加载中...</p>;
if (error) return <p>出错了:{error}</p>;
if (users.length === 0) return <p>暂无数据</p>;
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name} - {user.email}</li>
))}
</ul>
);
}
5.2 必须处理的竞态条件
上面的代码有一个隐藏的 bug。假设这个组件接收一个 userId prop,每次 userId 变化就重新请求:
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]);
问题来了:如果用户快速切换——先请求 userId=1,再请求 userId=2。如果请求 2 先返回、请求 1 后返回,setUser 会被请求 1 的结果覆盖,页面显示的是 userId=1 的数据,但用户期望看到的是 userId=2。
这就是竞态条件(Race Condition)。解决方式是用一个标志位忽略过时的响应:
useEffect(() => {
let isCancelled = false; // 标志位
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
if (!isCancelled) { // 只有最新的请求才更新状态
setUser(data);
}
});
return () => {
isCancelled = true; // 组件卸载或 userId 变化时,标记为"已取消"
};
}, [userId]);
useEffect 的清理函数(return 部分)会在下一次 effect 执行前和组件卸载时被调用。当 userId 从 1 变为 2 时,清理函数先把 isCancelled 设为 true,这样即使请求 1 后来返回了,也不会更新状态。
5.3 用 AbortController 做更彻底的取消
标志位方案虽然能防止”过时数据”污染状态,但网络请求本身仍在进行。如果你想真正取消网络请求(节省带宽和服务端资源),可以使用浏览器原生的 AbortController:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then((res) => res.json())
.then((data) => setUser(data))
.catch((err) => {
if (err.name !== 'AbortError') {
setError(err.message); // 只处理非取消的错误
}
});
return () => controller.abort(); // 真正取消网络请求
}, [userId]);
5.4 封装自定义 Hook:useFetch
请求逻辑在多个组件中都会用到,抽成自定义 Hook 是最佳实践:
import { useState, useEffect } from 'react';
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => {
setData(data);
setLoading(false);
})
.catch((err) => {
if (err.name !== 'AbortError') {
setError(err.message);
setLoading(false);
}
});
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
// 使用
function UserProfile({ userId }) {
const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
if (loading) return <p>加载中...</p>;
if (error) return <p>出错了:{error}</p>;
return <h2>{user.name}</h2>;
}
🤔 想一想 useFetch 目前只处理 GET 请求。如果你要支持 POST 请求(比如提交表单),你会怎么扩展这个 Hook 的 API 设计?是加一个
method参数,还是干脆拆分成useFetch和useMutation两个 Hook?
六、加载与错误状态处理 —— Loading、Error、Empty 三态模式
6.1 每个异步操作都有三种结局
任何与服务器交互的操作,都会经历三种状态:
- Loading(加载中): 请求已发出,等待响应
- Error(出错): 请求失败,需要告诉用户发生了什么
- Success(成功): 数据到手,又分为”有数据”和”空数据”两种情况
这就构成了经典的三态模式——在 UI 上,你需要为这三种状态分别设计展示方案。忽略任何一种都会带来糟糕的用户体验:没有 Loading 状态,用户会以为页面卡死了;没有 Error 状态,用户看到一片空白不知道发生了什么;没有 Empty 状态,用户不知道是”加载中”还是”真的没数据”。
6.2 封装通用的异步状态容器
与其每次手动写三个 if,不如封装一个通用组件:
function AsyncView({ loading, error, data, onRetry, children }) {
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 40 }}>
<p>加载中...</p>
</div>
);
}
if (error) {
return (
<div style={{ textAlign: 'center', padding: 40, color: 'red' }}>
<p>出错了:{error}</p>
{onRetry && <button onClick={onRetry}>重试</button>}
</div>
);
}
// data 为空的情况
if (!data || (Array.isArray(data) && data.length === 0)) {
return (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
<p>暂无数据</p>
</div>
);
}
// 有数据,渲染 children
return children(data);
}
使用时代码非常清爽:
function UserListPage() {
const { data, loading, error } = useFetch('/api/users');
const handleRetry = () => window.location.reload();
return (
<AsyncView loading={loading} error={error} data={data} onRetry={handleRetry}>
{(users) => (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</AsyncView>
);
}
这种模式叫 Render Props(第六章讲过)——AsyncView 通过 children 函数把数据”交给”调用者去渲染。它的好处是:三态逻辑只写一次,所有页面复用。
6.3 表单提交的状态处理
不只是数据加载,表单提交同样需要状态管理。一个生产级的提交按钮至少需要处理这些状态:
function SubmitButton({ isSubmitting, isSuccess }) {
if (isSuccess) {
return <button disabled style={{ background: '#52c41a', color: '#fff' }}>提交成功 ✓</button>;
}
return (
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '提交中...' : '提交'}
</button>
);
}
防重复提交是另一个必须处理的问题。用户双击按钮或者网络慢时连续点击,可能导致重复提交。解决方式很简单——提交时通过 isSubmitting 禁用按钮,请求结束后恢复。
七、React 19 新特性预览 —— use() hook 与 Server Actions
7.1 use() —— 在组件中直接”等待” Promise
React 19 引入了 use() hook,它可以在组件渲染过程中直接读取 Promise 的结果。配合 Suspense,数据加载的写法发生了巨大变化:
import { use, Suspense } from 'react';
// 注意:fetchUsers 必须在组件外创建或被缓存
// 不能在组件内每次渲染都创建新的 Promise
const usersPromise = fetch('/api/users').then((res) => res.json());
function UserList() {
const users = use(usersPromise); // 直接"等待"结果,不需要 useState + useEffect
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
function App() {
return (
<Suspense fallback={<p>加载中...</p>}>
<UserList />
</Suspense>
);
}
对比传统的 useState + useEffect 方式,use() 的写法更接近同步代码——你不用再操心 loading 状态和 setData,Suspense 帮你处理了加载中的 UI。
重要限制: use() 接收的 Promise 不能在每次渲染时重新创建。如果你在组件内部写 use(fetch(...)) 会导致无限循环。实际使用中,通常需要搭配框架层面的数据缓存方案(如 Next.js 的数据层)。
7.2 Server Actions —— 表单提交的新范式
React 19 还引入了 useActionState(之前叫 useFormState),可以让表单提交直接关联一个服务端函数:
import { useActionState } from 'react';
// 模拟一个 Server Action(实际需要框架支持,如 Next.js)
async function registerAction(previousState, formData) {
const username = formData.get('username');
const email = formData.get('email');
// 模拟服务端校验
if (username.length < 3) {
return { error: '用户名至少 3 个字符' };
}
// 模拟写入数据库
await new Promise((resolve) => setTimeout(resolve, 1000));
return { success: true, message: `用户 ${username} 注册成功` };
}
function RegisterForm() {
const [state, formAction, isPending] = useActionState(registerAction, null);
return (
<form action={formAction}>
<input name="username" placeholder="用户名" />
<input name="email" type="email" placeholder="邮箱" />
<button type="submit" disabled={isPending}>
{isPending ? '注册中...' : '注册'}
</button>
{state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
{state?.success && <p style={{ color: 'green' }}>{state.message}</p>}
</form>
);
}
注意 <form action={formAction}> 这种写法——action 不再是一个 URL,而是一个函数。React 会自动收集 FormData 并传给这个函数,同时管理 pending 状态。不再需要 e.preventDefault()、不再需要手动管理 isSubmitting。
目前状态: Server Actions 需要搭配支持 RSC(React Server Components)的框架(如 Next.js App Router)才能真正在服务端执行。在纯客户端应用中,useActionState 仍然可以用,但函数会在客户端执行。
八、实战:构建一个完整的用户注册表单
让我们把本章学到的所有知识串联起来,构建一个生产级的注册表单。这个表单需要:多字段输入与校验、密码强度提示、提交时禁用按钮防重复提交、服务端响应后的成功/失败反馈。
8.1 定义表单 Reducer
const initialState = {
values: { username: '', email: '', password: '', confirmPassword: '' },
errors: {},
touched: {},
isSubmitting: false,
submitResult: null, // { type: 'success' | 'error', message: string }
};
function registerReducer(state, action) {
switch (action.type) {
case 'FIELD_CHANGE': {
const newValues = { ...state.values, [action.field]: action.value };
const newErrors = { ...state.errors };
// 已触碰的字段实时校验
if (state.touched[action.field]) {
newErrors[action.field] = validateField(action.field, action.value, newValues);
}
// 密码变化时,如果确认密码已触碰,联动校验
if (action.field === 'password' && state.touched.confirmPassword) {
newErrors.confirmPassword = validateField('confirmPassword', newValues.confirmPassword, newValues);
}
return { ...state, values: newValues, errors: newErrors, submitResult: null };
}
case 'FIELD_BLUR':
return {
...state,
touched: { ...state.touched, [action.field]: true },
errors: { ...state.errors, [action.field]: action.error },
};
case 'VALIDATE_ALL':
return {
...state,
errors: action.errors,
touched: Object.keys(state.values).reduce((acc, k) => ({ ...acc, [k]: true }), {}),
};
case 'SUBMIT_START':
return { ...state, isSubmitting: true, submitResult: null };
case 'SUBMIT_SUCCESS':
return { ...initialState, submitResult: { type: 'success', message: action.message } };
case 'SUBMIT_ERROR':
return { ...state, isSubmitting: false, submitResult: { type: 'error', message: action.message } };
default:
return state;
}
}
8.2 校验逻辑
function validateField(name, value, allValues) {
switch (name) {
case 'username':
if (!value.trim()) return '请输入用户名';
if (value.length < 3 || value.length > 20) return '用户名长度 3~20 个字符';
if (!/^[a-zA-Z0-9_\u4e00-\u9fa5]+$/.test(value)) return '用户名只能包含字母、数字、下划线和中文';
return '';
case 'email':
if (!value.trim()) return '请输入邮箱';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return '请输入有效的邮箱地址';
return '';
case 'password':
if (!value) return '请输入密码';
if (value.length < 8) return '密码至少 8 位';
if (!/(?=.*[a-z])(?=.*[A-Z])/.test(value)) return '密码需包含大小写字母';
if (!/(?=.*\d)/.test(value)) return '密码需包含至少一个数字';
return '';
case 'confirmPassword':
if (!value) return '请再次输入密码';
if (value !== allValues.password) return '两次密码不一致';
return '';
default:
return '';
}
}
// 密码强度计算
function getPasswordStrength(password) {
if (!password) return { level: 0, text: '', color: '#eee' };
let score = 0;
if (password.length >= 8) score++;
if (password.length >= 12) score++;
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++;
if (/\d/.test(password)) score++;
if (/[^a-zA-Z0-9]/.test(password)) score++;
if (score <= 2) return { level: score, text: '弱', color: '#ff4d4f' };
if (score <= 3) return { level: score, text: '中', color: '#faad14' };
return { level: score, text: '强', color: '#52c41a' };
}
8.3 完整表单组件
import { useReducer } from 'react';
function RegisterPage() {
const [state, dispatch] = useReducer(registerReducer, initialState);
const { values, errors, touched, isSubmitting, submitResult } = state;
const handleChange = (e) => {
dispatch({ type: 'FIELD_CHANGE', field: e.target.name, value: e.target.value });
};
const handleBlur = (e) => {
const { name, value } = e.target;
dispatch({
type: 'FIELD_BLUR',
field: name,
error: validateField(name, value, values),
});
};
const handleSubmit = async (e) => {
e.preventDefault();
// 全量校验
const allErrors = {};
Object.entries(values).forEach(([key, value]) => {
allErrors[key] = validateField(key, value, values);
});
if (Object.values(allErrors).some(Boolean)) {
dispatch({ type: 'VALIDATE_ALL', errors: allErrors });
return;
}
dispatch({ type: 'SUBMIT_START' });
try {
const res = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: values.username,
email: values.email,
password: values.password,
}),
});
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.message || '注册失败');
}
dispatch({ type: 'SUBMIT_SUCCESS', message: '注册成功!欢迎加入。' });
} catch (err) {
dispatch({ type: 'SUBMIT_ERROR', message: err.message });
}
};
const strength = getPasswordStrength(values.password);
const fieldConfig = [
{ name: 'username', label: '用户名', type: 'text', placeholder: '3~20 个字符' },
{ name: 'email', label: '邮箱', type: 'email', placeholder: 'your@email.com' },
{ name: 'password', label: '密码', type: 'password', placeholder: '至少 8 位,包含大小写和数字' },
{ name: 'confirmPassword', label: '确认密码', type: 'password', placeholder: '再次输入密码' },
];
return (
<div style={{ maxWidth: 400, margin: '40px auto' }}>
<h1>用户注册</h1>
{submitResult && (
<div style={{
padding: '12px 16px',
marginBottom: 16,
borderRadius: 4,
background: submitResult.type === 'success' ? '#f6ffed' : '#fff2f0',
border: `1px solid ${submitResult.type === 'success' ? '#b7eb8f' : '#ffccc7'}`,
color: submitResult.type === 'success' ? '#52c41a' : '#ff4d4f',
}}>
{submitResult.message}
</div>
)}
<form onSubmit={handleSubmit}>
{fieldConfig.map(({ name, label, type, placeholder }) => (
<div key={name} style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>
{label} <span style={{ color: 'red' }}>*</span>
</label>
<input
name={name}
type={type}
placeholder={placeholder}
value={values[name]}
onChange={handleChange}
onBlur={handleBlur}
disabled={isSubmitting}
style={{
width: '100%',
padding: '8px 12px',
border: `1px solid ${errors[name] ? '#ff4d4f' : '#d9d9d9'}`,
borderRadius: 4,
boxSizing: 'border-box',
}}
/>
{/* 密码强度条 */}
{name === 'password' && values.password && (
<div style={{ marginTop: 4 }}>
<div style={{ display: 'flex', gap: 4 }}>
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} style={{
flex: 1,
height: 4,
borderRadius: 2,
background: i <= strength.level ? strength.color : '#eee',
}} />
))}
</div>
<span style={{ fontSize: 12, color: strength.color }}>
密码强度:{strength.text}
</span>
</div>
)}
{errors[name] && (
<p style={{ color: '#ff4d4f', fontSize: 12, margin: '4px 0 0' }}>
{errors[name]}
</p>
)}
</div>
))}
<button
type="submit"
disabled={isSubmitting}
style={{
width: '100%',
padding: '10px 0',
background: isSubmitting ? '#d9d9d9' : '#1677ff',
color: '#fff',
border: 'none',
borderRadius: 4,
fontSize: 16,
cursor: isSubmitting ? 'not-allowed' : 'pointer',
}}
>
{isSubmitting ? '注册中...' : '立即注册'}
</button>
</form>
</div>
);
}
这个实战案例覆盖了本章的核心知识点:
- 受控表单:每个字段的值由 state 管理
- useReducer:集中管理 values、errors、touched、isSubmitting 四组状态
- 三时机校验:失焦首次校验、已触碰字段实时校验、提交全量校验
- 密码联动:修改密码时,自动重新校验”确认密码”
- 提交状态管理:Loading 禁用按钮、成功重置表单、失败展示错误
- 密码强度可视化:将安全规则转化为用户可感知的进度条
📝 掌握度自测
-
以下哪种写法会导致 input 无法输入?
- A)
<input defaultValue="hello" /> - B)
<input value="hello" /> - C)
<input value={name} onChange={(e) => setName(e.target.value)} /> - D)
<input ref={inputRef} />
- A)
-
用 useEffect 发起网络请求时,清理函数的核心作用是什么?
- A) 清除浏览器缓存
- B) 取消或忽略过时的请求结果,防止竞态条件
- C) 释放组件占用的内存
- D) 重置 useState 的初始值
-
以下哪种表单校验时机的用户体验最好?
- A) 每输入一个字符就报错
- B) 只在提交时校验
- C) 首次失焦时校验,之后实时校验
- D) 随机时机校验
-
useReducer 相比多个 useState 管理表单的主要优势是什么?
- A) 性能更好,渲染次数更少
- B) 状态修改逻辑集中在 reducer 中,可独立测试,扩展方便
- C) 不需要写 switch-case 语句
- D) 可以跨组件共享状态
-
关于 React 19 的 use() hook,以下哪项描述是正确的?
- A) use() 可以替代所有的 useEffect 调用
- B) use() 接收的 Promise 可以在每次渲染时重新创建
- C) use() 需要配合 Suspense 使用,由 Suspense 处理加载状态
- D) use() 只能在服务端组件中使用
💡 自我评估
- 答对 5 题:你已经完全掌握了 React 表单和数据交互的核心模式,可以独立构建生产级的表单系统。
- 答对 3-4 题:基础扎实,建议重点回顾竞态条件的处理和 useReducer 管理复杂表单的实践,这是面试和实战的高频考点。
- 答对 0-2 题:建议动手实现文末的注册表单实战,边写边对照每一节的知识点,实践是最好的老师。
参考答案: 1-B, 2-B, 3-C, 4-B, 5-C
购买课程解锁全部内容
从组件到架构:12 章系统掌握现代 React
¥29.90