Skip to content
This page has been auto-translated and may contain errors.View in English

表单验证 ​

docs.scrimba.com

表单收集的是人填写的信息,而人难免会漏填字段、把邮箱地址打错、或者在该填数字的地方填上字母。你肯定不希望这些未经检查的数据原封不动地传到你的代码里。在提交之前,浏览器其实可以帮你检查很多东西——前提是你得把每个字段的要求写清楚。这个说明就写在 HTML 里,本章要讲的正是怎么写。

必填字段和输入类型 ​

最省事的两种检查,一是声明某个字段必须填写,二是声明这个字段应该填什么类型的值。

给 input 加上 required 这个词,浏览器就会在字段为空时拒绝提交表单。你不需要写任何代码来实现这一点,这个属性本身就是指令。

html
<input type="text" name="full_name" required>

另一半是 type 属性。它告诉浏览器这个值应该是什么形式,浏览器会替你检查。用 type="email",浏览器就会确保这个值看起来像个邮箱地址;用 type="number",它就只接受数字。此外还有专门用于日期、网址等的类型。

可以把它想象成医院里的一张表格:有些框旁边标着“必须填写”,日期那一栏已经分好了日、月、年的小格子。纸张本身就已经告诉你该往哪里填什么了。

required 是一个**布尔属性**:只要它出现,规则就生效,不需要赋任何值。标了 required 的控件在为空时会阻止表单提交。

type 属性身兼两职:它决定浏览器渲染出什么样的控件(日期选择器、数字调节框),同时也给这个值设定了一条内置的约束规则:

  • type="email" 要求有一个 @ 和一个像域名的结尾。
  • type="url" 要求内容能被解析为一个完整的网址。
  • type="number" 只接受数字输入,并且解锁了 min、max、step 这些属性。
  • type="tel" 本身不限制内容,但会在移动设备上弹出电话数字键盘。

优先选用最贴合场景的具体类型。它是用最少的代码换来最多的检查,还能让触屏设备弹出更合适的键盘。

required 和 type 是浏览器称之为**约束验证**(constraint validation,浏览器内置的一套机制,根据你在标记里声明的规则来检查控件的值)体系里最先接触到的两层。required 设定的是“值缺失”规则,type 设定的是与该类型绑定的格式规则。

关于 type 有两点值得在生产环境中留意。第一,type="email" 并不会按照完整的邮箱规范去校验。浏览器采用的是刻意宽松的匹配模式(一串字符、一个 @、再加上一个以点分隔的域名),因为过于严格的检查反而会拒绝掉实际可以送达的地址。把它当作一种形状检查就好,它不能证明这个地址真实存在。第二,遇到未知或不支持的 type,浏览器会回退到 type="text",所以一个字段在旧浏览器上永远不会变得不可用,只是失去了额外的检查能力。正是这种优雅降级,让你可以放心采用新类型,不必事先对照兼容性表格。

type 还决定了弹出的键盘样式,此外你还可以通过相关的 inputmode 属性(一个提示浏览器该显示哪种键盘布局、但不影响验证的属性)单独调整键盘。选对 type 既是一个验证问题,也同样是一个无障碍和易用性方面的决策。

下面是一个同时用到这两个属性的简单注册表单:

html
<form>
  <label>
    Email address
    <input type="email" name="email" required>
  </label>
  <label>
    Website
    <input type="url" name="website">
  </label>
  <button>Sign up</button>
</form>

邮箱字段必须填写,而且必须看起来像个邮箱地址。网站字段是可选的,但如果有人填了内容,就必须看起来像个网址。浏览器会在按下按钮的那一刻同时检查这两项。

Juno必填字段和输入类型 这两个小小的词做了不少事情。给字段加上 required,浏览器就不会放过一个空字段;设置 type,比如 email 或 number,浏览器就会检查这个值的形式对不对。选对与字段匹配的类型,检查就免费送给你了。
Juno必填字段和输入类型required 是个布尔属性:要么有,要么没有,不需要赋值。type 既决定了控件,也设定了格式规则,所以 type="email" 要求有一个 @ 和一个域名。始终优先用最贴合场景的具体类型,这是用最少的代码换来最多的检查,移动端键盘也会跟着变好用。
Juno必填字段和输入类型 这两个是约束验证体系(浏览器内置的规则检查机制)最先接触到的两层。值得记住的是:type="email" 只是一种宽松的形状检查,不能证明地址真实存在;不支持的 type 会悄悄回退到 text,而不是直接失效。选择 type 同样是一个无障碍层面的决策,因为它决定了用户看到的屏幕键盘。

