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

古法前端入门

第3讲 表单 ── 只用 HTML 做申请表单,看看到底发送了什么

发布

没有加 CSS、只有 HTML 的申请表单。有事由、支付方式的单选按钮、包含日期・类别・内容・金额・币种的 3 行明细、收据的文件选择、备注、确认规定的复选框,以及保存草稿和提交申请的按钮,已填入大阪出差的内容
本讲的目标:只用 HTML 做的申请表单,填入了大阪出差的内容(笔者的演示页面,用云端的 Chromium 截图。2026 年 10 月 17 日。浏览器边框是为了说明而后加的)

不靠 AI,一行一行亲手写的“古法”前端入门。第 3 讲是业务系统的重头戏——表单。
申请列表里“+ 新建申请”点进去的申请表单,这次同样只用 HTML 来做。
输入框的命名方法、控件的选择、浏览器自带的输入检查。最后还要实际提交一次,
看看按下按钮的那一刻,究竟发送了什么。

一

本讲的目标

新井前辈,申请表单我也让 AI 做好了!输入框里有浅色的“事由”两个字,挺时髦的吧。

古井哦?那输入事由之后,那行浅色字会怎样?

新井会消失……咦,那输入之后,就不知道这是哪一栏了?

古井正是如此。今天来讲表单的“名牌”和“内容”。

当前位置:第 3 讲

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

这次要做的是 form.html,也就是申请表单页面。内容如下。

  • 申请内容 ── 事由(必填)
  • 支付方式 ── 个人垫付,还是公司卡
  • 明细 ── 日期・类别・内容・金额・币种,最多 3 行(能增加行要到第 8 讲)
  • 收据 ── 图片或 PDF。合计 3 万日元以上必须上传(让表单遵守这条规则要到第 9 讲)
  • 备注与确认 ── 备注栏、已阅读规定的复选框、“保存草稿”“提交申请”按钮
二

表单是怎样发送的

表单用 <form> 把输入框和按钮包起来。按下按钮后,浏览器会按以下顺序工作。

从按下按钮到送达服务器

点击 提交申请 浏览器检查 required・min 等 整理成 name=value title=大阪出差 … 1 2 3 停下 显示提示 GET POST 加在 URL 后面 文件只有名字 用于搜索等 放进正文 连文件内容一起 申请用这个 4 服务器再检查 这里才是真正的关卡 5 保存草稿 跳过检查(formnovalidate)
form 的属性含义本讲的值
action发送目标的 URL/api/expenses(第 10 讲准备的模拟服务器)
method发送方式。get(加在 URL 后面)或 post(放进正文)post
enctype正文的打包方式。发送文件时必须用 multipart/form-datamultipart/form-data

区分的标准是:只是“查看”数据用 GET,“登记・修改”用 POST。像搜索那样希望同一个 URL 得到同样结果时,GET 很方便;但登记申请要用 POST。因为 GET 会把输入内容原样留在 URL 里,还会记进浏览器的历史记录。

三

起名字:label 与 name

输入框有两种“名字”:一种是给人看的名字(label),另一种是交给服务器的名字(name)。

label・id・name 的关系

<label for="title">事由</label> <input type="text" id="title" name="title"> for 与 id:给人和浏览器看 点击名称文字,就能选中该栏 读屏软件能读出栏目名称 name:给服务器看 提交时的名字,没有就不会被发送 title=大阪出差 <label><input type="checkbox"> 已阅读规定</label> ↑ 用 label 包起来就不需要 for 和 id(复选框、单选按钮常这样写)

开头新井高兴的“输入框里的浅色字”,是 placeholder 属性。拿它代替 label,会怎样呢?

并排的两个小表单。1 只有 placeholder 的版本,输入事由后栏目名称消失了。2 加了 label 的版本,输入后上方仍显示“事由”
两边都是输入事由之后。① 的 placeholder(浅色文字)消失了,看不出这是哪一栏;② 的 label 还在(笔者的演示页面,用云端的 Chromium 截图。2026 年 10 月 17 日。浏览器边框是为了说明而后加的)

