PAYATHON 2026

ASP.NET Core MVC 中 jQuery validation 客户端验证不生效

排障赵师傅

结论

客户端验证没有触发,原因有两个:

  1. document.forms['login'].submit() 调用原生 HTMLFormElement.submit(),会直接提交表单,不触发 submit 事件。jQuery Validation 注册的提交处理器因此不会执行。
  2. document.body.innerHTML = ... 会重建 <body> 下的所有 DOM 节点。原节点上的事件、jQuery 数据和验证器状态都会丢失,之后再重新解析表单也很容易出问题。

data-val-email 保存的是验证失败时显示的消息,不是验证规则编号。设置 data-val-email="2",只会让 email 规则的错误消息变成 "2",不会选择编号为 2 的验证器。

改动最少的办法是直接更新输入元素的属性,清除表单中的验证缓存,然后调用 $.validator.unobtrusive.parse()。提交控件则应使用真正的 type="submit" 按钮。

为什么代码没有报错

下面这段代码符合 JavaScript 语法,也是在调用合法的浏览器 API:

document.forms['login'].submit();

它的问题是绕过了正常的表单提交流程。jQuery Validation 依靠表单的 submit 事件执行验证:

$form.on("submit", ...);

原生 form.submit() 既不会触发这个事件,也不会执行浏览器的原生约束验证。表单会直接发送到服务器,所以看起来像是客户端验证完全失效了。

aria-required="true" 只是辅助功能信息,不会生成验证规则。客户端规则来自 data-val="true"、data-val-required、data-val-email 等属性。

替换验证消息并重新解析

直接修改目标输入框的 data-val-* 属性,不要重写 document.body.innerHTML:

$(function () {
    const $form = $('#login');

    const emailRequired = $('#ErrorEmailRequired').val();
    const emailInvalid = $('#ErrorInvalidEmail').val();
    const passwordRequired = $('#ErrorPasswordRequired').val();

    $('#Email')
        .attr('data-val-required', emailRequired)
        .attr('data-val-email', emailInvalid);

    $('#Password')
        .attr('data-val-required', passwordRequired);

    $form.removeData('validator');
    $form.removeData('unobtrusiveValidation');

    $.validator.unobtrusive.parse($form);
});

处理顺序如下:

  1. 更新 data-val-* 属性。
  2. 删除已经创建的 validator 和 unobtrusive validation 数据。
  3. 重新解析表单。

无需删除 novalidate:

form.removeAttr("novalidate");

jQuery Validation 会设置 novalidate,关闭浏览器自带的提示框,统一由插件显示错误消息。删除这个属性后,浏览器原生验证和 jQuery Validation 可能会同时介入。

使用真正的提交按钮

将链接:

<a id="submitButton" class="btn btn-primary">Login</a>

改成:

<button id="submitButton" type="submit" class="btn btn-primary">
    Login
</button>

同时删除这段事件处理代码:

$('#submitButton').on('click', function () {
    document.forms['login'].submit();
});

点击 type="submit" 按钮会触发表单的 submit 事件,jQuery Validation 才能检查规则,并在表单无效时阻止提交。

如果业务上必须使用链接,需要显式调用 .valid():

$('#submitButton').on('click', function (event) {
    event.preventDefault();

    const $form = $('#login');

    if ($form.valid()) {
        $form[0].submit();
    }
});

这段代码只会在 .valid() 返回 true 后调用原生 submit()。考虑到语义、键盘操作和可访问性,登录操作仍应优先使用 <button type="submit">。

document.body.innerHTML 的问题

执行下面的代码时:

document.body.innerHTML = document.body.innerHTML.replace(...);

浏览器会先把 <body> 中的 HTML 序列化为字符串,再根据修改后的字符串创建一套新节点。旧节点中的这些内容都会丢失:

  • jQuery 事件处理器;
  • jQuery Validation 的内部数据;
  • unobtrusive validation 的解析结果;
  • 其他组件附加的状态;
  • 指向旧 DOM 节点的 JavaScript 引用。

字符串替换还会带来匹配范围和编码问题。String.replace() 默认只替换第一个匹配项。如果翻译内容包含引号、<、> 或 &,直接拼接 HTML 也容易出错。使用 .attr() 修改属性可以避开这些问题。

自定义 Attribute 不会自动生成客户端规则

自定义 Attribute 只继承 ValidationAttribute 时,ASP.NET Core MVC 无法判断它在客户端对应 required 还是 email:

public class MultiLanguageEmailAddressAttribute : ValidationAttribute

