cherryjin

表单验证第二部分: 约束验证 API (JavaScript) |CSS-Tricks

原文链接: css-tricks.com

在上一篇文章中, 我向大家展示了如何通过组合输入类型 (例如, <input type='email'>) 和验证属性 (像requiredpattern)来使用原生的浏览器表单验证机制。

诚然,这种方式非常的简单而且轻量级。但是它仍然具有一些缺点 。

  1. 你可以通过 :invalid伪选择器对出现错误的字段域设置样式, 但是你并不能对错误信息本身设置样式。

  2. 各浏览器之间的行为也不一致.

通过 Christian HolstLuke Wroblewski (这两个是分开的)了解到,当用户离开一个字段时,即时的显示出错误信息,并在用户修复它之前一直保持错误信息的显示,这会提供一个更快更好的用户体验。

不幸的是, 没有一个浏览器的行为是这样的。 但是, 我们仍然有方法可以赋予浏览器这个行为,而不通过庞大的JavaScript表单验证库。

文章系列:

  1. HTML中的约束验证

  2. 用 JavaScript编写约束验证API (你正在阅读此篇!)

  3. 一个 Validity State API Polyfill

  4. 验证MailChimp订阅表单

约束验证API

除了 HTML 属性, 原生的浏览器验证也为我们提供了一个 JavaScript API,我们可以使用它来自定义表单验证的行为。

虽然这个 API 只暴露了少许的方法,但它们都非常强大, Validity State, 允许我们在脚本中使用浏览器自己的字段验证算法而不再需要我们自己编写。

在这篇文章中, 我会像你展示如何利用 Validity State自定义表单验证错误信息的行为,样式,和内容。

Validity State

validity 属性以布尔值(true/false)的形式提供了一系列关于表单域的信息 。

var myField = document.querySelector('input[type="text"]');
var validityState = myField.validity;

这个返回的对象包含下列属性:

  • valid - 当字段通过验证时,属性值为 true.

  • valueMissing - 当一个必填字段为空时,属性值为 true.

  • typeMismatch - 当字段的 typeemail或者 url但是输入的 value 不是正确的类型时,属性值为true.

  • tooShort - 当一个字段设置了 minLength 属性,且输入的 value 的长度小于设定值时,属性值为 true .

  • tooLong - 当一个字段设置了 maxLength 属性,且输入的 value的长度值大于设定值时,属性值为true .

  • patternMismatch - 当一个字段包含 pattern 属性,且输入的 value 与 pattern不匹配时,属性值为 true.

  • badInput - 当输入类型 type 值是 number ,且输入的value值不是一个数字时,属性值为 true

  • stepMismatch - 当字段拥有 step 属性,且输入的 value 值不符合设定的间隔值时,该属性值为 true

  • rangeOverflow - 当字段拥有 max 属性,且输入的数字 value 值大于设定的最大值时,该属性的值为true

  • rangeUnderflow - 当字段拥有 min 属性,且输入的数字 value 小于设定的最小值时,该属性的值为true

通过 validity 属性以及结合我们的输入类型和HTML验证属性, 我们只需要使用一点JavaScript代码就可以创建一个强健的表单验证脚本来为我们提供非常棒的用户体验。

让我们来实现它!

###禁用原生的表单验证

由于我们需要编写我们自己的验证脚本, 因此我们给表单添加 novalidate 属性来禁用浏览器原生的验证方式。我们仍然可以使用约束验证API — 我们只是想要阻止原生的错误信息的显示而已。

最好的方法是,使用JavaScript来添加这个属性,如果我们的脚本在加载过程中出现错误,也可以保证浏览器原生的表单验证方式可以正常运行。

// 当JS加载时添加 novalidate属性
var forms = document.querySelectorAll('form');
for (var i = 0; i < forms.length; i++) {
    forms[i].setAttribute('novalidate', true);
}

可能有些表单你不想进行验证(例如,每个页面都会显示的搜索表单)。 所以不是给每个表单都运行我们的验证脚本,而是只验证那些有 .validate 类的表单。

