表单语义、原生校验和提交状态:从一个资料编辑表单说起

手头这个需求是给中后台加一个用户资料编辑页:昵称、邮箱、手机号、密码修改、几个下拉选项,外加一个保存按钮。功能听起来简单,字段不多,逻辑也不复杂,但真要把这十几个字段的表单填完、校验完、提交完,还是暴露出好几处容易随手糊弄过去的地方。

第一版是照着组内老的登录页表单抄的结构,抄完发现登录页本身就有问题:按钮上只绑了 click,用户在密码框按回车没反应。原因是提交逻辑挂在按钮的点击事件上,而不是表单的 submit 事件上。这两者不是一回事——form 元素天然支持提交行为,按回车、点击 type="submit" 的按钮、辅助技术触发提交,走的都是同一条路径,都会派发 submit 事件。只监听按钮 click,等于把这条路径砍掉了大半:

1<form id="profile-form">
2  <div class="field">
3    <label for="nickname">昵称</label>
4    <input id="nickname" name="nickname" type="text" required>
5  </div>
6
7  <div class="field">
8    <label for="email">邮箱</label>
9    <input id="email" name="email" type="email" autocomplete="email" required>
10  </div>
11
12  <button type="submit">保存</button>
13  <button type="button" id="cancel-btn">取消</button>
14</form>
1const form = document.querySelector('#profile-form');
2
3form.addEventListener('submit', async (event) => {
4  event.preventDefault();
5  await submitProfile(new FormData(form));
6});

这里顺带修了一个更隐蔽的坑:表单里的"取消"按钮如果不写 type="button",默认类型就是 submit,点一下会把整个表单交上去。这个需求里取消按钮只是清空弹层,没有特意测试提交行为,差点就把这个默认值坑进去。

按钮的第三种类型 type="reset" 这次也顺带确认了一下行为——点击后会把表单里所有控件恢复成页面刚加载时的初始值,而不是清空成空字符串。这个区别在资料编辑页里很关键,因为字段是带着用户已有资料回显的,如果产品那边真的想要一个"重置"按钮,点击后应该回到"当前已保存的资料"而不是变成一片空白,这正好是 type="reset" 的原生行为,不需要额外写逻辑。不过这次弹层里最终去掉了这个按钮,只留了"取消"(直接关闭弹层)和"保存",因为测试时资深前端反馈"重置"和"取消"两个按钮放一起容易被点错,功能上有重叠,索性精简掉一个。

还有一个容易被忽略的按钮属性是 formnovalidate。同一个表单里如果既有"保存"又有"保存为草稿"这类允许跳过校验的操作,不需要为草稿按钮单独拆一个表单或者手动调用 event.preventDefault() 绕开校验,直接在按钮上加这个属性,点击这个按钮触发的提交就会跳过原生校验:

1<button type="submit">保存</button>
2<button type="submit" formnovalidate>保存为草稿</button>

这个属性只对当前按钮生效,同一个表单里其他 submit 按钮该校验还是照常校验,不会互相影响。

字段的 name 也不能省。资料编辑页第一版里手机号那个输入框漏写了 nameFormData 序列化出来直接少一个键,接口那边拿到的对象里压根没有 phone 字段,排查了一阵才发现是这个原因。name 是表单提交时的键,id 是给 label 关联和 JS 查找用的,这两个属性各管各的,缺一个都会出问题。

FormData 这个对象本身也值得多说两句。这次改版之前团队里更习惯的写法是手动拼一个普通对象再 JSON.stringify,遇到普通文本字段没问题,但资料编辑页后面加了一个"上传头像"的需求,普通对象就不够用了——文件对象没法塞进 JSON。FormData 直接从 form 元素里读所有带 name 的控件,包括 type="file" 的输入框,构造出来的是 multipart/form-data 格式,fetch 传的时候连 Content-Type 都不用手动设置,浏览器会根据 bodyFormData 实例自动带上正确的边界字符串:

1<input type="file" name="avatar" accept="image/png,image/jpeg">
1const formData = new FormData(form);
2formData.append('source', 'profile-edit'); // 额外附加一个非表单字段也没问题
3
4fetch('/api/profile', {
5  method: 'POST',
6  body: formData,
7});

