Skip to content

表单设计与校验 ​

表单是用户最容易放弃的地方。让人想关掉页面的往往是这些细节:

  • 还没输完就满屏飘红;
  • 用中文输入法打拼音时,搜索框就开始拿拼音字母去搜索;
  • 在 iPhone 上一点输入框,整个页面就被放大了;
  • 网络慢时连点两下提交,生成了两条一模一样的记录;
  • 提交失败后,辛苦填的内容全被清空。

这篇先建立“表单是一个小状态机”的思路,再逐个解决这些问题。

读完这篇你会

  • 设计合理的校验时机和错误提示
  • 处理中文输入法、移动端键盘、数字输入等容易踩坑的细节
  • 让表单提交不重复、不丢数据

1. 原理:表单是一个小状态机 ​

每个字段都有几组独立的状态:

text
是否被碰过:  未触碰(pristine) ──用户离开过一次──► 已触碰(touched)
值是否合法:  未知 ──► 校验中(异步,如查重名)──► 合法 / 不合法

整个表单也有自己的状态:

text
填写中 ──点击提交──► 提交中 ──成功──► 已提交
                       │
                       └──失败──► 填写中(保留数据,显示原因)

把这些状态想清楚,很多问题就有了答案:什么时候显示错误取决于“是否被碰过”;按钮能不能点取决于“是否在提交中”;失败后怎么办是回到“填写中”并保留数据,而不是清空。

2. 校验时机:别太早,也别太晚 ​

时机问题
每输入一个字就校验用户刚输入 ab,邮箱就报“格式错误”,体验很差
只在提交时校验填完二十个字段才知道第三个就错了

推荐的组合是:离开字段时第一次校验;出过错之后,改为输入时实时校验(让用户及时看到错误被修好);提交时对全部字段再校验一次,包括没碰过的必填项。

js
const touched = {}
const errors = {}

function onBlur(field) {
  touched[field] = true
  errors[field] = validate(field, form[field])
}

function onInput(field) {
  // 只有已经提示过错误的字段,才在输入时实时更新
  if (errors[field]) errors[field] = validate(field, form[field])
}

function onSubmit() {
  for (const field of Object.keys(form)) {
    touched[field] = true
    errors[field] = validate(field, form[field])
  }
  const firstError = Object.keys(errors).find((f) => errors[f])
  if (firstError) {
    document.getElementById(firstError)?.focus() // 把用户带到第一个错误处
    return
  }
  submit()
}

3. 错误提示:说清哪里错、怎么改 ​

不好的写法好的写法
无效邮箱格式不正确,应包含 @
必填请填写手机号
错误密码至少 8 位,需包含字母和数字
409 Conflict这个用户名已被注册,换一个试试

提示的位置固定在字段下方;同时用颜色和文字表达错误,并让读屏软件也能读到:

html
<label for="email">邮箱</label>
<input id="email" type="email" aria-invalid="true" aria-describedby="email-error" />
<p id="email-error" class="field__error">邮箱格式不正确,应包含 @</p>

4. 先用好原生属性 ​

属性作用
type="email" / "tel" / "url"手机上弹出对应的键盘,并有基础校验
inputmode="numeric" / "decimal"只控制弹出的键盘类型,不限制输入内容
autocomplete="name" / "tel" / "one-time-code"浏览器自动填充,one-time-code 能自动填入短信验证码
enterkeyhint="search" / "next" / "send"改变手机键盘回车键上的文字
required、minlength、maxlength、pattern基础校验

如果要完全自定义错误提示的样式,可以给 <form> 加 novalidate 关闭浏览器默认气泡,自己接管校验。

5. 坑一:中文输入法还在打拼音,就开始搜索了 ​

现象:在搜索框里输入“上海”,打拼音 shang 的过程中,列表已经开始拿 s、sh、sha 去搜索;输入法里按回车选词时,表单被直接提交了。

原因:使用输入法时,拼音阶段的每次按键也会触发 input 和 keydown 事件。这段“正在组字”的过程叫组合输入(composition),浏览器会在开始和结束时分别触发 compositionstart 和 compositionend。

修复:在组合输入期间跳过处理:

js
input.addEventListener('input', (event) => {
  if (event.isComposing) return // 还在打拼音,先不处理
  search(event.target.value)
})

input.addEventListener('compositionend', (event) => {
  search(event.target.value) // 选词结束,用最终的汉字搜索
})

input.addEventListener('keydown', (event) => {
  // 输入法选词时按的回车,不应该提交表单
  if (event.key === 'Enter' && !event.isComposing && event.keyCode !== 229) {
    submitSearch()
  }
})

keyCode === 229 是为了兼容部分浏览器在组合输入时不报告 isComposing 的情况。不同浏览器在组字结束时触发 compositionend 和 input 的先后顺序不完全一致,可能导致 search 被连续调用两次,所以 search 本身最好加上防抖或“和上次关键词相同就跳过”的判断。Vue 的 v-model 在文本框上默认会等组合输入结束才更新值,但你自己写的 @input、@keydown.enter 处理函数不会,需要像上面这样判断。