// 当JS加载时添加 novalidate属性
var forms = document.querySelectorAll('.validate');
for (var i = 0; i < forms.length; i++) {
    forms[i].setAttribute('novalidate', true);
}

查看Chris Ferdinandi的演示 Form Validation: Add novalidate programatically (@cferdinandi) 在 CodePen上。

当用户离开一个字段时,对该字段进行验证

无论用户何时离开一个字段, 我们都想要检查其输入的值是否合法。为了实现这个功能,我们需要注册一个事件监听器。

并不是为每个字段都添加一个监听器, 我们可以利用一个叫做事件冒泡(或者事件传播)的技术来监听所有的 blur 事件。

//监听所有的失去焦点的事件
document.addEventListener('blur', function (event) {
    // 当失去焦点时进行一些处理...
}, true);

你可能会注意到 addEventListener 的最后一个参数被设置成了 true. 这个参数叫做 useCapture, 它通常被设置成 falseblur 事件不像 click 事件,它并不能冒泡。设置这个参数为 true,我们就可以捕获所有的 blur 事件,而不仅仅只是那些发生在我们直接监听的元素上的。

接下来, 我们要确认这个失去焦点的元素是不是带有 .validate的表单中的字段, 我们可以调用event.target来获取这个元素, 调用event.target.form来获取它的父元素。 接着我们可以使用 classList来检查这个表单是否拥有 validation 类 。

如果以上条件都符合, 我们就可以检测字段是否符合验证规则。

// 监听所有的失去焦点的事件
document.addEventListener('blur', function (event) {

    // 只有当该字段是要验证的表单时才运行
    if (!event.target.form.classList.contains('validate')) return;

    // 验证字段
    var error = event.target.validity;
    console.log(error);

}, true);

如果 error 的值是 true, 那么这个字段就是符合验证规则的. 否则, 就会抛出错误。

查看Chris Ferdinandi的演示 Form Validation: Validate On Blur (@cferdinandi) 在CodePen上。

###捕获错误

一旦我们知道产生了一个错误, 那弄清楚这个错误的具体信息,对于我们来说是非常有用的.。我们可以使用其他 Validity State 属性来获取这些信息。

既然我们要检测每一个属性, 把代码放在一起可能会有一点长。我们可以把这部分代码写在一个单独的函数里,然后把表单字段传递给它。

// 验证字段
var hasError = function (field) {
    // Get the error
};

// 监听所有的失去焦点的事件
document.addEventListner('blur', function (event) {

    // 只有当该字段是要验证的表单时才运行
    if (!event.target.form.classList.contains('validate')) return;

    //验证字段
    var error = hasError(event.target);

}, true);

这有一些我们需要忽略的字段: 被禁用的字段, filereset 输入类型, 以及submit 输入类型 和 按钮。如果一个字段并不在上述字段中 ,我们就可以对它进行验证。

//验证字段
var hasError = function (field) {

    // 不要验证submits, buttons, file 和 reset inputs,以及被禁用的字段
    if (field.disabled || field.type === 'file' || field.type === 'reset' || field.type === 'submit' || field.type === 'button') return;

    // 获取 validity
    var validity = field.validity;

};

如果没有错误, 我们就返回 null. 否则,我们就检测每一个 Validity State属性,直到找到错误。

如果我们找到了这个属性, 我们就返回一个包含错误信息的字符串。如果没有一个属性的值是 true 但是 validity的属性值却是 false时, 我们就返回一个通用的 "catchall" 错误信息(我无法想象出现这种情况的场景,但未雨绸缪总是好的。)。