早些年这种场景大多是单独起一个隐藏的 iframe 或者用第三方上传组件糊过去,现在 FormData 配合 fetch 已经能把文件字段和普通字段放在同一个请求里提交,不用把上传单独拆成一条请求链路。唯一要注意的是 FormData 序列化出来的 body 不能再手动设置 Content-Type: application/json,混着用会导致后端解析失败,这个坑在接入头像上传时踩过一次。

input type 不是随便选的

这个表单里手机号、邮箱、密码、年龄区间下拉都用得上不同的输入类型,图省事全写 type="text" 是最常见的偷懒方式,但这样做会丢掉几个免费的能力:移动端弹出的软键盘布局、浏览器自带的格式校验、以及自动填充的匹配准确度。

1<input type="email" name="email" autocomplete="email">
2<input type="tel" name="phone" autocomplete="tel">
3<input type="password" name="newPassword" autocomplete="new-password">
4<input type="number" name="age" inputmode="numeric">

这几个类型在桌面端看起来差别不大,无非是浏览器加了一层格式提示,但在手机上差别很明显。type="tel" 弹出的是纯数字加 #* 的拨号盘键盘,type="number" 弹出的数字键盘在不同系统上略有差异,有些安卓机型还会带上小数点和负号,反而不适合手机号这种定长纯数字场景;type="email" 的软键盘会把 @. 放在显眼位置,省得用户切换字母键盘去找。手机号字段最后选的是 type="tel" 而不是 type="number",一是因为手机号不需要参与数值运算,二是 number 类型的输入框在部分浏览器里还带着上下调节的箭头控件,视觉上很奇怪,而且前导 0 这类合法但"数值上无意义"的输入会被浏览器悄悄处理掉,这对手机号、身份证号这类"看着是数字实际是字符串"的字段是个隐患。年龄区间这个字段倒是货真价实的数值,配合 inputmode="numeric" 能在不改变语义(它本质还是 type="text")的前提下单独控制软键盘布局,这个属性和 type="number" 不冲突,可以同时使用,也可以单独给 type="text" 的字段加,只影响移动端键盘、不影响原生校验规则。

autocomplete 这个属性也值得认真填。密码字段如果是"新密码"场景,要用 new-password 而不是 current-password,不然 Chrome 会把用户已保存的旧密码自动填进去,等于帮用户填错。这个坑是在改密码那个子表单里发现的——测试的时候密码框自动填了上次登录用的旧密码,一开始还以为是自己状态管理写错了。

密码管理器对 autocomplete 的依赖比想象中更重。资料编辑页里"修改密码"拆成了旧密码、新密码、确认新密码三个字段,如果三个都写成 new-password,Chrome 的密码管理器会犯迷糊,把旧密码框也当成"要生成的新密码"去提示;正确的写法是旧密码用 current-password,新密码和确认新密码两个都用 new-password,这样密码管理器才能正确识别出"这是一次改密操作"而不是"新注册",生成强密码建议的时候也只会提示在新密码框上:

1<input type="password" name="oldPassword" autocomplete="current-password">
2<input type="password" name="newPassword" autocomplete="new-password">
3<input type="password" name="confirmPassword" autocomplete="new-password">

手机号这个字段还加了 pattern,让浏览器在提交前做一层原生校验:

1<input
2  id="phone"
3  name="phone"
4  type="tel"
5  pattern="^1[0-9]{10}$"
6  required
7>

原生校验背后其实是一整套 Constraint Validation API,不只是 requiredpattern 这些属性生效那么简单。每个表单控件都有 validity 对象,可以直接读出校验失败的具体原因(valueMissingpatternMismatchtooShort 等等),也可以调用 checkValidity() 判断是否通过、reportValidity() 主动弹出浏览器自带的错误气泡:

1const phoneInput = document.querySelector('#phone');
2
3if (!phoneInput.checkValidity()) {
4  console.log(phoneInput.validity.patternMismatch); // true
5  phoneInput.reportValidity();
6}