重写 IsValid() 只能定义服务器端验证行为,不会自动生成 jQuery Validation 所需的 data-val-email。

如果要继续使用 Attribute,并由框架生成客户端规则,可以继承内置 Attribute,同时实现 IClientModelValidator。翻译键仍保存在 Attribute 中,页面渲染时再由服务读取实际消息。

以 email 验证为例:

using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;

public sealed class MultiLanguageEmailAddressAttribute
    : EmailAddressAttribute, IClientModelValidator
{
    private Translation _errorMessageId;

    public Translation ErrorMessageId
    {
        get => _errorMessageId;
        set
        {
            _errorMessageId = value;

            // 服务器端验证仍返回消息编号,
            // 后续可以按现有方式翻译 ModelState。
            ErrorMessage = ((int)value).ToString();
        }
    }

    public void AddValidation(ClientModelValidationContext context)
    {
        var messageProvider = context.ActionContext.HttpContext
            .RequestServices
            .GetRequiredService<IValidationMessageProvider>();

        var languageId = context.ActionContext.HttpContext.Session
            .GetInt32("languageid") ?? 1;

        MergeAttribute(context.Attributes, "data-val", "true");
        MergeAttribute(
            context.Attributes,
            "data-val-email",
            messageProvider.GetMessage(languageId, ErrorMessageId));
    }

    private static void MergeAttribute(
        IDictionary<string, string> attributes,
        string key,
        string value)
    {
        if (!attributes.ContainsKey(key))
        {
            attributes.Add(key, value);
        }
    }
}

消息服务可以从数据库或缓存中读取翻译:

public interface IValidationMessageProvider
{
    string GetMessage(int languageId, Translation key);
}

Required Attribute 的实现方式相同,只需继承 RequiredAttribute,并生成 data-val-required:

public sealed class MultiLanguageRequiredAttribute
    : RequiredAttribute, IClientModelValidator
{
    private Translation _errorMessageId;

    public Translation ErrorMessageId
    {
        get => _errorMessageId;
        set
        {
            _errorMessageId = value;
            ErrorMessage = ((int)value).ToString();
        }
    }

    public void AddValidation(ClientModelValidationContext context)
    {
        var messageProvider = context.ActionContext.HttpContext
            .RequestServices
            .GetRequiredService<IValidationMessageProvider>();

        var languageId = context.ActionContext.HttpContext.Session
            .GetInt32("languageid") ?? 1;

        MergeAttribute(context.Attributes, "data-val", "true");
        MergeAttribute(
            context.Attributes,
            "data-val-required",
            messageProvider.GetMessage(languageId, ErrorMessageId));
    }

    private static void MergeAttribute(
        IDictionary<string, string> attributes,
        string key,
        string value)
    {
        if (!attributes.ContainsKey(key))
        {
            attributes.Add(key, value);
        }
    }
}

采用这种实现后,视图中不必手写 data-val-email="2":

<input asp-for="Email" class="form-control" aria-required="true" />
<span asp-validation-for="Email" class="text-danger"></span>

<input asp-for="Password" class="form-control" aria-required="true" />
<span asp-validation-for="Password" class="text-danger"></span>

<button type="submit" class="btn btn-primary">Login</button>

服务器渲染的 HTML 会直接包含当前语言对应的消息:

<input
    type="text"
    id="Email"
    name="Email"
    data-val="true"
    data-val-required="Email Address is a required field"
    data-val-email="Not a valid Email Address">

这样就可以去掉隐藏的错误消息字段,也不再需要用 JavaScript 替换消息和重新初始化验证器。

其他注意事项

jQuery 脚本应按以下顺序加载:

<script src="~/lib/jquery/jquery.min.js"></script>
<script src="~/lib/jquery-validate/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>

这个顺序没有问题,但要避免重复加载这些脚本。

服务器端不能信任隐藏字段中的语言编号和错误消息,因为客户端可以修改隐藏字段。应根据 Session、当前用户设置或经过验证的 Culture 信息确定语言,再从数据库或缓存中读取消息。

如果翻译来自数据库,可以让 IValidationMessageProvider 使用请求级缓存或内存缓存,避免在渲染一个表单时为每个 Attribute 分别查询数据库。

现有的 TranslateModelState() 可以继续使用。不过,用 msg.Length < 5 判断消息是不是编号并不可靠。可以改用 translation:2 之类的明确格式,或者让验证结果直接携带可识别的翻译键。

备注:内容仅供参考。