表单验证第二部分:约束验证 API (JavaScript)

Avatar of Chris Ferdinandi
Chris Ferdinandi 发表

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

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

虽然这种方法非常简单且轻量级,但它也有一些缺点。

  1. 您可以使用 :invalid 伪选择器来设置包含错误的字段的样式,但无法设置错误消息本身的样式。
  2. 浏览器之间的行为也不一致。

Christian HolstLuke Wroblewski(分别)进行的用户研究发现,当用户离开字段时显示错误,并在问题得到解决之前保持错误持续存在,可以提供最佳和最快的用户体验。

不幸的是,没有一个浏览器原生支持这种行为。但是,有一种方法可以在不依赖大型 JavaScript 表单验证库的情况下获得这种行为。

文章系列

  1. HTML 中的约束验证
  2. JavaScript 中的约束验证 API(您当前所在位置!)
  3. 有效性状态 API polyfill
  4. 验证 MailChimp 订阅表单

约束验证 API

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

API 公开了几种不同的方法,但最强大的方法,有效性状态,允许我们在脚本中使用浏览器的字段验证算法,而不是编写我们自己的算法。

在本文中,我将向您展示如何使用有效性状态来自定义表单验证错误消息的行为、外观和内容。

有效性状态

validity 属性提供了一组关于表单字段的信息,以布尔值(true/false)的形式呈现。

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

返回的对象包含以下属性

  • valid – 当字段通过验证时为 true
  • valueMissing – 当字段为空但为必填字段时为 true
  • typeMismatch – 当字段 typeemailurl 但输入的 value 不是正确的类型时为 true
  • tooShort – 当字段包含 minLength 属性且输入的 value 短于该长度时为 true
  • tooLong – 当字段包含 maxLength 属性且输入的 value 长于该长度时为 true
  • patternMismatch – 当字段包含 pattern 属性且输入的 value 与模式不匹配时为 true
  • badInput – 当输入 typenumber 且输入的 value 不是数字时为 true
  • stepMismatch – 当字段具有 step 属性且输入的 value 不符合步长值时为 true
  • rangeOverflow – 当字段具有 max 属性且输入的数字 value 大于最大值时为 true
  • rangeUnderflow – 当字段具有 min 属性且输入的数字 value 小于最小值时为 true

通过将 validity 属性与我们的输入类型和 HTML 验证属性结合使用,我们可以构建一个健壮的表单验证脚本,该脚本可以使用相对少量的 JavaScript 提供出色的用户体验。

让我们开始吧!

禁用原生表单验证

由于我们正在编写验证脚本,因此我们希望通过向表单添加 novalidate 属性来禁用原生浏览器验证。我们仍然可以使用约束验证 API——我们只是想阻止原生错误消息显示。

作为最佳实践,我们应该使用 JavaScript 添加此属性,以便如果我们的脚本出现错误或加载失败,原生浏览器表单验证仍然可以工作。

// Add the novalidate attribute when the JS loads
var forms = document.querySelectorAll('form');
for (var i = 0; i < forms.length; i++) {
    forms[i].setAttribute('novalidate', true);
}

可能有一些表单您不希望进行验证(例如,每个页面上显示的搜索表单)。与其将我们的验证脚本应用于所有表单,不如将其仅应用于具有 .validate 类的表单。

// Add the novalidate attribute when the JS loads
var forms = document.querySelectorAll('.validate');
for (var i = 0; i < forms.length; i++) {
    forms[i].setAttribute('novalidate', true);
}

查看 CodePen 上 Chris Ferdinandi (@cferdinandi) 编写的 表单验证:以编程方式添加 `novalidate`

用户离开字段时检查有效性

每当用户离开字段时,我们都希望检查它是否有效。为此,我们将设置一个事件监听器。

与其向每个表单字段添加监听器,不如使用称为事件冒泡(或事件传播)的技术来监听所有 blur 事件。

// Listen to all blur events
document.addEventListener('blur', function (event) {
    // Do something on blur...
}, true);

您会注意到 addEventListener 中的最后一个参数设置为 true。此参数称为 useCapture,通常设置为 falseblur 事件不会像 click 等事件那样冒泡。将此参数设置为 true 允许我们捕获所有 blur 事件,而不仅仅是我们正在监听的元素上发生的事件。

接下来,我们要确保模糊的元素是具有 .validate 类的表单中的字段。我们可以使用 event.target 获取模糊的元素,并通过调用 event.target.form 获取其父表单。然后,我们将使用 classList 检查表单是否具有验证类。

如果它有,我们可以检查字段有效性。

// Listen to all blur events
document.addEventListener('blur', function (event) {

    // Only run if the field is in a form to be validated
    if (!event.target.form.classList.contains('validate')) return;

    // Validate the field
    var error = event.target.validity;
    console.log(error);

}, true);

