别让用户猜哪里填错了:vibe 项目的表单验证与提交实战
每个 vibe 项目都有一堆表单,AI 生成的只会堆 required——模糊的文案、异步竞态、重复提交、丢失的草稿才是用户流失的地方。本文给一人团队完整的表单实战:校验三层分工、错误文案改写、防抖防竞态的异步校验、idle/submitting/success/error 提交状态机与防重提交三保险、文件型表单、多步表单草稿、无障碍,以及上线前 12 项检查表。

划界:本篇讲的是通用表单的方法论,与站内三篇明确分工:auth-implementation-vibe-projects-guide(鉴权落地实战)里的登录/注册表单只是表单的一种,本篇讲的是"所有表单通用的验证与提交模式"——登录表单是本篇模式的一个实例;vibe-project-user-feedback-loop-guide(用户反馈闭环)第 3 节的"30 秒反馈表单"是"反馈场景的表单设计",本篇是"表单本身的工程实战"——场景 vs 机制;vibe-project-file-upload-guide(文件上传)讲的是"文件怎么传",本篇第 5 节讲的是"带文件的表单整体怎么做",上传链路直接复用它。
注册、结算、设置、工单——每个 vibe 项目都有一堆表单。AI 生成的表单长一个样:每个 input 后面跟一个 required,提交时浏览器弹一句"请填写此字段",完事。然后真实用户来了:手机号输了 10 位,报错只说"无效输入",他不知道错在哪;用户名输完发现已被占用,是在点了提交之后;网络卡了一下连点三次提交,生成了三笔订单;切到下一步再回来,前面填的全没了。表单是 vibe 项目里"离钱最近"的组件——注册流掉一个人就是少一个用户,结算页多一次报错就是少一笔订单。本文讲一人团队今天就能照着做的表单实战:校验分几层、错误文案怎么写、异步校验怎么防抖防竞态、提交状态机怎么画、文件和多步表单怎么处理、无障碍怎么做、上线前查什么。
一、校验三层:即时、失焦、提交前,各管各的
AI 最爱生成的写法是"全用 onChange 校验":用户每敲一个字母就全量跑一遍校验,刚输完"1"就报"手机号应为 11 位"。这种体验叫"审判式表单"——用户还没写完,判决先下来了。正确的分工是三层,各管各的:
- 第 1 层:输入时(onChange)——只做"格式引导",不判错。比如手机号输入框,输入时只做一件事:自动过滤非数字、按 3-4-4 分组显示。这一层不显示红色错误,它的目标是"让用户不容易输错",而不是"指出用户输错了"。
- 第 2 层:失焦时(onBlur)——判"这个字段"的错。用户离开字段,说明他自认为填完了,这时给反馈最合适。错误信息出现在字段正下方,红色,但语气是帮助不是审判。
- 第 3 层:提交前——全量校验,一次性告诉用户所有问题。这是最后一道门:跑全部规则,拦下所有不合格的提交,并把焦点送到第一个错误字段(第 7 节)。
三层分工表:
- onChange:格式化输入(数字分组、大小写转换、去空格)→ 不报错
- onBlur:单字段校验(格式、长度、必填)→ 字段下报错
- onSubmit:全量校验(含跨字段规则,如"确认密码一致")→ 顶部汇总 + 字段下报错 + 焦点跳转
用 React Hook Form 落地时,对应的是 mode 配置:mode: "onBlur" 让失焦校验成为默认,提交时 handleSubmit 自动跑全量。不要设 mode: "onChange" 全局开启——那就是 AI 默认给你挖的坑。截至 2026-10-16 亲核的公开资料:2026 年 9 月初的 npm latest 记录为 react-hook-form 7.87.0、@hookform/resolvers 5.9.1、zod 4.5.4,RHF 保持 v7 大版本、zod 已到 v4;是否用 zod 做 schema,以你项目的实际依赖为准,具体版本号以 npm 为准。
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
// schema 即文档:字段规则一处定义,前后端可复用同一份
const schema = z.object({
phone: z.string().regex(/^1\d{10}$/, "手机号应为 11 位数字,以 1 开头"),
password: z.string().min(8, "密码至少 8 位"),
confirm: z.string(),
}).refine((d) => d.password === d.confirm, {
message: "两次输入的密码不一致",
path: ["confirm"], // 错误挂到 confirm 字段下
});
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema),
mode: "onBlur", // 失焦校验为默认,提交时自动全量
});
二、错误文案写作:从"无效输入"到"告诉我怎么改"
错误文案只有一条金标准:用户看完知道"改什么、改成什么样"。"无效输入""格式错误""校验失败"这三句是 AI 的口头禅,全部不及格。改写对照:
- ❌ "无效输入" → ✅ "手机号应为 11 位数字,你输了 10 位"
- ❌ "格式错误" → ✅ "邮箱格式不对,检查一下 @ 后面是不是少了域名"
- ❌ "密码太弱" → ✅ "密码至少 8 位,再加 1 个数字试试"
- ❌ "提交失败" → ✅ "保存失败:网络超时,你填的内容还在,再点一次提交"
写文案的三条规则:
- 说人话,不说术语。"正则校验未通过"是写给开发者的,"应为 11 位数字"是写给用户的。文案评审时找个非技术朋友读一遍,他皱眉就重写。
- 给"改成什么样"。光说"不对"不够,要说"对的是什么样"。最好附上例子:"如 13812345678"。
- 错误出现的位置:字段下为主,顶部汇总为辅。单字段错误紧贴字段下方(用户视线不用跳);提交时多个字段都有错,顶部再加一条汇总条"有 3 处需要修改",点击可跳到对应字段。只在顶部报错、让用户自己去找是哪个字段——这是最常见的反模式。
文案全部收敛到 schema 的 message 里(见第 1 节代码),不要散落在组件的 JSX 中——以后改一句文案只改一处,也方便统一做多语言。
三、异步校验实战:唯一性检查的防抖与竞态
用户名、邮箱、邀请码这类"是否可用"的校验必须问后端。AI 生成的版本通常是 onChange 里直接 fetch,三个坑一次踩全:请求打爆后端、后发的请求先回来(竞态)、用户不知道"正在检查"。
import { useEffect, useRef, useState } from "react";
function useUniqueCheck(value: string, check: (v: string) => Promise<boolean>) {
const [state, setState] = useState<"idle" | "checking" | "ok" | "taken">("idle");
const seq = useRef(0); // 竞态守卫:只认最新一次请求的结果
useEffect(() => {
if (!value) { setState("idle"); return; }
setState("checking");
const mySeq = ++seq.current;
const timer = setTimeout(async () => {
const available = await check(value);
if (seq.current === mySeq) { // 后发先至:过期响应直接丢弃
setState(available ? "ok" : "taken");
}
}, 400); // 防抖 400ms:用户停手才发请求
return () => clearTimeout(timer);
}, [value]);
return state;
}
// UI 上:checking 显示 spinner,taken 在字段下报"该用户名已被占用,试试 xxx_2026"
三条实战规则:
- 防抖 300–500ms 再发请求。用户每敲一个字母就查一次,是对后端的 DDoS。400ms 是经验值:停手即查,不拖沓。
- 竞态必须处理。网络抖动时"先发的请求后回来"极其常见。用递增序号(或 AbortController 取消旧请求)保证"只认最后一次输入的结果",否则用户会看到"可用"和"被占用"来回横跳。
- pending 状态要可见。检查中显示小 spinner;更重要的是,提交按钮在异步校验 pending 时必须禁用——否则用户可能在"用户名是否可用"还没查完时就提交了。
四、提交状态机:idle → submitting → success / error
这是全文最重要的一张图。表单提交不是"调一下接口",而是一个状态机:
网页表单截图:字段下方显示内联验证错误信息
四个状态各管一摊:
- idle:按钮文案"立即提交",可点击。这是表单的常态。
- submitting:按钮禁用,文案变成"提交中…"。禁用是防重提交的第一道门——95% 的连点在这里被挡住。
- success:成功态。跳转、toast、清空表单三选一,不要停留在 submitting。
- error:错误态。按钮恢复可点,文案变回"重新提交";关键动作:自动聚焦第一个错误字段(第 7 节),并在顶部给出一句话摘要。
防重提交要三保险,缺一不可(见上图底部):
const inFlight = useRef(false); // 第②保险:函数级守卫
async function onSubmit(data: FormData) {
if (inFlight.current) return; // 回车连击、程序化重复调用在这里被挡
inFlight.current = true;
try {
await fetch("/api/orders", {
method: "POST",
headers: {
"Content-Type": "application/json",
// 第③保险:幂等键——网络重试、双 tab 打开同一表单时,后端认键去重
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify(data),
});
// → success
} catch {
// → error:文案写清楚"内容还在,再点一次"
} finally {
inFlight.current = false;
}
}
第③条是很多人漏掉的:按钮 disabled 挡得住连点,挡不住"用户开了两个 tab 同时提交"和"弱网下浏览器自动重试"。幂等键让后端对同一个键只处理一次,是防重提交的真正解法——代价是后端要存键→结果的映射,建议从订单、支付类接口先做起。
五、文件型表单:头像与附件的表单内联处理
带文件的表单(头像上传、工单附件、实名认证材料)多了一个维度:文件本身要先过检,再进表单。规则是前端预检 + 复用上传链路:
- 大小、类型、数量在前端先拦。用户选了个 200MB 的视频,不要等到上传 3 分钟后才告诉他"太大了"。input 的
accept属性 + JS 二次检查(file.size、file.type),秒级反馈。 - 上传链路直接复用
vibe-project-file-upload-guide的方案(直传对象存储、分片、断点续传),本篇不重复讲。本篇只讲表单侧:上传进度要显示在字段内(进度条),上传完成前提交按钮禁用——否则用户可能提交了一个"文件还没传完"的表单。 - 失败可重试、单文件可删。某个附件上传失败,只重试那一个,不重置整个表单;已选文件列表支持单独删除。这是文件型表单和普通字段最大的区别:它的"值"是有生命周期的。
// 伪代码:文件字段的状态
type FileFieldState =
| { status: "empty" }
| { status: "checking" } // 前端预检中(大小/类型)
| { status: "uploading"; progress: number }
| { status: "done"; url: string } // 上传完成,url 进表单数据
| { status: "error"; message: string }; // 可单独重试
// 提交守卫:所有文件字段 status === "done" 才允许提交
六、多步表单:分步校验与草稿自动保存
注册、入驻、问卷这类长表单要拆成多步。每一步是一个独立的子表单,规则如下:
- 分步校验:点"下一步"时只校验当前步的字段,不校验还没填的后面的步骤。用户在第 1 步就被第 3 步的错误拦住,是最劝退的设计。用 schema 的
pick拆分子 schema,每步一个。 - 草稿自动保存:每步填完写一份
localStorage(key 带表单 id 和版本号)。用户刷新、误关、手机切出去回不来——回来时问一句"检测到未完成的草稿,是否继续?",转化率能差出一截。注意隐私:含身份证、银行卡的表单,草稿要加密或只存非敏感字段,并在提交成功后清除。 - 返回上一步数据不丢:步骤状态(当前第几步 + 各步数据)放在表单级 state,不要卸载步骤组件就丢数据。最简单的实现:所有步骤的字段注册在同一个
useForm里,步骤切换只切换"显示哪一组",数据天然保留。 - 进度指示器:顶部明确"第 2 步 / 共 4 步",让用户有掌控感。允许点已完成的步骤回跳修改,回跳不清空数据。
// 步骤数据天然保留:一个 useForm 管所有步骤,切换只换显示
const methods = useForm({ resolver: zodResolver(fullSchema), mode: "onBlur" });
const [step, setStep] = useState(0);
const stepFields = [["name", "phone"], ["company", "role"], ["bio"]][step];
async function next() {
const ok = await methods.trigger(stepFields[step] as any); // 只校验当前步
if (ok) {
saveDraft(methods.getValues()); // 过步即存草稿
setStep((s) => s + 1);
}
// else:错误已在字段下显示,trigger 会自动聚焦第一个错误字段
}
七、无障碍:label、错误关联与焦点管理
表单是无障碍的重灾区,也是最容易拿分的——读屏用户填不了表单等于产品对他关闭。 checklist 很短,逐项落实:
- 每个输入都有 label,且用
htmlFor/id显式关联。placeholder 不是 label——用户一打字它就消失了,读屏软件也读不到稳定的名称。 - 错误信息用
aria-describedby指向。<input aria-describedby="phone-error">+<p id="phone-error">,这样读屏用户聚焦到字段时能同时听到错误内容,而不是只听到"无效"。 - 用
aria-invalid="true"标记出错字段,配合视觉上的红色边框(不要只用颜色:色弱用户需要图标或文字辅助)。 - 提交失败自动聚焦第一个错误字段。这是第 4 节状态机的 error 态的关键动作:一行代码
setFocus(firstErrorField)(RHF 自带),键盘用户和读屏用户立刻知道从哪改起。视觉上再滚动到该字段。 - 必填用
required属性 + 文字"必填"标注,不要只用红色星号——读屏软件对纯符号的播报不可靠。
八、上线检查表:12 项
- 1. 三层校验分工正确:onChange 只做格式化不报错;onBlur 单字段报错;提交前全量。抽查三个字段走一遍。
- 2. 错误文案评审:找非技术人员读一遍所有报错,他能说出"该怎么改"才算过。
- 3. 异步校验三件套:防抖、竞态处理、pending 时禁用提交——逐项验证。
- 4. 防重提交三保险:按钮 disabled、inFlight 守卫、幂等键——连点 10 次只产生一笔。
- 5. 弱网测试:Network 面板开 Slow 3G 走一遍提交,按钮文案、禁用、超时报错都要对。
- 6. 文件型表单:超大文件秒级拦截、上传中禁用提交、单文件可删可重试。
- 7. 多步表单:刷新页面草稿可恢复、回跳不丢数据、提交成功清草稿。
- 8. 无障碍五项:label 关联、aria-describedby、aria-invalid、提交失败聚焦、必填文字标注。
- 9. 移动端数字键盘:手机号、验证码、金额用
type="tel"或inputMode="numeric"调起数字键盘;不要用type="number"(它带出的是带加减 step 的键盘,且行为各浏览器不一致)。 - 10. 自动填充:姓名、邮箱、电话、地址加上正确的
autocomplete属性(name、email、tel……),移动端用户少打一半字。 - 11. 密码管理器兼容:登录/注册表单用标准
type="password"+autocomplete="current-password|new-password",1Password/系统钥匙串能正常填充——自定义"显示密码"眼睛图标不要破坏这个。 - 12. 错误上报:提交失败、异步校验异常打点上报,每周看一次 Top 错误字段——用户反复错的字段,就是文案或交互要改的地方。
表单优化没有终点,但有起点:先把第 4 节的状态机和第 1 节的三层分工落地,你的表单就已经超过 80% 的 vibe 项目了。剩下的,是每周看一次错误上报,让真实用户的报错替你排优化优先级。
评论 (0)
相关文章

AI 半小时就能交出一个 Cmd+K 面板,但匹配排序、快捷键冲突、输入法闪烁、移动端降级才是它翻车的地方。本文给一人团队一条完整路线:命令注册表数据模型、五步模糊匹配评分管线、带冲突检测的全局快捷键体系、无障碍键盘闭环、移动端入口降级、与权限联动的命令,以及上线前 10 项检查表。

10 月 14 日,Simon Willison 在 iPhone Claude app 的 chat 模式里给 Claude Opus 5.5 Max 发了一个 prompt;3 小时 7 分钟单次推理后,交出来一款带音乐、动画、成就系统的可玩 cozy 找不同游戏。他的评价:告诉模型“保证好玩”,出奇地管用。

AI 能糊出像文档的页面,但信息架构、版本化、搜索、与代码同步才是文档站能用的关键。本文给一人团队完整路线:README/帮助中心/文档站决策表、亲核过的四家工具对照(Fumadocs/Starlight/Docusaurus/Mintlify)、四块内容加两条读者路径的信息架构、快照式版本化、DocSearch 与本地搜索、OpenAPI 驱动的 API 参考、有帮助吗反馈闭环,以及上线前 10 项检查表。