// 验证字段
var hasError = function (field) {

    // 不验证 submits, buttons, file和reset inputs,以及被禁用的字段
    if (field.disabled || field.type === 'file' || field.type === 'reset' || field.type === 'submit' || field.type === 'button') return;

    // 获取 validity
    var validity = field.validity;

    // 如果通过验证,就返回 null
    if (validity.valid) return;

    // 如果是必填字段但是字段为空时
    if (validity.valueMissing) return 'Please fill out this field.';

    // 如果类型不正确
    if (validity.typeMismatch) return 'Please use the correct input type.';

    // 如果输入的字符数太短
    if (validity.tooShort) return 'Please lengthen this text.';

    //如果输入的字符数太长
    if (validity.tooLong) return 'Please shorten this text.';

    // 如果数字输入类型输入的值不是数字
    if (validity.badInput) return 'Please enter a number.';

    //如果数字值与步进间隔不匹配
    if (validity.stepMismatch) return 'Please select a valid value.';

    // 如果数字字段值超过max的值
    if (validity.rangeOverflow) return 'Please select a smaller value.';

    // 如果数字字段的值小于min的值
    if (validity.rangeUnderflow) return 'Please select a larger value.';

    //如果模式不匹配
    if (validity.patternMismatch) return 'Please match the requested format.';

    // 如果是其他的错误,就返回一个通用的catchall错误
    return 'The value you entered for this field is invalid.';

};

这是一个好的开端, 但是我们也可以做一些额外的解析,使得我们的错误提示更有用。对于 typeMismatch, 我们可以检测它具体是 email 还是 url ,然后相应的去设置错误信息。

// 如果输入的类型不正确
if (validity.typeMismatch) {

    // Email
    if (field.type === 'email') return 'Please enter an email address.';

    // URL
    if (field.type === 'url') return 'Please enter a URL.';

}

如果这个字段的值太长或者太短, 我们可以检测出这个字段本身设定的长度以及字段实际的长度。 我们可以把这些信息包含在错误提示当中。

// 如果输入的字符数太短
if (validity.tooShort) return 'Please lengthen this text to ' + field.getAttribute('minLength') + ' characters or more. You are currently using ' + field.value.length + ' characters.';

// 如果输入的字符数太长
if (validity.tooLong) return 'Please short this text to no more than ' + field.getAttribute('maxLength') + ' characters. You are currently using ' + field.value.length + ' characters.';

如果一个数字字段值超出了规定范围,我们可以在我们的错误提示中包含设定的最小值和最大值。

// 如果一个数字字段的值超过max的值
if (validity.rangeOverflow) return 'Please select a value that is no more than ' + field.getAttribute('max') + '.';

// 如果一个数字字段的值低于min的值
if (validity.rangeUnderflow) return 'Please select a value that is no less than ' + field.getAttribute('min') + '.';

如果 pattern 与字段值不匹配,并且这个字段有一个 title, 我们就用title属性的值作为我们的错误信息,就像原生的浏览器行为一样。

//如果模式不匹配
if (validity.patternMismatch) {

    // 如果包含模式信息,返回自定义错误
    if (field.hasAttribute('title')) return field.getAttribute('title');

    // 否则, 返回一般错误
    return 'Please match the requested format.';

}

这是我们的 hasError() 函数完整的代码。

// 验证字段
var hasError = function (field) {

    // 不验证 submits, buttons, file和reset inputs,以及被禁用的字段
    if (field.disabled || field.type === 'file' || field.type === 'reset' || field.type === 'submit' || field.type === 'button') return;

    // 获取 validity
    var validity = field.validity;

    // 如果通过验证,就返回 null
    if (validity.valid) return;

    // 如果是必填字段但是字段为空时
    if (validity.valueMissing) return 'Please fill out this field.';

    // 如果类型不正确
    if (validity.typeMismatch) {

        // Email
        if (field.type === 'email') return 'Please enter an email address.';

        // URL
        if (field.type === 'url') return 'Please enter a URL.';

    }

    // 如果输入的字符数太短
    if (validity.tooShort) return 'Please lengthen this text to ' + field.getAttribute('minLength') + ' characters or more. You are currently using ' + field.value.length + ' characters.';

    // 如果输入的字符数太长
    if (validity.tooLong) return 'Please shorten this text to no more than ' + field.getAttribute('maxLength') + ' characters. You are currently using ' + field.value.length + ' characters.';

    // 如果数字输入类型输入的值不是数字
    if (validity.badInput) return 'Please enter a number.';

    // 如果数字值与步进间隔不匹配
    if (validity.stepMismatch) return 'Please select a valid value.';

    // 如果数字字段的值大于max的值
    if (validity.rangeOverflow) return 'Please select a value that is no more than ' + field.getAttribute('max') + '.';

    // 如果数字字段的值小于min的值
    if (validity.rangeUnderflow) return 'Please select a value that is no less than ' + field.getAttribute('min') + '.';

    // 如果模式不匹配
    if (validity.patternMismatch) {

        // 如果包含模式信息,返回自定义错误
        if (field.hasAttribute('title')) return field.getAttribute('title');

        // 否则, 返回一般错误
        return 'Please match the requested format.';

    }

    // 如果是其他的错误, 返回一个通用的 catchall 错误
    return 'The value you entered for this field is invalid.';

};