如果 errortrue,则字段有效。否则,存在错误。

查看 CodePen 上 Chris Ferdinandi (@cferdinandi) 编写的 表单验证:在失去焦点时验证

获取错误

一旦我们知道存在错误,了解错误的实际内容就很有帮助。我们可以使用其他有效性状态属性来获取该信息。

由于我们需要检查每个属性,因此此代码可能会变得有点长。让我们为此设置一个单独的函数,并将我们的字段传递给它。

// Validate the field
var hasError = function (field) {
    // Get the error
};

// Listen to all blur events
document.addEventListner('blur', function (event) {

    // Only run if the field is in a form to be validated
    if (!event.target.form.classList.contains('validate')) return;

    // Validate the field
    var error = hasError(event.target);

}, true);

有一些字段类型我们希望忽略:禁用的字段、filereset 输入,以及 submit 输入和按钮。如果字段不是其中之一,让我们获取其有效性。

// Validate the field
var hasError = function (field) {

    // Don't validate submits, buttons, file and reset inputs, and disabled fields
    if (field.disabled || field.type === 'file' || field.type === 'reset' || field.type === 'submit' || field.type === 'button') return;

    // Get validity
    var validity = field.validity;

};

如果没有错误,我们将返回 null。否则,我们将检查每个有效性状态属性,直到找到错误。

当我们找到匹配项时,我们将返回一个包含错误的字符串。如果没有任何属性为 truevalidity 为 false,我们将返回一个通用的“兜底”错误消息(我无法想象这种情况会发生,但做好计划以防意外是好的)。

// Validate the field
var hasError = function (field) {

    // Don't validate submits, buttons, file and reset inputs, and disabled fields
    if (field.disabled || field.type === 'file' || field.type === 'reset' || field.type === 'submit' || field.type === 'button') return;

    // Get validity
    var validity = field.validity;

    // If valid, return null
    if (validity.valid) return;

    // If field is required and empty
    if (validity.valueMissing) return 'Please fill out this field.';

    // If not the right type
    if (validity.typeMismatch) return 'Please use the correct input type.';

    // If too short
    if (validity.tooShort) return 'Please lengthen this text.';

    // If too long
    if (validity.tooLong) return 'Please shorten this text.';

    // If number input isn't a number
    if (validity.badInput) return 'Please enter a number.';

    // If a number value doesn't match the step interval
    if (validity.stepMismatch) return 'Please select a valid value.';

    // If a number field is over the max
    if (validity.rangeOverflow) return 'Please select a smaller value.';

    // If a number field is below the min
    if (validity.rangeUnderflow) return 'Please select a larger value.';

    // If pattern doesn't match
    if (validity.patternMismatch) return 'Please match the requested format.';

    // If all else fails, return a generic catchall error
    return 'The value you entered for this field is invalid.';

};

这是一个良好的开端,但我们可以进行一些额外的解析以使我们的一些错误更有用。对于 typeMismatch,我们可以检查它是否应该是 emailurl 并相应地自定义错误。

// If not the right type
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 too short
if (validity.tooShort) return 'Please lengthen this text to ' + field.getAttribute('minLength') + ' characters or more. You are currently using ' + field.value.length + ' characters.';

// If too long
if (validity.tooLong) return 'Please short this text to no more than ' + field.getAttribute('maxLength') + ' characters. You are currently using ' + field.value.length + ' characters.';

如果数字字段超过或低于允许的范围,我们可以将该最小或最大允许值包含在我们的错误中。

// If a number field is over the max
if (validity.rangeOverflow) return 'Please select a value that is no more than ' + field.getAttribute('max') + '.';

// If a number field is below the min
if (validity.rangeUnderflow) return 'Please select a value that is no less than ' + field.getAttribute('min') + '.';

如果存在 pattern 不匹配且字段具有 title,我们可以将其用作我们的错误,就像原生浏览器行为一样。

// If pattern doesn't match
if (validity.patternMismatch) {

    // If pattern info is included, return custom error
    if (field.hasAttribute('title')) return field.getAttribute('title');

    // Otherwise, generic error
    return 'Please match the requested format.';

}

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

