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 至关重要:
- 屏幕阅读器通过 label 告诉用户输入框的用途
- 点击 label 会聚焦对应的 input(增大点击目标)
- 没有 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 请求),因此:
- 永远在后端重复验证所有输入
- 使用 HTTPS 传输表单数据
- 实现 CSRF 保护(令牌或 SameSite Cookie)
- 转义用户输入防止 XSS 注入
- 限制文件上传的类型和大小
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 展示状态。
参考资料
- MDN Web Docs — Web forms
- MDN Web Docs — FormData API
- HTML Living Standard — Forms
- web.dev — Learn Forms