HTML 表单

系统掌握 HTML 表单的结构化设计、原生验证机制与 FormData API,构建语义化、可访问且安全的数据采集层。

HTML 表单

一、表单在 Web 中的地位

1.1 数据入口的守门人

表单是用户与 Web 应用之间最重要的数据交换通道。登录、注册、搜索、支付、评论——几乎所有需要用户输入的场景都依赖表单。一个设计良好的表单不仅影响用户体验,更直接关系到数据质量和应用安全。

1.2 为什么不能只依赖框架

React Hook Form、Formik 等库简化了表单状态管理,但它们都建立在原生表单 API 之上。理解原生机制意味着:

  • 在不需要复杂状态管理的场景下避免引入额外依赖
  • 利用浏览器内置验证减少 JavaScript 代码量
  • 构建真正可访问的表单(屏幕阅读器依赖原生语义)

二、表单结构与语义

2.1 核心元素

<form action="/api/submit" method="POST" novalidate>
  <!-- fieldset 分组 + legend 标题 -->
  <fieldset>
    <legend>个人信息</legend>

    <!-- label 关联 input -->
    <label for="name">姓名</label>
    <input id="name" name="name" type="text" required />

    <label for="email">邮箱</label>
    <input id="email" name="email" type="email" required />
  </fieldset>

  <fieldset>
    <legend>偏好设置</legend>

    <label>
      <input type="checkbox" name="newsletter" value="yes" />
      订阅邮件通知
    </label>

    <label for="theme">主题</label>
    <select id="theme" name="theme">
      <option value="light">浅色</option>
      <option value="dark">深色</option>
      <option value="system">跟随系统</option>
    </select>
  </fieldset>

  <button type="submit">提交</button>
  <button type="reset">重置</button>
</form>

2.2 <label> 的两种关联方式

<!-- 显式关联(推荐):for 属性指向 input 的 id -->
<label for="username">用户名</label>
<input id="username" name="username" type="text" />

<!-- 隐式关联:label 包裹 input -->
<label>
  用户名
  <input name="username" type="text" />
</label>

为什么 label 至关重要

  1. 屏幕阅读器通过 label 告诉用户输入框的用途
  2. 点击 label 会聚焦对应的 input(增大点击目标)
  3. 没有 label 的 input 在辅助技术中几乎不可用

2.3 <fieldset><legend>

<fieldset> 将相关的表单控件分组,<legend> 提供组的标题。这对屏幕阅读器尤为重要——当用户进入一个 <fieldset> 时,阅读器会先朗读 <legend> 内容。

<fieldset>
  <legend>收货地址</legend>
  <!-- 地址相关字段 -->
</fieldset>

<!-- 对于单选按钮组特别有用 -->
<fieldset>
  <legend>支付方式</legend>
  <label><input type="radio" name="payment" value="card" /> 银行卡</label>
  <label><input type="radio" name="payment" value="alipay" /> 支付宝</label>
  <label><input type="radio" name="payment" value="wechat" /> 微信支付</label>
</fieldset>

三、输入类型详解

3.1 HTML5 输入类型一览

类型 用途 移动端优化 内置验证
text 单行文本 标准键盘 -
email 邮箱地址 @ 键盘 格式验证
password 密码 标准键盘 -
number 数值 数字键盘 min/max/step
tel 电话号码 电话键盘 -
url 网址 URL 键盘 格式验证
search 搜索 搜索键盘 -
date 日期 原生日期选择器 格式验证
time 时间 原生时间选择器 格式验证
datetime-local 日期+时间 原生选择器 格式验证
range 滑块 滑块控件 min/max/step
color 颜色选择 颜色选择器 格式验证
file 文件上传 文件选择 accept 过滤
checkbox 多选 复选框 -
radio 单选 单选按钮 -
hidden 隐藏字段 - -

3.2 选择正确的输入类型

选择合适的 type 不仅影响用户体验(移动端弹出正确的键盘),还能免费获得浏览器的内置验证:

<!-- email 类型:浏览器自动验证格式 + 移动端显示 @ 键盘 -->
<input type="email" required />

<!-- number 类型:自动显示上下箭头 + 支持 min/max -->
<input type="number" min="1" max="100" step="1" />