// Validate the field
var hasError = function (field) {

    // Don't validate submits, buttons, file and reset inputs, and disabled fields
    if (field.disabled || field.type === 'file' || field.type === 'reset' || field.type === 'submit' || field.type === 'button') return;

    // Get validity
    var validity = field.validity;

    // If valid, return null
    if (validity.valid) return;

    // If field is required and empty
    if (validity.valueMissing) return 'Please fill out this field.';

    // If not the right type
    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 too short
    if (validity.tooShort) return 'Please lengthen this text to ' + field.getAttribute('minLength') + ' characters or more. You are currently using ' + field.value.length + ' characters.';

    // If too long
    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 number input isn't a number
    if (validity.badInput) return 'Please enter a number.';

    // If a number value doesn't match the step interval
    if (validity.stepMismatch) return 'Please select a valid value.';

    // If a number field is over the max
    if (validity.rangeOverflow) return 'Please select a value that is no more than ' + field.getAttribute('max') + '.';

    // If a number field is below the min
    if (validity.rangeUnderflow) return 'Please select a value that is no less than ' + field.getAttribute('min') + '.';

    // If pattern doesn't match
    if (validity.patternMismatch) {

        // If pattern info is included, return custom error
        if (field.hasAttribute('title')) return field.getAttribute('title');

        // Otherwise, generic error
        return 'Please match the requested format.';

    }

    // If all else fails, return a generic catchall error
    return 'The value you entered for this field is invalid.';

};

在下面的 CodePen 中自己尝试一下。

查看 CodePen 上 Chris Ferdinandi (@cferdinandi) 编写的 表单验证:获取错误

显示错误消息

一旦我们得到错误,我们可以在字段下方显示它。我们将创建一个showError()函数来处理此问题,并将我们的字段和错误作为参数传递。然后,我们将在我们的事件监听器中调用它。

// Show the error message
var showError = function (field, error) {
    // Show the error message...
};

// Listen to all blur events
document.addEventListener('blur', function (event) {

    // Only run if the field is in a form to be validated
    if (!event.target.form.classList.contains('validate')) return;

    // Validate the field
    var error = hasError(event.target);

    // If there's an error, show it
    if (error) {
        showError(event.target, error);
    }

}, true);

在我们的showError函数中,我们将执行以下几件事

  1. 我们将向带有错误的字段添加一个类,以便我们可以对其进行样式设置。
  2. 如果错误消息已存在,我们将使用新文本更新它。
  3. 否则,我们将创建一个消息并将其注入到字段之后的 DOM 中。

我们还将使用字段 ID 为消息创建唯一的 ID,以便我们以后可以再次找到它(如果不存在 ID,则回退到字段name)。

var showError = function (field, error) {

    // Add error class to field
    field.classList.add('error');

    // Get field id or name
    var id = field.id || field.name;
    if (!id) return;

    // Check if error message field already exists
    // If not, create one
    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 );
    }

    // Update error message
    message.innerHTML = error;

    // Show error message
    message.style.display = 'block';
    message.style.visibility = 'visible';

};

为了确保屏幕阅读器和其他辅助技术知道我们的错误消息与我们的字段相关联,我们还需要添加aria-describedby角色。

var showError = function (field, error) {

    // Add error class to field
    field.classList.add('error');

    // Get field id or name
    var id = field.id || field.name;
    if (!id) return;

    // Check if error message field already exists
    // If not, create one
    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 );
    }

    // Add ARIA role to the field
    field.setAttribute('aria-describedby', 'error-for-' + id);

    // Update error message
    message.innerHTML = error;

    // Show error message
    message.style.display = 'block';
    message.style.visibility = 'visible';

};

设置错误消息的样式

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

作为一个简单的示例,您可能希望在带有错误的字段周围显示红色边框,并将错误消息设置为红色和斜体。

.error {
  border-color: red;
}

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

查看 Chris Ferdinandi 在 CodePen 上的笔 表单验证:显示错误 (@cferdinandi)。

隐藏错误消息

一旦我们显示错误,您的访客将(希望)修复它。一旦字段验证通过,我们需要删除错误消息。让我们创建另一个函数removeError(),并将字段作为参数传递。我们也将从事件监听器中调用此函数。

// Remove the error message
var removeError = function (field) {
    // Remove the error message...
};

// Listen to all blur events
document.addEventListener('blur', function (event) {

    // Only run if the field is in a form to be validated
    if (!event.target.form.classList.contains('validate')) return;

    // Validate the field
    var error = event.target.validity;

    // If there's an error, show it
    if (error) {
        showError(event.target, error);
        return;
    }

    // Otherwise, remove any existing error message
    removeError(event.target);

}, true);

removeError()中,我们希望

  1. 从我们的字段中删除错误类。
  2. 从字段中删除aria-describedby角色。
  3. 隐藏 DOM 中任何可见的错误消息。

因为我们可能在一个页面上有多个表单,并且这些表单有可能具有相同名称或 ID 的字段(即使这无效,但确实会发生),我们将使用querySelector限制我们对表单中字段所在表单的错误消息的搜索,而不是整个文档。

// Remove the error message
var removeError = function (field) {

    // Remove error class to field
    field.classList.remove('error');

    // Remove ARIA role from the field
    field.removeAttribute('aria-describedby');

    // Get field id or name
    var id = field.id || field.name;
    if (!id) return;

    // Check if an error message is in the DOM
    var message = field.form.querySelector('.error-message#error-for-' + id + '');
    if (!message) return;

    // If so, hide it
    message.innerHTML = '';
    message.style.display = 'none';
    message.style.visibility = 'hidden';

};