在codepen上自己试一下。

查看 Chris Ferdinandi的演示 Form Validation: Get the Error (@cferdinandi) 在 CodePen上。

显示错误信息

一旦我们捕获到错误,我们就可以在字段的下面将它显示出来。我们可以创建一个 showError() 函数来实现这个功能,传入表单字段和错误信息,然后在我们的事件监听器里调用它。

// 显示错误信息
var showError = function (field, error) {
    // 显示错误信息...
};

// 监听所有的blur事件
document.addEventListener('blur', function (event) {

    // 只有当该字段是要验证的表单时才运行
    if (!event.target.form.classList.contains('validate')) return;

    // 验证字段
    var error = hasError(event.target);

    // 如果有错误,就把它显示出来
    if (error) {
        showError(event.target, error);
    }

}, true);

在我们的 showError函数中, 我们会实现以下功能:

  1. 我们可以为带有错误的字段添加一个class ,这样我们就可以给它设定样式。

  2. 如果一个错误信息已经存在, 我们就用新的信息去更新它。

  3. 否则, 我们就创建一个信息,并在DOM结构中把它插入到该字段的后面。

我们 使用字段ID为这个信息创建一个独一无二的 ID,稍后我们可以获取到它(如果没有ID就返回这个字段的 name )。

var showError = function (field, error) {

    // 给字段添加错误类
    field.classList.add('error');

    // 获取字段 id 或 name
    var id = field.id || field.name;
    if (!id) return;

    // 检查错误消息字段是否已经存在
    // 如果不存在,就创建一个
    var message = field.form.querySelector('.error-message#error-for-' + id );
    if (!message) {
        message = document.createElement('div');
        message.className = 'error-message';
        message.id = 'error-for-' + id;
        field.parentNode.insertBefore( message, field.nextSibling );
    }

    // 更新错误信息
    message.innerHTML = error;

    // 显示错误信息
    message.style.display = 'block';
    message.style.visibility = 'visible';

};

为了确保屏幕浏览器或以及其它的辅助技术知道我们的错误信息与字段是相关联的, 我们需要添加一个 aria-describedby

var showError = function (field, error) {

    // 将错误类添加到字段
    field.classList.add('error');

    // 获取字段id 或者 name
    var id = field.id || field.name;
    if (!id) return;

    // 检查错误消息字段是否已经存在
    // 如果没有, 就创建一个
    var message = field.form.querySelector('.error-message#error-for-' + id );
    if (!message) {
        message = document.createElement('div');
        message.className = 'error-message';
        message.id = 'error-for-' + id;
        field.parentNode.insertBefore( message, field.nextSibling );
    }

    // 添加ARIA role 到字段
    field.setAttribute('aria-describedby', 'error-for-' + id);

    // 更新错误信息
    message.innerHTML = error;

    // 显示错误信息
    message.style.display = 'block';
    message.style.visibility = 'visible';

};

给错误信息设置样式

我们可以使用 .error.error-message 类来为我们的表单域和错误信息设置样式。

这有一个简单例子, 你可能希望在字段出现错误时把它的边框变成红色,并且让错误信息以红色斜体的格式显示。

.error {
  border-color: red;
}

.error-message {
  color: red;
  font-style: italic;
}

查看Chris Ferdinandi的演示 Form Validation: Display the Error (@cferdinandi) 在CodePen上。

隐藏错误信息