约束属性 ​

除了“填没填”和“类型对不对”之外,你常常还需要限定范围:长度至少多少、数字不能超过多少、格式必须符合某种规则。有一小组属性专门负责这些。

你可以为字段设定限制条件。刚开始有两个比较实用:

html
<input type="text" name="username" maxlength="20">
<input type="number" name="quantity" min="1" max="10">

maxlength="20" 会在字段填满二十个字符后阻止继续输入。min 和 max 设定了数字允许的最小值和最大值。浏览器会自动把用户限制在这个范围内,你不用自己再去检查。

约束属性一共就那么几个,每一个对应一条规则:

  • minlength / maxlength 设定文本值最少和最多能有多少个字符。
  • min / max 设定数字和日期的最小值和最大值。
  • step 设定数字允许的增量,所以 step="5" 会接受 0、5、10,但拒绝 3。
  • pattern 存放一个**正则表达式**(一种紧凑的写法,用来描述允许的文本应该长什么样),这个值必须完整匹配这个表达式。
html
<input type="text" name="username" minlength="3" maxlength="20" required>
<input type="number" name="quantity" min="1" max="10" step="1">
<input type="text" name="pin" pattern="[0-9]{4}" title="Four digits">

给 pattern 配上一个 title:有些浏览器会把它的文字展示在错误提示里,而且它也能告诉视力正常的用户这个字段期望什么内容。在 CSS 里,:valid 和 :invalid 可以让你根据控件当前是否满足约束来设置样式。

每个约束属性都对应浏览器能够报告的一种标志位,等你从 JavaScript 里读取验证状态时就会用到这一点。违反 min 会触发“range underflow”,超出 maxlength 会触发“too long”,不匹配 pattern 会触发“pattern mismatch”,以此类推。属性是声明,标志位是结果的呈现方式。

有两个细节容易让人栽跟头。pattern 是隐式锚定的:表达式必须匹配整个值,就好像它已经被包裹起来覆盖了从头到尾一样,所以 pattern="[0-9]{4}" 的意思是“正好四位数字”,而不是“包含四位数字”。另外,step 是从某个基准值(min,如果没有就是 0)出发用浮点数运算来计算的,所以 step="0.1" 有时会拒绝一个你原本以为能通过的值,这是因为二进制表示小数的方式所致;如果精度很重要,就明确设置一个 min 作为基准。

:valid 和 :invalid 这两个伪类(根据元素当前的状态而不是位置来匹配的 CSS 选择器)很有用,但也很粗糙:一个空的 required 字段从页面第一次渲染起就会匹配 :invalid,如果你把它设成红色,用户还没开始输入就先看到一堆错误提示。解决办法是用 :user-invalid,它只在用户与字段发生过交互之后才会匹配,这样反馈才会在有意义的时候出现,而不是一进页面就跳出来。

能省下最多困惑的一条规则是:pattern 必须匹配整个值,而不是其中一部分。

html
<input
  type="text"
  name="product_code"
  pattern="[A-Z]{2}-[0-9]{3}"
  title="Two letters, a hyphen, three digits, e.g. AB-123"
  required
>
Juno约束属性 字段定好必填和类型之后,你还可以再加限制。maxlength 限定能输入多少个字符,min 和 max 圈住一个数字的范围。浏览器会自动把用户限制在这些范围内,这是一大堆你根本不用写的检查代码。
Juno约束属性 这一组属性不多:文本用 minlength/maxlength,数字和日期用 min/max,增量用 step,精确形状用 pattern。给 pattern 配一个 title,让提示信息真正有意义。而且 pattern 匹配的是整个值,所以 [0-9]{4} 是总共四位数字,而不是“某处有四位数字”。
Juno约束属性 每个属性都对应一个之后可以读取的有效性标志位,这样标记和报告结果就能一一对应。有两个坑要注意:pattern 是从头到尾锚定的,step 是从一个基准值出发做浮点运算的,所以精度重要时要显式设置 min。样式要用 :user-invalid,而不是 :invalid,不然还没等人输入,字段就已经被涂成红色了。

浏览器原生的验证反馈 ​

声明规则只是故事的一半,另一半是当某个值不符合规则时,用户实际看到了什么。