<!-- date 类型:原生日期选择器(无需第三方日期库)-->
<input type="date" min="2024-01-01" max="2026-12-31" />

<!-- file 类型:限制接受的文件类型 -->
<input type="file" accept=".pdf,.doc,.docx" multiple />
<input type="file" accept="image/*" capture="environment" />

四、表单验证

4.1 HTML5 原生验证属性

<!-- required — 必填 -->
<input type="text" required />

<!-- minlength / maxlength — 文本长度限制 -->
<input type="text" minlength="3" maxlength="20" />

<!-- min / max — 数值/日期范围 -->
<input type="number" min="0" max="999" />

<!-- pattern — 正则表达式验证 -->
<input type="text" pattern="[A-Za-z]{3,}" title="至少3个英文字母" />

<!-- step — 数值步进 -->
<input type="number" step="0.01" /> <!-- 允许两位小数 -->

4.2 Constraint Validation API

浏览器提供了强大的验证 API,可以替代大量手写验证逻辑:

const input = document.querySelector('#email');

// 检查有效性
input.validity.valid;          // 是否全部通过
input.validity.valueMissing;   // required 但为空
input.validity.typeMismatch;   // 类型不匹配(如 email 格式错误)
input.validity.patternMismatch; // pattern 不匹配
input.validity.tooShort;       // 短于 minlength
input.validity.tooLong;        // 超过 maxlength
input.validity.rangeUnderflow; // 小于 min
input.validity.rangeOverflow;  // 大于 max
input.validity.stepMismatch;   // 不符合 step
input.validity.customError;    // 自定义错误

// 获取浏览器生成的错误信息
input.validationMessage; // "请输入有效的电子邮件地址"

// 设置自定义错误
input.setCustomValidity('该邮箱已被注册');
// 注意:设置后必须在条件满足时清除
input.setCustomValidity(''); // 清除自定义错误

// 手动触发验证
input.checkValidity();   // 返回 boolean,触发 invalid 事件
input.reportValidity();  // 返回 boolean,并显示错误提示

4.3 自定义验证 UI

const form = document.querySelector('form');

// 禁用浏览器默认验证 UI
// 方式一:HTML 属性
// <form novalidate>
// 方式二:JS 拦截
form.addEventListener('submit', (e) => {
  e.preventDefault();

  // 遍历所有控件
  for (const field of form.elements) {
    if (!field.checkValidity()) {
      // 显示自定义错误 UI
      showError(field, field.validationMessage);
    } else {
      clearError(field);
    }
  }

  if (form.checkValidity()) {
    submitForm(form);
  }
});

// 实时验证
form.addEventListener('input', (e) => {
  const field = e.target;
  if (field.validity.valid) {
    clearError(field);
  }
});

4.4 CSS 验证伪类

/* 必填字段 */
input:required { border-left: 3px solid orange; }

/* 可选字段 */
input:optional { border-left: 3px solid gray; }

/* 有效状态 */
input:valid { border-color: green; }

/* 无效状态 */
input:invalid { border-color: red; }

/* 用户交互后才显示验证状态(避免初始红色) */
input:user-invalid { border-color: red; }
input:user-valid { border-color: green; }

/* 禁用状态 */
input:disabled { opacity: 0.5; cursor: not-allowed; }

/* 选中状态 */
input:checked + label { font-weight: bold; }

/* 范围内/外 */
input:in-range { background: lightgreen; }
input:out-of-range { background: lightyellow; }

/* 占位符可见时 */
input:placeholder-shown { font-style: italic; }

五、表单提交与 FormData

5.1 传统提交 vs AJAX 提交

<!-- 传统提交:页面跳转 -->
<form method="POST" action="/api/submit">
  <input name="username" />
  <button type="submit">提交</button>
</form>

<!-- 编码方式 -->
<!-- application/x-www-form-urlencoded(默认)-->
<!-- multipart/form-data(文件上传必需)-->
<!-- text/plain(很少使用)-->
<form method="POST" enctype="multipart/form-data">
  <input type="file" name="avatar" />
</form>

5.2 FormData API

// 从已有表单构造
const form = document.querySelector('form');
const formData = new FormData(form);

// 手动构造
const formData = new FormData();
formData.append('username', 'alice');
formData.append('avatar', fileInput.files[0]);