一旦我们显示一个错误,你的用户就会去修复它(希望是)。当字段验证通过,我们希望移除这个错误信息。所以,让我们来创建另外一个函数, removeError(),并且传入字段。我们仍然在事件监听器中调用这个函数。

// 移除错误信息
var removeError = function (field) {
    // Remove the error message...
};

// 监听所有的 blur 事件
document.addEventListener('blur', function (event) {

    // 只有当该字段是要验证的表单时才运行
    if (!event.target.form.classList.contains('validate')) return;

    // 验证字段
    var error = event.target.validity;

    // 如果有错误, 显示出来
    if (error) {
        showError(event.target, error);
        return;
    }

    // 否则, 移除所有存在的错误信息
    removeError(event.target);

}, true);

removeError()函数里, 我们想要实现下面这个功能:

  1. 移除字段的 error class。

  2. 移除字段的aria-describedby

  3. 在DOM中隐藏所有的错误信息。

有时一个页面上会有多个表单,这些表单有可能具有相同的name或者ID (即使这是不符合规范的, 但这的确是发生了。), 我们使用querySelector方法,只在当前的表单字段上搜索错误信息,而不是整个document。

// 移除所有的错误信息
var removeError = function (field) {

    // 删除字段的错误类
    field.classList.remove('error');

    // 移除字段的 ARIA role
    field.removeAttribute('aria-describedby');

    //获取字段的 id 或者 name
    var id = field.id || field.name;
    if (!id) return;

    // 检查DOM中是否有错误消息
    var message = field.form.querySelector('.error-message#error-for-' + id + '');
    if (!message) return;

    // 如果是这样的话, 就隐藏它
    message.innerHTML = '';
    message.style.display = 'none';
    message.style.visibility = 'hidden';

};

查看 Chris Ferdinandi的演示 Form Validation: Remove the Error After It's Fixed (@cferdinandi)在 CodePen上。

如果这个字段是一个单选按钮或者复选框,我们需要改变把错误信息添加到DOM结构上的方法。

label标签经常出现在一个字段的后面,或者包围着一个字段,像那些输入类型。 此外,如果单选按钮是一个集合的一部分, 我们想要这个错误信息出现在这个集合的后面而不是单选按钮的后面。

查看Chris Ferdinandi的演示Form Validation: Issues with Radio Buttons & Checkboxes (@cferdinandi) 在 CodePen上。

首先, 我们需要修改一下 showError() 方法。 如果一个字段的 typeradio 并且它拥有一个 name, 我们就获取所有具有相同name的单选按钮 (即. 在这个集合中其他所有的单选按钮) 并且将这个集合中的最后一个元素赋值给 field变量。

// 显示错误信息
var showError = function (field, error) {

    // 给字段添加错误类
    field.classList.add('error');

    // 如果字段是一个单选按钮并且是集合的一部分,给所有的字段添加错误类并获取集合的最后一个元素
    if (field.type === 'radio' && field.name) {
        var group = document.getElementsByName(field.name);
        if (group.length > 0) {
            for (var i = 0; i < group.length; i++) {
                //只检查当前表单的字段
                if (group[i].form !== field.form) continue;
                group[i].classList.add('error');
            }
            field = group[group.length - 1];
        }
    }

    ...

};

当我们向DOM添加我们的信息时,首先我们应该检查一下该字段的类型是否是 radio 或者 checkbox。如果是的话,我们就获取label标签,将我们的错误信息插入到label标签的后面,而不是字段后面。

// 显示错误信息
var showError = function (field, error) {

    ...

    // 检查错误信息字段是否已经存在
    //如果不存在, 就创建一个
    var message = field.form.querySelector('.error-message#error-for-' + id );
    if (!message) {
        message = document.createElement('div');
        message.className = 'error-message';
        message.id = 'error-for-' + id;

        // 如果字段是一个单选按钮或复选框, 把错误字段插在label字段的后面
        var label;
        if (field.type === 'radio' || field.type ==='checkbox') {
            label = field.form.querySelector('label[for="' + id + '"]') || field.parentNode;
            if (label) {
                label.parentNode.insertBefore( message, label.nextSibling );
            }
        }

        // 否则,就插入在字段本身的后面
        if (!label) {
            field.parentNode.insertBefore( message, field.nextSibling );
        }
    }

    ...

};

