Old-School Front-End / 2026 ・ No.03
古法前端入门
第3讲 表单 ── 只用 HTML 做申请表单,看看到底发送了什么
发布
不靠 AI,一行一行亲手写的“古法”前端入门。第 3 讲是业务系统的重头戏——表单。
申请列表里“+ 新建申请”点进去的申请表单,这次同样只用 HTML 来做。
输入框的命名方法、控件的选择、浏览器自带的输入检查。最后还要实际提交一次,
看看按下按钮的那一刻,究竟发送了什么。
本讲的目标
新井前辈,申请表单我也让 AI 做好了!输入框里有浅色的“事由”两个字,挺时髦的吧。
古井哦?那输入事由之后,那行浅色字会怎样?
新井会消失……咦,那输入之后,就不知道这是哪一栏了?
古井正是如此。今天来讲表单的“名牌”和“内容”。
当前位置:第 3 讲
这次要做的是 form.html,也就是申请表单页面。内容如下。
- 申请内容 ── 事由(必填)
- 支付方式 ── 个人垫付,还是公司卡
- 明细 ── 日期・类别・内容・金额・币种,最多 3 行(能增加行要到第 8 讲)
- 收据 ── 图片或 PDF。合计 3 万日元以上必须上传(让表单遵守这条规则要到第 9 讲)
- 备注与确认 ── 备注栏、已阅读规定的复选框、“保存草稿”“提交申请”按钮
表单是怎样发送的
表单用 <form> 把输入框和按钮包起来。按下按钮后,浏览器会按以下顺序工作。
从按下按钮到送达服务器
| form 的属性 | 含义 | 本讲的值 |
|---|---|---|
| action | 发送目标的 URL | /api/expenses(第 10 讲准备的模拟服务器) |
| method | 发送方式。get(加在 URL 后面)或 post(放进正文) | post |
| enctype | 正文的打包方式。发送文件时必须用 multipart/form-data | multipart/form-data |
区分的标准是:只是“查看”数据用 GET,“登记・修改”用 POST。像搜索那样希望同一个 URL 得到同样结果时,GET 很方便;但登记申请要用 POST。因为 GET 会把输入内容原样留在 URL 里,还会记进浏览器的历史记录。
起名字:label 与 name
输入框有两种“名字”:一种是给人看的名字(label),另一种是交给服务器的名字(name)。
label・id・name 的关系
开头新井高兴的“输入框里的浅色字”,是 placeholder 属性。拿它代替 label,会怎样呢?
除了外观,我们也比较了浏览器生成的无障碍树(用 Playwright 取出的实际输出)。
# ① 只有 placeholder - paragraph: - textbox "事由": 大阪出差(交通费・住宿费) - paragraph: - textbox "内容" - paragraph: - spinbutton - paragraph: - button "提交申请" # ② 加了 label 的版本 - paragraph: - text: 事由 - textbox "事由": 大阪出差(交通费・住宿费) - paragraph: - text: 内容 - textbox "内容" - paragraph: - text: 金额 - spinbutton "金额" - paragraph: - button "提交申请"
Chrome 有时会拿 placeholder 当名字用,但 ① 的金额栏(type="number")名字消失了,只剩一个 spinbutton。W3C 的无障碍指南也指出:placeholder 不能代替 label。理由是:一开始输入它就消失,颜色浅不易阅读,各种辅助技术的处理也不一致。
placeholder 适合放“例:2026 年 10 月 大阪出差”这样的输入示例,栏目名称一定要用 label。AI 生成的表单常常只有 placeholder。“有没有 label?”是读表单时首先要确认的一点。
输入控件
大多数输入框都是 <input>,用 type 属性切换种类。下面列出报销系统用到的控件。
表单控件
| 控件 | 常用属性 | 使用要点 |
|---|---|---|
| type="text" | maxlength、autocomplete | 事由・内容。用 autocomplete 可以指定浏览器自动填充的方式 |
| type="number" | min、max、step | 金额。手机上弹出数字键盘。step="1" 可阻止小数 |
| type="date" | min、max | 日期。可以用日历选择,写法不会五花八门(10/15、10 月 15 日……) |
| select 与 option | value | 类别・币种。选项多或固定时使用。第一项放“请选择” |
| type="radio" | name、checked | 支付方式。name 相同的成为一组,只能选其中一个 |
| type="checkbox" | value、checked | 确认规定。不勾选的话什么都不会发送(第九章确认) |
| textarea | rows、maxlength | 备注。有结束标签,标签中间的内容就是初始值 |
| fieldset 与 legend | ― | 把相关的栏目归为一组并命名。对单选按钮组尤其重要 |
新井日期用普通的 text 不也行吗?打“10/15”就好了。
古井想象一下财务的人把“10/15”“10 月 15 日”“2026.10.15”排在一起做统计的样子。
新井……我用 date。
明细表与文件
明细是在上一讲的 table 里放入输入框做成的。它是有行有列的数据,做成表格最自然。
<tr> <td><input type="date" name="date" aria-label="第 1 行的日期" required></td> <td><select name="category" aria-label="第 1 行的类别" required>…</select></td> <td><input type="text" name="item" aria-label="第 1 行的内容" required></td> <td><input type="number" name="amount" aria-label="第 1 行的金额" min="1" step="1" required></td> <td><select name="currency" aria-label="第 1 行的币种">…</select></td> </tr>
- aria-label ── 表格里的输入框旁边没地方放 label。列标题虽然看得见,但要用 aria-label 给它加上读屏用的名字(“第 1 行的金额”等)
- 3 行的 name 都相同 ── 有 3 个 name="amount" 时,服务器会收到并排的 3 个 amount。第几行靠顺序区分(第九章看实物)
- 只有第 1 行 required ── 明细至少 1 行,第 2、3 行可以空着
收据用 type="file"。
<input type="file" id="receipt" name="receipt" accept="image/*,.pdf" multiple>
accept 用来筛选选择文件时显示的种类(图片和 PDF),multiple 表示可以一次选多个文件。要发送文件内容,form 上必须同时有 method="post" 和 enctype="multipart/form-data"。缺了任何一个,送到的就只有文件名。
按钮的种类
按钮用 <button> 来做,type 决定它的作用。
button 的 type
本讲的表单有两个提交按钮,都是 name="action",value 分别为 draft(保存草稿)和 submit(提交申请)。只有被按下的按钮的 value 会被发送,服务器据此区分“是草稿还是申请”。保存草稿的按钮加上了 formnovalidate,这样必填栏空着也能保存。
古井以前,每个表单都配一个“重置”按钮。
新井填完 10 行明细之后不小心按到……
古井对,一切归零。古法里的陷阱。业务系统里别放。
搭出申请表单
把前面的控件组合起来,就是 form.html 的全貌。header 和 footer 沿用上一讲 index.html 里的。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>新建申请 | 报销系统</title>
</head>
<body>
<header>
<p><a href="/"><img src="img/logo.svg" alt="" width="32" height="32"> 报销系统</a></p>
<nav>
<ul>
<li><a href="/">申请列表</a></li>
<li><a href="approve.html">审批</a></li>
<li><a href="dashboard.html">仪表盘</a></li>
</ul>
</nav>
<p>新井 太郎(申请人)</p>
</header>
<main>
<h1>新建申请</h1>
<p><a href="/">← 返回申请列表</a></p>
<form action="/api/expenses" method="post" enctype="multipart/form-data">
<fieldset>
<legend>申请内容</legend>
<p>
<label for="title">事由(必填)</label><br>
<input type="text" id="title" name="title" required maxlength="50">
</p>
</fieldset>
<fieldset>
<legend>支付方式</legend>
<label><input type="radio" name="payment" value="personal" checked> 个人垫付</label>
<label><input type="radio" name="payment" value="corporate"> 公司卡</label>
</fieldset>
<fieldset>
<legend>明细</legend>
<table>
<caption>明细(最多 3 行。增加行在第 8 讲)</caption>
<thead>
<tr><th scope="col">日期</th><th scope="col">类别</th><th scope="col">内容</th><th scope="col">金额</th><th scope="col">币种</th></tr>
</thead>
<tbody>
<tr>
<td><input type="date" name="date" aria-label="第 1 行的日期" required></td>
<td><select name="category" aria-label="第 1 行的类别" required><option value="">请选择</option><option value="transport">交通费</option><option value="hotel">住宿费</option><option value="meeting">会议・招待费</option><option value="supplies">消耗品</option><option value="other">其他</option></select></td>
<td><input type="text" name="item" aria-label="第 1 行的内容" required></td>
<td><input type="number" name="amount" aria-label="第 1 行的金额" min="1" step="1" required></td>
<td><select name="currency" aria-label="第 1 行的币种"><option value="JPY">日元</option><option value="USD">美元</option></select></td>
</tr>
<tr>
<td><input type="date" name="date" aria-label="第 2 行的日期"></td>
<td><select name="category" aria-label="第 2 行的类别"><option value="">请选择</option><option value="transport">交通费</option><option value="hotel">住宿费</option><option value="meeting">会议・招待费</option><option value="supplies">消耗品</option><option value="other">其他</option></select></td>
<td><input type="text" name="item" aria-label="第 2 行的内容"></td>
<td><input type="number" name="amount" aria-label="第 2 行的金额" min="1" step="1"></td>
<td><select name="currency" aria-label="第 2 行的币种"><option value="JPY">日元</option><option value="USD">美元</option></select></td>
</tr>
<tr>
<td><input type="date" name="date" aria-label="第 3 行的日期"></td>
<td><select name="category" aria-label="第 3 行的类别"><option value="">请选择</option><option value="transport">交通费</option><option value="hotel">住宿费</option><option value="meeting">会议・招待费</option><option value="supplies">消耗品</option><option value="other">其他</option></select></td>
<td><input type="text" name="item" aria-label="第 3 行的内容"></td>
<td><input type="number" name="amount" aria-label="第 3 行的金额" min="1" step="1"></td>
<td><select name="currency" aria-label="第 3 行的币种"><option value="JPY">日元</option><option value="USD">美元</option></select></td>
</tr>
</tbody>
</table>
</fieldset>
<fieldset>
<legend>收据</legend>
<p>
<label for="receipt">收据图片或 PDF(合计 3 万日元以上必须上传)</label><br>
<input type="file" id="receipt" name="receipt" accept="image/*,.pdf" multiple>
</p>
</fieldset>
<p>
<label for="note">备注</label><br>
<textarea id="note" name="note" rows="3" cols="40" maxlength="500"></textarea>
</p>
<p>
<label><input type="checkbox" name="agree" value="yes" required> 已阅读费用报销规定(必填)</label>
</p>
<p>
<button type="submit" name="action" value="draft" formnovalidate>保存草稿</button>
<button type="submit" name="action" value="submit">提交申请</button>
</p>
</form>
</main>
<footer>
<p><small>© 2026 KIJI 报销系统(练习用)</small></p>
</footer>
</body>
</html>
上一讲申请列表里“+ 新建申请”的链接,指向的就是这个页面。两者终于可以来回跳转了。
试试浏览器的检查
什么都不填就按“提交申请”,浏览器会在第一个空栏旁弹出提示,并阻止提交。我们从浏览器里取出了各栏的提示(中文设置下 Chromium 的实际文字)。
事由(必填) → 请填写此字段。 第 1 行的日期 → 请填写此字段。 第 1 行的类别 → 请在列表中选择一项。 第 1 行的内容 → 请填写此字段。 第 1 行的金额 → 请填写此字段。 已阅读费用报销规定(必填) → 如果要继续,请选中此框。 金额填 0 → 值必须大于或等于 1。 金额填 12.5 → 请输入有效值。两个最接近的有效值分别为12和13。
一行 JavaScript 都没写,就有这么多检查在起作用。只用 HTML 能做的检查如下。
HTML 的检查及其局限
浏览器的检查是对用户的体贴,而不是防护。在开发者工具里删掉 required,空着也能提交。安全指南的经典 OWASP 也说“客户端的检查可以被绕过,服务器上也一定要检查”。让 AI“加上输入检查”、结果只加在页面这一侧的情况也一样。请记住:在处理钱的系统里,服务器端的检查才是正式的。
到底发送了什么
最后,来看看按下按钮的瞬间发送了什么。先把发送方式改成 GET 试试(把目标改成 /echo 后的实际 URL。只显示前半段,下面是还原成可读形式的内容)。
/echo?title=%E5%A4%A7%E9%98%AA%E5%87%BA%E5%B7%AE%EF%BC%88%E4%BA%A4%E9%80%9A%E8%B4%B9%E3%83%BB%E4… title = 大阪出差(交通费・住宿费) payment = personal date = 2026-10-15 category = transport item = 新干线(东京→新大阪) amount = 14720 currency = JPY …(第 2、3 行也以同样的 date・category・item・amount・currency 名称继续)… receipt = receipt.pdf note = 与客户 A 公司开会 agree = yes action = submit
name 和输入的值组成 name=value,用 & 连在 URL 后面。中文会被转换成 %E5%A4… 的形式(URL 编码)。而且,receipt 只有“receipt.pdf”这个名字。GET 发送不了文件内容。
接着是用原本的 POST(multipart/form-data)发送时,服务器收到的正文。我们用一个在云端搭建、只负责记录收到内容的小服务器来接收(较长部分已省略)。
POST /api/expenses HTTP/1.1 Content-Type: multipart/form-data; boundary=----WebKitFormBo… ------WebKitFormBoundaryYE… Content-Disposition: form-data; name="title" 大阪出差(交通费・住宿费) ------WebKitFormBoundaryYE… Content-Disposition: form-data; name="payment" personal …(明细的 date・category・item・amount・currency 共 3 行)… ------WebKitFormBoundaryYE… Content-Disposition: form-data; name="receipt"; filename="receipt.pdf" Content-Type: application/pdf %PDF-1.4 % demo receipt %%EOF ------WebKitFormBoundaryYE… Content-Disposition: form-data; name="note" 与客户 A 公司开会 ------WebKitFormBoundaryYE… Content-Disposition: form-data; name="agree" yes ------WebKitFormBoundaryYE… Content-Disposition: form-data; name="action" submit ------WebKitFormBoundaryYE…--
每一栏用分隔线(boundary)隔开,receipt 部分装的是文件内容本身。最后是什么都不填、直接按“保存草稿”时收到的内容(整理成可读形式)。
title = (空) payment = personal date = (空) category = (空) item = (空) amount = (空) currency = JPY receipt = (空) note = (空) action = draft …明细第 2〜3 行也是空的。agree(复选框)没有被发送
- 跳过检查送达了 ── 多亏 formnovalidate,必填栏空着也发送了。服务器看到 action=draft,判断“是草稿,空着也行”
- 空栏也会作为“空”送达 ── title、amount 等只有名字、值为空
- 没勾选的复选框,连名字都不会发送 ── agree 整个不见了。服务器需要把“没有 agree”理解为“没勾选”
新井原来按一下按钮,飞出去的是这些东西啊……
古井知道里面是什么,和做服务器的人也说得上话。就算被问“agree 怎么没传过来”,你也不会慌了吧?
结 — 一页总结与下期预告
- 表单用 form 包起来,定好 action(发送目标)和 method(发送方式)。登记用 POST,发送文件要加 enctype="multipart/form-data"。
- 输入框要有 label(给人看的名字)和 name(交给服务器的名字)。placeholder 不能代替 label。
- 按内容选合适的 type:金额用 number,日期用 date,单选用 radio 或 select。相关的栏目用 fieldset 归组。
- 用 required・min・step 等,浏览器会帮你检查输入。但那是体贴而不是防护,服务器上也一定要检查。
- 发送的是 name=value 的组合。空栏以空值送达,没勾选的复选框不会发送。保存草稿用 formnovalidate 跳过检查。
新井HTML 的骨架总算齐了!不过外观还是老古董……
古井下一讲终于轮到 CSS,给它穿衣服。第 4 讲是选择器与盒子,用自己写的 CSS 把列表整理好。
参考资料
- MDN — <input>: The HTML Input element
- MDN — <label>: The Label element
- MDN — Sending form data
- MDN — Client-side form validation
- MDN — HTMLButtonElement: formNoValidate property
- MDN — HTML attribute: autocomplete
- W3C WAI — Forms Tutorial: Labeling Controls
- W3C WAI — Forms Tutorial: Form Instructions
- OWASP — Input Validation Cheat Sheet
- WHATWG — HTML Living Standard: Forms
页面截图是笔者制作的演示页面,于 2026 年 10 月 17 日用云端的 Chromium(Playwright)拍摄。检查提示是从中文设置的 Chromium 中取出的实际文字;GET 的 URL、POST 的正文、保存草稿时的内容,都是实际提交表单后记录下来的(POST 用云端搭建的小型记录服务器接收)。无障碍树是 Playwright 的 ariaSnapshot() 的输出。不同浏览器的提示文字和对 placeholder 的处理会有差异。登场人物均为虚构。
古法前端入门 第3讲 — 表单:只用 HTML 做申请表单,看看到底发送了什么 / 2026.10