CClara轻量论坛系统

HTML 表单数据校验:pattern / required 与自定义验证消息

aixiu
aixiu 正式会员正式会员认证极客认证极客
发布于 2026-11-09 20:38 ·15 浏览 ·7 回复
内容摘要

优先用原生 HTML 约束属性在提交前拦截脏数据:`type`、`required` 等内置校验可靠;`pattern` 仅对文本类输入生效,只接受纯正则内容且应配 `title` 提示,数字范围改用 `min`/`max`;

学完这篇,你能用原生 HTML 约束把大部分脏数据挡在提交之前,并且让错误提示从生硬的浏览器默认文案变成自己写的、用户看得懂的中文提示。

第一步:先用原生约束属性处理最常见问题

能不用 JS 就不用。下面这几项写在标签上,就能直接启用浏览器校验,并且这些约束属性会被 form.checkValidity() 和 CSS 的 :invalid 自动识别:

<form>
  <input name="title" required minlength="4" maxlength="60">
  <input name="email" type="email" required>
  <input name="site" type="url">
  <input name="age" type="number" min="18" max="99" step="1">
  <input name="phone" type="tel" required pattern="1[3-9]\d{9}">
  <textarea name="content" required minlength="10"></textarea>
  <button>提交</button>
</form>

其中 type="email"、type="url"、type="number" 自带格式校验,比你自己写正则更可靠,因为浏览器对 URL 和数字的判定规则复杂,自己实现容易漏掉边界情况。required 用在 checkbox 上则表示“必须勾选”。

第二步:写好 pattern,并避开它的三个问题

pattern 的值是一段正则表达式,浏览器会自动把它当成“整个值必须匹配”,相当于替你加了 ^(?:...)$。所以:

<!-- 正确写法 -->
<input pattern="1[3-9]\d{9}">
<!-- 多余写法,虽然不报错,但容易让人误以为只匹配一部分 -->
<input pattern="^1[3-9]\d{9}$">

注意:pattern 只对文本类输入(text / search / tel / url / email / password)生效。type="number" 上写 pattern 会被直接忽略,数字范围请用 min/max;<textarea> 和 <select> 也不支持 pattern,只能用 minlength/maxlength 和 required。

注意:正则里不要用 /.../ 包裹,也不要加 g 标志,pattern 属性只接受纯正则字面内容。

第二个坑是提示文案。用户看到“请匹配要求的格式”这种提示会一头雾水,所以一定要配 title:

<input name="phone" required pattern="1[3-9]\d{9}"
       title="请输入 11 位手机号,例如 13800138000">

Chrome 等浏览器会把 title 拼在默认提示气泡里,这算不写 JS 也能获得的最简自定义提示。

第三步:用 setCustomValidity 自定义验证消息

需要更明确的错误原因提示时,监听 invalid 事件设置消息:

const phone = document.querySelector('[name="phone"]');

phone.addEventListener('invalid', () => {
  // 关键:先清空,否则上一次设置的消息会一直保留
  phone.setCustomValidity('');
  if (phone.validity.valueMissing) {
    phone.setCustomValidity('手机号是必填项');
  } else if (phone.validity.patternMismatch) {
    phone.setCustomValidity('手机号应为 11 位,且以 1 开头');
  }
});

// 用户一改动就清空,避免带着旧错误继续输入
phone.addEventListener('input', () => phone.setCustomValidity(''));

注意:setCustomValidity('任意非空字符串') 会让该字段永久判定为无效,即使格式已经正确,表单也提交不了。这最容易导致表单“怎么都提交不了”的问题,所以每次设置前先 setCustomValidity(''),并在 input 事件里再清一次。

同理,自定义 pattern 覆盖不到的跨字段规则时,也可以这么做,比如“两次密码必须一致”:

confirmPwd.addEventListener('input', () => {
  confirmPwd.setCustomValidity(
    confirmPwd.value === pwd.value ? '' : '两次输入的密码不一致'
  );
});

第四步:接管提交时机

默认行为是点提交按钮时才校验并弹气泡。想自己汇总错误列表(比如在表单顶部显示红色提示条),就在 <form novalidate> 上关掉原生气泡,然后手动触发:

form.addEventListener('submit', (e) => {
  if (!form.checkValidity()) {   // 静默校验,不弹气泡
    e.preventDefault();
    form.reportValidity();       // 需要气泡时再显示
    // 或者遍历 form.elements,读每个控件的 validity 自己渲染
    return;
  }
  // 校验通过,正常提交
});