这套 API 免费、不依赖任何库,但样式几乎没法定制——错误气泡长什么样是浏览器决定的,各浏览器还不一样。中后台这类对视觉统一性要求高的场景,通常只把原生校验当第一道兜底(尤其是防止完全为空或格式明显不对的值被提交),真正展示给用户看的错误文案还是自己写。

validity 对象上其实挂了不少字段,不只是 patternMismatch 一个,实际排查的时候按需读就够了:

1const { validity } = phoneInput;
2
3if (validity.valueMissing) {
4  console.log('这个字段是必填的');
5} else if (validity.patternMismatch) {
6  console.log('格式不对,没匹配上 pattern');
7} else if (validity.tooShort) {
8  console.log('长度不够 minlength');
9} else if (validity.rangeOverflow || validity.rangeUnderflow) {
10  console.log('数值超出 min/max 范围');
11}

pattern 属性只在 typetexttelemailurlsearchpassword 这几种支持文本输入的类型上生效,而且匹配的是整个值而不是子串(等价于给正则自动加上 ^$),这个隐含行为一开始没注意,写了一个 pattern="[0-9]{11}",本以为只要包含 11 位数字就行,结果发现末尾多输入一个字符就直接不通过——因为浏览器是拿整个输入值去做完全匹配,跟 JS 里 String.prototype.match 的默认行为不是一回事。另外还可以配合 title 属性给出提示文案,虽然这段文案的展示时机和样式同样不受控制:

1<input
2  name="phone"
3  type="tel"
4  pattern="^1[0-9]{10}$"
5  title="请输入 11 位手机号"
6  required
7>

真正展示给用户看的错误文案还是自己写:

1function validateProfile(values) {
2  const errors = {};
3
4  if (!values.get('nickname')) {
5    errors.nickname = '请输入昵称';
6  }
7
8  if (!/^1[0-9]{10}$/.test(values.get('phone') ?? '')) {
9    errors.phone = '请输入正确的手机号';
10  }
11
12  return errors;
13}

这里 values.get('phone') 理论上不会是 undefined(毕竟字段有 name),但用 ?? 兜一层空值合并,比每次都写 values.get('phone') || '' 顺手,也不会在值是空字符串这类合法输入时被误伤(|| 遇到空字符串会继续往后取,?? 只在 null/undefined 时才取后面的值)。Chrome 80 今年二月发布之后,可选链和空值合并都能直接当浏览器原生特性用,不用再靠 TypeScript 编译降级。

团队另一条产品线是用 Element UI 的 el-form 搭的后台,走的是完全不同的路子:el-form-item 声明式绑 rules,底层是 async-validator 在跑校验规则,连异步校验(比如校验用户名是否已存在)都封装好了。这次这个资料编辑页是嵌在一个轻量弹层里,没有引入 Element UI 的整套表单组件,所以选择的是原生表单 + 自己写校验函数这条路。两种方案没有绝对的优劣——组件库的校验方案省了大量重复的错误展示逻辑,代价是多一层抽象和一份规则 DSL 要学;原生方案更透明,但字段一多,校验逻辑就得自己攒。

另一条产品线上那份 rules 定义完整贴出来大概是这样,负责那条产品线的同事刚好也在改一个类似的改密码表单,两边顺便对了一下写法:

1export default {
2  data() {
3    const validateConfirmPassword = (rule, value, callback) => {
4      if (value !== this.form.newPassword) {
5        callback(new Error('两次输入的密码不一致'));
6      } else {
7        callback();
8      }
9    };
10
11    const validateUsername = (rule, value, callback) => {
12      checkUsernameExists(value).then((exists) => {
13        if (exists) {
14          callback(new Error('用户名已被占用'));
15        } else {
16          callback();
17        }
18      });
19    };
20
21    return {
22      form: {
23        username: '',
24        newPassword: '',
25        confirmPassword: '',
26      },
27      rules: {
28        username: [
29          { required: true, message: '请输入用户名', trigger: 'blur' },
30          { min: 4, max: 16, message: '长度在 4 到 16 个字符', trigger: 'blur' },
31          { validator: validateUsername, trigger: 'blur' },
32        ],
33        newPassword: [
34          { required: true, message: '请输入新密码', trigger: 'blur' },
35          { min: 8, message: '密码至少 8 位', trigger: 'blur' },
36        ],
37        confirmPassword: [
38          { required: true, message: '请再次输入密码', trigger: 'blur' },
39          { validator: validateConfirmPassword, trigger: 'blur' },
40        ],
41      },
42    };
43  },
44};