当我们移除错误信息时,我们同样需要检查字段是否是一个集合中的单选按钮, 如果是的话,则使用这个集合中的最后一个单选按钮来获取我们的错误信息的ID 。

// 移除错误信息
var removeError = function (field) {

    // 移除字段的错误类
    field.classList.remove('error');

    // 如果字段是一个单选按钮并且是集合的一部分, 移除所有的错误信息并且获取集合的最后一个元素
    if (field.type === 'radio' && field.name) {
        var group = document.getElementsByName(field.name);
        if (group.length > 0) {
            for (var i = 0; i < group.length; i++) {
                // Only check fields in current form
                if (group[i].form !== field.form) continue;
                group[i].classList.remove('error');
            }
            field = group[group.length - 1];
        }
    }

    ...

};

查看Chris Ferdinandi的演示 Form Validation: Fixing Radio Buttons & Checkboxes (@cferdinandi) 在 CodePen上。

在提交表单时检查所有的字段

当用户提交表单时, 我们要对表单里的每一个字段进行验证,并且在不合法的字段下面显示错误信息。我们也需要给出现错误的字段默认获得焦点,这样用户就能第一时间的去修复它们。

要实行上述功能,我们需要给 submit事件添加一个事件监听器。

// 在提交之前检查所有的字段
document.addEventListener('submit', function (event) {
    // 验证所有的字段...
}, false);

如果表单拥有 .validate 类, 我们就获取表单的所有字段,并且依次迭代它们来检查错误信息。我们把检测到具有错误的表单字段存放在一个变量中,并在检查结束之后使其获得焦点。如果没有发现错误,表单就可以正常提交。

// 在提交时检查所有的字段
document.addEventListener('submit', function (event) {

    //只能运行在标记为验证的表单上
    if (!event.target.classList.contains('validate')) return;

    //获取所有的表单元素
    var fields = event.target.elements;

    //验证每一个字段
    // 将具有错误的第一个字段存储到变量中以便稍后我们将其默认获得焦点
    var error, hasErrors;
    for (var i = 0; i < fields.length; i++) {
        error = hasError(fields[i]);
        if (error) {
            showError(fields[i], error);
            if (!hasErrors) {
                hasErrors = fields[i];
            }
        }
    }

    // 如果有错误,停止提交表单并使出现错误的第一个元素获得焦点 
    if (hasErrors) {
        event.preventDefault();
        hasErrors.focus();
    }

    // 否则,正常提交表单
    // 您也可以在此处填入Ajax表单提交过程
}, false);

查看Chris Ferdinandi的演示Form Validation: Validate on Submit (@cferdinandi) 在 CodePen上。

把所有的代码都集合起来

我们的代码只有 6kb (2.7kb 压缩后). 你可以 在 GitHub上下载插件版本.

它能够在所有的现代浏览器上正常工作,并且支持 IE10以下的浏览器。但是,浏览器本身仍然有一些缺陷…

  1. 万事总不是那么的美好, 并不是所有的浏览器都支持是每一个 Validity State属性.

  2. Internet Explorer 就是这样, 当然, 虽然IE10+ 支持tooLong但是 Edge 并不支持。 这要搞清楚。

这有一个好消息: 通过一个轻量级的 polyfill (5kb, 2.7kb 压缩后) 我们可以将浏览器的支持性扩展到 IE9, 只需要添加那些IE不支持的属性,而不需要动我们的核心代码。

这有一个除了IE9其余的浏览器都支持的字段:单选按钮。IE9 不支持 CSS3 选择器 (像 [name="' + field.name + '"])。所以当我们使用这种方式来确定一个集合中被选择的按钮时,IE9会报错。

在下一篇文章中,我会向你介绍如何创建这个polyfill 。

文章系列:

  1. HTML中的约束验证

  2. 用JavaScript编写一个 (你正在阅读此篇!)

  3. 一个 Validity State API Polyfill

  4. 验证MailChimp订阅表单