// 常用方法
formData.get('username');          // 'alice'
formData.getAll('tags');           // ['js', 'ts']
formData.has('username');          // true
formData.set('username', 'bob');   // 覆盖
formData.delete('avatar');         // 删除
formData.append('tag', 'react');   // 追加(允许重复 key)

// 遍历
for (const [key, value] of formData.entries()) {
  console.log(`${key}: ${value}`);
}

// 发送
fetch('/api/submit', {
  method: 'POST',
  body: formData, // 自动设置 Content-Type: multipart/form-data
});

// 转换为 URLSearchParams(用于 JSON API)
const params = new URLSearchParams(formData);
fetch('/api/submit', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: params.toString(),
});

// 转换为普通对象
const data = Object.fromEntries(formData);
// 注意:同名字段只会保留最后一个值

5.3 formdata 事件

// 在 FormData 构造时拦截,添加额外数据
form.addEventListener('formdata', (e) => {
  e.formData.append('timestamp', Date.now());
  e.formData.append('csrfToken', getCsrfToken());
});

六、表单可访问性

6.1 核心原则

<!-- 1. 每个控件必须有 label -->
<label for="phone">电话号码</label>
<input id="phone" type="tel" name="phone" />

<!-- 2. 错误信息关联到控件 -->
<input id="email" type="email"
  aria-invalid="true"
  aria-describedby="email-error" />
<span id="email-error" role="alert">请输入有效的邮箱地址</span>

<!-- 3. 必填提示 -->
<label for="name">
  姓名 <span aria-hidden="true">*</span>
</label>
<input id="name" type="text" required aria-required="true" />

<!-- 4. 帮助文本 -->
<input id="password" type="password"
  aria-describedby="pwd-help" />
<p id="pwd-help">密码需包含至少 8 个字符,含大小写字母和数字</p>

<!-- 5. 禁用状态说明 -->
<button type="submit" disabled aria-disabled="true">
  提交(请先填写必填项)
</button>

6.2 键盘导航

  • Tab:在表单控件之间移动焦点
  • Shift+Tab:反向移动
  • Enter:提交表单(在 input 中)
  • Space:切换 checkbox / radio
  • Arrow Keys:在 radio 组和 select 中切换选项
  • Escape:关闭下拉菜单

确保自定义控件也支持这些键盘操作。


七、安全注意事项

7.1 前端安全边界

前端验证 → 用户体验(即时反馈)
后端验证 → 安全保障(不可绕过)

前端验证可以被绕过(禁用 JavaScript、使用 DevTools、直接发 HTTP 请求),因此:

  1. 永远在后端重复验证所有输入
  2. 使用 HTTPS 传输表单数据
  3. 实现 CSRF 保护(令牌或 SameSite Cookie)
  4. 转义用户输入防止 XSS 注入
  5. 限制文件上传的类型和大小

7.2 自动填充安全

<!-- 利用 autocomplete 属性帮助浏览器正确填充 -->
<input type="text" autocomplete="name" />
<input type="email" autocomplete="email" />
<input type="tel" autocomplete="tel" />
<input type="text" autocomplete="address-line1" />

<!-- 敏感字段禁用自动填充 -->
<input type="text" autocomplete="off" />
<!-- 注意:浏览器可能忽略 autocomplete="off" -->
<!-- 更可靠的方式是使用 autocomplete="new-password" -->

八、面试高频问题

Q: 表单的 action 和 method 属性各是什么作用?

回答要点action 指定表单数据提交的 URL,method 指定 HTTP 方法(GET 或 POST)。GET 将数据附加到 URL 查询字符串(有长度限制、不安全),POST 将数据放在请求体中(无长度限制、更安全)。文件上传必须使用 POST + enctype="multipart/form-data"

Q: 如何实现自定义表单验证而不依赖第三方库?

回答要点:使用 HTML5 Constraint Validation API。在 <form> 上设置 novalidate 禁用默认 UI,在 submit 事件中遍历 form.elements,通过 checkValidity()validity 对象判断错误类型,使用 setCustomValidity() 设置自定义错误消息,配合 CSS 伪类 :valid / :invalid / :user-invalid 展示状态。


参考资料

延展阅读