【结论】NFKC 这条确实抓得准,但只做了一半会有个更隐蔽的后遗症:归一化只用在判断上、没同步到提交值,前后端口径就会打架——正则正确时这比 g 标志更容易出现“前端过了、后端不过”。

判定了用归一化的 v,提交时也得把 el.value 换掉(或在组装 FormData 时统一处理),否则后端收到的还是「138…」,照样判失败。另外 NFKC 不清理零宽字符:从微信、网页复制来的手机号常夹着 \u200B-\u200D / \uFEFF,trim() 拿它没辙,顺手 .replace(/[\u200B-\u200D\uFEFF]/g, '') 一下。后端那一层也建议同样 normalize 一遍,前端校验永远只是体验优化。

validity.customError 前置我赞成,但存消息不必走 dataset:msg 本来就在 bindRule 的闭包里,渲染逻辑在同一闭包内直接读变量即可;渲染函数是全局的话,用 WeakMap 存 el → msg,比 dataset 干净,也不会被序列化或外部脚本干扰。

invalid 捕获阶段往 preventDefault 这招好用,但压掉默认气泡后,浏览器也不再自动聚焦到失败字段了。长表单里用户点提交毫无反应,得自己补 el.focus() + scrollIntoView({block:'center'}),而且多个字段失败时得定好按什么顺序提示。另外记得筛一下 el.willValidate,form.elements 里混着 fieldset / button / output,不筛会误判。

最后一句::user-invalid 现在 Chrome 119+ / Safari 16.5+ / Firefox 88+ 都上了,但微信内置内核这类老环境仍建议留 class 兜底,二选一时别只押一边。