async-validator 里每条规则可以是内置的 type/required/min/max/pattern 这些声明式条件,也可以是自定义的 validator 函数,函数签名固定是 (rule, value, callback),通过调用 callback()callback(new Error(...)) 来判断这条规则是否通过——这也是为什么它天然支持异步校验,callback 可以放在 then 里延迟调用,el-form 不关心校验函数是同步跑完还是等一个接口回来。跨字段校验(比如"确认密码"要和"新密码"保持一致)在这套体系里就是在 validator 里直接读 this.form 上的另一个字段值,没有专门的"关联字段"配置项。

原生表单这边如果也要做"确认密码"这类跨字段校验,思路是类似的,只是要自己在校验函数里手动去读另一个字段的当前值,而不是指望 checkValidity() 帮忙——原生 Constraint Validation API 是逐字段独立校验的,天然不支持"这个字段的合法性依赖另一个字段"这种关系:

1function validateProfile(values) {
2  const errors = {};
3  const newPassword = values.get('newPassword');
4  const confirmPassword = values.get('confirmPassword');
5
6  if (newPassword && newPassword !== confirmPassword) {
7    errors.confirmPassword = '两次输入的密码不一致';
8  }
9
10  return errors;
11}

有个细节容易漏掉:确认密码框本身改完之后要重新触发一次校验,但新密码框改完了也要顺带把确认密码框的校验状态刷新一遍,不然会出现"改了新密码,确认密码框还显示成功"的滞后状态。资料编辑页里这块是在新密码字段的 blur 回调里顺便调用了一次确认密码字段的校验函数,两个字段的错误提示才能保持同步。

实时校验会不会太吵

密码字段加了实时校验之后,第一版是每次 input 事件都触发一次完整校验,结果用户刚敲了一个字符,密码强度不够的错误提示就跳出来,体验很糙。这里的边界是:格式类的校验(是不是纯数字、长度是否达标)适合放到 blur 时机去做,用户输完一个字段、移开焦点,再告诉他对不对;只有需要即时反馈的场景(比如密码强度条、字符计数)才适合挂在 input 上,而且最好加一层防抖,避免用户还在输入过程中就被打断:

1function debounce(fn, wait) {
2  let timer = null;
3  return function (...args) {
4    clearTimeout(timer);
5    timer = setTimeout(() => fn.apply(this, args), wait);
6  };
7}
8
9const checkPasswordStrength = debounce((value) => {
10  updateStrengthBar(value);
11}, 300);
12
13passwordInput.addEventListener('input', (event) => {
14  checkPasswordStrength(event.target.value);
15});

blur 事件负责给出"这个字段错在哪"的明确结论,input 事件负责给出"输入过程中"的轻量反馈,两者混在一起用同一套触发时机,是这次调整前踩的坑。

密码强度这类需要"即时但不刺眼"反馈的场景,防抖之外还可以叠加一个更细的判断:只有当输入长度达到某个阈值之后才开始给反馈,避免用户刚敲第一个字符就被判定"强度不足":

1const checkPasswordStrength = debounce((value) => {
2  if (value.length < 6) {
3    hideStrengthBar();
4    return;
5  }
6  updateStrengthBar(value);
7}, 300);

这里还牵扯到一个受控还是非受控的选择问题。原生表单加一层 JS 校验,本质上走的是"非受控"路子——input 元素自己管理自己的 value,JS 只是在关键节点(blursubmit)去读一次当前值,平时不介入。Element UI 的 el-input 配合 v-model 则是"受控"写法,每次按键都会把 DOM 的值同步写回 data 里的响应式字段,再由 Vue 重新渲染回输入框,校验规则也是绑在这份响应式数据上跑的。

