ASP.NET Core MVC 中 jQuery validation 客户端验证不生效
结论
客户端验证没有触发,原因有两个:
document.forms['login'].submit()调用原生HTMLFormElement.submit(),会直接提交表单,不触发submit事件。jQuery Validation 注册的提交处理器因此不会执行。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);
});
处理顺序如下:
- 更新
data-val-*属性。 - 删除已经创建的 validator 和 unobtrusive validation 数据。
- 重新解析表单。
无需删除 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 之类的明确格式,或者让验证结果直接携带可识别的翻译键。
备注:内容仅供参考。
