Old-School Front-End / 2026 ・ No.09

古法前端入门

第9讲 JavaScript ③ ── 输入校验:清楚地拦下,告诉对方怎么改

发布

在 Bootstrap 的申请表单上按下“提交申请”的样子。表单上方是红色的汇总,“请检查输入内容(4 处)”下面有 4 个链接。明细第 2 行的内容和金额(约1万)、第 3 行的日期(2026/10/18)带着红框
本讲的目标。只给有问题的栏加红框,并用上方的汇总告诉用户“哪里要改、怎么改”(笔者的演示页面,在云端的 Chromium 中截图,是用 Playwright 实际输入和点击的画面。2026年10月17日。浏览器边框是为了说明而补画的)

不靠 AI,一行一行亲手写的“古法”前端入门。第9讲是输入校验。
第1讲约好“在页面上守住规则的方法放在第9讲”,第3讲约好“3 万日元以上必须附收据,由第9讲来守”。
先量一量浏览器自带的检查能帮到哪一步,不够的部分交给 JavaScript,
做成一次说清、告诉怎么改、改好马上放行的表单。

一

本讲的目标

新井前辈,我把事由空着点了“提交申请”……咦,被拦住了。说“请填写此字段。”

古井第3讲加的 required 起作用了。这是浏览器自带的检查。

新井那输入校验已经完成了吧!

古井在金额里填“约1万”再点一下试试。

新井……通过了。

古井还有“合计 3 万日元以上需要收据”,哪一栏的 required 都写不出来。今天用 JavaScript 把剩下的做完。

当前位置:第9讲

1 准备 2 HTML 骨架 3 表单 4 CSS 基础 5 布局 6 Bootstrap 7 JS 基础 8 DOM 与事件 9 输入校验 10 通信 11 登录 12 审批流程 13 统计与打印 14 jQuery 15 收尾 当前位置 准备 HTML CSS JavaScript 实战 收尾

给第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]+") → “请与所请求的格式保持一致。”

能交给浏览器的,和交不出去的

交给 required・pattern 时(Chromium 实测) 阻止空栏直接发送 JavaScript 不运行也有效 只提示第一个(哪怕有 3 个) 不能选提示文字:“请与所请求的格式保持一致。” 填“约1万”,required 也照样通过 写不了“3 万日元以上需要收据”这种跨栏规则 → 浏览器的检查留作保险,由 JS 接手(novalidate)

只是拦住空栏的话,浏览器就够了。但提示文字由浏览器和语言设置决定,就算被告知“请与所请求的格式保持一致”,用户也不知道是什么格式。还有一点,正如第6讲看到的,加上 Bootstrap 的 was-validated 后,连空着的选填栏也会打上绿色的勾。“没填也算 OK?”会让人困惑,所以这个也不用。

三

方针:用 novalidate 接手

于是我们关掉浏览器的检查,由 JavaScript 自己来查。关掉的方法是 form 的 novalidate 属性,不过不写在 HTML 里,而是在 JavaScript 里加上。

form.noValidate = true;   // 只有 JS 运行时,才关掉浏览器的检查

这样一来,万一 JavaScript 没能加载,required 带来的浏览器检查还在。不删掉 HTML 里的 required 也是同样的理由:浏览器的检查是一道保险。

按下“提交申请”之后

“提交申请” submit 事件 草稿? 否 validateClaim 传入页面内容 是 不检查 保存草稿 0 处? 是 通过 发送在第10讲 否 红框+汇总 焦点移到汇总 先用 event.preventDefault() 阻止发送(发送在第10讲) 是不是草稿,看 event.submitter(被按下的按钮)就知道

新井第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讲”的那句固定写法。正则表达式是描述字符排列规则的小语言,一个字符一个字符地读,就不可怕。

一个符号一个符号地读

/ ^ [0-9] + $ / ^ 从开头 [0-9] 一个数字(0〜9) + …重复一次以上 $ 到结尾(没有其他字符) 忘了 ^ 和 $ 的话(实测) 约 1 万 /[0-9]+/.test(…) → true。匹配到中间的“1”就通过了
# 正则表达式的验证(在 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 点前却是“昨天”

localDate() 东京的日期 10月17日 toISOString() 世界标准时间的日期 10月16日 10月17日 0:00 9:00 24:00 8:30 提交(实测) toISOString() 是世界标准时间(UTC),在日本早上 9 点前是“昨天” “今天”要用 getFullYear・getMonth・getDate 拼出当地日期

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' 还靠后)。

七

提示要写“怎么改”

难懂的提示,易懂的提示

“输入错误。” 不知道哪里错?为什么?怎么改? “第 2 行的金额请只填数字,例如“14720”。” 哪里 第 2 行的金额 怎么改 只填数字 例子 例如“14720” 与其用责备用户的词(“非法值”“错误”),不如写出下一步该做什么 输入过程中不增加红框,改好后当场消除
  • 哪里 ── 第几行的哪一栏。像明细这样同一栏重复出现时,行号必不可少
  • 怎么改 ── 不说“不合法”,而是写下一步该做什么
  • 例子 ── 格式与其用文字解释,不如给个例子最快
  • 不只靠颜色 ── 红框之外还要用文字说明,并用 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 平常不能获得焦点,所以要加上它。

在手机宽度下按“提交申请”的样子。画面最上方是红色汇总“请检查输入内容(3 处)”和 3 个链接。下面是带红框的事由栏和提示文字
在手机上,汇总也在画面最上方,焦点也移到这里(笔者的演示页面,在云端的 Chromium 中截图,是用 Playwright 把画面宽度设为 390px 的样子。2026年10月17日)

其实汇总里的链接就算保持普通链接,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(东京)。

3 个场景。1 什么都不填就提交,汇总里有 3 处,事由一栏变红,下面出现提示。2 改好后合计变成 42,240 日元,只有收据一栏是红的。3 附上收据再提交,红框消失,绿色提示条显示“检查通过”
①空着提交,②改好后超过 3 万日元、需要收据,③附上收据后通过(笔者的演示页面,在云端的 Chromium 中截图,是用 Playwright 实际输入和点击的画面。2026年10月17日。浏览器边框是为了说明而补画的)
# 实际操作并记录的值(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 日元之后的那次“提交申请”时出现的。

POINT

到这里为止的检查,全都在用户的浏览器里运行。用浏览器的开发者工具,就能改写 JavaScript,或者绕过表单直接发送,轻松钻过去。前端的检查是“体贴”,防守靠服务器。在接收的一端用同样的规则再确认一次,这是古往今来的铁律。

结 — 一页总结与下讲预告

  1. 浏览器的检查(required・pattern)是保险。只提示第一个,不能选提示文字,也写不了跨栏的规则。只在 JS 运行时设为 novalidate,由 JS 接手。
  2. 规则写成 validate.js 里的函数。不碰页面,只返回“哪一栏・为什么”的数组,所以能用手写测试一直确认到边界。
  3. 正则用 ^ 和 $ 锁住“从头到尾”。“今天”不用 toISOString,而是拼出当地的日期。
  4. 提示写“哪里・怎么改・例子”。红框只加在有问题的栏上,焦点移到上方的汇总。
  5. 红框只在提交时增加,改好马上消掉。前端的检查是体贴,防守靠服务器。

新井这样按下“提交申请”,就能送到财务那边了吧!

古井现在还只是说一句“检查通过”就停下了。下一讲是异步通信。用 fetch,不切换页面就和服务器来往。一直往后推的美元换算,也在那里解决。

参考

页面截图是笔者制作的演示页面,于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

← 返回文章列表