两种模式在这次的表单里都用到了:轻量弹层里的原生表单选择非受控,是因为字段不多、不需要在输入过程中实时联动别的 UI;另一条产品线的 el-form 选择受控,是因为那边有字段联动的需求——比如选了"企业用户"这个下拉项之后,下面要多出一个"统一社会信用代码"字段,这种"一个字段的值决定另一个字段是否渲染"的场景,必须让 JS 随时知道当前值是什么,受控写法几乎是唯一选择。反过来,如果字段之间没有强联动、只是攒起来一次性提交,非受控写法能省掉每次按键都触发一次组件重渲染的开销,长表单场景下这个差异是能感觉出来的——资料编辑页这次量级不大,暂时没有实测出明显差异,但另一条产品线的动态表单(十几个联动字段)如果全部走非受控,光是手动同步各个字段状态的代码量就会比直接上 v-model 多不少。

label 和错误提示要和字段建立关系

字段本身校验通过之后,接下来是把错误信息正确地"挂"到字段上。这个表单里用户名字段的错误提示一开始只是在输入框下面加了一段红字,视觉上没问题,但没有和输入框建立任何关系——鼠标看得到,键盘导航和屏幕阅读器完全感知不到有错误发生。

1<label for="username">用户名</label>
2<input
3  id="username"
4  name="username"
5  type="text"
6  aria-describedby="username-error"
7  aria-invalid="true"
8>
9<p id="username-error" role="alert">用户名至少需要 4 个字符</p>

aria-describedby 把错误文案和输入框关联起来,aria-invalid 告诉辅助技术这个字段当前处于错误状态,role="alert" 让错误一出现就能被朗读出来,不需要用户主动去找。这几个属性平时不会引起视觉上的任何变化,所以很容易被漏掉,但对键盘操作和读屏用户来说,是能不能正常填完表单的差别。

label 本身也是同一类问题。很多自定义组件为了视觉效果,会用一个 div 或者 span 顶替 label 的位置,看起来没差,但点击这段文字不会聚焦到对应输入框,读屏软件也不会把它当作字段说明来读。原生的关联方式并不复杂——label 包住控件,或者用 for 对应 id

1<label for="nickname">昵称</label>
2<input id="nickname" name="nickname" type="text">

这一步几乎不花额外时间,纯粹是写的时候有没有这个意识。

密码修改这部分字段有三个(旧密码、新密码、确认密码),单独用 fieldset 包起来、配一个 legend 说明这一组字段是干什么的,读屏用户进入这个分组时能先听到"修改密码"这个整体说明,再逐个听到每个字段的 label,比三个孤立的 label 更容易建立起"这几个字段是一组"的认知:

1<fieldset>
2  <legend>修改密码</legend>
3  <div class="field">
4    <label for="old-password">当前密码</label>
5    <input id="old-password" name="oldPassword" type="password" autocomplete="current-password">
6  </div>
7  <div class="field">
8    <label for="new-password">新密码</label>
9    <input id="new-password" name="newPassword" type="password" autocomplete="new-password">
10  </div>
11</fieldset>

fieldset 默认自带一圈边框和内边距,视觉上和这次的设计稿不一致,直接用 CSS 把 borderpadding 清掉就行,不影响它在语义层面提供的分组信息——这也是这次容易被自己坑的地方,第一反应是"样式不对,别用这个标签了",换成 div 加视觉分组,结果把语义也一起丢了。

提交防重复和失败反馈

表单校验通过、submit 事件触发之后,剩下的是提交本身的健壮性。这次资料编辑页的接口不算快,测试的时候发现手速快一点连点两下保存,后端日志里能看到两次一模一样的请求。用一个提交中的状态位挡住重复请求,是最直接的办法:

1let submitting = false;
2
3async function submitProfile(formData) {
4  if (submitting) {
5    return;
6  }
7
8  submitting = true;
9  toggleSaveButton(true);
10
11  const result = await postForm('/api/profile', formData);
12
13  submitting = false;
14  toggleSaveButton(false);
15
16  if (!result.ok) {
17    showFormError(result.message);
18    return;
19  }
20
21  showSuccess('保存成功');
22}