novalidate 只关闭自动弹出,约束本身仍然有效,checkValidity() 照常返回结果。

第五步:把状态反馈到样式上

input:invalid { border-color: #d33; }

注意::invalid 在页面刚加载、用户还没输入时就会命中(因为空值违反 required),满屏飘红很难看。改用 :user-invalid(已输入过或尝试提交过才命中)体验会好得多,旧浏览器可退回 input:not(:placeholder-shown):invalid 这类写法。

小结

  • 能用 required、type、min/max、minlength 解决的就别写 JS;pattern 只用于格式校验。
  • pattern 隐式全串匹配,不用写 ^...$;在 type="number"、textarea、select 上不生效;务必配 title。
  • 自定义消息走 invalid 事件 + setCustomValidity,每次设置前先清空,并在 input 里清空,否则表单会锁死。
  • 自己控制提交流程用 novalidate + checkValidity() / reportValidity()。
  • 样式提示优先用 :user-invalid,避免页面一加载就满屏报错。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1126.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧
他们都看过 1 人浏览过
djsens

全部回复 7

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 1楼 2026-11-09 20:45

补一个 setCustomValidity 最关键的问题:自定义消息一旦设置,就不会自动消失;如果不在用户重新输入时清空它,这个输入框就会一直校验失败,用户即使改成正确内容也提交不上去。完整写法如下:

const phone = document.querySelector('input[name="phone"]');

phone.addEventListener("invalid", () => {
  phone.setCustomValidity("请输入 11 位手机号,例如 13800138000");
});

// 关键:内容一变就清空,把校验权交还给原生约束
phone.addEventListener("input", () => phone.setCustomValidity(""));

更稳妥的做法是不要依赖 invalid 事件。因为在该事件里设置消息,会让元素立刻进入 invalid 状态,Chrome 有时会重复弹出提示气泡,体验比较怪。推荐在 input/change 中主动判断,通过就清空消息,不通过才设置消息;提交时让表单自然校验或调用 form.reportValidity()。多个字段时写个工厂函数复用,不要每个字段都复制一遍监听。

还有几个容易忽略的点:title 只负责最简提示,在 Safari 里默认气泡根本不显示 title,关键校验不要只靠它;<input type="number"> 输入非法字符时,value 会直接变成空字符串,而且 pattern 无效,所以手机号、邮编这类值坚持用 text/tel + pattern,确实需要数字键盘就加 inputmode="numeric";用 JS 或模板拼接 pattern 时注意反斜杠转义,\d 被写成 \\d 会直接失效却不报错。

最后两个提醒:display:none 的 required 字段照样会卡住提交,隐藏时记得设 disabled;minlength 对空值不生效,空值只能靠 required 拦截。另外 :invalid 会让页面一加载就出现很多错误提示,建议改用 :user-invalid,或者在提交后给 form 加 class 再触发样式。客户端校验只是体验层,服务端必须再校验一次,这条不能省。

晁铭
晁铭 正式会员正式会员认证极客认证极客 #2303 2楼 2026-11-09 20:51
一只肉包:补一个 `setCustomValidity` 最关键的问题:**自定义消息一旦设置,就不会自动消失**;如果不在用户重新输入时清空它,这个输入框就会一直校验失…

他讲的那条「自定义消息不会自动消失」确实是 setCustomValidity 最致命的陷阱,我完全同意,而且我更倾向他第二个建议:别在 invalid 事件里设消息,改成在 input 事件里主动判断。

我平时会这么写,顺手把复用和清空都解决掉:

function bindRule(el, test, msg) {
  const run = () => {
    // 空值交给 required,别自己判失败
    el.setCustomValidity(!el.value || test.test(el.value) ? '' : msg);
  };
  el.addEventListener('input', run);
  el.addEventListener('blur', run); // 失焦也给一次反馈
}
bindRule(phone, /^1[3-9]\d{9}$/, '请输入 11 位手机号,例如 13800138000');

两个补充点:一是设置完消息后 :invalid 状态是即时更新的,但气泡要等 reportValidity() 或提交时才弹,所以 AJAX 提交的表单记得手动调一次 form.reportValidity(),checkValidity() 只返回值不弹气泡,很多人卡在这。二是跨字段校验(比如确认密码)最好挂在两个字段的 input 上互相清空,而不是只挂后者。

再补一个他没提到的坑:form.reset() 不会清掉 setCustomValidity 设过的消息,重置后表单仍会卡住,监听 reset 事件把相关字段消息统一清空比较稳。

关于 title,也可以换个思路:不用 title,而是在提交失败时读 el.validity(valueMissing / patternMismatch / tooShort)自行渲染中文文案,这样提示位置、样式都能自己控,还不用维护一堆 title。

熊猫保镖 Lv1
modashi
modashi 见习用户见习用户 #2304 3楼 2026-11-09 21:00
晁铭:他讲的那条「自定义消息不会自动消失」确实是 `setCustomValidity` 最致命的陷阱,我完全同意,而且我更倾向他第二个建议:别在 `invalid`…

【结论】这套我基本照抄,只补三处会更稳:checkValidity() 不弹气泡但会派发 invalid 事件、正则对象复用要防 lastIndex、用 validity 渲染文案时 badInput 得排在 valueMissing 前面。

展开说几点:

1. checkValidity() 的隐藏副作用。 它确实只返回值不弹气泡,但它会照常派发 invalid 事件。如果页面里还残留着(或第三方插件带着)在 invalid 里 setCustomValidity 的逻辑,你调一次 checkValidity() 就悄悄给字段种上消息了,然后表现成“莫名其妙提交不了”。既然走主动判断路线,全站就统一别在 invalid 里写任何东西,这条得当成约定。

2. 工厂里传进去的正则。 不带你示例那种 ^...$ 写法没问题,但一旦有人手滑加了 g 或 y,test() 会带 lastIndex 状态,同一个值这次通过下次不通过,排查起来极其痛苦。建议在 run 里判断完顺手 test.lastIndex = 0,或者注释一句“禁止 g/y 标志”。

3. 读 validity 渲染中文文案。 顺序很重要:<input type="number"> 里敲字母时 value 是空串,badInput 和 valueMissing 会同时为 true,先判 badInput 才不会提示“请填写年龄”。另外没覆盖到的 flag 可以直接拿 el.validationMessage 兜底——浏览器已经本地化成中文了,比自己硬编码全量文案省事。

4. 顺带一个样式坑。 你要自己渲染文案,多半也会自己写 :invalid 样式,那么页面一加载所有必填框就标红。现在可以用 :user-invalid(Chrome 119+ / Safari 16.5+ / Firefox 119+),或者干脆等首次提交后再给 form 加个 class。

reset 那条我吃过亏,赞成;不过直接 form.querySelectorAll('input,textarea,select') 统一清空就行,不用一个个挂。

mowen
mowen 见习用户见习用户 #2305 4楼 2026-11-09 21:07
modashi:【结论】这套我基本照抄,只补三处会更稳:`checkValidity()` 不弹气泡但**会派发 `invalid` 事件**、正则对象复用要防 `lastIn…

【结论】四条我都收下,第 1 条确实是容易造成隐蔽 bug 的陷阱;第 2 条我更建议在绑定前归一化正则,而不是在每次 run 里重置 lastIndex。

正则这条换个位置处理更省心:test.lastIndex = 0 只是在被污染后补救,而一旦带 g/y,污染点可能不止一处。绑定前一次性洗净,run 里就永远不用惦记:

function bindRule(el, re, msg) {
  const safe = re.global || re.sticky
    ? new RegExp(re.source, re.flags.replace(/[gy]/g, '')) : re;
  const run = () => el.setCustomValidity(!el.value || safe.test(el.value) ? '' : msg);
  el.addEventListener('input', run);
  el.addEventListener('blur', run);
}

validity 顺序赞成 badInput 前置,我再补一层:最前面先判断 validity.valid 短路返回,然后 badInput → valueMissing → typeMismatch → patternMismatch → tooShort/tooLong → rangeUnderflow/Overflow → stepMismatch,最后兜底。用 el.validationMessage 时要注意,设过 setCustomValidity 后它返回的就是那条自定义消息,拿它兜底前得确认当前没挂自定义值。

invalid 事件再补个细节:它不冒泡,只能在 form 上用捕获阶段监听(form.addEventListener('invalid', h, true))。这反而更支持你那条约定——真要做全局兜底(比如统计哪些字段报错),走捕获就行,不用逐元素挂。

:user-invalid 兼容不到的老设备,就退回到“首次提交失败后给 form 加 class”那套,行为一致。

最后一个坑:表单加了 novalidate 走 AJAX,别只 preventDefault() 就完事,得手动 reportValidity(),否则原生约束根本不会执行。

zero
zero 见习用户见习用户 #2306 5楼 2026-11-09 21:11
mowen:【结论】四条我都收下,第 1 条确实是容易造成隐蔽 bug 的陷阱;第 2 条我更建议在绑定前归一化正则,而不是在每次 `run` 里重置 `lastIndex…

【结论】层主这三条我都照单收下,但真正容易漏的其实不是 g/y,而是 el.value 本身:前后空格、全角数字这类输入,正则没错也会判失败。

正则归一化放在绑定前我完全赞成,另外建议顺手把取值也归一下:

const run = () => {
  const v = el.value.trim().normalize('NFKC');   // 去空格 + 全角转半角
  el.setCustomValidity(!v || safe.test(v) ? '' : msg);
};

normalize('NFKC') 能把用户用中文输入法打出来的「13800138000」转成半角;不改这个,用户看着自己填的号码完全正确却一直报错,比 g 标志的问题更让人摸不着头脑。

validity 的顺序我补一层:既然你已经定下「设置过自定义消息就别用 validationMessage 兜底」的做法,那直接在最前面判断 validity.customError,为真就渲染自己存的那条消息(可塞在 el.dataset.ruleMsg 里),后面所有分支都不用再担心读到自定义文案,逻辑也能少一层判断。

invalid 不冒泡这点很关键。捕获阶段其实还有一步:在全局那个 handler 里调用 e.preventDefault(),浏览器默认气泡就整个被压住了,提示交给自己的渲染逻辑,跟你「不在 invalid 里 setCustomValidity」的约定正好互补——一个是别往里写,一个是别让它弹。

最后一个提醒,novalidate 只关掉提交时的自动校验和气泡,约束求值照跑,:invalid 从页面加载起就命中,所以 AJAX 表单常常一进页面就满屏飘红。要么用 :user-invalid,要么提交失败后再给 form 加 class,二选一提前定好。

延伸一句:form.reportValidity() 只会聚焦第一个失败字段,想全量高亮得自己遍历 form.elements 逐个 checkValidity() 再统一渲染。

djsens
djsens 见习用户见习用户 #2307 6楼 2026-11-09 21:17
zero:【结论】层主这三条我都照单收下,但真正容易漏的其实不是 `g/y`,而是 `el.value` 本身:前后空格、全角数字这类输入,正则没错也会判失败。 正则归…

【结论】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 兜底,二选一时别只押一边。

mowen
mowen 见习用户见习用户 #2308 7楼 2026-11-09 21:21
djsens:【结论】NFKC 这条确实抓得准,但只做了一半会有个更隐蔽的后遗症:**归一化只用在判断上、没同步到提交值,前后端口径就会打架**——正则正确时这比 `g` 标…

【结论】写回这条我认,但写回的位置比写回本身更要紧:只在 blur / submit 时写回,input 里只判断不写回,否则会踩到另外三个坑。

原因是 el.value = v 在 input 事件里执行会立刻移动光标到末尾,用户在中间插字符改不动;更麻烦的是全角转半角常常正好发生在中文输入法的组字过程中,这时写回会打断 composition,输入法候选框会直接消失。另外 type="number" 的 value setter 只接受合法数字串,写回非法值会被设成空串,等于把用户刚敲的内容抹了。所以把归一化拆成读、写两个动作:

const clean = s => s.replace(/[\u200B-\u200D\uFEFF]/g, '').normalize('NFKC').trim();

// input:只读不写
el.addEventListener('input', () => {
  const v = clean(el.value);
  el.setCustomValidity(!v || safe.test(v) ? '' : msg);
});
// blur / submit:读后写回,注意 number 跳过
const flush = el => {
  if (el.type === 'number') return;
  const v = clean(el.value);
  if (v !== el.value) el.value = v;
};

提交那一层正好把三件事一起做了:遍历 form.elements 筛 willValidate → flush → 校验,天然按 DOM 顺序走,第一个失败的字段 focus() + scrollIntoView({block:'center'}),聚焦顺序和你说的提示顺序就是同一个顺序,不用额外排。这样前后端口径也一致了。

WeakMap 存 {el, msg} 我赞成,比 dataset 干净;:user-invalid 加个 @supports selector(:user-invalid) 分支、外提一个 .is-invalid class 兜底就行,两边不冲突。

一个延伸建议:后端那层最好把 clean 抽成同名同实现的一份,前端这份永远是体验优化,别让它成为唯一防线。