当用户按下提交按钮、而某个字段有问题时,浏览器会阻止提交,并在第一个出问题的字段旁边显示一条小提示,然后把光标跳到那里。这条提示不需要你来写,浏览器会用访问者自己的语言自动生成并显示出来。

所以一个空着的 required 邮箱字段会弹出类似“请填写此字段”的提示,表单在问题解决之前不会发送出去。

验证是在提交时触发的。浏览器会按顺序检查各个控件,找到第一个无效的,把焦点放上去,并显示一个描述问题的小提示气泡。如果一切都通过了,表单就正常提交。

你可以通过 CSS 获得一定的样式控制权。:required、:valid、:invalid 和 :in-range 让你可以给字段做出视觉标记。但你不太容易重新定制提示气泡本身的样式,那属于浏览器自己的外观。你还可以用 novalidate 属性把整套原生验证关掉,这在你打算完全用 JavaScript 来验证时很有用。

css
input:user-invalid {
  border-color: #c0392b;
}
input:user-valid {
  border-color: #2d7a3f;
}

原生反馈用起来方便,几乎不花什么成本,但它也有一些限制,在你打算把它用到正式产品里之前,值得提前规划好。

提示气泡是一个**短暂存在**的元素:它在提交时出现,在下一次交互时消失,并不属于文档里可以被选中或设置样式的一部分。屏幕阅读器对它的支持在不同浏览器之间参差不齐,所以光靠这个气泡,对看不见它的人来说并不是一种可靠的错误通知方式。它的文本也是按浏览器的语言而不是页面的 lang 来本地化的,所以一个用英文写的表单,也可能对着浏览器语言设置为法语的访问者弹出法语错误信息——这对用户来说是对的,但对开发者来说可能挺意外。

你也可以在不提交表单的情况下自己触发同样的流程:reportValidity() 会执行检查并按需显示气泡。但除了快速做原型之外,更符合无障碍要求的做法是:用 novalidate 关掉原生气泡,然后自己在页面里渲染错误文字,并把它跟对应字段关联起来,这样它才是可见的、可设置样式的,也才能被稳定地播报出来。下一节会讲怎么做。

Juno浏览器原生的验证反馈 写了这么多属性,回报就在这里:按下提交按钮时如果某个字段有问题,浏览器会弹出一条小提示,把焦点指向那个字段,并拒绝发送。这些文字都不是你写的,是浏览器用访问者的语言自动生成的。对很多表单来说,这样的反馈已经够用了。
Juno浏览器原生的验证反馈 检查在提交时执行:第一个有问题的字段会得到焦点和一条提示气泡,表单会被拦下来,直到通过为止。你可以用 :user-valid 和 :user-invalid 给字段设置样式,但气泡本身的外观是浏览器说了算的。打算用 JavaScript 自己处理时,可以用 novalidate 把这套机制整个关掉。
Juno浏览器原生的验证反馈 提示气泡是短暂出现的,也没法设置样式,屏幕阅读器对它的支持参差不齐,而且它的文字跟着浏览器语言走,不跟着你的页面走。做原型没问题,用在生产环境里就有点单薄了。真正在意无障碍的时候,加上 novalidate,自己在页面里渲染错误文字,让它能被看到、能设置样式,也能被正确播报出来。

什么时候还是离不开 JavaScript ​

原生验证检查的是单个字段自身的规则。而不少实际场景中的检查根本不是这种形状,这时候就该轮到 JavaScript 出场了。

有些事情浏览器自己没办法检查:两个密码字段是不是一致,某个用户名是不是已经被别人占用了,某个优惠码是不是真实存在。这些都不是单个字段形式对不对的问题,所以也没有对应的属性能处理,这时候就得靠 JavaScript 来做检查。

这不是 HTML 的短板。内置检查免费搞定了常见的场景,剩下的那些交给 JavaScript 处理。

漏掉的那部分,是任何依赖不止一个字段、或者依赖页面目前还没有的信息的检查。一个常见的例子是确认两次输入的密码是否一致。你在 JavaScript 里做比较,再用 setCustomValidity 把结果反馈回原生验证体系——这个方法用来给控件设置一条自定义错误信息(空字符串表示“这个值是有效的”):

html
<input type="password" id="password" name="password" required>
<input type="password" id="confirm" name="confirm" required>

比较逻辑本身写在 <script> 里:

js
const password = document.querySelector("#password")
const confirm = document.querySelector("#confirm")

confirm.addEventListener("input", () => {
  // 空字符串会清除错误,并把该字段标记为有效
  const message = confirm.value === password.value ? "" : "Passwords do not match"
  confirm.setCustomValidity(message)
})

现在这个字段就跟其他字段一样参与到原生验证里了:两次密码不一致时会阻止提交,并显示你设置的信息。像用户名是否已被占用这种需要访问服务器的检查,思路是一样的,只是要等网络请求返回结果之后再设置提示信息。

JavaScript 是通过 约束验证 API(Constraint Validation API,浏览器暴露在表单控件上的一组属性和方法,用来读取和设置有效性)接入原生验证体系的。最常用到的几个:

  • setCustomValidity(message) 给控件设置一条自定义错误信息。非空字符串会把控件标记为无效,并成为它的错误信息;空字符串会清除这个自定义错误。
  • validity 是一个只读的 ValidityState 对象:每一种可能的失败原因对应一个布尔值(valueMissing、typeMismatch、patternMismatch、rangeOverflow、tooLong、customError 等等),另外还有一个 valid 表示总体结果。读它是为了搞清楚字段为什么失败,而不只是知道它失败了。
  • checkValidity() 只返回 true 或 false,不会显示任何东西;reportValidity() 效果一样,但会同时显示原生的提示气泡。

对无障碍错误提示来说,光靠颜色永远不够:一个红色边框对屏幕阅读器、或者对分不清红绿的人来说什么都说明不了。要把错误文字跟对应字段关联起来,让辅助技术能把它们一起读出来。给 input 设置 aria-describedby,指向错误元素的 id(这会告诉屏幕阅读器“把这段文字当作这个字段的说明来读”),并在字段出错期间设置 aria-invalid="true"(这会把这个字段播报为处于错误状态):

html
<input
  type="text"
  id="username"
  name="username"
  aria-describedby="username-error"
  aria-invalid="true"
  required
>
<p id="username-error" role="alert">That username is already taken.</p>

role="alert" 会让屏幕阅读器在提示一出现就立刻播报出来。无障碍那一章会更深入地讲怎么把控件跟它们的说明文字关联起来。

这就带来了一个取舍问题。原生验证代码更少,跟平台行为保持一致,而且在你的脚本加载完成之前就已经能用了,但它的反馈很难设置样式,也很难可靠地播报出来。自定义验证要写更多代码、担更多责任,但换来的是对措辞、时机和无障碍体验的完全掌控。大多数生产环境中的表单会两者都用:原生属性打底,再用 JavaScript 补上原生属性和提示信息够不着的那些检查。

不管你怎么选,有一条规则不能通融。客户端验证只是一种便利,绝不是保障。本章讲到的一切都运行在访问者的浏览器里,任何人都可以把它关掉、修改页面,或者绕开表单直接向你的服务器发请求。服务器必须把收到的每一个值都重新验证一遍,就当浏览器端从来没做过任何检查一样。把浏览器的检查当作一道快速、友好的第一道关卡,能替用户省掉一次往返,而真正保护你数据的检查,是在服务器上完成的那一道。

Juno什么时候还是离不开 JavaScript 内置检查一次只能管一个字段,所以但凡涉及比较多个字段的情况,比如“这两个密码是不是一样”,或者需要访问服务器才能确认的情况,比如“这个用户名有没有被占用”,都得靠 JavaScript。这很正常。HTML 免费搞定了日常场景,JavaScript 接手它自己看不见的那部分。
Juno什么时候还是离不开 JavaScript 当一项检查涉及两个字段、或者需要服务器参与时,就在 JavaScript 里算出结果,再用 setCustomValidity 把结果反馈回去:传一条信息字符串就让字段失败,传空字符串就清除它。之后这个字段就跟其他字段一样参与原生验证了。密码是否一致、用户名是否可用,就是最经典的两个例子。
Juno什么时候还是离不开 JavaScript 约束验证 API 就是你的入口:setCustomValidity 设置提示信息,validity 读取字段失败的原因,checkValidity 和 reportValidity 执行检查。要做无障碍的错误提示,就用 aria-describedby 和 aria-invalid 把文字跟字段关联起来,别只靠颜色。还有一条不能省:这里的每一项检查都跑在浏览器里,所以服务器必须把它们全部重新验证一遍。