Old-School Front-End / 2026 ・ No.09
古法前端入门
第9讲 JavaScript ③ ── 输入校验:清楚地拦下,告诉对方怎么改
发布
不靠 AI,一行一行亲手写的“古法”前端入门。第9讲是输入校验。
第1讲约好“在页面上守住规则的方法放在第9讲”,第3讲约好“3 万日元以上必须附收据,由第9讲来守”。
先量一量浏览器自带的检查能帮到哪一步,不够的部分交给 JavaScript,
做成一次说清、告诉怎么改、改好马上放行的表单。
本讲的目标
新井前辈,我把事由空着点了“提交申请”……咦,被拦住了。说“请填写此字段。”
古井第3讲加的 required 起作用了。这是浏览器自带的检查。
新井那输入校验已经完成了吧!
古井在金额里填“约1万”再点一下试试。
新井……通过了。
古井还有“合计 3 万日元以上需要收据”,哪一栏的 required 都写不出来。今天用 JavaScript 把剩下的做完。
当前位置:第9讲
给第8讲的申请表单加上下面这些输入校验。
- 一次说清 ── 把有问题的项目列在表单上方。点一下,就跳到那一栏
- 告诉怎么改 ── “第 2 行的金额请只填数字,例如“14720”。”
- 跨栏的规则 ── 合计 3 万日元以上必须附收据。未来的日期不行。完全空的行不计
- 不责怪 ── 正在输入时不增加红框,改好了当场消掉
规则写进新建的 js/validate.js,页面上的工作加到第8讲的 js/form.js 里。第7讲的 js/app.js,这次也一行都不改。
浏览器的检查,能交给它多少?
第3讲加的 required 和 pattern,是浏览器本来就有的检查机制(约束验证)。我用一个没有 JavaScript 的小表单,确认了 Chromium 的表现。
# 交给浏览器的检查(Chromium・中文。用没有 JS 的小表单实测) 按下“提交” → 不发送。光标移到第一栏(事由) 不合格的栏 → 3 个。但只提示第一栏 事由(required・空) → “请填写此字段。” 勾选框(required) → “如果要继续,请选中此框。” 金额(required)填“约1万” → 通过(不是空的就行) 金额(pattern="[0-9]+") → “请与所请求的格式保持一致。”
能交给浏览器的,和交不出去的
只是拦住空栏的话,浏览器就够了。但提示文字由浏览器和语言设置决定,就算被告知“请与所请求的格式保持一致”,用户也不知道是什么格式。还有一点,正如第6讲看到的,加上 Bootstrap 的 was-validated 后,连空着的选填栏也会打上绿色的勾。“没填也算 OK?”会让人困惑,所以这个也不用。
方针:用 novalidate 接手
于是我们关掉浏览器的检查,由 JavaScript 自己来查。关掉的方法是 form 的 novalidate 属性,不过不写在 HTML 里,而是在 JavaScript 里加上。
form.noValidate = true; // 只有 JS 运行时,才关掉浏览器的检查
这样一来,万一 JavaScript 没能加载,required 带来的浏览器检查还在。不删掉 HTML 里的 required 也是同样的理由:浏览器的检查是一道保险。
按下“提交申请”之后
新井第3讲给“保存草稿”加了 formnovalidate 吧。用了 novalidate,它不就没意义了吗?
古井对浏览器来说是这样。但属性还在。event.submitter 是被按下的按钮,看 event.submitter.formNoValidate,JS 就能判断“是草稿,不检查”。写在 HTML 里的意图,由 JS 接过来。
在事由栏里按 Enter 键,表单会被提交。这时算作“被按下的按钮”的,是表单里写在最前面的提交按钮。这个表单里“保存草稿”在前面,所以用 Enter 提交时,被当成了不做检查的保存草稿。这次是草稿,危害不大,但要记住:按钮的排列顺序也有含义。
把规则写成函数
和第7讲、第8讲一样,规则和页面分开,写成函数。validateClaim 接收申请的内容(一个普通的对象),返回“哪一栏・为什么不行”的数组。空数组就是通过。
| 栏位 | 规则 | 提示(例) |
|---|---|---|
| 事由 | 只有空格也不行 | 请填写事由。 |
| 明细 | 完全空的行不计。一行都没有就不行 | 请至少填写一行明细。 |
| 日期 | 必填。未来的日期不行 | 第 3 行的日期是未来的日期,请填写实际花费的日期。 |
| 类别・内容 | 必填 | 请填写第 2 行的内容(例:新干线 东京→新大阪)。 |
| 金额 | 空的・读不出的・0 日元都不行 | 第 2 行的金额请只填数字,例如“14720”。 |
| 收据 | 日元行的合计在 3 万日元以上时必填 | 合计在 3 万日元以上,请附上收据。 |
| 确认规定 | 必须勾选 | 请阅读费用报销规定并勾选。 |
金额用第7讲的 toAmount 来读。当时定下“空栏返回 null、读不出返回 NaN”,就是为了在这里分别给出“请填写”和“请只填数字”。
// 报销系统:输入校验的规则(第9讲)
// 不碰页面。接收申请的内容,返回“哪一栏・为什么不行”的列表
// 使用 app.js 的 toAmount・calcTotal・needsReceipt
/** 以 'YYYY-MM-DD' 返回今天的日期(电脑时钟所在地的日期) */
function localDate(d = new Date()) {
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${month}-${day}`;
}
/** 明细行是否完全为空(币种一开始就已选好,所以不看) */
function isBlankRow(row) {
return row.date === '' && row.category === '' && row.item.trim() === '' && row.amount.trim() === '';
}
/**
* 检查申请的内容
* claim = { title, rows: [{ date, category, item, amount, currency }], receipts, agreed }
* 返回值 = [{ field: 'amount-2', message: '…' }, …](空数组即为通过)
*/
function validateClaim(claim, today = localDate()) {
const errors = [];
const add = (field, message) => errors.push({ field, message });
if (claim.title.trim() === '') add('title', '请填写事由。');
const items = [];
let filled = 0;
claim.rows.forEach((row, i) => {
if (isBlankRow(row)) return; // 完全空的行不计(forEach 里的 return 就是“下一行”)
filled++;
const n = i + 1;
if (row.date === '') add(`date-${n}`, `请填写第 ${n} 行的日期。`);
else if (row.date > today) add(`date-${n}`, `第 ${n} 行的日期是未来的日期,请填写实际花费的日期。`); // 'YYYY-MM-DD' 之间比较文字大小,就能判断日期先后
if (row.category === '') add(`category-${n}`, `请选择第 ${n} 行的类别。`);
if (row.item.trim() === '') add(`item-${n}`, `请填写第 ${n} 行的内容(例:新干线 东京→新大阪)。`);
const amount = toAmount(row.amount);
if (amount === null) add(`amount-${n}`, `请填写第 ${n} 行的金额。`); // 空栏
else if (Number.isNaN(amount)) add(`amount-${n}`, `第 ${n} 行的金额请只填数字,例如“14720”。`); // 无法读成数字
else if (amount === 0) add(`amount-${n}`, `第 ${n} 行的金额是 0 日元,请填写实际花费的金额。`);
else if (row.currency === 'JPY') items.push({ amount }); // 合计只算日元的行(换算在第10讲)
});
if (filled === 0) add('date-1', '请至少填写一行明细。');
if (needsReceipt(calcTotal(items)) && claim.receipts === 0) add('receipt', '合计在 3 万日元以上,请附上收据。');
if (!claim.agreed) add('agree', '请阅读费用报销规定并勾选。');
return errors;
}
- field 是页面上栏位的 id ── form.js 事先给栏位加上带行号的 id(如 amount-2),再用它找到要加红框的栏
- 不责怪空行 ── “+ 添加一行”按多了留下的空行,不该一下冒出 4 个错误。forEach 里的 return 意思是“跳过这一行,看下一行”
- today 可以用参数传入 ── 平时是今天的日期。测试时固定为“2026-10-17”,不受时钟影响
因为是不碰页面的函数,可以写和第7讲一样的手写测试。诀窍是一定要放进边界(29,999 日元和 30,000 日元,今天和明天)。
✓ 正确的申请可以通过 ✓ 事由只有全角空格 ✓ 明细全部为空 ✓ 空行不计 ✓ 金额“约1万” ✓ 金额“0” ✓ 明天的日期 ✓ 今天的日期没问题 ✓ 合计 29,999 日元・没有收据 ✓ 合计 30,000 日元・没有收据 ✓ 合计 30,000 日元・有收据 ✓ 美元的行不计入合计 ✓ 没有确认规定 ✓ 只有第 2 行的内容为空 输入校验的测试: 14 项中 14 项通过
读懂正则 /^[0-9]+$/
这就是第7讲 toAmount 里说“详见第9讲”的那句固定写法。正则表达式是描述字符排列规则的小语言,一个字符一个字符地读,就不可怕。
一个符号一个符号地读
# 正则表达式的验证(在 Chromium 中运行) 字符串 /^[0-9]+$/ /[0-9]+/ '14720' ○ ○ '14,720' × ○ '14720' × × '' × × '12a' × ○ ← 只要有一个数字就能通过 '約1万' × ○ ← 只要有一个数字就能通过 '-500' × ○ ← 只要有一个数字就能通过 '1.5' × ○ '14720 ' × ○
忘了 ^ 和 $,就变成“只要某处有一个数字就通过”,“约1万”和“-500”都会混过去。toAmount 先把全角转成半角,去掉逗号和前后空格,再用这个式子确认从头到尾都是数字。所以“14,720”能读出来,“约1万”读不出来。
新井让 AI 写的话,会冒出更长的正则。像咒语一样,看不懂……
古井看不懂的东西,就像上面的表那样摆出例子来试。希望通过的,和希望拦下的。两边都符合预期,就可以用。
日期的坑:toISOString
要确认“未来的日期不行”,就需要“今天”。网上常见的写法是 new Date().toISOString().slice(0, 10)。我固定了时钟和时区,试了一下。
# 同一时刻的“今天”(在 Chromium 中固定时钟和时区实测) 时区和时刻 toISOString() localDate() 东京 08:30 2026-10-16 2026-10-17 ← 错了 东京 09:30 2026-10-17 2026-10-17 上海 07:30 2026-10-16 2026-10-17 ← 错了 纽约 21:00 2026-10-18 2026-10-17 ← 错了
同样是 10 月 17 日,早上 9 点前却是“昨天”
toISOString() 给出的是世界标准时间(UTC)的日期。日本比 UTC 快 9 小时,早上 9 点之前还是前一天(中国快 8 小时,早上 8 点之前也一样)。用它来做“今天”,早上 8 点半申请今早出租车费的人,就会被骂“这是未来的日期”。validate.js 的 localDate() 用 getFullYear・getMonth(从 0 开始数,所以要 +1)・getDate,拼出当地的日期。
另一个小技巧是用 padStart(2, '0') 把月和日补成两位。像 '2026-10-09' 这样位数对齐,只比较文字大小就能知道日期先后(写成 '2026-10-9' 的话,会被判断为比 '2026-10-10' 还靠后)。
提示要写“怎么改”
难懂的提示,易懂的提示
- 哪里 ── 第几行的哪一栏。像明细这样同一栏重复出现时,行号必不可少
- 怎么改 ── 不说“不合法”,而是写下一步该做什么
- 例子 ── 格式与其用文字解释,不如给个例子最快
- 不只靠颜色 ── 红框之外还要用文字说明,并用 aria-invalid="true" 告诉读屏软件“这一栏有问题”
新井我让 AI “加上错误提示”,结果全是“输入值不合法”。
古井“不合法”对被说的人来说是冤枉。人家只是不知道我们的规矩而已。
显示到页面:红框・汇总・焦点
validateClaim 的结果,由 form.js 的 showErrors 显示到页面上。要做的是三件事。
- 只给有问题的栏加红框 ── 加上 Bootstrap 的 is-invalid 类和 aria-invalid。不用 was-validated,空着的选填栏就不会打绿勾(第6讲留下的问题,到这里解决了)
- 表单上方的“汇总” ── 列出处数和指向各项的链接。表单再长,一开始就知道要改什么、改几处
- 把焦点移到汇总 ── 画面会滚到汇总,读屏软件也能读出汇总的内容(处数)
<div class="alert alert-danger" id="error-summary" tabindex="-1" hidden> <h2 class="h6 alert-heading">请检查输入内容</h2> <ul class="mb-0"></ul> <!-- 由 JS 在这里放入 li 和链接 --> </div>
tabindex="-1" 的意思是“按 Tab 键不会停在这里,但可以用 JS 的 focus() 让它获得焦点”。div 平常不能获得焦点,所以要加上它。
其实汇总里的链接就算保持普通链接,Chromium 也会把光标放进那一栏(实测)。只是 URL 末尾会加上 #amount-2,浏览器“后退”的历史也多了一条。而且那一栏会被紧贴到画面最上方(实测),上面的标题和行容易被挡住。所以我们接管点击,用 scrollIntoView 把栏位放到画面中间,再 focus。
最后一条规则是:只在按“提交申请”时增加红框,减少则当场进行。如果每次输入都全部重新检查,刚填完第 2 行的日期,“请填写内容”“请填写金额”就会立刻变红。明明还在输入中。所以每次输入时,只重新检查已经变红的栏,改好了就去掉红框。
// ---- 第9讲:输入校验 ----
const form = document.querySelector('form');
const summary = document.querySelector('#error-summary');
const result = document.querySelector('#result');
form.noValidate = true; // 关掉浏览器的检查,由自己来检查(JS 不运行时,浏览器的检查仍然有效)
/** 从页面读取申请的内容 */
function readClaim() {
return {
title: form.elements.title.value,
rows: [...tbody.rows].map((row) => ({
date: row.querySelector('[name="date"]').value,
category: row.querySelector('[name="category"]').value,
item: row.querySelector('[name="item"]').value,
amount: row.querySelector('[name="amount"]').value,
currency: row.querySelector('[name="currency"]').value,
})),
receipts: form.elements.receipt.files.length,
agreed: form.elements.agree.checked,
};
}
/** 把检查结果显示到页面上(红框和上方的汇总) */
function showErrors(errors) {
for (const field of form.querySelectorAll('.is-invalid')) {
field.classList.remove('is-invalid');
field.removeAttribute('aria-invalid');
}
const items = errors.map(({ field, message }) => {
const el = document.getElementById(field);
el.classList.add('is-invalid');
el.setAttribute('aria-invalid', 'true');
const a = document.createElement('a');
a.href = '#' + field;
a.className = 'alert-link';
a.textContent = message; // 文字用 textContent(第8讲)
const li = document.createElement('li');
li.append(a);
return li;
});
summary.querySelector('ul').replaceChildren(...items);
summary.querySelector('h2').textContent = `请检查输入内容(${errors.length} 处)`;
summary.hidden = errors.length === 0;
}
/** 只重新检查带红框的栏(改好了红框马上消失;增加红框只在按“提交申请”时) */
function recheck() {
const marked = new Set([...form.querySelectorAll('.is-invalid')].map((el) => el.id));
showErrors(validateClaim(readClaim()).filter((e) => marked.has(e.field)));
}
form.addEventListener('submit', (event) => {
event.preventDefault(); // 发送的机制在第10讲,这次先停在这里
result.hidden = true;
if (event.submitter.formNoValidate) { // “保存草稿”不检查(第3讲的 formnovalidate)
result.textContent = '草稿不做检查直接保存(保存的机制在第10讲)。';
result.hidden = false;
return;
}
const errors = validateClaim(readClaim());
showErrors(errors);
if (errors.length > 0) {
summary.focus(); // 移到汇总处,读屏软件也能告诉用户“有几处”
} else {
result.textContent = '检查通过。发送的机制在第10讲制作。';
result.hidden = false;
}
});
form.addEventListener('input', recheck); // 改好后,红框当场消失
// 汇总里的链接:把光标移到那一栏(不在 URL 上加 #…,也不增加“后退”的历史)
summary.addEventListener('click', (event) => {
const link = event.target.closest('a');
if (!link) return;
event.preventDefault();
const field = document.getElementById(link.hash.slice(1));
field.scrollIntoView({ block: 'center' }); // 把这一栏放到画面中间(上面的行和标题也能看到)
field.focus({ preventScroll: true });
});
动手试试
用 Playwright 实际输入和点击,每一步都记录了汇总的处数、带红框的栏和焦点的位置。因为有日期检查,浏览器的时钟固定在 2026年10月17日 10:00(东京)。
# 实际操作并记录的值(Chromium) 操作 汇总 红框的栏 焦点 刚打开时 ― ― ― 什么都不填就“提交申请” 3处 title date-1 agree error-summary 填写事由 2处 date-1 agree title 填写第 1 行 1处 agree amount-1 填写第 2、3 行 ※ 1处 agree amount-3 “提交申请” 4处 item-2 amount-2 date-3 agree error-summary 点汇总里的“第 2 行的金额…” 4处 item-2 amount-2 date-3 agree amount-2 第 2 行金额改为“12800” 3处 item-2 date-3 agree amount-2 改好内容和日期,勾选规定 ― ― agree “提交申请” 1处 receipt error-summary 附上收据 ― ― ― “提交申请” ― “检查通过” BUTTON ※ 第 2 行内容为空・金额“约1万”,第 3 行是明天的日期(2026-10-18)
在填写第 2、3 行的时候,汇总一直是 1 处,没有增加。按下“提交申请”才变成 4 处,每改一处就减少,到 0 处时汇总消失。收据的规则,是在改好金额、合计变成 42,240 日元之后的那次“提交申请”时出现的。
到这里为止的检查,全都在用户的浏览器里运行。用浏览器的开发者工具,就能改写 JavaScript,或者绕过表单直接发送,轻松钻过去。前端的检查是“体贴”,防守靠服务器。在接收的一端用同样的规则再确认一次,这是古往今来的铁律。
结 — 一页总结与下讲预告
- 浏览器的检查(required・pattern)是保险。只提示第一个,不能选提示文字,也写不了跨栏的规则。只在 JS 运行时设为 novalidate,由 JS 接手。
- 规则写成 validate.js 里的函数。不碰页面,只返回“哪一栏・为什么”的数组,所以能用手写测试一直确认到边界。
- 正则用 ^ 和 $ 锁住“从头到尾”。“今天”不用 toISOString,而是拼出当地的日期。
- 提示写“哪里・怎么改・例子”。红框只加在有问题的栏上,焦点移到上方的汇总。
- 红框只在提交时增加,改好马上消掉。前端的检查是体贴,防守靠服务器。
新井这样按下“提交申请”,就能送到财务那边了吧!
古井现在还只是说一句“检查通过”就停下了。下一讲是异步通信。用 fetch,不切换页面就和服务器来往。一直往后推的美元换算,也在那里解决。
参考
- MDN — Using HTML form validation and the Constraint Validation API
- MDN — HTML attribute: pattern
- MDN — SubmitEvent: submitter property
- MDN — Regular expressions
- MDN — Date.prototype.toISOString()
- MDN — ARIA: aria-invalid attribute
- MDN — tabindex
- MDN — Element: scrollIntoView() method
- Bootstrap 5.3 — Validation
- W3C — Understanding SC 3.3.1: Error Identification
- W3C — Understanding SC 3.3.3: Error Suggestion
- W3C — G139: Creating a mechanism that allows users to jump to errors
- OWASP — Input Validation Cheat Sheet
页面截图是笔者制作的演示页面,于2026年10月17日在云端的 Chromium(Playwright)中拍摄。截图环境里没有 PingFang SC,中文以 Noto Sans CJK SC 显示。交给浏览器检查时的提示文字、汇总的处数・带红框的栏・焦点的去向、汇总链接的行为、用 Enter 键提交时的处理、正则表达式的结果、固定时钟和时区时 toISOString 与 localDate 的日期,以及 14 项手写测试,都是用 Playwright 操作和运行同一个 Chromium 记录的实际值。浏览器检查的提示文字会因浏览器种类和语言设置而不同。form.html 已用 Nu Html Checker(vnu)确认没有错误。登场人物均为虚构。
古法前端入门 第9讲 — JavaScript ③:输入校验 / 2026.10