postForm 这一层把网络异常和业务失败统一收口,调用方只需要关心 ok 这一个字段:

1async function postForm(url, formData) {
2  try {
3    const response = await fetch(url, {
4      method: 'POST',
5      body: formData,
6    });
7
8    if (!response.ok) {
9      return { ok: false, message: '提交失败,请稍后重试' };
10    }
11
12    return { ok: true, data: await response.json() };
13  } catch (error) {
14    return { ok: false, message: '网络异常,请检查连接后重试' };
15  }
16}

服务端返回的字段级错误(比如"该手机号已被使用"这种前端校验不出来的业务规则)也要能落回具体字段上,而不是笼统弹一个提示框完事。这次接口返回的错误结构里带了 fieldmessage,前端拿到之后按 field 找到对应输入框,走的是和前面 aria-describedby 一样的展示路径——错误提示不管来自前端校验还是服务端校验,落地的展示方式应该是一致的,不然用户会觉得"怎么这次报错长得不一样"。

改了没保存,关页面前提醒一下

资料编辑页放到测试环境之后,QA 提了一个体验问题:改了好几个字段,手滑点到浏览器的返回按钮,页面直接跳走了,改的内容全没了,也没有任何提示。这类"表单脏值检测"的需求本质上是两件事:一是判断表单当前是不是处于"和初始状态不一致"的脏状态,二是在用户即将离开页面时,如果是脏状态就弹出确认。

脏值检测最省事的办法是提交时把表单的初始值存一份快照,每次字段变化时和快照比对:

1let initialSnapshot = null;
2
3function captureSnapshot(form) {
4  initialSnapshot = new URLSearchParams(new FormData(form)).toString();
5}
6
7function isFormDirty(form) {
8  const current = new URLSearchParams(new FormData(form)).toString();
9  return current !== initialSnapshot;
10}
11
12captureSnapshot(form);

URLSearchParams 包一层 FormData 是图个方便,能直接转成字符串比较,不用自己写深比较逻辑;缺点是字段顺序变化或者文件类型的字段会影响这个比较结果,文件字段这次没有参与脏值判断,单独处理。

拿到脏状态之后,接下来是拦截页面离开。浏览器提供的是 beforeunload 事件,用来处理关标签页、刷新、地址栏跳转这几种"离开当前文档"的场景:

1window.addEventListener('beforeunload', (event) => {
2  if (!isFormDirty(form)) {
3    return;
4  }
5  event.preventDefault();
6  event.returnValue = '';
7});

这里有几个限制要提前知道。首先,event.returnValue 必须显式赋值(哪怕是空字符串)才能触发浏览器自带的确认弹窗,早年一些博客文章里写的"返回一个字符串当作提示文案"在现在的 Chrome、Firefox 里已经不生效了,弹窗的文案是浏览器固定死的,不能自定义,这点和前面原生校验错误气泡的处境很像——控制不了样式,只能控制"要不要弹"。其次,beforeunload 只拦得住"离开文档"这个动作,拦不住 SPA 内部的路由跳转(比如点了侧边栏切到另一个页面,Vue Router 只是替换了组件,并没有真正离开这个 HTML 文档),这部分是靠 Vue Router 的导航守卫 beforeRouteLeave 单独处理的,和 beforeunload 是两条不同的拦截路径,缺一个都会漏掉对应场景。资料编辑页这次两边都接了,路由内跳转弹的是自己写的确认弹层,真正离开页面(刷新、关标签)弹的是浏览器原生确认框,视觉不统一但功能上不会有遗漏。

这个资料编辑表单最后跑起来,字段不算多,但走完 namelabeltype、原生校验、自定义校验、submit 事件、按钮 type、防重复提交、错误落回字段这一整条链路,才算是把"能填、能校验、能提交、能正确反馈失败"这几件事都接上了。视觉可以随便换皮肤,这条链路上的每一环缺一个,用户实际用起来都会卡一下。