怎么避免再犯:所有搜索框、聊天输入框、回车提交的表单,都用中文输入法实际测一遍。

6. 坑二:iPhone 上一点输入框,页面就被放大 ​

现象:在 iOS Safari 里点击输入框,页面自动放大,输入完成后也不会缩回去,布局全乱了。

原因:iOS Safari 会在输入框字号小于 16px 时自动放大页面,方便用户看清输入的文字。

修复:输入框的字号设为至少 16px:

css
input,
select,
textarea {
  font-size: 16px; /* 或 max(16px, 1rem) */
}

不要用 user-scalable=no 禁止缩放来“解决”它,那样会影响需要放大页面的用户。

7. 坑三:type="number" 吞掉了用户的输入 ​

现象:

  • 手机号、身份证号、卡号输入框用了 type="number",开头的 0 不见了,很长的数字末尾变成了 0;
  • 输入框里可以输入字母 e;
  • 鼠标在输入框上滚动,数值被意外地加减了。

原因:type="number" 是给“可以加减的数量”设计的,比如购买件数。它会把内容当作数字处理:e 是科学计数法(1e3 是合法数字),开头的 0 没有意义,超过 JavaScript 安全整数范围的数字会丢失精度。

修复:手机号、验证码、证件号这类“由数字组成的文本”,用文本框加 inputmode:

html
<input type="text" inputmode="numeric" autocomplete="tel" maxlength="11" />

只有真正的“数量”才用 type="number"。

8. 坑四:连点两下,提交了两次 ​

现象:网络慢时,用户以为没点上,又点了一次,后台出现两条重复的订单。

修复:提交期间禁用按钮,并且同一时间只允许一个提交请求:

js
let submitting = false

async function onSubmit() {
  if (submitting) return
  submitting = true
  submitButton.disabled = true
  submitButton.textContent = '提交中…'
  try {
    await api.createOrder(form)
    showSuccess()
  } catch (error) {
    showError('提交失败,请重试') // 数据保留在表单里,用户可以直接重试
  } finally {
    submitting = false
    submitButton.disabled = false
    submitButton.textContent = '提交'
  }
}

前端拦截只能防住“手快”,挡不住刷新重试、网络超时重发。真正可靠的去重要靠后端,比如前端生成一个请求 ID 一起提交,后端据此判断是否已经处理过,详见 事务、幂等与补偿。

9. 坑五:提交失败或误刷新,填的内容全没了 ​

现象:用户填了一个很长的申请表,点提交时网络断了,页面一刷新,所有内容都没了。

修复:

  • 提交失败不清空:只在成功后才重置表单;
  • 长表单自动保存草稿:输入时防抖保存到 localStorage,重新打开时询问是否恢复;成功提交后删除草稿;
  • 离开前提醒:有未保存的修改时,监听 beforeunload 提醒用户。
js
const saveDraft = debounce(() => {
  localStorage.setItem('apply-form-draft', JSON.stringify(form))
}, 500)

密码、身份证号、银行卡号等敏感字段不要存进草稿。localStorage 里的数据是明文的,同一设备上的其他人或恶意脚本都可能读到。

10. 前端校验只是体验,不是安全 ​

前端校验的作用是让用户少犯错、早发现。任何人都可以绕过前端,直接调用接口提交任意数据。所有规则(必填、格式、长度、权限、金额范围)都必须在后端再校验一次。前后端的错误提示最好统一:后端返回字段级的错误,前端显示在对应字段下方。

11. 长表单:分步和分组 ​

字段超过十个时,考虑拆成几步:

  • 每一步只校验当前步的字段;
  • 显示进度(“第 2 步,共 3 步”);
  • 可以返回上一步修改,已填内容不丢失;
  • 最后一步给出汇总确认页。

排查思路 ​

  1. 重复提交:打开 Network 面板,把网速调成 Slow 4G,快速连点提交按钮,数一数发出了几个请求。
  2. 输入法问题:用中文、日文输入法分别测试搜索框和回车提交。
  3. 移动端问题:在真机上测试(模拟器很难复现 iOS 自动放大和键盘遮挡),注意检查自动填充、验证码自动填入是否正常。
  4. 校验不一致:用接口调试工具直接调用接口,提交前端会拦截的数据,确认后端也会拒绝。

检查清单 ​

  • [ ] 离开字段时首次校验,出错后实时校验,提交时全量校验
  • [ ] 错误提示说明“哪里错、怎么改”,并用 aria-invalid、aria-describedby 关联
  • [ ] 搜索、回车提交等逻辑处理了输入法组合输入
  • [ ] 输入框字号不小于 16px
  • [ ] 手机号、证件号等不用 type="number"
  • [ ] 提交期间防重复,后端也有幂等保护
  • [ ] 失败不清空数据,长表单有草稿,敏感字段不存草稿
  • [ ] 所有规则在后端都有校验

延伸阅读 ​

为复用而记录,为理解而整理。