除了外观,我们也比较了浏览器生成的无障碍树(用 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。理由是:一开始输入它就消失,颜色浅不易阅读,各种辅助技术的处理也不一致。

POINT

placeholder 适合放“例:2026 年 10 月 大阪出差”这样的输入示例,栏目名称一定要用 label。AI 生成的表单常常只有 placeholder。“有没有 label?”是读表单时首先要确认的一点。

四

输入控件

大多数输入框都是 <input>,用 type 属性切换种类。下面列出报销系统用到的控件。

表单控件

大阪出差 type="text" 单行文字 事由・内容 14720 type="number" 数字 金额 2026-10-15 type="date" 日期 可用日历选择 交通费 select 从列表选一项 类别・币种 个人垫付 公司卡 type="radio" 选其中一个 支付方式 已确认 type="checkbox" 是/否 确认规定 选择 receipt.pdf type="file" 文件 收据 textarea 长文本 备注
控件常用属性使用要点
type="text"maxlength、autocomplete事由・内容。用 autocomplete 可以指定浏览器自动填充的方式
type="number"min、max、step金额。手机上弹出数字键盘。step="1" 可阻止小数
type="date"min、max日期。可以用日历选择,写法不会五花八门(10/15、10 月 15 日……)
select 与 optionvalue类别・币种。选项多或固定时使用。第一项放“请选择”
type="radio"name、checked支付方式。name 相同的成为一组,只能选其中一个
type="checkbox"value、checked确认规定。不勾选的话什么都不会发送(第九章确认)
textarearows、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

提交申请 type="submit" 提交表单 省略 type 时就是它 添加一行 type="button" 点了什么也不发生 用 JS 来动(第 8 讲) 重置 type="reset" 清空全部输入 误点就是悲剧,不要放 保存草稿的小技巧 <button type="submit" name="action" value="draft" formnovalidate> 用加了 formnovalidate 的按钮提交,必填栏空着也会跳过检查直接发送

本讲的表单有两个提交按钮,都是 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>
给申请表单加上红框和编号。1 申请内容的 fieldset,2 事由输入框,3 支付方式的单选按钮,4 明细表,5 收据的文件选择,6 备注的 textarea,7 确认规定的复选框,8 保存草稿和提交申请的按钮
完成的申请表单与用到的控件:① fieldset,② text,③ radio,④ 放有输入框的 table,⑤ file,⑥ textarea,⑦ checkbox,⑧ button(笔者的演示页面,用云端的 Chromium 截图。2026 年 10 月 17 日。浏览器边框是为了说明而后加的)

上一讲申请列表里“+ 新建申请”的链接,指向的就是这个页面。两者终于可以来回跳转了。

八

试试浏览器的检查

什么都不填就按“提交申请”,浏览器会在第一个空栏旁弹出提示,并阻止提交。我们从浏览器里取出了各栏的提示(中文设置下 Chromium 的实际文字)。

事由(必填)  →  请填写此字段。
第 1 行的日期  →  请填写此字段。
第 1 行的类别  →  请在列表中选择一项。
第 1 行的内容  →  请填写此字段。
第 1 行的金额  →  请填写此字段。
已阅读费用报销规定(必填)  →  如果要继续,请选中此框。
金额填 0  →  值必须大于或等于 1。
金额填 12.5  →  请输入有效值。两个最接近的有效值分别为12和13。

一行 JavaScript 都没写,就有这么多检查在起作用。只用 HTML 能做的检查如下。

HTML 的检查及其局限

required 阻止留空 min / max 阻止超出范围 step 阻止不合步长的数 maxlength 不让输入过长的文字 pattern 阻止不符合格式的内容 accept 筛选可选的文件类型 浏览器的检查 =热心的引导员 马上提醒,但能被绕过 服务器的检查 =保险柜的锁 一定要做,最后防线 ・在开发者工具里删掉属性,浏览器的检查就轻易失效 ・“3 万日元以上必须附收据”这类依赖其他栏的规则,留到第 9 讲 ・accept 只是筛选选择界面,仍可能收到其他类型的文件 ・真正的系统里,服务器要用同样的规则再检查一遍
NOTE

浏览器的检查是对用户的体贴,而不是防护。在开发者工具里删掉 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 怎么没传过来”,你也不会慌了吧?

结 — 一页总结与下期预告

  1. 表单用 form 包起来,定好 action(发送目标)和 method(发送方式)。登记用 POST,发送文件要加 enctype="multipart/form-data"。
  2. 输入框要有 label(给人看的名字)和 name(交给服务器的名字)。placeholder 不能代替 label。
  3. 按内容选合适的 type:金额用 number,日期用 date,单选用 radio 或 select。相关的栏目用 fieldset 归组。
  4. 用 required・min・step 等,浏览器会帮你检查输入。但那是体贴而不是防护,服务器上也一定要检查。
  5. 发送的是 name=value 的组合。空栏以空值送达,没勾选的复选框不会发送。保存草稿用 formnovalidate 跳过检查。

新井HTML 的骨架总算齐了!不过外观还是老古董……

古井下一讲终于轮到 CSS,给它穿衣服。第 4 讲是选择器与盒子,用自己写的 CSS 把列表整理好。

参考资料

页面截图是笔者制作的演示页面,于 2026 年 10 月 17 日用云端的 Chromium(Playwright)拍摄。检查提示是从中文设置的 Chromium 中取出的实际文字;GET 的 URL、POST 的正文、保存草稿时的内容,都是实际提交表单后记录下来的(POST 用云端搭建的小型记录服务器接收)。无障碍树是 Playwright 的 ariaSnapshot() 的输出。不同浏览器的提示文字和对 placeholder 的处理会有差异。登场人物均为虚构。

古法前端入门 第3讲 — 表单:只用 HTML 做申请表单,看看到底发送了什么 / 2026.10

← 返回文章列表