查看 Chris Ferdinandi 在 CodePen 上的笔 表单验证:修复后删除错误 (@cferdinandi)。

如果字段是单选按钮或复选框,我们需要更改我们向 DOM 添加错误消息的方式。

对于这些类型的输入,字段标签通常出现在字段之后,或者完全包含它。此外,如果单选按钮是某个组的一部分,我们希望错误出现在该组之后,而不是仅出现在单选按钮之后。

查看 Chris Ferdinandi 在 CodePen 上的笔 表单验证:单选按钮和复选框的问题 (@cferdinandi)。

首先,我们需要修改我们的showError()方法。如果字段typeradio并且它具有name,我们希望获取所有具有相同name的单选按钮(即组中的所有其他单选按钮),并将我们的field变量重置为组中的最后一个。

// Show the error message
var showError = function (field, error) {

    // Add error class to field
    field.classList.add('error');

    // If the field is a radio button and part of a group, error all and get the last item in the group
    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.add('error');
            }
            field = group[group.length - 1];
        }
    }

    ...

};

当我们要将消息注入到 DOM 中时,我们首先要检查字段类型是否为radiocheckbox。如果是,我们希望获取字段标签,并将我们的消息注入到它的后面,而不是字段本身的后面。

// Show the error message
var showError = function (field, error) {

    ...

    // Check if error message field already exists
    // If not, create one
    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;

        // If the field is a radio button or checkbox, insert error after the 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 );
            }
        }

        // Otherwise, insert it after the field
        if (!label) {
            field.parentNode.insertBefore( message, field.nextSibling );
        }
    }

    ...

};

当我们要删除错误时,我们也需要类似地检查字段是否为某个组的一部分的单选按钮,如果是,则使用该组中的最后一个单选按钮来获取错误消息的 ID。

// Remove the error message
var removeError = function (field) {

    // Remove error class to field
    field.classList.remove('error');

    // If the field is a radio button and part of a group, remove error from all and get the last item in the group
    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 在 CodePen 上的笔 表单验证:修复单选按钮和复选框 (@cferdinandi)。

提交时检查所有字段

当访客提交我们的表单时,我们应该首先验证表单中的每个字段,并在任何无效字段上显示错误消息。我们还应该将第一个带有错误的字段聚焦,以便访客可以立即采取措施进行更正。

我们将通过为submit事件添加一个监听器来做到这一点。

// Check all fields on submit
document.addEventListener('submit', function (event) {
    // Validate all fields...
}, false);

如果表单具有.validate类,我们将获取每个字段,循环遍历每个字段,并检查错误。我们将找到的第一个无效字段存储到一个变量中,并在完成后将其聚焦。如果未找到错误,则表单可以正常提交。

// Check all fields on submit
document.addEventListener('submit', function (event) {

    // Only run on forms flagged for validation
    if (!event.target.classList.contains('validate')) return;

    // Get all of the form elements
    var fields = event.target.elements;

    // Validate each field
    // Store the first field with an error to a variable so we can bring it into focus later
    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 there are errrors, don't submit form and focus on first element with error
    if (hasErrors) {
        event.preventDefault();
        hasErrors.focus();
    }

    // Otherwise, let the form submit normally
    // You could also bolt in an Ajax form submit process here

}, false);

查看 Chris Ferdinandi 在 CodePen 上的笔 表单验证:提交时验证 (@cferdinandi)。

整合所有内容

我们完成的脚本仅重 6kb(缩小后为 2.7kb)。您可以在 GitHub 上下载插件版本

它适用于所有现代浏览器,并提供对 IE10 及更高版本的支持。但是,有一些浏览器问题……

  1. 因为我们不能拥有美好的事物,并非每个浏览器都支持每个 Validity State 属性
  2. 当然,Internet Explorer 是主要的违规者,尽管 Edge 缺乏对tooLong的支持,即使 IE10+ 支持它。真是难以理解。

好消息是:使用轻量级 polyfill(5kb,缩小后为 2.7kb),我们可以将浏览器支持扩展到 IE9,并将缺失的属性添加到部分支持的浏览器,而无需触及任何核心代码。

IE9 支持有一个例外:单选按钮。IE9 不支持 CSS3 选择器(如[name="' + field.name + '"])。我们使用它来确保在组中至少选择了一个单选按钮。IE9 将始终返回错误。

我将在下一篇文章中向您展示如何创建此 polyfill。

文章系列

  1. HTML 中的约束验证
  2. JavaScript 中的约束验证 API(您当前所在位置!)
  3. 有效性状态 API polyfill
  4. 验证 MailChimp 订阅表单